M8-T06: add meter source management API
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -499,7 +499,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### 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 入口。
|
||||||
|
|
||||||
|
|||||||
Vendored
+811
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user