"""Plan or safely flash the EchoEar ESP32-S3 16 MiB A/B firmware. No serial port is opened unless the command is ``flash --execute``. All writes require a matching physical MAC, a fresh full-flash backup, and verified input images. The device remains in its bootloader after completion: reset it manually. Full USB firmware writes require --reset-runtime and subsequent Wi-Fi provisioning to clear old OTA version/journal metadata. Identity-only writes may preserve NVS. Requires Python 3.10+; hardware commands additionally need esptool 4.x or 5.x. """ from __future__ import annotations import argparse from dataclasses import dataclass from datetime import datetime, timezone import hashlib import json import os from pathlib import Path, PureWindowsPath import re import struct import subprocess import sys from device_prepare import private_directory PROJECT = Path(__file__).resolve().parents[1] FLASH_SIZE = 0x1000000 FACTORY_OFFSET = 0xE4E000 FACTORY_SIZE = 0x6000 NVS_OFFSET = 0x9000 NVS_SIZE = 0x6000 TABLE_OFFSET = 0x8000 SECTOR = 0x1000 # name: type, subtype, offset, size, flags. Exact supported 16 MiB A/B layout. LAYOUT = { "nvs": (1, 2, 0x9000, 0x6000, 0), "phy_init": (1, 1, 0xF000, 0x1000, 0), "ota_0": (0, 0x10, 0x10000, 0x400000, 0), "ota_1": (0, 0x11, 0x410000, 0x400000, 0), "otadata": (1, 0, 0x810000, 0x2000, 0), "model": (1, 0x82, 0x812000, 0x80000, 0), "coredump": (1, 3, 0x91E000, 0x10000, 0), "storage": (1, 0x83, 0x92E000, 0x200000, 0), "eaf": (1, 0x82, 0xB2E000, 0x320000, 0), "ble_factory": (1, 2, FACTORY_OFFSET, FACTORY_SIZE, 0), } REGIONS = { "bootloader": (0, 0x8000), "partition-table": (TABLE_OFFSET, SECTOR), "app": (0x10000, 0x400000), "otadata": (0x810000, 0x2000), "model": (0x812000, 0x80000), "eaf": (0xB2E000, 0x320000), } class FlashError(ValueError): """An input or hardware safety check failed.""" def require(condition, message): if not condition: raise FlashError(message) def number(value): require(type(value) in (str, int), "Expected an integer or hexadecimal string") try: return int(value, 0) if isinstance(value, str) else value except ValueError as exc: raise FlashError("Invalid integer in manifest") from exc def normalize_mac(value): require(isinstance(value, str), "MAC must be a string") compact = re.sub("[:-]", "", value or "") require(bool(re.fullmatch(r"[0-9a-fA-F]{12}", compact)), "MAC must contain 12 hex digits") require(compact.lower() not in ("000000000000", "ffffffffffff"), "Invalid MAC address") return ":".join(compact[i:i + 2].upper() for i in range(0, 12, 2)) def sha256(data): return hashlib.sha256(data).hexdigest() def load_json(path): try: value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, ValueError) as exc: raise FlashError(f"Cannot read JSON: {path}") from exc require(isinstance(value, dict), f"Expected a JSON object: {path}") return value def object_field(parent, name): value = parent.get(name) require(isinstance(value, dict), f"Missing or invalid object: {name}") return value def contained_file(directory, relative): require(isinstance(relative, str) and relative, "Missing image filename") win = PureWindowsPath(relative) require(not win.drive and not win.is_absolute() and not Path(relative).is_absolute(), "Absolute image paths are not allowed in manifests") require(".." not in win.parts and ".." not in Path(relative).parts, "Image path must not escape its build/factory directory") path = (directory / relative).resolve() require(path.is_relative_to(directory.resolve()), "Image symlink escapes its directory") require(path.is_file(), f"Image is missing: {path}") return path def partition_table(data): """Validate IDF's 32-byte partition records and the mandatory MD5 record.""" require(len(data) in (0xC00, 0x1000), "Partition table must be 3072 or 4096 bytes") result = {} for pos in range(0, 0xC00, 32): record = data[pos:pos + 32] if record[:16] == b"\xeb\xeb" + b"\xff" * 14: require(record[16:] == hashlib.md5(data[:pos]).digest(), "Partition-table MD5 mismatch") require(data[pos + 32:] == b"\xff" * (len(data) - pos - 32), "Unsupported signed or nonempty partition-table padding") require(result == LAYOUT, "Partition layout is not the supported 16 MiB / 4 MiB A/B layout") return result require(record[:2] == b"\xaa\x50", "Missing partition-table entry or MD5 record") _, kind, subtype, offset, size, label, flags = struct.unpack(" 0 and size % SECTOR == 0, "Unaligned partition") require(offset >= 0x9000 and offset + size <= FLASH_SIZE, "Partition out of flash bounds") for old in result.values(): require(offset + size <= old[2] or offset >= old[2] + old[3], "Overlapping partitions") result[name] = (kind, subtype, offset, size, flags) raise FlashError("Missing partition-table MD5") def esp_image(data, application=False): """Validate ESP image segments, XOR checksum, appended SHA256 and chip ID.""" require(len(data) >= 64 and data[0] == 0xE9, "Not an ESP executable image") require(1 <= data[1] <= 16, "Invalid ESP image segment count") require(struct.unpack_from(" 0 and position + size <= len(data), "Truncated or empty ESP segment") for byte in data[position:position + size]: checksum ^= byte position += size checksum_pos = (position // 16 + 1) * 16 - 1 require(checksum_pos + 33 == len(data), "Unexpected ESP image tail or signature") require(data[checksum_pos] == checksum, "ESP image checksum mismatch") require(data[-32:] == hashlib.sha256(data[:-32]).digest(), "ESP image SHA256 mismatch") if not application: return {} require(struct.unpack_from("= previous_end and end <= FLASH_SIZE, "Flash regions or their erase sectors overlap") previous_end = end return Plan(images, app_info, factory_summary, identity_only, reset_runtime) class Esptool: def __init__(self, python, port, baud, runner=subprocess.run): self.python, self.port, self.baud, self.runner = python, port, baud, runner self.major = 4 self.first = True def invoke(self, args): result = self.runner([self.python, "-X", "utf8", "-m", "esptool"] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", check=False) if result.returncode: raise FlashError(f"esptool failed ({result.returncode}); no later commands were run:\n{result.stdout}") return result.stdout def check_version(self): output = self.invoke(["version"]) match = re.search(r"(?