Files
WLED_MM_Infinity/tools/rfp_master_usb_relay.py
jan 4bc4e1257e
Some checks failed
WLED CI / wled_build (push) Has been cancelled
Deploy Nightly / wled_build (push) Has been cancelled
Deploy Nightly / Deploy nightly (push) Has been cancelled
Backup RFP Infinity controller state before Resolume changes
2026-05-14 12:31:13 +02:00

220 lines
8.9 KiB
Python
Executable File

#!/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())