Compare commits

...

8 Commits

9 changed files with 493 additions and 159 deletions

View File

@@ -1,16 +1,72 @@
import appdaemon.plugins.hass.hassapi as hass import appdaemon.plugins.hass.hassapi as hass
import pickle import urllib.request
import os import json
# =============================================================================
# EventDispatcher / EventHandler — HA event subscription helpers
# =============================================================================
#
# EventDispatcher
# ---------------
# Subscribes to a single HA event and invokes a callback when the event fires
# and its payload matches the configured filter.
#
# event_name : HA event name to listen for.
# event_data : dict of key/value pairs that must all match the event
# payload (deep partial match — nested dicts are matched
# recursively). None means "match any payload".
# Special key: ``device_name`` — for zha_event payloads you
# may supply the friendly ZHA device name instead of the raw
# IEEE address. It is resolved once at construction time via
# the ``zha/get_devices`` service and stored as
# ``device_ieee`` in the effective filter dict.
# reset_data : optional dict. When set, the dispatcher becomes a
# one-shot latch: the callback fires once on event_data
# match, then waits for a reset_data match before it can
# fire again. Useful for press/release pairs.
# event_context: arbitrary value forwarded as the third argument to the
# callback — use it to identify which dispatcher fired.
#
# Callback signature: callback(event_name, event_data, event_context)
#
# EventHandler
# ------------
# Convenience wrapper that creates one EventDispatcher per entry in an
# events_block dict (the YAML "events_to_listen" structure).
#
# YAML events_block format:
# events_to_listen:
# <key>:
# event_name: <ha_event_name> # required
# event_data: # optional — payload filter
# <field>: <value>
# reset_data: # optional — latch reset filter
# <field>: <value>
#
# Usage:
# handler = EventHandler(ad_api, self.args['events_to_listen'], my_callback)
# # Keep a reference to handler — it owns the subscriptions.
#
# Or construct the events_block programmatically:
# handler = EventHandler(
# ad_api,
# {'evt': {'event_name': 'MY_EVENT', 'event_data': {'key': 'value'}}},
# my_callback,
# event_context='optional_context'
# )
# =============================================================================
class EventDispatcher: class EventDispatcher:
def __init__(self,ad_api,event_name,callback,event_data,reset_data,event_context): def __init__(self,ad_api,event_name,callback,event_data,reset_data,event_context):
self.ad_api = ad_api
event_data = self._resolve_zha_device_name(event_data)
reset_data = self._resolve_zha_device_name(reset_data)
self.event_name = event_name self.event_name = event_name
self.callback = callback self.callback = callback
self.event_data = event_data self.event_data = event_data
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.ad_api = ad_api
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:
@@ -31,6 +87,59 @@ class EventDispatcher:
def on_event(self, event_name, data, kwargs): def on_event(self, event_name, data, kwargs):
self.process_event(data) self.process_event(data)
def _lookup_zha_ieee(self, device_name):
# Resolve a ZHA device friendly name to its IEEE address by rendering
# a Jinja2 template via the HA REST API (/api/template).
# Retrieves the HA URL and token from the HASS plugin config object.
try:
plugin = self.ad_api.AD.plugins.get_plugin_object('default')
ha_url = str(plugin.config.ha_url).rstrip('/')
token = plugin.config.token.get_secret_value()
template = (
"{% set ns = namespace(ieee='') %}"
"{% for eid in integration_entities('zha') %}"
" {% set did = device_id(eid) %}"
" {% if did and not ns.ieee %}"
" {% if device_attr(did, 'name_by_user') == '" + device_name + "'"
" or device_attr(did, 'name') == '" + device_name + "' %}"
" {% for conn in device_attr(did, 'connections') %}"
" {% if conn[0] == 'zigbee' %}{% set ns.ieee = conn[1] %}{% endif %}"
" {% endfor %}"
" {% endif %}"
" {% endif %}"
"{% endfor %}"
"{{ ns.ieee }}"
)
payload = json.dumps({'template': template}).encode('utf-8')
req = urllib.request.Request(
f"{ha_url}/api/template",
data=payload,
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'},
method='POST',
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = resp.read().decode('utf-8').strip()
return result if result else None
except Exception as e:
self.ad_api.log_error(f"[EventDispatcher] Failed to resolve ZHA device name '{device_name}': {e}")
return None
def _resolve_zha_device_name(self, data):
# Replace a device_name key in data with device_ieee looked up from
# the ZHA device registry. Returns data unchanged when the key is
# absent or data is None.
if data is None or 'device_name' not in data:
return data
name = data['device_name']
ieee = self._lookup_zha_ieee(name)
resolved = {k: v for k, v in data.items() if k != 'device_name'}
if ieee is not None:
self.ad_api.log_info(f"[EventDispatcher] Resolved ZHA device name '{name}' to IEEE '{ieee}'")
resolved['device_ieee'] = ieee
else:
self.ad_api.log_error(f"[EventDispatcher] ZHA device '{name}' not found — 'device_name' filter ignored")
return resolved
def process_event(self,data): def process_event(self,data):
def are_data_matching(ref_data, data): def are_data_matching(ref_data, data):
if ref_data != None: if ref_data != None:
@@ -69,31 +178,14 @@ class EventHandler:
self.__ad_api = ad_api self.__ad_api = ad_api
self.event_dispatchers = [] self.event_dispatchers = []
#try: if isinstance(events_block, list):
for event_block in events_block.values(): for event in events_block:
register_event_with_params(event_block,callback,event_context) self.add_dispatcher(event,callback,event_context=event_context)
#except (AttributeError): else:
# self.log(f"Format not supported : {events_block}") for event_block in events_block.values():
register_event_with_params(event_block,callback,event_context)
def log(self,message,**kwargs):
self.__ad_api.log(message,**kwargs)
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):
self.log(f'Registering dispatcher {callback.__name__} for event "{event_name}" ({event_data})') self.__ad_api.log_info(f'Registering dispatcher {callback.__name__} for event "{event_name}" ({event_data})')
dispatcher = EventDispatcher(self.__ad_api,event_name,callback,event_data,reset_data,event_context) dispatcher = EventDispatcher(self.__ad_api,event_name,callback,event_data,reset_data,event_context)
self.event_dispatchers.append(dispatcher) self.event_dispatchers.append(dispatcher)
# if not event_name in self.event_dispatchers:
# self.event_dispatchers[event_name] = list()
# self.log(f"listening event {event_name}")
# self.__ad_api.listen_event(self._on_event_internal,event_name)
# self.event_dispatchers[event_name].append(dispatcher)
# def _on_event_internal(self, event_name, data, kwargs):
# if event_name in self.event_dispatchers:
# for dispatcher in self.event_dispatchers[event_name]:
# if dispatcher.process_event(data) == True:
# break
# else:
# self.log(f"{event_name} has no dispatcher registered",level="WARNING")

