M8-R04: interpret WarmteLink timestamps as Amsterdam wall time
This commit is contained in:
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import re
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -52,7 +53,14 @@ class WarmteLinkIngestor:
|
||||
self._clock = clock or (lambda: datetime.now(UTC))
|
||||
self._previous: dict[int, _FrameSnapshot] = {}
|
||||
|
||||
def ingest(self, session: Session, *, source_id: int, telegram: P1Telegram) -> bool:
|
||||
def ingest(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
source_id: int,
|
||||
telegram: P1Telegram,
|
||||
received_at: datetime | None = None,
|
||||
) -> bool:
|
||||
"""Apply one parsed telegram in the caller's transaction.
|
||||
|
||||
Returns whether the frame was admitted. Callers that own a session
|
||||
@@ -65,44 +73,47 @@ class WarmteLinkIngestor:
|
||||
if source.kind != "warmtelink_serial":
|
||||
raise ValueError("Source is not a WarmteLink serial source")
|
||||
|
||||
received_at = _utc(self._clock()) if received_at is None else _utc(received_at)
|
||||
try:
|
||||
integrity = _integrity_status(telegram.integrity)
|
||||
except Exception:
|
||||
self._previous.pop(source_id, None)
|
||||
self._diagnose(source, "WarmteLink frame could not be normalized")
|
||||
self._diagnose(source, "WarmteLink frame could not be normalized", now=received_at)
|
||||
return False
|
||||
|
||||
if integrity is IntegrityStatus.INVALID:
|
||||
self._previous.pop(source_id, None)
|
||||
self._diagnose(source, "WarmteLink frame checksum is invalid")
|
||||
self._diagnose(source, "WarmteLink frame checksum is invalid", now=received_at)
|
||||
return False
|
||||
|
||||
try:
|
||||
snapshot = _snapshot(telegram)
|
||||
snapshot = _snapshot(telegram, received_at)
|
||||
fingerprints = _final_fingerprints(snapshot)
|
||||
except Exception:
|
||||
# A parser DTO remains an untrusted boundary. Do not let an
|
||||
# unnormalizable DTO bridge two otherwise matching candidates.
|
||||
self._previous.pop(source_id, None)
|
||||
self._diagnose(source, "WarmteLink frame could not be normalized")
|
||||
self._diagnose(source, "WarmteLink frame could not be normalized", now=received_at)
|
||||
return False
|
||||
if fingerprints is None:
|
||||
self._previous.pop(source_id, None)
|
||||
self._diagnose(source, "WarmteLink frame fingerprint is invalid")
|
||||
self._diagnose(source, "WarmteLink frame fingerprint is invalid", now=received_at)
|
||||
return False
|
||||
if integrity is IntegrityStatus.UNVERIFIABLE:
|
||||
previous = self._previous.get(source_id)
|
||||
if previous is None:
|
||||
self._previous[source_id] = snapshot
|
||||
self._diagnose(source, "Awaiting a second matching unverifiable WarmteLink frame")
|
||||
self._diagnose(
|
||||
source, "Awaiting a second matching unverifiable WarmteLink frame", now=received_at
|
||||
)
|
||||
return False
|
||||
reason = _continuity_problem(previous, snapshot)
|
||||
if reason is not None:
|
||||
self._previous[source_id] = snapshot
|
||||
self._diagnose(source, reason)
|
||||
self._diagnose(source, reason, now=received_at)
|
||||
return False
|
||||
try:
|
||||
self._admit(session, source, snapshot, integrity.value, fingerprints)
|
||||
self._admit(session, source, snapshot, integrity.value, fingerprints, received_at)
|
||||
except Exception:
|
||||
# ``ingest`` owns flushes and may be used directly by tests or
|
||||
# future callers. A failed write must never become a speculative
|
||||
@@ -133,20 +144,25 @@ class WarmteLinkIngestor:
|
||||
a generic source error. Consequently no partial latest/history update
|
||||
survives and the following frame may recover normally.
|
||||
"""
|
||||
received_at = _utc(self._clock())
|
||||
try:
|
||||
telegram = parser(frame)
|
||||
except Exception:
|
||||
self._previous.pop(source_id, None)
|
||||
self._record_error(session_factory, source_id, "WarmteLink frame could not be parsed")
|
||||
self._record_error(
|
||||
session_factory, source_id, "WarmteLink frame could not be parsed", now=received_at
|
||||
)
|
||||
return False
|
||||
try:
|
||||
with session_factory() as session:
|
||||
admitted = self.ingest(session, source_id=source_id, telegram=telegram)
|
||||
admitted = self.ingest(
|
||||
session, source_id=source_id, telegram=telegram, received_at=received_at
|
||||
)
|
||||
session.commit()
|
||||
return admitted
|
||||
except Exception:
|
||||
self._previous.pop(source_id, None)
|
||||
self._record_error(session_factory, source_id, "WarmteLink ingest failed")
|
||||
self._record_error(session_factory, source_id, "WarmteLink ingest failed", now=received_at)
|
||||
return False
|
||||
|
||||
def _admit(
|
||||
@@ -156,8 +172,8 @@ class WarmteLinkIngestor:
|
||||
snapshot: _FrameSnapshot,
|
||||
quality: str,
|
||||
fingerprints: tuple[str, ...],
|
||||
received_at: datetime,
|
||||
) -> None:
|
||||
received_at = _utc(self._clock())
|
||||
for sample, fingerprint in zip(snapshot.samples, fingerprints, strict=True):
|
||||
channel = upsert_discovered_channel(
|
||||
session,
|
||||
@@ -200,27 +216,33 @@ class WarmteLinkIngestor:
|
||||
source.last_error = None
|
||||
source.updated_at = received_at
|
||||
|
||||
def _diagnose(self, source: MeterSource, reason: str) -> None:
|
||||
now = _utc(self._clock())
|
||||
def _diagnose(self, source: MeterSource, reason: str, *, now: datetime | None = None) -> None:
|
||||
now = _utc(self._clock()) if now is None else now
|
||||
source.status = "error"
|
||||
source.last_error = reason
|
||||
source.updated_at = now
|
||||
|
||||
def _record_error(
|
||||
self, session_factory: Callable[[], Session], source_id: int, message: str) -> None:
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
source_id: int,
|
||||
message: str,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
try:
|
||||
with session_factory() as session:
|
||||
source = session.get(MeterSource, source_id)
|
||||
if source is not None:
|
||||
self._diagnose(source, message)
|
||||
self._diagnose(source, message, now=now)
|
||||
session.commit()
|
||||
except Exception:
|
||||
# Error reporting itself must not kill another source worker.
|
||||
return
|
||||
|
||||
|
||||
def _snapshot(telegram: P1Telegram) -> _FrameSnapshot:
|
||||
recorded_at = _parse_timestamp(telegram.timestamp)
|
||||
def _snapshot(telegram: P1Telegram, received_at: datetime) -> _FrameSnapshot:
|
||||
recorded_at = _parse_timestamp(telegram.timestamp, received_at)
|
||||
samples = tuple(_sample(channel) for channel in telegram.channels)
|
||||
if not samples:
|
||||
raise ValueError("WarmteLink telegram contains no cumulative channels")
|
||||
@@ -340,13 +362,30 @@ def _is_canonical_fingerprint(value: str | None) -> bool:
|
||||
return value is not None and _FINGERPRINT_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime:
|
||||
_AMSTERDAM = ZoneInfo("Europe/Amsterdam")
|
||||
_MAX_CLOCK_SKEW = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None, received_at: datetime) -> datetime:
|
||||
if value is None or len(value) != 13 or value[-1] not in {"S", "W"} or not value[:-1].isdigit():
|
||||
raise ValueError("WarmteLink timestamp is unavailable")
|
||||
naive = datetime.strptime(value[:-1], "%y%m%d%H%M%S")
|
||||
# The telegram's S/W marker is evidence of the intended Amsterdam offset.
|
||||
offset = 2 if value[-1] == "S" else 1
|
||||
return (naive.replace(tzinfo=UTC) - timedelta(hours=offset)).replace(tzinfo=UTC)
|
||||
# The S/W marker is only advisory: deployed devices have emitted W while
|
||||
# on CEST. Validate both folds by a UTC round trip, which rejects spring
|
||||
# gaps and leaves one (ordinary) or two (fall-back) real instants.
|
||||
candidates: list[datetime] = []
|
||||
for fold in (0, 1):
|
||||
candidate = naive.replace(tzinfo=_AMSTERDAM, fold=fold).astimezone(UTC)
|
||||
local = candidate.astimezone(_AMSTERDAM)
|
||||
if local.replace(tzinfo=None) == naive and local.fold == fold and candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
if not candidates:
|
||||
raise ValueError("WarmteLink timestamp is unavailable")
|
||||
received_at = _utc(received_at)
|
||||
recorded_at = min(candidates, key=lambda candidate: abs(candidate - received_at))
|
||||
if abs(recorded_at - received_at) > _MAX_CLOCK_SKEW:
|
||||
raise ValueError("WarmteLink timestamp is unavailable")
|
||||
return recorded_at
|
||||
|
||||
|
||||
def _suggestion(unit: str) -> str | None:
|
||||
|
||||
Reference in New Issue
Block a user