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.

This commit is contained in:
Ben Allfree
2026-04-10 11:03:36 -07:00
parent 4552915336
commit 1af200eb39
8 changed files with 559 additions and 242 deletions
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+114 -5
View File
@@ -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
+146 -52
View File
@@ -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<FlashPhase, string> = {
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 boards 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<FlashManifest | null>(null)
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
const [bundleLoadError, setBundleLoadError] = useState<string | null>(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 (
<div
className={`rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim()}
>
<div className={`rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim()}>
{condensed ? null : (
<>
<h3 className="text-lg font-semibold text-white">ESP flash (Web Serial)</h3>
<h3 className="text-lg font-semibold text-white">USB firmware flash (Web Serial)</h3>
<p className="text-sm text-slate-400">
Uses esptool-js. Connect USB, put the board in bootloader if needed, then flash. Wrong offsets can brick
hardwareverify the map.
esptool-js for ESP32-class layouts. Put the board in bootloader if needed use{" "}
<strong>Enter DFU mode</strong> for the USB CDC 1200-baud touch when supported.
</p>
</>
)}
{condensed ? (
<p className="text-sm text-slate-400">
USB + Chromium Web Serial. Verify the flash map before writingwrong images can brick hardware.
Chromium Web Serial + esptool-js (ESP32-class). Verify the flash map wrong images can brick hardware.
</p>
) : null}
{bundleLoadError ? (
<p className="text-xs text-amber-300/90">Could not prefetch bundle: {bundleLoadError}</p>
) : null}
{familyLine ? <p className="text-xs font-mono text-slate-400">{familyLine}</p> : null}
{flashBlockedReason ? (
<p className="text-sm text-amber-200/90 rounded-md border border-amber-800/40 bg-amber-950/30 px-3 py-2">
{flashBlockedReason}
</p>
) : null}
@@ -152,9 +231,9 @@ export default function EspFlasher({
</ul>
) : (
<p className="text-xs text-slate-500">
Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000or
single firmware.bin @ 0x0. With <code className="text-slate-400">flash-manifest.json</code>, offsets come
from the bundle.
Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000or single
firmware.bin @ 0x0. With <code className="text-slate-400">flash-manifest.json</code>, offsets come from the
bundle.
</p>
)}
@@ -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}
>
<option value={115200}>115200</option>
<option value={460800}>460800</option>
@@ -172,13 +252,27 @@ export default function EspFlasher({
</select>
</label>
<label className="text-sm text-slate-300 flex items-center gap-2">
<input type="checkbox" checked={eraseAll} onChange={e => setEraseAll(e.target.checked)} />
<input
type="checkbox"
checked={eraseAll}
onChange={e => setEraseAll(e.target.checked)}
disabled={!canEspFlash}
/>
Full chip erase (destructive)
</label>
<label className="text-sm text-slate-300 flex items-center gap-2">
<input type="checkbox" checked={noReset} onChange={e => setNoReset(e.target.checked)} />
<input
type="checkbox"
checked={noReset}
onChange={e => setNoReset(e.target.checked)}
disabled={!canEspFlash}
/>
No auto-reset (hold BOOT manually)
</label>
<label className="text-sm text-slate-300 flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={dfuTouchComplete} onChange={e => setDfuTouchComplete(e.target.checked)} />
Bootloader touch done
</label>
</div>
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
@@ -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}
</Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => void boot1200()}>
1200 baud reset
<Button type="button" variant="outline" disabled={busy} onClick={() => void enterDfuMode()}>
{dfuTouchComplete ? "Enter DFU mode (again)" : "Enter DFU mode"}
</Button>
</div>
{flashProgress ? (
flashProgress.kind === 'complete' ? (
flashProgress.kind === "complete" ? (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-950/40 px-3 py-2">
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-400" aria-hidden />
<span className="text-sm font-medium text-emerald-300">Flashing complete</span>
@@ -206,7 +300,7 @@ export default function EspFlasher({
<div className="space-y-2">
<p className="text-xs text-slate-400">{flashProgress.label}</p>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-800">
{flashProgress.kind === 'determinate' ? (
{flashProgress.kind === "determinate" ? (
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-150 ease-out"
style={{ width: `${flashProgress.pct}%` }}
+92 -27
View File
@@ -1,5 +1,5 @@
import { ESPLoader, Transport, type FlashSizeValues } from 'esptool-js'
import type { FlashPart } from './espFlashLayout'
import { ESPLoader, Transport, type FlashSizeValues } from "esptool-js"
import type { FlashPart } from "./espFlashLayout"
export type EspTerminal = {
clean: () => 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<void> {
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<void> {
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<void> {
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<void> {
@@ -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<void> {
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<void>(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)
}
+48
View File
@@ -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"
}
+18 -8
View File
@@ -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<string, Uint8Array> {
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 {
+139 -148
View File
@@ -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 ? <span>Tag list may be stale.</span> : null}
{refError ? <span className="text-red-400">{refError}</span> : null}
{!refError && hasRef && !resolvedSha ? <span>Resolving tag</span> : null}
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? (
<span>Scanning PlatformIO</span>
) : null}
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? <span>Scanning PlatformIO</span> : null}
{resolvedSha && scan?.scanStatus === "failed" ? (
<span className="text-red-300">Scan failed: {scan.scanError ?? "unknown"}</span>
) : 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() {
<details className="text-slate-500">
<summary className="cursor-pointer select-none hover:text-slate-400">Technical details</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
{build.errorSummary.length > 2500
? `${build.errorSummary.slice(-2500)}`
: build.errorSummary}
{build.errorSummary.length > 2500 ? `${build.errorSummary.slice(-2500)}` : build.errorSummary}
</pre>
</details>
</>
@@ -534,6 +527,7 @@ export default function RepoPage() {
{flashUrl ? (
<EspFlasher
bundleUrl={flashUrl}
targetEnv={resolvedTargetEnv}
condensed
flashButtonLabel="USB flash"
flashBusyLabel="Writing…"
@@ -545,9 +539,7 @@ export default function RepoPage() {
)
return (
<div
className={`${isFlashView ? "max-w-3xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}
>
<div className={`${isFlashView ? "max-w-3xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}>
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
{isFlashView ? (
<div className="min-w-0 space-y-5">
@@ -556,10 +548,7 @@ export default function RepoPage() {
{owner}/{repo}@{effectiveRef}
{resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher
</h1>
<Link
to={backToRepoPath}
className="inline-block text-sm text-cyan-400 hover:underline"
>
<Link to={backToRepoPath} className="inline-block text-sm text-cyan-400 hover:underline">
Repository
</Link>
</div>
@@ -567,93 +556,122 @@ export default function RepoPage() {
{ciAndFlasherEl}
</div>
) : (
<div
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start
<div
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start
[grid-template-areas:'repo-main''repo-aside''repo-readme']
lg:[grid-template-areas:'repo-main_repo-aside''repo-readme_repo-aside']"
>
<div className="min-w-0 space-y-5 [grid-area:repo-main]">
<div className="flex flex-nowrap items-end gap-2 overflow-x-auto border-b border-slate-800 pb-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ComboboxField
label="Tag"
layout="inline"
id="mesh-forge-tag"
options={tagOptions}
value={tagDraft}
placeholder="--tag--"
clearSelectionLabel="Clear tag"
onChange={v => {
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 ? (
>
<div className="min-w-0 space-y-5 [grid-area:repo-main]">
<div className="flex flex-nowrap items-end gap-2 overflow-x-auto border-b border-slate-800 pb-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ComboboxField
label="Target"
label="Tag"
layout="inline"
id="mesh-forge-target"
options={envNames}
value={envDraft}
placeholder="--target--"
clearSelectionLabel="Clear target"
id="mesh-forge-tag"
options={tagOptions}
value={tagDraft}
placeholder="--tag--"
clearSelectionLabel="Clear tag"
onChange={v => {
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}
/>
) : (
<label className="flex min-w-0 max-w-[min(100%,18rem)] flex-1 items-center gap-2 sm:max-w-[20rem]">
<span className="w-14 shrink-0 text-xs font-medium text-slate-500 sm:w-16">Target</span>
<input
type="text"
readOnly
disabled={!hasRef}
value=""
placeholder={targetPlaceholder}
className="h-9 min-w-28 flex-1 cursor-not-allowed rounded-md border border-slate-800 bg-slate-900/50 px-2.5 text-sm text-slate-500 placeholder:text-slate-600"
{hasRef && scanReady && envNames.length > 0 ? (
<ComboboxField
label="Target"
layout="inline"
id="mesh-forge-target"
options={envNames}
value={envDraft}
placeholder="--target--"
clearSelectionLabel="Clear target"
onChange={v => {
setEnvDraft(v)
if (!sourceRef) return
if (v === "") {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, null)}`, {
replace: true,
})
return
}
if (envNames.includes(v)) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
}
}}
disabled={false}
/>
</label>
)}
) : (
<label className="flex min-w-0 max-w-[min(100%,18rem)] flex-1 items-center gap-2 sm:max-w-[20rem]">
<span className="w-14 shrink-0 text-xs font-medium text-slate-500 sm:w-16">Target</span>
<input
type="text"
readOnly
disabled={!hasRef}
value=""
placeholder={targetPlaceholder}
className="h-9 min-w-28 flex-1 cursor-not-allowed rounded-md border border-slate-800 bg-slate-900/50 px-2.5 text-sm text-slate-500 placeholder:text-slate-600"
/>
</label>
)}
<Button
type="button"
className="h-9 shrink-0 bg-amber-600 px-4 text-white hover:bg-amber-700"
disabled={flashPrimaryDisabled}
onClick={queueFlashArtifacts}
>
{flashButtonLabel}
</Button>
<Button
type="button"
className="h-9 shrink-0 bg-amber-600 px-4 text-white hover:bg-amber-700"
disabled={flashPrimaryDisabled}
onClick={queueFlashArtifacts}
>
{flashButtonLabel}
</Button>
</div>
{statusStripEl}
</div>
{statusStripEl}
</div>
<aside className="[grid-area:repo-aside] border-b border-slate-800 pb-8 lg:border-b-0 lg:border-l lg:border-slate-800 lg:pb-0 lg:pl-8 space-y-4">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">About</h2>
<div>
<p className="text-xs text-slate-500 mb-1">{owner}</p>
<h3 className="flex flex-wrap items-center gap-1.5 text-xl font-bold text-white leading-tight">
<a className="hover:text-cyan-400" href={ghRepoRoot} target="_blank" rel="noreferrer">
{repo}
</a>
{!ghAboutHomepage ? (
<aside className="[grid-area:repo-aside] border-b border-slate-800 pb-8 lg:border-b-0 lg:border-l lg:border-slate-800 lg:pb-0 lg:pl-8 space-y-4">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">About</h2>
<div>
<p className="text-xs text-slate-500 mb-1">{owner}</p>
<h3 className="flex flex-wrap items-center gap-1.5 text-xl font-bold text-white leading-tight">
<a className="hover:text-cyan-400" href={ghRepoRoot} target="_blank" rel="noreferrer">
{repo}
</a>
{!ghAboutHomepage ? (
<a
className="inline-flex rounded p-0.5 text-slate-500 hover:text-white"
href={ghTree}
target="_blank"
rel="noreferrer"
title={effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
>
<Github className="size-4" aria-hidden />
<span className="sr-only">
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
</span>
</a>
) : null}
</h3>
</div>
{ghAboutDescription ? (
<p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p>
) : null}
{ghAboutHomepage ? (
<div className="flex flex-wrap items-center gap-1.5 text-sm">
<a
className="inline-flex items-center gap-1.5 text-cyan-400 hover:underline"
href={homepageHref(ghAboutHomepage)}
target="_blank"
rel="noreferrer"
>
<Link2 className="size-3.5 shrink-0 text-slate-400" aria-hidden />
{homepageLabel(ghAboutHomepage)}
</a>
<a
className="inline-flex rounded p-0.5 text-slate-500 hover:text-white"
href={ghTree}
@@ -666,64 +684,37 @@ export default function RepoPage() {
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
</span>
</a>
) : null}
</h3>
</div>
{ghAboutDescription ? <p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p> : null}
{ghAboutHomepage ? (
<div className="flex flex-wrap items-center gap-1.5 text-sm">
<a
className="inline-flex items-center gap-1.5 text-cyan-400 hover:underline"
href={homepageHref(ghAboutHomepage)}
target="_blank"
rel="noreferrer"
</div>
) : null}
<div className="pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-slate-600 text-slate-300 hover:border-slate-500 hover:bg-slate-800 hover:text-white"
title="Refresh tags from GitHub"
onClick={() => void refreshTags({ owner, repo }).catch(e => toast.error(String(e)))}
>
<Link2 className="size-3.5 shrink-0 text-slate-400" aria-hidden />
{homepageLabel(ghAboutHomepage)}
</a>
<a
className="inline-flex rounded p-0.5 text-slate-500 hover:text-white"
href={ghTree}
target="_blank"
rel="noreferrer"
title={effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
>
<Github className="size-4" aria-hidden />
<span className="sr-only">
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
</span>
</a>
<RefreshCw className="size-3.5" />
Refresh tags
</Button>
</div>
) : null}
<div className="pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-slate-600 text-slate-300 hover:border-slate-500 hover:bg-slate-800 hover:text-white"
title="Refresh tags from GitHub"
onClick={() => void refreshTags({ owner, repo }).catch(e => toast.error(String(e)))}
>
<RefreshCw className="size-3.5" />
Refresh tags
</Button>
</div>
</aside>
</aside>
<div className="[grid-area:repo-readme] prose prose-invert prose-sm max-w-none prose-hr:my-6">
{readmeMd === null ? (
<p className="text-slate-500 not-prose">Loading</p>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={readmeMarkdownComponents}
>
{readmeMd || "*No README.*"}
</ReactMarkdown>
)}
<div className="[grid-area:repo-readme] prose prose-invert prose-sm max-w-none prose-hr:my-6">
{readmeMd === null ? (
<p className="text-slate-500 not-prose">Loading</p>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={readmeMarkdownComponents}
>
{readmeMd || "*No README.*"}
</ReactMarkdown>
)}
</div>
</div>
</div>
)}
</section>
</div>