View File

@@ -1,131 +1,194 @@
import appdaemon.plugins.hass.hassapi as hass import appdaemon.plugins.hass.hassapi as hass
import pickle import pickle
import os import os
import json
import re
from virtualsensors import VirtualSensors from virtualsensors import VirtualSensors
from expressionparser import ParsingException from expressionparser import ParsingException
from logger_interface import LoggerInterface from logger_interface import LoggerInterface
# =============================================================================
# SmartObject — Base class for all ad_toolbox AppDaemon apps
# =============================================================================
# Inherit from SmartObject instead of hass.Hass to get all features below.
#
# FEATURES
# --------
# - Entity linking : bind the app to a single HA entity (self.entity)
# - Dataset persistence : self.dataset is auto-loaded/saved to disk (pickle)
# - Constants : key/value dict injected into expression contexts
# - Templates library : shared named-template provider from another app
# - Virtual sensors : declarative derived sensors defined in YAML
# - Attributes override : map HA sensor states onto entity attributes
#
# YAML CONFIGURATION
# ------------------
# All keys are optional unless noted.
#
# entity: <entity_id>
# HA entity this app is linked to (e.g. "sensor.my_sensor").
# Available as self.entity_id and self.entity after initialize().
# Also injected into the expression context as the constant "self".
#
# default_entity_state: <state_string>
# Initial state to set when entity does not yet exist in HA.
# If omitted and the entity is missing, get_default_entity_state() is
# called (raises an error by default — override it in subclasses).
#
# trace_events: <ha_event_name | list of ha_event_names>
# Log every occurrence of the named HA event(s) to the app log.
# Useful during development to inspect event payloads.
# Example: trace_events: MY_BUTTON_EVENT
# Example: trace_events: [MY_BUTTON_EVENT, ios.action_fired]
#
# mute: true
# Suppress all log output from this app instance.
#
# constants:
# <key>: <value>
# ...
# Arbitrary key/value pairs injected into expression and template
# contexts. "self" is always added automatically as the entity_id.
#
# templates_library: <app_name>
# Name of another SmartObject app that exposes a template library.
#
# virtual_sensors:
# <sensor_id>:
# ...
# Declarative virtual sensor definitions passed to VirtualSensors.
# See virtualsensors.py for the full sub-schema.
#
# attributes_override:
# <attribute_name>: <entity_id | static_value>
# ...
# Override attributes on self.entity. Each value can be either:
# - A HA entity_id → attribute tracks that entity's state live.
# - A static value → attribute is set once at startup.
#
# DATASET PERSISTENCE
# -------------------
# Subclasses can store arbitrary data in self.dataset (any pickle-able
# Python object). It is automatically:
# - Loaded from apps/data/<appname>.dataset at startup (if the file exists)
# - Saved to apps/data/<appname>.dataset on terminate (if not None)
#
# SUBCLASSING
# -----------
# Override on_initialize_smart_object() instead of initialize().
# Override get_default_entity_state() to provide a default state when the
# linked entity does not exist and no default_entity_state arg is given.
# =============================================================================
class SmartObject(hass.Hass,LoggerInterface): class SmartObject(hass.Hass,LoggerInterface):
def _sanitize_for_topic(self, value): # ------------------------------------------------------------------
return re.sub(r"[^a-zA-Z0-9_]+", "_", str(value)).strip("_").lower() # Public API
# ------------------------------------------------------------------
def _mqtt_lazy_init(self): # Create (or update) a HA entity and tag it with the originating app
if hasattr(self, '_mqtt'): # through the ad_app attribute.
return self._mqtt is not None #
self._mqtt = None # Parameters
try: # ----------
available = any( # entity_id : full HA entity id, e.g. "sensor.my_value"
i.get('domain') == 'mqtt' and i.get('service') == 'publish' # state : initial state string (optional)
for i in self.list_services('global') # attributes : dict of initial attributes (optional)
) # name : friendly name shown in HA UI
except Exception: # icon : MDI icon string, e.g. "mdi:thermometer"
available = False # unit_of_measurement : e.g. "°C", "%", "W"
if not available: # device_class : HA sensor device class, e.g. "temperature"
return False # state_class : "measurement", "total", "total_increasing"
node_id = self._sanitize_for_topic(self.name) # Returns the AppDaemon entity handle.
device_name = self.args.get('mqtt_device_name', self.name) def create_entity(self, entity_id, state=None, attributes=None, friendly_name=None, icon=None, unit_of_measurement=None, device_class=None, state_class=None):
self._mqtt = {
'node_id': node_id,
'device': {
'identifiers': [node_id],
'name': device_name,
'manufacturer': 'AppDaemon',
'model': self.__class__.__name__,
},
'entities': set(),
'handles': {},
}
return True
def _mqtt_publish(self, topic, payload, retain=False):
if not isinstance(payload, str):
payload = json.dumps(payload)
self.call_service('mqtt/publish', topic=topic, payload=payload, retain=retain)
def _mqtt_sync_state(self, entity_id, attribute, old, new, kwargs):
if not getattr(self, '_mqtt', None) or entity_id not in self._mqtt['entities']:
return
state_data = self.get_state(entity_id, attribute='all') or {}
payload = {'state': state_data.get('state')}
payload.update(state_data.get('attributes', {}))
node_id = self._mqtt['node_id']
obj = self._sanitize_for_topic(entity_id.split('.')[-1])
self._mqtt_publish(f"appdaemon/{node_id}/{obj}/state", payload, retain=True)
def create_entity(self, entity_id, state=None, attributes=None, name=None, icon=None, unit_of_measurement=None, device_class=None, state_class=None):
entity = self.get_entity(entity_id, check_existence=False) entity = self.get_entity(entity_id, check_existence=False)
if self._mqtt_lazy_init() and entity_id not in self._mqtt['entities']: if attributes is not None:
node_id = self._mqtt['node_id'] attributes = dict(attributes)
obj = self._sanitize_for_topic(entity_id.split('.')[-1]) else:
state_topic = f"appdaemon/{node_id}/{obj}/state" attributes = dict()
availability_topic = f"appdaemon/{node_id}/{obj}/availability"
config = { attributes['ad_app'] = self.name
'name': name or entity_id,
'unique_id': f"{node_id}_{obj}", if friendly_name is not None: attributes['friendly_name'] = friendly_name
'state_topic': state_topic, if icon is not None: attributes['icon'] = icon
'value_template': '{{ value_json.state }}', if unit_of_measurement is not None: attributes['unit_of_measurement'] = unit_of_measurement
'availability_topic': availability_topic, if device_class is not None: attributes['device_class'] = device_class
'payload_available': 'online', if state_class is not None: attributes['state_class'] = state_class
'payload_not_available': 'offline',
'device': self._mqtt['device'], if state is not None:
} entity.set_state(state=state, attributes=attributes)
if icon: config['icon'] = icon else:
if unit_of_measurement: config['unit_of_measurement'] = unit_of_measurement if not entity.exists():
if device_class: config['device_class'] = device_class entity.set_state(state='unknown')
if state_class: config['state_class'] = state_class entity.set_state(attributes=attributes)
self._mqtt_publish(f"homeassistant/sensor/{node_id}/{obj}/config", config, retain=True)
self._mqtt_publish(availability_topic, 'online', retain=True)
self._mqtt['entities'].add(entity_id)
self._mqtt['handles'][entity_id] = self.listen_state(self._mqtt_sync_state, entity_id)
if state is not None and attributes is not None:
self.set_state(entity_id, state=state, attributes=attributes)
elif state is not None:
self.set_state(entity_id, state=state)
elif attributes is not None:
self.set_state(entity_id, attributes=attributes)
elif not entity.exists():
self.set_state(entity_id, state='unknown')
return entity return entity
def _mqtt_terminate(self):
if not getattr(self, '_mqtt', None):
return
for entity_id in self._mqtt['entities']:
node_id = self._mqtt['node_id']
obj = self._sanitize_for_topic(entity_id.split('.')[-1])
try: self._mqtt_publish(f"appdaemon/{node_id}/{obj}/availability", 'offline', retain=True)
except Exception: pass
for handle in self._mqtt['handles'].values():
try: self.cancel_listen_state(handle, silent=True)
except Exception: pass
def get_dataset_folder(self): return os.path.join(str(self.AD.app_dir),"data")
# Override this in a subclass to return a default state string when the
# linked entity does not exist and no "default_entity_state" arg was set.
# The base implementation raises a fatal error.
def get_default_entity_state(self): self.log_error(f"{self.entity_id} doesn't exist. Please use default_entity_state if you wan't to create one or override get_default_entity_state()",True)
# Logs any HA event registered via trace_events with its full payload.
def _on_trace_event(self, event_name, data, kwargs):
self.log_info(f"[trace] event '{event_name}' fired. data = {data}")
# Override this in subclasses instead of initialize().
# Called at the end of initialize() after all YAML args are processed.
def on_initialize_smart_object(self): pass
# ------------------------------------------------------------------
# Dataset persistence helpers
# ------------------------------------------------------------------
# Returns the path to the shared data folder: <app_dir>/data/
def get_dataset_folder(self): return os.path.join(str(self.AD.app_dir),"data")
# Returns the full path of this app's dataset file:
# <app_dir>/data/<appname>.dataset
def get_dataset_name(self): def get_dataset_name(self):
return os.path.join(self.get_dataset_folder(),f"{self.name}.dataset") return os.path.join(self.get_dataset_folder(),f"{self.name}.dataset")
# ------------------------------------------------------------------
# Constants
# ------------------------------------------------------------------
# Merge constants_dict into self.constants. Constants are available
# inside expression / template evaluation contexts.
def add_constants(self,constants_dict): def add_constants(self,constants_dict):
self.log_info(f"Declaring constants {constants_dict}") self.log_info(f"Declaring constants {constants_dict}")
self.constants.update(constants_dict) self.constants.update(constants_dict)
def get_default_entity_state(self): self.log_error(f"{self.entity_id} doesn't exist. Please use default_entity_state if you wan't to create one or override get_default_entity_state()",True)
# ------------------------------------------------------------------
# AppDaemon lifecycle
# ------------------------------------------------------------------
# Main AppDaemon entry point. Processes all YAML args in order:
# mute → entity → constants → templates_library → virtual_sensors
# → attributes_override → dataset restore → on_initialize_smart_object()
def initialize(self): def initialize(self):
try: try:
#self.depends_on_module(["smartobject","virtualsensors","logger_interface"]) #self.depends_on_module(["smartobject","virtualsensors","logger_interface"])
self.initialize_logger_interface(self.get_ad_api()) self.initialize_logger_interface(self.get_ad_api())
self.log_info(f"Name = {self.name}") self.log_info(f"Name = {self.name}")
# mute: true → suppress all log output for this app
try: self.mute_logger(self.args['mute']) try: self.mute_logger(self.args['mute'])
except KeyError: pass except KeyError: pass
self.event_dispatchers = dict() self.event_dispatchers = dict()
self.constants = dict() self.constants = dict()
self.dataset = None self.dataset = None
#retrieve object param # trace_events: <event | list> → log event payloads for debugging
if 'trace_events' in self.args:
event_names = self.args['trace_events']
if not isinstance(event_names, list):
event_names = [event_names]
for event_name in event_names:
self.log_info(f"Tracing event '{event_name}'")
self.listen_event(self._on_trace_event, event_name)
# 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)
@@ -142,9 +205,11 @@ class SmartObject(hass.Hass,LoggerInterface):
self.entity_id = None self.entity_id = None
self.entity = None self.entity = None
# constants: {key: value, ...} → add to expression context
if 'constants' in self.args: if 'constants' in self.args:
self.add_constants(self.args['constants']) self.add_constants(self.args['constants'])
# templates_library: <app_name> → load shared template provider
self.templates_library = None self.templates_library = None
if 'templates_library' in self.args: if 'templates_library' in self.args:
library_name = self.args['templates_library'] library_name = self.args['templates_library']
@@ -155,9 +220,13 @@ class SmartObject(hass.Hass,LoggerInterface):
else: else:
self.log_error(f"Can't find the library app {library_name}") self.log_error(f"Can't find the library app {library_name}")
# virtual_sensors: {...} → create derived sensors from YAML spec
if 'virtual_sensors' in self.args: if 'virtual_sensors' in self.args:
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self,super_entity_id = self.entity_id,yaml_block = self.args['virtual_sensors'],templates_library = self.templates_library,constants = self.constants) self.virtual_sensors = VirtualSensors(ad_api = self,logger_interface = self,super_entity_id = self.entity_id,yaml_block = self.args['virtual_sensors'],templates_library = self.templates_library,constants = self.constants,app_name = self.name)
# attributes_override: {attr: entity_id | static_value, ...}
# Each attribute either mirrors a live HA sensor or holds a
# static value set once at startup.
if 'attributes_override' in self.args: if 'attributes_override' in self.args:
self.attribute_sensors = dict() self.attribute_sensors = dict()
new_attributes = dict() new_attributes = dict()
@@ -175,7 +244,8 @@ class SmartObject(hass.Hass,LoggerInterface):
self.log_info(f"Overriding {self.entity_id} attributes with {new_attributes}") self.log_info(f"Overriding {self.entity_id} attributes with {new_attributes}")
self.entity.set_state(state = self.entity.get_state(), attributes = new_attributes) self.entity.set_state(state = self.entity.get_state(), attributes = new_attributes)
#load dataset from disk # Restore persisted dataset from disk (pickle). The file is
# created/updated on terminate() when self.dataset is not None.
try: try:
try: os.makedirs(self.get_dataset_folder()) try: os.makedirs(self.get_dataset_folder())
except FileExistsError: pass except FileExistsError: pass
@@ -189,12 +259,10 @@ class SmartObject(hass.Hass,LoggerInterface):
except ParsingException as e: self.log_error(str(e),stop_app = True) except ParsingException as e: self.log_error(str(e),stop_app = True)
def on_initialize_smart_object(self): pass # AppDaemon shutdown hook. Persists self.dataset to disk (if set).
def terminate(self): def terminate(self):
self.event_dispatchers = None self.event_dispatchers = None
self.virtual_sensors = None self.virtual_sensors = None
self._mqtt_terminate()
try: has_dataset = self.dataset != None try: has_dataset = self.dataset != None
except AttributeError: has_dataset = False except AttributeError: has_dataset = False
if has_dataset: if has_dataset:

