FUE-T05: anchor energy-cost HA device identity to active meter (uuid id, label name, empty when none)

This commit is contained in:
2026-06-25 20:43:07 +02:00
parent f663981cdb
commit efbe36d7c0
3 changed files with 643 additions and 80 deletions
+61 -13
View File
@@ -373,12 +373,36 @@ register_provider(_modbus_provider)
def _energy_cost_provider(session: Session) -> list[ExposableEntity]: def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
"""Enumerate ExposableEntity objects for the energy cost subsystem. """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). - ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
- ``sell_price_now`` — current effective sell 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). - ``import_cost_total`` — cumulative import cost (total, monetary).
- ``export_revenue_total`` — cumulative export revenue (total_increasing, 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) Current-price algorithm (source-agnostic, with fallback)
--------------------------------------------------------- ---------------------------------------------------------
@@ -399,8 +423,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
Cumulative totals Cumulative totals
----------------- -----------------
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded** ``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded ``energy_cost_period`` rows within the current meter's window. Degraded rows
to avoid double-counting when they are later overwritten by real values. carry 0 costs and are excluded to avoid double-counting when they are later
overwritten by real values.
Currency Currency
-------- --------
@@ -414,16 +439,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
- ``"energy.sell_price_now"`` - ``"energy.sell_price_now"``
- ``"energy.import_cost_total"`` - ``"energy.import_cost_total"``
- ``"energy.export_revenue_total"`` - ``"energy.export_revenue_total"``
- ``"energy.import_cost_today"``
- ``"energy.export_revenue_today"``
DeviceInfo identifiers DeviceInfo identifiers
---------------------- ----------------------
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that **Two-element tuple** ``("energy-cost", meter.uuid)`` so that
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid ``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the
(the service uses index [1] as the node_id throughout). 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 = ( latest_period: EnergyCostPeriod | None = (
session.query(EnergyCostPeriod) 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: if latest_period is not None and latest_period.currency:
currency = 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 # provides_availability=False: the energy-cost device has only sensors and no
# online/offline heartbeat, so its entities must be "always available" in HA. # online/offline heartbeat, so its entities must be "always available" in HA.
# (Otherwise HA shows them unavailable despite state being published.) # (Otherwise HA shows them unavailable despite state being published.)
device_info = DeviceInfo( device_info = DeviceInfo(
identifiers=("energy-cost", "energy-cost"), identifiers=("energy-cost", active_meter.uuid),
name="Energy Cost", name=f"Energy Cost ({active_meter.label})",
provides_availability=False, provides_availability=False,
) )
+541 -57
View File
@@ -164,9 +164,18 @@ def energy_db(tmp_path: Path):
def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) t0 = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc)
meter_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
tibber_pricing = { tibber_pricing = {
"kind": "tibber", "kind": "tibber",
"buy": "0.2850", "buy": "0.2850",
@@ -280,6 +325,7 @@ def test_buy_price_getter_reads_tibber_snapshot(energy_db) -> None:
} }
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period( _make_period(
session, session,
period_start=t0, 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 1, 1, 12, 15, tzinfo=timezone.utc) t0 = datetime(2025, 1, 1, 12, 15, tzinfo=timezone.utc)
meter_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
tibber_pricing = { tibber_pricing = {
"kind": "tibber", "kind": "tibber",
"buy": "0.3100", "buy": "0.3100",
@@ -320,6 +370,7 @@ def test_sell_price_getter_reads_tibber_snapshot(energy_db) -> None:
} }
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period( _make_period(
session, session,
period_start=t0, 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 2, 1, 8, 0, tzinfo=timezone.utc) t0 = datetime(2025, 2, 1, 8, 0, tzinfo=timezone.utc)
meter_start = datetime(2025, 2, 1, 0, 0, tzinfo=timezone.utc)
manual_pricing = { manual_pricing = {
"kind": "manual", "kind": "manual",
"buy_dal": "0.2500", "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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period( _make_period(
session, session,
period_start=t0, 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 2, 1, 8, 15, tzinfo=timezone.utc) t0 = datetime(2025, 2, 1, 8, 15, tzinfo=timezone.utc)
meter_start = datetime(2025, 2, 1, 0, 0, tzinfo=timezone.utc)
manual_pricing = { manual_pricing = {
"kind": "manual", "kind": "manual",
"buy_dal": "0.2500", "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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period( _make_period(
session, session,
period_start=t0, 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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
buy_entry = next( 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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
sell_entry = next( 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 5, 1, 0, 0, tzinfo=timezone.utc) 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: 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) # Only a degraded row → SUM returns None (no rows to aggregate)
_make_period(session, period_start=t0, import_cost=0.0, degraded=True) _make_period(session, period_start=t0, import_cost=0.0, degraded=True)
session.commit() 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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
export_entry = next( 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: 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. """build_discovery_payload must NOT raise IndexError for energy_cost entities.
Validates that the 2-element identifiers=('energy-cost', 'energy-cost') tuple FUE-T05: identifiers is now ('energy-cost', meter.uuid).
satisfies the ha_discovery.py requirement to access identifiers[1] as node_id. Validates that the 2-element identifiers tuple satisfies ha_discovery.py's
This is a regression guard: if _energy_cost_provider used a 1-element tuple, requirement to access identifiers[1] as node_id.
this call would raise IndexError.
""" """
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
from app.services.ha_discovery import build_discovery_payload 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: with Session(energy_db) as session:
catalog = build_catalog(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" assert len(energy_entries) == 6, "Expected 6 energy_cost entities in catalog"
for entry in energy_entries: 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] # Must not raise — specifically no IndexError from identifiers[1]
topic, config = build_discovery_payload(entry.entity, "homeassistant") topic, config = build_discovery_payload(entry.entity, "homeassistant")
# Basic sanity checks on the result. # node_id = identifiers[1] with hyphens → underscores
# Note: ha_discovery._node_id() replaces hyphens with underscores, so node_id = meter_uuid.replace("-", "_")
# identifiers[1]="energy-cost" → node_id="energy_cost" in the topic. assert node_id in topic, (
assert "energy_cost" in topic, ( f"Expected meter uuid node_id {node_id!r} in discovery topic, got {topic!r}"
f"Expected 'energy_cost' (hyphen→underscore) in discovery topic, got {topic!r}"
) )
assert topic.endswith("/config"), ( assert topic.endswith("/config"), (
f"Discovery topic must end with /config, got {topic!r}" f"Discovery topic must end with /config, got {topic!r}"
) )
assert "unique_id" in config assert "unique_id" in config
# unique_id = "<identifiers[1]>_<key.replace('.','_')>" # unique_id seed is identifiers[1] (meter uuid) + entity key
# identifiers[1]="energy-cost" (hyphens NOT replaced in unique_id, only in node_id) assert meter_uuid in config["unique_id"], (
assert "energy" in config["unique_id"], ( f"unique_id must contain meter uuid, got {config['unique_id']!r}"
f"unique_id must contain 'energy', got {config['unique_id']!r}"
) )
assert "device" in config assert "device" in config
assert "energy-cost" in config["device"]["identifiers"] 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: 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 """The energy-cost device has no online/offline heartbeat, so its discovery
config must NOT declare an availability topic — otherwise HA marks the config must NOT declare an availability topic — otherwise HA marks the
entities ``unavailable`` forever even though state is being published. 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.integrations.expose import build_catalog
from app.services.ha_discovery import build_discovery_payload 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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.integrations.expose import DeviceInfo, ExposableEntity
from app.services.ha_discovery import build_discovery_payload 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( entity = ExposableEntity(
key="energy.import_cost_total", key="energy.import_cost_total",
component="sensor", 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") topic, config = build_discovery_payload(entity, discovery_prefix="homeassistant")
# node_id: hyphens replaced with underscores → "energy_cost" # node_id: identifiers[1] = meter_uuid, hyphens → underscores
expected_node = "energy_cost" expected_node = meter_uuid.replace("-", "_")
assert f"/{expected_node}/" in topic, ( 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/"), ( assert topic.startswith("homeassistant/sensor/"), (
f"Topic must start with homeassistant/sensor/, got {topic!r}" 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 "state_class" in config
assert config["state_class"] == "total" assert config["state_class"] == "total"
assert config.get("device_class") == "monetary" 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: 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 app.integrations.expose import build_catalog
from datetime import timedelta from datetime import timedelta
t0 = datetime(2025, 6, 1, 0, 0, tzinfo=timezone.utc) t0 = datetime(2025, 6, 1, 0, 0, tzinfo=timezone.utc)
t1 = t0 + timedelta(minutes=15) 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start, label="Stable Key Meter")
_make_period( _make_period(
session, session,
period_start=t0, 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: 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.models.modbus import ModbusDevice
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=now, label="Co-exist Electricity Meter")
device = ModbusDevice( device = ModbusDevice(
uuid="cccccccc-0000-0000-0000-000000000099", uuid="cccccccc-0000-0000-0000-000000000099",
friendly_name="Co-exist Meter", friendly_name="Co-exist Meter",
@@ -1273,9 +1421,16 @@ def reset_tariff(monkeypatch):
def _insert_manual_period(energy_db) -> None: 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) 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start, label="Tariff Test Meter")
_make_period( _make_period(
session, session,
period_start=t0, 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: 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.integrations.expose import build_catalog
from app.services.dsmr_ingest import set_current_tariff from app.services.dsmr_ingest import set_current_tariff
t0 = datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc) t0 = datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc)
meter_start = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
tibber_pricing = { tibber_pricing = {
"kind": "tibber", "kind": "tibber",
"buy": "0.3100", "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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period( _make_period(
session, session,
period_start=t0, 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: 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.integrations.expose import build_catalog
from app.services.dsmr_ingest import set_current_tariff from app.services.dsmr_ingest import set_current_tariff
t0 = datetime(2026, 4, 1, 9, 15, tzinfo=timezone.utc) t0 = datetime(2026, 4, 1, 9, 15, tzinfo=timezone.utc)
meter_start = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
tibber_pricing = { tibber_pricing = {
"kind": "tibber", "kind": "tibber",
"buy": "0.3100", "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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period( _make_period(
session, session,
period_start=t0, 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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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 allow it with total and total_increasing. monetary+total_increasing is the correct
combination for a non-negative, monotonically non-decreasing daily quantity that combination for a non-negative, monotonically non-decreasing daily quantity that
resets at local midnight. resets at local midnight.
FUE-T05: provider requires an active electricity meter.
""" """
from app.integrations.expose import build_catalog 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: with Session(energy_db) as session:
catalog = build_catalog(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: 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 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: with Session(energy_db) as session:
catalog = build_catalog(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. 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 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). (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 datetime import timedelta
from unittest.mock import patch 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 # Contract starting well before today
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) 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 import_cost_today = 1.23
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start, label="Today Window Meter")
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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: 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 datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from zoneinfo import ZoneInfo 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) 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) 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 export_today = 0.75
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start, label="Today Export Meter")
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start, label="No Contract Meter")
# Inactive contract # Inactive contract
_make_contract_with_version( _make_contract_with_version(
session, 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start, label="Degraded Only Meter")
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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: 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 FUE-T05 interaction: when no active meter exists, the provider returns [] and
cannot be resolved, so the getter returns None rather than a stale/wrong value. 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. 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) t0 = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
t1 = datetime(2026, 3, 1, 10, 15, 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: 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=t0, import_cost=1.00, degraded=False)
_make_period(session, period_start=t1, export_revenue=0.50, degraded=False) _make_period(session, period_start=t1, export_revenue=0.50, degraded=False)
session.commit() 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: with Session(energy_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_total") 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") export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_total")
import_val = import_entry.entity.value_getter(session) import_getter = import_entry.entity.value_getter
export_val = export_entry.entity.value_getter(session) 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, ( assert import_val is None, (
f"import_cost_total must be None with no active meter, got {import_val!r}" 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 D2 only changes the cumulative (*_total) anchor. The *_today getters
use today's local-day window [local midnight, local tomorrow midnight) in UTC. 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, They must work correctly regardless of which meter is active (meter started
and do NOT require an active meter (they use today's window directly). 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. FUE-T05: provider requires an active electricity meter to produce entities.
Expected: value = import_today + fixed_for_today, independent of any meter. 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 decimal import Decimal
from datetime import timedelta 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) 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) 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 import_cost_today = 0.88
export_today = 0.44 export_today = 0.44
with Session(energy_db) as session: 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( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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) expected_export = float(Decimal(str(export_today)) + daily_credit * 1)
assert import_val == pytest.approx(expected_import, rel=1e-6), ( 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}" f"expected {expected_import}, got {import_val!r}"
) )
assert export_val == pytest.approx(expected_export, rel=1e-6), ( 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}" 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"
)
+41 -10
View File
@@ -205,27 +205,58 @@ def test_register_provider_direct_call():
def test_build_catalog_empty_with_no_devices(expose_db): def test_build_catalog_empty_with_no_devices(expose_db):
"""build_catalog with no enabled modbus devices must contain no modbus entities. """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 FUE-T05: the energy_cost provider now requires an active electricity meter.
(4 original + 2 daily) regardless of device state, so the catalog will not be empty. Without one, it returns [] and the catalog contains no energy entities.
This test checks that no *modbus* entities are present when there are no enabled With one, it produces 6 entities.
modbus devices.
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 from app.integrations.expose import build_catalog
# Case: no modbus devices, no active meter → catalog is empty.
with Session(expose_db) as session: with Session(expose_db) as session:
catalog = build_catalog(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.")] modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
assert modbus_entities == [], ( assert modbus_entities == [], (
"Expected no modbus entities when no modbus devices are enabled" "Expected no modbus entities when no modbus devices are enabled"
) )
# The 6 energy_cost entities should always be present (4 original + 2 daily). # No active electricity meter → energy-cost provider returns [] → no energy entities.
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")} energy_keys_no_meter = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
assert len(energy_keys) == 6, ( assert len(energy_keys_no_meter) == 0, (
f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}" 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}"
) )