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:
2026-06-24 16:21:15 +02:00
parent 9bc46f8d2d
commit d7f0731d1c
6 changed files with 253 additions and 34 deletions
+3
View File
@@ -43,8 +43,11 @@ MQTT_TLS_ENABLED=false
# Optional: Home Assistant MQTT Discovery. # Optional: Home Assistant MQTT Discovery.
# Requires MQTT_ENABLED=true and a running MQTT broker. # Requires MQTT_ENABLED=true and a running MQTT broker.
# HA_DISCOVERY_PREFIX is the config topic prefix (must be "homeassistant" for HA auto-discovery).
# HA_STATE_TOPIC_PREFIX is the prefix for state/availability topics (separate from discovery).
HA_DISCOVERY_ENABLED=false HA_DISCOVERY_ENABLED=false
HA_DISCOVERY_PREFIX=homeassistant HA_DISCOVERY_PREFIX=homeassistant
HA_STATE_TOPIC_PREFIX=home_automation
# Optional: DSMR smart-meter ingest via MQTT (M6; default off — opt-in). # Optional: DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
# Set DSMR_INGEST_ENABLED=true to subscribe to the DSMR MQTT topic and # Set DSMR_INGEST_ENABLED=true to subscribe to the DSMR MQTT topic and
+3
View File
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing). # Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
ha_discovery_enabled: bool = False ha_discovery_enabled: bool = False
ha_discovery_prefix: str = "homeassistant" 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). # DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
# Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True. # Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True.
+7
View File
@@ -122,6 +122,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
"ha_discovery_prefix", "ha_discovery_prefix",
"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("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
ConfigField( ConfigField(
"DSMR", "DSMR",
@@ -351,6 +357,7 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"mqtt_tls_enabled": settings.mqtt_tls_enabled, "mqtt_tls_enabled": settings.mqtt_tls_enabled,
"ha_discovery_enabled": settings.ha_discovery_enabled, "ha_discovery_enabled": settings.ha_discovery_enabled,
"ha_discovery_prefix": settings.ha_discovery_prefix, "ha_discovery_prefix": settings.ha_discovery_prefix,
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled, "dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic, "dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s, "dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
+37 -21
View File
@@ -15,12 +15,17 @@ Design
doing any work. Callers never need to guard these conditions. doing any work. Callers never need to guard these conditions.
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never - **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
from mutable fields like ``friendly_name`` or auto-increment DB ids. 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 where ``node_id`` is the device uuid (slugified to be safe) and
``object_id`` is a uuid-prefixed stable string. ``object_id`` is a uuid-prefixed stable string. Uses ``ha_discovery_prefix``
- **State topic format**: ``<prefix>/<component>/<node_id>/<object_id>/state`` (default ``"homeassistant"``), which must stay under the HA discovery namespace.
- **Availability topic**: ``<prefix>/modbus/<node_id>/availability`` (shared - **State topic format**: ``<state_prefix>/<component>/<node_id>/<object_id>/state``
across all entities of the same device; publishes "online"/"offline"). 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 - **best-effort**: functions catch all exceptions internally so that callers
(e.g. the Modbus poll loop) never crash due to MQTT publish errors. (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( def build_discovery_payload(
entity: ExposableEntity, entity: ExposableEntity,
prefix: str, discovery_prefix: str,
state_prefix: str | None = None,
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
"""Build the HA MQTT Discovery config topic and payload dict for *entity*. """Build the HA MQTT Discovery config topic and payload dict for *entity*.
@@ -115,8 +121,14 @@ def build_discovery_payload(
---------- ----------
entity: entity:
An ``ExposableEntity`` (from ``build_catalog``). An ``ExposableEntity`` (from ``build_catalog``).
prefix: discovery_prefix:
The discovery prefix (e.g. ``"homeassistant"``). 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 Returns
------- -------
@@ -124,10 +136,13 @@ def build_discovery_payload(
``(topic, config_dict)`` — the config topic and the HA Discovery ``(topic, config_dict)`` — the config topic and the HA Discovery
payload (not yet JSON-serialised). payload (not yet JSON-serialised).
""" """
if state_prefix is None:
state_prefix = discovery_prefix
device_uuid = entity.device.identifiers[1] device_uuid = entity.device.identifiers[1]
avail_topic = _availability_topic(device_uuid, prefix) avail_topic = _availability_topic(device_uuid, state_prefix)
state_t = _state_topic(entity, prefix) state_t = _state_topic(entity, state_prefix)
topic = _discovery_topic(entity, prefix) topic = _discovery_topic(entity, discovery_prefix)
config: dict[str, Any] = { config: dict[str, Any] = {
"unique_id": _unique_id(entity), "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)") logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
return return
prefix = settings.ha_discovery_prefix discovery_prefix = settings.ha_discovery_prefix
state_prefix = settings.ha_state_topic_prefix
try: try:
catalog = build_catalog(session) catalog = build_catalog(session)
@@ -199,7 +215,7 @@ def publish_discovery(session: Session) -> None:
for entry in catalog: for entry in catalog:
entity = entry.entity entity = entry.entity
try: try:
topic, config = build_discovery_payload(entity, prefix) topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
if entry.enabled: if entry.enabled:
payload = json.dumps(config) payload = json.dumps(config)
mqtt_manager.publish(topic, payload, retain=True) 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)") logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
return return
prefix = settings.ha_discovery_prefix state_prefix = settings.ha_state_topic_prefix
try: try:
catalog = build_catalog(session) catalog = build_catalog(session)
@@ -255,7 +271,7 @@ def publish_states(session: Session) -> None:
continue continue
entity = entry.entity entity = entry.entity
try: try:
_publish_entity_state(entity, prefix, session) _publish_entity_state(entity, state_prefix, session)
except Exception: except Exception:
logger.exception( logger.exception(
"publish_states: error publishing state for %r; continuing", entity.key "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): if not _should_publish(settings):
return return
prefix = settings.ha_discovery_prefix state_prefix = settings.ha_state_topic_prefix
device_uuid = device.uuid device_uuid = device.uuid
online = bool(device.last_poll_ok) online = bool(device.last_poll_ok)
# Publish availability topic first. # Publish availability topic first.
try: try:
avail_topic = _availability_topic(device_uuid, prefix) avail_topic = _availability_topic(device_uuid, state_prefix)
avail_payload = "online" if online else "offline" avail_payload = "online" if online else "offline"
mqtt_manager.publish(avail_topic, avail_payload, retain=False) mqtt_manager.publish(avail_topic, avail_payload, retain=False)
except Exception: 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: if entity.component == "binary_sensor" and "online" in entity.key:
# Publish the binary_sensor state too. # Publish the binary_sensor state too.
try: try:
state_t = _state_topic(entity, prefix) state_t = _state_topic(entity, state_prefix)
mqtt_manager.publish(state_t, "ON", retain=False) mqtt_manager.publish(state_t, "ON", retain=False)
except Exception: except Exception:
logger.exception( logger.exception(
@@ -383,7 +399,7 @@ def publish_device_state(session: Session, device: Any) -> None:
value = entity.value_getter(session) value = entity.value_getter(session)
if value is None: if value is None:
continue continue
state_t = _state_topic(entity, prefix) state_t = _state_topic(entity, state_prefix)
mqtt_manager.publish(state_t, str(value), retain=False) mqtt_manager.publish(state_t, str(value), retain=False)
except Exception: except Exception:
logger.exception( logger.exception(
@@ -406,8 +422,8 @@ def publish_device_offline(session: Session, device_uuid: str) -> None:
return return
try: try:
prefix = settings.ha_discovery_prefix state_prefix = settings.ha_state_topic_prefix
avail_topic = _availability_topic(device_uuid, prefix) avail_topic = _availability_topic(device_uuid, state_prefix)
mqtt_manager.publish(avail_topic, "offline", retain=False) mqtt_manager.publish(avail_topic, "offline", retain=False)
except Exception: except Exception:
logger.exception( logger.exception(
+3 -1
View File
@@ -90,11 +90,13 @@ def _make_settings(
mqtt_enabled: bool = True, mqtt_enabled: bool = True,
ha_discovery_enabled: bool = True, ha_discovery_enabled: bool = True,
ha_discovery_prefix: str = "homeassistant", ha_discovery_prefix: str = "homeassistant",
ha_state_topic_prefix: str = "home_automation",
) -> MagicMock: ) -> MagicMock:
s = MagicMock() s = MagicMock()
s.mqtt_enabled = mqtt_enabled s.mqtt_enabled = mqtt_enabled
s.ha_discovery_enabled = ha_discovery_enabled s.ha_discovery_enabled = ha_discovery_enabled
s.ha_discovery_prefix = ha_discovery_prefix s.ha_discovery_prefix = ha_discovery_prefix
s.ha_state_topic_prefix = ha_state_topic_prefix
return s return s
@@ -691,7 +693,7 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
state_class="total", state_class="total",
) )
topic, config = build_discovery_payload(entity, prefix="homeassistant") topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
# node_id: hyphens replaced with underscores → "energy_cost" # node_id: hyphens replaced with underscores → "energy_cost"
expected_node = "energy_cost" expected_node = "energy_cost"
+200 -12
View File
@@ -90,11 +90,13 @@ def _make_settings(
mqtt_enabled: bool = True, mqtt_enabled: bool = True,
ha_discovery_enabled: bool = True, ha_discovery_enabled: bool = True,
ha_discovery_prefix: str = "homeassistant", ha_discovery_prefix: str = "homeassistant",
ha_state_topic_prefix: str = "home_automation",
) -> MagicMock: ) -> MagicMock:
s = MagicMock() s = MagicMock()
s.mqtt_enabled = mqtt_enabled s.mqtt_enabled = mqtt_enabled
s.ha_discovery_enabled = ha_discovery_enabled s.ha_discovery_enabled = ha_discovery_enabled
s.ha_discovery_prefix = ha_discovery_prefix s.ha_discovery_prefix = ha_discovery_prefix
s.ha_state_topic_prefix = ha_state_topic_prefix
return s return s
@@ -142,7 +144,7 @@ def test_build_discovery_payload_topic_format() -> None:
name="My Meter Voltage", name="My Meter Voltage",
) )
topic, config = build_discovery_payload(entity, prefix="homeassistant") topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
# node_id replaces hyphens with underscores # node_id replaces hyphens with underscores
node_id = uuid_val.replace("-", "_") node_id = uuid_val.replace("-", "_")
@@ -168,7 +170,7 @@ def test_build_discovery_payload_unique_id_from_uuid() -> None:
name="Meter A Current", name="Meter A Current",
) )
_, config = build_discovery_payload(entity, prefix="homeassistant") _, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
# unique_id must contain the device uuid (normalised) # unique_id must contain the device uuid (normalised)
uid = config["unique_id"].replace("-", "").replace("_", "") uid = config["unique_id"].replace("-", "").replace("_", "")
@@ -210,8 +212,8 @@ def test_build_discovery_payload_unique_id_is_stable_across_renames() -> None:
name="New Name Voltage", name="New Name Voltage",
) )
_, config_a = build_discovery_payload(entity_a, prefix="homeassistant") _, config_a = build_discovery_payload(entity_a, discovery_prefix="homeassistant")
_, config_b = build_discovery_payload(entity_b, prefix="homeassistant") _, config_b = build_discovery_payload(entity_b, discovery_prefix="homeassistant")
assert config_a["unique_id"] == config_b["unique_id"], ( assert config_a["unique_id"] == config_b["unique_id"], (
"unique_id must not change when friendly_name changes" "unique_id must not change when friendly_name changes"
@@ -234,7 +236,7 @@ def test_build_discovery_payload_device_block() -> None:
name="Friendly Name Voltage", name="Friendly Name Voltage",
) )
_, config = build_discovery_payload(entity, prefix="homeassistant") _, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
assert "device" in config assert "device" in config
assert "identifiers" in config["device"] assert "identifiers" in config["device"]
@@ -259,7 +261,7 @@ def test_build_discovery_payload_sensor_fields() -> None:
state_class="total_increasing", state_class="total_increasing",
) )
_, config = build_discovery_payload(entity, prefix="homeassistant") _, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
assert config["device_class"] == "energy" assert config["device_class"] == "energy"
assert config["unit_of_measurement"] == "kWh" assert config["unit_of_measurement"] == "kWh"
@@ -284,7 +286,7 @@ def test_build_discovery_payload_binary_sensor() -> None:
name="Meter Online Online", name="Meter Online Online",
) )
_, config = build_discovery_payload(entity, prefix="homeassistant") _, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
assert config.get("payload_on") == "ON" assert config.get("payload_on") == "ON"
assert config.get("payload_off") == "OFF" assert config.get("payload_off") == "OFF"
@@ -307,7 +309,7 @@ def test_build_discovery_payload_state_topic_matches_node_and_object() -> None:
name="Meter Voltage", name="Meter Voltage",
) )
_, config = build_discovery_payload(entity, prefix="homeassistant") _, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
state_topic = config["state_topic"] state_topic = config["state_topic"]
assert "homeassistant/sensor/" in state_topic assert "homeassistant/sensor/" in state_topic
@@ -991,7 +993,7 @@ def test_build_discovery_payload_includes_availability_topic() -> None:
name="Avail Meter Voltage", name="Avail Meter Voltage",
) )
_, config = build_discovery_payload(entity, prefix="homeassistant") _, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
expected_avail_topic = _availability_topic(uuid_val, "homeassistant") expected_avail_topic = _availability_topic(uuid_val, "homeassistant")
assert "availability" in config assert "availability" in config
@@ -1142,7 +1144,9 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
topics_to_payloads: dict[str, Any] = {t: p for t, p in published} topics_to_payloads: dict[str, Any] = {t: p for t, p in published}
# Availability topic must be "online". # Availability topic must be "online".
avail_topic = f"homeassistant/modbus/{uuid_val.replace('-', '_')}/availability" # State/availability topics now use ha_state_topic_prefix ("home_automation"), not
# ha_discovery_prefix ("homeassistant").
avail_topic = f"home_automation/modbus/{uuid_val.replace('-', '_')}/availability"
assert avail_topic in topics_to_payloads, ( assert avail_topic in topics_to_payloads, (
f"Availability topic {avail_topic!r} not published. All topics: {list(topics_to_payloads)}" f"Availability topic {avail_topic!r} not published. All topics: {list(topics_to_payloads)}"
) )
@@ -1153,7 +1157,8 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
# Voltage state topic must carry the real value from the reading (231.5). # Voltage state topic must carry the real value from the reading (231.5).
node_id = uuid_val.replace("-", "_") node_id = uuid_val.replace("-", "_")
voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage" voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage"
voltage_state_topic = f"homeassistant/sensor/{node_id}/{voltage_obj_id}/state" # State topics use ha_state_topic_prefix ("home_automation").
voltage_state_topic = f"home_automation/sensor/{node_id}/{voltage_obj_id}/state"
assert voltage_state_topic in topics_to_payloads, ( assert voltage_state_topic in topics_to_payloads, (
f"Voltage state topic {voltage_state_topic!r} not published. " f"Voltage state topic {voltage_state_topic!r} not published. "
f"Sensor state topics: {[t for t in topics_to_payloads if 'sensor' in t and 'state' in t]}" f"Sensor state topics: {[t for t in topics_to_payloads if 'sensor' in t and 'state' in t]}"
@@ -1165,7 +1170,8 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
# Online binary_sensor state topic must be "ON" (last_poll_ok=True). # Online binary_sensor state topic must be "ON" (last_poll_ok=True).
online_obj_id = f"modbus_{uuid_val.replace('-', '_')}_online" online_obj_id = f"modbus_{uuid_val.replace('-', '_')}_online"
online_state_topic = f"homeassistant/binary_sensor/{node_id}/{online_obj_id}/state" # State topics use ha_state_topic_prefix ("home_automation").
online_state_topic = f"home_automation/binary_sensor/{node_id}/{online_obj_id}/state"
assert online_state_topic in topics_to_payloads, ( assert online_state_topic in topics_to_payloads, (
f"Online state topic {online_state_topic!r} not published. " f"Online state topic {online_state_topic!r} not published. "
f"Topics: {list(topics_to_payloads)}" f"Topics: {list(topics_to_payloads)}"
@@ -1173,3 +1179,185 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
assert topics_to_payloads[online_state_topic] == "ON", ( assert topics_to_payloads[online_state_topic] == "ON", (
f"Expected 'ON' for online sensor, got {topics_to_payloads[online_state_topic]!r}" f"Expected 'ON' for online sensor, got {topics_to_payloads[online_state_topic]!r}"
) )
# ---------------------------------------------------------------------------
# 10. Topic prefix separation: config vs state/availability
# ---------------------------------------------------------------------------
def test_build_discovery_payload_state_topic_uses_state_prefix() -> None:
"""When discovery_prefix and state_prefix differ, state_topic must use state_prefix."""
from app.integrations.expose import DeviceInfo, ExposableEntity
from app.services.ha_discovery import build_discovery_payload
uuid_val = "aaaaaaaa-1111-2222-3333-444444444444"
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Split Prefix Meter")
entity = ExposableEntity(
key=f"modbus.{uuid_val}.voltage",
component="sensor",
device=device,
device_class="voltage",
unit="V",
name="Split Prefix Meter Voltage",
)
topic, config = build_discovery_payload(
entity,
discovery_prefix="homeassistant",
state_prefix="home_automation",
)
# Config topic must use the discovery prefix.
assert topic.startswith("homeassistant/"), (
f"Config topic must start with 'homeassistant/', got {topic!r}"
)
assert topic.endswith("/config"), f"Config topic must end with /config, got {topic!r}"
# State topic in payload must use the state prefix.
state_topic = config["state_topic"]
assert state_topic.startswith("home_automation/"), (
f"state_topic must start with 'home_automation/', got {state_topic!r}"
)
assert state_topic.endswith("/state"), f"state_topic must end with /state, got {state_topic!r}"
def test_build_discovery_payload_availability_topic_uses_state_prefix() -> None:
"""When state_prefix differs from discovery_prefix, availability must use state_prefix."""
from app.integrations.expose import DeviceInfo, ExposableEntity
from app.services.ha_discovery import build_discovery_payload
uuid_val = "bbbbbbbb-1111-2222-3333-444444444444"
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Avail Split Meter")
entity = ExposableEntity(
key=f"modbus.{uuid_val}.voltage",
component="sensor",
device=device,
device_class="voltage",
unit="V",
name="Avail Split Meter Voltage",
)
_, config = build_discovery_payload(
entity,
discovery_prefix="homeassistant",
state_prefix="home_automation",
)
assert "availability" in config, "Entity with provides_availability=True must have availability"
avail_topics = [a["topic"] for a in config["availability"]]
assert len(avail_topics) == 1
avail_topic = avail_topics[0]
assert avail_topic.startswith("home_automation/"), (
f"Availability topic must start with 'home_automation/', got {avail_topic!r}"
)
# The /modbus/ path segment must still be present (hardcoded structure preserved).
assert "/modbus/" in avail_topic, (
f"Availability topic must contain /modbus/, got {avail_topic!r}"
)
assert avail_topic.endswith("/availability"), (
f"Availability topic must end with /availability, got {avail_topic!r}"
)
def test_build_discovery_payload_custom_state_prefix_changes_state_not_config() -> None:
"""Custom ha_state_topic_prefix changes state/availability topics but NOT config topic."""
from app.integrations.expose import DeviceInfo, ExposableEntity
from app.services.ha_discovery import build_discovery_payload
uuid_val = "cccccccc-5555-6666-7777-888888888888"
device = DeviceInfo(identifiers=("modbus", uuid_val), name="Custom Prefix Meter")
entity = ExposableEntity(
key=f"modbus.{uuid_val}.current",
component="sensor",
device=device,
device_class="current",
unit="A",
name="Custom Prefix Meter Current",
)
topic_default, config_default = build_discovery_payload(
entity,
discovery_prefix="homeassistant",
state_prefix="home_automation",
)
topic_custom, config_custom = build_discovery_payload(
entity,
discovery_prefix="homeassistant",
state_prefix="custom_prefix",
)
# Config topic must be identical (discovery_prefix unchanged).
assert topic_default == topic_custom, (
f"Config topic must not change with different state_prefix. "
f"default={topic_default!r}, custom={topic_custom!r}"
)
# State topic must differ.
assert config_default["state_topic"] != config_custom["state_topic"], (
"state_topic must change when state_prefix changes"
)
assert config_custom["state_topic"].startswith("custom_prefix/"), (
f"Custom state_topic must start with 'custom_prefix/', got {config_custom['state_topic']!r}"
)
# Availability topic must differ.
avail_default = config_default["availability"][0]["topic"]
avail_custom = config_custom["availability"][0]["topic"]
assert avail_default != avail_custom, (
"Availability topic must change when state_prefix changes"
)
assert avail_custom.startswith("custom_prefix/"), (
f"Custom avail topic must start with 'custom_prefix/', got {avail_custom!r}"
)
def test_config_ha_state_topic_prefix_default_and_configurable() -> None:
"""ha_state_topic_prefix must default to 'home_automation' and be configurable."""
from app.config import Settings
# Default value.
s = Settings(_env_file=None)
assert s.ha_state_topic_prefix == "home_automation", (
f"Default ha_state_topic_prefix must be 'home_automation', got {s.ha_state_topic_prefix!r}"
)
# Configurable via constructor.
s2 = Settings(_env_file=None, ha_state_topic_prefix="custom_state")
assert s2.ha_state_topic_prefix == "custom_state", (
f"ha_state_topic_prefix must be overridable, got {s2.ha_state_topic_prefix!r}"
)
def test_config_page_ha_state_topic_prefix_in_discovery_section() -> None:
"""HA_STATE_TOPIC_PREFIX must appear in the 'Home Assistant Discovery' config section."""
from app.services.config_page import CONFIG_FIELDS
ha_state_field = next(
(f for f in CONFIG_FIELDS if f.env_name == "HA_STATE_TOPIC_PREFIX"), None
)
assert ha_state_field is not None, (
"HA_STATE_TOPIC_PREFIX must be registered in CONFIG_FIELDS"
)
assert ha_state_field.section == "Home Assistant Discovery", (
f"HA_STATE_TOPIC_PREFIX must be in 'Home Assistant Discovery' section, "
f"got {ha_state_field.section!r}"
)
assert ha_state_field.setting_attr == "ha_state_topic_prefix", (
f"setting_attr must be 'ha_state_topic_prefix', got {ha_state_field.setting_attr!r}"
)
def test_settings_payload_includes_ha_state_topic_prefix() -> None:
"""_settings_payload must include ha_state_topic_prefix so it can be round-tripped."""
from app.config import Settings
from app.services.config_page import _settings_payload
s = Settings(_env_file=None, ha_state_topic_prefix="my_prefix")
payload = _settings_payload(s)
assert "ha_state_topic_prefix" in payload, (
"_settings_payload must include 'ha_state_topic_prefix'"
)
assert payload["ha_state_topic_prefix"] == "my_prefix", (
f"Expected 'my_prefix', got {payload['ha_state_topic_prefix']!r}"
)