update: firmware and provisioning now supports bootloader and partition tables
This commit is contained in:
@@ -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/{env}/</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)" }}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user