ha_discovery: publish state/availability under home_automation prefix
Follow the standard MQTT Discovery split: discovery config stays under the HA discovery prefix (homeassistant/.../config, required), but the actual state and availability are published under our own prefix (default home_automation/). The config payload's state_topic/availability fields point at the new prefix so HA reads state from there. - new config ha_state_topic_prefix (default home_automation). - build_discovery_payload + all publish_* split discovery_prefix (config topic) from state_prefix (state/availability). energy-cost still omits availability; modbus availability structure unchanged. Tests updated.
This commit is contained in:
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
||||
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
||||
ha_discovery_enabled: bool = False
|
||||
ha_discovery_prefix: str = "homeassistant"
|
||||
# State/availability topics use a separate prefix so they live outside the HA discovery
|
||||
# namespace. HA still receives state via the state_topic declared in the discovery config.
|
||||
ha_state_topic_prefix: str = "home_automation"
|
||||
|
||||
# DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
|
||||
# Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True.
|
||||
|
||||
@@ -122,6 +122,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
"ha_discovery_prefix",
|
||||
"HA Discovery Prefix",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant Discovery",
|
||||
"HA_STATE_TOPIC_PREFIX",
|
||||
"ha_state_topic_prefix",
|
||||
"HA State Topic Prefix",
|
||||
),
|
||||
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
@@ -351,6 +357,7 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
||||
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
||||
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
||||
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
|
||||
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
|
||||
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
|
||||
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
|
||||
|
||||
@@ -15,12 +15,17 @@ Design
|
||||
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**: ``<prefix>/<component>/<node_id>/<object_id>/config``
|
||||
- **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.
|
||||
- **State topic format**: ``<prefix>/<component>/<node_id>/<object_id>/state``
|
||||
- **Availability topic**: ``<prefix>/modbus/<node_id>/availability`` (shared
|
||||
across all entities of the same device; publishes "online"/"offline").
|
||||
``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.
|
||||
"""
|
||||
@@ -107,7 +112,8 @@ def _unique_id(entity: ExposableEntity) -> str:
|
||||
|
||||
def build_discovery_payload(
|
||||
entity: ExposableEntity,
|
||||
prefix: str,
|
||||
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*.
|
||||
|
||||
@@ -115,8 +121,14 @@ def build_discovery_payload(
|
||||
----------
|
||||
entity:
|
||||
An ``ExposableEntity`` (from ``build_catalog``).
|
||||
prefix:
|
||||
The discovery prefix (e.g. ``"homeassistant"``).
|
||||
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
|
||||
-------
|
||||
@@ -124,10 +136,13 @@ def build_discovery_payload(
|
||||
``(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, prefix)
|
||||
state_t = _state_topic(entity, prefix)
|
||||
topic = _discovery_topic(entity, prefix)
|
||||
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),
|
||||
@@ -188,7 +203,8 @@ def publish_discovery(session: Session) -> None:
|
||||
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
|
||||
return
|
||||
|
||||
prefix = settings.ha_discovery_prefix
|
||||
discovery_prefix = settings.ha_discovery_prefix
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
|
||||
try:
|
||||
catalog = build_catalog(session)
|
||||
@@ -199,7 +215,7 @@ def publish_discovery(session: Session) -> None:
|
||||
for entry in catalog:
|
||||
entity = entry.entity
|
||||
try:
|
||||
topic, config = build_discovery_payload(entity, prefix)
|
||||
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
|
||||
if entry.enabled:
|
||||
payload = json.dumps(config)
|
||||
mqtt_manager.publish(topic, payload, retain=True)
|
||||
@@ -241,7 +257,7 @@ def publish_states(session: Session) -> None:
|
||||
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
|
||||
return
|
||||
|
||||
prefix = settings.ha_discovery_prefix
|
||||
state_prefix = settings.ha_state_topic_prefix
|
||||
|
||||
try:
|
||||
catalog = build_catalog(session)
|
||||
@@ -255,7 +271,7 @@ def publish_states(session: Session) -> None:
|
||||
continue
|
||||
entity = entry.entity
|
||||
try:
|
||||
_publish_entity_state(entity, prefix, session)
|
||||
_publish_entity_state(entity, state_prefix, session)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"publish_states: error publishing state for %r; continuing", entity.key
|
||||
@@ -329,13 +345,13 @@ def publish_device_state(session: Session, device: Any) -> None:
|
||||
if not _should_publish(settings):
|
||||
return
|
||||
|
||||
prefix = settings.ha_discovery_prefix
|
||||
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, prefix)
|
||||
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:
|
||||
@@ -367,7 +383,7 @@ def publish_device_state(session: Session, device: Any) -> None:
|
||||
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||
# Publish the binary_sensor state too.
|
||||
try:
|
||||
state_t = _state_topic(entity, prefix)
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
mqtt_manager.publish(state_t, "ON", retain=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@@ -383,7 +399,7 @@ def publish_device_state(session: Session, device: Any) -> None:
|
||||
value = entity.value_getter(session)
|
||||
if value is None:
|
||||
continue
|
||||
state_t = _state_topic(entity, prefix)
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
mqtt_manager.publish(state_t, str(value), retain=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@@ -406,8 +422,8 @@ def publish_device_offline(session: Session, device_uuid: str) -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
prefix = settings.ha_discovery_prefix
|
||||
avail_topic = _availability_topic(device_uuid, prefix)
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user