20 Commits
Author SHA1 Message Date
tliu93 f4cea3874b test(energy-cost): pin local_now in future-window summarize test
frontend / frontend (push) Failing after 6m38s
pytest / test (push) Successful in 20m16s
docker-image / build-and-push (push) Successful in 13m32s
test_future_window_counts_0_days relied on the real wall-clock date and assumed
7/1→8/1 2026 was entirely in the future; once that range started elapsing the
window counted fixed-fee days and the assertion failed. Pin local_now to
June 25 2026 (as the sibling window tests already do) so the case stays
deterministic.
2026-07-17 18:49:26 +02:00
tliu93 134f0abb5f fix(tibber): fetch newest price slots via priceInfoRange last:192
priceInfoRange is a Relay-style cursor connection over the subscription's
entire price history. With no cursor, first:96 returned the OLDEST 96 slots,
anchored at the subscription start date — a fixed window that never advanced.
For a contract added mid-period this meant refresh_prices kept re-upserting the
same day-one slots forever, so GET /api/energy/prices found nothing in the
today+tomorrow window and the UI showed "No Tibber price points available".

Use last:192 to return the newest 192 quarter-hourly slots (2 days), which ends
at the latest published slot and advances daily, fully covering the prices
endpoint's today+tomorrow window.
2026-07-17 18:49:26 +02:00
tliu93 3e04b15656 feat(modbus): add DDSU666 profile and select read function code per profile
frontend / frontend (push) Successful in 2m21s
pytest / test (push) Successful in 9m50s
docker-image / build-and-push (push) Successful in 4m24s
- Add CHINT DDSU666 profile (ddsu666.yaml): FC03 holding registers, voltage/
  current/active power (kW)/reactive power (kvar)/PF/frequency, import+export
  active energy. Word/byte order left at big-endian as a documented best guess
  (manual has no float example) — to be confirmed on-device.
- Add DDSU666-Modbus-Protocol.md reference extracted from the official manual,
  plus the source PDF (parity with the SDM120 reference).
- Generalize driver.read_blocks to dispatch FC03 (holding) or FC04 (input)
  based on a function_code argument (default 4, SDM120 behaviour unchanged);
  the code is validated before any connection is attempted.
- Wire profile.function_code through the CLI read command, the background
  poller, and the device /test endpoint — previously the profile field was
  declared but never honoured (read path was hardcoded to FC04).
- Tests: default -> FC04, function_code=3 -> FC03 holding, invalid FC rejected
  before connecting.