30
virtualevents.py Normal file
View File

@@ -0,0 +1,30 @@
import appdaemon.plugins.hass.hassapi as hass
import smartcondition as SmartCondition
from expressionparser import ParsingException
from logger_interface import LoggerInterface
class VirtualEvents(hass.Hass,LoggerInterface):
def initialize(self):
self.initialize_logger_interface(self.get_ad_api())
try: self.mute_logger(self.args['mute'])
except KeyError: pass
self.virtual_events = dict()
if "virtual_events" in self.args:
for event in self.args["virtual_events"]:
try: self.virtual_events[event] = SmartCondition.Evaluator(self,self.args["virtual_events"][event]['event_condition'],condition_name = event,on_succeed_cb = self.on_condition_succeed,pass_condition_name_to_cb = event, trigger_callback_on_activation = False, trigger_callback_on_entity_creation = False)
except ParsingException as e:
self.log_error(str(e))
continue
def on_condition_succeed(self,event_name):
event_yaml = self.args["virtual_events"][event_name]
if 'event_data' in event_yaml:
self.log(f"Sending event {event_yaml['event_name']} data = {event_yaml['event_data']}")
self.fire_event(event_yaml['event_name'],**event_yaml['event_data'])
else:
self.log(f"Sending event {event_yaml['event_name']}")
self.fire_event(event_yaml['event_name'])

