diff --git a/CLAUDE.md b/CLAUDE.md index 6d53f15..287dc09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 - Flash over the network (normal way since step 2b; keep USB for recovery): `curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67/api/ota` The board reboots into the new image on trial; check `GET /api/ota` shows the new version with `pending_verify: false`. +- Another board, over USB: `tools/flash_board.py` (checks chip revision and flash size, optional erase, flashes, finds the IP in the boot log, sets hostname / stream name / multicast so boards don't clash). Needs a build and esptool (IDF env or `pip install esptool`). - Serial: opening /dev/ttyACM0 can reset the board. Don't open it while an OTA image is on trial (a reset then counts as a failed boot and rolls back). - Rollback test build (self-test always fails), in its own build dir: `idf.py -B build-selftest-fail -DSDKCONFIG=build-selftest-fail/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.selftest_fail" build` diff --git a/tools/flash_board.py b/tools/flash_board.py new file mode 100755 index 0000000..c9450be --- /dev/null +++ b/tools/flash_board.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Flash this firmware onto another ESP32-P4 board over USB, then give it its own identity. + +Steps: pick the port, check the chip (ESP32-P4, revision the image supports, flash size), +optionally erase, write bootloader + partition table + app, wait for the board's IP in its boot +log, then set hostname / stream name / multicast address over the web API so it doesn't clash +with other boards on the network. + +Needs esptool (in the ESP-IDF Python env, or `pip install esptool`) and a build (`idf.py build`). +Every question can also be answered with an option, e.g.: + tools/flash_board.py --port /dev/ttyACM0 --erase --hostname p4-aes67-2 --name "P4 AES67 2" \\ + --mcast 239.69.1.11 +Boards already running this firmware and on the network update over the web (OTA) instead. +""" +import argparse +import json +import os +import re +import struct +import subprocess +import sys +import time +import urllib.request + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MIN_FLASH_MB = 32 # the bootloader header says 32 MB (sdkconfig); smaller chips aren't supported + + +def die(msg): + print(f"\nERROR: {msg}") + sys.exit(1) + + +def ask(prompt, default=None): + s = input(f"{prompt}{f' [{default}]' if default is not None else ''}: ").strip() + return s or (default if default is not None else "") + + +def yes(prompt, default): + s = input(f"{prompt} [{'Y/n' if default else 'y/N'}]: ").strip().lower() + return default if not s else s.startswith("y") + + +def esptool(port, *args, capture=True): + cmd = [sys.executable, "-m", "esptool", "--chip", "esp32p4", "--port", port, *args] + if not capture: + if subprocess.call(cmd) != 0: + die("esptool failed (see above)") + return "" + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + die(f"esptool {' '.join(args)} failed:\n{r.stdout}{r.stderr}") + return r.stdout + + +def pick_port(given): + if given: + return given + try: + from serial.tools import list_ports + except ImportError: + die("pyserial missing: run inside the ESP-IDF environment or `pip install esptool`") + ports = [p for p in list_ports.comports() if "ACM" in p.device or "USB" in p.device] + if not ports: + die("no USB serial port found: connect the board's USB-C port (not the PoE/Ethernet side)") + for i, p in enumerate(ports, 1): + print(f" {i}) {p.device} {p.description}") + if len(ports) == 1: + return ports[0].device if yes(f"Use {ports[0].device}?", True) else die("no port chosen") + n = ask("Port number", "1") + return ports[int(n) - 1].device + + +def load_images(build): + """(offset, path) pairs and flash options from build/flash_args.""" + fa = os.path.join(build, "flash_args") + if not os.path.exists(fa): + die(f"{fa} not found: build first (idf.py build)") + lines = open(fa).read().split("\n") + opts = lines[0].split() + images = [] + for line in lines[1:]: + if line.strip(): + off, path = line.split() + images.append((off, os.path.join(build, path))) + return opts, images + + +def image_rev_range(app): + """Chip revision range from the app image header (esp_image_header_t), as (min, max) * 100.""" + h = open(app, "rb").read(24) + if h[0] != 0xE9: + die(f"{app} is not an ESP app image") + min_full, max_full = struct.unpack_from("