ui updates

This commit is contained in:
Ben Allfree
2026-04-11 04:07:16 -07:00
parent af575ee2a5
commit c28955c920
4 changed files with 459 additions and 28 deletions
@@ -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<HTMLDialogElement>(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({
</div>
</div>
{!hasFactorySection ? (
{!canFullReset ? (
<p className="text-xs text-amber-300/90">
Full device reset is not available for this bundleonly an update. Typical reasons: an older download, or a
build that did not include a factory image.
+397
View File
@@ -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<void> {
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<void> {
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<Uint8Array> {
const reader = port.readable!.getReader()
const buf: number[] = []
let c0Count = 0
const timeout = new Promise<never>((_, 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<void> {
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<void> {
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<void> {
const payload = concat(int32LE(DFU_INIT_PACKET), dat, int16LE(0x0000))
await sendPacket(port, payload)
}
async function sendErasePage(port: SerialPort, pageAddress: number): Promise<void> {
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<void> {
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<string, Uint8Array>): 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<void> {
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 */ }
}
}
+46 -6
View File
@@ -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<FileSystemDirectoryHandle> {
}
/**
* 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<string, Uint8Array>
manifest: FlashManifest | null
factoryInstall: boolean
onPhase: (label: string) => void
onWriteProgress: (p: NrfWriteProgressPayload) => void
}): Promise<void> {
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
}
+2 -2
View File
@@ -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 = (
<div className="max-w-2xl space-y-4 text-sm">
{showFlashUsbPanel ? (
<EspFlasher
<DeviceFlasher
embedded
bundleUrl={flashUrl!}
targetEnv={resolvedTargetEnv}