M8-T06: add meter source management API

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent 1ea2f659e0
commit 5855fff451
10 changed files with 3857 additions and 8 deletions
+259
View File
@@ -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="", 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
+42 -7
View File
@@ -48,16 +48,20 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select
from app.api.routes.api.deps import require_csrf, require_session from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db from app.dependencies import get_db
from app.models.energy import Meter from app.models.energy import Meter
from app.models.meter_source import MeterSourceChannel
from app.schemas.meter import ( from app.schemas.meter import (
MeterDeclareRequest, MeterDeclareRequest,
MeterBindingSummary,
MeterListResponse, MeterListResponse,
MeterPatchRequest, MeterPatchRequest,
MeterResponse, MeterResponse,
) )
from app.services.meter_sources import ChannelNotFoundError, MeterSourceError, create_binding
from app.services import timezone as _tz_mod from app.services import timezone as _tz_mod
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.energy_cost import recompute_range from app.services.energy_cost import recompute_range
@@ -153,6 +157,22 @@ def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
return n 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 # GET /api/energy/meters
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -170,7 +190,7 @@ def list_energy_meters(
has the latest ``started_at``. has the latest ``started_at``.
""" """
meters = list_meters(db) 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)) return MeterListResponse(items=items, total=len(items))
@@ -219,14 +239,29 @@ def declare_energy_meter(
commodity=body.commodity, commodity=body.commodity,
note=body.note, 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( 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), 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. # Retroactive recompute: re-judge attribution from the new boundary onward.
now = datetime.now(UTC) now = datetime.now(UTC)
if started_at_utc < now: if started_at_utc < now:
@@ -247,7 +282,7 @@ def declare_energy_meter(
new_meter.label, new_meter.label,
started_at_utc.isoformat(), 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.label,
meter.started_at, meter.started_at,
) )
return MeterResponse.model_validate(meter) return _meter_response(meter)
+2
View File
@@ -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.energy_contracts import router as api_energy_contracts_router
from app.api.routes.api.expose import router as api_expose_router from app.api.routes.api.expose import router as api_expose_router
from app.api.routes.api.meters import router as api_meters_router from app.api.routes.api.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.modbus import router as api_modbus_router
from app.api.routes.api.session import router as api_session_router from app.api.routes.api.session import router as api_session_router
from app.api.routes import status from app.api.routes import status
@@ -308,6 +309,7 @@ def create_app() -> FastAPI:
app.include_router(api_energy_router) app.include_router(api_energy_router)
app.include_router(api_energy_contracts_router) app.include_router(api_energy_contracts_router)
app.include_router(api_meters_router) app.include_router(api_meters_router)
app.include_router(api_meter_sources_router)
app.include_router(api_expose_router) app.include_router(api_expose_router)
app.include_router(api_modbus_router) app.include_router(api_modbus_router)
app.include_router(api_session_router) app.include_router(api_session_router)
+17
View File
@@ -49,10 +49,21 @@ class MeterResponse(BaseModel):
reason: str reason: str
note: str | None note: str | None
created_at: datetime created_at: datetime
bindings: list["MeterBindingSummary"] = Field(default_factory=list)
model_config = {"from_attributes": True} 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): class MeterListResponse(BaseModel):
"""Response schema for GET /api/energy/meters. """Response schema for GET /api/energy/meters.
@@ -107,6 +118,12 @@ class MeterDeclareRequest(BaseModel):
max_length=32, max_length=32,
description="Energy commodity this meter measures. Defaults to 'electricity'.", 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): class MeterPatchRequest(BaseModel):
+133
View File
@@ -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
+1 -1
View File
@@ -499,7 +499,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T06 — Source / Channel / Binding HTTP 契约 [structural] ### M8-T06 — Source / Channel / Binding HTTP 契约 [structural]
- **Status**: `todo` - **Status**: `done`
- **Depends**: M8-T05 - **Depends**: M8-T05
- **Context**: 在基础服务与 DSMR 兼容稳定后,提供 §6 的管理接口及原子 Meter+binding 入口。 - **Context**: 在基础服务与 DSMR 兼容稳定后,提供 §6 的管理接口及原子 Meter+binding 入口。
+811
View File
@@ -638,6 +638,166 @@ export interface paths {
patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"]; patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"];
trace?: never; 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": { "/api/expose": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1230,6 +1390,60 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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 * CatalogEntrySchema
* @description An entity from the catalog with its current toggle state. * @description An entity from the catalog with its current toggle state.
@@ -1239,6 +1453,39 @@ export interface components {
/** Enabled */ /** Enabled */
enabled: boolean; 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 */
ConfigField: { ConfigField: {
/** Env Name */ /** Env Name */
@@ -1506,6 +1753,17 @@ export interface components {
/** Name */ /** Name */
name: string; name: string;
}; };
/** DiscoverResponse */
DiscoverResponse: {
/** Requested */
requested: boolean;
/** Supported */
supported: boolean;
/** Status */
status: string;
/** Detail */
detail?: string | null;
};
/** /**
* DsmrLatestResponse * DsmrLatestResponse
* @description Response for GET /api/energy/dsmr/latest. * @description Response for GET /api/energy/dsmr/latest.
@@ -1653,6 +1911,25 @@ export interface components {
*/ */
sell_normal: number; 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 * MeterDeclareRequest
* @description Request body for POST /api/energy/meters. * @description Request body for POST /api/energy/meters.
@@ -1689,6 +1966,11 @@ export interface components {
* @default electricity * @default electricity
*/ */
commodity: string; commodity: string;
/**
* Source Channel Uuid
* @description Optional compatible source channel to bind atomically to this meter.
*/
source_channel_uuid?: string | null;
}; };
/** /**
* MeterListResponse * MeterListResponse
@@ -1760,6 +2042,103 @@ export interface components {
* Format: date-time * Format: date-time
*/ */
created_at: string; 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 * MetricInfo
@@ -2216,6 +2595,39 @@ export interface components {
/** Message */ /** Message */
message: string; 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 */
StatusResponse: { StatusResponse: {
/** Status */ /** 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: { get_expose_api_expose_get: {
parameters: { parameters: {
query?: never; query?: never;
+1372
View File
File diff suppressed because it is too large Load Diff
+875
View File
@@ -1211,6 +1211,398 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $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: /api/expose:
get: get:
tags: tags:
@@ -2096,6 +2488,102 @@ paths:
schema: {} schema: {}
components: components:
schemas: 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: CatalogEntrySchema:
properties: properties:
entity: entity:
@@ -2109,6 +2597,72 @@ components:
- enabled - enabled
title: CatalogEntrySchema title: CatalogEntrySchema
description: An entity from the catalog with its current toggle state. 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: ConfigField:
properties: properties:
env_name: env_name:
@@ -2496,6 +3050,28 @@ components:
- name - name
title: DeviceInfoSchema title: DeviceInfoSchema
description: HA device grouping info for an exposable entity. 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: DsmrLatestResponse:
properties: properties:
found: found:
@@ -2736,6 +3312,36 @@ components:
Prices are the *effective* buy prices as used by the billing engine Prices are the *effective* buy prices as used by the billing engine
(energy_buy_x + energy_tax + ode) and the raw sell prices.' (energy_buy_x + energy_tax + ode) and the raw sell prices.'
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: MeterDeclareRequest:
properties: properties:
label: label:
@@ -2766,6 +3372,15 @@ components:
title: Commodity title: Commodity
description: Energy commodity this meter measures. Defaults to 'electricity'. description: Energy commodity this meter measures. Defaults to 'electricity'.
default: 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 type: object
required: required:
- label - label
@@ -2897,6 +3512,11 @@ components:
type: string type: string
format: date-time format: date-time
title: Created At title: Created At
bindings:
items:
$ref: '#/components/schemas/MeterBindingSummary'
type: array
title: Bindings
type: object type: object
required: required:
- id - id
@@ -2912,6 +3532,195 @@ components:
``ended_at`` is ``null`` for the currently active meter.' ``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: MetricInfo:
properties: properties:
key: key:
@@ -3649,6 +4458,72 @@ components:
- message - message
title: SmtpTestResponse title: SmtpTestResponse
description: Response from POST /api/config/smtp/test. 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: StatusResponse:
properties: properties:
status: status:
+345
View File
@@ -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": "", "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()