Compare commits

...
13 Commits
Author SHA1 Message Date
Pierre 86b5441603 removed old vsix 2026-11-10 19:08:21 +01:00
Pierre d5e7193b42 smartobject and virtualevent now correctly use the logger interface for their logs 2026-11-08 11:07:27 +01:00
Pierre 54c525c843 fix: ignore stale callbacks from rescheduled timers
- Add generation counters to SmartCondition and EventSensor timers.
- Preserve pending changes and the retention period when timers are restarted.
- Silently cancel timers that have already expired.
- Add regression tests for stale callbacks and expiry during cancellation.
2026-11-03 19:14:20 +01:00
Pierre 1a148d4e3f New time period virtual sensor 2026-10-25 20:39:52 +01:00
Pierre b97445ce92 A new epheleral_condition sensor type + fix issue with dangling callback 2026-10-16 08:49:57 +02:00
Pierre 3b0c24e795 display proper time stamp on log message 2026-10-16 08:49:12 +02:00
Pierre bf72dbb223 SmartObject now create its linked entity with create_entity 2026-10-11 11:28:20 +02:00
Pierre 97501875b8 made error/warning button push more obvious 2026-10-11 11:00:20 +02:00
Pierre 5ae930c710 add support for ascii encode 2026-10-10 15:25:00 +02:00
Pierre cf8839db6a remove the warning for default entity creation 2026-10-04 23:42:54 +02:00
Pierre 1d14219b93 fix most of the entity creation warning 2026-10-04 23:10:22 +02:00
Pierre d8756928a5 fixed create_entity warnings 2026-10-04 22:08:43 +02:00
Pierre caf9051864 added a warning button 2026-10-04 20:01:17 +02:00
18 changed files with 883 additions and 62 deletions

No files matched your search

