From cf689d351da65b0136bbfd6d365a94010006dc19 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 10 Apr 2026 05:59:51 -0700 Subject: [PATCH] Add support for W3C Web Serial API types and enhance EspFlasher component. Updated package.json and tsconfig.json to include new type definitions. Improved EspFlasher functionality with progress tracking and error handling during firmware flashing. Refactored espFlashLayout and espFlashRun for better firmware resolution and flashing process. Updated RepoPage to handle flash view state and integrate flashing functionality. --- bun.lock | 3 + package.json | 1 + src/components/EspFlasher.tsx | 90 +++++++----- src/lib/espFlashLayout.ts | 66 ++++++++- src/lib/espFlashRun.ts | 62 +++++++- src/lib/repoTreeUrl.ts | 29 ++-- src/pages/RepoPage.tsx | 263 ++++++++++++++++++++++------------ tsconfig.json | 2 +- 8 files changed, 366 insertions(+), 150 deletions(-) diff --git a/bun.lock b/bun.lock index ec9edf8..7c81fe0 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", + "@types/w3c-web-serial": "^1.0.8", "@vitejs/plugin-react": "^5.1.1", "class-variance-authority": "^0.7.1", "postcss": "^8.5.9", @@ -592,6 +593,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/w3c-web-serial": ["@types/w3c-web-serial@1.0.8", "", {}, "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="], diff --git a/package.json b/package.json index df4339e..b477df1 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", + "@types/w3c-web-serial": "^1.0.8", "@vitejs/plugin-react": "^5.1.1", "class-variance-authority": "^0.7.1", "postcss": "^8.5.9", diff --git a/src/components/EspFlasher.tsx b/src/components/EspFlasher.tsx index a66b266..53be0f4 100644 --- a/src/components/EspFlasher.tsx +++ b/src/components/EspFlasher.tsx @@ -1,10 +1,26 @@ import { Button } from '@/components/ui/button' -import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap, type FlashManifest } from '../lib/espFlashLayout' -import { pulseUsbBootloaderPort, runEspFlash } from '../lib/espFlashRun' -import { extractTarGz, findInTar } from '../lib/untarGz' -import { useCallback, useMemo, useRef, useState } from 'react' +import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from '../lib/espFlashLayout' +import type { FlashManifest } from '../lib/untarGz' +import { + isSerialUserCancelledError, + pulseUsbBootloaderPort, + runEspFlash, + type FlashPhase, +} from '../lib/espFlashRun' +import { extractTarGz } from '../lib/untarGz' +import { useCallback, useState } from 'react' import { toast } from 'sonner' +type FlashProgress = + | { kind: 'indeterminate'; label: string } + | { kind: 'determinate'; label: string; pct: number } + +const PHASE_LABEL: Record = { + connect: 'Connecting to bootloader…', + detect: 'Detecting flash size…', + write: 'Writing firmware…', +} + type EspFlasherProps = { bundleUrl: string /** Primary CTA label (default matches standalone copy). */ @@ -29,26 +45,7 @@ export default function EspFlasher({ const [noReset, setNoReset] = useState(false) const [baud, setBaud] = useState(921600) const [layoutPreview, setLayoutPreview] = useState(null) - const [log, setLog] = useState('') - const logRef = useRef('') - - const terminal = useMemo( - () => ({ - clean: () => { - logRef.current = '' - setLog('') - }, - write: (data: string) => { - logRef.current += data - setLog(logRef.current) - }, - writeLine: (data: string) => { - logRef.current += data + '\n' - setLog(logRef.current) - }, - }), - [] - ) + const [flashProgress, setFlashProgress] = useState(null) const prepareBundle = useCallback(async () => { const res = await fetch(bundleUrl) @@ -66,32 +63,46 @@ export default function EspFlasher({ return } setBusy(true) - terminal.clean() + setFlashProgress({ kind: 'indeterminate', label: 'Select a serial port…' }) try { + const port = await navigator.serial.requestPort() + setFlashProgress({ kind: 'indeterminate', label: 'Downloading firmware…' }) const files = await prepareBundle() const parts = buildFlashParts(files) if (!parts) { toast.error('Could not detect flash layout from bundle') - setBusy(false) return } await runEspFlash({ + port, parts, baud, eraseAll, - terminal, resetMode: noReset ? 'no_reset' : 'default_reset', + 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, + }) + }, }) toast.success('Flash complete') } catch (e) { + if (isSerialUserCancelledError(e)) { + return + } const msg = e instanceof Error ? e.message : String(e) - terminal.writeLine(`\nError: ${msg}`) toast.error('Flash failed', { description: msg }) } finally { setBusy(false) + setFlashProgress(null) } - }, [baud, eraseAll, noReset, prepareBundle, terminal]) + }, [baud, eraseAll, noReset, prepareBundle]) const boot1200 = useCallback(async () => { try { @@ -100,6 +111,9 @@ export default function EspFlasher({ description: 'If the board did not enter bootloader, hold BOOT, tap RST, then try flash again.', }) } catch (e) { + if (isSerialUserCancelledError(e)) { + return + } toast.error(e instanceof Error ? e.message : String(e)) } }, []) @@ -175,10 +189,20 @@ export default function EspFlasher({ - {log ? ( -
-          {log}
-        
+ {flashProgress ? ( +
+

{flashProgress.label}

+
+ {flashProgress.kind === 'determinate' ? ( +
+ ) : ( +
+ )} +
+
) : null}
) diff --git a/src/lib/espFlashLayout.ts b/src/lib/espFlashLayout.ts index 1a4fe41..6b3478c 100644 --- a/src/lib/espFlashLayout.ts +++ b/src/lib/espFlashLayout.ts @@ -2,6 +2,45 @@ import { findInTar, parseFlashManifest, type FlashManifest } from './untarGz' export type FlashPart = { data: Uint8Array; address: number; name: string } +function tarBasename(path: string): string { + const parts = path.replace(/^\.\//, '').split('/') + return parts[parts.length - 1] ?? path +} + +/** + * PlatformIO projects (e.g. Meshtastic) often emit versioned names like + * firmware-heltec-v3-2.7.20.factory.bin instead of firmware.factory.bin. + */ +function resolveVersionedFirmwareApp( + files: Map +): { data: Uint8Array; name: string } | undefined { + type Entry = { base: string; data: Uint8Array } + const list: Entry[] = [] + 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' + ) { + continue + } + list.push({ base, data }) + } + + const factory = list.find(e => /^firmware-.+\.factory\.bin$/i.test(e.base)) + if (factory) return { data: factory.data, name: factory.base } + + 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 +} + export function layoutPreviewFromManifest(m: FlashManifest): string[] { return m.images.map(im => `${im.file} @ ${String(im.offset)}`) } @@ -28,22 +67,35 @@ export function buildFlashParts(files: Map): FlashPart[] | n const bootloader = findInTar(files, 'bootloader.bin') const partitions = findInTar(files, 'partitions.bin') const bootApp0 = findInTar(files, 'boot_app0.bin') - const factory = findInTar(files, 'firmware.factory.bin') - const firmware = findInTar(files, 'firmware.bin') + const factoryExact = findInTar(files, 'firmware.factory.bin') + const firmwareExact = findInTar(files, 'firmware.bin') + const versioned = resolveVersionedFirmwareApp(files) - const app = factory ?? firmware - if (bootloader && partitions && app) { + let app: Uint8Array | undefined + let appName: string | undefined + if (factoryExact) { + app = factoryExact + appName = 'firmware.factory.bin' + } else 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: factory ? 'firmware.factory.bin' : 'firmware.bin' }, + { data: app, address: 0x10000, name: appName }, ] if (bootApp0) arr.push({ data: bootApp0, address: 0xe000, name: 'boot_app0.bin' }) return arr } - if (app && !bootloader && !partitions) { - return [{ data: app, address: 0x0, name: factory ? 'firmware.factory.bin' : 'firmware.bin' }] + if (app && appName && !bootloader && !partitions) { + return [{ data: app, address: 0x0, name: appName }] } return null diff --git a/src/lib/espFlashRun.ts b/src/lib/espFlashRun.ts index 1e90e00..e4e7fdb 100644 --- a/src/lib/espFlashRun.ts +++ b/src/lib/espFlashRun.ts @@ -1,25 +1,58 @@ -import { ESPLoader, Transport } from 'esptool-js' +import { ESPLoader, Transport, type FlashSizeValues } from 'esptool-js' import type { FlashPart } from './espFlashLayout' -type EspTerminal = { +export type EspTerminal = { clean: () => void write: (data: string) => void writeLine: (data: string) => void } +export const noopEspTerminal: EspTerminal = { + clean: () => {}, + write: () => {}, + writeLine: () => {}, +} + +export function isSerialUserCancelledError(e: unknown): boolean { + if (e instanceof DOMException && e.name === 'NotFoundError') return true + const msg = e instanceof Error ? e.message : String(e) + return msg.includes('No port selected') +} + +export type FlashPhase = 'connect' | 'detect' | 'write' + +export type WriteProgressPayload = { + imageIndex: number + imageCount: number + written: number + total: number + overallPct: number +} + export async function runEspFlash(options: { parts: FlashPart[] + port: SerialPort baud: number eraseAll: boolean - terminal: EspTerminal + terminal?: EspTerminal resetMode?: 'default_reset' | 'no_reset' + onPhase?: (phase: FlashPhase) => void + onWriteProgress?: (p: WriteProgressPayload) => void }): Promise { - const { parts, baud, eraseAll, terminal, resetMode = 'default_reset' } = options + const { + parts, + port, + baud, + eraseAll, + terminal = noopEspTerminal, + resetMode = 'default_reset', + onPhase, + onWriteProgress, + } = options if (!('serial' in navigator)) { throw new Error('Web Serial is not available (use Chromium on https:// or localhost)') } - const port = await navigator.serial.requestPort() const transport = new Transport(port) const loader = new ESPLoader({ transport, @@ -28,10 +61,15 @@ export async function runEspFlash(options: { }) const fileArray = parts.map(p => ({ data: p.data, address: p.address })) + const lengths = fileArray.map(f => f.data.byteLength) + const totalBytes = lengths.reduce((a, b) => a + b, 0) + onPhase?.('connect') await loader.main(resetMode) - const flashSize = await loader.detectFlashSize() + onPhase?.('detect') + const flashSize = (await loader.detectFlashSize()) as FlashSizeValues + onPhase?.('write') await loader.writeFlash({ fileArray, flashMode: 'dio', @@ -40,7 +78,17 @@ export async function runEspFlash(options: { eraseAll, compress: true, reportProgress: (i, written, total) => { - loader.info(`Image ${i + 1}/${fileArray.length}: ${Math.round((100 * written) / total)}%`) + let offset = 0 + for (let j = 0; j < i; j++) offset += lengths[j] ?? 0 + const overallPct = + totalBytes > 0 ? Math.min(100, Math.round((100 * (offset + written)) / totalBytes)) : 0 + onWriteProgress?.({ + imageIndex: i, + imageCount: fileArray.length, + written, + total, + overallPct, + }) }, }) diff --git a/src/lib/repoTreeUrl.ts b/src/lib/repoTreeUrl.ts index 62a35c9..75af93d 100644 --- a/src/lib/repoTreeUrl.ts +++ b/src/lib/repoTreeUrl.ts @@ -1,26 +1,37 @@ /** - * Mesh Forge tree URLs: `/owner/repo/tree//target/` + * Mesh Forge tree URLs: `/owner/repo/tree//target/` with optional `/flash` for the flasher-only view. * Source ref may contain `/` (nested tags are rare but allowed). `target` is a reserved final segment pair. */ const TARGET_TAIL = /\/target\/([^/]+)$/ +const FLASH_AFTER_TARGET = /\/target\/[^/]+\/flash$/ export function parseTreeSplat(treePath: string | undefined): { sourceRef: string | null targetEnv: string | null + flash: boolean } { - if (!treePath?.trim()) return { sourceRef: null, targetEnv: null } + if (!treePath?.trim()) return { sourceRef: null, targetEnv: null, flash: false } const segments = treePath.split("/").filter(Boolean) - if (segments.length === 0) return { sourceRef: null, targetEnv: null } - const joined = segments.map(s => decodeURIComponent(s)).join("/") + if (segments.length === 0) return { sourceRef: null, targetEnv: null, flash: false } + let joined = segments.map(s => decodeURIComponent(s)).join("/") + let flash = false + if (FLASH_AFTER_TARGET.test(joined)) { + flash = true + joined = joined.slice(0, -"/flash".length) + } const m = TARGET_TAIL.exec(joined) - if (!m) return { sourceRef: joined, targetEnv: null } + if (!m) return { sourceRef: joined, targetEnv: null, flash } const sourceRef = joined.slice(0, m.index).replace(/\/$/, "") || null const targetEnv = m[1] ? decodeURIComponent(m[1]) : null - return { sourceRef, targetEnv } + return { sourceRef, targetEnv, flash } } /** Path after `/tree/` (no leading slash). Empty string means no ref — short `/owner/repo` redirects to latest tag. */ -export function buildTreeSplatPath(sourceRef: string | null, targetEnv: string | null): string { +export function buildTreeSplatPath( + sourceRef: string | null, + targetEnv: string | null, + opts?: { flash?: boolean } +): string { const b = sourceRef?.trim() if (!b) return "" const enc = b @@ -29,5 +40,7 @@ export function buildTreeSplatPath(sourceRef: string | null, targetEnv: string | .join("/") const t = targetEnv?.trim() if (!t) return enc - return `${enc}/target/${encodeURIComponent(t)}` + const base = `${enc}/target/${encodeURIComponent(t)}` + if (opts?.flash) return `${base}/flash` + return base } diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 2a98a79..492ec9d 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -37,7 +37,11 @@ export default function RepoPage() { const treePath = params["*"] const owner = useMemo(() => decodeURIComponent(ownerParam), [ownerParam]) const repo = useMemo(() => decodeURIComponent(repoParam), [repoParam]) - const { sourceRef, targetEnv: targetFromUrl } = useMemo(() => parseTreeSplat(treePath), [treePath]) + const { sourceRef, targetEnv: targetFromUrl, flash: flashFromUrl } = useMemo( + () => parseTreeSplat(treePath), + [treePath] + ) + const isFlashView = flashFromUrl const hasRef = Boolean(sourceRef) const tagData = useQuery(api.repoTags.get, owner && repo ? { owner, repo } : "skip") @@ -103,6 +107,11 @@ export default function RepoPage() { const [readmeMd, setReadmeMd] = useState(null) const [readmeDownloadUrl, setReadmeDownloadUrl] = useState(null) useEffect(() => { + if (isFlashView) { + setReadmeMd(null) + setReadmeDownloadUrl(null) + return + } if (!effectiveRef) { setReadmeMd(null) setReadmeDownloadUrl(null) @@ -127,7 +136,7 @@ export default function RepoPage() { return () => { cancelled = true } - }, [owner, repo, effectiveRef, fetchReadme]) + }, [owner, repo, effectiveRef, fetchReadme, isFlashView]) const readmeMarkdownComponents = useMemo( () => ({ @@ -240,6 +249,11 @@ export default function RepoPage() { const [flashPrep, setFlashPrep] = useState<"idle" | "loading" | "ready" | "error">("idle") useEffect(() => { + if (!isFlashView) { + setFlashUrl(null) + setFlashPrep("idle") + return + } if (!build?._id || build.status !== "succeeded") { setFlashUrl(null) setFlashPrep("idle") @@ -262,13 +276,43 @@ export default function RepoPage() { return () => { cancelled = true } - }, [build?._id, build?.status, getSignedUrl]) + }, [isFlashView, build?._id, build?.status, getSignedUrl]) + + useEffect(() => { + if (!isFlashView || !owner || !repo || !effectiveRef || !resolvedSha || !resolvedTargetEnv) return + if (!(hasRef && resolvedSha && scan?.scanStatus === "complete" && envNames.length > 0)) return + void ensureBuild({ + owner, + repo, + ref: effectiveRef, + resolvedSourceSha: resolvedSha, + targetEnv: resolvedTargetEnv, + }).catch(e => toast.error(String(e))) + }, [ + isFlashView, + owner, + repo, + effectiveRef, + resolvedSha, + resolvedTargetEnv, + hasRef, + scan, + envNames.length, + ensureBuild, + ]) const queueFlashArtifacts = () => { if (!effectiveRef || !resolvedSha || !resolvedTargetEnv) return + const goFlash = () => + navigate( + `/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(effectiveRef, resolvedTargetEnv, { flash: true })}` + ) if (build?.status === "failed" && build._id) { void retryBuild({ buildId: build._id }) - .then(() => toast.message("Starting a new build…")) + .then(() => { + toast.message("Starting a new build…") + goFlash() + }) .catch(e => toast.error(String(e))) return } @@ -278,7 +322,11 @@ export default function RepoPage() { ref: effectiveRef, resolvedSourceSha: resolvedSha, targetEnv: resolvedTargetEnv, - }).catch(e => toast.error(String(e))) + }) + .then(() => { + goFlash() + }) + .catch(e => toast.error(String(e))) } const download = async () => { @@ -357,9 +405,121 @@ export default function RepoPage() { ? "No targets" : "--target--" + const backToRepoPath = `/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, resolvedTargetEnv || null)}` + + const statusStripEl = ( +
+ {tagData?.isStale ? Tag list may be stale. : null} + {refError ? {refError} : null} + {!refError && hasRef && !resolvedSha ? Resolving tag… : null} + {resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? ( + Scanning PlatformIO… + ) : null} + {resolvedSha && scan?.scanStatus === "failed" ? ( + Scan failed: {scan.scanError ?? "unknown"} + ) : null} +
+ ) + + const ciAndFlasherEl = ( +
+ {showCiCard ? ( +
+
+ CI + {build.status} + {build.githubRunId ? ( + + View run on GitHub + + ) : build.status === "failed" ? ( + + Mesh Forge workflow on GitHub + + ) : null} +
+ {build.status === "failed" && build.errorSummary ? ( +
+ {(() => { + const { headline, body } = buildFailurePresentation(build.errorSummary) + return ( + <> +

{headline}

+ {body ?

{body}

: null} +
+ Technical details +
+                        {build.errorSummary.length > 2500
+                          ? `…${build.errorSummary.slice(-2500)}`
+                          : build.errorSummary}
+                      
+
+ + ) + })()} +
+ ) : null} + {build.status === "succeeded" ? ( + + ) : null} +
+ ) : null} + + {flashPrep === "loading" ?

Preparing USB flasher…

: null} + {flashPrep === "error" ? ( +

+ Could not load a signed URL for flashing. Use Download bundle if you need the file. +

+ ) : null} + {flashUrl ? ( + + ) : null} +
+ ) + return ( -
+
+ {isFlashView ? ( +
+
+

+ {owner}/{repo}@{effectiveRef} + {resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher +

+ + ← Repository + +
+ {statusStripEl} + {ciAndFlasherEl} +
+ ) : (
- {tagData?.isStale ? Tag list may be stale. : null} - {refError ? {refError} : null} - {!refError && hasRef && !resolvedSha ? Resolving tag… : null} - {resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? ( - Scanning PlatformIO… - ) : null} - {resolvedSha && scan?.scanStatus === "failed" ? ( - Scan failed: {scan.scanError ?? "unknown"} - ) : null} -
- -
- {showCiCard ? ( -
-
- CI - {build.status} - {build.githubRunId ? ( - - View run on GitHub - - ) : build.status === "failed" ? ( - - Mesh Forge workflow on GitHub - - ) : null} -
- {build.status === "failed" && build.errorSummary ? ( -
- {(() => { - const { headline, body } = buildFailurePresentation(build.errorSummary) - return ( - <> -

{headline}

- {body ?

{body}

: null} -
- - Technical details - -
-                                {build.errorSummary.length > 2500
-                                  ? `…${build.errorSummary.slice(-2500)}`
-                                  : build.errorSummary}
-                              
-
- - ) - })()} -
- ) : null} - {build.status === "succeeded" ? ( - - ) : null} -
- ) : null} - - {flashPrep === "loading" ?

Preparing USB flasher…

: null} - {flashPrep === "error" ? ( -

- Could not load a signed URL for flashing. Use Download bundle if you need the file. -

- ) : null} - {flashUrl ? ( - - ) : null} -
+ {statusStripEl}
+ )} ) diff --git a/tsconfig.json b/tsconfig.json index a5a2062..9c0389c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "ES2022", "moduleResolution": "Bundler", "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["vite/client"], + "types": ["vite/client", "w3c-web-serial"], "noEmit": true, "skipLibCheck": true, "esModuleInterop": true,