Compare commits

..
4 Commits
11 changed files with 422 additions and 27 deletions

No files matched your search

+11 -10
View File
@@ -113,12 +113,11 @@ class SmartObject(hass.Hass,LoggerInterface):
if device_class is not None: attributes['device_class'] = device_class if device_class is not None: attributes['device_class'] = device_class
if state_class is not None: attributes['state_class'] = state_class if state_class is not None: attributes['state_class'] = state_class
if state is not None: if state is None:
entity.set_state(state=state, attributes=attributes, check_existence=False) state = entity.get_state() if entity.exists() else None
else: if state is None:
if not entity.exists(): state = 'unknown'
entity.set_state(state='unknown', check_existence=False) entity.set_state(state=state, attributes=attributes, check_existence=False)
entity.set_state(attributes=attributes)
return entity return entity
@@ -219,14 +218,16 @@ class SmartObject(hass.Hass,LoggerInterface):
# entity: <entity_id> → link app to a HA entity # entity: <entity_id> → link app to a HA entity
if "entity" in self.args: if "entity" in self.args:
self.entity_id = self.args["entity"] self.entity_id = self.args["entity"]
self.entity = self.get_entity(self.entity_id) self.entity = self.get_entity(self.entity_id, check_existence=False)
initial_state = None
if self.entity.exists(): if self.entity.exists():
self.log_info(f"Linked to {self.entity_id}") self.log_info(f"Linked to {self.entity_id}")
elif 'default_entity_state' in self.args: elif 'default_entity_state' in self.args:
self.entity.set_state(state = self.args["default_entity_state"]) initial_state = self.args["default_entity_state"]
self.log_info(f"Creating {self.entity_id}, default_state = {self.args['default_entity_state']}") self.log_info(f"Creating {self.entity_id}, default_state = {initial_state}")
else: else:
self.entity.set_state(state = self.get_default_entity_state()) initial_state = self.get_default_entity_state()
self.entity = self.create_entity(self.entity_id, state=initial_state)
self.constants['self'] = self.entity_id self.constants['self'] = self.entity_id
else: else:
+121
View File
@@ -0,0 +1,121 @@
"""Exercise timer expiry/cancellation ordering without a running AppDaemon."""
import ast
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
node = next(node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name == 'ContinuousCondition')
namespace = {'VirtualSensorBase': object, 'SmartCondition': Mock()}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
ContinuousCondition = namespace['ContinuousCondition']
class FakeScheduler:
def __init__(self):
self.timers = {}
self.next_id = 0
self.warnings = []
def run_in(self, callback, delay, **kwargs):
self.next_id += 1
handle = str(self.next_id)
self.timers[handle] = (callback, kwargs)
return handle
def expire(self, handle):
# AppDaemon removes the timer while its callback can still be queued.
callback, kwargs = self.timers.pop(handle)
return lambda: callback(kwargs)
def cancel_timer(self, handle, silent=False):
if handle in self.timers:
del self.timers[handle]
return True
if not silent:
self.warnings.append(handle)
return False
class ContinuousConditionTests(unittest.TestCase):
def setUp(self):
self.scheduler = FakeScheduler()
self.state = 'off'
entity = Mock()
entity.get_state.side_effect = lambda: self.state
entity.set_state.side_effect = lambda *, state: setattr(self, 'state', state)
self.sensor = ContinuousCondition()
self.sensor.ad_api = SimpleNamespace(
get_entity=lambda name: entity,
run_in=self.scheduler.run_in,
cancel_timer=self.scheduler.cancel_timer,
)
self.sensor.yaml_block = {'time': 5, 'conditions': 'paused'}
self.sensor.sensor_name = 'binary_sensor.is_vertex2_content_idle'
self.sensor.log_info = Mock()
self.sensor.logger_interface = None
self.sensor.constants = {}
self.sensor.templates_library = None
self.sensor.initialize()
def test_valid_expiry_sets_state_and_clears_handle(self):
self.sensor.on_condition_succeed()
self.scheduler.expire(self.sensor.cb_handle)()
self.assertEqual(self.state, 'on')
self.assertIsNone(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off')
def test_failure_cancels_running_timer(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
self.assertEqual(self.scheduler.timers, {})
self.assertIsNone(self.sensor.cb_handle)
self.assertEqual(self.state, 'off')
def test_resume_at_expiry_ignores_queued_callback_without_warning(self):
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
queued()
self.assertEqual(self.state, 'off')
self.assertEqual(self.scheduler.warnings, [])
self.assertIsNone(self.sensor.cb_handle)
def test_old_callback_preserves_new_timer_and_full_delay(self):
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.sensor.on_condition_succeed()
new_handle = self.sensor.cb_handle
queued()
self.assertEqual(self.state, 'off')
self.assertEqual(self.sensor.cb_handle, new_handle)
self.scheduler.expire(new_handle)()
self.assertEqual(self.state, 'on')
def test_repeated_success_keeps_existing_timer(self):
self.sensor.on_condition_succeed()
handle = self.sensor.cb_handle
self.sensor.on_condition_succeed()
self.assertEqual(self.sensor.cb_handle, handle)
self.assertEqual(len(self.scheduler.timers), 1)
def test_inverted_condition_also_ignores_stale_expiry(self):
self.sensor.invert = True
self.state = 'on'
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
queued()
self.assertEqual(self.state, 'on')
self.sensor.on_condition_succeed()
self.scheduler.expire(self.sensor.cb_handle)()
self.assertEqual(self.state, 'off')
if __name__ == '__main__':
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
"""Check initial windows, rearming and queued timer races."""
import ast
import unittest
from unittest.mock import Mock, patch
import time
from test_continuous_condition import ContinuousConditionTests, namespace, source
namespace['time'] = time
node = next(node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name == 'EphemeralCondition')
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
EphemeralCondition = namespace['EphemeralCondition']
class EphemeralConditionTests(unittest.TestCase):
def setUp(self):
ContinuousConditionTests.setUp(self)
self.sensor.__class__ = EphemeralCondition
self.sensor.attributes = {}
self.sensor.ad_api.get_state = Mock(return_value=None)
self.sensor.output_sensor.set_state.side_effect = lambda *, state, **kwargs: setattr(self, 'state', state)
self.sensor.initialize()
def test_window_expires_without_restarting_while_true(self):
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
handle = self.sensor.cb_handle
self.sensor.on_condition_succeed()
self.assertEqual(self.sensor.cb_handle, handle)
self.scheduler.expire(handle)()
self.assertEqual(self.state, 'off')
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_failure_ends_window_and_rearms(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off')
self.assertEqual(self.scheduler.timers, {})
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
self.scheduler.expire(self.sensor.cb_handle)()
self.assertEqual(self.state, 'off')
def test_queued_expiry_cannot_end_new_window(self):
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.sensor.on_condition_succeed()
handle = self.sensor.cb_handle
queued()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, handle)
self.scheduler.expire(handle)()
self.assertEqual(self.state, 'off')
def test_restart_resumes_remaining_time(self):
self.sensor.ad_api.get_state.return_value = 100
with patch.object(time, 'time', return_value=103):
self.sensor.initialize()
self.sensor.ad_api.run_in = Mock(return_value='restored')
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.ad_api.run_in.call_args.args[1], 2)
self.assertEqual(self.sensor.attributes['condition_started_at'], 100)
def test_restart_does_not_rearm_expired_window(self):
self.sensor.ad_api.get_state.return_value = 100
with patch.object(time, 'time', return_value=110):
self.sensor.initialize()
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.assertIsNone(self.sensor.attributes['condition_started_at'])
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
def test_invalid_restored_timestamp_starts_fresh(self):
for value in ('invalid', float('nan'), float('inf'), -1, 200):
with self.subTest(value=value), patch.object(time, 'time', return_value=100):
self.sensor.ad_api.get_state.return_value = value
self.sensor.initialize()
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.attributes['condition_started_at'], 100)
self.sensor.on_condition_fail()
def test_initial_true_condition_starts_window(self):
evaluator = namespace['SmartCondition'].Evaluator
def initialize_condition(*args, **kwargs):
kwargs['on_succeed_cb']()
evaluator.side_effect = initialize_condition
try:
self.sensor.initialize()
self.assertEqual(self.state, 'on')
self.assertIsNotNone(self.sensor.cb_handle)
finally:
evaluator.side_effect = None
if __name__ == '__main__':
unittest.main()
+58 -5
View File
@@ -27,6 +27,7 @@ class RetainConditionTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.sensor = RetainCondition() self.sensor = RetainCondition()
self.sensor.cb_handle = None self.sensor.cb_handle = None
self.sensor._timer_generation = 0
self.sensor.retain_time = 15 self.sensor.retain_time = 15
self.sensor.reset_retain_time_on_new_success = True self.sensor.reset_retain_time_on_new_success = True
self.state = 'off' self.state = 'off'
@@ -41,6 +42,54 @@ class RetainConditionTests(unittest.TestCase):
def set_state(self, *, state): def set_state(self, *, state):
self.state = state self.state = state
def queued_expiry(self):
call = self.sensor.ad_api.run_in.call_args
callback = call.args[0]
kwargs = dict(call.kwargs)
return lambda: callback(kwargs)
def test_return_at_expiry_ignores_queued_callback(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
queued = self.queued_expiry()
# The scheduler has already removed the timer and queued its callback.
self.sensor.ad_api.cancel_timer.return_value = False
self.sensor.condition.evaluate.return_value = Result.Succeeded
self.sensor.on_condition_succeed()
queued()
self.assertEqual(self.state, 'on')
self.sensor.condition.evaluate.assert_not_called()
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
def test_stale_expiry_cannot_shorten_new_retention(self):
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
queued = self.queued_expiry()
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
current_expiry = self.queued_expiry()
queued()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, 'new-timer')
current_expiry()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_stale_expiry_cannot_forget_timer_while_condition_disabled(self):
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
queued = self.queued_expiry()
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
self.sensor.condition.evaluate.return_value = Result.Disabled
queued()
self.assertEqual(self.sensor.cb_handle, 'new-timer')
self.assertEqual(self.state, 'on')
self.sensor.on_condition_succeed()
self.assertEqual(self.sensor.ad_api.cancel_timer.call_args.args, ('new-timer',))
def test_long_lived_signal_gets_full_delay_after_loss(self): def test_long_lived_signal_gets_full_delay_after_loss(self):
self.sensor.on_condition_succeed() self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
@@ -48,15 +97,16 @@ class RetainConditionTests(unittest.TestCase):
# No timer can expire while the signal stays valid. # No timer can expire while the signal stays valid.
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.sensor.ad_api.run_in.assert_called_once_with(self.sensor.on_time_elapse, 15) self.sensor.ad_api.run_in.assert_called_once_with(
self.sensor.on_time_elapse({}) self.sensor.on_time_elapse, 15, generation=self.sensor._timer_generation)
self.queued_expiry()()
self.assertEqual(self.state, 'off') self.assertEqual(self.state, 'off')
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self): def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
self.sensor.on_condition_succeed() self.sensor.on_condition_succeed()
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.sensor.on_condition_succeed() self.sensor.on_condition_succeed()
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer') self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
self.assertIsNone(self.sensor.cb_handle) self.assertIsNone(self.sensor.cb_handle)
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
@@ -79,8 +129,11 @@ class RetainConditionTests(unittest.TestCase):
with self.subTest(result=result): with self.subTest(result=result):
self.state = 'on' self.state = 'on'
self.sensor.condition.evaluate.return_value = result self.sensor.condition.evaluate.return_value = result
self.sensor.on_time_elapse({}) self.sensor.register_callback()
self.queued_expiry()()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.assertIsNone(self.sensor.cb_handle)
self.sensor.condition.evaluate.assert_called_with()
def test_explicit_no_reset_preserves_fixed_window(self): def test_explicit_no_reset_preserves_fixed_window(self):
self.sensor.reset_retain_time_on_new_success = False self.sensor.reset_retain_time_on_new_success = False
@@ -88,7 +141,7 @@ class RetainConditionTests(unittest.TestCase):
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.sensor.condition.evaluate.return_value = Result.Succeeded self.sensor.condition.evaluate.return_value = Result.Succeeded
self.sensor.on_time_elapse({}) self.queued_expiry()()
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off') self.assertEqual(self.state, 'off')
+85 -8
View File
@@ -406,6 +406,7 @@ class ValueSelector(VirtualSensorBase):
class ContinuousCondition(VirtualSensorBase): class ContinuousCondition(VirtualSensorBase):
def initialize(self): def initialize(self):
self.cb_handle = None self.cb_handle = None
self._timer_generation = 0
self.time = self.yaml_block['time'] self.time = self.yaml_block['time']
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition'] try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
except KeyError: self.ignore_unavailable_condition = False except KeyError: self.ignore_unavailable_condition = False
@@ -452,14 +453,22 @@ class ContinuousCondition(VirtualSensorBase):
def register_callback(self): def register_callback(self):
if self.cb_handle == None: if self.cb_handle == None:
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.time) self._timer_generation += 1
self.cb_handle = self.ad_api.run_in(
self.on_time_elapse, self.time, generation=self._timer_generation)
def cancel_callback(self): def cancel_callback(self):
if self.cb_handle != None: # An expired timer may already have a callback queued in the worker.
self.ad_api.cancel_timer(self.cb_handle) # Invalidate it before cancellation, even if it is no longer scheduled.
self.cb_handle = None self._timer_generation += 1
handle = self.cb_handle
self.cb_handle = None
if handle is not None:
self.ad_api.cancel_timer(handle, silent=True)
def on_time_elapse(self, kwargs): def on_time_elapse(self, kwargs):
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None self.cb_handle = None
value = 'off' if self.invert else 'on' value = 'off' if self.invert else 'on'
if self.output_sensor.get_state() != value: if self.output_sensor.get_state() != value:
@@ -474,9 +483,68 @@ class ContinuousCondition(VirtualSensorBase):
if self.output_sensor.get_state() != value: if self.output_sensor.get_state() != value:
self.output_sensor.set_state(state = value) self.output_sensor.set_state(state = value)
class EphemeralCondition(ContinuousCondition):
"""True during the first `time` seconds of each true condition.
condition_started_at is a Unix timestamp retained after expiry, until
the condition becomes false. Reloads resume this window when the entity
attributes survive; without a timestamp, a true condition starts anew.
"""
def initialize(self):
self._condition_active = False
if self.yaml_block.get('invert', False):
raise ValueError("ephemeral_condition does not support invert; negate conditions instead")
started_at = self.ad_api.get_state(
self.sensor_name, attribute='condition_started_at')
try:
started_at = float(started_at)
if not 0 <= started_at <= time.time():
started_at = None
except (TypeError, ValueError):
started_at = None
self._started_at = started_at
self.attributes['condition_started_at'] = started_at
# Unknown inputs during startup must not erase a restored window.
self.yaml_block = dict(self.yaml_block)
self.yaml_block.setdefault('ignore_unavailable_condition', True)
super().initialize()
def on_condition_succeed(self):
if self._condition_active:
return
self._condition_active = True
if self._started_at is None:
self._started_at = time.time()
self.attributes['condition_started_at'] = self._started_at
remaining = self._started_at + self.time - time.time()
self.output_sensor.set_state(
state='on' if remaining > 0 else 'off',
attributes={'condition_started_at': self._started_at})
if remaining > 0:
self._timer_generation += 1
self.cb_handle = self.ad_api.run_in(
self.on_time_elapse, remaining, generation=self._timer_generation)
def on_condition_fail(self):
self._condition_active = False
self._started_at = None
self.attributes['condition_started_at'] = None
self.cancel_callback()
self.output_sensor.set_state(
state='off', attributes={'condition_started_at': None})
def on_time_elapse(self, kwargs):
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None
self.output_sensor.set_state(state='off')
class RetainCondition(VirtualSensorBase): class RetainCondition(VirtualSensorBase):
def initialize(self): def initialize(self):
self.cb_handle = None self.cb_handle = None
self._timer_generation = 0
self.retain_time = self.yaml_block['retain_time'] self.retain_time = self.yaml_block['retain_time']
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success'] try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
except KeyError: self.reset_retain_time_on_new_success = True except KeyError: self.reset_retain_time_on_new_success = True
@@ -510,14 +578,21 @@ class RetainCondition(VirtualSensorBase):
def register_callback(self): def register_callback(self):
if self.cb_handle == None: if self.cb_handle == None:
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.retain_time) self._timer_generation += 1
self.cb_handle = self.ad_api.run_in(
self.on_time_elapse, self.retain_time, generation=self._timer_generation)
def cancel_callback(self): def cancel_callback(self):
if self.cb_handle != None: # Invalidate callbacks already queued by an expired timer as well.
self.ad_api.cancel_timer(self.cb_handle) self._timer_generation += 1
self.cb_handle = None handle = self.cb_handle
self.cb_handle = None
if handle is not None:
self.ad_api.cancel_timer(handle, silent=True)
def on_time_elapse(self, kwargs): def on_time_elapse(self, kwargs):
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None self.cb_handle = None
if self.condition.evaluate() == SmartCondition.Result.Failed: if self.condition.evaluate() == SmartCondition.Result.Failed:
if self.output_sensor.get_state() != 'off': if self.output_sensor.get_state() != 'off':
@@ -697,6 +772,8 @@ class VirtualSensors():
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = Averager(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name) self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = Averager(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
elif splitted_sensor[0] == 'value_selector': elif splitted_sensor[0] == 'value_selector':
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name) self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
elif splitted_sensor[0] == 'ephemeral_condition':
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = EphemeralCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
elif splitted_sensor[0] == 'retain_condition': elif splitted_sensor[0] == 'retain_condition':
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name) self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
elif splitted_sensor[0] == 'event_sensor': elif splitted_sensor[0] == 'event_sensor':
+25
View File
@@ -0,0 +1,25 @@
# VS Code extension instructions
## VS Code extension
After every change to this extension, complete the update by compiling,
packaging, and installing the extension in the connected VS Code environment.
This includes changes limited to the Log Explorer HTML, CSS, or JavaScript.
- Keep the current extension version unless the user explicitly requests a
version change. Do not bump `package.json` or `package-lock.json` automatically.
- From this directory, run `npm run compile` and the checks appropriate to
the change. Report any existing test failures separately.
- Package with `vsce package --out vscode-appdaemon-<current-version>.vsix`,
using the installed or cached `@vscode/vsce` CLI when available. Overwrite the
existing VSIX for that version; never pass a version increment to `vsce`.
- Install that exact VSIX using
`code --install-extension ./vscode-appdaemon-<current-version>.vsix --force`.
- Confirm the installed version with `code --list-extensions --show-versions`.
When possible, verify the installed changed files match the packaged files.
- Tell the user to reload the VS Code window to activate the update. If the
connected CLI is unavailable or installation fails, report that limitation
clearly and provide the generated VSIX path; do not claim it was installed.
The user's request authorizes this routine local build and installation after
extension edits. Do not ask for additional conversational confirmation.
+11
View File
@@ -14,3 +14,14 @@ summary{display:grid;grid-template-columns:auto minmax(0,1fr) repeat(3,max-conte
summary .last-log{grid-column:1/-1;min-width:0} summary .last-log{grid-column:1/-1;min-width:0}
.rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none} .rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none}
.app-label{flex-direction:row;align-items:center;gap:6px}.app-label .name{min-width:0}.app-link{color:var(--accent);font-size:13px;line-height:1;text-decoration:none;white-space:nowrap}.app-link:hover{text-decoration:underline}.app-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .app-label{flex-direction:row;align-items:center;gap:6px}.app-label .name{min-width:0}.app-link{color:var(--accent);font-size:13px;line-height:1;text-decoration:none;white-space:nowrap}.app-link:hover{text-decoration:underline}.app-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* Keep severity counts distinct from the currently selected filter. */
#error-count::before,#warning-count::before{content:"";display:inline-block;width:1.2em}
#error-count[aria-pressed="true"],#warning-count[aria-pressed="true"]{
color:var(--vscode-button-foreground,#fff);
background:var(--vscode-button-background,#0e639c);
border-color:var(--vscode-contrastActiveBorder,var(--accent));
box-shadow:inset 0 2px 4px rgba(0,0,0,.35);
}
#error-count[aria-pressed="true"]::before,#warning-count[aria-pressed="true"]::before{content:"✓"}
#error-count[aria-pressed="true"]:hover,#warning-count[aria-pressed="true"]:hover{background:var(--vscode-button-hoverBackground,#1177bb)}
+1 -1
View File
@@ -3,7 +3,7 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head> <link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
<body> <body>
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</button></div></header> <header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" aria-pressed="false" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" aria-pressed="false" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</button></div></header>
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav> <nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
<div id="warnings" role="status"></div> <div id="warnings" role="status"></div>
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main> <main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
+2 -2
View File
@@ -84,7 +84,7 @@
const relative = node('span', timeSince(e.time), 'relative-time'); const relative = node('span', timeSince(e.time), 'relative-time');
relative.dataset.time = String(e.time); relative.dataset.time = String(e.time);
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`; relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
meta.append(relative, node('span', e.level, `badge ${normalLevel(e)}`)); meta.append(node('span', e.timestamp, 'exact-time'), relative, node('span', e.level, `badge ${normalLevel(e)}`));
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint')); if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
item.append(blockTitle([e]), meta, logMessages([e], 'row-message')); item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); }; item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
@@ -115,7 +115,7 @@
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time'); const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
relative.dataset.time = String(unit.entries[0].time); relative.dataset.time = String(unit.entries[0].time);
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`; relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
meta.append(relative); meta.append(node('span', unit.entries[0].timestamp, 'exact-time'), relative);
for (const level of ['INFO', 'WARNING', 'ERROR']) { for (const level of ['INFO', 'WARNING', 'ERROR']) {
const count = unit.entries.filter(e => normalLevel(e) === level).length; const count = unit.entries.filter(e => normalLevel(e) === level).length;
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`)); if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
+3 -1
View File
@@ -88,6 +88,7 @@ test('shows same-event log lines together with one causes link and keeps newer e
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle')); const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
const rows=alpha.querySelector('.rows'),combined=rows.children[0]; const rows=alpha.querySelector('.rows'),combined=rows.children[0];
assert.ok(combined.classList.contains('event-log-group')); assert.ok(combined.classList.contains('event-log-group'));
assert.equal(combined.querySelector('.exact-time').textContent,trigger.timestamp);
assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/); assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/);
assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/); assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/);
assert.equal(combined.querySelectorAll('.link-hint').length,1); assert.equal(combined.querySelectorAll('.link-hint').length,1);
@@ -172,7 +173,7 @@ test('distinguishes log lines from supplemental records when configured',()=>{
dom.window.close(); dom.window.close();
}); });
test('shows and refreshes relative time on each log entry while retaining exact time on hover',async()=>{ test('shows and refreshes relative time on each log entry while displaying exact time alongside it',async()=>{
const {dom,d,entries}=setup(); const {dom,d,entries}=setup();
const entry=entries.find(e=>e.event_id==='a'); const entry=entries.find(e=>e.event_id==='a');
dom.window.Date.now=()=>entry.time+120000; dom.window.Date.now=()=>entry.time+120000;
@@ -182,6 +183,7 @@ test('shows and refreshes relative time on each log entry while retaining exact
const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible')); const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible'));
const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time'); const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time');
assert.equal(relativeTime().textContent,'2 minutes ago'); assert.equal(relativeTime().textContent,'2 minutes ago');
assert.equal(relativeTime().previousElementSibling.textContent,entries[3].timestamp);
assert.match(relativeTime().title,/10:00:10/); assert.match(relativeTime().title,/10:00:10/);
dom.window.Date.now=()=>entry.time+3600000; dom.window.Date.now=()=>entry.time+3600000;
await new Promise(resolve=>setTimeout(resolve,1600)); await new Promise(resolve=>setTimeout(resolve,1600));
Binary file not shown.