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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -18,6 +18,7 @@ from rich.console import Group
from rich.table import Table
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen
@@ -41,6 +42,19 @@ def rel_time(ts: float) -> str:
return f"{d // 3600}h {(d % 3600) // 60}m ago"
def fmt_duration(seconds: Optional[float]) -> str:
if seconds is None:
return "?"
s = int(round(seconds))
if s < 60:
return f"{s}s"
if s < 3600:
return f"{s // 60}m {s % 60:02d}s"
if s < 86400:
return f"{s // 3600}h {(s % 3600) // 60:02d}m"
return f"{s // 86400}d {(s % 86400) // 3600:02d}h"
def stream_text(ts_str: str, ev: dict) -> Text:
if ev.get("type") == "json":
fields = ev.get("fields", {})
@@ -109,6 +123,8 @@ class OmsApp(App[None]):
("a", "show_all", "Show all / stream"),
("enter", "select", "Select device"),
("q", "quit", "Quit"),
# priority so Ctrl+C quits even while a modal Input is focused
Binding("ctrl+c", "quit", "Quit", priority=True, show=False),
]
COLUMNS = [
@@ -337,6 +353,17 @@ class OmsApp(App[None]):
meta.add_row("link (C)", dev.c_field or "?")
meta.add_row("rssi", "?" if dev.rssi is None else f"{dev.rssi} dBm")
meta.add_row("telegrams", str(dev.count))
if dev.interval_samples >= 1:
est = fmt_duration(dev.interval_estimate)
last = fmt_duration(dev.interval_last)
detail = f"~{est}"
extra = f"last {last}, {dev.interval_samples} sample"
extra += "s" if dev.interval_samples != 1 else ""
interval_text = Text(detail, style="bold")
interval_text.append(f" ({extra})", style="dim")
meta.add_row("send interval", interval_text)
else:
meta.add_row("send interval", Text("— (need ≥2 telegrams)", style="dim"))
meta.add_row(
"first seen", time.strftime("%H:%M:%S", time.localtime(dev.first_seen))
)
+7
View File
@@ -19,6 +19,8 @@ FIELDNAMES = [
"last_seen",
"count",
"rssi",
"interval_s",
"interval_samples",
"encryption",
"security_mode",
"status",
@@ -50,6 +52,11 @@ def export_devices(path: str, devices: Iterable[Device]) -> int:
"last_seen": _fmt_time(d.last_seen),
"count": d.count,
"rssi": "" if d.rssi is None else d.rssi,
"interval_s": (
"" if d.interval_estimate is None
else round(d.interval_estimate, 1)
),
"interval_samples": d.interval_samples,
"encryption": d.encryption,
"security_mode": d.sec_mode or "",
"status": d.status,
+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
+3 -1
View File
@@ -27,7 +27,9 @@ _DLL_RE = re.compile(
r"M=(?P<mhex>[0-9a-fA-F]+) \((?P<mfct>[A-Z?]+)\) "
r"A=(?P<id>\w+) "
r"VER=(?P<ver>[0-9a-fA-F]+) "
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>[^)]*)\) "
# media can itself contain parentheses, e.g. "Radio converter (meter side)";
# match lazily up to the " (driver ...)" that always follows.
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>.*?)\) "
r"\(driver (?P<driver>\w+)\)"
r"(?:.*?RSSI=(?P<rssi>-?\d+))?"
)