#!/usr/bin/env python3 """Relay RFP node OTA updates through the USB-connected Infinity master.""" from __future__ import annotations import argparse import base64 import json import sys import time from pathlib import Path try: import serial except ImportError: # pragma: no cover - depends on local PlatformIO venv serial = None DEFAULT_BAUD = 921600 BOOTSTRAP_BAUD = 115200 DEFAULT_STARTUP_DELAY = 4.0 NODE_RELEASE = "RFP_N16R8_NODE3x106_V20260511E" NODE_HOSTS = range(11, 17) def targets_from_subnet(subnet: str) -> list[str]: prefix = ".".join(subnet.split(".")[:3]) return [f"{prefix}.{host}" for host in NODE_HOSTS] def drain_serial(ser: "serial.Serial", quiet_s: float = 0.2, max_s: float = 2.0) -> None: """Discard boot/debug lines before starting the command protocol.""" deadline = time.monotonic() + max_s quiet_deadline = time.monotonic() + quiet_s while time.monotonic() < deadline and time.monotonic() < quiet_deadline: raw = ser.readline() if raw: quiet_deadline = time.monotonic() + quiet_s def open_master_serial(port: str, baud: int, startup_delay: float) -> "serial.Serial": if serial is None: raise RuntimeError("pyserial is not installed in this Python environment") ser = serial.Serial(port, BOOTSTRAP_BAUD, timeout=1.0, write_timeout=30) if startup_delay > 0: time.sleep(startup_delay) drain_serial(ser) if baud != BOOTSTRAP_BAUD: ser.write(bytes([0xB5])) # WLED serial command: switch baud rate. ser.flush() time.sleep(0.25) ser.baudrate = baud time.sleep(0.25) drain_serial(ser) return ser def read_prefixed_line(ser: "serial.Serial", prefixes: tuple[str, ...], timeout_s: float) -> tuple[str, str]: deadline = time.monotonic() + timeout_s seen: list[str] = [] while time.monotonic() < deadline: raw = ser.readline() if not raw: continue line = raw.decode("utf-8", errors="replace").strip() if line: seen.append(line) seen = seen[-5:] for prefix in prefixes: if line.startswith(prefix): return prefix, line[len(prefix) :].strip() detail = f"; last serial lines: {' | '.join(seen)}" if seen else "" raise TimeoutError(f"timed out waiting for one of: {', '.join(prefixes)}{detail}") def master_info(ser: "serial.Serial", target: str, timeout_s: float = 8.0) -> dict: command = {"target": target} ser.write(("RFPINFO1 " + json.dumps(command, separators=(",", ":")) + "\n").encode()) ser.flush() prefix, payload = read_prefixed_line(ser, ("RFPINFO1 ", "RFPERR1 "), timeout_s) if prefix == "RFPERR1 ": raise RuntimeError(payload) return json.loads(payload) def relay_ota(ser: "serial.Serial", target: str, firmware: Path, expected_release: str, chunk_size: int) -> None: size = firmware.stat().st_size command = { "target": target, "size": size, "release": expected_release, "skipValidation": True, "ackBytes": chunk_size, } ser.write(("RFPOTA1 " + json.dumps(command, separators=(",", ":")) + "\n").encode()) ser.flush() prefix, payload = read_prefixed_line(ser, ("RFPREADY1 ", "RFPERR1 "), 12.0) if prefix == "RFPERR1 ": raise RuntimeError(payload) ready = json.loads(payload) if int(ready.get("proto", 1)) < 4 or int(ready.get("ackBytes", 0)) <= 0: raise RuntimeError( "USB relay master firmware is too old for base64 chunk mode. " "Flash the master first, then rerun this command." ) print(f"{target}: master ready {payload}") ser.write(b"RFPDATA1\n") ser.flush() sent = 0 started = time.monotonic() with firmware.open("rb") as fh: while True: chunk = fh.read(chunk_size) if not chunk: break encoded = base64.b64encode(chunk).decode("ascii") written = ser.write(f"RFPCHUNK1 {len(chunk)} {encoded}\n".encode("ascii")) ser.flush() if written <= 0: raise RuntimeError(f"{target}: serial write returned {written}") sent += len(chunk) while True: prefix, payload = read_prefixed_line(ser, ("RFPACK1 ", "RFPERR1 "), 30.0) if prefix == "RFPERR1 ": raise RuntimeError(payload) ack = json.loads(payload).get("bytes", 0) if int(ack) >= sent: break if sent == size or sent % (256 * 1024) < len(chunk): elapsed = max(0.1, time.monotonic() - started) print(f"{target}: streamed {sent}/{size} bytes ({sent / elapsed / 1024:.1f} KiB/s)") prefix, payload = read_prefixed_line(ser, ("RFPDONE1 ", "RFPERR1 "), 45.0) if prefix == "RFPERR1 ": raise RuntimeError(payload) print(f"{target}: relay done {payload}") def release_from_info(info: dict) -> str: return str(info.get("release") or info.get("rel") or "") def wait_for_release(ser: "serial.Serial", target: str, expected_release: str, timeout_s: float) -> dict: deadline = time.monotonic() + timeout_s last_error = "" while time.monotonic() < deadline: try: info = master_info(ser, target, timeout_s=8.0) if release_from_info(info) == expected_release: return info last_error = f"release is {release_from_info(info)!r}" except Exception as exc: # noqa: BLE001 - keep polling while node reboots last_error = str(exc) time.sleep(2.0) raise TimeoutError(f"{target}: expected release {expected_release} did not appear ({last_error})") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Update RFP nodes through the USB-connected master.") parser.add_argument("--port", required=True, help="Master serial port, for example /dev/ttyACM0") parser.add_argument("--firmware", required=True, type=Path, help="Node firmware .bin") parser.add_argument("--expect-release", default=NODE_RELEASE, help=f"Expected node release (default: {NODE_RELEASE})") parser.add_argument("--targets", help="Comma-separated node IP list") parser.add_argument("--subnet", default="192.168.178.0/24", help="Subnet used to derive .11-.16 when --targets is omitted") parser.add_argument("--start-from", help="Resume from this target IP") parser.add_argument("--baud", type=int, default=DEFAULT_BAUD, help=f"Relay baud rate after startup (default: {DEFAULT_BAUD})") parser.add_argument( "--startup-delay", type=float, default=DEFAULT_STARTUP_DELAY, help=f"Seconds to wait after opening the master serial port (default: {DEFAULT_STARTUP_DELAY})", ) parser.add_argument("--chunk-size", type=int, default=384, help="Raw firmware bytes per base64 serial chunk") parser.add_argument("--force-current-release", action="store_true", help="Reflash even if release already matches") return parser.parse_args() def main() -> int: args = parse_args() firmware = args.firmware if not firmware.exists(): print(f"Firmware file not found: {firmware}", file=sys.stderr) return 2 targets = [item.strip() for item in args.targets.split(",") if item.strip()] if args.targets else targets_from_subnet(args.subnet) if args.start_from: if args.start_from not in targets: print(f"--start-from target not found: {args.start_from}", file=sys.stderr) return 2 targets = targets[targets.index(args.start_from) :] print(f"Opening master serial relay on {args.port} at {args.baud} baud") with open_master_serial(args.port, args.baud, args.startup_delay) as ser: for index, target in enumerate(targets, start=1): print(f"[{index}/{len(targets)}] {target}: checking current release through master...") try: info = master_info(ser, target) current_release = release_from_info(info) print(f"[{index}/{len(targets)}] {target}: current release {current_release or '-'}") if current_release == args.expect_release and not args.force_current_release: print(f"[{index}/{len(targets)}] {target}: SKIP (release already matches)") continue except Exception as exc: # noqa: BLE001 print(f"[{index}/{len(targets)}] {target}: info warning: {exc}") print(f"[{index}/{len(targets)}] {target}: streaming OTA via master...") relay_ota(ser, target, firmware, args.expect_release, args.chunk_size) info = wait_for_release(ser, target, args.expect_release, timeout_s=150.0) print( f"[{index}/{len(targets)}] {target}: OK " f"(release {release_from_info(info)}, uptime {info.get('uptime', '-') }s)" ) print("Master USB relay update completed.") return 0 if __name__ == "__main__": raise SystemExit(main())