A disabled device with historical readings still cannot be deleted by default
(409 guard stays, to prevent accidental data loss). A new explicit opt-in lets
the user remove it for real:
- DELETE /api/modbus/devices/{uuid}?cascade=true removes the device's readings
and exposed_entity_toggle rows, then the device, in one transaction (FK order:
readings before device). Best-effort clears the HA discovery configs first
(MQTT not connected -> no-op, never blocks the delete). Default (no cascade)
unchanged. Returns 200 with deletion counts.
- Frontend: a 'Force Delete (all data)' button appears only after the 409, with
a red irreversible warning; cascade is sent only on that explicit action.
- Also corrected a stale comment: FK RESTRICT IS enforced at runtime now.
500 lines
18 KiB
Python
500 lines
18 KiB
Python
"""HA MQTT Discovery service — build and publish discovery configs + state.
|
|
|
|
This module handles:
|
|
|
|
1. Building HA MQTT Discovery payloads for each ``ExposableEntity``.
|
|
2. Publishing discovery configs (retained) for enabled entities.
|
|
3. Clearing (empty-payload) discovery configs for disabled entities.
|
|
4. Publishing current entity state values.
|
|
5. A per-device helper for use in the Modbus poll loop.
|
|
|
|
Design
|
|
------
|
|
- **No-op when MQTT / discovery is not configured**: every public function
|
|
checks ``ha_discovery_enabled`` and ``mqtt_manager.is_connected`` before
|
|
doing any work. Callers never need to guard these conditions.
|
|
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
|
|
from mutable fields like ``friendly_name`` or auto-increment DB ids.
|
|
- **Discovery topic format**: ``<discovery_prefix>/<component>/<node_id>/<object_id>/config``
|
|
where ``node_id`` is the device uuid (slugified to be safe) and
|
|
``object_id`` is a uuid-prefixed stable string. Uses ``ha_discovery_prefix``
|
|
(default ``"homeassistant"``), which must stay under the HA discovery namespace.
|
|
- **State topic format**: ``<state_prefix>/<component>/<node_id>/<object_id>/state``
|
|
Uses ``ha_state_topic_prefix`` (default ``"home_automation"``), separate from
|
|
the discovery namespace so HA discovery and state/availability topics live under
|
|
different prefixes.
|
|
- **Availability topic**: ``<state_prefix>/modbus/<node_id>/availability`` (shared
|
|
across all entities of the same device; publishes "online"/"offline"). Also uses
|
|
the state prefix, not the discovery prefix.
|
|
- **best-effort**: functions catch all exceptions internally so that callers
|
|
(e.g. the Modbus poll loop) never crash due to MQTT publish errors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.integrations.expose import ExposableEntity, build_catalog
|
|
from app.integrations.mqtt import mqtt_manager
|
|
from app.services.config_page import build_runtime_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _should_publish(settings: Any) -> bool:
|
|
"""Return True only if MQTT and HA Discovery are both enabled and connected."""
|
|
return bool(
|
|
settings.mqtt_enabled
|
|
and settings.ha_discovery_enabled
|
|
and mqtt_manager.is_connected
|
|
)
|
|
|
|
|
|
def _node_id(device_uuid: str) -> str:
|
|
"""Stable MQTT node_id derived from device uuid (replace hyphens for safety)."""
|
|
return device_uuid.replace("-", "_")
|
|
|
|
|
|
def _object_id(entity: ExposableEntity) -> str:
|
|
"""Stable MQTT object_id derived from entity key (dots + hyphens → underscores)."""
|
|
return entity.key.replace(".", "_").replace("-", "_")
|
|
|
|
|
|
def _discovery_topic(entity: ExposableEntity, prefix: str) -> str:
|
|
"""Build the HA Discovery config topic for *entity*.
|
|
|
|
Format: ``<prefix>/<component>/<node_id>/<object_id>/config``
|
|
"""
|
|
node = _node_id(entity.device.identifiers[1]) # device uuid
|
|
obj = _object_id(entity)
|
|
return f"{prefix}/{entity.component}/{node}/{obj}/config"
|
|
|
|
|
|
def _state_topic(entity: ExposableEntity, prefix: str) -> str:
|
|
"""Build the state publish topic for *entity*.
|
|
|
|
Format: ``<prefix>/<component>/<node_id>/<object_id>/state``
|
|
"""
|
|
node = _node_id(entity.device.identifiers[1])
|
|
obj = _object_id(entity)
|
|
return f"{prefix}/{entity.component}/{node}/{obj}/state"
|
|
|
|
|
|
def _availability_topic(device_uuid: str, prefix: str) -> str:
|
|
"""Shared availability topic for all entities of a device.
|
|
|
|
Format: ``<prefix>/modbus/<node_id>/availability``
|
|
"""
|
|
node = _node_id(device_uuid)
|
|
return f"{prefix}/modbus/{node}/availability"
|
|
|
|
|
|
def _unique_id(entity: ExposableEntity) -> str:
|
|
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
|
|
device_uuid = entity.device.identifiers[1]
|
|
# entity.key is already "modbus.<uuid>.<metric_key>" — use it as the seed
|
|
return f"{device_uuid}_{entity.key.replace('.', '_')}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public: build discovery payload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_discovery_payload(
|
|
entity: ExposableEntity,
|
|
discovery_prefix: str,
|
|
state_prefix: str | None = None,
|
|
) -> tuple[str, dict[str, Any]]:
|
|
"""Build the HA MQTT Discovery config topic and payload dict for *entity*.
|
|
|
|
Parameters
|
|
----------
|
|
entity:
|
|
An ``ExposableEntity`` (from ``build_catalog``).
|
|
discovery_prefix:
|
|
The HA Discovery config topic prefix (e.g. ``"homeassistant"``).
|
|
This determines where HA looks for the discovery config topic.
|
|
state_prefix:
|
|
The prefix used for state and availability topics (e.g.
|
|
``"home_automation"``). When omitted or ``None``, falls back to
|
|
``discovery_prefix`` for backward compatibility (e.g. in tests that
|
|
pass only one prefix).
|
|
|
|
Returns
|
|
-------
|
|
tuple[str, dict]
|
|
``(topic, config_dict)`` — the config topic and the HA Discovery
|
|
payload (not yet JSON-serialised).
|
|
"""
|
|
if state_prefix is None:
|
|
state_prefix = discovery_prefix
|
|
|
|
device_uuid = entity.device.identifiers[1]
|
|
avail_topic = _availability_topic(device_uuid, state_prefix)
|
|
state_t = _state_topic(entity, state_prefix)
|
|
topic = _discovery_topic(entity, discovery_prefix)
|
|
|
|
config: dict[str, Any] = {
|
|
"unique_id": _unique_id(entity),
|
|
"name": entity.name,
|
|
"state_topic": state_t,
|
|
"device": {
|
|
"identifiers": list(entity.device.identifiers),
|
|
"name": entity.device.name,
|
|
},
|
|
}
|
|
|
|
# Only declare an availability topic for devices that actually publish an
|
|
# online/offline heartbeat (e.g. Modbus, via its "online" binary_sensor).
|
|
# Devices without a heartbeat (e.g. energy-cost) omit availability so HA
|
|
# treats their entities as always-available — otherwise HA would mark them
|
|
# ``unavailable`` forever even though their state is being published.
|
|
if entity.device.provides_availability:
|
|
config["availability"] = [{"topic": avail_topic}]
|
|
config["availability_mode"] = "all"
|
|
|
|
# Component-specific fields
|
|
if entity.component == "binary_sensor":
|
|
# "online" binary sensor: payload ON/OFF
|
|
config["payload_on"] = "ON"
|
|
config["payload_off"] = "OFF"
|
|
if entity.device_class:
|
|
config["device_class"] = entity.device_class
|
|
else:
|
|
# sensor (and others)
|
|
if entity.device_class:
|
|
config["device_class"] = entity.device_class
|
|
if entity.unit:
|
|
config["unit_of_measurement"] = entity.unit
|
|
if entity.state_class:
|
|
config["state_class"] = entity.state_class
|
|
|
|
return topic, config
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public: publish discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def publish_discovery(session: Session) -> None:
|
|
"""Publish HA Discovery configs for all entities in the catalog.
|
|
|
|
- Enabled entities: publish retained JSON config payload.
|
|
- Disabled entities: publish empty (b"") payload to clear any previously
|
|
retained config from HA.
|
|
|
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
|
All MQTT errors are caught internally — this function never raises.
|
|
"""
|
|
from app.config import get_settings
|
|
settings = build_runtime_settings(session, get_settings())
|
|
if not _should_publish(settings):
|
|
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
|
|
return
|
|
|
|
discovery_prefix = settings.ha_discovery_prefix
|
|
state_prefix = settings.ha_state_topic_prefix
|
|
|
|
try:
|
|
catalog = build_catalog(session)
|
|
except Exception:
|
|
logger.exception("publish_discovery: failed to build catalog; aborting")
|
|
return
|
|
|
|
for entry in catalog:
|
|
entity = entry.entity
|
|
try:
|
|
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
|
if entry.enabled:
|
|
payload = json.dumps(config)
|
|
mqtt_manager.publish(topic, payload, retain=True)
|
|
logger.debug(
|
|
"publish_discovery: published config for %r → %s", entity.key, topic
|
|
)
|
|
else:
|
|
# Clear retained config for disabled entities.
|
|
mqtt_manager.publish(topic, b"", retain=True)
|
|
logger.debug(
|
|
"publish_discovery: cleared config for disabled entity %r → %s",
|
|
entity.key,
|
|
topic,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_discovery: error processing entity %r; continuing", entity.key
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public: publish states
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def publish_states(session: Session) -> None:
|
|
"""Publish current state values for all enabled entities.
|
|
|
|
Calls each entity's ``value_getter`` to obtain the current value and
|
|
publishes it to the entity's state topic. Also publishes availability
|
|
(online/offline) for each device.
|
|
|
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
|
All errors are caught internally — this function never raises.
|
|
"""
|
|
from app.config import get_settings
|
|
settings = build_runtime_settings(session, get_settings())
|
|
if not _should_publish(settings):
|
|
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
|
|
return
|
|
|
|
state_prefix = settings.ha_state_topic_prefix
|
|
|
|
try:
|
|
catalog = build_catalog(session)
|
|
except Exception:
|
|
logger.exception("publish_states: failed to build catalog; aborting")
|
|
return
|
|
|
|
# Collect enabled entries and publish
|
|
for entry in catalog:
|
|
if not entry.enabled:
|
|
continue
|
|
entity = entry.entity
|
|
try:
|
|
_publish_entity_state(entity, state_prefix, session)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_states: error publishing state for %r; continuing", entity.key
|
|
)
|
|
|
|
|
|
def _publish_entity_state(
|
|
entity: ExposableEntity,
|
|
prefix: str,
|
|
session: Session,
|
|
) -> None:
|
|
"""Publish one entity's current state value to its state topic.
|
|
|
|
Also publishes the availability topic for ``binary_sensor`` "online" entities.
|
|
"""
|
|
state_t = _state_topic(entity, prefix)
|
|
device_uuid = entity.device.identifiers[1]
|
|
|
|
if entity.component == "binary_sensor" and "online" in entity.key:
|
|
# The online sensor represents device availability.
|
|
# Ask the value_getter for the current ON/OFF string, then also publish
|
|
# the shared availability topic (online/offline).
|
|
raw_value = None
|
|
if entity.value_getter is not None:
|
|
try:
|
|
raw_value = entity.value_getter(session)
|
|
except Exception:
|
|
logger.exception("value_getter raised for online entity %r", entity.key)
|
|
# Default to offline when no reading is available.
|
|
online = (raw_value == "ON")
|
|
avail_payload = "online" if online else "offline"
|
|
avail_topic = _availability_topic(device_uuid, prefix)
|
|
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
|
# The state of the binary_sensor itself
|
|
state_payload = "ON" if online else "OFF"
|
|
mqtt_manager.publish(state_t, state_payload, retain=False)
|
|
else:
|
|
# Regular sensor: call value_getter(session) if available.
|
|
value = None
|
|
if entity.value_getter is not None:
|
|
try:
|
|
value = entity.value_getter(session)
|
|
except Exception:
|
|
logger.exception("value_getter raised for entity %r", entity.key)
|
|
|
|
if value is None:
|
|
logger.debug("publish_states: no value for %r — skipping state publish", entity.key)
|
|
return
|
|
|
|
mqtt_manager.publish(state_t, str(value), retain=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public: per-device state push (called by modbus_poll after each poll)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def publish_device_state(session: Session, device: Any) -> None:
|
|
"""Publish state + availability for all enabled entities of *device*.
|
|
|
|
Called by ``modbus_poll.poll_device`` after a successful (or failed) poll.
|
|
Publishes:
|
|
- The current availability topic ("online" / "offline") for the device.
|
|
- The state topic for each **enabled** entity of the device.
|
|
|
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
|
All errors are caught internally — never raises.
|
|
"""
|
|
from app.config import get_settings
|
|
settings = build_runtime_settings(session, get_settings())
|
|
if not _should_publish(settings):
|
|
return
|
|
|
|
state_prefix = settings.ha_state_topic_prefix
|
|
device_uuid = device.uuid
|
|
online = bool(device.last_poll_ok)
|
|
|
|
# Publish availability topic first.
|
|
try:
|
|
avail_topic = _availability_topic(device_uuid, state_prefix)
|
|
avail_payload = "online" if online else "offline"
|
|
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_device_state: failed to publish availability for device uuid=%s", device_uuid
|
|
)
|
|
|
|
if not online:
|
|
# Device is offline — no point pushing stale state values.
|
|
return
|
|
|
|
# Publish state for each enabled entity of this device.
|
|
try:
|
|
catalog = build_catalog(session)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_device_state: failed to build catalog for device uuid=%s", device_uuid
|
|
)
|
|
return
|
|
|
|
for entry in catalog:
|
|
if not entry.enabled:
|
|
continue
|
|
entity = entry.entity
|
|
# Only process entities belonging to this device.
|
|
if entity.device.identifiers[1] != device_uuid:
|
|
continue
|
|
# Skip the online binary_sensor itself (availability handled above).
|
|
if entity.component == "binary_sensor" and "online" in entity.key:
|
|
# Publish the binary_sensor state too.
|
|
try:
|
|
state_t = _state_topic(entity, state_prefix)
|
|
mqtt_manager.publish(state_t, "ON", retain=False)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_device_state: error publishing online sensor state for %r",
|
|
entity.key,
|
|
)
|
|
continue
|
|
|
|
# Regular sensor — call value_getter(session).
|
|
try:
|
|
value = None
|
|
if entity.value_getter is not None:
|
|
value = entity.value_getter(session)
|
|
if value is None:
|
|
continue
|
|
state_t = _state_topic(entity, state_prefix)
|
|
mqtt_manager.publish(state_t, str(value), retain=False)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_device_state: error publishing state for entity %r", entity.key
|
|
)
|
|
|
|
|
|
def clear_device_discovery(session: Session, device_uuid: str) -> None:
|
|
"""Best-effort: send empty retained payloads to all HA Discovery config topics
|
|
for the given device, effectively removing the device's entities from HA.
|
|
|
|
This must be called **before** the device rows are deleted from the DB, so
|
|
that ``build_catalog`` can still enumerate the device's entities.
|
|
|
|
Behaviour
|
|
---------
|
|
- No-op if MQTT / HA Discovery is not enabled or the MQTT client is not
|
|
connected.
|
|
- All exceptions are caught internally; this function never raises.
|
|
The caller proceeds with the DB deletion regardless of MQTT outcome.
|
|
|
|
Parameters
|
|
----------
|
|
session:
|
|
Active SQLAlchemy session (device must still exist in DB at call time).
|
|
device_uuid:
|
|
UUID string of the device being deleted.
|
|
"""
|
|
from app.config import get_settings
|
|
settings = build_runtime_settings(session, get_settings())
|
|
if not _should_publish(settings):
|
|
logger.debug(
|
|
"clear_device_discovery: skipped (MQTT/Discovery not enabled or not connected)"
|
|
)
|
|
return
|
|
|
|
discovery_prefix = settings.ha_discovery_prefix
|
|
state_prefix = settings.ha_state_topic_prefix
|
|
|
|
try:
|
|
catalog = build_catalog(session)
|
|
except Exception:
|
|
logger.exception(
|
|
"clear_device_discovery: failed to build catalog for device uuid=%s; skipping HA cleanup",
|
|
device_uuid,
|
|
)
|
|
return
|
|
|
|
cleared = 0
|
|
for entry in catalog:
|
|
entity = entry.entity
|
|
# Only clear entities belonging to this device.
|
|
if entity.device.identifiers[1] != device_uuid:
|
|
continue
|
|
try:
|
|
topic, _config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
|
mqtt_manager.publish(topic, b"", retain=True)
|
|
cleared += 1
|
|
logger.debug(
|
|
"clear_device_discovery: cleared config topic for entity %r → %s",
|
|
entity.key,
|
|
topic,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"clear_device_discovery: error clearing entity %r; continuing", entity.key
|
|
)
|
|
|
|
logger.info(
|
|
"clear_device_discovery: cleared %d HA discovery topic(s) for device uuid=%s",
|
|
cleared,
|
|
device_uuid,
|
|
)
|
|
|
|
|
|
def publish_device_offline(session: Session, device_uuid: str) -> None:
|
|
"""Publish an "offline" availability payload for *device_uuid*.
|
|
|
|
Called by ``modbus_poll.poll_device`` when a poll fails, to immediately
|
|
reflect the device going offline in HA (before the next full state sweep).
|
|
|
|
No-op if MQTT / discovery is not enabled or the client is not connected.
|
|
Never raises.
|
|
"""
|
|
from app.config import get_settings
|
|
settings = build_runtime_settings(session, get_settings())
|
|
if not _should_publish(settings):
|
|
return
|
|
|
|
try:
|
|
state_prefix = settings.ha_state_topic_prefix
|
|
avail_topic = _availability_topic(device_uuid, state_prefix)
|
|
mqtt_manager.publish(avail_topic, "offline", retain=False)
|
|
except Exception:
|
|
logger.exception(
|
|
"publish_device_offline: failed for device uuid=%s", device_uuid
|
|
)
|