update: firmware and provisioning now supports bootloader and partition tables

This commit is contained in:
2026-03-16 08:52:58 +02:00
parent 360725c93f
commit 4381a6681d
15 changed files with 776 additions and 49 deletions

View File

@@ -51,6 +51,234 @@ function UpdateTypeBadge({ type }) {
);
}
// ── colour tokens for API param tags ────────────────────────────────────────
// Soft, desaturated — readable on both light and dark backgrounds
const TAG = {
hwType: { color: "var(--danger-text)", bg: "var(--danger-bg)", border: "var(--danger-text)" }, // reddish
channel: { color: "var(--badge-blue-text)", bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)" }, // muted blue
version: { color: "var(--success-text)", bg: "var(--success-bg)", border: "var(--success-text)" }, // muted green
identity: { color: "#b8922a", bg: "#2a200a", border: "#b8922a" }, // warm yellow
};
function ParamTag({ children, kind = "identity" }) {
const t = TAG[kind];
return (
<span style={{
fontFamily: "monospace", fontSize: "0.68rem",
backgroundColor: t.bg, color: t.color,
border: `1px solid ${t.border}`,
borderRadius: "4px", padding: "1px 7px",
opacity: 0.9,
}}>
{children}
</span>
);
}
function EndpointCard({ method, label, pathParts, query, bodyFields, desc }) {
const isPost = method === "POST";
const methodStyle = isPost
? { bg: "var(--success-bg)", color: "var(--success-text)" }
: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" };
return (
<div className="rounded-md p-3 mb-3" style={{ backgroundColor: "var(--bg-secondary)", border: "1px solid var(--border-secondary)" }}>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-bold px-1.5 py-0.5 rounded" style={{ backgroundColor: methodStyle.bg, color: methodStyle.color }}>{method}</span>
<span className="text-xs font-semibold" style={{ color: "var(--text-muted)" }}>{label}</span>
</div>
<div className="flex flex-wrap items-center mb-2" style={{ fontFamily: "monospace", fontSize: "0.72rem" }}>
<span style={{ color: "var(--text-muted)" }}>console.bellsystems.net</span>
{pathParts.map((p, i) =>
p.plain
? <span key={i} style={{ color: "var(--text-primary)" }}>{p.text}</span>
: <ParamTag key={i} kind={p.kind}>{p.text}</ParamTag>
)}
</div>
{query && (
<div className="flex flex-wrap gap-1.5 mb-2">
{query.map((q) => (
<ParamTag key={q.key} kind={q.kind}>?{q.key}=<span style={{ opacity: 0.6 }}>{q.eg}</span></ParamTag>
))}
</div>
)}
{bodyFields && (
<div className="flex flex-wrap gap-1.5 mb-2">
{bodyFields.map((f) => <ParamTag key={f.key} kind={f.kind}>{f.key}</ParamTag>)}
</div>
)}
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{desc}</p>
</div>
);
}
const GET_ENDPOINTS = [
{
label: "Check for latest version",
method: "GET",
pathParts: [
{ text: "/api/firmware/", plain: true },
{ text: "{hw_type}", kind: "hwType" },
{ text: "/", plain: true },
{ text: "{channel}", kind: "channel" },
{ text: "/latest", plain: true },
],
query: [
{ key: "hw_version", kind: "version", eg: "1.0" },
{ key: "current_version", kind: "version", eg: "1.2.3" },
],
desc: "Returns metadata for the correct next firmware hop. Pass hw_version and current_version so the server resolves upgrade chains correctly.",
},
{
label: "Get specific version info",
method: "GET",
pathParts: [
{ text: "/api/firmware/", plain: true },
{ text: "{hw_type}", kind: "hwType" },
{ text: "/", plain: true },
{ text: "{channel}", kind: "channel" },
{ text: "/", plain: true },
{ text: "{version}", kind: "version" },
{ text: "/info", plain: true },
],
desc: "Returns metadata for a specific version. Used when resolving upgrade chains with min_fw_version constraints.",
},
{
label: "Download firmware binary",
method: "GET",
pathParts: [
{ text: "/api/firmware/", plain: true },
{ text: "{hw_type}", kind: "hwType" },
{ text: "/", plain: true },
{ text: "{channel}", kind: "channel" },
{ text: "/", plain: true },
{ text: "{version}", kind: "version" },
{ text: "/firmware.bin", plain: true },
],
desc: "Streams the raw .bin file. Devices fetch this after confirming the version via /latest or /info.",
},
];
const POST_ENDPOINTS = [
{
label: "OTA download event",
method: "POST",
pathParts: [{ text: "/api/ota/events/download", plain: true }],
bodyFields: [
{ key: "device_uid", kind: "identity" },
{ key: "hw_type", kind: "hwType" },
{ key: "hw_version", kind: "version" },
{ key: "from_version", kind: "version" },
{ key: "to_version", kind: "version" },
{ key: "channel", kind: "channel" },
],
desc: "Posted when the binary is fully written to the staged partition (before Update.end()). Best-effort — no retry on failure.",
},
{
label: "OTA flash confirmed",
method: "POST",
pathParts: [{ text: "/api/ota/events/flash", plain: true }],
bodyFields: [
{ key: "device_uid", kind: "identity" },
{ key: "hw_type", kind: "hwType" },
{ key: "hw_version", kind: "version" },
{ key: "from_version", kind: "version" },
{ key: "to_version", kind: "version" },
{ key: "channel", kind: "channel" },
{ key: "sha256", kind: "identity" },
],
desc: "Posted after Update.end() succeeds — partition committed, device about to reboot. This is ground truth for fleet version tracking.",
},
];
function ApiInfoModal({ onClose }) {
const [tab, setTab] = useState("get");
const TABS = [
{ id: "get", label: "GET" },
{ id: "post", label: "POST" },
{ id: "legend", label: "Legend" },
];
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full mx-4 flex flex-col"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
maxWidth: "720px",
maxHeight: "80vh",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-4" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Firmware API</h3>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>All endpoints are unauthenticated devices call them directly.</p>
</div>
<button
autoFocus
onClick={onClose}
onKeyDown={(e) => e.key === "Escape" && onClose()}
className="text-lg leading-none hover:opacity-70 cursor-pointer"
style={{ color: "var(--text-muted)" }}
>
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 pt-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className="px-4 py-1.5 text-xs font-medium rounded-t cursor-pointer transition-colors"
style={{
backgroundColor: tab === t.id ? "var(--bg-secondary)" : "transparent",
color: tab === t.id ? "var(--text-primary)" : "var(--text-muted)",
borderBottom: tab === t.id ? "2px solid var(--btn-primary)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{t.label}
</button>
))}
</div>
{/* Scrollable content */}
<div className="overflow-y-auto px-6 py-4" style={{ flex: 1 }}>
{tab === "get" && GET_ENDPOINTS.map((ep) => <EndpointCard key={ep.label} {...ep} />)}
{tab === "post" && POST_ENDPOINTS.map((ep) => <EndpointCard key={ep.label} {...ep} />)}
{tab === "legend" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{[
{ kind: "hwType", label: "{hw_type}", eg: "vesper_plus, chronos, agnus" },
{ kind: "channel", label: "{channel}", eg: "stable, beta, alpha, testing" },
{ kind: "version", label: "{version} / hw_version / current_version", eg: "1.0 · 2.5.1 · 1.2.3" },
{ kind: "identity", label: "device_uid / sha256", eg: "BSVSPR-26C13X-… · a3f1…" },
].map((row) => (
<div key={row.kind} className="flex items-start gap-3 rounded-md p-3" style={{ backgroundColor: "var(--bg-secondary)", border: "1px solid var(--border-secondary)" }}>
<ParamTag kind={row.kind}>{row.label}</ParamTag>
<div>
<div className="text-xs" style={{ color: "var(--text-secondary)", fontFamily: "monospace" }}>{row.eg}</div>
</div>
</div>
))}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
Colour coding is consistent across all tabs the same token type always appears in the same colour whether it is a path segment, query param, or POST body field.
</p>
</div>
)}
</div>
</div>
</div>
);
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
@@ -83,6 +311,8 @@ export default function FirmwareManager() {
const [channelFilter, setChannelFilter] = useState("");
const [showUpload, setShowUpload] = useState(false);
const [showFlashAssets, setShowFlashAssets] = useState(false);
const [showApiInfo, setShowApiInfo] = useState(false);
const [uploadHwType, setUploadHwType] = useState("vesper");
const [uploadChannel, setUploadChannel] = useState("stable");
const [uploadVersion, setUploadVersion] = useState("");
@@ -94,6 +324,15 @@ export default function FirmwareManager() {
const [uploadError, setUploadError] = useState("");
const fileInputRef = useRef(null);
const [flashAssetsHwType, setFlashAssetsHwType] = useState("vesper");
const [flashAssetsBootloader, setFlashAssetsBootloader] = useState(null);
const [flashAssetsPartitions, setFlashAssetsPartitions] = useState(null);
const [flashAssetsUploading, setFlashAssetsUploading] = useState(false);
const [flashAssetsError, setFlashAssetsError] = useState("");
const [flashAssetsSuccess, setFlashAssetsSuccess] = useState("");
const blInputRef = useRef(null);
const partInputRef = useRef(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
@@ -159,6 +398,43 @@ export default function FirmwareManager() {
}
};
const handleFlashAssetsUpload = async () => {
if (!flashAssetsBootloader && !flashAssetsPartitions) return;
setFlashAssetsError("");
setFlashAssetsSuccess("");
setFlashAssetsUploading(true);
const token = localStorage.getItem("access_token");
try {
const uploads = [];
if (flashAssetsBootloader) uploads.push({ file: flashAssetsBootloader, asset: "bootloader.bin" });
if (flashAssetsPartitions) uploads.push({ file: flashAssetsPartitions, asset: "partitions.bin" });
for (const { file, asset } of uploads) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`/api/manufacturing/flash-assets/${flashAssetsHwType}/${asset}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Failed to upload ${asset}`);
}
}
setFlashAssetsSuccess(`Flash assets saved for ${BOARD_TYPES.find(b => b.value === flashAssetsHwType)?.label || flashAssetsHwType}.`);
setFlashAssetsBootloader(null);
setFlashAssetsPartitions(null);
if (blInputRef.current) blInputRef.current.value = "";
if (partInputRef.current) partInputRef.current.value = "";
} catch (err) {
setFlashAssetsError(err.message);
} finally {
setFlashAssetsUploading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
@@ -173,7 +449,7 @@ export default function FirmwareManager() {
}
};
const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
const BOARD_TYPE_LABELS = { vesper: "Vesper", vesper_plus: "Vesper Plus", vesper_pro: "Vesper Pro", chronos: "Chronos", chronos_pro: "Chronos Pro", agnus: "Agnus", agnus_mini: "Agnus Mini" };
return (
<div>
@@ -187,17 +463,153 @@ export default function FirmwareManager() {
{hwTypeFilter || channelFilter ? " (filtered)" : ""}
</p>
</div>
{canAdd && (
<div className="flex gap-2">
<button
onClick={() => setShowUpload(true)}
onClick={() => setShowApiInfo(true)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
+ Upload Firmware
Firmware API Info
</button>
)}
{canAdd && (
<>
<button
onClick={() => { setShowFlashAssets((v) => !v); setShowUpload(false); }}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{
backgroundColor: showFlashAssets ? "var(--bg-card-hover)" : "var(--bg-card-hover)",
color: "var(--text-secondary)",
border: `1px solid ${showFlashAssets ? "var(--btn-primary)" : "var(--border-primary)"}`,
}}
>
Flash Assets
</button>
<button
onClick={() => { setShowUpload((v) => !v); setShowFlashAssets(false); }}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Upload Firmware
</button>
</>
)}
</div>
</div>
{/* Flash Assets panel */}
{showFlashAssets && (
<div className="ui-section-card mb-5">
<div className="ui-section-card__title-row">
<div>
<h2 className="ui-section-card__title">Flash Assets</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Bootloader and partition table binaries used during full provisioning. One set per board type.
Built by PlatformIO find them in <span style={{ fontFamily: "monospace" }}>.pio/build/&#123;env&#125;/</span>
</p>
</div>
</div>
{flashAssetsError && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{flashAssetsError}
</div>
)}
{flashAssetsSuccess && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success-text)", color: "var(--success-text)" }}>
{flashAssetsSuccess}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Board type selector + action buttons */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
<select
value={flashAssetsHwType}
onChange={(e) => { setFlashAssetsHwType(e.target.value); setFlashAssetsSuccess(""); }}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", marginTop: "auto" }}>
<button
onClick={handleFlashAssetsUpload}
disabled={flashAssetsUploading || (!flashAssetsBootloader && !flashAssetsPartitions)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{flashAssetsUploading ? "Uploading…" : "Save Assets"}
</button>
<button
type="button"
onClick={() => { setShowFlashAssets(false); setFlashAssetsError(""); setFlashAssetsSuccess(""); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
{/* Bootloader drop zone */}
{[
{ label: "Bootloader (0x1000)", file: flashAssetsBootloader, setFile: setFlashAssetsBootloader, ref: blInputRef, hint: "bootloader.bin" },
{ label: "Partition Table (0x8000)", file: flashAssetsPartitions, setFile: setFlashAssetsPartitions, ref: partInputRef, hint: "partitions.bin" },
].map(({ label, file, setFile, ref, hint }) => (
<div key={hint} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)" }}>{label}</label>
<div
onClick={() => ref.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".bin")) { setFile(f); setFlashAssetsSuccess(""); }
}}
style={{
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.5rem", padding: "1.25rem 1rem",
border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`,
borderRadius: "0.625rem",
backgroundColor: file ? "var(--badge-blue-bg)" : "var(--bg-input)",
cursor: "pointer", transition: "all 0.15s ease",
}}
>
<input ref={ref} type="file" accept=".bin" onChange={(e) => { setFile(e.target.files[0] || null); setFlashAssetsSuccess(""); }} style={{ display: "none" }} />
{file ? (
<>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
{file.name}
</span>
<span style={{ fontSize: "0.68rem", color: "var(--text-muted)" }}>{formatBytes(file.size)}</span>
</>
) : (
<>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center" }}>
Click or drop <span style={{ fontFamily: "monospace" }}>{hint}</span>
</span>
</>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Upload form */}
{showUpload && (
<div
@@ -598,6 +1010,11 @@ export default function FirmwareManager() {
</div>
</div>
{/* Firmware API Info modal */}
{showApiInfo && (
<ApiInfoModal onClose={() => setShowApiInfo(false)} />
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>

View File

@@ -92,9 +92,9 @@ function StatusBadge({ status }) {
);
}
function ProgressBar({ label, percent }) {
function ProgressBar({ label, percent, flex = false }) {
return (
<div className="space-y-1.5">
<div className="space-y-1.5" style={{ flex: flex ? 1 : undefined }}>
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
<span>{label}</span>
<span>{Math.round(percent)}%</span>
@@ -649,6 +649,8 @@ function StepFlash({ device, onFlashed }) {
const [connecting, setConnecting] = useState(false);
const [flashing, setFlashing] = useState(false);
const [done, setDone] = useState(false);
const [blProgress, setBlProgress] = useState(0);
const [partProgress, setPartProgress] = useState(0);
const [nvsProgress, setNvsProgress] = useState(0);
const [fwProgress, setFwProgress] = useState(0);
const [log, setLog] = useState([]);
@@ -777,6 +779,8 @@ function StepFlash({ device, onFlashed }) {
setError("");
setLog([]);
setSerial([]);
setBlProgress(0);
setPartProgress(0);
setNvsProgress(0);
setFwProgress(0);
setDone(false);
@@ -785,13 +789,23 @@ function StepFlash({ device, onFlashed }) {
try {
// 1. Fetch binaries
const sn = device.serial_number;
appendLog("Fetching bootloader binary…");
const blBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/bootloader.bin`);
appendLog(`Bootloader: ${blBuffer.byteLength} bytes`);
appendLog("Fetching partition table binary…");
const partBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/partitions.bin`);
appendLog(`Partition table: ${partBuffer.byteLength} bytes`);
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`);
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/nvs.bin`);
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`);
appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`);
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/firmware.bin`);
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
// 2. Connect ESPLoader
setFlashing(true);
@@ -811,24 +825,34 @@ function StepFlash({ device, onFlashed }) {
await loaderRef.current.main();
appendLog("ESP32 connected.");
// 3. Flash NVS + firmware
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
// 3. Flash all four regions: bootloader → partition table → NVS firmware
// fileIndex: 0=bootloader, 1=partitions, 2=nvs, 3=firmware
const blData = arrayBufferToString(blBuffer);
const partData = arrayBufferToString(partBuffer);
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
await loaderRef.current.writeFlash({
fileArray: [
{ data: nvsData, address: NVS_ADDRESS },
{ data: fwData, address: FW_ADDRESS },
{ data: blData, address: 0x1000 }, // bootloader
{ data: partData, address: 0x8000 }, // partition table
{ data: nvsData, address: NVS_ADDRESS }, // 0x9000
{ data: fwData, address: FW_ADDRESS }, // 0x10000
],
flashSize: "keep", flashMode: "keep", flashFreq: "keep",
eraseAll: false, compress: true,
reportProgress(fileIndex, written, total) {
if (fileIndex === 0) setNvsProgress((written / total) * 100);
else { setNvsProgress(100); setFwProgress((written / total) * 100); }
const pct = (written / total) * 100;
if (fileIndex === 0) { setBlProgress(pct); }
else if (fileIndex === 1) { setBlProgress(100); setPartProgress(pct); }
else if (fileIndex === 2) { setPartProgress(100); setNvsProgress(pct); }
else { setNvsProgress(100); setFwProgress(pct); }
},
calculateMD5Hash: () => "",
});
setBlProgress(100);
setPartProgress(100);
setNvsProgress(100);
setFwProgress(100);
appendLog("Flash complete. Resetting device…");
@@ -913,9 +937,13 @@ function StepFlash({ device, onFlashed }) {
<ErrorBox msg={error} />
{error && <div className="h-2" />}
{(flashing || nvsProgress > 0) && (
<div className="space-y-3 mb-4">
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
{(flashing || blProgress > 0) && (
<div className="space-y-3 mb-1">
<div style={{ display: "flex", gap: "1rem" }}>
<ProgressBar flex label="Bootloader (0x1000)" percent={blProgress} />
<ProgressBar flex label="Partition Table (0x8000)" percent={partProgress} />
</div>
<ProgressBar label="NVS (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
)}