M8-R15: fix HA discovery identities and thermal totals
This commit is contained in:
+500
-5
@@ -15,6 +15,7 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -443,14 +444,14 @@ def test_publish_discovery_enabled_vs_disabled_payload(disco_db) -> None:
|
||||
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, (
|
||||
voltage_calls = [(t, p, r) for t, p, r in config_calls if t == voltage_config_topic]
|
||||
assert voltage_calls, (
|
||||
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
|
||||
# First v1.6.1 repair run deliberately unloads the old config before it
|
||||
# re-adds the same unique_id under the corrected HA device identifier.
|
||||
_t, payload, retain = voltage_calls[-1]
|
||||
assert payload not in (b"", "", None), "Enabled entity should get non-empty config payload"
|
||||
assert retain is True, "Discovery config must be retained"
|
||||
|
||||
@@ -1378,3 +1379,497 @@ def test_stale_m8_entities_includes_ended_electricity_meter(disco_db) -> None:
|
||||
session.commit()
|
||||
keys = {entity.key for entity in _stale_m8_entities(session)}
|
||||
assert keys == {f"meter.{old.uuid}.total", f"meter.{old.uuid}.today"}
|
||||
|
||||
|
||||
def test_discovery_uses_single_namespaced_identifier_and_safe_thermal_topic() -> None:
|
||||
"""HA device grouping is independent from a dot-containing internal seed."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.services.ha_discovery import build_discovery_payload
|
||||
|
||||
identity = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
device = DeviceInfo(
|
||||
identifiers=(f"home-automation:thermal-cost:{identity}",),
|
||||
identity=identity,
|
||||
name="Thermal Energy Cost",
|
||||
provides_availability=False,
|
||||
)
|
||||
entity = ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.heating_total", component="sensor", device=device,
|
||||
device_class="monetary", unit="EUR", name="Thermal Heating Total", state_class="total",
|
||||
)
|
||||
topic, config = build_discovery_payload(entity, "homeassistant")
|
||||
assert config["device"]["identifiers"] == [f"home-automation:thermal-cost:{identity}"]
|
||||
assert all(part.replace("-", "").replace("_", "").isalnum()
|
||||
for part in topic.split("/")[2:4])
|
||||
assert "." not in topic
|
||||
|
||||
|
||||
def test_legacy_thermal_cleanup_persists_each_success_and_retries_only_failure(disco_db) -> None:
|
||||
"""Illegal v1.6.1 cleanup never re-sends a durably accepted topic."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.services import ha_discovery
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
identity = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
legacy = ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.heating_total", component="sensor",
|
||||
device=DeviceInfo(identifiers=("thermal-cost", identity), identity=identity, name="obsolete"),
|
||||
device_class=None, unit="", name="obsolete",
|
||||
)
|
||||
second_legacy = ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.heating_today", component="sensor",
|
||||
device=legacy.device, device_class=None, unit="", name="obsolete",
|
||||
)
|
||||
settings = _make_settings()
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[legacy, second_legacy]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
manager.publish.side_effect = [True, False]
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_count == 2
|
||||
with Session(disco_db) as session:
|
||||
progress = session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one()
|
||||
assert ha_discovery._legacy_discovery_topic(legacy, "homeassistant") in progress.value
|
||||
|
||||
manager.publish.reset_mock()
|
||||
manager.publish.side_effect = None
|
||||
manager.publish.return_value = True
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_args_list == [
|
||||
((ha_discovery._legacy_discovery_topic(second_legacy, "homeassistant"), b""), {"retain": True}),
|
||||
]
|
||||
|
||||
# A fresh Session simulates a process restart: no illegal topic is sent.
|
||||
manager.publish.reset_mock()
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_thermal_enumeration_failure_does_not_advance_marker(disco_db) -> None:
|
||||
"""An inventory error is pending work, never an empty successful cleanup."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
settings = _make_settings()
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", side_effect=RuntimeError("enumeration failed")),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
with Session(disco_db) as session:
|
||||
assert session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one_or_none() is None
|
||||
|
||||
|
||||
def test_legacy_cleanup_compatibility_freeze_keeps_previous_success_progress(disco_db) -> None:
|
||||
"""Startup freezes a pre-inventory ledger and preserves its success progress."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
device = DeviceInfo(identifiers=("legacy",), identity="old", name="obsolete")
|
||||
first = ExposableEntity(key="thermal_cost.old.heating_total", component="sensor", device=device,
|
||||
device_class=None, unit="", name="obsolete")
|
||||
second = ExposableEntity(key="thermal_cost.old.heating_today", component="sensor", device=device,
|
||||
device_class=None, unit="", name="obsolete")
|
||||
first_topic = ha_discovery._legacy_discovery_topic(first, "homeassistant")
|
||||
with Session(disco_db) as session:
|
||||
session.add(AppConfigEntry(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY,
|
||||
value=json.dumps({"complete": False, "topics": [first_topic]}),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=_make_settings()),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[first, second]),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=_make_settings()),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_args_list == [
|
||||
((ha_discovery._legacy_discovery_topic(second, "homeassistant"), b""), {"retain": True}),
|
||||
]
|
||||
with Session(disco_db) as session:
|
||||
ledger = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
assert ledger == {
|
||||
"complete": True,
|
||||
"inventory": [first_topic, ha_discovery._legacy_discovery_topic(second, "homeassistant")],
|
||||
"topics": sorted((first_topic, ha_discovery._legacy_discovery_topic(second, "homeassistant"))),
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_marks_fresh_install_complete_and_short_circuits(disco_db) -> None:
|
||||
"""A later first toggle cannot make a v1.6.1 topic after fresh startup."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
manager = _make_mock_manager()
|
||||
settings = _make_settings()
|
||||
with patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
with Session(disco_db) as session:
|
||||
ledger = session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one()
|
||||
assert ledger.value == '{"complete": true, "inventory": [], "topics": []}'
|
||||
|
||||
# A fresh Session is equivalent to a restarted process. If a user now
|
||||
# enables a thermal entity, the completed ledger prevents re-enumeration.
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities") as legacy,
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
legacy.assert_not_called()
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_completes_empty_compat_ledger_before_later_enable(disco_db) -> None:
|
||||
"""An Alembic-head empty compat ledger cannot manufacture a later old topic."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.energy import Meter
|
||||
from app.services import ha_discovery
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(disco_db) as session:
|
||||
heating = Meter(label="heating", commodity="heating", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
water = Meter(label="water", commodity="hot_water", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
session.add_all((heating, water))
|
||||
session.add(AppConfigEntry(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY,
|
||||
value=json.dumps({"complete": False, "topics": []}),
|
||||
updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
settings = _make_settings(ha_discovery_prefix="startup_prefix")
|
||||
with patch("app.services.ha_discovery.build_runtime_settings", return_value=settings):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
with Session(disco_db) as session:
|
||||
ledger = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
assert ledger == {"complete": True, "inventory": [], "topics": []}
|
||||
active_meters = session.query(Meter).filter(Meter.ended_at.is_(None)).all()
|
||||
pair = ha_discovery._thermal_cleanup_entities(
|
||||
[(next(meter for meter in active_meters if meter.commodity == "heating"),
|
||||
next(meter for meter in active_meters if meter.commodity == "hot_water"))],
|
||||
include_hot_water_total=False,
|
||||
)
|
||||
_enable_entity(session, pair[0].key)
|
||||
session.commit()
|
||||
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings",
|
||||
return_value=_make_settings(ha_discovery_prefix="changed_prefix")),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_propagates_inventory_failure(disco_db) -> None:
|
||||
"""Startup must fail closed instead of exposing a mutable cleanup window."""
|
||||
from app.services import ha_discovery
|
||||
|
||||
with patch("app.services.ha_discovery._legacy_thermal_entities", side_effect=RuntimeError("boom")):
|
||||
with Session(disco_db) as session:
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_propagates_durable_write_failure(disco_db) -> None:
|
||||
"""A failed freeze write is fatal; UI must not open with an unfrozen ledger."""
|
||||
from app.services import ha_discovery
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._set_migration_json", side_effect=RuntimeError("disk full")),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
with pytest.raises(RuntimeError, match="disk full"):
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
|
||||
def test_legacy_cleanup_startup_keeps_upgrade_with_enabled_topic_pending(disco_db) -> None:
|
||||
"""Existing enabled v1.6.1 inventory is not mistaken for a fresh install."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.services import ha_discovery
|
||||
|
||||
legacy = ExposableEntity(
|
||||
key="thermal_cost.old.heating_total", component="sensor",
|
||||
device=DeviceInfo(identifiers=("legacy",), identity="old", name="obsolete"),
|
||||
device_class=None, unit="", name="obsolete",
|
||||
)
|
||||
with patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[legacy]):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
with Session(disco_db) as session:
|
||||
ledger = session.query(AppConfigEntry).filter_by(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY
|
||||
).one()
|
||||
assert ledger.value == (
|
||||
'{"complete": false, "inventory": ["homeassistant/sensor/old/'
|
||||
'thermal_cost_old_heating_total/config"], "topics": []}'
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_cleanup_freezes_startup_upgrade_inventory_across_toggle_changes(disco_db) -> None:
|
||||
"""Alembic-head compat ledger survives UI disable and runtime prefix changes."""
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.energy import Meter
|
||||
from app.models.expose import ExposedEntityToggle
|
||||
from app.services import ha_discovery
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
settings = _make_settings(ha_discovery_prefix="frozen_prefix")
|
||||
with Session(disco_db) as session:
|
||||
heating = Meter(label="heating", commodity="heating", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
water = Meter(label="water", commodity="hot_water", started_at=now, ended_at=None,
|
||||
reason="initial", note=None, created_at=now)
|
||||
session.add_all((heating, water))
|
||||
session.flush()
|
||||
enabled = ha_discovery._thermal_cleanup_entities(
|
||||
[(heating, water)], include_hot_water_total=False
|
||||
)[:2]
|
||||
for entity in enabled:
|
||||
_enable_entity(session, entity.key)
|
||||
first_topic = ha_discovery._legacy_discovery_topic(enabled[0], "frozen_prefix")
|
||||
session.add(AppConfigEntry(
|
||||
key=ha_discovery._LEGACY_THERMAL_CLEANUP_KEY,
|
||||
value=json.dumps({"complete": False, "topics": [first_topic]}),
|
||||
updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
with patch("app.services.ha_discovery.build_runtime_settings", return_value=settings):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.initialize_legacy_thermal_cleanup(session)
|
||||
|
||||
with Session(disco_db) as session:
|
||||
ledger = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
inventory = ledger["inventory"]
|
||||
assert len(inventory) == 2
|
||||
assert all(topic.startswith("frozen_prefix/") for topic in inventory)
|
||||
assert ledger["topics"] == [first_topic]
|
||||
# This mirrors PUT /api/expose: persist the UI change before it invokes
|
||||
# publish_discovery in the same request.
|
||||
toggle = session.query(ExposedEntityToggle).filter_by(key=enabled[0].key).one()
|
||||
toggle.enabled = False
|
||||
session.commit()
|
||||
|
||||
manager = _make_mock_manager()
|
||||
failed_topic = inventory[-1]
|
||||
published: list[str] = []
|
||||
manager.publish.side_effect = lambda topic, _payload, **_kwargs: (
|
||||
published.append(topic) or topic != failed_topic
|
||||
)
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings",
|
||||
return_value=_make_settings(ha_discovery_prefix="changed_prefix")),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert published == [failed_topic]
|
||||
with Session(disco_db) as session:
|
||||
pending = ha_discovery._migration_json(session, ha_discovery._LEGACY_THERMAL_CLEANUP_KEY)
|
||||
assert pending["complete"] is False
|
||||
assert pending["inventory"] == inventory
|
||||
assert pending["topics"] == [first_topic]
|
||||
|
||||
manager.publish.reset_mock()
|
||||
manager.publish.side_effect = lambda topic, _payload, **_kwargs: published.append(topic) or True
|
||||
published.clear()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings",
|
||||
return_value=_make_settings(ha_discovery_prefix="changed_prefix")),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities") as enumerate_legacy,
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert published == [failed_topic]
|
||||
enumerate_legacy.assert_not_called()
|
||||
|
||||
manager.publish.reset_mock()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities") as enumerate_legacy,
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
enumerate_legacy.assert_not_called()
|
||||
manager.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_registry_repair_waits_for_registry_observation_and_partial_catalog_recovers(disco_db) -> None:
|
||||
"""The V2 repair is per entity and never mistakes broker ACK for HA ACK."""
|
||||
from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity
|
||||
from app.services import ha_discovery
|
||||
|
||||
def entity(kind: str, identity: str, metric: str) -> ExposableEntity:
|
||||
return ExposableEntity(
|
||||
key=f"{kind}.{identity}.{metric}" if kind != "energy" else f"energy.{metric}",
|
||||
component="sensor",
|
||||
device=DeviceInfo(
|
||||
identifiers=(f"home-automation:{kind}:{identity}",), identity=identity, name=identity,
|
||||
provides_availability=False,
|
||||
),
|
||||
device_class=None, unit="", name=metric,
|
||||
)
|
||||
|
||||
entities = [
|
||||
*(entity("meter", f"meter-{number}", "total") for number in range(3)),
|
||||
*(entity("source", f"source-{number}", "online") for number in range(2)),
|
||||
*(entity("modbus", f"modbus-{number}", "voltage") for number in range(2)),
|
||||
entity("energy", "electricity-epoch", "import_cost_total"),
|
||||
]
|
||||
catalog = [CatalogEntry(entity=item, enabled=True) for item in entities]
|
||||
manager = _make_mock_manager()
|
||||
manager.publish.return_value = True
|
||||
settings = _make_settings()
|
||||
calls: list[tuple[str, object]] = []
|
||||
manager.publish.side_effect = lambda topic, payload, **_kwargs: calls.append((topic, payload)) or True
|
||||
|
||||
bindings: dict[str, set[str]] = {ha_discovery._unique_id(item): {"old"} for item in entities}
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=catalog),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", side_effect=lambda _settings, ids: {
|
||||
key: value for key, value in bindings.items() if key in ids
|
||||
}),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
|
||||
topics = [ha_discovery._discovery_topic(item, "homeassistant") for item in entities]
|
||||
assert [payload for _topic, payload in calls[:len(entities)]] == [b""] * len(entities)
|
||||
assert [topic for topic, _payload in calls[:len(entities)]] == topics
|
||||
assert len({ha_discovery._unique_id(item) for item in entities}) == len(entities)
|
||||
|
||||
# HA confirms every tombstone; only then is each target re-added.
|
||||
calls.clear()
|
||||
bindings.clear()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=catalog),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", side_effect=lambda _settings, _ids: dict(bindings)),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert len(calls) == len(entities)
|
||||
assert all(payload not in (b"", "", None) for _topic, payload in calls)
|
||||
|
||||
|
||||
def test_registry_repair_unavailable_keeps_normal_discovery_publishing(disco_db) -> None:
|
||||
"""A broken optional HA WS link cannot leave legal configs tombstoned."""
|
||||
from app.integrations.expose import CatalogEntry, DeviceInfo, ExposableEntity
|
||||
from app.services import ha_discovery
|
||||
|
||||
entity = ExposableEntity(
|
||||
key="meter.meter-1.total", component="sensor",
|
||||
device=DeviceInfo(identifiers=("home-automation:meter:meter-1",), identity="meter-1", name="m1"),
|
||||
device_class=None, unit="", name="total",
|
||||
)
|
||||
manager = _make_mock_manager()
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=_make_settings()),
|
||||
patch("app.services.ha_discovery.mqtt_manager", manager),
|
||||
patch("app.services.ha_discovery.build_catalog", return_value=[CatalogEntry(entity, True)]),
|
||||
patch("app.services.ha_discovery._legacy_thermal_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._stale_m8_entities", return_value=[]),
|
||||
patch("app.services.ha_discovery._ha_registry_bindings", return_value=None),
|
||||
):
|
||||
with Session(disco_db) as session:
|
||||
ha_discovery.publish_discovery(session)
|
||||
assert manager.publish.call_args.args[1] != b""
|
||||
|
||||
def test_stale_thermal_cleanup_uses_safe_topics_and_all_fourteen_metrics(disco_db) -> None:
|
||||
"""Later thermal meter swaps clear only ended safe-format pair configs."""
|
||||
from app.models.energy import Meter
|
||||
from app.services import ha_discovery
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(disco_db) as session:
|
||||
old_heating = Meter(label="old heat", commodity="heating", started_at=now - timedelta(days=2),
|
||||
ended_at=now - timedelta(days=1), reason="meter_swap", note=None, created_at=now)
|
||||
old_water = Meter(label="old water", commodity="hot_water", started_at=now - timedelta(days=2),
|
||||
ended_at=now - timedelta(days=1), reason="meter_swap", note=None, created_at=now)
|
||||
active_heating = Meter(label="new heat", commodity="heating", started_at=now - timedelta(days=1),
|
||||
ended_at=None, reason="meter_swap", note=None, created_at=now)
|
||||
active_water = Meter(label="new water", commodity="hot_water", started_at=now - timedelta(days=1),
|
||||
ended_at=None, reason="meter_swap", note=None, created_at=now)
|
||||
session.add_all((old_heating, old_water, active_heating, active_water))
|
||||
session.commit()
|
||||
stale = ha_discovery._stale_m8_entities(session)
|
||||
|
||||
old_identity = ".".join(sorted((old_heating.uuid, old_water.uuid)))
|
||||
thermal = [item for item in stale if item.key.startswith("thermal_cost.")]
|
||||
assert len(thermal) == 14
|
||||
assert {item.key for item in thermal} == {
|
||||
f"thermal_cost.{old_identity}.{metric}_{suffix}"
|
||||
for metric in ("heating", "hot_water_heating", "hot_water_total", "water", "water_tax", "fixed", "all_in")
|
||||
for suffix in ("total", "today")
|
||||
}
|
||||
assert all("." not in ha_discovery._discovery_topic(item, "homeassistant") for item in thermal)
|
||||
assert not any(active_heating.uuid in item.key and active_water.uuid in item.key for item in thermal)
|
||||
|
||||
Reference in New Issue
Block a user