"""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", ha_state_topic_prefix: str = "home_automation", ) -> MagicMock: s = MagicMock() s.mqtt_enabled = mqtt_enabled s.ha_discovery_enabled = ha_discovery_enabled s.ha_discovery_prefix = ha_discovery_prefix s.ha_state_topic_prefix = ha_state_topic_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, discovery_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, discovery_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, discovery_prefix="homeassistant") _, config_b = build_discovery_payload(entity_b, discovery_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, discovery_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, discovery_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, discovery_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, discovery_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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_settings", return_value=settings), patch("app.services.ha_discovery.mqtt_manager", mock_mgr), ): from app.services.ha_discovery import publish_device_offline from unittest.mock import MagicMock as _MagicMock publish_device_offline(_MagicMock(), 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_should_publish_true_when_db_enabled_bootstrap_disabled(disco_db) -> None: """_should_publish must use DB-merged settings: if DB has mqtt_enabled=True and ha_discovery_enabled=True (bootstrap has both False), publish should proceed. This is the core regression test for Issue 2: bare get_settings() returns bootstrap (False); build_runtime_settings returns DB-merged (True). """ from app.services.config_page import build_runtime_settings from app.services.ha_discovery import _should_publish from app.models.config import AppConfigEntry from datetime import UTC, datetime now = datetime.now(UTC) # Write MQTT + discovery enabled into app_config (DB level) with Session(disco_db) as session: session.add(AppConfigEntry(key="MQTT_ENABLED", value="true", updated_at=now)) session.add(AppConfigEntry(key="MQTT_BROKER_HOST", value="broker.test", updated_at=now)) session.add(AppConfigEntry(key="HA_DISCOVERY_ENABLED", value="true", updated_at=now)) session.commit() # Bootstrap has mqtt_enabled=False and ha_discovery_enabled=False (env defaults) from app.config import Settings bootstrap = Settings(_env_file=None, mqtt_enabled=False, ha_discovery_enabled=False, app_database_url=str(disco_db.url)) with Session(disco_db) as session: runtime = build_runtime_settings(session, bootstrap) # Simulate a connected broker for the _should_publish check mock_mgr_connected = MagicMock() mock_mgr_connected.is_connected = True with patch("app.services.ha_discovery.mqtt_manager", mock_mgr_connected): result = _should_publish(runtime) assert result is True, ( "_should_publish must return True when DB has mqtt_enabled=True + ha_discovery_enabled=True, " "even if bootstrap Settings has both False" ) 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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_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.build_runtime_settings", return_value=settings), patch("app.services.ha_discovery.mqtt_manager", mock_mgr), ): from app.services.ha_discovery import publish_device_offline from unittest.mock import MagicMock as _MagicMock publish_device_offline(_MagicMock(), "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, discovery_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.build_runtime_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.build_runtime_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". # 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, ( 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" # 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, ( 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" # 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, ( 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}" ) # --------------------------------------------------------------------------- # 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}" )