From f14c848857e5671815dcd80e8cb04d5121489b60 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 10 Apr 2026 22:02:28 -0700 Subject: [PATCH] tdeck fix --- scripts/emit-flash-manifest.py | 181 ++++++++++++++++++++++++++++----- src/components/EspFlasher.tsx | 81 ++++++++++----- src/lib/nrfFlashRun.ts | 134 ++++++++++++++++++++++++ 3 files changed, 345 insertions(+), 51 deletions(-) create mode 100644 src/lib/nrfFlashRun.ts diff --git a/scripts/emit-flash-manifest.py b/scripts/emit-flash-manifest.py index 37f9721..c0ae4c1 100644 --- a/scripts/emit-flash-manifest.py +++ b/scripts/emit-flash-manifest.py @@ -324,6 +324,115 @@ def pick_mt_json(build_dir: str) -> str | None: return fw[0] if fw else paths[0] +# --------------------------------------------------------------------------- +# Path 3 — generic nRF52 (UF2 bootloader, no *.mt.json) +# --------------------------------------------------------------------------- + +def _pick_firmware_uf2(names: set[str]) -> str | None: + """Return the best candidate firmware *.uf2 (exclude nuke.uf2).""" + cands = sorted( + n for n in names + if n.lower().endswith(".uf2") and n.lower() != "nuke.uf2" + ) + # Prefer names starting with "firmware" + fw = [n for n in cands if n.lower().startswith("firmware")] + return fw[0] if fw else (cands[0] if cands else None) + + +def emit_generic_nrf52(names: set[str]) -> dict | None: + """ + Emit a UF2-based manifest for nRF52 builds. + update: firmware *.uf2 with role "uf2" + factory (optional): nuke.uf2 (role "nuke") + firmware *.uf2, eraseFlash true + """ + fw_uf2 = _pick_firmware_uf2(names) + if not fw_uf2: + return None + + doc: dict = { + "update": { + "images": [{"file": fw_uf2, "offset": 0, "role": "uf2"}], + "eraseFlash": False, + } + } + + if "nuke.uf2" in names: + doc["factory"] = { + "images": [ + {"file": "nuke.uf2", "offset": 0, "role": "nuke"}, + {"file": fw_uf2, "offset": 0, "role": "uf2"}, + ], + "eraseFlash": True, + } + + return doc + + +# --------------------------------------------------------------------------- +# Path 4 — generic ESP32 (standard PlatformIO bins, no *.mt.json) +# --------------------------------------------------------------------------- + +# Standard PlatformIO ESP32 flash offsets +_ESP32_OFFSETS: dict[str, int] = { + "bootloader.bin": 0x1000, + "partitions.bin": 0x8000, + "boot_app0.bin": 0xE000, +} +_ESP32_APP_OFFSET = 0x10000 + + +def _pick_esp32_app_bin(names: set[str]) -> str | None: + """Return firmware app bin (versioned or exact), excluding factory images.""" + if "firmware.bin" in names: + return "firmware.bin" + cands = sorted( + n for n in names + if re.match(r"^firmware-.+\.bin$", n, re.I) and not re.search(r"\.factory\.bin$", n, re.I) + ) + return cands[0] if cands else None + + +def emit_generic_esp32(names: set[str]) -> dict | None: + """ + Emit a standard ESP32 manifest from PlatformIO bin output. + update: bootloader + partitions + app (+ boot_app0 if present) + factory (optional): firmware-*.factory.bin @ 0x0, eraseFlash true + """ + bootloader = "bootloader.bin" if "bootloader.bin" in names else None + partitions = "partitions.bin" if "partitions.bin" in names else None + app_bin = _pick_esp32_app_bin(names) + + if not (bootloader and partitions and app_bin): + # Not enough for a canonical ESP32 layout + return None + + update_images: list[dict] = [ + {"file": "bootloader.bin", "offset": _ESP32_OFFSETS["bootloader.bin"]}, + {"file": "partitions.bin", "offset": _ESP32_OFFSETS["partitions.bin"]}, + ] + if "boot_app0.bin" in names: + update_images.append({"file": "boot_app0.bin", "offset": _ESP32_OFFSETS["boot_app0.bin"]}) + update_images.append({"file": app_bin, "offset": _ESP32_APP_OFFSET}) + update_images.sort(key=lambda im: im["offset"]) + + doc: dict = { + "update": {"images": update_images, "eraseFlash": False} + } + + factory_bin = pick_factory_bin(names) + if factory_bin: + doc["factory"] = { + "images": [{"file": factory_bin, "offset": 0}], + "eraseFlash": True, + } + + return doc + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + def main() -> int: if len(sys.argv) < 2: print( @@ -346,6 +455,7 @@ def main() -> int: out_path = os.path.join(build_dir, "flash-manifest.json") + # Path 1: existing manifest — merge targetFamily when absent if os.path.isfile(out_path): with open(out_path, encoding="utf-8") as f: manifest = json.load(f) @@ -366,35 +476,58 @@ def main() -> int: print(f"Keeping existing {out_path} (targetFamily already set)") return 0 - mt_path = pick_mt_json(build_dir) - if not mt_path: - print("No *.mt.json; not emitting flash-manifest.json") - return 0 - - with open(mt_path, encoding="utf-8") as f: - mt = json.load(f) - names = list_basenames(build_dir) - manifest_update = emit_meshtastic_update(build_dir, mt) - if not manifest_update: - print("Could not synthesize flash-manifest.json (update) from mt.json") + + # Path 2: Meshtastic *.mt.json + mt_path = pick_mt_json(build_dir) + if mt_path: + with open(mt_path, encoding="utf-8") as f: + mt = json.load(f) + + manifest_update = emit_meshtastic_update(build_dir, mt) + if not manifest_update: + print("Could not synthesize flash-manifest.json (update) from mt.json") + return 0 + + doc: dict = {"update": manifest_update} + factory = pick_factory_bin(names) + if factory: + manifest_full = emit_meshtastic_full(build_dir, mt, factory) + if manifest_full: + doc["factory"] = manifest_full + else: + print("Could not build factory section; omitting factory key", file=sys.stderr) + else: + print("No firmware-*.factory.bin in BUILD_DIR; omitting factory section") + + merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board) + _write_manifest(out_path, merged_doc) + print(f"Wrote {out_path} (update + {'factory' if 'factory' in merged_doc else 'no factory'})") return 0 - doc: dict = {"update": manifest_update} - factory = pick_factory_bin(names) - if factory: - manifest_full = emit_meshtastic_full(build_dir, mt, factory) - if manifest_full: - doc["factory"] = manifest_full - else: - print("Could not build factory section; omitting factory key", file=sys.stderr) - else: - print("No firmware-*.factory.bin in BUILD_DIR; omitting factory section") + # Path 3: generic nRF52 (UF2 output) + if pio_family == "nrf52": + doc = emit_generic_nrf52(names) + if doc: + merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board) + _write_manifest(out_path, merged_doc) + print(f"Wrote {out_path} (nRF52 UF2, update + {'factory' if 'factory' in merged_doc else 'no factory'})") + return 0 + print("nRF52 target but no *.uf2 found in BUILD_DIR; skipping manifest", file=sys.stderr) + return 0 - merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board) - _write_manifest(out_path, merged_doc) - print(f"Wrote {out_path} (update + {'factory' if 'factory' in merged_doc else 'no factory'})") + # Path 4: generic ESP32 (standard PlatformIO bin output) + if pio_family == "esp32": + doc = emit_generic_esp32(names) + if doc: + merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board) + _write_manifest(out_path, merged_doc) + print(f"Wrote {out_path} (ESP32 bins, update + {'factory' if 'factory' in merged_doc else 'no factory'})") + return 0 + print("ESP32 target but could not detect bootloader+partitions+firmware in BUILD_DIR; skipping manifest", file=sys.stderr) + return 0 + print(f"No manifest source found for family={pio_family!r}; skipping flash-manifest.json") return 0 diff --git a/src/components/EspFlasher.tsx b/src/components/EspFlasher.tsx index 3dcf20d..450734a 100644 --- a/src/components/EspFlasher.tsx +++ b/src/components/EspFlasher.tsx @@ -22,6 +22,7 @@ import { runEspFlash, type FlashPhase, } from "../lib/espFlashRun" +import { buildNrfPlan, isNrfFlashSupported, runNrfFlash } from "../lib/nrfFlashRun" import { resolveFlashTargetFamily } from "../lib/flashTargetFamily" import type { FlashManifest, FlashTargetFamily } from "../lib/untarGz" import { extractTarGz } from "../lib/untarGz" @@ -38,9 +39,6 @@ const PHASE_LABEL: Record = { } function unsupportedFlashMessage(family: FlashTargetFamily): string | null { - if (family === "nrf52") { - return "This bundle targets nRF52. In-browser flashing here uses esptool (ESP32). Use adafruit-nrfutil / nrfutil with the ZIP from Download bundle, or your board’s UF2/DFU workflow." - } if (family === "rp2040") { return "This bundle targets RP2040. Use UF2 drag-and-drop or picotool with artifacts from Download bundle — Web Serial esptool here is for ESP32-class boards only." } @@ -146,6 +144,12 @@ export default function EspFlasher({ toast.error("Web Serial is not supported in this browser") return } + if (resolvedFamily === "nrf52" && !isNrfFlashSupported()) { + toast.error("File System Access API not available", { + description: "Use Chrome or Edge for nRF52 UF2 flashing.", + }) + return + } setBusy(true) setFlashProgress({ kind: "indeterminate", label: "Select a serial port…" }) let finishedOk = false @@ -157,31 +161,50 @@ export default function EspFlasher({ setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" }) const files = await prepareBundle() - const plan = buildFlashParts(files, { - factoryInstall: eraseFlashForFactory, - resetDeviceStorage: false, - }) - if (!plan) { - toast.error("Could not detect flash layout from bundle") - return + + if (resolvedFamily === "nrf52") { + const manifest = manifestFromMap(files) + const plan = buildNrfPlan(files, manifest, eraseFlashForFactory) + if (!plan) { + toast.error("No UF2 found in bundle") + return + } + await runNrfFlash({ + plan, + onPhase: label => { + setFlashProgress({ kind: "indeterminate", label }) + }, + onWriteProgress: p => { + setFlashProgress({ kind: "determinate", label: "Writing firmware…", pct: p.pct }) + }, + }) + } else { + const plan = buildFlashParts(files, { + factoryInstall: eraseFlashForFactory, + resetDeviceStorage: false, + }) + if (!plan) { + toast.error("Could not detect flash layout from bundle") + return + } + await runEspFlash({ + port, + parts: plan.parts, + baud: ESP_FLASH_WEB_BAUD, + eraseAll: plan.eraseAll, + onPhase: phase => { + setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] }) + }, + onWriteProgress: p => { + setFlashProgress({ + kind: "determinate", + label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`, + pct: p.overallPct, + }) + }, + }) } - await runEspFlash({ - port, - parts: plan.parts, - baud: ESP_FLASH_WEB_BAUD, - eraseAll: plan.eraseAll, - onPhase: phase => { - setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] }) - }, - onWriteProgress: p => { - setFlashProgress({ - kind: "determinate", - label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`, - pct: p.overallPct, - }) - }, - }) finishedOk = true setFlashProgress({ kind: "complete" }) toast.success("Flash complete") @@ -189,6 +212,10 @@ export default function EspFlasher({ if (isSerialUserCancelledError(e)) { return } + // User dismissed the directory picker (nRF52 UF2 flow) + if (e instanceof DOMException && e.name === "AbortError") { + return + } const msg = e instanceof Error ? e.message : String(e) toast.error("Flash failed", { description: msg }) } finally { @@ -200,7 +227,7 @@ export default function EspFlasher({ setFlashProgress(null) } } - }, [eraseFlashForFactory, prepareBundle, canEspFlash, flashBlockedReason]) + }, [eraseFlashForFactory, prepareBundle, canEspFlash, flashBlockedReason, resolvedFamily]) const shareUrlTrimmed = useMemo(() => sharePageUrl?.trim() ?? "", [sharePageUrl]) const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function" diff --git a/src/lib/nrfFlashRun.ts b/src/lib/nrfFlashRun.ts new file mode 100644 index 0000000..d3b0bba --- /dev/null +++ b/src/lib/nrfFlashRun.ts @@ -0,0 +1,134 @@ +import { findInTar } from './untarGz' +import type { FlashManifest } from './untarGz' + +export type NrfFlashPlan = { + /** Written first when chip erase is requested; causes device to erase all flash and reboot. */ + nukeFile?: Uint8Array + /** The application UF2 to write after optional nuke. */ + firmwareFile: Uint8Array + firmwareName: string +} + +export type NrfWriteProgressPayload = { + phase: 'nuke' | 'firmware' + written: number + total: number + pct: number +} + +function sleepMs(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function pickDirectory(): Promise { + // showDirectoryPicker is present in Chromium but typed as unknown in some TS DOM versions. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const picker = (window as any).showDirectoryPicker as (() => Promise) | undefined + if (!picker) throw new Error('File System Access API is not available in this browser. Use Chrome or Edge.') + return picker.call(window) +} + +/** + * Build an nRF52 flash plan from the bundle tarball + parsed manifest. + * Reads the `update` section for a normal flash, or `factory` when factoryInstall is true. + * Images with role "uf2" are the firmware; role "nuke" is the erase-all UF2. + */ +export function buildNrfPlan( + files: Map, + manifest: FlashManifest | null, + factoryInstall: boolean +): NrfFlashPlan | null { + const section = factoryInstall ? (manifest?.factory ?? manifest?.update) : manifest?.update + if (!section?.images?.length) return null + + let firmwareFile: Uint8Array | undefined + let firmwareName: string | undefined + let nukeFile: Uint8Array | undefined + + for (const img of section.images) { + const role = img.role?.toLowerCase() + if (role === 'nuke') { + nukeFile = findInTar(files, img.file) + } else if (role === 'uf2' || img.file.toLowerCase().endsWith('.uf2')) { + if (!firmwareFile) { + firmwareFile = findInTar(files, img.file) + firmwareName = img.file + } + } + } + + if (!firmwareFile || !firmwareName) return null + return { firmwareFile, firmwareName, nukeFile } +} + +/** + * Write a single Uint8Array to a file in a directory handle, replacing any existing file. + */ +async function writeToDir(dirHandle: FileSystemDirectoryHandle, filename: string, data: Uint8Array): Promise { + const fileHandle = await dirHandle.getFileHandle(filename, { create: true }) + const writable = await fileHandle.createWritable() + try { + // Slice to a plain ArrayBuffer to satisfy FileSystemWriteChunkType + const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer + await writable.write(buf) + } finally { + await writable.close() + } +} + +/** + * Flash an nRF52 device via UF2 using the File System Access API. + * + * Flow (normal): + * 1. showDirectoryPicker — user selects the UF2 drive + * 2. Write firmware.uf2 → device reboots + * + * Flow (chip erase / factory): + * 1. showDirectoryPicker — user selects the UF2 drive + * 2. Write nuke.uf2 → device erases all flash and re-enters bootloader (~3 s) + * 3. showDirectoryPicker again — user selects the re-enumerated UF2 drive + * 4. Write firmware.uf2 → device reboots + */ +export async function runNrfFlash(options: { + plan: NrfFlashPlan + onPhase: (label: string) => void + onWriteProgress: (p: NrfWriteProgressPayload) => void +}): Promise { + const { plan, onPhase, onWriteProgress } = options + + if (plan.nukeFile) { + onPhase('Select the UF2 drive to erase…') + const nukeDir = await pickDirectory() + + onPhase('Erasing device flash…') + onWriteProgress({ phase: 'nuke', written: 0, total: plan.nukeFile.byteLength, pct: 0 }) + await writeToDir(nukeDir, 'nuke.uf2', plan.nukeFile) + onWriteProgress({ phase: 'nuke', written: plan.nukeFile.byteLength, total: plan.nukeFile.byteLength, pct: 100 }) + + // Device erases flash and re-enters UF2 bootloader; drive disappears then reappears. + onPhase('Waiting for device to re-enumerate…') + await sleepMs(3500) + + onPhase('Select the UF2 drive again…') + const fwDir = await pickDirectory() + + onPhase('Writing firmware…') + onWriteProgress({ phase: 'firmware', written: 0, total: plan.firmwareFile.byteLength, pct: 0 }) + await writeToDir(fwDir, plan.firmwareName, plan.firmwareFile) + onWriteProgress({ phase: 'firmware', written: plan.firmwareFile.byteLength, total: plan.firmwareFile.byteLength, pct: 100 }) + } else { + onPhase('Select the UF2 drive that appeared…') + const dir = await pickDirectory() + + onPhase('Writing firmware…') + onWriteProgress({ phase: 'firmware', written: 0, total: plan.firmwareFile.byteLength, pct: 0 }) + await writeToDir(dir, plan.firmwareName, plan.firmwareFile) + onWriteProgress({ phase: 'firmware', written: plan.firmwareFile.byteLength, total: plan.firmwareFile.byteLength, pct: 100 }) + } +} + +/** True when the File System Access API directory picker is available (Chromium). */ +export function isNrfFlashSupported(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return typeof (window as any).showDirectoryPicker === 'function' +}