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+))?"
)
+9
View File
@@ -48,6 +48,15 @@ async def test_device_appears_and_selects():
assert app.selected_id == "76348799"
@pytest.mark.asyncio
async def test_ctrl_c_quits():
app = OmsApp(device="none", autostart=False)
async with app.run_test() as pilot:
await pilot.press("ctrl+c")
await pilot.pause()
assert app._exit is True
@pytest.mark.asyncio
async def test_set_key_via_modal():
app = OmsApp(device="none", autostart=False)
+57
View File
@@ -74,6 +74,63 @@ def test_keyed_decrypt_marks_decrypted():
assert dev.status == "decrypted"
def test_send_interval_estimate():
store = DeviceStore()
# telegrams at t=0,16,32,64 -> gaps 16,16,32 (one missed) -> median 16
for t in (1000.0, 1016.0, 1032.0, 1064.0):
store.upsert_discovery(_dll("777"), now=t)
dev = store.get("777")
assert dev.interval_samples == 3
assert dev.interval_estimate == 16
assert dev.interval_last == 32
assert dev.interval_min == 16
def test_json_only_still_counts_and_intervals():
# Regression: a device whose DLL line fails to parse still gets counted
# (and its interval inferred) from the JSON telegrams alone.
store = DeviceStore()
for t in (2000.0, 2016.0, 2032.0):
store.apply_json(
{"type": "json", "id": "37287397", "name": "scan", "driver": "qheatv2",
"media": "radio converter (meter side)", "fields": {"total_kwh": 0}},
now=t,
)
dev = store.get("37287397")
assert dev.count == 3
assert dev.interval_samples == 2
assert dev.interval_estimate == 16
def test_dll_and_its_json_count_once():
# The DLL and its own JSON (same telegram, ~same instant) must not double.
store = DeviceStore()
store.upsert_discovery(_dll("123"), now=3000.0)
store.apply_json(
{"type": "json", "id": "123", "name": "scan", "fields": {"x": 1}},
now=3000.05,
)
assert store.get("123").count == 1
def test_single_telegram_has_no_interval():
store = DeviceStore()
store.upsert_discovery(_dll("888"), now=1000.0)
dev = store.get("888")
assert dev.interval_samples == 0
assert dev.interval_estimate is None
def test_duplicate_burst_does_not_pollute_interval():
store = DeviceStore()
store.upsert_discovery(_dll("999"), now=1000.0)
store.upsert_discovery(_dll("999"), now=1000.002) # duplicate DLL burst
store.upsert_discovery(_dll("999"), now=1016.0)
dev = store.get("999")
assert dev.interval_samples == 1
assert dev.interval_estimate == 16 # not ~0 from the burst
def test_unencrypted_device_is_open_not_locked():
store = DeviceStore()
store.upsert_discovery(_dll("333"))
+16
View File
@@ -33,6 +33,22 @@ def test_parse_json():
assert "rssi_dbm" not in ev["fields"]
def test_parse_dll_with_nested_parens_in_media():
# "Radio converter (meter side)" has parentheses inside the media name.
line = (
"(telegram) DLL L=3c C=44 (from meter SND_NR) M=4493 (QDS) A=37027095 "
"VER=23 TYPE=37 (Radio converter (meter side)) (driver qheatv2) DEV= RSSI=0"
)
ev = parse_line(line)
assert ev is not None
assert ev["type"] == "dll"
assert ev["id"] == "37027095"
assert ev["manufacturer"] == "QDS"
assert ev["media"] == "Radio converter (meter side)"
assert ev["driver"] == "qheatv2"
assert ev["rssi"] == 0
def test_parse_ell_encryption():
line = (
"(telegram) ELL CI=8d CC=20 (slow_resp sync) ACC=91 SN=d37cac21 "