220 lines
8.9 KiB
Python
Executable File
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())
|