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
+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