Compare commits

..
26 Commits
Author SHA1 Message Date
Pierre 86b5441603 removed old vsix 2026-11-10 19:08:21 +01:00
Pierre d5e7193b42 smartobject and virtualevent now correctly use the logger interface for their logs 2026-11-08 11:07:27 +01:00
Pierre 54c525c843 fix: ignore stale callbacks from rescheduled timers
- Add generation counters to SmartCondition and EventSensor timers.
- Preserve pending changes and the retention period when timers are restarted.
- Silently cancel timers that have already expired.
- Add regression tests for stale callbacks and expiry during cancellation.
2026-11-03 19:14:20 +01:00
Pierre 1a148d4e3f New time period virtual sensor 2026-10-25 20:39:52 +01:00
Pierre b97445ce92 A new epheleral_condition sensor type + fix issue with dangling callback 2026-10-16 08:49:57 +02:00
Pierre 3b0c24e795 display proper time stamp on log message 2026-10-16 08:49:12 +02:00
Pierre bf72dbb223 SmartObject now create its linked entity with create_entity 2026-10-11 11:28:20 +02:00
Pierre 97501875b8 made error/warning button push more obvious 2026-10-11 11:00:20 +02:00
Pierre 5ae930c710 add support for ascii encode 2026-10-10 15:25:00 +02:00
Pierre cf8839db6a remove the warning for default entity creation 2026-10-04 23:42:54 +02:00
Pierre 1d14219b93 fix most of the entity creation warning 2026-10-04 23:10:22 +02:00
Pierre d8756928a5 fixed create_entity warnings 2026-10-04 22:08:43 +02:00
Pierre caf9051864 added a warning button 2026-10-04 20:01:17 +02:00
Pierre 3171433770 some logs improvement 2026-10-04 19:00:06 +02:00
Pierre 0fa40dc2ff Did some logs improvement 2026-10-04 15:29:01 +02:00
Pierre 6838cc54d0 better error display 2026-10-04 14:45:44 +02:00
Pierre 3aa0120e3d Simplify the log grouping logic 2026-10-04 14:29:14 +02:00
Pierre f57e23d76a added a shortcut to open the loging app 2026-10-04 14:06:31 +02:00
Pierre 5614ad049d clean up deprecated buttons 2026-10-04 13:43:50 +02:00
Pierre 99422d8cfc added a scrollbox for each app logs 2026-10-04 13:12:14 +02:00
Pierre 6c345ccf81 added last log preview 2026-10-04 12:54:31 +02:00
Pierre cfb5334666 Improved error feedbacks 2026-10-04 12:39:38 +02:00
Pierre ece851f5a0 add support for expression in sensor_attributes 2026-10-04 11:49:58 +02:00
Pierre cded4a0200 New group logging 2026-09-27 22:54:08 +02:00
Pierre 400837b41a first draft of log explorer 2026-09-27 14:15:15 +02:00
Pierre 191e69eb1d new event forwarder 2026-09-26 19:21:19 +02:00
38 changed files with 3354 additions and 279 deletions

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
"""Forward HA events to conditional events and service calls.
Each forwarders entry contains events_to_listen (EventHandler format),
optional events_to_send and services_to_call mappings. Both action types
accept event_condition, evaluated on every incoming event. Payloads are
static YAML data, as in the original forwarder.
"""
import appdaemon.plugins.hass.hassapi as hass
import ad_toolbox.smartcondition as SmartCondition
from ad_toolbox.eventhandler import EventHandler
from ad_toolbox.expressionparser import ParsingException
from ad_toolbox.logger_interface import LoggerInterface
class ConditionalAction:
def __init__(self, owner, config):
self.owner = owner
self.config = config
self.condition = None
if 'event_condition' in config:
self.condition = SmartCondition.Evaluator(owner, config['event_condition'])
def is_allowed(self):
return (self.condition is None or
self.condition.evaluate(False) == SmartCondition.Result.Succeeded)
class EventSender(ConditionalAction):
def send_event(self, source_event, event_data):
if self.is_allowed():
data = self.config.get('event_data', {})
self.owner.log_info(f"{source_event} => {self.config['event_name']} data = {data}")
self.owner.fire_event(self.config['event_name'], **data)
class ServiceCaller(ConditionalAction):
def call_service(self, source_event, event_data):
if self.is_allowed():
data = self.config.get('data', {})
self.owner.log_info(f"{source_event} => Calling {self.config['service']} data = {data}")
self.owner.call_service(self.config['service'], **data)
class EventsForwarder(hass.Hass, LoggerInterface):
def initialize(self):
with self.log_group("App Initialization"):
self.initialize_logger_interface(self.get_ad_api())
self.mute_logger(self.args.get('mute', False))
self.forwarders = {}
self.services_caller = {}
self.event_handlers = []
for key, block in self.args.get('forwarders', {}).items():
try:
senders = [EventSender(self, config)
for config in block.get('events_to_send', {}).values()]
callers = [ServiceCaller(self, config)
for config in block.get('services_to_call', {}).values()]
self.forwarders[key] = senders
self.services_caller[key] = callers
self.event_handlers.append(EventHandler(
self, block['events_to_listen'], self.on_event, key))
except ParsingException as error:
self.log_error(f"Invalid forwarder {key}: {error}")
self.log_info("Initialisation completed")
def on_event(self, event_name, event_data, forwarder_key):
for sender in self.forwarders.get(forwarder_key, []):
sender.send_event(event_name, event_data)
for caller in self.services_caller.get(forwarder_key, []):
caller.call_service(event_name, event_data)
+59 -17
View File
@@ -1,44 +1,86 @@
import traceback import traceback
from contextlib import contextmanager
from contextvars import ContextVar
from uuid import uuid4
_ACTIVE_LOG_GROUP = ContextVar('appdaemon_log_group', default=None)
@contextmanager
def log_group(kind, label=None, *, inherit=True):
"""Tag LoggerInterface messages in this synchronous operation with one group ID."""
parent = _ACTIVE_LOG_GROUP.get() if inherit else None
group = parent or {
'id': uuid4().hex[:12],
'kind': kind,
'label': str(label) if label is not None else '',
}
token = _ACTIVE_LOG_GROUP.set(group)
try:
yield group
finally:
_ACTIVE_LOG_GROUP.reset(token)
class LoggerInterface: 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 mute_logger(self,mute : bool): def _write_log(self, message, level, log):
self.ad_api.log(self._tag_log_message(message), level=level, log=log,
**self._log_options)
def log_group(self, kind, label=None):
return log_group(kind, label)
@staticmethod
def _tag_log_message(message):
group = _ACTIVE_LOG_GROUP.get()
if group is None:
return message
marker = f"[group={group['kind']}:{group['id']}]"
if isinstance(message, str) and marker in message:
return message
return f"{message} {marker}"
def mute_logger(self, mute: bool):
if mute: if mute:
self.ad_api.log("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(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(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
if dump_stack: if dump_stack:
stack = "\n" stack = '\n'
for line in traceback.format_stack()[:-1]: for line in traceback.format_stack()[:-1]:
stack += line.strip() + '\n' stack += line.strip() + '\n'
else: stack = "" else:
stack = ''
message = self._tag_log_message(message)
self._write_log(message, 'ERROR', self.default_log)
self._write_log(message + stack, 'ERROR', 'error_log')
self.ad_api.log(message,level = "ERROR", log = self.default_log)
self.ad_api.log(message + stack,level = "ERROR", log = "error_log")
if stop_app: if stop_app:
self.stop_app(self.name) self.stop_app(self.name)
+45 -13
View File
@@ -1,6 +1,7 @@
#import appdaemon.plugins.hass.hassapi as hass #import appdaemon.plugins.hass.hassapi as hass
import enum import enum
from datetime import datetime from datetime import datetime
from contextlib import nullcontext
#from pickle import FALSE #from pickle import FALSE
from adexpressioncontext import ADExpressionContext from adexpressioncontext import ADExpressionContext
@@ -45,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
@@ -56,6 +58,13 @@ class Evaluator():
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
condition_name = parser.parse_args('condition_name',None,['log_prefix']) condition_name = parser.parse_args('condition_name',None,['log_prefix'])
log_group = parser.parse_args('log_group',None)
if log_group is not None:
self.__log_group = str(log_group)
elif isinstance(condition_name, list):
self.__log_group = ', '.join(map(str, condition_name))
else:
self.__log_group = str(condition_name or 'condition')
if condition_name: if condition_name:
if isinstance(condition_name,list): if isinstance(condition_name,list):
for name in condition_name: for name in condition_name:
@@ -161,14 +170,24 @@ class Evaluator():
raise e raise e
def __log_group_scope(self):
# SmartObject itself implements LoggerInterface, but many evaluators are
# constructed with only the app as their API and no logger_interface kwarg.
logger = self.__logger_interface or self.__appdaemon_api
group_scope = getattr(logger, 'log_group', None)
if callable(group_scope):
return group_scope(self.__log_group)
return nullcontext()
def evaluate(self,log = True): def evaluate(self,log = True):
try: result = self.__evaluate() with self.__log_group_scope():
except EvaluationException as e: try: result = self.__evaluate()
self.__log(f"{e}",level = "ERROR") except EvaluationException as e:
return None self.__log(f"{e}",level = "ERROR")
if log: return None
self.log_evaluation_result() if log:
return result self.log_evaluation_result()
return result
def log_callback_trigger_reason(self): def log_callback_trigger_reason(self):
if self.__callback_trigger_reason_log_string: if self.__callback_trigger_reason_log_string:
@@ -491,6 +510,10 @@ class Evaluator():
self.__last_evaluation_result = new_result self.__last_evaluation_result = new_result
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False): def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
with self.__log_group_scope():
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
has_log_trigger_reason = False has_log_trigger_reason = False
has_evaluation_result = False has_evaluation_result = False
@@ -551,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")
@@ -585,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)
+28 -13
View File
@@ -113,12 +113,11 @@ class SmartObject(hass.Hass,LoggerInterface):
if device_class is not None: attributes['device_class'] = device_class if device_class is not None: attributes['device_class'] = device_class
if state_class is not None: attributes['state_class'] = state_class if state_class is not None: attributes['state_class'] = state_class
if state is not None: if state is None:
entity.set_state(state=state, attributes=attributes) 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
@@ -183,10 +182,22 @@ class SmartObject(hass.Hass,LoggerInterface):
# mute → entity → constants → templates_library → virtual_sensors # mute → entity → constants → templates_library → virtual_sensors
# → attributes_override → dataset restore → on_initialize_smart_object() # → attributes_override → dataset restore → on_initialize_smart_object()
def initialize(self): def initialize(self):
self.initialize_logger_interface(self.get_ad_api())
with self.log_group('App Initialization') as group:
self._write_log(f"Initialization started [group={group['kind']}:{group['id']}]", 'INFO', None)
try:
succeeded = self._initialize_smart_object()
except BaseException:
self._write_log(f"Initialization failed [group={group['kind']}:{group['id']}]", 'ERROR', None)
raise
status = 'completed' if succeeded else 'failed'
level = 'INFO' if succeeded else 'ERROR'
self._write_log(f"Initialization {status} [group={group['kind']}:{group['id']}]", level, None)
def _initialize_smart_object(self):
try: try:
#self.depends_on_module(["smartobject","virtualsensors","logger_interface"]) #self.depends_on_module(["smartobject","virtualsensors","logger_interface"])
self.initialize_logger_interface(self.get_ad_api())
self.log_info(f"Name = {self.name}") self.log_info(f"Name = {self.name}")
# mute: true → suppress all log output for this app # mute: true → suppress all log output for this app
try: self.mute_logger(self.args['mute']) try: self.mute_logger(self.args['mute'])
@@ -207,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:
@@ -272,8 +285,10 @@ class SmartObject(hass.Hass,LoggerInterface):
self.log_info(self.get_dataset_name() + " loaded") self.log_info(self.get_dataset_name() + " loaded")
except (FileNotFoundError,EOFError): pass #self.log_info("File " + self.get_dataset_name() + " not found (and it's ok)") except (FileNotFoundError,EOFError): pass #self.log_info("File " + self.get_dataset_name() + " not found (and it's ok)")
self.on_initialize_smart_object() self.on_initialize_smart_object()
except ParsingException as e: self.log_error(str(e),stop_app = True) except ParsingException as e:
self.log_error(str(e), stop_app=True)
return False
return True
# AppDaemon shutdown hook. Persists self.dataset to disk (if set). # AppDaemon shutdown hook. Persists self.dataset to disk (if set).
def terminate(self): def terminate(self):
+24 -7
View File
@@ -1,4 +1,5 @@
#import appdaemon.plugins.hass.hassapi as hass #import appdaemon.plugins.hass.hassapi as hass
from contextlib import nullcontext
from datetime import datetime from datetime import datetime
from adexpressioncontext import ADExpressionContext from adexpressioncontext import ADExpressionContext
from expressionparser import ExpressionParser,ParsingException,EvaluationException from expressionparser import ExpressionParser,ParsingException,EvaluationException
@@ -31,6 +32,8 @@ class Evaluator():
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
expression_name = parser.parse_args('expression_name',None) expression_name = parser.parse_args('expression_name',None)
log_group = parser.parse_args('log_group',None)
self.__log_group = str(log_group if log_group is not None else expression_name or 'value')
if expression_name: if expression_name:
self.__debug_log_prefixes.append(f"[{expression_name}]") self.__debug_log_prefixes.append(f"[{expression_name}]")
@@ -96,14 +99,24 @@ class Evaluator():
self.__debug_log_prefixes.pop() self.__debug_log_prefixes.pop()
return result return result
def __log_group_scope(self):
# SmartObject itself implements LoggerInterface, but many evaluators are
# constructed with only the app as their API and no logger_interface kwarg.
logger = self.__logger_interface or self.__appdaemon_api
group_scope = getattr(logger, 'log_group', None)
if callable(group_scope):
return group_scope(self.__log_group)
return nullcontext()
def evaluate(self,log = True): def evaluate(self,log = True):
try: result = self.__evaluate() with self.__log_group_scope():
except EvaluationException as e: try: result = self.__evaluate()
self.__log(f"{e}",level = "ERROR") except EvaluationException as e:
return None self.__log(f"{e}",level = "ERROR")
if log: return None
self.log_evaluation_result() if log:
return result self.log_evaluation_result()
return result
def log_callback_trigger_reason(self): def log_callback_trigger_reason(self):
if self.__callback_trigger_reason_log_string: if self.__callback_trigger_reason_log_string:
@@ -220,6 +233,10 @@ class Evaluator():
self.__state_callbacks_handle = list() self.__state_callbacks_handle = list()
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False): def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
with self.__log_group_scope():
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
has_log_trigger_reason = False has_log_trigger_reason = False
has_evaluation_result = False has_evaluation_result = False
+121
View File
@@ -0,0 +1,121 @@
"""Exercise timer expiry/cancellation ordering without a running AppDaemon."""
import ast
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
node = next(node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name == 'ContinuousCondition')
namespace = {'VirtualSensorBase': object, 'SmartCondition': Mock()}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
ContinuousCondition = namespace['ContinuousCondition']
class FakeScheduler:
def __init__(self):
self.timers = {}
self.next_id = 0
self.warnings = []
def run_in(self, callback, delay, **kwargs):
self.next_id += 1
handle = str(self.next_id)
self.timers[handle] = (callback, kwargs)
return handle
def expire(self, handle):
# AppDaemon removes the timer while its callback can still be queued.
callback, kwargs = self.timers.pop(handle)
return lambda: callback(kwargs)
def cancel_timer(self, handle, silent=False):
if handle in self.timers:
del self.timers[handle]
return True
if not silent:
self.warnings.append(handle)
return False
class ContinuousConditionTests(unittest.TestCase):
def setUp(self):
self.scheduler = FakeScheduler()
self.state = 'off'
entity = Mock()
entity.get_state.side_effect = lambda: self.state
entity.set_state.side_effect = lambda *, state: setattr(self, 'state', state)
self.sensor = ContinuousCondition()
self.sensor.ad_api = SimpleNamespace(
get_entity=lambda name: entity,
run_in=self.scheduler.run_in,
cancel_timer=self.scheduler.cancel_timer,
)
self.sensor.yaml_block = {'time': 5, 'conditions': 'paused'}
self.sensor.sensor_name = 'binary_sensor.is_vertex2_content_idle'
self.sensor.log_info = Mock()
self.sensor.logger_interface = None
self.sensor.constants = {}
self.sensor.templates_library = None
self.sensor.initialize()
def test_valid_expiry_sets_state_and_clears_handle(self):
self.sensor.on_condition_succeed()
self.scheduler.expire(self.sensor.cb_handle)()
self.assertEqual(self.state, 'on')
self.assertIsNone(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off')
def test_failure_cancels_running_timer(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
self.assertEqual(self.scheduler.timers, {})
self.assertIsNone(self.sensor.cb_handle)
self.assertEqual(self.state, 'off')
def test_resume_at_expiry_ignores_queued_callback_without_warning(self):
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
queued()
self.assertEqual(self.state, 'off')
self.assertEqual(self.scheduler.warnings, [])
self.assertIsNone(self.sensor.cb_handle)
def test_old_callback_preserves_new_timer_and_full_delay(self):
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.sensor.on_condition_succeed()
new_handle = self.sensor.cb_handle
queued()
self.assertEqual(self.state, 'off')
self.assertEqual(self.sensor.cb_handle, new_handle)
self.scheduler.expire(new_handle)()
self.assertEqual(self.state, 'on')
def test_repeated_success_keeps_existing_timer(self):
self.sensor.on_condition_succeed()
handle = self.sensor.cb_handle
self.sensor.on_condition_succeed()
self.assertEqual(self.sensor.cb_handle, handle)
self.assertEqual(len(self.scheduler.timers), 1)
def test_inverted_condition_also_ignores_stale_expiry(self):
self.sensor.invert = True
self.state = 'on'
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
queued()
self.assertEqual(self.state, 'on')
self.sensor.on_condition_succeed()
self.scheduler.expire(self.sensor.cb_handle)()
self.assertEqual(self.state, 'off')
if __name__ == '__main__':
unittest.main()
+105
View File
@@ -0,0 +1,105 @@
"""Check initial windows, rearming and queued timer races."""
import ast
import unittest
from unittest.mock import Mock, patch
import time
from test_continuous_condition import ContinuousConditionTests, namespace, source
namespace['time'] = time
node = next(node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name == 'EphemeralCondition')
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
EphemeralCondition = namespace['EphemeralCondition']
class EphemeralConditionTests(unittest.TestCase):
def setUp(self):
ContinuousConditionTests.setUp(self)
self.sensor.__class__ = EphemeralCondition
self.sensor.attributes = {}
self.sensor.ad_api.get_state = Mock(return_value=None)
self.sensor.output_sensor.set_state.side_effect = lambda *, state, **kwargs: setattr(self, 'state', state)
self.sensor.initialize()
def test_window_expires_without_restarting_while_true(self):
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
handle = self.sensor.cb_handle
self.sensor.on_condition_succeed()
self.assertEqual(self.sensor.cb_handle, handle)
self.scheduler.expire(handle)()
self.assertEqual(self.state, 'off')
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_failure_ends_window_and_rearms(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off')
self.assertEqual(self.scheduler.timers, {})
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
self.scheduler.expire(self.sensor.cb_handle)()
self.assertEqual(self.state, 'off')
def test_queued_expiry_cannot_end_new_window(self):
self.sensor.on_condition_succeed()
queued = self.scheduler.expire(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.sensor.on_condition_succeed()
handle = self.sensor.cb_handle
queued()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, handle)
self.scheduler.expire(handle)()
self.assertEqual(self.state, 'off')
def test_restart_resumes_remaining_time(self):
self.sensor.ad_api.get_state.return_value = 100
with patch.object(time, 'time', return_value=103):
self.sensor.initialize()
self.sensor.ad_api.run_in = Mock(return_value='restored')
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.ad_api.run_in.call_args.args[1], 2)
self.assertEqual(self.sensor.attributes['condition_started_at'], 100)
def test_restart_does_not_rearm_expired_window(self):
self.sensor.ad_api.get_state.return_value = 100
with patch.object(time, 'time', return_value=110):
self.sensor.initialize()
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
self.sensor.on_condition_fail()
self.assertIsNone(self.sensor.attributes['condition_started_at'])
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
def test_invalid_restored_timestamp_starts_fresh(self):
for value in ('invalid', float('nan'), float('inf'), -1, 200):
with self.subTest(value=value), patch.object(time, 'time', return_value=100):
self.sensor.ad_api.get_state.return_value = value
self.sensor.initialize()
self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.attributes['condition_started_at'], 100)
self.sensor.on_condition_fail()
def test_initial_true_condition_starts_window(self):
evaluator = namespace['SmartCondition'].Evaluator
def initialize_condition(*args, **kwargs):
kwargs['on_succeed_cb']()
evaluator.side_effect = initialize_condition
try:
self.sensor.initialize()
self.assertEqual(self.state, 'on')
self.assertIsNotNone(self.sensor.cb_handle)
finally:
evaluator.side_effect = None
if __name__ == '__main__':
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
"""An expired timer queued before reactivation must not end the new window."""
import ast
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from test_continuous_condition import FakeScheduler
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
node = next(node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name == 'EventSensor')
namespace = {'VirtualSensorBase': object, 'EventHandler': Mock()}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
EventSensor = namespace['EventSensor']
class EventSensorTests(unittest.TestCase):
def setUp(self):
self.scheduler = FakeScheduler()
self.state = 'off'
entity = Mock()
entity.exists.return_value = True
entity.set_state.side_effect = lambda *, state: setattr(self, 'state', state)
self.sensor = EventSensor()
self.sensor.ad_api = SimpleNamespace(
get_entity=lambda name: entity,
run_in=self.scheduler.run_in,
cancel_timer=self.scheduler.cancel_timer,
)
self.sensor.sensor_name = 'binary_sensor.red_shelly_home'
self.sensor.yaml_block = {'retain_time': 120}
self.sensor.initialize()
def activate(self):
self.sensor.on_activation_event('signal_strength', {}, {})
return self.sensor.cb_handle
def test_current_expiry_turns_sensor_off(self):
handle = self.activate()
self.assertEqual(self.state, 'on')
self.scheduler.expire(handle)()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_reactivation_cancels_running_timer(self):
old = self.activate()
current = self.activate()
self.assertNotIn(old, self.scheduler.timers)
self.assertIn(current, self.scheduler.timers)
self.assertEqual(self.state, 'on')
self.assertEqual(self.scheduler.warnings, [])
def test_queued_expiry_preserves_reactivated_sensor_and_timer(self):
queued = self.scheduler.expire(self.activate())
current = self.activate()
queued()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, current)
self.assertEqual(self.scheduler.warnings, [])
self.scheduler.expire(current)()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_multiple_queued_expiries_cannot_end_latest_window(self):
first = self.scheduler.expire(self.activate())
second = self.scheduler.expire(self.activate())
current = self.activate()
second()
first()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, current)
self.assertEqual(self.scheduler.warnings, [])
self.scheduler.expire(current)()
self.assertEqual(self.state, 'off')
if __name__ == '__main__':
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Check volume routing with the real condition parser, without AppDaemon."""
import ast
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import yaml
TOOLBOX = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLBOX))
import smartcondition
from expressionparser import ParsingException
def load_classes(path, namespace):
nodes = [node for node in ast.parse(path.read_text()).body
if isinstance(node, ast.ClassDef)]
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), 'exec'), namespace)
return namespace
handlers = load_classes(TOOLBOX / 'eventhandler.py', {})
classes = load_classes(TOOLBOX / 'eventsforwarder.py', {
'hass': SimpleNamespace(Hass=type('Hass', (), {})), 'LoggerInterface': type('LoggerInterface', (), {}),
'SmartCondition': smartcondition, 'EventHandler': handlers['EventHandler'],
'ParsingException': ParsingException,
})
class ForwarderTests(unittest.TestCase):
def setUp(self):
# Do not fabricate optional API methods such as log_group on lookup.
self.owner = Mock(spec=[
'entity_exists', 'get_state', 'log', 'log_info', 'log_warning',
'log_error', 'fire_event', 'call_service',
])
self.states = {'media_player.bravia_theatre': 'off',
'binary_sensor.is_tv_on': 'off'}
self.owner.entity_exists.side_effect = lambda entity: entity in self.states
self.owner.get_state.side_effect = lambda entity, **kwargs: self.states.get(entity)
config = yaml.safe_load((TOOLBOX.parents[0] / 'global/homecinema.yaml').read_text())
self.blocks = config['homecinema_volume']['forwarders']
def test_priorities_and_repeated_commands(self):
for direction, block in self.blocks.items():
senders = [classes['EventSender'](self.owner, value)
for value in block['events_to_send'].values()]
callers = [classes['ServiceCaller'](self.owner, value)
for value in block['services_to_call'].values()]
for bravia in ('on', 'playing', 'paused', 'idle', 'off', 'standby', 'unknown', 'unavailable'):
for tv in ('on', 'off'):
with self.subTest(direction=direction, bravia=bravia, tv=tv):
self.states.update({'media_player.bravia_theatre': bravia,
'binary_sensor.is_tv_on': tv})
self.owner.fire_event.reset_mock()
self.owner.call_service.reset_mock()
for _ in range(2):
for sender in senders:
sender.send_event('button', {})
for caller in callers:
caller.call_service('button', {})
if bravia in ('on', 'playing', 'paused', 'idle'):
target = 'media_player.bravia_theatre'
elif tv == 'on':
self.assertEqual(self.owner.fire_event.call_count, 2)
self.owner.fire_event.assert_called_with(f'the_frame_{direction}')
self.owner.call_service.assert_not_called()
continue
else:
target = 'media_player.living_room_sonos'
self.owner.fire_event.assert_not_called()
self.assertEqual(self.owner.call_service.call_count, 2)
self.owner.call_service.assert_called_with(
f'media_player/{direction}', entity_id=target)
def test_unconditional_service_keeps_static_data(self):
caller = classes['ServiceCaller'](self.owner, {
'service': 'media_player/volume_up', 'data': {'entity_id': 'media_player.test'}})
caller.call_service('button', {'entity_id': 'ignored'})
self.owner.call_service.assert_called_once_with(
'media_player/volume_up', entity_id='media_player.test')
class SamsungVolumeTests(unittest.TestCase):
def setUp(self):
errors = SimpleNamespace(ConnectionFailure=ConnectionError, ResponseError=RuntimeError)
samsung = load_classes(TOOLBOX.parent / 'samsungtheframe.py', {
'SmartObject': type('SmartObject', (), {}), 'tv_exceptions': errors,
'websocket': SimpleNamespace(WebSocketException=ValueError),
})
self.tv_app = samsung['SamsungTheFrame']()
self.tv_app.is_on_sensor = 'binary_sensor.is_tv_on'
self.tv_app.get_state = Mock(return_value='on')
self.connection = Mock()
self.tv_app.connect = Mock(return_value=self.connection)
self.tv_app.log = Mock()
def test_volume_keys_and_connection_cleanup(self):
for direction, key in [('up', 'KEY_VOLUP'), ('down', 'KEY_VOLDOWN')]:
self.connection.reset_mock()
self.tv_app.on_volume_event('button', {}, direction)
self.connection.send_key.assert_called_once_with(key)
self.connection.close.assert_called_once_with()
def test_art_mode_does_not_send_volume(self):
self.tv_app.get_state.return_value = 'off'
self.tv_app.on_volume_event('button', {}, 'up')
self.tv_app.connect.assert_not_called()
def test_failure_is_logged_and_connection_closed(self):
self.connection.send_key.side_effect = ConnectionError('Disconnected')
self.tv_app.on_volume_event('button', {}, 'up')
self.tv_app.log.assert_called_once()
self.connection.close.assert_called_once_with()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,122 @@
"""Verify scoped log grouping without replacing AppDaemon API methods."""
import os
import pickle
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
TOOLBOX = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLBOX))
from logger_interface import LoggerInterface
import smartcondition
def load_smart_object():
source = (TOOLBOX / 'smartobject.py').read_text()
import ast
tree = ast.parse(source)
smart_object = next(node for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == 'SmartObject')
class FakeHass:
def log(self, message, level='INFO', log=None):
self.rows.append((level, message, log))
namespace = {'hass': SimpleNamespace(Hass=FakeHass),
'LoggerInterface': LoggerInterface, 'VirtualSensors': object,
'ParsingException': type('ParsingException', (Exception,), {}),
'pickle': pickle, 'os': os}
exec(compile(ast.Module(body=[smart_object], type_ignores=[]), str(TOOLBOX / 'smartobject.py'), 'exec'), namespace)
return namespace['SmartObject']
class FakeApp(LoggerInterface):
name = 'fake_app'
def __init__(self):
self.rows = []
super().__init__(self)
def log(self, message, level='INFO', log=None):
self.rows.append((level, message, log))
def initialize(self):
self.log_info('LoggerInterface initialization message')
self.log('Direct app log stays readable')
class LoggerGroupingTests(unittest.TestCase):
def test_initialize_is_not_implicitly_wrapped_or_tagged(self):
app = FakeApp()
original_initialize = app.initialize.__func__
original_log = app.log.__func__
app.initialize()
self.assertIs(app.initialize.__func__, original_initialize)
self.assertIs(app.log.__func__, original_log)
self.assertEqual(app.rows[0][1], 'LoggerInterface initialization message')
self.assertEqual(app.rows[1][1], 'Direct app log stays readable')
def test_smart_object_groups_its_own_lifecycle(self):
SmartObject = load_smart_object()
app = SmartObject.__new__(SmartObject)
app.rows = []
app.name = 'test_smart_object'
app.args = {}
with tempfile.TemporaryDirectory() as folder:
app.AD = SimpleNamespace(app_dir=folder)
app.get_ad_api = lambda: app
app.on_initialize_smart_object = lambda: app.log_info('Subclass initialization hook')
app.initialize()
self.assertIn('Initialization started [group=initialization:', app.rows[0][1])
group_id = app.rows[0][1].split(':', 1)[1].rstrip(']')
self.assertIn(f'[group=initialization:{group_id}]', app.rows[1][1])
self.assertIn(f'[group=initialization:{group_id}]', app.rows[2][1])
self.assertEqual(app.rows[-1][1], f'Initialization completed [group=initialization:{group_id}]')
def test_smartcondition_uses_smartobject_api_scope_without_logger_interface_argument(self):
app = FakeApp()
evaluator = smartcondition.Evaluator.__new__(smartcondition.Evaluator)
evaluator._Evaluator__logger_interface = None
evaluator._Evaluator__appdaemon_api = app
evaluator._Evaluator__log_group = 'smart_conditions'
with evaluator._Evaluator__log_group_scope():
app.log_info('Callback trigger by sensor.example(0 => 1)')
app.log_info('[smart_conditions][Evaluating] Triggered by condition')
marker_a = app.rows[0][1].split('[group=')[1].rstrip(']')
marker_b = app.rows[1][1].split('[group=')[1].rstrip(']')
self.assertEqual(marker_a, marker_b)
self.assertTrue(marker_a.startswith('smartcondition:'))
def test_log_group_takes_priority_over_condition_name(self):
app = FakeApp()
evaluator = smartcondition.Evaluator(app, {}, activated=False,
condition_name='condition-a', log_group='group-a')
self.assertEqual(evaluator._Evaluator__log_group, 'group-a')
self.assertEqual(evaluator._Evaluator__log_to_string('message'), '[condition-a] message')
with evaluator._Evaluator__log_group_scope() as group:
self.assertEqual(group['label'], 'group-a')
fallback = smartcondition.Evaluator(app, {}, activated=False,
condition_name=['condition-a', 'condition-b'])
self.assertEqual(fallback._Evaluator__log_group, 'condition-a, condition-b')
def test_nested_scopes_inherit_outer_group_and_reset_after_exception(self):
app = FakeApp()
with app.log_group('value_selector', 'sensor.a') as outer:
app.log_info('candidate rejected')
with app.log_group('smartcondition', 'condition-a') as inner:
self.assertIs(inner, outer)
app.log_info('condition result')
app.log_info('outside group')
self.assertIn(f'[group=value_selector:{outer["id"]}]', app.rows[0][1])
self.assertIn(f'[group=value_selector:{outer["id"]}]', app.rows[1][1])
self.assertEqual(app.rows[2][1], 'outside group')
with self.assertRaises(RuntimeError):
with app.log_group('smartcondition'):
raise RuntimeError('expected')
app.log_info('after failure')
self.assertEqual(app.rows[3][1], 'after failure')
if __name__ == '__main__':
unittest.main()
+58 -5
View File
@@ -27,6 +27,7 @@ class RetainConditionTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.sensor = RetainCondition() self.sensor = RetainCondition()
self.sensor.cb_handle = None self.sensor.cb_handle = None
self.sensor._timer_generation = 0
self.sensor.retain_time = 15 self.sensor.retain_time = 15
self.sensor.reset_retain_time_on_new_success = True self.sensor.reset_retain_time_on_new_success = True
self.state = 'off' self.state = 'off'
@@ -41,6 +42,54 @@ class RetainConditionTests(unittest.TestCase):
def set_state(self, *, state): def set_state(self, *, state):
self.state = state self.state = state
def queued_expiry(self):
call = self.sensor.ad_api.run_in.call_args
callback = call.args[0]
kwargs = dict(call.kwargs)
return lambda: callback(kwargs)
def test_return_at_expiry_ignores_queued_callback(self):
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
queued = self.queued_expiry()
# The scheduler has already removed the timer and queued its callback.
self.sensor.ad_api.cancel_timer.return_value = False
self.sensor.condition.evaluate.return_value = Result.Succeeded
self.sensor.on_condition_succeed()
queued()
self.assertEqual(self.state, 'on')
self.sensor.condition.evaluate.assert_not_called()
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
def test_stale_expiry_cannot_shorten_new_retention(self):
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
queued = self.queued_expiry()
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
current_expiry = self.queued_expiry()
queued()
self.assertEqual(self.state, 'on')
self.assertEqual(self.sensor.cb_handle, 'new-timer')
current_expiry()
self.assertEqual(self.state, 'off')
self.assertIsNone(self.sensor.cb_handle)
def test_stale_expiry_cannot_forget_timer_while_condition_disabled(self):
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
queued = self.queued_expiry()
self.sensor.on_condition_succeed()
self.sensor.on_condition_fail()
self.sensor.condition.evaluate.return_value = Result.Disabled
queued()
self.assertEqual(self.sensor.cb_handle, 'new-timer')
self.assertEqual(self.state, 'on')
self.sensor.on_condition_succeed()
self.assertEqual(self.sensor.ad_api.cancel_timer.call_args.args, ('new-timer',))
def test_long_lived_signal_gets_full_delay_after_loss(self): def test_long_lived_signal_gets_full_delay_after_loss(self):
self.sensor.on_condition_succeed() self.sensor.on_condition_succeed()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
@@ -48,15 +97,16 @@ class RetainConditionTests(unittest.TestCase):
# No timer can expire while the signal stays valid. # No timer can expire while the signal stays valid.
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.sensor.ad_api.run_in.assert_called_once_with(self.sensor.on_time_elapse, 15) self.sensor.ad_api.run_in.assert_called_once_with(
self.sensor.on_time_elapse({}) self.sensor.on_time_elapse, 15, generation=self.sensor._timer_generation)
self.queued_expiry()()
self.assertEqual(self.state, 'off') self.assertEqual(self.state, 'off')
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self): def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
self.sensor.on_condition_succeed() self.sensor.on_condition_succeed()
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.sensor.on_condition_succeed() self.sensor.on_condition_succeed()
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer') self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
self.assertIsNone(self.sensor.cb_handle) self.assertIsNone(self.sensor.cb_handle)
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
@@ -79,8 +129,11 @@ class RetainConditionTests(unittest.TestCase):
with self.subTest(result=result): with self.subTest(result=result):
self.state = 'on' self.state = 'on'
self.sensor.condition.evaluate.return_value = result self.sensor.condition.evaluate.return_value = result
self.sensor.on_time_elapse({}) self.sensor.register_callback()
self.queued_expiry()()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.assertIsNone(self.sensor.cb_handle)
self.sensor.condition.evaluate.assert_called_with()
def test_explicit_no_reset_preserves_fixed_window(self): def test_explicit_no_reset_preserves_fixed_window(self):
self.sensor.reset_retain_time_on_new_success = False self.sensor.reset_retain_time_on_new_success = False
@@ -88,7 +141,7 @@ class RetainConditionTests(unittest.TestCase):
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.assertEqual(self.state, 'on') self.assertEqual(self.state, 'on')
self.sensor.condition.evaluate.return_value = Result.Succeeded self.sensor.condition.evaluate.return_value = Result.Succeeded
self.sensor.on_time_elapse({}) self.queued_expiry()()
self.sensor.on_condition_fail() self.sensor.on_condition_fail()
self.assertEqual(self.state, 'off') self.assertEqual(self.state, 'off')
+122
View File
@@ -0,0 +1,122 @@
"""Verify debouncing when an expired AppDaemon timer is still queued."""
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from smartcondition import Evaluator
class FakeScheduler:
def __init__(self):
self.timers = {}
self.next_id = 0
self.cancelled = []
self.warnings = []
self.expire_on_cancel = False
self.queued = []
def run_in(self, callback, delay, **kwargs):
self.next_id += 1
handle = str(self.next_id)
self.timers[handle] = (callback, kwargs)
return handle
def timer_running(self, handle):
return handle in self.timers
def cancel_timer(self, handle, silent=False):
self.cancelled.append(handle)
if self.expire_on_cancel and handle in self.timers:
self.queued.append(self.expire(handle))
cancelled = self.timers.pop(handle, None) is not None
if not cancelled and not silent:
self.warnings.append(handle)
return cancelled
def expire(self, handle):
callback, kwargs = self.timers.pop(handle)
return lambda: callback(kwargs)
class DelayedCallbackTests(unittest.TestCase):
def setUp(self):
self.scheduler = FakeScheduler()
self.evaluator = Evaluator.__new__(Evaluator)
fields = dict(
appdaemon_api=self.scheduler, callback_delay=10,
trigger_callback_on_entity_creation=True,
condition_state_change_delayed=[],
condition_state_change_delayed_cb_handle=None,
delay_generation=0, trigger_callbacks=Mock(), log=Mock(),
)
for name, value in fields.items():
setattr(self.evaluator, '_Evaluator__' + name, value)
def change(self, old, new):
self.evaluator._Evaluator__on_condition_state_change(
'sensor.temperature', 'state', old, new, {})
return self.evaluator._Evaluator__condition_state_change_delayed_cb_handle
def test_queued_obsolete_callback_preserves_new_timer_and_changes(self):
old_handle = self.change('20', '21')
queued_callback = self.scheduler.expire(old_handle)
new_handle = self.change('21', '22')
queued_callback()
self.assertEqual(self.scheduler.warnings, [])
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
new_handle)
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
self.evaluator._Evaluator__log.assert_not_called()
self.scheduler.expire(new_handle)()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
reason = self.evaluator._Evaluator__trigger_callbacks.call_args.args[0]
self.assertIn('20 => 21', reason)
self.assertIn('21 => 22', reason)
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed, [])
self.assertIsNone(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle)
self.evaluator._Evaluator__log.assert_not_called()
def test_obsolete_callback_after_current_callback_is_ignored(self):
queued_callback = self.scheduler.expire(self.change('20', '21'))
self.scheduler.expire(self.change('21', '22'))()
queued_callback()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
self.evaluator._Evaluator__log.assert_not_called()
def test_running_timer_is_cancelled_when_rescheduled(self):
old_handle = self.change('20', '21')
new_handle = self.change('21', '22')
self.assertEqual(self.scheduler.cancelled, [old_handle])
self.assertFalse(self.scheduler.timer_running(old_handle))
self.scheduler.expire(new_handle)()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
def test_expiry_during_cancellation_is_silent_and_preserves_new_batch(self):
self.change('20', '21')
self.scheduler.expire_on_cancel = True
current = self.change('21', '22')
self.assertEqual(self.scheduler.warnings, [])
self.assertEqual(len(self.scheduler.queued), 1)
self.scheduler.queued[0]()
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
current)
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
self.scheduler.expire(current)()
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
self.evaluator._Evaluator__log.assert_not_called()
def test_zero_delay_remains_immediate(self):
self.evaluator._Evaluator__callback_delay = 0
self.change('20', '21')
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
self.assertEqual(self.scheduler.timers, {})
if __name__ == '__main__':
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Time-period boundaries, source updates and timer lifecycle without AppDaemon."""
import ast
import datetime
import re
import unittest
from pathlib import Path
from unittest.mock import Mock
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
nodes = [node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name in ('VirtualSensorBase', 'TimePeriod', 'VirtualSensors')]
from kwargsparser import kwargsParser
namespace = {'datetime': datetime, 're': re, 'kwargsParser': kwargsParser, 'ParsingException': ValueError}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), namespace)
TimePeriod = namespace['TimePeriod']
class TimePeriodTests(unittest.TestCase):
def make_sensor(self, start='08:00:00', end='18:00:00', now='2026-10-25 12:00:00'):
self.states = {'input_datetime.start': start, 'input_datetime.end': end}
self.api = Mock()
self.api.datetime.return_value = datetime.datetime.fromisoformat(now)
self.api.get_state.side_effect = self.states.get
self.api.entity_exists.return_value = True
self.api.get_entity.return_value.get_state.return_value = 'unknown'
self.sensor = TimePeriod(self.api, Mock(), virtual_sensor_name='period',
yaml_block={'start': 'input_datetime.start', 'end': 'input_datetime.end'})
self.sensor._initialize()
return self.sensor
def assert_state(self, expected):
self.assertEqual(self.sensor.entity.set_state.call_args.kwargs['state'], expected)
def test_factory_registration(self):
self.make_sensor()
factory = namespace['VirtualSensors'](ad_api=self.api, logger_interface=Mock(),
yaml_block={'sensors': {'time_period.example': {
'start': 'input_datetime.start', 'end': 'input_datetime.end'}}})
self.assertIsInstance(factory.virtual_sensors['binary_sensor.example'], TimePeriod)
def test_day_boundaries_and_timer(self):
self.make_sensor(now='2026-10-25 07:59:50')
self.assert_state('off')
self.assertEqual(self.api.run_in.call_args.args[1], 10)
for hour, expected in [('08:00:00', 'on'), ('17:59:59', 'on'), ('18:00:00', 'off')]:
self.api.datetime.return_value = datetime.datetime.fromisoformat('2026-10-25 ' + hour)
self.sensor.on_time_elapsed({'generation': self.sensor._timer_generation})
self.assert_state(expected)
def test_overnight_and_equal_boundaries(self):
for hour, expected in [('23:00:00', 'on'), ('02:00:00', 'on'), ('06:00:00', 'off'), ('12:00:00', 'off')]:
self.make_sensor('22:00:00', '06:00:00', '2026-10-25 ' + hour)
self.assert_state(expected)
self.make_sensor('08:00:00', '08:00:00')
self.assert_state('off')
def test_source_change_cancels_timer_and_ignores_stale_callback(self):
self.make_sensor()
self.assert_state('on')
previous_generation = self.sensor._timer_generation
previous_handle = self.sensor.cb_handle
self.states['input_datetime.end'] = '10:00:00'
self.sensor.on_input_change('input_datetime.end', 'state', '18:00:00', '10:00:00', {})
self.assert_state('off')
self.api.cancel_timer.assert_called_with(previous_handle, silent=True)
count = self.api.run_in.call_count
self.sensor.on_time_elapsed({'generation': previous_generation})
self.assertEqual(self.api.run_in.call_count, count)
self.assertEqual(self.api.listen_state.call_count, 2)
def test_unavailable_and_recovery(self):
for invalid in [None, 'unknown', 'unavailable', '25:00:00', '2026-10-25']:
self.make_sensor(start=invalid)
self.assert_state('unavailable')
self.states['input_datetime.start'] = '08:00:00'
self.sensor.update_period()
self.assert_state('on')
def test_dated_periods(self):
for start, end, expected in [
('2026-10-24 22:00:00', '2026-10-26 06:00:00', 'on'),
('2026-10-24 22:00:00', '2026-10-25 12:00:00', 'off'),
('2026-10-26 22:00:00', '2026-10-24 06:00:00', 'off'),
('2026-10-24', '2026-10-26', 'on'),
]:
self.make_sensor(start, end)
self.assert_state(expected)
def test_dependencies_constants_and_validation(self):
self.make_sensor()
self.assertEqual(self.sensor.sensor_name, 'binary_sensor.period')
self.sensor.constants['begin'] = 'input_datetime.start'
self.sensor.yaml_block['start'] = 'begin'
self.assertEqual(self.sensor.generate_dependencies_list(), list(self.states))
self.sensor.yaml_block['end'] = 'sensor.invalid'
with self.assertRaises(ValueError):
self.sensor.generate_dependencies_list()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,103 @@
"""Check reactive expressions alongside legacy entity-backed attributes."""
import ast
import re
import sys
import unittest
from pathlib import Path
MODULE_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(MODULE_DIR))
import smartvalue as SmartValue
from expressionparser import ParsingException
source = MODULE_DIR / "virtualsensors.py"
node = next(node for node in ast.parse(source.read_text()).body
if isinstance(node, ast.ClassDef) and node.name == "VirtualSensorBase")
namespace = {"SmartValue": SmartValue, "ParsingException": ParsingException, "re": re}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), "exec"), namespace)
VirtualSensorBase = namespace["VirtualSensorBase"]
class FakeAPI:
def __init__(self):
self.states = {
"sensor.icon_source": {"state": "mdi:bike", "attributes": {}},
"sensor.next_rain": {"state": "unknown", "attributes": {"minutes_until_rain": "12"}},
}
self.listeners = []
def entity_exists(self, entity):
return entity in self.states
def get_state(self, entity, attribute=None):
data = self.states.get(entity)
if data is None:
return None
return data["state"] if attribute is None else data["attributes"].get(attribute)
def set_state(self, entity, state=None, attributes=None):
data = self.states.setdefault(entity, {"state": None, "attributes": {}})
if state is not None:
data["state"] = state
if attributes is not None:
data["attributes"].update(attributes)
def listen_state(self, callback, entity, attribute=None):
self.listeners.append((callback, entity, attribute))
def change(self, entity, value, attribute=None):
previous = self.get_state(entity, attribute)
if attribute is None:
self.states[entity]["state"] = value
else:
self.states[entity]["attributes"][attribute] = value
for callback, watched_entity, watched_attribute in list(self.listeners):
if (entity, attribute) == (watched_entity, watched_attribute):
callback(entity, attribute, previous, value, {})
class FakeLogger:
def log_info(self, message):
pass
def log_warning(self, message):
pass
def log_error(self, message):
raise AssertionError(message)
class ExampleSensor(VirtualSensorBase):
def initialize(self):
self.ad_api.set_state(self.sensor_name, state="off")
class SensorAttributeExpressionsTests(unittest.TestCase):
def test_legacy_state_and_expressions_track_their_sources(self):
api = FakeAPI()
sensor = ExampleSensor(
api, FakeLogger(), virtual_sensor_name="example",
yaml_block={"sensor_attributes": {
"icon": "sensor.icon_source",
"minutes": "sensor.next_rain.minutes_until_rain",
"minutes_plus_five": "sensor.next_rain.minutes_until_rain + 5",
}},
)
self.assertEqual(set(sensor.generate_attributes_dependencies_list()),
{"sensor.icon_source", "sensor.next_rain"})
sensor._initialize()
attrs = api.states["sensor.example"]["attributes"]
self.assertEqual((attrs["icon"], attrs["minutes"], attrs["minutes_plus_five"]),
("mdi:bike", 12, 17))
api.change("sensor.next_rain", "7", "minutes_until_rain")
api.change("sensor.icon_source", "mdi:weather-rainy")
self.assertEqual((attrs["icon"], attrs["minutes"], attrs["minutes_plus_five"]),
("mdi:weather-rainy", 7, 12))
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -22,9 +22,9 @@ class VirtualEvents(hass.Hass,LoggerInterface):
def on_condition_succeed(self,event_name): 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'])
+281 -54
View File
@@ -4,6 +4,8 @@ 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
from logger_interface import LoggerInterface from logger_interface import LoggerInterface
from ad_toolbox.eventhandler import EventHandler from ad_toolbox.eventhandler import EventHandler
@@ -45,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()
@@ -55,31 +62,64 @@ class VirtualSensorBase:
# assert len(splitted_name) == 2,f"Invalid virtual sensor name : {virtual_sensor_name}" # assert len(splitted_name) == 2,f"Invalid virtual sensor name : {virtual_sensor_name}"
# return f"sensor.{splitted_name[1]}" # return f"sensor.{splitted_name[1]}"
def log(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}") # def log(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
def log_info(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}") # def log_info(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
def log_warning(self,message): self.logger_interface.log_warning(f"[{self.sensor_name}]{message}") # def log_warning(self,message): self.logger_interface.log_warning(f"[{self.sensor_name}]{message}")
def log_error(self,message): self.logger_interface.log_error(f"[{self.sensor_name}]{message}") # def log_error(self,message): self.logger_interface.log_error(f"[{self.sensor_name}]{message}")
def log_info(self,message): self.logger_interface.log_info(message)
def log_warning(self,message): self.logger_interface.log_warning(message)
def log_error(self,message): self.logger_interface.log_error(message)
def generate_dependencies_list(self): return [] def generate_dependencies_list(self): return []
@staticmethod
def is_entity_reference(value):
# Keep plain entity IDs on the legacy path so their raw state type is preserved.
return isinstance(value, str) and re.fullmatch(r"[a-z_][a-z0-9_]*\.[a-z0-9_]+", value) is not None
def generate_attributes_dependencies_list(self): def generate_attributes_dependencies_list(self):
dependencies = list() dependencies = list()
if 'sensor_attributes' in self.yaml_block: if 'sensor_attributes' in self.yaml_block:
for key,entity in self.yaml_block['sensor_attributes'].items(): for expression in self.yaml_block['sensor_attributes'].values():
dependencies.append(entity) if self.is_entity_reference(expression):
dependencies.append(expression)
else:
evaluator = SmartValue.Evaluator(
self.ad_api, expression, constants=self.constants,
sensor_existence_validation=False, activated=False, log_init=False)
self.extract_dependencies_from_entities_to_listen(
evaluator.get_entities_to_listen_as_list(), dependencies)
return dependencies return dependencies
def _initialize(self): def _initialize(self):
self.attribute_expressions = dict()
if 'sensor_attributes' in self.yaml_block: if 'sensor_attributes' in self.yaml_block:
for attribute_name,sensor_name in self.yaml_block['sensor_attributes'].items(): for attribute_name,expression in self.yaml_block['sensor_attributes'].items():
if not self.ad_api.entity_exists(sensor_name): if self.is_entity_reference(expression):
self.log_error(f"Error in sensor_attribute. {attribute_name}:{sensor_name}. {sensor_name} doesn't exist") if not self.ad_api.entity_exists(expression):
self.log_error(f"Error in sensor_attribute. {attribute_name}:{expression}. {expression} doesn't exist")
else:
self.attributes[attribute_name] = self.ad_api.get_state(expression)
self.ad_api.listen_state(self.on_attribute_sensor_state_change,expression)
else: else:
self.attributes[attribute_name] = self.ad_api.get_state(sensor_name) try:
self.ad_api.listen_state(self.on_attribute_sensor_state_change,sensor_name) evaluator = SmartValue.Evaluator(
self.ad_api, expression, logger_interface=self.logger_interface,
expression_name=f"{self.sensor_name}.{attribute_name}",
constants=self.constants, sensor_existence_validation=False,
trigger_callback_on_activation=False,
on_change_cb=self.on_attribute_expression_change,
pass_expression_name_to_cb=attribute_name)
self.attribute_expressions[attribute_name] = evaluator
self.attributes[attribute_name] = evaluator.evaluate(False)
except ParsingException as error:
self.log_error(error)
try: self.initialize() try: self.initialize()
except ParsingException as e: except ParsingException as e:
@@ -96,6 +136,10 @@ class VirtualSensorBase:
if attributes_update: if attributes_update:
self.ad_api.set_state(self.sensor_name, attributes = self.attributes) self.ad_api.set_state(self.sensor_name, attributes = self.attributes)
def on_attribute_expression_change(self, attribute_name, previous_value, value):
self.attributes[attribute_name] = value
self.ad_api.set_state(self.sensor_name, attributes=self.attributes)
def initialize(self): assert False def initialize(self): assert False
def extract_dependencies_from_entities_to_listen(self,entities_to_listen,dependencies): def extract_dependencies_from_entities_to_listen(self,entities_to_listen,dependencies):
@@ -297,7 +341,7 @@ class ValueSelector(VirtualSensorBase):
def process_config(self) -> list[SelectorConfig]: def process_config(self) -> list[SelectorConfig]:
configs = list() configs = list()
#self.log(f"yaml_block:{self.yaml_block}") #self.log_info(f"yaml_block:{self.yaml_block}")
if 'values' in self.yaml_block: values_block = self.yaml_block['values'] if 'values' in self.yaml_block: values_block = self.yaml_block['values']
else: values_block = self.yaml_block else: values_block = self.yaml_block
@@ -322,7 +366,7 @@ class ValueSelector(VirtualSensorBase):
selectors_config = self.process_config() selectors_config = self.process_config()
for config in selectors_config: for config in selectors_config:
#self.log(f"config: name = {config.name},conditions = {config.conditions},value = {config.value}") #self.log_info(f"config: name = {config.name},conditions = {config.conditions},value = {config.value}")
conditions = SmartCondition.Evaluator(self.ad_api,config.conditions, logger_interface = self.logger_interface,condition_name = config.name, sensor_existence_validation = False, constants = self.constants, log_init = False) conditions = SmartCondition.Evaluator(self.ad_api,config.conditions, logger_interface = self.logger_interface,condition_name = config.name, sensor_existence_validation = False, constants = self.constants, log_init = False)
self.extract_dependencies_from_entities_to_listen(conditions.get_entities_to_listen_as_list(),dependencies) self.extract_dependencies_from_entities_to_listen(conditions.get_entities_to_listen_as_list(),dependencies)
@@ -342,42 +386,46 @@ class ValueSelector(VirtualSensorBase):
self.output_sensor.set_state(state = value) self.output_sensor.set_state(state = value)
def update_value(self): def update_value(self):
previous_value = str(self.output_sensor.get_state()) with self.logger_interface.log_group(self.sensor_name):
previous_value = str(self.output_sensor.get_state())
for selector in self.value_selectors: for selector in self.value_selectors:
if selector.conditions.evaluate(False) == SmartCondition.Result.Succeeded: if selector.conditions.evaluate(False) == SmartCondition.Result.Succeeded:
new_value = selector.compute_value() new_value = selector.compute_value()
if previous_value != str(new_value): if previous_value != str(new_value):
#TODO: find a way to know wether if it's the condition or the value that trigger the update #TODO: find a way to know wether if it's the condition or the value that trigger the update
selector.conditions.log_callback_trigger_reason() selector.conditions.log_callback_trigger_reason()
selector.conditions.log_evaluation_result() selector.conditions.log_evaluation_result()
if selector.value: if selector.value:
selector.value.log_callback_trigger_reason() selector.value.log_callback_trigger_reason()
selector.value.log_evaluation_result() selector.value.log_evaluation_result()
self.set_value(new_value) self.set_value(new_value)
break break
self.log_info(f"{self.sensor_name} = {self.output_sensor.get_state()}")
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
try: self.invert = self.yaml_block['invert'] try: self.invert = self.yaml_block['invert']
except KeyError: self.invert = False except KeyError: self.invert = False
self.log(f"Initializing continuous condition for {self.sensor_name}") self.log_info(f"Initializing continuous condition for {self.sensor_name}")
self.output_sensor = self.ad_api.get_entity(self.sensor_name) self.output_sensor = self.ad_api.get_entity(self.sensor_name)
if not self.output_sensor.exists(): if not self.output_sensor.exists():
self.log(f"{self.sensor_name} does not exist") self.log_info(f"{self.sensor_name} does not exist")
try: try:
self.output_sensor.set_state(state = self.yaml_block['default_value']) self.output_sensor.set_state(state = self.yaml_block['default_value'])
self.log(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}") self.log_info(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
except KeyError: except KeyError:
self.output_sensor.set_state(state = 'off') self.output_sensor.set_state(state = 'off')
self.log(f"adding {self.sensor_name} with default state off") self.log_info(f"adding {self.sensor_name} with default state off")
else: else:
self.log(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}") self.log_info(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
if self.invert: if self.invert:
succeed_cb = self.on_condition_fail succeed_cb = self.on_condition_fail
@@ -406,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:
@@ -428,25 +484,84 @@ 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
self.log(f"Initializing retain condition for {self.sensor_name}") self.log_info(f"Initializing retain condition for {self.sensor_name}")
self.output_sensor = self.ad_api.get_entity(self.sensor_name) self.output_sensor = self.ad_api.get_entity(self.sensor_name)
if not self.output_sensor.exists(): if not self.output_sensor.exists():
self.log(f"{self.sensor_name} does not exist") self.log_info(f"{self.sensor_name} does not exist")
try: try:
self.output_sensor.set_state(state = self.yaml_block['default_value']) self.output_sensor.set_state(state = self.yaml_block['default_value'])
self.log(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}") self.log_info(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
except KeyError: except KeyError:
self.output_sensor.set_state(state = 'off') self.output_sensor.set_state(state = 'off')
self.log(f"adding {self.sensor_name} with default state off") self.log_info(f"adding {self.sensor_name} with default state off")
else: else:
self.log(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}") self.log_info(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
self.condition = SmartCondition.Evaluator(self.ad_api,self.yaml_block['conditions'], logger_interface = self.logger_interface,condition_name = self.sensor_name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_condition_fail, constants = self.constants, templates_library = self.templates_library) self.condition = SmartCondition.Evaluator(self.ad_api,self.yaml_block['conditions'], logger_interface = self.logger_interface,condition_name = self.sensor_name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_condition_fail, constants = self.constants, templates_library = self.templates_library)
# new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off' # new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
@@ -464,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':
@@ -497,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)
@@ -505,15 +711,23 @@ class BinarySensor(VirtualSensorBase):
self.output_sensor.set_state(state = 'off',attributes = self.attributes) self.output_sensor.set_state(state = 'off',attributes = self.attributes)
#self.ad_api.log(f"Creating Binary Sensor: binary_sensor = {self.virtual_sensor_name}, yaml_block = {self.yaml_block}") #self.ad_api.log(f"Creating Binary Sensor: binary_sensor = {self.virtual_sensor_name}, yaml_block = {self.yaml_block}")
self.condition = SmartCondition.Evaluator(self.ad_api,self.get_expression_yaml(), logger_interface = self.logger_interface, unavaibility_result = SmartCondition.Result.Unavailable,condition_name = self.virtual_sensor_name,on_change_cb = self.on_sensor_change, constants = self.constants, templates_library = self.templates_library) self.condition = SmartCondition.Evaluator(self.ad_api,self.get_expression_yaml(), logger_interface = self.logger_interface, unavaibility_result = SmartCondition.Result.Unavailable,condition_name = self.virtual_sensor_name,on_change_cb = self.on_sensor_change, constants = self.constants, templates_library = self.templates_library, log_group = self.sensor_name)
new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off' new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
self.output_sensor.set_state(state = new_value) self.output_sensor.set_state(state = new_value)
def on_sensor_change(self,prev_result,result): def on_sensor_change(self,prev_result,result):
if result == SmartCondition.Result.Unavailable: self.output_sensor.set_state(state = 'unavailable') target_state = None
elif result == SmartCondition.Result.Succeeded: self.output_sensor.set_state(state = 'on') if result == SmartCondition.Result.Unavailable: target_state = 'unavailable'
elif result == SmartCondition.Result.Failed: self.output_sensor.set_state(state = 'off') elif result == SmartCondition.Result.Succeeded: target_state = 'on'
# else: Disabled means we keep the previous value elif result == SmartCondition.Result.Failed: target_state = 'off'
else: target_state = None #Disabled means we keep the previous value
if target_state != None:
self.output_sensor.set_state(state = target_state)
self.log_info(f"{self.sensor_name} = {target_state}")
else:
self.log_info(f"SmartCondition of {self.sensor_name} is disabled")
def generate_dependencies_list(self): def generate_dependencies_list(self):
dependencies = list() dependencies = list()
@@ -543,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}"
@@ -608,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:
@@ -635,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':
@@ -643,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':
@@ -712,4 +938,5 @@ class VirtualSensors():
class VirtualSensorsApp(hass.Hass): class VirtualSensorsApp(hass.Hass):
def initialize(self): def initialize(self):
self.logger_interface = LoggerInterface(self.get_ad_api(),default_log = "virtualsensors_log") self.logger_interface = LoggerInterface(self.get_ad_api(),default_log = "virtualsensors_log")
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'],app_name = self.name) with self.logger_interface.log_group("App Initialization"):
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'],app_name = self.name)
+5
View File
@@ -2,3 +2,8 @@ node_modules/
src/ src/
tsconfig.json tsconfig.json
.gitignore .gitignore
tests/**
*.vsix
@eaDir/**
+25
View File
@@ -0,0 +1,25 @@
# VS Code extension instructions
## VS Code extension
After every change to this extension, complete the update by compiling,
packaging, and installing the extension in the connected VS Code environment.
This includes changes limited to the Log Explorer HTML, CSS, or JavaScript.
- Keep the current extension version unless the user explicitly requests a
version change. Do not bump `package.json` or `package-lock.json` automatically.
- From this directory, run `npm run compile` and the checks appropriate to
the change. Report any existing test failures separately.
- Package with `vsce package --out vscode-appdaemon-<current-version>.vsix`,
using the installed or cached `@vscode/vsce` CLI when available. Overwrite the
existing VSIX for that version; never pass a version increment to `vsce`.
- Install that exact VSIX using
`code --install-extension ./vscode-appdaemon-<current-version>.vsix --force`.
- Confirm the installed version with `code --list-extensions --show-versions`.
When possible, verify the installed changed files match the packaged files.
- Tell the user to reload the VS Code window to activate the update. If the
connected CLI is unavailable or installation fails, report that limitation
clearly and provide the generated VSIX path; do not claim it was installed.
The user's request authorizes this routine local build and installation after
extension edits. Do not ask for additional conversational confirmation.
+60 -72
View File
@@ -1,96 +1,84 @@
# Extension VS Code AppDaemon # AppDaemon for Home Assistant — VS Code extension
Extension VS Code pour développer et administrer les applications AppDaemon Version: **0.2.0**
utilisées avec Home Assistant.
Version documentée : `0.1.0` ## Install or update
## Installation Open **Extensions**, select **… → Install from VSIX…**, and choose
`vscode-appdaemon-0.2.0.vsix`. Reload VS Code when prompted.
1. Ouvrir **Extensions** dans VS Code (`Ctrl+Shift+X` / `Cmd+Shift+X`). ## Log explorer
2. Cliquer sur `...`, puis **Install from VSIX...**.
3. Sélectionner `vscode-appdaemon-0.1.0.vsix` dans ce dossier.
4. Recharger VS Code si nécessaire.
## Configuration Select **$(list-tree) AD Logs** in the VS Code status bar, or run **AppDaemon: Explore Logs** from the Command Palette. The explorer opens in a central editor tab, including when no document is open.
Les réglages sont disponibles dans **Paramètres → Extensions → AppDaemon**, ou By default, it reads the existing AppDaemon text logs from the workspace:
dans `.vscode/settings.json` :
- `logs/appdaemon.log`
- `logs/error.log`
- `logs/virtualsensors.log`
The explorer groups lines by the app name in the standard AppDaemon log format. It provides INFO/WARNING/ERROR counts, expandable app histories, links to app definitions, relative timestamps, sorting by latest activity or app name, text/time/level filters, live updates, and raw-log navigation. Duplicate error lines found in multiple files are merged. The log reader handles file rotation, truncation, multiline exceptions, UTF-8, and legacy Windows-1252 bytes.
**AD Logs** turns red when the loaded logs contain an ERROR or CRITICAL entry. **Clear logs** empties the configured log files and clears the current explorer history.
Lines carrying the same `[group=<name>:<id>]` marker are grouped together, and the supplied name is shown in the explorer. Ordinary AppDaemon lines without a group marker remain individual log entries. Trace metadata can still be used for causal links where present.
## Settings
```json ```json
{ {
"appdaemon.haUrl": "http://homeassistant:8123", "appdaemon.haUrl": "http://homeassistant:8123",
"appdaemon.haToken": "<TOKEN_HOME_ASSISTANT>", "appdaemon.haToken": "<HOME_ASSISTANT_TOKEN>",
"appdaemon.adUrl": "http://localhost:5050", "appdaemon.adUrl": "http://localhost:5050",
"appdaemon.errorLogPath": "logs/error.log", "appdaemon.errorLogPath": "logs/error.log",
"appdaemon.mainLogPath": "logs/appdaemon.log", "appdaemon.mainLogPath": "logs/appdaemon.log",
"appdaemon.entityRefreshInterval": 300 "appdaemon.entityRefreshInterval": 300,
"appdaemon.logExplorerPaths": [
"logs/appdaemon.log",
"logs/error.log",
"logs/virtualsensors.log"
],
"appdaemon.logTimezone": "Europe/Berlin",
"appdaemon.logEncoding": "auto"
} }
``` ```
| Réglage | Défaut | Description | | Setting | Purpose |
| --- | --- | --- | | --- | --- |
| `appdaemon.haUrl` | `http://localhost:8123` | URL de Home Assistant | | `haUrl`, `haToken` | Home Assistant connection for entity information |
| `appdaemon.haToken` | vide | Jeton longue durée Home Assistant | | `adUrl` | AppDaemon HTTP API URL |
| `appdaemon.adUrl` | `http://localhost:5050` | URL de l’API HTTP AppDaemon | | `errorLogPath`, `mainLogPath` | Log locations relative to the workspace |
| `appdaemon.errorLogPath` | `logs/error.log` | Journal d’erreurs, relatif au workspace | | `entityRefreshInterval` | Entity refresh interval in seconds |
| `appdaemon.mainLogPath` | `logs/appdaemon.log` | Journal principal, relatif au workspace | | `logExplorerPaths` | Text log files followed by the explorer |
| `appdaemon.entityRefreshInterval` | `300` | Rafraîchissement des entités, en secondes | | `logTimezone` | Timezone used to interpret AppDaemon timestamps |
| `logEncoding` | `auto`, `utf8`, or `windows-1252` for text logs |
Ne pas versionner le jeton Home Assistant. Utiliser de préférence les réglages Do not commit your Home Assistant token. Use user settings or an ignored local
utilisateur ou un fichier local ignoré par Git. settings file.
## Raccourcis clavier ## Other extension features
| Action | Windows / Linux | macOS | - Home Assistant entity completion and hover information in Python/YAML.
| --- | --- | --- | - Navigation to AppDaemon app and virtual-sensor definitions.
| Aller à une application AppDaemon | `Ctrl+Shift+A` | `Cmd+Shift+A` | - AppDaemon errors as VS Code diagnostics and an output channel.
| Aller à un capteur virtuel | `Ctrl+Shift+V` | `Cmd+Shift+V` | - Status bar actions for app reload, Home Assistant restart, log exploration,
and apps defined in the active YAML file.
Chaque raccourci ouvre une palette permettant de rechercher puis sélectionner | Shortcut | Action |
l’application ou le capteur souhaité. | --- | --- |
| Ctrl+Shift+A / Cmd+Shift+A | Go to App |
| Ctrl+Shift+V / Cmd+Shift+V | Go to Virtual Sensor |
Pour modifier un raccourci, ouvrir les raccourcis clavier (`Ctrl+K Ctrl+S`), Commands are available under **AppDaemon:** in the Command Palette: **Go to
rechercher `AppDaemon`, puis modifier la commande voulue. App**, **Restart Apps in Current File**, **Reload Apps**, **Restart Home
Assistant**, **Toggle Production Mode**, **Show Error Log**, **Refresh Entity
List**, **Clear Error Diagnostics**, and **Explore Logs**.
## Commandes disponibles ## Verification
Toutes les commandes sont accessibles avec la palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) ```sh
en recherchant `AppDaemon:`. npm test
```
- **Go to App** : ouvrir la définition d’une application AppDaemon.
- **Restart Apps in Current File** : redémarrer les apps du fichier YAML actif.
- **Reload Apps** : recharger toutes les applications AppDaemon.
- **Restart Home Assistant** : redémarrer Home Assistant ou l’hôte, après confirmation.
- **Toggle Production Mode** : activer ou désactiver le mode production.
- **Show Error Log** : afficher le journal d’erreurs.
- **Refresh Entity List** : recharger les entités Home Assistant.
- **Handle Errors** : afficher les erreurs et choisir de les consulter ou de les effacer.
- **Clear Error Diagnostics** : effacer les diagnostics d’erreur de VS Code.
## Barre d’état
La barre d’état propose des boutons pour recharger AppDaemon, redémarrer Home
Assistant, basculer le mode production, consulter les erreurs et redémarrer les
apps trouvées dans le fichier YAML actif.
Quand des erreurs sont détectées, `AD Errors` affiche leur nombre et permet de
consulter le journal ou d’effacer les diagnostics.
## Fonctionnalités d’aide au développement
Dans les fichiers YAML et Python, l’extension fournit notamment :
- la complétion des entités Home Assistant ;
- les informations au survol des entités ;
- la navigation vers les définitions de capteurs virtuels ;
- les symboles et la navigation vers les applications AppDaemon ;
- la remontée des erreurs présentes dans le journal AppDaemon.
## Dépannage rapide
- **Aucune entité proposée** : vérifier `haUrl`, le jeton et l’accès réseau, puis lancer `AppDaemon: Refresh Entity List`.
- **Les commandes AppDaemon échouent** : vérifier que l’API HTTP est active et que `appdaemon.adUrl` correspond à la section `http` de `appdaemon.yaml`.
- **Les erreurs ne remontent pas** : vérifier `appdaemon.errorLogPath`, relatif à la racine ouverte dans VS Code.
- **Un raccourci ne fonctionne pas** : rechercher la commande dans les raccourcis VS Code et vérifier les conflits.
The log explorer preview is available at `docs/log-explorer-preview.html` in the
workspace; it uses sample data and is not a live log server.
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 19 11 5l6 14M7.2 13.8h7.6"/>
<circle cx="18.5" cy="7" r="2"/>
<path d="M15.8 8.7 14 10M4 19h15"/>
<circle cx="4" cy="19" r="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 342 B

+27
View File
@@ -0,0 +1,27 @@
:root{color-scheme:dark light;--bg:var(--vscode-editor-background,#111820);--fg:var(--vscode-editor-foreground,#dae3ed);--panel:var(--vscode-sideBar-background,#18212c);--border:var(--vscode-panel-border,#334252);--muted:var(--vscode-descriptionForeground,#a0afbf);--accent:var(--vscode-focusBorder,#6caef0)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:13px var(--vscode-font-family,system-ui,sans-serif)}header{padding:22px 24px 16px;display:flex;justify-content:space-between;gap:20px;align-items:center}h1{font-size:21px;margin:0}h1 span{font-weight:400;color:var(--muted);font-size:16px;margin-left:10px}p{color:var(--muted);line-height:1.6}header p{margin:7px 0 0}.actions{display:flex;gap:8px}button,input,select{font:inherit;color:var(--fg);background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px 10px}button{cursor:pointer}button:hover{border-color:var(--accent)}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}nav{display:flex;align-items:center;gap:10px;padding:0 24px 18px;flex-wrap:wrap}nav input[type=search]{flex:1;min-width:220px}nav label{display:flex;align-items:center;gap:6px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(350px,1fr) minmax(380px,1fr);border-top:1px solid var(--border);height:calc(100vh - 150px)}section{overflow:auto;padding:18px 20px}.apps{border-right:1px solid var(--border)}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;gap:10px}h2{font-size:14px;margin:0}.section-title>span{color:var(--muted)}details{border:1px solid var(--border);border-radius:8px;margin-bottom:10px;background:var(--panel)}summary{cursor:pointer;padding:12px;display:flex;align-items:center;gap:10px}summary:before{content:'▸';color:var(--muted)}details[open]>summary:before{content:'▾'}summary .name{flex:1;overflow-wrap:anywhere}.badge{border-radius:5px;padding:3px 6px;font-size:11px;font-weight:600;white-space:nowrap}.INFO,.DEBUG{color:#91c5fa;background:#284561}.WARNING{color:#f3cd80;background:#594925}.ERROR,.CRITICAL{color:#ffacac;background:#5b3039}.rows{border-top:1px solid var(--border)}.row{display:block;width:100%;text-align:left;border:0;border-radius:0;background:transparent;padding:11px 13px;border-bottom:1px solid var(--border)}.row:last-child{border-bottom:0}.row.selected{background:var(--vscode-list-activeSelectionBackground,#263f57);box-shadow:inset 3px 0 var(--accent)}.row-meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;margin-bottom:5px}.row-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.5}.link-hint{margin-left:auto;color:var(--accent)}.empty{padding:40px 15px;color:var(--muted);line-height:1.6;text-align:center}.selected-message{font-size:15px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}.metadata{color:var(--muted);font-size:12px;margin:8px 0 15px}.legend{color:var(--muted);font-size:12px;margin:15px 0}.graph{overflow:auto;position:relative;margin:20px 0;min-height:120px}.graph svg{position:absolute;left:0;top:0;pointer-events:none}.graph-stage{position:relative}.node{position:absolute;width:320px;height:115px;overflow:auto;padding:12px;text-align:left;background:var(--panel);font-size:12px;line-height:1.5}.node strong{display:block;color:var(--accent);margin-bottom:5px}.node .text{white-space:pre-wrap;overflow-wrap:anywhere}.node.target{border-color:var(--accent)}.node.missing{border-style:dashed;color:var(--muted)}.tree ul{padding-left:23px;list-style:none;border-left:1px solid var(--border)}.tree li{margin:12px 0}.tree button{text-align:left;line-height:1.5}.tree .probable{color:#f3cd80;font-size:11px}.values{width:100%;border-collapse:collapse;font-size:12px}.values td{padding:8px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}.values td:first-child{color:var(--muted)}#warnings{color:#f3cd80;padding:0 24px}#warnings:empty{display:none}.more{padding:12px;color:var(--muted)}@media(max-width:850px){main{grid-template-columns:1fr;height:auto}.apps{border-right:0;border-bottom:1px solid var(--border)}section{max-height:65vh}header{align-items:flex-start;padding:18px}nav{padding:0 18px 15px}summary{gap:6px}.badge{font-size:10px}}
.app-label{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}.last-log{color:var(--muted);font-size:11px;font-weight:400;overflow-wrap:anywhere}.row{cursor:pointer;user-select:text;-webkit-user-select:text}.row:hover{background:var(--vscode-list-hoverBackground,#203040)}.selected-message,.row-message,.node,.tree,.values{user-select:text;-webkit-user-select:text}.section-title{flex-wrap:wrap}.section-title select{font-size:12px}.actions{flex-wrap:wrap}
@keyframes new-log-pulse{0%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}25%{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--accent) 85%,transparent);background-color:color-mix(in srgb,var(--accent) 12%,var(--panel))}100%{box-shadow:inset 0 0 0 0 color-mix(in srgb,var(--accent) 0%,transparent)}}.new-log-app>summary{animation:new-log-pulse 1.8s ease-out}.recent-log{animation:new-log-pulse 1.8s ease-out}@media(prefers-reduced-motion:reduce){.new-log-app>summary,.recent-log{animation:none;outline:2px solid var(--accent);outline-offset:-2px}}
.log-message.level-WARNING{color:var(--vscode-editorWarning-foreground,#f3cd80)}.log-message.level-ERROR{color:var(--vscode-editorError-foreground,#ffacac)}
.block-title{font-weight:600;margin-bottom:6px;color:var(--fg);overflow-wrap:anywhere}
#error-count,#warning-count{font-weight:700;font-size:14px}#error-count.has-errors{color:var(--vscode-editorError-foreground,#ffacac);border-color:currentColor;background:var(--vscode-inputValidation-errorBackground,#5b3039)}#warning-count.has-warnings{color:var(--vscode-editorWarning-foreground,#f3cd80);border-color:currentColor;background:var(--vscode-inputValidation-warningBackground,#594925)}
.last-log{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
summary{display:grid;grid-template-columns:auto minmax(0,1fr) repeat(3,max-content);align-items:center;column-gap:10px;row-gap:5px}
summary .last-log{grid-column:1/-1;min-width:0}
.rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none}
.app-label{flex-direction:row;align-items:center;gap:6px}.app-label .name{min-width:0}.app-link{color:var(--accent);font-size:13px;line-height:1;text-decoration:none;white-space:nowrap}.app-link:hover{text-decoration:underline}.app-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* Keep severity counts distinct from the currently selected filter. */
#error-count::before,#warning-count::before{content:"";display:inline-block;width:1.2em}
#error-count[aria-pressed="true"],#warning-count[aria-pressed="true"]{
color:var(--vscode-button-foreground,#fff);
background:var(--vscode-button-background,#0e639c);
border-color:var(--vscode-contrastActiveBorder,var(--accent));
box-shadow:inset 0 2px 4px rgba(0,0,0,.35);
}
#error-count[aria-pressed="true"]::before,#warning-count[aria-pressed="true"]::before{content:"✓"}
#error-count[aria-pressed="true"]:hover,#warning-count[aria-pressed="true"]:hover{background:var(--vscode-button-hoverBackground,#1177bb)}
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
<body>
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" aria-pressed="false" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" aria-pressed="false" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</button></div></header>
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
<div id="warnings" role="status"></div>
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
<script nonce="{{NONCE}}" src="{{JS}}"></script></body></html>
+428
View File
@@ -0,0 +1,428 @@
/* Shared UI: VS Code transport or a standalone review fixture. No log HTML is executed. */
(() => {
const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : {
postMessage() {}, getState() {}, setState() {}
};
const saved = vscode.getState() || {};
let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected;
const expanded = new Set(saved.expanded || []);
const visibleEventGroups = new Map();
const logScrollPositions = new Map();
const recentEvents = new Set();
const recentApps = new Set();
const appFlashTokens = new Map();
let receivedInitialData = false;
let pointerSelecting = false;
let countView;
let activeCountLevel;
const $ = id => document.getElementById(id);
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
function save() { vscode.setState({ selected, mode, origin, expanded: [...expanded], sort: $('sort').value }); }
function choose(id, fromList = true) { if (fromList) origin = id; selected = id; save(); renderApps(); renderDetail(); }
function filtered() {
const q = $('search').value.toLowerCase(), level = $('level').value, minutes = Number($('period').value);
const after = minutes ? Date.now() - minutes * 60000 : -Infinity;
return entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (!minutes || e.time >= after)
&& (level === 'all' || normalLevel(e) === level)
&& `${e.app} ${e.entity || ''} ${e.message}`.toLowerCase().includes(q));
}
const cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[^\]]+\]/g, '').replace(/ \[group=[^\]]+:[a-f0-9]{12}\]/gi, '');
const formatTime = (entry, includeYear = false) => {
if (!Number.isFinite(entry.time)) return entry.timestamp;
try {
return new Intl.DateTimeFormat(undefined, { timeZone: displayZone, day: '2-digit', month: 'short', ...(includeYear ? { year: 'numeric' } : {}), hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZoneName: 'shortOffset' }).format(new Date(entry.time));
} catch { return entry.timestamp; }
};
function timeSince(time) {
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`;
const years = Math.floor(days / 365);
return `${years} year${years === 1 ? '' : 's'} ago`;
}
function shortTimeSince(time) {
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
if (seconds < 60) return `${seconds} sec ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hr ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} d ago`;
const months = Math.floor(days / 30);
if (days < 365) return `${months} mo ago`;
return `${Math.floor(days / 365)} yr ago`;
}
function logMessages(items, cls) {
const container = node('div', undefined, cls);
items.forEach((entry, index) => {
if (index) container.append(document.createTextNode('\n'));
container.append(node('span', cleanMessage(entry), `log-message level-${normalLevel(entry)}`));
});
return container;
}
function blockTitle(items) {
const first = items[0];
const kind = items.find(entry => entry.group_kind)?.group_kind;
const name = kind || (items.length > 1 || first.trace_id ? 'Event' : 'Log');
const label = items.map(entry => entry.group_label).find(Boolean) || first.entity || first.app;
return node('div', kind ? name : `${name} · ${label}`, 'block-title');
}
function row(e) {
const item = node('div', undefined, `row${selected === e.event_id ? ' selected' : ''}${recentEvents.has(e.event_id) ? ' recent-log' : ''}`);
item.dataset.eventId = e.event_id; item.dataset.eventIds = e.event_id;
item.tabIndex = 0; item.setAttribute('role', 'button');
const meta = node('div', undefined, 'row-meta');
const relative = node('span', timeSince(e.time), 'relative-time');
relative.dataset.time = String(e.time);
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
meta.append(node('span', e.timestamp, 'exact-time'), relative, node('span', e.level, `badge ${normalLevel(e)}`));
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(e.event_id); } };
return item;
}
function groupedByEvent(items, groupKey) {
const groups = [];
items.forEach(e => {
const key = groupKey || (e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined);
const previous = groups[groups.length - 1];
if (key && previous?.key === key) previous.entries.push(e);
else groups.push({ key, entries: [e] });
});
return groups;
}
function appendEventRows(parent, items, groupKey) {
groupedByEvent(items, groupKey).forEach(unit => {
if (unit.entries.length === 1) { parent.append(row(unit.entries[0])); return; }
const representative = [...unit.entries].reverse().find(e => e.cause_ids?.length) || unit.entries[unit.entries.length - 1];
const ids = unit.entries.map(e => e.event_id);
visibleEventGroups.set(representative.event_id, unit.entries);
const recent = ids.some(id => recentEvents.has(id));
const item = node('div', undefined, `row event-log-group${ids.includes(selected) ? ' selected' : ''}${recent ? ' recent-log' : ''}`);
item.dataset.eventId = representative.event_id; item.dataset.eventIds = ids.join(',');
item.tabIndex = 0; item.setAttribute('role', 'button');
const meta = node('div', undefined, 'row-meta');
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
relative.dataset.time = String(unit.entries[0].time);
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
meta.append(node('span', unit.entries[0].timestamp, 'exact-time'), relative);
for (const level of ['INFO', 'WARNING', 'ERROR']) {
const count = unit.entries.filter(e => normalLevel(e) === level).length;
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
}
if (unit.entries.some(e => e.cause_ids.length)) meta.append(node('span', 'View causes ↗', 'link-hint'));
item.append(blockTitle(unit.entries, groupKey), meta, logMessages(unit.entries, 'row-message'));
item.onclick = () => { if (!window.getSelection()?.toString()) choose(representative.event_id); };
item.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(representative.event_id); } };
parent.append(item);
});
}
function renderApps() {
const appList = $('apps').parentElement;
const appListTop = appList.scrollTop;
document.querySelectorAll('#apps details[data-app]').forEach(detail => {
const rows = detail.querySelector('.rows');
if (!rows) return;
logScrollPositions.set(detail.dataset.app, {
top: rows.scrollTop,
follow: rows.scrollHeight - rows.clientHeight - rows.scrollTop <= 24
});
});
visibleEventGroups.clear();
const container = $('apps'); container.replaceChildren();
const groups = new Map();
filtered().forEach(e => { if (!groups.has(e.app)) groups.set(e.app, []); groups.get(e.app).push(e); });
$('count').textContent = `${groups.size} apps`;
if (!groups.size) container.append(node('div', 'No logs match these filters. Try a wider time range or clear the search.', 'empty'));
const lastLogs = new Map();
entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).forEach(e => {
if (!lastLogs.has(e.app) || e.time >= lastLogs.get(e.app).time) lastLogs.set(e.app, e);
});
const timeOf = (app, logs) => (lastLogs.get(app) || logs[logs.length - 1]).time;
$('error-count').setAttribute('aria-pressed', String($('level').value === 'ERROR'));
$('warning-count').setAttribute('aria-pressed', String($('level').value === 'WARNING'));
[...groups].sort(([a, al], [b, bl]) => {
const order = $('sort').value;
if (order === 'name') return a.localeCompare(b);
return (order === 'oldest' ? 1 : -1) * (timeOf(a, al) - timeOf(b, bl)) || a.localeCompare(b);
}).forEach(([app, logs]) => {
const detail = node('details'); detail.open = expanded.has(app); detail.dataset.app = app;
if (recentApps.has(app)) detail.classList.add('new-log-app');
const summary = node('summary'), label = node('div', undefined, 'app-label');
const last = lastLogs.get(app);
let preview;
label.append(node('span', app, 'name'));
const appLink = node('a', '↗', 'app-link');
appLink.href = '#';
appLink.title = `Open ${app} definition`;
appLink.setAttribute('aria-label', `Open ${app} definition`);
appLink.onclick = event => {
event.preventDefault(); event.stopPropagation();
vscode.postMessage({ type: 'app', app });
};
label.append(appLink);
if (last) {
const line = node('span', undefined, `last-log log-message level-${normalLevel(last)}`);
const age = node('span', shortTimeSince(last.time), 'last-age');
age.dataset.time = String(last.time);
age.title = `${formatTime(last, true)} · Exact: ${last.timestamp}`;
const message = cleanMessage(last);
line.title = message;
line.append(age, document.createTextNode(' : '), node('span', message.split(/\r?\n/, 1)[0].trim() || '—', 'last-message'));
preview = line;
} else preview = node('span', 'No log in loaded history', 'last-log');
summary.append(label);
['INFO', 'WARNING', 'ERROR'].forEach(level => summary.append(node('span', `${level} ${logs.filter(e => normalLevel(e) === level).length}`, `badge ${level}`)));
summary.append(preview);
detail.append(summary);
function fill() {
detail.querySelector('.rows')?.remove();
if (!detail.open) return;
const rows = node('div', undefined, 'rows');
const units = [], groupUnits = new Map();
logs.forEach(e => {
if (e.group_id) {
const key = `group:${e.group_id}`;
if (!groupUnits.has(key)) { const unit = { key, entries: [] }; groupUnits.set(key, unit); units.push(unit); }
groupUnits.get(key).entries.push(e);
} else {
const key = e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined;
const previous = units[units.length - 1];
if (key && previous && !previous.id && previous.key === key) previous.entries.push(e);
else units.push({ key, entries: [e] });
}
});
const startTime = unit => unit.entries[0].time;
units.sort((a, b) => startTime(a) - startTime(b));
if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more'));
units.slice(-150).forEach(unit => {
appendEventRows(rows, unit.entries, unit.key);
});
detail.append(rows);
const position = logScrollPositions.get(app);
rows.scrollTop = !position || position.follow ? rows.scrollHeight : position.top;
}
detail.addEventListener('toggle', event => {
if (event.target !== detail) return;
if (detail.open) expanded.add(app);
else { expanded.delete(app); logScrollPositions.delete(app); }
save(); fill();
});
container.append(detail); fill();
});
appList.scrollTop = appListTop;
}
function renderDetail() {
$('graph-mode').setAttribute('aria-pressed', String(mode === 'graph'));
$('tree-mode').setAttribute('aria-pressed', String(mode === 'tree'));
const e = entries.find(e => e.event_id === selected), container = $('detail');
$('graph-mode').parentElement.hidden = !e || !e.cause_ids.length;
if (!e) { container.replaceChildren(node('div', selected ? 'This entry is no longer in the loaded history.' : 'Select a log to view its details.', 'empty')); return; }
const selectedGroup = visibleEventGroups.get(e.event_id) || [e];
container.replaceChildren(logMessages(selectedGroup, 'selected-message'), node('div', `${e.app} · ${e.timestamp} · ${selectedGroup.length} log lines`, 'metadata'));
const raw = node('button', 'Open raw log'); raw.onclick = () => vscode.postMessage({ type: 'open', id: e.event_id }); container.append(raw);
const sourceEntries = selectedGroup.filter(entry => entry.source);
const sourceLocations = new Set();
sourceEntries.forEach(entry => {
const location = `${entry.source.path}:${entry.source.line}`;
if (sourceLocations.has(location)) return;
sourceLocations.add(location);
const source = node('button', 'Open source'); source.title = location;
source.onclick = () => vscode.postMessage({ type: 'source', id: entry.event_id }); container.append(source);
});
if (origin && origin !== selected) { const back = node('button', 'Back to selected log'); back.onclick = () => choose(origin); container.append(back); }
const byId = new Map(entries.map(e => [e.event_id, e]));
const mirroredEvaluations = new Map();
entries.filter(candidate => candidate.kind === 'log').forEach(log => {
log.cause_ids.forEach(id => {
const candidate = byId.get(id);
if (candidate?.kind === 'evaluation' && candidate.app === log.app && candidate.trace_id && candidate.trace_id === log.trace_id && cleanMessage(candidate) === cleanMessage(log)) mirroredEvaluations.set(id, log);
});
});
const causesFor = entry => entry.cause_ids.flatMap(id => {
const mirror = mirroredEvaluations.get(id);
return mirror?.event_id === entry.event_id ? (byId.get(id)?.cause_ids || []) : [id];
});
const probableCausesFor = entry => {
const causes = new Set(entry.probable_cause_ids || []);
entry.cause_ids.forEach(id => {
if (mirroredEvaluations.get(id)?.event_id === entry.event_id) (byId.get(id)?.probable_cause_ids || []).forEach(parent => causes.add(parent));
});
return causes;
};
const mirroredEvaluation = [...mirroredEvaluations].find(([, log]) => log.event_id === e.event_id)?.[0];
if (!causesFor(e).length) container.append(node('p', e.kind === 'log' ? 'The base AppDaemon log format does not record what caused this entry.' : 'Observed origin: no upstream cause was recorded.'));
const nodes = new Map(), edges = [];
function visit(id, depth, path) {
if (depth > 16 || nodes.size >= 80 || path.has(id)) return;
const entry = byId.get(id);
const existing = nodes.get(id);
if (existing && existing.depth >= depth) return;
nodes.set(id, { e: entry, depth, id });
if (!entry) return;
const next = new Set(path); next.add(id);
const probable = probableCausesFor(entry);
causesFor(entry).forEach(parent => {
if (!edges.some(edge => edge.from === parent && edge.to === id)) edges.push({ from: parent, to: id, probable: probable.has(parent) });
visit(parent, depth + 1, next);
});
}
visit(e.event_id, 0, new Set());
if (e.cause_ids.length) {
container.append(node('p', 'Solid: explicit link · Dashed: probable link · Missing causes may be outside the loaded history. Limited to 80 events and 16 levels.', 'legend'));
if (mode === 'graph') {
const graph = node('div', undefined, 'graph'), stage = node('div', undefined, 'graph-stage');
const nodeWidth = 320, nodeHeight = 115, laneGap = 350, levelGap = 155;
const maxDepth = Math.max(...[...nodes.values()].map(n => n.depth)), positions = new Map(), lanes = new Map();
nodes.forEach(n => { const level = maxDepth - n.depth; const lane = lanes.get(level) || 0; lanes.set(level, lane + 1); positions.set(n.id, { x: lane * laneGap + 10, y: level * levelGap + 10 }); });
const width = Math.max(nodeWidth + 20, ...[...positions.values()].map(p => p.x + nodeWidth + 10)), height = maxDepth * levelGap + nodeHeight + 20;
stage.style.width = `${width}px`; stage.style.height = `${height}px`;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', width); svg.setAttribute('height', height);
edges.forEach(edge => {
const a = positions.get(edge.from), b = positions.get(edge.to); if (!a || !b) return;
const path = document.createElementNS(svg.namespaceURI, 'path');
path.setAttribute('d', `M ${a.x + nodeWidth / 2} ${a.y + nodeHeight} C ${a.x + nodeWidth / 2} ${a.y + nodeHeight + 20},${b.x + nodeWidth / 2} ${b.y - 20},${b.x + nodeWidth / 2} ${b.y}`);
path.setAttribute('fill', 'none'); path.setAttribute('stroke', edge.probable ? '#d5ad68' : '#7199bc'); path.setAttribute('stroke-width', '2');
if (edge.probable) path.setAttribute('stroke-dasharray', '5 5'); svg.append(path);
const arrow = document.createElementNS(svg.namespaceURI, 'path'); arrow.setAttribute('d', `M ${b.x + nodeWidth / 2 - 5} ${b.y - 6} L ${b.x + nodeWidth / 2} ${b.y} L ${b.x + nodeWidth / 2 + 5} ${b.y - 6}`); arrow.setAttribute('fill', 'none'); arrow.setAttribute('stroke', '#7199bc'); svg.append(arrow);
});
stage.append(svg);
nodes.forEach(n => { const p = positions.get(n.id), b = node('button', undefined, `node${!n.e ? ' missing' : ''}${n.id === selected ? ' target' : ''}`); const message = n.e ? (visibleEventGroups.get(n.e.event_id) || [n.e]).map(cleanMessage).join('\n') : n.id; b.style.left = `${p.x}px`; b.style.top = `${p.y}px`; b.append(node('strong', n.e?.app || 'Missing cause'), node('div', message, 'text')); b.title = message; b.disabled = !n.e; b.onclick = () => choose(n.id, false); stage.append(b); });
graph.append(stage); container.append(graph);
} else {
const tree = node('div', undefined, 'tree');
let count = 0;
function branch(id, depth, path) {
const list = node('ul');
if (depth > 16 || count++ > 80 || path.has(id)) { list.append(node('li', 'Depth limit or cycle.')); return list; }
const n = byId.get(id), item = node('li'), button = node('button', n ? `${n.app} · ${(visibleEventGroups.get(n.event_id) || [n]).map(cleanMessage).join('\n')}` : 'Cause missing from loaded history'); button.disabled = !n; button.onclick = () => choose(id, false); item.append(button);
const next = new Set(path); next.add(id);
if (n) { const probable = probableCausesFor(n); causesFor(n).forEach(parent => { if (probable.has(parent)) item.append(node('div', 'Probable link ↓', 'probable')); item.append(branch(parent, depth + 1, next)); }); } list.append(item); return list;
}
tree.append(branch(e.event_id, 0, new Set())); container.append(tree);
}
}
const detailValues = e.values || (mirroredEvaluation ? byId.get(mirroredEvaluation)?.values : undefined);
if (detailValues && Object.keys(detailValues).length) {
container.append(node('h2', 'Values used during evaluation'));
const table = node('table', undefined, 'values'); Object.entries(detailValues).forEach(([key, value]) => { const row = node('tr'); row.append(node('td', key), node('td', JSON.stringify(value))); table.append(row); }); container.append(table);
}
}
function highlightNewLogs(incoming) {
const byApp = new Map();
incoming.forEach(e => {
recentEvents.add(e.event_id);
if (!byApp.has(e.app)) byApp.set(e.app, []);
byApp.get(e.app).push(e.event_id);
});
byApp.forEach((ids, app) => {
recentApps.add(app);
const token = {};
appFlashTokens.set(app, token);
setTimeout(() => {
ids.forEach(id => {
recentEvents.delete(id);
document.querySelectorAll('.row.recent-log').forEach(item => { if (item.dataset.eventId === id) item.classList.remove('recent-log'); });
});
document.querySelectorAll('.event-log-group.recent-log').forEach(item => {
if (!item.dataset.eventIds.split(',').some(id => recentEvents.has(id))) item.classList.remove('recent-log');
});
if (appFlashTokens.get(app) === token) {
recentApps.delete(app); appFlashTokens.delete(app);
document.querySelectorAll('.new-log-app').forEach(item => { if (item.dataset.app === app) item.classList.remove('new-log-app'); });
}
}, 1800);
});
}
function apply(message) {
const nextEntries = message.entries;
if (receivedInitialData) {
const known = new Set(entries.map(e => e.event_id));
highlightNewLogs(nextEntries.filter(e => e.kind === 'log' && !known.has(e.event_id)));
}
receivedInitialData = true;
appliedMessage = message; displayZone = message.timezone || displayZone; entries = nextEntries;
const logCount = entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).length;
const traceCount = entries.length - logCount;
const traceLabel = traceCount === 1 ? 'trace event' : 'trace events';
$('status').textContent = traceCount
? `${logCount} log lines + ${traceCount} ${traceLabel} · Live updates · Up to ${message.limit || 10000} records`
: `${logCount} log lines · Live updates · Up to ${message.limit || 10000} records`;
$('status').title = traceCount
? 'Showing AppDaemon logs and additional records from configured log files.'
: 'Reading the configured AppDaemon text logs. Causal links require additional metadata that the base log format does not contain.';
const errorCount = entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && normalLevel(e) === 'ERROR').length;
$('error-count').textContent = `${errorCount} error${errorCount === 1 ? '' : 's'}`;
$('error-count').classList.toggle('has-errors', errorCount > 0);
$('error-count').title = 'Errors and critical logs across all apps in loaded history. Click to show them.';
const warningCount = entries.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && normalLevel(e) === 'WARNING').length;
$('warning-count').textContent = `${warningCount} warning${warningCount === 1 ? '' : 's'}`;
$('warning-count').classList.toggle('has-warnings', warningCount > 0);
$('warning-count').title = 'Warnings across all apps in loaded history. Click to show them.';
$('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail();
}
function refreshLastLogAges() {
document.querySelectorAll('.last-age[data-time]').forEach(label => { label.textContent = shortTimeSince(label.dataset.time); });
document.querySelectorAll('.relative-time[data-time]').forEach(label => { label.textContent = timeSince(label.dataset.time); });
}
setInterval(refreshLastLogAges, 1000);
function selectionActive() { return pointerSelecting || Boolean(window.getSelection()?.toString()); }
window.addEventListener('message', event => {
if (event.data?.type === 'clear-result') {
$('clear').disabled = false;
$('clear').textContent = 'Clear logs';
if (event.data.failures?.length) $('warnings').textContent = event.data.failures.join(' · ');
return;
}
if (event.data?.type !== 'entries') return;
latest = event.data;
if (!selectionActive()) apply(latest);
else $('status').textContent = 'Updates buffered while text is selected';
});
document.addEventListener('pointerdown', event => { pointerSelecting = Boolean(event.target.closest?.('.row, .selected-message, .node, .values')); });
document.addEventListener('pointerup', () => { pointerSelecting = false; });
document.addEventListener('selectionchange', () => { if (!selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => {
countView = undefined; activeCountLevel = undefined; selected = undefined; origin = undefined;
save(); renderApps(); renderDetail();
}));
function toggleCountView(level) {
const leaving = activeCountLevel === level;
if (leaving) {
$('search').value = countView?.search || '';
$('period').value = countView?.period || '0';
$('level').value = countView?.level || 'all';
if (countView) { expanded.clear(); countView.expanded.forEach(app => expanded.add(app)); }
countView = undefined; activeCountLevel = undefined;
} else {
if (!activeCountLevel) countView = { search: $('search').value, period: $('period').value, level: $('level').value, expanded: [...expanded] };
activeCountLevel = level;
$('search').value = ''; $('period').value = '0'; $('level').value = level;
entries.filter(e => normalLevel(e) === level).forEach(e => expanded.add(e.app));
}
selected = undefined; origin = undefined;
save(); renderApps(); renderDetail();
if (!leaving) {
const first = document.querySelector('#apps .row');
if (first) { first.click(); first.closest('.rows').scrollTop = 0; first.focus({ preventScroll: true }); }
}
}
$('error-count').onclick = () => toggleCountView('ERROR');
$('warning-count').onclick = () => toggleCountView('WARNING');
$('clear').onclick = () => { selected = undefined; origin = undefined; save(); $('clear').disabled = true; $('clear').textContent = 'Clearing…'; vscode.postMessage({ type: 'clear' }); };
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
$('sort').value = saved.sort || 'latest';
vscode.postMessage({ type: 'ready' });
if (window.LOG_REVIEW_DATA) { apply({ timezone: 'Europe/Berlin', entries: window.LOG_REVIEW_DATA, warnings: ['Interface preview: sample data, no AppDaemon connection.'], limit: 10000 }); expanded.add('automation'); selected = 'review-5'; renderApps(); renderDetail(); }
})();
+533 -2
View File
@@ -1,21 +1,151 @@
{ {
"name": "vscode-appdaemon", "name": "vscode-appdaemon",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vscode-appdaemon", "name": "vscode-appdaemon",
"version": "0.1.0", "version": "0.2.0",
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/vscode": "^1.85.0", "@types/vscode": "^1.85.0",
"jsdom": "^26.1.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"
}, },
"engines": { "engines": {
"vscode": "^1.85.0" "vscode": "^1.85.0"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.39", "version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
@@ -33,6 +163,307 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.28",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
"integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -53,6 +484,106 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
} }
} }
} }
+40 -9
View File
@@ -1,8 +1,8 @@
{ {
"name": "vscode-appdaemon", "name": "vscode-appdaemon",
"displayName": "AppDaemon for Home Assistant", "displayName": "AppDaemon for Home Assistant",
"description": "AppDaemon development tools: status bar controls, entity autocompletion, hover tooltips, error viewer", "description": "AppDaemon development tools: log explorer, status bar controls, entity autocompletion, and error viewer",
"version": "0.1.0", "version": "0.2.0",
"publisher": "ad-toolbox", "publisher": "ad-toolbox",
"engines": { "engines": {
"vscode": "^1.85.0" "vscode": "^1.85.0"
@@ -47,6 +47,33 @@
"type": "number", "type": "number",
"default": 300, "default": 300,
"description": "Entity list refresh interval in seconds" "description": "Entity list refresh interval in seconds"
},
"appdaemon.logExplorerPaths": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"logs/appdaemon.log",
"logs/error.log",
"logs/virtualsensors.log"
],
"description": "Log files followed by the explorer, relative to the workspace."
},
"appdaemon.logTimezone": {
"type": "string",
"default": "Europe/Berlin",
"description": "Timezone used by AppDaemon text logs."
},
"appdaemon.logEncoding": {
"type": "string",
"enum": [
"auto",
"utf8",
"windows-1252"
],
"default": "auto",
"description": "Text log encoding. Auto preserves UTF-8 and decodes legacy Windows-1252 bytes."
} }
} }
}, },
@@ -86,13 +113,14 @@
"title": "AppDaemon: Refresh Entity List", "title": "AppDaemon: Refresh Entity List",
"icon": "$(sync)" "icon": "$(sync)"
}, },
{
"command": "appdaemon.handleErrors",
"title": "AppDaemon: Handle Errors"
},
{ {
"command": "appdaemon.clearErrors", "command": "appdaemon.clearErrors",
"title": "AppDaemon: Clear Error Diagnostics" "title": "AppDaemon: Clear Error Diagnostics"
},
{
"command": "appdaemon.exploreLogs",
"title": "AppDaemon: Explore Logs",
"icon": "$(list-tree)"
} }
], ],
"keybindings": [ "keybindings": [
@@ -111,16 +139,19 @@
"key": "ctrl+shift+v", "key": "ctrl+shift+v",
"mac": "cmd+shift+v" "mac": "cmd+shift+v"
} }
] ],
"menus": {}
}, },
"scripts": { "scripts": {
"vscode:prepublish": "npm run compile", "vscode:prepublish": "npm run compile",
"compile": "tsc -p ./", "compile": "tsc -p ./",
"watch": "tsc -watch -p ./" "watch": "tsc -watch -p ./",
"test": "npm run compile && node --test tests/*.test.js"
}, },
"devDependencies": { "devDependencies": {
"@types/vscode": "^1.85.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/vscode": "^1.85.0",
"jsdom": "^26.1.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"
} }
} }
-5
View File
@@ -14,8 +14,6 @@ function normalizeUrl(raw: string): string {
export class ADClient { export class ADClient {
private productionMode = false; private productionMode = false;
private _onProductionModeChanged = new vscode.EventEmitter<boolean>();
readonly onProductionModeChanged = this._onProductionModeChanged.event;
private log: vscode.OutputChannel; private log: vscode.OutputChannel;
constructor(log: vscode.OutputChannel) { constructor(log: vscode.OutputChannel) {
@@ -120,7 +118,6 @@ export class ADClient {
async setProductionMode(mode: boolean): Promise<void> { async setProductionMode(mode: boolean): Promise<void> {
await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode }); await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode });
this.productionMode = mode; this.productionMode = mode;
this._onProductionModeChanged.fire(mode);
} }
/** /**
@@ -135,7 +132,6 @@ export class ADClient {
if (key.includes('production_mode')) { if (key.includes('production_mode')) {
const val = (state[key] as { state?: unknown })?.state; const val = (state[key] as { state?: unknown })?.state;
this.productionMode = val === true || val === 'True' || val === 'true'; this.productionMode = val === true || val === 'True' || val === 'true';
this._onProductionModeChanged.fire(this.productionMode);
return this.productionMode; return this.productionMode;
} }
} }
@@ -155,6 +151,5 @@ export class ADClient {
} }
dispose() { dispose() {
this._onProductionModeChanged.dispose();
} }
} }
-14
View File
@@ -11,10 +11,6 @@ export class ErrorViewer {
private diagnosticCollection: vscode.DiagnosticCollection; private diagnosticCollection: vscode.DiagnosticCollection;
private watcher: fs.FSWatcher | undefined; private watcher: fs.FSWatcher | undefined;
private lastSize = 0; private lastSize = 0;
private errorCount = 0;
private _onErrorCountChanged = new vscode.EventEmitter<number>();
readonly onErrorCountChanged = this._onErrorCountChanged.event;
constructor() { constructor() {
this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors'); this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors');
@@ -30,12 +26,6 @@ export class ErrorViewer {
clearDiagnostics() { clearDiagnostics() {
this.diagnosticCollection.clear(); this.diagnosticCollection.clear();
this.errorCount = 0;
this._onErrorCountChanged.fire(0);
}
getErrorCount(): number {
return this.errorCount;
} }
// ── file watching ──────────────────────────────────────────────────────── // ── file watching ────────────────────────────────────────────────────────
@@ -115,12 +105,9 @@ export class ErrorViewer {
this.outputChannel.appendLine(trimmed); this.outputChannel.appendLine(trimmed);
if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) { if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) {
this.errorCount++;
this.parseDiagnostic(trimmed); this.parseDiagnostic(trimmed);
} }
} }
this._onErrorCountChanged.fire(this.errorCount);
} finally { } finally {
fs.closeSync(fd); fs.closeSync(fd);
} }
@@ -183,6 +170,5 @@ export class ErrorViewer {
} }
this.outputChannel.dispose(); this.outputChannel.dispose();
this.diagnosticCollection.dispose(); this.diagnosticCollection.dispose();
this._onErrorCountChanged.dispose();
} }
} }
+11 -20
View File
@@ -4,6 +4,7 @@ import { ADClient } from './adClient';
import { StatusBarManager } from './statusBar'; import { StatusBarManager } from './statusBar';
import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider'; import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider';
import { ErrorViewer } from './errorViewer'; import { ErrorViewer } from './errorViewer';
import { LogExplorer } from './logExplorer';
let haClient: HAClient; let haClient: HAClient;
let adClient: ADClient; let adClient: ADClient;
@@ -344,10 +345,10 @@ export async function activate(context: vscode.ExtensionContext) {
haClient = new HAClient(outputChannel); haClient = new HAClient(outputChannel);
adClient = new ADClient(outputChannel); adClient = new ADClient(outputChannel);
errorViewer = new ErrorViewer(); errorViewer = new ErrorViewer();
statusBar = new StatusBarManager(adClient); const logExplorer = new LogExplorer(context, count => statusBar.setLogErrorCount(count));
context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show()));
// Wire error count → status bar badge statusBar = new StatusBarManager();
errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count)); logExplorer.startPolling();
// ── Contextual per-file restart button ─────────────────────────────────── // ── Contextual per-file restart button ───────────────────────────────────
updateContextualButton(vscode.window.activeTextEditor); updateContextualButton(vscode.window.activeTextEditor);
@@ -443,10 +444,12 @@ export async function activate(context: vscode.ExtensionContext) {
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
}), }),
vscode.commands.registerCommand('appdaemon.goToApp', async () => { vscode.commands.registerCommand('appdaemon.goToApp', async (requestedName?: string) => {
const apps = await findAllApps(); const apps = (await findAllApps()).filter(app => !requestedName || app.appName === requestedName);
if (apps.length === 0) { if (apps.length === 0) {
vscode.window.showWarningMessage('AppDaemon: No app definitions found in workspace'); vscode.window.showWarningMessage(requestedName
? `AppDaemon: No definition found for ${requestedName}`
: 'AppDaemon: No app definitions found in workspace');
return; return;
} }
@@ -458,7 +461,7 @@ export async function activate(context: vscode.ExtensionContext) {
app app
})); }));
const picked = await vscode.window.showQuickPick(items, { const picked = apps.length === 1 && requestedName ? items[0] : await vscode.window.showQuickPick(items, {
placeHolder: 'Go to AppDaemon app…', placeHolder: 'Go to AppDaemon app…',
matchOnDescription: true, matchOnDescription: true,
matchOnDetail: false matchOnDetail: false
@@ -540,18 +543,6 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('appdaemon.showErrors', () => { vscode.commands.registerCommand('appdaemon.showErrors', () => {
errorViewer.show(); errorViewer.show();
}), }),
vscode.commands.registerCommand('appdaemon.handleErrors', async () => {
const pick = await vscode.window.showErrorMessage(
'AppDaemon errors detected',
'Show log',
'Clear'
);
if (pick === 'Show log') {
errorViewer.show();
} else if (pick === 'Clear') {
errorViewer.clearDiagnostics();
}
}),
vscode.commands.registerCommand('appdaemon.refreshEntities', async () => { vscode.commands.registerCommand('appdaemon.refreshEntities', async () => {
const entities = await haClient.fetchEntities(); const entities = await haClient.fetchEntities();
vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`); vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`);
+258
View File
@@ -0,0 +1,258 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
import { LogEntry, parseLine, decodeLogLine } from './logModel';
import { findLogSource, SourceDocument } from './logSource';
type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; errorBlock?: boolean; discard: boolean; generation: string };
export class LogExplorer implements vscode.Disposable {
private panel?: vscode.WebviewPanel;
private view?: vscode.WebviewView;
private activeWebview?: vscode.Webview;
private messageListener?: vscode.Disposable;
private timer?: ReturnType<typeof setInterval>;
private entries = new Map<string, LogEntry>();
private cursors = new Map<string, Cursor>();
private warnings = new Set<string>();
private busy = false;
private sourceDocuments?: Promise<SourceDocument[]>;
private sourceDocumentsAt = 0;
private publication = 0;
constructor(private context: vscode.ExtensionContext, private onErrorCountChanged?: (count: number) => void) {}
show() {
if (this.view) { this.view.show(); return; }
if (this.panel) { this.panel.reveal(); return; }
this.panel = vscode.window.createWebviewPanel('appdaemon.logs', 'AppDaemon · Logs', vscode.ViewColumn.One, {
enableScripts: true, retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
});
this.configureWebview(this.panel.webview);
this.panel.onDidDispose(() => {
this.panel = undefined;
if (!this.view) { this.activeWebview = undefined; }
});
this.startPolling();
}
resolveWebviewView(view: vscode.WebviewView) {
this.view = view;
view.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
};
this.configureWebview(view.webview);
const updateVisibility = () => { if (view.visible) { this.startPolling(); } };
view.onDidChangeVisibility(updateVisibility, undefined, this.context.subscriptions);
view.onDidDispose(() => {
if (this.view === view) { this.view = undefined; }
if (!this.panel) { this.activeWebview = undefined; }
}, undefined, this.context.subscriptions);
updateVisibility();
}
private configureWebview(webview: vscode.Webview) {
const nonce = randomBytes(16).toString('hex');
const media = (name: string) => webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'media', name));
const template = fs.readFileSync(path.join(this.context.extensionPath, 'media', 'logExplorer.html'), 'utf8');
webview.html = template.replaceAll('{{CSP}}', webview.cspSource).replaceAll('{{NONCE}}', nonce)
.replace('{{CSS}}', media('logExplorer.css').toString()).replace('{{JS}}', media('logExplorer.js').toString());
this.messageListener?.dispose();
this.messageListener = webview.onDidReceiveMessage(async message => {
if (message.type === 'ready') { this.publish(); }
if (message.type === 'app' && typeof message.app === 'string') {
await vscode.commands.executeCommand('appdaemon.goToApp', message.app);
}
if (message.type === 'open' && typeof message.id === 'string') {
const entry = this.entries.get(message.id);
if (!entry?.file) { return; }
try {
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(entry.file));
const editor = await vscode.window.showTextDocument(doc, { preview: true, viewColumn: vscode.ViewColumn.Beside });
const index = doc.getText().indexOf(entry.raw || entry.message);
if (index >= 0) {
const pos = doc.positionAt(index);
editor.selection = new vscode.Selection(pos, pos);
editor.revealRange(new vscode.Range(pos, pos));
} else { vscode.window.showInformationMessage('This entry has moved after log rotation.'); }
} catch (error) { vscode.window.showWarningMessage(`Unable to read log: ${String(error)}`); }
}
if (message.type === 'source' && typeof message.id === 'string') {
const source = this.entries.get(message.id)?.source;
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!source || !root || typeof source.path !== 'string' || !Number.isInteger(source.line)) { return; }
const target = source.path.startsWith('/conf/') ? path.resolve(root, source.path.slice(6)) : path.resolve(root, source.path);
const relative = path.relative(root, target);
if (relative.startsWith('..') || path.isAbsolute(relative)) { vscode.window.showInformationMessage('The source is outside the workspace.'); return; }
try {
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(target));
const editor = await vscode.window.showTextDocument(doc, { preview: true, viewColumn: vscode.ViewColumn.Beside });
const position = new vscode.Position(Math.max(0, Math.min(doc.lineCount - 1, source.line - 1)), 0);
editor.selection = new vscode.Selection(position, position);
editor.revealRange(new vscode.Range(position, position));
} catch (error) { vscode.window.showWarningMessage(`Source unavailable: ${String(error)}`); }
}
if (message.type === 'clear') { this.clearLogs(); }
}, undefined, this.context.subscriptions);
this.activeWebview = webview;
}
startPolling() {
if (this.timer) { return; }
this.poll();
this.timer = setInterval(() => this.poll(), 1000);
}
private stopPolling() {
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
}
private configuredFiles(): string[] {
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!folder) { return []; }
const config = vscode.workspace.getConfiguration('appdaemon');
const configured = config.get<string[]>('logExplorerPaths', ['logs/appdaemon.log', 'logs/error.log', 'logs/virtualsensors.log']);
return [...new Set(configured.map(p => path.resolve(folder, p)))];
}
private clearLogs() {
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const cleared = new Set<string>();
const failures: string[] = [];
if (folder) {
const realRoot = fs.realpathSync(folder);
for (const file of this.configuredFiles()) {
try {
const relative = path.relative(realRoot, fs.realpathSync(file));
if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('outside the workspace'); }
if (!fs.statSync(file).isFile()) { throw new Error('not a regular file'); }
fs.truncateSync(file, 0);
this.cursors.delete(file);
cleared.add(file);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { failures.push(`${path.basename(file)}: ${String(error)}`); }
}
}
}
for (const [id, entry] of this.entries) { if (entry.file && cleared.has(entry.file)) { this.entries.delete(id); } }
failures.forEach(failure => this.warnings.add(`Unable to clear ${failure}`));
this.publish();
this.activeWebview?.postMessage({ type: 'clear-result', cleared: cleared.size, failures });
}
private poll() {
if (this.busy) { return; }
this.busy = true;
let changed = false;
try {
const encoding = vscode.workspace.getConfiguration('appdaemon').get<string>('logEncoding', 'auto');
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
for (const file of this.configuredFiles()) {
if (!fs.existsSync(file)) { continue; }
try {
const stat = fs.statSync(file);
const identity = `${stat.dev}:${stat.ino}`;
let cursor = this.cursors.get(file);
if (!cursor || cursor.identity !== identity || stat.size < cursor.offset) {
const offset = Math.max(0, stat.size - 2 * 1024 * 1024);
cursor = { offset, identity, pending: Buffer.alloc(0), discard: offset > 0, generation: randomBytes(8).toString('hex') };
this.cursors.set(file, cursor);
}
if (stat.size <= cursor.offset) { continue; }
const length = Math.min(512 * 1024, stat.size - cursor.offset);
const buffer = Buffer.alloc(length);
const fd = fs.openSync(file, 'r');
let bytes: number;
try { bytes = fs.readSync(fd, buffer, 0, length, cursor.offset); } finally { fs.closeSync(fd); }
const start = cursor.offset - cursor.pending.length;
cursor.offset += bytes;
const content = Buffer.concat([cursor.pending, buffer.subarray(0, bytes)]);
let from = 0;
for (let end = content.indexOf(10); end >= 0; end = content.indexOf(10, from)) {
const line = decodeLogLine(content.subarray(from, end), file.includes('jsonl') ? 'utf8' : encoding).replace(/\r$/, '');
const position = start + from;
from = end + 1;
if (cursor.discard) { cursor.discard = false; continue; }
const entry = parseLine(line, file, position, zone);
if (entry) {
const isErrorLog = path.basename(file) === 'error.log';
const failedApp = isErrorLog && entry.app === 'Error' && entry.level === 'ERROR'
? entry.message.match(/^=+\s+Failed to start '([^']+)'/)
: undefined;
if (failedApp) {
entry.app = failedApp[1];
entry.message = `Failed to start '${failedApp[1]}'`;
cursor.errorBlock = true;
} else if (isErrorLog && cursor.errorBlock && cursor.last && entry.app === 'Error' && entry.level === 'ERROR') {
if ((cursor.last.raw?.length || 0) < 64 * 1024) {
cursor.last.message += '\n' + entry.message;
cursor.last.raw += '\n' + line;
}
if (/^=+\s*$/.test(entry.message)) { cursor.errorBlock = false; }
changed = true;
continue;
} else if (cursor.errorBlock) {
cursor.errorBlock = false;
}
if (!entry.trace_id && !line.startsWith('{')) { entry.event_id += ':' + cursor.generation; }
const existing = this.entries.get(entry.event_id);
if (!existing || entry.raw?.startsWith('{')) { this.entries.set(entry.event_id, entry); }
cursor.last = this.entries.get(entry.event_id);
changed = true;
} else if (line.trim() && cursor.last && !file.includes('jsonl')) {
// Traceback lines belong to the previous timestamped entry.
if ((cursor.last.raw?.length || 0) < 64 * 1024) {
cursor.last.message += '\n' + line;
cursor.last.raw += '\n' + line;
changed = true;
}
} else if (line.trim() && file.includes('jsonl')) { this.warnings.add(`Invalid event ignored: ${path.basename(file)}`); changed = true; }
}
cursor.pending = content.subarray(from);
if (cursor.pending.length > 256 * 1024) { cursor.pending = Buffer.alloc(0); cursor.discard = true; this.warnings.add('An oversized log line was skipped.'); }
} catch (error) { this.warnings.add(`${path.basename(file)} : ${String(error)}`); changed = true; }
}
while (this.entries.size > 10000) { this.entries.delete(this.entries.keys().next().value!); }
if (changed) { this.publish(); }
} finally { this.busy = false; }
}
private async resolveSources(entries: LogEntry[], publication: number) {
if (!entries.some(entry => !entry.source && entry.message.includes('Error parsing string')) || !vscode.workspace.findFiles) { return; }
if (!this.sourceDocuments || Date.now() - this.sourceDocumentsAt > 5000) {
this.sourceDocumentsAt = Date.now();
this.sourceDocuments = (async () => {
const files = await vscode.workspace.findFiles('**/*.{yaml,yml}', '**/{node_modules,.git}/**');
const documents: SourceDocument[] = [];
for (const uri of files) {
try {
const doc = await vscode.workspace.openTextDocument(uri);
documents.push({ path: uri.fsPath, text: doc.getText() });
} catch { /* A configuration file may have been removed. */ }
}
return documents;
})();
}
try {
const documents = await this.sourceDocuments;
if (publication !== this.publication) { return; }
let changed = false;
for (const entry of entries) {
if (entry.source) { continue; }
const source = findLogSource(entry, documents);
if (source) { entry.source = source; changed = true; }
}
if (changed) {
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
this.activeWebview?.postMessage({ type: 'entries', entries, warnings: [...this.warnings], limit: 10000, timezone: zone });
}
} catch { this.sourceDocuments = undefined; }
}
private publish() {
const publication = ++this.publication;
const entries = [...this.entries.values()].sort((a, b) => a.time - b.time);
// Legacy errors duplicated across main/error logs are merged only on exact match.
const seen = new Set<string>();
const unique = entries.filter(e => {
const key = e.trace_id ? e.event_id : `${e.timestamp}|${e.app}|${e.level}|${e.message}`;
if (seen.has(key)) { return false; } seen.add(key); return true;
});
this.onErrorCountChanged?.(unique.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (e.level === 'ERROR' || e.level === 'CRITICAL')).length);
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
this.activeWebview?.postMessage({ type: 'entries', entries: unique, warnings: [...this.warnings], limit: 10000, timezone: zone });
void this.resolveSources(unique, publication);
}
dispose() { this.stopPolling(); this.messageListener?.dispose(); this.panel?.dispose(); }
}
+78
View File
@@ -0,0 +1,78 @@
export interface LogEntry {
event_id: string; timestamp: string; app: string; level: string; message: string;
kind: string; cause_ids: string[]; probable_cause_ids?: string[];
entity?: string; values?: Record<string, unknown>;
source?: { path: string; line: number };
status?: string;
group_id?: string; group_kind?: string;
file?: string; raw?: string; time: number; trace_id?: string;
[key: string]: unknown;
}
const timeCache = new Map<string, number>();
const formatters = new Map<string, Intl.DateTimeFormat>();
export function rawTime(date: string, zone: string): number {
const cacheKey = zone + ':' + date;
const cached = timeCache.get(cacheKey);
if (cached !== undefined) { return cached; }
const m = date.match(/^(?:(\d{4})-(\d\d)-(\d\d)|(\d\d)-(\d\d)-(\d\d)) (\d\d):(\d\d):(\d\d)/);
if (!m) { return NaN; }
const year = Number(m[1] || (2000 + Number(m[6])));
const month = Number(m[2] || m[5]);
const day = Number(m[3] || m[4]);
const hour = Number(m[7]);
const minute = Number(m[8]);
const second = Number(m[9]);
const utc = Date.UTC(year, month - 1, day, hour, minute, second);
try {
let formatter = formatters.get(zone);
if (!formatter) { formatter = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }); formatters.set(zone, formatter); }
const parts = formatter.formatToParts(utc);
const p = Object.fromEntries(parts.map(part => [part.type, part.value]));
const time = utc - (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - utc);
timeCache.set(cacheKey, time);
if (timeCache.size > 4096) { timeCache.delete(timeCache.keys().next().value!); }
return time;
} catch { return utc; }
}
export function parseLine(line: string, file: string, offset: number, zone = 'Europe/Berlin'): LogEntry | undefined {
if (line.startsWith('{')) {
try {
const e = JSON.parse(line);
if (e.version !== 1 || typeof e.event_id !== 'string' || typeof e.message !== 'string' || typeof e.app !== 'string' || typeof e.timestamp !== 'string' || !Array.isArray(e.cause_ids) || !e.cause_ids.every((id: unknown) => typeof id === 'string')) { return undefined; }
return { ...e, level: String(e.level || 'INFO'), kind: String(e.kind || 'log'), file, raw: line, time: Date.parse(e.timestamp) };
} catch { return undefined; }
}
const m = line.match(/^((?:\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+([^:]+):\s?(.*)$/);
if (!m) { return undefined; }
const group = m[4].match(/\[group=([^\]\r\n]+):([a-f0-9]{12})\]/i);
const id = m[4].match(/\[event=([\w-]+) trace=([\w-]+)\]/);
const groupKind = group?.[1]?.trim();
return { event_id: id?.[1] || `${file}:${offset}`, trace_id: id?.[2], group_id: group?.[2], group_kind: groupKind, timestamp: m[1], time: rawTime(m[1], zone), app: m[3].trim(), level: m[2], message: m[4], kind: 'log', cause_ids: [], file, raw: line };
}
/** Preserve valid UTF-8 spans, including files containing mixed legacy bytes. */
export function decodeLogLine(bytes: Buffer, encoding = 'auto'): string {
if (encoding === 'utf8') { return bytes.toString('utf8'); }
const legacy = new TextDecoder('windows-1252');
if (encoding === 'windows-1252') { return legacy.decode(bytes); }
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { /* Mixed legacy log. */ }
const output: string[] = [];
let start = 0;
for (let i = 0; i < bytes.length;) {
const first = bytes[i];
const width = first < 0x80 ? 1 : first >= 0xc2 && first <= 0xdf ? 2 : first >= 0xe0 && first <= 0xef ? 3 : first >= 0xf0 && first <= 0xf4 ? 4 : 0;
let valid = width > 0 && i + width <= bytes.length;
for (let j = 1; valid && j < width; j++) { valid = bytes[i + j] >= 0x80 && bytes[i + j] <= 0xbf; }
if (valid && width > 1) {
const second = bytes[i + 1];
valid = !(first === 0xe0 && second < 0xa0 || first === 0xed && second > 0x9f || first === 0xf0 && second < 0x90 || first === 0xf4 && second > 0x8f);
}
if (valid) { i += width; } else {
if (i > start) { output.push(bytes.subarray(start, i).toString('utf8')); }
output.push(legacy.decode(bytes.subarray(i, i + 1)));
start = ++i;
}
}
if (start < bytes.length) { output.push(bytes.subarray(start).toString('utf8')); }
return output.join('');
}
+25
View File
@@ -0,0 +1,25 @@
import { LogEntry } from './logModel';
export type SourceDocument = { path: string; text: string };
/** Resolve parsing failures to the expression in their app's YAML configuration. */
export function findLogSource(entry: LogEntry, documents: SourceDocument[]): LogEntry['source'] | undefined {
const expression = entry.message.match(/Error parsing string "([^\n]+)"(?: at character|:|\n|$)/)?.[1];
if (!expression) { return undefined; }
const matches: Array<{ path: string; line: number; app: string }> = [];
for (const document of documents) {
let app = '';
document.text.split(/\r?\n/).forEach((line, index) => {
const key = line.match(/^([^\s#][^:]*):(?:\s|$)/);
if (key) { app = key[1].replace(/^['"]|['"]$/g, ''); }
if (!line.trimStart().startsWith('#') && line.includes(expression)) {
matches.push({ path: document.path, line: index + 1, app });
}
});
}
const local = matches.filter(match => match.app === entry.app);
const candidates = local.length ? local : matches;
if (candidates.length !== 1) { return undefined; }
const { path, line } = candidates[0];
return { path, line };
}
+14 -46
View File
@@ -1,14 +1,12 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { ADClient } from './adClient';
export class StatusBarManager { export class StatusBarManager {
private restartADItem: vscode.StatusBarItem; private restartADItem: vscode.StatusBarItem;
private restartHAItem: vscode.StatusBarItem; private restartHAItem: vscode.StatusBarItem;
private productionModeItem: vscode.StatusBarItem;
private errorItem: vscode.StatusBarItem;
private contextualItem: vscode.StatusBarItem; private contextualItem: vscode.StatusBarItem;
private logsItem: vscode.StatusBarItem;
constructor(private adClient: ADClient) { constructor() {
// Reload AppDaemon apps // Reload AppDaemon apps
this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
this.restartADItem.command = 'appdaemon.restartAD'; this.restartADItem.command = 'appdaemon.restartAD';
@@ -23,25 +21,16 @@ export class StatusBarManager {
this.restartHAItem.tooltip = 'Restart Home Assistant'; this.restartHAItem.tooltip = 'Restart Home Assistant';
this.restartHAItem.show(); this.restartHAItem.show();
// Production mode toggle // Log explorer shortcut, available even when no editor is open.
this.productionModeItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 98); this.logsItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 96);
this.productionModeItem.command = 'appdaemon.toggleProductionMode'; this.logsItem.command = 'appdaemon.exploreLogs';
this.updateProductionModeDisplay(); this.logsItem.text = '$(list-tree) AD Logs';
this.productionModeItem.show(); this.logsItem.tooltip = 'Open AppDaemon Log Explorer';
this.logsItem.show();
// Error log shortcut
this.errorItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 97);
this.errorItem.command = 'appdaemon.showErrors';
this.errorItem.text = '$(check) AD Errors';
this.errorItem.tooltip = 'Show AppDaemon Error Log';
this.errorItem.show();
// Contextual per-file restart button (hidden by default) // Contextual per-file restart button (hidden by default)
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101); this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
this.contextualItem.command = 'appdaemon.restartCurrentFileApps'; this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
// React to production mode changes
adClient.onProductionModeChanged(() => this.updateProductionModeDisplay());
} }
/** /**
@@ -61,38 +50,17 @@ export class StatusBarManager {
this.contextualItem.show(); this.contextualItem.show();
} }
updateProductionModeDisplay() { setLogErrorCount(count: number) {
const isProduction = this.adClient.isProductionMode(); this.logsItem.backgroundColor = count > 0 ? new vscode.ThemeColor('statusBarItem.errorBackground') : undefined;
if (isProduction) { this.logsItem.tooltip = count > 0
this.productionModeItem.text = '$(eye-closed) Prod ON'; ? `${count} error${count === 1 ? '' : 's'} in loaded AppDaemon logs — open Log Explorer`
this.productionModeItem.tooltip = 'Production Mode: ON — click to disable'; : 'Open AppDaemon Log Explorer';
this.productionModeItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
} else {
this.productionModeItem.text = '$(eye) Prod OFF';
this.productionModeItem.tooltip = 'Production Mode: OFF — click to enable';
this.productionModeItem.backgroundColor = undefined;
}
}
setErrorCount(count: number) {
if (count > 0) {
this.errorItem.text = `$(warning) AD Errors (${count})`;
this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.errorItem.tooltip = `${count} error(s) — click to show or clear`;
this.errorItem.command = 'appdaemon.handleErrors';
} else {
this.errorItem.text = '$(check) AD Errors';
this.errorItem.backgroundColor = undefined;
this.errorItem.tooltip = 'No AppDaemon errors — click to view log';
this.errorItem.command = 'appdaemon.showErrors';
}
} }
dispose() { dispose() {
this.restartADItem.dispose(); this.restartADItem.dispose();
this.restartHAItem.dispose(); this.restartHAItem.dispose();
this.productionModeItem.dispose(); this.logsItem.dispose();
this.errorItem.dispose();
this.contextualItem.dispose(); this.contextualItem.dispose();
} }
} }
@@ -0,0 +1,80 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Module = require('module');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-log-test-'));
const originalLoad = Module._load;
let messages = [];
const vscode = {workspace:{workspaceFolders:[{uri:{fsPath:root}}], getConfiguration:()=>({get:(key, fallback)=>fallback})}};
Module._load = function(id, ...args) { return id === 'vscode' ? vscode : originalLoad.call(this, id, ...args); };
const {LogExplorer} = require('../out/logExplorer');
Module._load = originalLoad;
function explorer() { const e = new LogExplorer({}); const webview={postMessage:m=>messages.push(m)}; e.panel={webview}; e.activeWebview=webview; return e; }
function latest() { return messages.at(-1)?.entries || []; }
fs.mkdirSync(path.join(root,'logs'));
const file=path.join(root,'logs/appdaemon.log');
test('follows incomplete UTF-8 lines and multiline exceptions',()=>{
const e=explorer(); messages=[];
const first='27-09-26 10:12:13 ERROR app: Échec';
const bytes=Buffer.from(first+'\n'); const split=bytes.indexOf(Buffer.from('É'))+1;
fs.writeFileSync(file,bytes.subarray(0,split));e.poll();assert.equal(latest().length,0);
fs.appendFileSync(file,Buffer.concat([bytes.subarray(split),Buffer.from(' File "app.py", line 42\nRuntimeError: failed\n')]));e.poll();
assert.equal(latest().length,1);assert.match(latest()[0].message,/Échec\n File/);assert.match(latest()[0].message,/RuntimeError/);
});
test('detects same-sized inode replacement and truncation',()=>{
const e=explorer();messages=[];
fs.writeFileSync(file,'27-09-26 10:12:13 INFO app: before\n');e.poll();
fs.renameSync(file,file+'.old');fs.writeFileSync(file,'27-09-26 10:12:14 INFO app: after!\n');e.poll();
assert.ok(latest().some(x=>x.message==='after!'));
fs.writeFileSync(file,'27-09-26 10:12:15 INFO a: x\n');e.poll();assert.ok(latest().some(x=>x.message==='x'));
});
test('reads base text logs and ignores structured events by default',()=>{
const e=explorer();messages=[];
fs.writeFileSync(file,'27-09-26 10:12:13 ERROR app: failed\n27-09-26 10:12:14 INFO AppDaemon: AppDaemon is ready\n');
fs.writeFileSync(path.join(root,'logs/error.log'),'27-09-26 10:12:13 ERROR app: failed\n');
fs.writeFileSync(path.join(root,'logs/events.jsonl'),JSON.stringify({version:1,event_id:'trace-only',timestamp:'2026-09-27T08:12:14Z',level:'INFO',kind:'evaluation',message:'not a raw log',app:'app',cause_ids:[]})+'\n');e.poll();
assert.equal(latest().filter(e=>e.message==='failed').length,1);
assert.ok(latest().some(e=>e.app==='AppDaemon' && e.message==='AppDaemon is ready'));
assert.equal(latest().some(e=>e.event_id==='trace-only'),false);
});
test('bounds retained history',()=>{
const e=explorer();messages=[];
fs.writeFileSync(file,Array.from({length:10500},(_,i)=>`27-09-26 10:12:13 INFO app: message ${i}\n`).join(''));
e.poll();e.poll();assert.ok(e.entries.size<=10000);
});
process.on('exit',()=>fs.rmSync(root,{recursive:true,force:true}));
test('groups plain app.log lines between explicit initialization markers',()=>{
const e=explorer();messages=[];
fs.writeFileSync(file,[
'27-09-26 10:12:13 INFO kitchen: Initialization started [group=initialization:0123456789ab]',
'27-09-26 10:12:13 INFO kitchen: Setting up an output through app.log()',
'27-09-26 10:12:14 INFO kitchen: Configured helper [group=initialization:0123456789ab]',
'27-09-26 10:12:14 INFO kitchen: Initialization completed [group=initialization:0123456789ab]'
].join('\n')+'\n');e.poll();
const grouped=latest().filter(entry=>entry.app==='kitchen');
assert.equal(grouped.length,4);
assert.deepEqual(grouped.map(entry=>entry.initialization_id),Array(4).fill('0123456789ab'));
assert.equal(grouped[1].group_inferred,true);
assert.equal(grouped[2].group_inferred,undefined);
});
test('clears configured log files and updates the log error count',()=>{
const counts=[];const e=new LogExplorer({},count=>counts.push(count));
messages=[];const webview={postMessage:m=>messages.push(m)};e.panel={webview};e.activeWebview=webview;
const errorFile=path.join(root,'logs/error.log');
fs.writeFileSync(file,'27-09-26 10:12:13 ERROR app: failed\n');
fs.writeFileSync(errorFile,'27-09-26 10:12:13 ERROR app: failed\n');
e.poll();
assert.equal(counts.at(-1),1);
e.clearLogs();
assert.equal(fs.statSync(file).size,0);
assert.equal(fs.statSync(errorFile).size,0);
assert.equal(counts.at(-1),0);
assert.deepEqual(messages.filter(message=>message.type==='entries').at(-1).entries,[]);
fs.appendFileSync(file,'27-09-26 10:12:15 CRITICAL app: new failure\n');e.poll();
assert.equal(counts.at(-1),1);
assert.equal(latest()[0].message,'new failure');
});
+35
View File
@@ -0,0 +1,35 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { parseLine, rawTime } = require('../out/logModel');
test('raw log levels, app names and readable IDs', () => {
const e = parseLine('27-09-26 10:12:13 WARNING my_app: Alerte : détail [event=e1 trace=t1]', '/logs/main.log', 123);
assert.equal(e.event_id, 'e1'); assert.equal(e.app, 'my_app'); assert.equal(e.level, 'WARNING'); assert.equal(e.trace_id, 't1');
});
test('timestamps use the AppDaemon timezone instead of the workstation timezone', () => {
assert.equal(rawTime('27-09-26 10:12:13', 'Europe/Berlin'), Date.parse('2026-09-27T08:12:13Z'));
assert.equal(rawTime('27-01-26 10:12:13', 'Europe/Berlin'), Date.parse('2026-01-27T09:12:13Z'));
});
test('structured events retain causal links and evaluation values', () => {
const entry = {version:1,event_id:'e2',cause_ids:['e1'],probable_cause_ids:['e1'],message:'Condition',app:'app',kind:'evaluation',timestamp:'2026-09-27T08:12:13Z',values:{'sensor.a':true}};
assert.deepEqual(parseLine(JSON.stringify(entry), '/logs/events.jsonl', 0).values, entry.values);
});
test('invalid structured records and continuations are rejected', () => {
for (const value of ['{broken}', JSON.stringify({version:1,event_id:'e',cause_ids:[null],message:'bad',app:'app',timestamp:'now'}), ' File "app.py", line 3']) assert.equal(parseLine(value,'/logs/log',0),undefined);
});
test('decodes UTF-8, Windows-1252 and mixed historical log bytes', () => {
const { decodeLogLine } = require('../out/logModel');
assert.equal(decodeLogLine(Buffer.from('Échec – déjà 🔥')), 'Échec – déjà 🔥');
assert.equal(decodeLogLine(Buffer.from([0xc9,0x63,0x68,0x65,0x63,0x20,0x96,0x20,0xe9])), 'Échec – é');
assert.equal(decodeLogLine(Buffer.concat([Buffer.from('déjà → '),Buffer.from([0xe9])])), 'déjà → é');
assert.equal(decodeLogLine(Buffer.from([0xe9]), 'utf8'), '\ufffd');
});
test('raw initialization IDs are retained without structured events', () => {
const e=parseLine('27-09-26 10:12:13 INFO app: Setting up [event=e1 trace=t1] [init=init1]','log',0);
assert.equal(e.initialization_id,'init1'); assert.equal(e.phase,'initialization');
});
test('parses readable group tags without changing the log message format', () => {
const e=parseLine('27-09-26 10:12:13 INFO app: Selected value [group=value_selector:0123456789ab]','log',0);
assert.equal(e.group_id,'0123456789ab'); assert.equal(e.group_kind,'value_selector');
assert.equal(e.initialization_id,undefined); assert.match(e.message,/\[group=value_selector:/);
});
+17
View File
@@ -0,0 +1,17 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {findLogSource}=require('../out/logSource');
const expression='sensor.target_temperature_heatpump + 0.5';
const entry={app:'living_room_heatpump',message:`Error parsing string "${expression}" at character 0:Unknown variable : sensor.target_temperature_heatpump`};
test('resolves the failing expression inside the matching app rather than another app',()=>{
const docs=[{path:'/conf/apps/heating.yaml',text:`bedroom_heatpump:\n target_temperature: ${expression}\nliving_room_heatpump:\n target_temperature: ${expression}\n`}];
assert.deepEqual(findLogSource(entry,docs),{path:'/conf/apps/heating.yaml',line:4});
});
test('resolves a unique expression in a shared template and ignores comments',()=>{
assert.deepEqual(findLogSource(entry,[{path:'/conf/apps/templates.yml',text:`# ${expression}\nlibrary:\n value: ${expression}`}]),{path:'/conf/apps/templates.yml',line:3});
});
test('does not guess when a source is missing or ambiguous',()=>{
assert.equal(findLogSource(entry,[]),undefined);
assert.equal(findLogSource(entry,[{path:'a.yaml',text:`living_room_heatpump:\n a: ${expression}\n b: ${expression}`}]),undefined);
assert.equal(findLogSource({...entry,message:'Initialization failed'},[]),undefined);
});
+262
View File
@@ -0,0 +1,262 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('fs');
const path=require('path');
const {JSDOM,VirtualConsole}=require('jsdom');
const media=path.join(__dirname,'../media');
function setup() {
const errors=[], sent=[];
const virtualConsole=new VirtualConsole();virtualConsole.on('jsdomError',e=>errors.push(e));
const html=fs.readFileSync(path.join(media,'logExplorer.html'),'utf8').replace(/<meta http-equiv="Content-Security-Policy"[^>]+>/,'').replace(/<script[^>]+src="\{\{JS\}\}"[^>]*><\/script>/,'');
const dom=new JSDOM(html,{runScripts:'outside-only',virtualConsole});
dom.window.acquireVsCodeApi=()=>({postMessage:message=>sent.push(message),getState:()=>({}),setState() {}});
dom.window.eval(fs.readFileSync(path.join(media,'logExplorer.js'),'utf8'));
const entry=(id,app,second,message,extra={})=>{const timestamp=`2026-09-27T08:00:${String(second).padStart(2,'0')}Z`;return {event_id:id,app,time:Date.parse(timestamp),timestamp,message,kind:'log',cause_ids:[],level:'INFO',...extra};};
const entries=[entry('init1','alpha',1,'Preparing sensors',{initialization_id:'init-one'}),entry('init2','alpha',2,'Ready',{initialization_id:'init-one'}),entry('init3','alpha',3,'Restarting',{initialization_id:'init-two'}),entry('a','alpha',10,'Température déjà disponible'),entry('b','beta',20,'Connection ready')];
const send=items=>dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'entries',entries:items,warnings:[],limit:10000}}));
send(entries);
return {dom,d:dom.window.document,entries,sent,errors,send};
}
test('English interface, latest timestamps, sorting and separate initialization groups',()=>{
const {dom,d,errors}=setup();
assert.equal(d.documentElement.lang,'en');
assert.equal(d.getElementById('copy'),null);
assert.equal(d.getElementById('import'),null);
assert.equal(d.getElementById('pause'),null);
assert.deepEqual([...d.querySelectorAll('[data-app]')].map(e=>e.dataset.app),['beta','alpha']);
assert.match(d.querySelector('[data-app="alpha"] .last-age').title,/10:00:10/);
assert.match(d.querySelector('[data-app="alpha"] summary .last-log').textContent,/ : Température déjà disponible$/);
const summary=d.querySelector('[data-app="alpha"] summary');
assert.deepEqual([...summary.children].slice(0,4).map(child=>child.className),['app-label','badge INFO','badge WARNING','badge ERROR']);
assert.ok(summary.children[4].classList.contains('last-log'));
assert.equal(summary.querySelector('.app-label .last-log'),null);
d.getElementById('sort').value='oldest';d.getElementById('sort').dispatchEvent(new dom.window.Event('input'));
assert.equal(d.querySelector('[data-app]').dataset.app,'alpha');
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
assert.equal(alpha.querySelectorAll('.rows>.row').length,3);
assert.equal(alpha.querySelectorAll('.event-log-group').length,1);
assert.equal(alpha.querySelector('.event-log-group .row-message').textContent,'Preparing sensors\nReady');
assert.equal(alpha.querySelectorAll('.rows details').length,0);
assert.equal(d.getElementById('group-init'),null);
assert.deepEqual(errors,[]);dom.window.close();
});
test('selects individual logs and entire initialization groups',()=>{
const {dom,d,errors}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
[...alpha.querySelectorAll('.row')].find(e=>e.textContent.includes('Température déjà disponible')).click();
assert.match(d.getElementById('detail').textContent,/Température déjà disponible/);
const group=alpha.querySelector('.event-log-group');
group.click();
assert.match(d.getElementById('detail').textContent,/Preparing sensors\s+Ready/);
assert.doesNotMatch(d.getElementById('detail').textContent,/Restarting/);
const input=d.getElementById('search'),paste=new dom.window.KeyboardEvent('keydown',{key:'v',ctrlKey:true,bubbles:true,cancelable:true});input.dispatchEvent(paste);assert.equal(paste.defaultPrevented,false);
assert.deepEqual(errors,[]);dom.window.close();
});
test('app link opens its YAML definition without expanding the log group',async()=>{
const {dom,d,sent}=setup();
const app=d.querySelector('[data-app="alpha"]'),link=app.querySelector('.app-link');
assert.equal(link.getAttribute('aria-label'),'Open alpha definition');
link.click();
await new Promise(resolve=>setTimeout(resolve,0));
assert.equal(sent.at(-1).type,'app');assert.equal(sent.at(-1).app,'alpha');
assert.equal(app.open,false);
dom.window.close();
});
test('retains selected text during live updates and leaves Ctrl+C to the browser',async()=>{
const {dom,d,entries,errors,send}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
const text=alpha.querySelector('.row-message'),range=d.createRange();range.selectNodeContents(text);dom.window.getSelection().addRange(range);
const original=text.textContent;
send([...entries,{...entries[3],event_id:'new',time:30,message:'New log'}]);
assert.equal(dom.window.getSelection().toString(),original);assert.match(d.getElementById('status').textContent,/buffered/);
const copy=new dom.window.KeyboardEvent('keydown',{key:'c',ctrlKey:true,bubbles:true,cancelable:true});
text.dispatchEvent(copy);assert.equal(copy.defaultPrevented,false);
dom.window.getSelection().removeAllRanges();d.dispatchEvent(new dom.window.Event('selectionchange'));await new Promise(resolve=>setTimeout(resolve,15));
assert.match(d.querySelector('[data-app="alpha"]').textContent,/New log/);
assert.deepEqual(errors,[]);dom.window.close();
});
test('log content is never interpreted as HTML',()=>{
const {dom,d,entries,send,errors}=setup();send([{...entries[0],app:'<script>bad</script>',initialization_id:undefined,message:'<img src=x onerror=alert(1)>'}]);
const app=d.querySelector('[data-app]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
assert.equal(d.querySelectorAll('#apps img,#apps script').length,0);assert.match(app.textContent,/<img/);assert.deepEqual(errors,[]);dom.window.close();
});
test('shows same-event log lines together with one causes link and keeps newer events below',()=>{
const {dom,d,entries,send}=setup();
const trigger={...entries[3],event_id:'motion-callback',time:entries[3].time+1000,message:'Callback trigger by sensor.kitchen_last_motion(7 => 0)',trace_id:'motion-event'};
const evaluation={...entries[3],event_id:'motion-evaluation',time:entries[3].time+1000,message:'[kitchen_occupancy][Evaluating] Triggered by (sensor.kitchen_last_motion[0] <= 5)[True]',trace_id:'motion-event',cause_ids:['state-event']};
const newer={...entries[3],event_id:'later-log',time:entries[3].time+2000,message:'Later event'};
send([trigger,evaluation,newer].sort((a,b)=>a.time-b.time));
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
const rows=alpha.querySelector('.rows'),combined=rows.children[0];
assert.ok(combined.classList.contains('event-log-group'));
assert.equal(combined.querySelector('.exact-time').textContent,trigger.timestamp);
assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/);
assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/);
assert.equal(combined.querySelectorAll('.link-hint').length,1);
assert.equal(rows.children[1].querySelector('.row-message').textContent,'Later event');
combined.click();
assert.match(d.getElementById('detail').textContent,/Callback trigger by sensor.kitchen_last_motion/);
dom.window.close();
});
test('does not show a mirrored evaluation twice in the cause graph',()=>{
const {dom,d,entries,send}=setup();
const time=entries[3].time+1000,trace='same-run';
const state={...entries[3],event_id:'state-event',kind:'state_changed',message:'sensor.ext_back_humidity.state: 66 → 65',time,trace_id:trace,cause_ids:[]};
const message='[living_room_cant_open_window][Evaluating] No valid conditions';
const evaluation={...entries[3],event_id:'technical-evaluation',kind:'evaluation',message,time,trace_id:trace,cause_ids:['state-event'],values:{'sensor.ext_back_humidity':'65'}};
const callback={...entries[3],event_id:'callback-log',message:'[living_room_cant_open_window] Callback trigger by sensor.ext_back_humidity(66 => 65)',time,trace_id:trace,cause_ids:['state-event']};
const mirroredLog={...entries[3],event_id:'evaluation-log',message,time:time+1,trace_id:trace,cause_ids:['technical-evaluation']};
send([state,evaluation,callback,mirroredLog].sort((a,b)=>a.time-b.time));
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
const combined=app.querySelector('.event-log-group');
assert.ok(combined);
assert.equal(combined.querySelectorAll('.link-hint').length,1);
combined.click();
const matchingNodes=[...d.querySelectorAll('#detail .node .text')].filter(item=>item.textContent.includes('No valid conditions'));
assert.equal(matchingNodes.length,1);
assert.equal(d.querySelectorAll('#detail .values tr').length,1);
dom.window.close();
});
test('pulses an app when a new log arrives and refreshes its last-log age',async()=>{
const {dom,d,entries,send}=setup();
const beta=entries.find(e=>e.app==='beta'),nextTime=beta.time+2000;
dom.window.Date.now=()=>beta.time+3000;
const newer={...beta,event_id:'beta-new',time:nextTime,timestamp:new Date(nextTime).toISOString(),message:'Fresh log'};
send([...entries,newer]);
const app=d.querySelector('[data-app="beta"]');
assert.ok(app.classList.contains('new-log-app'));
app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
assert.ok(app.querySelector('.recent-log'));
assert.equal(app.querySelector('.last-age').textContent,'1 sec ago');
assert.equal(app.querySelector('summary .last-log').textContent,'1 sec ago : Fresh log');
dom.window.Date.now=()=>nextTime+65000;
await new Promise(resolve=>setTimeout(resolve,1100));
assert.equal(app.querySelector('.last-age').textContent,'1 min ago');
assert.equal(app.querySelector('summary .last-log').textContent,'1 min ago : Fresh log');
dom.window.close();
});
test('expanded logs follow new events at the bottom and keep position while reading history',async()=>{
const {dom,d,entries,send}=setup();
Object.defineProperty(dom.window.HTMLElement.prototype,'scrollHeight',{configurable:true,get(){return this.classList.contains('rows') ? this.children.length*100 : 0;}});
Object.defineProperty(dom.window.HTMLElement.prototype,'clientHeight',{configurable:true,get(){return this.classList.contains('rows') ? 100 : 0;}});
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
const rows=()=>d.querySelector('[data-app="alpha"] .rows');
assert.equal(rows().scrollTop,rows().scrollHeight);
const first={...entries[3],event_id:'new-tail',time:entries[3].time+1000,message:'Newest'};
send([...entries,first]);
assert.equal(rows().scrollTop,rows().scrollHeight);
rows().scrollTop=20;
const second={...entries[3],event_id:'new-tail-2',time:entries[3].time+2000,message:'Newer'};
send([...entries,first,second]);
assert.equal(rows().scrollTop,20);
assert.match(rows().textContent,/Newer/);
dom.window.close();
});
test('the newest log stays at the bottom when older groups are omitted',()=>{
const {dom,d,entries,send}=setup();
const base=entries[3];
send(Array.from({length:152},(_,index)=>({...base,event_id:`event-${index}`,time:base.time+index*1000,message:`Message ${index}`})));
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
const rows=app.querySelector('.rows');
assert.ok(rows.firstElementChild.classList.contains('more'));
assert.match(rows.lastElementChild.textContent,/Message 151/);
dom.window.close();
});
test('distinguishes log lines from supplemental records when configured',()=>{
const {dom,d,entries,send}=setup();
const trace={...entries[3],event_id:'trace-evaluation',kind:'evaluation',message:'Condition was evaluated'};
send([...entries,trace]);
assert.match(d.getElementById('status').textContent,/5 log lines \+ 1 trace event/);
assert.match(d.getElementById('status').title,/configured log files/);
dom.window.close();
});
test('shows and refreshes relative time on each log entry while displaying exact time alongside it',async()=>{
const {dom,d,entries}=setup();
const entry=entries.find(e=>e.event_id==='a');
dom.window.Date.now=()=>entry.time+120000;
const app=d.querySelector('[data-app="alpha"]');
app.open=true;
app.dispatchEvent(new dom.window.Event('toggle'));
const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible'));
const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time');
assert.equal(relativeTime().textContent,'2 minutes ago');
assert.equal(relativeTime().previousElementSibling.textContent,entries[3].timestamp);
assert.match(relativeTime().title,/10:00:10/);
dom.window.Date.now=()=>entry.time+3600000;
await new Promise(resolve=>setTimeout(resolve,1600));
assert.equal(relativeTime().textContent,'1 hour ago');
dom.window.close();
});
test('shows consecutive logs from one explicit group as a single visible row',()=>{
const {dom,d,entries,send}=setup();
const group=[
{...entries[3],event_id:'selector-1',time:entries[3].time+1000,message:'Rejected candidate',group_id:'0123456789ab',group_kind:'value_selector'},
{...entries[3],event_id:'selector-2',time:entries[3].time+1000,message:'Selected candidate',group_id:'0123456789ab',group_kind:'value_selector'}
];
send(group);
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
const rows=[...app.querySelectorAll('.rows>.row')].filter(row=>row.textContent.includes('Rejected candidate'));
assert.equal(rows.length,1);assert.ok(rows[0].classList.contains('event-log-group'));
assert.match(rows[0].querySelector('.row-message').textContent,/Rejected candidate\nSelected candidate/);
assert.doesNotMatch(rows[0].textContent,/\[group=/);
dom.window.close();
});
test('global error counter shows all loaded errors and opens them despite active filters',()=>{
const {dom,d,entries,send}=setup();
send([...entries,{...entries[3],event_id:'alpha-error',level:'ERROR',message:'Alpha failed'},
{...entries[4],event_id:'beta-critical',level:'CRITICAL',message:'Beta failed'},
{...entries[4],event_id:'supplement',kind:'evaluation',level:'ERROR',message:'Supplemental'}]);
const counter=d.getElementById('error-count');
assert.equal(counter.textContent,'2 errors');assert.ok(counter.classList.contains('has-errors'));
d.getElementById('search').value='nothing';d.getElementById('period').value='15';
counter.click();
assert.equal(d.getElementById('search').value,'');assert.equal(d.getElementById('period').value,'0');
assert.equal(d.getElementById('level').value,'ERROR');
assert.equal(d.querySelectorAll('#apps .row').length,2);
assert.match(d.getElementById('detail').textContent,/failed/);
dom.window.close();
});
test('exits errors with the counter or All levels and restores normal app sorting',()=>{
const {dom,d,entries,send}=setup();
send([...entries,{...entries[3],event_id:'old-alpha-error',time:entries[0].time,level:'ERROR',message:'Old error'}]);
const counter=d.getElementById('error-count');
counter.click();assert.equal(counter.getAttribute('aria-pressed'),'true');
assert.equal(d.querySelector('[data-app]').dataset.app,'alpha');
counter.click();
assert.equal(d.getElementById('level').value,'all');
assert.equal(counter.getAttribute('aria-pressed'),'false');
assert.equal(d.querySelector('[data-app]').dataset.app,'beta');
counter.click();
d.getElementById('level').value='all';d.getElementById('level').dispatchEvent(new dom.window.Event('input'));
assert.equal(d.querySelector('[data-app]').dataset.app,'beta');
assert.equal(d.querySelectorAll('.row.selected').length,0);
assert.equal(counter.getAttribute('aria-pressed'),'false');
dom.window.close();
});
test('Open source in a group targets the parsing error even when the last entry has no source',()=>{
const {dom,d,entries,send,sent}=setup();
send([{...entries[0],event_id:'parse-error',level:'ERROR',source:{path:'/conf/apps/heating.yaml',line:160}},
{...entries[1],event_id:'init-failed',level:'ERROR',message:'Initialization failed'}]);
const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
app.querySelector('.row').click();
const source=[...d.querySelectorAll('#detail button')].find(button=>button.textContent==='Open source');
assert.ok(source);source.click();assert.equal(sent.at(-1).type,'source');assert.equal(sent.at(-1).id,'parse-error');
dom.window.close();
});
test('Clear logs sends a request and resets its button after completion',()=>{
const {dom,d,sent}=setup();
const button=d.getElementById('clear');button.click();
assert.equal(sent.at(-1).type,'clear');assert.equal(button.disabled,true);
dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:{type:'clear-result',cleared:2,failures:[]}}));
assert.equal(button.disabled,false);assert.equal(button.textContent,'Clear logs');
dom.window.close();
});
Binary file not shown.
Binary file not shown.