139 lines
4.1 KiB
Python
139 lines
4.1 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]+) "
|
|
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
|