"""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)