Files
home-automation/app/api/routes/api/meter_sources.py
T

322 lines
16 KiB
Python

"""Authenticated HTTP contract for meter sources, channels, and bindings."""
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.integrations.meter_sources import SourceProfileError, list_source_profiles, sanitize_source_config
from app.models.energy import DsmrReading, Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
from app.schemas.meter_source import (
BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelBindingSummaryResponse,
ChannelReadingResponse,
ChannelReadingsResponse, CommoditiesResponse, CommodityResponse, DiscoverResponse,
DiscoverChannelResponse,
MeterSourceChannelListResponse, MeterSourceChannelResponse, MeterSourceCreate,
MeterSourceListResponse, MeterSourcePatch, MeterSourceResponse, SourceConfigFieldResponse,
SourceProfileResponse, SourceProfilesResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.meter_sources import (
BindingNotFoundError, ChannelNotFoundError, MeterNotFoundError,
MeterSourceError, SourceDeleteRestrictedError, SourceNotFoundError, create_binding,
create_source, delete_source, list_bindings, list_sources, update_binding, update_source,
)
from app.services import timezone as _tz_mod
from app.services.warmtelink_worker import warmtelink_worker_manager
router = APIRouter(prefix="/api/energy", tags=["api-energy-meter-sources"])
def _reconcile_warmtelink_after_commit() -> None:
"""Runtime convergence is best-effort; the already committed API result wins."""
try:
warmtelink_worker_manager.reconcile()
except Exception:
# The manager records individual source failures itself. Do not turn a
# successful durable create/update/delete into a misleading HTTP 500.
return
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=_tz_mod.local_tz()).astimezone(UTC)
return value.astimezone(UTC)
def _source_or_404(db: Session, uuid: str) -> MeterSource:
source = db.execute(select(MeterSource).where(MeterSource.uuid == uuid)).scalar_one_or_none()
if source is None:
raise HTTPException(status_code=404, detail="Meter source not found.")
return source
def _channel_or_404(db: Session, source: MeterSource, uuid: str) -> MeterSourceChannel:
channel = db.execute(
select(MeterSourceChannel).where(
MeterSourceChannel.uuid == uuid, MeterSourceChannel.source_id == source.id
)
).scalar_one_or_none()
if channel is None:
raise HTTPException(status_code=404, detail="Meter source channel not found.")
return channel
def _source_response(source: MeterSource) -> MeterSourceResponse:
return MeterSourceResponse(
uuid=source.uuid, name=source.name, kind=source.kind, enabled=source.enabled,
config=sanitize_source_config(source.kind, source.config), status=source.status,
last_seen_at=source.last_seen_at, last_error=source.last_error,
created_at=source.created_at, updated_at=source.updated_at,
)
def binding_response(binding: MeterSourceBinding) -> BindingResponse:
return BindingResponse(
uuid=binding.uuid, meter_id=binding.meter_id, source_channel_uuid=binding.channel.uuid,
source_uuid=binding.channel.source.uuid, started_at=binding.started_at, ended_at=binding.ended_at,
created_at=binding.created_at, updated_at=binding.updated_at,
)
def _binding_error(exc: MeterSourceError) -> HTTPException:
if isinstance(exc, (SourceNotFoundError, ChannelNotFoundError, MeterNotFoundError, BindingNotFoundError)):
return HTTPException(status_code=404, detail=str(exc))
return HTTPException(status_code=422, detail=str(exc))
@router.get("/source-profiles", response_model=SourceProfilesResponse)
def source_profiles(_auth: AuthenticatedSession = Depends(require_session)) -> SourceProfilesResponse:
"""Return profile metadata; default secrets are never populated with stored values."""
profiles = []
for profile in list_source_profiles():
fields = [
SourceConfigFieldResponse(name=f.name, value_type=f.value_type.__name__, default=f.default,
required=f.required, secret=f.secret)
for f in profile.fields
]
profiles.append(SourceProfileResponse(
kind=profile.kind, fields=fields,
defaults={f.name: f.default for f in profile.fields if not f.required},
capabilities=sorted(profile.capabilities), allowed_units=sorted(profile.allowed_units),
))
return SourceProfilesResponse(items=profiles)
@router.get("/commodities", response_model=CommoditiesResponse)
def commodities(_auth: AuthenticatedSession = Depends(require_session)) -> CommoditiesResponse:
return CommoditiesResponse(items=[
CommodityResponse(key="electricity", unit="kWh", capabilities=["meter", "binding", "cost"]),
CommodityResponse(key="heating", unit="GJ", capabilities=["meter", "binding"]),
CommodityResponse(key="hot_water", unit="m³", capabilities=["meter", "binding"]),
])
@router.get("/sources", response_model=MeterSourceListResponse)
def get_sources(db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session)) -> MeterSourceListResponse:
items = [_source_response(source) for source in list_sources(db)]
return MeterSourceListResponse(items=items, total=len(items))
@router.post("/sources", response_model=MeterSourceResponse, status_code=status.HTTP_201_CREATED)
def post_source(body: MeterSourceCreate, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> MeterSourceResponse:
try:
source = create_source(db, name=body.name, kind=body.kind, config=body.config, enabled=body.enabled)
db.commit()
db.refresh(source)
_reconcile_warmtelink_after_commit()
return _source_response(source)
except (SourceProfileError, MeterSourceError) as exc:
db.rollback()
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.get("/sources/{source_uuid}", response_model=MeterSourceResponse)
def get_source_detail(source_uuid: str, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session)) -> MeterSourceResponse:
return _source_response(_source_or_404(db, source_uuid))
@router.patch("/sources/{source_uuid}", response_model=MeterSourceResponse)
def patch_source(source_uuid: str, body: MeterSourcePatch, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> MeterSourceResponse:
source = _source_or_404(db, source_uuid)
try:
updated = update_source(db, source.id, name=body.name, enabled=body.enabled, config_patch=body.config)
db.commit()
db.refresh(updated)
_reconcile_warmtelink_after_commit()
return _source_response(updated)
except (SourceProfileError, MeterSourceError) as exc:
db.rollback()
raise _binding_error(exc) from exc
@router.delete(
"/sources/{source_uuid}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
def remove_source(source_uuid: str, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> None:
source = _source_or_404(db, source_uuid)
# DSMR readings are not a relationship on MeterSource to avoid loading a large history.
if db.execute(select(DsmrReading.id).where(DsmrReading.meter_source_id == source.id).limit(1)).scalar() is not None:
raise HTTPException(status_code=409, detail="Meter source has dependent readings.")
try:
delete_source(db, source.id)
db.commit()
_reconcile_warmtelink_after_commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except SourceDeleteRestrictedError as exc:
db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/sources/{source_uuid}/discover", response_model=DiscoverResponse)
def discover_source(source_uuid: str, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> DiscoverResponse:
source = _source_or_404(db, source_uuid)
if source.kind == "warmtelink_serial":
if not source.enabled:
return DiscoverResponse(
requested=False, supported=True, status="error",
detail="The WarmteLink source is disabled.", channels=_discover_channels(db, source),
)
# This merely schedules lifecycle convergence. It never opens a serial
# descriptor or waits for a frame in the request thread; the one managed
# worker remains the sole owner of serial I/O and can keep reconnecting.
request = warmtelink_worker_manager.request_discovery(source.id)
if request.completed.is_set():
# A worker may have accepted a frame during the bounded wait.
# Refresh only durable accepted metadata, never candidates/raw data.
db.expire_all()
source = _source_or_404(db, source_uuid)
return DiscoverResponse(
requested=request.status != "error", supported=True, status=request.status,
request_id=request.request_id or None, detail=request.detail,
channels=_discover_channels(db, source),
)
return DiscoverResponse(requested=False, supported=True, status="managed_by_runtime",
detail="This source is discovered by its runtime subscription; no connection was opened.")
@router.get("/sources/{source_uuid}/channels", response_model=MeterSourceChannelListResponse)
def source_channels(source_uuid: str, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session)) -> MeterSourceChannelListResponse:
source = _source_or_404(db, source_uuid)
channels = db.execute(select(MeterSourceChannel).where(MeterSourceChannel.source_id == source.id)).scalars().all()
items = []
for channel in channels:
bindings = list_bindings(db, channel_id=channel.id)
meter_ids = [binding.meter_id for binding in bindings]
items.append(MeterSourceChannelResponse(
uuid=channel.uuid, label=channel.label, suggested_commodity=channel.suggested_commodity,
unit=channel.unit, device_type=channel.device_type, latest_value=channel.latest_value,
latest_at=channel.latest_at, latest_quality=channel.latest_quality, binding_count=len(bindings),
bound_meter_ids=meter_ids,
binding_summary=ChannelBindingSummaryResponse(count=len(bindings), meter_ids=meter_ids),
))
return MeterSourceChannelListResponse(items=items, total=len(items), source_status=source.status)
@router.get("/sources/{source_uuid}/channels/{channel_uuid}/readings", response_model=ChannelReadingsResponse)
def channel_readings(source_uuid: str, channel_uuid: str, limit: int = Query(default=100, ge=1, le=1000),
from_: datetime | None = Query(default=None, alias="from"),
to: datetime | None = Query(default=None), db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session)) -> ChannelReadingsResponse:
source = _source_or_404(db, source_uuid)
channel = _channel_or_404(db, source, channel_uuid)
if from_ is not None and to is not None and _as_utc(from_) >= _as_utc(to):
raise HTTPException(status_code=422, detail="'from' must be earlier than 'to'.")
if source.kind == "warmtelink_serial":
statement = select(WarmteLinkReading).where(WarmteLinkReading.channel_id == channel.id)
model = WarmteLinkReading
else:
# DSMR remains a source-level protocol history. Its channel is the
# public electricity identity, while payload/telegram diagnostics stay
# private to ingestion and the legacy latest endpoint.
statement = select(DsmrReading).where(DsmrReading.meter_source_id == source.id)
model = DsmrReading
if from_ is not None:
statement = statement.where(model.recorded_at >= _as_utc(from_))
if to is not None:
statement = statement.where(model.recorded_at < _as_utc(to))
rows = list(db.execute(statement.order_by(model.recorded_at.asc()).limit(limit)).scalars())
return ChannelReadingsResponse(
items=[ChannelReadingResponse(
recorded_at=row.recorded_at,
value=getattr(row, "value", None), quality=getattr(row, "quality", None),
) for row in rows],
total=len(rows),
)
def _discover_channels(db: Session, source: MeterSource) -> list[DiscoverChannelResponse]:
"""Return only public, accepted channel metadata for discover responses."""
return [
DiscoverChannelResponse(
uuid=channel.uuid, label=channel.label, unit=channel.unit,
latest_value=channel.latest_value, latest_at=channel.latest_at,
latest_quality=channel.latest_quality,
)
for channel in db.execute(
select(MeterSourceChannel).where(MeterSourceChannel.source_id == source.id)
).scalars()
]
@router.get("/meters/{meter_id}/bindings", response_model=BindingListResponse)
def meter_bindings(meter_id: int, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session)) -> BindingListResponse:
if db.get(Meter, meter_id) is None:
raise HTTPException(status_code=404, detail="Meter not found.")
items = [binding_response(binding) for binding in list_bindings(db, meter_id=meter_id)]
return BindingListResponse(items=items, total=len(items))
@router.post("/meters/{meter_id}/bindings", response_model=BindingResponse, status_code=status.HTTP_201_CREATED)
def post_meter_binding(meter_id: int, body: BindingCreate, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> BindingResponse:
channel = db.execute(select(MeterSourceChannel).where(MeterSourceChannel.uuid == body.source_channel_uuid)).scalar_one_or_none()
if channel is None:
raise HTTPException(status_code=404, detail="Meter source channel not found.")
try:
binding = create_binding(db, meter_id=meter_id, channel_id=channel.id, started_at=_as_utc(body.started_at),
ended_at=_as_utc(body.ended_at) if body.ended_at else None)
db.commit()
db.refresh(binding)
return binding_response(binding)
except MeterSourceError as exc:
db.rollback()
raise _binding_error(exc) from exc
@router.patch("/bindings/{binding_uuid}", response_model=BindingResponse)
def patch_binding(binding_uuid: str, body: BindingPatch, db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> BindingResponse:
binding = db.execute(select(MeterSourceBinding).where(MeterSourceBinding.uuid == binding_uuid)).scalar_one_or_none()
if binding is None:
raise HTTPException(status_code=404, detail="Meter source binding not found.")
try:
# ``ended_at`` has three meaningful states in the service layer: omitted
# keeps the existing boundary, null reopens the interval, and a datetime
# changes the exclusive end. Do not collapse omitted into null here.
changes: dict[str, datetime | None] = {}
if "started_at" in body.model_fields_set:
changes["started_at"] = _as_utc(body.started_at) if body.started_at is not None else None
if "ended_at" in body.model_fields_set:
changes["ended_at"] = _as_utc(body.ended_at) if body.ended_at is not None else None
updated = update_binding(db, binding.id, **changes)
db.commit()
db.refresh(updated)
return binding_response(updated)
except MeterSourceError as exc:
db.rollback()
raise _binding_error(exc) from exc