diff --git a/README.md b/README.md index 01375c4..5c2b038 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ Keys: | `q` | quit | Details include manufacturer, media/type, driver, version, RSSI, first/last -seen, telegram count, the encryption in use (OMS security mode 5 = AES-CBC, +seen, telegram count, the estimated **send interval** (median of the gaps +between telegrams, so missed transmissions don't skew it — needs ≥2 telegrams), +the encryption in use (OMS security mode 5 = AES-CBC, mode 7 = AES-CTR, or ELL AES-CTR), TPL/ELL layer info, and — once decoded — the latest measurement values with a timestamp. Unencrypted meters show as **open** (📖, readable without a key); encrypted ones are **locked** (🔒) until you add a diff --git a/oms-tui/oms_tui/__pycache__/app.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/app.cpython-313.pyc index abfcdcc..3d0bca3 100644 Binary files a/oms-tui/oms_tui/__pycache__/app.cpython-313.pyc and b/oms-tui/oms_tui/__pycache__/app.cpython-313.pyc differ diff --git a/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc index e2f787a..dff6c73 100644 Binary files a/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc and b/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc differ diff --git a/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc index db4053a..319658b 100644 Binary files a/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc and b/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc differ diff --git a/oms-tui/oms_tui/__pycache__/parser.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/parser.cpython-313.pyc index c0c5517..c96c637 100644 Binary files a/oms-tui/oms_tui/__pycache__/parser.cpython-313.pyc and b/oms-tui/oms_tui/__pycache__/parser.cpython-313.pyc differ diff --git a/oms-tui/oms_tui/app.py b/oms-tui/oms_tui/app.py index 8160006..5bed94e 100644 --- a/oms-tui/oms_tui/app.py +++ b/oms-tui/oms_tui/app.py @@ -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)) ) diff --git a/oms-tui/oms_tui/csvio.py b/oms-tui/oms_tui/csvio.py index bfa0c0c..807e87f 100644 --- a/oms-tui/oms_tui/csvio.py +++ b/oms-tui/oms_tui/csvio.py @@ -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, diff --git a/oms-tui/oms_tui/models.py b/oms-tui/oms_tui/models.py index 7bf01ae..4517a66 100644 --- a/oms-tui/oms_tui/models.py +++ b/oms-tui/oms_tui/models.py @@ -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 diff --git a/oms-tui/oms_tui/parser.py b/oms-tui/oms_tui/parser.py index ea6fb8f..fa53a05 100644 --- a/oms-tui/oms_tui/parser.py +++ b/oms-tui/oms_tui/parser.py @@ -27,7 +27,9 @@ _DLL_RE = re.compile( r"M=(?P[0-9a-fA-F]+) \((?P[A-Z?]+)\) " r"A=(?P\w+) " r"VER=(?P[0-9a-fA-F]+) " - r"TYPE=(?P[0-9a-fA-F]+) \((?P[^)]*)\) " + # media can itself contain parentheses, e.g. "Radio converter (meter side)"; + # match lazily up to the " (driver ...)" that always follows. + r"TYPE=(?P[0-9a-fA-F]+) \((?P.*?)\) " r"\(driver (?P\w+)\)" r"(?:.*?RSSI=(?P-?\d+))?" ) diff --git a/oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc b/oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc index 946074f..d63539a 100644 Binary files a/oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc and b/oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc differ diff --git a/oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc b/oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc index f822e31..0a8c983 100644 Binary files a/oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc and b/oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc differ diff --git a/oms-tui/tests/__pycache__/test_parser.cpython-313-pytest-9.0.3.pyc b/oms-tui/tests/__pycache__/test_parser.cpython-313-pytest-9.0.3.pyc index 4f7fd46..4df6af6 100644 Binary files a/oms-tui/tests/__pycache__/test_parser.cpython-313-pytest-9.0.3.pyc and b/oms-tui/tests/__pycache__/test_parser.cpython-313-pytest-9.0.3.pyc differ diff --git a/oms-tui/tests/test_app.py b/oms-tui/tests/test_app.py index c204fd8..7a36809 100644 --- a/oms-tui/tests/test_app.py +++ b/oms-tui/tests/test_app.py @@ -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) diff --git a/oms-tui/tests/test_models_csv.py b/oms-tui/tests/test_models_csv.py index 9aed0fe..2ec8456 100644 --- a/oms-tui/tests/test_models_csv.py +++ b/oms-tui/tests/test_models_csv.py @@ -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")) diff --git a/oms-tui/tests/test_parser.py b/oms-tui/tests/test_parser.py index 6f13684..5f358ce 100644 --- a/oms-tui/tests/test_parser.py +++ b/oms-tui/tests/test_parser.py @@ -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 " diff --git a/oms_devices.csv b/oms_devices.csv index cd3f747..c4070ec 100644 --- a/oms_devices.csv +++ b/oms_devices.csv @@ -1,25 +1,33 @@ -id,manufacturer,manufacturer_name,media,driver,version,first_seen,last_seen,count,rssi,status,aes_key -23252256,ITW,Itron,Water meter,itron,00,2026-07-24 12:13:32,2026-07-24 12:18:31,2,122,locked, -61379612,,,warm water,qwaterv2,,2026-07-24 12:13:38,2026-07-24 12:19:30,0,,open, -61471660,QDS,Qundis,water,qwaterv2,1d,2026-07-24 12:13:39,2026-07-24 12:19:22,5,124,open, -10179914,ITW,Itron,water,itron,00,2026-07-24 12:14:11,2026-07-24 12:19:11,2,46,open, -23252389,ITW,Itron,Water meter,itron,00,2026-07-24 12:14:38,2026-07-24 12:19:38,2,130,locked, -37287397,,,radio converter (meter side),qheatv2,,2026-07-24 12:14:39,2026-07-24 12:20:15,0,,open, -09237338,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:14:52,2026-07-24 12:19:52,2,61,locked, -68544330,,,heat,qheatv2,,2026-07-24 12:14:56,2026-07-24 12:14:56,0,,open, -23252290,ITW,Itron,Water meter,itron,00,2026-07-24 12:15:16,2026-07-24 12:15:16,1,34,locked, -00258334,ITW,Itron,water,itron,00,2026-07-24 12:16:02,2026-07-24 12:16:02,1,120,open, -08936799,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:16:09,2026-07-24 12:16:09,1,73,locked, -23252289,ITW,Itron,Water meter,itron,00,2026-07-24 12:16:17,2026-07-24 12:16:17,1,74,locked, -09155592,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:16:31,2026-07-24 12:16:31,1,57,locked, -02713603,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:17:05,2026-07-24 12:17:05,1,60,locked, -00340954,ITW,Itron,water,itron,00,2026-07-24 12:17:08,2026-07-24 12:17:08,1,85,open, -23252254,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:08,2026-07-24 12:17:08,1,46,locked, -23252292,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:09,2026-07-24 12:17:09,1,67,locked, -00227616,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:17:21,2026-07-24 12:17:21,1,37,locked, -23252281,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:33,2026-07-24 12:17:33,1,123,locked, -23252286,ITW,Itron,Water meter,itron,00,2026-07-24 12:17:54,2026-07-24 12:17:54,1,100,locked, -00093643,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:18:07,2026-07-24 12:18:07,1,51,locked, -00124993,ITW,Itron,water,itron,00,2026-07-24 12:18:16,2026-07-24 12:18:16,1,34,open, -00065762,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:18:31,2026-07-24 12:18:31,1,51,locked, -00257608,ITW,Itron,water,itron,00,2026-07-24 12:19:08,2026-07-24 12:19:08,1,39,open, +id,manufacturer,manufacturer_name,media,driver,version,first_seen,last_seen,count,rssi,interval_s,interval_samples,encryption,security_mode,status,aes_key +37287397,,,radio converter (meter side),qheatv2,,2026-07-24 12:35:10,2026-07-24 13:27:32,0,,,0,none,,open, +23252290,ITW,Itron,Water meter,itron,00,2026-07-24 12:35:17,2026-07-24 13:15:16,8,52,300.1,7,mode 5 (AES-CBC),5,locked, +00258334,ITW,Itron,water,itron,00,2026-07-24 12:36:02,2026-07-24 13:26:03,11,122,300.0,10,mode 5 (AES-CBC),5,locked, +61379612,,,warm water,qwaterv2,,2026-07-24 12:36:03,2026-07-24 13:26:46,0,,,0,none,,open, +08936799,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:36:08,2026-07-24 13:21:08,10,83,300.2,9,mode 5 (AES-CBC),5,locked, +23252289,ITW,Itron,Water meter,itron,00,2026-07-24 12:36:17,2026-07-24 13:21:17,7,104,300.6,6,mode 5 (AES-CBC),5,locked, +61471660,QDS,Qundis,water,qwaterv2,1d,2026-07-24 12:36:21,2026-07-24 13:27:45,35,118,109.5,34,mode 5 (AES-CBC),5,locked, +09155592,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:36:32,2026-07-24 13:21:32,10,51,300.2,9,mode 5 (AES-CBC),5,locked, +23252244,ITW,Itron,Water meter,itron,00,2026-07-24 12:36:49,2026-07-24 13:26:50,11,126,300.1,10,mode 5 (AES-CBC),5,locked, +02713603,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:37:05,2026-07-24 13:17:06,9,60,300.1,8,mode 5 (AES-CBC),5,locked, +00340954,ITW,Itron,water,itron,00,2026-07-24 12:37:08,2026-07-24 13:17:08,9,83,300.0,8,mode 5 (AES-CBC),5,locked, +23252292,ITW,Itron,Water meter,itron,00,2026-07-24 12:37:09,2026-07-24 13:22:08,10,118,300.1,9,mode 5 (AES-CBC),5,locked, +68544330,,,heat,qheatv2,,2026-07-24 12:37:20,2026-07-24 13:22:13,0,,,0,none,,open, +23252281,ITW,Itron,Water meter,itron,00,2026-07-24 12:37:32,2026-07-24 13:27:32,11,101,300.1,10,mode 5 (AES-CBC),5,locked, +23252286,ITW,Itron,Water meter,itron,00,2026-07-24 12:37:53,2026-07-24 13:27:53,11,51,300.0,10,mode 5 (AES-CBC),5,locked, +00093643,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:38:06,2026-07-24 13:23:05,10,97,300.1,9,mode 5 (AES-CBC),5,locked, +00124993,ITW,Itron,water,itron,00,2026-07-24 12:38:15,2026-07-24 13:23:15,10,78,300.0,9,mode 5 (AES-CBC),5,locked, +00065762,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:38:30,2026-07-24 13:23:30,10,55,300.0,9,mode 5 (AES-CBC),5,locked, +00257608,ITW,Itron,water,itron,00,2026-07-24 12:39:08,2026-07-24 13:24:08,6,33,599.6,5,mode 5 (AES-CBC),5,locked, +10179914,ITW,Itron,water,itron,00,2026-07-24 12:39:11,2026-07-24 13:24:11,10,59,299.8,9,mode 5 (AES-CBC),5,locked, +23252389,ITW,Itron,Water meter,itron,00,2026-07-24 12:39:38,2026-07-24 13:24:38,9,122,300.1,8,mode 5 (AES-CBC),5,locked, +09237338,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:39:51,2026-07-24 13:14:51,8,30,299.9,7,mode 5 (AES-CBC),5,locked, +23252380,ITW,Itron,Water meter,itron,00,2026-07-24 12:40:47,2026-07-24 13:25:47,4,60,599.9,3,mode 5 (AES-CBC),5,locked, +02548773,ITW,Itron,cold water,itron,00,2026-07-24 12:42:01,2026-07-24 13:22:00,9,49,299.8,8,mode 5 (AES-CBC),5,locked, +23252254,ITW,Itron,Water meter,itron,00,2026-07-24 12:42:08,2026-07-24 13:22:09,8,77,300.2,7,mode 5 (AES-CBC),5,locked, +09710983,ITW,Itron,Cold water meter,itron,00,2026-07-24 12:42:51,2026-07-24 13:07:51,4,34,300.0,3,mode 5 (AES-CBC),5,locked, +23252256,ITW,Itron,Water meter,itron,00,2026-07-24 12:43:31,2026-07-24 13:23:31,9,132,300.0,8,mode 5 (AES-CBC),5,locked, +23252283,ITW,Itron,Water meter,itron,00,2026-07-24 13:00:48,2026-07-24 13:15:48,4,67,299.9,3,mode 5 (AES-CBC),5,locked, +00227417,ITW,Itron,Cold water meter,itron,00,2026-07-24 13:05:16,2026-07-24 13:10:16,2,36,300.0,1,mode 5 (AES-CBC),5,locked, +00227616,ITW,Itron,Cold water meter,itron,00,2026-07-24 13:07:22,2026-07-24 13:17:22,3,29,300.0,2,mode 5 (AES-CBC),5,locked, +00287826,ITW,Itron,water,itron,00,2026-07-24 13:10:50,2026-07-24 13:15:51,2,37,300.3,1,mode 5 (AES-CBC),5,locked, +09710957,ITW,Itron,Cold water meter,itron,00,2026-07-24 13:11:07,2026-07-24 13:11:07,1,16,,0,mode 5 (AES-CBC),5,locked,