View File

@@ -7,7 +7,7 @@ import time
from logger_interface import LoggerInterface from logger_interface import LoggerInterface
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,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):
self.ad_api = ad_api self.ad_api = ad_api
self.logger_interface = logger_interface self.logger_interface = logger_interface
@@ -32,6 +32,9 @@ class VirtualSensorBase:
try: self.attributes = self.yaml_block['attributes'] try: self.attributes = self.yaml_block['attributes']
except (TypeError,KeyError): self.attributes = dict() except (TypeError,KeyError): self.attributes = dict()
if app_name:
self.attributes['ad_app'] = app_name
if constants: if constants:
self.constants = dict(constants) # we don't want to modify the parent dict self.constants = dict(constants) # we don't want to modify the parent dict
self.constants['self'] = self.sensor_name # we need to set self by ourself self.constants['self'] = self.sensor_name # we need to set self by ourself
@@ -554,7 +557,7 @@ class ValueSensor(VirtualSensorBase):
class VirtualSensors(): class VirtualSensors():
def __init__(self, ad_api = None,logger_interface = None, super_entity_id = None, yaml_block = None ,args_to_ignore_in_validation = [], constants = None, templates_library = None): def __init__(self, ad_api = None,logger_interface = None, super_entity_id = None, yaml_block = None ,args_to_ignore_in_validation = [], constants = None, templates_library = None, app_name = None):
assert ad_api assert ad_api
assert yaml_block assert yaml_block
assert logger_interface assert logger_interface
@@ -572,20 +575,20 @@ class VirtualSensors():
averagers = parser.parse_args('averagers',{}) averagers = parser.parse_args('averagers',{})
for averager in averagers: for averager in averagers:
self.virtual_sensors[f"sensor.{averager}"] = Averager(self.ad_api,self.logger_interface,averager,super_entity_id = super_entity_id,yaml_block = yaml_block['averagers'][averager],constants = constants, templates_library = templates_library) self.virtual_sensors[f"sensor.{averager}"] = Averager(self.ad_api,self.logger_interface,averager,super_entity_id = super_entity_id,yaml_block = yaml_block['averagers'][averager],constants = constants, templates_library = templates_library,app_name = app_name)
continuous_conditions = parser.parse_args('continuous_conditions',{}) continuous_conditions = parser.parse_args('continuous_conditions',{})
for continuous_condition in continuous_conditions: for continuous_condition in continuous_conditions:
self.virtual_sensors[f"binary_sensor.{continuous_condition}"] = ContinuousCondition(self.ad_api,self.logger_interface,continuous_condition,super_entity_id = super_entity_id,yaml_block = yaml_block['continuous_conditions'][continuous_condition],templates_library = templates_library) self.virtual_sensors[f"binary_sensor.{continuous_condition}"] = ContinuousCondition(self.ad_api,self.logger_interface,continuous_condition,super_entity_id = super_entity_id,yaml_block = yaml_block['continuous_conditions'][continuous_condition],templates_library = templates_library,app_name = app_name)
binary_sensors = parser.parse_args('binary_sensors',{}) binary_sensors = parser.parse_args('binary_sensors',{})
for binary_sensor in binary_sensors: for binary_sensor in binary_sensors:
#self.logger_interface.log_info(f"Creating Binary Sensor: binary_sensor = {binary_sensor}, super_entity_id = {super_entity_id}, yaml_block = {yaml_block['binary_sensors']}") #self.logger_interface.log_info(f"Creating Binary Sensor: binary_sensor = {binary_sensor}, super_entity_id = {super_entity_id}, yaml_block = {yaml_block['binary_sensors']}")
self.virtual_sensors[f"binary_sensor.{binary_sensor}"] = BinarySensor(self.ad_api,self.logger_interface,binary_sensor,super_entity_id = super_entity_id,yaml_block = yaml_block['binary_sensors'][binary_sensor],constants = constants,templates_library = templates_library) self.virtual_sensors[f"binary_sensor.{binary_sensor}"] = BinarySensor(self.ad_api,self.logger_interface,binary_sensor,super_entity_id = super_entity_id,yaml_block = yaml_block['binary_sensors'][binary_sensor],constants = constants,templates_library = templates_library,app_name = app_name)
value_selectors = parser.parse_args('value_selectors',{}) value_selectors = parser.parse_args('value_selectors',{})
for value_selector in value_selectors: for value_selector in value_selectors:
self.virtual_sensors[f"sensor.{value_selector}"] = ValueSelector(self.ad_api,self.logger_interface,value_selector,super_entity_id = super_entity_id,yaml_block = yaml_block['value_selectors'][value_selector],constants = constants,templates_library = templates_library) self.virtual_sensors[f"sensor.{value_selector}"] = ValueSelector(self.ad_api,self.logger_interface,value_selector,super_entity_id = super_entity_id,yaml_block = yaml_block['value_selectors'][value_selector],constants = constants,templates_library = templates_library,app_name = app_name)
sensors = parser.parse_args('sensors',{}) sensors = parser.parse_args('sensors',{})
for sensor in sensors: for sensor in sensors:
@@ -594,22 +597,20 @@ class VirtualSensors():
self.logger_interface.log_error(f"Invalid sensor name {splitted_sensor}") self.logger_interface.log_error(f"Invalid sensor name {splitted_sensor}")
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) 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] == '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) 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':
self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = ContinuousCondition(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) self.virtual_sensors[f"binary_sensor.{splitted_sensor[1]}"] = ContinuousCondition(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] == 'averager': elif splitted_sensor[0] == 'averager':
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) 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) 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) 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)
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]}")
parser.validate_args(args_to_ignore_in_validation)
dependencies_graph = dict() dependencies_graph = dict()
for sensor_name, virtual_sensor in self.virtual_sensors.items(): for sensor_name, virtual_sensor in self.virtual_sensors.items():
try: try:
@@ -672,4 +673,4 @@ 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']) 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)

