refactor: remove MQTT discovery feature and streamline entity creation

This commit is contained in:
2026-05-10 21:59:50 +02:00
parent 8454cc95f5
commit 875e0abd37

View File

@@ -1,8 +1,6 @@
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
@@ -20,8 +18,6 @@ from logger_interface import LoggerInterface
# - Templates library : shared named-template provider from another app # - Templates library : shared named-template provider from another app
# - Virtual sensors : declarative derived sensors defined in YAML # - Virtual sensors : declarative derived sensors defined in YAML
# - Attributes override : map HA sensor states onto entity attributes # - Attributes override : map HA sensor states onto entity attributes
# - MQTT discovery : entities created via create_entity() are auto-
# published as HA MQTT discovery sensors
# #
# YAML CONFIGURATION # YAML CONFIGURATION
# ------------------ # ------------------
@@ -68,15 +64,6 @@ from logger_interface import LoggerInterface
# - A HA entity_id → attribute tracks that entity's state live. # - A HA entity_id → attribute tracks that entity's state live.
# - A static value → attribute is set once at startup. # - A static value → attribute is set once at startup.
# #
# mqtt_device_name: <friendly name>
# Human-readable device name used in HA MQTT discovery payloads.
# Defaults to the AppDaemon app name (self.name) if omitted.
# Requires the MQTT integration to be available in HA.
# Used by the MQTT discovery feature (see create_entity()).
# When you create a new entity with create_entity(), it will be attached to a device through MQTT discovery with this name
# and will appear in HA as a child of that device.
# Since MQTT keep those entity forever, you might have to clean up some entities with MQTT Explorer if you change some entity names or delete some entities from your app.
#
# DATASET PERSISTENCE # DATASET PERSISTENCE
# ------------------- # -------------------
# Subclasses can store arbitrary data in self.dataset (any pickle-able # Subclasses can store arbitrary data in self.dataset (any pickle-able
@@ -97,8 +84,8 @@ class SmartObject(hass.Hass,LoggerInterface):
# Public API # Public API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Create (or update) a HA entity and, when MQTT is available, register # Create (or update) a HA entity and tag it with the originating app
# it with HA via MQTT discovery so it appears as a proper device entity. # through the ad_app attribute.
# #
# Parameters # Parameters
# ---------- # ----------
@@ -110,43 +97,45 @@ class SmartObject(hass.Hass,LoggerInterface):
# unit_of_measurement : e.g. "°C", "%", "W" # unit_of_measurement : e.g. "°C", "%", "W"
# device_class : HA sensor device class, e.g. "temperature" # device_class : HA sensor device class, e.g. "temperature"
# state_class : "measurement", "total", "total_increasing" # state_class : "measurement", "total", "total_increasing"
#
# The entity's live state is kept in sync with MQTT automatically via
# a listen_state subscription set up at registration time.
# Returns the AppDaemon entity handle. # Returns the AppDaemon entity handle.
def create_entity(self, entity_id, state=None, attributes=None, name=None, icon=None, unit_of_measurement=None, device_class=None, state_class=None): 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) 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 = { # elif state is not None and entity.exists():
'name': name or entity_id, # state_data = self.get_state(entity_id, attribute='all') or {}
'unique_id': f"{node_id}_{obj}", # current_attributes = state_data.get('attributes', {})
'state_topic': state_topic, # if current_attributes.get('ad_app') != self.name:
'value_template': '{{ value_json.state }}', # attributes = dict(current_attributes)
'availability_topic': availability_topic, # attributes['ad_app'] = self.name
'payload_available': 'online',
'payload_not_available': 'offline', attributes['ad_app'] = self.name
'device': self._mqtt['device'],
} if friendly_name is not None: attributes['friendly_name'] = friendly_name
if icon: config['icon'] = icon if icon is not None: attributes['icon'] = icon
if unit_of_measurement: config['unit_of_measurement'] = unit_of_measurement if unit_of_measurement is not None: attributes['unit_of_measurement'] = unit_of_measurement
if device_class: config['device_class'] = device_class if device_class is not None: attributes['device_class'] = device_class
if state_class: config['state_class'] = state_class if state_class is not None: attributes['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) if state is not None:
self._mqtt['entities'].add(entity_id) entity.set_state(state=state, attributes=attributes)
self._mqtt['handles'][entity_id] = self.listen_state(self._mqtt_sync_state, entity_id) else:
if state is not None and attributes is not None: entity.set_state(state='unknown', attributes=attributes)
self.set_state(entity_id, state=state, attributes=attributes)
elif state is not None: # if state is not None and attributes is not None:
self.set_state(entity_id, state=state) # self.set_state(entity_id, state=state, attributes=attributes)
elif attributes is not None: # elif state is not None:
self.set_state(entity_id, attributes=attributes) # if entity.exists():
elif not entity.exists(): # self.set_state(entity_id, state=state)
self.set_state(entity_id, state='unknown') # else:
# self.set_state(entity_id, state=state, attributes={'ad_app': self.name})
# elif attributes is not None:
# self.set_state(entity_id, attributes=attributes)
# elif not entity.exists():
# self.set_state(entity_id, state='unknown', attributes={'ad_app': self.name})
return entity return entity
# Override this in a subclass to return a default state string when the # Override this in a subclass to return a default state string when the
@@ -162,82 +151,6 @@ class SmartObject(hass.Hass,LoggerInterface):
# Called at the end of initialize() after all YAML args are processed. # Called at the end of initialize() after all YAML args are processed.
def on_initialize_smart_object(self): pass def on_initialize_smart_object(self): pass
# ------------------------------------------------------------------
# MQTT Discovery (internal)
# ------------------------------------------------------------------
# Called lazily on the first create_entity() call. Checks whether the
# MQTT publish service is available and, if so, initialises self._mqtt
# with the device metadata used in discovery payloads.
# Returns True when MQTT is ready, False otherwise (MQTT not installed
# or the service is not available).
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 _sanitize_for_topic(self, value):
return re.sub(r"[^a-zA-Z0-9_]+", "_", str(value)).strip("_").lower()
# Serialize payload to JSON if it is not already a string, then call
# the mqtt/publish service. retain=True is used for state and config
# topics so HA picks them up immediately after a broker restart.
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)
# listen_state callback — re-publishes the full state + attributes of
# entity_id to its MQTT state topic whenever the HA state changes.
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)
# Called from terminate(). Publishes "offline" availability for every
# registered entity so HA marks them as unavailable, then cancels all
# listen_state subscriptions created during create_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
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Dataset persistence helpers # Dataset persistence helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -325,7 +238,7 @@ class SmartObject(hass.Hass,LoggerInterface):
# virtual_sensors: {...} → create derived sensors from YAML spec # 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)
# attributes_override: {attr: entity_id | static_value, ...} # attributes_override: {attr: entity_id | static_value, ...}
# Each attribute either mirrors a live HA sensor or holds a # Each attribute either mirrors a live HA sensor or holds a
@@ -362,12 +275,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)
# AppDaemon shutdown hook. Persists self.dataset to disk (if set), # AppDaemon shutdown hook. Persists self.dataset to disk (if set).
# publishes MQTT offline availability, and cancels all state listeners.
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: