From fc8813c14a5374e225a970af2d9cef20303fcacc Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Fri, 24 Jul 2026 12:25:14 +0200 Subject: [PATCH] v1 --- flake.lock | 27 + flake.nix | 153 +++++ oms-tui/oms_tui/__init__.py | 3 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 241 bytes .../oms_tui/__pycache__/app.cpython-313.pyc | Bin 0 -> 34892 bytes .../oms_tui/__pycache__/csvio.cpython-313.pyc | Bin 0 -> 3094 bytes .../__pycache__/manufacturers.cpython-313.pyc | Bin 0 -> 1862 bytes .../__pycache__/models.cpython-313.pyc | Bin 0 -> 9901 bytes .../__pycache__/parser.cpython-313.pyc | Bin 0 -> 5378 bytes .../oms_tui/__pycache__/wmbus.cpython-313.pyc | Bin 0 -> 7153 bytes oms-tui/oms_tui/app.py | 580 ++++++++++++++++++ oms-tui/oms_tui/app.tcss | 70 +++ oms-tui/oms_tui/csvio.py | 77 +++ oms-tui/oms_tui/manufacturers.py | 51 ++ oms-tui/oms_tui/models.py | 195 ++++++ oms-tui/oms_tui/parser.py | 138 +++++ oms-tui/oms_tui/wmbus.py | 121 ++++ oms-tui/pyproject.toml | 23 + oms-tui/sample.msg | 2 + .../test_app.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 11780 bytes ...t_integration.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 5358 bytes ...st_models_csv.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 25292 bytes .../test_parser.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 11661 bytes oms-tui/tests/test_app.py | 84 +++ oms-tui/tests/test_integration.py | 43 ++ oms-tui/tests/test_models_csv.py | 149 +++++ oms-tui/tests/test_parser.py | 64 ++ oms_devices.csv | 25 + 28 files changed, 1805 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 oms-tui/oms_tui/__init__.py create mode 100644 oms-tui/oms_tui/__pycache__/__init__.cpython-313.pyc create mode 100644 oms-tui/oms_tui/__pycache__/app.cpython-313.pyc create mode 100644 oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc create mode 100644 oms-tui/oms_tui/__pycache__/manufacturers.cpython-313.pyc create mode 100644 oms-tui/oms_tui/__pycache__/models.cpython-313.pyc create mode 100644 oms-tui/oms_tui/__pycache__/parser.cpython-313.pyc create mode 100644 oms-tui/oms_tui/__pycache__/wmbus.cpython-313.pyc create mode 100644 oms-tui/oms_tui/app.py create mode 100644 oms-tui/oms_tui/app.tcss create mode 100644 oms-tui/oms_tui/csvio.py create mode 100644 oms-tui/oms_tui/manufacturers.py create mode 100644 oms-tui/oms_tui/models.py create mode 100644 oms-tui/oms_tui/parser.py create mode 100644 oms-tui/oms_tui/wmbus.py create mode 100644 oms-tui/pyproject.toml create mode 100644 oms-tui/sample.msg create mode 100644 oms-tui/tests/__pycache__/test_app.cpython-313-pytest-9.0.3.pyc create mode 100644 oms-tui/tests/__pycache__/test_integration.cpython-313-pytest-9.0.3.pyc create mode 100644 oms-tui/tests/__pycache__/test_models_csv.cpython-313-pytest-9.0.3.pyc create mode 100644 oms-tui/tests/__pycache__/test_parser.cpython-313-pytest-9.0.3.pyc create mode 100644 oms-tui/tests/test_app.py create mode 100644 oms-tui/tests/test_integration.py create mode 100644 oms-tui/tests/test_models_csv.py create mode 100644 oms-tui/tests/test_parser.py create mode 100644 oms_devices.csv diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0b9301e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1784796856, + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..445d6df --- /dev/null +++ b/flake.nix @@ -0,0 +1,153 @@ +{ + description = "SDR toolbox — programs for exploring an RTL-SDR stick"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + lib = pkgs.lib; + + # rtl_wmbus: demodulator that turns raw rtl_sdr I/Q into wM-Bus frames. + # Not in nixpkgs, so build from source (tiny pure-C project). + rtl_wmbus = pkgs.stdenv.mkDerivation { + pname = "rtl-wmbus"; + version = "1.1.0"; + src = pkgs.fetchFromGitHub { + owner = "xaelsouth"; + repo = "rtl-wmbus"; + rev = "1.1.0"; + sha256 = "1f9psbakvi0ymqmlbbnn96cbvd3bfphc90hw6xwid6r184pnzac6"; + }; + enableParallelBuilding = true; + installPhase = '' + runHook preInstall + install -Dm755 build/rtl_wmbus $out/bin/rtl_wmbus + runHook postInstall + ''; + meta.description = "Software defined receiver for wireless M-Bus with RTL-SDR"; + }; + + # wmbusmeters: reads/decodes wM-Bus (OMS) smart-meter telegrams. Dropped + # from nixpkgs, so build from source. At runtime it shells out to + # rtl_sdr | rtl_wmbus, so wrap those onto its PATH. + wmbusmeters = pkgs.stdenv.mkDerivation { + pname = "wmbusmeters"; + version = "3.0.0"; + src = pkgs.fetchFromGitHub { + owner = "wmbusmeters"; + repo = "wmbusmeters"; + rev = "3.0.0"; + sha256 = "1nnirlnb12zdadsvsybj8h2g6fy7hc7ih488i2qaxzinm1faxagp"; + }; + nativeBuildInputs = [ pkgs.pkg-config pkgs.makeWrapper ]; + buildInputs = [ pkgs.rtl-sdr pkgs.libusb1 pkgs.libxml2 ]; + enableParallelBuilding = true; + postPatch = '' + patchShebangs scripts + ''; + installPhase = '' + runHook preInstall + install -Dm755 build/wmbusmeters $out/bin/wmbusmeters + ln -s wmbusmeters $out/bin/wmbusmetersd + wrapProgram $out/bin/wmbusmeters \ + --prefix PATH : ${lib.makeBinPath [ pkgs.rtl-sdr rtl_wmbus ]} + runHook postInstall + ''; + meta.description = "Read wM-Bus / OMS smart meters (water, heat, gas, electricity)"; + }; + + # oms-tui: full-screen TUI for monitoring OMS / wM-Bus meters. Wraps + # wmbusmeters (put on PATH) and adds a live device list, detail view, + # live AES-key entry, and CSV export/import. + oms-tui = pkgs.python3Packages.buildPythonApplication { + pname = "oms-tui"; + version = "0.1.0"; + pyproject = true; + src = ./oms-tui; + build-system = [ pkgs.python3Packages.setuptools ]; + dependencies = [ pkgs.python3Packages.textual ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + nativeCheckInputs = [ + pkgs.python3Packages.pytestCheckHook + pkgs.python3Packages.pytest-asyncio + wmbusmeters # exercises the real decode pipeline in the test suite + ]; + postFixup = '' + wrapProgram $out/bin/oms-tui \ + --prefix PATH : ${lib.makeBinPath [ wmbusmeters ]} + ''; + meta.description = "TUI monitor for OMS / wireless M-Bus smart meters"; + }; + + # wmbus-listen: convenience wrapper around wmbusmeters for OMS discovery. + wmbus-listen = pkgs.writeShellApplication { + name = "wmbus-listen"; + runtimeInputs = [ wmbusmeters ]; + text = '' + # Listen for wM-Bus / OMS smart-meter telegrams on 868.95 MHz via RTL-SDR. + # + # Usage: + # wmbus-listen # discovery: show every telegram heard + # wmbus-listen NAME DRIVER ID KEY # decode one meter's readings + # e.g. wmbus-listen Water multical21 12345678 00112233445566778899AABBCCDDEEFF + # + # Env overrides: + # WMBUS_MODES link modes to listen for (default: c1,t1 — typical OMS) + # WMBUS_FORMAT output format: json | fields | hr (default: json) + modes="''${WMBUS_MODES:-c1,t1}" + format="''${WMBUS_FORMAT:-json}" + if [ "$#" -eq 0 ]; then + echo "Listening for OMS/wM-Bus telegrams (modes: $modes) on 868.95 MHz. Ctrl-C to stop." >&2 + echo "Unknown meters are printed as they are heard — note their id/driver to decode them." >&2 + exec wmbusmeters --format="$format" --listento="$modes" --verbose rtlwmbus + else + exec wmbusmeters --format="$format" --listento="$modes" rtlwmbus "$@" + fi + ''; + }; + + sdrTools = (with pkgs; [ + rtl-sdr # rtl_test, rtl_fm, rtl_power — verify the stick works first + sdrpp # modern spectrum browser with waterfall (run: sdrpp) + gqrx # classic beginner-friendly waterfall receiver + rtl_433 # decode 433/868 MHz sensors: weather stations, doorbells, meters + dump1090-fa # ADS-B aircraft tracking on 1090 MHz with a live web map + multimon-ng # decode POCSAG pagers, AFSK, DTMF (pipe rtl_fm into it) + welle-io # DAB+ digital radio + satdump # NOAA/Meteor weather satellite images + noaa-apt # simple NOAA APT satellite image decoder + # Heavier options — uncomment if wanted: + # sdrangel # kitchen-sink SDR app, many digital modes + # gnuradio # build your own DSP flowgraphs + ]) ++ [ + rtl_wmbus # wM-Bus demodulator (rtl_sdr | rtl_wmbus) + wmbusmeters # decode OMS smart meters + wmbus-listen # convenience: listen for OMS telegrams (see below) + oms-tui # full-screen OMS monitor TUI + ]; + in + { + devShells.${system}.default = pkgs.mkShell { + packages = sdrTools; + shellHook = '' + echo "SDR shell ready. Start with: rtl_test (OMS meters: wmbus-listen)" + ''; + }; + + packages.${system} = { + inherit rtl_wmbus wmbusmeters wmbus-listen oms-tui; + default = oms-tui; + + # Also expose the whole toolbox as one installable package set: + # nix profile install .#sdr-tools + sdr-tools = pkgs.buildEnv { + name = "sdr-tools"; + paths = sdrTools; + }; + }; + }; +} diff --git a/oms-tui/oms_tui/__init__.py b/oms-tui/oms_tui/__init__.py new file mode 100644 index 0000000..5a7f444 --- /dev/null +++ b/oms-tui/oms_tui/__init__.py @@ -0,0 +1,3 @@ +"""OMS / wireless M-Bus smart-meter monitor (TUI).""" + +__version__ = "0.1.0" diff --git a/oms-tui/oms_tui/__pycache__/__init__.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a546985ea4562c346fbc541bbab158ad22dfe28 GIT binary patch literal 241 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 literal 0 HcmV?d00001 diff --git a/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/csvio.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e2f787a1e4a272d7548faed76660f48c154136c6 GIT binary patch literal 3094 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 literal 0 HcmV?d00001 diff --git a/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc b/oms-tui/oms_tui/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db4053ac081a0be1d0353788e4624d6d24ce1fab GIT binary patch literal 9901 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 literal 0 HcmV?d00001 diff --git a/oms-tui/oms_tui/app.py b/oms-tui/oms_tui/app.py new file mode 100644 index 0000000..8160006 --- /dev/null +++ b/oms-tui/oms_tui/app.py @@ -0,0 +1,580 @@ +"""OMS / wireless M-Bus monitor — a Textual TUI around wmbusmeters. + +Left: live list of detected OMS devices. Right: details of the selected device +(or a live stream of all telegrams when nothing is selected). Keys can be set +live and the list exported/imported as CSV (AES keys included) for auto-decrypt. +""" + +from __future__ import annotations + +import argparse +import os +import time +from collections import deque +from typing import Deque, Optional, Tuple + +from rich import box +from rich.console import Group +from rich.table import Table +from rich.text import Text +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.message import Message +from textual.screen import ModalScreen +from textual.widgets import DataTable, Footer, Header, Input, Label, RichLog, Static + +from .csvio import export_devices, import_rows +from .models import Device, DeviceStore +from .wmbus import WMBusSource + +HEX = set("0123456789ABCDEF") + + +def rel_time(ts: float) -> str: + d = max(0, int(time.time() - ts)) + if d < 1: + return "now" + if d < 60: + return f"{d}s ago" + if d < 3600: + return f"{d // 60}m {d % 60}s ago" + return f"{d // 3600}h {(d % 3600) // 60}m ago" + + +def stream_text(ts_str: str, ev: dict) -> Text: + if ev.get("type") == "json": + fields = ev.get("fields", {}) + preview = " ".join(f"{k}={v}" for k, v in list(fields.items())[:5]) + line = Text() + line.append(f"{ts_str} ", style="dim") + line.append("🔓 ", style="green") + line.append(f"{ev['id']} ", style="bold green") + line.append(f"{ev.get('driver', '')} ", style="cyan") + line.append(preview) + return line + line = Text() + line.append(f"{ts_str} ", style="dim") + line.append("📡 ", style="yellow") + line.append(f"{ev['id']} ", style="bold") + line.append(f"{ev.get('manufacturer', '')} ", style="magenta") + line.append(f"{ev.get('media', '')}", style="") + rssi = ev.get("rssi") + if rssi is not None: + line.append(f" RSSI={rssi}", style="dim") + return line + + +class TelegramEvent(Message): + """A parsed telegram event handed off from the wmbusmeters reader.""" + + def __init__(self, ev: dict) -> None: + self.ev = ev + super().__init__() + + +class PromptScreen(ModalScreen[Optional[str]]): + """A single-line modal prompt. Returns the entered string, or None on cancel.""" + + BINDINGS = [("escape", "cancel", "Cancel")] + + def __init__(self, prompt: str, default: str = "") -> None: + super().__init__() + self._prompt = prompt + self._default = default + + def compose(self) -> ComposeResult: + with Vertical(id="dialog"): + yield Label(self._prompt) + yield Input(value=self._default, id="prompt-input") + + def on_mount(self) -> None: + self.query_one(Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self.dismiss(event.value) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class OmsApp(App[None]): + CSS_PATH = "app.tcss" + TITLE = "OMS Monitor" + SUB_TITLE = "wireless M-Bus smart meters" + + BINDINGS = [ + ("k", "set_key", "Set AES key"), + ("e", "export", "Export CSV"), + ("i", "import", "Import CSV"), + ("a", "show_all", "Show all / stream"), + ("enter", "select", "Select device"), + ("q", "quit", "Quit"), + ] + + COLUMNS = [ + ("id", "ID"), + ("manuf", "Manuf"), + ("media", "Media"), + ("last", "Last seen"), + ("msgs", "Msgs"), + ("st", ""), + ] + + def __init__( + self, + device: str = "rtlwmbus", + modes: str = "c1,t1", + wmbusmeters: str = "wmbusmeters", + stdin_data: Optional[bytes] = None, + import_csv: Optional[str] = None, + autostart: bool = True, + ) -> None: + super().__init__() + self.store = DeviceStore() + self.selected_id: Optional[str] = None + self._current_tid: Optional[str] = None # id of the telegram being parsed + self.show_all = True + self.global_stream: Deque[Tuple[str, dict]] = deque(maxlen=1000) + self._autostart = autostart and device != "none" + self._import_csv = import_csv + self.source = WMBusSource( + device=device, modes=modes, wmbusmeters=wmbusmeters, stdin_data=stdin_data + ) + + # ---- layout ----------------------------------------------------------- + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(id="body"): + with Vertical(id="left"): + yield Static("OMS devices (0)", id="left-title") + yield DataTable(id="devices", zebra_stripes=True, cursor_type="row") + with Vertical(id="right"): + with VerticalScroll(id="detail-scroll"): + yield Static(id="detail") + yield Static("Live stream", id="stream-title") + yield RichLog(id="stream", wrap=True, highlight=False, markup=False) + yield Footer() + + def on_mount(self) -> None: + table = self.query_one("#devices", DataTable) + for key, label in self.COLUMNS: + table.add_column(label, key=key) + self._refresh_detail() + self.set_interval(1.0, self._tick) + if self._import_csv: + self._do_import(self._import_csv, announce=False) + if self._autostart: + self.run_worker(self._start_source(), exclusive=False) + + async def _start_source(self) -> None: + keys = {d.id: d.key for d in self.store.devices.values() if d.key} + self.source.set_keys(keys) + self.source.stdin_data = self.source.stdin_data # keep replay if any + await self.source.start(lambda ev: self.post_message(TelegramEvent(ev))) + + async def on_unmount(self) -> None: + await self.source.stop() + + # ---- event handling --------------------------------------------------- + + def on_telegram_event(self, message: TelegramEvent) -> None: + ev = message.ev + etype = ev.get("type") + # ELL/TPL lines have no id of their own; they belong to the telegram + # whose DLL line preceded them. + if etype in ("ell", "tpl"): + apply = self.store.apply_ell if etype == "ell" else self.store.apply_tpl + dev = apply(ev, self._current_tid) + if dev is not None: + self._upsert_row(dev) # encryption may change the status icon + if self.selected_id == dev.id: + self._refresh_detail() + return + if etype not in ("dll", "json"): + return + now = time.time() + ts_str = time.strftime("%H:%M:%S", time.localtime(now)) + if etype == "dll": + self._current_tid = ev["id"] + dev, _ = self.store.upsert_discovery(ev, now) + else: + dev, _ = self.store.apply_json(ev, now) + + item = (ts_str, ev) + self.global_stream.append(item) + dev.telegrams.append(item) + + self._upsert_row(dev) + self.query_one("#left-title", Static).update(f"OMS devices ({len(self.store)})") + + stream = self.query_one("#stream", RichLog) + if self.show_all: + stream.write(stream_text(ts_str, ev)) + elif self.selected_id == dev.id: + stream.write(stream_text(ts_str, ev)) + self._refresh_detail() + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + row_key = getattr(event.row_key, "value", None) or str(event.row_key) + if not self.show_all: + self.selected_id = row_key + self._refresh_detail() + self._repaint_stream() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + row_key = getattr(event.row_key, "value", None) or str(event.row_key) + self.show_all = False + self.selected_id = row_key + self._update_stream_title() + self._refresh_detail() + self._repaint_stream() + + # ---- table ------------------------------------------------------------ + + def _row_cells(self, dev: Device): + return ( + dev.id, + dev.manufacturer or "?", + dev.media or "?", + rel_time(dev.last_seen), + str(dev.count), + dev.status_icon, + ) + + def _upsert_row(self, dev: Device) -> None: + table = self.query_one("#devices", DataTable) + cells = self._row_cells(dev) + if dev.id in {getattr(rk, "value", rk) for rk in table.rows}: + for (col_key, _), value in zip(self.COLUMNS, cells): + table.update_cell(dev.id, col_key, value) + else: + table.add_row(*cells, key=dev.id) + + def _tick(self) -> None: + table = self.query_one("#devices", DataTable) + existing = {getattr(rk, "value", rk) for rk in table.rows} + for dev in self.store.ordered(): + if dev.id in existing: + table.update_cell(dev.id, "last", rel_time(dev.last_seen)) + if not self.show_all and self.selected_id: + self._refresh_detail() + + # ---- detail panel ----------------------------------------------------- + + def _update_stream_title(self) -> None: + title = self.query_one("#stream-title", Static) + if self.show_all or not self.selected_id: + title.update("Live stream — all devices") + else: + title.update(f"Live stream — {self.selected_id}") + + def _refresh_detail(self) -> None: + detail = self.query_one("#detail", Static) + if self.show_all or not self.selected_id: + detail.update(self._summary_renderable()) + return + dev = self.store.get(self.selected_id) + if dev is None: + detail.update(self._summary_renderable()) + return + detail.update(self._device_renderable(dev)) + + def _summary_renderable(self): + total = len(self.store) + decrypted = sum(1 for d in self.store.devices.values() if d.decrypted) + keyed = sum(1 for d in self.store.devices.values() if d.key) + head = Text() + head.append("No device selected\n", style="bold") + head.append("Showing the live stream of all telegrams on the right.\n\n", style="dim") + head.append(f"Detected: {total} ", style="") + head.append(f"keyed: {keyed} ", style="cyan") + head.append(f"decrypted: {decrypted}\n\n", style="green") + head.append("Enter", style="bold") + head.append(" select · ", style="dim") + head.append("k", style="bold") + head.append(" set key · ", style="dim") + head.append("e", style="bold") + head.append(" export · ", style="dim") + head.append("i", style="bold") + head.append(" import · ", style="dim") + head.append("a", style="bold") + head.append(" back to stream", style="dim") + return head + + def _device_renderable(self, dev: Device): + header = Text() + header.append(f"{dev.status_icon} {dev.id}", style="bold") + header.append(f" {dev.status}", style=dev.status_color) + + meta = Table.grid(padding=(0, 2)) + meta.add_column(style="dim", justify="right") + meta.add_column() + mfct = dev.manufacturer_name + if dev.manufacturer and mfct != dev.manufacturer: + mfct = f"{mfct} ({dev.manufacturer})" + meta.add_row("manufacturer", mfct or "?") + meta.add_row("media / type", dev.media or "?") + meta.add_row("driver", dev.driver or "?") + meta.add_row("version", dev.version or "?") + + # encryption / security + enc = dev.encryption + enc_style = "red" if enc == "none" else "magenta" + enc_text = Text(enc, style=enc_style) + if dev.enc_layer: + enc_text.append(f" layer={dev.enc_layer}", style="dim") + if dev.enc_blocks: + enc_text.append(f" blocks={dev.enc_blocks}", style="dim") + meta.add_row("encryption", enc_text) + if dev.sec_mode: + meta.add_row("security mode", str(dev.sec_mode)) + if dev.tpl_ci: + meta.add_row("TPL CI", dev.tpl_ci) + if dev.ell_ci: + meta.add_row("ELL CI", f"{dev.ell_ci} session={dev.ell_sn or '?'}") + + 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)) + meta.add_row( + "first seen", time.strftime("%H:%M:%S", time.localtime(dev.first_seen)) + ) + meta.add_row("last seen", rel_time(dev.last_seen)) + if dev.encrypted or dev.key: + meta.add_row("AES key", dev.key or "— (press k to set)") + else: + meta.add_row("AES key", "— (not needed, unencrypted)") + + parts = [header, Text(""), meta] + if dev.fields: + when = ( + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(dev.last_decoded)) + if dev.last_decoded + else "?" + ) + rel = rel_time(dev.last_decoded) if dev.last_decoded else "?" + # Distinguish a real key-decrypt from a plaintext read of an + # (otherwise) encrypted meter, so we never claim more than we know. + partial = dev.encrypted and dev.fields_source != "key" + if partial: + title = f"Plaintext records (unencrypted part) — {when} ({rel})" + border = "yellow" + else: + title = f"Latest decoded values — {when} ({rel})" + border = "green" if dev.status in ("open", "decrypted") else "blue" + values = Table( + title=title, + title_style="bold", + title_justify="left", + expand=True, + box=box.ROUNDED, + border_style=border, + ) + values.add_column("field", style="cyan") + values.add_column("value", justify="right", style="bold") + for key, value in dev.fields.items(): + values.add_row(key, str(value)) + parts += [Text(""), values] + if partial: + parts.append( + Text("Some records are encrypted — press k to add the AES key.", + style="yellow") + ) + elif dev.key: + parts.append( + Text("\nKey set — waiting for the next telegram to decrypt…", + style="yellow") + ) + elif dev.encrypted: + parts.append( + Text("\nEncrypted. Press k to add this meter's AES key to decrypt.", + style="dim") + ) + else: + parts.append( + Text("\nListening… waiting to decode a telegram from this meter.", + style="dim") + ) + return Group(*parts) + + def _repaint_stream(self) -> None: + stream = self.query_one("#stream", RichLog) + stream.clear() + if self.show_all or not self.selected_id: + source = self.global_stream + else: + dev = self.store.get(self.selected_id) + source = dev.telegrams if dev else deque() + for ts_str, ev in source: + stream.write(stream_text(ts_str, ev)) + + # ---- actions ---------------------------------------------------------- + + def action_show_all(self) -> None: + self.show_all = True + self.selected_id = None + self._update_stream_title() + self._refresh_detail() + self._repaint_stream() + + def action_select(self) -> None: + table = self.query_one("#devices", DataTable) + if table.row_count == 0: + return + try: + row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key + except Exception: + return + self.show_all = False + self.selected_id = getattr(row_key, "value", None) or str(row_key) + self._update_stream_title() + self._refresh_detail() + self._repaint_stream() + + def action_set_key(self) -> None: + if self.show_all or not self.selected_id: + self.notify("Select a device first (Enter).", severity="warning") + return + dev = self.store.get(self.selected_id) + if dev is None: + return + + def handle(value: Optional[str]) -> None: + if value is None: + return + v = value.strip().upper() + if v == "": + dev.key = None + elif len(v) == 32 and all(c in HEX for c in v): + dev.key = v + dev.decrypted = False + else: + self.notify( + "Key must be 32 hex chars (or empty to clear).", severity="error" + ) + return + self._upsert_row(dev) + self._refresh_detail() + self.notify(f"Key {'set' if dev.key else 'cleared'} for {dev.id}. Restarting…") + self.run_worker(self._apply_keys(), exclusive=True) + + self.push_screen( + PromptScreen( + f"AES-128 key for {dev.id} (32 hex chars, empty to clear):", + dev.key or "", + ), + handle, + ) + + async def _apply_keys(self) -> None: + keys = {d.id: d.key for d in self.store.devices.values() if d.key} + self.source.set_keys(keys) + if self.source.running: + await self.source.restart() + + def action_export(self) -> None: + def handle(path: Optional[str]) -> None: + if not path: + return + try: + n = export_devices(path, self.store.ordered()) + except OSError as exc: + self.notify(f"Export failed: {exc}", severity="error") + return + self.notify(f"Exported {n} devices to {path}") + + self.push_screen(PromptScreen("Export CSV to path:", "oms_devices.csv"), handle) + + def action_import(self) -> None: + def handle(path: Optional[str]) -> None: + if not path: + return + self._do_import(path) + + self.push_screen(PromptScreen("Import CSV from path:", "oms_devices.csv"), handle) + + def _do_import(self, path: str, announce: bool = True) -> None: + try: + rows = import_rows(path) + except OSError as exc: + if announce: + self.notify(f"Import failed: {exc}", severity="error") + return + keys_loaded = 0 + for row in rows: + device_id = row["id"] + dev = self.store.get(device_id) + if dev is None: + dev = Device(id=device_id, count=0) + self.store.devices[device_id] = dev + self.store.order.append(device_id) + dev.manufacturer = dev.manufacturer or row.get("manufacturer", "") + dev.media = dev.media or row.get("media", "") + dev.driver = dev.driver or row.get("driver", "") + dev.version = dev.version or row.get("version", "") + key = row.get("aes_key", "") + if key: + dev.key = key.upper() + keys_loaded += 1 + if self.is_running: + self._upsert_row(dev) + if self.is_running: + self.query_one("#left-title", Static).update( + f"OMS devices ({len(self.store)})" + ) + self._refresh_detail() + if announce: + self.notify(f"Imported {keys_loaded} keys from {path}. Restarting…") + self.run_worker(self._apply_keys(), exclusive=True) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="oms-tui", description="Monitor OMS / wireless M-Bus smart meters." + ) + parser.add_argument( + "--device", + default=os.environ.get("OMS_WMBUS_DEVICE", "rtlwmbus"), + help="wmbusmeters device (default: rtlwmbus; drives the RTL-SDR)", + ) + parser.add_argument( + "--listento", + default=os.environ.get("OMS_WMBUS_MODES", "c1,t1"), + help="link modes to listen for (default: c1,t1)", + ) + parser.add_argument( + "--wmbusmeters", default="wmbusmeters", help="path to the wmbusmeters binary" + ) + parser.add_argument( + "--replay", + metavar="FILE", + help="replay an rtlwmbus-format capture instead of using the radio", + ) + parser.add_argument( + "--import", + dest="import_csv", + metavar="FILE", + help="preload AES keys from a CSV before listening", + ) + args = parser.parse_args() + + stdin_data = None + device = args.device + if args.replay: + with open(args.replay, "rb") as f: + stdin_data = f.read() + device = "stdin:rtlwmbus" + + app = OmsApp( + device=device, + modes=args.listento, + wmbusmeters=args.wmbusmeters, + stdin_data=stdin_data, + import_csv=args.import_csv, + ) + app.run() + + +if __name__ == "__main__": + main() diff --git a/oms-tui/oms_tui/app.tcss b/oms-tui/oms_tui/app.tcss new file mode 100644 index 0000000..2f4d5bd --- /dev/null +++ b/oms-tui/oms_tui/app.tcss @@ -0,0 +1,70 @@ +Screen { + layout: vertical; +} + +#body { + height: 1fr; +} + +#left { + width: 44%; + min-width: 34; + border-right: solid $primary; +} + +#left-title { + padding: 0 1; + background: $boost; + color: $text; + text-style: bold; +} + +#devices { + height: 1fr; +} + +#right { + width: 1fr; +} + +#detail-scroll { + height: auto; + max-height: 62%; + border-bottom: solid $primary; + scrollbar-size-vertical: 1; +} + +#detail { + height: auto; + padding: 1 2; +} + +#stream-title { + padding: 0 1; + background: $boost; + color: $text; + text-style: bold; +} + +#stream { + height: 1fr; + padding: 0 1; + background: $surface; +} + +/* Modal prompt for keys / file paths */ +PromptScreen { + align: center middle; +} + +#dialog { + width: 72; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; +} + +#dialog Label { + margin-bottom: 1; +} diff --git a/oms-tui/oms_tui/csvio.py b/oms-tui/oms_tui/csvio.py new file mode 100644 index 0000000..bfa0c0c --- /dev/null +++ b/oms-tui/oms_tui/csvio.py @@ -0,0 +1,77 @@ +"""CSV export/import of the device list, including AES keys.""" + +from __future__ import annotations + +import csv +import time +from typing import Dict, Iterable, List + +from .models import Device + +FIELDNAMES = [ + "id", + "manufacturer", + "manufacturer_name", + "media", + "driver", + "version", + "first_seen", + "last_seen", + "count", + "rssi", + "encryption", + "security_mode", + "status", + "aes_key", +] + + +def _fmt_time(ts: float) -> str: + if not ts: + return "" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) + + +def export_devices(path: str, devices: Iterable[Device]) -> int: + n = 0 + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=FIELDNAMES) + writer.writeheader() + for d in devices: + writer.writerow( + { + "id": d.id, + "manufacturer": d.manufacturer, + "manufacturer_name": d.manufacturer_name, + "media": d.media, + "driver": d.driver, + "version": d.version, + "first_seen": _fmt_time(d.first_seen), + "last_seen": _fmt_time(d.last_seen), + "count": d.count, + "rssi": "" if d.rssi is None else d.rssi, + "encryption": d.encryption, + "security_mode": d.sec_mode or "", + "status": d.status, + "aes_key": d.key or "", + } + ) + n += 1 + return n + + +def import_rows(path: str) -> List[Dict[str, str]]: + """Read a previously exported CSV. Returns a list of row dicts. + + Only ``id`` is required; ``aes_key`` and the metadata columns are optional, + so hand-written key files with just ``id,aes_key`` import fine too. + """ + rows: List[Dict[str, str]] = [] + with open(path, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + device_id = (row.get("id") or "").strip() + if not device_id: + continue + rows.append({k: (v or "").strip() for k, v in row.items()}) + return rows diff --git a/oms-tui/oms_tui/manufacturers.py b/oms-tui/oms_tui/manufacturers.py new file mode 100644 index 0000000..3295448 --- /dev/null +++ b/oms-tui/oms_tui/manufacturers.py @@ -0,0 +1,51 @@ +"""Map wM-Bus 3-letter FLAG manufacturer codes to full names. + +The DLL header only carries the 3-letter FLAG code (e.g. "KAM"). This is a +best-effort lookup of common metering manufacturers; unknown codes fall back +to the code itself. +""" + +_FLAG = { + "ABB": "ABB", + "AMT": "Aquametro", + "APA": "Apator", + "APT": "Apator", + "BME": "BMeters", + "BME": "BMeters", + "DME": "Diehl Metering", + "DWZ": "Lorenz", + "EFE": "Engelmann", + "ELS": "Elster", + "ELV": "Elvaco", + "EMH": "EMH Metering", + "ESY": "EasyMeter", + "GAV": "Carlo Gavazzi", + "GWF": "GWF", + "HYD": "Diehl (Hydrometer)", + "INE": "Innotas", + "ITW": "Itron", + "ITR": "Itron", + "KAM": "Kamstrup", + "KAW": "Kamstrup", + "LUG": "Landis+Gyr", + "LGB": "Landis+Gyr", + "MAD": "Maddalena", + "MTR": "Metrona", + "NZR": "NZR", + "QDS": "Qundis", + "REL": "Relay", + "RKE": "Viterra / Ista", + "SAP": "Sappel", + "SEN": "Sensus", + "SON": "Sontex", + "SPX": "Spanner-Pollux", + "TCH": "Techem", + "WEP": "Weptech", + "ZRI": "Zenner", +} + + +def manufacturer_name(code: str) -> str: + if not code: + return "" + return _FLAG.get(code.upper(), code.upper()) diff --git a/oms-tui/oms_tui/models.py b/oms-tui/oms_tui/models.py new file mode 100644 index 0000000..7bf01ae --- /dev/null +++ b/oms-tui/oms_tui/models.py @@ -0,0 +1,195 @@ +"""In-memory model of discovered OMS devices. Reset on every app start.""" + +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, Dict, List, Optional, Tuple + +from .manufacturers import manufacturer_name + + +@dataclass +class Device: + id: str + manufacturer: str = "" # 3-letter FLAG code, e.g. "KAM" + media: str = "" # e.g. "Cold water meter" + version: str = "" # hex version byte + driver: str = "" # wmbusmeters driver, e.g. "kamwater" + c_field: str = "" # DLL C field (technical) + rssi: Optional[int] = None + # encryption / link-layer detail (from ELL / TPL verbose lines) + enc: str = "" # "AES-CTR", "AES-CBC", or "" (none) + enc_layer: str = "" # "ELL" or "TPL" + sec_mode: int = 0 # TPL security mode number (5, 7, 0=none) + tpl_ci: str = "" # TPL CI byte + ell_ci: str = "" # ELL CI byte + ell_sn: str = "" # ELL session number / SN + enc_blocks: str = "" # number of encrypted TPL blocks (nb=) + first_seen: float = field(default_factory=time.time) + last_seen: float = field(default_factory=time.time) + count: int = 0 + key: Optional[str] = None # AES-128 key (32 hex chars) if configured + encrypted: bool = False # has any telegram used encryption? (sticky) + key_decrypted: bool = False # have we decoded an *encrypted* frame with our key? + decrypted: bool = False # do we have readable values (any source)? + fields_source: str = "" # "key" (real decrypt) or "plain" (unencrypted read) + fields: Dict[str, object] = field(default_factory=dict) # latest decoded values + last_decoded: Optional[float] = None # when `fields` was last refreshed + # recent telegrams as (hh:mm:ss, event) for the live stream + telegrams: Deque[Tuple[str, dict]] = field( + default_factory=lambda: deque(maxlen=500) + ) + _last_count_at: float = 0.0 # for de-duplicating multi-meter DLL bursts + + @property + def encryption(self) -> str: + """Human summary of the encryption in use, incl. the OMS security mode.""" + if self.enc_layer == "ELL" and self.enc: + return f"{self.enc} (ELL)" + if self.sec_mode: + return f"mode {self.sec_mode} ({self.enc})" + if self.enc: + return self.enc + return "none" + + @property + def manufacturer_name(self) -> str: + return manufacturer_name(self.manufacturer) + + @property + def status(self) -> str: + # Encryption status is authoritative (from TPL/ELL), not "do we have data". + if self.key: + return "decrypted" if self.key_decrypted else "key set" + if self.encrypted: + return "locked" # encrypted frames we can't read yet + if self.decrypted: + return "open" # unencrypted meter, readable as-is + return "listening" # seen, not yet classified + + @property + def status_icon(self) -> str: + return { + "decrypted": "🔓", + "open": "📖", + "key set": "🔑", + "locked": "🔒", + "listening": "📡", + }[self.status] + + @property + def status_color(self) -> str: + return { + "decrypted": "green", + "open": "green", + "key set": "yellow", + "locked": "red", + "listening": "cyan", + }[self.status] + + +# A single physical telegram can be logged by several matching meters (a keyed +# meter *and* the wildcard), producing back-to-back DLL lines microseconds +# apart. Real re-transmissions from a meter are seconds apart, so we treat +# same-device DLLs within this window as one telegram. +COUNT_DEDUP_WINDOW = 0.5 + + +class DeviceStore: + """Devices keyed by id, preserving discovery order.""" + + def __init__(self) -> None: + self.devices: Dict[str, Device] = {} + self.order: List[str] = [] + + def __len__(self) -> int: + return len(self.order) + + def get(self, device_id: str) -> Optional[Device]: + return self.devices.get(device_id) + + def ordered(self) -> List[Device]: + return [self.devices[i] for i in self.order] + + def _get_or_create(self, device_id: str, now: float) -> Tuple[Device, bool]: + dev = self.devices.get(device_id) + if dev is None: + dev = Device(id=device_id, first_seen=now, last_seen=now) + self.devices[device_id] = dev + self.order.append(device_id) + return dev, True + return dev, False + + def upsert_discovery(self, ev: dict, now: Optional[float] = None) -> Tuple[Device, bool]: + """Apply a DLL discovery event. Returns (device, is_new). Counts the telegram.""" + now = time.time() if now is None else now + dev, is_new = self._get_or_create(ev["id"], now) + dev.manufacturer = ev.get("manufacturer") or dev.manufacturer + dev.media = ev.get("media") or dev.media + dev.version = ev.get("version") or dev.version + dev.driver = ev.get("driver") or dev.driver + dev.c_field = ev.get("c_field") or dev.c_field + 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 + return dev, is_new + + def apply_ell(self, ev: dict, device_id: Optional[str]) -> Optional[Device]: + """Apply an ELL (extended link layer) event to the current telegram's device.""" + if not device_id: + return None + dev = self.devices.get(device_id) + if dev is None: + return None + dev.ell_ci = ev.get("ci") or dev.ell_ci + dev.ell_sn = ev.get("session") or ev.get("sn") or dev.ell_sn + if ev.get("enc"): + dev.enc = ev["enc"] + dev.enc_layer = "ELL" + dev.encrypted = True + return dev + + def apply_tpl(self, ev: dict, device_id: Optional[str]) -> Optional[Device]: + """Apply a TPL (transport layer) event to the current telegram's device.""" + if not device_id: + return None + dev = self.devices.get(device_id) + if dev is None: + return None + dev.tpl_ci = ev.get("ci") or dev.tpl_ci + # Only let the TPL override encryption if ELL didn't already claim it. + if ev.get("sec_mode"): + if dev.enc_layer != "ELL": + dev.enc = ev.get("enc") or dev.enc + dev.enc_layer = "TPL" + dev.sec_mode = ev["sec_mode"] + dev.enc_blocks = ev.get("nb") or dev.enc_blocks + dev.encrypted = True + return dev + + def apply_json(self, ev: dict, now: Optional[float] = None) -> Tuple[Device, bool]: + """Apply a decrypted JSON telegram event. Does not count (the DLL line does).""" + now = time.time() if now is None else now + dev, is_new = self._get_or_create(ev["id"], now) + dev.driver = ev.get("driver") or dev.driver + dev.media = ev.get("media") or dev.media + if ev.get("fields"): + # The wildcard meter is named "scan"; a keyed meter is "m_". + # Only the latter means we actually decrypted an encrypted frame. + name = ev.get("name") or "" + from_key = bool(name) and name != "scan" + dev.fields = dict(ev["fields"]) + dev.fields_source = "key" if from_key else "plain" + dev.decrypted = True + dev.last_decoded = now + if from_key: + dev.key_decrypted = True + dev.last_seen = now + return dev, is_new diff --git a/oms-tui/oms_tui/parser.py b/oms-tui/oms_tui/parser.py new file mode 100644 index 0000000..ea6fb8f --- /dev/null +++ b/oms-tui/oms_tui/parser.py @@ -0,0 +1,138 @@ +"""Parse wmbusmeters output lines into structured events. + +wmbusmeters is run with ``--format=json --verbose`` plus a wildcard +``scan auto '*' NOKEY`` meter. That produces, for *every* received telegram: + +* a verbose DLL line on stderr (universal discovery), e.g.:: + + (telegram) DLL L=2a C=44 (from meter SND_NR) M=2c2d (KAM) A=76348799 \ + VER=1b TYPE=16 (Cold water meter) (driver kamwater) DEV=rtlwmbus[] RSSI=97 + +* and, when a matching keyed meter is configured, a decrypted JSON line on + stdout, e.g. ``{"_":"telegram","id":"76348799","total_m3":6.408,...}``. + +stdout and stderr are merged, so a single parser handles both. +""" + +from __future__ import annotations + +import json +import re +from typing import Optional + +_DLL_RE = re.compile( + r"\(telegram\) DLL " + r"L=(?P[0-9a-fA-F]+) " + r"C=(?P[0-9a-fA-F]+).*?" + 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[^)]*)\) " + r"\(driver (?P\w+)\)" + r"(?:.*?RSSI=(?P-?\d+))?" +) + +# JSON keys that are metadata, not measurement values. +_META = {"_", "media", "meter", "name", "id", "timestamp", "device", "rssi_dbm"} + +# OMS/EN13757-3 TPL security modes we can name. +_SEC_MODE = {"AES_CBC_IV": 5, "AES_CBC_NO_IV": 4, "AES_CTR": 7, "AES_DES": 2} + + +def _find(pattern: str, line: str) -> str: + m = re.search(pattern, line) + return m.group(1) if m else "" + + +def parse_dll(line: str) -> Optional[dict]: + m = _DLL_RE.search(line) + if not m: + return None + g = m.groupdict() + return { + "type": "dll", + "id": g["id"], + "manufacturer": g["mfct"], + "version": g["ver"], + "media": g["media"], + "driver": g["driver"], + "c_field": g["C"], + "rssi": int(g["rssi"]) if g["rssi"] is not None else None, + } + + +def parse_json_telegram(line: str) -> Optional[dict]: + line = line.strip() + if not line.startswith("{"): + return None + try: + obj = json.loads(line) + except json.JSONDecodeError: + return None + if obj.get("_") != "telegram": + return None + fields = {k: v for k, v in obj.items() if k not in _META} + return { + "type": "json", + "id": str(obj.get("id", "")), + "driver": obj.get("meter", ""), + "media": obj.get("media", ""), + "name": obj.get("name", ""), + "timestamp": obj.get("timestamp", ""), + "rssi": obj.get("rssi_dbm"), + "fields": fields, + } + + +def parse_ell(line: str) -> Optional[dict]: + """Extended Link Layer line — carries ELL-layer encryption (e.g. AES-CTR).""" + if "(telegram) ELL" not in line: + return None + enc = "AES-CTR" if "AES_CTR" in line else ("AES-CBC" if "AES_CBC" in line else "") + return { + "type": "ell", + "ci": _find(r"CI=(\w+)", line), + "cc": _find(r"CC=(\w+)", line), + "sn": _find(r"SN=(\w+)", line), + "session": _find(r"session=(\d+)", line), + "enc": enc, + } + + +def parse_tpl(line: str) -> Optional[dict]: + """Transport Layer line — carries the TPL security mode (5=CBC, 7=CTR).""" + if "(telegram) TPL" not in line: + return None + sec_mode = 0 + enc = "" + for name, mode in _SEC_MODE.items(): + if name in line: + sec_mode = mode + enc = "AES-CBC" if "CBC" in name else ("AES-CTR" if "CTR" in name else name) + break + return { + "type": "tpl", + "ci": _find(r"CI=(\w+)", line), + "acc": _find(r"ACC=(\w+)", line), + "sts": _find(r"STS=(\w+)", line), + "cfg": _find(r"CFG=(\w+)", line), + "nb": _find(r"nb=(\d+)", line), + "sec_mode": sec_mode, + "enc": enc, + } + + +def parse_line(line: str) -> Optional[dict]: + """Return a structured event for a wmbusmeters output line, or None.""" + stripped = line.lstrip() + if stripped.startswith("{"): + ev = parse_json_telegram(line) + if ev is not None: + return ev + if "(telegram) DLL" in line: + return parse_dll(line) + if "(telegram) ELL" in line: + return parse_ell(line) + if "(telegram) TPL" in line: + return parse_tpl(line) + return None diff --git a/oms-tui/oms_tui/wmbus.py b/oms-tui/oms_tui/wmbus.py new file mode 100644 index 0000000..84bc76f --- /dev/null +++ b/oms-tui/oms_tui/wmbus.py @@ -0,0 +1,121 @@ +"""Drive wmbusmeters as a subprocess and stream parsed telegram events.""" + +from __future__ import annotations + +import asyncio +from typing import Callable, Dict, Optional + +from .parser import parse_line + +EventCallback = Callable[[dict], None] + + +class WMBusSource: + """Runs one wmbusmeters process and feeds parsed events to a callback. + + The command is:: + + wmbusmeters --format=json --verbose [--listento=MODES] DEVICE \ + [m_ auto ...] \ + scan auto '*' NOKEY + + The trailing wildcard meter makes wmbusmeters emit a verbose DLL line for + *every* telegram (universal discovery); keyed meters additionally emit + decrypted JSON. Changing keys requires relaunching the process (:meth:`restart`). + """ + + def __init__( + self, + device: str = "rtlwmbus", + modes: str = "c1,t1", + wmbusmeters: str = "wmbusmeters", + stdin_data: Optional[bytes] = None, + ) -> None: + self.device = device + self.modes = modes + self.wmbusmeters = wmbusmeters + self.stdin_data = stdin_data + self._keys: Dict[str, str] = {} + self._proc: Optional[asyncio.subprocess.Process] = None + self._task: Optional[asyncio.Task] = None + self._on_event: Optional[EventCallback] = None + self.running = False + + def set_keys(self, keys: Dict[str, str]) -> None: + self._keys = dict(keys) + + def build_cmd(self) -> list[str]: + cmd = [self.wmbusmeters, "--format=json", "--verbose"] + if self.device.startswith("rtl"): + cmd.append(f"--listento={self.modes}") + cmd.append(self.device) + for device_id, key in self._keys.items(): + cmd += [f"m_{device_id}", "auto", device_id, key] + cmd += ["scan", "auto", "*", "NOKEY"] + return cmd + + async def start(self, on_event: EventCallback) -> None: + self._on_event = on_event + await self._spawn() + + async def _spawn(self) -> None: + cmd = self.build_cmd() + use_stdin = self.stdin_data is not None + self._proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE if use_stdin else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + self.running = True + if use_stdin and self._proc.stdin is not None: + self._proc.stdin.write(self.stdin_data) # type: ignore[arg-type] + await self._proc.stdin.drain() + self._proc.stdin.close() + self._task = asyncio.create_task(self._read_loop()) + + async def _read_loop(self) -> None: + assert self._proc is not None and self._proc.stdout is not None + try: + while True: + raw = await self._proc.stdout.readline() + if not raw: + break + line = raw.decode("utf-8", "replace").rstrip("\n") + if not self._on_event: + continue + ev = parse_line(line) + if ev is not None: + self._on_event(ev) + except asyncio.CancelledError: # pragma: no cover - shutdown path + pass + + async def _kill(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): + pass + self._task = None + if self._proc is not None and self._proc.returncode is None: + try: + self._proc.terminate() + await asyncio.wait_for(self._proc.wait(), timeout=3) + except (asyncio.TimeoutError, ProcessLookupError): + try: + self._proc.kill() + except ProcessLookupError: + pass + self._proc = None + self.running = False + + async def restart(self) -> None: + await self._kill() + # a one-shot stdin replay has been consumed; don't re-feed on restart + if self.stdin_data is not None: + self.stdin_data = None + await self._spawn() + + async def stop(self) -> None: + await self._kill() diff --git a/oms-tui/pyproject.toml b/oms-tui/pyproject.toml new file mode 100644 index 0000000..0700894 --- /dev/null +++ b/oms-tui/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "oms-tui" +version = "0.1.0" +description = "TUI for monitoring OMS / wireless M-Bus smart meters via wmbusmeters" +requires-python = ">=3.10" +dependencies = ["textual>=0.60"] + +[project.scripts] +oms-tui = "oms_tui.app:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["oms_tui*"] + +[tool.setuptools.package-data] +oms_tui = ["*.tcss"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/oms-tui/sample.msg b/oms-tui/sample.msg new file mode 100644 index 0000000..03cca93 --- /dev/null +++ b/oms-tui/sample.msg @@ -0,0 +1,2 @@ +T1;1;1;2019-04-03 19:00:42.000;97;148;88888888;0x6e4401068888888805077a85006085bc2630713819512eb4cd87fba554fb43f67cf9654a68ee8e194088160df752e716238292e8af1ac20986202ee561d743602466915e42f1105d9c6782a54504e4f099e65a7656b930c73a30775122d2fdf074b5035cfaa7e0050bf32faae03a77 +C1;1;1;2020-01-23 10:25:13.000;97;148;76348799;0x2A442D2C998734761B168D2091D37CAC21E1D68CDAFFCD3DC452BD802913FF7B1706CA9E355D6C2701CC24 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 new file mode 100644 index 0000000000000000000000000000000000000000..946074f80c37f82a2f7539c03be550c0206bdc60 GIT binary patch literal 11780 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 literal 0 HcmV?d00001 diff --git a/oms-tui/tests/__pycache__/test_integration.cpython-313-pytest-9.0.3.pyc b/oms-tui/tests/__pycache__/test_integration.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c828ec055a87e671f651376c01cb36263139537d GIT binary patch literal 5358 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 Z81<1!lF^0nGcyBIG$Vvv6ve>6008&xGcy1H literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..f822e312e59af15c267136ea3d751fe8722233c9 GIT binary patch literal 25292 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 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..4f7fd469212e5ab6f332844da68d0c7f712eb634 GIT binary patch literal 11661 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 global stream mode + assert app.show_all is True + + # decrypted JSON updates the model + app.post_message(TelegramEvent(JSON)) + await pilot.pause() + dev = app.store.get("76348799") + assert dev.decrypted is True + assert dev.fields["total_m3"] == 6.408 + assert dev.count == 1 # one DLL telegram + + # select the device + app.action_select() + await pilot.pause() + assert app.show_all is False + assert app.selected_id == "76348799" + + +@pytest.mark.asyncio +async def test_set_key_via_modal(): + app = OmsApp(device="none", autostart=False) + async with app.run_test() as pilot: + app.post_message(TelegramEvent(DLL)) + await pilot.pause() + app.action_select() + await pilot.pause() + + app.action_set_key() # opens the modal prompt + await pilot.pause() + await pilot.press(*"28F64A24988064A079AA2C807D6102AE") + await pilot.press("enter") # submit + await pilot.pause() + + dev = app.store.get("76348799") + assert dev.key == "28F64A24988064A079AA2C807D6102AE" + assert dev.status == "key set" + + +@pytest.mark.asyncio +async def test_import_preloads_key(tmp_path): + path = str(tmp_path / "keys.csv") + with open(path, "w") as f: + f.write("id,aes_key\n76348799,28F64A24988064A079AA2C807D6102AE\n") + + app = OmsApp(device="none", autostart=False) + async with app.run_test() as pilot: + app._do_import(path, announce=False) + await pilot.pause() + dev = app.store.get("76348799") + assert dev is not None + assert dev.key == "28F64A24988064A079AA2C807D6102AE" + assert dev.status == "key set" diff --git a/oms-tui/tests/test_integration.py b/oms-tui/tests/test_integration.py new file mode 100644 index 0000000..9ccc165 --- /dev/null +++ b/oms-tui/tests/test_integration.py @@ -0,0 +1,43 @@ +"""End-to-end test through the real wmbusmeters binary (skipped if absent).""" + +import asyncio +import shutil + +import pytest + +from oms_tui.wmbus import WMBusSource + +TELEGRAM = ( + "C1;1;1;2020-01-23 10:25:13.000;97;148;76348799;" + "0x2A442D2C998734761B168D2091D37CAC21E1D68CDAFFCD3DC452BD802913FF7B1706CA9E355D6C2701CC24\n" +) +KEY = "28F64A24988064A079AA2C807D6102AE" + + +@pytest.mark.skipif(shutil.which("wmbusmeters") is None, reason="wmbusmeters not on PATH") +def test_pipeline_discovers_and_decrypts(): + events = [] + + async def run(): + src = WMBusSource( + device="stdin:rtlwmbus", stdin_data=(TELEGRAM * 3).encode() + ) + src.set_keys({"76348799": KEY}) + await src.start(events.append) + for _ in range(50): # up to ~5s for the process to finish replaying + await asyncio.sleep(0.1) + if any(e["type"] == "json" for e in events): + break + await src.stop() + + asyncio.run(run()) + + ids = {e.get("id") for e in events if e["type"] in ("dll", "json")} + assert "76348799" in ids + + dlls = [e for e in events if e["type"] == "dll"] + assert any(e["manufacturer"] == "KAM" for e in dlls) + + jsons = [e for e in events if e["type"] == "json"] + assert jsons, "no decrypted telegram produced" + assert any(e["fields"].get("total_m3") == 6.408 for e in jsons) diff --git a/oms-tui/tests/test_models_csv.py b/oms-tui/tests/test_models_csv.py new file mode 100644 index 0000000..9aed0fe --- /dev/null +++ b/oms-tui/tests/test_models_csv.py @@ -0,0 +1,149 @@ +import os + +from oms_tui.csvio import export_devices, import_rows +from oms_tui.models import DeviceStore + + +def _dll(device_id, mfct="KAM", media="Cold water meter"): + return { + "type": "dll", + "id": device_id, + "manufacturer": mfct, + "version": "1b", + "media": media, + "driver": "kamwater", + "c_field": "44", + "rssi": 97, + } + + +def test_duplicate_dll_burst_counts_once(): + # Two DLL lines for the SAME telegram (keyed meter + wildcard) arrive + # microseconds apart -> counted once. + store = DeviceStore() + store.upsert_discovery(_dll("111"), now=1000.0) + store.upsert_discovery(_dll("111"), now=1000.001) + assert store.get("111").count == 1 + + +def test_real_retransmissions_count_separately(): + store = DeviceStore() + store.upsert_discovery(_dll("111"), now=1000.0) + store.upsert_discovery(_dll("111"), now=1005.0) # seconds later + dev = store.get("111") + assert dev.count == 2 + assert dev.manufacturer_name == "Kamstrup" + assert dev.status == "listening" # DLL only, encryption not yet known + + +def test_encrypted_meter_with_plaintext_read_is_locked_not_open(): + # The exact contradiction the user hit: an encrypted (mode 5) meter that + # also exposes some plaintext records read by the wildcard ("scan"). + store = DeviceStore() + store.upsert_discovery(_dll("61471000")) + store.apply_tpl( + {"type": "tpl", "ci": "7a", "cfg": "8560", "nb": "6", "sec_mode": 5, + "enc": "AES-CBC"}, + "61471000", + ) + store.apply_json( + {"type": "json", "id": "61471000", "name": "scan", "driver": "x", + "media": "water", "fields": {"total_m3": 1.0}} + ) + dev = store.get("61471000") + assert dev.encrypted is True + assert dev.decrypted is True # we do have some values + assert dev.key_decrypted is False + assert dev.fields_source == "plain" + assert dev.status == "locked" # NOT "open" + assert dev.encryption == "mode 5 (AES-CBC)" + + +def test_keyed_decrypt_marks_decrypted(): + store = DeviceStore() + store.upsert_discovery(_dll("61471000")) + store.apply_tpl({"type": "tpl", "sec_mode": 5, "enc": "AES-CBC"}, "61471000") + dev = store.get("61471000") + dev.key = "00112233445566778899AABBCCDDEEFF" + store.apply_json( + {"type": "json", "id": "61471000", "name": "m_61471000", "driver": "x", + "media": "water", "fields": {"total_m3": 2.0}} + ) + assert dev.key_decrypted is True + assert dev.fields_source == "key" + assert dev.status == "decrypted" + + +def test_unencrypted_device_is_open_not_locked(): + store = DeviceStore() + store.upsert_discovery(_dll("333")) + store.apply_json( + {"type": "json", "id": "333", "driver": "apator162", "media": "water", + "fields": {"total_m3": 1.2}} + ) + dev = store.get("333") + assert dev.decrypted is True + assert dev.key is None + assert dev.status == "open" # not "locked" + assert dev.status_icon == "📖" + assert dev.status_color == "green" + + +def test_encryption_from_tpl_and_ell(): + store = DeviceStore() + store.upsert_discovery(_dll("444")) + store.apply_tpl( + {"type": "tpl", "ci": "7a", "cfg": "8560", "nb": "6", "sec_mode": 5, + "enc": "AES-CBC"}, + "444", + ) + dev = store.get("444") + assert dev.sec_mode == 5 + assert dev.encryption == "mode 5 (AES-CBC)" + assert dev.enc_blocks == "6" + + store2 = DeviceStore() + store2.upsert_discovery(_dll("555")) + store2.apply_ell( + {"type": "ell", "ci": "8d", "sn": "d37cac21", "session": "3", "enc": "AES-CTR"}, + "555", + ) + assert store2.get("555").encryption == "AES-CTR (ELL)" + + +def test_json_sets_decrypted_without_counting(): + store = DeviceStore() + store.upsert_discovery(_dll("222")) + store.apply_json( + {"type": "json", "id": "222", "driver": "kamwater", "media": "cold water", + "fields": {"total_m3": 6.408}} + ) + dev = store.get("222") + assert dev.count == 1 # JSON does not double-count + assert dev.decrypted is True + assert dev.fields["total_m3"] == 6.408 + + +def test_csv_roundtrip_with_keys(tmp_path): + store = DeviceStore() + store.upsert_discovery(_dll("76348799")) + store.get("76348799").key = "28F64A24988064A079AA2C807D6102AE" + store.upsert_discovery(_dll("88888888", mfct="TCH", media="Heat")) + + path = os.path.join(tmp_path, "out.csv") + assert export_devices(path, store.ordered()) == 2 + + rows = import_rows(path) + by_id = {r["id"]: r for r in rows} + assert by_id["76348799"]["aes_key"] == "28F64A24988064A079AA2C807D6102AE" + assert by_id["76348799"]["manufacturer"] == "KAM" + assert by_id["88888888"]["aes_key"] == "" + + +def test_import_minimal_key_file(tmp_path): + path = os.path.join(tmp_path, "keys.csv") + with open(path, "w") as f: + f.write("id,aes_key\n76348799,28F64A24988064A079AA2C807D6102AE\n") + rows = import_rows(path) + assert rows[0]["id"] == "76348799" + assert rows[0]["aes_key"].startswith("28F64A") diff --git a/oms-tui/tests/test_parser.py b/oms-tui/tests/test_parser.py new file mode 100644 index 0000000..6f13684 --- /dev/null +++ b/oms-tui/tests/test_parser.py @@ -0,0 +1,64 @@ +from oms_tui.parser import parse_line + +DLL = ( + "(telegram) DLL L=2a C=44 (from meter SND_NR) M=2c2d (KAM) A=76348799 " + "VER=1b TYPE=16 (Cold water meter) (driver kamwater) DEV=rtlwmbus[] RSSI=97" +) +JSON = ( + '{"_":"telegram","media":"cold water","meter":"kamwater","name":"Vatten",' + '"id":"76348799","total_m3":6.408,"target_m3":6.408,"status":"DRY",' + '"timestamp":"2020-01-23T10:25:13Z","device":"rtlwmbus[]","rssi_dbm":97}' +) + + +def test_parse_dll(): + ev = parse_line(DLL) + assert ev["type"] == "dll" + assert ev["id"] == "76348799" + assert ev["manufacturer"] == "KAM" + assert ev["version"] == "1b" + assert ev["media"] == "Cold water meter" + assert ev["driver"] == "kamwater" + assert ev["rssi"] == 97 + + +def test_parse_json(): + ev = parse_line(JSON) + assert ev["type"] == "json" + assert ev["id"] == "76348799" + assert ev["driver"] == "kamwater" + assert ev["fields"]["total_m3"] == 6.408 + # metadata keys are excluded from measurement fields + assert "timestamp" not in ev["fields"] + assert "rssi_dbm" not in ev["fields"] + + +def test_parse_ell_encryption(): + line = ( + "(telegram) ELL CI=8d CC=20 (slow_resp sync) ACC=91 SN=d37cac21 " + "(AES_CTR session=3 time=1755085) CRC=576c" + ) + ev = parse_line(line) + assert ev["type"] == "ell" + assert ev["enc"] == "AES-CTR" + assert ev["session"] == "3" + + +def test_parse_tpl_security_mode(): + line = "(telegram) TPL CI=7a ACC=85 STS=00 CFG=8560 (bidirectional AES_CBC_IV nb=6 cntn=0 ra=0 hc=0)" + ev = parse_line(line) + assert ev["type"] == "tpl" + assert ev["sec_mode"] == 5 + assert ev["enc"] == "AES-CBC" + assert ev["nb"] == "6" + + +def test_parse_tpl_no_security(): + ev = parse_line("(telegram) TPL CI=78") + assert ev["type"] == "tpl" + assert ev["sec_mode"] == 0 + + +def test_parse_noise_returns_none(): + assert parse_line("(config) number of meters: 0") is None + assert parse_line("") is None diff --git a/oms_devices.csv b/oms_devices.csv new file mode 100644 index 0000000..cd3f747 --- /dev/null +++ b/oms_devices.csv @@ -0,0 +1,25 @@ +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,