Files
home-automation/scripts/p1_probe.py
T

215 lines
7.5 KiB
Python

"""Read-only parser and command-line probe for DSMR and WarmteLink P1 telegrams."""
from __future__ import annotations
import argparse
import errno
import sys
import time
from typing import BinaryIO, Callable, TextIO
import serial
from app.integrations.p1 import IntegrityStatus, ObisField, P1Telegram, TelegramFramer, parse_telegram
__all__ = ["IntegrityStatus", "TelegramFramer", "build_parser", "parse_telegram", "run_probe"]
def build_parser() -> argparse.ArgumentParser:
"""Build the CLI parser for an explicitly selected, read-only serial device."""
parser = argparse.ArgumentParser(
description="Read-only WarmteLink P1 serial probe; it never writes to the device.",
epilog="Defaults are the locally measured WarmteLink 115200 7N1 framing, not DSMR defaults.",
)
parser.add_argument("--device", required=True, help="Explicit serial path, preferably /dev/serial/by-id/..."
)
parser.add_argument(
"--baudrate",
type=int,
default=115200,
help="Baud rate (default: 115200, the locally measured WarmteLink value).",
)
parser.add_argument(
"--bytesize",
type=int,
choices=(5, 6, 7, 8),
default=7,
help="Data bits (default: 7, locally measured; not the DSMR standard default).",
)
parser.add_argument(
"--parity",
choices=("N", "E", "O", "M", "S"),
type=lambda value: value.upper(),
default="N",
help="Parity (default: N, locally measured; not the DSMR standard default).",
)
parser.add_argument(
"--stopbits",
type=float,
choices=(1, 1.5, 2),
default=1,
help="Stop bits (default: 1, locally measured; not the DSMR standard default).",
)
parser.add_argument(
"--duration",
type=_positive_duration,
default=600.0,
help="Maximum capture time in seconds (default: 600).",
)
parser.add_argument(
"--show-changes",
action="store_true",
help="After the first frame, print only OBIS fields whose values changed.",
)
parser.add_argument(
"--raw-output",
type=argparse.FileType("wb"),
help="Optional path for the exact raw bytes read from the serial device.",
)
return parser
def _positive_duration(value: str) -> float:
try:
duration = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("duration must be a positive number") from exc
if duration <= 0:
raise argparse.ArgumentTypeError("duration must be greater than zero")
return duration
def _serial_error_message(exc: BaseException) -> str:
"""Return a practical, non-root diagnostic for a serial open/read failure."""
error_number = getattr(exc, "errno", None)
message = str(exc)
if error_number == errno.EACCES or "permission denied" in message.lower():
return f"serial permission denied: {message}. Add your user to the dialout group; do not run it as root."
if error_number == errno.EBUSY or "resource busy" in message.lower():
return f"serial device is busy: {message}. Close the program currently using this device and retry."
if error_number in {errno.ENODEV, errno.ENOENT, errno.EIO}:
return f"serial device disconnected or unavailable: {message}. Check the cable and --device path."
return f"serial I/O failed: {message}. Check the cable, device path, and serial framing settings."
def _format_field(field: ObisField) -> str:
raw_values = ", ".join(field.raw_values) or "<no values>"
value = f" parsed={field.value} {field.unit or ''}" if field.value is not None else ""
return f" {field.code}: {raw_values}{value}".rstrip()
def _comparison_values(field: ObisField) -> tuple[str, ...]:
"""Return a local change-detection token without exposing identifiers."""
return (field.comparison_token,) if field.comparison_token is not None else field.raw_values
def _print_telegram(
telegram: P1Telegram,
frame_number: int,
cadence: float | None,
previous_fields: dict[str, tuple[str, ...]],
show_changes: bool,
output: TextIO,
) -> dict[str, tuple[str, ...]]:
cadence_text = "first frame" if cadence is None else f"cadence={cadence:.1f}s"
print(
f"frame {frame_number}: {telegram.integrity.value}; bytes={telegram.frame_length}; {cadence_text}",
file=output,
)
print(f" integrity: {telegram.integrity_reason}", file=output)
current_fields = {field.code: _comparison_values(field) for field in telegram.fields}
fields = telegram.fields
if show_changes and previous_fields:
fields = tuple(field for field in fields if previous_fields.get(field.code) != _comparison_values(field))
print(f" changed fields: {len(fields)}", file=output)
for field in fields:
print(_format_field(field), file=output)
for channel in telegram.channels:
print(
f" channel {channel.number}: device_type={channel.device_type or '<unknown>'}; "
f"readings={len(channel.readings)}",
file=output,
)
return current_fields
SerialFactory = Callable[..., serial.Serial]
def run_probe(
args: argparse.Namespace,
*,
serial_factory: SerialFactory = serial.Serial,
clock: Callable[[], float] = time.monotonic,
output: TextIO = sys.stdout,
error_output: TextIO = sys.stderr,
) -> int:
"""Capture and report frames until duration elapses or the user interrupts.
The only operation on ``serial_port`` is ``read``. It is always closed,
including after an interrupt, timeout, or a read error.
"""
raw_output: BinaryIO | None = args.raw_output
serial_port: serial.Serial | None = None
try:
serial_port = serial_factory(
port=args.device,
baudrate=args.baudrate,
bytesize=args.bytesize,
parity=args.parity,
stopbits=args.stopbits,
timeout=1,
)
framer = TelegramFramer()
deadline = clock() + args.duration
frame_number = 0
last_frame_at: float | None = None
previous_fields: dict[str, tuple[str, ...]] = {}
while clock() < deadline:
chunk = serial_port.read(4096)
if not chunk:
continue
if raw_output is not None:
raw_output.write(chunk)
raw_output.flush()
for frame in framer.feed(chunk):
now = clock()
telegram = parse_telegram(frame)
frame_number += 1
cadence = None if last_frame_at is None else now - last_frame_at
previous_fields = _print_telegram(
telegram,
frame_number,
cadence,
previous_fields,
args.show_changes,
output,
)
last_frame_at = now
return 0
except KeyboardInterrupt:
print("capture interrupted; serial device closed", file=output)
return 0
except (serial.SerialException, OSError) as exc:
print(_serial_error_message(exc), file=error_output)
return 1
finally:
if serial_port is not None:
serial_port.close()
if raw_output is not None:
raw_output.close()
def main(argv: list[str] | None = None) -> int:
"""Run the command-line probe."""
args = build_parser().parse_args(argv)
return run_probe(args)
if __name__ == "__main__": # pragma: no cover - exercised through main()
raise SystemExit(main())