3 Commits
Author SHA1 Message Date
tliu93 c26160b10b FUE-T06: trigger HA discovery republish after meter declare/update
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 10m36s
2026-06-25 20:54:42 +02:00
tliu93 efbe36d7c0 FUE-T05: anchor energy-cost HA device identity to active meter (uuid id, label name, empty when none) 2026-06-25 20:43:07 +02:00
tliu93 f663981cdb FUE-T04: add Meter.uuid (stable HA identity anchor) + backfill migration 2026-06-25 20:20:01 +02:00
10 changed files with 934 additions and 82 deletions
@@ -0,0 +1,104 @@
"""add uuid column to meter table
Adds a stable ``uuid`` (UUID v4 string) column to the ``meter`` table so that
each meter epoch has a durable identity anchor suitable for use as an HA
Discovery ``unique_id``.
**Migration strategy (SQLite-safe)**:
SQLite does not support adding a NOT NULL + UNIQUE column to a non-empty table
in a single ``ALTER TABLE ADD COLUMN`` statement (adding a NOT NULL column
without a default value is rejected if the table already has rows). The
safe approach used here is:
1. Add ``uuid`` as a **nullable** column (SQLite allows this).
2. **Back-fill** every existing ``meter`` row with a distinct ``str(uuid4())``
value. Each row gets its *own* random UUID — not a shared value — so the
subsequent UNIQUE constraint is satisfied.
3. Use ``batch_alter_table`` (which re-creates the table under the hood in
SQLite) to alter the column to ``NOT NULL`` and add a UNIQUE constraint.
**Idempotency**: only rows where ``uuid IS NULL`` are back-filled; rows that
already have a uuid (e.g. from a repeated upgrade after a partial failure) are
left untouched.
**Audit**: after back-fill, the count of rows with ``uuid IS NULL`` must be
exactly zero; if not, the migration raises ``RuntimeError`` and rolls back.
**Data safety**: this migration is additive only — no existing rows are deleted
or overwritten; it only adds a new column and fills it in.
Revision ID: 20260625_14_meter_uuid
Revises: 20260625_13_meter_table
Create Date: 2026-06-25 00:00:00.000000
"""
import uuid as _uuid
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260625_14_meter_uuid"
down_revision: Union[str, None] = "20260625_13_meter_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# ------------------------------------------------------------------ #
# 1. Add uuid as a nullable column. #
# ------------------------------------------------------------------ #
with op.batch_alter_table("meter", schema=None) as batch_op:
batch_op.add_column(
sa.Column("uuid", sa.String(length=36), nullable=True)
)
# ------------------------------------------------------------------ #
# 2. Back-fill: assign a distinct UUID to every row that has #
# uuid IS NULL. Each row gets its own random value so that the #
# subsequent UNIQUE constraint is satisfied. #
# ------------------------------------------------------------------ #
rows = conn.execute(sa.text("SELECT id FROM meter WHERE uuid IS NULL")).fetchall()
for (meter_id,) in rows:
new_uuid = str(_uuid.uuid4())
conn.execute(
sa.text("UPDATE meter SET uuid = :uuid WHERE id = :mid"),
{"uuid": new_uuid, "mid": meter_id},
)
# ------------------------------------------------------------------ #
# 3. Audit: verify no rows remain with uuid IS NULL. #
# ------------------------------------------------------------------ #
null_count_row = conn.execute(
sa.text("SELECT COUNT(*) FROM meter WHERE uuid IS NULL")
).fetchone()
null_count: int = null_count_row[0] if null_count_row else 0
if null_count != 0:
raise RuntimeError(
f"meter.uuid back-fill audit failed: {null_count} meter row(s) still have "
"uuid IS NULL after back-fill. Migration aborted to protect data integrity."
)
# ------------------------------------------------------------------ #
# 4. Alter column to NOT NULL + UNIQUE (requires batch on SQLite). #
# batch_alter_table re-creates the table, so the UNIQUE constraint #
# and NOT NULL are applied atomically. #
# ------------------------------------------------------------------ #
with op.batch_alter_table("meter", schema=None) as batch_op:
batch_op.alter_column(
"uuid",
existing_type=sa.String(length=36),
nullable=False,
)
batch_op.create_unique_constraint("uq_meter_uuid", ["uuid"])
def downgrade() -> None:
# Drop the UNIQUE constraint and the uuid column (batch on SQLite).
with op.batch_alter_table("meter", schema=None) as batch_op:
batch_op.drop_constraint("uq_meter_uuid", type_="unique")
batch_op.drop_column("uuid")
+28
View File
@@ -79,6 +79,24 @@ router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
# ---------------------------------------------------------------------------
def _trigger_discovery_republish(session: Session) -> None:
"""Call publish_discovery after a meter write operation (best-effort).
No-op if MQTT / discovery is not enabled or the broker is not connected
(publish_discovery guards internally). All errors are swallowed so that a
discovery failure never breaks the API response.
Must be called **after** db.commit() so that publish_discovery sees the
final committed state of the meter table when it rebuilds the catalog.
"""
try:
from app.services.ha_discovery import publish_discovery
publish_discovery(session)
except Exception:
logger.exception("_trigger_discovery_republish: publish_discovery raised an error")
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
"""Return the meter with the given id or raise 404."""
meter: Optional[Meter] = db.get(Meter, meter_id)
@@ -217,6 +235,11 @@ def declare_energy_meter(
db.commit()
db.refresh(new_meter)
# Trigger HA discovery re-publish so the new active meter's energy-cost
# device/sensor configuration is pushed to Home Assistant. Best-effort:
# failures are logged and swallowed; the API response is not affected.
_trigger_discovery_republish(db)
logger.info(
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
body.commodity,
@@ -294,6 +317,11 @@ def patch_energy_meter(
db.commit()
db.refresh(meter)
# Trigger HA discovery re-publish so label renames on the active meter
# propagate to the HA device name. Best-effort: failures are logged and
# swallowed; the API response is not affected.
_trigger_discovery_republish(db)
logger.info(
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
meter_id,
+61 -13
View File
@@ -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,
)
+10
View File
@@ -11,6 +11,7 @@ Six tables:
from __future__ import annotations
import uuid as _uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
@@ -20,6 +21,10 @@ from sqlalchemy.types import JSON
from app.db import Base
def _uuid4_str() -> str:
return str(_uuid.uuid4())
class Meter(Base):
"""One physical electricity meter's installation epoch.
@@ -47,6 +52,11 @@ class Meter(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable internal identity — used as HA Discovery unique_id anchor.
uuid: Mapped[str] = mapped_column(
String(36), unique=True, nullable=False, default=_uuid4_str
)
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
label: Mapped[str] = mapped_column(String(255), nullable=False)
+1 -1
View File
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
from app.config import get_settings
APP_BASELINE_REVISION = "20260625_13_meter_table"
APP_BASELINE_REVISION = "20260625_14_meter_uuid"
class AppDatabaseAdoptionError(RuntimeError):
+93
View File
@@ -72,6 +72,24 @@ def _declare_payload(**overrides) -> dict:
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def mock_publish_discovery():
"""Auto-mock publish_discovery for all tests in this module.
The meters API now calls _trigger_discovery_republish (best-effort) after
every successful write. publish_discovery is lazy-imported inside that
helper, so we patch it at its canonical source path
(app.services.ha_discovery.publish_discovery). Tests that need to assert
the call receive this fixture explicitly; all others benefit from the
isolation it provides (no live MQTT broker required).
"""
with patch(
"app.services.ha_discovery.publish_discovery",
return_value=None,
) as mock:
yield mock
@pytest.fixture()
def meters_client(auth_database):
"""TestClient + SQLAlchemy engine for Meter API tests."""
@@ -660,3 +678,78 @@ def test_patch_started_at_earlier_updates_boundary(meters_client):
if ended is not None and ended.tzinfo is None:
ended = ended.replace(tzinfo=UTC)
assert ended == t1_earlier
# ---------------------------------------------------------------------------
# FUE-T06: HA discovery re-publish triggered after meter writes
# ---------------------------------------------------------------------------
def test_declare_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
"""POST /api/energy/meters triggers publish_discovery after successful commit."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Discovery Meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# publish_discovery must have been called exactly once after the declare.
mock_publish_discovery.assert_called_once()
def test_patch_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
"""PATCH /api/energy/meters/{id} triggers publish_discovery after successful commit."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
# Reset call count: the POST above also called publish_discovery.
mock_publish_discovery.reset_mock()
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"label": "Renamed Label"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
# publish_discovery must have been called exactly once after the PATCH.
mock_publish_discovery.assert_called_once()
def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client):
"""publish_discovery raising an exception must NOT cause POST declare to return 500.
The _trigger_discovery_republish helper is best-effort: it swallows all
exceptions so that a broken MQTT / discovery layer never breaks the API.
"""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with (
patch("app.api.routes.api.meters.recompute_range", return_value=0),
patch(
"app.services.ha_discovery.publish_discovery",
side_effect=RuntimeError("MQTT broker unreachable"),
),
):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Best Effort Meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
# The meter must be created successfully despite the discovery failure.
assert resp.status_code == 201
assert resp.json()["label"] == "Best Effort Meter"
+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:
"""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]>_<key.replace('.','_')>"
# 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"
)
+18 -1
View File
@@ -798,7 +798,7 @@ def test_meter_columns(energy_db):
inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("meter")}
non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"}
non_nullable = {"id", "uuid", "label", "commodity", "started_at", "reason", "created_at"}
nullable = {"ended_at", "note"}
for col_name in non_nullable:
@@ -810,11 +810,20 @@ def test_meter_columns(energy_db):
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
def test_meter_uuid_unique_constraint(energy_db):
"""meter.uuid must have a unique constraint."""
inspector = inspect(energy_db)
unique_constraints = inspector.get_unique_constraints("meter")
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
assert "uuid" in unique_cols, "meter.uuid must have a unique constraint"
def test_meter_orm_metadata():
"""Meter must be registered in Base.metadata with correct field types."""
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
table = Base.metadata.tables["meter"]
assert "id" in table.columns
assert "uuid" in table.columns
assert "label" in table.columns
assert "commodity" in table.columns
assert "started_at" in table.columns
@@ -824,6 +833,14 @@ def test_meter_orm_metadata():
assert "created_at" in table.columns
def test_meter_uuid_unique_in_metadata():
"""Meter.uuid must be declared unique and not nullable in ORM metadata."""
table = Base.metadata.tables["meter"]
col = table.columns["uuid"]
assert col.unique, "Meter.uuid must be declared unique in ORM metadata"
assert not col.nullable, "Meter.uuid must be NOT NULL in ORM metadata"
def test_meter_insert_and_retrieve(energy_db):
"""A Meter row can be inserted and retrieved with all fields intact."""
now = datetime.now(tz=timezone.utc)
+41 -10
View File
@@ -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}"
)
+37
View File
@@ -381,6 +381,43 @@ class TestDeclareMeter:
assert fetched.commodity == "gas"
assert fetched.note == "Rotameter serial XYZ"
def test_declare_meter_generates_uuid(self, session: Session):
"""declare_meter must auto-generate a non-empty uuid via ORM default."""
import re
UUID4_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)
m = declare_meter(
session,
label="Meter with UUID",
started_at=_T0,
reason="initial",
)
session.commit()
fetched = session.get(Meter, m.id)
assert fetched is not None
assert fetched.uuid is not None, "uuid must not be None after declare_meter"
assert fetched.uuid != "", "uuid must not be empty"
assert UUID4_RE.match(fetched.uuid), (
f"uuid {fetched.uuid!r} does not look like a valid UUID v4"
)
def test_declare_meter_each_gets_distinct_uuid(self, session: Session):
"""Each declared meter must receive a distinct UUID (not duplicated)."""
m1 = declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
t1 = _T0 + timedelta(days=10)
m2 = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
session.commit()
assert m1.uuid != m2.uuid, (
f"Two declared meters must have distinct UUIDs; both got {m1.uuid!r}"
)
# ---------------------------------------------------------------------------
# 5. Different commodities are independent