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
+29
View File
@@ -118,6 +118,35 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va
reset_db_caches()
def test_startup_initializes_fresh_legacy_discovery_cleanup_ledger(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The cleanup ledger exists before the expose UI can write its first toggle."""
import app.main as main
app_database_url = _prepare_app_db(tmp_path)
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
get_settings.cache_clear()
reset_db_caches()
main.ensure_auth_db_ready()
conn = sqlite3.connect(tmp_path / "app_ready.db")
try:
value = conn.execute(
"SELECT value FROM app_config WHERE key = ?",
("HA_DISCOVERY_LEGACY_THERMAL_CLEANUP_V1",),
).fetchone()[0]
finally:
conn.close()
assert value == '{"complete": true, "inventory": [], "topics": []}'
get_settings.cache_clear()
reset_db_caches()
def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
+48 -47
View File
@@ -13,7 +13,7 @@ Coverage:
7. value_getter returns None when no non-degraded period exists.
8. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
9. Integration: build_discovery_payload on an energy_cost entity does NOT raise
IndexError (validates 2-element identifiers).
IndexError (validates one-item HA identifiers and independent internal identities).
10. Keys are stable fixed strings (not derived from mutable data or DB ids).
11. Provider registered: energy_cost entities appear alongside modbus entities
in the full catalog.
@@ -767,9 +767,8 @@ def test_publish_states_noop_when_not_connected() -> None:
def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -> None:
"""build_discovery_payload must NOT raise IndexError for energy_cost entities.
FUE-T05: identifiers is now ('energy-cost', meter.uuid).
Validates that the 2-element identifiers tuple satisfies ha_discovery.py's
requirement to access identifiers[1] as node_id.
The HA grouping identifier and internal MQTT identity are deliberately
separate; a one-item HA identifier must therefore remain sufficient.
"""
from app.integrations.expose import build_catalog
from app.services.ha_discovery import build_discovery_payload
@@ -800,15 +799,11 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -
assert len(energy_entries) == 6, "Expected 6 energy_cost entities in catalog"
for entry in energy_entries:
# identifiers[1] must be the meter uuid (not "energy-cost").
assert entry.entity.device.identifiers[1] == meter_uuid, (
f"identifiers[1] must be meter uuid {meter_uuid!r}, "
f"got {entry.entity.device.identifiers[1]!r}"
)
# Must not raise — specifically no IndexError from identifiers[1]
assert entry.entity.device.internal_identity == meter_uuid
assert entry.entity.device.identifiers == (f"home-automation:energy-cost:{meter_uuid}",)
topic, config = build_discovery_payload(entry.entity, "homeassistant")
# node_id = identifiers[1] with hyphens → underscores
# node_id = internal meter identity with hyphens → underscores
node_id = meter_uuid.replace("-", "_")
assert node_id in topic, (
f"Expected meter uuid node_id {node_id!r} in discovery topic, got {topic!r}"
@@ -817,13 +812,12 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -
f"Discovery topic must end with /config, got {topic!r}"
)
assert "unique_id" in config
# unique_id seed is identifiers[1] (meter uuid) + entity key
# unique_id seed is the internal meter identity + entity key
assert meter_uuid in config["unique_id"], (
f"unique_id must contain meter uuid, got {config['unique_id']!r}"
)
assert "device" in config
assert "energy-cost" in config["device"]["identifiers"]
assert meter_uuid in config["device"]["identifiers"]
assert config["device"]["identifiers"] == [f"home-automation:energy-cost:{meter_uuid}"]
def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> None:
@@ -859,18 +853,18 @@ def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> N
def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
"""Discovery topic node_id for energy entities must be derived from meter uuid.
FUE-T05: identifiers[1] is now the active meter's uuid.
ha_discovery._node_id() replaces hyphens with underscores in identifiers[1]
to build the MQTT node_id. This test verifies that the topic reflects the
The internal identity is the active meter's uuid, independent from the
singleton HA identifier. This test verifies that the topic reflects the
meter uuid (not the old fixed 'energy-cost' string).
"""
from app.integrations.expose import DeviceInfo, ExposableEntity
from app.services.ha_discovery import build_discovery_payload
meter_uuid = "12345678-abcd-ef00-1234-567890abcdef"
# identifiers[1] = meter uuid — this is what FUE-T05 sets.
# ``identity`` is the meter UUID; HA grouping is a separate one-item tuple.
device = DeviceInfo(
identifiers=("energy-cost", meter_uuid),
identifiers=(f"home-automation:energy-cost:{meter_uuid}",),
identity=meter_uuid,
name="Test Meter",
provides_availability=False,
)
@@ -886,7 +880,7 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
# node_id: identifiers[1] = meter_uuid, hyphens → underscores
# node_id: internal identity = meter_uuid, hyphens → underscores
expected_node = meter_uuid.replace("-", "_")
assert f"/{expected_node}/" in topic, (
f"Expected meter uuid node_id {expected_node!r} in topic {topic!r}"
@@ -2345,12 +2339,11 @@ def test_energy_cost_provider_returns_empty_when_no_active_meter(energy_db) -> N
)
def test_energy_cost_provider_identifiers_match_meter_uuid(energy_db) -> None:
"""FUE-T05 ②: with an active meter, identifiers[1] == meter.uuid.
def test_energy_cost_provider_identity_matches_meter_uuid(energy_db) -> None:
"""The active meter anchors the internal identity and namespaced HA identifier.
The HA device identity is anchored to the active meter's uuid. ha_discovery.py
uses identifiers[1] as the MQTT node_id and unique_id seed; changing the active
meter (meter swap) produces a new uuid → new node_id → new HA sensor.
The HA device identity and MQTT internal identity are both anchored to the
active meter's uuid. Changing the meter produces a new node_id and HA sensor.
"""
from app.integrations.expose import _energy_cost_provider
@@ -2367,11 +2360,8 @@ def test_energy_cost_provider_identifiers_match_meter_uuid(energy_db) -> None:
assert len(entities) == 6, f"Expected 6 entities, got {len(entities)}"
for entity in entities:
assert entity.device.identifiers == ("energy-cost", meter_uuid), (
f"identifiers must be ('energy-cost', meter.uuid); "
f"expected ('energy-cost', {meter_uuid!r}), "
f"got {entity.device.identifiers!r}"
)
assert entity.device.internal_identity == meter_uuid
assert entity.device.identifiers == (f"home-automation:energy-cost:{meter_uuid}",)
assert entity.device.name == meter_label, (
f"device.name must be exactly the meter label {meter_label!r}, "
f"got {entity.device.name!r}"
@@ -2382,7 +2372,7 @@ def test_energy_cost_entity_keys_do_not_contain_meter_uuid(energy_db) -> None:
"""FUE-T05 ③: entity keys remain stable 'energy.*' strings (no uuid injected).
Keys are the anchor for the toggle table; they must NOT change when the meter
changes. Only identifiers[1] (node_id / unique_id) changes on a meter swap.
changes. Only the internal identity (node_id / unique_id) changes on a meter swap.
"""
from app.integrations.expose import _energy_cost_provider
@@ -2418,7 +2408,7 @@ def test_energy_cost_entity_keys_do_not_contain_meter_uuid(energy_db) -> None:
def test_energy_cost_toggle_survives_meter_swap(energy_db) -> None:
"""FUE-T05 ③ (toggle stability): enabled toggle on 'energy.buy_price_now' survives meter swap.
After a meter swap the provider queries a new active meter → new identifiers[1] /
After a meter swap the provider queries a new active meter → new internal identity /
unique_id / topic in HA. But the entity key stays 'energy.buy_price_now', so the
existing toggle row (keyed by 'energy.buy_price_now') is still found → enabled=True.
@@ -2475,11 +2465,7 @@ def test_energy_cost_toggle_survives_meter_swap(energy_db) -> None:
(e for e in catalog if e.entity.key == "energy.buy_price_now"), None
)
assert buy_entry is not None, "energy.buy_price_now must be present in catalog"
# identifiers[1] must now be the NEW meter's uuid
assert buy_entry.entity.device.identifiers[1] == new_meter_uuid, (
f"After swap, identifiers[1] must be new meter uuid {new_meter_uuid!r}, "
f"got {buy_entry.entity.device.identifiers[1]!r}"
)
assert buy_entry.entity.device.internal_identity == new_meter_uuid
# Toggle state must still be enabled (key unchanged → same toggle row found)
assert buy_entry.enabled is True, (
"energy.buy_price_now toggle must remain enabled after meter swap "
@@ -2488,7 +2474,7 @@ def test_energy_cost_toggle_survives_meter_swap(energy_db) -> None:
def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None:
"""FUE-T05 ④: after meter swap, provider produces new identifiers[1] (new meter uuid).
"""After a meter swap, provider produces a new internal meter identity.
Old uuid's entities are no longer produced → HA sensor for old uuid is frozen.
New uuid's entities appear → HA creates fresh sensors for the new meter.
@@ -2544,12 +2530,9 @@ def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None:
f"Expected 6 entities after swap, got {len(entities_after_swap)}"
)
for entity in entities_after_swap:
assert entity.device.identifiers[1] == new_uuid, (
f"After swap, identifiers[1] must be new uuid {new_uuid!r}, "
f"got {entity.device.identifiers[1]!r}"
)
assert entity.device.internal_identity == new_uuid
# Old uuid must not appear in identifiers
assert entity.device.identifiers[1] != old_uuid, (
assert entity.device.internal_identity != old_uuid, (
f"After swap, old uuid {old_uuid!r} must not appear in identifiers"
)
@@ -2809,8 +2792,22 @@ def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) ->
assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh"
assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy"
assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing"
device_ids = {
entries[f"source.{heating_source.uuid}.online"].entity.device.identifiers[0],
entries[f"meter.{heating.uuid}.total"].entity.device.identifiers[0],
entries[f"meter.{water.uuid}.total"].entity.device.identifiers[0],
entries[f"meter.{electricity.uuid}.total"].entity.device.identifiers[0],
}
assert len(device_ids) == 4
assert all(len(entry.entity.device.identifiers) == 1 for entry in entries.values())
thermal = [entry for key, entry in entries.items() if key.startswith("thermal_cost.")]
assert len(thermal) == 12
assert len(thermal) == 14
assert {entry.entity.key.rsplit(".", 1)[-1] for entry in thermal} >= {
"hot_water_total_total", "hot_water_total_today"
}
assert {entry.entity.name for entry in thermal if ".hot_water_total_" in entry.entity.key} == {
"Thermal Hot Water Total", "Thermal Hot Water Today"
}
assert all(entry.enabled is False for entry in thermal)
assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal)
@@ -2972,9 +2969,10 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
captured: dict[str, datetime] = {}
result = {
"period_count": 1, "fixed_cost": Decimal("0"), "total_cost": Decimal("0"),
"breakdown": {key: Decimal("0") for key in (
"heating", "hot_water_heating", "hot_water", "hot_water_tax",
)},
"breakdown": {
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
},
}
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
@@ -2994,4 +2992,7 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
entity = next(item.entity for item in build_catalog(session)
if item.entity.key.endswith(".heating_today"))
assert entity.value_getter(session) == Decimal("0")
hot_water_total = next(item.entity for item in build_catalog(session)
if item.entity.key.endswith(".hot_water_total_today"))
assert hot_water_total.value_getter(session) == Decimal("4.00")
assert captured["end"] == now
+15 -1
View File
@@ -362,7 +362,21 @@ def test_build_catalog_device_grouping(expose_db):
assert len(identifiers_set) == 1, (
"All entities for one device must share the same DeviceInfo identifiers"
)
assert identifiers_set.pop() == ("modbus", test_uuid)
own_identifiers = identifiers_set.pop()
assert own_identifiers == (f"home-automation:modbus:{test_uuid}",)
# A second Modbus device must never overlap its singleton HA identifier.
other_uuid = "aaaaaaaa-0000-0000-0000-000000000099"
with Session(expose_db) as session:
_make_modbus_device(session, friendly_name="SDM120 E", uuid=other_uuid)
session.commit()
with Session(expose_db) as session:
second_catalog = build_catalog(session)
other_identifiers = {
entry.entity.device.identifiers for entry in second_catalog if other_uuid in entry.entity.key
}
assert other_identifiers == {(f"home-automation:modbus:{other_uuid}",)}
assert {own_identifiers} != other_identifiers
def test_build_catalog_metric_metadata_from_profile(expose_db):
+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)
+84
View File
@@ -111,3 +111,87 @@ def test_homeassistant_client_raises_on_invalid_arguments() -> None:
with pytest.raises(ValueError, match="webhook_id"):
client.trigger_webhook(webhook_id="", body={})
def test_discovery_registry_bindings_reads_entity_and_device_registry(monkeypatch: pytest.MonkeyPatch) -> None:
"""The repair confirmation reads HA's authoritative registry bindings."""
sent: list[dict] = []
class _Socket:
replies = iter((
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
'{"id":1,"success":true,"result":[{"platform":"mqtt","unique_id":"u1","device_id":"d1"}]}',
'{"id":2,"success":true,"result":[{"id":"d1","identifiers":[["mqtt","home-automation:meter:m1"]]}]}',
))
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def recv(self, *, timeout=None):
assert timeout is not None
assert timeout <= 1.5
return next(self.replies)
def send(self, payload):
sent.append(json.loads(payload))
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
bindings = HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1", "missing"})
assert bindings == {"u1": {"home-automation:meter:m1"}}
assert [message.get("type") for message in sent] == [
"auth", "config/entity_registry/list", "config/device_registry/list"
]
def test_discovery_registry_bindings_uses_one_deadline_and_ignores_non_mqtt_identifiers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only official ["mqtt", value] pairs participate in repair matching."""
received_timeouts: list[float] = []
class _Socket:
replies = iter((
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
'{"id":1,"success":true,"result":['
'{"platform":"mqtt","unique_id":"u1","device_id":"d1"},'
'{"platform":"mqtt","unique_id":"u2","device_id":"d2"},'
'{"platform":"mqtt","unique_id":"u3","device_id":"d3"}]}',
'{"id":2,"success":true,"result":['
'{"id":"d1","identifiers":[["mqtt","expected"],["esphome","aux"]]},'
'{"id":"d2","identifiers":[["esphome","expected"],"expected",["mqtt",3],[]]},'
'{"id":"d3","identifiers":[["mqtt","expected"],["mqtt","old"]]}]}',
))
def __enter__(self): return self
def __exit__(self, *_args): return None
def send(self, _payload): return None
def recv(self, *, timeout=None):
received_timeouts.append(timeout)
return next(self.replies)
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
bindings = HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1", "u2", "u3"})
assert bindings == {"u1": {"expected"}, "u2": set(), "u3": {"expected", "old"}}
assert len(received_timeouts) == 4
assert all(timeout is not None and 0 < timeout <= 1.5 for timeout in received_timeouts)
def test_discovery_registry_bindings_silent_socket_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
class _Socket:
def __enter__(self): return self
def __exit__(self, *_args): return None
def send(self, _payload): return None
def recv(self, *, timeout=None):
assert timeout is not None and timeout > 0
raise TimeoutError("silent")
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
with pytest.raises(HomeAssistantRequestError, match="registry query failed"):
HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1"})
+13 -4
View File
@@ -257,7 +257,7 @@ def test_publish_passes_topic_payload_retain_to_paho() -> None:
manager._connected = True
manager._client = mock_client
manager.publish("test/topic", '{"key": "value"}', retain=True, qos=1)
assert manager.publish("test/topic", '{"key": "value"}', retain=True, qos=1) is True
mock_client.publish.assert_called_once_with(
"test/topic", payload='{"key": "value"}', qos=1, retain=True
@@ -267,8 +267,7 @@ def test_publish_passes_topic_payload_retain_to_paho() -> None:
def test_publish_is_noop_when_not_connected() -> None:
manager = MqttManager()
# No connect — should silently skip
manager.publish("topic", "payload", retain=False)
# No exception raised
assert manager.publish("topic", "payload", retain=False) is False
def test_publish_does_not_raise_on_paho_error() -> None:
@@ -279,7 +278,17 @@ def test_publish_does_not_raise_on_paho_error() -> None:
manager._connected = True
# Must not raise
manager.publish("topic", "payload")
assert manager.publish("topic", "payload") is False
def test_publish_returns_false_when_paho_rejects_message() -> None:
manager = MqttManager()
mock_client = MagicMock()
mock_client.publish.return_value.rc = 1
manager._client = mock_client
manager._connected = True
assert manager.publish("topic", "payload") is False
# ---------------------------------------------------------------------------