Compare commits
4
Commits
5ae930c710
...
b97445ce92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97445ce92 | ||
|
|
3b0c24e795 | ||
|
|
bf72dbb223 | ||
|
|
97501875b8 |
No files matched your search
+11
-10
@@ -113,12 +113,11 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
if device_class is not None: attributes['device_class'] = device_class
|
if device_class is not None: attributes['device_class'] = device_class
|
||||||
if state_class is not None: attributes['state_class'] = state_class
|
if state_class is not None: attributes['state_class'] = state_class
|
||||||
|
|
||||||
if state is not None:
|
if state is None:
|
||||||
entity.set_state(state=state, attributes=attributes, check_existence=False)
|
state = entity.get_state() if entity.exists() else None
|
||||||
else:
|
if state is None:
|
||||||
if not entity.exists():
|
state = 'unknown'
|
||||||
entity.set_state(state='unknown', check_existence=False)
|
entity.set_state(state=state, attributes=attributes, check_existence=False)
|
||||||
entity.set_state(attributes=attributes)
|
|
||||||
|
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
@@ -219,14 +218,16 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
# entity: <entity_id> → link app to a HA entity
|
# entity: <entity_id> → link app to a HA entity
|
||||||
if "entity" in self.args:
|
if "entity" in self.args:
|
||||||
self.entity_id = self.args["entity"]
|
self.entity_id = self.args["entity"]
|
||||||
self.entity = self.get_entity(self.entity_id)
|
self.entity = self.get_entity(self.entity_id, check_existence=False)
|
||||||
|
initial_state = None
|
||||||
if self.entity.exists():
|
if self.entity.exists():
|
||||||
self.log_info(f"Linked to {self.entity_id}")
|
self.log_info(f"Linked to {self.entity_id}")
|
||||||
elif 'default_entity_state' in self.args:
|
elif 'default_entity_state' in self.args:
|
||||||
self.entity.set_state(state = self.args["default_entity_state"])
|
initial_state = self.args["default_entity_state"]
|
||||||
self.log_info(f"Creating {self.entity_id}, default_state = {self.args['default_entity_state']}")
|
self.log_info(f"Creating {self.entity_id}, default_state = {initial_state}")
|
||||||
else:
|
else:
|
||||||
self.entity.set_state(state = self.get_default_entity_state())
|
initial_state = self.get_default_entity_state()
|
||||||
|
self.entity = self.create_entity(self.entity_id, state=initial_state)
|
||||||
|
|
||||||
self.constants['self'] = self.entity_id
|
self.constants['self'] = self.entity_id
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -27,6 +27,7 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.sensor = RetainCondition()
|
self.sensor = RetainCondition()
|
||||||
self.sensor.cb_handle = None
|
self.sensor.cb_handle = None
|
||||||
|
self.sensor._timer_generation = 0
|
||||||
self.sensor.retain_time = 15
|
self.sensor.retain_time = 15
|
||||||
self.sensor.reset_retain_time_on_new_success = True
|
self.sensor.reset_retain_time_on_new_success = True
|
||||||
self.state = 'off'
|
self.state = 'off'
|
||||||
@@ -41,6 +42,54 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
def set_state(self, *, state):
|
def set_state(self, *, state):
|
||||||
self.state = state
|
self.state = state
|
||||||
|
|
||||||
|
def queued_expiry(self):
|
||||||
|
call = self.sensor.ad_api.run_in.call_args
|
||||||
|
callback = call.args[0]
|
||||||
|
kwargs = dict(call.kwargs)
|
||||||
|
return lambda: callback(kwargs)
|
||||||
|
|
||||||
|
def test_return_at_expiry_ignores_queued_callback(self):
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
queued = self.queued_expiry()
|
||||||
|
# The scheduler has already removed the timer and queued its callback.
|
||||||
|
self.sensor.ad_api.cancel_timer.return_value = False
|
||||||
|
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
queued()
|
||||||
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.sensor.condition.evaluate.assert_not_called()
|
||||||
|
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
|
||||||
|
|
||||||
|
def test_stale_expiry_cannot_shorten_new_retention(self):
|
||||||
|
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
queued = self.queued_expiry()
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
current_expiry = self.queued_expiry()
|
||||||
|
queued()
|
||||||
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.assertEqual(self.sensor.cb_handle, 'new-timer')
|
||||||
|
current_expiry()
|
||||||
|
self.assertEqual(self.state, 'off')
|
||||||
|
self.assertIsNone(self.sensor.cb_handle)
|
||||||
|
|
||||||
|
def test_stale_expiry_cannot_forget_timer_while_condition_disabled(self):
|
||||||
|
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
queued = self.queued_expiry()
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
self.sensor.condition.evaluate.return_value = Result.Disabled
|
||||||
|
queued()
|
||||||
|
self.assertEqual(self.sensor.cb_handle, 'new-timer')
|
||||||
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.assertEqual(self.sensor.ad_api.cancel_timer.call_args.args, ('new-timer',))
|
||||||
|
|
||||||
def test_long_lived_signal_gets_full_delay_after_loss(self):
|
def test_long_lived_signal_gets_full_delay_after_loss(self):
|
||||||
self.sensor.on_condition_succeed()
|
self.sensor.on_condition_succeed()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
@@ -48,15 +97,16 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
# No timer can expire while the signal stays valid.
|
# No timer can expire while the signal stays valid.
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
self.sensor.ad_api.run_in.assert_called_once_with(self.sensor.on_time_elapse, 15)
|
self.sensor.ad_api.run_in.assert_called_once_with(
|
||||||
self.sensor.on_time_elapse({})
|
self.sensor.on_time_elapse, 15, generation=self.sensor._timer_generation)
|
||||||
|
self.queued_expiry()()
|
||||||
self.assertEqual(self.state, 'off')
|
self.assertEqual(self.state, 'off')
|
||||||
|
|
||||||
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
|
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
|
||||||
self.sensor.on_condition_succeed()
|
self.sensor.on_condition_succeed()
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.sensor.on_condition_succeed()
|
self.sensor.on_condition_succeed()
|
||||||
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer')
|
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
|
||||||
self.assertIsNone(self.sensor.cb_handle)
|
self.assertIsNone(self.sensor.cb_handle)
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
@@ -79,8 +129,11 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
with self.subTest(result=result):
|
with self.subTest(result=result):
|
||||||
self.state = 'on'
|
self.state = 'on'
|
||||||
self.sensor.condition.evaluate.return_value = result
|
self.sensor.condition.evaluate.return_value = result
|
||||||
self.sensor.on_time_elapse({})
|
self.sensor.register_callback()
|
||||||
|
self.queued_expiry()()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.assertIsNone(self.sensor.cb_handle)
|
||||||
|
self.sensor.condition.evaluate.assert_called_with()
|
||||||
|
|
||||||
def test_explicit_no_reset_preserves_fixed_window(self):
|
def test_explicit_no_reset_preserves_fixed_window(self):
|
||||||
self.sensor.reset_retain_time_on_new_success = False
|
self.sensor.reset_retain_time_on_new_success = False
|
||||||
@@ -88,7 +141,7 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
||||||
self.sensor.on_time_elapse({})
|
self.queued_expiry()()
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.assertEqual(self.state, 'off')
|
self.assertEqual(self.state, 'off')
|
||||||
|
|
||||||
|
|||||||
+85
-8
@@ -406,6 +406,7 @@ class ValueSelector(VirtualSensorBase):
|
|||||||
class ContinuousCondition(VirtualSensorBase):
|
class ContinuousCondition(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
self.time = self.yaml_block['time']
|
self.time = self.yaml_block['time']
|
||||||
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
|
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
|
||||||
except KeyError: self.ignore_unavailable_condition = False
|
except KeyError: self.ignore_unavailable_condition = False
|
||||||
@@ -452,14 +453,22 @@ class ContinuousCondition(VirtualSensorBase):
|
|||||||
|
|
||||||
def register_callback(self):
|
def register_callback(self):
|
||||||
if self.cb_handle == None:
|
if self.cb_handle == None:
|
||||||
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.time)
|
self._timer_generation += 1
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapse, self.time, generation=self._timer_generation)
|
||||||
|
|
||||||
def cancel_callback(self):
|
def cancel_callback(self):
|
||||||
if self.cb_handle != None:
|
# An expired timer may already have a callback queued in the worker.
|
||||||
self.ad_api.cancel_timer(self.cb_handle)
|
# Invalidate it before cancellation, even if it is no longer scheduled.
|
||||||
self.cb_handle = None
|
self._timer_generation += 1
|
||||||
|
handle = self.cb_handle
|
||||||
|
self.cb_handle = None
|
||||||
|
if handle is not None:
|
||||||
|
self.ad_api.cancel_timer(handle, silent=True)
|
||||||
|
|
||||||
def on_time_elapse(self, kwargs):
|
def on_time_elapse(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
value = 'off' if self.invert else 'on'
|
value = 'off' if self.invert else 'on'
|
||||||
if self.output_sensor.get_state() != value:
|
if self.output_sensor.get_state() != value:
|
||||||
@@ -474,9 +483,68 @@ class ContinuousCondition(VirtualSensorBase):
|
|||||||
if self.output_sensor.get_state() != value:
|
if self.output_sensor.get_state() != value:
|
||||||
self.output_sensor.set_state(state = value)
|
self.output_sensor.set_state(state = value)
|
||||||
|
|
||||||
|
class EphemeralCondition(ContinuousCondition):
|
||||||
|
"""True during the first `time` seconds of each true condition.
|
||||||
|
|
||||||
|
condition_started_at is a Unix timestamp retained after expiry, until
|
||||||
|
the condition becomes false. Reloads resume this window when the entity
|
||||||
|
attributes survive; without a timestamp, a true condition starts anew.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
self._condition_active = False
|
||||||
|
if self.yaml_block.get('invert', False):
|
||||||
|
raise ValueError("ephemeral_condition does not support invert; negate conditions instead")
|
||||||
|
started_at = self.ad_api.get_state(
|
||||||
|
self.sensor_name, attribute='condition_started_at')
|
||||||
|
try:
|
||||||
|
started_at = float(started_at)
|
||||||
|
if not 0 <= started_at <= time.time():
|
||||||
|
started_at = None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
started_at = None
|
||||||
|
self._started_at = started_at
|
||||||
|
self.attributes['condition_started_at'] = started_at
|
||||||
|
# Unknown inputs during startup must not erase a restored window.
|
||||||
|
self.yaml_block = dict(self.yaml_block)
|
||||||
|
self.yaml_block.setdefault('ignore_unavailable_condition', True)
|
||||||
|
super().initialize()
|
||||||
|
|
||||||
|
def on_condition_succeed(self):
|
||||||
|
if self._condition_active:
|
||||||
|
return
|
||||||
|
self._condition_active = True
|
||||||
|
if self._started_at is None:
|
||||||
|
self._started_at = time.time()
|
||||||
|
self.attributes['condition_started_at'] = self._started_at
|
||||||
|
remaining = self._started_at + self.time - time.time()
|
||||||
|
self.output_sensor.set_state(
|
||||||
|
state='on' if remaining > 0 else 'off',
|
||||||
|
attributes={'condition_started_at': self._started_at})
|
||||||
|
if remaining > 0:
|
||||||
|
self._timer_generation += 1
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapse, remaining, generation=self._timer_generation)
|
||||||
|
|
||||||
|
def on_condition_fail(self):
|
||||||
|
self._condition_active = False
|
||||||
|
self._started_at = None
|
||||||
|
self.attributes['condition_started_at'] = None
|
||||||
|
self.cancel_callback()
|
||||||
|
self.output_sensor.set_state(
|
||||||
|
state='off', attributes={'condition_started_at': None})
|
||||||
|
|
||||||
|
def on_time_elapse(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
|
self.cb_handle = None
|
||||||
|
self.output_sensor.set_state(state='off')
|
||||||
|
|
||||||
|
|
||||||
class RetainCondition(VirtualSensorBase):
|
class RetainCondition(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
self.retain_time = self.yaml_block['retain_time']
|
self.retain_time = self.yaml_block['retain_time']
|
||||||
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
|
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
|
||||||
except KeyError: self.reset_retain_time_on_new_success = True
|
except KeyError: self.reset_retain_time_on_new_success = True
|
||||||
@@ -510,14 +578,21 @@ class RetainCondition(VirtualSensorBase):
|
|||||||
|
|
||||||
def register_callback(self):
|
def register_callback(self):
|
||||||
if self.cb_handle == None:
|
if self.cb_handle == None:
|
||||||
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.retain_time)
|
self._timer_generation += 1
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapse, self.retain_time, generation=self._timer_generation)
|
||||||
|
|
||||||
def cancel_callback(self):
|
def cancel_callback(self):
|
||||||
if self.cb_handle != None:
|
# Invalidate callbacks already queued by an expired timer as well.
|
||||||
self.ad_api.cancel_timer(self.cb_handle)
|
self._timer_generation += 1
|
||||||
self.cb_handle = None
|
handle = self.cb_handle
|
||||||
|
self.cb_handle = None
|
||||||
|
if handle is not None:
|
||||||
|
self.ad_api.cancel_timer(handle, silent=True)
|
||||||
|
|
||||||
def on_time_elapse(self, kwargs):
|
def on_time_elapse(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
if self.condition.evaluate() == SmartCondition.Result.Failed:
|
if self.condition.evaluate() == SmartCondition.Result.Failed:
|
||||||
if self.output_sensor.get_state() != 'off':
|
if self.output_sensor.get_state() != 'off':
|
||||||
@@ -697,6 +772,8 @@ class VirtualSensors():
|
|||||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = Averager(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = Averager(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'value_selector':
|
elif splitted_sensor[0] == 'value_selector':
|
||||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
|
elif splitted_sensor[0] == 'ephemeral_condition':
|
||||||
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = EphemeralCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'retain_condition':
|
elif splitted_sensor[0] == 'retain_condition':
|
||||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'event_sensor':
|
elif splitted_sensor[0] == 'event_sensor':
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -14,3 +14,14 @@ summary{display:grid;grid-template-columns:auto minmax(0,1fr) repeat(3,max-conte
|
|||||||
summary .last-log{grid-column:1/-1;min-width:0}
|
summary .last-log{grid-column:1/-1;min-width:0}
|
||||||
.rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none}
|
.rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none}
|
||||||
.app-label{flex-direction:row;align-items:center;gap:6px}.app-label .name{min-width:0}.app-link{color:var(--accent);font-size:13px;line-height:1;text-decoration:none;white-space:nowrap}.app-link:hover{text-decoration:underline}.app-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
.app-label{flex-direction:row;align-items:center;gap:6px}.app-label .name{min-width:0}.app-link{color:var(--accent);font-size:13px;line-height:1;text-decoration:none;white-space:nowrap}.app-link:hover{text-decoration:underline}.app-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
|
||||||
|
/* Keep severity counts distinct from the currently selected filter. */
|
||||||
|
#error-count::before,#warning-count::before{content:"";display:inline-block;width:1.2em}
|
||||||
|
#error-count[aria-pressed="true"],#warning-count[aria-pressed="true"]{
|
||||||
|
color:var(--vscode-button-foreground,#fff);
|
||||||
|
background:var(--vscode-button-background,#0e639c);
|
||||||
|
border-color:var(--vscode-contrastActiveBorder,var(--accent));
|
||||||
|
box-shadow:inset 0 2px 4px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
#error-count[aria-pressed="true"]::before,#warning-count[aria-pressed="true"]::before{content:"✓"}
|
||||||
|
#error-count[aria-pressed="true"]:hover,#warning-count[aria-pressed="true"]:hover{background:var(--vscode-button-hoverBackground,#1177bb)}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
|
||||||
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
|
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
|
||||||
<body>
|
<body>
|
||||||
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</button></div></header>
|
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" aria-pressed="false" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" aria-pressed="false" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</button></div></header>
|
||||||
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
|
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
|
||||||
<div id="warnings" role="status"></div>
|
<div id="warnings" role="status"></div>
|
||||||
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
|
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
const relative = node('span', timeSince(e.time), 'relative-time');
|
const relative = node('span', timeSince(e.time), 'relative-time');
|
||||||
relative.dataset.time = String(e.time);
|
relative.dataset.time = String(e.time);
|
||||||
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
|
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
|
||||||
meta.append(relative, node('span', e.level, `badge ${normalLevel(e)}`));
|
meta.append(node('span', e.timestamp, 'exact-time'), relative, node('span', e.level, `badge ${normalLevel(e)}`));
|
||||||
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
||||||
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
|
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
|
||||||
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
|
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
|
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
|
||||||
relative.dataset.time = String(unit.entries[0].time);
|
relative.dataset.time = String(unit.entries[0].time);
|
||||||
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
|
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
|
||||||
meta.append(relative);
|
meta.append(node('span', unit.entries[0].timestamp, 'exact-time'), relative);
|
||||||
for (const level of ['INFO', 'WARNING', 'ERROR']) {
|
for (const level of ['INFO', 'WARNING', 'ERROR']) {
|
||||||
const count = unit.entries.filter(e => normalLevel(e) === level).length;
|
const count = unit.entries.filter(e => normalLevel(e) === level).length;
|
||||||
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
|
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
|
||||||
|
|||||||
@@ -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.
Reference in new issue
Block a user