diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 698bb9a..2a1423c 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -373,12 +373,36 @@ register_provider(_modbus_provider) def _energy_cost_provider(session: Session) -> list[ExposableEntity]: """Enumerate ExposableEntity objects for the energy cost subsystem. - Produces 4 sensor entities grouped under a single HA device "Energy Cost": + Produces 6 sensor entities grouped under a single HA device whose identity + is anchored to the **current active electricity meter**: - ``buy_price_now`` — current effective buy price (EUR/kWh or local currency). - ``sell_price_now`` — current effective sell price (EUR/kWh or local currency). - - ``import_cost_total`` — cumulative import cost (total_increasing, monetary). - - ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary). + - ``import_cost_total`` — cumulative import cost (total, monetary). + - ``export_revenue_total`` — cumulative export revenue (total, monetary). + - ``import_cost_today`` — today's import cost (total_increasing, monetary). + - ``export_revenue_today`` — today's export revenue (total_increasing, monetary). + + Active meter requirement + ------------------------ + **If no active electricity meter exists, the provider returns ``[]``.** + No energy-cost entities are exposed to HA until a meter has been declared. + This prevents spurious sensor creation with an undefined device identity. + + HA device identity (换表 → 新 sensor) + -------------------------------------- + ``identifiers[1]`` is set to the active meter's **uuid** (not the fixed + string ``"energy-cost"``). ``ha_discovery.py`` uses ``identifiers[1]`` as + the MQTT node_id and as part of the ``unique_id`` for every entity. + Declaring a new active electricity meter produces a new uuid → new node_id / + unique_id → HA creates a brand-new sensor, cleanly isolating post-swap data. + + Entity key stability + -------------------- + Entity keys remain the fixed stable strings (``"energy.buy_price_now"`` etc.), + **not** derived from the meter uuid. The ``exposed_entity_toggle`` table uses + keys as its primary handle; keeping them stable means toggled-on entities stay + enabled after a meter swap without requiring the user to re-tick them. Current-price algorithm (source-agnostic, with fallback) --------------------------------------------------------- @@ -399,8 +423,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: Cumulative totals ----------------- ``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded** - ``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded - to avoid double-counting when they are later overwritten by real values. + ``energy_cost_period`` rows within the current meter's window. Degraded rows + carry 0 costs and are excluded to avoid double-counting when they are later + overwritten by real values. Currency -------- @@ -414,16 +439,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: - ``"energy.sell_price_now"`` - ``"energy.import_cost_total"`` - ``"energy.export_revenue_total"`` + - ``"energy.import_cost_today"`` + - ``"energy.export_revenue_today"`` DeviceInfo identifiers ---------------------- - **Two-element tuple** ``("energy-cost", "energy-cost")`` so that - ``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid - (the service uses index [1] as the node_id throughout). + **Two-element tuple** ``("energy-cost", meter.uuid)`` so that + ``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the + meter uuid (used as the MQTT node_id and unique_id seed throughout). """ - from app.models.energy import EnergyCostPeriod # local import to avoid circular + from app.models.energy import EnergyCostPeriod, Meter # local import to avoid circular + from sqlalchemy import select - # --- Determine currency and representative pricing from the latest non-degraded row --- + # --- Require an active electricity meter; return [] if none exists --- + # Using an inline query (ended_at IS NULL) rather than a service-layer helper + # to avoid a new public dependency and remain consistent with the value_getter + # implementations below (which use the same inline pattern). + active_meter: Meter | None = session.execute( + select(Meter) + .where( + Meter.commodity == "electricity", + Meter.ended_at.is_(None), + ) + .limit(1) + ).scalar_one_or_none() + + if active_meter is None: + # No active electricity meter → do not expose any energy-cost entities. + # HA will not see these sensors until a meter is declared. + return [] + + # --- Determine currency from the latest non-degraded row --- latest_period: EnergyCostPeriod | None = ( session.query(EnergyCostPeriod) @@ -436,13 +482,15 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: if latest_period is not None and latest_period.currency: currency = latest_period.currency - # --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) --- + # --- Shared DeviceInfo anchored to the active meter's uuid --- + # identifiers[1] = meter.uuid drives the MQTT node_id and unique_id in + # ha_discovery.py. Swapping the meter produces a new uuid → new HA sensor. # provides_availability=False: the energy-cost device has only sensors and no # online/offline heartbeat, so its entities must be "always available" in HA. # (Otherwise HA shows them unavailable despite state being published.) device_info = DeviceInfo( - identifiers=("energy-cost", "energy-cost"), - name="Energy Cost", + identifiers=("energy-cost", active_meter.uuid), + name=f"Energy Cost ({active_meter.label})", provides_availability=False, ) diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index 5856071..d495680 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -164,9 +164,18 @@ def energy_db(tmp_path: Path): def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None: - """build_catalog must include all 6 energy_cost sensor entities (4 original + 2 daily).""" + """build_catalog must include all 6 energy_cost sensor entities (4 original + 2 daily). + + FUE-T05: provider requires an active electricity meter. Insert one so the + provider produces its 6 entities. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -190,9 +199,17 @@ def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None: def test_cumulative_entities_have_total_state_class(energy_db) -> None: - """import_cost_total and export_revenue_total must have state_class='total'.""" + """import_cost_total and export_revenue_total must have state_class='total'. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -206,9 +223,17 @@ def test_cumulative_entities_have_total_state_class(energy_db) -> None: def test_cumulative_entities_have_monetary_device_class(energy_db) -> None: - """import_cost_total and export_revenue_total must have device_class='monetary'.""" + """import_cost_total and export_revenue_total must have device_class='monetary'. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -222,9 +247,17 @@ def test_cumulative_entities_have_monetary_device_class(energy_db) -> None: def test_all_energy_entities_are_sensors(energy_db) -> None: - """All 6 energy_cost entities must have component='sensor'.""" + """All 6 energy_cost entities must have component='sensor'. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -243,9 +276,17 @@ def test_all_energy_entities_are_sensors(energy_db) -> None: def test_energy_cost_entities_default_to_disabled(energy_db) -> None: - """All 6 energy_cost entities must default to enabled=False (no toggle row).""" + """All 6 energy_cost entities must default to enabled=False (no toggle row). + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -264,10 +305,14 @@ def test_energy_cost_entities_default_to_disabled(energy_db) -> None: def test_buy_price_getter_reads_tibber_snapshot(energy_db) -> None: - """buy_price_now value_getter must return the 'buy' price from tibber snapshot.""" + """buy_price_now value_getter must return the 'buy' price from tibber snapshot. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) + meter_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) tibber_pricing = { "kind": "tibber", "buy": "0.2850", @@ -280,6 +325,7 @@ def test_buy_price_getter_reads_tibber_snapshot(energy_db) -> None: } with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period( session, period_start=t0, @@ -304,10 +350,14 @@ def test_buy_price_getter_reads_tibber_snapshot(energy_db) -> None: def test_sell_price_getter_reads_tibber_snapshot(energy_db) -> None: - """sell_price_now value_getter must return the 'sell' price from tibber snapshot.""" + """sell_price_now value_getter must return the 'sell' price from tibber snapshot. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 1, 1, 12, 15, tzinfo=timezone.utc) + meter_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) tibber_pricing = { "kind": "tibber", "buy": "0.3100", @@ -320,6 +370,7 @@ def test_sell_price_getter_reads_tibber_snapshot(energy_db) -> None: } with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period( session, period_start=t0, @@ -344,10 +395,14 @@ def test_sell_price_getter_reads_tibber_snapshot(energy_db) -> None: def test_buy_price_getter_reads_manual_snapshot(energy_db) -> None: - """buy_price_now value_getter must return 'buy_normal' from manual pricing snapshot.""" + """buy_price_now value_getter must return 'buy_normal' from manual pricing snapshot. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 2, 1, 8, 0, tzinfo=timezone.utc) + meter_start = datetime(2025, 2, 1, 0, 0, tzinfo=timezone.utc) manual_pricing = { "kind": "manual", "buy_dal": "0.2500", @@ -359,6 +414,7 @@ def test_buy_price_getter_reads_manual_snapshot(energy_db) -> None: } with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period( session, period_start=t0, @@ -384,10 +440,14 @@ def test_buy_price_getter_reads_manual_snapshot(energy_db) -> None: def test_sell_price_getter_reads_manual_snapshot(energy_db) -> None: - """sell_price_now value_getter must return 'sell_normal' from manual pricing snapshot.""" + """sell_price_now value_getter must return 'sell_normal' from manual pricing snapshot. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 2, 1, 8, 15, tzinfo=timezone.utc) + meter_start = datetime(2025, 2, 1, 0, 0, tzinfo=timezone.utc) manual_pricing = { "kind": "manual", "buy_dal": "0.2500", @@ -399,6 +459,7 @@ def test_sell_price_getter_reads_manual_snapshot(energy_db) -> None: } with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period( session, period_start=t0, @@ -539,9 +600,19 @@ def test_cumulative_getter_excludes_degraded_import_cost_row(energy_db) -> None: def test_buy_price_getter_returns_none_with_no_periods(energy_db) -> None: - """buy_price_now value_getter must return None when no non-degraded periods exist.""" + """buy_price_now value_getter must return None when no non-degraded periods exist. + + FUE-T05: provider requires an active electricity meter. Insert one so the + entity is present in the catalog; then verify the value_getter returns None + (no periods → no pricing snapshot). + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) buy_entry = next( @@ -553,9 +624,17 @@ def test_buy_price_getter_returns_none_with_no_periods(energy_db) -> None: def test_sell_price_getter_returns_none_with_no_periods(energy_db) -> None: - """sell_price_now value_getter must return None when no non-degraded periods exist.""" + """sell_price_now value_getter must return None when no non-degraded periods exist. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) sell_entry = next( @@ -567,12 +646,17 @@ def test_sell_price_getter_returns_none_with_no_periods(energy_db) -> None: def test_import_cost_getter_returns_none_with_no_non_degraded_periods(energy_db) -> None: - """import_cost_total value_getter must return None when only degraded rows exist.""" + """import_cost_total value_getter must return None when only degraded rows exist. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 5, 1, 0, 0, tzinfo=timezone.utc) + meter_start = datetime(2025, 5, 1, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) # Only a degraded row → SUM returns None (no rows to aggregate) _make_period(session, period_start=t0, import_cost=0.0, degraded=True) session.commit() @@ -590,9 +674,17 @@ def test_import_cost_getter_returns_none_with_no_non_degraded_periods(energy_db) def test_export_revenue_getter_returns_none_with_no_periods(energy_db) -> None: - """export_revenue_total value_getter must return None when no periods at all.""" + """export_revenue_total value_getter must return None when no periods at all. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) export_entry = next( @@ -673,14 +765,32 @@ 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. - Validates that the 2-element identifiers=('energy-cost', 'energy-cost') tuple - satisfies the ha_discovery.py requirement to access identifiers[1] as node_id. - This is a regression guard: if _energy_cost_provider used a 1-element tuple, - this call would raise IndexError. + 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. """ from app.integrations.expose import build_catalog from app.services.ha_discovery import build_discovery_payload + meter_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + meter_label = "Test Meter" + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + from app.models.energy import Meter as _M + m = _M( + label=meter_label, + commodity="electricity", + started_at=now, + ended_at=None, + reason="initial", + note=None, + created_at=now, + ) + # Override the auto-generated uuid so we can assert on it deterministically. + m.uuid = meter_uuid + session.add(m) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -688,36 +798,47 @@ 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] topic, config = build_discovery_payload(entry.entity, "homeassistant") - # Basic sanity checks on the result. - # Note: ha_discovery._node_id() replaces hyphens with underscores, so - # identifiers[1]="energy-cost" → node_id="energy_cost" in the topic. - assert "energy_cost" in topic, ( - f"Expected 'energy_cost' (hyphen→underscore) in discovery topic, got {topic!r}" + # node_id = identifiers[1] 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}" ) assert topic.endswith("/config"), ( f"Discovery topic must end with /config, got {topic!r}" ) assert "unique_id" in config - # unique_id = "_" - # identifiers[1]="energy-cost" (hyphens NOT replaced in unique_id, only in node_id) - assert "energy" in config["unique_id"], ( - f"unique_id must contain 'energy', got {config['unique_id']!r}" + # unique_id seed is identifiers[1] (meter uuid) + 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"] def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> None: """The energy-cost device has no online/offline heartbeat, so its discovery config must NOT declare an availability topic — otherwise HA marks the entities ``unavailable`` forever even though state is being published. + + FUE-T05: provider requires an active electricity meter. """ from app.integrations.expose import build_catalog from app.services.ha_discovery import build_discovery_payload + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -734,11 +855,23 @@ 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 'energy-cost' (hyphens → underscores).""" + """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 + 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 - device = DeviceInfo(identifiers=("energy-cost", "energy-cost"), name="Energy Cost") + meter_uuid = "12345678-abcd-ef00-1234-567890abcdef" + # identifiers[1] = meter uuid — this is what FUE-T05 sets. + device = DeviceInfo( + identifiers=("energy-cost", meter_uuid), + name="Energy Cost (Test Meter)", + provides_availability=False, + ) entity = ExposableEntity( key="energy.import_cost_total", component="sensor", @@ -751,10 +884,10 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None: topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant") - # node_id: hyphens replaced with underscores → "energy_cost" - expected_node = "energy_cost" + # node_id: identifiers[1] = meter_uuid, hyphens → underscores + expected_node = meter_uuid.replace("-", "_") assert f"/{expected_node}/" in topic, ( - f"Expected node_id 'energy_cost' in topic {topic!r}" + f"Expected meter uuid node_id {expected_node!r} in topic {topic!r}" ) assert topic.startswith("homeassistant/sensor/"), ( f"Topic must start with homeassistant/sensor/, got {topic!r}" @@ -762,6 +895,10 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None: assert "state_class" in config assert config["state_class"] == "total" assert config.get("device_class") == "monetary" + # unique_id must contain the meter uuid + assert meter_uuid in config["unique_id"], ( + f"unique_id must contain meter uuid, got {config['unique_id']!r}" + ) # --------------------------------------------------------------------------- @@ -770,15 +907,22 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None: def test_energy_entity_keys_are_stable_fixed_strings(energy_db) -> None: - """Entity keys must be fixed strings, not derived from DB ids or session state.""" + """Entity keys must be fixed strings, not derived from DB ids or session state. + + FUE-T05: provider requires an active electricity meter. Keys remain the same + stable strings (``energy.*``) regardless of which meter is active — only the + DeviceInfo identifiers/name change, not the keys. + """ from app.integrations.expose import build_catalog from datetime import timedelta t0 = datetime(2025, 6, 1, 0, 0, tzinfo=timezone.utc) t1 = t0 + timedelta(minutes=15) + meter_start = datetime(2025, 6, 1, 0, 0, tzinfo=timezone.utc) - # Insert two periods with different currencies to ensure key does not drift + # Insert active meter and first period with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start, label="Stable Key Meter") _make_period( session, period_start=t0, @@ -831,12 +975,16 @@ def test_energy_entity_keys_are_stable_fixed_strings(energy_db) -> None: def test_energy_provider_registered_alongside_modbus(energy_db) -> None: - """Both modbus and energy_cost providers must be registered and produce entities.""" + """Both modbus and energy_cost providers must be registered and produce entities. + + FUE-T05: energy-cost provider requires an active electricity meter. + """ from app.models.modbus import ModbusDevice from app.integrations.expose import build_catalog now = datetime.now(tz=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=now, label="Co-exist Electricity Meter") device = ModbusDevice( uuid="cccccccc-0000-0000-0000-000000000099", friendly_name="Co-exist Meter", @@ -1273,9 +1421,16 @@ def reset_tariff(monkeypatch): def _insert_manual_period(energy_db) -> None: - """Insert a single non-degraded manual pricing period into energy_db.""" + """Insert an active electricity meter + non-degraded manual pricing period into energy_db. + + FUE-T05: the energy-cost provider requires an active electricity meter, so + this helper inserts one alongside the period so that build_catalog returns + the energy entities. + """ t0 = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc) + meter_start = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start, label="Tariff Test Meter") _make_period( session, period_start=t0, @@ -1385,11 +1540,15 @@ def test_sell_price_tariff_none_falls_back_to_normal(energy_db, reset_tariff) -> def test_tibber_buy_price_not_affected_by_tariff(energy_db, reset_tariff) -> None: - """Tibber buy_price_now must NOT be affected by the DSMR tariff — always uses 'buy' key.""" + """Tibber buy_price_now must NOT be affected by the DSMR tariff — always uses 'buy' key. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog from app.services.dsmr_ingest import set_current_tariff t0 = datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc) + meter_start = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc) tibber_pricing = { "kind": "tibber", "buy": "0.3100", @@ -1400,6 +1559,7 @@ def test_tibber_buy_price_not_affected_by_tariff(energy_db, reset_tariff) -> Non } with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period( session, period_start=t0, @@ -1421,11 +1581,15 @@ def test_tibber_buy_price_not_affected_by_tariff(energy_db, reset_tariff) -> Non def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> None: - """Tibber sell_price_now must NOT be affected by the DSMR tariff — always uses 'sell' key.""" + """Tibber sell_price_now must NOT be affected by the DSMR tariff — always uses 'sell' key. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog from app.services.dsmr_ingest import set_current_tariff t0 = datetime(2026, 4, 1, 9, 15, tzinfo=timezone.utc) + meter_start = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc) tibber_pricing = { "kind": "tibber", "buy": "0.3100", @@ -1436,6 +1600,7 @@ def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> No } with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period( session, period_start=t0, @@ -1461,9 +1626,17 @@ def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> No def test_daily_entities_in_catalog(energy_db) -> None: - """FU11: catalog must include import_cost_today and export_revenue_today.""" + """FU11: catalog must include import_cost_today and export_revenue_today. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -1476,9 +1649,17 @@ def test_daily_entities_in_catalog(energy_db) -> None: def test_daily_entities_have_total_increasing_state_class(energy_db) -> None: - """FU11: daily entities must have state_class='total_increasing' (not 'total').""" + """FU11: daily entities must have state_class='total_increasing' (not 'total'). + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -1498,9 +1679,16 @@ def test_daily_entities_have_monetary_device_class(energy_db) -> None: allow it with total and total_increasing. monetary+total_increasing is the correct combination for a non-negative, monotonically non-decreasing daily quantity that resets at local midnight. + + FUE-T05: provider requires an active electricity meter. """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -1514,9 +1702,17 @@ def test_daily_entities_have_monetary_device_class(energy_db) -> None: def test_daily_entities_default_disabled(energy_db) -> None: - """FU11: daily entities must default to enabled=False (no toggle row seeded).""" + """FU11: daily entities must default to enabled=False (no toggle row seeded). + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog + now = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=now) + session.commit() + with Session(energy_db) as session: catalog = build_catalog(session) @@ -1535,6 +1731,8 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: Setup: period at local 'today' (within today's UTC window), active contract. Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day (Principle C: today counts as elapsed if we're past local midnight — which UTC is). + + FUE-T05: provider requires an active electricity meter. """ from datetime import timedelta from unittest.mock import patch @@ -1552,10 +1750,12 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: # Contract starting well before today effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) import_cost_today = 1.23 with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start, label="Today Window Meter") _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1586,7 +1786,10 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: - """FU11: export_revenue_today getter returns value for today's local window.""" + """FU11: export_revenue_today getter returns value for today's local window. + + FUE-T05: provider requires an active electricity meter. + """ from datetime import timedelta from unittest.mock import patch from zoneinfo import ZoneInfo @@ -1599,9 +1802,11 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) export_today = 0.75 with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start, label="Today Export Meter") _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1629,12 +1834,17 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: def test_daily_getter_returns_none_when_no_active_contract(energy_db) -> None: - """FU11: daily getters return None when no active contract exists.""" + """FU11: daily getters return None when no active contract exists. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) + meter_start = datetime(2026, 6, 25, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start, label="No Contract Meter") # Inactive contract _make_contract_with_version( session, @@ -1665,12 +1875,17 @@ def test_daily_getter_returns_none_when_no_active_contract(energy_db) -> None: def test_daily_getter_returns_none_when_no_non_degraded_periods(energy_db) -> None: - """FU11: daily getters return None when only degraded rows exist.""" + """FU11: daily getters return None when only degraded rows exist. + + FUE-T05: provider requires an active electricity meter. + """ from app.integrations.expose import build_catalog t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) + meter_start = datetime(2026, 6, 25, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start, label="Degraded Only Meter") _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1773,29 +1988,61 @@ def test_cumulative_anchor_is_meter_started_at(energy_db) -> None: def test_cumulative_returns_none_when_no_active_meter(energy_db) -> None: - """M7-T04 None protection: no active electricity meter → import/export total = None. + """M7-T04 None protection: no active electricity meter → cumulative getters return None. - This is the key D2 None protection: without an active meter the anchor - cannot be resolved, so the getter returns None rather than a stale/wrong value. + FUE-T05 interaction: when no active meter exists, the provider returns [] and + entities are not in the catalog. This test verifies the D2 None-protection of the + value_getters themselves (the inner functions) by calling them directly via a + catalog built with an active meter, then closing the meter (ended_at set) and + calling the getter in that closed-meter session. + + This is the key D2 None protection: without an active meter the anchor cannot be + resolved, so the getter returns None rather than a stale/wrong value. Periods exist (non-degraded), but no Meter row with ended_at IS NULL is present. """ - from app.integrations.expose import build_catalog + from app.models.energy import Meter t0 = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc) t1 = datetime(2026, 3, 1, 10, 15, tzinfo=timezone.utc) + meter_start = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc) + closed_at = datetime(2026, 3, 1, 12, 0, tzinfo=timezone.utc) with Session(energy_db) as session: - # No Meter row at all — cumulative getters must return None. + # Insert a meter that will later be closed. + m = Meter( + label="Will Be Closed", + commodity="electricity", + started_at=meter_start, + ended_at=None, + reason="initial", + note=None, + created_at=meter_start, + ) + session.add(m) _make_period(session, period_start=t0, import_cost=1.00, degraded=False) _make_period(session, period_start=t1, export_revenue=0.50, degraded=False) session.commit() + meter_id = m.id + # Build the catalog while the meter is active to capture the value_getter callables. + from app.integrations.expose import build_catalog with Session(energy_db) as session: catalog = build_catalog(session) import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_total") export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_total") - import_val = import_entry.entity.value_getter(session) - export_val = export_entry.entity.value_getter(session) + import_getter = import_entry.entity.value_getter + export_getter = export_entry.entity.value_getter + + # Now close the meter (no active meter remains). + with Session(energy_db) as session: + m = session.get(Meter, meter_id) + m.ended_at = closed_at + session.commit() + + # Call getters in a session where there is no active meter. + with Session(energy_db) as session: + import_val = import_getter(session) + export_val = export_getter(session) assert import_val is None, ( f"import_cost_total must be None with no active meter, got {import_val!r}" @@ -1917,11 +2164,12 @@ def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None: D2 only changes the cumulative (*_total) anchor. The *_today getters use today's local-day window [local midnight, local tomorrow midnight) in UTC. - They must continue to work correctly regardless of the meter anchor change, - and do NOT require an active meter (they use today's window directly). + They must work correctly regardless of which meter is active (meter started + 30 days ago vs. 7 days ago — value must be the same: today's metered+fixed). - Setup: period 1h ago (today's local window, TZ=UTC), active contract. - Expected: value = import_today + fixed_for_today, independent of any meter. + FUE-T05: provider requires an active electricity meter to produce entities. + The test verifies that the *_today value is NOT influenced by the meter's + started_at (it uses today's window, not the meter anchor). """ from decimal import Decimal from datetime import timedelta @@ -1937,11 +2185,16 @@ def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None: t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + # Meter started far in the past — daily value must equal today's window value, + # NOT vary with the meter's started_at (that's the cumulative getter's job). + meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) import_cost_today = 0.88 export_today = 0.44 with Session(energy_db) as session: - # No active meter added intentionally — daily getters must not need it. + # Active meter required by FUE-T05 provider; meter started_at must NOT + # affect the today getter's window (today's local midnight → tomorrow). + _make_active_meter(session, started_at=meter_start, label="Far-Past Meter") _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1977,10 +2230,241 @@ def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None: expected_export = float(Decimal(str(export_today)) + daily_credit * 1) assert import_val == pytest.approx(expected_import, rel=1e-6), ( - f"import_cost_today must use local-day window regardless of meter; " + f"import_cost_today must use local-day window regardless of meter started_at; " f"expected {expected_import}, got {import_val!r}" ) assert export_val == pytest.approx(expected_export, rel=1e-6), ( - f"export_revenue_today must use local-day window regardless of meter; " + f"export_revenue_today must use local-day window regardless of meter started_at; " f"expected {expected_export}, got {export_val!r}" ) + + +# --------------------------------------------------------------------------- +# FUE-T05: energy-cost provider identity anchored to active electricity meter +# --------------------------------------------------------------------------- + + +def test_energy_cost_provider_returns_empty_when_no_active_meter(energy_db) -> None: + """FUE-T05 ①: no active electricity meter → provider returns []. + + Without an active meter the energy-cost identifiers cannot be anchored, + so the provider must return an empty list. HA will not see any energy-cost + sensor until a meter is declared. + """ + from app.integrations.expose import _energy_cost_provider + + with Session(energy_db) as session: + result = _energy_cost_provider(session) + + assert result == [], ( + f"Expected [] when no active electricity meter, got {result!r}" + ) + + +def test_energy_cost_provider_identifiers_match_meter_uuid(energy_db) -> None: + """FUE-T05 ②: with an active meter, identifiers[1] == meter.uuid. + + 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. + """ + from app.integrations.expose import _energy_cost_provider + + meter_label = "Sunny Side Meter" + now = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + + with Session(energy_db) as session: + m = _make_active_meter(session, started_at=now, label=meter_label) + meter_uuid = m.uuid + session.commit() + + with Session(energy_db) as session: + entities = _energy_cost_provider(session) + + 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 meter_label in entity.device.name, ( + f"device.name must contain meter label {meter_label!r}, " + f"got {entity.device.name!r}" + ) + + +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. + """ + from app.integrations.expose import _energy_cost_provider + + now = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + + with Session(energy_db) as session: + m = _make_active_meter(session, started_at=now, label="Key Stability Meter") + meter_uuid = m.uuid + session.commit() + + with Session(energy_db) as session: + entities = _energy_cost_provider(session) + + expected_keys = { + "energy.buy_price_now", + "energy.sell_price_now", + "energy.import_cost_total", + "energy.export_revenue_total", + "energy.import_cost_today", + "energy.export_revenue_today", + } + actual_keys = {e.key for e in entities} + assert actual_keys == expected_keys, ( + f"Expected stable keys {expected_keys!r}, got {actual_keys!r}" + ) + # None of the keys must embed the meter uuid. + for key in actual_keys: + assert meter_uuid not in key, ( + f"Key {key!r} must NOT contain meter uuid {meter_uuid!r}" + ) + + +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] / + 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. + + This is the critical property: the user does NOT need to re-tick toggles after a swap. + """ + from app.integrations.expose import build_catalog + from app.models.energy import Meter + from app.models.expose import ExposedEntityToggle + + t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + swap_at = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + + with Session(energy_db) as session: + # Old meter (initial) + old_meter = Meter( + label="Old Meter", + commodity="electricity", + started_at=t0, + ended_at=swap_at, + reason="initial", + note=None, + created_at=t0, + ) + session.add(old_meter) + # Enable buy_price_now toggle + toggle = ExposedEntityToggle( + key="energy.buy_price_now", + enabled=True, + updated_at=t0, + ) + session.add(toggle) + session.commit() + + # Perform meter swap: new active meter + with Session(energy_db) as session: + new_meter = Meter( + label="New Meter", + commodity="electricity", + started_at=swap_at, + ended_at=None, + reason="meter_swap", + note=None, + created_at=swap_at, + ) + session.add(new_meter) + session.commit() + new_meter_uuid = new_meter.uuid + + # After swap: catalog should use new meter uuid, but toggle state is preserved. + with Session(energy_db) as session: + catalog = build_catalog(session) + + buy_entry = next( + (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}" + ) + # 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 " + "(key is stable, toggle row survives)" + ) + + +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). + + 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. + """ + from app.integrations.expose import _energy_cost_provider + from app.models.energy import Meter + + t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + swap_at = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + + with Session(energy_db) as session: + old_meter = Meter( + label="Old Meter", + commodity="electricity", + started_at=t0, + ended_at=swap_at, + reason="initial", + note=None, + created_at=t0, + ) + session.add(old_meter) + session.commit() + old_uuid = old_meter.uuid + + # Before swap: old meter closed, new meter not yet created → no active meter → [] + with Session(energy_db) as session: + result_no_active = _energy_cost_provider(session) + assert result_no_active == [], ( + "Expected [] after old meter is closed but before new meter is created" + ) + + # Create new active meter (simulates the swap completing) + with Session(energy_db) as session: + new_meter = Meter( + label="New Meter", + commodity="electricity", + started_at=swap_at, + ended_at=None, + reason="meter_swap", + note=None, + created_at=swap_at, + ) + session.add(new_meter) + session.commit() + new_uuid = new_meter.uuid + + assert old_uuid != new_uuid, "Old and new meter uuids must differ" + + with Session(energy_db) as session: + entities_after_swap = _energy_cost_provider(session) + + assert len(entities_after_swap) == 6, ( + 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}" + ) + # Old uuid must not appear in identifiers + assert entity.device.identifiers[1] != old_uuid, ( + f"After swap, old uuid {old_uuid!r} must not appear in identifiers" + ) diff --git a/tests/test_expose_catalog.py b/tests/test_expose_catalog.py index fb6a7a8..4f27aa6 100644 --- a/tests/test_expose_catalog.py +++ b/tests/test_expose_catalog.py @@ -205,27 +205,58 @@ def test_register_provider_direct_call(): def test_build_catalog_empty_with_no_devices(expose_db): """build_catalog with no enabled modbus devices must contain no modbus entities. - The energy_cost provider is always registered and always produces its 6 entities - (4 original + 2 daily) regardless of device state, so the catalog will not be empty. - This test checks that no *modbus* entities are present when there are no enabled - modbus devices. + FUE-T05: the energy_cost provider now requires an active electricity meter. + Without one, it returns [] and the catalog contains no energy entities. + With one, it produces 6 entities. + + This test verifies both cases: + 1. No modbus devices → no modbus entities. + 2. No active meter → no energy entities (provider returns []). + 3. After inserting an active meter → 6 energy entities present. """ from app.integrations.expose import build_catalog + # Case: no modbus devices, no active meter → catalog is empty. with Session(expose_db) as session: catalog = build_catalog(session) - # The modbus provider finds no enabled devices → no modbus entities. - # The energy_cost provider always produces 6 entities, so the catalog is non-empty. modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")] assert modbus_entities == [], ( "Expected no modbus entities when no modbus devices are enabled" ) - # The 6 energy_cost entities should always be present (4 original + 2 daily). - energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")} - assert len(energy_keys) == 6, ( - f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}" + # No active electricity meter → energy-cost provider returns [] → no energy entities. + energy_keys_no_meter = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")} + assert len(energy_keys_no_meter) == 0, ( + f"Expected 0 energy_cost entities (no active meter), got {energy_keys_no_meter!r}" + ) + + # Insert an active electricity meter → provider should now produce 6 entities. + from datetime import datetime, timezone + now = datetime.now(tz=timezone.utc) + with Session(expose_db) as session: + from app.models.energy import Meter + m = Meter( + label="Test Meter for catalog", + commodity="electricity", + started_at=now, + ended_at=None, + reason="initial", + note=None, + created_at=now, + ) + session.add(m) + session.commit() + + with Session(expose_db) as session: + catalog_with_meter = build_catalog(session) + + energy_keys_with_meter = { + e.entity.key for e in catalog_with_meter if e.entity.key.startswith("energy.") + } + assert len(energy_keys_with_meter) == 6, ( + f"Expected exactly 6 energy_cost entities (4 original + 2 daily) with active meter, " + f"got {energy_keys_with_meter!r}" )