diff --git a/app/api/routes/api/meter_sources.py b/app/api/routes/api/meter_sources.py new file mode 100644 index 0000000..9f2fea9 --- /dev/null +++ b/app/api/routes/api/meter_sources.py @@ -0,0 +1,259 @@ +"""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 +from app.schemas.meter_source import ( + BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelReadingResponse, + ChannelReadingsResponse, CommoditiesResponse, CommodityResponse, DiscoverResponse, + 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 + +router = APIRouter(prefix="/api/energy", tags=["api-energy-meter-sources"]) + + +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) + 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) + 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() + 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": + return DiscoverResponse(requested=False, supported=False, status="not_implemented", + detail="Serial discovery is available after the WarmteLink worker is installed.") + 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) + 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=[binding.meter_id for binding in bindings], + )) + return MeterSourceChannelListResponse(items=items, total=len(items)) + + +@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), + start: datetime | None = None, end: datetime | None = None, db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session)) -> ChannelReadingsResponse: + source = _source_or_404(db, source_uuid) + _channel_or_404(db, source, channel_uuid) + if source.kind != "dsmr_mqtt": + return ChannelReadingsResponse(items=[], total=0) + statement = select(DsmrReading).where(DsmrReading.meter_source_id == source.id) + if start is not None: + statement = statement.where(DsmrReading.recorded_at >= _as_utc(start)) + if end is not None: + statement = statement.where(DsmrReading.recorded_at < _as_utc(end)) + rows = list(db.execute(statement.order_by(DsmrReading.recorded_at.desc()).limit(limit)).scalars()) + # The DSMR payload remains available only from its legacy compatibility endpoint; + # this generic endpoint intentionally exposes no telegram/equipment identifiers. + return ChannelReadingsResponse(items=[ChannelReadingResponse(recorded_at=row.recorded_at) for row in rows], total=len(rows)) + + +@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 diff --git a/app/api/routes/api/meters.py b/app/api/routes/api/meters.py index 2db6ac0..ff4a1c2 100644 --- a/app/api/routes/api/meters.py +++ b/app/api/routes/api/meters.py @@ -48,16 +48,20 @@ 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 from app.services import timezone as _tz_mod from app.services.auth import AuthenticatedSession from app.services.energy_cost import recompute_range @@ -153,6 +157,22 @@ def _trigger_recompute(db: Session, start: datetime, label: str) -> int: 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 # --------------------------------------------------------------------------- @@ -170,7 +190,7 @@ def list_energy_meters( has the latest ``started_at``. """ meters = list_meters(db) - items = [MeterResponse.model_validate(m) for m in meters] + items = [_meter_response(m) for m in meters] return MeterListResponse(items=items, total=len(items)) @@ -219,14 +239,29 @@ def declare_energy_meter( commodity=body.commodity, note=body.note, ) - except MeterOverlapError as exc: + 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.") + create_binding( + db, + meter_id=new_meter.id, + channel_id=channel.id, + started_at=started_at_utc, + ) + except (MeterOverlapError, MeterSourceError) as exc: + # declare_meter may already have closed the previous epoch. Rolling back + # here makes Meter + binding declaration genuinely atomic. + db.rollback() raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError) + else 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: @@ -247,7 +282,7 @@ def declare_energy_meter( new_meter.label, started_at_utc.isoformat(), ) - return MeterResponse.model_validate(new_meter) + return _meter_response(new_meter) # --------------------------------------------------------------------------- @@ -328,4 +363,4 @@ def patch_energy_meter( meter.label, meter.started_at, ) - return MeterResponse.model_validate(meter) + return _meter_response(meter) diff --git a/app/main.py b/app/main.py index e502d5b..8549fd5 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ 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.expose import router as api_expose_router from app.api.routes.api.meters import router as api_meters_router +from app.api.routes.api.meter_sources import router as api_meter_sources_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 import status @@ -308,6 +309,7 @@ def create_app() -> FastAPI: app.include_router(api_energy_router) app.include_router(api_energy_contracts_router) app.include_router(api_meters_router) + app.include_router(api_meter_sources_router) app.include_router(api_expose_router) app.include_router(api_modbus_router) app.include_router(api_session_router) diff --git a/app/schemas/meter.py b/app/schemas/meter.py index 6a79c37..20de70a 100644 --- a/app/schemas/meter.py +++ b/app/schemas/meter.py @@ -49,10 +49,21 @@ class MeterResponse(BaseModel): reason: str note: str | None created_at: datetime + bindings: list["MeterBindingSummary"] = Field(default_factory=list) model_config = {"from_attributes": True} +class MeterBindingSummary(BaseModel): + """Stable, non-sensitive binding identity embedded in meter responses.""" + + uuid: str + source_channel_uuid: str + source_uuid: str + started_at: datetime + ended_at: datetime | None + + class MeterListResponse(BaseModel): """Response schema for GET /api/energy/meters. @@ -107,6 +118,12 @@ class MeterDeclareRequest(BaseModel): max_length=32, description="Energy commodity this meter measures. Defaults to 'electricity'.", ) + source_channel_uuid: str | None = Field( + default=None, + min_length=1, + max_length=36, + description="Optional compatible source channel to bind atomically to this meter.", + ) class MeterPatchRequest(BaseModel): diff --git a/app/schemas/meter_source.py b/app/schemas/meter_source.py new file mode 100644 index 0000000..9426d2a --- /dev/null +++ b/app/schemas/meter_source.py @@ -0,0 +1,133 @@ +"""Public schemas for protocol-agnostic meter sources and bindings.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, Field + + +class SourceConfigFieldResponse(BaseModel): + name: str + value_type: str + default: Any = None + required: bool + secret: bool + + +class SourceProfileResponse(BaseModel): + kind: str + fields: list[SourceConfigFieldResponse] + defaults: dict[str, Any] + capabilities: list[str] + allowed_units: list[str] + + +class SourceProfilesResponse(BaseModel): + items: list[SourceProfileResponse] + + +class MeterSourceCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + kind: str = Field(..., min_length=1, max_length=64) + config: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True + + +class MeterSourcePatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + config: dict[str, Any] | None = None + enabled: bool | None = None + + +class MeterSourceResponse(BaseModel): + uuid: str + name: str + kind: str + enabled: bool + config: dict[str, Any] + status: str + last_seen_at: datetime | None + last_error: str | None + created_at: datetime + updated_at: datetime + + +class MeterSourceListResponse(BaseModel): + items: list[MeterSourceResponse] + total: int + + +class DiscoverResponse(BaseModel): + requested: bool + supported: bool + status: str + detail: str | None = None + + +class CommodityResponse(BaseModel): + key: str + unit: str + capabilities: list[str] + + +class CommoditiesResponse(BaseModel): + items: list[CommodityResponse] + + +class MeterSourceChannelResponse(BaseModel): + uuid: str + label: str + suggested_commodity: str | None + unit: str + device_type: str | None + latest_value: Decimal | None + latest_at: datetime | None + latest_quality: str | None + binding_count: int + bound_meter_ids: list[int] + + +class MeterSourceChannelListResponse(BaseModel): + items: list[MeterSourceChannelResponse] + total: int + + +class ChannelReadingResponse(BaseModel): + recorded_at: datetime + value: Decimal | None = None + quality: str | None = None + + +class ChannelReadingsResponse(BaseModel): + items: list[ChannelReadingResponse] + total: int + + +class BindingCreate(BaseModel): + source_channel_uuid: str = Field(..., min_length=1, max_length=36) + started_at: datetime + ended_at: datetime | None = None + + +class BindingPatch(BaseModel): + started_at: datetime | None = None + ended_at: datetime | None = None + + +class BindingResponse(BaseModel): + uuid: str + meter_id: int + source_channel_uuid: str + source_uuid: str + started_at: datetime + ended_at: datetime | None + created_at: datetime + updated_at: datetime + + +class BindingListResponse(BaseModel): + items: list[BindingResponse] + total: int diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index de0610e..7d504fe 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -499,7 +499,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 ### M8-T06 — Source / Channel / Binding HTTP 契约 [structural] -- **Status**: `todo` +- **Status**: `done` - **Depends**: M8-T05 - **Context**: 在基础服务与 DSMR 兼容稳定后,提供 §6 的管理接口及原子 Meter+binding 入口。 diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index bc044cf..c3b057c 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -638,6 +638,166 @@ export interface paths { patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"]; trace?: never; }; + "/api/energy/source-profiles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Source Profiles + * @description Return profile metadata; default secrets are never populated with stored values. + */ + get: operations["source_profiles_api_energy_source_profiles_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/commodities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Commodities */ + get: operations["commodities_api_energy_commodities_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/sources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Sources */ + get: operations["get_sources_api_energy_sources_get"]; + put?: never; + /** Post Source */ + post: operations["post_source_api_energy_sources_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/sources/{source_uuid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Source Detail */ + get: operations["get_source_detail_api_energy_sources__source_uuid__get"]; + put?: never; + post?: never; + /** Remove Source */ + delete: operations["remove_source_api_energy_sources__source_uuid__delete"]; + options?: never; + head?: never; + /** Patch Source */ + patch: operations["patch_source_api_energy_sources__source_uuid__patch"]; + trace?: never; + }; + "/api/energy/sources/{source_uuid}/discover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Discover Source */ + post: operations["discover_source_api_energy_sources__source_uuid__discover_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/sources/{source_uuid}/channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Source Channels */ + get: operations["source_channels_api_energy_sources__source_uuid__channels_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Channel Readings */ + get: operations["channel_readings_api_energy_sources__source_uuid__channels__channel_uuid__readings_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/meters/{meter_id}/bindings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Meter Bindings */ + get: operations["meter_bindings_api_energy_meters__meter_id__bindings_get"]; + put?: never; + /** Post Meter Binding */ + post: operations["post_meter_binding_api_energy_meters__meter_id__bindings_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/bindings/{binding_uuid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch Binding */ + patch: operations["patch_binding_api_energy_bindings__binding_uuid__patch"]; + trace?: never; + }; "/api/expose": { parameters: { query?: never; @@ -1230,6 +1390,60 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** BindingCreate */ + BindingCreate: { + /** Source Channel Uuid */ + source_channel_uuid: string; + /** + * Started At + * Format: date-time + */ + started_at: string; + /** Ended At */ + ended_at?: string | null; + }; + /** BindingListResponse */ + BindingListResponse: { + /** Items */ + items: components["schemas"]["BindingResponse"][]; + /** Total */ + total: number; + }; + /** BindingPatch */ + BindingPatch: { + /** Started At */ + started_at?: string | null; + /** Ended At */ + ended_at?: string | null; + }; + /** BindingResponse */ + BindingResponse: { + /** Uuid */ + uuid: string; + /** Meter Id */ + meter_id: number; + /** Source Channel Uuid */ + source_channel_uuid: string; + /** Source Uuid */ + source_uuid: string; + /** + * Started At + * Format: date-time + */ + started_at: string; + /** Ended At */ + ended_at: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; /** * CatalogEntrySchema * @description An entity from the catalog with its current toggle state. @@ -1239,6 +1453,39 @@ export interface components { /** Enabled */ enabled: boolean; }; + /** ChannelReadingResponse */ + ChannelReadingResponse: { + /** + * Recorded At + * Format: date-time + */ + recorded_at: string; + /** Value */ + value?: string | null; + /** Quality */ + quality?: string | null; + }; + /** ChannelReadingsResponse */ + ChannelReadingsResponse: { + /** Items */ + items: components["schemas"]["ChannelReadingResponse"][]; + /** Total */ + total: number; + }; + /** CommoditiesResponse */ + CommoditiesResponse: { + /** Items */ + items: components["schemas"]["CommodityResponse"][]; + }; + /** CommodityResponse */ + CommodityResponse: { + /** Key */ + key: string; + /** Unit */ + unit: string; + /** Capabilities */ + capabilities: string[]; + }; /** ConfigField */ ConfigField: { /** Env Name */ @@ -1506,6 +1753,17 @@ export interface components { /** Name */ name: string; }; + /** DiscoverResponse */ + DiscoverResponse: { + /** Requested */ + requested: boolean; + /** Supported */ + supported: boolean; + /** Status */ + status: string; + /** Detail */ + detail?: string | null; + }; /** * DsmrLatestResponse * @description Response for GET /api/energy/dsmr/latest. @@ -1653,6 +1911,25 @@ export interface components { */ sell_normal: number; }; + /** + * MeterBindingSummary + * @description Stable, non-sensitive binding identity embedded in meter responses. + */ + MeterBindingSummary: { + /** Uuid */ + uuid: string; + /** Source Channel Uuid */ + source_channel_uuid: string; + /** Source Uuid */ + source_uuid: string; + /** + * Started At + * Format: date-time + */ + started_at: string; + /** Ended At */ + ended_at: string | null; + }; /** * MeterDeclareRequest * @description Request body for POST /api/energy/meters. @@ -1689,6 +1966,11 @@ export interface components { * @default electricity */ commodity: string; + /** + * Source Channel Uuid + * @description Optional compatible source channel to bind atomically to this meter. + */ + source_channel_uuid?: string | null; }; /** * MeterListResponse @@ -1760,6 +2042,103 @@ export interface components { * Format: date-time */ created_at: string; + /** Bindings */ + bindings?: components["schemas"]["MeterBindingSummary"][]; + }; + /** MeterSourceChannelListResponse */ + MeterSourceChannelListResponse: { + /** Items */ + items: components["schemas"]["MeterSourceChannelResponse"][]; + /** Total */ + total: number; + }; + /** MeterSourceChannelResponse */ + MeterSourceChannelResponse: { + /** Uuid */ + uuid: string; + /** Label */ + label: string; + /** Suggested Commodity */ + suggested_commodity: string | null; + /** Unit */ + unit: string; + /** Device Type */ + device_type: string | null; + /** Latest Value */ + latest_value: string | null; + /** Latest At */ + latest_at: string | null; + /** Latest Quality */ + latest_quality: string | null; + /** Binding Count */ + binding_count: number; + /** Bound Meter Ids */ + bound_meter_ids: number[]; + }; + /** MeterSourceCreate */ + MeterSourceCreate: { + /** Name */ + name: string; + /** Kind */ + kind: string; + /** Config */ + config?: { + [key: string]: unknown; + }; + /** + * Enabled + * @default true + */ + enabled: boolean; + }; + /** MeterSourceListResponse */ + MeterSourceListResponse: { + /** Items */ + items: components["schemas"]["MeterSourceResponse"][]; + /** Total */ + total: number; + }; + /** MeterSourcePatch */ + MeterSourcePatch: { + /** Name */ + name?: string | null; + /** Config */ + config?: { + [key: string]: unknown; + } | null; + /** Enabled */ + enabled?: boolean | null; + }; + /** MeterSourceResponse */ + MeterSourceResponse: { + /** Uuid */ + uuid: string; + /** Name */ + name: string; + /** Kind */ + kind: string; + /** Enabled */ + enabled: boolean; + /** Config */ + config: { + [key: string]: unknown; + }; + /** Status */ + status: string; + /** Last Seen At */ + last_seen_at: string | null; + /** Last Error */ + last_error: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; }; /** * MetricInfo @@ -2216,6 +2595,39 @@ export interface components { /** Message */ message: string; }; + /** SourceConfigFieldResponse */ + SourceConfigFieldResponse: { + /** Name */ + name: string; + /** Value Type */ + value_type: string; + /** Default */ + default?: unknown; + /** Required */ + required: boolean; + /** Secret */ + secret: boolean; + }; + /** SourceProfileResponse */ + SourceProfileResponse: { + /** Kind */ + kind: string; + /** Fields */ + fields: components["schemas"]["SourceConfigFieldResponse"][]; + /** Defaults */ + defaults: { + [key: string]: unknown; + }; + /** Capabilities */ + capabilities: string[]; + /** Allowed Units */ + allowed_units: string[]; + }; + /** SourceProfilesResponse */ + SourceProfilesResponse: { + /** Items */ + items: components["schemas"]["SourceProfileResponse"][]; + }; /** StatusResponse */ StatusResponse: { /** Status */ @@ -3303,6 +3715,405 @@ export interface operations { }; }; }; + source_profiles_api_energy_source_profiles_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"]["SourceProfilesResponse"]; + }; + }; + }; + }; + commodities_api_energy_commodities_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"]["CommoditiesResponse"]; + }; + }; + }; + }; + get_sources_api_energy_sources_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"]["MeterSourceListResponse"]; + }; + }; + }; + }; + post_source_api_energy_sources_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeterSourceCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterSourceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_source_detail_api_energy_sources__source_uuid__get: { + parameters: { + query?: never; + header?: never; + path: { + source_uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterSourceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + remove_source_api_energy_sources__source_uuid__delete: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + source_uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_source_api_energy_sources__source_uuid__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + source_uuid: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeterSourcePatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterSourceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + discover_source_api_energy_sources__source_uuid__discover_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + source_uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiscoverResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + source_channels_api_energy_sources__source_uuid__channels_get: { + parameters: { + query?: never; + header?: never; + path: { + source_uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterSourceChannelListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + channel_readings_api_energy_sources__source_uuid__channels__channel_uuid__readings_get: { + parameters: { + query?: { + limit?: number; + start?: string | null; + end?: string | null; + }; + header?: never; + path: { + source_uuid: string; + channel_uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChannelReadingsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + meter_bindings_api_energy_meters__meter_id__bindings_get: { + parameters: { + query?: never; + header?: never; + path: { + meter_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BindingListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_meter_binding_api_energy_meters__meter_id__bindings_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + meter_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BindingCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BindingResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_binding_api_energy_bindings__binding_uuid__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + binding_uuid: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BindingPatch"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BindingResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_expose_api_expose_get: { parameters: { query?: never; diff --git a/openapi/openapi.json b/openapi/openapi.json index 790f178..7f722d5 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1528,6 +1528,657 @@ } } }, + "/api/energy/source-profiles": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Source Profiles", + "description": "Return profile metadata; default secrets are never populated with stored values.", + "operationId": "source_profiles_api_energy_source_profiles_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceProfilesResponse" + } + } + } + } + } + } + }, + "/api/energy/commodities": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Commodities", + "operationId": "commodities_api_energy_commodities_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommoditiesResponse" + } + } + } + } + } + } + }, + "/api/energy/sources": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Get Sources", + "operationId": "get_sources_api_energy_sources_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterSourceListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Post Source", + "operationId": "post_source_api_energy_sources_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/MeterSourceCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterSourceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/sources/{source_uuid}": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Get Source Detail", + "operationId": "get_source_detail_api_energy_sources__source_uuid__get", + "parameters": [ + { + "name": "source_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterSourceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Patch Source", + "operationId": "patch_source_api_energy_sources__source_uuid__patch", + "parameters": [ + { + "name": "source_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Uuid" + } + }, + { + "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/MeterSourcePatch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterSourceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Remove Source", + "operationId": "remove_source_api_energy_sources__source_uuid__delete", + "parameters": [ + { + "name": "source_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Uuid" + } + }, + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/sources/{source_uuid}/discover": { + "post": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Discover Source", + "operationId": "discover_source_api_energy_sources__source_uuid__discover_post", + "parameters": [ + { + "name": "source_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Uuid" + } + }, + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/sources/{source_uuid}/channels": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Source Channels", + "operationId": "source_channels_api_energy_sources__source_uuid__channels_get", + "parameters": [ + { + "name": "source_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterSourceChannelListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Channel Readings", + "operationId": "channel_readings_api_energy_sources__source_uuid__channels__channel_uuid__readings_get", + "parameters": [ + { + "name": "source_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Uuid" + } + }, + { + "name": "channel_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Channel Uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelReadingsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/meters/{meter_id}/bindings": { + "get": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Meter Bindings", + "operationId": "meter_bindings_api_energy_meters__meter_id__bindings_get", + "parameters": [ + { + "name": "meter_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meter Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindingListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Post Meter Binding", + "operationId": "post_meter_binding_api_energy_meters__meter_id__bindings_post", + "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/BindingCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindingResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/bindings/{binding_uuid}": { + "patch": { + "tags": [ + "api-energy-meter-sources" + ], + "summary": "Patch Binding", + "operationId": "patch_binding_api_energy_bindings__binding_uuid__patch", + "parameters": [ + { + "name": "binding_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Binding Uuid" + } + }, + { + "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/BindingPatch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindingResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/expose": { "get": { "tags": [ @@ -2650,6 +3301,149 @@ }, "components": { "schemas": { + "BindingCreate": { + "properties": { + "source_channel_uuid": { + "type": "string", + "maxLength": 36, + "minLength": 1, + "title": "Source Channel Uuid" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Ended At" + } + }, + "type": "object", + "required": [ + "source_channel_uuid", + "started_at" + ], + "title": "BindingCreate" + }, + "BindingListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/BindingResponse" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "BindingListResponse" + }, + "BindingPatch": { + "properties": { + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Ended At" + } + }, + "type": "object", + "title": "BindingPatch" + }, + "BindingResponse": { + "properties": { + "uuid": { + "type": "string", + "title": "Uuid" + }, + "meter_id": { + "type": "integer", + "title": "Meter Id" + }, + "source_channel_uuid": { + "type": "string", + "title": "Source Channel Uuid" + }, + "source_uuid": { + "type": "string", + "title": "Source Uuid" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Ended At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "uuid", + "meter_id", + "source_channel_uuid", + "source_uuid", + "started_at", + "ended_at", + "created_at", + "updated_at" + ], + "title": "BindingResponse" + }, "CatalogEntrySchema": { "properties": { "entity": { @@ -2668,6 +3462,106 @@ "title": "CatalogEntrySchema", "description": "An entity from the catalog with its current toggle state." }, + "ChannelReadingResponse": { + "properties": { + "recorded_at": { + "type": "string", + "format": "date-time", + "title": "Recorded At" + }, + "value": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Value" + }, + "quality": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Quality" + } + }, + "type": "object", + "required": [ + "recorded_at" + ], + "title": "ChannelReadingResponse" + }, + "ChannelReadingsResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ChannelReadingResponse" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "ChannelReadingsResponse" + }, + "CommoditiesResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CommodityResponse" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "CommoditiesResponse" + }, + "CommodityResponse": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Capabilities" + } + }, + "type": "object", + "required": [ + "key", + "unit", + "capabilities" + ], + "title": "CommodityResponse" + }, "ConfigField": { "properties": { "env_name": { @@ -3161,6 +4055,40 @@ "title": "DeviceInfoSchema", "description": "HA device grouping info for an exposable entity." }, + "DiscoverResponse": { + "properties": { + "requested": { + "type": "boolean", + "title": "Requested" + }, + "supported": { + "type": "boolean", + "title": "Supported" + }, + "status": { + "type": "string", + "title": "Status" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + } + }, + "type": "object", + "required": [ + "requested", + "supported", + "status" + ], + "title": "DiscoverResponse" + }, "DsmrLatestResponse": { "properties": { "found": { @@ -3495,6 +4423,49 @@ "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." }, + "MeterBindingSummary": { + "properties": { + "uuid": { + "type": "string", + "title": "Uuid" + }, + "source_channel_uuid": { + "type": "string", + "title": "Source Channel Uuid" + }, + "source_uuid": { + "type": "string", + "title": "Source Uuid" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Ended At" + } + }, + "type": "object", + "required": [ + "uuid", + "source_channel_uuid", + "source_uuid", + "started_at", + "ended_at" + ], + "title": "MeterBindingSummary", + "description": "Stable, non-sensitive binding identity embedded in meter responses." + }, "MeterDeclareRequest": { "properties": { "label": { @@ -3532,6 +4503,20 @@ "title": "Commodity", "description": "Energy commodity this meter measures. Defaults to 'electricity'.", "default": "electricity" + }, + "source_channel_uuid": { + "anyOf": [ + { + "type": "string", + "maxLength": 36, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Source Channel Uuid", + "description": "Optional compatible source channel to bind atomically to this meter." } }, "type": "object", @@ -3671,6 +4656,13 @@ "type": "string", "format": "date-time", "title": "Created At" + }, + "bindings": { + "items": { + "$ref": "#/components/schemas/MeterBindingSummary" + }, + "type": "array", + "title": "Bindings" } }, "type": "object", @@ -3687,6 +4679,296 @@ "title": "MeterResponse", "description": "Response schema for a single Meter epoch row.\n\n``ended_at`` is ``null`` for the currently active meter." }, + "MeterSourceChannelListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MeterSourceChannelResponse" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "MeterSourceChannelListResponse" + }, + "MeterSourceChannelResponse": { + "properties": { + "uuid": { + "type": "string", + "title": "Uuid" + }, + "label": { + "type": "string", + "title": "Label" + }, + "suggested_commodity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Suggested Commodity" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "device_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Type" + }, + "latest_value": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Latest Value" + }, + "latest_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Latest At" + }, + "latest_quality": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Latest Quality" + }, + "binding_count": { + "type": "integer", + "title": "Binding Count" + }, + "bound_meter_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Bound Meter Ids" + } + }, + "type": "object", + "required": [ + "uuid", + "label", + "suggested_commodity", + "unit", + "device_type", + "latest_value", + "latest_at", + "latest_quality", + "binding_count", + "bound_meter_ids" + ], + "title": "MeterSourceChannelResponse" + }, + "MeterSourceCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Kind" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + } + }, + "type": "object", + "required": [ + "name", + "kind" + ], + "title": "MeterSourceCreate" + }, + "MeterSourceListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MeterSourceResponse" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "MeterSourceListResponse" + }, + "MeterSourcePatch": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + } + }, + "type": "object", + "title": "MeterSourcePatch" + }, + "MeterSourceResponse": { + "properties": { + "uuid": { + "type": "string", + "title": "Uuid" + }, + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "status": { + "type": "string", + "title": "Status" + }, + "last_seen_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Seen At" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "uuid", + "name", + "kind", + "enabled", + "config", + "status", + "last_seen_at", + "last_error", + "created_at", + "updated_at" + ], + "title": "MeterSourceResponse" + }, "MetricInfo": { "properties": { "key": { @@ -4717,6 +5999,96 @@ "title": "SmtpTestResponse", "description": "Response from POST /api/config/smtp/test." }, + "SourceConfigFieldResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "value_type": { + "type": "string", + "title": "Value Type" + }, + "default": { + "title": "Default" + }, + "required": { + "type": "boolean", + "title": "Required" + }, + "secret": { + "type": "boolean", + "title": "Secret" + } + }, + "type": "object", + "required": [ + "name", + "value_type", + "required", + "secret" + ], + "title": "SourceConfigFieldResponse" + }, + "SourceProfileResponse": { + "properties": { + "kind": { + "type": "string", + "title": "Kind" + }, + "fields": { + "items": { + "$ref": "#/components/schemas/SourceConfigFieldResponse" + }, + "type": "array", + "title": "Fields" + }, + "defaults": { + "additionalProperties": true, + "type": "object", + "title": "Defaults" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Capabilities" + }, + "allowed_units": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Allowed Units" + } + }, + "type": "object", + "required": [ + "kind", + "fields", + "defaults", + "capabilities", + "allowed_units" + ], + "title": "SourceProfileResponse" + }, + "SourceProfilesResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SourceProfileResponse" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "SourceProfilesResponse" + }, "StatusResponse": { "properties": { "status": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9ff53d9..aec1e8e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1211,6 +1211,398 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/energy/source-profiles: + get: + tags: + - api-energy-meter-sources + summary: Source Profiles + description: Return profile metadata; default secrets are never populated with + stored values. + operationId: source_profiles_api_energy_source_profiles_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SourceProfilesResponse' + /api/energy/commodities: + get: + tags: + - api-energy-meter-sources + summary: Commodities + operationId: commodities_api_energy_commodities_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CommoditiesResponse' + /api/energy/sources: + get: + tags: + - api-energy-meter-sources + summary: Get Sources + operationId: get_sources_api_energy_sources_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterSourceListResponse' + post: + tags: + - api-energy-meter-sources + summary: Post Source + operationId: post_source_api_energy_sources_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/MeterSourceCreate' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterSourceResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/sources/{source_uuid}: + get: + tags: + - api-energy-meter-sources + summary: Get Source Detail + operationId: get_source_detail_api_energy_sources__source_uuid__get + parameters: + - name: source_uuid + in: path + required: true + schema: + type: string + title: Source Uuid + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterSourceResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + patch: + tags: + - api-energy-meter-sources + summary: Patch Source + operationId: patch_source_api_energy_sources__source_uuid__patch + parameters: + - name: source_uuid + in: path + required: true + schema: + type: string + title: Source Uuid + - 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/MeterSourcePatch' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterSourceResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - api-energy-meter-sources + summary: Remove Source + operationId: remove_source_api_energy_sources__source_uuid__delete + parameters: + - name: source_uuid + in: path + required: true + schema: + type: string + title: Source Uuid + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/sources/{source_uuid}/discover: + post: + tags: + - api-energy-meter-sources + summary: Discover Source + operationId: discover_source_api_energy_sources__source_uuid__discover_post + parameters: + - name: source_uuid + in: path + required: true + schema: + type: string + title: Source Uuid + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/sources/{source_uuid}/channels: + get: + tags: + - api-energy-meter-sources + summary: Source Channels + operationId: source_channels_api_energy_sources__source_uuid__channels_get + parameters: + - name: source_uuid + in: path + required: true + schema: + type: string + title: Source Uuid + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterSourceChannelListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings: + get: + tags: + - api-energy-meter-sources + summary: Channel Readings + operationId: channel_readings_api_energy_sources__source_uuid__channels__channel_uuid__readings_get + parameters: + - name: source_uuid + in: path + required: true + schema: + type: string + title: Source Uuid + - name: channel_uuid + in: path + required: true + schema: + type: string + title: Channel Uuid + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: start + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start + - name: end + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ChannelReadingsResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/meters/{meter_id}/bindings: + get: + tags: + - api-energy-meter-sources + summary: Meter Bindings + operationId: meter_bindings_api_energy_meters__meter_id__bindings_get + parameters: + - name: meter_id + in: path + required: true + schema: + type: integer + title: Meter Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BindingListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - api-energy-meter-sources + summary: Post Meter Binding + operationId: post_meter_binding_api_energy_meters__meter_id__bindings_post + 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/BindingCreate' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BindingResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/bindings/{binding_uuid}: + patch: + tags: + - api-energy-meter-sources + summary: Patch Binding + operationId: patch_binding_api_energy_bindings__binding_uuid__patch + parameters: + - name: binding_uuid + in: path + required: true + schema: + type: string + title: Binding Uuid + - 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/BindingPatch' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BindingResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/expose: get: tags: @@ -2096,6 +2488,102 @@ paths: schema: {} components: schemas: + BindingCreate: + properties: + source_channel_uuid: + type: string + maxLength: 36 + minLength: 1 + title: Source Channel Uuid + started_at: + type: string + format: date-time + title: Started At + ended_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Ended At + type: object + required: + - source_channel_uuid + - started_at + title: BindingCreate + BindingListResponse: + properties: + items: + items: + $ref: '#/components/schemas/BindingResponse' + type: array + title: Items + total: + type: integer + title: Total + type: object + required: + - items + - total + title: BindingListResponse + BindingPatch: + properties: + started_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Started At + ended_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Ended At + type: object + title: BindingPatch + BindingResponse: + properties: + uuid: + type: string + title: Uuid + meter_id: + type: integer + title: Meter Id + source_channel_uuid: + type: string + title: Source Channel Uuid + source_uuid: + type: string + title: Source Uuid + started_at: + type: string + format: date-time + title: Started At + ended_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Ended At + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + type: object + required: + - uuid + - meter_id + - source_channel_uuid + - source_uuid + - started_at + - ended_at + - created_at + - updated_at + title: BindingResponse CatalogEntrySchema: properties: entity: @@ -2109,6 +2597,72 @@ components: - enabled title: CatalogEntrySchema description: An entity from the catalog with its current toggle state. + ChannelReadingResponse: + properties: + recorded_at: + type: string + format: date-time + title: Recorded At + value: + anyOf: + - type: string + pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$ + - type: 'null' + title: Value + quality: + anyOf: + - type: string + - type: 'null' + title: Quality + type: object + required: + - recorded_at + title: ChannelReadingResponse + ChannelReadingsResponse: + properties: + items: + items: + $ref: '#/components/schemas/ChannelReadingResponse' + type: array + title: Items + total: + type: integer + title: Total + type: object + required: + - items + - total + title: ChannelReadingsResponse + CommoditiesResponse: + properties: + items: + items: + $ref: '#/components/schemas/CommodityResponse' + type: array + title: Items + type: object + required: + - items + title: CommoditiesResponse + CommodityResponse: + properties: + key: + type: string + title: Key + unit: + type: string + title: Unit + capabilities: + items: + type: string + type: array + title: Capabilities + type: object + required: + - key + - unit + - capabilities + title: CommodityResponse ConfigField: properties: env_name: @@ -2496,6 +3050,28 @@ components: - name title: DeviceInfoSchema description: HA device grouping info for an exposable entity. + DiscoverResponse: + properties: + requested: + type: boolean + title: Requested + supported: + type: boolean + title: Supported + status: + type: string + title: Status + detail: + anyOf: + - type: string + - type: 'null' + title: Detail + type: object + required: + - requested + - supported + - status + title: DiscoverResponse DsmrLatestResponse: properties: found: @@ -2736,6 +3312,36 @@ components: Prices are the *effective* buy prices as used by the billing engine (energy_buy_x + energy_tax + ode) and the raw sell prices.' + MeterBindingSummary: + properties: + uuid: + type: string + title: Uuid + source_channel_uuid: + type: string + title: Source Channel Uuid + source_uuid: + type: string + title: Source Uuid + started_at: + type: string + format: date-time + title: Started At + ended_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Ended At + type: object + required: + - uuid + - source_channel_uuid + - source_uuid + - started_at + - ended_at + title: MeterBindingSummary + description: Stable, non-sensitive binding identity embedded in meter responses. MeterDeclareRequest: properties: label: @@ -2766,6 +3372,15 @@ components: title: Commodity description: Energy commodity this meter measures. Defaults to 'electricity'. default: electricity + source_channel_uuid: + anyOf: + - type: string + maxLength: 36 + minLength: 1 + - type: 'null' + title: Source Channel Uuid + description: Optional compatible source channel to bind atomically to this + meter. type: object required: - label @@ -2897,6 +3512,11 @@ components: type: string format: date-time title: Created At + bindings: + items: + $ref: '#/components/schemas/MeterBindingSummary' + type: array + title: Bindings type: object required: - id @@ -2912,6 +3532,195 @@ components: ``ended_at`` is ``null`` for the currently active meter.' + MeterSourceChannelListResponse: + properties: + items: + items: + $ref: '#/components/schemas/MeterSourceChannelResponse' + type: array + title: Items + total: + type: integer + title: Total + type: object + required: + - items + - total + title: MeterSourceChannelListResponse + MeterSourceChannelResponse: + properties: + uuid: + type: string + title: Uuid + label: + type: string + title: Label + suggested_commodity: + anyOf: + - type: string + - type: 'null' + title: Suggested Commodity + unit: + type: string + title: Unit + device_type: + anyOf: + - type: string + - type: 'null' + title: Device Type + latest_value: + anyOf: + - type: string + pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$ + - type: 'null' + title: Latest Value + latest_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Latest At + latest_quality: + anyOf: + - type: string + - type: 'null' + title: Latest Quality + binding_count: + type: integer + title: Binding Count + bound_meter_ids: + items: + type: integer + type: array + title: Bound Meter Ids + type: object + required: + - uuid + - label + - suggested_commodity + - unit + - device_type + - latest_value + - latest_at + - latest_quality + - binding_count + - bound_meter_ids + title: MeterSourceChannelResponse + MeterSourceCreate: + properties: + name: + type: string + maxLength: 255 + minLength: 1 + title: Name + kind: + type: string + maxLength: 64 + minLength: 1 + title: Kind + config: + additionalProperties: true + type: object + title: Config + enabled: + type: boolean + title: Enabled + default: true + type: object + required: + - name + - kind + title: MeterSourceCreate + MeterSourceListResponse: + properties: + items: + items: + $ref: '#/components/schemas/MeterSourceResponse' + type: array + title: Items + total: + type: integer + title: Total + type: object + required: + - items + - total + title: MeterSourceListResponse + MeterSourcePatch: + properties: + name: + anyOf: + - type: string + maxLength: 255 + minLength: 1 + - type: 'null' + title: Name + config: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Config + enabled: + anyOf: + - type: boolean + - type: 'null' + title: Enabled + type: object + title: MeterSourcePatch + MeterSourceResponse: + properties: + uuid: + type: string + title: Uuid + name: + type: string + title: Name + kind: + type: string + title: Kind + enabled: + type: boolean + title: Enabled + config: + additionalProperties: true + type: object + title: Config + status: + type: string + title: Status + last_seen_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Seen At + last_error: + anyOf: + - type: string + - type: 'null' + title: Last Error + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + type: object + required: + - uuid + - name + - kind + - enabled + - config + - status + - last_seen_at + - last_error + - created_at + - updated_at + title: MeterSourceResponse MetricInfo: properties: key: @@ -3649,6 +4458,72 @@ components: - message title: SmtpTestResponse description: Response from POST /api/config/smtp/test. + SourceConfigFieldResponse: + properties: + name: + type: string + title: Name + value_type: + type: string + title: Value Type + default: + title: Default + required: + type: boolean + title: Required + secret: + type: boolean + title: Secret + type: object + required: + - name + - value_type + - required + - secret + title: SourceConfigFieldResponse + SourceProfileResponse: + properties: + kind: + type: string + title: Kind + fields: + items: + $ref: '#/components/schemas/SourceConfigFieldResponse' + type: array + title: Fields + defaults: + additionalProperties: true + type: object + title: Defaults + capabilities: + items: + type: string + type: array + title: Capabilities + allowed_units: + items: + type: string + type: array + title: Allowed Units + type: object + required: + - kind + - fields + - defaults + - capabilities + - allowed_units + title: SourceProfileResponse + SourceProfilesResponse: + properties: + items: + items: + $ref: '#/components/schemas/SourceProfileResponse' + type: array + title: Items + type: object + required: + - items + title: SourceProfilesResponse StatusResponse: properties: status: diff --git a/tests/test_meter_source_api.py b/tests/test_meter_source_api.py new file mode 100644 index 0000000..2cd2148 --- /dev/null +++ b/tests/test_meter_source_api.py @@ -0,0 +1,345 @@ +"""Contract tests for M8 source/channel/binding management routes.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import patch + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from app.models.energy import DsmrReading, Meter +from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel + +_CSRF = "test-csrf-token" + + +def _login(client: TestClient) -> None: + assert client.post("/api/auth/login", json={"username": "admin", "password": "test-password"}).status_code == 200 + + +def _client(auth_database): + from app.main import create_app + + engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False}) + return TestClient(create_app()), engine + + +def _create_source(client: TestClient, *, config: dict | None = None) -> dict: + response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ + "name": "Synthetic DSMR", "kind": "dsmr_mqtt", "config": config or {}, + }) + assert response.status_code == 201 + return response.json() + + +def _add_channel(engine, source_uuid: str, *, key: str = "electricity") -> str: + with Session(engine) as session: + source = session.execute(select(MeterSource).where(MeterSource.uuid == source_uuid)).scalar_one() + now = datetime.now(UTC) + channel = MeterSourceChannel( + source_id=source.id, channel_key=key, label="Electricity", unit="kWh", + created_at=now, updated_at=now, + ) + session.add(channel) + session.commit() + return channel.uuid + + +def test_source_profiles_and_crud_mask_secrets(auth_database): + client, engine = _client(auth_database) + with client: + assert client.get("/api/energy/source-profiles").status_code == 401 + _login(client) + profiles = client.get("/api/energy/source-profiles") + assert profiles.status_code == 200 + assert {item["kind"] for item in profiles.json()["items"]} == {"dsmr_mqtt", "warmtelink_serial"} + + create = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ + "name": "Synthetic DSMR", "kind": "dsmr_mqtt", + "config": {"username": "user", "password": "not-for-api"}, + }) + assert create.status_code == 201 + source = create.json() + assert source["config"]["username"] == "" + assert source["config"]["password"] == "" + assert "not-for-api" not in str(source) + + patch = client.patch(f"/api/energy/sources/{source['uuid']}", headers={"X-CSRF-Token": _CSRF}, json={"config": {"password": ""}}) + assert patch.status_code == 200 + assert patch.json()["config"]["password"] == "" + with Session(engine) as session: + stored = session.execute( + select(MeterSource).where(MeterSource.uuid == source["uuid"]) + ).scalar_one() + assert stored.config["password"] == "not-for-api" + assert client.post(f"/api/energy/sources/{source['uuid']}/discover", headers={"X-CSRF-Token": _CSRF}).status_code == 200 + assert client.delete(f"/api/energy/sources/{source['uuid']}", headers={"X-CSRF-Token": _CSRF}).status_code == 204 + engine.dispose() + + +def test_binding_routes_and_atomic_meter_declaration(auth_database): + client, engine = _client(auth_database) + with client: + _login(client) + source_response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ + "name": "Synthetic DSMR", "kind": "dsmr_mqtt", "config": {}, + }) + source_uuid = source_response.json()["uuid"] + with Session(engine) as session: + source = session.query(MeterSource).filter_by(uuid=source_uuid).one() + now = datetime.now(UTC) + channel = MeterSourceChannel( + source_id=source.id, channel_key="electricity", label="Electricity", unit="kWh", + created_at=now, updated_at=now, + ) + session.add(channel) + session.commit() + channel_uuid = channel.uuid + + declaration = { + "label": "Bound meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", + "commodity": "electricity", "source_channel_uuid": channel_uuid, + } + created = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=declaration) + assert created.status_code == 201 + assert created.json()["bindings"][0]["source_channel_uuid"] == channel_uuid + meter_id = created.json()["id"] + assert client.get(f"/api/energy/meters/{meter_id}/bindings").json()["total"] == 1 + assert client.get(f"/api/energy/sources/{source_uuid}/channels").json()["items"][0]["binding_count"] == 1 + + invalid = dict(declaration, label="Must roll back", started_at="2030-02-01T00:00:00Z", source_channel_uuid="missing-channel") + assert client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=invalid).status_code == 404 + assert client.get("/api/energy/meters").json()["total"] == 1 + with Session(engine) as session: + original = session.get(Meter, meter_id) + assert original is not None + assert original.ended_at is None + assert session.execute(select(Meter).where(Meter.label == "Must roll back")).scalar_one_or_none() is None + assert session.execute(select(MeterSourceBinding).where(MeterSourceBinding.meter_id == meter_id)).scalars().all() + engine.dispose() + + +def test_source_and_binding_error_contracts_csrf_timezone_and_dsmr_compatibility(auth_database, monkeypatch): + """Exercise the public error boundary without opening serial or MQTT I/O.""" + from zoneinfo import ZoneInfo + + from app.services import timezone as timezone_service + + monkeypatch.setattr(timezone_service, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) + client, engine = _client(auth_database) + with client: + # All management reads require a session and mutations require CSRF. + assert client.get("/api/energy/commodities").status_code == 401 + _login(client) + assert client.post("/api/energy/sources", json={ + "name": "No CSRF", "kind": "dsmr_mqtt", "config": {}, + }).status_code == 403 + assert client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ + "name": "Invalid", "kind": "dsmr_mqtt", "config": {"unexpected": True}, + }).status_code == 422 + assert client.get("/api/energy/sources/does-not-exist").status_code == 404 + + source = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ + "name": "DSMR source", "kind": "dsmr_mqtt", "config": {"password": "stored-secret"}, + }) + assert source.status_code == 201 + source_uuid = source.json()["uuid"] + assert "stored-secret" not in client.get(f"/api/energy/sources/{source_uuid}").text + assert client.get("/api/energy/commodities").json()["items"] == [ + {"key": "electricity", "unit": "kWh", "capabilities": ["meter", "binding", "cost"]}, + {"key": "heating", "unit": "GJ", "capabilities": ["meter", "binding"]}, + {"key": "hot_water", "unit": "m³", "capabilities": ["meter", "binding"]}, + ] + + with Session(engine) as session: + source_model = session.query(MeterSource).filter_by(uuid=source_uuid).one() + now = datetime.now(UTC) + channel = MeterSourceChannel( + source_id=source_model.id, channel_key="electricity", label="Electricity", unit="kWh", + created_at=now, updated_at=now, + ) + session.add(channel) + session.add(DsmrReading( + meter_source_id=source_model.id, telegram_id=7, recorded_at=now, + payload={"compatibility": "latest"}, + )) + session.commit() + channel_uuid = channel.uuid + + # Retained channels prohibit deletion; there is no cascade escape hatch. + assert client.delete(f"/api/energy/sources/{source_uuid}", headers={"X-CSRF-Token": _CSRF}).status_code == 409 + assert client.get(f"/api/energy/sources/{source_uuid}/channels/not-a-channel/readings").status_code == 404 + readings = client.get(f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings") + assert readings.status_code == 200 + assert readings.json()["total"] == 1 + assert "telegram_id" not in readings.text + latest = client.get("/api/energy/dsmr/latest") + assert latest.status_code == 200 + assert latest.json()["payload"] == {"compatibility": "latest"} + + # This declaration is deliberately retroactive for timezone coverage; + # mock the billing sweep so the API contract test stays bounded. + with patch("app.api.routes.api.meters.recompute_range", return_value=0): + meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ + "label": "Local-time meter", "started_at": "2025-01-02T00:00:00", "reason": "initial", + }) + assert meter.status_code == 201 + meter_id = meter.json()["id"] + binding = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ + "source_channel_uuid": channel_uuid, "started_at": "2025-01-02T00:00:00", + }) + assert binding.status_code == 201 + binding_body = binding.json() + localized_start = datetime.fromisoformat(binding_body["started_at"].replace("Z", "+00:00")) + # SQLite returns UTC columns without tzinfo; retain the UTC clock instant + # regardless of that transport detail. + assert localized_start.replace(tzinfo=None) == datetime(2025, 1, 1, 23, 0, 0) + assert client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ + "source_channel_uuid": channel_uuid, "started_at": "2025-01-02T00:00:00", + }).status_code == 422 + assert client.patch(f"/api/energy/bindings/{binding_body['uuid']}", headers={"X-CSRF-Token": _CSRF}, json={ + "ended_at": "2025-01-02T00:00:00", + }).status_code == 422 + assert client.patch(f"/api/energy/bindings/{binding_body['uuid']}", headers={"X-CSRF-Token": _CSRF}, json={ + "ended_at": "2025-01-03T00:00:00", + }).status_code == 200 + assert client.patch("/api/energy/bindings/not-a-binding", headers={"X-CSRF-Token": _CSRF}, json={ + "ended_at": "2025-01-03T00:00:00", + }).status_code == 404 + engine.dispose() + + +def test_management_reads_require_auth_and_mutations_require_csrf(auth_database): + """Every M8-T06 management route enforces the session/CSRF contract.""" + client, engine = _client(auth_database) + with client: + for path in ( + "/api/energy/source-profiles", + "/api/energy/commodities", + "/api/energy/sources", + "/api/energy/sources/missing", + "/api/energy/sources/missing/channels", + "/api/energy/sources/missing/channels/missing/readings", + "/api/energy/meters/1/bindings", + ): + assert client.get(path).status_code == 401 + + _login(client) + assert client.post("/api/energy/sources", json={ + "name": "No CSRF", "kind": "dsmr_mqtt", "config": {}, + }).status_code == 403 + source = _create_source(client) + channel_uuid = _add_channel(engine, source["uuid"]) + meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ + "label": "CSRF meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", + }) + assert meter.status_code == 201 + meter_id = meter.json()["id"] + binding = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ + "source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z", + }) + assert binding.status_code == 201 + + assert client.patch(f"/api/energy/sources/{source['uuid']}", json={"name": "blocked"}).status_code == 403 + assert client.delete(f"/api/energy/sources/{source['uuid']}").status_code == 403 + assert client.post(f"/api/energy/sources/{source['uuid']}/discover").status_code == 403 + assert client.post(f"/api/energy/meters/{meter_id}/bindings", json={ + "source_channel_uuid": channel_uuid, "started_at": "2031-01-01T00:00:00Z", + }).status_code == 403 + assert client.patch(f"/api/energy/bindings/{binding.json()['uuid']}", json={}).status_code == 403 + engine.dispose() + + +def test_source_channel_binding_response_contract_and_discover_capabilities(auth_database): + client, engine = _client(auth_database) + with client: + _login(client) + source = _create_source(client, config={"username": "private-user", "password": "private-secret"}) + channel_uuid = _add_channel(engine, source["uuid"]) + listed = client.get("/api/energy/sources") + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + source_item = next(item for item in listed.json()["items"] if item["uuid"] == source["uuid"]) + assert source_item["uuid"] == source["uuid"] + detail = client.get(f"/api/energy/sources/{source['uuid']}") + assert detail.status_code == 200 + assert detail.json()["uuid"] == source["uuid"] + for body in (listed.json(), detail.json()): + rendered = str(body) + assert "private-secret" not in rendered + assert "channel_key" not in rendered + assert "fingerprint" not in rendered + + discovered = client.post(f"/api/energy/sources/{source['uuid']}/discover", headers={"X-CSRF-Token": _CSRF}) + assert discovered.status_code == 200 + assert discovered.json() == { + "requested": False, "supported": True, "status": "managed_by_runtime", + "detail": "This source is discovered by its runtime subscription; no connection was opened.", + } + channels = client.get(f"/api/energy/sources/{source['uuid']}/channels") + assert channels.status_code == 200 + channel = channels.json()["items"][0] + assert channel["uuid"] == channel_uuid + assert set(channel) == { + "uuid", "label", "suggested_commodity", "unit", "device_type", "latest_value", + "latest_at", "latest_quality", "binding_count", "bound_meter_ids", + } + + meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ + "label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", + }) + assert meter.status_code == 201 + binding = client.post(f"/api/energy/meters/{meter.json()['id']}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ + "source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z", + }) + assert binding.status_code == 201 + binding_item = client.get(f"/api/energy/meters/{meter.json()['id']}/bindings").json()["items"][0] + assert binding_item["uuid"] == binding.json()["uuid"] + assert binding_item["source_uuid"] == source["uuid"] + assert binding_item["source_channel_uuid"] == channel_uuid + assert set(binding_item) == { + "uuid", "meter_id", "source_channel_uuid", "source_uuid", "started_at", "ended_at", + "created_at", "updated_at", + } + engine.dispose() + + +def test_binding_patch_omitted_null_and_adjacent_half_open_boundaries(auth_database): + client, engine = _client(auth_database) + with client: + _login(client) + source = _create_source(client) + channel_uuid = _add_channel(engine, source["uuid"]) + meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ + "label": "Timeline meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", + }) + assert meter.status_code == 201 + meter_id = meter.json()["id"] + first = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ + "source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z", + "ended_at": "2030-02-01T00:00:00Z", + }) + assert first.status_code == 201 + first_uuid = first.json()["uuid"] + corrected = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={ + "started_at": "2030-01-02T00:00:00Z", + }) + assert corrected.status_code == 200 + assert corrected.json()["ended_at"] == "2030-02-01T00:00:00" + unchanged = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={}) + assert unchanged.status_code == 200 + assert unchanged.json()["ended_at"] == "2030-02-01T00:00:00" + reopened = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={"ended_at": None}) + assert reopened.status_code == 200 + assert reopened.json()["ended_at"] is None + reclosed = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={ + "ended_at": "2030-02-01T00:00:00Z", + }) + assert reclosed.status_code == 200 + adjacent = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ + "source_channel_uuid": channel_uuid, "started_at": "2030-02-01T00:00:00Z", + }) + assert adjacent.status_code == 201 + engine.dispose()