Compare commits
13
Commits
3171433770
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b5441603 | ||
|
|
d5e7193b42 | ||
|
|
54c525c843 | ||
|
|
1a148d4e3f | ||
|
|
b97445ce92 | ||
|
|
3b0c24e795 | ||
|
|
bf72dbb223 | ||
|
|
97501875b8 | ||
|
|
5ae930c710 | ||
|
|
cf8839db6a | ||
|
|
1d14219b93 | ||
|
|
d8756928a5 | ||
|
|
caf9051864 |
No files matched your search
+13
-8
@@ -27,14 +27,19 @@ class LoggerInterface:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.initialized = False
|
self.initialized = False
|
||||||
|
|
||||||
def __init__(self, ad_api, default_log='main_log', mute=False):
|
def __init__(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
|
||||||
self.initialize_logger_interface(ad_api, default_log, mute)
|
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.ad_api = ad_api
|
||||||
self.default_log = default_log
|
self.default_log = default_log
|
||||||
self.mute = False
|
self.mute = False
|
||||||
self.initialized = True
|
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):
|
def log_group(self, kind, label=None):
|
||||||
return log_group(kind, label)
|
return log_group(kind, label)
|
||||||
@@ -51,17 +56,17 @@ class LoggerInterface:
|
|||||||
|
|
||||||
def mute_logger(self, mute: bool):
|
def mute_logger(self, mute: bool):
|
||||||
if mute:
|
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)
|
self.mute = bool(mute)
|
||||||
|
|
||||||
def log_info(self, message):
|
def log_info(self, message):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
if not self.mute:
|
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):
|
def log_warning(self, message):
|
||||||
assert self.initialized
|
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):
|
def log_error(self, message, stop_app=False, dump_stack=False):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
@@ -74,8 +79,8 @@ class LoggerInterface:
|
|||||||
stack = ''
|
stack = ''
|
||||||
|
|
||||||
message = self._tag_log_message(message)
|
message = self._tag_log_message(message)
|
||||||
self.ad_api.log(message, level='ERROR', log=self.default_log)
|
self._write_log(message, 'ERROR', self.default_log)
|
||||||
self.ad_api.log(message + stack, level='ERROR', log='error_log')
|
self._write_log(message + stack, 'ERROR', 'error_log')
|
||||||
|
|
||||||
if stop_app:
|
if stop_app:
|
||||||
self.stop_app(self.name)
|
self.stop_app(self.name)
|
||||||
+14
-4
@@ -46,6 +46,7 @@ class Evaluator():
|
|||||||
self.__state_callbacks_handle = list()
|
self.__state_callbacks_handle = list()
|
||||||
self.__condition_state_change_delayed = list()
|
self.__condition_state_change_delayed = list()
|
||||||
self.__condition_state_change_delayed_cb_handle = None
|
self.__condition_state_change_delayed_cb_handle = None
|
||||||
|
self.__delay_generation = 0
|
||||||
|
|
||||||
self.__appdaemon_api = appdaemon_api
|
self.__appdaemon_api = appdaemon_api
|
||||||
self.__activated = False
|
self.__activated = False
|
||||||
@@ -573,6 +574,10 @@ class Evaluator():
|
|||||||
self.__last_evaluation_result = result
|
self.__last_evaluation_result = result
|
||||||
|
|
||||||
def __on_condition_state_change_delayed(self, kwargs):
|
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
|
self.__condition_state_change_delayed_cb_handle = None
|
||||||
if len(self.__condition_state_change_delayed) == 0:
|
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")
|
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.__update_result()
|
||||||
self.__trigger_callbacks(f"Callback trigger by {entity}({old} => {new})")
|
self.__trigger_callbacks(f"Callback trigger by {entity}({old} => {new})")
|
||||||
else:
|
else:
|
||||||
if self.__condition_state_change_delayed_cb_handle != None and self.__appdaemon_api.timer_running(self.__condition_state_change_delayed_cb_handle):
|
self.__delay_generation += 1
|
||||||
self.__appdaemon_api.cancel_timer(self.__condition_state_change_delayed_cb_handle)
|
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_cb_handle = None
|
||||||
|
|
||||||
self.__condition_state_change_delayed.append((entity,old,new,datetime.now()))
|
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.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(self.__on_condition_state_change_delayed, self.__callback_delay)
|
self.__on_condition_state_change_delayed, self.__callback_delay,
|
||||||
|
generation=self.__delay_generation,
|
||||||
|
)
|
||||||
|
|
||||||
def __log(self,message,**kwargs):
|
def __log(self,message,**kwargs):
|
||||||
log_string = self.__log_to_string(message)
|
log_string = self.__log_to_string(message)
|
||||||
|
|||||||
+14
-13
@@ -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)
|
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')
|
entity.set_state(state=state, attributes=attributes, check_existence=False)
|
||||||
entity.set_state(attributes=attributes)
|
|
||||||
|
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
@@ -185,15 +184,15 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.initialize_logger_interface(self.get_ad_api())
|
self.initialize_logger_interface(self.get_ad_api())
|
||||||
with self.log_group('App Initialization') as group:
|
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:
|
try:
|
||||||
succeeded = self._initialize_smart_object()
|
succeeded = self._initialize_smart_object()
|
||||||
except BaseException:
|
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
|
raise
|
||||||
status = 'completed' if succeeded else 'failed'
|
status = 'completed' if succeeded else 'failed'
|
||||||
level = 'INFO' if succeeded else 'ERROR'
|
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):
|
def _initialize_smart_object(self):
|
||||||
try:
|
try:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -22,9 +22,9 @@ class VirtualEvents(hass.Hass,LoggerInterface):
|
|||||||
def on_condition_succeed(self,event_name):
|
def on_condition_succeed(self,event_name):
|
||||||
event_yaml = self.args["virtual_events"][event_name]
|
event_yaml = self.args["virtual_events"][event_name]
|
||||||
if 'event_data' in event_yaml:
|
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'])
|
self.fire_event(event_yaml['event_name'],**event_yaml['event_data'])
|
||||||
else:
|
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'])
|
self.fire_event(event_yaml['event_name'])
|
||||||
|
|
||||||
+189
-12
@@ -4,6 +4,7 @@ import smartvalue as SmartValue
|
|||||||
from kwargsparser import kwargsParser
|
from kwargsparser import kwargsParser
|
||||||
from expressionparser import ParsingException
|
from expressionparser import ParsingException
|
||||||
import time
|
import time
|
||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
from logger_interface import LoggerInterface
|
from logger_interface import LoggerInterface
|
||||||
from ad_toolbox.eventhandler import EventHandler
|
from ad_toolbox.eventhandler import EventHandler
|
||||||
@@ -46,6 +47,11 @@ class VirtualSensorBase:
|
|||||||
if super_entity_id != None:
|
if super_entity_id != None:
|
||||||
self.constants['super'] = super_entity_id
|
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:
|
if self_initialize:
|
||||||
self._initialize()
|
self._initialize()
|
||||||
|
|
||||||
@@ -401,6 +407,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
|
||||||
@@ -447,14 +454,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:
|
||||||
@@ -469,9 +484,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
|
||||||
@@ -505,14 +579,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':
|
||||||
@@ -538,6 +619,90 @@ class RetainCondition(VirtualSensorBase):
|
|||||||
if self.output_sensor.get_state() != 'off':
|
if self.output_sensor.get_state() != 'off':
|
||||||
self.output_sensor.set_state(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):
|
class BinarySensor(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
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.retain_time = self.yaml_block['retain_time']
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
|
|
||||||
self.event_handlers = []
|
self.event_handlers = []
|
||||||
if "activation_events" in self.yaml_block:
|
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))
|
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):
|
def on_activation_event(self, event_name, data, kwargs):
|
||||||
|
self._timer_generation += 1
|
||||||
self.output_sensor.set_state(state = 'on')
|
self.output_sensor.set_state(state = 'on')
|
||||||
if self.cb_handle is not None:
|
if self.cb_handle is not None:
|
||||||
self.ad_api.cancel_timer(self.cb_handle)
|
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)
|
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):
|
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.cb_handle = None
|
||||||
|
self.output_sensor.set_state(state = 'off')
|
||||||
|
|
||||||
def generate_sensor_name(self,virtual_sensor_name):
|
def generate_sensor_name(self,virtual_sensor_name):
|
||||||
return f"binary_sensor.{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():
|
for sensor_name,default_value in default_values.items():
|
||||||
if not self.ad_api.entity_exists(sensor_name):
|
if not self.ad_api.entity_exists(sensor_name):
|
||||||
self.logger_interface.log_info(f"Creating sensor {sensor_name} = {default_value}")
|
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',{})
|
averagers = parser.parse_args('averagers',{})
|
||||||
for averager in averagers:
|
for averager in averagers:
|
||||||
@@ -684,6 +857,8 @@ class VirtualSensors():
|
|||||||
|
|
||||||
if splitted_sensor[0] == 'binary_sensor':
|
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)
|
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':
|
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)
|
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':
|
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)
|
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':
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -7,10 +7,21 @@
|
|||||||
|
|
||||||
.block-title{font-weight:600;margin-bottom:6px;color:var(--fg);overflow-wrap:anywhere}
|
.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}
|
.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{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}
|
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)}
|
||||||
@@ -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="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>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
const appFlashTokens = new Map();
|
const appFlashTokens = new Map();
|
||||||
let receivedInitialData = false;
|
let receivedInitialData = false;
|
||||||
let pointerSelecting = false;
|
let pointerSelecting = false;
|
||||||
let errorView;
|
let countView;
|
||||||
|
let activeCountLevel;
|
||||||
const $ = id => document.getElementById(id);
|
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 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;
|
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
|
||||||
@@ -83,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); };
|
||||||
@@ -114,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}`));
|
||||||
@@ -149,6 +150,7 @@
|
|||||||
});
|
});
|
||||||
const timeOf = (app, logs) => (lastLogs.get(app) || logs[logs.length - 1]).time;
|
const timeOf = (app, logs) => (lastLogs.get(app) || logs[logs.length - 1]).time;
|
||||||
$('error-count').setAttribute('aria-pressed', String($('level').value === 'ERROR'));
|
$('error-count').setAttribute('aria-pressed', String($('level').value === 'ERROR'));
|
||||||
|
$('warning-count').setAttribute('aria-pressed', String($('level').value === 'WARNING'));
|
||||||
[...groups].sort(([a, al], [b, bl]) => {
|
[...groups].sort(([a, al], [b, bl]) => {
|
||||||
const order = $('sort').value;
|
const order = $('sort').value;
|
||||||
if (order === 'name') return a.localeCompare(b);
|
if (order === 'name') return a.localeCompare(b);
|
||||||
@@ -363,6 +365,10 @@
|
|||||||
$('error-count').textContent = `${errorCount} error${errorCount === 1 ? '' : 's'}`;
|
$('error-count').textContent = `${errorCount} error${errorCount === 1 ? '' : 's'}`;
|
||||||
$('error-count').classList.toggle('has-errors', errorCount > 0);
|
$('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.';
|
$('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();
|
$('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail();
|
||||||
}
|
}
|
||||||
function refreshLastLogAges() {
|
function refreshLastLogAges() {
|
||||||
@@ -387,21 +393,22 @@
|
|||||||
document.addEventListener('pointerup', () => { pointerSelecting = false; });
|
document.addEventListener('pointerup', () => { pointerSelecting = false; });
|
||||||
document.addEventListener('selectionchange', () => { if (!selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
|
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', () => {
|
['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();
|
save(); renderApps(); renderDetail();
|
||||||
}));
|
}));
|
||||||
$('error-count').onclick = () => {
|
function toggleCountView(level) {
|
||||||
const leaving = $('level').value === 'ERROR';
|
const leaving = activeCountLevel === level;
|
||||||
if (leaving) {
|
if (leaving) {
|
||||||
$('search').value = errorView?.search || '';
|
$('search').value = countView?.search || '';
|
||||||
$('period').value = errorView?.period || '0';
|
$('period').value = countView?.period || '0';
|
||||||
$('level').value = errorView?.level || 'all';
|
$('level').value = countView?.level || 'all';
|
||||||
if (errorView) { expanded.clear(); errorView.expanded.forEach(app => expanded.add(app)); }
|
if (countView) { expanded.clear(); countView.expanded.forEach(app => expanded.add(app)); }
|
||||||
errorView = undefined;
|
countView = undefined; activeCountLevel = undefined;
|
||||||
} else {
|
} else {
|
||||||
errorView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] };
|
if (!activeCountLevel) countView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] };
|
||||||
$('search').value = ''; $('period').value = '0'; $('level').value = 'ERROR';
|
activeCountLevel = level;
|
||||||
entries.filter(e => normalLevel(e) === 'ERROR').forEach(e => expanded.add(e.app));
|
$('search').value = ''; $('period').value = '0'; $('level').value = level;
|
||||||
|
entries.filter(e => normalLevel(e) === level).forEach(e => expanded.add(e.app));
|
||||||
}
|
}
|
||||||
selected = undefined; origin = undefined;
|
selected = undefined; origin = undefined;
|
||||||
save(); renderApps(); renderDetail();
|
save(); renderApps(); renderDetail();
|
||||||
@@ -409,7 +416,9 @@
|
|||||||
const first = document.querySelector('#apps .row');
|
const first = document.querySelector('#apps .row');
|
||||||
if (first) { first.click(); first.closest('.rows').scrollTop = 0; first.focus({ preventScroll: true }); }
|
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' }); };
|
$('clear').onclick = () => { selected = undefined; origin = undefined; save(); $('clear').disabled = true; $('clear').textContent = 'Clearing…'; vscode.postMessage({ type: 'clear' }); };
|
||||||
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
|
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
|
||||||
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
|
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
Reference in new issue
Block a user