Backup RFP Infinity controller state before Resolume changes
This commit is contained in:
219
tools/rfp_master_usb_relay.py
Executable file
219
tools/rfp_master_usb_relay.py
Executable file
@@ -0,0 +1,219 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user