import { useState, useRef, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { ESPLoader, Transport } from "esptool-js";
import api from "../api/client";
// ─── constants ───────────────────────────────────────────────────────────────
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"},
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"},
];
// Color palette per board family (idle → selected → hover glow)
const BOARD_FAMILY_COLORS = {
vesper: {
selectedBg: "#0a1929",
selectedBorder:"#3b82f6",
selectedText: "#60a5fa",
hoverBorder: "#3b82f6",
glowColor: "rgba(59,130,246,0.35)",
idleBorder: "#1d3a5c",
idleText: "#7ca8d4",
},
agnus: {
selectedBg: "#1a1400",
selectedBorder:"#f59e0b",
selectedText: "#fbbf24",
hoverBorder: "#f59e0b",
glowColor: "rgba(245,158,11,0.35)",
idleBorder: "#4a3800",
idleText: "#c79d3a",
},
chronos: {
selectedBg: "#1a0808",
selectedBorder:"#ef4444",
selectedText: "#f87171",
hoverBorder: "#ef4444",
glowColor: "rgba(239,68,68,0.35)",
idleBorder: "#5c1a1a",
idleText: "#d47a7a",
},
};
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
// Display board version stored as semver string ("1.0", "2.1") as "Rev 1.0"
function formatHwVersion(v) {
if (!v) return "—";
// Already in semver form ("1.0") or legacy 2-digit ("01" → "Rev 1.0")
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
// Legacy: "01" → "1.0", "02" → "2.0"
const n = parseInt(v, 10);
if (!isNaN(n)) return `Rev ${n}.0`;
return `Rev ${v}`;
}
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
sold: { bg: "#1e1036", color: "#c084fc" },
claimed: { bg: "#2e1a00", color: "#fb923c" },
decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
// Statuses that can be re-flashed via the wizard
const FLASHABLE_STATUSES = ["manufactured", "flashed", "provisioned"];
const FLASH_BAUD = 460800;
const NVS_ADDRESS = 0x9000;
const FW_ADDRESS = 0x10000;
const VERIFY_POLL_MS = 5000;
const VERIFY_TIMEOUT_MS = 120_000;
// ─── small helpers ────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
{status}
);
}
function ProgressBar({ label, percent }) {
return (
{label}
{Math.round(percent)}%
);
}
function ErrorBox({ msg }) {
if (!msg) return null;
return (
{msg}
);
}
function inputStyle() {
return { backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" };
}
// ─── Step Indicator ───────────────────────────────────────────────────────────
// Steps: 0=Mode, 1=Select, 2=Flash, 3=Verify, 4=Done
// current is 1-based to match step state (step 0 = mode picker, shown differently)
const STEP_LABELS = ["Begin", "Device", "Flash", "Verify", "Done"];
function CheckeredFlagIcon() {
return (
);
}
function StepIndicator({ current }) {
// current: 1..4 (step numbers matching STEP_LABELS index + 1)
return (
{STEP_LABELS.map((label, i) => {
const idx = i + 1;
const done = idx < current;
const active = idx === current;
const pending = idx > current;
const isLast = i === STEP_LABELS.length - 1;
// Color tokens
const dotBg = done ? "#53b15786"
: active ? "#22c55e"
: "#251a1a";
const dotColor = done ? "#cbedb9"
: active ? "#ffffff"
: "#555";
const labelColor = active ? "#22c55e" : done ? "#53b15786" : "#555";
const labelGlow = active ? "0 0 8px rgba(34,197,94,0.45)" : "none";
const lineColor = done ? "#22c55e" : "var(--border-primary)";
// Active dot is 38px, others are 30px
const dotSize = active ? 38 : 30;
return (
{/* Node */}
{done ? (
) : isLast ? (
) : idx}
{label}
{/* Connector line */}
{i < STEP_LABELS.length - 1 && (
)}
);
})}
);
}
// ─── Step 0 — Mode picker ─────────────────────────────────────────────────────
function StepModePicker({ onPick }) {
return (
What would you like to do?
Choose how to start the provisioning process.
{/* Flash Existing */}
{/* Deploy New — same style as Flash Existing */}
);
}
// ─── Step 1a — Flash Existing: pick from inventory ────────────────────────────
function StepSelectExisting({ onSelected }) {
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState(null);
const doSearch = async (q) => {
setError("");
setLoading(true);
try {
const params = new URLSearchParams({ limit: "50" });
if (q) params.set("search", q);
// Only show flashable statuses
const all = await Promise.all(
FLASHABLE_STATUSES.map((s) =>
api.get(`/manufacturing/devices?${params}&status=${s}`).then((d) => d.devices)
)
);
const merged = all.flat().sort((a, b) =>
(b.created_at || "").localeCompare(a.created_at || "")
);
setResults(merged);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => { doSearch(""); }, []);
const handleSearchSubmit = (e) => {
e.preventDefault();
doSearch(search);
};
if (selected) {
return (
Device Selected
);
}
return (
Select a Device to Flash
{results.length === 0 && !loading && (
No flashable devices found.
)}
{results.length > 0 && (
| Serial |
Type |
Ver |
Status |
{results.map((d) => (
setSelected(d)}
className="cursor-pointer transition-colors"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
>
| {d.serial_number} |
{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}
|
{formatHwVersion(d.hw_version)}
|
|
))}
)}
);
}
// ─── Board Type Tile (shared between StepDeployNew and AddDeviceModal) ────────
function BoardTypeTile({ bt, isSelected, pal, onClick }) {
const [hovered, setHovered] = useState(false);
const borderColor = isSelected
? pal.selectedBorder
: hovered
? pal.hoverBorder
: pal.idleBorder;
const boxShadow = isSelected
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
: hovered
? `0 0 12px 3px ${pal.glowColor}`
: "none";
return (
);
}
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
function StepDeployNew({ onSelected, onCreatedSn }) {
const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("1.0");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
if (!boardType || !boardVersion.trim()) return;
setError("");
setCreating(true);
try {
const batch = await api.post("/manufacturing/batch", {
board_type: boardType,
board_version: boardVersion.trim(),
quantity: 1,
});
const sn = batch.serial_numbers[0];
onCreatedSn(sn); // register for abort-cleanup
const device = await api.get(`/manufacturing/devices/${sn}`);
onSelected(device);
} catch (err) {
setError(err.message);
} finally {
setCreating(false);
}
};
// Group boards by family for row layout
const vesperBoards = BOARD_TYPES.filter((b) => b.family === "vesper");
const agnusBoards = BOARD_TYPES.filter((b) => b.family === "agnus");
const chronosBoards = BOARD_TYPES.filter((b) => b.family === "chronos");
const renderTileRow = (boards, cols) => (
{boards.map((bt) => (
setBoardType(bt.value)}
/>
))}
);
return (
{/* Board type tiles — Vesper: 3 col, Agnus: 2 col centered, Chronos: 2 col centered */}
Board Type
{renderTileRow(vesperBoards, 3)}
{agnusBoards.map((bt) => (
setBoardType(bt.value)}
/>
))}
{chronosBoards.map((bt) => (
setBoardType(bt.value)}
/>
))}
{/* Board revision (left) + Generate button (right) */}
);
}
// ─── Shared info cell ─────────────────────────────────────────────────────────
function InfoCell({ label, value, mono = false }) {
return (
);
}
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, onFlashed }) {
const [portConnected, setPortConnected] = useState(false);
const [portName, setPortName] = useState("");
const [connecting, setConnecting] = useState(false);
const [flashing, setFlashing] = useState(false);
const [done, setDone] = useState(false);
const [nvsProgress, setNvsProgress] = useState(0);
const [fwProgress, setFwProgress] = useState(0);
const [log, setLog] = useState([]);
const [serial, setSerial] = useState([]);
const [serialAutoScroll, setSerialAutoScroll] = useState(true);
const [error, setError] = useState("");
const loaderRef = useRef(null);
const portRef = useRef(null);
const serialReaderRef = useRef(null);
const serialActiveRef = useRef(false);
const serialAutoScrollRef = useRef(true); // mirrors state — readable inside async loops
const logEndRef = useRef(null);
const serialEndRef = useRef(null);
// Keep the ref in sync with state so async loops always see the current value
const handleSetSerialAutoScroll = (val) => {
serialAutoScrollRef.current = val;
setSerialAutoScroll(val);
};
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]);
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]);
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: "smooth" });
const scrollSerial = () => { if (serialAutoScrollRef.current) serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); };
// When auto-scroll is re-enabled, jump to bottom immediately
useEffect(() => {
if (serialAutoScroll) serialEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [serialAutoScroll]);
const fetchBinary = async (url) => {
const token = localStorage.getItem("access_token");
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`);
}
return resp.arrayBuffer();
};
const arrayBufferToString = (buf) => {
const bytes = new Uint8Array(buf);
let str = "";
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
return str;
};
const startSerialMonitor = async (port) => {
serialActiveRef.current = true;
await new Promise((r) => setTimeout(r, 1000));
try {
await port.open({ baudRate: 115200 });
} catch (openErr) {
appendSerial(`[Error opening port: ${openErr.message}]`);
scrollSerial();
return;
}
let reader;
try {
reader = port.readable.getReader();
} catch (readerErr) {
appendSerial(`[Error getting reader: ${readerErr.message}]`);
scrollSerial();
try { await port.close(); } catch (_) {}
return;
}
serialReaderRef.current = reader;
const textDecoder = new TextDecoder();
let lineBuffer = "";
try {
while (serialActiveRef.current) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
lineBuffer += textDecoder.decode(value, { stream: true });
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop();
for (const line of lines) {
if (line.trim()) {
appendSerial(line);
scrollSerial();
}
}
}
} catch (_) {
// Reader cancelled on cleanup — expected
} finally {
try { reader.releaseLock(); } catch (_) {}
}
};
const disconnectPort = async () => {
serialActiveRef.current = false;
try { await serialReaderRef.current?.cancel(); } catch (_) {}
try { serialReaderRef.current?.releaseLock(); } catch (_) {}
try { await portRef.current?.close(); } catch (_) {}
portRef.current = null;
setPortConnected(false);
setPortName("");
appendSerial("[Port disconnected]");
};
const handleConnectPort = async () => {
setError("");
setConnecting(true);
try {
const port = await navigator.serial.requestPort();
portRef.current = port;
// Try to get a display name from port info
const info = port.getInfo?.() || {};
const label = info.usbVendorId
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
: "Serial Port";
setPortName(label);
setPortConnected(true);
} catch (err) {
setError(err.message || "Port selection cancelled.");
} finally {
setConnecting(false);
}
};
const handleStartFlash = async () => {
if (!portRef.current) return;
setError("");
setLog([]);
setSerial([]);
setNvsProgress(0);
setFwProgress(0);
setDone(false);
const port = portRef.current;
try {
// 1. Fetch binaries
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
appendLog(`NVS binary: ${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`);
// 2. Connect ESPLoader
setFlashing(true);
appendLog("Connecting to ESP32…");
const transport = new Transport(port, true);
loaderRef.current = new ESPLoader({
transport,
baudrate: FLASH_BAUD,
terminal: {
clean() {},
writeLine: (line) => { appendLog(line); scrollLog(); },
write: (msg) => { appendLog(msg); scrollLog(); },
},
});
await loaderRef.current.main();
appendLog("ESP32 connected.");
// 3. Flash NVS + firmware
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
await loaderRef.current.writeFlash({
fileArray: [
{ data: nvsData, address: NVS_ADDRESS },
{ data: fwData, address: FW_ADDRESS },
],
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); }
},
calculateMD5Hash: () => "",
});
setNvsProgress(100);
setFwProgress(100);
appendLog("Flash complete. Resetting device…");
// 4. Hard-reset via RTS pulse
try {
const t = loaderRef.current.transport;
await t.setRTS(true);
await new Promise((r) => setTimeout(r, 100));
await t.setRTS(false);
await new Promise((r) => setTimeout(r, 100));
} catch (rstErr) {
appendLog(`[Reset warning: ${rstErr.message}]`);
}
appendLog("Hard reset sent. Device is booting…");
// 5. Disconnect esptool transport
try { await loaderRef.current.transport.disconnect(); } catch (_) {}
appendLog("esptool disconnected. Opening serial monitor…");
// 6. Update status → flashed
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "flashed", note: "Flashed via browser provisioning wizard" }),
});
setFlashing(false);
setDone(true);
// 7. Re-open for serial monitor
appendSerial("── Serial monitor started (115200 baud) ──");
startSerialMonitor(port);
} catch (err) {
setError(err.message || String(err));
setFlashing(false);
try { await loaderRef.current?.transport?.disconnect(); } catch (_) {}
}
};
const webSerialAvailable = "serial" in navigator;
const busy = connecting || flashing;
// ── Row 1: Info+buttons (left) | Flash Output (right) ──────────────────────
const InfoPanel = (
{/* Header: title + COM status */}
Device to Flash
{!webSerialAvailable && (
Web Serial API not available. Use Chrome or Edge on a desktop system.
)}
{error &&
}
{(flashing || nvsProgress > 0) && (
)}
{/* Spacer — pushes bottom bar to the actual bottom of the card */}
{/* Bottom bar: info (left) | buttons (right) */}
{/* Left: status hint + tech info */}
{portConnected && !flashing && !done && log.length === 0 && (
Ready to flash.
)}
{flashing && (
Flashing — do not disconnect…
)}
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
{/* Right: action buttons */}
{!busy && (
{!portConnected && (
)}
{portConnected && done && (
)}
{done && (
)}
{portConnected && !done && (
)}
)}
);
const FlashOutputPanel = (
Flash Output
{log.length === 0 ? (
{flashing ? "Connecting…" : "Output will appear here once flashing starts."}
) : (
log.map((line, i) =>
{line}
)
)}
);
return (
{/* Row 1: Info | Flash Output */}
{InfoPanel}
{FlashOutputPanel}
{/* Row 2: Serial Output — full width, resizable by drag.
↓ EDIT THIS VALUE to adjust the serial monitor height ↓ */}
Serial Output
{serialActiveRef.current && (
Live
)}
{serial.length === 0 ? (
{done ? "Waiting for device boot…" : "Available after flash completes."}
) : (
serial.map((line, i) =>
{line}
)
)}
);
}
// ─── Step 3 — Verify ──────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) {
const [polling, setPolling] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const [verified, setVerified] = useState(false);
const [heartbeatData, setHeartbeatData] = useState(null);
const [error, setError] = useState("");
const intervalRef = useRef(null);
const timeoutRef = useRef(null);
// Auto-start polling on mount
const startPolling = useCallback(() => {
if (polling) return;
setPolling(true);
setTimedOut(false);
setError("");
const startTime = Date.now();
intervalRef.current = setInterval(async () => {
try {
const hbData = await api.get(
`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`
);
if (hbData.heartbeats?.length > 0) {
const latest = hbData.heartbeats[0];
const receivedMs = latest.received_at
? Date.parse(latest.received_at.replace(" ", "T") + "Z")
: NaN;
if (!isNaN(receivedMs) && receivedMs > startTime) {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
try {
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "provisioned", note: "Auto-verified via wizard" }),
});
} catch (_) {}
const deviceData = await api.get(`/manufacturing/devices/${device.serial_number}`);
setHeartbeatData(latest);
setPolling(false);
setVerified(true);
onVerified({ ...deviceData, mfg_status: "provisioned" });
return;
}
}
} catch (err) {
setError(err.message);
}
}, VERIFY_POLL_MS);
timeoutRef.current = setTimeout(() => {
clearInterval(intervalRef.current);
setPolling(false);
setTimedOut(true);
}, VERIFY_TIMEOUT_MS);
}, [polling, device.serial_number, onVerified]);
// Auto-start on mount
useEffect(() => {
startPolling();
return () => {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stopPolling = () => {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
setPolling(false);
};
return (
{/* Fixed-height card so layout doesn't jump when data arrives */}
Waiting for Device
{/* Loading state */}
{polling && !verified && (
Waiting for device to connect…
Power cycle the device and ensure it can reach the MQTT broker.
)}
{/* Verified state */}
{verified && heartbeatData && (
)}
{/* Timed out state */}
{timedOut && !verified && (
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
)}
{error && !timedOut && !verified && (
)}
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
);
}
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext }) {
const navigate = useNavigate();
return (
Device Provisioned
{device.serial_number} is live.
{/* Row 1: Serial Number (left) | Status (right) */}
Serial Number
{device.serial_number}
{/* Row 2: Board Type (left) | HW Version (right) */}
Board Type
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}
HW Version
{formatHwVersion(device.hw_version)}
);
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
// Steps:
// 0 = Mode picker ("Flash Existing" vs "Deploy New")
// 1 = Select device (varies by mode)
// 2 = Flash
// 3 = Verify
// 4 = Done
export default function ProvisioningWizard() {
const [step, setStep] = useState(0);
const [mode, setMode] = useState(null); // "existing" | "new"
const [device, setDevice] = useState(null);
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
const handleModePicked = (m) => {
setMode(m);
setStep(1);
};
const handleDeviceSelected = (dev) => {
setDevice(dev);
setStep(2);
};
const handleFlashed = () => setStep(3);
const handleVerified = (updatedDevice) => {
setDevice(updatedDevice);
setStep(4);
};
const handleBack = async () => {
if (step === 1) {
// Going back to mode picker — if Deploy New created a serial, clean it up
if (mode === "new" && createdSn) {
await cleanupCreatedSn(createdSn);
setCreatedSn(null);
}
setStep(0);
setMode(null);
setDevice(null);
} else if (step === 2) {
setStep(1);
setDevice(null);
} else if (step === 3) {
setStep(2);
}
};
const handleAbort = async () => {
if (mode === "new" && createdSn) {
await cleanupCreatedSn(createdSn);
setCreatedSn(null);
}
setStep(0);
setMode(null);
setDevice(null);
};
const handleProvisionNext = () => {
setDevice(null);
setCreatedSn(null);
setStep(0);
setMode(null);
};
// Determine Back/Abort visibility
const showBack = step >= 1 && step <= 3;
const showAbort = step >= 1 && step <= 3;
// Flash step takes full available width; all others are centered at 720px
const isFlashStep = step === 2;
return (
{/* ── Sticky top bar ── */}
{/* Title + controls row */}
{/* Left: Title */}
Provisioning Wizard
{/* Center: StepIndicator — always visible, current=step+1 to account for Begin as step 1 */}
{/* Right: Back + Abort grouped together */}
{showBack && (
)}
{showAbort && (
)}
{/* ── Step content ── */}
{step === 0 && }
{step === 1 && mode === "existing" && (
)}
{step === 1 && mode === "new" && (
setCreatedSn(sn)}
/>
)}
{step === 2 && device && (
)}
{step === 3 && device && (
)}
{step === 4 && device && (
)}
);
}
// ─── Cleanup helper ───────────────────────────────────────────────────────────
// Deletes a newly-created (un-flashed) serial number when user aborts Deploy New
async function cleanupCreatedSn(sn) {
try {
await api.request(`/manufacturing/devices/${sn}`, { method: "DELETE" });
} catch (_) {
// Best-effort — if the endpoint doesn't exist yet, silently ignore
}
}