390 lines
14 KiB
Python
390 lines
14 KiB
Python
"""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 sqlalchemy import select
|
|
|
|
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.models.meter_source import MeterSourceChannel
|
|
from app.schemas.meter import (
|
|
MeterDeclareRequest,
|
|
MeterBindingSummary,
|
|
MeterListResponse,
|
|
MeterPatchRequest,
|
|
MeterResponse,
|
|
)
|
|
from app.services.meter_sources import (
|
|
ChannelNotFoundError,
|
|
MeterSourceError,
|
|
create_binding,
|
|
create_binding_for_meter_swap,
|
|
)
|
|
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, commit=False)
|
|
logger.info(
|
|
"%s: recomputed %d period(s) in window [%s, %s).",
|
|
label,
|
|
n,
|
|
start.isoformat(),
|
|
end.isoformat(),
|
|
)
|
|
return n
|
|
|
|
|
|
def _meter_response(meter: Meter) -> MeterResponse:
|
|
"""Serialize meter plus binding summaries without exposing source config."""
|
|
response = MeterResponse.model_validate(meter)
|
|
response.bindings = [
|
|
MeterBindingSummary(
|
|
uuid=binding.uuid,
|
|
source_channel_uuid=binding.channel.uuid,
|
|
source_uuid=binding.channel.source.uuid,
|
|
started_at=binding.started_at,
|
|
ended_at=binding.ended_at,
|
|
)
|
|
for binding in meter.source_bindings
|
|
]
|
|
return response
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = [_meter_response(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:
|
|
old_meter = db.execute(
|
|
select(Meter).where(Meter.commodity == body.commodity, Meter.ended_at.is_(None))
|
|
).scalar_one_or_none()
|
|
new_meter = declare_meter(
|
|
db,
|
|
label=body.label,
|
|
started_at=started_at_utc,
|
|
reason=body.reason.value,
|
|
commodity=body.commodity,
|
|
note=body.note,
|
|
)
|
|
db.flush() # assign PK before an optional binding and recompute
|
|
if body.source_channel_uuid is not None:
|
|
channel = db.execute(
|
|
select(MeterSourceChannel).where(MeterSourceChannel.uuid == body.source_channel_uuid)
|
|
).scalar_one_or_none()
|
|
if channel is None:
|
|
raise ChannelNotFoundError("Meter source channel was not found.")
|
|
if body.reason.value == "meter_swap":
|
|
create_binding_for_meter_swap(
|
|
db,
|
|
old_meter_id=old_meter.id if old_meter is not None else None,
|
|
new_meter_id=new_meter.id,
|
|
channel_id=channel.id,
|
|
started_at=started_at_utc,
|
|
)
|
|
else:
|
|
create_binding(
|
|
db,
|
|
meter_id=new_meter.id,
|
|
channel_id=channel.id,
|
|
started_at=started_at_utc,
|
|
)
|
|
|
|
# Keep recompute in this transaction: a failure must not leave a new
|
|
# meter, its predecessor, or either binding at a half-applied boundary.
|
|
now = datetime.now(UTC)
|
|
if started_at_utc < now:
|
|
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
|
|
db.commit()
|
|
except (MeterOverlapError, MeterSourceError) as exc:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError)
|
|
else status.HTTP_422_UNPROCESSABLE_ENTITY),
|
|
detail=str(exc),
|
|
)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
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 _meter_response(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,
|
|
)
|
|
|
|
# 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()
|
|
except MeterIntervalError as exc:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=str(exc),
|
|
)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
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 _meter_response(meter)
|