M8-T11: add WarmteLink discovery and history API
This commit is contained in:
@@ -12,10 +12,12 @@ 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.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
|
||||
from app.schemas.meter_source import (
|
||||
BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelReadingResponse,
|
||||
BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelBindingSummaryResponse,
|
||||
ChannelReadingResponse,
|
||||
ChannelReadingsResponse, CommoditiesResponse, CommodityResponse, DiscoverResponse,
|
||||
DiscoverChannelResponse,
|
||||
MeterSourceChannelListResponse, MeterSourceChannelResponse, MeterSourceCreate,
|
||||
MeterSourceListResponse, MeterSourcePatch, MeterSourceResponse, SourceConfigFieldResponse,
|
||||
SourceProfileResponse, SourceProfilesResponse,
|
||||
@@ -181,8 +183,25 @@ 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.")
|
||||
if not source.enabled:
|
||||
return DiscoverResponse(
|
||||
requested=False, supported=True, status="error",
|
||||
detail="The WarmteLink source is disabled.", channels=_discover_channels(db, source),
|
||||
)
|
||||
# This merely schedules lifecycle convergence. It never opens a serial
|
||||
# descriptor or waits for a frame in the request thread; the one managed
|
||||
# worker remains the sole owner of serial I/O and can keep reconnecting.
|
||||
request = warmtelink_worker_manager.request_discovery(source.id)
|
||||
if request.completed.is_set():
|
||||
# A worker may have accepted a frame during the bounded wait.
|
||||
# Refresh only durable accepted metadata, never candidates/raw data.
|
||||
db.expire_all()
|
||||
source = _source_or_404(db, source_uuid)
|
||||
return DiscoverResponse(
|
||||
requested=request.status != "error", supported=True, status=request.status,
|
||||
request_id=request.request_id or None, detail=request.detail,
|
||||
channels=_discover_channels(db, source),
|
||||
)
|
||||
return DiscoverResponse(requested=False, supported=True, status="managed_by_runtime",
|
||||
detail="This source is discovered by its runtime subscription; no connection was opened.")
|
||||
|
||||
@@ -195,32 +214,61 @@ def source_channels(source_uuid: str, db: Session = Depends(get_db),
|
||||
items = []
|
||||
for channel in channels:
|
||||
bindings = list_bindings(db, channel_id=channel.id)
|
||||
meter_ids = [binding.meter_id for binding in bindings]
|
||||
items.append(MeterSourceChannelResponse(
|
||||
uuid=channel.uuid, label=channel.label, suggested_commodity=channel.suggested_commodity,
|
||||
unit=channel.unit, device_type=channel.device_type, latest_value=channel.latest_value,
|
||||
latest_at=channel.latest_at, latest_quality=channel.latest_quality, binding_count=len(bindings),
|
||||
bound_meter_ids=[binding.meter_id for binding in bindings],
|
||||
bound_meter_ids=meter_ids,
|
||||
binding_summary=ChannelBindingSummaryResponse(count=len(bindings), meter_ids=meter_ids),
|
||||
))
|
||||
return MeterSourceChannelListResponse(items=items, total=len(items))
|
||||
return MeterSourceChannelListResponse(items=items, total=len(items), source_status=source.status)
|
||||
|
||||
|
||||
@router.get("/sources/{source_uuid}/channels/{channel_uuid}/readings", response_model=ChannelReadingsResponse)
|
||||
def channel_readings(source_uuid: str, channel_uuid: str, limit: int = Query(default=100, ge=1, le=1000),
|
||||
start: datetime | None = None, end: datetime | None = None, db: Session = Depends(get_db),
|
||||
from_: datetime | None = Query(default=None, alias="from"),
|
||||
to: datetime | None = Query(default=None), db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session)) -> ChannelReadingsResponse:
|
||||
source = _source_or_404(db, source_uuid)
|
||||
_channel_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))
|
||||
channel = _channel_or_404(db, source, channel_uuid)
|
||||
if from_ is not None and to is not None and _as_utc(from_) >= _as_utc(to):
|
||||
raise HTTPException(status_code=422, detail="'from' must be earlier than 'to'.")
|
||||
if source.kind == "warmtelink_serial":
|
||||
statement = select(WarmteLinkReading).where(WarmteLinkReading.channel_id == channel.id)
|
||||
model = WarmteLinkReading
|
||||
else:
|
||||
# DSMR remains a source-level protocol history. Its channel is the
|
||||
# public electricity identity, while payload/telegram diagnostics stay
|
||||
# private to ingestion and the legacy latest endpoint.
|
||||
statement = select(DsmrReading).where(DsmrReading.meter_source_id == source.id)
|
||||
model = DsmrReading
|
||||
if from_ is not None:
|
||||
statement = statement.where(model.recorded_at >= _as_utc(from_))
|
||||
if to is not None:
|
||||
statement = statement.where(model.recorded_at < _as_utc(to))
|
||||
rows = list(db.execute(statement.order_by(model.recorded_at.asc()).limit(limit)).scalars())
|
||||
return ChannelReadingsResponse(
|
||||
items=[ChannelReadingResponse(
|
||||
recorded_at=row.recorded_at,
|
||||
value=getattr(row, "value", None), quality=getattr(row, "quality", None),
|
||||
) for row in rows],
|
||||
total=len(rows),
|
||||
)
|
||||
|
||||
|
||||
def _discover_channels(db: Session, source: MeterSource) -> list[DiscoverChannelResponse]:
|
||||
"""Return only public, accepted channel metadata for discover responses."""
|
||||
return [
|
||||
DiscoverChannelResponse(
|
||||
uuid=channel.uuid, label=channel.label, unit=channel.unit,
|
||||
latest_value=channel.latest_value, latest_at=channel.latest_at,
|
||||
latest_quality=channel.latest_quality,
|
||||
)
|
||||
for channel in db.execute(
|
||||
select(MeterSourceChannel).where(MeterSourceChannel.source_id == source.id)
|
||||
).scalars()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/meters/{meter_id}/bindings", response_model=BindingListResponse)
|
||||
|
||||
Reference in New Issue
Block a user