v1
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""OMS / wireless M-Bus smart-meter monitor (TUI)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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_<id>".
|
||||
# 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
|
||||
@@ -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<L>[0-9a-fA-F]+) "
|
||||
r"C=(?P<C>[0-9a-fA-F]+).*?"
|
||||
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>[^)]*)\) "
|
||||
r"\(driver (?P<driver>\w+)\)"
|
||||
r"(?:.*?RSSI=(?P<rssi>-?\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
|
||||
@@ -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_<id> auto <id> <key> ...] \
|
||||
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()
|
||||
Reference in New Issue
Block a user