v1
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user