"""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 "" 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 ''}; " 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())