Compare commits
10 Commits
923cfd4152
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a910ba80 | |||
| 875e0abd37 | |||
| 8454cc95f5 | |||
| a7846b54e5 | |||
| ea8d62710c | |||
| 261132d9af | |||
| 7dd39e0ea9 | |||
| abdc962500 | |||
| da282058d8 | |||
| 3e1f0f3ca9 |
142
eventhandler.py
142
eventhandler.py
@@ -1,16 +1,72 @@
|
||||
import appdaemon.plugins.hass.hassapi as hass
|
||||
import pickle
|
||||
import os
|
||||
import urllib.request
|
||||
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:
|
||||
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.callback = callback
|
||||
self.event_data = event_data
|
||||
self.reset_data = reset_data
|
||||
self.waiting_for_reset = False
|
||||
self.event_context = event_context
|
||||
self.ad_api = ad_api
|
||||
if event_data == None:
|
||||
self.ad_api.listen_event(self.on_event,event_name)
|
||||
else:
|
||||
@@ -31,6 +87,59 @@ class EventDispatcher:
|
||||
def on_event(self, event_name, data, kwargs):
|
||||
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 are_data_matching(ref_data, data):
|
||||
if ref_data != None:
|
||||
@@ -69,31 +178,14 @@ class EventHandler:
|
||||
self.__ad_api = ad_api
|
||||
self.event_dispatchers = []
|
||||
|
||||
#try:
|
||||
if isinstance(events_block, list):
|
||||
for event in events_block:
|
||||
self.add_dispatcher(event,callback,event_context=event_context)
|
||||
else:
|
||||
for event_block in events_block.values():
|
||||
register_event_with_params(event_block,callback,event_context)
|
||||
#except (AttributeError):
|
||||
# self.log(f"Format not supported : {events_block}")
|
||||
|
||||
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):
|
||||
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)
|
||||
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")
|
||||
262
smartobject.py
262
smartobject.py
@@ -1,131 +1,194 @@
|
||||
import appdaemon.plugins.hass.hassapi as hass
|
||||
import pickle
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from virtualsensors import VirtualSensors
|
||||
from expressionparser import ParsingException
|
||||
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):
|
||||
|
||||
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):
|
||||
if hasattr(self, '_mqtt'):
|
||||
return self._mqtt is not None
|
||||
self._mqtt = None
|
||||
try:
|
||||
available = any(
|
||||
i.get('domain') == 'mqtt' and i.get('service') == 'publish'
|
||||
for i in self.list_services('global')
|
||||
)
|
||||
except Exception:
|
||||
available = False
|
||||
if not available:
|
||||
return False
|
||||
node_id = self._sanitize_for_topic(self.name)
|
||||
device_name = self.args.get('mqtt_device_name', self.name)
|
||||
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):
|
||||
# Create (or update) a HA entity and tag it with the originating app
|
||||
# through the ad_app attribute.
|
||||
#
|
||||
# Parameters
|
||||
# ----------
|
||||
# entity_id : full HA entity id, e.g. "sensor.my_value"
|
||||
# state : initial state string (optional)
|
||||
# attributes : dict of initial attributes (optional)
|
||||
# name : friendly name shown in HA UI
|
||||
# icon : MDI icon string, e.g. "mdi:thermometer"
|
||||
# unit_of_measurement : e.g. "°C", "%", "W"
|
||||
# device_class : HA sensor device class, e.g. "temperature"
|
||||
# state_class : "measurement", "total", "total_increasing"
|
||||
# Returns the AppDaemon entity handle.
|
||||
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):
|
||||
entity = self.get_entity(entity_id, check_existence=False)
|
||||
if self._mqtt_lazy_init() and entity_id not in self._mqtt['entities']:
|
||||
node_id = self._mqtt['node_id']
|
||||
obj = self._sanitize_for_topic(entity_id.split('.')[-1])
|
||||
state_topic = f"appdaemon/{node_id}/{obj}/state"
|
||||
availability_topic = f"appdaemon/{node_id}/{obj}/availability"
|
||||
config = {
|
||||
'name': name or entity_id,
|
||||
'unique_id': f"{node_id}_{obj}",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.state }}',
|
||||
'availability_topic': availability_topic,
|
||||
'payload_available': 'online',
|
||||
'payload_not_available': 'offline',
|
||||
'device': self._mqtt['device'],
|
||||
}
|
||||
if icon: config['icon'] = icon
|
||||
if unit_of_measurement: config['unit_of_measurement'] = unit_of_measurement
|
||||
if device_class: config['device_class'] = device_class
|
||||
if state_class: config['state_class'] = state_class
|
||||
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')
|
||||
if attributes is not None:
|
||||
attributes = dict(attributes)
|
||||
else:
|
||||
attributes = dict()
|
||||
|
||||
attributes['ad_app'] = self.name
|
||||
|
||||
if friendly_name is not None: attributes['friendly_name'] = friendly_name
|
||||
if icon is not None: attributes['icon'] = icon
|
||||
if unit_of_measurement is not None: attributes['unit_of_measurement'] = unit_of_measurement
|
||||
if device_class is not None: attributes['device_class'] = device_class
|
||||
if state_class is not None: attributes['state_class'] = state_class
|
||||
|
||||
if state is not None:
|
||||
entity.set_state(state=state, attributes=attributes)
|
||||
else:
|
||||
if not entity.exists():
|
||||
entity.set_state(state='unknown')
|
||||
entity.set_state(attributes=attributes)
|
||||
|
||||
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
|
||||
# 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):
|
||||
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):
|
||||
self.log_info(f"Declaring constants {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):
|
||||
try:
|
||||
#self.depends_on_module(["smartobject","virtualsensors","logger_interface"])
|
||||
|
||||
self.initialize_logger_interface(self.get_ad_api())
|
||||
self.log_info(f"Name = {self.name}")
|
||||
# mute: true → suppress all log output for this app
|
||||
try: self.mute_logger(self.args['mute'])
|
||||
except KeyError: pass
|
||||
self.event_dispatchers = dict()
|
||||
self.constants = dict()
|
||||
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:
|
||||
self.entity_id = self.args["entity"]
|
||||
self.entity = self.get_entity(self.entity_id)
|
||||
@@ -142,9 +205,11 @@ class SmartObject(hass.Hass,LoggerInterface):
|
||||
self.entity_id = None
|
||||
self.entity = None
|
||||
|
||||
# constants: {key: value, ...} → add to expression context
|
||||
if 'constants' in self.args:
|
||||
self.add_constants(self.args['constants'])
|
||||
|
||||
# templates_library: <app_name> → load shared template provider
|
||||
self.templates_library = None
|
||||
if 'templates_library' in self.args:
|
||||
library_name = self.args['templates_library']
|
||||
@@ -155,9 +220,13 @@ class SmartObject(hass.Hass,LoggerInterface):
|
||||
else:
|
||||
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:
|
||||
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:
|
||||
self.attribute_sensors = 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.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: os.makedirs(self.get_dataset_folder())
|
||||
except FileExistsError: pass
|
||||
@@ -189,12 +259,10 @@ class SmartObject(hass.Hass,LoggerInterface):
|
||||
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):
|
||||
self.event_dispatchers = None
|
||||
self.virtual_sensors = None
|
||||
self._mqtt_terminate()
|
||||
try: has_dataset = self.dataset != None
|
||||
except AttributeError: has_dataset = False
|
||||
if has_dataset:
|
||||
|
||||
30
virtualevents.py
Normal file
30
virtualevents.py
Normal 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'])
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from logger_interface import LoggerInterface
|
||||
|
||||
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.logger_interface = logger_interface
|
||||
|
||||
@@ -32,6 +32,9 @@ class VirtualSensorBase:
|
||||
try: self.attributes = self.yaml_block['attributes']
|
||||
except (TypeError,KeyError): self.attributes = dict()
|
||||
|
||||
if app_name:
|
||||
self.attributes['ad_app'] = app_name
|
||||
|
||||
if constants:
|
||||
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
|
||||
@@ -554,7 +557,7 @@ class ValueSensor(VirtualSensorBase):
|
||||
|
||||
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 yaml_block
|
||||
assert logger_interface
|
||||
@@ -572,20 +575,20 @@ class VirtualSensors():
|
||||
|
||||
averagers = parser.parse_args('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',{})
|
||||
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',{})
|
||||
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.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',{})
|
||||
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',{})
|
||||
for sensor in sensors:
|
||||
@@ -594,22 +597,20 @@ class VirtualSensors():
|
||||
self.logger_interface.log_error(f"Invalid sensor name {splitted_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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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:
|
||||
self.logger_interface.log_error(f"Invalid sensor prefix {splitted_sensor[0]}")
|
||||
|
||||
parser.validate_args(args_to_ignore_in_validation)
|
||||
|
||||
dependencies_graph = dict()
|
||||
for sensor_name, virtual_sensor in self.virtual_sensors.items():
|
||||
try:
|
||||
@@ -672,4 +673,4 @@ class VirtualSensors():
|
||||
class VirtualSensorsApp(hass.Hass):
|
||||
def initialize(self):
|
||||
self.logger_interface = LoggerInterface(self.get_ad_api(),default_log = "virtualsensors_log")
|
||||
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'])
|
||||
self.virtual_sensors = VirtualSensors(ad_api = self.get_ad_api(),logger_interface = self.logger_interface,yaml_block = self.args,args_to_ignore_in_validation = ['module','class','global_dependencies','priority','name','config_path'],app_name = self.name)
|
||||
2
vscode-appdaemon/.gitignore
vendored
Normal file
2
vscode-appdaemon/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
out/
|
||||
node_modules/
|
||||
4
vscode-appdaemon/.vscodeignore
Normal file
4
vscode-appdaemon/.vscodeignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
src/
|
||||
tsconfig.json
|
||||
.gitignore
|
||||
58
vscode-appdaemon/package-lock.json
generated
Normal file
58
vscode-appdaemon/package-lock.json
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "vscode-appdaemon",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vscode-appdaemon",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/vscode": {
|
||||
"version": "1.116.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.116.0.tgz",
|
||||
"integrity": "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
116
vscode-appdaemon/package.json
Normal file
116
vscode-appdaemon/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "vscode-appdaemon",
|
||||
"displayName": "AppDaemon for Home Assistant",
|
||||
"description": "AppDaemon development tools: status bar controls, entity autocompletion, hover tooltips, error viewer",
|
||||
"version": "0.1.0",
|
||||
"publisher": "ad-toolbox",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"configuration": {
|
||||
"title": "AppDaemon",
|
||||
"properties": {
|
||||
"appdaemon.haUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8123",
|
||||
"description": "Home Assistant URL"
|
||||
},
|
||||
"appdaemon.haToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Home Assistant Long-Lived Access Token"
|
||||
},
|
||||
"appdaemon.adUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:5050",
|
||||
"description": "AppDaemon API URL (http section in appdaemon.yaml)"
|
||||
},
|
||||
"appdaemon.errorLogPath": {
|
||||
"type": "string",
|
||||
"default": "logs/error.log",
|
||||
"description": "Path to AppDaemon error log (relative to workspace root)"
|
||||
},
|
||||
"appdaemon.mainLogPath": {
|
||||
"type": "string",
|
||||
"default": "logs/appdaemon.log",
|
||||
"description": "Path to AppDaemon main log (relative to workspace root)"
|
||||
},
|
||||
"appdaemon.entityRefreshInterval": {
|
||||
"type": "number",
|
||||
"default": 300,
|
||||
"description": "Entity list refresh interval in seconds"
|
||||
}
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "appdaemon.goToApp",
|
||||
"title": "AppDaemon: Go to App",
|
||||
"icon": "$(symbol-class)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.restartCurrentFileApps",
|
||||
"title": "AppDaemon: Restart Apps in Current File",
|
||||
"icon": "$(debug-restart)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.restartAD",
|
||||
"title": "AppDaemon: Reload Apps",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.restartHA",
|
||||
"title": "AppDaemon: Restart Home Assistant",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.toggleProductionMode",
|
||||
"title": "AppDaemon: Toggle Production Mode",
|
||||
"icon": "$(eye)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.showErrors",
|
||||
"title": "AppDaemon: Show Error Log",
|
||||
"icon": "$(warning)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.refreshEntities",
|
||||
"title": "AppDaemon: Refresh Entity List",
|
||||
"icon": "$(sync)"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.handleErrors",
|
||||
"title": "AppDaemon: Handle Errors"
|
||||
},
|
||||
{
|
||||
"command": "appdaemon.clearErrors",
|
||||
"title": "AppDaemon: Clear Error Diagnostics"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "appdaemon.goToApp",
|
||||
"key": "ctrl+shift+a",
|
||||
"mac": "cmd+shift+a"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run compile",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
160
vscode-appdaemon/src/adClient.ts
Normal file
160
vscode-appdaemon/src/adClient.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
/** Ensure URL has http(s):// prefix and no trailing slash */
|
||||
function normalizeUrl(raw: string): string {
|
||||
let url = raw.trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export class ADClient {
|
||||
private productionMode = false;
|
||||
private _onProductionModeChanged = new vscode.EventEmitter<boolean>();
|
||||
readonly onProductionModeChanged = this._onProductionModeChanged.event;
|
||||
private log: vscode.OutputChannel;
|
||||
|
||||
constructor(log: vscode.OutputChannel) {
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
private getConfig() {
|
||||
const config = vscode.workspace.getConfiguration('appdaemon');
|
||||
return {
|
||||
url: normalizeUrl(config.get<string>('adUrl', 'http://localhost:5050'))
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const { url } = this.getConfig();
|
||||
const fullUrl = new URL(path, url);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
this.log.appendLine(`[AD] ${method} ${fullUrl.href}`);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const options: http.RequestOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: parseInt(fullUrl.port, 10) || (isHttps ? 443 : 5050),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: Buffer) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
this.log.appendLine(`[AD] ${res.statusCode} (${data.length} bytes)`);
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(data) as T);
|
||||
} catch {
|
||||
resolve(data as unknown as T);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
this.log.appendLine(`[AD] ERROR: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a specific list of AppDaemon apps by name.
|
||||
*/
|
||||
async restartApps(appNames: string[]): Promise<void> {
|
||||
this.log.appendLine(`[AD] Restarting specific apps: ${appNames.join(', ')}`);
|
||||
await Promise.all(appNames.map(app =>
|
||||
this.request('POST', '/api/appdaemon/service/admin/app/restart', { app })
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload all AppDaemon apps by fetching the app list then restarting each one.
|
||||
*/
|
||||
async reloadApps(): Promise<void> {
|
||||
// Fetch app names from admin state
|
||||
type AdminState = { state: Record<string, unknown> };
|
||||
const resp = await this.request<AdminState>('GET', '/api/appdaemon/state/admin');
|
||||
const stateMap = resp?.state ?? (resp as unknown as Record<string, unknown>);
|
||||
const appNames = Object.keys(stateMap)
|
||||
.filter(k => k.startsWith('app.'))
|
||||
.map(k => k.slice(4)); // strip "app." prefix
|
||||
|
||||
if (appNames.length === 0) {
|
||||
throw new Error('No apps found in AppDaemon state');
|
||||
}
|
||||
|
||||
this.log.appendLine(`[AD] Restarting ${appNames.length} apps: ${appNames.join(', ')}`);
|
||||
|
||||
await Promise.all(appNames.map(app =>
|
||||
this.request('POST', '/api/appdaemon/service/admin/app/restart', { app })
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set AppDaemon production mode on/off via the admin API.
|
||||
*/
|
||||
async setProductionMode(mode: boolean): Promise<void> {
|
||||
await this.request('POST', '/api/appdaemon/service/admin/production_mode/set', { mode });
|
||||
this.productionMode = mode;
|
||||
this._onProductionModeChanged.fire(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to read the current production mode from AppDaemon state.
|
||||
*/
|
||||
async fetchProductionMode(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await this.request<{ state: Record<string, { state?: unknown }> }>('GET', '/api/appdaemon/state/admin');
|
||||
const state = resp?.state ?? resp as unknown as Record<string, { state?: unknown }>;
|
||||
if (state && typeof state === 'object') {
|
||||
for (const key of Object.keys(state)) {
|
||||
if (key.includes('production_mode')) {
|
||||
const val = (state[key] as { state?: unknown })?.state;
|
||||
this.productionMode = val === true || val === 'True' || val === 'true';
|
||||
this._onProductionModeChanged.fire(this.productionMode);
|
||||
return this.productionMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to cached value
|
||||
}
|
||||
return this.productionMode;
|
||||
}
|
||||
|
||||
isProductionMode(): boolean {
|
||||
return this.productionMode;
|
||||
}
|
||||
|
||||
async toggleProductionMode(): Promise<void> {
|
||||
await this.setProductionMode(!this.productionMode);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._onProductionModeChanged.dispose();
|
||||
}
|
||||
}
|
||||
231
vscode-appdaemon/src/entityProvider.ts
Normal file
231
vscode-appdaemon/src/entityProvider.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { HAClient, HAEntity } from './haClient';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Completion Provider
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class EntityCompletionProvider implements vscode.CompletionItemProvider {
|
||||
private cachedDomains: Set<string> = new Set();
|
||||
|
||||
constructor(private haClient: HAClient) {
|
||||
this.rebuildDomainCache();
|
||||
haClient.onEntitiesUpdated(() => this.rebuildDomainCache());
|
||||
}
|
||||
|
||||
private rebuildDomainCache() {
|
||||
this.cachedDomains = new Set(this.haClient.getDomains());
|
||||
}
|
||||
|
||||
provideCompletionItems(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
_token: vscode.CancellationToken,
|
||||
context: vscode.CompletionContext
|
||||
): vscode.CompletionList | undefined {
|
||||
const entities = this.haClient.getEntities();
|
||||
if (entities.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lineText = document.lineAt(position).text;
|
||||
const textBefore = lineText.substring(0, position.character);
|
||||
|
||||
// ── Case 1: "domain.partial" — user typed a dot after a domain name ──
|
||||
const dotMatch = textBefore.match(/\b([a-z][a-z_]*)\.([a-z0-9_]*)$/);
|
||||
if (dotMatch) {
|
||||
const domain = dotMatch[1];
|
||||
const partial = dotMatch[2];
|
||||
if (this.cachedDomains.has(domain)) {
|
||||
// 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) {
|
||||
return new vscode.CompletionList(items, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 2: Trigger was "." but domain is unknown — skip ─────────────
|
||||
if (context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter &&
|
||||
context.triggerCharacter === '.') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Case 3: YAML value context — broad entity search ─────────────────
|
||||
if (document.languageId === 'yaml') {
|
||||
const valueMatch = textBefore.match(/(?::\s+|:\s*['"]|[-]\s+|[-]\s*['"])([a-z][a-z0-9_.]*)?$/);
|
||||
if (valueMatch) {
|
||||
const partial = valueMatch[1] || '';
|
||||
return new vscode.CompletionList(
|
||||
this.buildAllItems(entities, partial),
|
||||
partial.length < 3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 4: Python string context — inside quotes ────────────────────
|
||||
if (document.languageId === 'python') {
|
||||
const stringMatch = textBefore.match(/['"]([a-z][a-z0-9_.]*)$/);
|
||||
if (stringMatch) {
|
||||
const partial = stringMatch[1];
|
||||
return new vscode.CompletionList(
|
||||
this.buildAllItems(entities, partial),
|
||||
partial.length < 3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private buildDomainItems(
|
||||
entities: HAEntity[],
|
||||
domain: string,
|
||||
partial: string,
|
||||
replaceRange: vscode.Range
|
||||
): vscode.CompletionItem[] {
|
||||
const items: vscode.CompletionItem[] = [];
|
||||
const prefix = `${domain}.`;
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity.entity_id.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (partial && !entity.entity_id.substring(prefix.length).includes(partial)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = new vscode.CompletionItem(
|
||||
entity.entity_id,
|
||||
vscode.CompletionItemKind.Value
|
||||
);
|
||||
|
||||
const friendly = entity.attributes?.friendly_name as string | undefined;
|
||||
item.detail = friendly
|
||||
? `${friendly} — ${entity.state}`
|
||||
: entity.state;
|
||||
item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity));
|
||||
item.filterText = entity.entity_id;
|
||||
item.sortText = entity.entity_id;
|
||||
item.range = replaceRange;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private buildAllItems(entities: HAEntity[], partial: string): vscode.CompletionItem[] {
|
||||
const items: vscode.CompletionItem[] = [];
|
||||
const lowerPartial = partial.toLowerCase();
|
||||
|
||||
for (const entity of entities) {
|
||||
if (lowerPartial && !entity.entity_id.includes(lowerPartial)) {
|
||||
const friendly = (entity.attributes?.friendly_name as string || '').toLowerCase();
|
||||
if (!friendly.includes(lowerPartial)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const item = new vscode.CompletionItem(
|
||||
entity.entity_id,
|
||||
vscode.CompletionItemKind.Value
|
||||
);
|
||||
|
||||
const friendly = entity.attributes?.friendly_name as string | undefined;
|
||||
item.detail = friendly
|
||||
? `${friendly} — ${entity.state}`
|
||||
: entity.state;
|
||||
item.documentation = new vscode.MarkdownString(formatEntityMarkdown(entity));
|
||||
item.filterText = entity.entity_id;
|
||||
item.sortText = entity.entity_id;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Hover Provider
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENTITY_ID_PATTERN = /[a-z][a-z_]*\.[a-z0-9][a-z0-9_]*/;
|
||||
|
||||
export class EntityHoverProvider implements vscode.HoverProvider {
|
||||
constructor(private haClient: HAClient) {}
|
||||
|
||||
provideHover(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): vscode.Hover | undefined {
|
||||
const range = document.getWordRangeAtPosition(position, ENTITY_ID_PATTERN);
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const word = document.getText(range);
|
||||
const entity = this.haClient.getEntity(word);
|
||||
if (!entity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const md = new vscode.MarkdownString();
|
||||
md.supportHtml = true;
|
||||
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']);
|
||||
const attrs = Object.entries(entity.attributes)
|
||||
.filter(([k]) => !skip.has(k))
|
||||
.slice(0, 12);
|
||||
|
||||
for (const [key, value] of attrs) {
|
||||
const display = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
const truncated = display.length > 60 ? display.substring(0, 57) + '...' : display;
|
||||
const safeKey = key.replace(/\|/g, '\\|');
|
||||
const safeVal = truncated.replace(/\|/g, '\\|');
|
||||
md.appendMarkdown(`| ${safeKey} | \`${safeVal}\` |\n`);
|
||||
}
|
||||
}
|
||||
|
||||
md.appendMarkdown(`\n*Last changed: ${entity.last_changed}*`);
|
||||
|
||||
return new vscode.Hover(md, range);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatEntityMarkdown(entity: HAEntity): string {
|
||||
const lines: string[] = [];
|
||||
const friendly = entity.attributes?.friendly_name as string | undefined;
|
||||
lines.push(`**${friendly || entity.entity_id}**\n`);
|
||||
lines.push(`- **State:** \`${entity.state}\``);
|
||||
lines.push(`- **Entity ID:** \`${entity.entity_id}\``);
|
||||
|
||||
if (entity.attributes) {
|
||||
const skip = new Set(['friendly_name', 'icon', 'entity_picture']);
|
||||
const attrs = Object.entries(entity.attributes)
|
||||
.filter(([k]) => !skip.has(k))
|
||||
.slice(0, 8);
|
||||
|
||||
if (attrs.length > 0) {
|
||||
lines.push('\n**Attributes:**');
|
||||
for (const [key, value] of attrs) {
|
||||
const val = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
lines.push(`- ${key}: \`${val}\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`\n*Last changed: ${entity.last_changed}*`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
188
vscode-appdaemon/src/errorViewer.ts
Normal file
188
vscode-appdaemon/src/errorViewer.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Watches the AppDaemon error log, streams new entries to an Output Channel,
|
||||
* and creates VS Code Diagnostics for lines that reference source files.
|
||||
*/
|
||||
export class ErrorViewer {
|
||||
private outputChannel: vscode.OutputChannel;
|
||||
private diagnosticCollection: vscode.DiagnosticCollection;
|
||||
private watcher: fs.FSWatcher | undefined;
|
||||
private lastSize = 0;
|
||||
private errorCount = 0;
|
||||
|
||||
private _onErrorCountChanged = new vscode.EventEmitter<number>();
|
||||
readonly onErrorCountChanged = this._onErrorCountChanged.event;
|
||||
|
||||
constructor() {
|
||||
this.outputChannel = vscode.window.createOutputChannel('AppDaemon Errors');
|
||||
this.diagnosticCollection = vscode.languages.createDiagnosticCollection('appdaemon');
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────────────
|
||||
|
||||
show() {
|
||||
this.outputChannel.show(true);
|
||||
}
|
||||
|
||||
clearDiagnostics() {
|
||||
this.diagnosticCollection.clear();
|
||||
this.errorCount = 0;
|
||||
this._onErrorCountChanged.fire(0);
|
||||
}
|
||||
|
||||
getErrorCount(): number {
|
||||
return this.errorCount;
|
||||
}
|
||||
|
||||
// ── file watching ────────────────────────────────────────────────────────
|
||||
|
||||
private getLogPath(): string | undefined {
|
||||
const config = vscode.workspace.getConfiguration('appdaemon');
|
||||
const relative = config.get<string>('errorLogPath', 'logs/error.log');
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders) {
|
||||
return undefined;
|
||||
}
|
||||
return path.join(folders[0].uri.fsPath, relative);
|
||||
}
|
||||
|
||||
private startWatching() {
|
||||
const logPath = this.getLogPath();
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do an initial read
|
||||
this.readNewContent(logPath);
|
||||
|
||||
// Watch for changes with fs.watchFile (robust for log files that get appended)
|
||||
try {
|
||||
fs.watchFile(logPath, { interval: 2000 }, () => {
|
||||
this.readNewContent(logPath);
|
||||
});
|
||||
this.watcher = {} as fs.FSWatcher; // sentinel so we know to unwatchFile
|
||||
} catch {
|
||||
// File may not exist yet; retry later via timer
|
||||
const retryInterval = setInterval(() => {
|
||||
if (fs.existsSync(logPath)) {
|
||||
clearInterval(retryInterval);
|
||||
this.startWatching();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
private readNewContent(logPath: string) {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(logPath);
|
||||
const currentSize = stat.size;
|
||||
|
||||
// Log rotation / truncation
|
||||
if (currentSize < this.lastSize) {
|
||||
this.lastSize = 0;
|
||||
this.outputChannel.clear();
|
||||
this.clearDiagnostics();
|
||||
}
|
||||
|
||||
if (currentSize <= this.lastSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = fs.openSync(logPath, 'r');
|
||||
try {
|
||||
const bytesToRead = currentSize - this.lastSize;
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
fs.readSync(fd, buffer, 0, bytesToRead, this.lastSize);
|
||||
this.lastSize = currentSize;
|
||||
|
||||
const newContent = buffer.toString('utf-8');
|
||||
const newLines = newContent.split('\n');
|
||||
|
||||
for (const line of newLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.outputChannel.appendLine(trimmed);
|
||||
|
||||
if (/\b(ERROR|CRITICAL|WARNING)\b/.test(trimmed)) {
|
||||
this.errorCount++;
|
||||
this.parseDiagnostic(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
this._onErrorCountChanged.fire(this.errorCount);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore transient read errors
|
||||
}
|
||||
}
|
||||
|
||||
// ── diagnostic extraction ────────────────────────────────────────────────
|
||||
|
||||
private parseDiagnostic(line: string) {
|
||||
// Match Python tracebacks: File "/path/to/file.py", line 42
|
||||
const fileMatch = line.match(/File "([^"]+)", line (\d+)/);
|
||||
if (!fileMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = fileMatch[1];
|
||||
const lineNumber = Math.max(0, parseInt(fileMatch[2], 10) - 1);
|
||||
|
||||
// Only create diagnostics for files inside the workspace
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders) {
|
||||
return;
|
||||
}
|
||||
const inWorkspace = folders.some(f => filePath.startsWith(f.uri.fsPath));
|
||||
if (!inWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(filePath);
|
||||
const existing = [...(this.diagnosticCollection.get(uri) || [])];
|
||||
|
||||
const severity = line.includes('ERROR') || line.includes('CRITICAL')
|
||||
? vscode.DiagnosticSeverity.Error
|
||||
: vscode.DiagnosticSeverity.Warning;
|
||||
|
||||
// Extract a readable message
|
||||
const msgMatch = line.match(/(?:ERROR|CRITICAL|WARNING)\s+(.+)/);
|
||||
const message = msgMatch ? msgMatch[1] : line;
|
||||
|
||||
const range = new vscode.Range(lineNumber, 0, lineNumber, 200);
|
||||
const diagnostic = new vscode.Diagnostic(range, message, severity);
|
||||
diagnostic.source = 'AppDaemon';
|
||||
|
||||
existing.push(diagnostic);
|
||||
this.diagnosticCollection.set(uri, existing);
|
||||
} catch {
|
||||
// Ignore invalid URIs
|
||||
}
|
||||
}
|
||||
|
||||
// ── cleanup ──────────────────────────────────────────────────────────────
|
||||
|
||||
dispose() {
|
||||
const logPath = this.getLogPath();
|
||||
if (logPath) {
|
||||
try { fs.unwatchFile(logPath); } catch { /* noop */ }
|
||||
}
|
||||
this.outputChannel.dispose();
|
||||
this.diagnosticCollection.dispose();
|
||||
this._onErrorCountChanged.dispose();
|
||||
}
|
||||
}
|
||||
326
vscode-appdaemon/src/extension.ts
Normal file
326
vscode-appdaemon/src/extension.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { HAClient } from './haClient';
|
||||
import { ADClient } from './adClient';
|
||||
import { StatusBarManager } from './statusBar';
|
||||
import { EntityCompletionProvider, EntityHoverProvider } from './entityProvider';
|
||||
import { ErrorViewer } from './errorViewer';
|
||||
|
||||
let haClient: HAClient;
|
||||
let adClient: ADClient;
|
||||
let statusBar: StatusBarManager;
|
||||
let errorViewer: ErrorViewer;
|
||||
let outputChannel: vscode.OutputChannel;
|
||||
|
||||
// Non-app top-level YAML keys to ignore
|
||||
const NON_APP_KEYS = new Set([
|
||||
'global_modules', 'global_dependencies', 'secrets',
|
||||
'appdaemon', 'http', 'hadashboard', 'admin', 'api', 'plugins'
|
||||
]);
|
||||
|
||||
// ── App parsing helpers ───────────────────────────────────────────────────────
|
||||
|
||||
type AppEntry = { appName: string; line: number; moduleName: string };
|
||||
|
||||
function parseAppEntries(text: string): AppEntry[] {
|
||||
const lines = text.split('\n');
|
||||
const entries: AppEntry[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^([a-z][a-z0-9_]*):\s*$/);
|
||||
if (!m) { continue; }
|
||||
const key = m[1];
|
||||
if (NON_APP_KEYS.has(key)) { continue; }
|
||||
let moduleName = '';
|
||||
for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) {
|
||||
if (lines[j].match(/^\S/)) { 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 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) {
|
||||
if (!editor) { statusBar.updateContextualApps([]); return; }
|
||||
const apps = parseAppsFromDocument(editor.document);
|
||||
statusBar.updateContextualApps(apps);
|
||||
}
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
// ── Output channel for debug ─────────────────────────────────────────────
|
||||
outputChannel = vscode.window.createOutputChannel('AppDaemon');
|
||||
outputChannel.appendLine('AppDaemon extension activating...');
|
||||
|
||||
const config = vscode.workspace.getConfiguration('appdaemon');
|
||||
outputChannel.appendLine(` haUrl = ${config.get('haUrl')}`);
|
||||
outputChannel.appendLine(` haToken = ${config.get<string>('haToken', '') ? '***set***' : '*** NOT SET ***'}`);
|
||||
outputChannel.appendLine(` adUrl = ${config.get('adUrl')}`);
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────────────────────
|
||||
haClient = new HAClient(outputChannel);
|
||||
adClient = new ADClient(outputChannel);
|
||||
errorViewer = new ErrorViewer();
|
||||
statusBar = new StatusBarManager(adClient);
|
||||
|
||||
// Wire error count → status bar badge
|
||||
errorViewer.onErrorCountChanged((count) => statusBar.setErrorCount(count));
|
||||
|
||||
// ── Contextual per-file restart button ───────────────────────────────────
|
||||
updateContextualButton(vscode.window.activeTextEditor);
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(updateContextualButton),
|
||||
vscode.workspace.onDidSaveTextDocument(doc => {
|
||||
if (vscode.window.activeTextEditor?.document === doc) {
|
||||
updateContextualButton(vscode.window.activeTextEditor);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Initial entity load (non-blocking)
|
||||
haClient.fetchEntities().then(entities => {
|
||||
if (entities.length > 0) {
|
||||
outputChannel.appendLine(`Ready: ${entities.length} entities loaded`);
|
||||
} else {
|
||||
outputChannel.appendLine('Warning: 0 entities loaded — check haUrl and haToken settings');
|
||||
}
|
||||
});
|
||||
|
||||
// Try to read current production mode from AD
|
||||
adClient.fetchProductionMode().catch(() => { /* ignore on startup */ });
|
||||
|
||||
// ── Language features (YAML + Python) ────────────────────────────────────
|
||||
const selector: vscode.DocumentSelector = [
|
||||
{ scheme: 'file', language: 'yaml' },
|
||||
{ scheme: 'file', language: 'python' }
|
||||
];
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCompletionItemProvider(
|
||||
selector,
|
||||
new EntityCompletionProvider(haClient),
|
||||
'.' // trigger completion when user types a dot
|
||||
),
|
||||
vscode.languages.registerHoverProvider(
|
||||
selector,
|
||||
new EntityHoverProvider(haClient)
|
||||
),
|
||||
vscode.languages.registerDocumentSymbolProvider(
|
||||
{ scheme: 'file', language: 'yaml' },
|
||||
new ADDocumentSymbolProvider()
|
||||
),
|
||||
vscode.languages.registerWorkspaceSymbolProvider(
|
||||
new ADWorkspaceSymbolProvider()
|
||||
)
|
||||
);
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────
|
||||
|
||||
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 () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('AppDaemon: No active file');
|
||||
return;
|
||||
}
|
||||
const apps = parseAppsFromDocument(editor.document);
|
||||
if (apps.length === 0) {
|
||||
vscode.window.showWarningMessage('AppDaemon: No app definitions found in this file');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adClient.restartApps(apps);
|
||||
vscode.window.showInformationMessage(`AppDaemon: Restarted ${apps.join(', ')}`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
vscode.window.showErrorMessage(`AppDaemon: Restart failed — ${msg}`);
|
||||
}
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('appdaemon.restartAD', async () => {
|
||||
try {
|
||||
await adClient.reloadApps();
|
||||
vscode.window.showInformationMessage('AppDaemon: Apps reloaded');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
vscode.window.showErrorMessage(`AppDaemon: Reload failed — ${msg}`);
|
||||
}
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('appdaemon.restartHA', async () => {
|
||||
const answer = await vscode.window.showWarningMessage(
|
||||
'Restart Home Assistant?',
|
||||
{ modal: true },
|
||||
'Restart HA',
|
||||
'Restart Host'
|
||||
);
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (answer === 'Restart Host') {
|
||||
await haClient.restartHost();
|
||||
vscode.window.showInformationMessage('Home Assistant: Host restart initiated');
|
||||
} else {
|
||||
await haClient.restartHA();
|
||||
vscode.window.showInformationMessage('Home Assistant: Restart initiated');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
vscode.window.showErrorMessage(`Home Assistant: Restart failed — ${msg}`);
|
||||
}
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('appdaemon.toggleProductionMode', async () => {
|
||||
try {
|
||||
await adClient.toggleProductionMode();
|
||||
const mode = adClient.isProductionMode() ? 'ON' : 'OFF';
|
||||
vscode.window.showInformationMessage(`AppDaemon: Production mode ${mode}`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
vscode.window.showErrorMessage(`AppDaemon: Toggle failed — ${msg}`);
|
||||
}
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('appdaemon.showErrors', () => {
|
||||
errorViewer.show();
|
||||
}),
|
||||
vscode.commands.registerCommand('appdaemon.handleErrors', async () => {
|
||||
const pick = await vscode.window.showErrorMessage(
|
||||
'AppDaemon errors detected',
|
||||
'Show log',
|
||||
'Clear'
|
||||
);
|
||||
if (pick === 'Show log') {
|
||||
errorViewer.show();
|
||||
} else if (pick === 'Clear') {
|
||||
errorViewer.clearDiagnostics();
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('appdaemon.refreshEntities', async () => {
|
||||
const entities = await haClient.fetchEntities();
|
||||
vscode.window.showInformationMessage(`AppDaemon: ${entities.length} entities loaded`);
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('appdaemon.clearErrors', () => {
|
||||
errorViewer.clearDiagnostics();
|
||||
vscode.window.showInformationMessage('AppDaemon: Error diagnostics cleared');
|
||||
})
|
||||
);
|
||||
|
||||
// ── Disposables ──────────────────────────────────────────────────────────
|
||||
context.subscriptions.push(
|
||||
outputChannel,
|
||||
{ dispose: () => haClient.dispose() },
|
||||
{ dispose: () => adClient.dispose() },
|
||||
{ dispose: () => statusBar.dispose() },
|
||||
{ dispose: () => errorViewer.dispose() }
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
haClient?.dispose();
|
||||
adClient?.dispose();
|
||||
statusBar?.dispose();
|
||||
errorViewer?.dispose();
|
||||
}
|
||||
162
vscode-appdaemon/src/haClient.ts
Normal file
162
vscode-appdaemon/src/haClient.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
export interface HAEntity {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
attributes: Record<string, unknown>;
|
||||
last_changed: string;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
/** Ensure URL has http(s):// prefix and no trailing slash */
|
||||
function normalizeUrl(raw: string): string {
|
||||
let url = raw.trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export class HAClient {
|
||||
private entities: HAEntity[] = [];
|
||||
private entityMap: Map<string, HAEntity> = new Map();
|
||||
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private _onEntitiesUpdated = new vscode.EventEmitter<HAEntity[]>();
|
||||
readonly onEntitiesUpdated = this._onEntitiesUpdated.event;
|
||||
private log: vscode.OutputChannel;
|
||||
|
||||
constructor(log: vscode.OutputChannel) {
|
||||
this.log = log;
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
private getConfig() {
|
||||
const config = vscode.workspace.getConfiguration('appdaemon');
|
||||
return {
|
||||
url: normalizeUrl(config.get<string>('haUrl', 'http://localhost:8123')),
|
||||
token: config.get<string>('haToken', ''),
|
||||
refreshInterval: config.get<number>('entityRefreshInterval', 300)
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const { url, token } = this.getConfig();
|
||||
if (!token) {
|
||||
throw new Error('HA token not configured — set appdaemon.haToken in settings');
|
||||
}
|
||||
|
||||
const fullUrl = new URL(path, url);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
this.log.appendLine(`[HA] ${method} ${fullUrl.href}`);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const options: http.RequestOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: parseInt(fullUrl.port, 10) || (isHttps ? 443 : 8123),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: Buffer) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
this.log.appendLine(`[HA] ${res.statusCode} (${data.length} bytes)`);
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(data) as T);
|
||||
} catch {
|
||||
resolve(data as unknown as T);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
this.log.appendLine(`[HA] ERROR: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
req.setTimeout(15000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async fetchEntities(): Promise<HAEntity[]> {
|
||||
try {
|
||||
this.entities = await this.request<HAEntity[]>('GET', '/api/states');
|
||||
this.entityMap.clear();
|
||||
for (const entity of this.entities) {
|
||||
this.entityMap.set(entity.entity_id, entity);
|
||||
}
|
||||
this.log.appendLine(`[HA] Loaded ${this.entities.length} entities (${this.getDomains().length} domains)`);
|
||||
this._onEntitiesUpdated.fire(this.entities);
|
||||
return this.entities;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.log.appendLine(`[HA] fetchEntities FAILED: ${msg}`);
|
||||
vscode.window.showWarningMessage(`AppDaemon: Failed to fetch entities — ${msg}`);
|
||||
return this.entities;
|
||||
}
|
||||
}
|
||||
|
||||
getEntities(): HAEntity[] {
|
||||
return this.entities;
|
||||
}
|
||||
|
||||
getEntity(entityId: string): HAEntity | undefined {
|
||||
return this.entityMap.get(entityId);
|
||||
}
|
||||
|
||||
getDomains(): string[] {
|
||||
const domains = new Set<string>();
|
||||
for (const entity of this.entities) {
|
||||
const dot = entity.entity_id.indexOf('.');
|
||||
if (dot > 0) {
|
||||
domains.add(entity.entity_id.substring(0, dot));
|
||||
}
|
||||
}
|
||||
return Array.from(domains).sort();
|
||||
}
|
||||
|
||||
async restartHA(): Promise<void> {
|
||||
await this.request('POST', '/api/services/homeassistant/restart');
|
||||
}
|
||||
|
||||
async restartHost(): Promise<void> {
|
||||
await this.request('POST', '/api/services/homeassistant/restart_host');
|
||||
}
|
||||
|
||||
private startAutoRefresh() {
|
||||
const { refreshInterval } = this.getConfig();
|
||||
if (refreshInterval > 0) {
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.fetchEntities();
|
||||
}, refreshInterval * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
this._onEntitiesUpdated.dispose();
|
||||
}
|
||||
}
|
||||
98
vscode-appdaemon/src/statusBar.ts
Normal file
98
vscode-appdaemon/src/statusBar.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ADClient } from './adClient';
|
||||
|
||||
export class StatusBarManager {
|
||||
private restartADItem: vscode.StatusBarItem;
|
||||
private restartHAItem: vscode.StatusBarItem;
|
||||
private productionModeItem: vscode.StatusBarItem;
|
||||
private errorItem: vscode.StatusBarItem;
|
||||
private contextualItem: vscode.StatusBarItem;
|
||||
|
||||
constructor(private adClient: ADClient) {
|
||||
// Reload AppDaemon apps
|
||||
this.restartADItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
this.restartADItem.command = 'appdaemon.restartAD';
|
||||
this.restartADItem.text = '$(refresh) AD Apps';
|
||||
this.restartADItem.tooltip = 'Reload all AppDaemon Apps';
|
||||
this.restartADItem.show();
|
||||
|
||||
// Restart Home Assistant
|
||||
this.restartHAItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 99);
|
||||
this.restartHAItem.command = 'appdaemon.restartHA';
|
||||
this.restartHAItem.text = '$(refresh) HA';
|
||||
this.restartHAItem.tooltip = 'Restart Home Assistant';
|
||||
this.restartHAItem.show();
|
||||
|
||||
// Production mode toggle
|
||||
this.productionModeItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 98);
|
||||
this.productionModeItem.command = 'appdaemon.toggleProductionMode';
|
||||
this.updateProductionModeDisplay();
|
||||
this.productionModeItem.show();
|
||||
|
||||
// Error log shortcut
|
||||
this.errorItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 97);
|
||||
this.errorItem.command = 'appdaemon.showErrors';
|
||||
this.errorItem.text = '$(check) AD Errors';
|
||||
this.errorItem.tooltip = 'Show AppDaemon Error Log';
|
||||
this.errorItem.show();
|
||||
|
||||
// Contextual per-file restart button (hidden by default)
|
||||
this.contextualItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 101);
|
||||
this.contextualItem.command = 'appdaemon.restartCurrentFileApps';
|
||||
|
||||
// React to production mode changes
|
||||
adClient.onProductionModeChanged(() => this.updateProductionModeDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (or hide) the contextual button based on apps found in the current file.
|
||||
* @param appNames App names parsed from the active YAML, or empty to hide.
|
||||
*/
|
||||
updateContextualApps(appNames: string[]) {
|
||||
if (appNames.length === 0) {
|
||||
this.contextualItem.hide();
|
||||
return;
|
||||
}
|
||||
const label = appNames.length <= 2
|
||||
? appNames.join(', ')
|
||||
: `${appNames.length} apps`;
|
||||
this.contextualItem.text = `$(refresh) ${label}`;
|
||||
this.contextualItem.tooltip = `Restart: ${appNames.join(', ')}`;
|
||||
this.contextualItem.show();
|
||||
}
|
||||
|
||||
updateProductionModeDisplay() {
|
||||
const isProduction = this.adClient.isProductionMode();
|
||||
if (isProduction) {
|
||||
this.productionModeItem.text = '$(eye-closed) Prod ON';
|
||||
this.productionModeItem.tooltip = 'Production Mode: ON — click to disable';
|
||||
this.productionModeItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
} else {
|
||||
this.productionModeItem.text = '$(eye) Prod OFF';
|
||||
this.productionModeItem.tooltip = 'Production Mode: OFF — click to enable';
|
||||
this.productionModeItem.backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setErrorCount(count: number) {
|
||||
if (count > 0) {
|
||||
this.errorItem.text = `$(warning) AD Errors (${count})`;
|
||||
this.errorItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
||||
this.errorItem.tooltip = `${count} error(s) — click to show or clear`;
|
||||
this.errorItem.command = 'appdaemon.handleErrors';
|
||||
} else {
|
||||
this.errorItem.text = '$(check) AD Errors';
|
||||
this.errorItem.backgroundColor = undefined;
|
||||
this.errorItem.tooltip = 'No AppDaemon errors — click to view log';
|
||||
this.errorItem.command = 'appdaemon.showErrors';
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.restartADItem.dispose();
|
||||
this.restartHAItem.dispose();
|
||||
this.productionModeItem.dispose();
|
||||
this.errorItem.dispose();
|
||||
this.contextualItem.dispose();
|
||||
}
|
||||
}
|
||||
15
vscode-appdaemon/tsconfig.json
Normal file
15
vscode-appdaemon/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2022"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"exclude": ["node_modules", "out"]
|
||||
}
|
||||
BIN
vscode-appdaemon/vscode-appdaemon-0.1.0.vsix
Normal file
BIN
vscode-appdaemon/vscode-appdaemon-0.1.0.vsix
Normal file
Binary file not shown.
Reference in New Issue
Block a user