mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-11 03:12:55 +02:00
Refactor EspFlasher component to implement factory reset functionality and update flash manifest handling. Modify buildFlashParts to support factory and update sections in manifests, ensuring proper erase policies. Enhance user interface to reflect changes in reset behavior and provide clearer warnings regarding data loss during flashing.
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { buildFlashParts, manifestFromMap } from "../lib/espFlashLayout"
|
||||
import { buildFlashParts, manifestFromMap, manifestHasFactorySection } from "../lib/espFlashLayout"
|
||||
import {
|
||||
ensureSerialPortClosed,
|
||||
ESP_FLASH_WEB_BAUD,
|
||||
@@ -85,7 +85,7 @@ export default function EspFlasher({
|
||||
}: EspFlasherProps) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const shareDialogRef = useRef<HTMLDialogElement>(null)
|
||||
const [resetDeviceStorage, setResetDeviceStorage] = useState(false)
|
||||
const [eraseFlashForFactory, setEraseFlashForFactory] = useState(false)
|
||||
const [layoutPreview, setLayoutPreview] = useState<FlashManifest | null>(null)
|
||||
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
|
||||
const [bundleLoadError, setBundleLoadError] = useState<string | null>(null)
|
||||
@@ -106,6 +106,7 @@ export default function EspFlasher({
|
||||
const m = manifestFromMap(files)
|
||||
if (!cancelled) {
|
||||
setLayoutPreview(m)
|
||||
setEraseFlashForFactory(prev => (manifestHasFactorySection(m) ? prev : false))
|
||||
setBundleLoadError(null)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -123,6 +124,8 @@ export default function EspFlasher({
|
||||
const flashBlockedReason = unsupportedFlashMessage(resolvedFamily)
|
||||
const canEspFlash = flashBlockedReason === null
|
||||
|
||||
const hasFactorySection = useMemo(() => manifestHasFactorySection(layoutPreview), [layoutPreview])
|
||||
|
||||
const prepareBundle = useCallback(async () => {
|
||||
const res = await fetch(bundleUrl)
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
|
||||
@@ -130,6 +133,7 @@ export default function EspFlasher({
|
||||
const files = extractTarGz(buf)
|
||||
const m = manifestFromMap(files)
|
||||
setLayoutPreview(m)
|
||||
setEraseFlashForFactory(prev => (manifestHasFactorySection(m) ? prev : false))
|
||||
return files
|
||||
}, [bundleUrl])
|
||||
|
||||
@@ -153,17 +157,20 @@ export default function EspFlasher({
|
||||
|
||||
setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" })
|
||||
const files = await prepareBundle()
|
||||
const parts = buildFlashParts(files, { resetDeviceStorage })
|
||||
if (!parts) {
|
||||
const plan = buildFlashParts(files, {
|
||||
factoryInstall: eraseFlashForFactory,
|
||||
resetDeviceStorage: false,
|
||||
})
|
||||
if (!plan) {
|
||||
toast.error("Could not detect flash layout from bundle")
|
||||
return
|
||||
}
|
||||
|
||||
await runEspFlash({
|
||||
port,
|
||||
parts,
|
||||
parts: plan.parts,
|
||||
baud: ESP_FLASH_WEB_BAUD,
|
||||
eraseAll: false,
|
||||
eraseAll: plan.eraseAll,
|
||||
onPhase: phase => {
|
||||
setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] })
|
||||
},
|
||||
@@ -193,7 +200,7 @@ export default function EspFlasher({
|
||||
setFlashProgress(null)
|
||||
}
|
||||
}
|
||||
}, [resetDeviceStorage, prepareBundle, canEspFlash, flashBlockedReason])
|
||||
}, [eraseFlashForFactory, prepareBundle, canEspFlash, flashBlockedReason])
|
||||
|
||||
const shareUrlTrimmed = useMemo(() => sharePageUrl?.trim() ?? "", [sharePageUrl])
|
||||
const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function"
|
||||
@@ -264,31 +271,39 @@ export default function EspFlasher({
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={resetDeviceStorage}
|
||||
aria-label="Reset device storage"
|
||||
disabled={!canEspFlash}
|
||||
onClick={() => setResetDeviceStorage(v => !v)}
|
||||
aria-checked={eraseFlashForFactory}
|
||||
aria-label="Full device reset (erase and reinstall from scratch)"
|
||||
disabled={!canEspFlash || busy || !hasFactorySection}
|
||||
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 ${
|
||||
resetDeviceStorage ? "bg-amber-600" : "bg-slate-700"
|
||||
eraseFlashForFactory ? "bg-amber-600" : "bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 translate-y-0.5 rounded-full bg-white shadow transition-transform duration-200 ease-out ${
|
||||
resetDeviceStorage ? "translate-x-[22px]" : "translate-x-0.5"
|
||||
eraseFlashForFactory ? "translate-x-[22px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-200">Reset device storage</span>
|
||||
<span className="text-sm font-medium text-slate-200">Full device reset</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resetDeviceStorage ? (
|
||||
{!hasFactorySection ? (
|
||||
<p className="text-xs text-amber-300/90">
|
||||
Full device reset is not available for this bundle—only an update. Typical reasons: an older download, a
|
||||
build that did not include a factory image, or firmware not produced by this app’s usual ESP32 flow.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{eraseFlashForFactory ? (
|
||||
<p
|
||||
className="text-sm text-red-400 border border-red-500/40 bg-red-950/35 rounded-md px-3 py-2"
|
||||
className="text-sm text-amber-200/90 rounded-md border border-amber-800/40 bg-amber-950/25 px-3 py-2"
|
||||
role="status"
|
||||
>
|
||||
<strong className="font-semibold text-red-300">Warning:</strong> all user data on the device will be deleted
|
||||
when you flash (channels, preferences, and stored files).
|
||||
<strong className="font-semibold text-amber-100">You will lose everything on the radio:</strong> channels and
|
||||
settings, private keys, node info, message history, and any stored maps or telemetry. Only use this if you are
|
||||
deliberately starting over or recovering a bad state.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
+54
-14
@@ -1,16 +1,26 @@
|
||||
import { findInTar, parseFlashManifest, type FlashManifest, type FlashManifestImage } from './untarGz'
|
||||
import {
|
||||
findInTar,
|
||||
parseFlashManifest,
|
||||
type FlashManifest,
|
||||
type FlashManifestSection,
|
||||
} from './untarGz'
|
||||
|
||||
export type FlashPart = { data: Uint8Array; address: number; name: string }
|
||||
|
||||
function manifestImageOffset(im: FlashManifestImage): number | null {
|
||||
const addr = typeof im.offset === 'string' ? parseInt(im.offset, 0) : Number(im.offset)
|
||||
return Number.isFinite(addr) ? addr : null
|
||||
export type BuildFlashPlan = {
|
||||
parts: FlashPart[]
|
||||
eraseAll: boolean
|
||||
}
|
||||
|
||||
export type BuildFlashPartsOptions = {
|
||||
/**
|
||||
* When true, flash optional LittleFS images from the manifest (wipes Meshtastic storage on device).
|
||||
* Bootloader, partitions, firmware, and other non-LittleFS images are always included when present.
|
||||
* 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
|
||||
}
|
||||
@@ -29,6 +39,20 @@ function isLittlefsManifestFile(file: string): boolean {
|
||||
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).
|
||||
@@ -60,20 +84,23 @@ function resolveVersionedFirmwareApp(
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Build ordered flash parts from a flat map (tar paths or bare filenames → bytes). */
|
||||
/** 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 = {}
|
||||
): FlashPart[] | null {
|
||||
): 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 m.images) {
|
||||
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
|
||||
@@ -81,7 +108,12 @@ export function buildFlashParts(
|
||||
if (!Number.isFinite(addr)) return null
|
||||
out.push({ data, address: addr, name: img.file })
|
||||
}
|
||||
if (out.length) return sortFlashParts(out)
|
||||
if (out.length) {
|
||||
return {
|
||||
parts: sortFlashParts(out),
|
||||
eraseAll: Boolean(section.eraseFlash),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,18 +140,26 @@ export function buildFlashParts(
|
||||
{ data: app, address: 0x10000, name: appName },
|
||||
]
|
||||
if (bootApp0) arr.push({ data: bootApp0, address: 0xe000, name: 'boot_app0.bin' })
|
||||
return sortFlashParts(arr)
|
||||
return { parts: sortFlashParts(arr), eraseAll: false }
|
||||
}
|
||||
|
||||
if (app && appName && !bootloader && !partitions) {
|
||||
return [{ data: app, address: 0x0, name: appName }]
|
||||
return { parts: [{ data: app, address: 0x0, name: appName }], eraseAll: false }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function manifestFromMap(files: Map<string, Uint8Array>): FlashManifest | null {
|
||||
const raw = findInTar(files, 'flash-manifest.json')
|
||||
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)
|
||||
}
|
||||
|
||||
+21
-6
@@ -48,28 +48,43 @@ export function findInTar(files: Map<string, Uint8Array>, filename: string): Uin
|
||||
export type FlashManifestImage = {
|
||||
file: string
|
||||
offset: number | string
|
||||
/** When true on LittleFS rows, Mesh Forge skips unless the user enables Reset device storage. */
|
||||
/** 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 = {
|
||||
images: FlashManifestImage[]
|
||||
update?: FlashManifestSection
|
||||
factory?: FlashManifestSection
|
||||
/** Legacy single-layout manifest. */
|
||||
images?: FlashManifestImage[]
|
||||
eraseFlash?: boolean
|
||||
targetFamily?: FlashTargetFamily
|
||||
/** Raw PlatformIO `platform` (debug / advanced UI). */
|
||||
platform?: string
|
||||
/** Raw PlatformIO `board` (debug / advanced UI). */
|
||||
board?: string
|
||||
}
|
||||
|
||||
export function parseFlashManifest(json: string): FlashManifest | null {
|
||||
try {
|
||||
const o = JSON.parse(json) as FlashManifest
|
||||
if (!o || !Array.isArray(o.images)) return null
|
||||
return o
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user