Compare commits
22
Commits
ece851f5a0
..
main
| 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 |
No files matched your search
+19
-17
@@ -43,23 +43,25 @@ class ServiceCaller(ConditionalAction):
|
|||||||
|
|
||||||
class EventsForwarder(hass.Hass, LoggerInterface):
|
class EventsForwarder(hass.Hass, LoggerInterface):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.initialize_logger_interface(self.get_ad_api())
|
with self.log_group("App Initialization"):
|
||||||
self.mute_logger(self.args.get('mute', False))
|
self.initialize_logger_interface(self.get_ad_api())
|
||||||
self.forwarders = {}
|
self.mute_logger(self.args.get('mute', False))
|
||||||
self.services_caller = {}
|
self.forwarders = {}
|
||||||
self.event_handlers = []
|
self.services_caller = {}
|
||||||
for key, block in self.args.get('forwarders', {}).items():
|
self.event_handlers = []
|
||||||
try:
|
for key, block in self.args.get('forwarders', {}).items():
|
||||||
senders = [EventSender(self, config)
|
try:
|
||||||
for config in block.get('events_to_send', {}).values()]
|
senders = [EventSender(self, config)
|
||||||
callers = [ServiceCaller(self, config)
|
for config in block.get('events_to_send', {}).values()]
|
||||||
for config in block.get('services_to_call', {}).values()]
|
callers = [ServiceCaller(self, config)
|
||||||
self.forwarders[key] = senders
|
for config in block.get('services_to_call', {}).values()]
|
||||||
self.services_caller[key] = callers
|
self.forwarders[key] = senders
|
||||||
self.event_handlers.append(EventHandler(
|
self.services_caller[key] = callers
|
||||||
self, block['events_to_listen'], self.on_event, key))
|
self.event_handlers.append(EventHandler(
|
||||||
except ParsingException as error:
|
self, block['events_to_listen'], self.on_event, key))
|
||||||
self.log_error(f"Invalid forwarder {key}: {error}")
|
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):
|
def on_event(self, event_name, event_data, forwarder_key):
|
||||||
for sender in self.forwarders.get(forwarder_key, []):
|
for sender in self.forwarders.get(forwarder_key, []):
|
||||||
|
|||||||
+13
-8
@@ -27,14 +27,19 @@ class LoggerInterface:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.initialized = False
|
self.initialized = False
|
||||||
|
|
||||||
def __init__(self, ad_api, default_log='main_log', mute=False):
|
def __init__(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
|
||||||
self.initialize_logger_interface(ad_api, default_log, mute)
|
self.initialize_logger_interface(ad_api, default_log, mute, ascii_encode=ascii_encode)
|
||||||
|
|
||||||
def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False):
|
def initialize_logger_interface(self, ad_api, default_log='main_log', mute=False, *, ascii_encode=None):
|
||||||
self.ad_api = ad_api
|
self.ad_api = ad_api
|
||||||
self.default_log = default_log
|
self.default_log = default_log
|
||||||
self.mute = False
|
self.mute = False
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
|
self._log_options = {} if ascii_encode is None else {'ascii_encode': ascii_encode}
|
||||||
|
|
||||||
|
def _write_log(self, message, level, log):
|
||||||
|
self.ad_api.log(self._tag_log_message(message), level=level, log=log,
|
||||||
|
**self._log_options)
|
||||||
|
|
||||||
def log_group(self, kind, label=None):
|
def log_group(self, kind, label=None):
|
||||||
return log_group(kind, label)
|
return log_group(kind, label)
|
||||||
@@ -51,17 +56,17 @@ class LoggerInterface:
|
|||||||
|
|
||||||
def mute_logger(self, mute: bool):
|
def mute_logger(self, mute: bool):
|
||||||
if mute:
|
if mute:
|
||||||
self.ad_api.log(self._tag_log_message('Muting logger'), level='INFO', log=self.default_log)
|
self._write_log('Muting logger', 'INFO', self.default_log)
|
||||||
self.mute = bool(mute)
|
self.mute = bool(mute)
|
||||||
|
|
||||||
def log_info(self, message):
|
def log_info(self, message):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
if not self.mute:
|
if not self.mute:
|
||||||
self.ad_api.log(self._tag_log_message(message), level='INFO', log=self.default_log)
|
self._write_log(message, 'INFO', self.default_log)
|
||||||
|
|
||||||
def log_warning(self, message):
|
def log_warning(self, message):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
self.ad_api.log(self._tag_log_message(message), level='WARNING', log=self.default_log)
|
self._write_log(message, 'WARNING', self.default_log)
|
||||||
|
|
||||||
def log_error(self, message, stop_app=False, dump_stack=False):
|
def log_error(self, message, stop_app=False, dump_stack=False):
|
||||||
assert self.initialized
|
assert self.initialized
|
||||||
@@ -74,8 +79,8 @@ class LoggerInterface:
|
|||||||
stack = ''
|
stack = ''
|
||||||
|
|
||||||
message = self._tag_log_message(message)
|
message = self._tag_log_message(message)
|
||||||
self.ad_api.log(message, level='ERROR', log=self.default_log)
|
self._write_log(message, 'ERROR', self.default_log)
|
||||||
self.ad_api.log(message + stack, level='ERROR', log='error_log')
|
self._write_log(message + stack, 'ERROR', 'error_log')
|
||||||
|
|
||||||
if stop_app:
|
if stop_app:
|
||||||
self.stop_app(self.name)
|
self.stop_app(self.name)
|
||||||
+25
-9
@@ -46,6 +46,7 @@ class Evaluator():
|
|||||||
self.__state_callbacks_handle = list()
|
self.__state_callbacks_handle = list()
|
||||||
self.__condition_state_change_delayed = list()
|
self.__condition_state_change_delayed = list()
|
||||||
self.__condition_state_change_delayed_cb_handle = None
|
self.__condition_state_change_delayed_cb_handle = None
|
||||||
|
self.__delay_generation = 0
|
||||||
|
|
||||||
self.__appdaemon_api = appdaemon_api
|
self.__appdaemon_api = appdaemon_api
|
||||||
self.__activated = False
|
self.__activated = False
|
||||||
@@ -57,7 +58,13 @@ class Evaluator():
|
|||||||
|
|
||||||
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
|
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
|
||||||
condition_name = parser.parse_args('condition_name',None,['log_prefix'])
|
condition_name = parser.parse_args('condition_name',None,['log_prefix'])
|
||||||
self.__trace_name = ', '.join(map(str, condition_name)) if isinstance(condition_name, list) else str(condition_name or 'condition')
|
log_group = parser.parse_args('log_group',None)
|
||||||
|
if log_group is not None:
|
||||||
|
self.__log_group = str(log_group)
|
||||||
|
elif isinstance(condition_name, list):
|
||||||
|
self.__log_group = ', '.join(map(str, condition_name))
|
||||||
|
else:
|
||||||
|
self.__log_group = str(condition_name or 'condition')
|
||||||
if condition_name:
|
if condition_name:
|
||||||
if isinstance(condition_name,list):
|
if isinstance(condition_name,list):
|
||||||
for name in condition_name:
|
for name in condition_name:
|
||||||
@@ -163,17 +170,17 @@ class Evaluator():
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
def __log_group(self):
|
def __log_group_scope(self):
|
||||||
# SmartObject itself implements LoggerInterface, but many evaluators are
|
# SmartObject itself implements LoggerInterface, but many evaluators are
|
||||||
# constructed with only the app as their API and no logger_interface kwarg.
|
# constructed with only the app as their API and no logger_interface kwarg.
|
||||||
logger = self.__logger_interface or self.__appdaemon_api
|
logger = self.__logger_interface or self.__appdaemon_api
|
||||||
group_scope = getattr(logger, 'log_group', None)
|
group_scope = getattr(logger, 'log_group', None)
|
||||||
if callable(group_scope):
|
if callable(group_scope):
|
||||||
return group_scope('smartcondition', self.__trace_name)
|
return group_scope(self.__log_group)
|
||||||
return nullcontext()
|
return nullcontext()
|
||||||
|
|
||||||
def evaluate(self,log = True):
|
def evaluate(self,log = True):
|
||||||
with self.__log_group():
|
with self.__log_group_scope():
|
||||||
try: result = self.__evaluate()
|
try: result = self.__evaluate()
|
||||||
except EvaluationException as e:
|
except EvaluationException as e:
|
||||||
self.__log(f"{e}",level = "ERROR")
|
self.__log(f"{e}",level = "ERROR")
|
||||||
@@ -503,7 +510,7 @@ class Evaluator():
|
|||||||
self.__last_evaluation_result = new_result
|
self.__last_evaluation_result = new_result
|
||||||
|
|
||||||
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
|
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
|
||||||
with self.__log_group():
|
with self.__log_group_scope():
|
||||||
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
|
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
|
||||||
|
|
||||||
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
|
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
|
||||||
@@ -567,6 +574,10 @@ class Evaluator():
|
|||||||
self.__last_evaluation_result = result
|
self.__last_evaluation_result = result
|
||||||
|
|
||||||
def __on_condition_state_change_delayed(self, kwargs):
|
def __on_condition_state_change_delayed(self, kwargs):
|
||||||
|
# An expired timer may already be queued when a newer change reschedules it.
|
||||||
|
# Ignore it before touching the current timer handle or pending changes.
|
||||||
|
if kwargs.get("generation") != self.__delay_generation:
|
||||||
|
return
|
||||||
self.__condition_state_change_delayed_cb_handle = None
|
self.__condition_state_change_delayed_cb_handle = None
|
||||||
if len(self.__condition_state_change_delayed) == 0:
|
if len(self.__condition_state_change_delayed) == 0:
|
||||||
self.__log(f"Callback has been trigger but self.__condition_state_change_delayed is empty, this shouldn't happen, the callback won't be called", level = "WARNING")
|
self.__log(f"Callback has been trigger but self.__condition_state_change_delayed is empty, this shouldn't happen, the callback won't be called", level = "WARNING")
|
||||||
@@ -601,13 +612,18 @@ class Evaluator():
|
|||||||
# self.__update_result()
|
# self.__update_result()
|
||||||
self.__trigger_callbacks(f"Callback trigger by {entity}({old} => {new})")
|
self.__trigger_callbacks(f"Callback trigger by {entity}({old} => {new})")
|
||||||
else:
|
else:
|
||||||
if self.__condition_state_change_delayed_cb_handle != None and self.__appdaemon_api.timer_running(self.__condition_state_change_delayed_cb_handle):
|
self.__delay_generation += 1
|
||||||
self.__appdaemon_api.cancel_timer(self.__condition_state_change_delayed_cb_handle)
|
handle = self.__condition_state_change_delayed_cb_handle
|
||||||
|
if handle is not None:
|
||||||
|
# Expiry may already have removed the timer from the scheduler.
|
||||||
|
self.__appdaemon_api.cancel_timer(handle, silent=True)
|
||||||
self.__condition_state_change_delayed_cb_handle = None
|
self.__condition_state_change_delayed_cb_handle = None
|
||||||
|
|
||||||
self.__condition_state_change_delayed.append((entity,old,new,datetime.now()))
|
self.__condition_state_change_delayed.append((entity,old,new,datetime.now()))
|
||||||
#self.__log("State changed :" + str(entity) + "(" + str(old) + " => " + str(new) + ") but callback delayed for " + str(self.__callback_delay) + "s")
|
self.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(
|
||||||
self.__condition_state_change_delayed_cb_handle = self.__appdaemon_api.run_in(self.__on_condition_state_change_delayed, self.__callback_delay)
|
self.__on_condition_state_change_delayed, self.__callback_delay,
|
||||||
|
generation=self.__delay_generation,
|
||||||
|
)
|
||||||
|
|
||||||
def __log(self,message,**kwargs):
|
def __log(self,message,**kwargs):
|
||||||
log_string = self.__log_to_string(message)
|
log_string = self.__log_to_string(message)
|
||||||
|
|||||||
+15
-14
@@ -113,12 +113,11 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
if device_class is not None: attributes['device_class'] = device_class
|
if device_class is not None: attributes['device_class'] = device_class
|
||||||
if state_class is not None: attributes['state_class'] = state_class
|
if state_class is not None: attributes['state_class'] = state_class
|
||||||
|
|
||||||
if state is not None:
|
if state is None:
|
||||||
entity.set_state(state=state, attributes=attributes)
|
state = entity.get_state() if entity.exists() else None
|
||||||
else:
|
if state is None:
|
||||||
if not entity.exists():
|
state = 'unknown'
|
||||||
entity.set_state(state='unknown')
|
entity.set_state(state=state, attributes=attributes, check_existence=False)
|
||||||
entity.set_state(attributes=attributes)
|
|
||||||
|
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
@@ -184,16 +183,16 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
# → attributes_override → dataset restore → on_initialize_smart_object()
|
# → attributes_override → dataset restore → on_initialize_smart_object()
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.initialize_logger_interface(self.get_ad_api())
|
self.initialize_logger_interface(self.get_ad_api())
|
||||||
with self.log_group('initialization', self.name) as group:
|
with self.log_group('App Initialization') as group:
|
||||||
self.log(f"Initialization started [group=initialization:{group['id']}]")
|
self._write_log(f"Initialization started [group={group['kind']}:{group['id']}]", 'INFO', None)
|
||||||
try:
|
try:
|
||||||
succeeded = self._initialize_smart_object()
|
succeeded = self._initialize_smart_object()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self.log(f"Initialization failed [group=initialization:{group['id']}]", level='ERROR')
|
self._write_log(f"Initialization failed [group={group['kind']}:{group['id']}]", 'ERROR', None)
|
||||||
raise
|
raise
|
||||||
status = 'completed' if succeeded else 'failed'
|
status = 'completed' if succeeded else 'failed'
|
||||||
level = 'INFO' if succeeded else 'ERROR'
|
level = 'INFO' if succeeded else 'ERROR'
|
||||||
self.log(f"Initialization {status} [group=initialization:{group['id']}]", level=level)
|
self._write_log(f"Initialization {status} [group={group['kind']}:{group['id']}]", level, None)
|
||||||
|
|
||||||
def _initialize_smart_object(self):
|
def _initialize_smart_object(self):
|
||||||
try:
|
try:
|
||||||
@@ -219,14 +218,16 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
# entity: <entity_id> → link app to a HA entity
|
# entity: <entity_id> → link app to a HA entity
|
||||||
if "entity" in self.args:
|
if "entity" in self.args:
|
||||||
self.entity_id = self.args["entity"]
|
self.entity_id = self.args["entity"]
|
||||||
self.entity = self.get_entity(self.entity_id)
|
self.entity = self.get_entity(self.entity_id, check_existence=False)
|
||||||
|
initial_state = None
|
||||||
if self.entity.exists():
|
if self.entity.exists():
|
||||||
self.log_info(f"Linked to {self.entity_id}")
|
self.log_info(f"Linked to {self.entity_id}")
|
||||||
elif 'default_entity_state' in self.args:
|
elif 'default_entity_state' in self.args:
|
||||||
self.entity.set_state(state = self.args["default_entity_state"])
|
initial_state = self.args["default_entity_state"]
|
||||||
self.log_info(f"Creating {self.entity_id}, default_state = {self.args['default_entity_state']}")
|
self.log_info(f"Creating {self.entity_id}, default_state = {initial_state}")
|
||||||
else:
|
else:
|
||||||
self.entity.set_state(state = self.get_default_entity_state())
|
initial_state = self.get_default_entity_state()
|
||||||
|
self.entity = self.create_entity(self.entity_id, state=initial_state)
|
||||||
|
|
||||||
self.constants['self'] = self.entity_id
|
self.constants['self'] = self.entity_id
|
||||||
else:
|
else:
|
||||||
|
|||||||
+24
-7
@@ -1,4 +1,5 @@
|
|||||||
#import appdaemon.plugins.hass.hassapi as hass
|
#import appdaemon.plugins.hass.hassapi as hass
|
||||||
|
from contextlib import nullcontext
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from adexpressioncontext import ADExpressionContext
|
from adexpressioncontext import ADExpressionContext
|
||||||
from expressionparser import ExpressionParser,ParsingException,EvaluationException
|
from expressionparser import ExpressionParser,ParsingException,EvaluationException
|
||||||
@@ -31,6 +32,8 @@ class Evaluator():
|
|||||||
|
|
||||||
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
|
self.__no_log_prefix = parser.parse_args('no_log_prefix',False) #as no_log_prefix is used to feedback parser error, it need to be defined first
|
||||||
expression_name = parser.parse_args('expression_name',None)
|
expression_name = parser.parse_args('expression_name',None)
|
||||||
|
log_group = parser.parse_args('log_group',None)
|
||||||
|
self.__log_group = str(log_group if log_group is not None else expression_name or 'value')
|
||||||
if expression_name:
|
if expression_name:
|
||||||
self.__debug_log_prefixes.append(f"[{expression_name}]")
|
self.__debug_log_prefixes.append(f"[{expression_name}]")
|
||||||
|
|
||||||
@@ -96,14 +99,24 @@ class Evaluator():
|
|||||||
self.__debug_log_prefixes.pop()
|
self.__debug_log_prefixes.pop()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def __log_group_scope(self):
|
||||||
|
# SmartObject itself implements LoggerInterface, but many evaluators are
|
||||||
|
# constructed with only the app as their API and no logger_interface kwarg.
|
||||||
|
logger = self.__logger_interface or self.__appdaemon_api
|
||||||
|
group_scope = getattr(logger, 'log_group', None)
|
||||||
|
if callable(group_scope):
|
||||||
|
return group_scope(self.__log_group)
|
||||||
|
return nullcontext()
|
||||||
|
|
||||||
def evaluate(self,log = True):
|
def evaluate(self,log = True):
|
||||||
try: result = self.__evaluate()
|
with self.__log_group_scope():
|
||||||
except EvaluationException as e:
|
try: result = self.__evaluate()
|
||||||
self.__log(f"{e}",level = "ERROR")
|
except EvaluationException as e:
|
||||||
return None
|
self.__log(f"{e}",level = "ERROR")
|
||||||
if log:
|
return None
|
||||||
self.log_evaluation_result()
|
if log:
|
||||||
return result
|
self.log_evaluation_result()
|
||||||
|
return result
|
||||||
|
|
||||||
def log_callback_trigger_reason(self):
|
def log_callback_trigger_reason(self):
|
||||||
if self.__callback_trigger_reason_log_string:
|
if self.__callback_trigger_reason_log_string:
|
||||||
@@ -220,6 +233,10 @@ class Evaluator():
|
|||||||
self.__state_callbacks_handle = list()
|
self.__state_callbacks_handle = list()
|
||||||
|
|
||||||
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
|
def __trigger_callbacks(self,trigger_reason,triggered_from_activation = False):
|
||||||
|
with self.__log_group_scope():
|
||||||
|
return self.__trigger_callbacks_in_group(trigger_reason, triggered_from_activation)
|
||||||
|
|
||||||
|
def __trigger_callbacks_in_group(self,trigger_reason,triggered_from_activation = False):
|
||||||
has_log_trigger_reason = False
|
has_log_trigger_reason = False
|
||||||
has_evaluation_result = False
|
has_evaluation_result = False
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -78,8 +78,8 @@ class LoggerGroupingTests(unittest.TestCase):
|
|||||||
evaluator = smartcondition.Evaluator.__new__(smartcondition.Evaluator)
|
evaluator = smartcondition.Evaluator.__new__(smartcondition.Evaluator)
|
||||||
evaluator._Evaluator__logger_interface = None
|
evaluator._Evaluator__logger_interface = None
|
||||||
evaluator._Evaluator__appdaemon_api = app
|
evaluator._Evaluator__appdaemon_api = app
|
||||||
evaluator._Evaluator__trace_name = 'smart_conditions'
|
evaluator._Evaluator__log_group = 'smart_conditions'
|
||||||
with evaluator._Evaluator__log_group():
|
with evaluator._Evaluator__log_group_scope():
|
||||||
app.log_info('Callback trigger by sensor.example(0 => 1)')
|
app.log_info('Callback trigger by sensor.example(0 => 1)')
|
||||||
app.log_info('[smart_conditions][Evaluating] Triggered by condition')
|
app.log_info('[smart_conditions][Evaluating] Triggered by condition')
|
||||||
marker_a = app.rows[0][1].split('[group=')[1].rstrip(']')
|
marker_a = app.rows[0][1].split('[group=')[1].rstrip(']')
|
||||||
@@ -87,6 +87,19 @@ class LoggerGroupingTests(unittest.TestCase):
|
|||||||
self.assertEqual(marker_a, marker_b)
|
self.assertEqual(marker_a, marker_b)
|
||||||
self.assertTrue(marker_a.startswith('smartcondition:'))
|
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):
|
def test_nested_scopes_inherit_outer_group_and_reset_after_exception(self):
|
||||||
app = FakeApp()
|
app = FakeApp()
|
||||||
with app.log_group('value_selector', 'sensor.a') as outer:
|
with app.log_group('value_selector', 'sensor.a') as outer:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.sensor = RetainCondition()
|
self.sensor = RetainCondition()
|
||||||
self.sensor.cb_handle = None
|
self.sensor.cb_handle = None
|
||||||
|
self.sensor._timer_generation = 0
|
||||||
self.sensor.retain_time = 15
|
self.sensor.retain_time = 15
|
||||||
self.sensor.reset_retain_time_on_new_success = True
|
self.sensor.reset_retain_time_on_new_success = True
|
||||||
self.state = 'off'
|
self.state = 'off'
|
||||||
@@ -41,6 +42,54 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
def set_state(self, *, state):
|
def set_state(self, *, state):
|
||||||
self.state = state
|
self.state = state
|
||||||
|
|
||||||
|
def queued_expiry(self):
|
||||||
|
call = self.sensor.ad_api.run_in.call_args
|
||||||
|
callback = call.args[0]
|
||||||
|
kwargs = dict(call.kwargs)
|
||||||
|
return lambda: callback(kwargs)
|
||||||
|
|
||||||
|
def test_return_at_expiry_ignores_queued_callback(self):
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
queued = self.queued_expiry()
|
||||||
|
# The scheduler has already removed the timer and queued its callback.
|
||||||
|
self.sensor.ad_api.cancel_timer.return_value = False
|
||||||
|
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
queued()
|
||||||
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.sensor.condition.evaluate.assert_not_called()
|
||||||
|
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
|
||||||
|
|
||||||
|
def test_stale_expiry_cannot_shorten_new_retention(self):
|
||||||
|
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
queued = self.queued_expiry()
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
current_expiry = self.queued_expiry()
|
||||||
|
queued()
|
||||||
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.assertEqual(self.sensor.cb_handle, 'new-timer')
|
||||||
|
current_expiry()
|
||||||
|
self.assertEqual(self.state, 'off')
|
||||||
|
self.assertIsNone(self.sensor.cb_handle)
|
||||||
|
|
||||||
|
def test_stale_expiry_cannot_forget_timer_while_condition_disabled(self):
|
||||||
|
self.sensor.ad_api.run_in.side_effect = ['old-timer', 'new-timer']
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
queued = self.queued_expiry()
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.sensor.on_condition_fail()
|
||||||
|
self.sensor.condition.evaluate.return_value = Result.Disabled
|
||||||
|
queued()
|
||||||
|
self.assertEqual(self.sensor.cb_handle, 'new-timer')
|
||||||
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.sensor.on_condition_succeed()
|
||||||
|
self.assertEqual(self.sensor.ad_api.cancel_timer.call_args.args, ('new-timer',))
|
||||||
|
|
||||||
def test_long_lived_signal_gets_full_delay_after_loss(self):
|
def test_long_lived_signal_gets_full_delay_after_loss(self):
|
||||||
self.sensor.on_condition_succeed()
|
self.sensor.on_condition_succeed()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
@@ -48,15 +97,16 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
# No timer can expire while the signal stays valid.
|
# No timer can expire while the signal stays valid.
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
self.sensor.ad_api.run_in.assert_called_once_with(self.sensor.on_time_elapse, 15)
|
self.sensor.ad_api.run_in.assert_called_once_with(
|
||||||
self.sensor.on_time_elapse({})
|
self.sensor.on_time_elapse, 15, generation=self.sensor._timer_generation)
|
||||||
|
self.queued_expiry()()
|
||||||
self.assertEqual(self.state, 'off')
|
self.assertEqual(self.state, 'off')
|
||||||
|
|
||||||
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
|
def test_returning_signal_cancels_off_and_next_loss_gets_new_delay(self):
|
||||||
self.sensor.on_condition_succeed()
|
self.sensor.on_condition_succeed()
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.sensor.on_condition_succeed()
|
self.sensor.on_condition_succeed()
|
||||||
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer')
|
self.sensor.ad_api.cancel_timer.assert_called_once_with('timer', silent=True)
|
||||||
self.assertIsNone(self.sensor.cb_handle)
|
self.assertIsNone(self.sensor.cb_handle)
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
@@ -79,8 +129,11 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
with self.subTest(result=result):
|
with self.subTest(result=result):
|
||||||
self.state = 'on'
|
self.state = 'on'
|
||||||
self.sensor.condition.evaluate.return_value = result
|
self.sensor.condition.evaluate.return_value = result
|
||||||
self.sensor.on_time_elapse({})
|
self.sensor.register_callback()
|
||||||
|
self.queued_expiry()()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
|
self.assertIsNone(self.sensor.cb_handle)
|
||||||
|
self.sensor.condition.evaluate.assert_called_with()
|
||||||
|
|
||||||
def test_explicit_no_reset_preserves_fixed_window(self):
|
def test_explicit_no_reset_preserves_fixed_window(self):
|
||||||
self.sensor.reset_retain_time_on_new_success = False
|
self.sensor.reset_retain_time_on_new_success = False
|
||||||
@@ -88,7 +141,7 @@ class RetainConditionTests(unittest.TestCase):
|
|||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.assertEqual(self.state, 'on')
|
self.assertEqual(self.state, 'on')
|
||||||
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
self.sensor.condition.evaluate.return_value = Result.Succeeded
|
||||||
self.sensor.on_time_elapse({})
|
self.queued_expiry()()
|
||||||
self.sensor.on_condition_fail()
|
self.sensor.on_condition_fail()
|
||||||
self.assertEqual(self.state, 'off')
|
self.assertEqual(self.state, 'off')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Verify debouncing when an expired AppDaemon timer is still queued."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from smartcondition import Evaluator
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScheduler:
|
||||||
|
def __init__(self):
|
||||||
|
self.timers = {}
|
||||||
|
self.next_id = 0
|
||||||
|
self.cancelled = []
|
||||||
|
self.warnings = []
|
||||||
|
self.expire_on_cancel = False
|
||||||
|
self.queued = []
|
||||||
|
|
||||||
|
def run_in(self, callback, delay, **kwargs):
|
||||||
|
self.next_id += 1
|
||||||
|
handle = str(self.next_id)
|
||||||
|
self.timers[handle] = (callback, kwargs)
|
||||||
|
return handle
|
||||||
|
|
||||||
|
def timer_running(self, handle):
|
||||||
|
return handle in self.timers
|
||||||
|
|
||||||
|
def cancel_timer(self, handle, silent=False):
|
||||||
|
self.cancelled.append(handle)
|
||||||
|
if self.expire_on_cancel and handle in self.timers:
|
||||||
|
self.queued.append(self.expire(handle))
|
||||||
|
cancelled = self.timers.pop(handle, None) is not None
|
||||||
|
if not cancelled and not silent:
|
||||||
|
self.warnings.append(handle)
|
||||||
|
return cancelled
|
||||||
|
|
||||||
|
def expire(self, handle):
|
||||||
|
callback, kwargs = self.timers.pop(handle)
|
||||||
|
return lambda: callback(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DelayedCallbackTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.scheduler = FakeScheduler()
|
||||||
|
self.evaluator = Evaluator.__new__(Evaluator)
|
||||||
|
fields = dict(
|
||||||
|
appdaemon_api=self.scheduler, callback_delay=10,
|
||||||
|
trigger_callback_on_entity_creation=True,
|
||||||
|
condition_state_change_delayed=[],
|
||||||
|
condition_state_change_delayed_cb_handle=None,
|
||||||
|
delay_generation=0, trigger_callbacks=Mock(), log=Mock(),
|
||||||
|
)
|
||||||
|
for name, value in fields.items():
|
||||||
|
setattr(self.evaluator, '_Evaluator__' + name, value)
|
||||||
|
|
||||||
|
def change(self, old, new):
|
||||||
|
self.evaluator._Evaluator__on_condition_state_change(
|
||||||
|
'sensor.temperature', 'state', old, new, {})
|
||||||
|
return self.evaluator._Evaluator__condition_state_change_delayed_cb_handle
|
||||||
|
|
||||||
|
def test_queued_obsolete_callback_preserves_new_timer_and_changes(self):
|
||||||
|
old_handle = self.change('20', '21')
|
||||||
|
queued_callback = self.scheduler.expire(old_handle)
|
||||||
|
new_handle = self.change('21', '22')
|
||||||
|
queued_callback()
|
||||||
|
self.assertEqual(self.scheduler.warnings, [])
|
||||||
|
|
||||||
|
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
|
||||||
|
new_handle)
|
||||||
|
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
|
||||||
|
self.evaluator._Evaluator__log.assert_not_called()
|
||||||
|
|
||||||
|
self.scheduler.expire(new_handle)()
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||||
|
reason = self.evaluator._Evaluator__trigger_callbacks.call_args.args[0]
|
||||||
|
self.assertIn('20 => 21', reason)
|
||||||
|
self.assertIn('21 => 22', reason)
|
||||||
|
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed, [])
|
||||||
|
self.assertIsNone(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle)
|
||||||
|
self.evaluator._Evaluator__log.assert_not_called()
|
||||||
|
|
||||||
|
def test_obsolete_callback_after_current_callback_is_ignored(self):
|
||||||
|
queued_callback = self.scheduler.expire(self.change('20', '21'))
|
||||||
|
self.scheduler.expire(self.change('21', '22'))()
|
||||||
|
queued_callback()
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||||
|
self.evaluator._Evaluator__log.assert_not_called()
|
||||||
|
|
||||||
|
def test_running_timer_is_cancelled_when_rescheduled(self):
|
||||||
|
old_handle = self.change('20', '21')
|
||||||
|
new_handle = self.change('21', '22')
|
||||||
|
self.assertEqual(self.scheduler.cancelled, [old_handle])
|
||||||
|
self.assertFalse(self.scheduler.timer_running(old_handle))
|
||||||
|
self.scheduler.expire(new_handle)()
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||||
|
|
||||||
|
def test_expiry_during_cancellation_is_silent_and_preserves_new_batch(self):
|
||||||
|
self.change('20', '21')
|
||||||
|
self.scheduler.expire_on_cancel = True
|
||||||
|
current = self.change('21', '22')
|
||||||
|
self.assertEqual(self.scheduler.warnings, [])
|
||||||
|
self.assertEqual(len(self.scheduler.queued), 1)
|
||||||
|
self.scheduler.queued[0]()
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_not_called()
|
||||||
|
self.assertEqual(self.evaluator._Evaluator__condition_state_change_delayed_cb_handle,
|
||||||
|
current)
|
||||||
|
self.assertEqual(len(self.evaluator._Evaluator__condition_state_change_delayed), 2)
|
||||||
|
self.scheduler.expire(current)()
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||||
|
self.evaluator._Evaluator__log.assert_not_called()
|
||||||
|
|
||||||
|
def test_zero_delay_remains_immediate(self):
|
||||||
|
self.evaluator._Evaluator__callback_delay = 0
|
||||||
|
self.change('20', '21')
|
||||||
|
self.evaluator._Evaluator__trigger_callbacks.assert_called_once()
|
||||||
|
self.assertEqual(self.scheduler.timers, {})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Time-period boundaries, source updates and timer lifecycle without AppDaemon."""
|
||||||
|
import ast
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
source = Path(__file__).resolve().parents[1] / 'virtualsensors.py'
|
||||||
|
nodes = [node for node in ast.parse(source.read_text()).body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name in ('VirtualSensorBase', 'TimePeriod', 'VirtualSensors')]
|
||||||
|
from kwargsparser import kwargsParser
|
||||||
|
namespace = {'datetime': datetime, 're': re, 'kwargsParser': kwargsParser, 'ParsingException': ValueError}
|
||||||
|
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), namespace)
|
||||||
|
TimePeriod = namespace['TimePeriod']
|
||||||
|
|
||||||
|
|
||||||
|
class TimePeriodTests(unittest.TestCase):
|
||||||
|
def make_sensor(self, start='08:00:00', end='18:00:00', now='2026-10-25 12:00:00'):
|
||||||
|
self.states = {'input_datetime.start': start, 'input_datetime.end': end}
|
||||||
|
self.api = Mock()
|
||||||
|
self.api.datetime.return_value = datetime.datetime.fromisoformat(now)
|
||||||
|
self.api.get_state.side_effect = self.states.get
|
||||||
|
self.api.entity_exists.return_value = True
|
||||||
|
self.api.get_entity.return_value.get_state.return_value = 'unknown'
|
||||||
|
self.sensor = TimePeriod(self.api, Mock(), virtual_sensor_name='period',
|
||||||
|
yaml_block={'start': 'input_datetime.start', 'end': 'input_datetime.end'})
|
||||||
|
self.sensor._initialize()
|
||||||
|
return self.sensor
|
||||||
|
|
||||||
|
def assert_state(self, expected):
|
||||||
|
self.assertEqual(self.sensor.entity.set_state.call_args.kwargs['state'], expected)
|
||||||
|
|
||||||
|
def test_factory_registration(self):
|
||||||
|
self.make_sensor()
|
||||||
|
factory = namespace['VirtualSensors'](ad_api=self.api, logger_interface=Mock(),
|
||||||
|
yaml_block={'sensors': {'time_period.example': {
|
||||||
|
'start': 'input_datetime.start', 'end': 'input_datetime.end'}}})
|
||||||
|
self.assertIsInstance(factory.virtual_sensors['binary_sensor.example'], TimePeriod)
|
||||||
|
|
||||||
|
def test_day_boundaries_and_timer(self):
|
||||||
|
self.make_sensor(now='2026-10-25 07:59:50')
|
||||||
|
self.assert_state('off')
|
||||||
|
self.assertEqual(self.api.run_in.call_args.args[1], 10)
|
||||||
|
for hour, expected in [('08:00:00', 'on'), ('17:59:59', 'on'), ('18:00:00', 'off')]:
|
||||||
|
self.api.datetime.return_value = datetime.datetime.fromisoformat('2026-10-25 ' + hour)
|
||||||
|
self.sensor.on_time_elapsed({'generation': self.sensor._timer_generation})
|
||||||
|
self.assert_state(expected)
|
||||||
|
|
||||||
|
def test_overnight_and_equal_boundaries(self):
|
||||||
|
for hour, expected in [('23:00:00', 'on'), ('02:00:00', 'on'), ('06:00:00', 'off'), ('12:00:00', 'off')]:
|
||||||
|
self.make_sensor('22:00:00', '06:00:00', '2026-10-25 ' + hour)
|
||||||
|
self.assert_state(expected)
|
||||||
|
self.make_sensor('08:00:00', '08:00:00')
|
||||||
|
self.assert_state('off')
|
||||||
|
|
||||||
|
def test_source_change_cancels_timer_and_ignores_stale_callback(self):
|
||||||
|
self.make_sensor()
|
||||||
|
self.assert_state('on')
|
||||||
|
previous_generation = self.sensor._timer_generation
|
||||||
|
previous_handle = self.sensor.cb_handle
|
||||||
|
self.states['input_datetime.end'] = '10:00:00'
|
||||||
|
self.sensor.on_input_change('input_datetime.end', 'state', '18:00:00', '10:00:00', {})
|
||||||
|
self.assert_state('off')
|
||||||
|
self.api.cancel_timer.assert_called_with(previous_handle, silent=True)
|
||||||
|
count = self.api.run_in.call_count
|
||||||
|
self.sensor.on_time_elapsed({'generation': previous_generation})
|
||||||
|
self.assertEqual(self.api.run_in.call_count, count)
|
||||||
|
self.assertEqual(self.api.listen_state.call_count, 2)
|
||||||
|
|
||||||
|
def test_unavailable_and_recovery(self):
|
||||||
|
for invalid in [None, 'unknown', 'unavailable', '25:00:00', '2026-10-25']:
|
||||||
|
self.make_sensor(start=invalid)
|
||||||
|
self.assert_state('unavailable')
|
||||||
|
self.states['input_datetime.start'] = '08:00:00'
|
||||||
|
self.sensor.update_period()
|
||||||
|
self.assert_state('on')
|
||||||
|
|
||||||
|
def test_dated_periods(self):
|
||||||
|
for start, end, expected in [
|
||||||
|
('2026-10-24 22:00:00', '2026-10-26 06:00:00', 'on'),
|
||||||
|
('2026-10-24 22:00:00', '2026-10-25 12:00:00', 'off'),
|
||||||
|
('2026-10-26 22:00:00', '2026-10-24 06:00:00', 'off'),
|
||||||
|
('2026-10-24', '2026-10-26', 'on'),
|
||||||
|
]:
|
||||||
|
self.make_sensor(start, end)
|
||||||
|
self.assert_state(expected)
|
||||||
|
|
||||||
|
def test_dependencies_constants_and_validation(self):
|
||||||
|
self.make_sensor()
|
||||||
|
self.assertEqual(self.sensor.sensor_name, 'binary_sensor.period')
|
||||||
|
self.sensor.constants['begin'] = 'input_datetime.start'
|
||||||
|
self.sensor.yaml_block['start'] = 'begin'
|
||||||
|
self.assertEqual(self.sensor.generate_dependencies_list(), list(self.states))
|
||||||
|
self.sensor.yaml_block['end'] = 'sensor.invalid'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.sensor.generate_dependencies_list()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+2
-2
@@ -22,9 +22,9 @@ class VirtualEvents(hass.Hass,LoggerInterface):
|
|||||||
def on_condition_succeed(self,event_name):
|
def on_condition_succeed(self,event_name):
|
||||||
event_yaml = self.args["virtual_events"][event_name]
|
event_yaml = self.args["virtual_events"][event_name]
|
||||||
if 'event_data' in event_yaml:
|
if 'event_data' in event_yaml:
|
||||||
self.log(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
|
self.log_info(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
|
||||||
self.fire_event(event_yaml['event_name'],**event_yaml['event_data'])
|
self.fire_event(event_yaml['event_name'],**event_yaml['event_data'])
|
||||||
else:
|
else:
|
||||||
self.log(f"Sending event {event_yaml['event_name']}")
|
self.log_info(f"Sending event {event_yaml['event_name']}")
|
||||||
self.fire_event(event_yaml['event_name'])
|
self.fire_event(event_yaml['event_name'])
|
||||||
|
|
||||||
+242
-47
@@ -4,6 +4,7 @@ import smartvalue as SmartValue
|
|||||||
from kwargsparser import kwargsParser
|
from kwargsparser import kwargsParser
|
||||||
from expressionparser import ParsingException
|
from expressionparser import ParsingException
|
||||||
import time
|
import time
|
||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
from logger_interface import LoggerInterface
|
from logger_interface import LoggerInterface
|
||||||
from ad_toolbox.eventhandler import EventHandler
|
from ad_toolbox.eventhandler import EventHandler
|
||||||
@@ -46,6 +47,11 @@ class VirtualSensorBase:
|
|||||||
if super_entity_id != None:
|
if super_entity_id != None:
|
||||||
self.constants['super'] = super_entity_id
|
self.constants['super'] = super_entity_id
|
||||||
|
|
||||||
|
self.entity_existed = self.ad_api.entity_exists(self.sensor_name)
|
||||||
|
if not self.entity_existed:
|
||||||
|
self.ad_api.set_state(self.sensor_name, state='unknown',attributes=self.attributes, check_existence=False)
|
||||||
|
self.entity = self.ad_api.get_entity(self.sensor_name)
|
||||||
|
|
||||||
if self_initialize:
|
if self_initialize:
|
||||||
self._initialize()
|
self._initialize()
|
||||||
|
|
||||||
@@ -56,13 +62,19 @@ class VirtualSensorBase:
|
|||||||
# assert len(splitted_name) == 2,f"Invalid virtual sensor name : {virtual_sensor_name}"
|
# assert len(splitted_name) == 2,f"Invalid virtual sensor name : {virtual_sensor_name}"
|
||||||
# return f"sensor.{splitted_name[1]}"
|
# return f"sensor.{splitted_name[1]}"
|
||||||
|
|
||||||
def log(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
|
# def log(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
|
||||||
|
|
||||||
def log_info(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
|
# def log_info(self,message): self.logger_interface.log_info(f"[{self.sensor_name}]{message}")
|
||||||
|
|
||||||
def log_warning(self,message): self.logger_interface.log_warning(f"[{self.sensor_name}]{message}")
|
# def log_warning(self,message): self.logger_interface.log_warning(f"[{self.sensor_name}]{message}")
|
||||||
|
|
||||||
def log_error(self,message): self.logger_interface.log_error(f"[{self.sensor_name}]{message}")
|
# def log_error(self,message): self.logger_interface.log_error(f"[{self.sensor_name}]{message}")
|
||||||
|
|
||||||
|
def log_info(self,message): self.logger_interface.log_info(message)
|
||||||
|
|
||||||
|
def log_warning(self,message): self.logger_interface.log_warning(message)
|
||||||
|
|
||||||
|
def log_error(self,message): self.logger_interface.log_error(message)
|
||||||
|
|
||||||
def generate_dependencies_list(self): return []
|
def generate_dependencies_list(self): return []
|
||||||
|
|
||||||
@@ -329,7 +341,7 @@ class ValueSelector(VirtualSensorBase):
|
|||||||
|
|
||||||
def process_config(self) -> list[SelectorConfig]:
|
def process_config(self) -> list[SelectorConfig]:
|
||||||
configs = list()
|
configs = list()
|
||||||
#self.log(f"yaml_block:{self.yaml_block}")
|
#self.log_info(f"yaml_block:{self.yaml_block}")
|
||||||
if 'values' in self.yaml_block: values_block = self.yaml_block['values']
|
if 'values' in self.yaml_block: values_block = self.yaml_block['values']
|
||||||
else: values_block = self.yaml_block
|
else: values_block = self.yaml_block
|
||||||
|
|
||||||
@@ -354,7 +366,7 @@ class ValueSelector(VirtualSensorBase):
|
|||||||
selectors_config = self.process_config()
|
selectors_config = self.process_config()
|
||||||
|
|
||||||
for config in selectors_config:
|
for config in selectors_config:
|
||||||
#self.log(f"config: name = {config.name},conditions = {config.conditions},value = {config.value}")
|
#self.log_info(f"config: name = {config.name},conditions = {config.conditions},value = {config.value}")
|
||||||
conditions = SmartCondition.Evaluator(self.ad_api,config.conditions, logger_interface = self.logger_interface,condition_name = config.name, sensor_existence_validation = False, constants = self.constants, log_init = False)
|
conditions = SmartCondition.Evaluator(self.ad_api,config.conditions, logger_interface = self.logger_interface,condition_name = config.name, sensor_existence_validation = False, constants = self.constants, log_init = False)
|
||||||
self.extract_dependencies_from_entities_to_listen(conditions.get_entities_to_listen_as_list(),dependencies)
|
self.extract_dependencies_from_entities_to_listen(conditions.get_entities_to_listen_as_list(),dependencies)
|
||||||
|
|
||||||
@@ -374,42 +386,46 @@ class ValueSelector(VirtualSensorBase):
|
|||||||
self.output_sensor.set_state(state = value)
|
self.output_sensor.set_state(state = value)
|
||||||
|
|
||||||
def update_value(self):
|
def update_value(self):
|
||||||
previous_value = str(self.output_sensor.get_state())
|
with self.logger_interface.log_group(self.sensor_name):
|
||||||
|
previous_value = str(self.output_sensor.get_state())
|
||||||
|
|
||||||
for selector in self.value_selectors:
|
for selector in self.value_selectors:
|
||||||
if selector.conditions.evaluate(False) == SmartCondition.Result.Succeeded:
|
if selector.conditions.evaluate(False) == SmartCondition.Result.Succeeded:
|
||||||
new_value = selector.compute_value()
|
new_value = selector.compute_value()
|
||||||
if previous_value != str(new_value):
|
if previous_value != str(new_value):
|
||||||
#TODO: find a way to know wether if it's the condition or the value that trigger the update
|
#TODO: find a way to know wether if it's the condition or the value that trigger the update
|
||||||
selector.conditions.log_callback_trigger_reason()
|
selector.conditions.log_callback_trigger_reason()
|
||||||
selector.conditions.log_evaluation_result()
|
selector.conditions.log_evaluation_result()
|
||||||
if selector.value:
|
if selector.value:
|
||||||
selector.value.log_callback_trigger_reason()
|
selector.value.log_callback_trigger_reason()
|
||||||
selector.value.log_evaluation_result()
|
selector.value.log_evaluation_result()
|
||||||
self.set_value(new_value)
|
self.set_value(new_value)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
self.log_info(f"{self.sensor_name} = {self.output_sensor.get_state()}")
|
||||||
|
|
||||||
class ContinuousCondition(VirtualSensorBase):
|
class ContinuousCondition(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
self.time = self.yaml_block['time']
|
self.time = self.yaml_block['time']
|
||||||
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
|
try: self.ignore_unavailable_condition = self.yaml_block['ignore_unavailable_condition']
|
||||||
except KeyError: self.ignore_unavailable_condition = False
|
except KeyError: self.ignore_unavailable_condition = False
|
||||||
try: self.invert = self.yaml_block['invert']
|
try: self.invert = self.yaml_block['invert']
|
||||||
except KeyError: self.invert = False
|
except KeyError: self.invert = False
|
||||||
self.log(f"Initializing continuous condition for {self.sensor_name}")
|
self.log_info(f"Initializing continuous condition for {self.sensor_name}")
|
||||||
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
||||||
|
|
||||||
if not self.output_sensor.exists():
|
if not self.output_sensor.exists():
|
||||||
self.log(f"{self.sensor_name} does not exist")
|
self.log_info(f"{self.sensor_name} does not exist")
|
||||||
try:
|
try:
|
||||||
self.output_sensor.set_state(state = self.yaml_block['default_value'])
|
self.output_sensor.set_state(state = self.yaml_block['default_value'])
|
||||||
self.log(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
|
self.log_info(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
self.output_sensor.set_state(state = 'off')
|
self.output_sensor.set_state(state = 'off')
|
||||||
self.log(f"adding {self.sensor_name} with default state off")
|
self.log_info(f"adding {self.sensor_name} with default state off")
|
||||||
else:
|
else:
|
||||||
self.log(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
|
self.log_info(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
|
||||||
|
|
||||||
if self.invert:
|
if self.invert:
|
||||||
succeed_cb = self.on_condition_fail
|
succeed_cb = self.on_condition_fail
|
||||||
@@ -438,14 +454,22 @@ class ContinuousCondition(VirtualSensorBase):
|
|||||||
|
|
||||||
def register_callback(self):
|
def register_callback(self):
|
||||||
if self.cb_handle == None:
|
if self.cb_handle == None:
|
||||||
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.time)
|
self._timer_generation += 1
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapse, self.time, generation=self._timer_generation)
|
||||||
|
|
||||||
def cancel_callback(self):
|
def cancel_callback(self):
|
||||||
if self.cb_handle != None:
|
# An expired timer may already have a callback queued in the worker.
|
||||||
self.ad_api.cancel_timer(self.cb_handle)
|
# Invalidate it before cancellation, even if it is no longer scheduled.
|
||||||
self.cb_handle = None
|
self._timer_generation += 1
|
||||||
|
handle = self.cb_handle
|
||||||
|
self.cb_handle = None
|
||||||
|
if handle is not None:
|
||||||
|
self.ad_api.cancel_timer(handle, silent=True)
|
||||||
|
|
||||||
def on_time_elapse(self, kwargs):
|
def on_time_elapse(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
value = 'off' if self.invert else 'on'
|
value = 'off' if self.invert else 'on'
|
||||||
if self.output_sensor.get_state() != value:
|
if self.output_sensor.get_state() != value:
|
||||||
@@ -460,25 +484,84 @@ class ContinuousCondition(VirtualSensorBase):
|
|||||||
if self.output_sensor.get_state() != value:
|
if self.output_sensor.get_state() != value:
|
||||||
self.output_sensor.set_state(state = value)
|
self.output_sensor.set_state(state = value)
|
||||||
|
|
||||||
|
class EphemeralCondition(ContinuousCondition):
|
||||||
|
"""True during the first `time` seconds of each true condition.
|
||||||
|
|
||||||
|
condition_started_at is a Unix timestamp retained after expiry, until
|
||||||
|
the condition becomes false. Reloads resume this window when the entity
|
||||||
|
attributes survive; without a timestamp, a true condition starts anew.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
self._condition_active = False
|
||||||
|
if self.yaml_block.get('invert', False):
|
||||||
|
raise ValueError("ephemeral_condition does not support invert; negate conditions instead")
|
||||||
|
started_at = self.ad_api.get_state(
|
||||||
|
self.sensor_name, attribute='condition_started_at')
|
||||||
|
try:
|
||||||
|
started_at = float(started_at)
|
||||||
|
if not 0 <= started_at <= time.time():
|
||||||
|
started_at = None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
started_at = None
|
||||||
|
self._started_at = started_at
|
||||||
|
self.attributes['condition_started_at'] = started_at
|
||||||
|
# Unknown inputs during startup must not erase a restored window.
|
||||||
|
self.yaml_block = dict(self.yaml_block)
|
||||||
|
self.yaml_block.setdefault('ignore_unavailable_condition', True)
|
||||||
|
super().initialize()
|
||||||
|
|
||||||
|
def on_condition_succeed(self):
|
||||||
|
if self._condition_active:
|
||||||
|
return
|
||||||
|
self._condition_active = True
|
||||||
|
if self._started_at is None:
|
||||||
|
self._started_at = time.time()
|
||||||
|
self.attributes['condition_started_at'] = self._started_at
|
||||||
|
remaining = self._started_at + self.time - time.time()
|
||||||
|
self.output_sensor.set_state(
|
||||||
|
state='on' if remaining > 0 else 'off',
|
||||||
|
attributes={'condition_started_at': self._started_at})
|
||||||
|
if remaining > 0:
|
||||||
|
self._timer_generation += 1
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapse, remaining, generation=self._timer_generation)
|
||||||
|
|
||||||
|
def on_condition_fail(self):
|
||||||
|
self._condition_active = False
|
||||||
|
self._started_at = None
|
||||||
|
self.attributes['condition_started_at'] = None
|
||||||
|
self.cancel_callback()
|
||||||
|
self.output_sensor.set_state(
|
||||||
|
state='off', attributes={'condition_started_at': None})
|
||||||
|
|
||||||
|
def on_time_elapse(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
|
self.cb_handle = None
|
||||||
|
self.output_sensor.set_state(state='off')
|
||||||
|
|
||||||
|
|
||||||
class RetainCondition(VirtualSensorBase):
|
class RetainCondition(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
self.retain_time = self.yaml_block['retain_time']
|
self.retain_time = self.yaml_block['retain_time']
|
||||||
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
|
try: self.reset_retain_time_on_new_success = self.yaml_block['reset_retain_time_on_new_success']
|
||||||
except KeyError: self.reset_retain_time_on_new_success = True
|
except KeyError: self.reset_retain_time_on_new_success = True
|
||||||
self.log(f"Initializing retain condition for {self.sensor_name}")
|
self.log_info(f"Initializing retain condition for {self.sensor_name}")
|
||||||
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
||||||
|
|
||||||
if not self.output_sensor.exists():
|
if not self.output_sensor.exists():
|
||||||
self.log(f"{self.sensor_name} does not exist")
|
self.log_info(f"{self.sensor_name} does not exist")
|
||||||
try:
|
try:
|
||||||
self.output_sensor.set_state(state = self.yaml_block['default_value'])
|
self.output_sensor.set_state(state = self.yaml_block['default_value'])
|
||||||
self.log(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
|
self.log_info(f"adding {self.sensor_name} with default state {self.yaml_block['default_value']}")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
self.output_sensor.set_state(state = 'off')
|
self.output_sensor.set_state(state = 'off')
|
||||||
self.log(f"adding {self.sensor_name} with default state off")
|
self.log_info(f"adding {self.sensor_name} with default state off")
|
||||||
else:
|
else:
|
||||||
self.log(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
|
self.log_info(f"{self.sensor_name} already exist with state = {self.output_sensor.get_state()}")
|
||||||
|
|
||||||
self.condition = SmartCondition.Evaluator(self.ad_api,self.yaml_block['conditions'], logger_interface = self.logger_interface,condition_name = self.sensor_name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_condition_fail, constants = self.constants, templates_library = self.templates_library)
|
self.condition = SmartCondition.Evaluator(self.ad_api,self.yaml_block['conditions'], logger_interface = self.logger_interface,condition_name = self.sensor_name,on_succeed_cb = self.on_condition_succeed, on_fail_cb = self.on_condition_fail, constants = self.constants, templates_library = self.templates_library)
|
||||||
# new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
|
# new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
|
||||||
@@ -496,14 +579,21 @@ class RetainCondition(VirtualSensorBase):
|
|||||||
|
|
||||||
def register_callback(self):
|
def register_callback(self):
|
||||||
if self.cb_handle == None:
|
if self.cb_handle == None:
|
||||||
self.cb_handle = self.ad_api.run_in(self.on_time_elapse, self.retain_time)
|
self._timer_generation += 1
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapse, self.retain_time, generation=self._timer_generation)
|
||||||
|
|
||||||
def cancel_callback(self):
|
def cancel_callback(self):
|
||||||
if self.cb_handle != None:
|
# Invalidate callbacks already queued by an expired timer as well.
|
||||||
self.ad_api.cancel_timer(self.cb_handle)
|
self._timer_generation += 1
|
||||||
self.cb_handle = None
|
handle = self.cb_handle
|
||||||
|
self.cb_handle = None
|
||||||
|
if handle is not None:
|
||||||
|
self.ad_api.cancel_timer(handle, silent=True)
|
||||||
|
|
||||||
def on_time_elapse(self, kwargs):
|
def on_time_elapse(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
if self.condition.evaluate() == SmartCondition.Result.Failed:
|
if self.condition.evaluate() == SmartCondition.Result.Failed:
|
||||||
if self.output_sensor.get_state() != 'off':
|
if self.output_sensor.get_state() != 'off':
|
||||||
@@ -529,6 +619,90 @@ class RetainCondition(VirtualSensorBase):
|
|||||||
if self.output_sensor.get_state() != 'off':
|
if self.output_sensor.get_state() != 'off':
|
||||||
self.output_sensor.set_state(state = 'off')
|
self.output_sensor.set_state(state = 'off')
|
||||||
|
|
||||||
|
class TimePeriod(VirtualSensorBase):
|
||||||
|
"""Active in [start, end), using two input_datetime entities.
|
||||||
|
|
||||||
|
Time-only inputs repeat daily and may cross midnight. Dated inputs
|
||||||
|
describe a single interval. Equal boundaries describe an empty interval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def generate_sensor_name(self, virtual_sensor_name):
|
||||||
|
return f"binary_sensor.{virtual_sensor_name}"
|
||||||
|
|
||||||
|
def generate_dependencies_list(self):
|
||||||
|
inputs = []
|
||||||
|
for key in ('start', 'end'):
|
||||||
|
value = self.yaml_block.get(key)
|
||||||
|
value = self.constants.get(value, value) if isinstance(value, str) else value
|
||||||
|
if not self.is_entity_reference(value) or not value.startswith('input_datetime.'):
|
||||||
|
raise ValueError(f"{self.sensor_name}: {key} must reference an input_datetime")
|
||||||
|
inputs.append(value)
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
self.start_entity, self.end_entity = self.generate_dependencies_list()
|
||||||
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
|
for entity in set((self.start_entity, self.end_entity)):
|
||||||
|
self.ad_api.listen_state(self.on_input_change, entity)
|
||||||
|
self.update_period()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_boundary(value):
|
||||||
|
for fmt, kind in (('%H:%M:%S', 'time'),
|
||||||
|
('%Y-%m-%d %H:%M:%S', 'datetime'),
|
||||||
|
('%Y-%m-%d', 'date')):
|
||||||
|
try:
|
||||||
|
parsed = datetime.datetime.strptime(value, fmt)
|
||||||
|
return kind, parsed.time() if kind == 'time' else parsed
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
raise ValueError('Unavailable or invalid input_datetime state')
|
||||||
|
|
||||||
|
def on_input_change(self, entity, attribute, old, new, kwargs):
|
||||||
|
self.update_period()
|
||||||
|
|
||||||
|
def on_time_elapsed(self, kwargs):
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
|
self.cb_handle = None
|
||||||
|
self.update_period()
|
||||||
|
|
||||||
|
def update_period(self):
|
||||||
|
self._timer_generation += 1
|
||||||
|
if self.cb_handle is not None:
|
||||||
|
self.ad_api.cancel_timer(self.cb_handle, silent=True)
|
||||||
|
self.cb_handle = None
|
||||||
|
now = self.ad_api.datetime()
|
||||||
|
delay = 60.0
|
||||||
|
try:
|
||||||
|
start_kind, start = self.parse_boundary(self.ad_api.get_state(self.start_entity))
|
||||||
|
end_kind, end = self.parse_boundary(self.ad_api.get_state(self.end_entity))
|
||||||
|
if start_kind != end_kind:
|
||||||
|
raise ValueError('Both inputs must have the same date/time format')
|
||||||
|
if start_kind == 'time':
|
||||||
|
current = now.time()
|
||||||
|
active = (start <= current < end if start <= end
|
||||||
|
else current >= start or current < end)
|
||||||
|
boundaries = [datetime.datetime.combine(now.date(), value)
|
||||||
|
for value in (start, end)]
|
||||||
|
boundaries = [value if value > now else value + datetime.timedelta(days=1)
|
||||||
|
for value in boundaries]
|
||||||
|
else:
|
||||||
|
active = start <= now < end
|
||||||
|
boundaries = [value for value in (start, end) if value > now]
|
||||||
|
state = 'on' if active else 'off'
|
||||||
|
if boundaries:
|
||||||
|
delay = min(delay, min((value - now).total_seconds() for value in boundaries))
|
||||||
|
except ValueError:
|
||||||
|
state = 'unavailable'
|
||||||
|
if self.entity.get_state() != state:
|
||||||
|
self.entity.set_state(state=state)
|
||||||
|
# Refresh at boundaries, and at least every minute to follow clock/DST changes.
|
||||||
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_time_elapsed, delay, generation=self._timer_generation)
|
||||||
|
|
||||||
|
|
||||||
class BinarySensor(VirtualSensorBase):
|
class BinarySensor(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
||||||
@@ -537,15 +711,23 @@ class BinarySensor(VirtualSensorBase):
|
|||||||
self.output_sensor.set_state(state = 'off',attributes = self.attributes)
|
self.output_sensor.set_state(state = 'off',attributes = self.attributes)
|
||||||
|
|
||||||
#self.ad_api.log(f"Creating Binary Sensor: binary_sensor = {self.virtual_sensor_name}, yaml_block = {self.yaml_block}")
|
#self.ad_api.log(f"Creating Binary Sensor: binary_sensor = {self.virtual_sensor_name}, yaml_block = {self.yaml_block}")
|
||||||
self.condition = SmartCondition.Evaluator(self.ad_api,self.get_expression_yaml(), logger_interface = self.logger_interface, unavaibility_result = SmartCondition.Result.Unavailable,condition_name = self.virtual_sensor_name,on_change_cb = self.on_sensor_change, constants = self.constants, templates_library = self.templates_library)
|
self.condition = SmartCondition.Evaluator(self.ad_api,self.get_expression_yaml(), logger_interface = self.logger_interface, unavaibility_result = SmartCondition.Result.Unavailable,condition_name = self.virtual_sensor_name,on_change_cb = self.on_sensor_change, constants = self.constants, templates_library = self.templates_library, log_group = self.sensor_name)
|
||||||
new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
|
new_value = 'on' if self.condition.evaluate() == SmartCondition.Result.Succeeded else 'off'
|
||||||
self.output_sensor.set_state(state = new_value)
|
self.output_sensor.set_state(state = new_value)
|
||||||
|
|
||||||
def on_sensor_change(self,prev_result,result):
|
def on_sensor_change(self,prev_result,result):
|
||||||
if result == SmartCondition.Result.Unavailable: self.output_sensor.set_state(state = 'unavailable')
|
target_state = None
|
||||||
elif result == SmartCondition.Result.Succeeded: self.output_sensor.set_state(state = 'on')
|
if result == SmartCondition.Result.Unavailable: target_state = 'unavailable'
|
||||||
elif result == SmartCondition.Result.Failed: self.output_sensor.set_state(state = 'off')
|
elif result == SmartCondition.Result.Succeeded: target_state = 'on'
|
||||||
# else: Disabled means we keep the previous value
|
elif result == SmartCondition.Result.Failed: target_state = 'off'
|
||||||
|
else: target_state = None #Disabled means we keep the previous value
|
||||||
|
|
||||||
|
if target_state != None:
|
||||||
|
self.output_sensor.set_state(state = target_state)
|
||||||
|
self.log_info(f"{self.sensor_name} = {target_state}")
|
||||||
|
else:
|
||||||
|
self.log_info(f"SmartCondition of {self.sensor_name} is disabled")
|
||||||
|
|
||||||
|
|
||||||
def generate_dependencies_list(self):
|
def generate_dependencies_list(self):
|
||||||
dependencies = list()
|
dependencies = list()
|
||||||
@@ -575,20 +757,28 @@ class EventSensor(VirtualSensorBase):
|
|||||||
|
|
||||||
self.retain_time = self.yaml_block['retain_time']
|
self.retain_time = self.yaml_block['retain_time']
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self._timer_generation = 0
|
||||||
|
|
||||||
self.event_handlers = []
|
self.event_handlers = []
|
||||||
if "activation_events" in self.yaml_block:
|
if "activation_events" in self.yaml_block:
|
||||||
self.event_handlers.append(EventHandler(self.ad_api, self.yaml_block["activation_events"], self.on_activation_event,logger_interface = self.logger_interface))
|
self.event_handlers.append(EventHandler(self.ad_api, self.yaml_block["activation_events"], self.on_activation_event,logger_interface = self.logger_interface))
|
||||||
|
|
||||||
def on_activation_event(self, event_name, data, kwargs):
|
def on_activation_event(self, event_name, data, kwargs):
|
||||||
|
self._timer_generation += 1
|
||||||
self.output_sensor.set_state(state = 'on')
|
self.output_sensor.set_state(state = 'on')
|
||||||
if self.cb_handle is not None:
|
if self.cb_handle is not None:
|
||||||
self.ad_api.cancel_timer(self.cb_handle)
|
self.ad_api.cancel_timer(self.cb_handle, silent=True)
|
||||||
self.cb_handle = self.ad_api.run_in(self.on_retain_time_elapsed, self.retain_time)
|
self.cb_handle = self.ad_api.run_in(
|
||||||
|
self.on_retain_time_elapsed, self.retain_time,
|
||||||
|
generation=self._timer_generation,
|
||||||
|
)
|
||||||
|
|
||||||
def on_retain_time_elapsed(self, kwargs):
|
def on_retain_time_elapsed(self, kwargs):
|
||||||
self.output_sensor.set_state(state = 'off')
|
# Cancellation cannot retract a callback already queued by AppDaemon.
|
||||||
|
if kwargs.get('generation') != self._timer_generation:
|
||||||
|
return
|
||||||
self.cb_handle = None
|
self.cb_handle = None
|
||||||
|
self.output_sensor.set_state(state = 'off')
|
||||||
|
|
||||||
def generate_sensor_name(self,virtual_sensor_name):
|
def generate_sensor_name(self,virtual_sensor_name):
|
||||||
return f"binary_sensor.{virtual_sensor_name}"
|
return f"binary_sensor.{virtual_sensor_name}"
|
||||||
@@ -640,7 +830,7 @@ class VirtualSensors():
|
|||||||
for sensor_name,default_value in default_values.items():
|
for sensor_name,default_value in default_values.items():
|
||||||
if not self.ad_api.entity_exists(sensor_name):
|
if not self.ad_api.entity_exists(sensor_name):
|
||||||
self.logger_interface.log_info(f"Creating sensor {sensor_name} = {default_value}")
|
self.logger_interface.log_info(f"Creating sensor {sensor_name} = {default_value}")
|
||||||
self.ad_api.set_state(sensor_name, state = str(default_value))
|
self.ad_api.set_state(sensor_name, state = str(default_value),check_existence=False)
|
||||||
|
|
||||||
averagers = parser.parse_args('averagers',{})
|
averagers = parser.parse_args('averagers',{})
|
||||||
for averager in averagers:
|
for averager in averagers:
|
||||||
@@ -667,6 +857,8 @@ class VirtualSensors():
|
|||||||
|
|
||||||
if splitted_sensor[0] == 'binary_sensor':
|
if splitted_sensor[0] == 'binary_sensor':
|
||||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = BinarySensor(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = BinarySensor(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
|
elif splitted_sensor[0] == 'time_period':
|
||||||
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = TimePeriod(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'sensor':
|
elif splitted_sensor[0] == 'sensor':
|
||||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSensor(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSensor(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'continuous_condition':
|
elif splitted_sensor[0] == 'continuous_condition':
|
||||||
@@ -675,6 +867,8 @@ class VirtualSensors():
|
|||||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = Averager(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = Averager(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'value_selector':
|
elif splitted_sensor[0] == 'value_selector':
|
||||||
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"sensor.{splitted_sensor[1]}"] = ValueSelector(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
|
elif splitted_sensor[0] == 'ephemeral_condition':
|
||||||
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = EphemeralCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'retain_condition':
|
elif splitted_sensor[0] == 'retain_condition':
|
||||||
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = RetainCondition(self.ad_api,self.logger_interface,splitted_sensor[1],super_entity_id = super_entity_id,yaml_block = yaml_block['sensors'][sensor],constants = constants,templates_library = templates_library,app_name = app_name)
|
||||||
elif splitted_sensor[0] == 'event_sensor':
|
elif splitted_sensor[0] == 'event_sensor':
|
||||||
@@ -744,4 +938,5 @@ class VirtualSensors():
|
|||||||
class VirtualSensorsApp(hass.Hass):
|
class VirtualSensorsApp(hass.Hass):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.logger_interface = LoggerInterface(self.get_ad_api(),default_log = "virtualsensors_log")
|
self.logger_interface = LoggerInterface(self.get_ad_api(),default_log = "virtualsensors_log")
|
||||||
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'],app_name = self.name)
|
with self.logger_interface.log_group("App Initialization"):
|
||||||
|
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'],app_name = self.name)
|
||||||
@@ -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.
|
||||||
@@ -17,9 +17,11 @@ By default, it reads the existing AppDaemon text logs from the workspace:
|
|||||||
- `logs/error.log`
|
- `logs/error.log`
|
||||||
- `logs/virtualsensors.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, relative timestamps, sorting by latest activity or app name, text/time/level filters, live updates, copy/paste, raw-log navigation, and archived-log import. 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.
|
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.
|
||||||
|
|
||||||
The standard AppDaemon text logs do not contain causal links or reliable initialization boundaries. The details panel will say so for ordinary entries. Existing messages that already contain trace or initialization metadata can still be grouped or linked; the explorer does not create that metadata. **Open history** can also load archived files. No AppDaemon Python changes are needed for this raw-log view.
|
**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
|
## Settings
|
||||||
|
|
||||||
@@ -59,8 +61,8 @@ settings file.
|
|||||||
- Home Assistant entity completion and hover information in Python/YAML.
|
- Home Assistant entity completion and hover information in Python/YAML.
|
||||||
- Navigation to AppDaemon app and virtual-sensor definitions.
|
- Navigation to AppDaemon app and virtual-sensor definitions.
|
||||||
- AppDaemon errors as VS Code diagnostics and an output channel.
|
- AppDaemon errors as VS Code diagnostics and an output channel.
|
||||||
- Status bar actions for app reload, Home Assistant restart, production mode,
|
- Status bar actions for app reload, Home Assistant restart, log exploration,
|
||||||
error handling and apps defined in the active YAML file.
|
and apps defined in the active YAML file.
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -70,7 +72,7 @@ settings file.
|
|||||||
Commands are available under **AppDaemon:** in the Command Palette: **Go to
|
Commands are available under **AppDaemon:** in the Command Palette: **Go to
|
||||||
App**, **Restart Apps in Current File**, **Reload Apps**, **Restart Home
|
App**, **Restart Apps in Current File**, **Reload Apps**, **Restart Home
|
||||||
Assistant**, **Toggle Production Mode**, **Show Error Log**, **Refresh Entity
|
Assistant**, **Toggle Production Mode**, **Show Error Log**, **Refresh Entity
|
||||||
List**, **Handle Errors**, **Clear Error Diagnostics**, and **Explore Logs**.
|
List**, **Clear Error Diagnostics**, and **Explore Logs**.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,22 @@
|
|||||||
.log-message.level-WARNING{color:var(--vscode-editorWarning-foreground,#f3cd80)}.log-message.level-ERROR{color:var(--vscode-editorError-foreground,#ffacac)}
|
.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}
|
.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)}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src {{CSP}}; script-src 'nonce-{{NONCE}}';">
|
||||||
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
|
<link rel="stylesheet" href="{{CSS}}"><title>AppDaemon · Logs</title></head>
|
||||||
<body>
|
<body>
|
||||||
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="import">Open history</button><button id="copy">Copy selected log</button><button id="pause">Pause</button></div></header>
|
<header><div><h1>AppDaemon <span>Logs</span></h1><p id="status" role="status">Reading logs…</p></div><div class="actions"><button id="error-count" aria-pressed="false" title="Show errors across all apps in loaded history">0 errors</button><button id="warning-count" aria-pressed="false" title="Show warnings across all apps in loaded history">0 warnings</button><button id="clear" title="Empty configured log files">Clear logs</button></div></header>
|
||||||
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
|
<nav aria-label="Filters"><input id="search" type="search" placeholder="Search apps, entities and messages…" aria-label="Search"><select id="period" aria-label="Time range"><option value="15">15 minutes</option><option value="60">1 hour</option><option value="1440">24 hours</option><option value="0" selected>All loaded</option></select><select id="level" aria-label="Level"><option value="all">All levels</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></nav>
|
||||||
<div id="warnings" role="status"></div>
|
<div id="warnings" role="status"></div>
|
||||||
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
|
<main><section class="apps" aria-label="Applications"><div class="section-title"><h2>Applications</h2><span id="count"></span><select id="sort" aria-label="Sort apps"><option value="latest" selected>Latest log first</option><option value="oldest">Oldest log first</option><option value="name">App name</option></select></div><div id="apps"></div></section><section class="causes" aria-label="Log details"><div class="section-title"><h2>Log details</h2><div><button id="graph-mode" aria-pressed="true">Graph</button><button id="tree-mode" aria-pressed="false">Tree</button></div></div><div id="detail"><div class="empty">Select a log to view its details.<p>The base AppDaemon log format does not include causal links.</p></div></div></section></main>
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
/* Shared UI: VS Code transport or a standalone review fixture. No log HTML is executed. */
|
/* Shared UI: VS Code transport or a standalone review fixture. No log HTML is executed. */
|
||||||
(() => {
|
(() => {
|
||||||
const inVSCode = typeof acquireVsCodeApi === 'function';
|
const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : {
|
||||||
const vscode = inVSCode ? acquireVsCodeApi() : {
|
postMessage() {}, getState() {}, setState() {}
|
||||||
async postMessage(message) {
|
|
||||||
if (message.type !== 'copy') return;
|
|
||||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(message.text);
|
|
||||||
else {
|
|
||||||
const area = document.createElement('textarea'); area.value = message.text;
|
|
||||||
document.body.append(area); area.select(); document.execCommand?.('copy'); area.remove();
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new MessageEvent('message', { data: { type: 'copied' } }));
|
|
||||||
}, getState() {}, setState() {}
|
|
||||||
};
|
};
|
||||||
const saved = vscode.getState() || {};
|
const saved = vscode.getState() || {};
|
||||||
let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', paused = false, selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected;
|
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 expanded = new Set(saved.expanded || []);
|
||||||
const visibleEventGroups = new Map();
|
const visibleEventGroups = new Map();
|
||||||
|
const logScrollPositions = new Map();
|
||||||
const recentEvents = new Set();
|
const recentEvents = new Set();
|
||||||
const recentApps = new Set();
|
const recentApps = new Set();
|
||||||
const appFlashTokens = new Map();
|
const appFlashTokens = new Map();
|
||||||
let receivedInitialData = false;
|
let receivedInitialData = false;
|
||||||
let pointerSelecting = false;
|
let pointerSelecting = false;
|
||||||
|
let countView;
|
||||||
|
let activeCountLevel;
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
|
const node = (tag, text, cls) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; };
|
||||||
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
|
const normalLevel = e => e.level === 'CRITICAL' ? 'ERROR' : e.level;
|
||||||
@@ -33,14 +27,13 @@
|
|||||||
&& (level === 'all' || normalLevel(e) === level)
|
&& (level === 'all' || normalLevel(e) === level)
|
||||||
&& `${e.app} ${e.entity || ''} ${e.message}`.toLowerCase().includes(q));
|
&& `${e.app} ${e.entity || ''} ${e.message}`.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
const cleanMessage = e => e.message.replace(/ \[event=[\w-]+ trace=[\w-]+\]/g, '').replace(/ \[init=[\w-]+\]/g, '').replace(/ \[group=(?:initialization|smartcondition|value_selector):[a-f0-9]{12}\]/gi, '');
|
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) => {
|
const formatTime = (entry, includeYear = false) => {
|
||||||
if (!Number.isFinite(entry.time)) return entry.timestamp;
|
if (!Number.isFinite(entry.time)) return entry.timestamp;
|
||||||
try {
|
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));
|
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; }
|
} catch { return entry.timestamp; }
|
||||||
};
|
};
|
||||||
const copyText = e => `${formatTime(e, true)} ${e.level} ${e.app}: ${cleanMessage(e)}`;
|
|
||||||
function timeSince(time) {
|
function timeSince(time) {
|
||||||
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
|
const seconds = Math.max(0, Math.floor((Date.now() - Number(time)) / 1000));
|
||||||
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
|
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
|
||||||
@@ -55,7 +48,19 @@
|
|||||||
const years = Math.floor(days / 365);
|
const years = Math.floor(days / 365);
|
||||||
return `${years} year${years === 1 ? '' : 's'} ago`;
|
return `${years} year${years === 1 ? '' : 's'} ago`;
|
||||||
}
|
}
|
||||||
function copy(text) { if (text) vscode.postMessage({ type: 'copy', text }); }
|
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) {
|
function logMessages(items, cls) {
|
||||||
const container = node('div', undefined, cls);
|
const container = node('div', undefined, cls);
|
||||||
items.forEach((entry, index) => {
|
items.forEach((entry, index) => {
|
||||||
@@ -64,13 +69,12 @@
|
|||||||
});
|
});
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
function blockTitle(items, groupKey) {
|
function blockTitle(items) {
|
||||||
const first = items[0];
|
const first = items[0];
|
||||||
const kind = groupKey?.startsWith('initialization:') || first.initialization_id ? 'initialization' : items.find(entry => entry.group_kind)?.group_kind;
|
const kind = items.find(entry => entry.group_kind)?.group_kind;
|
||||||
const names = { initialization: 'Initialization', smartcondition: 'Smart condition', value_selector: 'Value selector' };
|
const name = kind || (items.length > 1 || first.trace_id ? 'Event' : 'Log');
|
||||||
const name = names[kind] || (items.length > 1 || first.trace_id ? 'Event' : 'Log');
|
const label = items.map(entry => entry.group_label).find(Boolean) || first.entity || first.app;
|
||||||
const label = kind === 'initialization' ? first.app : items.map(entry => entry.group_label || cleanMessage(entry).match(/^\[([^\]]+)\]/)?.[1]).find(Boolean) || first.entity || first.app;
|
return node('div', kind ? name : `${name} · ${label}`, 'block-title');
|
||||||
return node('div', `${name} · ${label}`, 'block-title');
|
|
||||||
}
|
}
|
||||||
function row(e) {
|
function row(e) {
|
||||||
const item = node('div', undefined, `row${selected === e.event_id ? ' selected' : ''}${recentEvents.has(e.event_id) ? ' recent-log' : ''}`);
|
const item = node('div', undefined, `row${selected === e.event_id ? ' selected' : ''}${recentEvents.has(e.event_id) ? ' recent-log' : ''}`);
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
const relative = node('span', timeSince(e.time), 'relative-time');
|
const relative = node('span', timeSince(e.time), 'relative-time');
|
||||||
relative.dataset.time = String(e.time);
|
relative.dataset.time = String(e.time);
|
||||||
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
|
relative.title = `${formatTime(e, true)} · Exact: ${e.timestamp}`;
|
||||||
meta.append(relative, node('span', e.level, `badge ${normalLevel(e)}`));
|
meta.append(node('span', e.timestamp, 'exact-time'), relative, node('span', e.level, `badge ${normalLevel(e)}`));
|
||||||
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
if (e.cause_ids.length) meta.append(node('span', 'View causes ↗', 'link-hint'));
|
||||||
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
|
item.append(blockTitle([e]), meta, logMessages([e], 'row-message'));
|
||||||
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
|
item.onclick = () => { if (!window.getSelection()?.toString()) choose(e.event_id); };
|
||||||
@@ -111,7 +115,7 @@
|
|||||||
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
|
const relative = node('span', timeSince(unit.entries[0].time), 'relative-time');
|
||||||
relative.dataset.time = String(unit.entries[0].time);
|
relative.dataset.time = String(unit.entries[0].time);
|
||||||
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
|
relative.title = `${formatTime(unit.entries[0], true)} · Exact: ${unit.entries[0].timestamp}`;
|
||||||
meta.append(relative);
|
meta.append(node('span', unit.entries[0].timestamp, 'exact-time'), relative);
|
||||||
for (const level of ['INFO', 'WARNING', 'ERROR']) {
|
for (const level of ['INFO', 'WARNING', 'ERROR']) {
|
||||||
const count = unit.entries.filter(e => normalLevel(e) === level).length;
|
const count = unit.entries.filter(e => normalLevel(e) === level).length;
|
||||||
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
|
if (count) meta.append(node('span', count === 1 ? level : `${level} ${count}`, `badge ${level}`));
|
||||||
@@ -124,6 +128,16 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderApps() {
|
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();
|
visibleEventGroups.clear();
|
||||||
const container = $('apps'); container.replaceChildren();
|
const container = $('apps'); container.replaceChildren();
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
@@ -132,9 +146,11 @@
|
|||||||
if (!groups.size) container.append(node('div', 'No logs match these filters. Try a wider time range or clear the search.', 'empty'));
|
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();
|
const lastLogs = new Map();
|
||||||
entries.filter(e => e.kind === 'log' || e.kind.startsWith('initialization_')).forEach(e => {
|
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);
|
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;
|
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]) => {
|
[...groups].sort(([a, al], [b, bl]) => {
|
||||||
const order = $('sort').value;
|
const order = $('sort').value;
|
||||||
if (order === 'name') return a.localeCompare(b);
|
if (order === 'name') return a.localeCompare(b);
|
||||||
@@ -144,45 +160,67 @@
|
|||||||
if (recentApps.has(app)) detail.classList.add('new-log-app');
|
if (recentApps.has(app)) detail.classList.add('new-log-app');
|
||||||
const summary = node('summary'), label = node('div', undefined, 'app-label');
|
const summary = node('summary'), label = node('div', undefined, 'app-label');
|
||||||
const last = lastLogs.get(app);
|
const last = lastLogs.get(app);
|
||||||
|
let preview;
|
||||||
label.append(node('span', app, 'name'));
|
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) {
|
if (last) {
|
||||||
const age = node('span', `Last log: ${timeSince(last.time)}`, 'last-log last-age');
|
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.dataset.time = String(last.time);
|
||||||
age.title = `${formatTime(last, true)} · Exact: ${last.timestamp}`;
|
age.title = `${formatTime(last, true)} · Exact: ${last.timestamp}`;
|
||||||
label.append(age);
|
const message = cleanMessage(last);
|
||||||
} else label.append(node('span', 'No log in loaded history', 'last-log'));
|
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);
|
summary.append(label);
|
||||||
['INFO', 'WARNING', 'ERROR'].forEach(level => summary.append(node('span', `${level} ${logs.filter(e => normalLevel(e) === level).length}`, `badge ${level}`)));
|
['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);
|
detail.append(summary);
|
||||||
function fill() {
|
function fill() {
|
||||||
detail.querySelector('.rows')?.remove();
|
detail.querySelector('.rows')?.remove();
|
||||||
if (!detail.open) return;
|
if (!detail.open) return;
|
||||||
const rows = node('div', undefined, 'rows');
|
const rows = node('div', undefined, 'rows');
|
||||||
const units = [], initGroups = new Map();
|
const units = [], groupUnits = new Map();
|
||||||
logs.forEach(e => {
|
logs.forEach(e => {
|
||||||
if (e.initialization_id) {
|
if (e.group_id) {
|
||||||
if (!initGroups.has(e.initialization_id)) { const unit = { id: e.initialization_id, entries: [] }; initGroups.set(e.initialization_id, unit); units.push(unit); }
|
const key = `group:${e.group_id}`;
|
||||||
initGroups.get(e.initialization_id).entries.push(e);
|
if (!groupUnits.has(key)) { const unit = { key, entries: [] }; groupUnits.set(key, unit); units.push(unit); }
|
||||||
|
groupUnits.get(key).entries.push(e);
|
||||||
} else {
|
} else {
|
||||||
const key = e.group_id ? `group:${e.group_id}` : e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined;
|
const key = e.kind === 'log' && e.trace_id ? `trace:${e.trace_id}` : undefined;
|
||||||
const previous = units[units.length - 1];
|
const previous = units[units.length - 1];
|
||||||
if (key && previous && !previous.id && previous.key === key) previous.entries.push(e);
|
if (key && previous && !previous.id && previous.key === key) previous.entries.push(e);
|
||||||
else units.push({ key, entries: [e] });
|
else units.push({ key, entries: [e] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const startTime = unit => unit.id
|
const startTime = unit => unit.entries[0].time;
|
||||||
? (entries.find(e => e.app === app && e.initialization_id === unit.id && e.kind === 'initialization_started') || unit.entries[0]).time
|
|
||||||
: unit.entries[0].time;
|
|
||||||
units.sort((a, b) => startTime(a) - startTime(b));
|
units.sort((a, b) => startTime(a) - startTime(b));
|
||||||
units.slice(-150).forEach(unit => {
|
|
||||||
appendEventRows(rows, unit.entries, unit.id ? `initialization:${unit.id}` : undefined);
|
|
||||||
});
|
|
||||||
if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more'));
|
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);
|
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; detail.open ? expanded.add(app) : expanded.delete(app); save(); fill(); });
|
detail.addEventListener('toggle', event => {
|
||||||
fill(); container.append(detail);
|
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() {
|
function renderDetail() {
|
||||||
$('graph-mode').setAttribute('aria-pressed', String(mode === 'graph'));
|
$('graph-mode').setAttribute('aria-pressed', String(mode === 'graph'));
|
||||||
@@ -193,7 +231,15 @@
|
|||||||
const selectedGroup = visibleEventGroups.get(e.event_id) || [e];
|
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'));
|
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 raw = node('button', 'Open raw log'); raw.onclick = () => vscode.postMessage({ type: 'open', id: e.event_id }); container.append(raw);
|
||||||
if (e.source) { const source = node('button', 'Open source'); source.onclick = () => vscode.postMessage({ type: 'source', id: e.event_id }); container.append(source); }
|
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); }
|
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 byId = new Map(entries.map(e => [e.event_id, e]));
|
||||||
const mirroredEvaluations = new Map();
|
const mirroredEvaluations = new Map();
|
||||||
@@ -313,37 +359,67 @@
|
|||||||
? `${logCount} log lines + ${traceCount} ${traceLabel} · Live updates · Up to ${message.limit || 10000} records`
|
? `${logCount} log lines + ${traceCount} ${traceLabel} · Live updates · Up to ${message.limit || 10000} records`
|
||||||
: `${logCount} log lines · Live updates · Up to ${message.limit || 10000} records`;
|
: `${logCount} log lines · Live updates · Up to ${message.limit || 10000} records`;
|
||||||
$('status').title = traceCount
|
$('status').title = traceCount
|
||||||
? 'Showing AppDaemon logs and additional records from configured history files.'
|
? '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.';
|
: '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();
|
$('warnings').textContent = (message.warnings || []).join(' · '); renderApps(); renderDetail();
|
||||||
}
|
}
|
||||||
function refreshLastLogAges() {
|
function refreshLastLogAges() {
|
||||||
document.querySelectorAll('.last-age[data-time]').forEach(label => { label.textContent = `Last log: ${timeSince(label.dataset.time)}`; });
|
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); });
|
document.querySelectorAll('.relative-time[data-time]').forEach(label => { label.textContent = timeSince(label.dataset.time); });
|
||||||
}
|
}
|
||||||
setInterval(refreshLastLogAges, 1000);
|
setInterval(refreshLastLogAges, 1000);
|
||||||
function selectionActive() { return pointerSelecting || Boolean(window.getSelection()?.toString()); }
|
function selectionActive() { return pointerSelecting || Boolean(window.getSelection()?.toString()); }
|
||||||
window.addEventListener('message', event => {
|
window.addEventListener('message', event => {
|
||||||
if (event.data?.type === 'copied') { $('copy').textContent = 'Copied'; setTimeout(() => $('copy').textContent = 'Copy selected log', 1200); return; }
|
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;
|
if (event.data?.type !== 'entries') return;
|
||||||
latest = event.data;
|
latest = event.data;
|
||||||
if (!paused && !selectionActive()) apply(latest);
|
if (!selectionActive()) apply(latest);
|
||||||
else if (!paused) $('status').textContent = 'Updates buffered while text is selected';
|
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('pointerdown', event => { pointerSelecting = Boolean(event.target.closest?.('.row, .selected-message, .node, .values')); });
|
||||||
document.addEventListener('pointerup', () => { pointerSelecting = false; });
|
document.addEventListener('pointerup', () => { pointerSelecting = false; });
|
||||||
document.addEventListener('selectionchange', () => { if (!paused && !selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!paused && !selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
|
document.addEventListener('selectionchange', () => { if (!selectionActive() && latest && latest !== appliedMessage) setTimeout(() => { if (!selectionActive() && latest !== appliedMessage) apply(latest); }, 0); });
|
||||||
document.addEventListener('keydown', event => {
|
['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && !event.target.closest?.('input, textarea, [contenteditable=true]')) {
|
countView = undefined; activeCountLevel = undefined; selected = undefined; origin = undefined;
|
||||||
const entry = entries.find(e => e.event_id === selected);
|
save(); renderApps(); renderDetail();
|
||||||
const text = window.getSelection()?.toString() || (entry ? (visibleEventGroups.get(entry.event_id) || [entry]).map(copyText).join('\n') : '');
|
}));
|
||||||
if (text && (inVSCode || !window.getSelection()?.toString())) { event.preventDefault(); copy(text); }
|
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;
|
||||||
$('copy').onclick = () => { const e = entries.find(e => e.event_id === selected); copy(window.getSelection()?.toString() || (e ? (visibleEventGroups.get(e.event_id) || [e]).map(copyText).join('\n') : '')); };
|
save(); renderApps(); renderDetail();
|
||||||
['search', 'period', 'level', 'sort'].forEach(id => $(id).addEventListener('input', () => { save(); renderApps(); }));
|
if (!leaving) {
|
||||||
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; if (!paused && latest) apply(latest); else if (paused) $('status').textContent = 'Display paused · New events are still being collected'; };
|
const first = document.querySelector('#apps .row');
|
||||||
$('import').onclick = () => vscode.postMessage({ type: 'import' });
|
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(); };
|
$('graph-mode').onclick = () => { mode = 'graph'; save(); renderDetail(); };
|
||||||
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
|
$('tree-mode').onclick = () => { mode = 'tree'; save(); renderDetail(); };
|
||||||
$('sort').value = saved.sort || 'latest';
|
$('sort').value = saved.sort || 'latest';
|
||||||
|
|||||||
@@ -113,10 +113,6 @@
|
|||||||
"title": "AppDaemon: Refresh Entity List",
|
"title": "AppDaemon: Refresh Entity List",
|
||||||
"icon": "$(sync)"
|
"icon": "$(sync)"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"command": "appdaemon.handleErrors",
|
|
||||||
"title": "AppDaemon: Handle Errors"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "appdaemon.clearErrors",
|
"command": "appdaemon.clearErrors",
|
||||||
"title": "AppDaemon: Clear Error Diagnostics"
|
"title": "AppDaemon: Clear Error Diagnostics"
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ function normalizeUrl(raw: string): string {
|
|||||||
|
|
||||||
export class ADClient {
|
export class ADClient {
|
||||||
private productionMode = false;
|
private productionMode = false;
|
||||||
private _onProductionModeChanged = new vscode.EventEmitter<boolean>();
|
|
||||||
readonly onProductionModeChanged = this._onProductionModeChanged.event;
|
|
||||||
private log: vscode.OutputChannel;
|
private log: vscode.OutputChannel;
|
||||||
|
|
||||||
constructor(log: vscode.OutputChannel) {
|
constructor(log: vscode.OutputChannel) {
|
||||||
@@ -120,7 +118,6 @@ export class ADClient {
|
|||||||
async setProductionMode(mode: boolean): Promise<void> {
|
async setProductionMode(mode: boolean): Promise<void> {
|
||||||
await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode });
|
await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode });
|
||||||
this.productionMode = mode;
|
this.productionMode = mode;
|
||||||
this._onProductionModeChanged.fire(mode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,7 +132,6 @@ export class ADClient {
|
|||||||
if (key.includes('production_mode')) {
|
if (key.includes('production_mode')) {
|
||||||
const val = (state[key] as { state?: unknown })?.state;
|
const val = (state[key] as { state?: unknown })?.state;
|
||||||
this.productionMode = val === true || val === 'True' || val === 'true';
|
this.productionMode = val === true || val === 'True' || val === 'true';
|
||||||
this._onProductionModeChanged.fire(this.productionMode);
|
|
||||||
return this.productionMode;
|
return this.productionMode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +151,5 @@ export class ADClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._onProductionModeChanged.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,6 @@ export class ErrorViewer {
|
|||||||
private diagnosticCollection: vscode.DiagnosticCollection;
|
private diagnosticCollection: vscode.DiagnosticCollection;
|
||||||
private watcher: fs.FSWatcher | undefined;
|
private watcher: fs.FSWatcher | undefined;
|
||||||
private lastSize = 0;
|
private lastSize = 0;
|
||||||
private errorCount = 0;
|
|
||||||
|
|
||||||
private _onErrorCountChanged = new vscode.EventEmitter<number>();
|
|
||||||
readonly onErrorCountChanged = this._onErrorCountChanged.event;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors');
|
this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors');
|
||||||
@@ -30,12 +26,6 @@ export class ErrorViewer {
|
|||||||
|
|
||||||
clearDiagnostics() {
|
clearDiagnostics() {
|
||||||
this.diagnosticCollection.clear();
|
this.diagnosticCollection.clear();
|
||||||
this.errorCount = 0;
|
|
||||||
this._onErrorCountChanged.fire(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
getErrorCount(): number {
|
|
||||||
return this.errorCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── file watching ────────────────────────────────────────────────────────
|
// ── file watching ────────────────────────────────────────────────────────
|
||||||
@@ -115,12 +105,9 @@ export class ErrorViewer {
|
|||||||
this.outputChannel.appendLine(trimmed);
|
this.outputChannel.appendLine(trimmed);
|
||||||
|
|
||||||
if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) {
|
if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) {
|
||||||
this.errorCount++;
|
|
||||||
this.parseDiagnostic(trimmed);
|
this.parseDiagnostic(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._onErrorCountChanged.fire(this.errorCount);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.closeSync(fd);
|
fs.closeSync(fd);
|
||||||
}
|
}
|
||||||
@@ -183,6 +170,5 @@ export class ErrorViewer {
|
|||||||
}
|
}
|
||||||
this.outputChannel.dispose();
|
this.outputChannel.dispose();
|
||||||
this.diagnosticCollection.dispose();
|
this.diagnosticCollection.dispose();
|
||||||
this._onErrorCountChanged.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,12 +345,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
haClient = new HAClient(outputChannel);
|
haClient = new HAClient(outputChannel);
|
||||||
adClient = new ADClient(outputChannel);
|
adClient = new ADClient(outputChannel);
|
||||||
errorViewer = new ErrorViewer();
|
errorViewer = new ErrorViewer();
|
||||||
const logExplorer = new LogExplorer(context);
|
const logExplorer = new LogExplorer(context, count => statusBar.setLogErrorCount(count));
|
||||||
context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show()));
|
context.subscriptions.push(logExplorer, vscode.commands.registerCommand('appdaemon.exploreLogs', () => logExplorer.show()));
|
||||||
statusBar = new StatusBarManager(adClient);
|
statusBar = new StatusBarManager();
|
||||||
|
logExplorer.startPolling();
|
||||||
// Wire error count → status bar badge
|
|
||||||
errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count));
|
|
||||||
|
|
||||||
// ── Contextual per-file restart button ───────────────────────────────────
|
// ── Contextual per-file restart button ───────────────────────────────────
|
||||||
updateContextualButton(vscode.window.activeTextEditor);
|
updateContextualButton(vscode.window.activeTextEditor);
|
||||||
@@ -446,10 +444,12 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
|
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
vscode.commands.registerCommand('appdaemon.goToApp', async () => {
|
vscode.commands.registerCommand('appdaemon.goToApp', async (requestedName?: string) => {
|
||||||
const apps = await findAllApps();
|
const apps = (await findAllApps()).filter(app => !requestedName || app.appName === requestedName);
|
||||||
if (apps.length === 0) {
|
if (apps.length === 0) {
|
||||||
vscode.window.showWarningMessage('AppDaemon: No app definitions found in workspace');
|
vscode.window.showWarningMessage(requestedName
|
||||||
|
? `AppDaemon: No definition found for ${requestedName}`
|
||||||
|
: 'AppDaemon: No app definitions found in workspace');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +461,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
app
|
app
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const picked = await vscode.window.showQuickPick(items, {
|
const picked = apps.length === 1 && requestedName ? items[0] : await vscode.window.showQuickPick(items, {
|
||||||
placeHolder: 'Go to AppDaemon app…',
|
placeHolder: 'Go to AppDaemon app…',
|
||||||
matchOnDescription: true,
|
matchOnDescription: true,
|
||||||
matchOnDetail: false
|
matchOnDetail: false
|
||||||
@@ -543,18 +543,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand('appdaemon.showErrors', () => {
|
vscode.commands.registerCommand('appdaemon.showErrors', () => {
|
||||||
errorViewer.show();
|
errorViewer.show();
|
||||||
}),
|
}),
|
||||||
vscode.commands.registerCommand('appdaemon.handleErrors', async () => {
|
|
||||||
const pick = await vscode.window.showErrorMessage(
|
|
||||||
'AppDaemon errors detected',
|
|
||||||
'Show log',
|
|
||||||
'Clear'
|
|
||||||
);
|
|
||||||
if (pick === 'Show log') {
|
|
||||||
errorViewer.show();
|
|
||||||
} else if (pick === 'Clear') {
|
|
||||||
errorViewer.clearDiagnostics();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
vscode.commands.registerCommand('appdaemon.refreshEntities', async () => {
|
vscode.commands.registerCommand('appdaemon.refreshEntities', async () => {
|
||||||
const entities = await haClient.fetchEntities();
|
const entities = await haClient.fetchEntities();
|
||||||
vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`);
|
vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { LogEntry, parseLine, decodeLogLine } from './logModel';
|
import { LogEntry, parseLine, decodeLogLine } from './logModel';
|
||||||
|
import { findLogSource, SourceDocument } from './logSource';
|
||||||
|
|
||||||
type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; discard: boolean; generation: string };
|
type Cursor = { offset: number; identity: string; pending: Buffer; last?: LogEntry; errorBlock?: boolean; discard: boolean; generation: string };
|
||||||
export class LogExplorer implements vscode.Disposable {
|
export class LogExplorer implements vscode.Disposable {
|
||||||
private panel?: vscode.WebviewPanel;
|
private panel?: vscode.WebviewPanel;
|
||||||
private view?: vscode.WebviewView;
|
private view?: vscode.WebviewView;
|
||||||
@@ -13,10 +14,12 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
private timer?: ReturnType<typeof setInterval>;
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
private entries = new Map<string, LogEntry>();
|
private entries = new Map<string, LogEntry>();
|
||||||
private cursors = new Map<string, Cursor>();
|
private cursors = new Map<string, Cursor>();
|
||||||
private imported = new Set<string>();
|
|
||||||
private warnings = new Set<string>();
|
private warnings = new Set<string>();
|
||||||
private busy = false;
|
private busy = false;
|
||||||
constructor(private context: vscode.ExtensionContext) {}
|
private sourceDocuments?: Promise<SourceDocument[]>;
|
||||||
|
private sourceDocumentsAt = 0;
|
||||||
|
private publication = 0;
|
||||||
|
constructor(private context: vscode.ExtensionContext, private onErrorCountChanged?: (count: number) => void) {}
|
||||||
show() {
|
show() {
|
||||||
if (this.view) { this.view.show(); return; }
|
if (this.view) { this.view.show(); return; }
|
||||||
if (this.panel) { this.panel.reveal(); return; }
|
if (this.panel) { this.panel.reveal(); return; }
|
||||||
@@ -27,7 +30,7 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
this.configureWebview(this.panel.webview);
|
this.configureWebview(this.panel.webview);
|
||||||
this.panel.onDidDispose(() => {
|
this.panel.onDidDispose(() => {
|
||||||
this.panel = undefined;
|
this.panel = undefined;
|
||||||
if (!this.view) { this.stopPolling(); this.activeWebview = undefined; }
|
if (!this.view) { this.activeWebview = undefined; }
|
||||||
});
|
});
|
||||||
this.startPolling();
|
this.startPolling();
|
||||||
}
|
}
|
||||||
@@ -38,11 +41,11 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
|
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')]
|
||||||
};
|
};
|
||||||
this.configureWebview(view.webview);
|
this.configureWebview(view.webview);
|
||||||
const updateVisibility = () => view.visible ? this.startPolling() : this.stopPolling();
|
const updateVisibility = () => { if (view.visible) { this.startPolling(); } };
|
||||||
view.onDidChangeVisibility(updateVisibility, undefined, this.context.subscriptions);
|
view.onDidChangeVisibility(updateVisibility, undefined, this.context.subscriptions);
|
||||||
view.onDidDispose(() => {
|
view.onDidDispose(() => {
|
||||||
if (this.view === view) { this.view = undefined; }
|
if (this.view === view) { this.view = undefined; }
|
||||||
if (!this.panel) { this.stopPolling(); this.activeWebview = undefined; }
|
if (!this.panel) { this.activeWebview = undefined; }
|
||||||
}, undefined, this.context.subscriptions);
|
}, undefined, this.context.subscriptions);
|
||||||
updateVisibility();
|
updateVisibility();
|
||||||
}
|
}
|
||||||
@@ -54,11 +57,10 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
.replace('{{CSS}}', media('logExplorer.css').toString()).replace('{{JS}}', media('logExplorer.js').toString());
|
.replace('{{CSS}}', media('logExplorer.css').toString()).replace('{{JS}}', media('logExplorer.js').toString());
|
||||||
this.messageListener?.dispose();
|
this.messageListener?.dispose();
|
||||||
this.messageListener = webview.onDidReceiveMessage(async message => {
|
this.messageListener = webview.onDidReceiveMessage(async message => {
|
||||||
if (message.type === 'copy' && typeof message.text === 'string' && message.text.length <= 1024 * 1024) {
|
|
||||||
try { await vscode.env.clipboard.writeText(message.text); webview.postMessage({ type: 'copied' }); }
|
|
||||||
catch (error) { vscode.window.showWarningMessage(`Unable to copy: ${String(error)}`); }
|
|
||||||
}
|
|
||||||
if (message.type === 'ready') { this.publish(); }
|
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') {
|
if (message.type === 'open' && typeof message.id === 'string') {
|
||||||
const entry = this.entries.get(message.id);
|
const entry = this.entries.get(message.id);
|
||||||
if (!entry?.file) { return; }
|
if (!entry?.file) { return; }
|
||||||
@@ -77,7 +79,7 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
const source = this.entries.get(message.id)?.source;
|
const source = this.entries.get(message.id)?.source;
|
||||||
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
if (!source || !root || typeof source.path !== 'string' || !Number.isInteger(source.line)) { return; }
|
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(source.path);
|
const target = source.path.startsWith('/conf/') ? path.resolve(root, source.path.slice(6)) : path.resolve(root, source.path);
|
||||||
const relative = path.relative(root, target);
|
const relative = path.relative(root, target);
|
||||||
if (relative.startsWith('..') || path.isAbsolute(relative)) { vscode.window.showInformationMessage('The source is outside the workspace.'); return; }
|
if (relative.startsWith('..') || path.isAbsolute(relative)) { vscode.window.showInformationMessage('The source is outside the workspace.'); return; }
|
||||||
try {
|
try {
|
||||||
@@ -88,15 +90,11 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
editor.revealRange(new vscode.Range(position, position));
|
editor.revealRange(new vscode.Range(position, position));
|
||||||
} catch (error) { vscode.window.showWarningMessage(`Source unavailable: ${String(error)}`); }
|
} catch (error) { vscode.window.showWarningMessage(`Source unavailable: ${String(error)}`); }
|
||||||
}
|
}
|
||||||
if (message.type === 'import') {
|
if (message.type === 'clear') { this.clearLogs(); }
|
||||||
const files = await vscode.window.showOpenDialog({ canSelectMany: true, filters: { Logs: ['log', '1', '2', '3'] } });
|
|
||||||
files?.forEach(uri => this.imported.add(uri.fsPath));
|
|
||||||
this.poll();
|
|
||||||
}
|
|
||||||
}, undefined, this.context.subscriptions);
|
}, undefined, this.context.subscriptions);
|
||||||
this.activeWebview = webview;
|
this.activeWebview = webview;
|
||||||
}
|
}
|
||||||
private startPolling() {
|
startPolling() {
|
||||||
if (this.timer) { return; }
|
if (this.timer) { return; }
|
||||||
this.poll();
|
this.poll();
|
||||||
this.timer = setInterval(() => this.poll(), 1000);
|
this.timer = setInterval(() => this.poll(), 1000);
|
||||||
@@ -104,12 +102,36 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
private stopPolling() {
|
private stopPolling() {
|
||||||
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
||||||
}
|
}
|
||||||
private files(): string[] {
|
private configuredFiles(): string[] {
|
||||||
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
if (!folder) { return [...this.imported]; }
|
if (!folder) { return []; }
|
||||||
const config = vscode.workspace.getConfiguration('appdaemon');
|
const config = vscode.workspace.getConfiguration('appdaemon');
|
||||||
const configured = config.get<string[]>('logExplorerPaths', ['logs/appdaemon.log', 'logs/error.log', 'logs/virtualsensors.log']);
|
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)), ...this.imported])];
|
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() {
|
private poll() {
|
||||||
if (this.busy) { return; }
|
if (this.busy) { return; }
|
||||||
@@ -118,7 +140,7 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
try {
|
try {
|
||||||
const encoding = vscode.workspace.getConfiguration('appdaemon').get<string>('logEncoding', 'auto');
|
const encoding = vscode.workspace.getConfiguration('appdaemon').get<string>('logEncoding', 'auto');
|
||||||
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
|
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
|
||||||
for (const file of this.files()) {
|
for (const file of this.configuredFiles()) {
|
||||||
if (!fs.existsSync(file)) { continue; }
|
if (!fs.existsSync(file)) { continue; }
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(file);
|
const stat = fs.statSync(file);
|
||||||
@@ -146,6 +168,25 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
if (cursor.discard) { cursor.discard = false; continue; }
|
if (cursor.discard) { cursor.discard = false; continue; }
|
||||||
const entry = parseLine(line, file, position, zone);
|
const entry = parseLine(line, file, position, zone);
|
||||||
if (entry) {
|
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; }
|
if (!entry.trace_id && !line.startsWith('{')) { entry.event_id += ':' + cursor.generation; }
|
||||||
const existing = this.entries.get(entry.event_id);
|
const existing = this.entries.get(entry.event_id);
|
||||||
if (!existing || entry.raw?.startsWith('{')) { this.entries.set(entry.event_id, entry); }
|
if (!existing || entry.raw?.startsWith('{')) { this.entries.set(entry.event_id, entry); }
|
||||||
@@ -168,43 +209,50 @@ export class LogExplorer implements vscode.Disposable {
|
|||||||
if (changed) { this.publish(); }
|
if (changed) { this.publish(); }
|
||||||
} finally { this.busy = false; }
|
} 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() {
|
private publish() {
|
||||||
const entries = [...this.entries.values()].sort((a, b) => {
|
const publication = ++this.publication;
|
||||||
const time = a.time - b.time;
|
const entries = [...this.entries.values()].sort((a, b) => a.time - b.time);
|
||||||
if (time) { return time; }
|
|
||||||
// Include raw app.log() lines written in the same second as initialization markers.
|
|
||||||
const priority = (entry: LogEntry) => /Initialization (?:started|completed|failed)/.test(entry.message)
|
|
||||||
? (entry.message.includes('started') ? -1 : 1) : 0;
|
|
||||||
return priority(a) - priority(b);
|
|
||||||
});
|
|
||||||
// Legacy errors duplicated across main/error logs are merged only on exact match.
|
// Legacy errors duplicated across main/error logs are merged only on exact match.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique = entries.filter(e => {
|
const unique = entries.filter(e => {
|
||||||
const key = e.trace_id ? e.event_id : `${e.timestamp}|${e.app}|${e.level}|${e.message}`;
|
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;
|
if (seen.has(key)) { return false; } seen.add(key); return true;
|
||||||
});
|
});
|
||||||
// Direct app.log() calls have no LoggerInterface tag. Infer their initialization
|
this.onErrorCountChanged?.(unique.filter(e => (e.kind === 'log' || e.kind.startsWith('initialization_')) && (e.level === 'ERROR' || e.level === 'CRITICAL')).length);
|
||||||
// group while an explicit per-app initialize() scope is open.
|
|
||||||
const activeInitialization = new Map<string, string>();
|
|
||||||
for (const entry of unique) {
|
|
||||||
const marker = entry.message.match(/Initialization (started|completed|failed) \[group=initialization:([a-f0-9]{12})\]/i);
|
|
||||||
if (marker?.[1] === 'started') { activeInitialization.set(entry.app, marker[2]); }
|
|
||||||
const activeId = activeInitialization.get(entry.app);
|
|
||||||
if (activeId && !entry.group_id) {
|
|
||||||
entry.group_id = activeId;
|
|
||||||
entry.group_kind = 'initialization';
|
|
||||||
entry.initialization_id = activeId;
|
|
||||||
entry.phase = 'initialization';
|
|
||||||
entry.group_inferred = true;
|
|
||||||
}
|
|
||||||
if (entry.group_kind === 'initialization') {
|
|
||||||
entry.initialization_id = entry.initialization_id || entry.group_id;
|
|
||||||
entry.phase = 'initialization';
|
|
||||||
}
|
|
||||||
if (marker && (marker[1] === 'completed' || marker[1] === 'failed')) { activeInitialization.delete(entry.app); }
|
|
||||||
}
|
|
||||||
const zone = vscode.workspace.getConfiguration('appdaemon').get<string>('logTimezone', 'Europe/Berlin');
|
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 });
|
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(); }
|
dispose() { this.stopPolling(); this.messageListener?.dispose(); this.panel?.dispose(); }
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ export interface LogEntry {
|
|||||||
kind: string; cause_ids: string[]; probable_cause_ids?: string[];
|
kind: string; cause_ids: string[]; probable_cause_ids?: string[];
|
||||||
entity?: string; values?: Record<string, unknown>;
|
entity?: string; values?: Record<string, unknown>;
|
||||||
source?: { path: string; line: number };
|
source?: { path: string; line: number };
|
||||||
initialization_id?: string; phase?: string; status?: string;
|
status?: string;
|
||||||
group_id?: string; group_kind?: string; group_inferred?: boolean;
|
group_id?: string; group_kind?: string;
|
||||||
file?: string; raw?: string; time: number; trace_id?: string;
|
file?: string; raw?: string; time: number; trace_id?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,15 @@ export function rawTime(date: string, zone: string): number {
|
|||||||
const cacheKey = zone + ':' + date;
|
const cacheKey = zone + ':' + date;
|
||||||
const cached = timeCache.get(cacheKey);
|
const cached = timeCache.get(cacheKey);
|
||||||
if (cached !== undefined) { return cached; }
|
if (cached !== undefined) { return cached; }
|
||||||
const m = date.match(/^(\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)/);
|
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; }
|
if (!m) { return NaN; }
|
||||||
const utc = Date.UTC(2000 + Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6]));
|
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 {
|
try {
|
||||||
let formatter = formatters.get(zone);
|
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); }
|
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); }
|
||||||
@@ -36,14 +42,12 @@ export function parseLine(line: string, file: string, offset: number, zone = 'Eu
|
|||||||
return { ...e, level: String(e.level || 'INFO'), kind: String(e.kind || 'log'), file, raw: line, time: Date.parse(e.timestamp) };
|
return { ...e, level: String(e.level || 'INFO'), kind: String(e.kind || 'log'), file, raw: line, time: Date.parse(e.timestamp) };
|
||||||
} catch { return undefined; }
|
} catch { return undefined; }
|
||||||
}
|
}
|
||||||
const m = line.match(/^(\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+([^:]+):\s?(.*)$/);
|
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; }
|
if (!m) { return undefined; }
|
||||||
const init = m[4].match(/\[init=([\w-]+)\]/);
|
const group = m[4].match(/\[group=([^\]\r\n]+):([a-f0-9]{12})\]/i);
|
||||||
const group = m[4].match(/\[group=(initialization|smartcondition|value_selector):([a-f0-9]{12})\]/i);
|
|
||||||
const id = m[4].match(/\[event=([\w-]+) trace=([\w-]+)\]/);
|
const id = m[4].match(/\[event=([\w-]+) trace=([\w-]+)\]/);
|
||||||
const groupKind = group?.[1]?.toLowerCase();
|
const groupKind = group?.[1]?.trim();
|
||||||
const initializationId = groupKind === 'initialization' ? group?.[2] : init?.[1];
|
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 };
|
||||||
return { event_id: id?.[1] || `${file}:${offset}`, trace_id: id?.[2], initialization_id: initializationId, phase: initializationId ? 'initialization' : undefined, 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. */
|
/** Preserve valid UTF-8 spans, including files containing mixed legacy bytes. */
|
||||||
|
|||||||
@@ -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,15 +1,12 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { ADClient } from './adClient';
|
|
||||||
|
|
||||||
export class StatusBarManager {
|
export class StatusBarManager {
|
||||||
private restartADItem: vscode.StatusBarItem;
|
private restartADItem: vscode.StatusBarItem;
|
||||||
private restartHAItem: vscode.StatusBarItem;
|
private restartHAItem: vscode.StatusBarItem;
|
||||||
private productionModeItem: vscode.StatusBarItem;
|
|
||||||
private errorItem: vscode.StatusBarItem;
|
|
||||||
private contextualItem: vscode.StatusBarItem;
|
private contextualItem: vscode.StatusBarItem;
|
||||||
private logsItem: vscode.StatusBarItem;
|
private logsItem: vscode.StatusBarItem;
|
||||||
|
|
||||||
constructor(private adClient: ADClient) {
|
constructor() {
|
||||||
// Reload AppDaemon apps
|
// Reload AppDaemon apps
|
||||||
this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||||
this.restartADItem.command = 'appdaemon.restartAD';
|
this.restartADItem.command = 'appdaemon.restartAD';
|
||||||
@@ -24,19 +21,6 @@ export class StatusBarManager {
|
|||||||
this.restartHAItem.tooltip = 'Restart Home Assistant';
|
this.restartHAItem.tooltip = 'Restart Home Assistant';
|
||||||
this.restartHAItem.show();
|
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.
|
// Log explorer shortcut, available even when no editor is open.
|
||||||
this.logsItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 96);
|
this.logsItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 96);
|
||||||
this.logsItem.command = 'appdaemon.exploreLogs';
|
this.logsItem.command = 'appdaemon.exploreLogs';
|
||||||
@@ -47,9 +31,6 @@ export class StatusBarManager {
|
|||||||
// Contextual per-file restart button (hidden by default)
|
// Contextual per-file restart button (hidden by default)
|
||||||
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
|
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
|
||||||
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
|
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
|
||||||
|
|
||||||
// React to production mode changes
|
|
||||||
adClient.onProductionModeChanged(() => this.updateProductionModeDisplay());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,38 +50,16 @@ export class StatusBarManager {
|
|||||||
this.contextualItem.show();
|
this.contextualItem.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProductionModeDisplay() {
|
setLogErrorCount(count: number) {
|
||||||
const isProduction = this.adClient.isProductionMode();
|
this.logsItem.backgroundColor = count > 0 ? new vscode.ThemeColor('statusBarItem.errorBackground') : undefined;
|
||||||
if (isProduction) {
|
this.logsItem.tooltip = count > 0
|
||||||
this.productionModeItem.text = '$(eye-closed) Prod ON';
|
? `${count} error${count === 1 ? '' : 's'} in loaded AppDaemon logs — open Log Explorer`
|
||||||
this.productionModeItem.tooltip = 'Production Mode: ON — click to disable';
|
: 'Open AppDaemon Log Explorer';
|
||||||
this.productionModeItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
|
||||||
} else {
|
|
||||||
this.productionModeItem.text = '$(eye) Prod OFF';
|
|
||||||
this.productionModeItem.tooltip = 'Production Mode: OFF — click to enable';
|
|
||||||
this.productionModeItem.backgroundColor = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorCount(count: number) {
|
|
||||||
if (count > 0) {
|
|
||||||
this.errorItem.text = `$(warning) AD Errors (${count})`;
|
|
||||||
this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
|
||||||
this.errorItem.tooltip = `${count} error(s) — click to show or clear`;
|
|
||||||
this.errorItem.command = 'appdaemon.handleErrors';
|
|
||||||
} else {
|
|
||||||
this.errorItem.text = '$(check) AD Errors';
|
|
||||||
this.errorItem.backgroundColor = undefined;
|
|
||||||
this.errorItem.tooltip = 'No AppDaemon errors — click to view log';
|
|
||||||
this.errorItem.command = 'appdaemon.showErrors';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.restartADItem.dispose();
|
this.restartADItem.dispose();
|
||||||
this.restartHAItem.dispose();
|
this.restartHAItem.dispose();
|
||||||
this.productionModeItem.dispose();
|
|
||||||
this.errorItem.dispose();
|
|
||||||
this.logsItem.dispose();
|
this.logsItem.dispose();
|
||||||
this.contextualItem.dispose();
|
this.contextualItem.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,3 +60,21 @@ test('groups plain app.log lines between explicit initialization markers',()=>{
|
|||||||
assert.equal(grouped[1].group_inferred,true);
|
assert.equal(grouped[1].group_inferred,true);
|
||||||
assert.equal(grouped[2].group_inferred,undefined);
|
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,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);
|
||||||
|
});
|
||||||
@@ -20,9 +20,16 @@ function setup() {
|
|||||||
test('English interface, latest timestamps, sorting and separate initialization groups',()=>{
|
test('English interface, latest timestamps, sorting and separate initialization groups',()=>{
|
||||||
const {dom,d,errors}=setup();
|
const {dom,d,errors}=setup();
|
||||||
assert.equal(d.documentElement.lang,'en');
|
assert.equal(d.documentElement.lang,'en');
|
||||||
assert.equal(d.getElementById('copy').textContent,'Copy selected log');
|
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.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"] .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'));
|
d.getElementById('sort').value='oldest';d.getElementById('sort').dispatchEvent(new dom.window.Event('input'));
|
||||||
assert.equal(d.querySelector('[data-app]').dataset.app,'alpha');
|
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'));
|
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
@@ -33,25 +40,36 @@ test('English interface, latest timestamps, sorting and separate initialization
|
|||||||
assert.equal(d.getElementById('group-init'),null);
|
assert.equal(d.getElementById('group-init'),null);
|
||||||
assert.deepEqual(errors,[]);dom.window.close();
|
assert.deepEqual(errors,[]);dom.window.close();
|
||||||
});
|
});
|
||||||
test('copies selected logs and entire initialization groups through VS Code transport',()=>{
|
test('selects individual logs and entire initialization groups',()=>{
|
||||||
const {dom,d,sent,errors}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
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();d.getElementById('copy').click();
|
[...alpha.querySelectorAll('.row')].find(e=>e.textContent.includes('Température déjà disponible')).click();
|
||||||
assert.equal(sent.at(-1).type,'copy');assert.match(sent.at(-1).text,/Température déjà disponible/);
|
assert.match(d.getElementById('detail').textContent,/Température déjà disponible/);
|
||||||
const group=alpha.querySelector('.event-log-group');
|
const group=alpha.querySelector('.event-log-group');
|
||||||
group.click();d.getElementById('copy').click();
|
group.click();
|
||||||
assert.match(d.getElementById('detail').textContent,/Preparing sensors\s+Ready/);
|
assert.match(d.getElementById('detail').textContent,/Preparing sensors\s+Ready/);
|
||||||
assert.match(sent.at(-1).text,/Preparing sensors/);assert.match(sent.at(-1).text,/Ready/);assert.doesNotMatch(sent.at(-1).text,/Restarting/);
|
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);
|
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();
|
assert.deepEqual(errors,[]);dom.window.close();
|
||||||
});
|
});
|
||||||
test('retains selected text during live updates and copies it with Ctrl+C',async()=>{
|
|
||||||
const {dom,d,entries,sent,errors,send}=setup();const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
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 text=alpha.querySelector('.row-message'),range=d.createRange();range.selectNodeContents(text);dom.window.getSelection().addRange(range);
|
||||||
const original=text.textContent;
|
const original=text.textContent;
|
||||||
send([...entries,{...entries[3],event_id:'new',time:30,message:'New log'}]);
|
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/);
|
assert.equal(dom.window.getSelection().toString(),original);assert.match(d.getElementById('status').textContent,/buffered/);
|
||||||
text.dispatchEvent(new dom.window.KeyboardEvent('keydown',{key:'c',ctrlKey:true,bubbles:true,cancelable:true}));
|
const copy=new dom.window.KeyboardEvent('keydown',{key:'c',ctrlKey:true,bubbles:true,cancelable:true});
|
||||||
assert.equal(sent.at(-1).text,original);
|
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));
|
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.match(d.querySelector('[data-app="alpha"]').textContent,/New log/);
|
||||||
assert.deepEqual(errors,[]);dom.window.close();
|
assert.deepEqual(errors,[]);dom.window.close();
|
||||||
@@ -62,7 +80,7 @@ test('log content is never interpreted as HTML',()=>{
|
|||||||
assert.equal(d.querySelectorAll('#apps img,#apps script').length,0);assert.match(app.textContent,/<img/);assert.deepEqual(errors,[]);dom.window.close();
|
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',()=>{
|
test('shows same-event log lines together with one causes link and keeps newer events below',()=>{
|
||||||
const {dom,d,entries,send,sent}=setup();
|
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 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 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'};
|
const newer={...entries[3],event_id:'later-log',time:entries[3].time+2000,message:'Later event'};
|
||||||
@@ -70,13 +88,12 @@ test('shows same-event log lines together with one causes link and keeps newer e
|
|||||||
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
const alpha=d.querySelector('[data-app="alpha"]');alpha.open=true;alpha.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
const rows=alpha.querySelector('.rows'),combined=rows.children[0];
|
const rows=alpha.querySelector('.rows'),combined=rows.children[0];
|
||||||
assert.ok(combined.classList.contains('event-log-group'));
|
assert.ok(combined.classList.contains('event-log-group'));
|
||||||
|
assert.equal(combined.querySelector('.exact-time').textContent,trigger.timestamp);
|
||||||
assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/);
|
assert.match(combined.querySelector('.row-message').textContent,/Callback trigger by sensor.kitchen_last_motion/);
|
||||||
assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/);
|
assert.match(combined.querySelector('.row-message').textContent,/Triggered by \(sensor.kitchen_last_motion/);
|
||||||
assert.equal(combined.querySelectorAll('.link-hint').length,1);
|
assert.equal(combined.querySelectorAll('.link-hint').length,1);
|
||||||
assert.equal(rows.children[1].querySelector('.row-message').textContent,'Later event');
|
assert.equal(rows.children[1].querySelector('.row-message').textContent,'Later event');
|
||||||
combined.click();d.getElementById('copy').click();
|
combined.click();
|
||||||
assert.match(sent.at(-1).text,/Callback trigger by sensor.kitchen_last_motion/);
|
|
||||||
assert.match(sent.at(-1).text,/Triggered by \(sensor.kitchen_last_motion/);
|
|
||||||
assert.match(d.getElementById('detail').textContent,/Callback trigger by sensor.kitchen_last_motion/);
|
assert.match(d.getElementById('detail').textContent,/Callback trigger by sensor.kitchen_last_motion/);
|
||||||
dom.window.close();
|
dom.window.close();
|
||||||
});
|
});
|
||||||
@@ -109,10 +126,41 @@ test('pulses an app when a new log arrives and refreshes its last-log age',async
|
|||||||
assert.ok(app.classList.contains('new-log-app'));
|
assert.ok(app.classList.contains('new-log-app'));
|
||||||
app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
|
app.open=true;app.dispatchEvent(new dom.window.Event('toggle'));
|
||||||
assert.ok(app.querySelector('.recent-log'));
|
assert.ok(app.querySelector('.recent-log'));
|
||||||
assert.equal(app.querySelector('.last-age').textContent,'Last log: 1 second ago');
|
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;
|
dom.window.Date.now=()=>nextTime+65000;
|
||||||
await new Promise(resolve=>setTimeout(resolve,1100));
|
await new Promise(resolve=>setTimeout(resolve,1100));
|
||||||
assert.equal(app.querySelector('.last-age').textContent,'Last log: 1 minute ago');
|
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();
|
dom.window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,11 +169,11 @@ test('distinguishes log lines from supplemental records when configured',()=>{
|
|||||||
const trace={...entries[3],event_id:'trace-evaluation',kind:'evaluation',message:'Condition was evaluated'};
|
const trace={...entries[3],event_id:'trace-evaluation',kind:'evaluation',message:'Condition was evaluated'};
|
||||||
send([...entries,trace]);
|
send([...entries,trace]);
|
||||||
assert.match(d.getElementById('status').textContent,/5 log lines \+ 1 trace event/);
|
assert.match(d.getElementById('status').textContent,/5 log lines \+ 1 trace event/);
|
||||||
assert.match(d.getElementById('status').title,/configured history files/);
|
assert.match(d.getElementById('status').title,/configured log files/);
|
||||||
dom.window.close();
|
dom.window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows and refreshes relative time on each log entry while retaining exact time on hover',async()=>{
|
test('shows and refreshes relative time on each log entry while displaying exact time alongside it',async()=>{
|
||||||
const {dom,d,entries}=setup();
|
const {dom,d,entries}=setup();
|
||||||
const entry=entries.find(e=>e.event_id==='a');
|
const entry=entries.find(e=>e.event_id==='a');
|
||||||
dom.window.Date.now=()=>entry.time+120000;
|
dom.window.Date.now=()=>entry.time+120000;
|
||||||
@@ -135,6 +183,7 @@ test('shows and refreshes relative time on each log entry while retaining exact
|
|||||||
const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible'));
|
const row=[...d.querySelectorAll('[data-app="alpha"] .row')].find(item=>item.textContent.includes('Température déjà disponible'));
|
||||||
const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time');
|
const relativeTime=()=>[...app.querySelectorAll('.row')].find(item=>item.textContent.includes('Température déjà disponible')).querySelector('.relative-time');
|
||||||
assert.equal(relativeTime().textContent,'2 minutes ago');
|
assert.equal(relativeTime().textContent,'2 minutes ago');
|
||||||
|
assert.equal(relativeTime().previousElementSibling.textContent,entries[3].timestamp);
|
||||||
assert.match(relativeTime().title,/10:00:10/);
|
assert.match(relativeTime().title,/10:00:10/);
|
||||||
dom.window.Date.now=()=>entry.time+3600000;
|
dom.window.Date.now=()=>entry.time+3600000;
|
||||||
await new Promise(resolve=>setTimeout(resolve,1600));
|
await new Promise(resolve=>setTimeout(resolve,1600));
|
||||||
@@ -157,3 +206,57 @@ test('shows consecutive logs from one explicit group as a single visible row',()
|
|||||||
assert.doesNotMatch(rows[0].textContent,/\[group=/);
|
assert.doesNotMatch(rows[0].textContent,/\[group=/);
|
||||||
dom.window.close();
|
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