View File

@@ -51,6 +51,11 @@
} }
}, },
"commands": [ "commands": [
{
"command": "appdaemon.goToApp",
"title": "AppDaemon: Go to App",
"icon": "$(symbol-class)"
},
{ {
"command": "appdaemon.restartCurrentFileApps", "command": "appdaemon.restartCurrentFileApps",
"title": "AppDaemon: Restart Apps in Current File", "title": "AppDaemon: Restart Apps in Current File",
@@ -81,10 +86,21 @@
"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"
} }
],
"keybindings": [
{
"command": "appdaemon.goToApp",
"key": "ctrl+shift+a",
"mac": "cmd+shift+a"
}
] ]
}, },
"scripts": { "scripts": {

View File

@@ -37,7 +37,10 @@ export class EntityCompletionProvider implements vscode.CompletionItemProvider {
const domain = dotMatch[1]; const domain = dotMatch[1];
const partial = dotMatch[2]; const partial = dotMatch[2];
if (this.cachedDomains.has(domain)) { if (this.cachedDomains.has(domain)) {
const items = this.buildDomainItems(entities, domain, partial); // Range covers "domain.partial" so the full entity_id replaces it
const replaceStart = position.translate(0, -(domain.length + 1 + partial.length));
const replaceRange = new vscode.Range(replaceStart, position);
const items = this.buildDomainItems(entities, domain, partial, replaceRange);
if (items.length > 0) { if (items.length > 0) {
return new vscode.CompletionList(items, false); return new vscode.CompletionList(items, false);
} }
@@ -80,7 +83,8 @@ export class EntityCompletionProvider implements vscode.CompletionItemProvider {
private buildDomainItems( private buildDomainItems(
entities: HAEntity[], entities: HAEntity[],
domain: string, domain: string,
partial: string partial: string,
replaceRange: vscode.Range
): vscode.CompletionItem[] { ): vscode.CompletionItem[] {
const items: vscode.CompletionItem[] = []; const items: vscode.CompletionItem[] = [];
const prefix = `${domain}.`; const prefix = `${domain}.`;
@@ -105,6 +109,7 @@ export class EntityCompletionProvider implements vscode.CompletionItemProvider {
item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity)); item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity));
item.filterText = entity.entity_id; item.filterText = entity.entity_id;
item.sortText = entity.entity_id; item.sortText = entity.entity_id;
item.range = replaceRange;
items.push(item); items.push(item);
} }
return items; return items;

View File

@@ -17,28 +17,101 @@ const NON_APP_KEYS = new Set([
'appdaemon', 'http', 'hadashboard', 'admin', 'api', 'plugins' 'appdaemon', 'http', 'hadashboard', 'admin', 'api', 'plugins'
]); ]);
/** // ── App parsing helpers ───────────────────────────────────────────────────────
* Parse AppDaemon app names from a YAML document.
* Returns only top-level keys whose block contains a `module:` line. type AppEntry = { appName: string; line: number; moduleName: string };
*/
function parseAppsFromDocument(doc: vscode.TextDocument): string[] { function parseAppEntries(text: string): AppEntry[] {
if (doc.languageId !== 'yaml') { return []; } const lines = text.split('\n');
const lines = doc.getText().split('\n'); const entries: AppEntry[] = [];
const apps: string[] = [];
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^([a-z][a-z0-9_]*):\s*$/); const m = lines[i].match(/^([a-z][a-z0-9_]*):\s*$/);
if (!m) { continue; } if (!m) { continue; }
const key = m[1]; const key = m[1];
if (NON_APP_KEYS.has(key)) { continue; } if (NON_APP_KEYS.has(key)) { continue; }
// Scan the indented block below for a `module:` key let moduleName = '';
for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) { for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) {
if (lines[j].match(/^\S/)) { break; } // end of block if (lines[j].match(/^\S/)) { break; }
if (lines[j].match(/^\s+module\s*:/)) { apps.push(key); break; } const mod = lines[j].match(/^\s+module\s*:\s*(\S+)/);
if (mod) { moduleName = mod[1]; entries.push({ appName: key, line: i, moduleName }); break; }
} }
} }
return apps; return entries;
} }
/**
* Parse AppDaemon app names from a YAML document.
*/
function parseAppsFromDocument(doc: vscode.TextDocument): string[] {
if (doc.languageId !== 'yaml') { return []; }
return parseAppEntries(doc.getText()).map(e => e.appName);
}
/**
* Scan all YAML files in the workspace and return app definitions with location.
*/
async function findAllApps(): Promise<Array<{
appName: string;
uri: vscode.Uri;
line: number;
fileName: string;
moduleName: string;
}>> {
const yamlFiles = await vscode.workspace.findFiles('**/*.yaml', '**/node_modules/**');
const results: Array<{ appName: string; uri: vscode.Uri; line: number; fileName: string; moduleName: string }> = [];
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 entry of parseAppEntries(doc.getText())) {
results.push({ ...entry, uri, fileName });
}
}
return results;
}
// ── Symbol Providers ─────────────────────────────────────────────────────────
class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
provideDocumentSymbols(doc: vscode.TextDocument): vscode.DocumentSymbol[] {
if (doc.languageId !== 'yaml') { return []; }
return parseAppEntries(doc.getText()).map(entry => {
const pos = new vscode.Position(entry.line, 0);
const range = new vscode.Range(pos, pos);
const sym = new vscode.DocumentSymbol(
entry.appName,
`module: ${entry.moduleName}`,
vscode.SymbolKind.Class,
range,
range
);
return sym;
});
}
}
class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
const apps = await findAllApps();
const lq = query.toLowerCase();
return apps
.filter(a => !lq || a.appName.toLowerCase().includes(lq))
.map(a => new vscode.SymbolInformation(
a.appName,
vscode.SymbolKind.Class,
`module: ${a.moduleName}`,
new vscode.Location(a.uri, new vscode.Position(a.line, 0))
));
}
}
// ─────────────────────────────────────────────────────────────────────────────
function updateContextualButton(editor: vscode.TextEditor | undefined) { function updateContextualButton(editor: vscode.TextEditor | undefined) {
if (!editor) { statusBar.updateContextualApps([]); return; } if (!editor) { statusBar.updateContextualApps([]); return; }
const apps = parseAppsFromDocument(editor.document); const apps = parseAppsFromDocument(editor.document);
@@ -102,12 +175,48 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.languages.registerHoverProvider( vscode.languages.registerHoverProvider(
selector, selector,
new EntityHoverProvider(haClient) new EntityHoverProvider(haClient)
),
vscode.languages.registerDocumentSymbolProvider(
{ scheme: 'file', language: 'yaml' },
new ADDocumentSymbolProvider()
),
vscode.languages.registerWorkspaceSymbolProvider(
new ADWorkspaceSymbolProvider()
) )
); );
// ── Commands ───────────────────────────────────────────────────────────── // ── Commands ─────────────────────────────────────────────────────────────
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('appdaemon.goToApp', async () => {
const apps = await findAllApps();
if (apps.length === 0) {
vscode.window.showWarningMessage('AppDaemon: No app definitions found in workspace');
return;
}
type AppItem = vscode.QuickPickItem & { app: typeof apps[0] };
const items: AppItem[] = apps.map(app => ({
label: `$(symbol-class) ${app.appName}`,
description: app.fileName,
detail: `Line ${app.line + 1}`,
app
}));
const picked = await vscode.window.showQuickPick(items, {
placeHolder: 'Go to AppDaemon app…',
matchOnDescription: true,
matchOnDetail: false
});
if (!picked) { return; }
const doc = await vscode.workspace.openTextDocument(picked.app.uri);
const editor = await vscode.window.showTextDocument(doc);
const pos = new vscode.Position(picked.app.line, 0);
editor.selection = new vscode.Selection(pos, pos);
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
}),
vscode.commands.registerCommand('appdaemon.restartCurrentFileApps', async () => { vscode.commands.registerCommand('appdaemon.restartCurrentFileApps', async () => {
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
if (!editor) { if (!editor) {
@@ -176,7 +285,18 @@ 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`);

View File

@@ -78,11 +78,13 @@ export class StatusBarManager {
if (count > 0) { if (count > 0) {
this.errorItem.text = `$(warning) AD Errors (${count})`; this.errorItem.text = `$(warning) AD Errors (${count})`;
this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.errorItem.tooltip = `${count} error(s) in AppDaemon log — click to view`; this.errorItem.tooltip = `${count} error(s) — click to show or clear`;
this.errorItem.command = 'appdaemon.handleErrors';
} else { } else {
this.errorItem.text = '$(check) AD Errors'; this.errorItem.text = '$(check) AD Errors';
this.errorItem.backgroundColor = undefined; this.errorItem.backgroundColor = undefined;
this.errorItem.tooltip = 'No AppDaemon errors — click to view log'; this.errorItem.tooltip = 'No AppDaemon errors — click to view log';
this.errorItem.command = 'appdaemon.showErrors';
} }
} }