2026-06-30 17:16:25 +02:00
tliu93 f2e8f6a8e7 docs(roadmap): queue Authentication next-steps — sliding session renewal + long-lived token targets (location/poo ingestion)
frontend / frontend (push) Successful in 2m16s
pytest / test (push) Successful in 10m40s
2026-06-27 22:16:02 +02:00
tliu93 90a03e7fd6 FUE-T09: grace-shift *_today daily reset past local midnight + dedicated 00:00:10 publish
docker-image / build-and-push (push) Successful in 4m29s
frontend / frontend (push) Successful in 2m17s
pytest / test (push) Successful in 11m2s
2026-06-26 12:21:51 +02:00
tliu93 d3fc90b320 FUE-T08: defer daily fixed-fee/heffingskorting settlement to local 01:05 in summarize() 2026-06-26 11:44:34 +02:00
tliu93 d4acfc438a FUE-T07: use meter label directly as energy-cost HA device name (drop 'Energy Cost' prefix)
frontend / frontend (push) Successful in 2m14s
pytest / test (push) Successful in 10m49s
2026-06-25 21:28:13 +02:00
tliu93 c26160b10b FUE-T06: trigger HA discovery republish after meter declare/update
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 10m36s
2026-06-25 20:54:42 +02:00
tliu93 efbe36d7c0 FUE-T05: anchor energy-cost HA device identity to active meter (uuid id, label name, empty when none) 2026-06-25 20:43:07 +02:00
tliu93 f663981cdb FUE-T04: add Meter.uuid (stable HA identity anchor) + backfill migration 2026-06-25 20:20:01 +02:00
tliu93 da05fd2f09 FUE-T03: correct meters POST docstring (no recomputed_periods field) and inaccurate test comments
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 8m51s
2026-06-25 18:36:46 +02:00
tliu93 188168b16a FUE-T02: fix EditMeterForm timestamp parsing to use shared parseBackendTimestamp (handles offset form) 2026-06-25 18:33:42 +02:00
tliu93 f1e7ce3133 FUE-T01: count billing window start day in full for fixed-fee/credit accrual (symmetric with end day) 2026-06-25 18:33:42 +02:00
tliu93 e3d0d17cac M7-T07: finalize M7 docs (roadmap entry, design index, status done, meter-epochs module doc)
frontend / frontend (push) Successful in 2m10s
pytest / test (push) Successful in 8m34s
2026-06-25 17:01:54 +02:00
tliu93 33f5de0591 M7-T06: add meter management UI (timeline, declare swap, edit) to Energy view 2026-06-25 17:01:54 +02:00
tliu93 839faaf95b M7-T05: add meter CRUD API + retroactive recompute + OpenAPI regen 2026-06-25 17:01:54 +02:00
tliu93 82bb329500 M7-T04: anchor cumulative cost entities to active meter start (per-meter reset) 2026-06-25 17:01:54 +02:00
tliu93 17057ed41e M7-T03: make billing engine meter-aware (no cross-meter delta, delta sanity guard, period.meter_id) 2026-06-25 17:01:54 +02:00
tliu93 227b146c2c M7-T02: add meter service layer (declare/swap/close/edit, meter_at, mutual exclusion) 2026-06-25 17:01:54 +02:00
tliu93 32a20785ee M7-T01: add meter table + model + energy_cost_period.meter_id + backfill migration 2026-06-25 14:48:39 +02:00
38 changed files with 8437 additions and 361 deletions
@@ -0,0 +1,238 @@
"""add meter table and energy_cost_period.meter_id
Introduces the ``meter`` table (one row per physical meter installation epoch)
and a nullable FK column ``energy_cost_period.meter_id`` that attributes each
billing period to a specific physical meter.
**Backfill logic (§3.7 of the M7 design doc)**:
If the database already contains any ``dsmr_reading`` or
``energy_cost_period`` rows, one initial ``meter`` row is created:
label = "Initial meter"
commodity = "electricity"
started_at = earliest dsmr_reading.recorded_at
(or, if none, earliest energy_cost_period.period_start,
or, if still none, the migration timestamp)
ended_at = NULL (still active)
reason = "initial"
All existing ``energy_cost_period`` rows are then back-filled with that
initial meter's id.
**Idempotency**: the backfill is guarded with a check for any existing
``meter`` row whose ``reason = 'initial'`` and ``commodity = 'electricity'``
and ``ended_at IS NULL``, so repeating the upgrade does not create duplicate
meters or overwrite already-filled meter_id values.
**Audit (on-non-degraded periods only)**: after backfilling, the number of
non-degraded ``energy_cost_period`` rows with ``meter_id IS NULL`` must be
zero; if it is not, the migration raises a ``RuntimeError`` and rolls back.
**Data safety**: this migration is additive only — no existing rows are
deleted or overwritten; it only creates a new table, adds a nullable column,
and back-fills that column.
Revision ID: 20260625_13_meter_table
Revises: 20260624_12_dsmr_decouple_telegram_id
Create Date: 2026-06-25 00:00:00.000000
"""
from datetime import datetime, timezone
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260625_13_meter_table"
down_revision: Union[str, None] = "20260624_12_dsmr_decouple_telegram_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ------------------------------------------------------------------ #
# 1. Create the meter table. #
# ------------------------------------------------------------------ #
op.create_table(
"meter",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("commodity", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reason", sa.String(length=64), nullable=False),
sa.Column("note", sa.String(length=1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# ------------------------------------------------------------------ #
# 2. Add meter_id column to energy_cost_period (nullable FK). #
# ------------------------------------------------------------------ #
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.add_column(
sa.Column("meter_id", sa.Integer(), nullable=True)
)
batch_op.create_foreign_key(
"fk_energy_cost_period_meter_id",
"meter",
["meter_id"],
["id"],
ondelete="RESTRICT",
)
# ------------------------------------------------------------------ #
# 3. Backfill initial meter (idempotent). #
# ------------------------------------------------------------------ #
conn = op.get_bind()
# Check if there is already an initial meter (idempotency guard).
existing_initial = conn.execute(
sa.text(
"SELECT id FROM meter "
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
"LIMIT 1"
)
).fetchone()
if existing_initial is not None:
# Already backfilled — nothing to do.
return
# Determine whether there is any historical data to create a meter for.
has_readings = conn.execute(
sa.text("SELECT 1 FROM dsmr_reading LIMIT 1")
).fetchone()
has_periods = conn.execute(
sa.text("SELECT 1 FROM energy_cost_period LIMIT 1")
).fetchone()
if not has_readings and not has_periods:
# Empty database: no historical data, so no initial meter is needed.
# meter_id will remain NULL on any future rows until T02 service layer
# starts populating it.
return
# Determine started_at: earliest dsmr_reading.recorded_at, falling back to
# earliest energy_cost_period.period_start, and finally to now().
earliest_reading_row = conn.execute(
sa.text("SELECT MIN(recorded_at) AS ts FROM dsmr_reading")
).fetchone()
earliest_period_row = conn.execute(
sa.text("SELECT MIN(period_start) AS ts FROM energy_cost_period")
).fetchone()
started_at_value: datetime | None = None
if earliest_reading_row and earliest_reading_row[0] is not None:
# SQLite returns ISO strings for datetime columns; parse to datetime.
raw = earliest_reading_row[0]
started_at_value = _parse_sqlite_datetime(raw)
if started_at_value is None and earliest_period_row and earliest_period_row[0] is not None:
raw = earliest_period_row[0]
started_at_value = _parse_sqlite_datetime(raw)
if started_at_value is None:
started_at_value = datetime.now(tz=timezone.utc)
now_utc = datetime.now(tz=timezone.utc)
# Insert the initial meter row.
conn.execute(
sa.text(
"INSERT INTO meter (label, commodity, started_at, ended_at, reason, note, created_at) "
"VALUES (:label, :commodity, :started_at, NULL, :reason, NULL, :created_at)"
),
{
"label": "Initial meter",
"commodity": "electricity",
"started_at": _iso(started_at_value),
"reason": "initial",
"created_at": _iso(now_utc),
},
)
# Retrieve the newly created meter id.
meter_row = conn.execute(
sa.text(
"SELECT id FROM meter "
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
"LIMIT 1"
)
).fetchone()
assert meter_row is not None, "Initial meter row not found after insert"
meter_id: int = meter_row[0]
# Back-fill all existing energy_cost_period rows that have meter_id IS NULL.
conn.execute(
sa.text(
"UPDATE energy_cost_period SET meter_id = :mid WHERE meter_id IS NULL"
),
{"mid": meter_id},
)
# ------------------------------------------------------------------ #
# 4. Audit: verify all non-degraded periods have a meter_id. #
# ------------------------------------------------------------------ #
unmatched_row = conn.execute(
sa.text(
"SELECT COUNT(*) FROM energy_cost_period "
"WHERE meter_id IS NULL AND degraded = 0"
)
).fetchone()
unmatched_count: int = unmatched_row[0] if unmatched_row else 0
if unmatched_count != 0:
raise RuntimeError(
f"Meter backfill audit failed: {unmatched_count} non-degraded "
"energy_cost_period row(s) still have meter_id IS NULL after backfill. "
"Migration aborted to protect data integrity."
)
def downgrade() -> None:
# Remove the FK column from energy_cost_period first (references meter).
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.drop_constraint("fk_energy_cost_period_meter_id", type_="foreignkey")
batch_op.drop_column("meter_id")
# Drop the meter table.
op.drop_table("meter")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_sqlite_datetime(value: str | datetime) -> datetime:
"""Parse a SQLite datetime value into an aware UTC datetime.
SQLite stores datetimes as ISO 8601 strings. SQLAlchemy may return them
as plain strings or as naive datetimes (no tzinfo) depending on the driver
and column declaration. This helper normalises both forms to an aware UTC
``datetime``.
"""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
# String form — strip trailing Z or +00:00 variants, then attach UTC.
s = str(value).strip()
for suffix in ("+00:00", "Z", " UTC"):
if s.endswith(suffix):
s = s[: -len(suffix)]
# SQLite uses space as the T separator in some formats.
s = s.replace(" ", "T")
try:
dt = datetime.fromisoformat(s)
except ValueError:
# Fallback: strip subseconds if present to handle unusual formats.
dt = datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
return dt.replace(tzinfo=timezone.utc)
def _iso(dt: datetime) -> str:
"""Serialise a datetime to an ISO 8601 string for SQLite storage."""
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.strftime("%Y-%m-%dT%H:%M:%S")
@@ -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")
+331
View File
@@ -0,0 +1,331 @@
"""Meter CRUD, swap declaration, and retroactive recompute API (M7-T05).
All endpoints are under /api/energy/meters, require an authenticated session,
and write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
header.
Route semantics
---------------
GET /api/energy/meters — list all meter epochs (ascending started_at)
POST /api/energy/meters — declare a meter swap / initial meter epoch
PATCH /api/energy/meters/{id} — edit label / note, or correct started_at (retroactive)
Retroactive recompute
---------------------
Whenever a write operation changes a meter's ``started_at`` (new declaration
or PATCH correction), the affected billing window is re-judged via
``recompute_range``:
- **POST** (new meter, possibly retroactive):
window = [new_meter.started_at, now)
Rationale: the new meter's ``started_at`` closes the previous meter at that
point; all periods from that boundary forward may have a different meter
attribution. Using ``now`` as the upper bound is safe because
``recompute_range`` only processes closed quarters and the operation is
idempotent.
- **PATCH started_at** (retroactive correction):
window = [min(old_started_at, new_started_at), now)
Rationale: shifting the boundary in either direction affects all periods
between the old and new boundary (and potentially beyond if re-attribution
cascades). Using the minimum of the two timestamps guarantees the entire
affected range is covered; using ``now`` as the upper bound is safe and
idempotent.
``started_at`` localisation (Principle A, FU10 convention)
----------------------------------------------------------
If the client sends a timezone-naive ``started_at`` value, it is interpreted as
the **server's local wall-clock time** and converted to UTC before storage.
Timezone-aware values are converted to UTC as-is. This is identical to the
``_localize_effective_from`` convention used in ``energy_contracts.py``.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.energy import Meter
from app.schemas.meter import (
MeterDeclareRequest,
MeterListResponse,
MeterPatchRequest,
MeterResponse,
)
from app.services import timezone as _tz_mod
from app.services.auth import AuthenticatedSession
from app.services.energy_cost import recompute_range
from app.services.meters import (
MeterIntervalError,
MeterOverlapError,
declare_meter,
list_meters,
update_meter,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _trigger_discovery_republish(session: Session) -> None:
"""Call publish_discovery after a meter write operation (best-effort).
No-op if MQTT / discovery is not enabled or the broker is not connected
(publish_discovery guards internally). All errors are swallowed so that a
discovery failure never breaks the API response.
Must be called **after** db.commit() so that publish_discovery sees the
final committed state of the meter table when it rebuilds the catalog.
"""
try:
from app.services.ha_discovery import publish_discovery
publish_discovery(session)
except Exception:
logger.exception("_trigger_discovery_republish: publish_discovery raised an error")
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
"""Return the meter with the given id or raise 404."""
meter: Optional[Meter] = db.get(Meter, meter_id)
if meter is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Meter {meter_id!r} not found.",
)
return meter
def _localize_started_at(dt: datetime) -> datetime:
"""Resolve *dt* to an aware UTC datetime for storage.
Follows the same Principle-A convention as ``_localize_effective_from``
in ``energy_contracts.py`` (FU10):
- Timezone-aware → convert to UTC as-is.
- Timezone-naive → interpret as server local wall-clock time, localize
with ``local_tz()``, then convert to UTC.
A front-end sending ``"2026-06-25T00:00:00"`` (no Z) has it interpreted
as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
not as UTC midnight.
"""
if dt.tzinfo is not None:
return dt.astimezone(UTC)
tz = _tz_mod.local_tz()
local_dt = dt.replace(tzinfo=tz)
return local_dt.astimezone(UTC)
def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
"""Trigger recompute_range from *start* to now (UTC).
This is the standard "retroactive window" call: everything from the
affected boundary up to the current moment needs re-attribution.
Using ``now`` as the upper bound is safe because ``recompute_range``
only touches closed quarter-hour periods and the operation is idempotent.
"""
end = datetime.now(UTC)
if start >= end:
# started_at is in the future — nothing to recompute.
logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start)
return 0
n = recompute_range(db, start, end)
logger.info(
"%s: recomputed %d period(s) in window [%s, %s).",
label,
n,
start.isoformat(),
end.isoformat(),
)
return n
# ---------------------------------------------------------------------------
# GET /api/energy/meters
# ---------------------------------------------------------------------------
@router.get("/meters", response_model=MeterListResponse)
def list_energy_meters(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> MeterListResponse:
"""List all meter epochs in ascending ``started_at`` order.
Returns the full historical sequence of meter installations across all
commodities. The active meter (``ended_at=null``) appears last because it
has the latest ``started_at``.
"""
meters = list_meters(db)
items = [MeterResponse.model_validate(m) for m in meters]
return MeterListResponse(items=items, total=len(items))
# ---------------------------------------------------------------------------
# POST /api/energy/meters
# ---------------------------------------------------------------------------
@router.post(
"/meters",
response_model=MeterResponse,
status_code=status.HTTP_201_CREATED,
)
def declare_energy_meter(
body: MeterDeclareRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterResponse:
"""Declare a new meter epoch (swap, home move, or initial declaration).
Closes the current active meter for the given commodity at ``started_at``
and opens a new active meter. If no active meter exists, the new meter is
simply created without closing anything.
**Validation**: ``started_at`` must be **≥** the current active meter's
own ``started_at`` (no chronological backdate below the active epoch's
start). Equal timestamps are allowed (replaces the current meter at the
same logical moment). Violation → 422.
**Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The recompute is transparent — the
response body is the created meter (``MeterResponse``) only and does **not**
include a recompute count; callers should re-fetch costs if they need the
updated totals.
"""
started_at_utc = _localize_started_at(body.started_at)
try:
new_meter = declare_meter(
db,
label=body.label,
started_at=started_at_utc,
reason=body.reason.value,
commodity=body.commodity,
note=body.note,
)
except MeterOverlapError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
db.flush() # assign PK before recompute (recompute uses session, needs meter in DB)
# Retroactive recompute: re-judge attribution from the new boundary onward.
now = datetime.now(UTC)
if started_at_utc < now:
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
db.commit()
db.refresh(new_meter)
# Trigger HA discovery re-publish so the new active meter's energy-cost
# device/sensor configuration is pushed to Home Assistant. Best-effort:
# failures are logged and swallowed; the API response is not affected.
_trigger_discovery_republish(db)
logger.info(
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
body.commodity,
new_meter.id,
new_meter.label,
started_at_utc.isoformat(),
)
return MeterResponse.model_validate(new_meter)
# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------
@router.patch("/meters/{meter_id}", response_model=MeterResponse)
def patch_energy_meter(
meter_id: int,
body: MeterPatchRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterResponse:
"""Partially update a meter epoch: rename, edit note, or correct started_at.
- ``label``: updates the human-readable label.
- ``note``: updates the free-form note.
- ``started_at``: **retroactive correction** — shifts this meter's start
boundary. The service layer maintains timeline continuity by also
updating the preceding meter's ``ended_at``. Validation:
* Must be strictly after the previous meter's own ``started_at``.
* Must be strictly before this meter's ``ended_at`` (if closed).
Violation → 422.
**Retroactive recompute when ``started_at`` changes**: billing records in
the window ``[min(old, new), now)`` are re-judged to reflect the corrected
meter attribution.
Not found → 404.
"""
meter = _get_meter_or_404(db, meter_id)
# Capture old started_at before mutation (needed for recompute window).
old_started_at: Optional[datetime] = meter.started_at
# Localise started_at if provided.
new_started_at_utc: Optional[datetime] = None
if body.started_at is not None:
new_started_at_utc = _localize_started_at(body.started_at)
try:
update_meter(
db,
meter,
label=body.label,
note=body.note,
started_at=new_started_at_utc,
)
except MeterIntervalError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
# Retroactive recompute if started_at was changed.
if new_started_at_utc is not None and old_started_at is not None:
# Normalise old_started_at to UTC-aware for comparison.
if old_started_at.tzinfo is None:
old_started_at = old_started_at.replace(tzinfo=UTC)
# Window = [min(old, new), now) — covers all periods whose attribution
# may have changed due to the boundary shift in either direction.
window_start = min(old_started_at, new_started_at_utc)
_trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}")
db.commit()
db.refresh(meter)
# Trigger HA discovery re-publish so label renames on the active meter
# propagate to the HA device name. Best-effort: failures are logged and
# swallowed; the API response is not affected.
_trigger_discovery_republish(db)
logger.info(
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
meter_id,
meter.label,
meter.started_at,
)
return MeterResponse.model_validate(meter)
+1
View File
@@ -488,6 +488,7 @@ def test_read(
device.port, device.port,
device.unit_id, device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks], [{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
) )
payload: dict[str, Any] = decode_profile(profile, registers) payload: dict[str, Any] = decode_profile(profile, registers)
return ModbusTestReadResponse(ok=True, payload=payload) return ModbusTestReadResponse(ok=True, payload=payload)
+127 -67
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Callable, Optional, Protocol from typing import Any, Callable, Optional, Protocol
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -369,16 +370,44 @@ register_provider(_modbus_provider)
# Energy Cost provider # Energy Cost provider
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把
# 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。
_TODAY_RESET_GRACE = timedelta(seconds=5)
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 +428,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 +444,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 +487,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=active_meter.label,
provides_availability=False, provides_availability=False,
) )
@@ -549,53 +602,57 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_import_cost_getter() -> Callable[["Session"], Any]: def _make_import_cost_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative import cost plus prorated standing charges. """Return a getter for the cumulative import cost plus prorated standing charges.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``, D2 (M7): anchor = current active electricity meter's ``started_at``.
where ``anchor_utc`` is the **max** of: After a meter swap the cumulative resets to zero for the new meter —
- the earliest version's effective_from (contract billing start), and old-meter periods have ``period_start < new_meter.started_at`` and fall
- the earliest non-degraded EnergyCostPeriod.period_start (recording start). outside the [anchor, now) window, so they are naturally excluded.
Cross-meter boundary periods are already degraded and also excluded.
This prevents counting fixed costs for the period before any data was recorded No active electricity meter → returns None (cannot anchor; safer than
(e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not returning a stale or wrong value). The check is done via an inline
want to include ~€270 of standing charges for a period with no meter data). query (``ended_at IS NULL``) rather than ``meter_at(now)`` to be robust
against the edge case where the active meter's ``started_at`` is in the
future (``meter_at(now)`` would return None in that scenario).
No non-degraded periods at all → returns None (has_data guard).
No active contract → ``summarize`` still runs but fixed_costs = 0;
the return value is the pure metered sum within the current meter window.
This is consistent with D2: the anchor is the meter, not the contract.
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
Returns None when no non-degraded period exists.
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
""" """
def _getter(sess: "Session") -> Any: def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP from sqlalchemy import func as _func, select as _select
from sqlalchemy import func as _func
# Quick check: any non-degraded period at all? (avoids full summarize overhead # Quick check: any non-degraded period at all?
# when there are zero periods, which should return None)
has_data = sess.query(_func.sum(_ECP.import_cost)).filter( has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False) _ECP.degraded.is_(False)
).scalar() ).scalar()
if has_data is None: if has_data is None:
return None return None
versions = active_contract_versions(sess) # D2: anchor = active electricity meter's started_at.
if not versions: # Inline query (ended_at IS NULL) is more robust than meter_at(now)
# No active contract — return pure SUM(import_cost) without standing charges. # because it avoids the edge case where started_at is in the future.
return float(has_data) active_meter = sess.execute(
_select(_Meter)
.where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
# anchor = max(contract billing start, earliest recording start). if active_meter is None:
# This prevents accumulating fixed costs for days with no meter data. # No active electricity meter — cannot anchor; return None.
contract_anchor_utc = _cu(versions[0].effective_from) return None
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
_ECP.degraded.is_(False)
).scalar()
if earliest_period_start is not None:
recording_anchor_utc = _cu(earliest_period_start)
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
else:
anchor_utc = contract_anchor_utc
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC) now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc) result = _summarize(sess, anchor_utc, now_utc)
@@ -608,20 +665,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_export_revenue_getter() -> Callable[["Session"], Any]: def _make_export_revenue_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative export revenue plus prorated tax credit. """Return a getter for the cumulative export revenue plus prorated tax credit.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``. D2 (M7): anchor = current active electricity meter's ``started_at``.
Anchor is the same max(contract_start, recording_start) logic as import getter. Same reasoning as the import cost getter — see its docstring.
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
Returns None when no non-degraded period exists in [anchor, now]. Returns None when no non-degraded period exists or no active electricity meter.
""" """
def _getter(sess: "Session") -> Any: def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP from sqlalchemy import func as _func, select as _select
from sqlalchemy import func as _func
# Quick check: any non-degraded period at all? # Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
@@ -630,22 +685,22 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
if has_data is None: if has_data is None:
return None return None
versions = active_contract_versions(sess) # D2: anchor = active electricity meter's started_at.
if not versions: active_meter = sess.execute(
# No active contract — return pure SUM(export_revenue) without credits. _select(_Meter)
return float(has_data) .where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
# anchor = max(contract billing start, earliest recording start). if active_meter is None:
contract_anchor_utc = _cu(versions[0].effective_from) # No active electricity meter — cannot anchor; return None.
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter( return None
_ECP.degraded.is_(False)
).scalar()
if earliest_period_start is not None:
recording_anchor_utc = _cu(earliest_period_start)
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
else:
anchor_utc = contract_anchor_utc
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC) now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc) result = _summarize(sess, anchor_utc, now_utc)
@@ -685,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
return None return None
# Today's window in UTC, using monkeypatch-safe module attribute calls. # Today's window in UTC, using monkeypatch-safe module attribute calls.
today_local = _tz_mod.local_now().date() # Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after
# local midnight the getter still returns yesterday's window. This prevents
# a "归零后的值" from being published while HA's clock (which may lag a few
# seconds) would stamp it as 23:59:59 of the previous day.
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
tomorrow_local = today_local + _td(days=1) tomorrow_local = today_local + _td(days=1)
today_start_utc = _tz_mod.local_midnight_utc(today_local) today_start_utc = _tz_mod.local_midnight_utc(today_local)
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
@@ -722,7 +781,8 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
if not versions: if not versions:
return None return None
today_local = _tz_mod.local_now().date() # Grace: same logic as import_cost_today — see that getter's comment.
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
tomorrow_local = today_local + _td(days=1) tomorrow_local = today_local + _td(days=1)
today_start_utc = _tz_mod.local_midnight_utc(today_local) today_start_utc = _tz_mod.local_midnight_utc(today_local)
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
+32 -5
View File
@@ -1,8 +1,11 @@
"""Modbus TCP driver — thin wrapper around pymodbus. """Modbus TCP driver — thin wrapper around pymodbus.
This module provides a single public function ``read_blocks`` that performs This module provides a single public function ``read_blocks`` that performs
one or more FC04 (Read Input Registers) block reads against a Modbus TCP one or more block reads against a Modbus TCP gateway — using either FC04
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map. (Read Input Registers) or FC03 (Read Holding Registers), selected per call
via the ``function_code`` argument — and returns a flat
``dict[register_address -> 16-bit_value]`` map. The function code comes from
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
Design decisions Design decisions
---------------- ----------------
@@ -80,9 +83,10 @@ def read_blocks(
unit_id: int, unit_id: int,
blocks: Sequence[Block], blocks: Sequence[Block],
*, *,
function_code: int = 4,
timeout: float = 3.0, timeout: float = 3.0,
) -> dict[int, int]: ) -> dict[int, int]:
"""Read one or more contiguous register blocks via FC04 (input registers). """Read one or more contiguous register blocks via FC03 or FC04.
Parameters Parameters
---------- ----------
@@ -96,6 +100,11 @@ def read_blocks(
Sequence of ``{"start": int, "count": int}`` dicts describing the Sequence of ``{"start": int, "count": int}`` dicts describing the
contiguous register ranges to read. ``count`` is the number of contiguous register ranges to read. ``count`` is the number of
16-bit registers (not bytes). 16-bit registers (not bytes).
function_code:
Modbus read function code: ``4`` for FC04 (Read Input Registers,
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
DDSU666 and other devices that expose measurements as holding
registers). Comes from the device profile's ``function_code`` field.
timeout: timeout:
TCP connect/read timeout in seconds (default 3 s). TCP connect/read timeout in seconds (default 3 s).
@@ -107,12 +116,21 @@ def read_blocks(
Raises Raises
------ ------
ModbusDriverError
If ``function_code`` is neither 3 nor 4 (validated before any
connection is attempted).
ModbusConnectionError ModbusConnectionError
If the TCP connection to the gateway fails. If the TCP connection to the gateway fails.
ModbusResponseError ModbusResponseError
If the gateway returns a Modbus exception frame or an unexpected If the gateway returns a Modbus exception frame or an unexpected
number of registers. number of registers.
""" """
if function_code not in (3, 4):
raise ModbusDriverError(
f"Unsupported read function code FC{function_code:02d} "
f"(only FC03 holding-register and FC04 input-register reads are supported)"
)
client = ModbusTcpClient(host, port=port, timeout=timeout) client = ModbusTcpClient(host, port=port, timeout=timeout)
try: try:
connected = client.connect() connected = client.connect()
@@ -125,7 +143,7 @@ def read_blocks(
for block in blocks: for block in blocks:
start: int = block["start"] start: int = block["start"]
count: int = block["count"] count: int = block["count"]
_read_block(client, unit_id, start, count, registers) _read_block(client, unit_id, start, count, registers, function_code=function_code)
return registers return registers
@@ -145,9 +163,18 @@ def _read_block(
start: int, start: int,
count: int, count: int,
result: dict[int, int], result: dict[int, int],
*,
function_code: int,
) -> None: ) -> None:
"""Read one block and merge into *result*. Raises on any error.""" """Read one block and merge into *result*. Raises on any error.
``function_code`` is assumed already validated to be 3 or 4 by the caller
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
"""
try: try:
if function_code == 3:
response = client.read_holding_registers(start, count=count, device_id=unit_id)
else: # function_code == 4 (input registers)
response = client.read_input_registers(start, count=count, device_id=unit_id) response = client.read_input_registers(start, count=count, device_id=unit_id)
except ConnectionException as exc: except ConnectionException as exc:
raise ModbusConnectionError( raise ModbusConnectionError(
@@ -0,0 +1,82 @@
name: ddsu666
description: CHINT DDSU666 single-phase smart meter
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
word_order: big # high register first — ASSUMED; verify with a known voltage reading
byte_order: big # high byte first within each register (confirmed by manual CRC example)
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
# device uses the opposite word order and this profile (and the decoder) need adjusting.
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
blocks:
# Instantaneous quantities 0x20000x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
- { start: 0x2000, count: 0x0010 }
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
# than one span, to avoid touching the undocumented/reserved 0x40020x4009 gap.
- { start: 0x4000, count: 0x0002 }
- { start: 0x400A, count: 0x0002 }
metrics:
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
- key: voltage
address: 0x2000 # U — Voltage (V)
type: float32
unit: "V"
device_class: voltage
ha_component: sensor
- key: current
address: 0x2002 # I — Current (A)
type: float32
unit: "A"
device_class: current
ha_component: sensor
- key: active_power
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
type: float32
unit: "kW"
device_class: power
ha_component: sensor
- key: reactive_power
address: 0x2006 # Q — Reactive power (kvar)
type: float32
unit: "kvar"
device_class: reactive_power
ha_component: sensor
- key: power_factor
address: 0x200A # PF — Power factor (dimensionless)
type: float32
unit: ""
device_class: power_factor
ha_component: sensor
- key: frequency
address: 0x200E # Freq — Frequency (Hz)
type: float32
unit: "Hz"
device_class: frequency
ha_component: sensor
- key: import_energy
address: 0x4000 # Ep — positive/forward active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
- key: export_energy
address: 0x400A # -Ep — reverse active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
+17 -9
View File
@@ -31,18 +31,24 @@ _TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
_DEFAULT_TIMEOUT = 15.0 _DEFAULT_TIMEOUT = 15.0
# GraphQL query to fetch a range of 15-minute price nodes. # GraphQL query to fetch a range of 15-minute price nodes.
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to # ``priceInfoRange`` is a Relay-style cursor connection over the subscription's
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max, # entire available price history. With no before/after cursor:
# but the Tibber API typically starts from the current slot and returns at # * ``first: N`` returns the OLDEST N nodes (anchored at the subscription
# most the remaining hours of today plus tomorrow, so 96 is a good cap for # start date) — a fixed window that never advances, so it is WRONG for
# "today + tomorrow"). # "current + upcoming" prices.
# * ``last: N`` returns the NEWEST N nodes (ending at the latest published
# slot: tomorrow 23:45 once day-ahead prices are out, else today 23:45).
# We want the newest slots, so we use ``last``. 192 = 2 days × 24 h × 4 slots,
# which fully covers the "today + tomorrow" window that GET /api/energy/prices
# queries (the earliest of the 192 newest slots reaches back past today 00:00
# UTC in either publish state).
_PRICE_RANGE_QUERY = """ _PRICE_RANGE_QUERY = """
{ {
viewer { viewer {
homes { homes {
id id
currentSubscription { currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) { priceInfoRange(resolution: QUARTER_HOURLY, last: 192) {
nodes { nodes {
startsAt startsAt
total total
@@ -232,9 +238,11 @@ def fetch_price_range(
) -> list[PricePoint]: ) -> list[PricePoint]:
"""Fetch a range of 15-minute price nodes from the Tibber API. """Fetch a range of 15-minute price nodes from the Tibber API.
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, last: 192)`` query
and parses every returned node into a ``PricePoint``. The number of nodes and parses every returned node into a ``PricePoint``. ``last`` (not
is not assumed — all returned nodes are parsed regardless of count. ``first``) is used so the newest slots are returned; ``first`` would anchor
at the subscription start date and never advance. The number of nodes is
not assumed — all returned nodes are parsed regardless of count.
Parameters Parameters
---------- ----------
+29
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,6 +17,7 @@ from app.api.routes.api.data import router as api_data_router
from app.api.routes.api.energy import router as api_energy_router from app.api.routes.api.energy import router as api_energy_router
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
from app.api.routes.api.expose import router as api_expose_router from app.api.routes.api.expose import router as api_expose_router
from app.api.routes.api.meters import router as api_meters_router
from app.api.routes.api.modbus import router as api_modbus_router from app.api.routes.api.modbus import router as api_modbus_router
from app.api.routes.api.session import router as api_session_router from app.api.routes.api.session import router as api_session_router
from app.api.routes import status from app.api.routes import status
@@ -35,6 +37,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE
from app.services.ha_discovery import publish_discovery, publish_states from app.services.ha_discovery import publish_discovery, publish_states
from app.services.tibber_prices import refresh_prices from app.services.tibber_prices import refresh_prices
from app.services.energy_cost import compute_closed_periods from app.services.energy_cost import compute_closed_periods
from app.services.timezone import local_tz
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None:
session.close() session.close()
def _run_midnight_state_publish() -> None:
"""本地午夜后不久专门发布一次状态,让 *_today 的每日归零稳稳落在午夜之后
(对 HA 钟慢几秒鲁棒)。best-effort:失败仅记日志,不影响调度器。"""
session_local = get_session_local()
session = session_local()
try:
from app.services.ha_discovery import publish_states
publish_states(session)
except Exception:
logger.exception("_run_midnight_state_publish: failed (non-fatal)")
finally:
session.close()
def ensure_auth_db_ready() -> None: def ensure_auth_db_ready() -> None:
session_local = get_session_local() session_local = get_session_local()
session: Session = session_local() session: Session = session_local()
@@ -232,6 +249,17 @@ async def lifespan(_: FastAPI):
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
) )
# Dedicated midnight publish: fire at local 00:00:10 so *_today grace (5 s) has
# already elapsed and the day-rolled value is pushed to HA immediately, rather
# than waiting for the next 60-second ha-state-publish sweep.
scheduler.add_job(
_run_midnight_state_publish,
trigger=CronTrigger(hour=0, minute=0, second=10, timezone=local_tz()),
id="midnight-today-publish",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.start() scheduler.start()
# MQTT: connect using DB-merged runtime settings so broker configured via UI # MQTT: connect using DB-merged runtime settings so broker configured via UI
@@ -280,6 +308,7 @@ def create_app() -> FastAPI:
app.include_router(api_data_router) app.include_router(api_data_router)
app.include_router(api_energy_router) app.include_router(api_energy_router)
app.include_router(api_energy_contracts_router) app.include_router(api_energy_contracts_router)
app.include_router(api_meters_router)
app.include_router(api_expose_router) app.include_router(api_expose_router)
app.include_router(api_modbus_router) app.include_router(api_modbus_router)
app.include_router(api_session_router) app.include_router(api_session_router)
+79 -1
View File
@@ -1,6 +1,7 @@
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem. """SQLAlchemy models for the energy pricing and DSMR metering subsystem.
Five tables: Six tables:
- meter: physical electricity meter lifecycle epoch.
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled). - dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
- energy_contract: contract head (manual or tibber, one active at a time). - energy_contract: contract head (manual or tibber, one active at a time).
- energy_contract_version: versioned pricing values; append-only for auditability. - energy_contract_version: versioned pricing values; append-only for auditability.
@@ -10,6 +11,7 @@ Five 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
@@ -19,6 +21,71 @@ 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):
"""One physical electricity meter's installation epoch.
A ``meter`` record represents the period ``[started_at, ended_at)`` during
which a particular physical meter was installed and active. Replacing a meter
(swap, home move, etc.) is modelled by closing the current record
(``ended_at = swap_timestamp``) and opening a new one
(``started_at = swap_timestamp``).
**Invariant**: for each ``commodity`` there is at most one active meter
(``ended_at IS NULL``) at any point in time. The service layer enforces
this — no DB-level constraint is added to keep the migration simple and to
allow the application to return a meaningful error message.
``commodity`` defaults to ``"electricity"``; the field is a free-form string
(no CHECK constraint) so future commodities (``gas``, ``heating``) can be
added without a schema change.
``reason`` captures why this epoch started — one of ``initial``,
``meter_swap``, ``home_move``, or ``other`` — stored as a plain string so
the application layer controls the allowed set.
"""
__tablename__ = "meter"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable internal identity — used as HA Discovery unique_id anchor.
uuid: Mapped[str] = mapped_column(
String(36), unique=True, nullable=False, default=_uuid4_str
)
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
label: Mapped[str] = mapped_column(String(255), nullable=False)
# Energy commodity this meter measures. Defaults to "electricity".
commodity: Mapped[str] = mapped_column(String(32), nullable=False, default="electricity")
# UTC timestamp when this meter epoch starts (inclusive). May be in the past
# (retroactive declaration); effective billing start = max(started_at, data start).
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# UTC timestamp when this meter epoch ends (exclusive). NULL = currently active.
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Why this epoch was created. Application-layer validation enforces the
# allowed set; no CHECK constraint to keep migrations simple.
reason: Mapped[str] = mapped_column(String(64), nullable=False)
# Free-form note (e.g. location, physical meter id, reason details).
note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# UTC timestamp of when this row was created.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship to cost periods attributed to this meter epoch (not loaded eagerly).
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
back_populates="meter", cascade="save-update, merge"
)
class DsmrReading(Base): class DsmrReading(Base):
"""One down-sampled DSMR telegram stored as a full JSON blob. """One down-sampled DSMR telegram stored as a full JSON blob.
@@ -217,6 +284,14 @@ class EnergyCostPeriod(Base):
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
) )
# FK to the meter epoch this period belongs to. RESTRICT prevents deletion of
# a meter that still has attributed cost periods. Nullable for backwards
# compatibility (pre-M7 rows) and degraded periods where the meter was not
# determinable.
meter_id: Mapped[int | None] = mapped_column(
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
)
# True when the period was computed with incomplete data (missing readings or # True when the period was computed with incomplete data (missing readings or
# missing price); serves as a flag for later recomputation. # missing price); serves as a flag for later recomputation.
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -229,6 +304,9 @@ class EnergyCostPeriod(Base):
back_populates="cost_periods" back_populates="cost_periods"
) )
# Relationship back to the meter epoch.
meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods")
# Index on recorded_at for efficient time-range queries on DSMR readings. # Index on recorded_at for efficient time-range queries on DSMR readings.
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at; # (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
+131
View File
@@ -0,0 +1,131 @@
"""Pydantic schemas for the Meter CRUD + swap declaration API (M7-T05).
Schema hierarchy
----------------
MeterResponse — single meter row (id/label/commodity/started_at/ended_at/reason/note/created_at)
MeterListResponse — ordered list of MeterResponse items
MeterDeclareRequest — POST /api/energy/meters body (declare a swap or initial meter)
MeterPatchRequest — PATCH /api/energy/meters/{id} body (all fields optional)
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MeterReason(str, Enum):
"""Allowed values for the meter epoch creation reason."""
initial = "initial"
meter_swap = "meter_swap"
home_move = "home_move"
other = "other"
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class MeterResponse(BaseModel):
"""Response schema for a single Meter epoch row.
``ended_at`` is ``null`` for the currently active meter.
"""
id: int
label: str
commodity: str
started_at: datetime
ended_at: datetime | None
reason: str
note: str | None
created_at: datetime
model_config = {"from_attributes": True}
class MeterListResponse(BaseModel):
"""Response schema for GET /api/energy/meters.
Meters are returned in ascending ``started_at`` order so the caller sees
the historical installation sequence.
"""
items: list[MeterResponse]
total: int
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
_VALID_REASONS = ", ".join(r.value for r in MeterReason)
class MeterDeclareRequest(BaseModel):
"""Request body for POST /api/energy/meters.
Declares a new meter epoch (swap, home move, or initial declaration). The
service layer closes the current active meter for the given commodity at
``started_at`` and opens a new one.
``started_at`` follows the Principle-A localisation convention: a
timezone-naive value is interpreted as the **server's local wall-clock time**
(e.g. CEST midnight → stored as UTC the night before); a timezone-aware
value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
every meter declaration must carry an explicit start timestamp.
``commodity`` defaults to ``"electricity"``; the field is available for
future use with ``gas`` or ``heating``.
"""
label: str = Field(..., min_length=1, max_length=255)
started_at: datetime = Field(
...,
description=(
"UTC (or server-local naive) datetime from which this meter epoch starts. "
"May be in the past (retroactive declaration)."
),
)
reason: MeterReason = Field(
...,
description=f"Why this epoch was created. One of: {_VALID_REASONS}.",
)
note: str | None = Field(default=None, max_length=1024)
commodity: str = Field(
default="electricity",
min_length=1,
max_length=32,
description="Energy commodity this meter measures. Defaults to 'electricity'.",
)
class MeterPatchRequest(BaseModel):
"""Request body for PATCH /api/energy/meters/{id}.
All fields are optional. Only non-``None`` values are applied.
Updating ``started_at`` is a **retroactive correction**: the service layer
maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
and the API layer triggers ``recompute_range`` over the affected window so
that billing attribution is re-judged.
"""
label: str | None = Field(default=None, min_length=1, max_length=255)
note: str | None = Field(default=None, max_length=1024)
started_at: datetime | None = Field(
default=None,
description=(
"Retroactive correction of the meter epoch start timestamp. "
"Triggers billing recompute over the affected window."
),
)
+275 -50
View File
@@ -1,7 +1,7 @@
"""Billing engine for DSMR 15-minute energy metering periods. """Billing engine for DSMR 15-minute energy metering periods.
This module implements the two-layer billing model described in §3.4 of the This module implements the two-layer billing model described in §3.4 of the
M6 design document: M6 design document, extended in M7-T03 to be meter-aware:
**Layer 1 — per-period metering cost (immutable, price-snapshot)** **Layer 1 — per-period metering cost (immutable, price-snapshot)**
``compute_period(session, t0)`` computes the import cost, export revenue, and ``compute_period(session, t0)`` computes the import cost, export revenue, and
@@ -31,17 +31,49 @@ Design notes
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"`` - **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
for cumulative kWh registers. ``register_at`` converts them to Decimal. for cumulative kWh registers. ``register_at`` converts them to Decimal.
- **Degraded vs skip semantics**: - **Degraded vs skip semantics**:
- *No meter coverage* (``meter_at`` returns None for t0): write a
``degraded=True`` row with ``meter_id=None``.
- *Cross-meter boundary* (m0.id != m1.id for t0/t1): write a ``degraded=True``
row with ``meter_id=m0.id``; losing this one period at the swap boundary is
acceptable (D5 decision).
- *Missing readings* (``register_at`` returns None for start or end - *Missing readings* (``register_at`` returns None for start or end
boundary): write a ``degraded=True`` row with costs at 0 so the period is boundary within the meter window): write a ``degraded=True`` row with
tracked and can be retried by ``compute_closed_periods``. ``meter_id=m0.id`` so the period is tracked and can be retried by
``compute_closed_periods``.
- *Negative or excessively large delta* (delta sanity guard D6): write a
``degraded=True`` row with ``meter_id=m0.id``; prevents negative costs and
grossly inflated costs from meter resets, DSMR rollover, or data spikes.
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do - *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
not write a row); the period will be retried once prices arrive. not write a row); the period will be retried once prices arrive.
- *Missing active contract version*: skip (no contract to compute against). - *Missing active contract version*: skip (no contract to compute against).
- **Meter-aware register lookup**: ``register_at`` now accepts a ``meter``
parameter and restricts the DSMR reading query to readings within
``[meter.started_at, meter.ended_at)`` (half-open), preventing old-meter
readings from leaking into a new-meter epoch.
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all - **Lookback window in ``compute_closed_periods``**: to avoid scanning all
historical DSMR data on every tick, the function looks back at most 7 days historical DSMR data on every tick, the function looks back at most 7 days
from the current time. This covers typical short outages (no data / no from the current time. This covers typical short outages (no data / no
contract) while staying bounded. Periods older than 7 days must be contract) while staying bounded. Periods older than 7 days must be
recovered via an explicit ``recompute_range`` call. recovered via an explicit ``recompute_range`` call.
Meter-aware compute_period ordering rationale (M7-T03)
-------------------------------------------------------
The order of checks inside ``compute_period`` is:
1. **Immutability guard** (existing non-degraded row, overwrite=False) → return False.
2. **Meter determination** (m0 = meter_at(t0), m1 = meter_at(t1)):
- No meter (m0 is None) → write degraded, meter_id=None.
- Cross-meter boundary (m0.id != m1.id) → write degraded, meter_id=m0.id.
3. **Active contract version check** → skip (no write) if absent.
4. **Boundary register readings** within m0's window → write degraded if missing.
5. **Delta sanity guard** → write degraded if any delta < 0 or > _MAX_DELTA_KWH.
6. **Price strategy** → skip (no write) if Tibber price missing.
7. **Upsert billing record** with meter_id=m0.id.
Why meter before contract? The meter is a *structural* prerequisite: without a
known meter epoch we cannot trust the delta at all, so we commit a degraded row
immediately. The contract skip, by contrast, is transient (the period can be
re-computed once a contract is configured), so it produces no row.
""" """
from __future__ import annotations from __future__ import annotations
@@ -59,8 +91,9 @@ from app.integrations.pricing.strategies import (
TibberPriceNotFoundError, TibberPriceNotFoundError,
get_strategy, get_strategy,
) )
from app.models.energy import DsmrReading, EnergyCostPeriod from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
from app.services.contracts import active_contract_version_at, active_contract_versions from app.services.contracts import active_contract_version_at, active_contract_versions
from app.services.meters import meter_at
from app.services.timezone import local_date, local_now from app.services.timezone import local_date, local_now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,6 +114,19 @@ _LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
# producing a spurious zero-delta "successful" row. # producing a spurious zero-delta "successful" row.
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES) _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# Maximum plausible kWh delta for a single 15-minute period (D6 sanity guard).
# A typical Dutch household uses well under 5 kWh per quarter hour even under
# heavy load. 100 kWh per 15 minutes corresponds to ~400 kW — far beyond any
# residential consumption — but is lenient enough to never fire on legitimate
# data. Any delta at or above this threshold indicates a meter reset, DSMR
# rollover, sign error, or other data anomaly, and the period is marked
# degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100")
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
# DSMR payload register keys (cumulative kWh, JSON string values). # DSMR payload register keys (cumulative kWh, JSON string values).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) _KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2) _KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
@@ -123,15 +169,29 @@ def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# register_at — boundary reading lookup # register_at — boundary reading lookup (meter-aware)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | None: def register_at(
"""Return the four cumulative kWh register values at *boundary*. session: Session,
boundary: datetime,
meter: Meter,
) -> dict[str, Decimal] | None:
"""Return the four cumulative kWh register values at *boundary*, within *meter*'s window.
Queries the most recent ``DsmrReading`` with ``recorded_at ≤ boundary`` Queries the most recent ``DsmrReading`` with:
and extracts the four energy registers from ``payload``: ``recorded_at ≤ boundary``
AND ``recorded_at ≥ meter.started_at``
AND (``meter.ended_at IS NULL`` OR ``recorded_at < meter.ended_at``)
The meter window constraint (half-open ``[started_at, ended_at)``) ensures
that readings from a previous meter epoch are never used to anchor a new
meter's computation. Without this guard, the final reading of the old meter
would be visible at the start of the new meter's epoch and produce a
cross-meter delta, defeating the isolation guarantee.
Extracts the four energy registers from ``payload``:
d1 — electricity_delivered_1 (delivered low-tariff / dal) d1 — electricity_delivered_1 (delivered low-tariff / dal)
d2 — electricity_delivered_2 (delivered high-tariff / normal) d2 — electricity_delivered_2 (delivered high-tariff / normal)
@@ -142,18 +202,40 @@ def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | No
------- -------
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None`` dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
when: when:
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary``. - No ``DsmrReading`` row exists with ``recorded_at ≤ boundary`` within
*meter*'s epoch window.
- The most recent such reading is older than ``_READING_MAX_STALENESS``
relative to *boundary* (freshness guard).
- Any of the four register keys is absent from the payload. - Any of the four register keys is absent from the payload.
- Any of the four register values is ``None`` (null in JSON). - Any of the four register values is ``None`` (null in JSON).
SQLite naive datetime note
--------------------------
``recorded_at`` is stored as a naive UTC datetime in SQLite. Comparisons
against *boundary* (always tz-aware UTC) use ``_as_utc()`` for the
freshness check. The SQL ``WHERE`` clause comparisons work correctly
because SQLAlchemy's SQLite dialect strips tzinfo when binding parameters
(leaving the wall-clock UTC value unchanged), consistent with the storage
format.
""" """
row: DsmrReading | None = ( # Build the meter-window constraints: [started_at, ended_at).
session.execute( meter_lower = meter.started_at # DsmrReading.recorded_at >= meter.started_at
meter_upper = meter.ended_at # DsmrReading.recorded_at < meter.ended_at (if set)
stmt = (
select(DsmrReading) select(DsmrReading)
.where(DsmrReading.recorded_at <= boundary) .where(
DsmrReading.recorded_at <= boundary,
DsmrReading.recorded_at >= meter_lower,
)
.order_by(DsmrReading.recorded_at.desc()) .order_by(DsmrReading.recorded_at.desc())
.limit(1) .limit(1)
).scalar_one_or_none()
) )
# Apply the upper bound only when the meter is closed (ended_at is not None).
if meter_upper is not None:
stmt = stmt.where(DsmrReading.recorded_at < meter_upper)
row: DsmrReading | None = session.execute(stmt).scalar_one_or_none()
if row is None: if row is None:
return None return None
@@ -214,8 +296,14 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
Side-effects Side-effects
------------ ------------
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``. - Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
- If readings are missing at either boundary: inserts/updates a degraded - If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates
row (costs=0, degraded=True). a degraded row with ``meter_id=None``.
- If the period spans a meter boundary (``meter_at(t0).id != meter_at(t1).id``):
inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision).
- If readings are missing at either boundary within the meter window:
inserts/updates a degraded row with ``meter_id=m0.id``.
- If any delta is negative or exceeds ``_MAX_DELTA_KWH`` (D6 sanity guard):
inserts/updates a degraded row with ``meter_id=m0.id``.
- If the active contract version is missing: **skips** (returns False, no write). - If the active contract version is missing: **skips** (returns False, no write).
- If the Tibber price is missing (TibberPriceNotFoundError): **skips** - If the Tibber price is missing (TibberPriceNotFoundError): **skips**
(returns False, no write). (returns False, no write).
@@ -229,7 +317,50 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
if existing is not None and not existing.degraded and not overwrite: if existing is not None and not existing.degraded and not overwrite:
return False return False
# --- Active contract version at t0 (checked before readings) --- # --- Meter determination (structural prerequisite, checked before contract) ---
#
# A missing or cross-boundary meter is a structural problem: we cannot trust
# the delta at all, so we write a degraded row immediately. This is different
# from the contract skip (transient, no write): the degraded row ensures the
# period appears in the history and can be revisited once the meter timeline
# is corrected and a recompute_range is triggered.
#
# Ordering rationale:
# 1. No meter (m0 is None) → degraded(meter_id=None): no epoch for t0.
# 2. Cross-meter boundary (m0.id != m1.id) → degraded(meter_id=m0.id): D5.
# 3. (Single meter, proceed) → contract check → readings → delta guard → price.
#
# We place meter before contract so that "cross-table period" is always
# marked degraded regardless of contract state. If we checked contract
# first, a missing-contract skip would silently discard the cross-table
# evidence; once a contract is added and recompute runs, the engine would
# incorrectly use cross-table reads.
m0 = meter_at(session, t0)
m1 = meter_at(session, t1)
if m0 is None:
# No meter epoch covers t0 — degraded with no meter attribution.
logger.debug(
"compute_period(%s): no active meter at t0 — writing degraded (meter_id=None).",
t0.isoformat(),
)
_upsert_degraded(session, t0, now, existing, meter_id=None)
return True
if m1 is None or m0.id != m1.id:
# Period spans a meter boundary or t1 has no meter. Degrade with m0's id
# (t0's meter attribution): the period's start belongs to m0's epoch.
logger.debug(
"compute_period(%s): period crosses meter boundary "
"(m0.id=%s, m1.id=%s) — writing degraded.",
t0.isoformat(),
m0.id,
m1.id if m1 is not None else None,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Active contract version at t0 ---
# If there is no active contract covering t0, skip the period entirely. # If there is no active contract covering t0, skip the period entirely.
# We do not write a degraded row — there is no meaningful state to recover # We do not write a degraded row — there is no meaningful state to recover
# without a contract (we would not know which strategy to apply once data # without a contract (we would not know which strategy to apply once data
@@ -240,14 +371,13 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat()) logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
return False return False
# --- Boundary readings --- # --- Boundary readings within m0's meter window ---
start_regs = register_at(session, t0) start_regs = register_at(session, t0, m0)
end_regs = register_at(session, t1) end_regs = register_at(session, t1, m0)
if start_regs is None or end_regs is None: if start_regs is None or end_regs is None:
# Missing readings → write/update a degraded placeholder so the period # Missing readings within the meter window → degraded with m0 attribution.
# is visible and can be retried by compute_closed_periods once data arrives. _upsert_degraded(session, t0, now, existing, meter_id=m0.id)
_upsert_degraded(session, t0, now, existing)
return True # a record was written (degraded) return True # a record was written (degraded)
# --- Compute deltas (end start) --- # --- Compute deltas (end start) ---
@@ -258,6 +388,26 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
r2=end_regs["r2"] - start_regs["r2"], r2=end_regs["r2"] - start_regs["r2"],
) )
# --- Delta sanity guard (D6) ---
# Any negative delta indicates a meter reset, DSMR rollover, or data error.
# Any delta exceeding _MAX_DELTA_KWH (100 kWh per 15 min = 400 kW average)
# is implausible for residential use and indicates an anomaly.
# Both cases produce a degraded row so no negative or grossly inflated cost
# is ever written to the billing record.
all_deltas = (deltas.d1, deltas.d2, deltas.r1, deltas.r2)
if any(d < Decimal("0") for d in all_deltas) or any(d > _MAX_DELTA_KWH for d in all_deltas):
logger.debug(
"compute_period(%s): delta sanity guard triggered "
"(d1=%s, d2=%s, r1=%s, r2=%s) — writing degraded.",
t0.isoformat(),
deltas.d1,
deltas.d2,
deltas.r1,
deltas.r2,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Price strategy --- # --- Price strategy ---
strategy = get_strategy(version.contract.kind) strategy = get_strategy(version.contract.kind)
try: try:
@@ -288,6 +438,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
existing.currency = version.contract.currency existing.currency = version.contract.currency
existing.pricing = pricing existing.pricing = pricing
existing.contract_version_id = version.id existing.contract_version_id = version.id
existing.meter_id = m0.id
existing.degraded = False existing.degraded = False
existing.computed_at = now existing.computed_at = now
else: else:
@@ -303,6 +454,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
currency=version.contract.currency, currency=version.contract.currency,
pricing=pricing, pricing=pricing,
contract_version_id=version.id, contract_version_id=version.id,
meter_id=m0.id,
degraded=False, degraded=False,
computed_at=now, computed_at=now,
) )
@@ -316,9 +468,25 @@ def _upsert_degraded(
t0: datetime, t0: datetime,
now: datetime, now: datetime,
existing: EnergyCostPeriod | None, existing: EnergyCostPeriod | None,
*,
meter_id: int | None,
) -> None: ) -> None:
"""Insert or update a degraded placeholder for period *t0*. """Insert or update a degraded placeholder for period *t0*.
Parameters
----------
session:
Active SQLAlchemy session.
t0:
UTC start of the 15-minute period.
now:
Current UTC timestamp for the ``computed_at`` field.
existing:
The existing ``EnergyCostPeriod`` row for this period, or ``None``.
meter_id:
The meter ID to attribute this degraded period to, or ``None`` when
no meter epoch covers the period (no-meter degraded case).
When *existing* is not None (row was previously written — either degraded When *existing* is not None (row was previously written — either degraded
or successful), the row is explicitly reset to the standard degraded state. or successful), the row is explicitly reset to the standard degraded state.
This is required for the ``recompute_range`` (overwrite=True) path: if the This is required for the ``recompute_range`` (overwrite=True) path: if the
@@ -326,6 +494,11 @@ def _upsert_degraded(
since disappeared, the stale non-zero costs must be cleared so the row since disappeared, the stale non-zero costs must be cleared so the row
accurately reflects the current "missing readings" state rather than accurately reflects the current "missing readings" state rather than
masquerading as a valid result. masquerading as a valid result.
The ``meter_id`` is always updated to reflect the current meter attribution
judgment (the result of ``meter_at`` at the time of recompute). This
ensures that a retroactive ``started_at`` change + ``recompute_range`` will
re-attribute historical degraded periods to the correct meter epoch.
""" """
if existing is not None: if existing is not None:
# Explicitly reset to degraded state — identical field values to the # Explicitly reset to degraded state — identical field values to the
@@ -340,6 +513,7 @@ def _upsert_degraded(
existing.net_cost = 0.0 existing.net_cost = 0.0
existing.pricing = {} existing.pricing = {}
existing.contract_version_id = None existing.contract_version_id = None
existing.meter_id = meter_id
existing.degraded = True existing.degraded = True
existing.computed_at = now existing.computed_at = now
else: else:
@@ -355,6 +529,7 @@ def _upsert_degraded(
currency="EUR", # placeholder; real currency known after contract lookup currency="EUR", # placeholder; real currency known after contract lookup
pricing={}, pricing={},
contract_version_id=None, contract_version_id=None,
meter_id=meter_id,
degraded=True, degraded=True,
computed_at=now, computed_at=now,
) )
@@ -381,6 +556,7 @@ def compute_closed_periods(session: Session) -> int:
3. For each boundary, calls ``compute_period(overwrite=False)``, which: 3. For each boundary, calls ``compute_period(overwrite=False)``, which:
- Skips periods that already have a *successful* (non-degraded) record. - Skips periods that already have a *successful* (non-degraded) record.
- Retries periods that have a *degraded* record. - Retries periods that have a *degraded* record.
- Writes degraded rows for periods with no meter or cross-meter boundaries.
- Skips periods for which no active contract version exists or the - Skips periods for which no active contract version exists or the
Tibber price is unavailable (without writing a degraded row). Tibber price is unavailable (without writing a degraded row).
@@ -429,11 +605,17 @@ def recompute_range(session: Session, start: datetime, end: datetime) -> int:
This is the *explicit opt-in* path for recovering from: This is the *explicit opt-in* path for recovering from:
- Periods where readings or prices arrived late. - Periods where readings or prices arrived late.
- Price corrections (new contract version retroactively applied). - Price corrections (new contract version retroactively applied).
- Retroactive meter changes (``update_meter`` with new ``started_at``) —
re-running this function will re-judge meter attribution and re-compute
costs using the corrected epoch boundaries.
- Any other reason to override the immutability guard. - Any other reason to override the immutability guard.
The function iterates over every UTC quarter-hour boundary in The function iterates over every UTC quarter-hour boundary in
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``. ``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
Existing rows (including successful ones) are overwritten. Existing rows (including successful ones) are overwritten; their
``meter_id`` fields will reflect the *current* ``meter_at`` judgment for
each period's start timestamp, naturally re-attributing periods when meter
``started_at`` values have been retroactively corrected.
Parameters Parameters
---------- ----------
@@ -500,15 +682,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
+ fixed_costs -- per-day standing charges, cross-version + fixed_costs -- per-day standing charges, cross-version
- credits -- per-day heffingskorting, cross-version - credits -- per-day heffingskorting, cross-version
**Fixed-cost / credit counting — Principle C**: **Fixed-cost / credit counting — Principle C (symmetric begin/end)**:
Only *already-elapsed* whole local calendar days are counted. For each Both the local calendar day in which *start* falls and the local calendar
local calendar date D in the window ``[start_local_date, min(end_local_date, day in which *end* falls are counted as full days. Fixed charges
tomorrow_local))``, the contract version whose rate covers D (the one whose (network_fee, management_fee) and the energy-tax credit (heffingskorting)
effective_from local-date ≤ D < next version's effective_from local-date) is are assessed on a "service-is-active" basis — if the meter was online on a
used. Days that have not yet started in local time (D > today_local) are given calendar day, the full day's charge/credit applies, regardless of
never counted. This is cross-version: if the active contract has V1 from whether the window starts at midnight or mid-morning.
June 1 and V2 from June 25, querying June 130 uses V1 for days 1-24 and V2
for day 25. Switching versions never resets the counter. For each local calendar date D in the range
``[local_date(start), min(local_date(end), today_local)]``:
- D ≤ today_local (only elapsed / today days count as "whole days").
- The contract version whose effective_from local-date ≤ D is used.
- This is cross-version: if V1 is from June 1 and V2 from June 25,
querying June 130 uses V1 for days 1-24 and V2 for day 25.
The end-day (last_counted) is included when the end's local midnight falls
strictly before end_utc; combined with the always-counted start day this
makes the begin/end handling symmetric. A short same-day window therefore
counts its single local day. A window contributes 0 days only when the
counted range is empty (first_counted > last_counted) — e.g. a window lying
entirely in the future, since last_counted is capped at today_local.
Daily getters (``*_today``) use windows exactly aligned to local midnight,
so their ``first_counted`` is always today — unaffected by this fix.
Days that have not yet started in local time (D > today_local) are
never counted. Switching versions never resets the counter.
**Timezone note**: the ``days`` field in the returned dict still represents **Timezone note**: the ``days`` field in the returned dict still represents
the window length in calendar days (total_seconds / 86400), for backward the window length in calendar days (total_seconds / 86400), for backward
@@ -568,29 +768,46 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Fixed costs and credits: Principle C cross-version whole-day counting --- # --- Fixed costs and credits: Principle C cross-version whole-day counting ---
today_local: _date = local_now().date() _now_local = local_now()
today_local: _date = _now_local.date()
# --- Compute [first_counted, last_counted] local date range (inclusive) --- # --- Compute [first_counted, last_counted] local date range (inclusive) ---
# #
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc) # Both the window-start day and the window-end day are counted as complete
# AND D ≤ today_local (only elapsed / today days count as "whole days"). # local calendar days, regardless of whether the window starts/ends at midnight.
# #
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today) # Principle C (symmetric begin/end):
# AND the local midnight is within the billing window." # • first_counted = local calendar date of start_utc (start day always counted)
# • last_counted = local calendar date of end_utc (end day counted if its
# local midnight is strictly before end_utc)
# • Both are then capped at today_local (only elapsed / today days count).
# #
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any # Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that # (heffingskorting) are assessed on a "service-is-active" basis, not on how many
# contains CEST midnight (= 22:00 UTC) contributes 1 day. # hours the service was actually running within that calendar day. If the meter
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
# and credit still apply because the service was online for that day.
# #
# Implementation: compute the first and last local date whose midnight is in range. # Previous asymmetric behaviour: a start_utc that was *later* than the local
# midnight of local_start_date caused first_counted to be bumped to the *next*
# day, silently dropping the anchor day's charges/credits. This was incorrect
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
# is often an above-midnight started_at. The end-side had always been symmetric
# (counted if local midnight < end_utc), creating an inconsistency.
#
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
# is still today regardless of the fix.
#
# A short same-day window now counts its single local day (the start day is
# always counted, symmetric with the end side). A window contributes 0 days
# only when the counted range is empty (first_counted > last_counted) — e.g. a
# window lying entirely in the future, where last_counted is capped at
# today_local while first_counted is later.
from app.services.timezone import local_midnight_utc as _lmu from app.services.timezone import local_midnight_utc as _lmu
local_start_date: _date = local_date(start_utc) # Start side: always count the local calendar day in which start_utc falls.
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day. first_counted: _date = local_date(start_utc)
if _lmu(local_start_date) >= start_utc:
first_counted: _date = local_start_date
else:
first_counted = local_start_date + _td(days=1)
local_end_date: _date = local_date(end_utc) local_end_date: _date = local_date(end_utc)
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range. # Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
@@ -599,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
else: else:
last_counted = local_end_date - _td(days=1) last_counted = local_end_date - _td(days=1)
# Cap: only elapsed or today's days. # Settlement cap: "today" is only counted once the local clock has passed the
last_counted = min(last_counted, today_local) # settlement offset since midnight (01:05). This defers the daily standing-charge
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
_today_midnight_utc = _lmu(today_local)
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
settled_cap = today_local
else:
settled_cap = today_local - _td(days=1)
last_counted = min(last_counted, settled_cap)
# If the range is empty (first_counted > last_counted), no days are counted. # If the range is empty (first_counted > last_counted), no days are counted.
+480
View File
@@ -0,0 +1,480 @@
"""Service layer for Meter epoch CRUD, swap/close, and time-range lookup.
All functions accept an explicit SQLAlchemy Session; callers are responsible
for committing or rolling back the transaction.
Design decisions
----------------
- ``meter_at``: half-open interval ``[started_at, ended_at)`` lookup;
returns the meter whose epoch covers *ts* for the given commodity.
SQLite naive datetime is normalised via ``_as_utc()`` before comparison.
- ``declare_meter``: validates that the new ``started_at`` is **not earlier
than** the current active meter's ``started_at`` (rejects back-dating below
the active meter's own start). Equal timestamps are allowed because the
typical "swap now" use-case sets ``started_at`` to the current moment, which
coincides with the active meter's ``started_at`` only in degenerate test
scenarios — but blocking equal values would make that workflow impossible.
The old active meter is closed (``ended_at = started_at``) and a new active
meter is opened in the same operation, guaranteeing continuity: the old
meter's ``ended_at`` equals the new meter's ``started_at`` (contiguous,
no gap, no overlap).
- ``update_meter``: when ``started_at`` is modified, the service keeps the
timeline contiguous by also updating the **previous** meter's ``ended_at``
(the one whose ``ended_at`` matched the old ``started_at``) to the new
``started_at``. Validation ensures the new ``started_at``:
* is strictly after the previous meter's own ``started_at``
(cannot push the boundary before the previous meter even started);
* is strictly before the current meter's ``ended_at``, if set
(cannot push the boundary past where the current meter was already
closed).
- Mutual exclusion (at most one active meter per commodity) is enforced by the
service layer; no DB-level unique partial index is added to keep migrations
simple and to allow the application to return a meaningful error message.
SQLite timezone note
--------------------
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
read-back they come out as **timezone-naive** datetimes. Wherever this code
compares timestamps from the DB against timezone-aware values, it calls
``_as_utc()`` to make both sides comparable without tripping on
"offset-naive vs offset-aware" TypeErrors.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.energy import Meter
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _as_utc(dt: datetime) -> datetime:
"""Return *dt* as a timezone-aware UTC datetime.
SQLite's ``DateTime(timezone=True)`` column type stores datetimes as naive
UTC strings and gives them back as naive datetimes on read. This helper
re-attaches the UTC timezone info when it is missing, making cross-origin
comparisons safe.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class MeterError(ValueError):
"""Base class for meter service validation errors."""
class MeterOverlapError(MeterError):
"""Raised when a new meter's ``started_at`` would create an overlap or backdate.
Specifically: the new meter's ``started_at`` must be greater than or equal
to the current active meter's ``started_at``. Allowing a value strictly
earlier than the active meter's start would mean the new epoch begins before
the current one, which is chronologically inconsistent.
"""
class MeterIntervalError(MeterError):
"""Raised when an ``update_meter`` call would produce an inconsistent interval.
Examples of inconsistent intervals:
- New ``started_at`` ≥ this meter's ``ended_at`` (epoch would be empty/inverted).
- New ``started_at`` ≤ the previous meter's own ``started_at`` (previous meter
would become empty/inverted after its ``ended_at`` is updated).
"""
# ---------------------------------------------------------------------------
# Internal query helpers
# ---------------------------------------------------------------------------
def _active_meter(session: Session, commodity: str) -> Optional[Meter]:
"""Return the current active (``ended_at IS NULL``) meter for *commodity*, or None."""
return session.execute(
select(Meter)
.where(
Meter.commodity == commodity,
Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
def _meter_before(session: Session, meter: Meter) -> Optional[Meter]:
"""Return the meter whose ``ended_at`` equals *meter*'s ``started_at``.
This is the meter that was closed when *meter* was opened; its ``ended_at``
needs to stay equal to *meter*'s ``started_at`` to maintain timeline
continuity. Returns None if *meter* is the first epoch for its commodity.
The lookup compares naive/aware datetimes via string to avoid SQLite timezone
quirks: both are formatted as ISO 8601 UTC strings for the WHERE clause.
We rely on the fact that the service layer always stores the same timestamp
object as both ``prev.ended_at`` and ``new.started_at``, so their string
representations are identical.
"""
# Normalise the target to a tz-aware UTC datetime for comparison.
# We scan in Python (rather than SQL) to avoid SQLite naive-vs-aware
# mismatch issues when comparing DateTime columns against tz-aware values.
target = _as_utc(meter.started_at)
# Fetch all closed meters of the same commodity and find the one whose
# ended_at equals this meter's started_at (the standard contiguous handoff).
candidates = session.execute(
select(Meter)
.where(
Meter.commodity == meter.commodity,
Meter.ended_at.is_not(None),
)
).scalars().all()
for candidate in candidates:
if candidate.id == meter.id:
continue
candidate_ended = _as_utc(candidate.ended_at)
if candidate_ended == target:
return candidate
return None
# ---------------------------------------------------------------------------
# Core service functions
# ---------------------------------------------------------------------------
def meter_at(
session: Session,
ts: datetime,
commodity: str = "electricity",
) -> Optional[Meter]:
"""Return the meter epoch that covers *ts* for *commodity*.
A meter covers *ts* when:
``started_at ≤ ts`` AND (``ended_at IS NULL`` OR ``ts < ended_at``)
This is the standard half-open interval ``[started_at, ended_at)`` lookup,
consistent with ``active_contract_version_at`` in ``contracts.py``.
Returns ``None`` when no meter covers *ts* (e.g. before the first epoch
was declared, or after a gap).
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
ts:
UTC datetime to look up.
commodity:
Energy commodity (default ``"electricity"``).
Returns
-------
Meter | None
Implementation note — why the upper bound is pushed into SQL
------------------------------------------------------------
When ``declare_meter`` is called with ``started_at == active.started_at``
(the "equal-timestamp swap" allowed by §3.5), the old meter is closed as a
zero-width epoch ``[T0, T0)`` and the new active meter also has
``started_at == T0``. Two rows share the same ``started_at``; SQLite's
row-ordering for ``ORDER BY started_at DESC LIMIT 1`` is then determined by
rowid (i.e. insertion order), which returns the older zero-width row first.
If the upper bound were checked in Python *after* fetching that one row, the
condition ``ts < ended_at`` would be False for **any** ``ts >= T0`` (because
``ended_at == T0``), causing the function to return ``None`` and making the
new active meter permanently invisible.
Pushing both bounds into the SQL ``WHERE`` clause eliminates the ambiguity:
the zero-width row is excluded by ``ts < ended_at`` before ``LIMIT 1`` is
applied, so only the genuinely covering row survives.
SQLite naive-vs-aware datetime note: SQLAlchemy's SQLite dialect strips the
``tzinfo`` from aware datetimes when binding parameters (it does *not*
convert to UTC first). Since all datetimes in this codebase are UTC (either
naive-UTC from the DB or aware-UTC from ``datetime.now(UTC)``), stripping
the tzinfo leaves the wall-clock value unchanged and comparisons remain
correct. This is consistent with how ``contracts.py`` handles the same
situation (see OBS 2 in the M7-T02 review notes).
"""
# Push both the lower and upper bounds into the SQL WHERE clause so that
# zero-width epochs (ended_at == started_at) are excluded *before* LIMIT 1
# is applied. This prevents an equal-timestamp swap from making the new
# active meter invisible (see implementation note above).
stmt = (
select(Meter)
.where(
Meter.commodity == commodity,
Meter.started_at <= ts,
(Meter.ended_at.is_(None)) | (Meter.ended_at > ts),
)
.order_by(Meter.started_at.desc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def declare_meter(
session: Session,
*,
label: str,
started_at: datetime,
reason: str,
commodity: str = "electricity",
note: Optional[str] = None,
) -> Meter:
"""Declare a meter swap or initial meter epoch.
Closes the current active meter for *commodity* (if one exists) by setting
its ``ended_at`` to *started_at*, then opens a new active meter. The
resulting timeline is **contiguous**: old meter's ``ended_at`` equals new
meter's ``started_at``.
If there is no current active meter (first-ever declaration for this
commodity), the new meter is simply opened without closing anything.
Validation
----------
- If a current active meter exists, *started_at* must be **≥** that meter's
own ``started_at``. A value strictly earlier would place the new epoch
entirely before the current active meter, which is a chronological
contradiction ("back-dating before the active epoch's start"). Raises
``MeterOverlapError`` when this constraint is violated.
Note: equal timestamps (``started_at == active.started_at``) are allowed
because that scenario effectively replaces the current meter at the same
logical moment (e.g. correcting a mis-entry), which is a valid use-case.
The old meter is then closed with ``ended_at == started_at`` (a zero-width
epoch), which is intentional and auditable.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
label:
Human-readable label for the new meter.
started_at:
UTC datetime at which this meter epoch starts. May be in the past.
reason:
Why this epoch was created (``"initial"``, ``"meter_swap"``,
``"home_move"``, or ``"other"``).
commodity:
Energy commodity (default ``"electricity"``).
note:
Optional free-form note.
Returns
-------
Meter
The newly created, not-yet-committed active meter.
Raises
------
MeterOverlapError
If *started_at* is strictly earlier than the current active meter's
``started_at`` (chronological backdate below the active epoch's start).
"""
active = _active_meter(session, commodity)
if active is not None:
# Reject back-dating: new started_at must be ≥ current active's started_at.
if _as_utc(started_at) < _as_utc(active.started_at):
raise MeterOverlapError(
f"New meter started_at ({started_at.isoformat()}) must be ≥ the current "
f"active {commodity!r} meter's started_at "
f"({active.started_at.isoformat()}). "
"Declare a started_at on or after the active meter's start to avoid "
"a chronologically inconsistent epoch ordering."
)
# Close the current active meter at the swap point (contiguous handoff).
active.ended_at = started_at
logger.info(
"Closed active %r meter id=%d (ended_at=%s)",
commodity,
active.id,
started_at.isoformat(),
)
now = datetime.now(UTC)
new_meter = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=None,
reason=reason,
note=note,
created_at=now,
)
session.add(new_meter)
logger.info(
"Declared new %r meter %r (started_at=%s, reason=%s)",
commodity,
label,
started_at.isoformat(),
reason,
)
return new_meter
def list_meters(
session: Session,
commodity: Optional[str] = None,
) -> list[Meter]:
"""List all meter epochs, ordered by started_at ascending.
Active meters (``ended_at IS NULL``) sort naturally to the end of the
timeline since they have the latest ``started_at``. Within a single
commodity, the ascending ``started_at`` order reflects the historical
sequence of installed meters.
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
commodity:
If provided, filter to this commodity only. If ``None``, return all
meters across all commodities.
Returns
-------
list[Meter]
Meters ordered by (started_at ASC).
"""
stmt = select(Meter).order_by(Meter.started_at.asc())
if commodity is not None:
stmt = stmt.where(Meter.commodity == commodity)
return list(session.execute(stmt).scalars().all())
def update_meter(
session: Session,
meter: Meter,
*,
label: Optional[str] = None,
note: Optional[str] = None,
started_at: Optional[datetime] = None,
) -> Meter:
"""Update a meter's mutable fields (label, note, started_at).
Passing ``None`` for a field leaves it unchanged. At least one keyword
argument must be non-``None``; calling with all-``None`` is a no-op but
is not an error.
Updating ``started_at`` (retroactive correction)
------------------------------------------------
When ``started_at`` is provided the service maintains **timeline
continuity** across the adjacent meter boundaries:
1. **Previous meter's ``ended_at``** — if the meter immediately before
this one has ``ended_at == meter.started_at`` (the standard contiguous
handoff), its ``ended_at`` is updated to the new ``started_at`` so the
boundary between the two epochs stays seamless.
2. **Validation** — the new ``started_at`` is checked for consistency:
a. It must be **strictly after** the previous meter's own ``started_at``
(otherwise the previous meter's epoch would collapse to zero or
invert).
b. It must be **strictly before** this meter's ``ended_at`` (if set),
so this meter's epoch remains non-empty.
Note: triggering a billing recompute (``recompute_range``) after a
retroactive ``started_at`` change is **out of scope** for this service
layer; that is the API layer's responsibility (M7-T05).
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
meter:
The ``Meter`` ORM instance to update (already loaded from the session).
label:
New human-readable label; ``None`` = keep existing.
note:
New free-form note; ``None`` = keep existing.
started_at:
New start timestamp for this meter epoch; ``None`` = keep existing.
Returns
-------
Meter
The updated ``Meter`` instance (not yet committed).
Raises
------
MeterIntervalError
If the new ``started_at`` would produce an invalid (empty or inverted)
epoch for this meter or the immediately preceding one.
"""
if label is not None:
meter.label = label
logger.info("Updated meter id=%d label=%r", meter.id, label)
if note is not None:
meter.note = note
logger.info("Updated meter id=%d note=%r", meter.id, note)
if started_at is not None:
old_started_at = meter.started_at
# --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
if meter.ended_at is not None:
if _as_utc(started_at) >= _as_utc(meter.ended_at):
raise MeterIntervalError(
f"New started_at ({started_at.isoformat()}) must be strictly before "
f"this meter's ended_at ({meter.ended_at.isoformat()}). "
"The meter epoch would become empty or inverted."
)
# --- Find the immediately preceding meter (its ended_at == meter's old started_at).
prev = _meter_before(session, meter)
# --- Validate lower bound: new started_at must be strictly after prev's started_at.
if prev is not None:
if _as_utc(started_at) <= _as_utc(prev.started_at):
raise MeterIntervalError(
f"New started_at ({started_at.isoformat()}) must be strictly after "
f"the previous meter's started_at ({prev.started_at.isoformat()}). "
"Moving the boundary that far back would collapse the previous meter's epoch."
)
# Maintain continuity: update the previous meter's ended_at to match the new start.
prev.ended_at = started_at
logger.info(
"Updated previous meter id=%d ended_at=%s (boundary shift from %s)",
prev.id,
started_at.isoformat(),
old_started_at.isoformat(),
)
meter.started_at = started_at
logger.info(
"Updated meter id=%d started_at=%s (was %s)",
meter.id,
started_at.isoformat(),
old_started_at.isoformat(),
)
return meter
+1
View File
@@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
device.port, device.port,
device.unit_id, device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks], [{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
) )
payload = profiles.decode(profile, registers) payload = profiles.decode(profile, registers)
+1
View File
@@ -8,6 +8,7 @@
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP**先做** - [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP**先做**
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏) - [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
- [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺 - [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺
- [`m7-meter-epochs-archival.md`](./m7-meter-epochs-archival.md) — 电表生命周期 / 换表归档(Meter epochs
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。 本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
+9 -7
View File
@@ -123,7 +123,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T01 — `meter` 表 + 模型 + `energy_cost_period.meter_id` + 迁移回填 `[schema]` ### M7-T01 — `meter` 表 + 模型 + `energy_cost_period.meter_id` + 迁移回填 `[schema]`
- **Status**: `todo` - **Status**: `done`
- **Depends**: none - **Depends**: none
- **Context**: 引入 Meter 实体的数据地基;为现有数据回填初始表。 - **Context**: 引入 Meter 实体的数据地基;为现有数据回填初始表。
@@ -149,7 +149,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T02 — Meter 服务层(swap/close/edit + `meter_at` + 互斥/校验) ### M7-T02 — Meter 服务层(swap/close/edit + `meter_at` + 互斥/校验)
- **Status**: `todo` - **Status**: `done`
- **Depends**: M7-T01 - **Depends**: M7-T01
- **Context**: 换表/继承的业务逻辑与按时刻查表。 - **Context**: 换表/继承的业务逻辑与按时刻查表。
@@ -174,7 +174,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id` ### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id`
- **Status**: `todo` - **Status**: `done`
- **Depends**: M7-T02 - **Depends**: M7-T02
- **Context**: 让计费永不跨表算 delta,并兜底异常 delta。 - **Context**: 让计费永不跨表算 delta,并兜底异常 delta。
@@ -201,7 +201,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T04 — 累计 per-meter 归零(expose 锚点) ### M7-T04 — 累计 per-meter 归零(expose 锚点)
- **Status**: `todo` - **Status**: `done`
- **Depends**: M7-T03 - **Depends**: M7-T03
- **Context**: 换表后 MQTT 累计量从零起算。 - **Context**: 换表后 MQTT 累计量从零起算。
@@ -226,7 +226,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T05 — Meter API + 追溯 recompute + OpenAPI ### M7-T05 — Meter API + 追溯 recompute + OpenAPI
- **Status**: `todo` - **Status**: `done`
- **Depends**: M7-T02 - **Depends**: M7-T02
- **Context**: 暴露电表 CRUD 与换表声明;追溯后重算。 - **Context**: 暴露电表 CRUD 与换表声明;追溯后重算。
@@ -254,7 +254,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T06 — 前端电表管理 UI ### M7-T06 — 前端电表管理 UI
- **Status**: `todo` - **Status**: `done`
- **Depends**: M7-T05 - **Depends**: M7-T05
- **Context**: 让用户在 Energy 页声明换表、查看电表时间线。 - **Context**: 让用户在 Energy 页声明换表、查看电表时间线。
@@ -280,7 +280,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T07 — 文档 / OpenAPI / roadmap 收尾 ### M7-T07 — 文档 / OpenAPI / roadmap 收尾
- **Status**: `todo` - **Status**: `done`
- **Depends**: M7-T01..T06 - **Depends**: M7-T01..T06
- **Context**: 把 M7 落档、更新 roadmap 与索引。 - **Context**: 把 M7 落档、更新 roadmap 与索引。
@@ -313,6 +313,8 @@ M7-T01 (meter 表+模型+列+迁移回填)
## 10. 里程碑完成定义(DoD) ## 10. 里程碑完成定义(DoD)
**已完成**M7-T01..T07 全部 done
- `meter` 数据地基 + 回填上线;计费引擎永不跨表算 delta,跨表/无表/异常 delta 一律降级。 - `meter` 数据地基 + 回填上线;计费引擎永不跨表算 delta,跨表/无表/异常 delta 一律降级。
- 累计量按当前表归零;追溯换表可重算。 - 累计量按当前表归零;追溯换表可重算。
- Meter CRUD API + 前端管理 UI 可用;OpenAPI/schema 已同步。 - Meter CRUD API + 前端管理 UI 可用;OpenAPI/schema 已同步。
+114
View File
@@ -0,0 +1,114 @@
# Meter Epochs(电表生命周期 / 换表归档)
本文档说明 **Meter epoch** 概念、换表声明流程、计费隔离行为、累计量归零、追溯重算,以及与 HA 累计 blip 的已知行为。
## 为什么需要 Meter epoch
计费引擎采用**寄存器差值(delta)模型**:每 15 分钟成本 =(周期末读数 − 周期初读数)× 单价。荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类场景。
问题在于:**新表的寄存器基数与旧表无关**,若跨表算 delta,会产出负成本或巨额假成本。Meter epoch 让引擎知道"某时刻起属于哪一块物理表",从而永不跨表算 delta。
## 核心概念
一条 `meter` 记录 = **一块物理电表的一段安装期** `[started_at, ended_at)`
- `started_at`:表安装/继承/搬家的起点(可在过去,追溯声明)。
- `ended_at`null = 当前 active;非 null = 已退役表的结束时刻。
- `label`:人读标签,便于识别(如 "旧 2G 表 @ Dorpsstraat 1")。
- `reason``initial`(初始建档)/ `meter_swap`(换表)/ `home_move`(搬家)/ `other`
- `commodity`:默认 `electricity`;为未来 gas/heating 预留,每 commodity 至多一个 active 表。
**换表** = 关闭旧 active 表(`ended_at = T`+ 新建 `started_at = T` 的表。系统**无法自动识别换表**(DSMR 帧无电表序列号),靠用户**显式声明**才能隔离。
## 计费隔离行为
每个 15 分钟计费周期,引擎先查该周期两端(t0/t1)分别属于哪块表:
| 情况 | 结果 |
| --- | --- |
| 同一块表(正常)| 在表窗口内取读数、算 delta、正常计费 |
| 无表覆盖(未声明)| **降级**(成本置 0`degraded=true` |
| 跨表边界(t0 ≠ t1 的表)| **降级**(丢这一个周期,可接受;换表常伴随长时间无数据) |
| delta < 0 或 > 100 kWh/15min | **降级**(兜底异常,如 DSMR 回绕/毛刺,与换表无关也防住) |
**忘了声明换表怎么办**:搬家必有的数据空档(staleness)+ delta 护栏保证不会产出垃圾成本,只是新数据暂混在旧表 epoch、累计未归零。事后补声明 + 追溯重算即可纠正(见下方"追溯换表")。
## 换表声明流程
### 通过前端(Energy 页 → Meters tab
1. 在 Energy 页切到 **Meters** tab,查看当前电表时间线。
2. 点击"声明换表 / 继承新表",填写:
- **Label**:新表的人读标签(如 "4G 新表 2026"
- **安装日期**:新表的 `started_at`(可选择历史日期追溯)
- **Reason**`meter_swap` / `home_move` / `other`
3. 保存后,旧表自动关闭(`ended_at = 填写日期`),新表成为 active。
4. 如填写的是过去日期,系统自动触发受影响窗口的重算(`recompute_range`),跨表周期变为降级,新表内周期重新归属。
### 通过 API
```http
POST /api/energy/meters
Content-Type: application/json
{
"label": "4G 2026",
"started_at": "2026-07-01T00:00:00",
"reason": "meter_swap",
"note": ""
}
```
`started_at` 必须 `≥` 当前 active 表的 `started_at`(拒绝倒挂)。
查询所有表(时间线):
```http
GET /api/energy/meters
```
编辑 label / 备注 / 修正日期:
```http
PATCH /api/energy/meters/{id}
```
## 追溯换表(started_at 在过去)
若换表日期在过去但当时忘了声明:
1. 声明新表,填写过去的安装日期。
2. 系统自动对 `[started_at, now]` 范围内的周期触发 `recompute_range`
- 跨表边界周期 → 降级。
- 归属新表窗口内的周期 → `meter_id` 更新为新表。
3. MQTT 累计量锚点切换至新表起点,下一次 expose 推送时累计量从新表起点重算。
## 累计量归零(per-meter
MQTT 上报的累计成本实体(`import_cost_total` / `export_revenue_total`)锚点 = **当前 active electricity 表的 `started_at`**。换表后:
- 累计量从新表安装时刻起重新累加,不跨表维护历史偏移。
- 日归零实体(`*_today`)不受影响(其窗口必落在当前表内)。
### 已知行为:HA 长期统计的一次性负 blip
累计实体 `state_class: total`,换表归零时其值会**下台阶**。Home Assistant 长期统计(energy dashboard / statistics)可能在换表那一刻记录一次性负 blip。
这是**已知、可接受的行为**:HA 自身存旧值做 down-sample,短期 blip 不影响日常电费读数。已与用户确认先这样观察。如需消除 blip,可在未来通过 `last_reset` 信号通知 HA(见设计文档 §9 后续杠杆,本里程碑不做)。
## 回填(迁移时的初始表)
首次升级到含 M7 迁移的版本时:
- 若库内已有任何 `dsmr_reading` / `energy_cost_period`,迁移自动创建一条 `reason="initial"` 的初始表(`started_at = 最早 dsmr_reading.recorded_at`),并回填现有 `energy_cost_period.meter_id`
- 若是全新空库,初始表不创建(无历史数据)。
- 回填幂等:重复跑迁移不会创建多条初始表;回填后对账(非降级周期 `meter_id IS NULL` 数必须为 0)。
## 非目标(本里程碑不做)
- Gas / 区域供暖的计费(`commodity != "electricity"` 的 strategy)。
- "家庭(home)"分组实体;多合同时间线积分。
- 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。
- `last_reset` 信号(消除 HA 长期统计 blip)。
> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)
Binary file not shown.
+198
View File
@@ -0,0 +1,198 @@
# DDSU666 Modbus 协议(从官方 PDF 提取)
> 来源:`docs/references/DDSU666 Single phase Smart Meter.pdf`
> CHINT / 正泰仪表 **DDSU666 Single phase Smart Meter — Operation Manual**,文档号 `ZTY0.464.1224`,版本 **V2**2020 年 8 月;厂商 Zhejiang Chint Instrument & Meter Co., Ltd.
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
> 同类文档见 SDM120 的 `SDM120-Modbus-Protocol.md`;两表差异较大,见下方 §6「与 SDM120 的关键差异」。
## 设备速览(来自手册 Table 1 / Table 5
| 项 | DDSU666(直接接入) | DDSU666-CT(经互感器) |
| --- | --- | --- |
| 精度等级 | Active Class B | Active Class C |
| 参考电压 | 230 V | 230 V |
| 电流规格 | 0.255(80) A | 0.0151.5(6) A |
| 表常数 | 800 imp/kWh | 6400 imp/kWh |
| 接入方式 | 直接接入 | 经电流互感器 |
- 单相电子式电能表,DIN35mm 导轨安装;测量电压、电流、有功/无功功率、频率、功率因数、正/反向有功电能。
- 电能测量范围 `0999999.99 kWh`(LCD 只显示 6 位,自动移动小数点)。
- 通信:RS485**Modbus-RTU**(也支持 DL/T 645-2007,可切换,见 §5 `0005H ChangeProtocol`)。
- 手册 Table 1 标注 Frequency Reference = 60Hz,但 LCD 示例又写 `F=50.00Hz`(手册自身不一致);**实际频率以寄存器 `200EH` 读数为准**,不要把 50/60 写死。
## 0. 本项目的接入方式(重要)
DDSU666 物理层是 **Modbus RTURS-485 串口)**,半双工。和 SDM120 一样,本项目通过一个 **Modbus-TCP 网关**接入:
- 后端用 **Modbus TCP**`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
- TCP 帧用 MBAP header、**无 CRC**CRC 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `CRC (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
- **Slave Address / Unit ID = 电表的通信地址 Addr**(范围 1–247;面板按键只能设 1–99;见 §5 `0006H`),在 TCP 请求里作为 unit id 传入。
- 若以后直连串口(RTU),才需要管波特率 / 数据格式 / CRC:**默认串口格式是 8 数据位、无校验、2 停止位(8N2)**,与 SDM120 的 8N1 不同——直连时务必对齐。
- 电表必须处于 **Modbus 协议模式**(而非 DL/T 645)才能用本协议;可经面板长按切换,或写 `0005H = 2`(见 §5)。
## 1. 协议帧格式(Appendix A
异步传输,按字节为单位。一帧 10 位字符 = **1 起始位(0) + 8 数据位(无校验) + 2 停止位(1)**(其它格式可定制)。
### 信息帧结构(Table A.1
| 字段 | 长度 | 说明 |
| --- | --- | --- |
| Start(起始) | >3.5 字符静默 | 帧间至少 3.5 字符空闲时间作为分隔 |
| Address code(地址码) | 1 字节 | 目标从机地址 1–247;每个从机在总线上地址唯一 |
| Function code(功能码) | 1 字节 | 仅支持 **03H / 10H**(见 §2 |
| Data(数据域) | n 字节 | 随功能码不同而不同(起始地址、寄存器数、寄存器数据等) |
| CRC check codeCRC 校验) | 2 字节 | 16-bit CRC(**低字节在前、高字节在后**;多项式 `A001` |
| End(结束) | >3.5 字符静默 | 帧间静默 |
> TCP 网关模式下 Start/End 静默与 CRC 由网关处理,本项目不关心。
### 功能码 03H 示例(读寄存器,Table A.3/A.4
读从机 `01H`、起始地址 `0CH`、2 个寄存器:
- 主机发送:`01 03 00 0C 00 02 04 08`(最后 `04 08` 是 CRC,低字节 `04` 在前)。
- 从机返回(设 `0CH/0DH` 内容为 `0000H``1388H`):`01 03 04 00 00 13 88 F7 65`
- `04` = 字节数;`00 00` = `0CH` 数据;`13 88` = `0DH` 数据;`F7 65` = CRC(低字节 `F7` 在前)。
> **注意**:单个 16-bit 寄存器内是「高字节在前、低字节在后」(Table A.4 里 `0DH` 数据返回 `13 88` = `0x1388`)。这一点对解码浮点的字节序很关键,见 §3。
### 功能码 10H 示例(写多个寄存器,Table A.5/A.6
向从机 `01H`、起始地址 `00H` 连续写 3 个寄存器 `0002H,1388H,000AH`
- 主机发送:`01 10 00 00 00 03 06 00 02 13 88 00 0A 9B E9`
- `06` = 写入字节数(3 寄存器 × 2 字节);随后是 3 个寄存器的数据;末尾 `9B E9` CRC。
- 从机返回:`01 10 00 00 00 03 80 08`(回显起始地址 + 寄存器数 + CRC `80 08`)。
### 异常响应(Table A.7/A.8
- 异常时返回的 Function Code = **原功能码 + 128**(即最高位置 1`03H→83H``10H→90H`)。
- 数据为单字节 Error Code
| Error Code | 含义 | 说明 |
| --- | --- | --- |
| `01H` | Illegal function code | 收到的功能码本表不支持 |
| `02H` | Illegal register address | 寄存器地址超出有效范围 |
| `03H` | Illegal data value | 数据值超出对应地址的取值范围 |
## 2. 功能码
DDSU666 **只支持两个功能码**Table A.2):
| 功能码 | 作用 | 说明 |
| --- | --- | --- |
| **03H** | Read register(读寄存器) | 读一个或多个寄存器——**测量值、电量、配置全部走它** |
| **10H** | Write multiple registers(写多个寄存器) | 向 n 个连续寄存器写 n 个 16-bit 数据(改配置 / 清电量) |
> ⚠️ **DDSU666 没有「输入寄存器 / FC04」概念**——所有数据(包括电压电流功率)都用 **FC 03H** 读保持寄存器。这是它和 SDM120(测量值走 FC04)最大的踩坑差异,见 §6。
## 3. 数据编码
DDSU666 有两类数据:
1. **配置 / 参数寄存器(§5`0000H``0010H`**:每个 1 个寄存器、`16-bit with symbols`**16 位有符号整数**)。
2. **测量 / 电量寄存器(§4`2000H`+ / `4000H`+**:每个参数 = **32-bit IEEE-754 单精度浮点**(手册写 “single precision floating decimal”),占 **2 个相邻寄存器**Length = 2 Word)。
**浮点字节序 / 字序**
- **字节序(byte order)= 大端:寄存器内高字节在前** —— 由 Table A.4 的 `0x1388` 返回为 `13 88` 确认。
- **字序(word order,两个寄存器谁是高 16 位)**:手册**没有给出浮点解码的实例**,未明确标注。按标准 Modbus 浮点惯例应为**大端字序(高寄存器在前,`ABCD`)**,与本项目 SDM120 驱动一致(`registers_to_float``>f`,高寄存器在前)。
- ⚠️ **需上机实测确认**:读 `2000H`(电压)应解出 ~230V 这样的合理值;若解出乱数,多半是字序相反,改成「低寄存器在前」再试。CHINT 同系列(DTSU/DDSU666)现场固件偶有字序差异,**首次接入务必用一个已知量(电压)校验**,不要凭手册想当然。
> Python 解码(大端、高寄存器在前):`struct.unpack('>f', struct.pack('>HH', hi_reg, lo_reg))[0]`。
> pymodbus`BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。
## 4. 测量 / 电量寄存器表(FC 03H 读)
全部为只读、`Float`32-bit),每项占 **2 个寄存器**。地址为 Modbus 协议原始地址(即帧里的 Start Register Address Hi/Lo),手册用十六进制。
### 4.1 瞬时量(“Electric quantity of the secondary side”,`2000H` 段)
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
| --- | --- | --- | --- | --- |
| `2000H` | 电压 Voltage | U | V | |
| `2002H` | 电流 Current | I | A | |
| `2004H` | 有功功率 Active power | P | **kW** | 手册标注 “the unit is KW”——**不是 W** |
| `2006H` | 无功功率 Reactive power | Q | **kvar** | |
| `2008H` | (保留 RESERVED | — | — | 占 2 寄存器,跳过 |
| `200AH` | 功率因数 Power factor | PF | — | 无量纲 |
| `200CH` | (保留 RESERVED | — | — | 占 2 寄存器,跳过 |
| `200EH` | 频率 Frequency | Freq | Hz | |
### 4.2 电量(“Electrical data of the secondary side”,`4000H` 段)
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
| --- | --- | --- | --- | --- |
| `4000H` | 正向(导入)有功电能 Active in electricity | Ep | kWh | 正向 / forward active energy |
| `400AH` | 反向(导出)有功电能 Reverse in electricity | -Ep | kWh | 反向 / reverse active energy |
> 手册里 `4000H` 与 `400AH` 之间(`4002H``4009H`)未列出,视为保留/未文档化。
**读取分块建议**
- 瞬时量:`2000H``200FH`**16 个寄存器连续**,一次块读即可覆盖 U…Freq(含两段 RESERVED,解码时跳过)。
- 电量:`4000H`2 寄存器)与 `400AH`(2 寄存器)相距较远,分两小块读,或读 `4000H``400BH`(12 寄存器)一次取出后挑用——以网关 / 电表是否允许跨保留地址块读为准,谨慎起见分开读更稳。
### 常用核心子集(日常监控够用)
电压 `2000H`、电流 `2002H`、有功功率 `2004H`kW)、功率因数 `200AH`、频率 `200EH`、正向有功电能 `4000H`、反向有功电能 `400AH`
## 5. 配置 / 参数寄存器表(FC 03H 读 / FC 10H 写)(Table 9
每个 1 个寄存器、**16-bit 有符号整数**。`R/W` 列来自手册。
| 地址(hex) | 代号 | 含义 | R/W | 取值 / 说明 |
| --- | --- | --- | --- | --- |
| `0000H` | UCode | 编程密码 Programming password code | R/W | 写配置前的密码字 |
| `0001H` | REV. | 保留;**实际读出的是版本号** | R | |
| `0002H` | ClrE | 电能清零 CLr.E | R/W | **写 `1` 清除总电量**(不可逆,慎用) |
| `0003H``0004H` | RESERVED | 保留 | — | |
| `0005H` | ChangeProtocol | 协议切换 | R/W | **`2` = Modbus-RTU**`1` = DL/T 645-2007 |
| `0006H` | Addr | 通信地址 | R/W | 1247(面板按键仅 199 |
| `0007H``000AH` | RESERVED | 保留 | — | |
| `000BH` | Meter type | 表型 Meter type | R | 只读设备类型标识 |
| `000CH` | BAud | 通信波特率 | R/W | **`1`=2400bps`2`=4800bps`3`=9600bps**(手册寄存器仅列这三档;通信章另提到也支持 1200bps) |
| `000DH``0010H` | RESERVED | 保留 | — | |
> ⚠️ 写 `0002H`(清电量)、`0006H`(改地址)、`000CH`(改波特率)、`0005H`(切协议)都会改变电表状态或通信参数,配错可能**清空累计电量**或**导致通信中断**。本项目默认**只读采集**,不在自动化链路里写电表配置寄存器。
> 写配置通常需先经 `0000H UCode` 密码字校验;具体密码值手册正文未给出,需向厂商确认或经面板操作。
## 6. 与 SDM120 的关键差异(迁移 / 复用驱动时必看)
本项目已有 SDM120 profile`app/integrations/modbus/profiles/sdm120.yaml`)。DDSU666 **不能照搬**,主要差异:
| 维度 | SDM120 (Eastron) | DDSU666 (CHINT) |
| --- | --- | --- |
| 读测量值功能码 | **FC 04**(输入寄存器 3X | **FC 03**(保持寄存器,无 FC04 |
| 测量值起始地址 | `0x0000` 起(30001 | 瞬时量 `0x2000` 起;电量 `0x4000`/`0x400A` |
| 有功功率单位 | **W**(瓦) | **kW(千瓦)** —— 入库前注意换算 / 单位标注 |
| 无功功率单位 | VAr | kvar |
| 配置寄存器格式 | FloatFC03/16 | **16-bit 有符号整数**FC03/10 |
| 写功能码 | 16 / 0x10 | 10H(同 0x10 |
| 串口默认格式 | 8N1(1 停止位) | **8N22 停止位)** |
| 多协议 | 仅 Modbus | Modbus **与 DL/T 645-2007 可切换**(需确保在 Modbus 模式) |
| 浮点字序 | 大端、高寄存器在前(手册有实例佐证) | 字节序大端已确认;**字序手册无实例,需上机实测** |
## 7. 给本项目采集驱动的要点小结
1.**Modbus TCP 网关**`ModbusTcpClient(host, port)``slave=<Addr>`;电表须在 **Modbus 协议模式**
2. 所有读取(测量 + 电量 + 配置)都用 **FC 03H**——**没有 FC04**。
3. 测量值在 `0x2000` 段、电量在 `0x4000`/`0x400A`,均为 **float32**;解码大端字节序,**字序默认高寄存器在前但务必用电压实测校验**。
4. **有功功率单位是 kW、无功是 kvar**——与 SDM120 的 W/VAr 不同,新建 profile / 入库映射时单位别抄错。
5. 配置寄存器(`0x0000``0x0010`)是 **16-bit 有符号整数**,不是 float。
6. 默认**只读**`0002H` 写 1 会**清空累计电量**、`0006H/000CH/0005H` 会改通信参数,自动化链路里一律不写。
7. 新建 profile 时这是 `ddsu666` 这一个 register profile 的定义;建议 `function_code: 3``word_order: big`(先按大端字序,接入后用电压读数验证)、瞬时量与电量分块读。
## 8. 实测记录(真机验证,2026-06-30)
首次接入一台 **DDSU666 直接接入版(5(80)A** 实测,确认以下几点:
- **字序大端,确认无误**:电压 / 电流 / 频率 / 电能用「大端、高寄存器在前」解码全部得到合理值(如 233.9 V / 0.055 A / 49.99 Hz),与 §3 的假设一致。`ddsu666.yaml``word_order: big` / `byte_order: big` **无需修改**,§3 里「字序需上机实测」一项可视为已关闭。
- **FC03 读通**:所有量走 FC03profile `ddsu666` 在采集链路(CLI `read` / 设备 `/test` / 后台轮询)中工作正常。
- **低电流下「瞬时功率读 0、但电能照常累加」**:测试负载仅为一台 PoE 交换机(≈230 V / 0.05 A,真实有功仅几瓦),**远低于本表测量量程下限 Imin≈0.25 A**。此工况下:
- 瞬时 `active_power``0x2004`)与 `power_factor``0x200A`)寄存器返回**全零**(原始 hex `0x0000 0x0000`);电表 LCD 上功率在 0~3.3 W、PF 在 0~0.6 之间抖动。
- 抖动成因 = **低电流测量噪声 + 开关电源(SMPS,无 PFC)畸变电流**(电流为电压峰值附近的窄脉冲、谐波重 → 畸变功率因数天然偏低)。**不是**「采样率与开关频率拍频」:计量芯片 SH79F7019 采样在 kHz 量级,远低于开关电源 50–200 kHz 的开关频率,两者不在一个频段。
- 但累计电能 `import_energy``0x4000`)**正常累加**(实测 0 → 0.01 kWh)——电表内部积分器在防潜动起始电流(`0.004·Ib`≈0.02 A)之上照常计量。
- **结论**:低于量程下限时**瞬时功率 / PF 不可信,但电能计量不丢**;电流进入量程(正常负载)后瞬时量即稳定可信。这是 5(80)A 大量程表对极小负载的固有特性,**非缺陷、非解码问题**。
- **排错提示**:若日后看到 DDSU666「功率一直 0」,先确认负载电流是否在 Imin 以上——多半是负载太轻而非链路故障;可用 `scripts/modbus_cli probe --fc 3 --address 0x2000 --count 16``0x2004/0x2005` 原始寄存器是否真为全零佐证。
+70 -2
View File
@@ -2,7 +2,7 @@
本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。 本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。 > 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
## 当前基线(v1.0.3 ## 当前基线(v1.0.3
@@ -39,6 +39,7 @@
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5** | | **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5** |
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 | | **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 | | **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
@@ -214,6 +215,48 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
--- ---
## M7 — 电表生命周期 / 换表归档(✅ 已完成)
### 目标
让计费系统正确处理**电表更换**这一必然事件:荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类。引入显式的 **Meter(电表)epoch** 概念,标记"某时刻起属于哪一块物理表",使引擎**永不跨表算 delta**,并让累计量按表归零、历史可追溯可查。
### 关键能力
- **Meter epoch 数据模型**:一条 `meter` 记录 = 一块物理电表的一段安装期 `[started_at, ended_at)`。换表 = 关闭旧 active 表(`ended_at=T`+ 新建 `started_at=T` 的表。每 `commodity` 至多一个 active 表;`commodity` 字段预留 `gas`/`heating`
- **计费永不跨表**`compute_period` 先查 t0/t1 两端的 `meter_at`;无表覆盖或跨表边界 → **降级**(成本置 0),绝不产负成本或巨额假成本。
- **delta 护栏**:任一寄存器 delta `< 0``> _MAX_DELTA_KWH`100 kWh/15min,远超住宅用量)→ 降级。兜底表重置 / DSMR 回绕 / 数据毛刺,与换表无关的异常也一并防住。
- **累计按当前表归零(D2**`expose.py` 累计 `import_cost_total`/`export_revenue_total` 锚点 = 当前 active electricity meter 的 `started_at`;换表后累计从零起新序列,不维护跨表偏移。
- **追溯换表可重算**`started_at` 可在过去;PATCH 修正日期后,触发受影响窗口 `recompute_range` 重判跨表周期归属。
- **Meter CRUD API + 前端管理 UI**`GET/POST/PATCH /api/energy/meters`+ 追溯 recompute);Energy 页新增 Meters tab,展示电表时间线,支持"换表/继承"表单与编辑。
### 新增表与列(单库 app 链,migration `20260625_13_meter_table`
| 表/列 | 关键设计 |
| --- | --- |
| `meter` | `id``label``commodity`(默认 `electricity`)、`started_at``ended_at`null=active)、`reason``initial`/`meter_swap`/`home_move`/`other`)、`note``created_at` |
| `energy_cost_period.meter_id` | nullable FK → `meter.id`;记录每周期归属,便于审计/按表查询/归档 |
迁移含回填:有历史数据时自动创建一条 `reason="initial"` 的初始表,并回填现有 `energy_cost_period.meter_id`;幂等 + 对账(非降级周期回填后 `meter_id IS NULL` 数必须为 0)。
### 已锁定决策摘要
- **D1**Meter 不绑定 home`label` 编址 + `commodity` 区分品类。
- **D2**:换表后累计量归零(锚当前表起点),不维护跨表偏移。
- **D3**:合同与电表是独立时间线,合同不引用电表;同址换表时合同自动沿用。
- **D4**`started_at` 可在过去;有效计费起点 = `max(started_at, 数据起点)`
- **D5**:跨表边界周期判 degraded(换表常伴随长时间无数据,可接受)。
- **D6**:负/异常大 delta → degraded,通用兜底。
- **D7**gas/heating 计费不在本里程碑;`commodity` 字段为未来扩展预留。
**已知行为(可接受)**:累计 `_total``state_class: total`,换表归零时 HA 长期统计可能在换表那一刻记一次性负 blip。已与用户确认先这样、观察后按需处理(`last_reset` 信号见设计文档 §9 留痕,本里程碑不做)。
> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)
>
> 模块概念说明:[`docs/meter-epochs.md`](./meter-epochs.md)
---
## M3 — 开放与移动端(远期试水) ## M3 — 开放与移动端(远期试水)
### 目标 ### 目标
@@ -245,11 +288,36 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。 **动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
**本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权**2026-06-27 与用户确认):
- `POST /location/record``app/api/routes/location.py:18`)——位置记录上报。**目前无任何鉴权**。当前数据经 Home Assistant 转发进来,上 token 后 **HA 侧需携带该 token**;也可由其他客户端直接上报。
- `POST /poo/record``app/api/routes/poo.py:21`+ `GET /poo/latest``poo.py:57`)——小狗排便记录上报 / 最新查询。**目前无任何鉴权**。
- 这些是设备 / 脚本(非浏览器)端点,session cookie 不适用,正是 long-lived token 的用武之地。(浏览器 CRUD `/api/data/*` 已由 session 保护,不在此列。)
**范围(粗略,待细化)** **范围(粗略,待细化)**
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。 - 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。 - 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 token 鉴权依赖
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。 - 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
- 与下面第 3 条「Session 滑动续期」同属 Authentication 主题(一个是设备/脚本的长期凭据,一个是浏览器短会话体验),实现时鉴权层可一并梳理。
### 3. Session 滑动自动续期(Authentication
**动机**2026-06-27 与用户确认):当前 session 是**绝对过期**——登录即定死、活动不续期,满 TTL 必须重新登录,体验割裂。希望改成**滑动续期(sliding / rolling)**:只要用户还在活动就自动延长,提供"在用就不掉线"的体验。
**现状(实现起点,便于快速拾起)**
- TTL 默认 **12 小时**`auth_session_ttl_hours``app/config.py:38`;配置页 `app/services/config_page.py:45` 可运行时改)。
- 登录时**一次性写死**`create_session``expires_at = now + ttl``app/services/auth.py:94`+ cookie `max_age = ttl``app/api/routes/api/session.py:153`)。
- 每请求**只读校验、从不延长**`get_authenticated_session``app/services/auth.py:103`)只判断 `expires_at <= now`,过期时仅顺手标 `revoked``set_cookie` 只在登录路由调用一次,**无 per-request 中间件**。→ 所以是绝对过期,不是滑动。
**设计要点(待写设计文档时展开)**
- 校验通过时 bump `expires_at = now + ttl` 并**重发 cookie**(滑动窗口)。
- **写节流**:不要每个请求都写 DB——仅当剩余寿命已过半(或距上次续期 > N 分钟)才续期,避免高频写放大。
- **绝对寿命硬顶**:除滑动 TTL 外再设 `created_at + max_lifetime` 上限,防止"永不过期"的会话(安全考量)。
- 新增配置项:滑动 TTL、绝对寿命上限、续期节流阈值。
- 注意:改动只对**新逻辑生效**,已存在 session 的 `expires_at` 行为按新校验路径走即可;上线前过校验闸门。
## Future Ideas(暂不排期,想到先记下) ## Future Ideas(暂不排期,想到先记下)
+279
View File
@@ -559,6 +559,85 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/energy/meters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Energy Meters
* @description List all meter epochs in ascending ``started_at`` order.
*
* Returns the full historical sequence of meter installations across all
* commodities. The active meter (``ended_at=null``) appears last because it
* has the latest ``started_at``.
*/
get: operations["list_energy_meters_api_energy_meters_get"];
put?: never;
/**
* Declare Energy Meter
* @description Declare a new meter epoch (swap, home move, or initial declaration).
*
* Closes the current active meter for the given commodity at ``started_at``
* and opens a new active meter. If no active meter exists, the new meter is
* simply created without closing anything.
*
* **Validation**: ``started_at`` must be **≥** the current active meter's
* own ``started_at`` (no chronological backdate below the active epoch's
* start). Equal timestamps are allowed (replaces the current meter at the
* same logical moment). Violation → 422.
*
* **Retroactive recompute**: if ``started_at`` is in the past, billing
* records from that point forward are re-judged via ``recompute_range`` to
* reflect the new meter attribution. The recompute is transparent — the
* response body is the created meter (``MeterResponse``) only and does **not**
* include a recompute count; callers should re-fetch costs if they need the
* updated totals.
*/
post: operations["declare_energy_meter_api_energy_meters_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/energy/meters/{meter_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch Energy Meter
* @description Partially update a meter epoch: rename, edit note, or correct started_at.
*
* - ``label``: updates the human-readable label.
* - ``note``: updates the free-form note.
* - ``started_at``: **retroactive correction** — shifts this meter's start
* boundary. The service layer maintains timeline continuity by also
* updating the preceding meter's ``ended_at``. Validation:
* * Must be strictly after the previous meter's own ``started_at``.
* * Must be strictly before this meter's ``ended_at`` (if closed).
* Violation → 422.
*
* **Retroactive recompute when ``started_at`` changes**: billing records in
* the window ``[min(old, new), now)`` are re-judged to reflect the corrected
* meter attribution.
*
* Not found → 404.
*/
patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"];
trace?: never;
};
"/api/expose": { "/api/expose": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1569,6 +1648,114 @@ export interface components {
*/ */
sell_normal: number; sell_normal: number;
}; };
/**
* MeterDeclareRequest
* @description Request body for POST /api/energy/meters.
*
* Declares a new meter epoch (swap, home move, or initial declaration). The
* service layer closes the current active meter for the given commodity at
* ``started_at`` and opens a new one.
*
* ``started_at`` follows the Principle-A localisation convention: a
* timezone-naive value is interpreted as the **server's local wall-clock time**
* (e.g. CEST midnight → stored as UTC the night before); a timezone-aware
* value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
* every meter declaration must carry an explicit start timestamp.
*
* ``commodity`` defaults to ``"electricity"``; the field is available for
* future use with ``gas`` or ``heating``.
*/
MeterDeclareRequest: {
/** Label */
label: string;
/**
* Started At
* Format: date-time
* @description UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration).
*/
started_at: string;
/** @description Why this epoch was created. One of: initial, meter_swap, home_move, other. */
reason: components["schemas"]["MeterReason"];
/** Note */
note?: string | null;
/**
* Commodity
* @description Energy commodity this meter measures. Defaults to 'electricity'.
* @default electricity
*/
commodity: string;
};
/**
* MeterListResponse
* @description Response schema for GET /api/energy/meters.
*
* Meters are returned in ascending ``started_at`` order so the caller sees
* the historical installation sequence.
*/
MeterListResponse: {
/** Items */
items: components["schemas"]["MeterResponse"][];
/** Total */
total: number;
};
/**
* MeterPatchRequest
* @description Request body for PATCH /api/energy/meters/{id}.
*
* All fields are optional. Only non-``None`` values are applied.
*
* Updating ``started_at`` is a **retroactive correction**: the service layer
* maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
* and the API layer triggers ``recompute_range`` over the affected window so
* that billing attribution is re-judged.
*/
MeterPatchRequest: {
/** Label */
label?: string | null;
/** Note */
note?: string | null;
/**
* Started At
* @description Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window.
*/
started_at?: string | null;
};
/**
* MeterReason
* @description Allowed values for the meter epoch creation reason.
* @enum {string}
*/
MeterReason: "initial" | "meter_swap" | "home_move" | "other";
/**
* MeterResponse
* @description Response schema for a single Meter epoch row.
*
* ``ended_at`` is ``null`` for the currently active meter.
*/
MeterResponse: {
/** Id */
id: number;
/** Label */
label: string;
/** Commodity */
commodity: string;
/**
* Started At
* Format: date-time
*/
started_at: string;
/** Ended At */
ended_at: string | null;
/** Reason */
reason: string;
/** Note */
note: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** /**
* MetricInfo * MetricInfo
* @description Metadata for a single measurable quantity in a device's profile. * @description Metadata for a single measurable quantity in a device's profile.
@@ -3007,6 +3194,98 @@ export interface operations {
}; };
}; };
}; };
list_energy_meters_api_energy_meters_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeterListResponse"];
};
};
};
};
declare_energy_meter_api_energy_meters_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MeterDeclareRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeterResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
patch_energy_meter_api_energy_meters__meter_id__patch: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
meter_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MeterPatchRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeterResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_expose_api_expose_get: { get_expose_api_expose_get: {
parameters: { parameters: {
query?: never; query?: never;
+446
View File
@@ -0,0 +1,446 @@
/**
* Tests for MeterManager component.
*
* Coverage:
* 1. Loading state rendering.
* 2. Error state rendering.
* 3. Empty state when no meters exist.
* 4. Meter timeline list rendering (label, dates, active badge, reason).
* 5. "Declare New Meter" button opens form modal.
* 6. Declare meter form submit calls POST /api/energy/meters.
* 7. Declare meter 422 () error is displayed.
* 8. Edit button opens edit form modal.
* 9. Edit meter saves label/note via PATCH /api/energy/meters/{meter_id}.
* 10. Edit meter retroactive started_at triggers recompute notice.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { MeterManager } from './MeterManager'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const ACTIVE_METER = {
id: 1,
label: 'Initial 2G meter',
commodity: 'electricity',
started_at: '2024-01-15T00:00:00Z',
ended_at: null,
reason: 'initial',
note: null,
created_at: '2024-01-15T00:00:00Z',
}
const CLOSED_METER = {
id: 2,
label: 'Old 4G meter',
commodity: 'electricity',
started_at: '2023-06-01T00:00:00Z',
ended_at: '2024-01-15T00:00:00Z',
reason: 'meter_swap',
note: 'Replaced by grid company',
created_at: '2023-06-01T00:00:00Z',
}
const METERS_RESPONSE = {
items: [CLOSED_METER, ACTIVE_METER],
total: 2,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MeterManager — loading / error / empty states', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<MeterManager />)
expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
})
it('renders error state when GET fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-load-error')).toBeInTheDocument()
})
})
it('renders empty state when no meters exist', async () => {
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-empty')).toBeInTheDocument()
})
})
})
describe('MeterManager — meter list', () => {
beforeEach(() => vi.clearAllMocks())
it('renders meter timeline with label, dates, status badge, reason', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-table')).toBeInTheDocument()
})
// Labels
expect(screen.getByText('Initial 2G meter')).toBeInTheDocument()
expect(screen.getByText('Old 4G meter')).toBeInTheDocument()
// Status badges
expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active')
expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed')
// Reason badges
expect(screen.getByText('initial')).toBeInTheDocument()
expect(screen.getByText('meter_swap')).toBeInTheDocument()
})
it('renders "Declare New Meter" button', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()
})
})
})
describe('MeterManager — declare new meter', () => {
beforeEach(() => vi.clearAllMocks())
it('opens declare modal when "Declare New Meter" is clicked', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => {
expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()
})
it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
// Fill form
await user.type(screen.getByTestId('meter-label'), 'New meter label')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
// Select reason via the combobox (Mantine Select renders a combobox)
await user.click(screen.getByTestId('meter-reason'))
await waitFor(() => screen.getByText('Initial installation'))
await user.click(screen.getByText('Initial installation'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({
body: expect.objectContaining({
label: 'New meter label',
// FU10 local-midnight naive convention: no Z suffix
started_at: '2026-01-01T00:00:00',
reason: 'initial',
commodity: 'electricity',
}),
}),
)
})
})
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValue(
new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }),
)
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
await user.type(screen.getByTestId('meter-label'), 'Bad meter')
await user.type(screen.getByTestId('meter-started-at'), '2020-01-01')
await user.click(screen.getByTestId('meter-reason'))
await waitFor(() => screen.getByText('Meter swap (same address)'))
await user.click(screen.getByText('Meter swap (same address)'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument()
})
expect(screen.getByTestId('declare-meter-error').textContent).toContain(
'started_at must be ≥',
)
})
it('cancel button closes the declare modal', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument())
await user.click(screen.getByTestId('declare-meter-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument()
})
})
})
describe('MeterManager — edit meter date initialisation', () => {
beforeEach(() => vi.clearAllMocks())
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
// the local date is deterministically '2024-01-15' on any CI runner.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-01-15')
})
it('initialises date input from started_at (naive, no tz marker)', async () => {
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
// equals the UTC date exactly.
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-03-20')
})
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
// and handles explicit offsets properly.
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
// Must not be empty (which would indicate Invalid Date from the old buggy path)
expect(dateInput.value).not.toBe('')
expect(dateInput.value).toBe('2024-01-15')
})
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
// Open the edit form and immediately submit without changing any fields except label.
// The date should be considered unchanged → no started_at in the PATCH body.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change only label; leave date untouched
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'New label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchBody = mockPatch.mock.calls[0][1].body
expect(patchBody).not.toHaveProperty('started_at')
})
})
describe('MeterManager — edit meter', () => {
beforeEach(() => vi.clearAllMocks())
it('opens edit modal when Edit button is clicked', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => {
expect(screen.getByTestId('edit-meter-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()
})
it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Clear label and type new one
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'Renamed meter')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith(
'/api/energy/meters/{meter_id}',
expect.objectContaining({
params: { path: { meter_id: ACTIVE_METER.id } },
body: expect.objectContaining({ label: 'Renamed meter' }),
}),
)
})
})
it('shows recompute notice when started_at is changed (retroactive correction)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change the date field
const dateInput = screen.getByTestId('edit-meter-started-at')
await user.clear(dateInput)
await user.type(dateInput, '2024-02-01')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument()
})
})
it('displays error when PATCH fails', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
const { ApiError } = await import('../api/client')
mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' }))
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change label so there's something to patch
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'Different label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument()
})
expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
})
})
+503
View File
@@ -0,0 +1,503 @@
/**
* MeterManager electricity meter timeline UI.
*
* Features:
* - Table of meter epochs: label / interval (started_at ended_at or "active") /
* active badge / reason.
* - "Declare New Meter" button: form with label + date (started_at) + reason +
* optional note. Sends local-midnight naive datetime per FU10 convention.
* - Edit modal: update label, note, or correct started_at (retroactive).
* - Retroactive feedback: if started_at is changed, a success notice mentions
* that affected billing periods have been recomputed.
* - Loading / error / empty states.
*/
import { useState } from 'react'
import {
Table,
Button,
Group,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
Modal,
TextInput,
Textarea,
Select,
Notification,
} from '@mantine/core'
import {
useMeters,
useDeclareMeter,
useUpdateMeter,
type MeterResponse,
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const REASON_OPTIONS: { value: MeterReason; label: string }[] = [
{ value: 'initial', label: 'Initial installation' },
{ value: 'meter_swap', label: 'Meter swap (same address)' },
{ value: 'home_move', label: 'Home / address move' },
{ value: 'other', label: 'Other' },
]
/**
* Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime
* string (no Z suffix) following the FU10 / ContractForm convention.
* The backend interprets naive datetimes as server local wall-clock time.
*/
function toLocalMidnightNaive(dateStr: string): string {
return `${dateStr}T00:00:00`
}
/**
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
* Used to populate <input type="date"> fields.
* Returns '' if the Date is invalid.
*/
function toLocalDateInputString(d: Date): string {
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
interface DeclareMeterFormProps {
onClose: () => void
onSaved: () => void
}
function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
const [label, setLabel] = useState('')
const [dateStr, setDateStr] = useState('')
const [reason, setReason] = useState<string | null>(null)
const [note, setNote] = useState('')
const [error, setError] = useState<string | null>(null)
const declareMutation = useDeclareMeter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!label.trim()) {
setError('Label is required.')
return
}
if (!dateStr) {
setError('Start date is required.')
return
}
if (!reason) {
setError('Reason is required.')
return
}
try {
await declareMutation.mutateAsync({
label: label.trim(),
started_at: toLocalMidnightNaive(dateStr),
reason: reason as MeterReason,
note: note.trim() || undefined,
commodity: 'electricity',
})
onSaved()
onClose()
} catch (err) {
if (err instanceof ApiError) {
const detail = (err.body as { detail?: string } | null)?.detail
setError(detail ?? `Error ${err.status}: failed to declare meter.`)
} else {
setError('Failed to declare meter. Please try again.')
}
}
}
return (
<Modal
opened
onClose={onClose}
title="Declare New Meter"
size="md"
data-testid="declare-meter-modal"
>
<form onSubmit={handleSubmit} data-testid="declare-meter-form">
<Stack gap="sm">
<TextInput
label="Label"
description={'Human-readable identifier, e.g. "2G meter @ Dorpsstraat 1"'}
required
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
data-testid="meter-label"
/>
<TextInput
label="Start date"
description="Date in YYYY-MM-DD format (interpreted as local midnight)"
type="date"
required
value={dateStr}
onChange={(e) => setDateStr(e.currentTarget.value)}
data-testid="meter-started-at"
/>
<Select
label="Reason"
required
data={REASON_OPTIONS}
value={reason}
onChange={setReason}
data-testid="meter-reason"
/>
<Textarea
label="Note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
data-testid="meter-note"
/>
{error && (
<Alert color="red" data-testid="declare-meter-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="declare-meter-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={declareMutation.isPending}
data-testid="declare-meter-submit"
>
Declare Meter
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Edit meter form (modal)
// ---------------------------------------------------------------------------
interface EditMeterFormProps {
meter: MeterResponse
onClose: () => void
onSaved: (retroactive: boolean) => void
}
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
const [label, setLabel] = useState(meter.label)
const [note, setNote] = useState(meter.note ?? '')
// Convert started_at to a local date string for the <input type="date">.
// parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
// matching the backend's storage convention), Z-suffixed strings, and strings with
// explicit offsets like +02:00. We then extract the local-timezone date components
// so the displayed date matches the local wall-clock date of the meter start.
const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
const [dateStr, setDateStr] = useState(initialDateStr)
const [error, setError] = useState<string | null>(null)
const updateMutation = useUpdateMeter()
// Detect if the user changed started_at (retroactive correction).
const startedAtChanged = dateStr !== initialDateStr
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const patchBody: { label?: string | null; note?: string | null; started_at?: string | null } = {}
if (label.trim() !== meter.label) patchBody.label = label.trim()
const noteVal = note.trim() || null
if (noteVal !== meter.note) patchBody.note = noteVal
if (startedAtChanged && dateStr) {
patchBody.started_at = toLocalMidnightNaive(dateStr)
}
if (Object.keys(patchBody).length === 0) {
onClose()
return
}
try {
await updateMutation.mutateAsync({ id: meter.id, body: patchBody })
onSaved(startedAtChanged)
onClose()
} catch (err) {
if (err instanceof ApiError) {
const detail = (err.body as { detail?: string } | null)?.detail
setError(detail ?? `Error ${err.status}: failed to update meter.`)
} else {
setError('Failed to update meter. Please try again.')
}
}
}
return (
<Modal
opened
onClose={onClose}
title={`Edit Meter — ${meter.label}`}
size="md"
data-testid="edit-meter-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-meter-form">
<Stack gap="sm">
<TextInput
label="Label"
required
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
data-testid="edit-meter-label"
/>
<TextInput
label="Start date"
description="Retroactive correction: shifts the epoch boundary and re-judges billing periods"
type="date"
value={dateStr}
onChange={(e) => setDateStr(e.currentTarget.value)}
data-testid="edit-meter-started-at"
/>
<Textarea
label="Note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
data-testid="edit-meter-note"
/>
{error && (
<Alert color="red" data-testid="edit-meter-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="edit-meter-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-meter-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Meter timeline table
// ---------------------------------------------------------------------------
interface MeterTableProps {
meters: MeterResponse[]
onEdit: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit }: MeterTableProps) {
if (meters.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
No meters declared yet. Click "Declare New Meter" to add one.
</Text>
)
}
return (
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="meters-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Commodity</Table.Th>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{meters.map((meter) => {
const isActive = meter.ended_at === null
return (
<Table.Tr key={meter.id} data-testid={`meter-row-${meter.id}`}>
<Table.Td>
<Text fw={500} size="sm">
{meter.label}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{meter.commodity}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatLocalDate(meter.started_at)}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{meter.ended_at ? formatLocalDate(meter.ended_at) : '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge
color={isActive ? 'green' : 'gray'}
variant="light"
size="sm"
data-testid={`meter-status-${meter.id}`}
>
{isActive ? 'active' : 'closed'}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm" color="blue">
{meter.reason}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`}
>
Edit
</Button>
</Group>
</Table.Td>
</Table.Tr>
)
})}
</Table.Tbody>
</Table>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
export function MeterManager() {
const metersQuery = useMeters()
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
const [recomputeNotice, setRecomputeNotice] = useState(false)
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (metersQuery.isLoading) {
return (
<Center py="xl" data-testid="meters-loading">
<Loader />
</Center>
)
}
if (metersQuery.isError || !metersQuery.data) {
return (
<Alert color="red" data-testid="meters-load-error">
Failed to load meters. Please refresh.
</Alert>
)
}
const meters = metersQuery.data.items
return (
<Stack gap="lg" data-testid="meter-manager">
<Group justify="space-between" align="center">
<Text fw={500}>Electricity Meters</Text>
<Button
onClick={() => setShowDeclareForm(true)}
data-testid="meter-declare-button"
>
Declare New Meter
</Button>
</Group>
{recomputeNotice && (
<Notification
color="teal"
title="Billing periods recomputed"
onClose={() => setRecomputeNotice(false)}
data-testid="meter-recompute-notice"
>
The start date was corrected. Affected billing periods have been
re-judged and cost attributions updated.
</Notification>
)}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
<DeclareMeterForm
onClose={() => setShowDeclareForm(false)}
onSaved={() => setShowDeclareForm(false)}
/>
)}
{/* Edit existing meter */}
{editMeter && (
<EditMeterForm
meter={editMeter}
onClose={() => setEditMeter(null)}
onSaved={(retroactive) => {
setEditMeter(null)
if (retroactive) setRecomputeNotice(true)
}}
/>
)}
</Stack>
)
}
+66
View File
@@ -214,6 +214,12 @@ export function useMetrics(uuid: string) {
// =========================================================================== // ===========================================================================
// Re-exported energy types for consumers // Re-exported energy types for consumers
export type MeterResponse = components['schemas']['MeterResponse']
export type MeterListResponse = components['schemas']['MeterListResponse']
export type MeterDeclareRequest = components['schemas']['MeterDeclareRequest']
export type MeterPatchRequest = components['schemas']['MeterPatchRequest']
export type MeterReason = components['schemas']['MeterReason']
export type ContractResponse = components['schemas']['ContractResponse'] export type ContractResponse = components['schemas']['ContractResponse']
export type ContractDetailResponse = components['schemas']['ContractDetailResponse'] export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
export type ContractVersionResponse = components['schemas']['ContractVersionResponse'] export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
@@ -434,6 +440,66 @@ export function useRecomputeCosts() {
}) })
} }
// ===========================================================================
// Meter hooks — typed TanStack Query wrappers for /api/energy/meters.
//
// Query-key conventions:
// ['energy-meters'] — meter list
// ===========================================================================
// ---------------------------------------------------------------------------
// Query: list all meter epochs
// ---------------------------------------------------------------------------
export function useMeters() {
return useQuery({
queryKey: ['energy-meters'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/meters')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Mutation: declare a new meter epoch (swap / home move / initial)
// ---------------------------------------------------------------------------
export function useDeclareMeter() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: MeterDeclareRequest) =>
apiClient.POST('/api/energy/meters', { body }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
},
})
}
// ---------------------------------------------------------------------------
// Mutation: update (PATCH) a meter epoch (label / note / started_at)
// ---------------------------------------------------------------------------
export function useUpdateMeter() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: MeterPatchRequest }) =>
apiClient.PATCH('/api/energy/meters/{meter_id}', {
params: { path: { meter_id: id } },
body,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
// Retroactive started_at correction triggers recompute server-side.
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
},
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Query: time-range readings for a device (window + limit — never full-table) // Query: time-range readings for a device (window + limit — never full-table)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+38
View File
@@ -381,6 +381,44 @@ describe('EnergyPage — test-read', () => {
}) })
}) })
describe('EnergyPage — meters tab', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders Meters tab in the tab list', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('tab-meters')).toBeInTheDocument()
})
expect(screen.getByTestId('tab-meters').textContent).toContain('Meters')
})
it('renders meters panel when Meters tab is clicked', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices') {
return Promise.resolve({ data: { items: [DEVICE], total: 1 } })
}
if (path === '/api/energy/meters') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
return Promise.resolve({ data: null })
})
renderEnergy()
await waitFor(() => expect(screen.getByTestId('tab-meters')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('tab-meters'))
await waitFor(() => {
expect(screen.getByTestId('panel-meters')).toBeInTheDocument()
})
})
})
describe('EnergyPage — auto-refresh switch', () => { describe('EnergyPage — auto-refresh switch', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
+8
View File
@@ -42,6 +42,7 @@ import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMe
import { DeviceForm } from '../energy/DeviceForm' import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts' import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager' import { ContractManager } from '../energy/ContractManager'
import { MeterManager } from '../energy/MeterManager'
import { TibberPrices } from '../energy/TibberPrices' import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView' import { CostView } from '../energy/CostView'
import { DsmrPanel } from '../energy/DsmrPanel' import { DsmrPanel } from '../energy/DsmrPanel'
@@ -661,6 +662,9 @@ export function EnergyPage() {
<Tabs.Tab value="devices" data-testid="tab-devices"> <Tabs.Tab value="devices" data-testid="tab-devices">
Devices Devices
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="meters" data-testid="tab-meters">
Meters
</Tabs.Tab>
<Tabs.Tab value="contracts" data-testid="tab-contracts"> <Tabs.Tab value="contracts" data-testid="tab-contracts">
Contracts Contracts
</Tabs.Tab> </Tabs.Tab>
@@ -679,6 +683,10 @@ export function EnergyPage() {
<DevicesTab /> <DevicesTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="meters" data-testid="panel-meters">
<MeterManager />
</Tabs.Panel>
<Tabs.Panel value="contracts" data-testid="panel-contracts"> <Tabs.Panel value="contracts" data-testid="panel-contracts">
<ContractManager /> <ContractManager />
</Tabs.Panel> </Tabs.Panel>
+8
View File
@@ -20,5 +20,13 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['./src/test-setup.ts'], setupFiles: ['./src/test-setup.ts'],
env: {
// Lock the test timezone to UTC so that date-formatting assertions
// (which rely on toLocalDateInputString / getFullYear etc.) produce
// deterministic results regardless of the CI runner's local timezone.
// All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD
// values in MeterManager.test.tsx are correct under UTC.
TZ: 'UTC',
},
}, },
}) })
+341
View File
@@ -1379,6 +1379,155 @@
} }
} }
}, },
"/api/energy/meters": {
"get": {
"tags": [
"api-energy-meters"
],
"summary": "List Energy Meters",
"description": "List all meter epochs in ascending ``started_at`` order.\n\nReturns the full historical sequence of meter installations across all\ncommodities. The active meter (``ended_at=null``) appears last because it\nhas the latest ``started_at``.",
"operationId": "list_energy_meters_api_energy_meters_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterListResponse"
}
}
}
}
}
},
"post": {
"tags": [
"api-energy-meters"
],
"summary": "Declare Energy Meter",
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The recompute is transparent — the\nresponse body is the created meter (``MeterResponse``) only and does **not**\ninclude a recompute count; callers should re-fetch costs if they need the\nupdated totals.",
"operationId": "declare_energy_meter_api_energy_meters_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterDeclareRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/meters/{meter_id}": {
"patch": {
"tags": [
"api-energy-meters"
],
"summary": "Patch Energy Meter",
"description": "Partially update a meter epoch: rename, edit note, or correct started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``: updates the free-form note.\n- ``started_at``: **retroactive correction** — shifts this meter's start\n boundary. The service layer maintains timeline continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n * Must be strictly after the previous meter's own ``started_at``.\n * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing records in\nthe window ``[min(old, new), now)`` are re-judged to reflect the corrected\nmeter attribution.\n\nNot found → 404.",
"operationId": "patch_energy_meter_api_energy_meters__meter_id__patch",
"parameters": [
{
"name": "meter_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Meter Id"
}
},
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterPatchRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/expose": { "/api/expose": {
"get": { "get": {
"tags": [ "tags": [
@@ -3334,6 +3483,198 @@
"title": "ManualTariffSchema", "title": "ManualTariffSchema",
"description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices." "description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices."
}, },
"MeterDeclareRequest": {
"properties": {
"label": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Label"
},
"started_at": {
"type": "string",
"format": "date-time",
"title": "Started At",
"description": "UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration)."
},
"reason": {
"$ref": "#/components/schemas/MeterReason",
"description": "Why this epoch was created. One of: initial, meter_swap, home_move, other."
},
"note": {
"anyOf": [
{
"type": "string",
"maxLength": 1024
},
{
"type": "null"
}
],
"title": "Note"
},
"commodity": {
"type": "string",
"maxLength": 32,
"minLength": 1,
"title": "Commodity",
"description": "Energy commodity this meter measures. Defaults to 'electricity'.",
"default": "electricity"
}
},
"type": "object",
"required": [
"label",
"started_at",
"reason"
],
"title": "MeterDeclareRequest",
"description": "Request body for POST /api/energy/meters.\n\nDeclares a new meter epoch (swap, home move, or initial declaration). The\nservice layer closes the current active meter for the given commodity at\n``started_at`` and opens a new one.\n\n``started_at`` follows the Principle-A localisation convention: a\ntimezone-naive value is interpreted as the **server's local wall-clock time**\n(e.g. CEST midnight → stored as UTC the night before); a timezone-aware\nvalue is converted to UTC as-is. Omitting ``started_at`` is not allowed —\nevery meter declaration must carry an explicit start timestamp.\n\n``commodity`` defaults to ``\"electricity\"``; the field is available for\nfuture use with ``gas`` or ``heating``."
},
"MeterListResponse": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/MeterResponse"
},
"type": "array",
"title": "Items"
},
"total": {
"type": "integer",
"title": "Total"
}
},
"type": "object",
"required": [
"items",
"total"
],
"title": "MeterListResponse",
"description": "Response schema for GET /api/energy/meters.\n\nMeters are returned in ascending ``started_at`` order so the caller sees\nthe historical installation sequence."
},
"MeterPatchRequest": {
"properties": {
"label": {
"anyOf": [
{
"type": "string",
"maxLength": 255,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Label"
},
"note": {
"anyOf": [
{
"type": "string",
"maxLength": 1024
},
{
"type": "null"
}
],
"title": "Note"
},
"started_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Started At",
"description": "Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window."
}
},
"type": "object",
"title": "MeterPatchRequest",
"description": "Request body for PATCH /api/energy/meters/{id}.\n\nAll fields are optional. Only non-``None`` values are applied.\n\nUpdating ``started_at`` is a **retroactive correction**: the service layer\nmaintains timeline continuity (adjusting the preceding meter's ``ended_at``)\nand the API layer triggers ``recompute_range`` over the affected window so\nthat billing attribution is re-judged."
},
"MeterReason": {
"type": "string",
"enum": [
"initial",
"meter_swap",
"home_move",
"other"
],
"title": "MeterReason",
"description": "Allowed values for the meter epoch creation reason."
},
"MeterResponse": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"label": {
"type": "string",
"title": "Label"
},
"commodity": {
"type": "string",
"title": "Commodity"
},
"started_at": {
"type": "string",
"format": "date-time",
"title": "Started At"
},
"ended_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Ended At"
},
"reason": {
"type": "string",
"title": "Reason"
},
"note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Note"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"label",
"commodity",
"started_at",
"ended_at",
"reason",
"note",
"created_at"
],
"title": "MeterResponse",
"description": "Response schema for a single Meter epoch row.\n\n``ended_at`` is ``null`` for the currently active meter."
},
"MetricInfo": { "MetricInfo": {
"properties": { "properties": {
"key": { "key": {
+310
View File
@@ -1077,6 +1077,140 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
/api/energy/meters:
get:
tags:
- api-energy-meters
summary: List Energy Meters
description: 'List all meter epochs in ascending ``started_at`` order.
Returns the full historical sequence of meter installations across all
commodities. The active meter (``ended_at=null``) appears last because it
has the latest ``started_at``.'
operationId: list_energy_meters_api_energy_meters_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MeterListResponse'
post:
tags:
- api-energy-meters
summary: Declare Energy Meter
description: 'Declare a new meter epoch (swap, home move, or initial declaration).
Closes the current active meter for the given commodity at ``started_at``
and opens a new active meter. If no active meter exists, the new meter is
simply created without closing anything.
**Validation**: ``started_at`` must be **≥** the current active meter''s
own ``started_at`` (no chronological backdate below the active epoch''s
start). Equal timestamps are allowed (replaces the current meter at the
same logical moment). Violation → 422.
**Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The recompute is transparent — the
response body is the created meter (``MeterResponse``) only and does **not**
include a recompute count; callers should re-fetch costs if they need the
updated totals.'
operationId: declare_energy_meter_api_energy_meters_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MeterDeclareRequest'
responses:
'201':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MeterResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/meters/{meter_id}:
patch:
tags:
- api-energy-meters
summary: Patch Energy Meter
description: "Partially update a meter epoch: rename, edit note, or correct\
\ started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``:\
\ updates the free-form note.\n- ``started_at``: **retroactive correction**\
\ — shifts this meter's start\n boundary. The service layer maintains timeline\
\ continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n\
\ * Must be strictly after the previous meter's own ``started_at``.\n \
\ * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation\
\ → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing\
\ records in\nthe window ``[min(old, new), now)`` are re-judged to reflect\
\ the corrected\nmeter attribution.\n\nNot found → 404."
operationId: patch_energy_meter_api_energy_meters__meter_id__patch
parameters:
- name: meter_id
in: path
required: true
schema:
type: integer
title: Meter Id
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MeterPatchRequest'
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MeterResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/expose: /api/expose:
get: get:
tags: tags:
@@ -2595,6 +2729,182 @@ components:
Prices are the *effective* buy prices as used by the billing engine Prices are the *effective* buy prices as used by the billing engine
(energy_buy_x + energy_tax + ode) and the raw sell prices.' (energy_buy_x + energy_tax + ode) and the raw sell prices.'
MeterDeclareRequest:
properties:
label:
type: string
maxLength: 255
minLength: 1
title: Label
started_at:
type: string
format: date-time
title: Started At
description: UTC (or server-local naive) datetime from which this meter
epoch starts. May be in the past (retroactive declaration).
reason:
$ref: '#/components/schemas/MeterReason'
description: 'Why this epoch was created. One of: initial, meter_swap,
home_move, other.'
note:
anyOf:
- type: string
maxLength: 1024
- type: 'null'
title: Note
commodity:
type: string
maxLength: 32
minLength: 1
title: Commodity
description: Energy commodity this meter measures. Defaults to 'electricity'.
default: electricity
type: object
required:
- label
- started_at
- reason
title: MeterDeclareRequest
description: 'Request body for POST /api/energy/meters.
Declares a new meter epoch (swap, home move, or initial declaration). The
service layer closes the current active meter for the given commodity at
``started_at`` and opens a new one.
``started_at`` follows the Principle-A localisation convention: a
timezone-naive value is interpreted as the **server''s local wall-clock time**
(e.g. CEST midnight → stored as UTC the night before); a timezone-aware
value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
every meter declaration must carry an explicit start timestamp.
``commodity`` defaults to ``"electricity"``; the field is available for
future use with ``gas`` or ``heating``.'
MeterListResponse:
properties:
items:
items:
$ref: '#/components/schemas/MeterResponse'
type: array
title: Items
total:
type: integer
title: Total
type: object
required:
- items
- total
title: MeterListResponse
description: 'Response schema for GET /api/energy/meters.
Meters are returned in ascending ``started_at`` order so the caller sees
the historical installation sequence.'
MeterPatchRequest:
properties:
label:
anyOf:
- type: string
maxLength: 255
minLength: 1
- type: 'null'
title: Label
note:
anyOf:
- type: string
maxLength: 1024
- type: 'null'
title: Note
started_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Started At
description: Retroactive correction of the meter epoch start timestamp. Triggers
billing recompute over the affected window.
type: object
title: MeterPatchRequest
description: 'Request body for PATCH /api/energy/meters/{id}.
All fields are optional. Only non-``None`` values are applied.
Updating ``started_at`` is a **retroactive correction**: the service layer
maintains timeline continuity (adjusting the preceding meter''s ``ended_at``)
and the API layer triggers ``recompute_range`` over the affected window so
that billing attribution is re-judged.'
MeterReason:
type: string
enum:
- initial
- meter_swap
- home_move
- other
title: MeterReason
description: Allowed values for the meter epoch creation reason.
MeterResponse:
properties:
id:
type: integer
title: Id
label:
type: string
title: Label
commodity:
type: string
title: Commodity
started_at:
type: string
format: date-time
title: Started At
ended_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Ended At
reason:
type: string
title: Reason
note:
anyOf:
- type: string
- type: 'null'
title: Note
created_at:
type: string
format: date-time
title: Created At
type: object
required:
- id
- label
- commodity
- started_at
- ended_at
- reason
- note
- created_at
title: MeterResponse
description: 'Response schema for a single Meter epoch row.
``ended_at`` is ``null`` for the currently active meter.'
MetricInfo: MetricInfo:
properties: properties:
key: key:
+1 -1
View File
@@ -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 = "20260624_12_dsmr_decouple_telegram_id" APP_BASELINE_REVISION = "20260625_14_meter_uuid"
class AppDatabaseAdoptionError(RuntimeError): class AppDatabaseAdoptionError(RuntimeError):
+2 -1
View File
@@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None:
print(f"Profile : {profile.name}{profile.description}") print(f"Profile : {profile.name}{profile.description}")
print(f"Gateway : {host}:{port} unit_id={unit_id}") print(f"Gateway : {host}:{port} unit_id={unit_id}")
print(f"Function : FC{profile.function_code:02d}")
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}") print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
print() print()
@@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None:
from app.integrations.modbus.driver import read_blocks from app.integrations.modbus.driver import read_blocks
try: try:
registers = read_blocks(host, port, unit_id, blocks) registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code)
except ModbusConnectionError as exc: except ModbusConnectionError as exc:
print(f"Connection error: {exc}", file=sys.stderr) print(f"Connection error: {exc}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+755
View File
@@ -0,0 +1,755 @@
"""Tests for M7-T05: Meter CRUD + swap declaration + retroactive recompute API.
Coverage matrix
---------------
GET /api/energy/meters
- unauthenticated 401
- authenticated, no meters 200, items=[]
- after declaring meters items ordered by started_at asc
POST /api/energy/meters
- unauthenticated 401
- missing CSRF 403
- valid (first meter, no active) 201, MeterResponse
- valid swap (active meter exists) 201, old meter closed, new meter active
- retroactive started_at 201, triggers recompute for affected window
- started_at before active meter's started_at (overlap) → 422
PATCH /api/energy/meters/{id}
- unauthenticated 401
- missing CSRF 403
- not found 404
- rename label 200, label updated
- edit note 200, note updated
- correct started_at (retroactive) 200, triggers recompute for affected window
- started_at interval violation 422
Retroactive recompute integration
- PATCH started_at change triggers recompute over min(old, new)..now window
- POST retroactive declaration triggers recompute from new started_at
"""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.models.energy import Meter
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_CSRF = "test-csrf-token"
def _login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
def _declare_payload(**overrides) -> dict:
base = {
"label": "Test Meter",
"started_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC).isoformat(),
"reason": "initial",
"commodity": "electricity",
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def mock_publish_discovery():
"""Auto-mock publish_discovery for all tests in this module.
The meters API now calls _trigger_discovery_republish (best-effort) after
every successful write. publish_discovery is lazy-imported inside that
helper, so we patch it at its canonical source path
(app.services.ha_discovery.publish_discovery). Tests that need to assert
the call receive this fixture explicitly; all others benefit from the
isolation it provides (no live MQTT broker required).
"""
with patch(
"app.services.ha_discovery.publish_discovery",
return_value=None,
) as mock:
yield mock
@pytest.fixture()
def meters_client(auth_database):
"""TestClient + SQLAlchemy engine for Meter API tests."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as test_client:
yield test_client, engine
engine.dispose()
# ---------------------------------------------------------------------------
# GET /api/energy/meters
# ---------------------------------------------------------------------------
def test_list_meters_unauthenticated_returns_401(meters_client):
client, _ = meters_client
resp = client.get("/api/energy/meters")
assert resp.status_code == 401
def test_list_meters_empty(meters_client):
client, _ = meters_client
_login(client)
resp = client.get("/api/energy/meters")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
def test_list_meters_ordered_by_started_at(meters_client):
"""After declaring two meters, list returns them in ascending started_at order."""
client, _ = meters_client
_login(client)
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
# Declare first meter (initial)
resp1 = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp1.status_code == 201
# Declare swap meter
resp2 = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 201
resp = client.get("/api/energy/meters")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
items = body["items"]
# Ordered by started_at ascending: Meter A first, Meter B second
assert items[0]["label"] == "Meter A"
assert items[1]["label"] == "Meter B"
# Meter B is active (ended_at is null)
assert items[1]["ended_at"] is None
# Meter A is closed
assert items[0]["ended_at"] is not None
# ---------------------------------------------------------------------------
# POST /api/energy/meters
# ---------------------------------------------------------------------------
def test_declare_meter_unauthenticated_returns_401(meters_client):
client, _ = meters_client
resp = client.post(
"/api/energy/meters",
json=_declare_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_declare_meter_missing_csrf_returns_403(meters_client):
client, _ = meters_client
_login(client)
resp = client.post("/api/energy/meters", json=_declare_payload())
assert resp.status_code == 403
def test_declare_meter_first_no_active(meters_client):
"""Declaring the first meter succeeds without closing any previous meter."""
client, engine = 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="First Meter", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
body = resp.json()
assert body["label"] == "First Meter"
assert body["commodity"] == "electricity"
assert body["reason"] == "initial"
assert body["ended_at"] is None # active
# DB check: one meter, active
with Session(engine) as s:
meters = s.execute(select(Meter)).scalars().all()
assert len(meters) == 1
assert meters[0].ended_at is None
def test_declare_meter_swap_closes_previous(meters_client):
"""Declaring a swap closes the previous active meter at started_at."""
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 3, 15, 12, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
# First meter
resp1 = client.post(
"/api/energy/meters",
json=_declare_payload(label="Old Meter", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp1.status_code == 201
old_id = resp1.json()["id"]
# Swap
resp2 = client.post(
"/api/energy/meters",
json=_declare_payload(label="New Meter", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 201
body2 = resp2.json()
assert body2["label"] == "New Meter"
assert body2["ended_at"] is None # new meter is active
# DB check: old meter is closed at t1
with Session(engine) as s:
old_meter = s.get(Meter, old_id)
assert old_meter is not None
assert old_meter.ended_at is not None
# ended_at should equal t1 (modulo naive/aware round-trip)
ended_naive = old_meter.ended_at
if ended_naive.tzinfo is None:
ended_naive = ended_naive.replace(tzinfo=UTC)
assert ended_naive == t1
def test_declare_meter_overlap_returns_422(meters_client):
"""Declaring a meter with started_at before active meter's started_at → 422."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
t_before = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
# Declare first meter
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# Attempt to declare a meter before t0 → overlap error
# (t_before is in the past so would trigger recompute, but service layer raises first)
resp2 = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Backdated Meter", started_at=t_before.isoformat(), reason="meter_swap"
),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 422
assert "started_at" in resp2.json()["detail"].lower()
def test_declare_meter_missing_fields_returns_422(meters_client):
"""Missing required fields (started_at, reason) → 422 from Pydantic validation."""
client, _ = meters_client
_login(client)
resp = client.post(
"/api/energy/meters",
json={"label": "No Reason Meter"}, # missing started_at and reason
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_declare_meter_response_fields(meters_client):
"""POST response contains all expected MeterResponse fields."""
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="Full Fields Meter",
started_at=t0.isoformat(),
reason="home_move",
note="Testing note",
),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
body = resp.json()
for field in ("id", "label", "commodity", "started_at", "ended_at", "reason", "note", "created_at"):
assert field in body, f"Missing field {field!r} in response"
assert body["note"] == "Testing note"
assert body["reason"] == "home_move"
def test_declare_meter_retroactive_triggers_recompute(meters_client):
"""Declaring a meter with started_at in the past triggers recompute_range.
Both POST calls are made with recompute_range mocked so the test does not
spend time iterating over thousands of empty quarter-hour periods.
"""
client, _ = meters_client
_login(client)
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t_past = datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC)
with patch(
"app.api.routes.api.meters.recompute_range", return_value=5
) as mock_recompute:
# First meter (initial); also in the past, so recompute is called here too.
client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
# Reset call count before the swap we are actually testing.
mock_recompute.reset_mock()
# Retroactive swap: started_at in the past → should trigger recompute
resp = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Retroactive Swap", started_at=t_past.isoformat(), reason="meter_swap"
),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# recompute_range should have been called with start == t_past
assert mock_recompute.called
call_args = mock_recompute.call_args
recompute_start = call_args[0][1] # positional arg index 1 (session is 0)
# Normalise for comparison
if recompute_start.tzinfo is None:
recompute_start = recompute_start.replace(tzinfo=UTC)
assert recompute_start <= t_past
# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------
def test_patch_meter_unauthenticated_returns_401(meters_client):
client, _ = meters_client
resp = client.patch(
"/api/energy/meters/1",
json={"label": "Renamed"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_patch_meter_missing_csrf_returns_403(meters_client):
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(started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
resp = client.patch(f"/api/energy/meters/{meter_id}", json={"label": "Renamed"})
assert resp.status_code == 403
def test_patch_meter_not_found_returns_404(meters_client):
client, _ = meters_client
_login(client)
resp = client.patch(
"/api/energy/meters/99999",
json={"label": "Does Not Exist"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 404
def test_patch_meter_rename_label(meters_client):
"""PATCH label updates the meter's human-readable label."""
client, engine = 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"]
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"label": "Updated Label"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["label"] == "Updated Label"
# DB check
with Session(engine) as s:
m = s.get(Meter, meter_id)
assert m.label == "Updated Label"
def test_patch_meter_edit_note(meters_client):
"""PATCH note updates the meter's note field."""
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(started_at=t0.isoformat(), note=None),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"note": "Added a note"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["note"] == "Added a note"
def test_patch_meter_started_at_retroactive_triggers_recompute(meters_client):
"""PATCH started_at triggers recompute over min(old, new)..now window."""
client, _ = meters_client
_login(client)
# Set up two meters: initial + swap. All POST calls are mocked to avoid
# running recompute over thousands of empty historical periods.
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
resp_b = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
meter_b_id = resp_b.json()["id"]
# Correct Meter B's started_at to a slightly different past timestamp
t1_corrected = datetime(2024, 12, 15, 0, 0, 0, tzinfo=UTC) # earlier than t1
with patch(
"app.api.routes.api.meters.recompute_range", return_value=10
) as mock_recompute:
resp = client.patch(
f"/api/energy/meters/{meter_b_id}",
json={"started_at": t1_corrected.isoformat()},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
# recompute should be triggered
assert mock_recompute.called
call_args = mock_recompute.call_args
recompute_start = call_args[0][1]
if recompute_start.tzinfo is None:
recompute_start = recompute_start.replace(tzinfo=UTC)
# Window start should be min(t1_corrected, t1) = t1_corrected
assert recompute_start <= t1_corrected
def test_patch_meter_started_at_interval_violation_returns_422(meters_client):
"""PATCH started_at that would create an invalid interval → 422."""
client, _ = meters_client
_login(client)
# Set up: initial meter A, then swap to B. POST calls mocked to avoid slow recompute.
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp_a = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
meter_a_id = resp_a.json()["id"]
client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
# Try to set Meter A's started_at to after its ended_at (t1) → interval error
t_too_late = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
resp = client.patch(
f"/api/energy/meters/{meter_a_id}",
json={"started_at": t_too_late.isoformat()},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
"""PATCH that only changes label does NOT trigger recompute."""
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) as mock_recompute:
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
mock_recompute.reset_mock()
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"label": "Renamed Only"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
# recompute should NOT be triggered (no started_at change)
assert not mock_recompute.called
# ---------------------------------------------------------------------------
# Timeline continuity (recompute mocked to avoid slow computation over empty quarters)
# ---------------------------------------------------------------------------
def test_swap_timeline_continuity(meters_client):
"""After two swaps, meter timeline is contiguous and self-consistent."""
client, engine = meters_client
_login(client)
t0 = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t2 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
# Declare 3 meters in sequence. POST calls mocked to avoid slow recompute over
# years of empty quarter-hour periods.
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
for label, ts, reason in [
("M1", t0, "initial"),
("M2", t1, "meter_swap"),
("M3", t2, "meter_swap"),
]:
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label=label, started_at=ts.isoformat(), reason=reason),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# Verify timeline via list endpoint
resp = client.get("/api/energy/meters")
items = resp.json()["items"]
assert len(items) == 3
# M1: [t0, t1); M2: [t1, t2); M3: [t2, None)
m1 = next(i for i in items if i["label"] == "M1")
m2 = next(i for i in items if i["label"] == "M2")
m3 = next(i for i in items if i["label"] == "M3")
assert m1["ended_at"] is not None
assert m2["ended_at"] is not None
assert m3["ended_at"] is None # active
# ended_at of M1 == started_at of M2 (contiguous)
m1_ended = datetime.fromisoformat(m1["ended_at"]).replace(tzinfo=None)
m2_started = datetime.fromisoformat(m2["started_at"]).replace(tzinfo=None)
assert m1_ended == m2_started
m2_ended = datetime.fromisoformat(m2["ended_at"]).replace(tzinfo=None)
m3_started = datetime.fromisoformat(m3["started_at"]).replace(tzinfo=None)
assert m2_ended == m3_started
# ---------------------------------------------------------------------------
# Reason enum validation
# ---------------------------------------------------------------------------
def test_declare_meter_invalid_reason_returns_422(meters_client):
"""Unknown reason value → 422 from Pydantic enum validation."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), reason="unknown_reason_xyz"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Retroactive recompute & boundary update (recompute mocked) — window coverage check
# ---------------------------------------------------------------------------
def test_patch_started_at_earlier_updates_boundary(meters_client):
"""Moving started_at earlier should update the previous meter's ended_at."""
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
t1_earlier = datetime(2024, 12, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp_a = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
meter_a_id = resp_a.json()["id"]
resp_b = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
meter_b_id = resp_b.json()["id"]
# Correct Meter B's started_at to t1_earlier (moves boundary earlier)
resp = client.patch(
f"/api/energy/meters/{meter_b_id}",
json={"started_at": t1_earlier.isoformat()},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["id"] == meter_b_id
# DB check: Meter A's ended_at should now equal t1_earlier
with Session(engine) as s:
meter_a = s.get(Meter, meter_a_id)
assert meter_a is not None
ended = meter_a.ended_at
if ended is not None and ended.tzinfo is None:
ended = ended.replace(tzinfo=UTC)
assert ended == t1_earlier
# ---------------------------------------------------------------------------
# FUE-T06: HA discovery re-publish triggered after meter writes
# ---------------------------------------------------------------------------
def test_declare_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
"""POST /api/energy/meters triggers publish_discovery after successful commit."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Discovery Meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# publish_discovery must have been called exactly once after the declare.
mock_publish_discovery.assert_called_once()
def test_patch_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
"""PATCH /api/energy/meters/{id} triggers publish_discovery after successful commit."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
# Reset call count: the POST above also called publish_discovery.
mock_publish_discovery.reset_mock()
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"label": "Renamed Label"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
# publish_discovery must have been called exactly once after the PATCH.
mock_publish_discovery.assert_called_once()
def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client):
"""publish_discovery raising an exception must NOT cause POST declare to return 500.
The _trigger_discovery_republish helper is best-effort: it swallows all
exceptions so that a broken MQTT / discovery layer never breaks the API.
"""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with (
patch("app.api.routes.api.meters.recompute_range", return_value=0),
patch(
"app.services.ha_discovery.publish_discovery",
side_effect=RuntimeError("MQTT broker unreachable"),
),
):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Best Effort Meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
# The meter must be created successfully despite the discovery failure.
assert resp.status_code == 201
assert resp.json()["label"] == "Best Effort Meter"
+1038 -57
View File
File diff suppressed because it is too large Load Diff
+1126 -136
View File
File diff suppressed because it is too large Load Diff
+460 -9
View File
@@ -1,13 +1,15 @@
"""Tests for M6-T01: energy pricing and DSMR metering tables and ORM models. """Tests for energy pricing, DSMR metering, and meter epoch tables and ORM models.
Covers: Covers:
1. Migration shape: upgrade to head creates all five tables with correct columns, 1. Migration shape: upgrade to head creates all six tables with correct columns,
constraints, and indexes; downgrade -1 cleanly removes them. constraints, and indexes; downgrade -1 cleanly removes them.
2. ORM metadata: Base.metadata.tables contains all five tables; FKs are RESTRICT; 2. ORM metadata: Base.metadata.tables contains all six tables; FKs are RESTRICT;
JSON columns are present; unique and index constraints are correct. JSON columns are present; unique and index constraints are correct.
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head. 3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip. 4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip.
5. FK RESTRICT: deleting a referenced parent row must fail when children exist. 5. FK RESTRICT: deleting a referenced parent row must fail when children exist.
6. Meter model: fields, nullability, FK from energy_cost_period.
7. Migration backfill: initial meter creation, idempotency, audit.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +29,7 @@ from app.models.energy import (
DsmrReading, DsmrReading,
EnergyContract, EnergyContract,
EnergyContractVersion, EnergyContractVersion,
Meter,
TibberPrice, TibberPrice,
EnergyCostPeriod, EnergyCostPeriod,
) )
@@ -92,10 +95,11 @@ def energy_db_with_fk(tmp_path: Path):
def test_energy_tables_exist_after_upgrade(energy_db): def test_energy_tables_exist_after_upgrade(energy_db):
"""All five energy tables must exist after upgrade to head.""" """All six energy tables must exist after upgrade to head."""
inspector = inspect(energy_db) inspector = inspect(energy_db)
table_names = inspector.get_table_names() table_names = inspector.get_table_names()
expected_tables = { expected_tables = {
"meter",
"dsmr_reading", "dsmr_reading",
"energy_contract", "energy_contract",
"energy_contract_version", "energy_contract_version",
@@ -200,7 +204,7 @@ def test_energy_cost_period_columns(energy_db):
"import_cost", "export_revenue", "net_cost", "currency", "import_cost", "export_revenue", "net_cost", "currency",
"pricing", "degraded", "computed_at", "pricing", "degraded", "computed_at",
} }
nullable = {"contract_version_id"} nullable = {"contract_version_id", "meter_id"}
for col_name in non_nullable: for col_name in non_nullable:
assert col_name in columns, f"Missing column: {col_name}" assert col_name in columns, f"Missing column: {col_name}"
@@ -234,10 +238,35 @@ def test_energy_cost_period_fk_to_contract_version(energy_db):
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id.""" """energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
inspector = inspect(energy_db) inspector = inspect(energy_db)
fks = inspector.get_foreign_keys("energy_cost_period") fks = inspector.get_foreign_keys("energy_cost_period")
assert len(fks) == 1, f"Expected 1 FK on energy_cost_period, got {len(fks)}" # Two FKs: contract_version_id → energy_contract_version.id
fk = fks[0] # meter_id → meter.id
fk_by_col = {
col: fk
for fk in fks
for col in fk["constrained_columns"]
}
assert "contract_version_id" in fk_by_col, (
"energy_cost_period must have a FK on contract_version_id"
)
fk = fk_by_col["contract_version_id"]
assert fk["referred_table"] == "energy_contract_version" assert fk["referred_table"] == "energy_contract_version"
assert "contract_version_id" in fk["constrained_columns"] assert "id" in fk["referred_columns"]
def test_energy_cost_period_fk_to_meter(energy_db):
"""energy_cost_period.meter_id must have a FK referencing meter.id."""
inspector = inspect(energy_db)
fks = inspector.get_foreign_keys("energy_cost_period")
fk_by_col = {
col: fk
for fk in fks
for col in fk["constrained_columns"]
}
assert "meter_id" in fk_by_col, (
"energy_cost_period must have a FK on meter_id"
)
fk = fk_by_col["meter_id"]
assert fk["referred_table"] == "meter"
assert "id" in fk["referred_columns"] assert "id" in fk["referred_columns"]
@@ -308,8 +337,9 @@ def test_dsmr_decouple_migration_downgrade_restores_source_id_unique(tmp_path: P
def test_base_metadata_contains_energy_tables(): def test_base_metadata_contains_energy_tables():
"""Base.metadata.tables must include all five new energy tables.""" """Base.metadata.tables must include all six energy tables."""
expected = { expected = {
"meter",
"dsmr_reading", "dsmr_reading",
"energy_contract", "energy_contract",
"energy_contract_version", "energy_contract_version",
@@ -366,6 +396,17 @@ def test_energy_cost_period_fk_ondelete_restrict():
) )
def test_energy_cost_period_meter_id_fk_ondelete_restrict():
"""The FK from energy_cost_period.meter_id must be ON DELETE RESTRICT."""
table = Base.metadata.tables["energy_cost_period"]
col = table.columns["meter_id"]
assert col.foreign_keys, "meter_id must have a foreign key"
fk = next(iter(col.foreign_keys))
assert fk.ondelete == "RESTRICT", (
f"meter_id FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
)
def test_dsmr_reading_recorded_at_unique_in_metadata(): def test_dsmr_reading_recorded_at_unique_in_metadata():
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata, """DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
and source_id must NOT be unique (decoupled from the telegram id).""" and source_id must NOT be unique (decoupled from the telegram id)."""
@@ -739,3 +780,413 @@ def test_cost_period_restrict_prevents_version_deletion(tmp_path: Path):
session.commit() session.commit()
finally: finally:
engine.dispose() engine.dispose()
# ---------------------------------------------------------------------------
# 6. Meter model tests (M7-T01)
# ---------------------------------------------------------------------------
def test_meter_table_exists_after_upgrade(energy_db):
"""meter table must exist after upgrade to head."""
inspector = inspect(energy_db)
assert "meter" in inspector.get_table_names(), "meter table missing after upgrade to head"
def test_meter_columns(energy_db):
"""meter must have all required columns with correct nullability."""
inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("meter")}
non_nullable = {"id", "uuid", "label", "commodity", "started_at", "reason", "created_at"}
nullable = {"ended_at", "note"}
for col_name in non_nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
for col_name in nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
def test_meter_uuid_unique_constraint(energy_db):
"""meter.uuid must have a unique constraint."""
inspector = inspect(energy_db)
unique_constraints = inspector.get_unique_constraints("meter")
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
assert "uuid" in unique_cols, "meter.uuid must have a unique constraint"
def test_meter_orm_metadata():
"""Meter must be registered in Base.metadata with correct field types."""
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
table = Base.metadata.tables["meter"]
assert "id" in table.columns
assert "uuid" in table.columns
assert "label" in table.columns
assert "commodity" in table.columns
assert "started_at" in table.columns
assert "ended_at" in table.columns
assert "reason" in table.columns
assert "note" 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):
"""A Meter row can be inserted and retrieved with all fields intact."""
now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session:
m = Meter(
label="Test meter @ Dorpsstraat 1",
commodity="electricity",
started_at=now,
ended_at=None,
reason="initial",
note="2G smart meter",
created_at=now,
)
session.add(m)
session.commit()
meter_id = m.id
with Session(energy_db) as session:
fetched = session.get(Meter, meter_id)
assert fetched is not None
assert fetched.label == "Test meter @ Dorpsstraat 1"
assert fetched.commodity == "electricity"
assert fetched.reason == "initial"
assert fetched.note == "2G smart meter"
assert fetched.ended_at is None
def test_meter_ended_at_nullable(energy_db):
"""Meter.ended_at and Meter.note can both be None (active/ongoing meter)."""
now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session:
m = Meter(
label="Active meter",
commodity="electricity",
started_at=now,
ended_at=None,
reason="home_move",
note=None,
created_at=now,
)
session.add(m)
session.commit()
meter_id = m.id
with Session(energy_db) as session:
fetched = session.get(Meter, meter_id)
assert fetched is not None
assert fetched.ended_at is None
assert fetched.note is None
def test_energy_cost_period_meter_id_nullable(energy_db):
"""EnergyCostPeriod.meter_id must be nullable (no meter FK required)."""
now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session:
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.5,
d2_kwh=1.2,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.225,
export_revenue=0.0,
net_cost=0.225,
currency="EUR",
pricing={"kind": "manual"},
contract_version_id=None,
meter_id=None,
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
period_id = period.id
with Session(energy_db) as session:
fetched = session.get(EnergyCostPeriod, period_id)
assert fetched is not None
assert fetched.meter_id is None
def test_energy_cost_period_meter_id_fk_enforced(tmp_path: Path):
"""Inserting an EnergyCostPeriod with a non-existent meter_id must fail (RESTRICT FK)."""
db_path = tmp_path / "meter_fk_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = _engine_with_fk(db_url)
try:
now = datetime.now(tz=timezone.utc)
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(engine) as session:
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.0,
d2_kwh=0.0,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.0,
export_revenue=0.0,
net_cost=0.0,
currency="EUR",
pricing={},
contract_version_id=None,
meter_id=9999, # non-existent meter
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
finally:
engine.dispose()
def test_meter_restrict_prevents_deletion_with_cost_periods(tmp_path: Path):
"""Deleting a Meter that has cost periods attributed to it must fail (ON DELETE RESTRICT)."""
db_path = tmp_path / "meter_restrict_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = _engine_with_fk(db_url)
try:
now = datetime.now(tz=timezone.utc)
with Session(engine) as session:
m = Meter(
label="RESTRICT Test Meter",
commodity="electricity",
started_at=now,
ended_at=None,
reason="initial",
note=None,
created_at=now,
)
session.add(m)
session.flush()
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.1,
d2_kwh=0.2,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.05,
export_revenue=0.0,
net_cost=0.05,
currency="EUR",
pricing={"kind": "manual"},
contract_version_id=None,
meter_id=m.id,
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
meter_id = m.id
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(engine) as session:
session.execute(
text("DELETE FROM meter WHERE id = :mid"),
{"mid": meter_id},
)
session.commit()
finally:
engine.dispose()
# ---------------------------------------------------------------------------
# 7. Migration backfill tests (M7-T01)
# ---------------------------------------------------------------------------
def test_migration_empty_db_no_initial_meter(tmp_path: Path):
"""Upgrading an empty database must not create any meter rows."""
db_path = tmp_path / "empty_backfill_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
count = session.execute(text("SELECT COUNT(*) FROM meter")).scalar()
assert count == 0, f"Expected 0 meter rows in empty DB after upgrade, got {count}"
engine.dispose()
def test_migration_backfill_creates_initial_meter_from_readings(tmp_path: Path):
"""Upgrading with existing dsmr_reading rows must create exactly one initial meter
and back-fill all energy_cost_period rows with its id."""
from datetime import timedelta
db_path = tmp_path / "backfill_readings_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
# Upgrade only to the revision just before M7-T01 (i.e. pre-meter).
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
now = datetime.now(tz=timezone.utc)
t0 = now - timedelta(hours=2)
t1 = now - timedelta(hours=1)
with Session(engine) as session:
# Insert two dsmr_reading rows.
session.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:ts, NULL, '{}')"
),
{"ts": t0.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:ts, NULL, '{}')"
),
{"ts": t1.strftime("%Y-%m-%dT%H:%M:%S")},
)
# Insert two energy_cost_period rows (no meter_id column yet at this revision).
session.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, "
"import_cost, export_revenue, net_cost, currency, pricing, "
"contract_version_id, degraded, computed_at) "
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
"NULL, 0, :now)"
),
{"ps": t0.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, "
"import_cost, export_revenue, net_cost, currency, pricing, "
"contract_version_id, degraded, computed_at) "
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
"NULL, 0, :now)"
),
{"ps": t1.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.commit()
engine.dispose()
# Now upgrade to head (applies M7-T01 migration with backfill).
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
# Exactly one initial meter must exist.
meter_count = session.execute(
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
).scalar()
assert meter_count == 1, f"Expected 1 initial meter, got {meter_count}"
# All non-degraded energy_cost_period rows must have meter_id set.
null_count = session.execute(
text(
"SELECT COUNT(*) FROM energy_cost_period "
"WHERE meter_id IS NULL AND degraded = 0"
)
).scalar()
assert null_count == 0, (
f"Expected 0 non-degraded periods with NULL meter_id, got {null_count}"
)
# The initial meter started_at must match the earliest dsmr_reading.recorded_at.
meter_row = session.execute(
text("SELECT started_at FROM meter WHERE reason = 'initial' LIMIT 1")
).fetchone()
assert meter_row is not None
meter_started = meter_row[0]
# Both meter_started and t0 are now UTC strings / datetimes — just verify
# the date portion matches (second-level precision is sufficient).
assert t0.strftime("%Y-%m-%dT%H:%M:%S") in str(meter_started), (
f"initial meter started_at {meter_started!r} should match earliest reading {t0}"
)
engine.dispose()
def test_migration_backfill_idempotent(tmp_path: Path):
"""Running upgrade head twice must not create duplicate initial meters or corrupt data."""
db_path = tmp_path / "idempotent_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
# Upgrade to pre-meter revision and insert a reading.
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
now = datetime.now(tz=timezone.utc)
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
session.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:ts, NULL, '{}')"
),
{"ts": now.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.commit()
engine.dispose()
# First upgrade to head.
command.upgrade(alembic_cfg, "head")
# Downgrade and re-upgrade to simulate idempotency (tests the guard condition).
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
meter_count = session.execute(
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
).scalar()
assert meter_count == 1, (
f"Expected exactly 1 initial meter after repeated upgrade, got {meter_count}"
)
engine.dispose()
def test_migration_downgrade_removes_meter_table(tmp_path: Path):
"""Downgrading from M7-T01 must remove the meter table and the meter_id column."""
db_path = tmp_path / "downgrade_meter_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
inspector = inspect(engine)
assert "meter" in inspector.get_table_names(), "meter must exist before downgrade"
ecp_cols = {col["name"] for col in inspector.get_columns("energy_cost_period")}
assert "meter_id" in ecp_cols, "meter_id must exist in energy_cost_period before downgrade"
engine.dispose()
# Downgrade one step (removes the meter table + meter_id column).
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
inspector = inspect(engine)
assert "meter" not in inspector.get_table_names(), "meter must be gone after downgrade"
ecp_cols_after = {col["name"] for col in inspector.get_columns("energy_cost_period")}
assert "meter_id" not in ecp_cols_after, (
"meter_id must be removed from energy_cost_period after downgrade"
)
engine.dispose()
+41 -10
View File
@@ -205,27 +205,58 @@ def test_register_provider_direct_call():
def test_build_catalog_empty_with_no_devices(expose_db): def test_build_catalog_empty_with_no_devices(expose_db):
"""build_catalog with no enabled modbus devices must contain no modbus entities. """build_catalog with no enabled modbus devices must contain no modbus entities.
The energy_cost provider is always registered and always produces its 6 entities FUE-T05: the energy_cost provider now requires an active electricity meter.
(4 original + 2 daily) regardless of device state, so the catalog will not be empty. Without one, it returns [] and the catalog contains no energy entities.
This test checks that no *modbus* entities are present when there are no enabled With one, it produces 6 entities.
modbus devices.
This test verifies both cases:
1. No modbus devices no modbus entities.
2. No active meter no energy entities (provider returns []).
3. After inserting an active meter 6 energy entities present.
""" """
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
# Case: no modbus devices, no active meter → catalog is empty.
with Session(expose_db) as session: with Session(expose_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
# The modbus provider finds no enabled devices → no modbus entities.
# The energy_cost provider always produces 6 entities, so the catalog is non-empty.
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")] modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
assert modbus_entities == [], ( assert modbus_entities == [], (
"Expected no modbus entities when no modbus devices are enabled" "Expected no modbus entities when no modbus devices are enabled"
) )
# The 6 energy_cost entities should always be present (4 original + 2 daily). # No active electricity meter → energy-cost provider returns [] → no energy entities.
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")} energy_keys_no_meter = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
assert len(energy_keys) == 6, ( assert len(energy_keys_no_meter) == 0, (
f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}" f"Expected 0 energy_cost entities (no active meter), got {energy_keys_no_meter!r}"
)
# Insert an active electricity meter → provider should now produce 6 entities.
from datetime import datetime, timezone
now = datetime.now(tz=timezone.utc)
with Session(expose_db) as session:
from app.models.energy import Meter
m = Meter(
label="Test Meter for catalog",
commodity="electricity",
started_at=now,
ended_at=None,
reason="initial",
note=None,
created_at=now,
)
session.add(m)
session.commit()
with Session(expose_db) as session:
catalog_with_meter = build_catalog(session)
energy_keys_with_meter = {
e.entity.key for e in catalog_with_meter if e.entity.key.startswith("energy.")
}
assert len(energy_keys_with_meter) == 6, (
f"Expected exactly 6 energy_cost entities (4 original + 2 daily) with active meter, "
f"got {energy_keys_with_meter!r}"
) )
+639
View File
@@ -0,0 +1,639 @@
"""Tests for M7-T02: Meter service layer.
Coverage
--------
1. ``meter_at`` half-open interval boundary semantics.
2. ``declare_meter`` first declaration (no active meter), normal swap, backdate rejection.
3. Mutual exclusion each commodity has at most one active meter after swaps.
4. Interval continuity old meter's ended_at == new meter's started_at after swap.
5. Different commodities are independent (electricity swap doesn't touch gas meters).
6. ``list_meters`` ordering and commodity filtering.
7. ``update_meter`` label/note update, started_at retroactive correction with
interval consistency, and validation errors.
8. ``update_meter`` first-meter (no previous) retroactive started_at change.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, event as sa_event
from sqlalchemy.orm import Session
from app.models.energy import Meter
from app.services.meters import (
MeterIntervalError,
MeterOverlapError,
declare_meter,
list_meters,
meter_at,
update_meter,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_app_alembic_config(database_url: str) -> Config:
cfg = Config("alembic_app.ini")
cfg.set_main_option("sqlalchemy.url", database_url)
return cfg
def _engine_with_fk(db_url: str):
"""Create a SQLAlchemy engine with SQLite FK enforcement enabled."""
engine = create_engine(db_url, connect_args={"check_same_thread": False})
@sa_event.listens_for(engine, "connect")
def _enable_fk(dbapi_conn, _rec):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
return engine
@pytest.fixture()
def meter_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to the current Alembic head with FK enforcement."""
db_path = tmp_path / "meter_service_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = _engine_with_fk(db_url)
yield engine
engine.dispose()
@pytest.fixture()
def session(meter_db):
"""Provide a single SQLAlchemy session for a test, auto-rolling back on exit."""
with Session(meter_db) as s:
yield s
# Tests that commit explicitly are fine; for read-only tests the context
# manager handles cleanup.
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_T0 = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) # base timestamp for tests
def _make_meter(
session: Session,
*,
started_at: datetime,
ended_at: datetime | None = None,
label: str = "Test meter",
commodity: str = "electricity",
reason: str = "initial",
note: str | None = None,
) -> Meter:
"""Insert a Meter row directly (bypassing service logic) for test setup."""
m = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=ended_at,
reason=reason,
note=note,
created_at=datetime.now(UTC),
)
session.add(m)
session.flush()
return m
# ---------------------------------------------------------------------------
# 1. meter_at — half-open interval semantics
# ---------------------------------------------------------------------------
class TestMeterAt:
def test_returns_none_when_no_meters(self, session: Session):
"""meter_at must return None when the table is empty."""
assert meter_at(session, _T0) is None
def test_returns_active_meter_for_ts_after_start(self, session: Session):
"""An active meter (ended_at IS NULL) covers any ts ≥ started_at."""
m = _make_meter(session, started_at=_T0, ended_at=None)
result = meter_at(session, _T0 + timedelta(hours=1))
assert result is not None
assert result.id == m.id
def test_exact_started_at_is_inclusive(self, session: Session):
"""ts == started_at must be covered by that meter (left-closed boundary)."""
m = _make_meter(session, started_at=_T0, ended_at=None)
result = meter_at(session, _T0)
assert result is not None
assert result.id == m.id
def test_ts_before_started_at_returns_none(self, session: Session):
"""ts < started_at must not be covered."""
_make_meter(session, started_at=_T0, ended_at=None)
result = meter_at(session, _T0 - timedelta(seconds=1))
assert result is None
def test_exact_ended_at_is_exclusive(self, session: Session):
"""ts == ended_at must NOT be covered by the closed meter (right-open boundary)."""
t1 = _T0 + timedelta(hours=2)
_make_meter(session, started_at=_T0, ended_at=t1, label="Old meter")
# The new active meter starts exactly at t1.
new = _make_meter(session, started_at=t1, ended_at=None, label="New meter")
result = meter_at(session, t1)
assert result is not None
assert result.id == new.id
def test_ts_just_before_ended_at_is_covered(self, session: Session):
"""ts just before ended_at must still be covered by the closing meter."""
t1 = _T0 + timedelta(hours=2)
m = _make_meter(session, started_at=_T0, ended_at=t1)
result = meter_at(session, t1 - timedelta(seconds=1))
assert result is not None
assert result.id == m.id
def test_commodity_filter(self, session: Session):
"""meter_at must only return the meter for the requested commodity."""
m_elec = _make_meter(session, started_at=_T0, commodity="electricity")
_make_meter(session, started_at=_T0, commodity="gas")
result = meter_at(session, _T0, commodity="electricity")
assert result is not None
assert result.id == m_elec.id
result_gas = meter_at(session, _T0, commodity="gas")
assert result_gas is not None
assert result_gas.commodity == "gas"
def test_no_meter_for_unknown_commodity(self, session: Session):
"""meter_at returns None when no meter exists for the requested commodity."""
_make_meter(session, started_at=_T0, commodity="electricity")
assert meter_at(session, _T0, commodity="heating") is None
def test_two_contiguous_epochs_correct_routing(self, session: Session):
"""With two contiguous meters, meter_at routes each ts to the correct epoch."""
t1 = _T0 + timedelta(hours=3)
m0 = _make_meter(session, started_at=_T0, ended_at=t1, label="Meter 0")
m1 = _make_meter(session, started_at=t1, ended_at=None, label="Meter 1")
# ts in first epoch
assert meter_at(session, _T0 + timedelta(hours=1)).id == m0.id
# ts exactly at boundary → second epoch
assert meter_at(session, t1).id == m1.id
# ts in second epoch
assert meter_at(session, t1 + timedelta(hours=1)).id == m1.id
def test_equal_started_at_swap_meter_at_still_returns_new_active(self, session: Session):
"""Regression: after equal-timestamp swap, meter_at must return the new active meter.
When declare_meter is called with started_at == active.started_at (the
"equal-timestamp replace" allowed by §3.5), the old meter becomes a
zero-width epoch [T0, T0). A previous bug caused meter_at to select
the zero-width row first (same started_at, lower rowid) and then fail
the upper-bound check, returning None for *any* ts >= T0. This test
pins the correct behaviour: meter_at(T0) and meter_at(T0+δ) must both
return the new active meter, not None.
"""
# Declare first meter at T0.
declare_meter(session, label="M1 (original)", started_at=_T0, reason="initial")
session.commit()
# Declare second meter at *the same* T0 — equal-timestamp swap.
new_m = declare_meter(
session, label="M2 (replacement)", started_at=_T0, reason="meter_swap"
)
session.commit()
# meter_at at exactly T0 must return the new active meter.
result_at_T0 = meter_at(session, _T0)
assert result_at_T0 is not None, (
"meter_at(T0) returned None after equal-timestamp swap; "
"the new active meter should cover T0"
)
assert result_at_T0.id == new_m.id, (
f"meter_at(T0) returned meter id={result_at_T0.id} (label={result_at_T0.label!r}), "
f"expected id={new_m.id} (the new active meter)"
)
# meter_at slightly after T0 must also return the new active meter.
result_after_T0 = meter_at(session, _T0 + timedelta(seconds=1))
assert result_after_T0 is not None, (
"meter_at(T0+1s) returned None after equal-timestamp swap"
)
assert result_after_T0.id == new_m.id, (
f"meter_at(T0+1s) returned meter id={result_after_T0.id}, "
f"expected id={new_m.id} (the new active meter)"
)
# ---------------------------------------------------------------------------
# 2 & 3. declare_meter — first declaration, swap, mutual exclusion
# ---------------------------------------------------------------------------
class TestDeclareMeter:
def test_first_declaration_no_active(self, session: Session):
"""Declaring the first meter must create an active meter with ended_at IS NULL."""
m = declare_meter(
session,
label="Initial meter",
started_at=_T0,
reason="initial",
)
session.commit()
fetched = session.get(Meter, m.id)
assert fetched is not None
assert fetched.ended_at is None
assert fetched.commodity == "electricity"
assert fetched.reason == "initial"
def test_swap_closes_old_meter(self, session: Session):
"""Declaring a second meter must close the previous active meter."""
first = declare_meter(
session, label="First meter", started_at=_T0, reason="initial"
)
session.commit()
first_id = first.id
t1 = _T0 + timedelta(days=30)
second = declare_meter(
session, label="Second meter", started_at=t1, reason="meter_swap"
)
session.commit()
# First meter must now be closed at exactly t1.
closed = session.get(Meter, first_id)
assert closed.ended_at is not None
from app.services.meters import _as_utc
assert _as_utc(closed.ended_at) == _as_utc(t1)
# Second meter must be active.
assert second.ended_at is None
def test_swap_interval_contiguous(self, session: Session):
"""Old meter's ended_at must exactly equal new meter's started_at after swap."""
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
t1 = _T0 + timedelta(days=10)
new_m = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
session.commit()
# Query the old (now-closed) meter.
meters = list_meters(session, commodity="electricity")
assert len(meters) == 2
old_m = next(m for m in meters if m.id != new_m.id)
from app.services.meters import _as_utc
assert _as_utc(old_m.ended_at) == _as_utc(new_m.started_at), (
"Timeline gap or overlap: old ended_at must == new started_at"
)
def test_at_most_one_active_per_commodity_after_multiple_swaps(self, session: Session):
"""After N swaps, exactly one meter per commodity must be active."""
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
declare_meter(
session, label="M2", started_at=_T0 + timedelta(days=10), reason="meter_swap"
)
session.commit()
declare_meter(
session, label="M3", started_at=_T0 + timedelta(days=20), reason="meter_swap"
)
session.commit()
active_meters = [m for m in list_meters(session, commodity="electricity") if m.ended_at is None]
assert len(active_meters) == 1, f"Expected 1 active meter, got {len(active_meters)}"
def test_backdate_rejected(self, session: Session):
"""started_at strictly before active meter's started_at must raise MeterOverlapError."""
declare_meter(session, label="Current", started_at=_T0, reason="initial")
session.commit()
with pytest.raises(MeterOverlapError):
declare_meter(
session,
label="Too early",
started_at=_T0 - timedelta(hours=1),
reason="meter_swap",
)
def test_backdate_raises_before_any_db_write(self, session: Session):
"""When backdate is rejected, no new meter row must be written."""
declare_meter(session, label="Current", started_at=_T0, reason="initial")
session.commit()
meters_before = list_meters(session, commodity="electricity")
count_before = len(meters_before)
with pytest.raises(MeterOverlapError):
declare_meter(
session,
label="Bad meter",
started_at=_T0 - timedelta(minutes=5),
reason="meter_swap",
)
# Rollback the failed operation explicitly (simulating what the caller would do).
session.rollback()
# Re-open session to verify state.
with Session(session.get_bind()) as s2:
meters_after = list_meters(s2, commodity="electricity")
assert len(meters_after) == count_before, (
"No extra meter must be written when backdate is rejected"
)
def test_equal_started_at_allowed(self, session: Session):
"""started_at == active meter's started_at must NOT raise (equal is allowed)."""
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
# Should not raise — equal timestamps are valid (replaces meter at same instant).
new_m = declare_meter(
session, label="M2", started_at=_T0, reason="meter_swap"
)
session.commit()
assert new_m.ended_at is None
def test_note_and_commodity_stored(self, session: Session):
"""declare_meter must persist note and commodity correctly."""
m = declare_meter(
session,
label="Gas meter",
started_at=_T0,
reason="initial",
commodity="gas",
note="Rotameter serial XYZ",
)
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.commodity == "gas"
assert fetched.note == "Rotameter serial XYZ"
def test_declare_meter_generates_uuid(self, session: Session):
"""declare_meter must auto-generate a non-empty uuid via ORM default."""
import re
UUID4_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)
m = declare_meter(
session,
label="Meter with UUID",
started_at=_T0,
reason="initial",
)
session.commit()
fetched = session.get(Meter, m.id)
assert fetched is not None
assert fetched.uuid is not None, "uuid must not be None after declare_meter"
assert fetched.uuid != "", "uuid must not be empty"
assert UUID4_RE.match(fetched.uuid), (
f"uuid {fetched.uuid!r} does not look like a valid UUID v4"
)
def test_declare_meter_each_gets_distinct_uuid(self, session: Session):
"""Each declared meter must receive a distinct UUID (not duplicated)."""
m1 = declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
t1 = _T0 + timedelta(days=10)
m2 = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
session.commit()
assert m1.uuid != m2.uuid, (
f"Two declared meters must have distinct UUIDs; both got {m1.uuid!r}"
)
# ---------------------------------------------------------------------------
# 5. Different commodities are independent
# ---------------------------------------------------------------------------
class TestCommodityIsolation:
def test_electricity_swap_does_not_affect_gas_meter(self, session: Session):
"""Swapping the electricity meter must not touch the gas meter's active state."""
declare_meter(session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity")
declare_meter(session, label="Gas 1", started_at=_T0, reason="initial", commodity="gas")
session.commit()
declare_meter(
session,
label="Elec 2",
started_at=_T0 + timedelta(days=5),
reason="meter_swap",
commodity="electricity",
)
session.commit()
# Gas meter must still be active.
gas_meters = list_meters(session, commodity="gas")
active_gas = [m for m in gas_meters if m.ended_at is None]
assert len(active_gas) == 1, "Gas meter must remain active after electricity swap"
assert active_gas[0].label == "Gas 1"
# Electricity: exactly one active.
elec_meters = list_meters(session, commodity="electricity")
active_elec = [m for m in elec_meters if m.ended_at is None]
assert len(active_elec) == 1
assert active_elec[0].label == "Elec 2"
def test_different_commodity_backdate_is_independent(self, session: Session):
"""Backdate validation is per-commodity: gas meter start does not constrain electricity."""
# Declare gas meter at a later time.
declare_meter(
session, label="Gas 1", started_at=_T0 + timedelta(days=10), reason="initial", commodity="gas"
)
session.commit()
# Declaring an electricity meter at an earlier time must succeed (no gas constraint).
m = declare_meter(
session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity"
)
session.commit()
assert m is not None
# ---------------------------------------------------------------------------
# 6. list_meters
# ---------------------------------------------------------------------------
class TestListMeters:
def test_returns_empty_list_when_no_meters(self, session: Session):
assert list_meters(session) == []
def test_ordered_by_started_at_asc(self, session: Session):
"""list_meters must return meters in ascending started_at order."""
t1 = _T0 + timedelta(days=10)
t2 = _T0 + timedelta(days=20)
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
session.commit()
declare_meter(session, label="M3", started_at=t2, reason="meter_swap")
session.commit()
meters = list_meters(session, commodity="electricity")
assert [m.label for m in meters] == ["M1", "M2", "M3"]
def test_commodity_filter_returns_only_matching(self, session: Session):
"""list_meters with commodity kwarg must filter correctly."""
declare_meter(session, label="Elec", started_at=_T0, reason="initial", commodity="electricity")
declare_meter(session, label="Gas", started_at=_T0, reason="initial", commodity="gas")
session.commit()
elec = list_meters(session, commodity="electricity")
gas = list_meters(session, commodity="gas")
all_meters = list_meters(session)
assert len(elec) == 1 and elec[0].commodity == "electricity"
assert len(gas) == 1 and gas[0].commodity == "gas"
assert len(all_meters) == 2
# ---------------------------------------------------------------------------
# 7 & 8. update_meter
# ---------------------------------------------------------------------------
class TestUpdateMeter:
def test_update_label(self, session: Session):
"""update_meter must update label without touching other fields."""
m = _make_meter(session, started_at=_T0)
update_meter(session, m, label="New label")
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.label == "New label"
assert fetched.note is None # unchanged
def test_update_note(self, session: Session):
"""update_meter must update note without touching other fields."""
m = _make_meter(session, started_at=_T0)
update_meter(session, m, note="Some note")
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.note == "Some note"
assert fetched.label == "Test meter" # unchanged
def test_update_label_and_note_simultaneously(self, session: Session):
"""update_meter must update both label and note in a single call."""
m = _make_meter(session, started_at=_T0)
update_meter(session, m, label="Updated label", note="Updated note")
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.label == "Updated label"
assert fetched.note == "Updated note"
def test_update_started_at_first_meter(self, session: Session):
"""Changing started_at of the first (only) active meter must work without a previous meter."""
m = _make_meter(session, started_at=_T0, ended_at=None)
new_start = _T0 + timedelta(hours=2)
update_meter(session, m, started_at=new_start)
session.commit()
fetched = session.get(Meter, m.id)
from app.services.meters import _as_utc
assert _as_utc(fetched.started_at) == _as_utc(new_start)
def test_update_started_at_updates_previous_ended_at(self, session: Session):
"""Retroactive started_at change must propagate to the previous meter's ended_at."""
t1 = _T0 + timedelta(days=10)
prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev meter")
curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr meter")
session.commit()
new_start = _T0 + timedelta(days=7) # shift boundary 3 days earlier
update_meter(session, curr, started_at=new_start)
session.commit()
from app.services.meters import _as_utc
fetched_prev = session.get(Meter, prev.id)
fetched_curr = session.get(Meter, curr.id)
# The previous meter's ended_at must now equal the new started_at.
assert _as_utc(fetched_prev.ended_at) == _as_utc(new_start), (
"Previous meter's ended_at must be updated to maintain continuity"
)
# The current meter's started_at must reflect the change.
assert _as_utc(fetched_curr.started_at) == _as_utc(new_start)
def test_update_started_at_continuity_maintained(self, session: Session):
"""After retroactive started_at change, prev.ended_at == curr.started_at (no gap)."""
t1 = _T0 + timedelta(days=10)
prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev")
curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr")
session.commit()
new_start = _T0 + timedelta(days=12) # shift boundary 2 days later
update_meter(session, curr, started_at=new_start)
session.commit()
from app.services.meters import _as_utc
fetched_prev = session.get(Meter, prev.id)
fetched_curr = session.get(Meter, curr.id)
assert _as_utc(fetched_prev.ended_at) == _as_utc(fetched_curr.started_at), (
"Timeline must remain contiguous after retroactive started_at shift"
)
def test_update_started_at_rejects_at_or_past_ended_at(self, session: Session):
"""New started_at ≥ ended_at must raise MeterIntervalError (empty/inverted epoch)."""
t1 = _T0 + timedelta(days=10)
m = _make_meter(session, started_at=_T0, ended_at=t1)
with pytest.raises(MeterIntervalError):
update_meter(session, m, started_at=t1) # == ended_at → empty epoch
with pytest.raises(MeterIntervalError):
update_meter(session, m, started_at=t1 + timedelta(hours=1)) # > ended_at
def test_update_started_at_rejects_at_or_before_prev_started_at(self, session: Session):
"""New started_at ≤ prev.started_at must raise MeterIntervalError."""
t1 = _T0 + timedelta(days=10)
_make_meter(session, started_at=_T0, ended_at=t1, label="Prev")
curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr")
session.commit()
with pytest.raises(MeterIntervalError):
# exactly at prev.started_at — would collapse prev epoch to zero
update_meter(session, curr, started_at=_T0)
with pytest.raises(MeterIntervalError):
# strictly before prev.started_at — inverts prev epoch
update_meter(session, curr, started_at=_T0 - timedelta(hours=1))
def test_noop_call_does_not_raise(self, session: Session):
"""Calling update_meter with all None args must succeed without error."""
m = _make_meter(session, started_at=_T0)
result = update_meter(session, m)
assert result is m
def test_update_started_at_no_previous_meter_shift_backward(self, session: Session):
"""For the first meter (no previous), shifting started_at backward must succeed."""
m = _make_meter(session, started_at=_T0, ended_at=None)
earlier = _T0 - timedelta(days=5)
update_meter(session, m, started_at=earlier)
session.commit()
from app.services.meters import _as_utc
fetched = session.get(Meter, m.id)
assert _as_utc(fetched.started_at) == _as_utc(earlier)
+51
View File
@@ -217,6 +217,57 @@ class TestReadBlocks:
mock_client.close.assert_called_once() mock_client.close.assert_called_once()
@patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_default_function_code_uses_fc04_input_registers(
self, mock_client_cls: MagicMock
) -> None:
"""With no function_code given, read_blocks uses FC04 (read_input_registers)."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.connect.return_value = True
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}])
mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1)
mock_client.read_holding_registers.assert_not_called()
@patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_function_code_3_uses_fc03_holding_registers(
self, mock_client_cls: MagicMock
) -> None:
"""function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.connect.return_value = True
mock_client.read_holding_registers.return_value = _make_ok_response(
[0x4366, 0x3334, 0x3F80, 0x0000]
)
blocks = [{"start": 0x2000, "count": 4}]
result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3)
assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000}
mock_client.read_holding_registers.assert_called_once_with(
0x2000, count=4, device_id=1
)
mock_client.read_input_registers.assert_not_called()
@patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_invalid_function_code_raises_before_connecting(
self, mock_client_cls: MagicMock
) -> None:
"""An unsupported function code is rejected without opening a connection."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with pytest.raises(ModbusDriverError, match="Unsupported read function code"):
read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16)
# No client should have been constructed or connected for a bad FC.
mock_client_cls.assert_not_called()
mock_client.connect.assert_not_called()
@patch("app.integrations.modbus.driver.ModbusTcpClient") @patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None: def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x).""" """unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""