Files
oms-sdr/oms-tui/oms_tui/parser.py
T
2026-07-24 13:57:13 +02:00

141 lines
4.3 KiB
Python

"""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]+) "
# media can itself contain parentheses, e.g. "Radio converter (meter side)";
# match lazily up to the " (driver ...)" that always follows.
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>.*?)\) "
r"\(driver (?P<driver>\w+)\)"
r"(?:.*?RSSI=(?P<rssi>-?\d+))?"
)
# 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