Compare commits
8 Commits
05a910ba80
...
b997836401
| Author | SHA1 | Date | |
|---|---|---|---|
| b997836401 | |||
| c71094641b | |||
| dbccd2adf6 | |||
| 56e3f6e9e0 | |||
| 943361453a | |||
| b5145147d7 | |||
| 91a48a4604 | |||
| 13e37816f8 |
@@ -1,5 +1,5 @@
|
|||||||
import pyparsing as pp
|
import pyparsing as pp
|
||||||
from expressionparser import BaseExpressionContext
|
from expressionparser import BaseExpressionContext, ExpressionParser
|
||||||
|
|
||||||
class ADExpressionContext(BaseExpressionContext):
|
class ADExpressionContext(BaseExpressionContext):
|
||||||
def __init__(self,appdaemon_api, sensor_existence_validation = True):
|
def __init__(self,appdaemon_api, sensor_existence_validation = True):
|
||||||
@@ -13,6 +13,7 @@ class ADExpressionContext(BaseExpressionContext):
|
|||||||
self.sensors_default_value = dict()
|
self.sensors_default_value = dict()
|
||||||
self.sensor_states = dict()
|
self.sensor_states = dict()
|
||||||
self.sensor_existence_validation = sensor_existence_validation
|
self.sensor_existence_validation = sensor_existence_validation
|
||||||
|
self.local_expressions = {}
|
||||||
|
|
||||||
self.declare_default_constants()
|
self.declare_default_constants()
|
||||||
|
|
||||||
@@ -131,7 +132,22 @@ class ADExpressionContext(BaseExpressionContext):
|
|||||||
def get_entities_to_listen(self):
|
def get_entities_to_listen(self):
|
||||||
return self.entities_to_listen
|
return self.entities_to_listen
|
||||||
|
|
||||||
def get_constant_value(self,name): return self.variables[name]
|
def get_constant_value(self, name):
|
||||||
|
if name in self.local_expressions:
|
||||||
|
return self.local_expressions[name].eval()
|
||||||
|
return self.variables[name]
|
||||||
|
|
||||||
|
def declare_local_expression(self, name: str, expression_string: str):
|
||||||
|
assert name not in self.variables, f"'{name}' is already declared"
|
||||||
|
assert name.replace('_', '').isalnum() and name.isascii(), f"Invalid local expression name: {name}"
|
||||||
|
# Register as a constant placeholder so the variable parser recognises it
|
||||||
|
self.variables[name] = None
|
||||||
|
self.constants.append(name)
|
||||||
|
self.parser = None # reset variable parser cache
|
||||||
|
# Parse the expression; set_context registers its sensor dependencies
|
||||||
|
# Store only the parsed tree so evaluation skips the top-level reset()
|
||||||
|
full_parser = ExpressionParser(expression_string, self)
|
||||||
|
self.local_expressions[name] = full_parser.parsed_data
|
||||||
|
|
||||||
def declare_constant(self,name : str,value):
|
def declare_constant(self,name : str,value):
|
||||||
def is_a_sensor(string):
|
def is_a_sensor(string):
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import json
|
|||||||
# <field>: <value>
|
# <field>: <value>
|
||||||
# reset_data: # optional — latch reset filter
|
# reset_data: # optional — latch reset filter
|
||||||
# <field>: <value>
|
# <field>: <value>
|
||||||
|
# debounce_seconds: <float> # optional — ignore re-triggers within
|
||||||
|
# # this delay (seconds). Useful for
|
||||||
|
# # buttons that fire duplicate events.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# handler = EventHandler(ad_api, self.args['events_to_listen'], my_callback)
|
# handler = EventHandler(ad_api, self.args['events_to_listen'], my_callback)
|
||||||
@@ -57,8 +60,10 @@ import json
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class EventDispatcher:
|
class EventDispatcher:
|
||||||
def __init__(self,ad_api,event_name,callback,event_data,reset_data,event_context):
|
def __init__(self,ad_api,logger_interface,event_name,callback,event_data,reset_data,event_context,debounce_seconds=None):
|
||||||
self.ad_api = ad_api
|
self.ad_api = ad_api
|
||||||
|
self.logger_interface = logger_interface
|
||||||
|
|
||||||
event_data = self._resolve_zha_device_name(event_data)
|
event_data = self._resolve_zha_device_name(event_data)
|
||||||
reset_data = self._resolve_zha_device_name(reset_data)
|
reset_data = self._resolve_zha_device_name(reset_data)
|
||||||
self.event_name = event_name
|
self.event_name = event_name
|
||||||
@@ -67,6 +72,8 @@ class EventDispatcher:
|
|||||||
self.reset_data = reset_data
|
self.reset_data = reset_data
|
||||||
self.waiting_for_reset = False
|
self.waiting_for_reset = False
|
||||||
self.event_context = event_context
|
self.event_context = event_context
|
||||||
|
self.debounce_seconds = debounce_seconds
|
||||||
|
self._last_trigger_time = None
|
||||||
if event_data == None:
|
if event_data == None:
|
||||||
self.ad_api.listen_event(self.on_event,event_name)
|
self.ad_api.listen_event(self.on_event,event_name)
|
||||||
else:
|
else:
|
||||||
@@ -121,7 +128,7 @@ class EventDispatcher:
|
|||||||
result = resp.read().decode('utf-8').strip()
|
result = resp.read().decode('utf-8').strip()
|
||||||
return result if result else None
|
return result if result else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ad_api.log_error(f"[EventDispatcher] Failed to resolve ZHA device name '{device_name}': {e}")
|
self.logger_interface.log_error(f"[EventDispatcher] Failed to resolve ZHA device name '{device_name}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_zha_device_name(self, data):
|
def _resolve_zha_device_name(self, data):
|
||||||
@@ -134,10 +141,10 @@ class EventDispatcher:
|
|||||||
ieee = self._lookup_zha_ieee(name)
|
ieee = self._lookup_zha_ieee(name)
|
||||||
resolved = {k: v for k, v in data.items() if k != 'device_name'}
|
resolved = {k: v for k, v in data.items() if k != 'device_name'}
|
||||||
if ieee is not None:
|
if ieee is not None:
|
||||||
self.ad_api.log_info(f"[EventDispatcher] Resolved ZHA device name '{name}' to IEEE '{ieee}'")
|
self.logger_interface.log_info(f"[EventDispatcher] Resolved ZHA device name '{name}' to IEEE '{ieee}'")
|
||||||
resolved['device_ieee'] = ieee
|
resolved['device_ieee'] = ieee
|
||||||
else:
|
else:
|
||||||
self.ad_api.log_error(f"[EventDispatcher] ZHA device '{name}' not found — 'device_name' filter ignored")
|
self.logger_interface.log_error(f"[EventDispatcher] ZHA device '{name}' not found — 'device_name' filter ignored")
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def process_event(self,data):
|
def process_event(self,data):
|
||||||
@@ -159,6 +166,13 @@ class EventDispatcher:
|
|||||||
|
|
||||||
#special threatment for event
|
#special threatment for event
|
||||||
if match_event_data and not self.waiting_for_reset:
|
if match_event_data and not self.waiting_for_reset:
|
||||||
|
if self.debounce_seconds is not None:
|
||||||
|
import time
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._last_trigger_time is not None and (now - self._last_trigger_time) < self.debounce_seconds:
|
||||||
|
return False
|
||||||
|
self._last_trigger_time = now
|
||||||
|
|
||||||
if self.reset_data != None:
|
if self.reset_data != None:
|
||||||
self.waiting_for_reset = True
|
self.waiting_for_reset = True
|
||||||
|
|
||||||
@@ -171,11 +185,15 @@ class EventDispatcher:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
class EventHandler:
|
class EventHandler:
|
||||||
def __init__(self, ad_api,events_block,callback,event_context = None): #event_context is passed back to the CB has a context
|
def __init__(self, ad_api,events_block,callback,event_context = None, logger_interface = None): #event_context is passed back to the CB has a context
|
||||||
def register_event_with_params(event_block,callback,event_context):
|
def register_event_with_params(event_block,callback,event_context):
|
||||||
self.add_dispatcher(event_block['event_name'],callback,event_block['event_data'] if 'event_data' in event_block else None, event_block['reset_data'] if 'reset_data' in event_block else None,event_context)
|
self.add_dispatcher(event_block['event_name'],callback,event_block['event_data'] if 'event_data' in event_block else None, event_block['reset_data'] if 'reset_data' in event_block else None,event_context, event_block.get('debounce_seconds'))
|
||||||
|
|
||||||
self.__ad_api = ad_api
|
self.__ad_api = ad_api
|
||||||
|
if logger_interface:
|
||||||
|
self.__logger_interface = logger_interface
|
||||||
|
else:
|
||||||
|
self.__logger_interface = ad_api
|
||||||
self.event_dispatchers = []
|
self.event_dispatchers = []
|
||||||
|
|
||||||
if isinstance(events_block, list):
|
if isinstance(events_block, list):
|
||||||
@@ -185,7 +203,8 @@ class EventHandler:
|
|||||||
for event_block in events_block.values():
|
for event_block in events_block.values():
|
||||||
register_event_with_params(event_block,callback,event_context)
|
register_event_with_params(event_block,callback,event_context)
|
||||||
|
|
||||||
def add_dispatcher(self,event_name,callback,event_data = None,reset_data = None ,event_context = None):
|
def add_dispatcher(self,event_name,callback,event_data = None,reset_data = None ,event_context = None, debounce_seconds = None):
|
||||||
self.__ad_api.log_info(f'Registering dispatcher {callback.__name__} for event "{event_name}" ({event_data})')
|
if self.__logger_interface:
|
||||||
dispatcher = EventDispatcher(self.__ad_api,event_name,callback,event_data,reset_data,event_context)
|
self.__logger_interface.log_info(f'Registering dispatcher {callback.__name__} for event "{event_name}" ({event_data})')
|
||||||
|
dispatcher = EventDispatcher(self.__ad_api,self.__logger_interface,event_name,callback,event_data,reset_data,event_context,debounce_seconds)
|
||||||
self.event_dispatchers.append(dispatcher)
|
self.event_dispatchers.append(dispatcher)
|
||||||
@@ -71,6 +71,7 @@ class EvaluateBase(object):
|
|||||||
if value == 'false': return False
|
if value == 'false': return False
|
||||||
if value == False: return False
|
if value == False: return False
|
||||||
if value == None: return False
|
if value == None: return False
|
||||||
|
if value == '': return False
|
||||||
# this should be handle explicitely by the condition
|
# this should be handle explicitely by the condition
|
||||||
# edit: this is actually usefull if don't want disable to be evaluated as true
|
# edit: this is actually usefull if don't want disable to be evaluated as true
|
||||||
if value == 'unavailable': return False
|
if value == 'unavailable': return False
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Evaluator():
|
|||||||
__LOG_INIT = "log_init"
|
__LOG_INIT = "log_init"
|
||||||
__EXTRA_ENTITIES_TO_LISTEN = "extra_entities_to_listen"
|
__EXTRA_ENTITIES_TO_LISTEN = "extra_entities_to_listen"
|
||||||
__CONSTANTS = "constants"
|
__CONSTANTS = "constants"
|
||||||
|
__LOCAL_EXPRESSIONS_STRING = "local_expressions"
|
||||||
__UNAVAIBILITY_RESULT = "unavaibility_result"
|
__UNAVAIBILITY_RESULT = "unavaibility_result"
|
||||||
|
|
||||||
def __init__(self, appdaemon_api, conditions_block,**kwargs):
|
def __init__(self, appdaemon_api, conditions_block,**kwargs):
|
||||||
@@ -313,6 +314,12 @@ class Evaluator():
|
|||||||
if disable_conditions_args:
|
if disable_conditions_args:
|
||||||
add_conditions_to_conditions_block(conditions_block,self.__DISABLE_CONDITIONS_STRING,disable_conditions_args)
|
add_conditions_to_conditions_block(conditions_block,self.__DISABLE_CONDITIONS_STRING,disable_conditions_args)
|
||||||
|
|
||||||
|
local_expressions_args = param_parser.parse_args(self.__LOCAL_EXPRESSIONS_STRING, None)
|
||||||
|
if local_expressions_args:
|
||||||
|
if self.__LOCAL_EXPRESSIONS_STRING not in conditions_block:
|
||||||
|
conditions_block[self.__LOCAL_EXPRESSIONS_STRING] = {}
|
||||||
|
conditions_block[self.__LOCAL_EXPRESSIONS_STRING].update(local_expressions_args)
|
||||||
|
|
||||||
if param_parser.parse_args(self.__TEMPLATE_CONDITIONS_STRING, None):
|
if param_parser.parse_args(self.__TEMPLATE_CONDITIONS_STRING, None):
|
||||||
self.__log(f"Template '{template}' contain recursive template",level = "ERROR")
|
self.__log(f"Template '{template}' contain recursive template",level = "ERROR")
|
||||||
|
|
||||||
@@ -377,7 +384,19 @@ class Evaluator():
|
|||||||
if self.__log_init:
|
if self.__log_init:
|
||||||
self.__log(f"{constant_name} = {value}")
|
self.__log(f"{constant_name} = {value}")
|
||||||
self.expression_context.declare_constant(constant_name,value)
|
self.expression_context.declare_constant(constant_name,value)
|
||||||
|
|
||||||
|
local_expressions = param_parser.parse_args(self.__LOCAL_EXPRESSIONS_STRING, None)
|
||||||
|
if local_expressions:
|
||||||
|
if self.__log_init:
|
||||||
|
self.__log(f"declaring local expressions:")
|
||||||
|
for expr_name, expr_string in local_expressions.items():
|
||||||
|
if self.__log_init:
|
||||||
|
self.__log(f"{expr_name} = {expr_string}")
|
||||||
|
self.expression_context.declare_local_expression(expr_name, str(expr_string))
|
||||||
|
# Ensure sensors referenced only in local_expressions are registered as
|
||||||
|
# listeners even if no condition string references them directly.
|
||||||
|
self.__add_to_entities_to_listen(self.expression_context.get_entities_to_listen())
|
||||||
|
|
||||||
|
|
||||||
trigger_conditions_args = param_parser.parse_args(self.__TRIGGER_CONDITIONS_STRING,None)
|
trigger_conditions_args = param_parser.parse_args(self.__TRIGGER_CONDITIONS_STRING,None)
|
||||||
if trigger_conditions_args:
|
if trigger_conditions_args:
|
||||||
|
|||||||
@@ -135,6 +135,22 @@ class SmartObject(hass.Hass,LoggerInterface):
|
|||||||
# Called at the end of initialize() after all YAML args are processed.
|
# Called at the end of initialize() after all YAML args are processed.
|
||||||
def on_initialize_smart_object(self): pass
|
def on_initialize_smart_object(self): pass
|
||||||
|
|
||||||
|
# Presence simulation extension point.
|
||||||
|
# HomeAlarm can call this method by app name.
|
||||||
|
def start_presence_simulation(self, source_app=None, slot_name=None, payload=None):
|
||||||
|
self.log_error(
|
||||||
|
f"{self.name} does not implement start_presence_simulation "
|
||||||
|
f"(source_app={source_app}, slot_name={slot_name})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Presence simulation extension point.
|
||||||
|
# HomeAlarm can call this method by app name.
|
||||||
|
def stop_presence_simulation(self, source_app=None, slot_name=None, payload=None):
|
||||||
|
self.log_error(
|
||||||
|
f"{self.name} does not implement stop_presence_simulation "
|
||||||
|
f"(source_app={source_app}, slot_name={slot_name})"
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Dataset persistence helpers
|
# Dataset persistence helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ templates_library:
|
|||||||
trigger_conditions: binary_sensor.off == False
|
trigger_conditions: binary_sensor.off == False
|
||||||
blocking_conditions:
|
blocking_conditions:
|
||||||
- binary_sensor.on
|
- binary_sensor.on
|
||||||
|
T4:
|
||||||
|
local_expressions:
|
||||||
|
local_threshold: "binary_sensor.true ? 170 : 200"
|
||||||
|
trigger_conditions: sensor.int170 == local_threshold
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
test_1:
|
test_1:
|
||||||
@@ -44,3 +48,25 @@ tests:
|
|||||||
conditions:
|
conditions:
|
||||||
template_conditions: <T2>,<T3>
|
template_conditions: <T2>,<T3>
|
||||||
trigger_conditions: binary_sensor.true and binary_sensor.false
|
trigger_conditions: binary_sensor.true and binary_sensor.false
|
||||||
|
|
||||||
|
# local_expressions in a template
|
||||||
|
test_5_local_expr_in_template_true:
|
||||||
|
expected_result: Succeeded
|
||||||
|
conditions:
|
||||||
|
template_conditions: <T4>
|
||||||
|
|
||||||
|
# local_expressions declared directly in conditions block
|
||||||
|
test_6_local_expr_inline_true:
|
||||||
|
expected_result: Succeeded
|
||||||
|
conditions:
|
||||||
|
local_expressions:
|
||||||
|
my_threshold: "binary_sensor.true ? 17 : 200"
|
||||||
|
trigger_conditions: sensor.int17 == my_threshold
|
||||||
|
|
||||||
|
# local_expressions inline, condition not met
|
||||||
|
test_7_local_expr_inline_false:
|
||||||
|
expected_result: Failed
|
||||||
|
conditions:
|
||||||
|
local_expressions:
|
||||||
|
my_threshold: "binary_sensor.false ? 17 : 200"
|
||||||
|
trigger_conditions: sensor.int17 == my_threshold
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from kwargsparser import kwargsParser
|
|||||||
from expressionparser import ParsingException
|
from expressionparser import ParsingException
|
||||||
import time
|
import time
|
||||||
from logger_interface import LoggerInterface
|
from logger_interface import LoggerInterface
|
||||||
|
from ad_toolbox.eventhandler import EventHandler
|
||||||
|
|
||||||
class VirtualSensorBase:
|
class VirtualSensorBase:
|
||||||
def __init__(self,ad_api,logger_interface, virtual_sensor_name = None, sensor_name = None,super_entity_id = None, yaml_block = None,templates_library = None, constants = None, app_name = None,self_initialize = False):
|
def __init__(self,ad_api,logger_interface, virtual_sensor_name = None, sensor_name = None,super_entity_id = None, yaml_block = None,templates_library = None, constants = None, app_name = None,self_initialize = False):
|
||||||
@@ -25,7 +26,8 @@ class VirtualSensorBase:
|
|||||||
self.virtual_sensor_name = sensor_name.split('.')[1]
|
self.virtual_sensor_name = sensor_name.split('.')[1]
|
||||||
self.sensor_domain = self.sensor_name.split(".")[0]
|
self.sensor_domain = self.sensor_name.split(".")[0]
|
||||||
|
|
||||||
assert yaml_block ,f"{self.sensor_name} have a null Yaml block"
|
if not yaml_block:
|
||||||
|
self.log_error(f"{self.sensor_name} have a null Yaml block")
|
||||||
self.yaml_block = yaml_block
|
self.yaml_block = yaml_block
|
||||||
self.templates_library = templates_library
|
self.templates_library = templates_library
|
||||||
|
|
||||||
@@ -524,6 +526,34 @@ class BinarySensor(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"binary_sensor.{splitted_name[1]}"
|
return f"binary_sensor.{splitted_name[1]}"
|
||||||
|
|
||||||
|
# set a binary_sensor to true for x seconds when an event is received
|
||||||
|
class EventSensor(VirtualSensorBase):
|
||||||
|
def initialize(self):
|
||||||
|
self.output_sensor = self.ad_api.get_entity(self.sensor_name)
|
||||||
|
|
||||||
|
if not self.output_sensor.exists():
|
||||||
|
self.output_sensor.set_state(state = 'off',attributes = self.attributes)
|
||||||
|
|
||||||
|
self.retain_time = self.yaml_block['retain_time']
|
||||||
|
self.cb_handle = None
|
||||||
|
|
||||||
|
self.event_handlers = []
|
||||||
|
if "activation_events" in self.yaml_block:
|
||||||
|
self.event_handlers.append(EventHandler(self.ad_api, self.yaml_block["activation_events"], self.on_activation_event,logger_interface = self.logger_interface))
|
||||||
|
|
||||||
|
def on_activation_event(self, event_name, data, kwargs):
|
||||||
|
self.output_sensor.set_state(state = 'on')
|
||||||
|
if self.cb_handle is not None:
|
||||||
|
self.ad_api.cancel_timer(self.cb_handle)
|
||||||
|
self.cb_handle = self.ad_api.run_in(self.on_retain_time_elapsed, self.retain_time)
|
||||||
|
|
||||||
|
def on_retain_time_elapsed(self, kwargs):
|
||||||
|
self.output_sensor.set_state(state = 'off')
|
||||||
|
self.cb_handle = None
|
||||||
|
|
||||||
|
def generate_sensor_name(self,virtual_sensor_name):
|
||||||
|
return f"binary_sensor.{virtual_sensor_name}"
|
||||||
|
|
||||||
class ValueSensor(VirtualSensorBase):
|
class ValueSensor(VirtualSensorBase):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
# should be done in the base class
|
# should be done in the base class
|
||||||
@@ -608,6 +638,8 @@ class VirtualSensors():
|
|||||||
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] == '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':
|
||||||
|
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = EventSensor(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)
|
||||||
else:
|
else:
|
||||||
self.logger_interface.log_error(f"Invalid sensor prefix {splitted_sensor[0]}")
|
self.logger_interface.log_error(f"Invalid sensor prefix {splitted_sensor[0]}")
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keybindings": [
|
"keybindings": [
|
||||||
|
{
|
||||||
|
"command": "appdaemon.goToVirtualSensor",
|
||||||
|
"key": "ctrl+shift+v",
|
||||||
|
"mac": "cmd+shift+v"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "appdaemon.goToApp",
|
"command": "appdaemon.goToApp",
|
||||||
"key": "ctrl+shift+a",
|
"key": "ctrl+shift+a",
|
||||||
"mac": "cmd+shift+a"
|
"mac": "cmd+shift+a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "appdaemon.goToVirtualSensor",
|
||||||
|
"key": "ctrl+shift+v",
|
||||||
|
"mac": "cmd+shift+v"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,6 +75,122 @@ async function findAllApps(): Promise<Array<{
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Virtual Sensor parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type VirtualSensorEntry = {
|
||||||
|
sensorId: string;
|
||||||
|
line: number;
|
||||||
|
appName: string;
|
||||||
|
uri: vscode.Uri;
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VSSectionType = 'sensors' | 'continuous_conditions' | 'averagers' | 'binary_sensors' | 'value_selectors' | 'retain_conditions' | 'default_values' | '';
|
||||||
|
|
||||||
|
function mapVirtualSensorId(key: string, section: VSSectionType): string | null {
|
||||||
|
if (section === 'default_values') {
|
||||||
|
return /^(binary_sensor|sensor)\.[a-z0-9_]+$/.test(key) ? key : null;
|
||||||
|
}
|
||||||
|
if (section === 'sensors') {
|
||||||
|
const dot = key.indexOf('.');
|
||||||
|
if (dot < 0) { return null; }
|
||||||
|
const prefix = key.slice(0, dot);
|
||||||
|
const name = key.slice(dot + 1);
|
||||||
|
switch (prefix) {
|
||||||
|
case 'sensor': case 'value_selector': case 'averager': return `sensor.${name}`;
|
||||||
|
case 'binary_sensor': case 'continuous_condition': case 'retain_condition': return `binary_sensor.${name}`;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (section === 'continuous_conditions' || section === 'retain_conditions' || section === 'binary_sensors') {
|
||||||
|
return key.includes('.') ? null : `binary_sensor.${key}`;
|
||||||
|
}
|
||||||
|
if (section === 'averagers' || section === 'value_selectors') {
|
||||||
|
return key.includes('.') ? null : `sensor.${key}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVSSectionName(raw: string, indent: number): VSSectionType {
|
||||||
|
const re = new RegExp(`^\\s{${indent}}(sensors|continuous_conditions|averagers|binary_sensors|value_selectors|retain_conditions|default_values):\\s*$`);
|
||||||
|
const m = raw.match(re);
|
||||||
|
return m ? m[1] as VSSectionType : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVirtualSensorsFromText(text: string): Array<{ sensorId: string; line: number; appName: string }> {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const entries: Array<{ sensorId: string; line: number; appName: string }> = [];
|
||||||
|
|
||||||
|
let currentApp = '';
|
||||||
|
let isVSApp = false; // module: virtualsensors
|
||||||
|
let vsSection: VSSectionType = ''; // active sub-section in VirtualSensorsApp at indent=2
|
||||||
|
let inVSKey = false; // in virtual_sensors: sub-block of another app
|
||||||
|
let nestedSection: VSSectionType = ''; // active sub-section in virtual_sensors: at indent=4
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const raw = lines[i];
|
||||||
|
const trimmed = raw.trimStart();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) { continue; }
|
||||||
|
const indent = raw.length - trimmed.length;
|
||||||
|
|
||||||
|
if (indent === 0) {
|
||||||
|
const m = raw.match(/^([a-z][a-z0-9_]*):\s*$/);
|
||||||
|
currentApp = m ? m[1] : '';
|
||||||
|
isVSApp = false; vsSection = ''; inVSKey = false; nestedSection = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indent === 2) {
|
||||||
|
vsSection = ''; inVSKey = false; nestedSection = '';
|
||||||
|
if (/^\s+module:\s*virtualsensors/.test(raw)) {
|
||||||
|
isVSApp = true;
|
||||||
|
} else if (isVSApp) {
|
||||||
|
vsSection = parseVSSectionName(raw, 2);
|
||||||
|
} else {
|
||||||
|
if (/^\s+virtual_sensors:\s*$/.test(raw)) { inVSKey = true; }
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indent === 4) {
|
||||||
|
nestedSection = '';
|
||||||
|
if (isVSApp && vsSection) {
|
||||||
|
const km = raw.match(/^\s+([a-z][a-z0-9_.]+):/);
|
||||||
|
if (km) {
|
||||||
|
const sensorId = mapVirtualSensorId(km[1], vsSection);
|
||||||
|
if (sensorId) { entries.push({ sensorId, line: i, appName: currentApp }); }
|
||||||
|
}
|
||||||
|
} else if (inVSKey) {
|
||||||
|
nestedSection = parseVSSectionName(raw, 4);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indent === 6 && inVSKey && nestedSection) {
|
||||||
|
const km = raw.match(/^\s+([a-z][a-z0-9_.]+):/);
|
||||||
|
if (km) {
|
||||||
|
const sensorId = mapVirtualSensorId(km[1], nestedSection);
|
||||||
|
if (sensorId) { entries.push({ sensorId, line: i, appName: currentApp }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAllVirtualSensors(): Promise<VirtualSensorEntry[]> {
|
||||||
|
const yamlFiles = await vscode.workspace.findFiles('**/*.yaml', '**/node_modules/**');
|
||||||
|
const results: VirtualSensorEntry[] = [];
|
||||||
|
for (const uri of yamlFiles) {
|
||||||
|
let doc: vscode.TextDocument;
|
||||||
|
try { doc = await vscode.workspace.openTextDocument(uri); } catch { continue; }
|
||||||
|
const fileName = vscode.workspace.asRelativePath(uri);
|
||||||
|
for (const e of parseVirtualSensorsFromText(doc.getText())) {
|
||||||
|
results.push({ ...e, uri, fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Symbol Providers ─────────────────────────────────────────────────────────
|
// ── Symbol Providers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
||||||
@@ -97,9 +213,9 @@ class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
|||||||
|
|
||||||
class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
|
class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
|
||||||
async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
|
async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
|
||||||
const apps = await findAllApps();
|
|
||||||
const lq = query.toLowerCase();
|
const lq = query.toLowerCase();
|
||||||
return apps
|
const [apps, sensors] = await Promise.all([findAllApps(), findAllVirtualSensors()]);
|
||||||
|
const appSymbols = apps
|
||||||
.filter(a => !lq || a.appName.toLowerCase().includes(lq))
|
.filter(a => !lq || a.appName.toLowerCase().includes(lq))
|
||||||
.map(a => new vscode.SymbolInformation(
|
.map(a => new vscode.SymbolInformation(
|
||||||
a.appName,
|
a.appName,
|
||||||
@@ -107,6 +223,102 @@ class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
|
|||||||
`module: ${a.moduleName}`,
|
`module: ${a.moduleName}`,
|
||||||
new vscode.Location(a.uri, new vscode.Position(a.line, 0))
|
new vscode.Location(a.uri, new vscode.Position(a.line, 0))
|
||||||
));
|
));
|
||||||
|
const sensorSymbols = sensors
|
||||||
|
.filter(s => !lq || s.sensorId.toLowerCase().includes(lq))
|
||||||
|
.map(s => new vscode.SymbolInformation(
|
||||||
|
s.sensorId,
|
||||||
|
vscode.SymbolKind.Variable,
|
||||||
|
s.appName,
|
||||||
|
new vscode.Location(s.uri, new vscode.Position(s.line, 0))
|
||||||
|
));
|
||||||
|
return [...appSymbols, ...sensorSymbols];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ADVirtualSensorDefinitionProvider implements vscode.DefinitionProvider {
|
||||||
|
// Simple cache: reset on save
|
||||||
|
private cache: VirtualSensorEntry[] | undefined;
|
||||||
|
private cacheExpiry = 0;
|
||||||
|
|
||||||
|
constructor(private haClient: HAClient) {}
|
||||||
|
|
||||||
|
invalidate() { this.cache = undefined; }
|
||||||
|
|
||||||
|
async getSensors(): Promise<VirtualSensorEntry[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.cache && now < this.cacheExpiry) { return this.cache; }
|
||||||
|
this.cache = await findAllVirtualSensors();
|
||||||
|
this.cacheExpiry = now + 5000; // 5 s TTL
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideDefinition(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position
|
||||||
|
): Promise<vscode.Definition | undefined> {
|
||||||
|
// Match any entity ID pattern (same as hover provider)
|
||||||
|
const wordRange = document.getWordRangeAtPosition(
|
||||||
|
position,
|
||||||
|
/[a-z][a-z_]*\.[a-z0-9][a-z0-9_]*/
|
||||||
|
);
|
||||||
|
if (!wordRange) { return undefined; }
|
||||||
|
const entityId = document.getText(wordRange);
|
||||||
|
|
||||||
|
// 1. Check virtual sensor index (parsed from YAML)
|
||||||
|
const sensors = await this.getSensors();
|
||||||
|
const vsMatch = sensors.find(s => s.sensorId === entityId);
|
||||||
|
if (vsMatch) {
|
||||||
|
return new vscode.Location(vsMatch.uri, new vscode.Position(vsMatch.line, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: check HA entity's ad_app attribute → navigate to that app
|
||||||
|
const entity = this.haClient.getEntity(entityId);
|
||||||
|
const adApp = entity?.attributes?.ad_app as string | undefined;
|
||||||
|
if (!adApp) { return undefined; }
|
||||||
|
const apps = await findAllApps();
|
||||||
|
const appMatch = apps.find(a => a.appName === adApp);
|
||||||
|
if (!appMatch) { return undefined; }
|
||||||
|
return new vscode.Location(appMatch.uri, new vscode.Position(appMatch.line, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ADVirtualSensorHoverProvider implements vscode.HoverProvider {
|
||||||
|
constructor(
|
||||||
|
private haClient: HAClient,
|
||||||
|
private defProvider: ADVirtualSensorDefinitionProvider
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async provideHover(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
position: vscode.Position
|
||||||
|
): Promise<vscode.Hover | undefined> {
|
||||||
|
// Find a virtual sensor whose definition is on this exact line
|
||||||
|
const sensors = await this.defProvider.getSensors();
|
||||||
|
const match = sensors.find(
|
||||||
|
s => s.uri.fsPath === document.uri.fsPath && s.line === position.line
|
||||||
|
);
|
||||||
|
if (!match) { return undefined; }
|
||||||
|
|
||||||
|
const entity = this.haClient.getEntity(match.sensorId);
|
||||||
|
if (!entity) { return undefined; }
|
||||||
|
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
md.isTrusted = true;
|
||||||
|
const friendly = entity.attributes?.friendly_name as string | undefined;
|
||||||
|
md.appendMarkdown(`### ${friendly || entity.entity_id}\n\n`);
|
||||||
|
md.appendMarkdown(`| | |\n|---|---|\n`);
|
||||||
|
md.appendMarkdown(`| **Entity** | \`${entity.entity_id}\` |\n`);
|
||||||
|
md.appendMarkdown(`| **State** | **\`${entity.state}\`** |\n`);
|
||||||
|
if (entity.attributes) {
|
||||||
|
const skip = new Set(['friendly_name', 'icon', 'entity_picture', 'supported_features', 'supported_color_modes']);
|
||||||
|
for (const [key, value] of Object.entries(entity.attributes).filter(([k]) => !skip.has(k)).slice(0, 12)) {
|
||||||
|
const display = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
|
const truncated = display.length > 60 ? display.substring(0, 57) + '...' : display;
|
||||||
|
md.appendMarkdown(`| ${key.replace(/\|/g, '\\|')} | \`${truncated.replace(/\|/g, '\\|')}\` |\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
md.appendMarkdown(`\n*Last changed: ${entity.last_changed}*`);
|
||||||
|
return new vscode.Hover(md);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +378,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
{ scheme: 'file', language: 'python' }
|
{ scheme: 'file', language: 'python' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const virtualSensorDefProvider = new ADVirtualSensorDefinitionProvider(haClient);
|
||||||
|
|
||||||
|
// Invalidate definition cache on save so edits are picked up immediately
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidSaveTextDocument(doc => {
|
||||||
|
if (doc.languageId === 'yaml') { virtualSensorDefProvider.invalidate(); }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.languages.registerCompletionItemProvider(
|
vscode.languages.registerCompletionItemProvider(
|
||||||
selector,
|
selector,
|
||||||
@@ -182,12 +403,46 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
),
|
),
|
||||||
vscode.languages.registerWorkspaceSymbolProvider(
|
vscode.languages.registerWorkspaceSymbolProvider(
|
||||||
new ADWorkspaceSymbolProvider()
|
new ADWorkspaceSymbolProvider()
|
||||||
|
),
|
||||||
|
vscode.languages.registerDefinitionProvider(
|
||||||
|
{ scheme: 'file', language: 'yaml' },
|
||||||
|
virtualSensorDefProvider
|
||||||
|
),
|
||||||
|
vscode.languages.registerHoverProvider(
|
||||||
|
{ scheme: 'file', language: 'yaml' },
|
||||||
|
new ADVirtualSensorHoverProvider(haClient, virtualSensorDefProvider)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Commands ─────────────────────────────────────────────────────────────
|
// ── Commands ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand('appdaemon.goToVirtualSensor', async () => {
|
||||||
|
const sensors = await findAllVirtualSensors();
|
||||||
|
if (sensors.length === 0) {
|
||||||
|
vscode.window.showWarningMessage('AppDaemon: No virtual sensors found in workspace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
type SensorItem = vscode.QuickPickItem & { sensor: VirtualSensorEntry };
|
||||||
|
const items: SensorItem[] = sensors.map(s => ({
|
||||||
|
label: `$(circuit-board) ${s.sensorId}`,
|
||||||
|
description: s.fileName,
|
||||||
|
detail: `in ${s.appName}`,
|
||||||
|
sensor: s
|
||||||
|
}));
|
||||||
|
const picked = await vscode.window.showQuickPick(items, {
|
||||||
|
placeHolder: 'Go to virtual sensor…',
|
||||||
|
matchOnDescription: true,
|
||||||
|
matchOnDetail: true
|
||||||
|
});
|
||||||
|
if (!picked) { return; }
|
||||||
|
const doc = await vscode.workspace.openTextDocument(picked.sensor.uri);
|
||||||
|
const editor = await vscode.window.showTextDocument(doc);
|
||||||
|
const pos = new vscode.Position(picked.sensor.line, 0);
|
||||||
|
editor.selection = new vscode.Selection(pos, pos);
|
||||||
|
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
|
||||||
|
}),
|
||||||
|
|
||||||
vscode.commands.registerCommand('appdaemon.goToApp', async () => {
|
vscode.commands.registerCommand('appdaemon.goToApp', async () => {
|
||||||
const apps = await findAllApps();
|
const apps = await findAllApps();
|
||||||
if (apps.length === 0) {
|
if (apps.length === 0) {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user