Refactor firmware flashing process by updating EspFlasher component to support new props for GitHub links and download functionality. Modify buildFlashParts to handle optional LittleFS images based on resetDeviceStorage flag. Enhance emit-flash-manifest.py to exclude factory images from USB bundles. Update CI workflows to improve artifact staging and documentation generation.

This commit is contained in:
Ben Allfree
2026-04-10 17:34:12 -07:00
parent 8618c201f2
commit a9e1e38b74
9 changed files with 546 additions and 349 deletions
+2
View File
@@ -127,12 +127,14 @@ jobs:
mkdir -p "$STAGE"
shopt -s nullglob
for f in "$BUILD_DIR"/*.bin "$BUILD_DIR"/*.uf2 "$BUILD_DIR"/*.hex; do
case "$f" in *.factory.bin) continue ;; esac
cp -a "$f" "$STAGE/"
done
shopt -u nullglob
if [ -f "$BUILD_DIR/flash-manifest.json" ]; then
cp -a "$BUILD_DIR/flash-manifest.json" "$STAGE/"
fi
bash "${{ github.workspace }}/scripts/stage-fw-bundle-docs.sh" "$ROOT" "$STAGE"
if [ -z "$(find "$STAGE" -mindepth 1 -maxdepth 1 -type f -print -quit)" ]; then
echo "No firmware artifacts found in $BUILD_DIR"
exit 1
+2
View File
@@ -127,12 +127,14 @@ jobs:
mkdir -p "$STAGE"
shopt -s nullglob
for f in "$BUILD_DIR"/*.bin "$BUILD_DIR"/*.uf2 "$BUILD_DIR"/*.hex; do
case "$f" in *.factory.bin) continue ;; esac
cp -a "$f" "$STAGE/"
done
shopt -u nullglob
if [ -f "$BUILD_DIR/flash-manifest.json" ]; then
cp -a "$BUILD_DIR/flash-manifest.json" "$STAGE/"
fi
bash "${{ github.workspace }}/scripts/stage-fw-bundle-docs.sh" "$ROOT" "$STAGE"
if [ -z "$(find "$STAGE" -mindepth 1 -maxdepth 1 -type f -print -quit)" ]; then
echo "No firmware artifacts found in $BUILD_DIR"
exit 1
+13 -42
View File
@@ -6,7 +6,9 @@ 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.
from partition table + on-disk split artifacts (no *.factory.bin — USB bundles
omit the merged image). Only LittleFS rows use optional:true (Mesh Forge:
Reset device storage).
Optional args: PROJECT_ROOT TARGET_ENV — merged PIO config for that env fills
targetFamily (and platform/board) for the USB flasher UI.
@@ -148,20 +150,6 @@ def part_offset_for_slot(parts: list[dict], part_name: str) -> int | None:
return None
def factory_app_offset(parts: list[dict]) -> int:
for p in parts:
if str(p.get("subtype", "")) == "factory":
o = parse_offset(p.get("offset"))
if o is not None:
return o
for p in parts:
if p.get("type") == "app" and p.get("subtype") == "ota_0":
o = parse_offset(p.get("offset"))
if o is not None:
return o
return 0x10000
def ota1_offset(parts: list[dict]) -> int | None:
for p in parts:
if str(p.get("subtype", "")) == "ota_1":
@@ -179,27 +167,19 @@ def _offset_int(im: dict) -> int:
def _dedupe_same_offset(images: list[dict]) -> list[dict]:
"""
One esptool image per physical offset. Meshtastic maps both firmware-*.bin (app0)
and firmware-*.factory.bin to the same ota_0 slot — keep factory, drop the duplicate.
"""
"""One esptool image per physical offset (prefer non-optional, then lexicographic file)."""
buckets: dict[int, list[dict]] = {}
for im in images:
buckets.setdefault(_offset_int(im), []).append(im)
def rank(im: dict) -> tuple:
f = im["file"]
opt = im.get("optional") is True
if re.search(r"\.factory\.bin$", f, re.I):
return (0, 0 if not opt else 1, f)
if not opt:
return (1, 0, f)
return (2, 0, f)
out: list[dict] = []
for off in sorted(buckets):
group = buckets[off]
out.append(group[0] if len(group) == 1 else min(group, key=rank))
out.append(
group[0]
if len(group) == 1
else min(group, key=lambda im: (im.get("optional") is True, str(im.get("file", ""))))
)
return out
@@ -233,20 +213,11 @@ def emit_from_mt(build_dir: str, mt: dict) -> dict | None:
off = part_offset_for_slot(parts, str(part_name))
if off is None:
continue
opt = bool(
fname.startswith("littlefs-")
or re.match(r"^mt-.+-ota\.bin$", fname, re.I)
or (
re.match(r"^firmware-.+\.bin$", fname, re.I)
and not re.search(r"\.factory\.bin$", fname, re.I)
)
)
# Only LittleFS is optional in the bundle; Mesh Forge flashes it when the user enables
# "Reset device storage". Bootloader, partitions, app, and BLE OTA are always flashed when present.
opt = bool(fname.startswith("littlefs-"))
add(fname, off, optional=opt)
factory_bins = sorted(n for n in names if re.match(r"^firmware-.+\.factory\.bin$", n, re.I))
if factory_bins:
add(factory_bins[0], factory_app_offset(parts))
for n in sorted(names):
if n.startswith("littlefs-") and n.endswith(".bin"):
off = spiffs_offset(parts)
@@ -257,7 +228,7 @@ def emit_from_mt(build_dir: str, mt: dict) -> dict | None:
if re.match(r"^mt-.+-ota\.bin$", n, re.I):
off = ota1_offset(parts)
if off is not None:
add(n, off, optional=True)
add(n, off, optional=False)
if not images:
return None
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Copy README and license files from repo root into the firmware tarball stage when present.
set -euo pipefail
ROOT="${1:?root dir}"
STAGE="${2:?stage dir}"
for f in \
README \
README.md \
README.rst \
README.txt \
readme.md \
LICENSE \
LICENSE.md \
LICENSE.txt \
COPYING \
COPYRIGHT; do
if [[ -f "$ROOT/$f" ]]; then
cp -a "$ROOT/$f" "$STAGE/"
fi
done
+294 -139
View File
@@ -1,12 +1,24 @@
import { Button } from "@/components/ui/button"
import { Check, CheckCircle2, X } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
CheckCircle2,
Download,
ExternalLink,
Github,
Link2,
Mail,
PlayCircle,
Share2,
Smartphone,
X,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { buildFlashParts, flashInstallRowsFromManifest, manifestFromMap } from "../lib/espFlashLayout"
import { buildFlashParts, manifestFromMap } from "../lib/espFlashLayout"
import {
ensureSerialPortClosed,
ESP_FLASH_WEB_BAUD,
isSerialUserCancelledError,
pulseUsbBootloaderPort,
pulseUsbBootloaderOnPort,
runEspFlash,
type FlashPhase,
} from "../lib/espFlashRun"
@@ -42,34 +54,43 @@ 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"
className?: string
/** Tighter copy when shown in the repo hero. */
condensed?: boolean
/** Firmware source repo tree at this ref (no target / flash path segment). */
githubRepoTreeHref?: string | null
/** Mesh Forge GitHub Actions run for this build. */
githubActionsRunHref?: string | null
/** Download firmware bundle (icon button below controls). */
onDownloadBundle?: (() => void) | null
/** Current page URL for the share popover (exact flasher view). */
sharePageUrl?: string | null
/** Omit outer card chrome when parent already provides the bordered container. */
embedded?: boolean
}
export default function EspFlasher({
bundleUrl,
targetEnv = null,
flashButtonLabel = "Connect serial & flash",
flashBusyLabel = "Flashing…",
flashButtonSize = "default",
flashButtonLabel = "Flash",
flashBusyLabel = "Writing…",
flashButtonSize = "lg",
className = "",
condensed = false,
githubRepoTreeHref = null,
githubActionsRunHref = null,
onDownloadBundle = null,
sharePageUrl = null,
embedded = false,
}: EspFlasherProps) {
const [busy, setBusy] = useState(false)
const [eraseAll, setEraseAll] = useState(false)
const [baud, setBaud] = useState(921600)
const shareDialogRef = useRef<HTMLDialogElement>(null)
const [resetDeviceStorage, setResetDeviceStorage] = useState(false)
const [layoutPreview, setLayoutPreview] = useState<FlashManifest | null>(null)
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
const [bundleLoadError, setBundleLoadError] = useState<string | null>(null)
const [dfuPulsedOnce, setDfuPulsedOnce] = useState(false)
useEffect(() => {
setDfuPulsedOnce(false)
setLayoutPreview(null)
setBundleLoadError(null)
let cancelled = false
@@ -102,11 +123,6 @@ export default function EspFlasher({
const flashBlockedReason = unsupportedFlashMessage(resolvedFamily)
const canEspFlash = flashBlockedReason === null
const installPlanRows = useMemo(
() => (layoutPreview ? flashInstallRowsFromManifest(layoutPreview, eraseAll) : []),
[layoutPreview, eraseAll]
)
const prepareBundle = useCallback(async () => {
const res = await fetch(bundleUrl)
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
@@ -132,9 +148,12 @@ export default function EspFlasher({
let port: SerialPort | undefined
try {
port = await navigator.serial.requestPort()
setFlashProgress({ kind: "indeterminate", label: "USB bootloader reset…" })
await pulseUsbBootloaderOnPort(port)
setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" })
const files = await prepareBundle()
const parts = buildFlashParts(files, { eraseAll })
const parts = buildFlashParts(files, { resetDeviceStorage })
if (!parts) {
toast.error("Could not detect flash layout from bundle")
return
@@ -143,8 +162,8 @@ export default function EspFlasher({
await runEspFlash({
port,
parts,
baud,
eraseAll,
baud: ESP_FLASH_WEB_BAUD,
eraseAll: false,
onPhase: phase => {
setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] })
},
@@ -174,134 +193,63 @@ export default function EspFlasher({
setFlashProgress(null)
}
}
}, [baud, eraseAll, prepareBundle, canEspFlash, flashBlockedReason])
}, [resetDeviceStorage, prepareBundle, canEspFlash, flashBlockedReason])
const enterDfuMode = useCallback(async () => {
try {
await pulseUsbBootloaderPort()
setDfuPulsedOnce(true)
toast.success("1200-baud touch sent", {
description:
"If the device re-enumerated, pick the serial port and flash. If not, hold BOOT, tap RST, then try again.",
})
} catch (e) {
if (isSerialUserCancelledError(e)) {
return
}
toast.error(e instanceof Error ? e.message : String(e))
}
const shareUrlTrimmed = useMemo(() => sharePageUrl?.trim() ?? "", [sharePageUrl])
const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function"
const closeShareDialog = useCallback(() => {
shareDialogRef.current?.close()
}, [])
const familyLine =
resolvedFamily !== "esp32" || layoutPreview?.targetFamily
? `Target: ${resolvedFamily}${layoutPreview?.platform ? ` · ${layoutPreview.platform.trim()}` : ""}`
: null
const hasIconRow =
Boolean(githubRepoTreeHref?.trim()) ||
Boolean(githubActionsRunHref?.trim()) ||
typeof onDownloadBundle === "function" ||
Boolean(shareUrlTrimmed)
const copyShareLink = useCallback(async () => {
if (!shareUrlTrimmed) return
try {
await navigator.clipboard.writeText(shareUrlTrimmed)
toast.success("Link copied")
closeShareDialog()
} catch {
toast.error("Could not copy link")
}
}, [shareUrlTrimmed, closeShareDialog])
const openNativeShare = useCallback(async () => {
if (!shareUrlTrimmed || !canNativeShare) return
try {
await navigator.share({
url: shareUrlTrimmed,
title: "Mesh Forge Web Flasher",
})
closeShareDialog()
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return
toast.error("Share was cancelled or failed")
}
}, [shareUrlTrimmed, canNativeShare, closeShareDialog])
const rootClass = embedded
? `space-y-3 ${className}`.trim()
: `rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim()
return (
<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">USB firmware flash (Web Serial)</h3>
<p className="text-sm text-slate-400">
esptool-js for ESP32-class layouts. Put the board in <strong>ROM serial-download mode</strong> 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">
Chromium Web Serial + esptool-js (ESP32-class). Verify the flash map wrong images can brick hardware.
</p>
) : null}
<div className={rootClass}>
{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}
{layoutPreview ? (
<div className="space-y-1">
<table className="w-full text-left text-xs font-mono text-slate-300 border border-slate-600 rounded-md overflow-hidden">
<thead className="bg-slate-800/80 text-slate-400">
<tr>
<th className="px-2 py-1.5 font-medium" scope="col">
Image
</th>
<th className="px-2 py-1.5 font-medium w-30" scope="col">
Offset
</th>
<th className="px-2 py-1.5 font-medium w-24 text-center" scope="col">
Install
</th>
</tr>
</thead>
<tbody>
{installPlanRows.map((row, i) => (
<tr key={`${row.offset}-${i}-${row.file}`} className="border-t border-slate-700/80">
<td className="px-2 py-1.5 break-all">{row.file}</td>
<td className="px-2 py-1.5 text-slate-400 whitespace-nowrap">{row.offsetHex}</td>
<td className="px-2 py-1.5 text-center align-middle">
{row.willInstall ? (
<span className="inline-flex items-center justify-center text-emerald-400" title="Will flash">
<Check className="h-4 w-4" aria-hidden />
<span className="sr-only">Yes</span>
</span>
) : (
<span className="inline-flex items-center justify-center text-red-400" title="Skipped (enable full chip erase)">
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">No</span>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-slate-500">
Optional images are skipped unless <span className="text-slate-400">Full chip erase</span> is checked.
</p>
</div>
) : (
<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.
</p>
)}
<div className="flex flex-wrap gap-3 items-center">
<label className="text-sm text-slate-300 flex items-center gap-2">
Baud
<select
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>
<option value={921600}>921600</option>
</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)}
disabled={!canEspFlash}
/>
Full chip erase (destructive)
</label>
</div>
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Button
type="button"
size={flashButtonSize}
@@ -311,11 +259,218 @@ export default function EspFlasher({
>
{busy ? flashBusyLabel : flashButtonLabel}
</Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => void enterDfuMode()}>
{dfuPulsedOnce ? "Enter DFU mode (again)" : "Enter DFU mode"}
</Button>
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={resetDeviceStorage}
aria-label="Reset device storage"
disabled={!canEspFlash}
onClick={() => setResetDeviceStorage(v => !v)}
className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border border-slate-600 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 disabled:cursor-not-allowed disabled:opacity-50 ${
resetDeviceStorage ? "bg-amber-600" : "bg-slate-700"
}`}
>
<span
className={`pointer-events-none inline-block h-6 w-6 translate-y-0.5 rounded-full bg-white shadow transition-transform duration-200 ease-out ${
resetDeviceStorage ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
<span className="text-sm font-medium text-slate-200">Reset device storage</span>
</div>
</div>
{resetDeviceStorage ? (
<p
className="text-sm text-red-400 border border-red-500/40 bg-red-950/35 rounded-md px-3 py-2"
role="status"
>
<strong className="font-semibold text-red-300">Warning:</strong> all user data on the device will be deleted
when you flash (channels, preferences, and stored files).
</p>
) : null}
{hasIconRow ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
{githubRepoTreeHref?.trim() ? (
<a
href={githubRepoTreeHref.trim()}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white"
title="View source on GitHub"
>
<Github className="h-5 w-5" aria-hidden />
<span className="sr-only">View source on GitHub</span>
</a>
) : null}
{githubActionsRunHref?.trim() ? (
<a
href={githubActionsRunHref.trim()}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white"
title="View build on GitHub"
>
<PlayCircle className="h-5 w-5" aria-hidden />
<span className="sr-only">View build on GitHub</span>
</a>
) : null}
{typeof onDownloadBundle === "function" ? (
<button
type="button"
onClick={() => onDownloadBundle()}
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white disabled:opacity-50"
title="Download bundle"
>
<Download className="h-5 w-5" aria-hidden />
<span className="sr-only">Download bundle</span>
</button>
) : null}
{shareUrlTrimmed ? (
<>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white"
title="Share this page"
onClick={() => shareDialogRef.current?.showModal()}
>
<Share2 className="h-5 w-5" aria-hidden />
<span className="sr-only">Share this page</span>
</button>
<dialog
ref={shareDialogRef}
className="fixed inset-0 z-50 m-0 h-full max-h-none w-full max-w-none border-0 bg-transparent p-0 text-slate-100 backdrop:bg-slate-950/65 backdrop:backdrop-blur-[2px]"
onClick={e => {
if (e.target === e.currentTarget) closeShareDialog()
}}
>
<div
className="flex min-h-full w-full items-center justify-center p-4 sm:p-8"
onClick={e => {
if (e.target === e.currentTarget) closeShareDialog()
}}
>
<div
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-slate-600/70 bg-slate-950 shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_25px_80px_-12px_rgba(0,0,0,0.85)]"
onClick={e => e.stopPropagation()}
>
<div
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-amber-500/50 to-transparent"
aria-hidden
/>
<header className="border-b border-slate-800/90 bg-slate-900/40 px-5 pb-4 pt-5 sm:px-6 sm:pt-6">
<button
type="button"
className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
onClick={closeShareDialog}
aria-label="Close"
>
<X className="h-5 w-5" aria-hidden />
</button>
<div className="flex gap-4 pr-10">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-linear-to-br from-amber-500/20 to-cyan-600/10 text-amber-400 ring-1 ring-amber-500/25">
<Share2 className="h-6 w-6" aria-hidden />
</div>
<div className="min-w-0 space-y-1">
<h2 className="text-lg font-semibold tracking-tight text-slate-50">Share Web Flasher</h2>
<p className="text-sm leading-relaxed text-slate-400">
Same firmware view in Mesh Forge repo, ref, target, and bundle.
</p>
</div>
</div>
</header>
<div className="space-y-5 px-5 py-5 sm:px-6 sm:py-6">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">Link</p>
<p className="mt-2 max-h-24 overflow-y-auto rounded-xl border border-slate-700/80 bg-slate-900/60 px-3.5 py-3 font-mono text-[12px] leading-snug text-slate-300 wrap-anywhere">
{shareUrlTrimmed}
</p>
</div>
<div className="flex flex-col gap-2">
<Button
type="button"
className="h-11 w-full gap-2 bg-amber-600 text-white hover:bg-amber-500"
onClick={() => void copyShareLink()}
>
<Link2 className="h-4 w-4" aria-hidden />
Copy link
</Button>
{canNativeShare ? (
<Button
type="button"
variant="outline"
className="h-11 w-full gap-2 border-slate-600 bg-slate-900/50 text-slate-100 hover:bg-slate-800"
onClick={() => void openNativeShare()}
>
<Smartphone className="h-4 w-4" aria-hidden />
Share via this device
</Button>
) : null}
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">Open or post</p>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<Button
type="button"
variant="outline"
size="sm"
title="Open in new tab"
className="h-11 gap-2 border-slate-600 bg-slate-900/40 text-slate-200 hover:bg-slate-800"
onClick={() => {
window.open(shareUrlTrimmed, "_blank", "noopener,noreferrer")
closeShareDialog()
}}
>
<ExternalLink className="h-4 w-4 shrink-0 opacity-90" aria-hidden />
<span className="truncate">New tab</span>
</Button>
<a
href={`mailto:?subject=${encodeURIComponent("Mesh Forge Web Flasher")}&body=${encodeURIComponent(shareUrlTrimmed)}`}
title="Email this link"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
onClick={closeShareDialog}
>
<Mail className="h-4 w-4 shrink-0 opacity-90" aria-hidden />
<span className="truncate">Email</span>
</a>
<a
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrlTrimmed)}&text=${encodeURIComponent("Mesh Forge Web Flasher")}`}
target="_blank"
rel="noreferrer"
title="Post on X"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
onClick={closeShareDialog}
>
<span className="shrink-0 text-[13px] font-bold leading-none text-slate-100" aria-hidden>
𝕏
</span>
<span className="truncate">X</span>
</a>
<a
href={`https://bsky.app/intent/compose?text=${encodeURIComponent(shareUrlTrimmed)}`}
target="_blank"
rel="noreferrer"
title="Post on Bluesky"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
onClick={closeShareDialog}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.5)]" aria-hidden />
<span className="truncate">Bluesky</span>
</a>
</div>
</div>
</div>
</div>
</div>
</dialog>
</>
) : null}
</div>
) : null}
{flashProgress ? (
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">
+14 -40
View File
@@ -2,22 +2,17 @@ import { findInTar, parseFlashManifest, type FlashManifest, type FlashManifestIm
export type FlashPart = { data: Uint8Array; address: number; name: string }
export type FlashInstallPlanRow = {
file: string
offset: number
offsetHex: string
optional: boolean
willInstall: boolean
}
function manifestImageOffset(im: FlashManifestImage): number | null {
const addr = typeof im.offset === 'string' ? parseInt(im.offset, 0) : Number(im.offset)
return Number.isFinite(addr) ? addr : null
}
export type BuildFlashPartsOptions = {
/** When false, manifest rows with optional:true are omitted. */
eraseAll?: boolean
/**
* When true, flash optional LittleFS images from the manifest (wipes Meshtastic storage on device).
* Bootloader, partitions, firmware, and other non-LittleFS images are always included when present.
*/
resetDeviceStorage?: boolean
}
function sortFlashParts(parts: FlashPart[]): FlashPart[] {
@@ -29,9 +24,14 @@ function tarBasename(path: string): string {
return parts[parts.length - 1] ?? path
}
function isLittlefsManifestFile(file: string): boolean {
const base = tarBasename(file)
return base.toLowerCase().startsWith('littlefs-') && base.toLowerCase().endsWith('.bin')
}
/**
* PlatformIO projects (e.g. Meshtastic) often emit versioned names like
* firmware-heltec-v3-2.7.20.factory.bin instead of firmware.factory.bin.
* firmware-heltec-v3-2.7.20.bin (split app image; merged factory.bin is not bundled for USB flash).
*/
function resolveVersionedFirmwareApp(
files: Map<string, Uint8Array>
@@ -52,9 +52,6 @@ function resolveVersionedFirmwareApp(
list.push({ base, data })
}
const factory = list.find(e => /^firmware-.+\.factory\.bin$/i.test(e.base))
if (factory) return { data: factory.data, name: factory.base }
const app = list.find(
e => /^firmware-.+\.bin$/i.test(e.base) && !/\.factory\.bin$/i.test(e.base)
)
@@ -63,31 +60,12 @@ function resolveVersionedFirmwareApp(
return undefined
}
/** Sorted install plan for UI; `willInstall` matches `buildFlashParts` optional + eraseAll rules. */
export function flashInstallRowsFromManifest(m: FlashManifest, eraseAll: boolean): FlashInstallPlanRow[] {
const rows: FlashInstallPlanRow[] = []
for (const im of m.images) {
const offset = manifestImageOffset(im)
if (offset === null) continue
const optional = im.optional === true
const willInstall = !optional || eraseAll
rows.push({
file: im.file,
offset,
offsetHex: `0x${offset.toString(16)}`,
optional,
willInstall,
})
}
return rows.sort((a, b) => a.offset - b.offset)
}
/** Build ordered flash parts from a flat map (tar paths or bare filenames → bytes). */
export function buildFlashParts(
files: Map<string, Uint8Array>,
options: BuildFlashPartsOptions = {}
): FlashPart[] | null {
const eraseAll = options.eraseAll ?? false
const resetDeviceStorage = options.resetDeviceStorage ?? false
const manifestRaw = findInTar(files, 'flash-manifest.json')
if (manifestRaw) {
@@ -96,7 +74,7 @@ export function buildFlashParts(
if (m) {
const out: FlashPart[] = []
for (const img of m.images) {
if (!eraseAll && img.optional === true) continue
if (img.optional === true && isLittlefsManifestFile(img.file) && !resetDeviceStorage) continue
const data = findInTar(files, img.file)
if (!data) return null
const addr = typeof img.offset === 'string' ? parseInt(img.offset, 0) : Number(img.offset)
@@ -110,16 +88,12 @@ export function buildFlashParts(
const bootloader = findInTar(files, 'bootloader.bin')
const partitions = findInTar(files, 'partitions.bin')
const bootApp0 = findInTar(files, 'boot_app0.bin')
const factoryExact = findInTar(files, 'firmware.factory.bin')
const firmwareExact = findInTar(files, 'firmware.bin')
const versioned = resolveVersionedFirmwareApp(files)
let app: Uint8Array | undefined
let appName: string | undefined
if (factoryExact) {
app = factoryExact
appName = 'firmware.factory.bin'
} else if (firmwareExact) {
if (firmwareExact) {
app = firmwareExact
appName = 'firmware.bin'
} else if (versioned) {
+17 -6
View File
@@ -191,16 +191,18 @@ export async function runEspFlash(options: {
await transport.disconnect()
}
/** Conservative default for `runEspFlash` over Web Serial (fewer cable/hub issues than 921600). */
export const ESP_FLASH_WEB_BAUD = 115200
/** 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")
}
const port = await navigator.serial.requestPort()
/**
* USB CDC “1200 baud” bootloader entry on a port the user already picked (ESP32-class).
* Closes the port when done; wait before returning so the device can re-enumerate.
*/
export async function pulseUsbBootloaderOnPort(port: SerialPort): Promise<void> {
await ensureSerialPortClosed(port)
try {
await openSerialPortWithRecovery(port, { baudRate: 1200 })
@@ -210,3 +212,12 @@ export async function pulseUsbBootloaderPort(): Promise<void> {
}
await sleepMs(CDC_TOUCH_AFTER_CLOSE_MS)
}
/** Prompt for a serial port, then run {@link pulseUsbBootloaderOnPort}. */
export async function pulseUsbBootloaderPort(): Promise<void> {
if (!("serial" in navigator)) {
throw new Error("Web Serial is not available")
}
const port = await navigator.serial.requestPort()
await pulseUsbBootloaderOnPort(port)
}
+1 -1
View File
@@ -48,7 +48,7 @@ export function findInTar(files: Map<string, Uint8Array>, filename: string): Uin
export type FlashManifestImage = {
file: string
offset: number | string
/** When true, Mesh Forge skips this image unless the user enables full chip erase. */
/** When true on LittleFS rows, Mesh Forge skips unless the user enables Reset device storage. */
optional?: boolean
role?: string
}
+182 -121
View File
@@ -13,7 +13,7 @@ import {
type ImgHTMLAttributes,
} from "react"
import ReactMarkdown from "react-markdown"
import { Link, useNavigate, useParams } from "react-router-dom"
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
@@ -31,6 +31,7 @@ const meshForgeWorkflowUrl = `https://github.com/${MESH_FORGE_ACTIONS_REPO}/acti
export default function RepoPage() {
const navigate = useNavigate()
const location = useLocation()
const params = useParams<{ owner: string; repo: string; "*": string }>()
const ownerParam = params.owner ?? ""
const repoParam = params.repo ?? ""
@@ -45,6 +46,11 @@ export default function RepoPage() {
const isFlashView = flashFromUrl
const hasRef = Boolean(sourceRef)
const shareFlashPageUrl = useMemo(() => {
if (typeof window === "undefined") return ""
return `${window.location.origin}${location.pathname}${location.search}`
}, [location.pathname, location.search])
const tagData = useQuery(api.repoTags.get, owner && repo ? { owner, repo } : "skip")
const refreshTags = useAction(api.repoTags.refresh)
const resolveRef = useAction(api.repoScans.resolveRefToSha)
@@ -414,139 +420,194 @@ export default function RepoPage() {
</div>
)
const ciAndFlasherEl = (
<div className="max-w-2xl space-y-4">
{showCiCard ? (
<div className="rounded-lg border border-slate-800 bg-slate-900/40 p-4 space-y-2 text-sm">
{build.githubRunId || build.status === "failed" ? (
<div className="flex flex-wrap gap-2 items-center">
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
>
Mesh Forge workflow on GitHub
</a>
)}
</div>
) : null}
{buildInProgress ? (
<div className="space-y-2 pt-1">
{(() => {
const step = build.ciProgressStep
const total = build.ciProgressTotal
const label = build.ciProgressLabel
const hasSteps =
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 (
<>
<div
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
pct === null ? "animate-pulse" : ""
}`}
>
{pct !== null ? (
<div
className="h-full rounded-full bg-linear-to-r from-cyan-600 to-emerald-500 transition-[width] duration-500 ease-out shadow-[0_0_12px_rgba(34,211,238,0.35)]"
style={{ width: `${pct}%` }}
/>
) : (
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
)}
</div>
<p className="text-xs text-slate-400">
{hasSteps ? (
<>
Step {step} of {total}
{label ? ` · ${label}` : ""}
</>
) : (
<>Waiting for CI progress</>
)}
</p>
</>
)
})()}
</div>
) : null}
{build.status === "failed" && build.errorSummary ? (
<div className="space-y-2 text-xs">
{(() => {
const { headline, body } = buildFailurePresentation(build.errorSummary)
return (
<>
<p className="font-medium text-slate-200">{headline}</p>
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
<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}
</pre>
</details>
</>
)
})()}
</div>
) : null}
{build.status === "succeeded" ? (
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
Download bundle
</Button>
) : null}
</div>
) : null}
const showFlashUsbPanel = Boolean(flashUrl)
const showCiInner = Boolean(showCiCard && build && !showFlashUsbPanel)
{flashPrep === "loading" ? <p className="text-sm text-slate-400">Preparing USB flasher</p> : null}
{flashPrep === "error" ? (
<p className="text-sm text-amber-200/90">
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the file.
</p>
) : null}
{flashUrl ? (
const ciAndFlasherEl = (
<div className="max-w-2xl space-y-4 text-sm">
{showFlashUsbPanel ? (
<EspFlasher
bundleUrl={flashUrl}
embedded
bundleUrl={flashUrl!}
targetEnv={resolvedTargetEnv}
condensed
flashButtonLabel="USB flash"
flashBusyLabel="Writing…"
flashButtonSize="lg"
className="border-amber-900/50 bg-amber-950/25"
githubRepoTreeHref={ghTree}
githubActionsRunHref={
build?.githubRunId
? `https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`
: null
}
onDownloadBundle={() => void download()}
sharePageUrl={shareFlashPageUrl}
/>
) : showCiInner ? (
<>
{build.status === "failed" ? (
<div className="flex flex-wrap gap-2 items-center">
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
>
Mesh Forge workflow on GitHub
</a>
)}
</div>
) : null}
{buildInProgress ? (
<div className="space-y-2 pt-1">
{(() => {
const step = build.ciProgressStep
const total = build.ciProgressTotal
const label = build.ciProgressLabel
const hasSteps =
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 (
<>
<div
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
pct === null ? "animate-pulse" : ""
}`}
>
{pct !== null ? (
<div
className="h-full rounded-full bg-linear-to-r from-cyan-600 to-emerald-500 transition-[width] duration-500 ease-out shadow-[0_0_12px_rgba(34,211,238,0.35)]"
style={{ width: `${pct}%` }}
/>
) : (
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
)}
</div>
<p className="text-xs text-slate-400">
{hasSteps ? (
<>
Step {step} of {total}
{label ? ` · ${label}` : ""}
</>
) : (
<>Waiting for CI progress</>
)}
</p>
{build.githubRunId ? (
<a
className="inline-block text-cyan-400 hover:underline text-xs pt-0.5"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : null}
</>
)
})()}
</div>
) : null}
{build.status === "failed" && build.errorSummary ? (
<div className="space-y-2 text-xs">
{(() => {
const { headline, body } = buildFailurePresentation(build.errorSummary)
return (
<>
<p className="font-medium text-slate-200">{headline}</p>
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
<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}
</pre>
</details>
</>
)
})()}
</div>
) : null}
{build.status === "succeeded" && !flashUrl ? (
<>
{flashPrep === "loading" ? (
<p className="text-sm text-slate-400">Preparing USB flasher</p>
) : null}
{flashPrep === "error" ? (
<p className="text-sm text-amber-200/90">
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the
file.
</p>
) : null}
{flashPrep !== "loading" && flashPrep !== "error" ? (
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
Download bundle
</Button>
) : null}
</>
) : null}
</>
) : null}
</div>
)
return (
<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">
<div className="space-y-2">
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-slate-100 wrap-break-word">
{owner}/{repo}@{effectiveRef}
{resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher
<div className={`${isFlashView ? "max-w-4xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}>
{isFlashView ? (
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
<div className="min-w-0 space-y-6 md:space-y-8">
<header className="space-y-5 border-b border-slate-800/90 pb-8">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-500/90">
Web Flasher
</p>
<Link
to={backToRepoPath}
className="text-sm text-cyan-400 hover:text-cyan-300 hover:underline underline-offset-4 shrink-0"
>
Back to repository
</Link>
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-white leading-[1.05] font-mono wrap-break-word">
{owner}
<span className="text-slate-600 font-light mx-1 sm:mx-1.5">/</span>
{repo}
</h1>
<Link to={backToRepoPath} className="inline-block text-sm text-cyan-400 hover:underline">
Repository
</Link>
</div>
<div className="space-y-3 max-w-2xl">
<dl className="flex flex-col gap-2 text-sm sm:text-base">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<dt className="text-xs font-semibold uppercase tracking-wider text-slate-500 shrink-0 w-30">
Source ref
</dt>
<dd className="font-mono text-slate-200 min-w-0 wrap-break-word">{effectiveRef}</dd>
</div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<dt className="text-xs font-semibold uppercase tracking-wider text-slate-500 shrink-0 w-30">
Target
</dt>
<dd className="font-mono text-amber-200/95 min-w-0 wrap-break-word">
{resolvedTargetEnv ?? "—"}
</dd>
</div>
</dl>
</div>
</header>
{statusStripEl}
{ciAndFlasherEl}
</div>
) : (
</section>
) : (
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
<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']
@@ -707,8 +768,8 @@ export default function RepoPage() {
)}
</div>
</div>
)}
</section>
</section>
)}
</div>
)
}