M8-R15: fix HA discovery identities and thermal totals
This commit is contained in:
+48
-47
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user