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