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.*"} + + )} +
-
)}