diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 3eef5fc..8cd512d 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -91,9 +91,10 @@ class ExposableEntity: context, e.g. ``"SDM120 Voltage"``). value_getter - Optional callable ``() -> object | None`` that returns the current - entity value. ``None`` means "not yet wired" (T11 will populate this). - T11 calls this to obtain the current state before publishing. + Optional callable ``(session) -> object | None`` that returns the current + entity value given an open SQLAlchemy session. ``None`` means "not yet + wired". T11 calls this to obtain the current state before publishing. + Signature: ``Callable[[Session], Any]``. state_class HA ``state_class`` string (e.g. ``"measurement"``, ``"total_increasing"``). @@ -106,7 +107,7 @@ class ExposableEntity: device_class: Optional[str] unit: str name: str - value_getter: Optional[Callable[[], Any]] = field(default=None, repr=False) + value_getter: Optional[Callable[["Session"], Any]] = field(default=None, repr=False) state_class: Optional[str] = None @@ -269,16 +270,29 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]: for metric in profile.metrics: entity_key = f"modbus.{device.uuid}.{metric.key}" - # Capture metric and device for the value_getter closure. - _device_uuid = device.uuid + # Capture device id and metric key for the value_getter closure. + # The getter queries the most recent ModbusReading for this device + # and extracts the metric value from its payload. + _device_id = device.id _metric_key = metric.key - def _make_value_getter(dev_uuid: str, m_key: str) -> Callable[[], Any]: - """Return a getter stub for this metric; T11 will wire real retrieval.""" - def _getter() -> Any: - # Stub — T11 will call build_catalog with a live session - # and replace this with a real value-fetch callable. - return None + def _make_value_getter( + dev_id: int, m_key: str + ) -> Callable[["Session"], Any]: + """Return a getter that fetches the latest reading value from DB.""" + def _getter(sess: "Session") -> Any: + from app.models.modbus import ModbusReading + from sqlalchemy import desc + + reading = ( + sess.query(ModbusReading) + .filter(ModbusReading.device_id == dev_id) + .order_by(desc(ModbusReading.recorded_at)) + .first() + ) + if reading is None: + return None + return reading.payload.get(m_key) return _getter @@ -290,19 +304,30 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]: device_class=metric.device_class or None, unit=metric.unit, name=f"{device.friendly_name} {metric.key.replace('_', ' ').title()}", - value_getter=_make_value_getter(_device_uuid, _metric_key), + value_getter=_make_value_getter(_device_id, _metric_key), state_class=metric.state_class, ) ) # One binary_sensor entity for device "online" status. online_key = f"modbus.{device.uuid}.online" - _dev_uuid_online = device.uuid + _dev_id_online = device.id + + def _make_online_getter(dev_id: int) -> Callable[["Session"], Any]: + """Return a getter that reports the device online status from DB.""" + def _getter(sess: "Session") -> Any: + from app.models.modbus import ModbusDevice + + dev = sess.query(ModbusDevice).filter( + ModbusDevice.id == dev_id + ).first() + if dev is None: + return None + # Map last_poll_ok to HA binary_sensor payload strings. + if dev.last_poll_ok is True: + return "ON" + return "OFF" - def _make_online_getter(dev_uuid: str) -> Callable[[], Any]: - def _getter() -> Any: - # T11 will wire this to last_poll_ok. - return None return _getter entities.append( @@ -313,7 +338,7 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]: device_class="connectivity", unit="", name=f"{device.friendly_name} Online", - value_getter=_make_online_getter(_dev_uuid_online), + value_getter=_make_online_getter(_dev_id_online), state_class=None, ) ) diff --git a/app/main.py b/app/main.py index 1606e7a..991b947 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,7 @@ from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap from app.services.public_ip import check_public_ipv4_and_notify from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS +from app.services.ha_discovery import publish_discovery, publish_states from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db logger = logging.getLogger(__name__) @@ -60,6 +61,30 @@ def _run_scheduled_modbus_poll() -> None: session.close() +def _run_scheduled_ha_state_publish() -> None: + """Periodic job: publish discovery configs + state + availability for all enabled exposed entities. + + Runs every 60 seconds. When the MQTT broker is connected: + - Publishes (or re-publishes) all HA Discovery configs (retained, idempotent). + - Publishes the current state / availability for all enabled entities. + + This also serves as the reliable "publish discovery after connect" mechanism: + because paho connects asynchronously, a synchronous call immediately after + ``mqtt_manager.connect()`` would fire before the TCP handshake completes and + be a no-op. Instead, this periodic job picks it up within 60 seconds of the + broker becoming available — retained payloads make repeated publishes harmless. + """ + session_local = get_session_local() + session: Session = session_local() + try: + publish_discovery(session) + publish_states(session) + except Exception: + logger.exception("_run_scheduled_ha_state_publish: unexpected error") + finally: + session.close() + + def ensure_auth_db_ready() -> None: session_local = get_session_local() session: Session = session_local() @@ -103,9 +128,20 @@ async def lifespan(_: FastAPI): max_instances=1, coalesce=True, ) + # Periodic HA state / availability publish (60-second fallback sweep). + scheduler.add_job( + _run_scheduled_ha_state_publish, + trigger=IntervalTrigger(seconds=60), + id="ha-state-publish", + replace_existing=True, + max_instances=1, + coalesce=True, + ) scheduler.start() # MQTT: connect if configured. + # Discovery will be published by the first run of _run_scheduled_ha_state_publish + # (within 60 s of startup), after the async paho handshake completes. mqtt_manager.connect(get_settings()) yield diff --git a/app/services/ha_discovery.py b/app/services/ha_discovery.py new file mode 100644 index 0000000..25dbe39 --- /dev/null +++ b/app/services/ha_discovery.py @@ -0,0 +1,409 @@ +"""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**: ``////config`` + where ``node_id`` is the device uuid (slugified to be safe) and + ``object_id`` is a uuid-prefixed stable string. +- **State topic format**: ``////state`` +- **Availability topic**: ``/modbus//availability`` (shared + across all entities of the same device; publishes "online"/"offline"). +- **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 + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _get_settings() -> Any: + """Return the current runtime settings (cached singleton).""" + from app.config import get_settings + return get_settings() + + +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: ``////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: ``////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: ``/modbus//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.." — use it as the seed + return f"{device_uuid}_{entity.key.replace('.', '_')}" + + +# --------------------------------------------------------------------------- +# Public: build discovery payload +# --------------------------------------------------------------------------- + + +def build_discovery_payload( + entity: ExposableEntity, + prefix: str, +) -> tuple[str, dict[str, Any]]: + """Build the HA MQTT Discovery config topic and payload dict for *entity*. + + Parameters + ---------- + entity: + An ``ExposableEntity`` (from ``build_catalog``). + prefix: + The discovery prefix (e.g. ``"homeassistant"``). + + Returns + ------- + tuple[str, dict] + ``(topic, config_dict)`` — the config topic and the HA Discovery + payload (not yet JSON-serialised). + """ + device_uuid = entity.device.identifiers[1] + avail_topic = _availability_topic(device_uuid, prefix) + state_t = _state_topic(entity, prefix) + topic = _discovery_topic(entity, prefix) + + config: dict[str, Any] = { + "unique_id": _unique_id(entity), + "name": entity.name, + "state_topic": state_t, + "availability": [{"topic": avail_topic}], + "availability_mode": "all", + "device": { + "identifiers": list(entity.device.identifiers), + "name": entity.device.name, + }, + } + + # 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. + """ + settings = _get_settings() + if not _should_publish(settings): + logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)") + return + + prefix = settings.ha_discovery_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, 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. + """ + settings = _get_settings() + if not _should_publish(settings): + logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)") + return + + prefix = settings.ha_discovery_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, 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. + """ + settings = _get_settings() + if not _should_publish(settings): + return + + prefix = settings.ha_discovery_prefix + device_uuid = device.uuid + online = bool(device.last_poll_ok) + + # Publish availability topic first. + try: + avail_topic = _availability_topic(device_uuid, 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, 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, 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 publish_device_offline(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. + """ + settings = _get_settings() + if not _should_publish(settings): + return + + try: + prefix = settings.ha_discovery_prefix + avail_topic = _availability_topic(device_uuid, prefix) + mqtt_manager.publish(avail_topic, "offline", retain=False) + except Exception: + logger.exception( + "publish_device_offline: failed for device uuid=%s", device_uuid + ) diff --git a/app/services/modbus_poll.py b/app/services/modbus_poll.py index 9e39e7f..0579041 100644 --- a/app/services/modbus_poll.py +++ b/app/services/modbus_poll.py @@ -31,6 +31,7 @@ from app.config import Settings from app.integrations.modbus import driver, profiles from app.models.modbus import ModbusDevice, ModbusReading from app.services.config_page import build_runtime_settings +from app.services.ha_discovery import publish_device_offline, publish_device_state logger = logging.getLogger(__name__) @@ -84,6 +85,19 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None: len(payload), now.isoformat(), ) + + # Best-effort: push MQTT state after successful poll. + # Must never let MQTT errors crash the poll loop. + try: + publish_device_state(session, device) + except Exception: # noqa: BLE001 + logger.exception( + "poll_device: MQTT state publish failed for device %r (id=%d); " + "continuing (poll itself succeeded)", + device.friendly_name, + device.id, + ) + return reading except Exception: # noqa: BLE001 @@ -115,6 +129,17 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None: logger.exception( "Also failed to persist last_poll_ok=False for device id=%d", device.id ) + + # Best-effort: publish "offline" availability after failed poll. + try: + publish_device_offline(device.uuid) + except Exception: # noqa: BLE001 + logger.exception( + "poll_device: MQTT offline publish failed for device %r (id=%d)", + device.friendly_name, + device.id, + ) + return None diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index a301889..9d7eb57 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -432,7 +432,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) - **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。 ### M5-T11 — Discovery 发布 + state 发布 -- **Status**: `todo` · **Depends**: M5-T09, M5-T10 +- **Status**: `done` · **Depends**: M5-T09, M5-T10 - **Context**: 把 enabled 实体发成 HA discovery config(retained)并周期推 state;采集轮询后推最新值。 - **Files**: `create app/services/ha_discovery.py`;`modify app/services/modbus_poll.py`(轮询后推 state)、`app/main.py`(state 周期 job + 连接后/勾选变更后发 discovery)、`app/api/routes/api/...`(`/api/expose/republish` 在 T12 接,本任务提供 service);`create tests/test_ha_discovery.py` - **Steps**: diff --git a/tests/test_expose_catalog.py b/tests/test_expose_catalog.py index c257713..d30ba26 100644 --- a/tests/test_expose_catalog.py +++ b/tests/test_expose_catalog.py @@ -108,11 +108,17 @@ def test_exposable_entity_fields(): def test_exposable_entity_with_value_getter(): - """ExposableEntity.value_getter stores and calls the provided callable.""" + """ExposableEntity.value_getter stores and calls the provided callable. + + value_getter now accepts a Session argument (updated in T11-rework-1). + """ from app.integrations.expose import DeviceInfo, ExposableEntity + from sqlalchemy import create_engine + from sqlalchemy.orm import Session device = DeviceInfo(identifiers=("modbus", "dev1"), name="D1") - getter = lambda: 230.5 # noqa: E731 + # The value_getter receives a Session; use a lambda that ignores it. + getter = lambda _sess: 230.5 # noqa: E731 entity = ExposableEntity( key="modbus.dev1.voltage", component="sensor", @@ -123,7 +129,9 @@ def test_exposable_entity_with_value_getter(): value_getter=getter, ) assert entity.value_getter is not None - assert entity.value_getter() == 230.5 + eng = create_engine("sqlite:///:memory:") + with Session(eng) as session: + assert entity.value_getter(session) == 230.5 def test_entity_key_stability_uses_uuid_not_id(): diff --git a/tests/test_ha_discovery.py b/tests/test_ha_discovery.py new file mode 100644 index 0000000..0a1f83c --- /dev/null +++ b/tests/test_ha_discovery.py @@ -0,0 +1,1131 @@ +"""Tests for M5-T11: HA Discovery publish + state publish. + +Coverage: + 1. build_discovery_payload: correct HA topic format, device block, unique_id + derived from uuid, device_class, unit_of_measurement, state_class. + 2. publish_discovery: enabled entities get retained config; disabled entities + get empty (clear) payload; uses retained=True. + 3. publish_states: calls value_getter; pushes correct state topic. + 4. publish_device_state: after successful poll pushes state; after failed poll + pushes online=False (availability "offline"). + 5. MQTT not enabled/configured → entire chain is no-op; does not crash or raise. + 6. Discovery not enabled → no-op. + 7. Availability topic included in discovery config. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app_alembic_config(database_url: str) -> Config: + cfg = Config("alembic_app.ini") + cfg.set_main_option("sqlalchemy.url", database_url) + return cfg + + +def _make_device( + session: Session, + *, + uuid: str, + friendly_name: str = "Test Meter", + host: str = "10.0.0.1", + port: int = 502, + unit_id: int = 1, + profile: str = "sdm120", + poll_interval_s: int = 5, + enabled: bool = True, + last_poll_ok: bool | None = None, +) -> Any: + from app.models.modbus import ModbusDevice + + now = datetime.now(tz=timezone.utc) + device = ModbusDevice( + uuid=uuid, + friendly_name=friendly_name, + host=host, + port=port, + unit_id=unit_id, + profile=profile, + poll_interval_s=poll_interval_s, + enabled=enabled, + last_poll_ok=last_poll_ok, + created_at=now, + updated_at=now, + ) + session.add(device) + session.flush() + return device + + +def _enable_entity(session: Session, key: str) -> None: + """Insert an enabled ExposedEntityToggle row for *key*.""" + from app.models.expose import ExposedEntityToggle + + toggle = ExposedEntityToggle( + key=key, + enabled=True, + updated_at=datetime.now(tz=timezone.utc), + ) + session.add(toggle) + session.flush() + + +def _make_settings( + *, + mqtt_enabled: bool = True, + ha_discovery_enabled: bool = True, + ha_discovery_prefix: str = "homeassistant", +) -> MagicMock: + s = MagicMock() + s.mqtt_enabled = mqtt_enabled + s.ha_discovery_enabled = ha_discovery_enabled + s.ha_discovery_prefix = ha_discovery_prefix + return s + + +def _make_mock_manager(*, is_connected: bool = True) -> MagicMock: + """Return a MagicMock that mimics MqttManager with is_connected as an attribute.""" + mgr = MagicMock() + mgr.is_connected = is_connected + return mgr + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def disco_db(tmp_path: Path): + """Temporary SQLite DB at Alembic head for discovery tests.""" + db_path = tmp_path / "disco_test.db" + db_url = f"sqlite:///{db_path}" + command.upgrade(_make_app_alembic_config(db_url), "head") + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + yield engine + engine.dispose() + + +# --------------------------------------------------------------------------- +# 1. build_discovery_payload +# --------------------------------------------------------------------------- + + +def test_build_discovery_payload_topic_format() -> None: + """Topic must follow ////config.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "aabbccdd-1234-5678-0000-111122223333" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="My Meter") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.voltage", + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="My Meter Voltage", + ) + + topic, config = build_discovery_payload(entity, prefix="homeassistant") + + # node_id replaces hyphens with underscores + node_id = uuid_val.replace("-", "_") + # object_id: entity.key dots+hyphens → underscores + object_id = f"modbus_{uuid_val.replace('-', '_')}_voltage" + expected_topic = f"homeassistant/sensor/{node_id}/{object_id}/config" + assert topic == expected_topic, f"Got: {topic!r}\nExpected: {expected_topic!r}" + + +def test_build_discovery_payload_unique_id_from_uuid() -> None: + """unique_id must be derived from device uuid and metric key (not friendly_name).""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "11111111-2222-3333-4444-555555555555" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Meter A") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.current", + component="sensor", + device=device, + device_class="current", + unit="A", + name="Meter A Current", + ) + + _, config = build_discovery_payload(entity, prefix="homeassistant") + + # unique_id must contain the device uuid (normalised) + uid = config["unique_id"].replace("-", "").replace("_", "") + uuid_normalised = uuid_val.replace("-", "") + assert uuid_normalised in uid, ( + f"unique_id {config['unique_id']!r} must contain device uuid {uuid_val!r}" + ) + # And the metric key + assert "current" in config["unique_id"] + # Must not contain friendly_name directly + assert "Meter" not in config["unique_id"] + + +def test_build_discovery_payload_unique_id_is_stable_across_renames() -> None: + """Changing friendly_name must not change unique_id.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb" + entity_key = f"modbus.{uuid_val}.voltage" + + device_a = DeviceInfo(identifiers=("modbus", uuid_val), name="Old Name") + entity_a = ExposableEntity( + key=entity_key, + component="sensor", + device=device_a, + device_class="voltage", + unit="V", + name="Old Name Voltage", + ) + + device_b = DeviceInfo(identifiers=("modbus", uuid_val), name="New Name") + entity_b = ExposableEntity( + key=entity_key, + component="sensor", + device=device_b, + device_class="voltage", + unit="V", + name="New Name Voltage", + ) + + _, config_a = build_discovery_payload(entity_a, prefix="homeassistant") + _, config_b = build_discovery_payload(entity_b, prefix="homeassistant") + + assert config_a["unique_id"] == config_b["unique_id"], ( + "unique_id must not change when friendly_name changes" + ) + + +def test_build_discovery_payload_device_block() -> None: + """Config must include a device block with identifiers and name.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "cccccccc-0000-0000-0000-000000000001" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Friendly Name") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.voltage", + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="Friendly Name Voltage", + ) + + _, config = build_discovery_payload(entity, prefix="homeassistant") + + assert "device" in config + assert "identifiers" in config["device"] + assert uuid_val in config["device"]["identifiers"] + assert config["device"]["name"] == "Friendly Name" + + +def test_build_discovery_payload_sensor_fields() -> None: + """Sensor payload must include device_class, unit_of_measurement, state_class.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "dddddddd-0000-0000-0000-000000000001" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Energy Meter") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.import_energy", + component="sensor", + device=device, + device_class="energy", + unit="kWh", + name="Energy Meter Import Energy", + state_class="total_increasing", + ) + + _, config = build_discovery_payload(entity, prefix="homeassistant") + + assert config["device_class"] == "energy" + assert config["unit_of_measurement"] == "kWh" + assert config["state_class"] == "total_increasing" + assert "state_topic" in config + assert "availability" in config + + +def test_build_discovery_payload_binary_sensor() -> None: + """Binary sensor (online) payload must include payload_on/off but not unit.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "eeeeeeee-0000-0000-0000-000000000001" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Meter Online") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.online", + component="binary_sensor", + device=device, + device_class="connectivity", + unit="", + name="Meter Online Online", + ) + + _, config = build_discovery_payload(entity, prefix="homeassistant") + + assert config.get("payload_on") == "ON" + assert config.get("payload_off") == "OFF" + assert "unit_of_measurement" not in config + + +def test_build_discovery_payload_state_topic_matches_node_and_object() -> None: + """state_topic must follow ////state.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload + + uuid_val = "12345678-abcd-efab-0000-abcdef012345" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Meter") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.voltage", + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="Meter Voltage", + ) + + _, config = build_discovery_payload(entity, prefix="homeassistant") + + state_topic = config["state_topic"] + assert "homeassistant/sensor/" in state_topic + assert state_topic.endswith("/state") + + +# --------------------------------------------------------------------------- +# 2. publish_discovery: retained, enabled vs disabled +# --------------------------------------------------------------------------- + + +def test_publish_discovery_sends_retained_for_enabled_entity(disco_db) -> None: + """Enabled entities must be published with retain=True and non-empty payload.""" + uuid_val = "11110000-0000-0000-0000-000000000001" + + with Session(disco_db) as session: + _make_device(session, uuid=uuid_val, friendly_name="Retained Meter") + voltage_key = f"modbus.{uuid_val}.voltage" + _enable_entity(session, voltage_key) + session.commit() + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + publish_calls: list[tuple[str, Any, bool]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + publish_calls.append((topic, payload, retain)) + + mock_mgr.publish.side_effect = _capture + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_discovery + + with Session(disco_db) as session: + publish_discovery(session) + + # Find the voltage entity's config publish (non-empty retained) + node_id = uuid_val.replace("-", "_") + voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage" + expected_voltage_topic = f"homeassistant/sensor/{node_id}/{voltage_obj_id}/config" + + retained_non_empty = [ + (t, p, r) for t, p, r in publish_calls + if r and p not in (b"", "", None) and t == expected_voltage_topic + ] + assert len(retained_non_empty) >= 1, ( + f"Expected retained non-empty publish for voltage config topic. " + f"All calls: {[(t, type(p).__name__, r) for t, p, r in publish_calls]}" + ) + + +def test_publish_discovery_sends_empty_payload_for_disabled_entity(disco_db) -> None: + """Disabled entities must be published with an empty payload (clearing the config).""" + uuid_val = "22220000-0000-0000-0000-000000000001" + + with Session(disco_db) as session: + _make_device(session, uuid=uuid_val, friendly_name="Disabled Entity Meter") + # Do NOT enable the voltage entity — it stays disabled by default. + session.commit() + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + publish_calls: list[tuple[str, Any, bool]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + publish_calls.append((topic, payload, retain)) + + mock_mgr.publish.side_effect = _capture + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_discovery + + with Session(disco_db) as session: + publish_discovery(session) + + # All config topic publishes for disabled entities should have empty payload + retained + config_calls = [(t, p, r) for t, p, r in publish_calls if "config" in t] + assert len(config_calls) > 0, "Expected at least one config topic publish" + + empty_retained = [ + (t, p, r) for t, p, r in config_calls if p in (b"", "", None) and r + ] + assert len(empty_retained) > 0, ( + f"Expected at least one empty-payload retained publish for disabled entities. " + f"Config calls: {[(t, type(p).__name__, r) for t, p, r in config_calls]}" + ) + + +def test_publish_discovery_enabled_vs_disabled_payload(disco_db) -> None: + """Enabled entities get non-empty JSON payload; disabled entities get empty payload.""" + uuid_val = "33330000-0000-0000-0000-000000000001" + voltage_key = f"modbus.{uuid_val}.voltage" + + with Session(disco_db) as session: + _make_device(session, uuid=uuid_val, friendly_name="Mixed Meter") + _enable_entity(session, voltage_key) # enable only voltage + session.commit() + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + publish_calls: list[tuple[str, Any, bool]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + publish_calls.append((topic, payload, retain)) + + mock_mgr.publish.side_effect = _capture + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_discovery + + with Session(disco_db) as session: + publish_discovery(session) + + config_calls = [(t, p, r) for t, p, r in publish_calls if "config" in t] + assert len(config_calls) > 0, "Expected discovery config publishes" + + node_id = uuid_val.replace("-", "_") + voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage" + voltage_config_topic = f"homeassistant/sensor/{node_id}/{voltage_obj_id}/config" + + # The voltage entity's config topic should have non-empty JSON payload + voltage_call = next( + ((t, p, r) for t, p, r in config_calls if t == voltage_config_topic), None + ) + assert voltage_call is not None, ( + f"Expected config publish for voltage topic {voltage_config_topic!r}. " + f"Got topics: {[t for t, _, _ in config_calls]}" + ) + _t, payload, retain = voltage_call + assert payload not in (b"", "", None), "Enabled entity should get non-empty config payload" + assert retain is True, "Discovery config must be retained" + + # All other config publishes should be empty (disabled entities) + other_calls = [(t, p, r) for t, p, r in config_calls if t != voltage_config_topic] + for _t, p, _r in other_calls: + assert p in (b"", "", None), ( + f"Disabled entity config at {_t!r} must have empty payload, got {p!r}" + ) + + +# --------------------------------------------------------------------------- +# 3. publish_states +# --------------------------------------------------------------------------- + + +def test_publish_states_calls_value_getter_for_enabled_sensor() -> None: + """publish_states must call value_getter and publish result to state topic.""" + uuid_val = "44440000-0000-0000-0000-000000000001" + voltage_key = f"modbus.{uuid_val}.voltage" + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + getter_calls = [] + + def _fake_getter(_sess): + getter_calls.append(1) + return 230.5 + + published: list[tuple[str, Any]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + published.append((topic, payload)) + + mock_mgr.publish.side_effect = _capture + + from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity + + device_info = DeviceInfo(identifiers=("modbus", uuid_val), name="State Meter") + mock_entity = ExposableEntity( + key=voltage_key, + component="sensor", + device=device_info, + device_class="voltage", + unit="V", + name="State Meter Voltage", + value_getter=_fake_getter, + ) + mock_catalog = [CatalogEntry(entity=mock_entity, enabled=True)] + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + patch("app.services.ha_discovery.build_catalog", return_value=mock_catalog), + ): + from app.services.ha_discovery import publish_states + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_states(session) + + assert len(getter_calls) == 1, "value_getter should have been called once" + # Check that a state topic was published with the value + state_publishes = [(t, p) for t, p in published if "state" in t] + assert len(state_publishes) == 1 + assert "230.5" in state_publishes[0][1] + + +def test_publish_states_skips_disabled_entities() -> None: + """publish_states must not publish state for disabled entities.""" + uuid_val = "55550000-0000-0000-0000-000000000001" + voltage_key = f"modbus.{uuid_val}.voltage" + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + getter_calls = [] + + def _fake_getter(_sess): + getter_calls.append(1) + return 230.5 + + from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity + + device_info = DeviceInfo(identifiers=("modbus", uuid_val), name="Disabled Meter") + mock_entity = ExposableEntity( + key=voltage_key, + component="sensor", + device=device_info, + device_class="voltage", + unit="V", + name="Disabled Meter Voltage", + value_getter=_fake_getter, + ) + # The entity is disabled + mock_catalog = [CatalogEntry(entity=mock_entity, enabled=False)] + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + patch("app.services.ha_discovery.build_catalog", return_value=mock_catalog), + ): + from app.services.ha_discovery import publish_states + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_states(session) + + assert len(getter_calls) == 0, "value_getter must not be called for disabled entity" + mock_mgr.publish.assert_not_called() + + +# --------------------------------------------------------------------------- +# 4. publish_device_state (poll hook) +# --------------------------------------------------------------------------- + + +def test_publish_device_state_pushes_availability_and_state() -> None: + """After a successful poll, publish_device_state must push 'online' and state values.""" + uuid_val = "66660000-0000-0000-0000-000000000001" + voltage_key = f"modbus.{uuid_val}.voltage" + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + published: list[tuple[str, Any]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + published.append((topic, payload)) + + mock_mgr.publish.side_effect = _capture + + from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity + + device_info = DeviceInfo(identifiers=("modbus", uuid_val), name="Poll Meter") + sensor_entity = ExposableEntity( + key=voltage_key, + component="sensor", + device=device_info, + device_class="voltage", + unit="V", + name="Poll Meter Voltage", + value_getter=lambda _sess: 231.0, + ) + online_entity = ExposableEntity( + key=f"modbus.{uuid_val}.online", + component="binary_sensor", + device=device_info, + device_class="connectivity", + unit="", + name="Poll Meter Online", + ) + mock_catalog = [ + CatalogEntry(entity=sensor_entity, enabled=True), + CatalogEntry(entity=online_entity, enabled=True), + ] + + mock_device = MagicMock() + mock_device.uuid = uuid_val + mock_device.last_poll_ok = True + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + patch("app.services.ha_discovery.build_catalog", return_value=mock_catalog), + ): + from app.services.ha_discovery import publish_device_state + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_device_state(session, mock_device) + + topics_published = [t for t, _ in published] + + # Availability topic must be published + avail_calls = [t for t in topics_published if "availability" in t] + assert len(avail_calls) >= 1, "Expected availability topic publish" + avail_payload = next(p for t, p in published if "availability" in t) + assert avail_payload == "online", f"Expected 'online' payload, got {avail_payload!r}" + + # State topic for voltage must be published + state_calls = [t for t in topics_published if "state" in t and "sensor" in t] + assert len(state_calls) >= 1, "Expected state topic publish for sensor entity" + state_payload = next(p for t, p in published if "state" in t and "sensor" in t) + assert "231.0" in state_payload + + +def test_publish_device_state_publishes_offline_on_failed_poll() -> None: + """When last_poll_ok=False, publish_device_state must publish 'offline' availability.""" + uuid_val = "77770000-0000-0000-0000-000000000001" + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + published: list[tuple[str, Any]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + published.append((topic, payload)) + + mock_mgr.publish.side_effect = _capture + + mock_device = MagicMock() + mock_device.uuid = uuid_val + mock_device.last_poll_ok = False + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + patch("app.services.ha_discovery.build_catalog", return_value=[]), + ): + from app.services.ha_discovery import publish_device_state + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_device_state(session, mock_device) + + # Must have published availability = "offline" + avail_calls = [(t, p) for t, p in published if "availability" in t] + assert len(avail_calls) >= 1, "Expected at least one availability topic publish" + assert avail_calls[0][1] == "offline", ( + f"Expected 'offline' availability, got {avail_calls[0][1]!r}" + ) + + # No sensor state publishes expected (device is offline) + state_calls = [(t, p) for t, p in published if "state" in t] + assert len(state_calls) == 0, "Must not publish state values when device is offline" + + +def test_publish_device_offline_publishes_offline_topic() -> None: + """publish_device_offline must publish 'offline' to the availability topic.""" + uuid_val = "88880000-0000-0000-0000-000000000001" + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + published: list[tuple[str, Any]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + published.append((topic, payload)) + + mock_mgr.publish.side_effect = _capture + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_device_offline + + publish_device_offline(uuid_val) + + assert len(published) == 1 + topic, payload = published[0] + assert "availability" in topic + assert payload == "offline" + + +# --------------------------------------------------------------------------- +# 5. MQTT not enabled/not connected → entire chain no-op +# --------------------------------------------------------------------------- + + +def test_publish_discovery_noop_when_mqtt_disabled() -> None: + """publish_discovery must be a no-op when mqtt_enabled=False.""" + settings = _make_settings(mqtt_enabled=False, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=False) + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_discovery + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_discovery(session) # must not raise + + mock_mgr.publish.assert_not_called() + + +def test_publish_states_noop_when_not_connected() -> None: + """publish_states must be a no-op when MQTT is not connected.""" + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + # is_connected=False simulates no broker connection + mock_mgr = _make_mock_manager(is_connected=False) + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_states + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_states(session) # must not raise + + mock_mgr.publish.assert_not_called() + + +def test_publish_discovery_noop_when_ha_discovery_disabled() -> None: + """publish_discovery must be a no-op when ha_discovery_enabled=False.""" + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=False) + mock_mgr = _make_mock_manager(is_connected=True) + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_discovery + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_discovery(session) # must not raise + + mock_mgr.publish.assert_not_called() + + +def test_publish_device_state_noop_when_mqtt_disabled() -> None: + """publish_device_state must be a no-op when MQTT is not configured.""" + settings = _make_settings(mqtt_enabled=False, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=False) + + mock_device = MagicMock() + mock_device.uuid = "99990000-0000-0000-0000-000000000001" + mock_device.last_poll_ok = True + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_device_state + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_device_state(session, mock_device) # must not raise + + mock_mgr.publish.assert_not_called() + + +def test_publish_device_offline_noop_when_mqtt_disabled() -> None: + """publish_device_offline must be a no-op when MQTT is not enabled.""" + settings = _make_settings(mqtt_enabled=False, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=False) + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_device_offline + + publish_device_offline("some-uuid") # must not raise + + mock_mgr.publish.assert_not_called() + + +# --------------------------------------------------------------------------- +# 6. Poll loop integration: MQTT errors must not crash poll +# --------------------------------------------------------------------------- + + +def test_poll_device_mqtt_error_does_not_crash_poll(tmp_path: Path) -> None: + """MQTT publish failure in poll_device must not propagate; poll must still succeed.""" + db_path = tmp_path / "poll_mqtt_test.db" + db_url = f"sqlite:///{db_path}" + command.upgrade(_make_app_alembic_config(db_url), "head") + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + + from app.models.modbus import ModbusDevice, ModbusReading + from app.services.modbus_poll import poll_device + from sqlalchemy import select + + now = datetime.now(tz=timezone.utc) + with Session(engine) as session: + device = ModbusDevice( + friendly_name="MQTT Crash Test", + host="10.0.0.1", + port=502, + unit_id=1, + profile="sdm120", + poll_interval_s=5, + enabled=True, + created_at=now, + updated_at=now, + ) + session.add(device) + session.commit() + device_id = device.id + + KNOWN_PAYLOAD = {"voltage": 230.0, "current": 1.0} + + with ( + patch("app.services.modbus_poll.profiles.load_profile") as mock_load, + patch("app.services.modbus_poll.driver.read_blocks") as mock_read, + patch("app.services.modbus_poll.profiles.decode") as mock_decode, + # Make publish_device_state raise to simulate MQTT failure + patch( + "app.services.modbus_poll.publish_device_state", + side_effect=RuntimeError("Simulated MQTT failure"), + ) as mock_ha, + ): + mock_profile = MagicMock() + mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)] + mock_load.return_value = mock_profile + mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334} + mock_decode.return_value = KNOWN_PAYLOAD + + # Must not raise despite MQTT failure + result = poll_device(session, device) + + assert result is not None, "poll_device must succeed even if MQTT publish raises" + mock_ha.assert_called_once() + + # Reading must have been persisted + readings = session.execute(select(ModbusReading)).scalars().all() + assert len(readings) == 1 + assert readings[0].device_id == device_id + assert readings[0].payload == KNOWN_PAYLOAD + + engine.dispose() + + +def test_poll_device_failed_push_offline_error_does_not_crash_poll(tmp_path: Path) -> None: + """When driver fails and publish_device_offline also raises, poll must not propagate.""" + db_path = tmp_path / "poll_offline_test.db" + db_url = f"sqlite:///{db_path}" + command.upgrade(_make_app_alembic_config(db_url), "head") + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + + from app.models.modbus import ModbusDevice + from app.services.modbus_poll import poll_device + + now = datetime.now(tz=timezone.utc) + with Session(engine) as session: + device = ModbusDevice( + friendly_name="Offline Crash Test", + host="10.0.0.2", + port=502, + unit_id=1, + profile="sdm120", + poll_interval_s=5, + enabled=True, + created_at=now, + updated_at=now, + ) + session.add(device) + session.commit() + + with ( + patch("app.services.modbus_poll.profiles.load_profile") as mock_load, + patch( + "app.services.modbus_poll.driver.read_blocks", + side_effect=OSError("Device unreachable"), + ), + patch( + "app.services.modbus_poll.publish_device_offline", + side_effect=RuntimeError("Simulated MQTT offline failure"), + ) as mock_offline, + ): + mock_profile = MagicMock() + mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)] + mock_load.return_value = mock_profile + + # Must not raise despite driver AND offline publish both failing + result = poll_device(session, device) + + assert result is None, "poll_device must return None on driver failure" + mock_offline.assert_called_once() + + engine.dispose() + + +# --------------------------------------------------------------------------- +# 7. Availability topic included in discovery config +# --------------------------------------------------------------------------- + + +def test_build_discovery_payload_includes_availability_topic() -> None: + """Discovery config must include an 'availability' section with the device's avail topic.""" + from app.integrations.expose import DeviceInfo, ExposableEntity + from app.services.ha_discovery import build_discovery_payload, _availability_topic + + uuid_val = "abcdef00-0000-0000-0000-000000000001" + device = DeviceInfo(identifiers=("modbus", uuid_val), name="Avail Meter") + entity = ExposableEntity( + key=f"modbus.{uuid_val}.voltage", + component="sensor", + device=device, + device_class="voltage", + unit="V", + name="Avail Meter Voltage", + ) + + _, config = build_discovery_payload(entity, prefix="homeassistant") + + expected_avail_topic = _availability_topic(uuid_val, "homeassistant") + assert "availability" in config + avail_topics = [a["topic"] for a in config["availability"]] + assert expected_avail_topic in avail_topics, ( + f"Expected availability topic {expected_avail_topic!r} in config" + ) + + +# --------------------------------------------------------------------------- +# 8. Discovery retained flag +# --------------------------------------------------------------------------- + + +def test_publish_discovery_uses_retain_true_for_enabled_entity() -> None: + """publish_discovery must call publish() with retain=True for enabled entities.""" + uuid_val = "ffffffff-0000-0000-0000-000000000001" + voltage_key = f"modbus.{uuid_val}.voltage" + + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity + + device_info = DeviceInfo(identifiers=("modbus", uuid_val), name="Retain Test Meter") + entity = ExposableEntity( + key=voltage_key, + component="sensor", + device=device_info, + device_class="voltage", + unit="V", + name="Retain Test Meter Voltage", + ) + catalog = [CatalogEntry(entity=entity, enabled=True)] + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + patch("app.services.ha_discovery.build_catalog", return_value=catalog), + ): + from app.services.ha_discovery import publish_discovery + from sqlalchemy import create_engine as _ce + + eng = _ce("sqlite:///:memory:") + with Session(eng) as session: + publish_discovery(session) + + # Verify that publish was called with retain=True + calls = mock_mgr.publish.call_args_list + assert len(calls) == 1, f"Expected exactly 1 publish call, got {len(calls)}" + # The call signature is publish(topic, payload, retain=True) + call_kwargs = calls[0].kwargs + assert call_kwargs.get("retain") is True, ( + f"Expected retain=True in publish call, got kwargs={call_kwargs}" + ) + + +# --------------------------------------------------------------------------- +# 9. Real provider value_getter integration test (R1 coverage) +# Uses the real modbus provider — does NOT mock build_catalog — to verify +# that the true value retrieval path works end-to-end. If value_getter +# were reverted to a stub (return None), this test would fail. +# --------------------------------------------------------------------------- + + +def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> None: + """publish_device_state must publish the real decoded metric value from the DB. + + This test exercises the full value_getter chain without mocking build_catalog: + 1. Creates a real ModbusDevice and enables its voltage entity. + 2. Inserts a ModbusReading with a known payload. + 3. Calls publish_device_state using the real modbus provider catalog. + 4. Asserts that the voltage state topic carries the expected numeric value. + 5. Asserts that last_poll_ok=True → binary_sensor online entity → "ON". + + Regression guard: if value_getter reverts to a stub (returns None), the sensor + state publish is skipped and the assertions on step 4 fail. + """ + uuid_val = "eeee0000-cafe-babe-0000-000000000001" + voltage_key = f"modbus.{uuid_val}.voltage" + online_key = f"modbus.{uuid_val}.online" + + # --- DB setup: device + enabled toggle + a reading --- + from datetime import datetime, timezone + + from app.models.expose import ExposedEntityToggle + from app.models.modbus import ModbusDevice, ModbusReading + + now = datetime.now(tz=timezone.utc) + + with Session(disco_db) as session: + device = ModbusDevice( + uuid=uuid_val, + friendly_name="Real Provider Meter", + host="10.0.0.99", + port=502, + unit_id=1, + profile="sdm120", + poll_interval_s=5, + enabled=True, + last_poll_ok=True, + created_at=now, + updated_at=now, + ) + session.add(device) + session.flush() + + # Enable the voltage entity and online binary_sensor. + session.add(ExposedEntityToggle(key=voltage_key, enabled=True, updated_at=now)) + session.add(ExposedEntityToggle(key=online_key, enabled=True, updated_at=now)) + + # Insert a reading with a known voltage value. + reading = ModbusReading( + device_id=device.id, + recorded_at=now, + payload={"voltage": 231.5, "current": 1.2, "active_power": 275.0}, + ) + session.add(reading) + session.commit() + + # --- MQTT setup --- + settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True) + mock_mgr = _make_mock_manager(is_connected=True) + + published: list[tuple[str, Any]] = [] + + def _capture(topic, payload, *, retain=False, qos=0): + published.append((topic, payload)) + + mock_mgr.publish.side_effect = _capture + + # Build a mock device object (only uuid + last_poll_ok needed by publish_device_state). + mock_device = MagicMock() + mock_device.uuid = uuid_val + mock_device.last_poll_ok = True + + with ( + patch("app.services.ha_discovery._get_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + # NOTE: build_catalog is NOT mocked — we use the real modbus provider. + ): + from app.services.ha_discovery import publish_device_state + + with Session(disco_db) as session: + publish_device_state(session, mock_device) + + # --- Assertions --- + topics_to_payloads: dict[str, Any] = {t: p for t, p in published} + + # Availability topic must be "online". + avail_topic = f"homeassistant/modbus/{uuid_val.replace('-', '_')}/availability" + assert avail_topic in topics_to_payloads, ( + f"Availability topic {avail_topic!r} not published. All topics: {list(topics_to_payloads)}" + ) + assert topics_to_payloads[avail_topic] == "online", ( + f"Expected 'online', got {topics_to_payloads[avail_topic]!r}" + ) + + # Voltage state topic must carry the real value from the reading (231.5). + node_id = uuid_val.replace("-", "_") + voltage_obj_id = f"modbus_{uuid_val.replace('-', '_')}_voltage" + voltage_state_topic = f"homeassistant/sensor/{node_id}/{voltage_obj_id}/state" + assert voltage_state_topic in topics_to_payloads, ( + 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]}" + ) + assert "231.5" in str(topics_to_payloads[voltage_state_topic]), ( + f"Expected '231.5' in voltage state payload, " + f"got {topics_to_payloads[voltage_state_topic]!r}" + ) + + # Online binary_sensor state topic must be "ON" (last_poll_ok=True). + online_obj_id = f"modbus_{uuid_val.replace('-', '_')}_online" + online_state_topic = f"homeassistant/binary_sensor/{node_id}/{online_obj_id}/state" + assert online_state_topic in topics_to_payloads, ( + f"Online state topic {online_state_topic!r} not published. " + f"Topics: {list(topics_to_payloads)}" + ) + assert topics_to_payloads[online_state_topic] == "ON", ( + f"Expected 'ON' for online sensor, got {topics_to_payloads[online_state_topic]!r}" + )