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.dependencies import get_db
|
||||||
from app.integrations.meter_sources import SourceProfileError, list_source_profiles, sanitize_source_config
|
from app.integrations.meter_sources import SourceProfileError, list_source_profiles, sanitize_source_config
|
||||||
from app.models.energy import DsmrReading, Meter
|
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 (
|
from app.schemas.meter_source import (
|
||||||
BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelReadingResponse,
|
BindingCreate, BindingListResponse, BindingPatch, BindingResponse, ChannelBindingSummaryResponse,
|
||||||
|
ChannelReadingResponse,
|
||||||
ChannelReadingsResponse, CommoditiesResponse, CommodityResponse, DiscoverResponse,
|
ChannelReadingsResponse, CommoditiesResponse, CommodityResponse, DiscoverResponse,
|
||||||
|
DiscoverChannelResponse,
|
||||||
MeterSourceChannelListResponse, MeterSourceChannelResponse, MeterSourceCreate,
|
MeterSourceChannelListResponse, MeterSourceChannelResponse, MeterSourceCreate,
|
||||||
MeterSourceListResponse, MeterSourcePatch, MeterSourceResponse, SourceConfigFieldResponse,
|
MeterSourceListResponse, MeterSourcePatch, MeterSourceResponse, SourceConfigFieldResponse,
|
||||||
SourceProfileResponse, SourceProfilesResponse,
|
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:
|
_auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf)) -> DiscoverResponse:
|
||||||
source = _source_or_404(db, source_uuid)
|
source = _source_or_404(db, source_uuid)
|
||||||
if source.kind == "warmtelink_serial":
|
if source.kind == "warmtelink_serial":
|
||||||
return DiscoverResponse(requested=False, supported=False, status="not_implemented",
|
if not source.enabled:
|
||||||
detail="Serial discovery is available after the WarmteLink worker is installed.")
|
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",
|
return DiscoverResponse(requested=False, supported=True, status="managed_by_runtime",
|
||||||
detail="This source is discovered by its runtime subscription; no connection was opened.")
|
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 = []
|
items = []
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
bindings = list_bindings(db, channel_id=channel.id)
|
bindings = list_bindings(db, channel_id=channel.id)
|
||||||
|
meter_ids = [binding.meter_id for binding in bindings]
|
||||||
items.append(MeterSourceChannelResponse(
|
items.append(MeterSourceChannelResponse(
|
||||||
uuid=channel.uuid, label=channel.label, suggested_commodity=channel.suggested_commodity,
|
uuid=channel.uuid, label=channel.label, suggested_commodity=channel.suggested_commodity,
|
||||||
unit=channel.unit, device_type=channel.device_type, latest_value=channel.latest_value,
|
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),
|
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)
|
@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),
|
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:
|
_auth: AuthenticatedSession = Depends(require_session)) -> ChannelReadingsResponse:
|
||||||
source = _source_or_404(db, source_uuid)
|
source = _source_or_404(db, source_uuid)
|
||||||
_channel_or_404(db, source, channel_uuid)
|
channel = _channel_or_404(db, source, channel_uuid)
|
||||||
if source.kind != "dsmr_mqtt":
|
if from_ is not None and to is not None and _as_utc(from_) >= _as_utc(to):
|
||||||
return ChannelReadingsResponse(items=[], total=0)
|
raise HTTPException(status_code=422, detail="'from' must be earlier than 'to'.")
|
||||||
statement = select(DsmrReading).where(DsmrReading.meter_source_id == source.id)
|
if source.kind == "warmtelink_serial":
|
||||||
if start is not None:
|
statement = select(WarmteLinkReading).where(WarmteLinkReading.channel_id == channel.id)
|
||||||
statement = statement.where(DsmrReading.recorded_at >= _as_utc(start))
|
model = WarmteLinkReading
|
||||||
if end is not None:
|
else:
|
||||||
statement = statement.where(DsmrReading.recorded_at < _as_utc(end))
|
# DSMR remains a source-level protocol history. Its channel is the
|
||||||
rows = list(db.execute(statement.order_by(DsmrReading.recorded_at.desc()).limit(limit)).scalars())
|
# public electricity identity, while payload/telegram diagnostics stay
|
||||||
# The DSMR payload remains available only from its legacy compatibility endpoint;
|
# private to ingestion and the legacy latest endpoint.
|
||||||
# this generic endpoint intentionally exposes no telegram/equipment identifiers.
|
statement = select(DsmrReading).where(DsmrReading.meter_source_id == source.id)
|
||||||
return ChannelReadingsResponse(items=[ChannelReadingResponse(recorded_at=row.recorded_at) for row in rows], total=len(rows))
|
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)
|
@router.get("/meters/{meter_id}/bindings", response_model=BindingListResponse)
|
||||||
|
|||||||
@@ -64,7 +64,23 @@ class DiscoverResponse(BaseModel):
|
|||||||
requested: bool
|
requested: bool
|
||||||
supported: bool
|
supported: bool
|
||||||
status: str
|
status: str
|
||||||
|
request_id: int | None = None
|
||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
channels: list["DiscoverChannelResponse"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverChannelResponse(BaseModel):
|
||||||
|
uuid: str
|
||||||
|
label: str
|
||||||
|
unit: str
|
||||||
|
latest_value: Decimal | None
|
||||||
|
latest_at: datetime | None
|
||||||
|
latest_quality: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelBindingSummaryResponse(BaseModel):
|
||||||
|
count: int
|
||||||
|
meter_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
class CommodityResponse(BaseModel):
|
class CommodityResponse(BaseModel):
|
||||||
@@ -88,11 +104,13 @@ class MeterSourceChannelResponse(BaseModel):
|
|||||||
latest_quality: str | None
|
latest_quality: str | None
|
||||||
binding_count: int
|
binding_count: int
|
||||||
bound_meter_ids: list[int]
|
bound_meter_ids: list[int]
|
||||||
|
binding_summary: ChannelBindingSummaryResponse
|
||||||
|
|
||||||
|
|
||||||
class MeterSourceChannelListResponse(BaseModel):
|
class MeterSourceChannelListResponse(BaseModel):
|
||||||
items: list[MeterSourceChannelResponse]
|
items: list[MeterSourceChannelResponse]
|
||||||
total: int
|
total: int
|
||||||
|
source_status: str
|
||||||
|
|
||||||
|
|
||||||
class ChannelReadingResponse(BaseModel):
|
class ChannelReadingResponse(BaseModel):
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
@@ -27,6 +27,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_BACKOFF_SECONDS = (1, 2, 4, 8, 16, 32, 60)
|
_BACKOFF_SECONDS = (1, 2, 4, 8, 16, 32, 60)
|
||||||
_JOIN_TIMEOUT_SECONDS = 5
|
_JOIN_TIMEOUT_SECONDS = 5
|
||||||
|
_DISCOVERY_LOCK_TIMEOUT_SECONDS = 0.05
|
||||||
|
_DISCOVERY_WAIT_SECONDS = 0.1
|
||||||
|
_DISCOVERY_TIMEOUT_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
class ReadOnlySerial(Protocol):
|
class ReadOnlySerial(Protocol):
|
||||||
@@ -69,6 +72,18 @@ class _WorkerConfig:
|
|||||||
config: dict
|
config: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiscoveryRequest:
|
||||||
|
"""One source-scoped request, completed only by its serial owner."""
|
||||||
|
|
||||||
|
request_id: int
|
||||||
|
source_id: int
|
||||||
|
deadline: datetime
|
||||||
|
status: str = "pending"
|
||||||
|
detail: str | None = None
|
||||||
|
completed: threading.Event = field(default_factory=threading.Event)
|
||||||
|
|
||||||
|
|
||||||
class WarmteLinkWorker:
|
class WarmteLinkWorker:
|
||||||
"""One interruptible, read-only serial loop for one meter source."""
|
"""One interruptible, read-only serial loop for one meter source."""
|
||||||
|
|
||||||
@@ -88,6 +103,8 @@ class WarmteLinkWorker:
|
|||||||
self._clock = clock or _EventClock()
|
self._clock = clock or _EventClock()
|
||||||
self._serial: ReadOnlySerial | None = None
|
self._serial: ReadOnlySerial | None = None
|
||||||
self._serial_lock = threading.Lock()
|
self._serial_lock = threading.Lock()
|
||||||
|
self._discovery_lock = threading.Lock()
|
||||||
|
self._discoveries: list[DiscoveryRequest] = []
|
||||||
# Never inherit a daemon flag from a caller's background thread: a serial
|
# Never inherit a daemon flag from a caller's background thread: a serial
|
||||||
# descriptor and its orderly shutdown must remain visible to the process.
|
# descriptor and its orderly shutdown must remain visible to the process.
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
@@ -105,6 +122,35 @@ class WarmteLinkWorker:
|
|||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
self._close_serial()
|
self._close_serial()
|
||||||
|
|
||||||
|
def request_discovery(self, request: DiscoveryRequest) -> None:
|
||||||
|
"""Queue a read request; this worker remains the sole serial owner."""
|
||||||
|
with self._discovery_lock:
|
||||||
|
self._discoveries.append(request)
|
||||||
|
|
||||||
|
def _finish_discoveries(self, status: str, detail: str | None = None) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with self._discovery_lock:
|
||||||
|
pending, self._discoveries = self._discoveries, []
|
||||||
|
for request in pending:
|
||||||
|
if request.completed.is_set():
|
||||||
|
continue
|
||||||
|
if request.deadline <= now and status == "completed":
|
||||||
|
request.status, request.detail = "error", "Discovery timed out."
|
||||||
|
else:
|
||||||
|
request.status, request.detail = status, detail
|
||||||
|
request.completed.set()
|
||||||
|
|
||||||
|
def _expire_discoveries(self) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with self._discovery_lock:
|
||||||
|
expired = [request for request in self._discoveries if request.deadline <= now]
|
||||||
|
self._discoveries = [request for request in self._discoveries if request.deadline > now]
|
||||||
|
for request in expired:
|
||||||
|
if request.completed.is_set():
|
||||||
|
continue
|
||||||
|
request.status, request.detail = "error", "Discovery timed out."
|
||||||
|
request.completed.set()
|
||||||
|
|
||||||
def join(self, timeout: float = _JOIN_TIMEOUT_SECONDS) -> bool:
|
def join(self, timeout: float = _JOIN_TIMEOUT_SECONDS) -> bool:
|
||||||
self._thread.join(timeout)
|
self._thread.join(timeout)
|
||||||
return not self._thread.is_alive()
|
return not self._thread.is_alive()
|
||||||
@@ -142,6 +188,7 @@ class WarmteLinkWorker:
|
|||||||
return
|
return
|
||||||
self._serial = device
|
self._serial = device
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
|
self._expire_discoveries()
|
||||||
chunk = device.read(1024)
|
chunk = device.read(1024)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
# ``timeout`` reads are normal, but still yield so a bad
|
# ``timeout`` reads are normal, but still yield so a bad
|
||||||
@@ -152,13 +199,16 @@ class WarmteLinkWorker:
|
|||||||
for frame in frames:
|
for frame in frames:
|
||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
self._ingestor.handle_frame(
|
admitted = self._ingestor.handle_frame(
|
||||||
self.source_id, frame, session_factory=self._session_factory
|
self.source_id, frame, session_factory=self._session_factory
|
||||||
)
|
)
|
||||||
|
if admitted:
|
||||||
|
self._finish_discoveries("completed")
|
||||||
# A complete frame proves transport recovery even if its
|
# A complete frame proves transport recovery even if its
|
||||||
# contents are rejected by the privacy/admission layer.
|
# contents are rejected by the privacy/admission layer.
|
||||||
backoff_index = 0
|
backoff_index = 0
|
||||||
except Exception:
|
except Exception:
|
||||||
|
self._finish_discoveries("error", "WarmteLink discovery failed.")
|
||||||
self._record_error("WarmteLink serial connection failed")
|
self._record_error("WarmteLink serial connection failed")
|
||||||
delay = _BACKOFF_SECONDS[min(backoff_index, len(_BACKOFF_SECONDS) - 1)]
|
delay = _BACKOFF_SECONDS[min(backoff_index, len(_BACKOFF_SECONDS) - 1)]
|
||||||
backoff_index += 1
|
backoff_index += 1
|
||||||
@@ -181,6 +231,7 @@ class WarmteLinkWorkerManager:
|
|||||||
self._workers: dict[int, tuple[_WorkerConfig, WarmteLinkWorker]] = {}
|
self._workers: dict[int, tuple[_WorkerConfig, WarmteLinkWorker]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._reapers: set[int] = set()
|
self._reapers: set[int] = set()
|
||||||
|
self._next_discovery_id = 0
|
||||||
self._shutting_down = False
|
self._shutting_down = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -203,6 +254,48 @@ class WarmteLinkWorkerManager:
|
|||||||
self._shutting_down = False
|
self._shutting_down = False
|
||||||
self.reconcile()
|
self.reconcile()
|
||||||
|
|
||||||
|
def request_discovery(self, source_id: int) -> DiscoveryRequest:
|
||||||
|
"""Ask the current source worker for one bounded read/discovery attempt.
|
||||||
|
|
||||||
|
This intentionally does not reconcile or open a descriptor. Lifecycle
|
||||||
|
convergence remains separate; a request can neither replace nor stop a
|
||||||
|
worker when an HTTP client times out or disconnects.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
request = DiscoveryRequest(0, source_id, now)
|
||||||
|
if not self._lock.acquire(timeout=_DISCOVERY_LOCK_TIMEOUT_SECONDS):
|
||||||
|
request.status, request.detail = "error", "Discovery queue is busy."
|
||||||
|
request.completed.set()
|
||||||
|
return request
|
||||||
|
try:
|
||||||
|
self._next_discovery_id += 1
|
||||||
|
request.request_id = self._next_discovery_id
|
||||||
|
request.deadline = now + timedelta(seconds=_DISCOVERY_TIMEOUT_SECONDS)
|
||||||
|
if self._shutting_down:
|
||||||
|
request.status, request.detail = "error", "WarmteLink manager is stopped."
|
||||||
|
request.completed.set()
|
||||||
|
elif (entry := self._workers.get(source_id)) is None:
|
||||||
|
request.status, request.detail = "error", "WarmteLink worker is not running."
|
||||||
|
request.completed.set()
|
||||||
|
else:
|
||||||
|
entry[1].request_discovery(request)
|
||||||
|
timer = threading.Timer(_DISCOVERY_TIMEOUT_SECONDS, self._timeout_discovery, args=(request,))
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
finally:
|
||||||
|
self._lock.release()
|
||||||
|
# A tiny bounded wait makes an immediately available frame observable,
|
||||||
|
# without turning an HTTP call into serial I/O or an unbounded wait.
|
||||||
|
request.completed.wait(_DISCOVERY_WAIT_SECONDS)
|
||||||
|
return request
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _timeout_discovery(request: DiscoveryRequest) -> None:
|
||||||
|
"""Resolve a stale HTTP request without touching its healthy worker."""
|
||||||
|
if not request.completed.is_set():
|
||||||
|
request.status, request.detail = "error", "Discovery timed out."
|
||||||
|
request.completed.set()
|
||||||
|
|
||||||
def _read_desired(self) -> dict[int, _WorkerConfig]:
|
def _read_desired(self) -> dict[int, _WorkerConfig]:
|
||||||
with self._session_factory() as session:
|
with self._session_factory() as session:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -689,7 +689,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T11 — WarmteLink discover、latest 与 history API
|
### M8-T11 — WarmteLink discover、latest 与 history API
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T10
|
- **Depends**: M8-T10
|
||||||
- **Context**: worker 链路稳定后,把一次发现、状态和规范化历史接到已建立的 source API。
|
- **Context**: worker 链路稳定后,把一次发现、状态和规范化历史接到已建立的 source API。
|
||||||
|
|
||||||
|
|||||||
Vendored
+31
-2
@@ -1453,6 +1453,13 @@ export interface components {
|
|||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
/** ChannelBindingSummaryResponse */
|
||||||
|
ChannelBindingSummaryResponse: {
|
||||||
|
/** Count */
|
||||||
|
count: number;
|
||||||
|
/** Meter Ids */
|
||||||
|
meter_ids: number[];
|
||||||
|
};
|
||||||
/** ChannelReadingResponse */
|
/** ChannelReadingResponse */
|
||||||
ChannelReadingResponse: {
|
ChannelReadingResponse: {
|
||||||
/**
|
/**
|
||||||
@@ -1753,6 +1760,21 @@ export interface components {
|
|||||||
/** Name */
|
/** Name */
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
/** DiscoverChannelResponse */
|
||||||
|
DiscoverChannelResponse: {
|
||||||
|
/** Uuid */
|
||||||
|
uuid: string;
|
||||||
|
/** Label */
|
||||||
|
label: string;
|
||||||
|
/** Unit */
|
||||||
|
unit: string;
|
||||||
|
/** Latest Value */
|
||||||
|
latest_value: string | null;
|
||||||
|
/** Latest At */
|
||||||
|
latest_at: string | null;
|
||||||
|
/** Latest Quality */
|
||||||
|
latest_quality: string | null;
|
||||||
|
};
|
||||||
/** DiscoverResponse */
|
/** DiscoverResponse */
|
||||||
DiscoverResponse: {
|
DiscoverResponse: {
|
||||||
/** Requested */
|
/** Requested */
|
||||||
@@ -1761,8 +1783,12 @@ export interface components {
|
|||||||
supported: boolean;
|
supported: boolean;
|
||||||
/** Status */
|
/** Status */
|
||||||
status: string;
|
status: string;
|
||||||
|
/** Request Id */
|
||||||
|
request_id?: number | null;
|
||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
/** Channels */
|
||||||
|
channels?: components["schemas"]["DiscoverChannelResponse"][];
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* DsmrLatestResponse
|
* DsmrLatestResponse
|
||||||
@@ -2051,6 +2077,8 @@ export interface components {
|
|||||||
items: components["schemas"]["MeterSourceChannelResponse"][];
|
items: components["schemas"]["MeterSourceChannelResponse"][];
|
||||||
/** Total */
|
/** Total */
|
||||||
total: number;
|
total: number;
|
||||||
|
/** Source Status */
|
||||||
|
source_status: string;
|
||||||
};
|
};
|
||||||
/** MeterSourceChannelResponse */
|
/** MeterSourceChannelResponse */
|
||||||
MeterSourceChannelResponse: {
|
MeterSourceChannelResponse: {
|
||||||
@@ -2074,6 +2102,7 @@ export interface components {
|
|||||||
binding_count: number;
|
binding_count: number;
|
||||||
/** Bound Meter Ids */
|
/** Bound Meter Ids */
|
||||||
bound_meter_ids: number[];
|
bound_meter_ids: number[];
|
||||||
|
binding_summary: components["schemas"]["ChannelBindingSummaryResponse"];
|
||||||
};
|
};
|
||||||
/** MeterSourceCreate */
|
/** MeterSourceCreate */
|
||||||
MeterSourceCreate: {
|
MeterSourceCreate: {
|
||||||
@@ -3977,8 +4006,8 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
start?: string | null;
|
from?: string | null;
|
||||||
end?: string | null;
|
to?: string | null;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
+115
-6
@@ -1945,7 +1945,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "start",
|
"name": "from",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": false,
|
"required": false,
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -1958,11 +1958,11 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Start"
|
"title": "From"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "end",
|
"name": "to",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"required": false,
|
"required": false,
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -1975,7 +1975,7 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "End"
|
"title": "To"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -3462,6 +3462,27 @@
|
|||||||
"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."
|
||||||
},
|
},
|
||||||
|
"ChannelBindingSummaryResponse": {
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Count"
|
||||||
|
},
|
||||||
|
"meter_ids": {
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Meter Ids"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"count",
|
||||||
|
"meter_ids"
|
||||||
|
],
|
||||||
|
"title": "ChannelBindingSummaryResponse"
|
||||||
|
},
|
||||||
"ChannelReadingResponse": {
|
"ChannelReadingResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"recorded_at": {
|
"recorded_at": {
|
||||||
@@ -4055,6 +4076,67 @@
|
|||||||
"title": "DeviceInfoSchema",
|
"title": "DeviceInfoSchema",
|
||||||
"description": "HA device grouping info for an exposable entity."
|
"description": "HA device grouping info for an exposable entity."
|
||||||
},
|
},
|
||||||
|
"DiscoverChannelResponse": {
|
||||||
|
"properties": {
|
||||||
|
"uuid": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Uuid"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Label"
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Unit"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"uuid",
|
||||||
|
"label",
|
||||||
|
"unit",
|
||||||
|
"latest_value",
|
||||||
|
"latest_at",
|
||||||
|
"latest_quality"
|
||||||
|
],
|
||||||
|
"title": "DiscoverChannelResponse"
|
||||||
|
},
|
||||||
"DiscoverResponse": {
|
"DiscoverResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"requested": {
|
"requested": {
|
||||||
@@ -4069,6 +4151,17 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Status"
|
"title": "Status"
|
||||||
},
|
},
|
||||||
|
"request_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Request Id"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -4079,6 +4172,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Detail"
|
"title": "Detail"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DiscoverChannelResponse"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Channels"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -4691,12 +4791,17 @@
|
|||||||
"total": {
|
"total": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"title": "Total"
|
"title": "Total"
|
||||||
|
},
|
||||||
|
"source_status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Source Status"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"items",
|
"items",
|
||||||
"total"
|
"total",
|
||||||
|
"source_status"
|
||||||
],
|
],
|
||||||
"title": "MeterSourceChannelListResponse"
|
"title": "MeterSourceChannelListResponse"
|
||||||
},
|
},
|
||||||
@@ -4781,6 +4886,9 @@
|
|||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Bound Meter Ids"
|
"title": "Bound Meter Ids"
|
||||||
|
},
|
||||||
|
"binding_summary": {
|
||||||
|
"$ref": "#/components/schemas/ChannelBindingSummaryResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -4794,7 +4902,8 @@
|
|||||||
"latest_at",
|
"latest_at",
|
||||||
"latest_quality",
|
"latest_quality",
|
||||||
"binding_count",
|
"binding_count",
|
||||||
"bound_meter_ids"
|
"bound_meter_ids",
|
||||||
|
"binding_summary"
|
||||||
],
|
],
|
||||||
"title": "MeterSourceChannelResponse"
|
"title": "MeterSourceChannelResponse"
|
||||||
},
|
},
|
||||||
|
|||||||
+73
-4
@@ -1467,7 +1467,7 @@ paths:
|
|||||||
minimum: 1
|
minimum: 1
|
||||||
default: 100
|
default: 100
|
||||||
title: Limit
|
title: Limit
|
||||||
- name: start
|
- name: from
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
@@ -1475,8 +1475,8 @@ paths:
|
|||||||
- type: string
|
- type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
- type: 'null'
|
- type: 'null'
|
||||||
title: Start
|
title: From
|
||||||
- name: end
|
- name: to
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
@@ -1484,7 +1484,7 @@ paths:
|
|||||||
- type: string
|
- type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
- type: 'null'
|
- type: 'null'
|
||||||
title: End
|
title: To
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successful Response
|
description: Successful Response
|
||||||
@@ -2597,6 +2597,21 @@ 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.
|
||||||
|
ChannelBindingSummaryResponse:
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
title: Count
|
||||||
|
meter_ids:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
title: Meter Ids
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- count
|
||||||
|
- meter_ids
|
||||||
|
title: ChannelBindingSummaryResponse
|
||||||
ChannelReadingResponse:
|
ChannelReadingResponse:
|
||||||
properties:
|
properties:
|
||||||
recorded_at:
|
recorded_at:
|
||||||
@@ -3050,6 +3065,43 @@ 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.
|
||||||
|
DiscoverChannelResponse:
|
||||||
|
properties:
|
||||||
|
uuid:
|
||||||
|
type: string
|
||||||
|
title: Uuid
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
title: Label
|
||||||
|
unit:
|
||||||
|
type: string
|
||||||
|
title: Unit
|
||||||
|
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
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- uuid
|
||||||
|
- label
|
||||||
|
- unit
|
||||||
|
- latest_value
|
||||||
|
- latest_at
|
||||||
|
- latest_quality
|
||||||
|
title: DiscoverChannelResponse
|
||||||
DiscoverResponse:
|
DiscoverResponse:
|
||||||
properties:
|
properties:
|
||||||
requested:
|
requested:
|
||||||
@@ -3061,11 +3113,21 @@ components:
|
|||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
title: Status
|
title: Status
|
||||||
|
request_id:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: 'null'
|
||||||
|
title: Request Id
|
||||||
detail:
|
detail:
|
||||||
anyOf:
|
anyOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: 'null'
|
- type: 'null'
|
||||||
title: Detail
|
title: Detail
|
||||||
|
channels:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverChannelResponse'
|
||||||
|
type: array
|
||||||
|
title: Channels
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- requested
|
- requested
|
||||||
@@ -3542,10 +3604,14 @@ components:
|
|||||||
total:
|
total:
|
||||||
type: integer
|
type: integer
|
||||||
title: Total
|
title: Total
|
||||||
|
source_status:
|
||||||
|
type: string
|
||||||
|
title: Source Status
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- items
|
- items
|
||||||
- total
|
- total
|
||||||
|
- source_status
|
||||||
title: MeterSourceChannelListResponse
|
title: MeterSourceChannelListResponse
|
||||||
MeterSourceChannelResponse:
|
MeterSourceChannelResponse:
|
||||||
properties:
|
properties:
|
||||||
@@ -3593,6 +3659,8 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
type: array
|
type: array
|
||||||
title: Bound Meter Ids
|
title: Bound Meter Ids
|
||||||
|
binding_summary:
|
||||||
|
$ref: '#/components/schemas/ChannelBindingSummaryResponse'
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- uuid
|
- uuid
|
||||||
@@ -3605,6 +3673,7 @@ components:
|
|||||||
- latest_quality
|
- latest_quality
|
||||||
- binding_count
|
- binding_count
|
||||||
- bound_meter_ids
|
- bound_meter_ids
|
||||||
|
- binding_summary
|
||||||
title: MeterSourceChannelResponse
|
title: MeterSourceChannelResponse
|
||||||
MeterSourceCreate:
|
MeterSourceCreate:
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from queue import Empty, Queue
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -10,7 +15,7 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.energy import DsmrReading, Meter
|
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
|
||||||
|
|
||||||
_CSRF = "test-csrf-token"
|
_CSRF = "test-csrf-token"
|
||||||
|
|
||||||
@@ -212,6 +217,7 @@ def test_source_and_binding_error_contracts_csrf_timezone_and_dsmr_compatibility
|
|||||||
readings = client.get(f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings")
|
readings = client.get(f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings")
|
||||||
assert readings.status_code == 200
|
assert readings.status_code == 200
|
||||||
assert readings.json()["total"] == 1
|
assert readings.json()["total"] == 1
|
||||||
|
assert readings.json()["items"] == [{"recorded_at": now.isoformat().replace("+00:00", ""), "value": None, "quality": None}]
|
||||||
assert "telegram_id" not in readings.text
|
assert "telegram_id" not in readings.text
|
||||||
latest = client.get("/api/energy/dsmr/latest")
|
latest = client.get("/api/energy/dsmr/latest")
|
||||||
assert latest.status_code == 200
|
assert latest.status_code == 200
|
||||||
@@ -314,7 +320,9 @@ def test_source_channel_binding_response_contract_and_discover_capabilities(auth
|
|||||||
assert discovered.status_code == 200
|
assert discovered.status_code == 200
|
||||||
assert discovered.json() == {
|
assert discovered.json() == {
|
||||||
"requested": False, "supported": True, "status": "managed_by_runtime",
|
"requested": False, "supported": True, "status": "managed_by_runtime",
|
||||||
|
"request_id": None,
|
||||||
"detail": "This source is discovered by its runtime subscription; no connection was opened.",
|
"detail": "This source is discovered by its runtime subscription; no connection was opened.",
|
||||||
|
"channels": [],
|
||||||
}
|
}
|
||||||
channels = client.get(f"/api/energy/sources/{source['uuid']}/channels")
|
channels = client.get(f"/api/energy/sources/{source['uuid']}/channels")
|
||||||
assert channels.status_code == 200
|
assert channels.status_code == 200
|
||||||
@@ -322,8 +330,9 @@ def test_source_channel_binding_response_contract_and_discover_capabilities(auth
|
|||||||
assert channel["uuid"] == channel_uuid
|
assert channel["uuid"] == channel_uuid
|
||||||
assert set(channel) == {
|
assert set(channel) == {
|
||||||
"uuid", "label", "suggested_commodity", "unit", "device_type", "latest_value",
|
"uuid", "label", "suggested_commodity", "unit", "device_type", "latest_value",
|
||||||
"latest_at", "latest_quality", "binding_count", "bound_meter_ids",
|
"latest_at", "latest_quality", "binding_count", "bound_meter_ids", "binding_summary",
|
||||||
}
|
}
|
||||||
|
assert channels.json()["source_status"] == "unknown"
|
||||||
|
|
||||||
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||||
"label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
"label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
||||||
@@ -381,3 +390,261 @@ def test_binding_patch_omitted_null_and_adjacent_half_open_boundaries(auth_datab
|
|||||||
})
|
})
|
||||||
assert adjacent.status_code == 201
|
assert adjacent.status_code == 201
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_warmtelink_discover_and_minute_history_are_bounded_and_private(auth_database, monkeypatch):
|
||||||
|
"""Discover delegates to the manager; readings expose accepted minute samples only."""
|
||||||
|
from app.api.routes.api import meter_sources
|
||||||
|
|
||||||
|
requested: list[int] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
meter_sources.warmtelink_worker_manager, "request_discovery",
|
||||||
|
lambda source_id: requested.append(source_id) or SimpleNamespace(
|
||||||
|
status="completed", request_id=1, detail=None, completed=SimpleNamespace(is_set=lambda: False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(meter_sources.warmtelink_worker_manager, "reconcile", lambda: None)
|
||||||
|
client, engine = _client(auth_database)
|
||||||
|
with client:
|
||||||
|
_login(client)
|
||||||
|
created = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
|
||||||
|
"name": "WarmteLink", "kind": "warmtelink_serial", "config": {"path": "/dev/fake"},
|
||||||
|
})
|
||||||
|
assert created.status_code == 201
|
||||||
|
source_uuid = created.json()["uuid"]
|
||||||
|
now = datetime(2030, 1, 1, 12, 0, 30, tzinfo=UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
source = session.execute(select(MeterSource).where(MeterSource.uuid == source_uuid)).scalar_one()
|
||||||
|
source.status = "online"
|
||||||
|
channel = MeterSourceChannel(
|
||||||
|
source_id=source.id, channel_key="heating", label="Heating", unit="GJ",
|
||||||
|
latest_value=Decimal("7.002"), latest_at=now, latest_quality="unverifiable",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(channel)
|
||||||
|
session.flush()
|
||||||
|
session.add_all([
|
||||||
|
WarmteLinkReading(
|
||||||
|
channel_id=channel.id, recorded_at=now - timedelta(minutes=1), received_at=now,
|
||||||
|
value=Decimal("7.001"), unit="GJ", quality="unverifiable", equipment_fingerprint="masked",
|
||||||
|
),
|
||||||
|
WarmteLinkReading(
|
||||||
|
channel_id=channel.id, recorded_at=now, received_at=now,
|
||||||
|
value=Decimal("7.002"), unit="GJ", quality="unverifiable", equipment_fingerprint="masked",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
channel_uuid = channel.uuid
|
||||||
|
|
||||||
|
discover = client.post(f"/api/energy/sources/{source_uuid}/discover", headers={"X-CSRF-Token": _CSRF})
|
||||||
|
assert discover.status_code == 200
|
||||||
|
assert discover.json()["status"] == "completed"
|
||||||
|
assert requested and "fingerprint" not in discover.text and "channel_key" not in discover.text
|
||||||
|
assert discover.json()["channels"][0]["latest_quality"] == "unverifiable"
|
||||||
|
|
||||||
|
history = client.get(
|
||||||
|
f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings",
|
||||||
|
params={"from": "2030-01-01T11:59:00Z", "to": "2030-01-01T12:01:00Z", "limit": 1},
|
||||||
|
)
|
||||||
|
assert history.status_code == 200
|
||||||
|
assert history.json()["total"] == 1
|
||||||
|
assert history.json()["items"] == [{
|
||||||
|
"recorded_at": "2030-01-01T11:59:30", "value": "7.001", "quality": "unverifiable",
|
||||||
|
}]
|
||||||
|
assert client.get(
|
||||||
|
f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings",
|
||||||
|
params={"from": "2030-01-01T12:01:00Z", "to": "2030-01-01T12:00:00Z"},
|
||||||
|
).status_code == 422
|
||||||
|
assert client.get(
|
||||||
|
f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings", params={"limit": 0}
|
||||||
|
).status_code == 422
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_warmtelink_discover_auth_csrf_disabled_and_source_ownership(auth_database, monkeypatch):
|
||||||
|
from app.api.routes.api import meter_sources
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
meter_sources.warmtelink_worker_manager, "request_discovery",
|
||||||
|
lambda _source_id: SimpleNamespace(
|
||||||
|
status="pending", request_id=1, detail=None, completed=SimpleNamespace(is_set=lambda: False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(meter_sources.warmtelink_worker_manager, "reconcile", lambda: None)
|
||||||
|
client, engine = _client(auth_database)
|
||||||
|
with client:
|
||||||
|
serial = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
|
||||||
|
"name": "Serial", "kind": "warmtelink_serial", "enabled": False, "config": {"path": "/dev/fake"},
|
||||||
|
})
|
||||||
|
assert serial.status_code == 401 # no session yet
|
||||||
|
_login(client)
|
||||||
|
serial = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
|
||||||
|
"name": "Serial", "kind": "warmtelink_serial", "enabled": False, "config": {"path": "/dev/fake"},
|
||||||
|
})
|
||||||
|
other = _create_source(client)
|
||||||
|
channel_uuid = _add_channel(engine, other["uuid"])
|
||||||
|
assert client.post(f"/api/energy/sources/{serial.json()['uuid']}/discover").status_code == 403
|
||||||
|
disabled = client.post(
|
||||||
|
f"/api/energy/sources/{serial.json()['uuid']}/discover", headers={"X-CSRF-Token": _CSRF}
|
||||||
|
)
|
||||||
|
assert disabled.status_code == 200 and disabled.json()["status"] == "error"
|
||||||
|
assert client.get(
|
||||||
|
f"/api/energy/sources/{serial.json()['uuid']}/channels/{channel_uuid}/readings"
|
||||||
|
).status_code == 404
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_manager_is_source_scoped_and_never_replaces_a_worker(auth_database):
|
||||||
|
"""The real manager queues requests on one fake read-only serial owner."""
|
||||||
|
from app.services.warmtelink_worker import WarmteLinkWorkerManager
|
||||||
|
|
||||||
|
engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False})
|
||||||
|
with Session(engine) as session:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
source = MeterSource(
|
||||||
|
name="Serial", kind="warmtelink_serial", enabled=True, config={"path": "/dev/fake"},
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
session.commit()
|
||||||
|
source_id = source.id
|
||||||
|
|
||||||
|
class FakeReadOnlyWorker:
|
||||||
|
instances: list["FakeReadOnlyWorker"] = []
|
||||||
|
|
||||||
|
def __init__(self, _source_id, _config, **_kwargs):
|
||||||
|
self.requests = []
|
||||||
|
self.thread = SimpleNamespace(is_alive=lambda: True)
|
||||||
|
self.__class__.instances.append(self)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def join(self, timeout=5):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def request_discovery(self, request):
|
||||||
|
self.requests.append(request)
|
||||||
|
|
||||||
|
manager = WarmteLinkWorkerManager(
|
||||||
|
session_factory=lambda: Session(engine), worker_factory=FakeReadOnlyWorker,
|
||||||
|
)
|
||||||
|
manager.reconcile()
|
||||||
|
assert manager.worker_count == 1
|
||||||
|
first = manager.request_discovery(source_id)
|
||||||
|
assert first.status == "pending"
|
||||||
|
worker = FakeReadOnlyWorker.instances[0]
|
||||||
|
assert len(worker.requests) == 1
|
||||||
|
# A client timing out/cancelling leaves the queued request and worker alone;
|
||||||
|
# completing it later cannot open another descriptor or create bindings.
|
||||||
|
worker.requests[0].status = "completed"
|
||||||
|
worker.requests[0].completed.set()
|
||||||
|
results = []
|
||||||
|
threads = [threading.Thread(target=lambda: results.append(manager.request_discovery(source_id))) for _ in range(2)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
assert manager.worker_count == 1
|
||||||
|
assert len(FakeReadOnlyWorker.instances) == 1
|
||||||
|
assert len(worker.requests) == 3
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(Meter).count() == 0
|
||||||
|
assert session.query(MeterSourceBinding).count() == 0
|
||||||
|
manager.shutdown()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_warmtelink_discovery_waits_for_admission_and_keeps_its_serial_owner(
|
||||||
|
auth_database, monkeypatch,
|
||||||
|
):
|
||||||
|
"""A rejected candidate is neither a discovery success nor exposed metadata."""
|
||||||
|
from app.integrations.p1 import dsmr_crc16
|
||||||
|
from app.services import warmtelink_worker
|
||||||
|
from app.services.warmtelink_worker import WarmteLinkWorkerManager
|
||||||
|
|
||||||
|
engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False})
|
||||||
|
with Session(engine) as session:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
source = MeterSource(
|
||||||
|
name="Serial", kind="warmtelink_serial", enabled=True, config={"path": "/dev/fake"},
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
session.commit()
|
||||||
|
source_id = source.id
|
||||||
|
|
||||||
|
def frame(second: int, *, crc: bool = False) -> bytes:
|
||||||
|
body = (
|
||||||
|
b"/WARMTE\r\n"
|
||||||
|
+ f"0-0:1.0.0(2608221200{second:02d}S)\r\n".encode()
|
||||||
|
+ b"0-0:96.1.1(REDACTED)\r\n"
|
||||||
|
+ b"0-1:24.1.0(006)\r\n"
|
||||||
|
+ b"0-1:96.1.0(REDACTED)\r\n"
|
||||||
|
+ f"0-1:24.2.1(2608221200{second:02d}S)(5.900*m3)\r\n".encode()
|
||||||
|
+ b"0-2:24.1.0(012)\r\n"
|
||||||
|
+ b"0-2:96.1.0(REDACTED)\r\n"
|
||||||
|
+ f"0-2:24.2.1(2608221200{second:02d}S)(0.017*GJ)\r\n".encode()
|
||||||
|
)
|
||||||
|
payload = body + b"!"
|
||||||
|
return payload + (f"{dsmr_crc16(payload):04X}".encode() if crc else b"") + b"\r\n"
|
||||||
|
|
||||||
|
class FakeReadOnlySerial:
|
||||||
|
instances: list["FakeReadOnlySerial"] = []
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.frames: Queue[bytes] = Queue()
|
||||||
|
self.closed = False
|
||||||
|
self.__class__.instances.append(self)
|
||||||
|
|
||||||
|
def read(self, _size: int = 1) -> bytes:
|
||||||
|
try:
|
||||||
|
return self.frames.get(timeout=0.01)
|
||||||
|
except Empty:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(warmtelink_worker, "_DISCOVERY_TIMEOUT_SECONDS", 0.15)
|
||||||
|
monkeypatch.setattr(warmtelink_worker, "_DISCOVERY_WAIT_SECONDS", 0.02)
|
||||||
|
manager = WarmteLinkWorkerManager(
|
||||||
|
session_factory=lambda: Session(engine), serial_factory=lambda _config: FakeReadOnlySerial(),
|
||||||
|
)
|
||||||
|
manager.reconcile()
|
||||||
|
serial = FakeReadOnlySerial.instances[0]
|
||||||
|
|
||||||
|
request = manager.request_discovery(source_id)
|
||||||
|
assert request.status == "pending"
|
||||||
|
serial.frames.put(frame(0)) # First unverifiable candidate is not admitted.
|
||||||
|
time.sleep(0.04)
|
||||||
|
assert not request.completed.is_set()
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(MeterSourceChannel).filter_by(source_id=source_id).count() == 0
|
||||||
|
assert session.query(MeterSourceBinding).count() == 0
|
||||||
|
|
||||||
|
serial.frames.put(frame(10)) # Strictly continuous successor admits both channels.
|
||||||
|
assert request.completed.wait(1)
|
||||||
|
assert request.status == "completed"
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert session.query(MeterSourceChannel).filter_by(source_id=source_id).count() == 2
|
||||||
|
assert session.query(MeterSourceBinding).count() == 0
|
||||||
|
|
||||||
|
rejected = manager.request_discovery(source_id)
|
||||||
|
assert rejected.status == "pending"
|
||||||
|
serial.frames.put(b"/malformed!\r\n")
|
||||||
|
assert rejected.completed.wait(1)
|
||||||
|
assert rejected.status == "error" and rejected.detail == "Discovery timed out."
|
||||||
|
assert manager.worker_count == 1 and len(FakeReadOnlySerial.instances) == 1
|
||||||
|
|
||||||
|
recovered = manager.request_discovery(source_id)
|
||||||
|
serial.frames.put(frame(20, crc=True))
|
||||||
|
assert recovered.completed.wait(1)
|
||||||
|
assert recovered.status == "completed"
|
||||||
|
assert manager.worker_count == 1 and len(FakeReadOnlySerial.instances) == 1
|
||||||
|
manager.shutdown()
|
||||||
|
assert serial.closed
|
||||||
|
engine.dispose()
|
||||||
|
|||||||
Reference in New Issue
Block a user