diff --git a/src/components/EspFlasher.tsx b/src/components/EspFlasher.tsx index a03f23b..9bb8a83 100644 --- a/src/components/EspFlasher.tsx +++ b/src/components/EspFlasher.tsx @@ -1,8 +1,8 @@ import { Button } from "@/components/ui/button" -import { CheckCircle2 } from "lucide-react" -import { useCallback, useEffect, useState } from "react" +import { Check, CheckCircle2, X } from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" import { toast } from "sonner" -import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from "../lib/espFlashLayout" +import { buildFlashParts, flashInstallRowsFromManifest, manifestFromMap } from "../lib/espFlashLayout" import { ensureSerialPortClosed, isSerialUserCancelledError, @@ -20,7 +20,7 @@ type FlashProgress = | { kind: "complete" } const PHASE_LABEL: Record = { - connect: "Connecting to bootloader…", + connect: "Connecting to ROM bootloader…", detect: "Detecting flash size…", write: "Writing firmware…", } @@ -62,15 +62,14 @@ export default function EspFlasher({ }: EspFlasherProps) { const [busy, setBusy] = useState(false) const [eraseAll, setEraseAll] = useState(false) - const [noReset, setNoReset] = useState(false) const [baud, setBaud] = useState(921600) const [layoutPreview, setLayoutPreview] = useState(null) const [flashProgress, setFlashProgress] = useState(null) const [bundleLoadError, setBundleLoadError] = useState(null) - const [dfuTouchComplete, setDfuTouchComplete] = useState(false) + const [dfuPulsedOnce, setDfuPulsedOnce] = useState(false) useEffect(() => { - setDfuTouchComplete(false) + setDfuPulsedOnce(false) setLayoutPreview(null) setBundleLoadError(null) let cancelled = false @@ -103,6 +102,11 @@ export default function EspFlasher({ const flashBlockedReason = unsupportedFlashMessage(resolvedFamily) const canEspFlash = flashBlockedReason === null + const installPlanRows = useMemo( + () => (layoutPreview ? flashInstallRowsFromManifest(layoutPreview, eraseAll) : []), + [layoutPreview, eraseAll] + ) + const prepareBundle = useCallback(async () => { const res = await fetch(bundleUrl) if (!res.ok) throw new Error(`Download failed: ${res.status}`) @@ -130,7 +134,7 @@ export default function EspFlasher({ port = await navigator.serial.requestPort() setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" }) const files = await prepareBundle() - const parts = buildFlashParts(files) + const parts = buildFlashParts(files, { eraseAll }) if (!parts) { toast.error("Could not detect flash layout from bundle") return @@ -141,7 +145,6 @@ export default function EspFlasher({ parts, baud, eraseAll, - resetMode: noReset ? "no_reset" : "default_reset", onPhase: phase => { setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] }) }, @@ -171,15 +174,15 @@ export default function EspFlasher({ setFlashProgress(null) } } - }, [baud, eraseAll, noReset, prepareBundle, canEspFlash, flashBlockedReason]) + }, [baud, eraseAll, prepareBundle, canEspFlash, flashBlockedReason]) const enterDfuMode = useCallback(async () => { try { await pulseUsbBootloaderPort() - setDfuTouchComplete(true) - toast.success("DFU / bootloader touch sent", { + setDfuPulsedOnce(true) + toast.success("1200-baud touch sent", { description: - "If the device re-enumerated, pick the bootloader port and flash. If not, hold BOOT, tap RST, then try again.", + "If the device re-enumerated, pick the serial port and flash. If not, hold BOOT, tap RST, then try again.", }) } catch (e) { if (isSerialUserCancelledError(e)) { @@ -200,8 +203,8 @@ export default function EspFlasher({ <>

USB firmware flash (Web Serial)

- esptool-js for ESP32-class layouts. Put the board in bootloader if needed — use{" "} - Enter DFU mode for the USB CDC 1200-baud touch when supported. + esptool-js for ESP32-class layouts. Put the board in ROM serial-download mode if needed — + use Enter DFU mode for the USB CDC 1200-baud touch when supported.

)} @@ -224,11 +227,47 @@ export default function EspFlasher({ ) : null} {layoutPreview ? ( -
    - {layoutPreviewFromManifest(layoutPreview).map((line, i) => ( -
  • {line}
  • - ))} -
+
+ + + + + + + + + + {installPlanRows.map((row, i) => ( + + + + + + ))} + +
+ Image + + Offset + + Install +
{row.file}{row.offsetHex} + {row.willInstall ? ( + + + Yes + + ) : ( + + + No + + )} +
+

+ Optional images are skipped unless Full chip erase is checked. +

+
) : (

Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or single @@ -260,19 +299,6 @@ export default function EspFlasher({ /> Full chip erase (destructive) - -

@@ -286,7 +312,7 @@ export default function EspFlasher({ {busy ? flashBusyLabel : flashButtonLabel}
diff --git a/src/lib/espFlashLayout.ts b/src/lib/espFlashLayout.ts index 7fa91d0..8d1131c 100644 --- a/src/lib/espFlashLayout.ts +++ b/src/lib/espFlashLayout.ts @@ -1,7 +1,20 @@ -import { findInTar, parseFlashManifest, type FlashManifest } from './untarGz' +import { findInTar, parseFlashManifest, type FlashManifest, type FlashManifestImage } from './untarGz' export type FlashPart = { data: Uint8Array; address: number; name: string } +export type FlashInstallPlanRow = { + file: string + offset: number + offsetHex: string + optional: boolean + willInstall: boolean +} + +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 BuildFlashPartsOptions = { /** When false, manifest rows with optional:true are omitted. */ eraseAll?: boolean @@ -50,11 +63,23 @@ function resolveVersionedFirmwareApp( return undefined } -export function layoutPreviewFromManifest(m: FlashManifest): string[] { - return m.images.map(im => { - const tag = im.optional ? ' — optional unless full chip erase' : '' - return `${im.file} @ ${String(im.offset)}${tag}` - }) +/** Sorted install plan for UI; `willInstall` matches `buildFlashParts` optional + eraseAll rules. */ +export function flashInstallRowsFromManifest(m: FlashManifest, eraseAll: boolean): FlashInstallPlanRow[] { + const rows: FlashInstallPlanRow[] = [] + for (const im of m.images) { + const offset = manifestImageOffset(im) + if (offset === null) continue + const optional = im.optional === true + const willInstall = !optional || eraseAll + rows.push({ + file: im.file, + offset, + offsetHex: `0x${offset.toString(16)}`, + optional, + willInstall, + }) + } + return rows.sort((a, b) => a.offset - b.offset) } /** Build ordered flash parts from a flat map (tar paths or bare filenames → bytes). */