M8-R15: fix HA discovery identities and thermal totals
frontend / frontend (push) Successful in 47s
pytest / test (push) Successful in 4m1s
docker-image / build-and-push (push) Successful in 1m38s

This commit is contained in:
2026-08-28 01:20:52 +02:00
parent 8180082f90
commit 018f13d73d
13 changed files with 1284 additions and 136 deletions
+500 -5
View File
@@ -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)