Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26160b10b | ||
|
|
efbe36d7c0 | ||
|
|
f663981cdb |
@@ -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")
|
||||||
@@ -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:
|
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
|
||||||
"""Return the meter with the given id or raise 404."""
|
"""Return the meter with the given id or raise 404."""
|
||||||
meter: Optional[Meter] = db.get(Meter, meter_id)
|
meter: Optional[Meter] = db.get(Meter, meter_id)
|
||||||
@@ -217,6 +235,11 @@ def declare_energy_meter(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_meter)
|
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(
|
logger.info(
|
||||||
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
||||||
body.commodity,
|
body.commodity,
|
||||||
@@ -294,6 +317,11 @@ def patch_energy_meter(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(meter)
|
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(
|
logger.info(
|
||||||
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
||||||
meter_id,
|
meter_id,
|
||||||
|
|||||||
+61
-13
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Six tables:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
||||||
@@ -20,6 +21,10 @@ from sqlalchemy.types import JSON
|
|||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid4_str() -> str:
|
||||||
|
return str(_uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
class Meter(Base):
|
class Meter(Base):
|
||||||
"""One physical electricity meter's installation epoch.
|
"""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)
|
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).
|
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
||||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260625_13_meter_table"
|
APP_BASELINE_REVISION = "20260625_14_meter_uuid"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -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()
|
@pytest.fixture()
|
||||||
def meters_client(auth_database):
|
def meters_client(auth_database):
|
||||||
"""TestClient + SQLAlchemy engine for Meter API tests."""
|
"""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:
|
if ended is not None and ended.tzinfo is None:
|
||||||
ended = ended.replace(tzinfo=UTC)
|
ended = ended.replace(tzinfo=UTC)
|
||||||
assert ended == t1_earlier
|
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
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -798,7 +798,7 @@ def test_meter_columns(energy_db):
|
|||||||
inspector = inspect(energy_db)
|
inspector = inspect(energy_db)
|
||||||
columns = {col["name"]: col for col in inspector.get_columns("meter")}
|
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"}
|
nullable = {"ended_at", "note"}
|
||||||
|
|
||||||
for col_name in non_nullable:
|
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"
|
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():
|
def test_meter_orm_metadata():
|
||||||
"""Meter must be registered in Base.metadata with correct field types."""
|
"""Meter must be registered in Base.metadata with correct field types."""
|
||||||
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
|
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
|
||||||
table = Base.metadata.tables["meter"]
|
table = Base.metadata.tables["meter"]
|
||||||
assert "id" in table.columns
|
assert "id" in table.columns
|
||||||
|
assert "uuid" in table.columns
|
||||||
assert "label" in table.columns
|
assert "label" in table.columns
|
||||||
assert "commodity" in table.columns
|
assert "commodity" in table.columns
|
||||||
assert "started_at" in table.columns
|
assert "started_at" in table.columns
|
||||||
@@ -824,6 +833,14 @@ def test_meter_orm_metadata():
|
|||||||
assert "created_at" in table.columns
|
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):
|
def test_meter_insert_and_retrieve(energy_db):
|
||||||
"""A Meter row can be inserted and retrieved with all fields intact."""
|
"""A Meter row can be inserted and retrieved with all fields intact."""
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,43 @@ class TestDeclareMeter:
|
|||||||
assert fetched.commodity == "gas"
|
assert fetched.commodity == "gas"
|
||||||
assert fetched.note == "Rotameter serial XYZ"
|
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
|
# 5. Different commodities are independent
|
||||||
|
|||||||
Reference in New Issue
Block a user