Files
WLED_MM_Infinity/tools/rfp_update_master_usb_then_nodes.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

273 lines
11 KiB
Python
Executable File

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