M8-T11: add WarmteLink discovery and history API

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 4884a19e3d
commit a9458394f2
8 changed files with 671 additions and 38 deletions
+66 -18
View File
@@ -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)
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)
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))
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)
+18
View File
@@ -64,7 +64,23 @@ class DiscoverResponse(BaseModel):
requested: bool
supported: bool
status: str
request_id: int | 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):
@@ -88,11 +104,13 @@ class MeterSourceChannelResponse(BaseModel):
latest_quality: str | None
binding_count: int
bound_meter_ids: list[int]
binding_summary: ChannelBindingSummaryResponse
class MeterSourceChannelListResponse(BaseModel):
items: list[MeterSourceChannelResponse]
total: int
source_status: str
class ChannelReadingResponse(BaseModel):
+96 -3
View File
@@ -8,8 +8,8 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import logging
import threading
from typing import Protocol
@@ -27,6 +27,9 @@ logger = logging.getLogger(__name__)
_BACKOFF_SECONDS = (1, 2, 4, 8, 16, 32, 60)
_JOIN_TIMEOUT_SECONDS = 5
_DISCOVERY_LOCK_TIMEOUT_SECONDS = 0.05
_DISCOVERY_WAIT_SECONDS = 0.1
_DISCOVERY_TIMEOUT_SECONDS = 5
class ReadOnlySerial(Protocol):
@@ -69,6 +72,18 @@ class _WorkerConfig:
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:
"""One interruptible, read-only serial loop for one meter source."""
@@ -88,6 +103,8 @@ class WarmteLinkWorker:
self._clock = clock or _EventClock()
self._serial: ReadOnlySerial | None = None
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
# descriptor and its orderly shutdown must remain visible to the process.
self._thread = threading.Thread(
@@ -105,6 +122,35 @@ class WarmteLinkWorker:
self._stop_event.set()
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:
self._thread.join(timeout)
return not self._thread.is_alive()
@@ -142,6 +188,7 @@ class WarmteLinkWorker:
return
self._serial = device
while not self._stop_event.is_set():
self._expire_discoveries()
chunk = device.read(1024)
if not chunk:
# ``timeout`` reads are normal, but still yield so a bad
@@ -152,13 +199,16 @@ class WarmteLinkWorker:
for frame in frames:
if self._stop_event.is_set():
break
self._ingestor.handle_frame(
admitted = self._ingestor.handle_frame(
self.source_id, frame, session_factory=self._session_factory
)
if admitted:
self._finish_discoveries("completed")
# A complete frame proves transport recovery even if its
# contents are rejected by the privacy/admission layer.
backoff_index = 0
except Exception:
self._finish_discoveries("error", "WarmteLink discovery failed.")
self._record_error("WarmteLink serial connection failed")
delay = _BACKOFF_SECONDS[min(backoff_index, len(_BACKOFF_SECONDS) - 1)]
backoff_index += 1
@@ -181,6 +231,7 @@ class WarmteLinkWorkerManager:
self._workers: dict[int, tuple[_WorkerConfig, WarmteLinkWorker]] = {}
self._lock = threading.Lock()
self._reapers: set[int] = set()
self._next_discovery_id = 0
self._shutting_down = False
@property
@@ -203,6 +254,48 @@ class WarmteLinkWorkerManager:
self._shutting_down = False
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]:
with self._session_factory() as session:
return {
+1 -1
View File
@@ -689,7 +689,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T11 — WarmteLink discover、latest 与 history API
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M8-T10
- **Context**: worker 链路稳定后,把一次发现、状态和规范化历史接到已建立的 source API。
+31 -2
View File
@@ -1453,6 +1453,13 @@ export interface components {
/** Enabled */
enabled: boolean;
};
/** ChannelBindingSummaryResponse */
ChannelBindingSummaryResponse: {
/** Count */
count: number;
/** Meter Ids */
meter_ids: number[];
};
/** ChannelReadingResponse */
ChannelReadingResponse: {
/**
@@ -1753,6 +1760,21 @@ export interface components {
/** Name */
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: {
/** Requested */
@@ -1761,8 +1783,12 @@ export interface components {
supported: boolean;
/** Status */
status: string;
/** Request Id */
request_id?: number | null;
/** Detail */
detail?: string | null;
/** Channels */
channels?: components["schemas"]["DiscoverChannelResponse"][];
};
/**
* DsmrLatestResponse
@@ -2051,6 +2077,8 @@ export interface components {
items: components["schemas"]["MeterSourceChannelResponse"][];
/** Total */
total: number;
/** Source Status */
source_status: string;
};
/** MeterSourceChannelResponse */
MeterSourceChannelResponse: {
@@ -2074,6 +2102,7 @@ export interface components {
binding_count: number;
/** Bound Meter Ids */
bound_meter_ids: number[];
binding_summary: components["schemas"]["ChannelBindingSummaryResponse"];
};
/** MeterSourceCreate */
MeterSourceCreate: {
@@ -3977,8 +4006,8 @@ export interface operations {
parameters: {
query?: {
limit?: number;
start?: string | null;
end?: string | null;
from?: string | null;
to?: string | null;
};
header?: never;
path: {
+115 -6
View File
@@ -1945,7 +1945,7 @@
}
},
{
"name": "start",
"name": "from",
"in": "query",
"required": false,
"schema": {
@@ -1958,11 +1958,11 @@
"type": "null"
}
],
"title": "Start"
"title": "From"
}
},
{
"name": "end",
"name": "to",
"in": "query",
"required": false,
"schema": {
@@ -1975,7 +1975,7 @@
"type": "null"
}
],
"title": "End"
"title": "To"
}
}
],
@@ -3462,6 +3462,27 @@
"title": "CatalogEntrySchema",
"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": {
"properties": {
"recorded_at": {
@@ -4055,6 +4076,67 @@
"title": "DeviceInfoSchema",
"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": {
"properties": {
"requested": {
@@ -4069,6 +4151,17 @@
"type": "string",
"title": "Status"
},
"request_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Request Id"
},
"detail": {
"anyOf": [
{
@@ -4079,6 +4172,13 @@
}
],
"title": "Detail"
},
"channels": {
"items": {
"$ref": "#/components/schemas/DiscoverChannelResponse"
},
"type": "array",
"title": "Channels"
}
},
"type": "object",
@@ -4691,12 +4791,17 @@
"total": {
"type": "integer",
"title": "Total"
},
"source_status": {
"type": "string",
"title": "Source Status"
}
},
"type": "object",
"required": [
"items",
"total"
"total",
"source_status"
],
"title": "MeterSourceChannelListResponse"
},
@@ -4781,6 +4886,9 @@
},
"type": "array",
"title": "Bound Meter Ids"
},
"binding_summary": {
"$ref": "#/components/schemas/ChannelBindingSummaryResponse"
}
},
"type": "object",
@@ -4794,7 +4902,8 @@
"latest_at",
"latest_quality",
"binding_count",
"bound_meter_ids"
"bound_meter_ids",
"binding_summary"
],
"title": "MeterSourceChannelResponse"
},
+73 -4
View File
@@ -1467,7 +1467,7 @@ paths:
minimum: 1
default: 100
title: Limit
- name: start
- name: from
in: query
required: false
schema:
@@ -1475,8 +1475,8 @@ paths:
- type: string
format: date-time
- type: 'null'
title: Start
- name: end
title: From
- name: to
in: query
required: false
schema:
@@ -1484,7 +1484,7 @@ paths:
- type: string
format: date-time
- type: 'null'
title: End
title: To
responses:
'200':
description: Successful Response
@@ -2597,6 +2597,21 @@ components:
- enabled
title: CatalogEntrySchema
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:
properties:
recorded_at:
@@ -3050,6 +3065,43 @@ components:
- name
title: DeviceInfoSchema
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:
properties:
requested:
@@ -3061,11 +3113,21 @@ components:
status:
type: string
title: Status
request_id:
anyOf:
- type: integer
- type: 'null'
title: Request Id
detail:
anyOf:
- type: string
- type: 'null'
title: Detail
channels:
items:
$ref: '#/components/schemas/DiscoverChannelResponse'
type: array
title: Channels
type: object
required:
- requested
@@ -3542,10 +3604,14 @@ components:
total:
type: integer
title: Total
source_status:
type: string
title: Source Status
type: object
required:
- items
- total
- source_status
title: MeterSourceChannelListResponse
MeterSourceChannelResponse:
properties:
@@ -3593,6 +3659,8 @@ components:
type: integer
type: array
title: Bound Meter Ids
binding_summary:
$ref: '#/components/schemas/ChannelBindingSummaryResponse'
type: object
required:
- uuid
@@ -3605,6 +3673,7 @@ components:
- latest_quality
- binding_count
- bound_meter_ids
- binding_summary
title: MeterSourceChannelResponse
MeterSourceCreate:
properties:
+270 -3
View File
@@ -2,7 +2,12 @@
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 fastapi.testclient import TestClient
@@ -10,7 +15,7 @@ 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
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
_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")
assert readings.status_code == 200
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
latest = client.get("/api/energy/dsmr/latest")
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.json() == {
"requested": False, "supported": True, "status": "managed_by_runtime",
"request_id": None,
"detail": "This source is discovered by its runtime subscription; no connection was opened.",
"channels": [],
}
channels = client.get(f"/api/energy/sources/{source['uuid']}/channels")
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 set(channel) == {
"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={
"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
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()