Compare commits

..

10 Commits

16 changed files with 1694 additions and 143 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 in events_block:
self.add_dispatcher(event,callback,event_context=event_context)
else:
for event_block in events_block.values(): for event_block in events_block.values():
register_event_with_params(event_block,callback,event_context) register_event_with_params(event_block,callback,event_context)
#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): 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): # Override this in a subclass to return a default state string when the
if not getattr(self, '_mqtt', None): # linked entity does not exist and no "default_entity_state" arg was set.
return # The base implementation raises a fatal error.
for entity_id in self._mqtt['entities']: 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)
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
# 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") 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)

2
vscode-appdaemon/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
out/
node_modules/

View File

@@ -0,0 +1,4 @@
node_modules/
src/
tsconfig.json
.gitignore

58
vscode-appdaemon/package-lock.json generated Normal file
View 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"
}
}
}

View 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"
}
}

View 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();
}
}

View 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');
}

View 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();
}
}

View 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();
}

View 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();
}
}

View 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();
}
}

View 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"]
}

Binary file not shown.