consolidate

This commit is contained in:
Ben Allfree
2026-04-11 05:09:59 -07:00
parent c28955c920
commit 1ec72c81b4
9 changed files with 212 additions and 860 deletions
+17 -24
View File
@@ -13,7 +13,7 @@ import {
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { buildFlashParts, manifestFromMap, manifestHasFactorySection } from "../lib/espFlashLayout"
import { buildFlashParts } from "../lib/espFlashLayout"
import {
ensureSerialPortClosed,
ESP_FLASH_WEB_BAUD,
@@ -23,8 +23,8 @@ import {
type FlashPhase,
} from "../lib/espFlashRun"
import { runNrfFlash } from "../lib/nrfFlashRun"
import { resolveFlashTargetFamily } from "../lib/flashTargetFamily"
import type { FlashManifest, FlashTargetFamily } from "../lib/untarGz"
import { inferTargetFamilyFromBundle, inferTargetFamilyFromEnv } from "../lib/flashTargetFamily"
import type { FlashTargetFamily } from "../lib/untarGz"
import { extractTarGz } from "../lib/untarGz"
type FlashProgress =
@@ -84,12 +84,14 @@ export default function DeviceFlasher({
const [busy, setBusy] = useState(false)
const shareDialogRef = useRef<HTMLDialogElement>(null)
const [eraseFlashForFactory, setEraseFlashForFactory] = useState(false)
const [layoutPreview, setLayoutPreview] = useState<FlashManifest | null>(null)
const [bundleFamily, setBundleFamily] = useState<FlashTargetFamily>(
inferTargetFamilyFromEnv(targetEnv) ?? "esp32"
)
const [bundleCanErase, setBundleCanErase] = useState(false)
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
const [bundleLoadError, setBundleLoadError] = useState<string | null>(null)
useEffect(() => {
setLayoutPreview(null)
setBundleLoadError(null)
let cancelled = false
void (async () => {
@@ -101,10 +103,11 @@ export default function DeviceFlasher({
}
const buf = new Uint8Array(await res.arrayBuffer())
const files = extractTarGz(buf)
const m = manifestFromMap(files)
const { family, canErase } = inferTargetFamilyFromBundle(files, targetEnv)
if (!cancelled) {
setLayoutPreview(m)
setEraseFlashForFactory(prev => (manifestHasFactorySection(m) ? prev : false))
setBundleFamily(family)
setBundleCanErase(canErase)
setEraseFlashForFactory(prev => (canErase ? prev : false))
setBundleLoadError(null)
}
} catch (e) {
@@ -116,25 +119,20 @@ export default function DeviceFlasher({
return () => {
cancelled = true
}
}, [bundleUrl])
}, [bundleUrl, targetEnv])
const resolvedFamily = resolveFlashTargetFamily(layoutPreview, targetEnv)
const resolvedFamily = bundleFamily
const flashBlockedReason = unsupportedFlashMessage(resolvedFamily)
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
// ESP32 can always chip-erase (merged binary supports it); nRF52 only when canErase is set.
const canFullReset = resolvedFamily === "esp32" || bundleCanErase
const prepareBundle = useCallback(async () => {
const res = await fetch(bundleUrl)
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
const buf = new Uint8Array(await res.arrayBuffer())
const files = extractTarGz(buf)
const m = manifestFromMap(files)
setLayoutPreview(m)
setEraseFlashForFactory(prev => (manifestHasFactorySection(m) ? prev : false))
return files
return extractTarGz(buf)
}, [bundleUrl])
const flash = useCallback(async () => {
@@ -159,11 +157,9 @@ export default function DeviceFlasher({
const files = await prepareBundle()
if (resolvedFamily === "nrf52") {
const manifest = manifestFromMap(files)
await runNrfFlash({
port: port!,
files,
manifest,
factoryInstall: eraseFlashForFactory,
onPhase: label => {
setFlashProgress({ kind: "indeterminate", label })
@@ -173,10 +169,7 @@ export default function DeviceFlasher({
},
})
} else {
const plan = buildFlashParts(files, {
factoryInstall: eraseFlashForFactory && hasFactorySection,
resetDeviceStorage: false,
})
const plan = buildFlashParts(files)
if (!plan) {
toast.error("Could not detect flash layout from bundle")
return
+13 -142
View File
@@ -1,10 +1,3 @@
import {
findInTar,
parseFlashManifest,
type FlashManifest,
type FlashManifestSection,
} from './untarGz'
export type FlashPart = { data: Uint8Array; address: number; name: string }
export type BuildFlashPlan = {
@@ -12,154 +5,32 @@ export type BuildFlashPlan = {
eraseAll: boolean
}
export type BuildFlashPartsOptions = {
/**
* When true, use manifest `factory` section (chip erase + merged factory + OTA + filesystem).
* When false, use `update` section or legacy flat `images`.
*/
factoryInstall?: boolean
/**
* Legacy: when an image has optional:true (e.g. LittleFS), skip unless true.
* Ignored for Meshtastic dual manifests (update has no optional rows).
*/
resetDeviceStorage?: boolean
}
function sortFlashParts(parts: FlashPart[]): FlashPart[] {
return [...parts].sort((a, b) => a.address - b.address)
}
function tarBasename(path: string): string {
const parts = path.replace(/^\.\//, '').split('/')
return parts[parts.length - 1] ?? path
}
function isLittlefsManifestFile(file: string): boolean {
const base = tarBasename(file)
return base.toLowerCase().startsWith('littlefs-') && base.toLowerCase().endsWith('.bin')
}
function activeSection(m: FlashManifest, factoryInstall: boolean): FlashManifestSection | null {
if (factoryInstall) {
const f = m.factory
if (f && Array.isArray(f.images) && f.images.length > 0) return f
return null
}
const u = m.update
if (u && Array.isArray(u.images) && u.images.length > 0) return u
if (Array.isArray(m.images) && m.images.length > 0) {
return { images: m.images, eraseFlash: m.eraseFlash }
}
return null
}
/**
* PlatformIO projects (e.g. Meshtastic) often emit versioned names like
* firmware-heltec-v3-2.7.20.bin (split app image; merged factory.bin is not bundled for USB flash).
* Build the flash plan from a firmware bundle (tar paths or bare filenames → bytes).
*
* For ESP32: expects a single merged binary (firmware-*.factory.bin) at address 0x0.
* PlatformIO's mergebin target handles all chip-specific offsets during the build.
*
* For custom firmware drag-and-drop (no manifest): finds any .bin that is not a
* sub-component and flashes it at 0x0.
*/
function resolveVersionedFirmwareApp(
files: Map<string, Uint8Array>
): { data: Uint8Array; name: string } | undefined {
type Entry = { base: string; data: Uint8Array }
const list: Entry[] = []
export function buildFlashParts(files: Map<string, Uint8Array>): BuildFlashPlan | null {
for (const [path, data] of files) {
const base = tarBasename(path)
const lower = base.toLowerCase()
if (lower.startsWith('littlefs-')) continue
if (
lower === 'bootloader.bin' ||
lower === 'partitions.bin' ||
lower === 'boot_app0.bin'
lower.endsWith('.bin') &&
lower !== 'bootloader.bin' &&
lower !== 'partitions.bin' &&
lower !== 'boot_app0.bin'
) {
continue
}
list.push({ base, data })
}
const app = list.find(
e => /^firmware-.+\.bin$/i.test(e.base) && !/\.factory\.bin$/i.test(e.base)
)
if (app) return { data: app.data, name: app.base }
return undefined
}
/** Build ordered flash parts + erase policy from a flat map (tar paths or bare filenames → bytes). */
export function buildFlashParts(
files: Map<string, Uint8Array>,
options: BuildFlashPartsOptions = {}
): BuildFlashPlan | null {
const resetDeviceStorage = options.resetDeviceStorage ?? false
const factoryInstall = options.factoryInstall ?? false
const manifestRaw = findInTar(files, 'flash-manifest.json')
if (manifestRaw) {
const text = new TextDecoder().decode(manifestRaw)
const m = parseFlashManifest(text)
if (m) {
const section = activeSection(m, factoryInstall)
if (!section) return null
const out: FlashPart[] = []
for (const img of section.images) {
if (img.optional === true && isLittlefsManifestFile(img.file) && !resetDeviceStorage) continue
const data = findInTar(files, img.file)
if (!data) return null
const addr = typeof img.offset === 'string' ? parseInt(img.offset, 0) : Number(img.offset)
if (!Number.isFinite(addr)) return null
out.push({ data, address: addr, name: img.file })
}
if (out.length) {
return {
parts: sortFlashParts(out),
eraseAll: Boolean(section.eraseFlash),
}
}
return { parts: [{ data, address: 0x0, name: base }], eraseAll: false }
}
}
const bootloader = findInTar(files, 'bootloader.bin')
const partitions = findInTar(files, 'partitions.bin')
const bootApp0 = findInTar(files, 'boot_app0.bin')
const firmwareExact = findInTar(files, 'firmware.bin')
const versioned = resolveVersionedFirmwareApp(files)
let app: Uint8Array | undefined
let appName: string | undefined
if (firmwareExact) {
app = firmwareExact
appName = 'firmware.bin'
} else if (versioned) {
app = versioned.data
appName = versioned.name
}
if (bootloader && partitions && app && appName) {
const arr: FlashPart[] = [
{ data: bootloader, address: 0x1000, name: 'bootloader.bin' },
{ data: partitions, address: 0x8000, name: 'partitions.bin' },
{ data: app, address: 0x10000, name: appName },
]
if (bootApp0) arr.push({ data: bootApp0, address: 0xe000, name: 'boot_app0.bin' })
return { parts: sortFlashParts(arr), eraseAll: false }
}
if (app && appName && !bootloader && !partitions) {
return { parts: [{ data: app, address: 0x0, name: appName }], eraseAll: false }
}
return null
}
export function manifestFromMap(
files: Map<string, Uint8Array>,
manifestFile = 'flash-manifest.json'
): FlashManifest | null {
const raw = findInTar(files, manifestFile)
if (!raw) return null
return parseFlashManifest(new TextDecoder().decode(raw))
}
/** True if manifest includes a factory (erase + merged image) section with images. */
export function manifestHasFactorySection(m: FlashManifest | null): boolean {
return Boolean(m?.factory?.images && m.factory.images.length > 0)
}
+34 -23
View File
@@ -1,13 +1,7 @@
import type { FlashManifest, FlashTargetFamily } from "./untarGz"
import type { FlashTargetFamily } from "./untarGz"
const KNOWN: readonly FlashTargetFamily[] = ["esp32", "esp8266", "nrf52", "rp2040", "unknown"]
function coerceTargetFamily(v: unknown): FlashTargetFamily | undefined {
if (typeof v !== "string") return undefined
return KNOWN.includes(v as FlashTargetFamily) ? (v as FlashTargetFamily) : undefined
}
/** Heuristic when manifest lacks targetFamily (older bundles). */
/** Heuristic when bundle file types are ambiguous (e.g. UF2 shared by nRF52 and RP2040). */
export function inferTargetFamilyFromEnv(env: string | null | undefined): FlashTargetFamily | null {
if (!env?.trim()) return null
const e = env.toLowerCase()
@@ -27,22 +21,39 @@ export function inferTargetFamilyFromEnv(env: string | null | undefined): FlashT
}
/**
* Prefer manifest.targetFamily from CI; else env-name heuristic; else esp32 for legacy ESP-only bundles.
* Derive target family and erase capability from the files in a firmware bundle.
*
* Rules (in priority order):
* *.factory.bin present → esp32 (always supports chip erase)
* firmware.dat present → nrf52 Nordic DFU (always supports erase)
* *.uf2 present (not nuke.uf2) → nrf52 or rp2040 (use env name to disambiguate)
*
* canErase:
* esp32 → always true
* nrf52 DFU → always true
* nrf52 UF2 → true only when nuke.uf2 is also in the bundle
*/
export function resolveFlashTargetFamily(
manifest: FlashManifest | null,
targetEnv: string | null | undefined
): FlashTargetFamily {
const fromManifest = coerceTargetFamily(manifest?.targetFamily)
if (fromManifest && fromManifest !== "unknown") {
return fromManifest
export function inferTargetFamilyFromBundle(
files: Map<string, Uint8Array>,
targetEnv?: string | null
): { family: FlashTargetFamily; canErase: boolean } {
let hasUf2 = false
let hasNukeUf2 = false
for (const path of files.keys()) {
const base = path.replace(/^.*\//, "").toLowerCase()
if (base.endsWith(".factory.bin")) return { family: "esp32", canErase: true }
if (base === "firmware.dat") return { family: "nrf52", canErase: true }
if (base === "nuke.uf2") hasNukeUf2 = true
else if (base.endsWith(".uf2")) hasUf2 = true
}
const fromEnv = inferTargetFamilyFromEnv(targetEnv ?? null)
if (fromEnv) {
return fromEnv
if (hasUf2) {
const family = inferTargetFamilyFromEnv(targetEnv) ?? "nrf52"
return { family, canErase: hasNukeUf2 }
}
if (fromManifest === "unknown") {
return "unknown"
}
return "esp32"
// No clear signal — fall back to env name heuristic or default to esp32.
const family = inferTargetFamilyFromEnv(targetEnv) ?? "esp32"
return { family, canErase: family === "esp32" }
}
+15 -44
View File
@@ -1,5 +1,4 @@
import { findInTar } from './untarGz'
import type { FlashManifest } from './untarGz'
import { buildNordicDfuPlan, runNordicDfu } from './nrfDfuRun'
export type NrfFlashPlan = {
@@ -30,59 +29,32 @@ function pickDirectory(): Promise<FileSystemDirectoryHandle> {
}
/**
* 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.
*
* Falls back to scanning the bundle directly for *.uf2 when no manifest is present
* (covers pre-manifest bundles and builds where emit-flash-manifest.py did not run).
* Build an nRF52 UF2 flash plan from the bundle files.
* Scans for *.uf2 (preferring names starting with "firmware", excluding nuke.uf2).
* When factoryInstall is true, also includes nuke.uf2 if present in the bundle.
*/
export function buildNrfPlan(
files: Map<string, Uint8Array>,
manifest: FlashManifest | null,
factoryInstall: boolean
): NrfFlashPlan | null {
const section = factoryInstall ? (manifest?.factory ?? manifest?.update) : manifest?.update
let firmwareFile: Uint8Array | undefined
let firmwareName: string | undefined
if (section?.images?.length) {
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 { firmwareFile, firmwareName, nukeFile }
}
}
// Fallback: scan bundle files directly for *.uf2 (no manifest or manifest had no UF2 images).
// Prefer names starting with "firmware", exclude nuke.uf2.
let fallbackName: string | undefined
let fallbackData: Uint8Array | undefined
for (const [path, data] of files) {
const base = path.replace(/^.*\//, '')
const baseLower = base.toLowerCase()
if (!baseLower.endsWith('.uf2') || baseLower === 'nuke.uf2') continue
if (!fallbackName || baseLower.startsWith('firmware')) {
fallbackName = base
fallbackData = data
if (!firmwareName || baseLower.startsWith('firmware')) {
firmwareName = base
firmwareFile = data
if (baseLower.startsWith('firmware')) break
}
}
if (!fallbackData || !fallbackName) return null
return { firmwareFile: fallbackData, firmwareName: fallbackName }
if (!firmwareFile || !firmwareName) return null
const nukeFile = factoryInstall ? findInTar(files, 'nuke.uf2') : undefined
return { firmwareFile, firmwareName, nukeFile }
}
/**
@@ -153,7 +125,7 @@ async function runUf2Flash(options: {
/**
* 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
* contains a Nordic DFU package (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.
@@ -161,12 +133,11 @@ async function runUf2Flash(options: {
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
const { port, files, factoryInstall, onPhase, onWriteProgress } = options
// Prefer Nordic Serial DFU when the bundle contains a Nordic DFU package (bin + dat).
const dfuPlan = buildNordicDfuPlan(files)
@@ -182,7 +153,7 @@ export async function runNrfFlash(options: {
}
// Fall back to UF2 drive picker (Meshtastic nRF52 and other UF2 bootloader boards).
const uf2Plan = buildNrfPlan(files, manifest, factoryInstall)
const uf2Plan = buildNrfPlan(files, factoryInstall)
if (!uf2Plan) {
throw new Error('No flashable firmware found in bundle (expected Nordic DFU .bin/.dat or a .uf2 file)')
}
-42
View File
@@ -45,47 +45,5 @@ export function findInTar(files: Map<string, Uint8Array>, filename: string): Uin
return undefined
}
export type FlashManifestImage = {
file: string
offset: number | string
/** When true on LittleFS rows, Mesh Forge skips unless optional handling passes (legacy flat manifests). */
optional?: boolean
role?: string
}
/** One flash plan (update or factory) inside flash-manifest.json. */
export type FlashManifestSection = {
images: FlashManifestImage[]
/** When true, flasher performs full chip erase before write. */
eraseFlash?: boolean
}
/** Coarse MCU family for USB flasher entry + tool selection (from CI / PlatformIO). */
export type FlashTargetFamily = "esp32" | "esp8266" | "nrf52" | "rp2040" | "unknown"
/**
* Root flash-manifest.json: Meshtastic-style `update` + optional `factory`, or legacy flat `images`.
*/
export type FlashManifest = {
update?: FlashManifestSection
factory?: FlashManifestSection
/** Legacy single-layout manifest. */
images?: FlashManifestImage[]
eraseFlash?: boolean
targetFamily?: FlashTargetFamily
platform?: string
board?: string
}
export function parseFlashManifest(json: string): FlashManifest | null {
try {
const o = JSON.parse(json) as FlashManifest
if (!o || typeof o !== "object") return null
if (o.update && Array.isArray(o.update.images) && o.update.images.length > 0) return o
if (o.factory && Array.isArray(o.factory.images) && o.factory.images.length > 0) return o
if (Array.isArray(o.images) && o.images.length > 0) return o
return null
} catch {
return null
}
}