Compare commits
26
Commits
a387a13fa2
...
86b5441603
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b5441603 | ||
|
|
d5e7193b42 | ||
|
|
54c525c843 | ||
|
|
1a148d4e3f | ||
|
|
b97445ce92 | ||
|
|
3b0c24e795 | ||
|
|
bf72dbb223 | ||
|
|
97501875b8 | ||
|
|
5ae930c710 | ||
|
|
cf8839db6a | ||
|
|
1d14219b93 | ||
|
|
d8756928a5 | ||
|
|
caf9051864 | ||
|
|
3171433770 | ||
|
|
0fa40dc2ff | ||
|
|
6838cc54d0 | ||
|
|
3aa0120e3d | ||
|
|
f57e23d76a | ||
|
|
5614ad049d | ||
|
|
99422d8cfc | ||
|
|
6c345ccf81 | ||
|
|
cfb5334666 | ||
|
|
ece851f5a0 | ||
|
|
cded4a0200 | ||
|
|
400837b41a | ||
|
|
191e69eb1d |
No files matched your search
@@ -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
@@ -1,44 +1,86 @@
|
||||
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:
|
||||
|
||||
def __init__(self):
|
||||
self.initialized = False
|
||||
|
||||
def __init__(self,ad_api, default_log = "main_log",mute = False):
|
||||
self.initialize_logger_interface(ad_api,default_log,mute)
|
||||
def __init__(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
|
||||
self.initialize_logger_interface(ad_api, default_log, mute, ascii_encode=ascii_encode)
|
||||
|
||||
def initialize_logger_interface(self,ad_api, default_log = "main_log",mute = False):
|
||||
def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
|
||||
self.ad_api = ad_api
|
||||
self.default_log = default_log
|
||||
self.mute = False
|
||||
self.initialized = True
|
||||
self._log_options = {} if ascii_encode is None else {'ascii_encode': ascii_encode}
|
||||
|
||||
def 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:
|
||||
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)
|
||||
|
||||
def log_info(self,message):
|
||||
def log_info(self, message):
|
||||
assert self.initialized
|
||||
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
|
||||
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
|
||||
|
||||
if dump_stack:
|
||||
stack = "\n"
|
||||
stack = '\n'
|
||||
for line in traceback.format_stack()[:-1]:
|
||||
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:
|
||||
self.stop_app(self.name)
|
||||
self.stop_app(self.name)
|
||||
+45
-13
@@ -1,6 +1,7 @@
|
||||
#import appdaemon.plugins.hass.hassapi as hass
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from contextlib import nullcontext
|
||||
#from pickle import FALSE
|
||||
|
||||
from adexpressioncontext import ADExpressionContext
|
||||
@@ -45,6 +46,7 @@ class Evaluator():
|
||||
self.__state_callbacks_handle = list()
|
||||
self.__condition_state_change_delayed = list()
|
||||
self.__condition_state_change_delayed_cb_handle = None
|
||||
self.__delay_generation = 0
|
||||
|
||||
self.__appdaemon_api = appdaemon_api
|
||||
self.__activated = False
|
||||
@@ -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
|
||||
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 isinstance(condition_name,list):
|
||||
for name in condition_name:
|
||||
@@ -161,14 +170,24 @@ class Evaluator():
|
||||
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):
|
||||
try: result = self.__evaluate()
|
||||
except EvaluationException as e:
|
||||
self.__log(f"{e}",level = "ERROR")
|
||||
return None
|
||||
if log:
|
||||
self.log_evaluation_result()
|
||||
return result
|
||||
with self.__log_group_scope():
|
||||
try: result = self.__evaluate()
|
||||
except EvaluationException as e:
|
||||
self.__log(f"{e}",level = "ERROR")
|
||||
return None
|
||||
if log:
|
||||
self.log_evaluation_result()
|
||||
return result
|
||||
|
||||
def log_callback_trigger_reason(self):
|
||||
if self.__callback_trigger_reason_log_string:
|
||||
@@ -491,6 +510,10 @@ class Evaluator():
|
||||
self.__last_evaluation_result = new_result
|
||||
|
||||
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_evaluation_result = False
|
||||
|
||||
@@ -551,6 +574,10 @@ class Evaluator():
|
||||
self.__last_evaluation_result = result
|
||||
|
||||
def __on_condition_state_change_delayed(self, kwargs):
|
||||
# An expired timer may already be queued when a newer change reschedules it.
|
||||
# Ignore it before touching the current timer handle or pending changes.
|
||||
if kwargs.get("generation") != self.__delay_generation:
|
||||
return
|
||||
self.__condition_state_change_delayed_cb_handle = None
|
||||
if len(self.__condition_state_change_delayed) == 0:
|
||||
self.__log(f"Callback has been trigger but self.__condition_state_change_delayed is empty, this shouldn't happen, the callback won't be called", level = "WARNING")
|
||||
@@ -585,13 +612,18 @@ class Evaluator():
|
||||
# self.__update_result()
|
||||
self.__trigger_callbacks(f"Callback trigger by {entity}({old} => {new})")
|
||||
else:
|
||||
if self.__condition_state_change_delayed_cb_handle != None and self.__appdaemon_api.timer_running(self.__condition_state_change_delayed_cb_handle):
|
||||
self.__appdaemon_api.cancel_timer(self.__condition_state_change_delayed_cb_handle)
|
||||
self.__delay_generation += 1
|
||||
handle = self.__condition_state_change_delayed_cb_handle
|
||||
if handle is not None:
|
||||
# Expiry may already have removed the timer from the scheduler.
|
||||
self.__appdaemon_api.cancel_timer(handle, silent=True)
|
||||
self.__condition_state_change_delayed_cb_handle = None
|
||||
|
||||
self.__condition_state_change_delayed.append((entity,old,new,datetime.now()))
|
||||
#self.__log("State changed :" + str(entity) + "(" + str(old) + " => " + str(new) + ") but callback delayed for " + str(self.__callback_delay) + "s")
|
||||
self.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(self.__on_condition_state_change_delayed, self.__callback_delay)
|
||||
|
||||
self.__condition_state_change_delayed.append((entity,old,new,datetime.now()))
|
||||
self.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(
|
||||
self.__on_condition_state_change_delayed, self.__callback_delay,
|
||||
generation=self.__delay_generation,
|
||||
)
|
||||
|
||||
def __log(self,message,**kwargs):
|
||||
log_string = self.__log_to_string(message)
|
||||
|
||||
+28
-13
@@ -113,12 +113,11 @@ class SmartObject(hass.Hass,LoggerInterface):
|
||||
if device_class is not None: attributes['device_class'] = device_class
|
||||
if state_class is not None: attributes['state_class'] = state_class
|
||||
|
||||
if state is not None:
|
||||
entity.set_state(state=state, attributes=attributes)
|
||||
else:
|
||||
if not entity.exists():
|
||||
entity.set_state(state='unknown')
|
||||
entity.set_state(attributes=attributes)
|
||||
if state is None:
|
||||
state = entity.get_state() if entity.exists() else None
|
||||
if state is None:
|
||||
state = 'unknown'
|
||||
entity.set_state(state=state, attributes=attributes, check_existence=False)
|
||||
|
||||
return entity
|
||||
|
||||
@@ -183,10 +182,22 @@ class SmartObject(hass.Hass,LoggerInterface):
|
||||
# mute → entity → constants → templates_library → virtual_sensors
|
||||
# → attributes_override → dataset restore → on_initialize_smart_object()
|
||||
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:
|
||||
#self.depends_on_module(["smartobject","virtualsensors","logger_interface"])
|
||||
|
||||
self.initialize_logger_interface(self.get_ad_api())
|
||||
self.log_info(f"Name = {self.name}")
|
||||
# mute: true → suppress all log output for this app
|
||||
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
|
||||
if "entity" in self.args:
|
||||
self.entity_id = self.args["entity"]
|
||||
self.entity = self.get_entity(self.entity_id)
|
||||
self.entity = self.get_entity(self.entity_id, check_existence=False)
|
||||
initial_state = None
|
||||
if self.entity.exists():
|
||||
self.log_info(f"Linked to {self.entity_id}")
|
||||
elif 'default_entity_state' in self.args:
|
||||
self.entity.set_state(state = self.args["default_entity_state"])
|
||||
self.log_info(f"Creating {self.entity_id}, default_state = {self.args['default_entity_state']}")
|
||||
initial_state = self.args["default_entity_state"]
|
||||
self.log_info(f"Creating {self.entity_id}, default_state = {initial_state}")
|
||||
else:
|
||||
self.entity.set_state(state = self.get_default_entity_state())
|
||||
initial_state = self.get_default_entity_state()
|
||||
self.entity = self.create_entity(self.entity_id, state=initial_state)
|
||||
|
||||
self.constants['self'] = self.entity_id
|
||||
else:
|
||||
@@ -272,8 +285,10 @@ class SmartObject(hass.Hass,LoggerInterface):
|
||||
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)")
|
||||
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).
|
||||
def terminate(self):
|
||||
|
||||
+24
-7
@@ -1,4 +1,5 @@
|
||||
#import appdaemon.plugins.hass.hassapi as hass
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime
|
||||
from adexpressioncontext import ADExpressionContext
|
||||
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
|
||||
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:
|
||||
self.__debug_log_prefixes.append(f"[{expression_name}]")
|
||||
|
||||
@@ -96,14 +99,24 @@ class Evaluator():
|
||||
self.__debug_log_prefixes.pop()
|
||||
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):
|
||||
try: result = self.__evaluate()
|
||||
except EvaluationException as e:
|
||||
self.__log(f"{e}",level = "ERROR")
|
||||
return None
|
||||
if log:
|
||||
self.log_evaluation_result()
|
||||
return result
|
||||
with self.__log_group_scope():
|
||||
try: result = self.__evaluate()
|
||||
except EvaluationException as e:
|
||||
self.__log(f"{e}",level = "ERROR")
|
||||
return None
|
||||
if log:
|
||||
self.log_evaluation_result()
|
||||
return result
|
||||
|
||||
def log_callback_trigger_reason(self):
|
||||
if self.__callback_trigger_reason_log_string:
|
||||
@@ -220,6 +233,10 @@ class Evaluator():
|
||||
self.__state_callbacks_handle = list()
|
||||
|
||||
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_evaluation_result = False
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Exercise timer expiry/cancellation ordering without a running AppDaemon."""
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
|
||||
node = next(node for node in ast.parse(source.read_text()).body
|
||||
if isinstance(node, ast.ClassDef) and node.name == 'ContinuousCondition')
|
||||
namespace = {'VirtualSensorBase': object, 'SmartCondition': Mock()}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
|
||||
ContinuousCondition = namespace['ContinuousCondition']
|
||||
|
||||
|
||||
class FakeScheduler:
|
||||
def __init__(self):
|
||||
self.timers = {}
|
||||
self.next_id = 0
|
||||
self.warnings = []
|
||||
|
||||
def run_in(self, callback, delay, **kwargs):
|
||||
self.next_id += 1
|
||||
handle = str(self.next_id)
|
||||
self.timers[handle] = (callback, kwargs)
|
||||
return handle
|
||||
|
||||
def expire(self, handle):
|
||||
# AppDaemon removes the timer while its callback can still be queued.
|
||||
callback, kwargs = self.timers.pop(handle)
|
||||
return lambda: callback(kwargs)
|
||||
|
||||
def cancel_timer(self, handle, silent=False):
|
||||
if handle in self.timers:
|
||||
del self.timers[handle]
|
||||
return True
|
||||
if not silent:
|
||||
self.warnings.append(handle)
|
||||
return False
|
||||
|
||||
|
||||
class ContinuousConditionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scheduler = FakeScheduler()
|
||||
self.state = 'off'
|
||||
entity = Mock()
|
||||
entity.get_state.side_effect = lambda: self.state
|
||||
entity.set_state.side_effect = lambda *, state: setattr(self, 'state', state)
|
||||
self.sensor = ContinuousCondition()
|
||||
self.sensor.ad_api = SimpleNamespace(
|
||||
get_entity=lambda name: entity,
|
||||
run_in=self.scheduler.run_in,
|
||||
cancel_timer=self.scheduler.cancel_timer,
|
||||
)
|
||||
self.sensor.yaml_block = {'time': 5, 'conditions': 'paused'}
|
||||
self.sensor.sensor_name = 'binary_sensor.is_vertex2_content_idle'
|
||||
self.sensor.log_info = Mock()
|
||||
self.sensor.logger_interface = None
|
||||
self.sensor.constants = {}
|
||||
self.sensor.templates_library = None
|
||||
self.sensor.initialize()
|
||||
|
||||
def test_valid_expiry_sets_state_and_clears_handle(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.scheduler.expire(self.sensor.cb_handle)()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
def test_failure_cancels_running_timer(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertEqual(self.scheduler.timers, {})
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
def test_resume_at_expiry_ignores_queued_callback_without_warning(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
queued = self.scheduler.expire(self.sensor.cb_handle)
|
||||
self.sensor.on_condition_fail()
|
||||
queued()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertEqual(self.scheduler.warnings, [])
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
|
||||
def test_old_callback_preserves_new_timer_and_full_delay(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
queued = self.scheduler.expire(self.sensor.cb_handle)
|
||||
self.sensor.on_condition_fail()
|
||||
self.sensor.on_condition_succeed()
|
||||
new_handle = self.sensor.cb_handle
|
||||
queued()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertEqual(self.sensor.cb_handle, new_handle)
|
||||
self.scheduler.expire(new_handle)()
|
||||
self.assertEqual(self.state, 'on')
|
||||
|
||||
def test_repeated_success_keeps_existing_timer(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
handle = self.sensor.cb_handle
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.sensor.cb_handle, handle)
|
||||
self.assertEqual(len(self.scheduler.timers), 1)
|
||||
|
||||
def test_inverted_condition_also_ignores_stale_expiry(self):
|
||||
self.sensor.invert = True
|
||||
self.state = 'on'
|
||||
self.sensor.on_condition_succeed()
|
||||
queued = self.scheduler.expire(self.sensor.cb_handle)
|
||||
self.sensor.on_condition_fail()
|
||||
queued()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.sensor.on_condition_succeed()
|
||||
self.scheduler.expire(self.sensor.cb_handle)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Check initial windows, rearming and queued timer races."""
|
||||
import ast
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import time
|
||||
|
||||
from test_continuous_condition import ContinuousConditionTests, namespace, source
|
||||
|
||||
namespace['time'] = time
|
||||
node = next(node for node in ast.parse(source.read_text()).body
|
||||
if isinstance(node, ast.ClassDef) and node.name == 'EphemeralCondition')
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
|
||||
EphemeralCondition = namespace['EphemeralCondition']
|
||||
|
||||
|
||||
class EphemeralConditionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
ContinuousConditionTests.setUp(self)
|
||||
self.sensor.__class__ = EphemeralCondition
|
||||
self.sensor.attributes = {}
|
||||
self.sensor.ad_api.get_state = Mock(return_value=None)
|
||||
self.sensor.output_sensor.set_state.side_effect = lambda *, state, **kwargs: setattr(self, 'state', state)
|
||||
self.sensor.initialize()
|
||||
|
||||
def test_window_expires_without_restarting_while_true(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'on')
|
||||
handle = self.sensor.cb_handle
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.sensor.cb_handle, handle)
|
||||
self.scheduler.expire(handle)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
|
||||
def test_failure_ends_window_and_rearms(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertEqual(self.scheduler.timers, {})
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.scheduler.expire(self.sensor.cb_handle)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
def test_queued_expiry_cannot_end_new_window(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
queued = self.scheduler.expire(self.sensor.cb_handle)
|
||||
self.sensor.on_condition_fail()
|
||||
self.sensor.on_condition_succeed()
|
||||
handle = self.sensor.cb_handle
|
||||
queued()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.sensor.cb_handle, handle)
|
||||
self.scheduler.expire(handle)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
def test_restart_resumes_remaining_time(self):
|
||||
self.sensor.ad_api.get_state.return_value = 100
|
||||
with patch.object(time, 'time', return_value=103):
|
||||
self.sensor.initialize()
|
||||
self.sensor.ad_api.run_in = Mock(return_value='restored')
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.sensor.ad_api.run_in.call_args.args[1], 2)
|
||||
self.assertEqual(self.sensor.attributes['condition_started_at'], 100)
|
||||
|
||||
def test_restart_does_not_rearm_expired_window(self):
|
||||
self.sensor.ad_api.get_state.return_value = 100
|
||||
with patch.object(time, 'time', return_value=110):
|
||||
self.sensor.initialize()
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertIsNone(self.sensor.attributes['condition_started_at'])
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'on')
|
||||
|
||||
def test_invalid_restored_timestamp_starts_fresh(self):
|
||||
for value in ('invalid', float('nan'), float('inf'), -1, 200):
|
||||
with self.subTest(value=value), patch.object(time, 'time', return_value=100):
|
||||
self.sensor.ad_api.get_state.return_value = value
|
||||
self.sensor.initialize()
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.sensor.attributes['condition_started_at'], 100)
|
||||
self.sensor.on_condition_fail()
|
||||
|
||||
def test_initial_true_condition_starts_window(self):
|
||||
evaluator = namespace['SmartCondition'].Evaluator
|
||||
def initialize_condition(*args, **kwargs):
|
||||
kwargs['on_succeed_cb']()
|
||||
evaluator.side_effect = initialize_condition
|
||||
try:
|
||||
self.sensor.initialize()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertIsNotNone(self.sensor.cb_handle)
|
||||
finally:
|
||||
evaluator.side_effect = None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""An expired timer queued before reactivation must not end the new window."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from test_continuous_condition import FakeScheduler
|
||||
|
||||
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
|
||||
node = next(node for node in ast.parse(source.read_text()).body
|
||||
if isinstance(node, ast.ClassDef) and node.name == 'EventSensor')
|
||||
namespace = {'VirtualSensorBase': object, 'EventHandler': Mock()}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(source), 'exec'), namespace)
|
||||
EventSensor = namespace['EventSensor']
|
||||
|
||||
|
||||
class EventSensorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scheduler = FakeScheduler()
|
||||
self.state = 'off'
|
||||
entity = Mock()
|
||||
entity.exists.return_value = True
|
||||
entity.set_state.side_effect = lambda *, state: setattr(self, 'state', state)
|
||||
self.sensor = EventSensor()
|
||||
self.sensor.ad_api = SimpleNamespace(
|
||||
get_entity=lambda name: entity,
|
||||
run_in=self.scheduler.run_in,
|
||||
cancel_timer=self.scheduler.cancel_timer,
|
||||
)
|
||||
self.sensor.sensor_name = 'binary_sensor.red_shelly_home'
|
||||
self.sensor.yaml_block = {'retain_time': 120}
|
||||
self.sensor.initialize()
|
||||
|
||||
def activate(self):
|
||||
self.sensor.on_activation_event('signal_strength', {}, {})
|
||||
return self.sensor.cb_handle
|
||||
|
||||
def test_current_expiry_turns_sensor_off(self):
|
||||
handle = self.activate()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.scheduler.expire(handle)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
|
||||
def test_reactivation_cancels_running_timer(self):
|
||||
old = self.activate()
|
||||
current = self.activate()
|
||||
self.assertNotIn(old, self.scheduler.timers)
|
||||
self.assertIn(current, self.scheduler.timers)
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.scheduler.warnings, [])
|
||||
|
||||
def test_queued_expiry_preserves_reactivated_sensor_and_timer(self):
|
||||
queued = self.scheduler.expire(self.activate())
|
||||
current = self.activate()
|
||||
queued()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.sensor.cb_handle, current)
|
||||
self.assertEqual(self.scheduler.warnings, [])
|
||||
self.scheduler.expire(current)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
|
||||
def test_multiple_queued_expiries_cannot_end_latest_window(self):
|
||||
first = self.scheduler.expire(self.activate())
|
||||
second = self.scheduler.expire(self.activate())
|
||||
current = self.activate()
|
||||
second()
|
||||
first()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.sensor.cb_handle, current)
|
||||
self.assertEqual(self.scheduler.warnings, [])
|
||||
self.scheduler.expire(current)()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -27,6 +27,7 @@ class RetainConditionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sensor = RetainCondition()
|
||||
self.sensor.cb_handle = None
|
||||
self.sensor._timer_generation = 0
|
||||
self.sensor.retain_time = 15
|
||||
self.sensor.reset_retain_time_on_new_success = True
|
||||
self.state = 'off'
|
||||
@@ -41,6 +42,54 @@ class RetainConditionTests(unittest.TestCase):
|
||||
def set_state(self, *, state):
|
||||
self.state = state
|
||||
|
||||
def queued_expiry(self):
|
||||
call = self.sensor.ad_api.run_in.call_args
|
||||
callback = call.args[0]
|
||||
kwargs = dict(call.kwargs)
|
||||
return lambda: callback(kwargs)
|
||||
|
||||
def test_return_at_expiry_ignores_queued_callback(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
queued = self.queued_expiry()
|
||||
# The scheduler has already removed the timer and queued its callback.
|
||||
self.sensor.ad_api.cancel_timer.return_value = False
|
||||
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
||||
self.sensor.on_condition_succeed()
|
||||
queued()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.sensor.condition.evaluate.assert_not_called()
|
||||
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
|
||||
|
||||
def test_stale_expiry_cannot_shorten_new_retention(self):
|
||||
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
queued = self.queued_expiry()
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
current_expiry = self.queued_expiry()
|
||||
queued()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertEqual(self.sensor.cb_handle, 'new-timer')
|
||||
current_expiry()
|
||||
self.assertEqual(self.state, 'off')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
|
||||
def test_stale_expiry_cannot_forget_timer_while_condition_disabled(self):
|
||||
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
queued = self.queued_expiry()
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
self.sensor.condition.evaluate.return_value = Result.Disabled
|
||||
queued()
|
||||
self.assertEqual(self.sensor.cb_handle, 'new-timer')
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.sensor.ad_api.cancel_timer.call_args.args, ('new-timer',))
|
||||
|
||||
def test_long_lived_signal_gets_full_delay_after_loss(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.assertEqual(self.state, 'on')
|
||||
@@ -48,15 +97,16 @@ class RetainConditionTests(unittest.TestCase):
|
||||
# No timer can expire while the signal stays valid.
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.sensor.ad_api.run_in.assert_called_once_with(self.sensor.on_time_elapse, 15)
|
||||
self.sensor.on_time_elapse({})
|
||||
self.sensor.ad_api.run_in.assert_called_once_with(
|
||||
self.sensor.on_time_elapse, 15, generation=self.sensor._timer_generation)
|
||||
self.queued_expiry()()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.on_condition_fail()
|
||||
self.sensor.on_condition_succeed()
|
||||
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer')
|
||||
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.sensor.on_condition_fail()
|
||||
@@ -79,8 +129,11 @@ class RetainConditionTests(unittest.TestCase):
|
||||
with self.subTest(result=result):
|
||||
self.state = 'on'
|
||||
self.sensor.condition.evaluate.return_value = result
|
||||
self.sensor.on_time_elapse({})
|
||||
self.sensor.register_callback()
|
||||
self.queued_expiry()()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.assertIsNone(self.sensor.cb_handle)
|
||||
self.sensor.condition.evaluate.assert_called_with()
|
||||
|
||||
def test_explicit_no_reset_preserves_fixed_window(self):
|
||||
self.sensor.reset_retain_time_on_new_success = False
|
||||
@@ -88,7 +141,7 @@ class RetainConditionTests(unittest.TestCase):
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertEqual(self.state, 'on')
|
||||
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
||||
self.sensor.on_time_elapse({})
|
||||
self.queued_expiry()()
|
||||
self.sensor.on_condition_fail()
|
||||
self.assertEqual(self.state, 'off')
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Verify debouncing when an expired AppDaemon timer is still queued."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from smartcondition import Evaluator
|
||||
|
||||
|
||||
class FakeScheduler:
|
||||
def __init__(self):
|
||||
self.timers = {}
|
||||
self.next_id = 0
|
||||
self.cancelled = []
|
||||
self.warnings = []
|
||||
self.expire_on_cancel = False
|
||||
self.queued = []
|
||||
|
||||
def run_in(self, callback, delay, **kwargs):
|
||||
self.next_id += 1
|
||||
handle = str(self.next_id)
|
||||
self.timers[handle] = (callback, kwargs)
|
||||
return handle
|
||||
|
||||
def timer_running(self, handle):
|
||||
return handle in self.timers
|
||||
|
||||
def cancel_timer(self, handle, silent=False):
|
||||
self.cancelled.append(handle)
|
||||
if self.expire_on_cancel and handle in self.timers:
|
||||
self.queued.append(self.expire(handle))
|
||||
cancelled = self.timers.pop(handle, None) is not None
|
||||
if not cancelled and not silent:
|
||||
self.warnings.append(handle)
|
||||
return cancelled
|
||||
|
||||
def expire(self, handle):
|
||||
callback, kwargs = self.timers.pop(handle)
|
||||
return lambda: callback(kwargs)
|
||||
|
||||
|
||||
class DelayedCallbackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scheduler = FakeScheduler()
|
||||
self.evaluator = Evaluator.__new__(Evaluator)
|
||||
fields = dict(
|
||||
appdaemon_api=self.scheduler, callback_delay=10,
|
||||
trigger_callback_on_entity_creation=True,
|
||||
condition_state_change_delayed=[],
|
||||
condition_state_change_delayed_cb_handle=None,
|
||||
delay_generation=0, trigger_callbacks=Mock(), log=Mock(),
|
||||
)
|
||||
for name, value in fields.items():
|
||||
setattr(self.evaluator, '_Evaluator__' + name, value)
|
||||
|
||||
def change(self, old, new):
|
||||
self.evaluator._Evaluator__on_condition_state_change(
|
||||
'sensor.temperature', 'state', old, new, {})
|
||||
return self.evaluator._Evaluator__condition_state_change_delayed_cb_handle
|
||||
|
||||
def test_queued_obsolete_callback_preserves_new_timer_and_changes(self):
|
||||
old_handle = self.change('20', '21')
|
||||
queued_callback = self.scheduler.expire(old_handle)
|
||||
new_handle = self.change('21', '22')
|
||||
queued_callback()
|
||||
self.assertEqual(self.scheduler.warnings, [])
|
||||
|
||||
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
|
||||
new_handle)
|
||||
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
|
||||
self.evaluator._Evaluator__log.assert_not_called()
|
||||
|
||||
self.scheduler.expire(new_handle)()
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||
reason = self.evaluator._Evaluator__trigger_callbacks.call_args.args[0]
|
||||
self.assertIn('20 => 21', reason)
|
||||
self.assertIn('21 => 22', reason)
|
||||
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed, [])
|
||||
self.assertIsNone(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle)
|
||||
self.evaluator._Evaluator__log.assert_not_called()
|
||||
|
||||
def test_obsolete_callback_after_current_callback_is_ignored(self):
|
||||
queued_callback = self.scheduler.expire(self.change('20', '21'))
|
||||
self.scheduler.expire(self.change('21', '22'))()
|
||||
queued_callback()
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||
self.evaluator._Evaluator__log.assert_not_called()
|
||||
|
||||
def test_running_timer_is_cancelled_when_rescheduled(self):
|
||||
old_handle = self.change('20', '21')
|
||||
new_handle = self.change('21', '22')
|
||||
self.assertEqual(self.scheduler.cancelled, [old_handle])
|
||||
self.assertFalse(self.scheduler.timer_running(old_handle))
|
||||
self.scheduler.expire(new_handle)()
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||
|
||||
def test_expiry_during_cancellation_is_silent_and_preserves_new_batch(self):
|
||||
self.change('20', '21')
|
||||
self.scheduler.expire_on_cancel = True
|
||||
current = self.change('21', '22')
|
||||
self.assertEqual(self.scheduler.warnings, [])
|
||||
self.assertEqual(len(self.scheduler.queued), 1)
|
||||
self.scheduler.queued[0]()
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
|
||||
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
|
||||
current)
|
||||
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
|
||||
self.scheduler.expire(current)()
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||
self.evaluator._Evaluator__log.assert_not_called()
|
||||
|
||||
def test_zero_delay_remains_immediate(self):
|
||||
self.evaluator._Evaluator__callback_delay = 0
|
||||
self.change('20', '21')
|
||||
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||
self.assertEqual(self.scheduler.timers, {})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Time-period boundaries, source updates and timer lifecycle without AppDaemon."""
|
||||
import ast
|
||||
import datetime
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
|
||||
nodes = [node for node in ast.parse(source.read_text()).body
|
||||
if isinstance(node, ast.ClassDef) and node.name in ('VirtualSensorBase', 'TimePeriod', 'VirtualSensors')]
|
||||
from kwargsparser import kwargsParser
|
||||
namespace = {'datetime': datetime, 're': re, 'kwargsParser': kwargsParser, 'ParsingException': ValueError}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), namespace)
|
||||
TimePeriod = namespace['TimePeriod']
|
||||
|
||||
|
||||
class TimePeriodTests(unittest.TestCase):
|
||||
def make_sensor(self, start='08:00:00', end='18:00:00', now='2026-10-25 12:00:00'):
|
||||
self.states = {'input_datetime.start': start, 'input_datetime.end': end}
|
||||
self.api = Mock()
|
||||
self.api.datetime.return_value = datetime.datetime.fromisoformat(now)
|
||||
self.api.get_state.side_effect = self.states.get
|
||||
self.api.entity_exists.return_value = True
|
||||
self.api.get_entity.return_value.get_state.return_value = 'unknown'
|
||||
self.sensor = TimePeriod(self.api, Mock(), virtual_sensor_name='period',
|
||||
yaml_block={'start': 'input_datetime.start', 'end': 'input_datetime.end'})
|
||||
self.sensor._initialize()
|
||||
return self.sensor
|
||||
|
||||
def assert_state(self, expected):
|
||||
self.assertEqual(self.sensor.entity.set_state.call_args.kwargs['state'], expected)
|
||||
|
||||
def test_factory_registration(self):
|
||||
self.make_sensor()
|
||||
factory = namespace['VirtualSensors'](ad_api=self.api, logger_interface=Mock(),
|
||||
yaml_block={'sensors': {'time_period.example': {
|
||||
'start': 'input_datetime.start', 'end': 'input_datetime.end'}}})
|
||||
self.assertIsInstance(factory.virtual_sensors['binary_sensor.example'], TimePeriod)
|
||||
|
||||
def test_day_boundaries_and_timer(self):
|
||||
self.make_sensor(now='2026-10-25 07:59:50')
|
||||
self.assert_state('off')
|
||||
self.assertEqual(self.api.run_in.call_args.args[1], 10)
|
||||
for hour, expected in [('08:00:00', 'on'), ('17:59:59', 'on'), ('18:00:00', 'off')]:
|
||||
self.api.datetime.return_value = datetime.datetime.fromisoformat('2026-10-25 ' + hour)
|
||||
self.sensor.on_time_elapsed({'generation': self.sensor._timer_generation})
|
||||
self.assert_state(expected)
|
||||
|
||||
def test_overnight_and_equal_boundaries(self):
|
||||
for hour, expected in [('23:00:00', 'on'), ('02:00:00', 'on'), ('06:00:00', 'off'), ('12:00:00', 'off')]:
|
||||
self.make_sensor('22:00:00', '06:00:00', '2026-10-25 ' + hour)
|
||||
self.assert_state(expected)
|
||||
self.make_sensor('08:00:00', '08:00:00')
|
||||
self.assert_state('off')
|
||||
|
||||
def test_source_change_cancels_timer_and_ignores_stale_callback(self):
|
||||
self.make_sensor()
|
||||
self.assert_state('on')
|
||||
previous_generation = self.sensor._timer_generation
|
||||
previous_handle = self.sensor.cb_handle
|
||||
self.states['input_datetime.end'] = '10:00:00'
|
||||
self.sensor.on_input_change('input_datetime.end', 'state', '18:00:00', '10:00:00', {})
|
||||
self.assert_state('off')
|
||||
self.api.cancel_timer.assert_called_with(previous_handle, silent=True)
|
||||
count = self.api.run_in.call_count
|
||||
self.sensor.on_time_elapsed({'generation': previous_generation})
|
||||
self.assertEqual(self.api.run_in.call_count, count)
|
||||
self.assertEqual(self.api.listen_state.call_count, 2)
|
||||
|
||||
def test_unavailable_and_recovery(self):
|
||||
for invalid in [None, 'unknown', 'unavailable', '25:00:00', '2026-10-25']:
|
||||
self.make_sensor(start=invalid)
|
||||
self.assert_state('unavailable')
|
||||
self.states['input_datetime.start'] = '08:00:00'
|
||||
self.sensor.update_period()
|
||||
self.assert_state('on')
|
||||
|
||||
def test_dated_periods(self):
|
||||
for start, end, expected in [
|
||||
('2026-10-24 22:00:00', '2026-10-26 06:00:00', 'on'),
|
||||
('2026-10-24 22:00:00', '2026-10-25 12:00:00', 'off'),
|
||||
('2026-10-26 22:00:00', '2026-10-24 06:00:00', 'off'),
|
||||
('2026-10-24', '2026-10-26', 'on'),
|
||||
]:
|
||||
self.make_sensor(start, end)
|
||||
self.assert_state(expected)
|
||||
|
||||
def test_dependencies_constants_and_validation(self):
|
||||
self.make_sensor()
|
||||
self.assertEqual(self.sensor.sensor_name, 'binary_sensor.period')
|
||||
self.sensor.constants['begin'] = 'input_datetime.start'
|
||||
self.sensor.yaml_block['start'] = 'begin'
|
||||
self.assertEqual(self.sensor.generate_dependencies_list(), list(self.states))
|
||||
self.sensor.yaml_block['end'] = 'sensor.invalid'
|
||||
with self.assertRaises(ValueError):
|
||||
self.sensor.generate_dependencies_list()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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
@@ -22,9 +22,9 @@ class VirtualEvents(hass.Hass,LoggerInterface):
|
||||
def on_condition_succeed(self,event_name):
|
||||
event_yaml = self.args["virtual_events"][event_name]
|
||||
if 'event_data' in event_yaml:
|
||||
self.log(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
|
||||
self.log_info(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
|
||||
self.fire_event(event_yaml['event_name'],**event_yaml['event_data'])
|
||||
else:
|
||||
self.log(f"Sending event {event_yaml['event_name']}")
|
||||
self.log_info(f"Sending event {event_yaml['event_name']}")
|
||||
self.fire_event(event_yaml['event_name'])
|
||||
|
||||
+281
-54
@@ -4,6 +4,8 @@ import smartvalue as SmartValue
|
||||
from kwargsparser import kwargsParser
|
||||
from expressionparser import ParsingException
|
||||
import time
|
||||
import datetime
|
||||
import re
|
||||
from logger_interface import LoggerInterface
|
||||
from ad_toolbox.eventhandler import EventHandler
|
||||
|
||||
@@ -45,6 +47,11 @@ class VirtualSensorBase:
|
||||
if super_entity_id != None:
|
||||
self.constants['super'] = super_entity_id
|
||||
|
||||
self.entity_existed = self.ad_api.entity_exists(self.sensor_name)
|
||||
if not self.entity_existed:
|
||||
self.ad_api.set_state(self.sensor_name, state='unknown',attributes=self.attributes, check_existence=False)
|
||||
self.entity = self.ad_api.get_entity(self.sensor_name)
|
||||
|
||||
if self_initialize:
|
||||
self._initialize()
|
||||
|
||||
@@ -55,31 +62,64 @@ class VirtualSensorBase:
|
||||
# assert len(splitted_name) == 2,f"Invalid virtual sensor name : {virtual_sensor_name}"
|
||||
# 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 []
|
||||
|
||||
@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):
|
||||
dependencies = list()
|
||||
if 'sensor_attributes' in self.yaml_block:
|
||||
for key,entity in self.yaml_block['sensor_attributes'].items():
|
||||
dependencies.append(entity)
|
||||
for expression in self.yaml_block['sensor_attributes'].values():
|
||||
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
|
||||
|
||||
def _initialize(self):
|
||||
self.attribute_expressions = dict()
|
||||
if 'sensor_attributes' in self.yaml_block:
|
||||
for attribute_name,sensor_name in self.yaml_block['sensor_attributes'].items():
|
||||
if not self.ad_api.entity_exists(sensor_name):
|
||||
self.log_error(f"Error in sensor_attribute. {attribute_name}:{sensor_name}. {sensor_name} doesn't exist")
|
||||
for attribute_name,expression in self.yaml_block['sensor_attributes'].items():
|
||||
if self.is_entity_reference(expression):
|
||||
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:
|
||||
self.attributes[attribute_name] = self.ad_api.get_state(sensor_name)
|
||||
self.ad_api.listen_state(self.on_attribute_sensor_state_change,sensor_name)
|
||||
try:
|
||||
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()
|
||||
except ParsingException as e:
|
||||
@@ -96,6 +136,10 @@ class VirtualSensorBase:
|
||||
if attributes_update:
|
||||
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 extract_dependencies_from_entities_to_listen(self,entities_to_listen,dependencies):
|
||||
@@ -297,7 +341,7 @@ class ValueSelector(VirtualSensorBase):
|
||||
|
||||
def process_config(self) -> list[SelectorConfig]:
|
||||
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']
|
||||
else: values_block = self.yaml_block
|
||||
|
||||
@@ -322,7 +366,7 @@ class ValueSelector(VirtualSensorBase):
|
||||
selectors_config = self.process_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)
|
||||
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)
|
||||
|
||||
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:
|
||||
if selector.conditions.evaluate(False) == SmartCondition.Result.Succeeded:
|
||||
new_value = selector.compute_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
|
||||
selector.conditions.log_callback_trigger_reason()
|
||||
selector.conditions.log_evaluation_result()
|
||||
if selector.value:
|
||||
selector.value.log_callback_trigger_reason()
|
||||
selector.value.log_evaluation_result()
|
||||
self.set_value(new_value)
|
||||
break
|
||||
for selector in self.value_selectors:
|
||||
if selector.conditions.evaluate(False) == SmartCondition.Result.Succeeded:
|
||||
new_value = selector.compute_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
|
||||
selector.conditions.log_callback_trigger_reason()
|
||||
selector.conditions.log_evaluation_result()
|
||||
if selector.value:
|
||||
selector.value.log_callback_trigger_reason()
|
||||
selector.value.log_evaluation_result()
|
||||
self.set_value(new_value)
|
||||
break
|
||||
|
||||
self.log_info(f"{self.sensor_name} = {self.output_sensor.get_state()}")
|
||||
|
||||
class ContinuousCondition(VirtualSensorBase):
|
||||
def initialize(self):
|
||||
self.cb_handle = None
|
||||
self._timer_generation = 0
|
||||
self.time = self.yaml_block['time']
|
||||
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
|
||||
except KeyError: self.ignore_unavailable_condition = False
|
||||
try: self.invert = self.yaml_block['invert']
|
||||
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)
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
succeed_cb = self.on_condition_fail
|
||||
@@ -406,14 +454,22 @@ class ContinuousCondition(VirtualSensorBase):
|
||||
|
||||
def register_callback(self):
|
||||
if self.cb_handle == None:
|
||||
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.time)
|
||||
self._timer_generation += 1
|
||||
self.cb_handle = self.ad_api.run_in(
|
||||
self.on_time_elapse, self.time, generation=self._timer_generation)
|
||||
|
||||
def cancel_callback(self):
|
||||
if self.cb_handle != None:
|
||||
self.ad_api.cancel_timer(self.cb_handle)
|
||||
self.cb_handle = None
|
||||
# An expired timer may already have a callback queued in the worker.
|
||||
# Invalidate it before cancellation, even if it is no longer scheduled.
|
||||
self._timer_generation += 1
|
||||
handle = self.cb_handle
|
||||
self.cb_handle = None
|
||||
if handle is not None:
|
||||
self.ad_api.cancel_timer(handle, silent=True)
|
||||
|
||||
def on_time_elapse(self, kwargs):
|
||||
if kwargs.get('generation') != self._timer_generation:
|
||||
return
|
||||
self.cb_handle = None
|
||||
value = 'off' if self.invert else 'on'
|
||||
if self.output_sensor.get_state() != value:
|
||||
@@ -428,25 +484,84 @@ class ContinuousCondition(VirtualSensorBase):
|
||||
if self.output_sensor.get_state() != value:
|
||||
self.output_sensor.set_state(state = value)
|
||||
|
||||
class EphemeralCondition(ContinuousCondition):
|
||||
"""True during the first `time` seconds of each true condition.
|
||||
|
||||
condition_started_at is a Unix timestamp retained after expiry, until
|
||||
the condition becomes false. Reloads resume this window when the entity
|
||||
attributes survive; without a timestamp, a true condition starts anew.
|
||||
"""
|
||||
|
||||
def initialize(self):
|
||||
self._condition_active = False
|
||||
if self.yaml_block.get('invert', False):
|
||||
raise ValueError("ephemeral_condition does not support invert; negate conditions instead")
|
||||
started_at = self.ad_api.get_state(
|
||||
self.sensor_name, attribute='condition_started_at')
|
||||
try:
|
||||
started_at = float(started_at)
|
||||
if not 0 <= started_at <= time.time():
|
||||
started_at = None
|
||||
except (TypeError, ValueError):
|
||||
started_at = None
|
||||
self._started_at = started_at
|
||||
self.attributes['condition_started_at'] = started_at
|
||||
# Unknown inputs during startup must not erase a restored window.
|
||||
self.yaml_block = dict(self.yaml_block)
|
||||
self.yaml_block.setdefault('ignore_unavailable_condition', True)
|
||||
super().initialize()
|
||||
|
||||
def on_condition_succeed(self):
|
||||
if self._condition_active:
|
||||
return
|
||||
self._condition_active = True
|
||||
if self._started_at is None:
|
||||
self._started_at = time.time()
|
||||
self.attributes['condition_started_at'] = self._started_at
|
||||
remaining = self._started_at + self.time - time.time()
|
||||
self.output_sensor.set_state(
|
||||
state='on' if remaining > 0 else 'off',
|
||||
attributes={'condition_started_at': self._started_at})
|
||||
if remaining > 0:
|
||||
self._timer_generation += 1
|
||||
self.cb_handle = self.ad_api.run_in(
|
||||
self.on_time_elapse, remaining, generation=self._timer_generation)
|
||||
|
||||
def on_condition_fail(self):
|
||||
self._condition_active = False
|
||||
self._started_at = None
|
||||
self.attributes['condition_started_at'] = None
|
||||
self.cancel_callback()
|
||||
self.output_sensor.set_state(
|
||||
state='off', attributes={'condition_started_at': None})
|
||||
|
||||
def on_time_elapse(self, kwargs):
|
||||
if kwargs.get('generation') != self._timer_generation:
|
||||
return
|
||||
self.cb_handle = None
|
||||
self.output_sensor.set_state(state='off')
|
||||
|
||||
|
||||
class RetainCondition(VirtualSensorBase):
|
||||
def initialize(self):
|
||||
self.cb_handle = None
|
||||
self._timer_generation = 0
|
||||
self.retain_time = self.yaml_block['retain_time']
|
||||
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
|
||||
except KeyError: self.reset_retain_time_on_new_success = True
|
||||
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)
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
# new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
|
||||
@@ -464,14 +579,21 @@ class RetainCondition(VirtualSensorBase):
|
||||
|
||||
def register_callback(self):
|
||||
if self.cb_handle == None:
|
||||
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.retain_time)
|
||||
self._timer_generation += 1
|
||||
self.cb_handle = self.ad_api.run_in(
|
||||
self.on_time_elapse, self.retain_time, generation=self._timer_generation)
|
||||
|
||||
def cancel_callback(self):
|
||||
if self.cb_handle != None:
|
||||
self.ad_api.cancel_timer(self.cb_handle)
|
||||
self.cb_handle = None
|
||||
# Invalidate callbacks already queued by an expired timer as well.
|
||||
self._timer_generation += 1
|
||||
handle = self.cb_handle
|
||||
self.cb_handle = None
|
||||
if handle is not None:
|
||||
self.ad_api.cancel_timer(handle, silent=True)
|
||||
|
||||
def on_time_elapse(self, kwargs):
|
||||
if kwargs.get('generation') != self._timer_generation:
|
||||
return
|
||||
self.cb_handle = None
|
||||
if self.condition.evaluate() == SmartCondition.Result.Failed:
|
||||
if self.output_sensor.get_state() != 'off':
|
||||
@@ -497,6 +619,90 @@ class RetainCondition(VirtualSensorBase):
|
||||
if self.output_sensor.get_state() != 'off':
|
||||
self.output_sensor.set_state(state = 'off')
|
||||
|
||||
class TimePeriod(VirtualSensorBase):
|
||||
"""Active in [start, end), using two input_datetime entities.
|
||||
|
||||
Time-only inputs repeat daily and may cross midnight. Dated inputs
|
||||
describe a single interval. Equal boundaries describe an empty interval.
|
||||
"""
|
||||
|
||||
def generate_sensor_name(self, virtual_sensor_name):
|
||||
return f"binary_sensor.{virtual_sensor_name}"
|
||||
|
||||
def generate_dependencies_list(self):
|
||||
inputs = []
|
||||
for key in ('start', 'end'):
|
||||
value = self.yaml_block.get(key)
|
||||
value = self.constants.get(value, value) if isinstance(value, str) else value
|
||||
if not self.is_entity_reference(value) or not value.startswith('input_datetime.'):
|
||||
raise ValueError(f"{self.sensor_name}: {key} must reference an input_datetime")
|
||||
inputs.append(value)
|
||||
return inputs
|
||||
|
||||
def initialize(self):
|
||||
self.start_entity, self.end_entity = self.generate_dependencies_list()
|
||||
self.cb_handle = None
|
||||
self._timer_generation = 0
|
||||
for entity in set((self.start_entity, self.end_entity)):
|
||||
self.ad_api.listen_state(self.on_input_change, entity)
|
||||
self.update_period()
|
||||
|
||||
@staticmethod
|
||||
def parse_boundary(value):
|
||||
for fmt, kind in (('%H:%M:%S', 'time'),
|
||||
('%Y-%m-%d %H:%M:%S', 'datetime'),
|
||||
('%Y-%m-%d', 'date')):
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, fmt)
|
||||
return kind, parsed.time() if kind == 'time' else parsed
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
raise ValueError('Unavailable or invalid input_datetime state')
|
||||
|
||||
def on_input_change(self, entity, attribute, old, new, kwargs):
|
||||
self.update_period()
|
||||
|
||||
def on_time_elapsed(self, kwargs):
|
||||
if kwargs.get('generation') != self._timer_generation:
|
||||
return
|
||||
self.cb_handle = None
|
||||
self.update_period()
|
||||
|
||||
def update_period(self):
|
||||
self._timer_generation += 1
|
||||
if self.cb_handle is not None:
|
||||
self.ad_api.cancel_timer(self.cb_handle, silent=True)
|
||||
self.cb_handle = None
|
||||
now = self.ad_api.datetime()
|
||||
delay = 60.0
|
||||
try:
|
||||
start_kind, start = self.parse_boundary(self.ad_api.get_state(self.start_entity))
|
||||
end_kind, end = self.parse_boundary(self.ad_api.get_state(self.end_entity))
|
||||
if start_kind != end_kind:
|
||||
raise ValueError('Both inputs must have the same date/time format')
|
||||
if start_kind == 'time':
|
||||
current = now.time()
|
||||
active = (start <= current < end if start <= end
|
||||
else current >= start or current < end)
|
||||
boundaries = [datetime.datetime.combine(now.date(), value)
|
||||
for value in (start, end)]
|
||||
boundaries = [value if value > now else value + datetime.timedelta(days=1)
|
||||
for value in boundaries]
|
||||
else:
|
||||
active = start <= now < end
|
||||
boundaries = [value for value in (start, end) if value > now]
|
||||
state = 'on' if active else 'off'
|
||||
if boundaries:
|
||||
delay = min(delay, min((value - now).total_seconds() for value in boundaries))
|
||||
except ValueError:
|
||||
state = 'unavailable'
|
||||
if self.entity.get_state() != state:
|
||||
self.entity.set_state(state=state)
|
||||
# Refresh at boundaries, and at least every minute to follow clock/DST changes.
|
||||
self.cb_handle = self.ad_api.run_in(
|
||||
self.on_time_elapsed, delay, generation=self._timer_generation)
|
||||
|
||||
|
||||
class BinarySensor(VirtualSensorBase):
|
||||
def initialize(self):
|
||||
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
||||
@@ -505,15 +711,23 @@ class BinarySensor(VirtualSensorBase):
|
||||
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.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'
|
||||
self.output_sensor.set_state(state = new_value)
|
||||
|
||||
def on_sensor_change(self,prev_result,result):
|
||||
if result == SmartCondition.Result.Unavailable: self.output_sensor.set_state(state = 'unavailable')
|
||||
elif result == SmartCondition.Result.Succeeded: self.output_sensor.set_state(state = 'on')
|
||||
elif result == SmartCondition.Result.Failed: self.output_sensor.set_state(state = 'off')
|
||||
# else: Disabled means we keep the previous value
|
||||
target_state = None
|
||||
if result == SmartCondition.Result.Unavailable: target_state = 'unavailable'
|
||||
elif result == SmartCondition.Result.Succeeded: target_state = 'on'
|
||||
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):
|
||||
dependencies = list()
|
||||
@@ -543,20 +757,28 @@ class EventSensor(VirtualSensorBase):
|
||||
|
||||
self.retain_time = self.yaml_block['retain_time']
|
||||
self.cb_handle = None
|
||||
self._timer_generation = 0
|
||||
|
||||
self.event_handlers = []
|
||||
if "activation_events" in self.yaml_block:
|
||||
self.event_handlers.append(EventHandler(self.ad_api, self.yaml_block["activation_events"], self.on_activation_event,logger_interface = self.logger_interface))
|
||||
|
||||
def on_activation_event(self, event_name, data, kwargs):
|
||||
self._timer_generation += 1
|
||||
self.output_sensor.set_state(state = 'on')
|
||||
if self.cb_handle is not None:
|
||||
self.ad_api.cancel_timer(self.cb_handle)
|
||||
self.cb_handle = self.ad_api.run_in(self.on_retain_time_elapsed, self.retain_time)
|
||||
self.ad_api.cancel_timer(self.cb_handle, silent=True)
|
||||
self.cb_handle = self.ad_api.run_in(
|
||||
self.on_retain_time_elapsed, self.retain_time,
|
||||
generation=self._timer_generation,
|
||||
)
|
||||
|
||||
def on_retain_time_elapsed(self, kwargs):
|
||||
self.output_sensor.set_state(state = 'off')
|
||||
# Cancellation cannot retract a callback already queued by AppDaemon.
|
||||
if kwargs.get('generation') != self._timer_generation:
|
||||
return
|
||||
self.cb_handle = None
|
||||
self.output_sensor.set_state(state = 'off')
|
||||
|
||||
def generate_sensor_name(self,virtual_sensor_name):
|
||||
return f"binary_sensor.{virtual_sensor_name}"
|
||||
@@ -608,7 +830,7 @@ class VirtualSensors():
|
||||
for sensor_name,default_value in default_values.items():
|
||||
if not self.ad_api.entity_exists(sensor_name):
|
||||
self.logger_interface.log_info(f"Creating sensor {sensor_name} = {default_value}")
|
||||
self.ad_api.set_state(sensor_name, state = str(default_value))
|
||||
self.ad_api.set_state(sensor_name, state = str(default_value),check_existence=False)
|
||||
|
||||
averagers = parser.parse_args('averagers',{})
|
||||
for averager in averagers:
|
||||
@@ -635,6 +857,8 @@ class VirtualSensors():
|
||||
|
||||
if splitted_sensor[0] == 'binary_sensor':
|
||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = BinarySensor(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||
elif splitted_sensor[0] == 'time_period':
|
||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = TimePeriod(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||
elif splitted_sensor[0] == 'sensor':
|
||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSensor(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||
elif splitted_sensor[0] == 'continuous_condition':
|
||||
@@ -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)
|
||||
elif splitted_sensor[0] == 'value_selector':
|
||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||
elif splitted_sensor[0] == 'ephemeral_condition':
|
||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = EphemeralCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||
elif splitted_sensor[0] == 'retain_condition':
|
||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||
elif splitted_sensor[0] == 'event_sensor':
|
||||
@@ -712,4 +938,5 @@ class VirtualSensors():
|
||||
class VirtualSensorsApp(hass.Hass):
|
||||
def initialize(self):
|
||||
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)
|
||||
@@ -2,3 +2,8 @@ node_modules/
|
||||
src/
|
||||
tsconfig.json
|
||||
.gitignore
|
||||
|
||||
tests/**
|
||||
|
||||
*.vsix
|
||||
@eaDir/**
|
||||
@@ -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
@@ -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
|
||||
utilisées avec Home Assistant.
|
||||
Version: **0.2.0**
|
||||
|
||||
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`).
|
||||
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.
|
||||
## Log explorer
|
||||
|
||||
## 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
|
||||
dans `.vscode/settings.json` :
|
||||
By default, it reads the existing AppDaemon text logs from the workspace:
|
||||
|
||||
- `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
|
||||
{
|
||||
"appdaemon.haUrl": "http://homeassistant:8123",
|
||||
"appdaemon.haToken": "<TOKEN_HOME_ASSISTANT>",
|
||||
"appdaemon.haToken": "<HOME_ASSISTANT_TOKEN>",
|
||||
"appdaemon.adUrl": "http://localhost:5050",
|
||||
"appdaemon.errorLogPath": "logs/error.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 |
|
||||
| --- | --- | --- |
|
||||
| `appdaemon.haUrl` | `http://localhost:8123` | URL de Home Assistant |
|
||||
| `appdaemon.haToken` | vide | Jeton longue durée Home Assistant |
|
||||
| `appdaemon.adUrl` | `http://localhost:5050` | URL de l’API HTTP AppDaemon |
|
||||
| `appdaemon.errorLogPath` | `logs/error.log` | Journal d’erreurs, relatif au workspace |
|
||||
| `appdaemon.mainLogPath` | `logs/appdaemon.log` | Journal principal, relatif au workspace |
|
||||
| `appdaemon.entityRefreshInterval` | `300` | Rafraîchissement des entités, en secondes |
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| `haUrl`, `haToken` | Home Assistant connection for entity information |
|
||||
| `adUrl` | AppDaemon HTTP API URL |
|
||||
| `errorLogPath`, `mainLogPath` | Log locations relative to the workspace |
|
||||
| `entityRefreshInterval` | Entity refresh interval in seconds |
|
||||
| `logExplorerPaths` | Text log files followed by the explorer |
|
||||
| `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
|
||||
utilisateur ou un fichier local ignoré par Git.
|
||||
Do not commit your Home Assistant token. Use user settings or an ignored local
|
||||
settings file.
|
||||
|
||||
## Raccourcis clavier
|
||||
## Other extension features
|
||||
|
||||
| Action | Windows / Linux | macOS |
|
||||
| --- | --- | --- |
|
||||
| Aller à une application AppDaemon | `Ctrl+Shift+A` | `Cmd+Shift+A` |
|
||||
| Aller à un capteur virtuel | `Ctrl+Shift+V` | `Cmd+Shift+V` |
|
||||
- Home Assistant entity completion and hover information in Python/YAML.
|
||||
- Navigation to AppDaemon app and virtual-sensor definitions.
|
||||
- AppDaemon errors as VS Code diagnostics and an output channel.
|
||||
- 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
|
||||
l’application ou le capteur souhaité.
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| 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`),
|
||||
rechercher `AppDaemon`, puis modifier la commande voulue.
|
||||
Commands are available under **AppDaemon:** in the Command Palette: **Go to
|
||||
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`)
|
||||
en recherchant `AppDaemon:`.
|
||||
|
||||
- **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.
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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 |
@@ -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)}
|
||||
@@ -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>
|
||||
@@ -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(); }
|
||||
})();
|
||||
Generated
+533
-2
@@ -1,21 +1,151 @@
|
||||
{
|
||||
"name": "vscode-appdaemon",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vscode-appdaemon",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
@@ -33,6 +163,307 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -53,6 +484,106 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "vscode-appdaemon",
|
||||
"displayName": "AppDaemon for Home Assistant",
|
||||
"description": "AppDaemon development tools: status bar controls, entity autocompletion, hover tooltips, error viewer",
|
||||
"version": "0.1.0",
|
||||
"description": "AppDaemon development tools: log explorer, status bar controls, entity autocompletion, and error viewer",
|
||||
"version": "0.2.0",
|
||||
"publisher": "ad-toolbox",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
@@ -47,6 +47,33 @@
|
||||
"type": "number",
|
||||
"default": 300,
|
||||
"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",
|
||||
"icon": "$(sync)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.handleErrors",
|
||||
"title": "AppDaemon: Handle Errors"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.clearErrors",
|
||||
"title": "AppDaemon: Clear Error Diagnostics"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.exploreLogs",
|
||||
"title": "AppDaemon: Explore Logs",
|
||||
"icon": "$(list-tree)"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
@@ -111,16 +139,19 @@
|
||||
"key": "ctrl+shift+v",
|
||||
"mac": "cmd+shift+v"
|
||||
}
|
||||
]
|
||||
],
|
||||
"menus": {}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run compile",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./"
|
||||
"watch": "tsc -watch -p ./",
|
||||
"test": "npm run compile && node --test tests/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ function normalizeUrl(raw: string): string {
|
||||
|
||||
export class ADClient {
|
||||
private productionMode = false;
|
||||
private _onProductionModeChanged = new vscode.EventEmitter<boolean>();
|
||||
readonly onProductionModeChanged = this._onProductionModeChanged.event;
|
||||
private log: vscode.OutputChannel;
|
||||
|
||||
constructor(log: vscode.OutputChannel) {
|
||||
@@ -120,7 +118,6 @@ export class ADClient {
|
||||
async setProductionMode(mode: boolean): Promise<void> {
|
||||
await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode });
|
||||
this.productionMode = mode;
|
||||
this._onProductionModeChanged.fire(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +132,6 @@ export class ADClient {
|
||||
if (key.includes('production_mode')) {
|
||||
const val = (state[key] as { state?: unknown })?.state;
|
||||
this.productionMode = val === true || val === 'True' || val === 'true';
|
||||
this._onProductionModeChanged.fire(this.productionMode);
|
||||
return this.productionMode;
|
||||
}
|
||||
}
|
||||
@@ -155,6 +151,5 @@ export class ADClient {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._onProductionModeChanged.dispose();
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,6 @@ export class ErrorViewer {
|
||||
private diagnosticCollection: vscode.DiagnosticCollection;
|
||||
private watcher: fs.FSWatcher | undefined;
|
||||
private lastSize = 0;
|
||||
private errorCount = 0;
|
||||
|
||||
private _onErrorCountChanged = new vscode.EventEmitter<number>();
|
||||
readonly onErrorCountChanged = this._onErrorCountChanged.event;
|
||||
|
||||
constructor() {
|
||||
this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors');
|
||||
@@ -30,12 +26,6 @@ export class ErrorViewer {
|
||||
|
||||
clearDiagnostics() {
|
||||
this.diagnosticCollection.clear();
|
||||
this.errorCount = 0;
|
||||
this._onErrorCountChanged.fire(0);
|
||||
}
|
||||
|
||||
getErrorCount(): number {
|
||||
return this.errorCount;
|
||||
}
|
||||
|
||||
// ── file watching ────────────────────────────────────────────────────────
|
||||
@@ -115,12 +105,9 @@ export class ErrorViewer {
|
||||
this.outputChannel.appendLine(trimmed);
|
||||
|
||||
if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) {
|
||||
this.errorCount++;
|
||||
this.parseDiagnostic(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
this._onErrorCountChanged.fire(this.errorCount);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
@@ -183,6 +170,5 @@ export class ErrorViewer {
|
||||
}
|
||||
this.outputChannel.dispose();
|
||||
this.diagnosticCollection.dispose();
|
||||
this._onErrorCountChanged.dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ADClient } from './adClient';
|
||||
import { StatusBarManager } from './statusBar';
|
||||
import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider';
|
||||
import { ErrorViewer } from './errorViewer';
|
||||
import { LogExplorer } from './logExplorer';
|
||||
|
||||
let haClient: HAClient;
|
||||
let adClient: ADClient;
|
||||
@@ -344,10 +345,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
haClient = new HAClient(outputChannel);
|
||||
adClient = new ADClient(outputChannel);
|
||||
errorViewer = new ErrorViewer();
|
||||
statusBar = new StatusBarManager(adClient);
|
||||
|
||||
// Wire error count → status bar badge
|
||||
errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count));
|
||||
const logExplorer = new LogExplorer(context, count => statusBar.setLogErrorCount(count));
|
||||
context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show()));
|
||||
statusBar = new StatusBarManager();
|
||||
logExplorer.startPolling();
|
||||
|
||||
// ── Contextual per-file restart button ───────────────────────────────────
|
||||
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);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('appdaemon.goToApp', async () => {
|
||||
const apps = await findAllApps();
|
||||
vscode.commands.registerCommand('appdaemon.goToApp', async (requestedName?: string) => {
|
||||
const apps = (await findAllApps()).filter(app => !requestedName || app.appName === requestedName);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -458,7 +461,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
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…',
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: false
|
||||
@@ -540,18 +543,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand('appdaemon.showErrors', () => {
|
||||
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 () => {
|
||||
const entities = await haClient.fetchEntities();
|
||||
vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`);
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ADClient } from './adClient';
|
||||
|
||||
export class StatusBarManager {
|
||||
private restartADItem: vscode.StatusBarItem;
|
||||
private restartHAItem: vscode.StatusBarItem;
|
||||
private productionModeItem: vscode.StatusBarItem;
|
||||
private errorItem: vscode.StatusBarItem;
|
||||
private contextualItem: vscode.StatusBarItem;
|
||||
private logsItem: vscode.StatusBarItem;
|
||||
|
||||
constructor(private adClient: ADClient) {
|
||||
constructor() {
|
||||
// Reload AppDaemon apps
|
||||
this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
this.restartADItem.command = 'appdaemon.restartAD';
|
||||
@@ -23,25 +21,16 @@ export class StatusBarManager {
|
||||
this.restartHAItem.tooltip = 'Restart Home Assistant';
|
||||
this.restartHAItem.show();
|
||||
|
||||
// Production mode toggle
|
||||
this.productionModeItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 98);
|
||||
this.productionModeItem.command = 'appdaemon.toggleProductionMode';
|
||||
this.updateProductionModeDisplay();
|
||||
this.productionModeItem.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();
|
||||
// Log explorer shortcut, available even when no editor is open.
|
||||
this.logsItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 96);
|
||||
this.logsItem.command = 'appdaemon.exploreLogs';
|
||||
this.logsItem.text = '$(list-tree) AD Logs';
|
||||
this.logsItem.tooltip = 'Open AppDaemon Log Explorer';
|
||||
this.logsItem.show();
|
||||
|
||||
// Contextual per-file restart button (hidden by default)
|
||||
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
|
||||
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
|
||||
|
||||
// React to production mode changes
|
||||
adClient.onProductionModeChanged(() => this.updateProductionModeDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,38 +50,17 @@ export class StatusBarManager {
|
||||
this.contextualItem.show();
|
||||
}
|
||||
|
||||
updateProductionModeDisplay() {
|
||||
const isProduction = this.adClient.isProductionMode();
|
||||
if (isProduction) {
|
||||
this.productionModeItem.text = '$(eye-closed) Prod ON';
|
||||
this.productionModeItem.tooltip = 'Production Mode: ON — click to disable';
|
||||
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';
|
||||
}
|
||||
setLogErrorCount(count: number) {
|
||||
this.logsItem.backgroundColor = count > 0 ? new vscode.ThemeColor('statusBarItem.errorBackground') : undefined;
|
||||
this.logsItem.tooltip = count > 0
|
||||
? `${count} error${count === 1 ? '' : 's'} in loaded AppDaemon logs — open Log Explorer`
|
||||
: 'Open AppDaemon Log Explorer';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.restartADItem.dispose();
|
||||
this.restartHAItem.dispose();
|
||||
this.productionModeItem.dispose();
|
||||
this.errorItem.dispose();
|
||||
this.logsItem.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');
|
||||
});
|
||||
@@ -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:/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
Reference in new issue
Block a user