From 1af200eb399d2d1a813673043c01ae41fb7cd961 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 10 Apr 2026 11:03:36 -0700 Subject: [PATCH] Enhance firmware flashing process by updating emit-flash-manifest.py to merge target family metadata from PlatformIO when missing. Updated CI workflows to pass additional parameters for improved manifest generation. Refactor EspFlasher component to utilize target environment for better flash handling and error messaging. Improved error handling and user feedback during the flashing process. --- .github/workflows/custom_build.yml | 2 +- .github/workflows/custom_build_test.yml | 2 +- scripts/emit-flash-manifest.py | 119 +++++++++- src/components/EspFlasher.tsx | 198 +++++++++++----- src/lib/espFlashRun.ts | 119 +++++++--- src/lib/flashTargetFamily.ts | 48 ++++ src/lib/untarGz.ts | 26 ++- src/pages/RepoPage.tsx | 287 ++++++++++++------------ 8 files changed, 559 insertions(+), 242 deletions(-) create mode 100644 src/lib/flashTargetFamily.ts diff --git a/.github/workflows/custom_build.yml b/.github/workflows/custom_build.yml index dc68a97..341a849 100644 --- a/.github/workflows/custom_build.yml +++ b/.github/workflows/custom_build.yml @@ -110,7 +110,7 @@ jobs: echo "Build output directory missing" exit 1 fi - python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR" + python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR" "$ROOT" "${{ inputs.target_env }}" ARTIFACT_NAME="firmware-${{ inputs.build_key }}-${{ github.run_id }}.tar.gz" STAGE=/tmp/fw-bundle rm -rf "$STAGE" diff --git a/.github/workflows/custom_build_test.yml b/.github/workflows/custom_build_test.yml index dc68a97..341a849 100644 --- a/.github/workflows/custom_build_test.yml +++ b/.github/workflows/custom_build_test.yml @@ -110,7 +110,7 @@ jobs: echo "Build output directory missing" exit 1 fi - python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR" + python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR" "$ROOT" "${{ inputs.target_env }}" ARTIFACT_NAME="firmware-${{ inputs.build_key }}-${{ github.run_id }}.tar.gz" STAGE=/tmp/fw-bundle rm -rf "$STAGE" diff --git a/scripts/emit-flash-manifest.py b/scripts/emit-flash-manifest.py index 4d989a7..55a036c 100644 --- a/scripts/emit-flash-manifest.py +++ b/scripts/emit-flash-manifest.py @@ -2,9 +2,14 @@ """ Emit flash-manifest.json for Mesh Forge USB flasher. -If BUILD_DIR/flash-manifest.json already exists (project-supplied), leave it. +If BUILD_DIR/flash-manifest.json already exists (project-supplied), merge in +targetFamily from PlatformIO when missing. + Otherwise, if Meshtastic-style *.mt.json is present, synthesize a manifest from partition table + on-disk artifacts. + +Optional args: PROJECT_ROOT TARGET_ENV — merged PIO config for that env fills +targetFamily (and platform/board) for the USB flasher UI. """ from __future__ import annotations @@ -15,6 +20,85 @@ import re import sys +def _normalize_platform(platform: str | None) -> str: + if not platform: + return "" + p = platform.strip().lower() + if "#" in p: + p = p.split("#", 1)[0].strip() + if "@" in p: + p = p.split("@", 1)[0].strip() + if "/" in p: + p = p.rsplit("/", 1)[-1].strip() + return p + + +def _platform_to_target_family(platform: str | None, board: str | None) -> str: + pl = _normalize_platform(platform) + b = (board or "").lower() + if "8266" in pl or "esp8266" in b: + return "esp8266" + if "nrf52" in pl or "nrf52840" in b or "nrf52833" in b or "nrf52832" in b: + return "nrf52" + if "rp2040" in pl or "rp2040" in b or "raspberrypi" in pl: + return "rp2040" + if "espressif32" in pl or "esp32" in pl or "esp32" in b or "esp32c3" in b or "esp32s3" in b: + return "esp32" + return "unknown" + + +def _resolve_pio_target_family(project_root: str, target_env: str) -> tuple[str, str | None, str | None]: + try: + from platformio.project.config import ProjectConfig + except ImportError: + print("platformio not installed; targetFamily will be unknown", file=sys.stderr) + return "unknown", None, None + + ini = os.path.join(project_root, "platformio.ini") + if not os.path.isfile(ini): + return "unknown", None, None + + old = os.getcwd() + try: + os.chdir(project_root) + config = ProjectConfig("platformio.ini") + section = f"env:{target_env}" + if section not in config.sections(): + print(f"PIO env not found: {target_env!r}", file=sys.stderr) + return "unknown", None, None + platform = config.get(section, "platform") + board = config.get(section, "board") + fam = _platform_to_target_family(platform, board) + return fam, platform, board + except Exception as e: + print(f"PIO targetFamily resolution failed: {e}", file=sys.stderr) + return "unknown", None, None + finally: + os.chdir(old) + + +def _merge_target_family_meta( + manifest: dict, + target_family: str, + platform: str | None, + board: str | None, +) -> dict: + """Add targetFamily / platform / board only when absent.""" + out = dict(manifest) + if "targetFamily" not in out: + out["targetFamily"] = target_family + if platform and "platform" not in out: + out["platform"] = platform.strip() if isinstance(platform, str) else platform + if board and "board" not in out: + out["board"] = board.strip() if isinstance(board, str) else board + return out + + +def _write_manifest(path: str, data: dict) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + def parse_offset(v: object) -> int | None: if v is None: return None @@ -199,13 +283,38 @@ def pick_mt_json(build_dir: str) -> str | None: def main() -> int: if len(sys.argv) < 2: - print("usage: emit-flash-manifest.py BUILD_DIR", file=sys.stderr) + print( + "usage: emit-flash-manifest.py BUILD_DIR [PROJECT_ROOT TARGET_ENV]", + file=sys.stderr, + ) return 2 build_dir = os.path.abspath(sys.argv[1]) + project_root: str | None = None + target_env: str | None = None + if len(sys.argv) >= 4: + project_root = os.path.abspath(sys.argv[2]) + target_env = sys.argv[3] + + pio_family, pio_platform, pio_board = ( + _resolve_pio_target_family(project_root, target_env) + if project_root and target_env + else ("unknown", None, None) + ) + out_path = os.path.join(build_dir, "flash-manifest.json") if os.path.isfile(out_path): - print(f"Keeping existing {out_path}") + with open(out_path, encoding="utf-8") as f: + manifest = json.load(f) + if not isinstance(manifest, dict) or not isinstance(manifest.get("images"), list): + print(f"Invalid existing {out_path}", file=sys.stderr) + return 1 + merged = _merge_target_family_meta(manifest, pio_family, pio_platform, pio_board) + if merged != manifest: + _write_manifest(out_path, merged) + print(f"Merged targetFamily into {out_path}") + else: + print(f"Keeping existing {out_path} (targetFamily already set)") return 0 mt_path = pick_mt_json(build_dir) @@ -221,8 +330,8 @@ def main() -> int: print("Could not synthesize flash-manifest.json from mt.json") return 0 - with open(out_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, indent=2) + manifest = _merge_target_family_meta(manifest, pio_family, pio_platform, pio_board) + _write_manifest(out_path, manifest) print(f"Wrote {out_path}") return 0 diff --git a/src/components/EspFlasher.tsx b/src/components/EspFlasher.tsx index c0d486a..a03f23b 100644 --- a/src/components/EspFlasher.tsx +++ b/src/components/EspFlasher.tsx @@ -1,34 +1,51 @@ -import { Button } from '@/components/ui/button' -import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from '../lib/espFlashLayout' -import type { FlashManifest } from '../lib/untarGz' +import { Button } from "@/components/ui/button" +import { CheckCircle2 } from "lucide-react" +import { useCallback, useEffect, useState } from "react" +import { toast } from "sonner" +import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from "../lib/espFlashLayout" import { + ensureSerialPortClosed, isSerialUserCancelledError, pulseUsbBootloaderPort, runEspFlash, type FlashPhase, -} from '../lib/espFlashRun' -import { extractTarGz } from '../lib/untarGz' -import { CheckCircle2 } from 'lucide-react' -import { useCallback, useState } from 'react' -import { toast } from 'sonner' +} from "../lib/espFlashRun" +import { resolveFlashTargetFamily } from "../lib/flashTargetFamily" +import type { FlashManifest, FlashTargetFamily } from "../lib/untarGz" +import { extractTarGz } from "../lib/untarGz" type FlashProgress = - | { kind: 'indeterminate'; label: string } - | { kind: 'determinate'; label: string; pct: number } - | { kind: 'complete' } + | { kind: "indeterminate"; label: string } + | { kind: "determinate"; label: string; pct: number } + | { kind: "complete" } const PHASE_LABEL: Record = { - connect: 'Connecting to bootloader…', - detect: 'Detecting flash size…', - write: 'Writing firmware…', + connect: "Connecting to bootloader…", + detect: "Detecting flash size…", + write: "Writing firmware…", +} + +function unsupportedFlashMessage(family: FlashTargetFamily): string | null { + if (family === "nrf52") { + return "This bundle targets nRF52. In-browser flashing here uses esptool (ESP32). Use adafruit-nrfutil / nrfutil with the ZIP from Download bundle, or your board’s UF2/DFU workflow." + } + if (family === "rp2040") { + return "This bundle targets RP2040. Use UF2 drag-and-drop or picotool with artifacts from Download bundle — Web Serial esptool here is for ESP32-class boards only." + } + if (family === "esp8266") { + return "This bundle targets ESP8266. The embedded flasher is tuned for ESP32; use esptool.py locally with Download bundle if you need USB flashing." + } + return null } type EspFlasherProps = { bundleUrl: string + /** PlatformIO env for the selected build; used if manifest omits targetFamily. */ + targetEnv?: string | null /** Primary CTA label (default matches standalone copy). */ flashButtonLabel?: string flashBusyLabel?: string - flashButtonSize?: 'default' | 'lg' + flashButtonSize?: "default" | "lg" className?: string /** Tighter copy when shown in the repo hero. */ condensed?: boolean @@ -36,10 +53,11 @@ type EspFlasherProps = { export default function EspFlasher({ bundleUrl, - flashButtonLabel = 'Connect serial & flash', - flashBusyLabel = 'Flashing…', - flashButtonSize = 'default', - className = '', + targetEnv = null, + flashButtonLabel = "Connect serial & flash", + flashBusyLabel = "Flashing…", + flashButtonSize = "default", + className = "", condensed = false, }: EspFlasherProps) { const [busy, setBusy] = useState(false) @@ -48,6 +66,42 @@ export default function EspFlasher({ 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) + + useEffect(() => { + setDfuTouchComplete(false) + setLayoutPreview(null) + setBundleLoadError(null) + let cancelled = false + void (async () => { + try { + const res = await fetch(bundleUrl) + if (!res.ok) { + if (!cancelled) setBundleLoadError(`Download failed: ${res.status}`) + return + } + const buf = new Uint8Array(await res.arrayBuffer()) + const files = extractTarGz(buf) + const m = manifestFromMap(files) + if (!cancelled) { + setLayoutPreview(m) + setBundleLoadError(null) + } + } catch (e) { + if (!cancelled) { + setBundleLoadError(e instanceof Error ? e.message : String(e)) + } + } + })() + return () => { + cancelled = true + } + }, [bundleUrl]) + + const resolvedFamily = resolveFlashTargetFamily(layoutPreview, targetEnv) + const flashBlockedReason = unsupportedFlashMessage(resolvedFamily) + const canEspFlash = flashBlockedReason === null const prepareBundle = useCallback(async () => { const res = await fetch(bundleUrl) @@ -60,20 +114,25 @@ export default function EspFlasher({ }, [bundleUrl]) const flash = useCallback(async () => { - if (!('serial' in navigator)) { - toast.error('Web Serial is not supported in this browser') + if (!canEspFlash) { + toast.error("Unsupported for in-browser flash", { description: flashBlockedReason }) + return + } + if (!("serial" in navigator)) { + toast.error("Web Serial is not supported in this browser") return } setBusy(true) - setFlashProgress({ kind: 'indeterminate', label: 'Select a serial port…' }) + setFlashProgress({ kind: "indeterminate", label: "Select a serial port…" }) let finishedOk = false + let port: SerialPort | undefined try { - const port = await navigator.serial.requestPort() - setFlashProgress({ kind: 'indeterminate', label: 'Downloading firmware…' }) + 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') + toast.error("Could not detect flash layout from bundle") return } @@ -82,40 +141,45 @@ export default function EspFlasher({ parts, baud, eraseAll, - resetMode: noReset ? 'no_reset' : 'default_reset', + resetMode: noReset ? "no_reset" : "default_reset", onPhase: phase => { - setFlashProgress({ kind: 'indeterminate', label: PHASE_LABEL[phase] }) + setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] }) }, onWriteProgress: p => { setFlashProgress({ - kind: 'determinate', + kind: "determinate", label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`, pct: p.overallPct, }) }, }) finishedOk = true - setFlashProgress({ kind: 'complete' }) - toast.success('Flash complete') + setFlashProgress({ kind: "complete" }) + toast.success("Flash complete") } catch (e) { if (isSerialUserCancelledError(e)) { return } const msg = e instanceof Error ? e.message : String(e) - toast.error('Flash failed', { description: msg }) + toast.error("Flash failed", { description: msg }) } finally { + if (port && !finishedOk) { + void ensureSerialPortClosed(port) + } setBusy(false) if (!finishedOk) { setFlashProgress(null) } } - }, [baud, eraseAll, noReset, prepareBundle]) + }, [baud, eraseAll, noReset, prepareBundle, canEspFlash, flashBlockedReason]) - const boot1200 = useCallback(async () => { + const enterDfuMode = useCallback(async () => { try { await pulseUsbBootloaderPort() - toast.success('1200 baud pulse sent', { - description: 'If the board did not enter bootloader, hold BOOT, tap RST, then try flash again.', + setDfuTouchComplete(true) + toast.success("DFU / bootloader touch sent", { + description: + "If the device re-enumerated, pick the bootloader port and flash. If not, hold BOOT, tap RST, then try again.", }) } catch (e) { if (isSerialUserCancelledError(e)) { @@ -125,22 +189,37 @@ export default function EspFlasher({ } }, []) + const familyLine = + resolvedFamily !== "esp32" || layoutPreview?.targetFamily + ? `Target: ${resolvedFamily}${layoutPreview?.platform ? ` · ${layoutPreview.platform.trim()}` : ""}` + : null + return ( -
+
{condensed ? null : ( <> -

ESP flash (Web Serial)

+

USB firmware flash (Web Serial)

- Uses esptool-js. Connect USB, put the board in bootloader if needed, then flash. Wrong offsets can brick - hardware—verify the map. + 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.

)} {condensed ? (

- USB + Chromium Web Serial. Verify the flash map before writing—wrong images can brick hardware. + Chromium Web Serial + esptool-js (ESP32-class). Verify the flash map — wrong images can brick hardware. +

+ ) : null} + + {bundleLoadError ? ( +

Could not prefetch bundle: {bundleLoadError}

+ ) : null} + + {familyLine ?

{familyLine}

: null} + + {flashBlockedReason ? ( +

+ {flashBlockedReason}

) : null} @@ -152,9 +231,9 @@ export default function EspFlasher({ ) : (

- Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or - single firmware.bin @ 0x0. With flash-manifest.json, offsets come - from the bundle. + Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or single + firmware.bin @ 0x0. With flash-manifest.json, offsets come from the + bundle.

)} @@ -165,6 +244,7 @@ export default function EspFlasher({ className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white" value={baud} onChange={e => setBaud(Number(e.target.value))} + disabled={!canEspFlash} > @@ -172,13 +252,27 @@ export default function EspFlasher({ +
@@ -186,18 +280,18 @@ export default function EspFlasher({ type="button" size={flashButtonSize} className="bg-amber-600 hover:bg-amber-700" - disabled={busy} + disabled={busy || !canEspFlash} onClick={() => void flash()} > {busy ? flashBusyLabel : flashButtonLabel} -
{flashProgress ? ( - flashProgress.kind === 'complete' ? ( + flashProgress.kind === "complete" ? (
Flashing complete @@ -206,7 +300,7 @@ export default function EspFlasher({

{flashProgress.label}

- {flashProgress.kind === 'determinate' ? ( + {flashProgress.kind === "determinate" ? (
void @@ -14,12 +14,74 @@ export const noopEspTerminal: EspTerminal = { } export function isSerialUserCancelledError(e: unknown): boolean { - if (e instanceof DOMException && e.name === 'NotFoundError') return true + if (e instanceof DOMException && e.name === "NotFoundError") return true const msg = e instanceof Error ? e.message : String(e) - return msg.includes('No port selected') + return msg.includes("No port selected") } -export type FlashPhase = 'connect' | 'detect' | 'write' +function sleepMs(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isPortAlreadyOpenError(e: unknown): boolean { + if (e instanceof DOMException && e.name === "InvalidStateError") return true + const msg = e instanceof Error ? e.message : String(e) + return /already open/i.test(msg) +} + +/** + * When readable/writable exist, the port is still open. Close it so a later `open()` succeeds. + * Handles failed prior sessions that skipped `disconnect()` or a `close()` that threw. + */ +export async function ensureSerialPortClosed(port: SerialPort): Promise { + if (port.readable === null && port.writable === null) { + return + } + try { + if (port.readable && !port.readable.locked) { + const reader = port.readable.getReader() + await reader.cancel().catch(() => {}) + reader.releaseLock() + } + } catch { + // Readable may be locked by another consumer (e.g. stuck esptool readLoop). + } + try { + if (port.writable && !port.writable.locked) { + const writer = port.writable.getWriter() + await writer.close().catch(() => {}) + writer.releaseLock() + } + } catch { + // ignore + } + for (let i = 0; i < 5; i++) { + if (port.readable === null && port.writable === null) { + return + } + try { + await port.close() + } catch { + // ignore + } + await sleepMs(80) + } +} + +async function openSerialPortWithRecovery(port: SerialPort, options: SerialOptions): Promise { + try { + await port.open(options) + } catch (e) { + if (!isPortAlreadyOpenError(e)) { + throw e + } + await ensureSerialPortClosed(port) + await sleepMs(120) + await port.open(options) + } +} + +export type FlashPhase = "connect" | "detect" | "write" export type WriteProgressPayload = { imageIndex: number @@ -35,7 +97,7 @@ export async function runEspFlash(options: { baud: number eraseAll: boolean terminal?: EspTerminal - resetMode?: 'default_reset' | 'no_reset' + resetMode?: "default_reset" | "no_reset" onPhase?: (phase: FlashPhase) => void onWriteProgress?: (p: WriteProgressPayload) => void }): Promise { @@ -45,14 +107,16 @@ export async function runEspFlash(options: { baud, eraseAll, terminal = noopEspTerminal, - resetMode = 'default_reset', + resetMode = "default_reset", onPhase, onWriteProgress, } = options - if (!('serial' in navigator)) { - throw new Error('Web Serial is not available (use Chromium on https:// or localhost)') + if (!("serial" in navigator)) { + throw new Error("Web Serial is not available (use Chromium on https:// or localhost)") } + await ensureSerialPortClosed(port) + const transport = new Transport(port) const loader = new ESPLoader({ transport, @@ -64,24 +128,23 @@ export async function runEspFlash(options: { const lengths = fileArray.map(f => f.data.byteLength) const totalBytes = lengths.reduce((a, b) => a + b, 0) - onPhase?.('connect') + onPhase?.("connect") await loader.main(resetMode) - onPhase?.('detect') + onPhase?.("detect") const flashSize = (await loader.detectFlashSize()) as FlashSizeValues - onPhase?.('write') + onPhase?.("write") await loader.writeFlash({ fileArray, - flashMode: 'dio', - flashFreq: '40m', + flashMode: "dio", + flashFreq: "40m", flashSize, eraseAll, compress: true, reportProgress: (i, 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 + const overallPct = totalBytes > 0 ? Math.min(100, Math.round((100 * (offset + written)) / totalBytes)) : 0 onWriteProgress?.({ imageIndex: i, imageCount: fileArray.length, @@ -92,24 +155,26 @@ export async function runEspFlash(options: { }, }) - await loader.after('hard_reset') + await loader.after("hard_reset") await transport.disconnect() } -/** Classic ESP32/S3 USB CDC bootloader entry: open port at 1200 baud briefly. */ +/** Match MeshCore `lib/dfu.js` CDC touch timing (1200 baud → close → wait for re-enumeration). */ +const CDC_TOUCH_OPEN_MS = 100 +const CDC_TOUCH_AFTER_CLOSE_MS = 1500 + +/** Classic ESP32/S3 (and many nRF CDC) USB bootloader entry: open port at 1200 baud, then close and wait. */ export async function pulseUsbBootloaderPort(): Promise { - if (!('serial' in navigator)) { - throw new Error('Web Serial is not available') + if (!("serial" in navigator)) { + throw new Error("Web Serial is not available") } const port = await navigator.serial.requestPort() + await ensureSerialPortClosed(port) try { - await port.open({ baudRate: 1200 }) - await new Promise(resolve => setTimeout(resolve, 200)) + await openSerialPortWithRecovery(port, { baudRate: 1200 }) + await sleepMs(CDC_TOUCH_OPEN_MS) } finally { - try { - await port.close() - } catch { - // ignore - } + await ensureSerialPortClosed(port) } + await sleepMs(CDC_TOUCH_AFTER_CLOSE_MS) } diff --git a/src/lib/flashTargetFamily.ts b/src/lib/flashTargetFamily.ts new file mode 100644 index 0000000..a446fd2 --- /dev/null +++ b/src/lib/flashTargetFamily.ts @@ -0,0 +1,48 @@ +import type { FlashManifest, 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). */ +export function inferTargetFamilyFromEnv(env: string | null | undefined): FlashTargetFamily | null { + if (!env?.trim()) return null + const e = env.toLowerCase() + if (/nrf|rak4631|rak2560|t-echo|wio|tracker-t|canaryone|meshlink|mesh-tab|wm1110|sensecap|nrf52840|nrf52/i.test(e)) { + return "nrf52" + } + if (/esp8266|8266|d1_mini|nodemcu/i.test(e)) { + return "esp8266" + } + if (/rp2040|pico|challenger_2040|picow|rak11310/i.test(e)) { + return "rp2040" + } + if (/esp32|tlora|tbeam|heltec|challenger|station|s3|c3|hru|ebyte|nibble|nano-g1|radiomaster|m5stack|ai-c3/i.test(e)) { + return "esp32" + } + return null +} + +/** + * Prefer manifest.targetFamily from CI; else env-name heuristic; else esp32 for legacy ESP-only bundles. + */ +export function resolveFlashTargetFamily( + manifest: FlashManifest | null, + targetEnv: string | null | undefined +): FlashTargetFamily { + const fromManifest = coerceTargetFamily(manifest?.targetFamily) + if (fromManifest && fromManifest !== "unknown") { + return fromManifest + } + const fromEnv = inferTargetFamilyFromEnv(targetEnv ?? null) + if (fromEnv) { + return fromEnv + } + if (fromManifest === "unknown") { + return "unknown" + } + return "esp32" +} diff --git a/src/lib/untarGz.ts b/src/lib/untarGz.ts index 3dd61f7..f8f1cf5 100644 --- a/src/lib/untarGz.ts +++ b/src/lib/untarGz.ts @@ -1,7 +1,7 @@ -import pako from 'pako' +import pako from "pako" function basenameKey(path: string): string { - const parts = path.replace(/^\.\//, '').split('/') + const parts = path.replace(/^\.\//, "").split("/") return parts[parts.length - 1] ?? path } @@ -16,18 +16,18 @@ export function extractTarGz(gz: Uint8Array): Map { const header = tar.subarray(off, off + 512) off += 512 - const name = dec.decode(header.subarray(0, 100)).split('\0')[0].trim() + const name = dec.decode(header.subarray(0, 100)).split("\0")[0].trim() if (!name) break const typeflag = dec.decode(header.subarray(156, 157)) - const sizeField = dec.decode(header.subarray(124, 136)).split('\0')[0].trim() + const sizeField = dec.decode(header.subarray(124, 136)).split("\0")[0].trim() const size = parseInt(sizeField, 8) || 0 - const prefix = dec.decode(header.subarray(345, 500)).split('\0')[0].trim() - const path = (prefix ? `${prefix}/${name}` : name).replace(/^\.\//, '') + const prefix = dec.decode(header.subarray(345, 500)).split("\0")[0].trim() + const path = (prefix ? `${prefix}/${name}` : name).replace(/^\.\//, "") const pad = (512 - (size % 512)) % 512 - if (typeflag === '0' || typeflag === '\0' || typeflag === '') { + if (typeflag === "0" || typeflag === "\0" || typeflag === "") { out.set(path, new Uint8Array(tar.subarray(off, off + size))) } @@ -53,7 +53,17 @@ export type FlashManifestImage = { role?: string } -export type FlashManifest = { images: FlashManifestImage[] } +/** Coarse MCU family for USB flasher entry + tool selection (from CI / PlatformIO). */ +export type FlashTargetFamily = "esp32" | "esp8266" | "nrf52" | "rp2040" | "unknown" + +export type FlashManifest = { + images: FlashManifestImage[] + 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 { diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 77caab9..eba094c 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -37,10 +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, flash: flashFromUrl } = useMemo( - () => parseTreeSplat(treePath), - [treePath] - ) + const { + sourceRef, + targetEnv: targetFromUrl, + flash: flashFromUrl, + } = useMemo(() => parseTreeSplat(treePath), [treePath]) const isFlashView = flashFromUrl const hasRef = Boolean(sourceRef) @@ -412,9 +413,7 @@ export default function RepoPage() { {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 == null || scan.scanStatus === "in_progress") ? Scanning PlatformIO… : null} {resolvedSha && scan?.scanStatus === "failed" ? ( Scan failed: {scan.scanError ?? "unknown"} ) : null} @@ -459,11 +458,7 @@ export default function RepoPage() { const total = build.ciProgressTotal const label = build.ciProgressLabel const hasSteps = - typeof step === "number" && - typeof total === "number" && - total > 0 && - step >= 1 && - step <= total + typeof step === "number" && typeof total === "number" && total > 0 && step >= 1 && step <= total const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null return ( <> @@ -507,9 +502,7 @@ export default function RepoPage() {
Technical details
-                        {build.errorSummary.length > 2500
-                          ? `…${build.errorSummary.slice(-2500)}`
-                          : build.errorSummary}
+                        {build.errorSummary.length > 2500 ? `…${build.errorSummary.slice(-2500)}` : build.errorSummary}
                       
@@ -534,6 +527,7 @@ export default function RepoPage() { {flashUrl ? ( +
{isFlashView ? (
@@ -556,10 +548,7 @@ export default function RepoPage() { {owner}/{repo}@{effectiveRef} {resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher - + ← Repository
@@ -567,93 +556,122 @@ export default function RepoPage() { {ciAndFlasherEl}
) : ( -
-
-
- { - setTagDraft(v) - if (v === "") { - navigate(`/${ownerParam}/${repoParam}`) - return - } - if (tagOptions.includes(v)) { - navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`) - } - }} - disabled={tagOptions.length === 0} - /> - {hasRef && scanReady && envNames.length > 0 ? ( + > +
+
{ - setEnvDraft(v) - if (!sourceRef) return + setTagDraft(v) if (v === "") { - navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, null)}`, { - replace: true, - }) + navigate(`/${ownerParam}/${repoParam}`) return } - if (envNames.includes(v)) { - navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`) + if (tagOptions.includes(v)) { + navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`) } }} - disabled={false} + disabled={tagOptions.length === 0} /> - ) : ( - - )} + ) : ( + + )} - + +
+ + {statusStripEl}
- {statusStripEl} -
- - + -
- {readmeMd === null ? ( -

Loading…

- ) : ( - - {readmeMd || "*No README.*"} - - )} +
+ {readmeMd === null ? ( +

Loading…

+ ) : ( + + {readmeMd || "*No README.*"} + + )} +
-
)}