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