+13 -8
View File
@@ -27,14 +27,19 @@ class LoggerInterface:
def __init__(self):
self.initialized = False
def __init__(self, ad_api, default_log='main_log', mute=False):
self.initialize_logger_interface(ad_api, default_log, mute)
def __init__(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
self.initialize_logger_interface(ad_api, default_log, mute, ascii_encode=ascii_encode)
def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False):
def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
self.ad_api = ad_api
self.default_log = default_log
self.mute = False
self.initialized = True
self._log_options = {} if ascii_encode is None else {'ascii_encode': ascii_encode}
def _write_log(self, message, level, log):
self.ad_api.log(self._tag_log_message(message), level=level, log=log,
**self._log_options)
def log_group(self, kind, label=None):
return log_group(kind, label)
@@ -51,17 +56,17 @@ class LoggerInterface:
def mute_logger(self, mute: bool):
if mute:
self.ad_api.log(self._tag_log_message('Muting logger'), level='INFO', log=self.default_log)
self._write_log('Muting logger', 'INFO', self.default_log)
self.mute = bool(mute)
def log_info(self, message):
assert self.initialized
if not self.mute:
self.ad_api.log(self._tag_log_message(message), level='INFO', log=self.default_log)
self._write_log(message, 'INFO', self.default_log)
def log_warning(self, message):
assert self.initialized
self.ad_api.log(self._tag_log_message(message), level='WARNING', log=self.default_log)
self._write_log(message, 'WARNING', self.default_log)
def log_error(self, message, stop_app=False, dump_stack=False):
assert self.initialized
@@ -74,8 +79,8 @@ class LoggerInterface:
stack = ''
message = self._tag_log_message(message)
self.ad_api.log(message, level='ERROR', log=self.default_log)
self.ad_api.log(message + stack, level='ERROR', log='error_log')
self._write_log(message, 'ERROR', self.default_log)
self._write_log(message + stack, 'ERROR', 'error_log')
if stop_app:
self.stop_app(self.name)
+14 -4
View File
@@ -46,6 +46,7 @@ class Evaluator():
self.__state_callbacks_handle = list()
self.__condition_state_change_delayed = list()
self.__condition_state_change_delayed_cb_handle = None
self.__delay_generation = 0
self.__appdaemon_api = appdaemon_api
self.__activated = False
@@ -573,6 +574,10 @@ class Evaluator():
self.__last_evaluation_result = result
def __on_condition_state_change_delayed(self, kwargs):
# An expired timer may already be queued when a newer change reschedules it.
# Ignore it before touching the current timer handle or pending changes.
if kwargs.get("generation") != self.__delay_generation:
return
self.__condition_state_change_delayed_cb_handle = None
if len(self.__condition_state_change_delayed) == 0:
self.__log(f"Callback has been trigger but self.__condition_state_change_delayed is empty, this shouldn't happen, the callback won't be called", level = "WARNING")
@@ -607,13 +612,18 @@ class Evaluator():
# self.__update_result()
self.__trigger_callbacks(f"Callback trigger by {entity}({old} => {new})")
else:
if self.__condition_state_change_delayed_cb_handle != None and self.__appdaemon_api.timer_running(self.__condition_state_change_delayed_cb_handle):
self.__appdaemon_api.cancel_timer(self.__condition_state_change_delayed_cb_handle)
self.__delay_generation += 1
handle = self.__condition_state_change_delayed_cb_handle
if handle is not None:
# Expiry may already have removed the timer from the scheduler.
self.__appdaemon_api.cancel_timer(handle, silent=True)
self.__condition_state_change_delayed_cb_handle = None
self.__condition_state_change_delayed.append((entity,old,new,datetime.now()))
#self.__log("State changed :" + str(entity) + "(" + str(old) + " => " + str(new) + ") but callback delayed for " + str(self.__callback_delay) + "s")
self.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(self.__on_condition_state_change_delayed, self.__callback_delay)
self.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(
self.__on_condition_state_change_delayed, self.__callback_delay,
generation=self.__delay_generation,
)
def __log(self,message,**kwargs):
log_string = self.__log_to_string(message)
+14 -13
View File
@@ -113,12 +113,11 @@ class SmartObject(hass.Hass,LoggerInterface):
if device_class is not None: attributes['device_class'] = device_class
if state_class is not None: attributes['state_class'] = state_class
if state is not None:
entity.set_state(state=state, attributes=attributes)
else:
if not entity.exists():
entity.set_state(state='unknown')
entity.set_state(attributes=attributes)
if state is None:
state = entity.get_state() if entity.exists() else None
if state is None:
state = 'unknown'
entity.set_state(state=state, attributes=attributes, check_existence=False)
return entity
@@ -185,15 +184,15 @@ class SmartObject(hass.Hass,LoggerInterface):
def initialize(self):
self.initialize_logger_interface(self.get_ad_api())
with self.log_group('App Initialization') as group:
self.log(f"Initialization started [group={group['kind']}:{group['id']}]")
self._write_log(f"Initialization started [group={group['kind']}:{group['id']}]", 'INFO', None)
try:
succeeded = self._initialize_smart_object()
except BaseException:
self.log(f"Initialization failed [group={group['kind']}:{group['id']}]", level='ERROR')
self._write_log(f"Initialization failed [group={group['kind']}:{group['id']}]", 'ERROR', None)
raise
status = 'completed' if succeeded else 'failed'
level = 'INFO' if succeeded else 'ERROR'
self.log(f"Initialization {status} [group={group['kind']}:{group['id']}]", level=level)
self._write_log(f"Initialization {status} [group={group['kind']}:{group['id']}]", level, None)
def _initialize_smart_object(self):
try:
@@ -219,14 +218,16 @@ class SmartObject(hass.Hass,LoggerInterface):
# entity: <entity_id> → link app to a HA entity
if "entity" in self.args:
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():
self.log_info(f"Linked to {self.entity_id}")
elif 'default_entity_state' in self.args:
self.entity.set_state(state = self.args["default_entity_state"])
self.log_info(f"Creating {self.entity_id}, default_state = {self.args['default_entity_state']}")
initial_state = self.args["default_entity_state"]
self.log_info(f"Creating {self.entity_id}, default_state = {initial_state}")
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
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()
+79
View File
@@ -0,0 +1,79 @@
"""An expired timer queued before reactivation must not end the new window."""
import ast
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from test_continuous_condition import FakeScheduler
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 == 'EventSensor')
namespace = {'VirtualSensorBase': object, 'EventHandler': Mock()}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
EventSensor = namespace['EventSensor']
class EventSensorTests(unittest.TestCase):
def setUp(self):
self.scheduler = FakeScheduler()
self.state = 'off'
entity = Mock()
entity.exists.return_value = True
entity.set_state.side_effect = lambda *, state: setattr(self, 'state', state)
self.sensor = EventSensor()
self.sensor.ad_api = SimpleNamespace(
get_entity=lambda name: entity,
run_in=self.scheduler.run_in,
cancel_timer=self.scheduler.cancel_timer,
)
self.sensor.sensor_name = 'binary_sensor.red_shelly_home'
self.sensor.yaml_block = {'retain_time': 120}
self.sensor.initialize()
def activate(self):
self.sensor.on_activation_event('signal_strength', {}, {})
return self.sensor.cb_handle
def test_current_expiry_turns_sensor_off(self):
handle = self.activate()
self.assertEqual(self.state, 'on')
self.scheduler.expire(handle)()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_reactivation_cancels_running_timer(self):
old = self.activate()
current = self.activate()
self.assertNotIn(old, self.scheduler.timers)
self.assertIn(current, self.scheduler.timers)
self.assertEqual(self.state, 'on')
self.assertEqual(self.scheduler.warnings, [])
def test_queued_expiry_preserves_reactivated_sensor_and_timer(self):
queued = self.scheduler.expire(self.activate())
current = self.activate()
queued()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, current)
self.assertEqual(self.scheduler.warnings, [])
self.scheduler.expire(current)()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_multiple_queued_expiries_cannot_end_latest_window(self):
first = self.scheduler.expire(self.activate())
second = self.scheduler.expire(self.activate())
current = self.activate()
second()
first()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, current)
self.assertEqual(self.scheduler.warnings, [])
self.scheduler.expire(current)()
self.assertEqual(self.state, 'off')
if __name__ == '__main__':
unittest.main()
+58 -5
View File
@@ -27,6 +27,7 @@ class RetainConditionTests(unittest.TestCase):
def setUp(self):
self.sensor = RetainCondition()
self.sensor.cb_handle = None
self.sensor._timer_generation = 0
self.sensor.retain_time = 15
self.sensor.reset_retain_time_on_new_success = True
self.state = 'off'
@@ -41,6 +42,54 @@ class RetainConditionTests(unittest.TestCase):
def set_state(self, *, 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):
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
@@ -48,15 +97,16 @@ class RetainConditionTests(unittest.TestCase):
# No timer can expire while the signal stays valid.
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'on')
self.sensor.ad_api.run_in.assert_called_once_with(self.sensor.on_time_elapse, 15)
self.sensor.on_time_elapse({})
self.sensor.ad_api.run_in.assert_called_once_with(
self.sensor.on_time_elapse, 15, generation=self.sensor._timer_generation)
self.queued_expiry()()
self.assertEqual(self.state, 'off')
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
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.assertEqual(self.state, 'on')
self.sensor.on_condition_fail()
@@ -79,8 +129,11 @@ class RetainConditionTests(unittest.TestCase):
with self.subTest(result=result):
self.state = 'on'
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.assertIsNone(self.sensor.cb_handle)
self.sensor.condition.evaluate.assert_called_with()
def test_explicit_no_reset_preserves_fixed_window(self):
self.sensor.reset_retain_time_on_new_success = False
@@ -88,7 +141,7 @@ class RetainConditionTests(unittest.TestCase):
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'on')
self.sensor.condition.evaluate.return_value = Result.Succeeded
self.sensor.on_time_elapse({})
self.queued_expiry()()
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off')
+122
View File
@@ -0,0 +1,122 @@
"""Verify debouncing when an expired AppDaemon timer is still queued."""
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from smartcondition import Evaluator
class FakeScheduler:
def __init__(self):
self.timers = {}
self.next_id = 0
self.cancelled = []
self.warnings = []
self.expire_on_cancel = False
self.queued = []
def run_in(self, callback, delay, **kwargs):
self.next_id += 1
handle = str(self.next_id)
self.timers[handle] = (callback, kwargs)
return handle
def timer_running(self, handle):
return handle in self.timers
def cancel_timer(self, handle, silent=False):
self.cancelled.append(handle)
if self.expire_on_cancel and handle in self.timers:
self.queued.append(self.expire(handle))
cancelled = self.timers.pop(handle, None) is not None
if not cancelled and not silent:
self.warnings.append(handle)
return cancelled
def expire(self, handle):
callback, kwargs = self.timers.pop(handle)
return lambda: callback(kwargs)
class DelayedCallbackTests(unittest.TestCase):
def setUp(self):
self.scheduler = FakeScheduler()
self.evaluator = Evaluator.__new__(Evaluator)
fields = dict(
appdaemon_api=self.scheduler, callback_delay=10,
trigger_callback_on_entity_creation=True,
condition_state_change_delayed=[],
condition_state_change_delayed_cb_handle=None,
delay_generation=0, trigger_callbacks=Mock(), log=Mock(),
)
for name, value in fields.items():
setattr(self.evaluator, '_Evaluator__' + name, value)
def change(self, old, new):
self.evaluator._Evaluator__on_condition_state_change(
'sensor.temperature', 'state', old, new, {})
return self.evaluator._Evaluator__condition_state_change_delayed_cb_handle
def test_queued_obsolete_callback_preserves_new_timer_and_changes(self):
old_handle = self.change('20', '21')
queued_callback = self.scheduler.expire(old_handle)
new_handle = self.change('21', '22')
queued_callback()
self.assertEqual(self.scheduler.warnings, [])
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
new_handle)
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
self.evaluator._Evaluator__log.assert_not_called()
self.scheduler.expire(new_handle)()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
reason = self.evaluator._Evaluator__trigger_callbacks.call_args.args[0]
self.assertIn('20 => 21', reason)
self.assertIn('21 => 22', reason)
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed, [])
self.assertIsNone(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle)
self.evaluator._Evaluator__log.assert_not_called()
def test_obsolete_callback_after_current_callback_is_ignored(self):
queued_callback = self.scheduler.expire(self.change('20', '21'))
self.scheduler.expire(self.change('21', '22'))()
queued_callback()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
self.evaluator._Evaluator__log.assert_not_called()
def test_running_timer_is_cancelled_when_rescheduled(self):
old_handle = self.change('20', '21')
new_handle = self.change('21', '22')
self.assertEqual(self.scheduler.cancelled, [old_handle])
self.assertFalse(self.scheduler.timer_running(old_handle))
self.scheduler.expire(new_handle)()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
def test_expiry_during_cancellation_is_silent_and_preserves_new_batch(self):
self.change('20', '21')
self.scheduler.expire_on_cancel = True
current = self.change('21', '22')
self.assertEqual(self.scheduler.warnings, [])
self.assertEqual(len(self.scheduler.queued), 1)
self.scheduler.queued[0]()
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
current)
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
self.scheduler.expire(current)()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
self.evaluator._Evaluator__log.assert_not_called()
def test_zero_delay_remains_immediate(self):
self.evaluator._Evaluator__callback_delay = 0
self.change('20', '21')
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
self.assertEqual(self.scheduler.timers, {})
if __name__ == '__main__':
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Time-period boundaries, source updates and timer lifecycle without AppDaemon."""
import ast
import datetime
import re
import unittest
from pathlib import Path
from unittest.mock import Mock
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
nodes = [node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name in ('VirtualSensorBase', 'TimePeriod', 'VirtualSensors')]
from kwargsparser import kwargsParser
namespace = {'datetime': datetime, 're': re, 'kwargsParser': kwargsParser, 'ParsingException': ValueError}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), namespace)
TimePeriod = namespace['TimePeriod']
class TimePeriodTests(unittest.TestCase):
def make_sensor(self, start='08:00:00', end='18:00:00', now='2026-10-25 12:00:00'):
self.states = {'input_datetime.start': start, 'input_datetime.end': end}
self.api = Mock()
self.api.datetime.return_value = datetime.datetime.fromisoformat(now)
self.api.get_state.side_effect = self.states.get
self.api.entity_exists.return_value = True
self.api.get_entity.return_value.get_state.return_value = 'unknown'
self.sensor = TimePeriod(self.api, Mock(), virtual_sensor_name='period',
yaml_block={'start': 'input_datetime.start', 'end': 'input_datetime.end'})
self.sensor._initialize()
return self.sensor
def assert_state(self, expected):
self.assertEqual(self.sensor.entity.set_state.call_args.kwargs['state'], expected)
def test_factory_registration(self):
self.make_sensor()
factory = namespace['VirtualSensors'](ad_api=self.api, logger_interface=Mock(),
yaml_block={'sensors': {'time_period.example': {
'start': 'input_datetime.start', 'end': 'input_datetime.end'}}})
self.assertIsInstance(factory.virtual_sensors['binary_sensor.example'], TimePeriod)
def test_day_boundaries_and_timer(self):
self.make_sensor(now='2026-10-25 07:59:50')
self.assert_state('off')
self.assertEqual(self.api.run_in.call_args.args[1], 10)
for hour, expected in [('08:00:00', 'on'), ('17:59:59', 'on'), ('18:00:00', 'off')]:
self.api.datetime.return_value = datetime.datetime.fromisoformat('2026-10-25 ' + hour)
self.sensor.on_time_elapsed({'generation': self.sensor._timer_generation})
self.assert_state(expected)
def test_overnight_and_equal_boundaries(self):
for hour, expected in [('23:00:00', 'on'), ('02:00:00', 'on'), ('06:00:00', 'off'), ('12:00:00', 'off')]:
self.make_sensor('22:00:00', '06:00:00', '2026-10-25 ' + hour)
self.assert_state(expected)
self.make_sensor('08:00:00', '08:00:00')
self.assert_state('off')
def test_source_change_cancels_timer_and_ignores_stale_callback(self):
self.make_sensor()
self.assert_state('on')
previous_generation = self.sensor._timer_generation
previous_handle = self.sensor.cb_handle
self.states['input_datetime.end'] = '10:00:00'
self.sensor.on_input_change('input_datetime.end', 'state', '18:00:00', '10:00:00', {})
self.assert_state('off')
self.api.cancel_timer.assert_called_with(previous_handle, silent=True)
count = self.api.run_in.call_count
self.sensor.on_time_elapsed({'generation': previous_generation})
self.assertEqual(self.api.run_in.call_count, count)
self.assertEqual(self.api.listen_state.call_count, 2)
def test_unavailable_and_recovery(self):
for invalid in [None, 'unknown', 'unavailable', '25:00:00', '2026-10-25']:
self.make_sensor(start=invalid)
self.assert_state('unavailable')
self.states['input_datetime.start'] = '08:00:00'
self.sensor.update_period()
self.assert_state('on')
def test_dated_periods(self):
for start, end, expected in [
('2026-10-24 22:00:00', '2026-10-26 06:00:00', 'on'),
('2026-10-24 22:00:00', '2026-10-25 12:00:00', 'off'),
('2026-10-26 22:00:00', '2026-10-24 06:00:00', 'off'),
('2026-10-24', '2026-10-26', 'on'),
]:
self.make_sensor(start, end)
self.assert_state(expected)
def test_dependencies_constants_and_validation(self):
self.make_sensor()
self.assertEqual(self.sensor.sensor_name, 'binary_sensor.period')
self.sensor.constants['begin'] = 'input_datetime.start'
self.sensor.yaml_block['start'] = 'begin'
self.assertEqual(self.sensor.generate_dependencies_list(), list(self.states))
self.sensor.yaml_block['end'] = 'sensor.invalid'
with self.assertRaises(ValueError):
self.sensor.generate_dependencies_list()
if __name__ == '__main__':
unittest.main()
+2 -2
View File
@@ -22,9 +22,9 @@ class VirtualEvents(hass.Hass,LoggerInterface):
def on_condition_succeed(self,event_name):
event_yaml = self.args["virtual_events"][event_name]
if 'event_data' in event_yaml:
self.log(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
self.log_info(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
self.fire_event(event_yaml['event_name'],**event_yaml['event_data'])
else:
self.log(f"Sending event {event_yaml['event_name']}")
self.log_info(f"Sending event {event_yaml['event_name']}")
self.fire_event(event_yaml['event_name'])
+189 -12
View File
@@ -4,6 +4,7 @@ import smartvalue as SmartValue
from kwargsparser import kwargsParser
from expressionparser import ParsingException
import time
import datetime
import re
from logger_interface import LoggerInterface
from ad_toolbox.eventhandler import EventHandler
@@ -46,6 +47,11 @@ class VirtualSensorBase:
if super_entity_id != None:
self.constants['super'] = super_entity_id
self.entity_existed = self.ad_api.entity_exists(self.sensor_name)
if not self.entity_existed:
self.ad_api.set_state(self.sensor_name, state='unknown',attributes=self.attributes, check_existence=False)
self.entity = self.ad_api.get_entity(self.sensor_name)
if self_initialize:
self._initialize()
@@ -401,6 +407,7 @@ class ValueSelector(VirtualSensorBase):
class ContinuousCondition(VirtualSensorBase):
def initialize(self):
self.cb_handle = None
self._timer_generation = 0
self.time = self.yaml_block['time']
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
except KeyError: self.ignore_unavailable_condition = False
@@ -447,14 +454,22 @@ class ContinuousCondition(VirtualSensorBase):
def register_callback(self):
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):
if self.cb_handle != None:
self.ad_api.cancel_timer(self.cb_handle)
self.cb_handle = None
# An expired timer may already have a callback queued in the worker.
# Invalidate it before cancellation, even if it is no longer scheduled.
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):
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None
value = 'off' if self.invert else 'on'
if self.output_sensor.get_state() != value:
@@ -469,9 +484,68 @@ class ContinuousCondition(VirtualSensorBase):
if self.output_sensor.get_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):
def initialize(self):
self.cb_handle = None
self._timer_generation = 0
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']
except KeyError: self.reset_retain_time_on_new_success = True
@@ -505,14 +579,21 @@ class RetainCondition(VirtualSensorBase):
def register_callback(self):
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):
if self.cb_handle != None:
self.ad_api.cancel_timer(self.cb_handle)
self.cb_handle = None
# Invalidate callbacks already queued by an expired timer as well.
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):
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None
if self.condition.evaluate() == SmartCondition.Result.Failed:
if self.output_sensor.get_state() != 'off':
@@ -538,6 +619,90 @@ class RetainCondition(VirtualSensorBase):
if self.output_sensor.get_state() != 'off':
self.output_sensor.set_state(state = 'off')
class TimePeriod(VirtualSensorBase):
"""Active in [start, end), using two input_datetime entities.
Time-only inputs repeat daily and may cross midnight. Dated inputs
describe a single interval. Equal boundaries describe an empty interval.
"""
def generate_sensor_name(self, virtual_sensor_name):
return f"binary_sensor.{virtual_sensor_name}"
def generate_dependencies_list(self):
inputs = []
for key in ('start', 'end'):
value = self.yaml_block.get(key)
value = self.constants.get(value, value) if isinstance(value, str) else value
if not self.is_entity_reference(value) or not value.startswith('input_datetime.'):
raise ValueError(f"{self.sensor_name}: {key} must reference an input_datetime")
inputs.append(value)
return inputs
def initialize(self):
self.start_entity, self.end_entity = self.generate_dependencies_list()
self.cb_handle = None
self._timer_generation = 0
for entity in set((self.start_entity, self.end_entity)):
self.ad_api.listen_state(self.on_input_change, entity)
self.update_period()
@staticmethod
def parse_boundary(value):
for fmt, kind in (('%H:%M:%S', 'time'),
('%Y-%m-%d %H:%M:%S', 'datetime'),
('%Y-%m-%d', 'date')):
try:
parsed = datetime.datetime.strptime(value, fmt)
return kind, parsed.time() if kind == 'time' else parsed
except (TypeError, ValueError):
continue
raise ValueError('Unavailable or invalid input_datetime state')
def on_input_change(self, entity, attribute, old, new, kwargs):
self.update_period()
def on_time_elapsed(self, kwargs):
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None
self.update_period()
def update_period(self):
self._timer_generation += 1
if self.cb_handle is not None:
self.ad_api.cancel_timer(self.cb_handle, silent=True)
self.cb_handle = None
now = self.ad_api.datetime()
delay = 60.0
try:
start_kind, start = self.parse_boundary(self.ad_api.get_state(self.start_entity))
end_kind, end = self.parse_boundary(self.ad_api.get_state(self.end_entity))
if start_kind != end_kind:
raise ValueError('Both inputs must have the same date/time format')
if start_kind == 'time':
current = now.time()
active = (start <= current < end if start <= end
else current >= start or current < end)
boundaries = [datetime.datetime.combine(now.date(), value)
for value in (start, end)]
boundaries = [value if value > now else value + datetime.timedelta(days=1)
for value in boundaries]
else:
active = start <= now < end
boundaries = [value for value in (start, end) if value > now]
state = 'on' if active else 'off'
if boundaries:
delay = min(delay, min((value - now).total_seconds() for value in boundaries))
except ValueError:
state = 'unavailable'
if self.entity.get_state() != state:
self.entity.set_state(state=state)
# Refresh at boundaries, and at least every minute to follow clock/DST changes.
self.cb_handle = self.ad_api.run_in(
self.on_time_elapsed, delay, generation=self._timer_generation)
class BinarySensor(VirtualSensorBase):
def initialize(self):
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
@@ -592,20 +757,28 @@ class EventSensor(VirtualSensorBase):
self.retain_time = self.yaml_block['retain_time']
self.cb_handle = None
self._timer_generation = 0
self.event_handlers = []
if "activation_events" in self.yaml_block:
self.event_handlers.append(EventHandler(self.ad_api, self.yaml_block["activation_events"], self.on_activation_event,logger_interface = self.logger_interface))
def on_activation_event(self, event_name, data, kwargs):
self._timer_generation += 1
self.output_sensor.set_state(state = 'on')
if self.cb_handle is not None:
self.ad_api.cancel_timer(self.cb_handle)
self.cb_handle = self.ad_api.run_in(self.on_retain_time_elapsed, self.retain_time)
self.ad_api.cancel_timer(self.cb_handle, silent=True)
self.cb_handle = self.ad_api.run_in(
self.on_retain_time_elapsed, self.retain_time,
generation=self._timer_generation,
)
def on_retain_time_elapsed(self, kwargs):
self.output_sensor.set_state(state = 'off')
# Cancellation cannot retract a callback already queued by AppDaemon.
if kwargs.get('generation') != self._timer_generation:
return
self.cb_handle = None
self.output_sensor.set_state(state = 'off')
def generate_sensor_name(self,virtual_sensor_name):
return f"binary_sensor.{virtual_sensor_name}"
@@ -657,7 +830,7 @@ class VirtualSensors():
for sensor_name,default_value in default_values.items():
if not self.ad_api.entity_exists(sensor_name):
self.logger_interface.log_info(f"Creating sensor {sensor_name} = {default_value}")
self.ad_api.set_state(sensor_name, state = str(default_value))
self.ad_api.set_state(sensor_name, state = str(default_value),check_existence=False)
averagers = parser.parse_args('averagers',{})
for averager in averagers:
@@ -684,6 +857,8 @@ class VirtualSensors():
if splitted_sensor[0] == 'binary_sensor':
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = BinarySensor(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] == 'time_period':
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = TimePeriod(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] == 'sensor':
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSensor(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] == 'continuous_condition':
@@ -692,6 +867,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)
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)
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':
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':
+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.
+12 -1
View File
@@ -7,10 +7,21 @@
.block-title{font-weight:600;margin-bottom:6px;color:var(--fg);overflow-wrap:anywhere}
#error-count{font-weight:700;font-size:14px}#error-count.has-errors{color:var(--vscode-editorError-foreground,#ffacac);border-color:currentColor;background:var(--vscode-inputValidation-errorBackground,#5b3039)}
#error-count,#warning-count{font-weight:700;font-size:14px}#error-count.has-errors{color:var(--vscode-editorError-foreground,#ffacac);border-color:currentColor;background:var(--vscode-inputValidation-errorBackground,#5b3039)}#warning-count.has-warnings{color:var(--vscode-editorWarning-foreground,#f3cd80);border-color:currentColor;background:var(--vscode-inputValidation-warningBackground,#594925)}
.last-log{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
summary{display:grid;grid-template-columns:auto minmax(0,1fr) repeat(3,max-content);align-items:center;column-gap:10px;row-gap:5px}
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}
.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}}';">
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
<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="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>
<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>
+24 -15
View File
@@ -13,7 +13,8 @@
const appFlashTokens = new Map();
let receivedInitialData = false;
let pointerSelecting = false;
let errorView;
let countView;
let activeCountLevel;
const $ = id => document.getElementById(id);
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
@@ -83,7 +84,7 @@
const relative = node('span', timeSince(e.time), 'relative-time');
relative.dataset.time = String(e.time);
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'));
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
@@ -114,7 +115,7 @@
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
relative.dataset.time = String(unit.entries[0].time);
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']) {
const count = unit.entries.filter(e => normalLevel(e) === level).length;
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
@@ -149,6 +150,7 @@
});
const timeOf = (app, logs) => (lastLogs.get(app) || logs[logs.length - 1]).time;
$('error-count').setAttribute('aria-pressed', String($('level').value === 'ERROR'));
$('warning-count').setAttribute('aria-pressed', String($('level').value === 'WARNING'));
[...groups].sort(([a, al], [b, bl]) => {
const order = $('sort').value;
if (order === 'name') return a.localeCompare(b);
@@ -363,6 +365,10 @@
$('error-count').textContent = `${errorCount} error${errorCount === 1 ? '' : 's'}`;
$('error-count').classList.toggle('has-errors', errorCount > 0);
$('error-count').title = 'Errors and critical logs across all apps in loaded history. Click to show them.';
const warningCount = entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && normalLevel(e) === 'WARNING').length;
$('warning-count').textContent = `${warningCount} warning${warningCount === 1 ? '' : 's'}`;
$('warning-count').classList.toggle('has-warnings', warningCount > 0);
$('warning-count').title = 'Warnings across all apps in loaded history. Click to show them.';
$('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail();
}
function refreshLastLogAges() {
@@ -387,21 +393,22 @@
document.addEventListener('pointerup', () => { pointerSelecting = false; });
document.addEventListener('selectionchange', () => { if (!selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => {
errorView = undefined; selected = undefined; origin = undefined;
countView = undefined; activeCountLevel = undefined; selected = undefined; origin = undefined;
save(); renderApps(); renderDetail();
}));
$('error-count').onclick = () => {
const leaving = $('level').value === 'ERROR';
function toggleCountView(level) {
const leaving = activeCountLevel === level;
if (leaving) {
$('search').value = errorView?.search || '';
$('period').value = errorView?.period || '0';
$('level').value = errorView?.level || 'all';
if (errorView) { expanded.clear(); errorView.expanded.forEach(app => expanded.add(app)); }
errorView = undefined;
$('search').value = countView?.search || '';
$('period').value = countView?.period || '0';
$('level').value = countView?.level || 'all';
if (countView) { expanded.clear(); countView.expanded.forEach(app => expanded.add(app)); }
countView = undefined; activeCountLevel = undefined;
} else {
errorView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] };
$('search').value = ''; $('period').value = '0'; $('level').value = 'ERROR';
entries.filter(e => normalLevel(e) === 'ERROR').forEach(e => expanded.add(e.app));
if (!activeCountLevel) countView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] };
activeCountLevel = level;
$('search').value = ''; $('period').value = '0'; $('level').value = level;
entries.filter(e => normalLevel(e) === level).forEach(e => expanded.add(e.app));
}
selected = undefined; origin = undefined;
save(); renderApps(); renderDetail();
@@ -409,7 +416,9 @@
const first = document.querySelector('#apps .row');
if (first) { first.click(); first.closest('.rows').scrollTop = 0; first.focus({ preventScroll: true }); }
}
};
}
$('error-count').onclick = () => toggleCountView('ERROR');
$('warning-count').onclick = () => toggleCountView('WARNING');
$('clear').onclick = () => { selected = undefined; origin = undefined; save(); $('clear').disabled = true; $('clear').textContent = 'Clearing…'; vscode.postMessage({ type: 'clear' }); };
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
+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 rows=alpha.querySelector('.rows'),combined=rows.children[0];
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,/Triggered by \(sensor.kitchen_last_motion/);
assert.equal(combined.querySelectorAll('.link-hint').length,1);
@@ -172,7 +173,7 @@ test('distinguishes log lines from supplemental records when configured',()=>{
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 entry=entries.find(e=>e.event_id==='a');
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 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().previousElementSibling.textContent,entries[3].timestamp);
assert.match(relativeTime().title,/10:00:10/);
dom.window.Date.now=()=>entry.time+3600000;
await new Promise(resolve=>setTimeout(resolve,1600));
Binary file not shown.
Binary file not shown.