#!/usr/bin/env python3 """Flash the USB-connected master first, then update nodes through it. This is the preferred RFP show update path when the laptop is connected to the master by USB but does not need to join the RFP Wi-Fi. It intentionally avoids `erase_flash` and `uploadfs`; normal updates only replace bootloader, partition table, boot_app0 and the app image. """ from __future__ import annotations import argparse import glob import subprocess import sys import time from pathlib import Path TOOLS_DIR = Path(__file__).resolve().parent if str(TOOLS_DIR) not in sys.path: sys.path.insert(0, str(TOOLS_DIR)) import rfp_update_all_ota as ota # noqa: E402 from rfp_master_usb_relay import ( # noqa: E402 DEFAULT_BAUD as DEFAULT_RELAY_BAUD, DEFAULT_STARTUP_DELAY as DEFAULT_RELAY_STARTUP_DELAY, master_info, open_master_serial, relay_ota, release_from_info, wait_for_release, ) DEFAULT_FLASH_BAUD = 460800 BOOT_APP0 = Path(".piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin") def detect_port() -> str: ports = sorted(glob.glob("/dev/ttyACM*") + glob.glob("/dev/ttyUSB*")) if not ports: raise RuntimeError("Kein /dev/ttyACM* oder /dev/ttyUSB* gefunden") return ports[0] def run(cmd: list[str], root: Path, env: dict[str, str], dry_run: bool) -> None: print("+ " + " ".join(cmd)) if dry_run: return subprocess.run(cmd, cwd=root, env=env, check=True) def maybe_stop_modemmanager(root: Path, env: dict[str, str], args: argparse.Namespace) -> None: if args.no_stop_modemmanager: return print("Stopping ModemManager temporarily, if present...") cmd = ["sudo", "systemctl", "stop", "ModemManager"] print("+ " + " ".join(cmd)) if not args.dry_run: subprocess.run(cmd, cwd=root, env=env, check=False) def build_selected(root: Path, env: dict[str, str], args: argparse.Namespace, node_targets: list[str]) -> None: if args.no_build: return if not args.nodes_only: print(f"Building master firmware: {ota.MASTER_ENV}") ota.build(root, ota.MASTER_ENV, env, args.dry_run) if node_targets and not args.master_only: print(f"Building node firmware: {ota.NODE_ENV}") ota.build(root, ota.NODE_ENV, env, args.dry_run) def require_file(root: Path, path: Path, label: str, dry_run: bool) -> None: if dry_run: return full = root / path if not full.exists(): raise FileNotFoundError(f"{label} fehlt: {full}") def wait_for_port(port: str, timeout_s: float, dry_run: bool) -> None: if dry_run: return deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: if Path(port).exists(): return time.sleep(0.25) raise TimeoutError(f"Serial-Port {port} ist nach {timeout_s:.0f}s nicht wieder erschienen") def flash_master_usb(root: Path, master_firmware: Path, env: dict[str, str], args: argparse.Namespace) -> None: build_dir = Path(".pio/build") / ota.MASTER_ENV bootloader = build_dir / "bootloader.bin" partitions = build_dir / "partitions.bin" require_file(root, bootloader, "Master bootloader.bin", args.dry_run) require_file(root, partitions, "Master partitions.bin", args.dry_run) require_file(root, BOOT_APP0, "boot_app0.bin", args.dry_run) require_file(root, master_firmware, "Master firmware", args.dry_run) print("Flashing master over USB without erase_flash or uploadfs...") cmd = [ ota.python_executable(root), ".piohome/packages/tool-esptoolpy/esptool.py", "--chip", "esp32s3", "--port", args.port, "--baud", str(args.flash_baud), "--before", "default_reset", "--after", "hard_reset", "write_flash", "-z", "--flash_mode", "qio", "--flash_freq", "80m", "--flash_size", "16MB", "0x0", str(bootloader), "0x8000", str(partitions), "0xe000", str(BOOT_APP0), "0x10000", str(master_firmware), ] run(cmd, root, env, args.dry_run) wait_for_port(args.port, args.port_timeout, args.dry_run) def wait_for_master_release(ser, master_ip: str, expected_release: str, timeout_s: float) -> dict: deadline = time.monotonic() + timeout_s last_error = "" while time.monotonic() < deadline: try: info = master_info(ser, master_ip, timeout_s=8.0) current = release_from_info(info) if current == expected_release: return info last_error = f"release is {current!r}" except Exception as exc: # noqa: BLE001 - keep retrying while Wi-Fi starts last_error = str(exc) time.sleep(2.0) raise TimeoutError(f"Master release {expected_release} wurde nicht sichtbar ({last_error})") def update_nodes_with_open_master(ser, targets: list[str], node_firmware: Path, args: argparse.Namespace) -> None: if not targets: return firmware = ota.repo_root() / node_firmware 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, timeout_s=8.0) current_release = release_from_info(info) print(f"[{index}/{len(targets)}] {target}: current release {current_release or '-'}") if current_release == ota.NODE_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, ota.NODE_RELEASE, args.chunk_size) info = wait_for_release(ser, target, ota.NODE_RELEASE, timeout_s=args.node_release_timeout) print( f"[{index}/{len(targets)}] {target}: OK " f"(release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)" ) def selected_node_targets(nodes: list[str], args: argparse.Namespace) -> list[str]: if args.master_only: return [] if not args.start_from: return nodes if args.start_from not in nodes: raise ValueError(f"--start-from {args.start_from} ist keine Node-IP in diesem Plan") return nodes[nodes.index(args.start_from) :] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Preferred RFP update: flash master by USB first, then update nodes via the master." ) role = parser.add_mutually_exclusive_group() role.add_argument("--master-only", action="store_true", help="Only flash and verify the USB-connected master") role.add_argument("--nodes-only", action="store_true", help="Skip master flashing and only update nodes via the USB-connected master") parser.add_argument("--port", help="Master serial port, for example /dev/ttyACM0. Auto-detected when omitted") parser.add_argument("--subnet", default=ota.DEFAULT_SUBNET, help=f"Show subnet (default: {ota.DEFAULT_SUBNET})") parser.add_argument("--start-from", help="Resume node updates from this IP, for example 192.168.178.14") parser.add_argument("--no-build", action="store_true", help="Use existing build_output/release binaries") parser.add_argument("--dry-run", action="store_true", help="Print steps without flashing") parser.add_argument("--flash-baud", type=int, default=DEFAULT_FLASH_BAUD, help=f"USB esptool baud (default: {DEFAULT_FLASH_BAUD})") parser.add_argument("--relay-baud", type=int, default=DEFAULT_RELAY_BAUD, help=f"Master relay baud (default: {DEFAULT_RELAY_BAUD})") parser.add_argument( "--relay-startup-delay", type=float, default=DEFAULT_RELAY_STARTUP_DELAY, help=f"Seconds to wait after opening master serial (default: {DEFAULT_RELAY_STARTUP_DELAY})", ) parser.add_argument("--master-release-timeout", type=float, default=90.0, help="Seconds to wait for master release after USB flash") parser.add_argument("--node-release-timeout", type=float, default=150.0, help="Seconds to wait for each node release after OTA") parser.add_argument("--port-timeout", type=float, default=20.0, help="Seconds to wait for serial port after USB reset") parser.add_argument("--chunk-size", type=int, default=384, help="Raw firmware bytes per base64 relay chunk") parser.add_argument("--force-current-release", action="store_true", help="Reflash nodes even when release already matches") parser.add_argument("--no-stop-modemmanager", action="store_true", help="Do not stop ModemManager before USB flashing") return parser.parse_args() def main() -> int: args = parse_args() root = ota.repo_root() env = ota.platformio_env(root) nodes, master = ota.targets_from_subnet(args.subnet) node_targets = selected_node_targets(nodes, args) if not args.port: args.port = detect_port() print("RFP Infinity USB-master update plan:") if not args.nodes_only: print(f"- {master} (master via USB on {args.port})") if node_targets: for ip in node_targets: print(f"- {ip} (node via master relay)") print() build_selected(root, env, args, node_targets) node_firmware = ota.release_firmware(root, ota.NODE_RELEASE, ota.NODE_FIRMWARE_FALLBACK) master_firmware = ota.release_firmware(root, ota.MASTER_RELEASE, ota.MASTER_FIRMWARE_FALLBACK) print(f"Master firmware: {master_firmware} (expect release {ota.MASTER_RELEASE})") print(f"Node firmware: {node_firmware} (expect release {ota.NODE_RELEASE})") print("Master USB flash: no erase_flash, no uploadfs, hard reset after app flash") print() if not args.nodes_only: maybe_stop_modemmanager(root, env, args) flash_master_usb(root, master_firmware, env, args) if args.master_only and not node_targets: print("Opening master serial to verify release...") if not args.dry_run: with open_master_serial(args.port, args.relay_baud, args.relay_startup_delay) as ser: info = wait_for_master_release(ser, master, ota.MASTER_RELEASE, args.master_release_timeout) print(f"Master OK (release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)") print("Master USB update completed.") return 0 print(f"Opening master serial relay on {args.port} at {args.relay_baud} baud") if args.dry_run: print("Dry run complete.") return 0 with open_master_serial(args.port, args.relay_baud, args.relay_startup_delay) as ser: info = wait_for_master_release(ser, master, ota.MASTER_RELEASE, args.master_release_timeout) print(f"Master OK (release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)") update_nodes_with_open_master(ser, node_targets, node_firmware, args) print("USB-master-first update completed.") return 0 if __name__ == "__main__": raise SystemExit(main())