273 lines
11 KiB
Python
Executable File
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())
|