From c28955c920880b104f79e173838b75abbddab691 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sat, 11 Apr 2026 04:07:16 -0700 Subject: [PATCH] ui updates --- .../{EspFlasher.tsx => DeviceFlasher.tsx} | 34 +- src/lib/nrfDfuRun.ts | 397 ++++++++++++++++++ src/lib/nrfFlashRun.ts | 52 ++- src/pages/RepoPage.tsx | 4 +- 4 files changed, 459 insertions(+), 28 deletions(-) rename src/components/{EspFlasher.tsx => DeviceFlasher.tsx} (96%) create mode 100644 src/lib/nrfDfuRun.ts diff --git a/src/components/EspFlasher.tsx b/src/components/DeviceFlasher.tsx similarity index 96% rename from src/components/EspFlasher.tsx rename to src/components/DeviceFlasher.tsx index 16c5830..f38313c 100644 --- a/src/components/EspFlasher.tsx +++ b/src/components/DeviceFlasher.tsx @@ -22,7 +22,7 @@ import { runEspFlash, type FlashPhase, } from "../lib/espFlashRun" -import { buildNrfPlan, isNrfFlashSupported, runNrfFlash } from "../lib/nrfFlashRun" +import { runNrfFlash } from "../lib/nrfFlashRun" import { resolveFlashTargetFamily } from "../lib/flashTargetFamily" import type { FlashManifest, FlashTargetFamily } from "../lib/untarGz" import { extractTarGz } from "../lib/untarGz" @@ -48,7 +48,7 @@ function unsupportedFlashMessage(family: FlashTargetFamily): string | null { return null } -type EspFlasherProps = { +type DeviceFlasherProps = { bundleUrl: string /** PlatformIO env for the selected build; used if manifest omits targetFamily. */ targetEnv?: string | null @@ -68,7 +68,7 @@ type EspFlasherProps = { embedded?: boolean } -export default function EspFlasher({ +export default function DeviceFlasher({ bundleUrl, targetEnv = null, flashButtonLabel = "Flash", @@ -80,7 +80,7 @@ export default function EspFlasher({ onDownloadBundle = null, sharePageUrl = null, embedded = false, -}: EspFlasherProps) { +}: DeviceFlasherProps) { const [busy, setBusy] = useState(false) const shareDialogRef = useRef(null) const [eraseFlashForFactory, setEraseFlashForFactory] = useState(false) @@ -123,6 +123,8 @@ export default function EspFlasher({ const canEspFlash = flashBlockedReason === null const hasFactorySection = useMemo(() => manifestHasFactorySection(layoutPreview), [layoutPreview]) + // ESP32 can always chip-erase regardless of whether the bundle has a factory section. + const canFullReset = resolvedFamily === "esp32" || hasFactorySection const prepareBundle = useCallback(async () => { const res = await fetch(bundleUrl) @@ -144,12 +146,6 @@ 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 @@ -164,13 +160,11 @@ export default function EspFlasher({ 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, + port: port!, + files, + manifest, + factoryInstall: eraseFlashForFactory, onPhase: label => { setFlashProgress({ kind: "indeterminate", label }) }, @@ -180,7 +174,7 @@ export default function EspFlasher({ }) } else { const plan = buildFlashParts(files, { - factoryInstall: eraseFlashForFactory, + factoryInstall: eraseFlashForFactory && hasFactorySection, resetDeviceStorage: false, }) if (!plan) { @@ -191,7 +185,7 @@ export default function EspFlasher({ port, parts: plan.parts, baud: ESP_FLASH_WEB_BAUD, - eraseAll: plan.eraseAll, + eraseAll: eraseFlashForFactory || plan.eraseAll, onPhase: phase => { setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] }) }, @@ -300,7 +294,7 @@ export default function EspFlasher({ role="switch" aria-checked={eraseFlashForFactory} aria-label="Full device reset (erase and reinstall from scratch)" - disabled={!canEspFlash || busy || !hasFactorySection} + disabled={!canEspFlash || busy || !canFullReset} onClick={() => setEraseFlashForFactory(v => !v)} className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border border-slate-600 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 disabled:cursor-not-allowed disabled:opacity-50 ${ eraseFlashForFactory ? "bg-amber-600" : "bg-slate-700" @@ -316,7 +310,7 @@ export default function EspFlasher({ - {!hasFactorySection ? ( + {!canFullReset ? (

Full device reset is not available for this bundle—only an update. Typical reasons: an older download, or a build that did not include a factory image. diff --git a/src/lib/nrfDfuRun.ts b/src/lib/nrfDfuRun.ts new file mode 100644 index 0000000..bb119fd --- /dev/null +++ b/src/lib/nrfDfuRun.ts @@ -0,0 +1,397 @@ +/** + * Nordic Legacy Serial DFU (v0.5) over Web Serial. + * + * Protocol ported from meshcore-dev/flasher.meshcore.io/lib/dfu.js, which was itself + * adapted from Adafruit's adafruit-nrfutil Python implementation. + * + * Supported bundle format: Nordic DFU manifest.json + firmware.bin + firmware.dat + * (same as what PlatformIO produces for MeshCore RAK4631 and similar nRF52 boards). + */ + +import { findInTar } from './untarGz' + +// --------------------------------------------------------------------------- +// Protocol constants (adapted from dfu/dfu_transport_serial.py) +// --------------------------------------------------------------------------- + +const DFU_BAUD = 115200 +const READ_TIMEOUT_MS = 5000 +const FLASH_PAGE_SIZE = 4096 +const FLASH_PAGE_ERASE_TIME_MS = 90 // nRF52840 max ~89.7 ms +const FLASH_WORD_WRITE_TIME_MS = 0.1 // nRF52840 max ~100 µs per word +const FLASH_PAGE_WRITE_TIME_MS = (FLASH_PAGE_SIZE / 4) * FLASH_WORD_WRITE_TIME_MS // ≈ 102 ms +const DFU_PACKET_MAX_SIZE = 512 + +const DATA_INTEGRITY_CHECK_PRESENT = 1 +const RELIABLE_PACKET = 1 +const HCI_PACKET_TYPE = 14 + +const DFU_INIT_PACKET = 1 +const DFU_START_PACKET = 3 +const DFU_DATA_PACKET = 4 +const DFU_STOP_DATA_PACKET = 5 +const DFU_ERASE_PAGE = 6 +const DFU_UPDATE_MODE_APP = 4 + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Nordic DFU manifest.json "application" shape (dfu_version 0.5). */ +type NordicManifest = { + manifest: { + application: { + bin_file: string + dat_file: string + } + dfu_version?: number + } +} + +export type NordicDfuPlan = { + appBin: Uint8Array + /** Init packet (.dat file) — CRC + metadata for the bootloader. */ + initPacket: Uint8Array +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function sleepMs(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function int32LE(value: number): Uint8Array { + const buf = new ArrayBuffer(4) + new DataView(buf).setUint32(0, value, true) + return new Uint8Array(buf) +} + +function int16LE(value: number): Uint8Array { + const buf = new ArrayBuffer(2) + new DataView(buf).setUint16(0, value, true) + return new Uint8Array(buf) +} + +function concat(...arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((n, a) => n + a.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const a of arrays) { + out.set(a, off) + off += a.length + } + return out +} + +// --------------------------------------------------------------------------- +// CRC16 (adapted from dfu/crc16.py) +// --------------------------------------------------------------------------- + +function calcCrc16(data: Uint8Array, crc = 0xffff): number { + for (let i = 0; i < data.length; i++) { + crc = ((crc >> 8) & 0x00ff) | ((crc << 8) & 0xff00) + crc ^= data[i] + crc ^= (crc & 0x00ff) >> 4 + crc ^= (crc << 8) << 4 + crc ^= ((crc & 0x00ff) << 4) << 1 + } + return crc & 0xffff +} + +// --------------------------------------------------------------------------- +// SLIP framing +// --------------------------------------------------------------------------- + +function slipPartsToFourBytes(seq: number, dip: number, rp: number, pktType: number, pktLen: number): Uint8Array { + const b = new Uint8Array(4) + b[0] = seq | (((seq + 1) % 8) << 3) | (dip << 6) | (rp << 7) + b[1] = pktType | ((pktLen & 0x000f) << 4) + b[2] = (pktLen & 0x0ff0) >> 4 + b[3] = (~(b[0] + b[1] + b[2]) + 1) & 0xff + return b +} + +function slipEncodeEscChars(data: Uint8Array): Uint8Array { + const result: number[] = [] + for (const byte of data) { + if (byte === 0xc0) { + result.push(0xdb, 0xdc) + } else if (byte === 0xdb) { + result.push(0xdb, 0xdd) + } else { + result.push(byte) + } + } + return new Uint8Array(result) +} + +function slipDecode(data: number[]): Uint8Array { + const result: number[] = [] + let i = 0 + while (i < data.length) { + if (data[i] === 0xdb) { + i++ + if (i >= data.length) throw new Error('Invalid SLIP escape: truncated') + result.push(data[i] === 0xdc ? 0xc0 : data[i] === 0xdd ? 0xdb : (() => { throw new Error(`Invalid SLIP escape: 0xDB 0x${data[i].toString(16)}`) })()) + } else if (data[i] !== 0xc0) { + result.push(data[i]) + } + i++ + } + return new Uint8Array(result) +} + +// --------------------------------------------------------------------------- +// HCI packet +// --------------------------------------------------------------------------- + +let hciSequenceNumber = 0 + +function makeHciPacket(payload: Uint8Array): Uint8Array { + hciSequenceNumber = (hciSequenceNumber + 1) % 8 + const header = slipPartsToFourBytes( + hciSequenceNumber, + DATA_INTEGRITY_CHECK_PRESENT, + RELIABLE_PACKET, + HCI_PACKET_TYPE, + payload.length + ) + const withHeader = concat(header, payload) + const crc = calcCrc16(withHeader) + const withCrc = concat(withHeader, new Uint8Array([crc & 0xff, (crc >> 8) & 0xff])) + const encoded = slipEncodeEscChars(withCrc) + return concat(new Uint8Array([0xc0]), encoded, new Uint8Array([0xc0])) +} + +// --------------------------------------------------------------------------- +// Serial I/O helpers +// --------------------------------------------------------------------------- + +async function writeRaw(port: SerialPort, data: Uint8Array): Promise { + const writer = port.writable!.getWriter() + try { + await writer.write(data) + } finally { + writer.releaseLock() + } +} + +/** + * Read until two 0xC0 SLIP delimiters are received. + * Returns the decoded payload between the delimiters. + */ +async function readAck(port: SerialPort): Promise { + const reader = port.readable!.getReader() + const buf: number[] = [] + let c0Count = 0 + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('DFU ACK timeout')), READ_TIMEOUT_MS) + ) + + try { + await Promise.race([ + (async () => { + while (c0Count < 2) { + const { value, done } = await reader.read() + if (done) throw new Error('Port closed before ACK') + if (value) { + for (const b of value) { + buf.push(b) + if (b === 0xc0) c0Count++ + } + } + } + })(), + timeout, + ]) + } finally { + reader.releaseLock() + } + + const first = buf.indexOf(0xc0) + const second = buf.indexOf(0xc0, first + 1) + if (first === -1 || second === -1) throw new Error('Incomplete ACK frame') + return slipDecode(buf.slice(first + 1, second)) +} + +let lastAck = -1 + +async function sendPacket(port: SerialPort, payload: Uint8Array): Promise { + const pkt = makeHciPacket(payload) + await writeRaw(port, pkt) + + const decoded = await readAck(port) + if (decoded.length < 2) throw new Error('ACK too short') + const ack = (decoded[0] >> 3) & 0x07 + if (lastAck !== -1 && ack !== (lastAck + 1) % 8) { + hciSequenceNumber = 0 + throw new Error(`ACK sequence mismatch: expected ${(lastAck + 1) % 8}, got ${ack}`) + } + lastAck = ack +} + +// --------------------------------------------------------------------------- +// DFU commands +// --------------------------------------------------------------------------- + +async function sendStartDfu(port: SerialPort, appSize: number): Promise { + const payload = concat( + int32LE(DFU_START_PACKET), + int32LE(DFU_UPDATE_MODE_APP), + int32LE(0), // softdevice size + int32LE(0), // bootloader size + int32LE(appSize) + ) + await sendPacket(port, payload) + // Wait for flash erase proportional to app size + const eraseMs = Math.max(500, (Math.ceil(appSize / FLASH_PAGE_SIZE) + 1) * FLASH_PAGE_ERASE_TIME_MS) + await sleepMs(eraseMs) +} + +async function sendInitPacket(port: SerialPort, dat: Uint8Array): Promise { + const payload = concat(int32LE(DFU_INIT_PACKET), dat, int16LE(0x0000)) + await sendPacket(port, payload) +} + +async function sendErasePage(port: SerialPort, pageAddress: number): Promise { + const payload = concat(int32LE(DFU_ERASE_PAGE), int32LE(pageAddress)) + await sendPacket(port, payload) + await sleepMs(FLASH_PAGE_ERASE_TIME_MS) +} + +async function eraseFullFlash(port: SerialPort, appSize: number): Promise { + const numPages = Math.ceil(appSize / FLASH_PAGE_SIZE) + for (let i = 0; i < numPages; i++) { + await sendErasePage(port, i * FLASH_PAGE_SIZE) + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Detect a Nordic DFU bundle in the tarball map. + * + * Path 1: Nordic DFU manifest.json (from MeshCore PIO output or adafruit-nrfutil zip). + * Looks for manifest.json with { manifest: { application: { bin_file, dat_file } } }. + * + * Path 2: Bare firmware.dat + firmware.bin without a Nordic manifest.json. + * CI generates firmware.dat via adafruit-nrfutil for nRF52 builds; both files land + * in the bundle alongside the .uf2. Detected by presence of any *.dat file. + */ +export function buildNordicDfuPlan(files: Map): NordicDfuPlan | null { + // Path 1: Nordic DFU manifest.json + const manifestRaw = findInTar(files, 'manifest.json') + if (manifestRaw) { + try { + const m = JSON.parse(new TextDecoder().decode(manifestRaw)) as NordicManifest + const app = m?.manifest?.application + if (app?.bin_file && app?.dat_file) { + const appBin = findInTar(files, app.bin_file) + const initPacket = findInTar(files, app.dat_file) + if (appBin && initPacket) return { appBin, initPacket } + } + } catch { /* fall through to Path 2 */ } + } + + // Path 2: Bare .dat + .bin (CI-generated; no Nordic manifest needed) + let initPacket: Uint8Array | undefined + let datBaseName: string | undefined + for (const [path, data] of files) { + const base = path.replace(/^.*\//, '') + if (base.toLowerCase().endsWith('.dat')) { + initPacket = data + datBaseName = base.replace(/\.dat$/i, '') + break + } + } + if (!initPacket || !datBaseName) return null + + // Prefer matching base name (firmware.dat → firmware.bin), fall back to any firmware.bin + const appBin = findInTar(files, `${datBaseName}.bin`) ?? findInTar(files, 'firmware.bin') + if (!appBin) return null + + return { appBin, initPacket } +} + +/** + * Run a Nordic Legacy Serial DFU update. + * The port should already be closed (after the 1200-baud CDC touch); + * this function opens it at 115200 and handles teardown. + */ +export async function runNordicDfu(options: { + port: SerialPort + plan: NordicDfuPlan + eraseAll?: boolean + onPhase: (label: string) => void + onProgress: (pct: number) => void +}): Promise { + const { port, plan, eraseAll = false, onPhase, onProgress } = options + + // Reset HCI state for this session + hciSequenceNumber = 0 + lastAck = -1 + + onPhase('Connecting to bootloader…') + await port.open({ baudRate: DFU_BAUD }) + + try { + if (eraseAll) { + onPhase('Erasing flash…') + await eraseFullFlash(port, plan.appBin.length) + } + + onPhase('Sending DFU start…') + await sendStartDfu(port, plan.appBin.length) + + onPhase('Sending init packet…') + await sendInitPacket(port, plan.initPacket) + + onPhase('Writing firmware…') + const chunks: Uint8Array[] = [] + for (let i = 0; i < plan.appBin.length; i += DFU_PACKET_MAX_SIZE) { + chunks.push(plan.appBin.subarray(i, i + DFU_PACKET_MAX_SIZE)) + } + + let bytesSent = 0 + // Brief stabilization pause before the first data packet (mirrors Python implementation) + await sleepMs(FLASH_PAGE_WRITE_TIME_MS) + + for (let i = 0; i < chunks.length; i++) { + const payload = concat(int32LE(DFU_DATA_PACKET), chunks[i]) + await sendPacket(port, payload) + bytesSent += chunks[i].length + onProgress(Math.min(100, Math.round((bytesSent / plan.appBin.length) * 100))) + + // Yield after every 8 packets (one flash page) to let the bootloader catch up + if ((i + 1) % 8 === 0) { + await sleepMs(FLASH_PAGE_WRITE_TIME_MS) + } + } + + // Final page write wait + stop + await sleepMs(FLASH_PAGE_WRITE_TIME_MS) + await sendPacket(port, int32LE(DFU_STOP_DATA_PACKET)) + } finally { + // Clean up streams before closing + try { + if (port.readable) { + const r = port.readable.getReader() + await r.cancel().catch(() => {}) + r.releaseLock() + } + } catch { /* ignore */ } + try { + if (port.writable) { + const w = port.writable.getWriter() + await w.close().catch(() => {}) + w.releaseLock() + } + } catch { /* ignore */ } + try { await port.close() } catch { /* ignore */ } + } +} diff --git a/src/lib/nrfFlashRun.ts b/src/lib/nrfFlashRun.ts index f1187b2..7dee190 100644 --- a/src/lib/nrfFlashRun.ts +++ b/src/lib/nrfFlashRun.ts @@ -1,5 +1,6 @@ import { findInTar } from './untarGz' import type { FlashManifest } from './untarGz' +import { buildNordicDfuPlan, runNordicDfu } from './nrfDfuRun' export type NrfFlashPlan = { /** Written first when chip erase is requested; causes device to erase all flash and reboot. */ @@ -29,7 +30,7 @@ function pickDirectory(): Promise { } /** - * Build an nRF52 flash plan from the bundle tarball + parsed manifest. + * Build an nRF52 UF2 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. * @@ -112,7 +113,7 @@ async function writeToDir(dirHandle: FileSystemDirectoryHandle, filename: string * 3. showDirectoryPicker again — user selects the re-enumerated UF2 drive * 4. Write firmware.uf2 → device reboots */ -export async function runNrfFlash(options: { +async function runUf2Flash(options: { plan: NrfFlashPlan onPhase: (label: string) => void onWriteProgress: (p: NrfWriteProgressPayload) => void @@ -150,8 +151,47 @@ export async function runNrfFlash(options: { } } -/** 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' +/** + * Flash an nRF52 device. Routes to Nordic Serial DFU (seamless, progress bar) when the bundle + * contains a Nordic DFU manifest (firmware.bin + firmware.dat), otherwise falls back to UF2 + * via the File System Access API drive picker. + * + * The port must have already received the 1200-baud CDC bootloader pulse and be closed. + */ +export async function runNrfFlash(options: { + port: SerialPort + files: Map + manifest: FlashManifest | null + factoryInstall: boolean + onPhase: (label: string) => void + onWriteProgress: (p: NrfWriteProgressPayload) => void +}): Promise { + const { port, files, manifest, factoryInstall, onPhase, onWriteProgress } = options + + // Prefer Nordic Serial DFU when the bundle contains a Nordic DFU package (bin + dat). + const dfuPlan = buildNordicDfuPlan(files) + if (dfuPlan) { + await runNordicDfu({ + port, + plan: dfuPlan, + eraseAll: factoryInstall, + onPhase, + onProgress: pct => onWriteProgress({ phase: 'firmware', written: pct, total: 100, pct }), + }) + return + } + + // Fall back to UF2 drive picker (Meshtastic nRF52 and other UF2 bootloader boards). + const uf2Plan = buildNrfPlan(files, manifest, factoryInstall) + if (!uf2Plan) { + throw new Error('No flashable firmware found in bundle (expected Nordic DFU .bin/.dat or a .uf2 file)') + } + await runUf2Flash({ plan: uf2Plan, onPhase, onWriteProgress }) +} + +/** True when at least one nRF52 flash method is available in this browser. */ +export function isNrfFlashSupported(): boolean { + // Nordic DFU only needs Web Serial (already checked by caller). + // UF2 additionally needs File System Access API — but we always try DFU first. + return true } diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 2833ee1..08391ec 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -19,7 +19,7 @@ import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" import { toast } from "sonner" import { ComboboxField } from "../components/ComboboxField" -import EspFlasher from "../components/EspFlasher" +import DeviceFlasher from "../components/DeviceFlasher" import { normalizeBuildKey } from "../lib/buildKey" import { buildFailurePresentation } from "../lib/formatBuildErrorSummary" import { homepageHref, homepageLabel } from "../lib/githubHomepage" @@ -426,7 +426,7 @@ export default function RepoPage() { const ciAndFlasherEl = (

{showFlashUsbPanel ? ( -