This commit is contained in:
Lars Nolden
2026-07-24 13:57:13 +02:00
parent fc8813c14a
commit 50a9ee8c58
16 changed files with 206 additions and 32 deletions
+51 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import statistics
import time
from collections import deque
from dataclasses import dataclass, field
@@ -42,6 +43,37 @@ class Device:
default_factory=lambda: deque(maxlen=500)
)
_last_count_at: float = 0.0 # for de-duplicating multi-meter DLL bursts
# arrival times of counted telegrams, for estimating the send interval
rx_times: Deque[float] = field(default_factory=lambda: deque(maxlen=64))
@property
def intervals(self) -> List[float]:
t = list(self.rx_times)
return [b - a for a, b in zip(t, t[1:])]
@property
def interval_samples(self) -> int:
return max(0, len(self.rx_times) - 1)
@property
def interval_estimate(self) -> Optional[float]:
"""Best estimate of the transmit period: median of observed gaps.
Median is robust to missed telegrams (which show up as gaps that are
multiples of the true period) and to the odd retransmission burst.
"""
gaps = self.intervals
return statistics.median(gaps) if gaps else None
@property
def interval_last(self) -> Optional[float]:
gaps = self.intervals
return gaps[-1] if gaps else None
@property
def interval_min(self) -> Optional[float]:
gaps = self.intervals
return min(gaps) if gaps else None
@property
def encryption(self) -> str:
@@ -113,6 +145,20 @@ class DeviceStore:
def ordered(self) -> List[Device]:
return [self.devices[i] for i in self.order]
@staticmethod
def _note_telegram(dev: Device, now: float) -> None:
"""Count one telegram and record its arrival, de-duplicating the
multi-line burst a single telegram produces (DLL[+DLL]+ELL+TPL+JSON,
all within microseconds) so counts and the interval stay accurate.
Driven by *any* telegram-level event, so a DLL line we fail to parse
can't silently zero out the count and send-interval.
"""
if dev._last_count_at == 0.0 or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW:
dev.count += 1
dev.rx_times.append(now)
dev._last_count_at = now
def _get_or_create(self, device_id: str, now: float) -> Tuple[Device, bool]:
dev = self.devices.get(device_id)
if dev is None:
@@ -134,11 +180,7 @@ class DeviceStore:
if ev.get("rssi") is not None:
dev.rssi = ev["rssi"]
dev.last_seen = now
# Count once per telegram, ignoring the duplicate DLL burst from a
# second matching meter.
if is_new or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW:
dev.count += 1
dev._last_count_at = now
self._note_telegram(dev, now)
return dev, is_new
def apply_ell(self, ev: dict, device_id: Optional[str]) -> Optional[Device]:
@@ -192,4 +234,8 @@ class DeviceStore:
if from_key:
dev.key_decrypted = True
dev.last_seen = now
# Also drive the counter from JSON, so telegrams still count even if
# their DLL line failed to parse. The dedup window collapses the DLL
# and its JSON (same telegram) into one.
self._note_telegram(dev, now)
return dev, is_new