tdeck fix

This commit is contained in:
Ben Allfree
2026-04-10 22:02:28 -07:00
parent ca43841da8
commit f14c848857
3 changed files with 345 additions and 51 deletions
+54 -27
View File
@@ -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<FlashPhase, string> = {
}
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 boards 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"
+134
View File
@@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function pickDirectory(): Promise<FileSystemDirectoryHandle> {
// 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<FileSystemDirectoryHandle>) | 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<string, Uint8Array>,
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<void> {
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<void> {
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'
}