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,