1514 lines
59 KiB
JavaScript
1514 lines
59 KiB
JavaScript
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 (
|
|
<span
|
|
className="px-2.5 py-1 text-sm rounded-full capitalize font-medium"
|
|
style={{ backgroundColor: style.bg, color: style.color }}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ProgressBar({ label, percent }) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
|
|
<span>{label}</span>
|
|
<span>{Math.round(percent)}%</span>
|
|
</div>
|
|
<div className="h-2 rounded-full overflow-hidden" style={{ backgroundColor: "var(--bg-card-hover)" }}>
|
|
<div
|
|
className="h-full rounded-full transition-all duration-200"
|
|
style={{ width: `${percent}%`, backgroundColor: "var(--accent)" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorBox({ msg }) {
|
|
if (!msg) return null;
|
|
return (
|
|
<div
|
|
className="text-sm rounded-md p-3 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
|
>
|
|
{msg}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M4 2v20h2V13h14V3H4zm2 2h3v3H6V4zm0 5h3v3H6V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function StepIndicator({ current }) {
|
|
// current: 1..4 (step numbers matching STEP_LABELS index + 1)
|
|
return (
|
|
<div className="flex items-center" style={{ minWidth: 0 }}>
|
|
{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 (
|
|
<div key={label} className="flex items-center" style={{ minWidth: 0 }}>
|
|
{/* Node */}
|
|
<div className="flex flex-col items-center" style={{ flexShrink: 0 }}>
|
|
<div
|
|
style={{
|
|
width: dotSize, height: dotSize,
|
|
borderRadius: "60%",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
backgroundColor: dotBg,
|
|
color: dotColor,
|
|
fontSize: active ? "1.0rem" : "0.85rem",
|
|
fontWeight: 500,
|
|
border: active ? "2px solid #22c55e" : done ? "2px solid #6d9b78c0" : "2px solid #333",
|
|
boxShadow: active ? "0 0 18px 8px rgba(34,197,94,0.4)" : "none",
|
|
transition: "all 0.2s",
|
|
}}
|
|
>
|
|
{done ? (
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : isLast ? (
|
|
<CheckeredFlagIcon />
|
|
) : idx}
|
|
</div>
|
|
<span
|
|
className="text-xs mt-1 whitespace-nowrap font-medium"
|
|
style={{
|
|
color: labelColor,
|
|
textShadow: labelGlow,
|
|
opacity: pending ? 0.5 : 1,
|
|
maxWidth: 72,
|
|
textAlign: "center",
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Connector line */}
|
|
{i < STEP_LABELS.length - 1 && (
|
|
<div
|
|
style={{
|
|
height: 2,
|
|
flex: "1 1 16px",
|
|
minWidth: 28,
|
|
maxWidth: 40,
|
|
backgroundColor: lineColor,
|
|
marginBottom: 18,
|
|
marginLeft: 4,
|
|
marginRight: 4,
|
|
transition: "background-color 0.2s",
|
|
flexShrink: 1,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Step 0 — Mode picker ─────────────────────────────────────────────────────
|
|
|
|
function StepModePicker({ onPick }) {
|
|
return (
|
|
<div>
|
|
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-heading)" }}>
|
|
What would you like to do?
|
|
</h2>
|
|
<p className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
|
|
Choose how to start the provisioning process.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Flash Existing */}
|
|
<button
|
|
onClick={() => onPick("existing")}
|
|
className="rounded-xl border p-6 text-left cursor-pointer"
|
|
style={{
|
|
backgroundColor: "var(--bg-card)",
|
|
borderColor: "var(--border-primary)",
|
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--accent)";
|
|
e.currentTarget.style.boxShadow = "0 0 14px 4px rgba(34,197,94,0.25)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--border-primary)";
|
|
e.currentTarget.style.boxShadow = "none";
|
|
}}
|
|
>
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
|
style={{ backgroundColor: "var(--bg-card-hover)" }}
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<p className="font-semibold text-sm mb-1" style={{ color: "var(--text-heading)" }}>Flash Existing</p>
|
|
<p className="text-xs leading-relaxed" style={{ color: "var(--text-muted)" }}>
|
|
Re-flash a device already in inventory — manufactured, flashed, or provisioned.
|
|
</p>
|
|
</button>
|
|
|
|
{/* Deploy New — same style as Flash Existing */}
|
|
<button
|
|
onClick={() => onPick("new")}
|
|
className="rounded-xl border p-6 text-left cursor-pointer"
|
|
style={{
|
|
backgroundColor: "var(--bg-card)",
|
|
borderColor: "var(--border-primary)",
|
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--accent)";
|
|
e.currentTarget.style.boxShadow = "0 0 14px 4px rgba(34,197,94,0.25)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--border-primary)";
|
|
e.currentTarget.style.boxShadow = "none";
|
|
}}
|
|
>
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
|
style={{ backgroundColor: "var(--bg-card-hover)" }}
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</div>
|
|
<p className="font-semibold text-sm mb-1" style={{ color: "var(--text-heading)" }}>Deploy New</p>
|
|
<p className="text-xs leading-relaxed" style={{ color: "var(--text-muted)" }}>
|
|
Generate a new serial number, select board type and revision, then flash and provision.
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div
|
|
className="rounded-lg border p-5"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
>
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
|
|
Device Selected
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
<InfoCell label="Serial Number" value={selected.serial_number} mono />
|
|
<InfoCell label="Board Type" value={BOARD_TYPE_LABELS[selected.hw_type] || selected.hw_type} />
|
|
<InfoCell label="HW Version" value={formatHwVersion(selected.hw_version)} />
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
|
|
<StatusBadge status={selected.mfg_status} />
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => onSelected(selected)}
|
|
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
Continue →
|
|
</button>
|
|
<button
|
|
onClick={() => setSelected(null)}
|
|
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
>
|
|
Change Device
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div
|
|
className="rounded-lg border p-5"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
>
|
|
<h3 className="text-sm font-semibold mb-3" style={{ color: "var(--text-heading)" }}>
|
|
Select a Device to Flash
|
|
</h3>
|
|
<form onSubmit={handleSearchSubmit} className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Search serial, batch, type…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="flex-1 px-3 py-2 rounded-md text-sm border"
|
|
style={inputStyle()}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
{loading ? "…" : "Search"}
|
|
</button>
|
|
</form>
|
|
<ErrorBox msg={error} />
|
|
|
|
{results.length === 0 && !loading && (
|
|
<p className="text-sm text-center py-4" style={{ color: "var(--text-muted)" }}>
|
|
No flashable devices found.
|
|
</p>
|
|
)}
|
|
|
|
{results.length > 0 && (
|
|
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Serial</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Ver</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{results.map((d) => (
|
|
<tr
|
|
key={d.id}
|
|
onClick={() => 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 = "")}
|
|
>
|
|
<td className="px-3 py-2 font-mono text-xs" style={{ color: "var(--text-primary)" }}>{d.serial_number}</td>
|
|
<td className="px-3 py-2 text-xs" style={{ color: "var(--text-secondary)" }}>
|
|
{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}
|
|
</td>
|
|
<td className="px-3 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
|
{formatHwVersion(d.hw_version)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<StatusBadge status={d.mfg_status} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<button
|
|
onClick={onClick}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
className="rounded-lg border p-3 text-left transition-all cursor-pointer"
|
|
style={{
|
|
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card)",
|
|
borderColor,
|
|
boxShadow,
|
|
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
|
|
}}
|
|
>
|
|
<p
|
|
className="font-bold text-xs tracking-wide"
|
|
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}
|
|
>
|
|
{bt.name}
|
|
</p>
|
|
<p className="text-xs mt-0.5 font-mono opacity-60" style={{ color: "var(--text-muted)" }}>
|
|
{bt.codename}
|
|
</p>
|
|
<p className="text-xs mt-1 leading-snug" style={{ color: "var(--text-muted)", opacity: 0.75 }}>
|
|
{bt.desc}
|
|
</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── 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) => (
|
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 10, justifyItems: "stretch" }}>
|
|
{boards.map((bt) => (
|
|
<BoardTypeTile
|
|
key={bt.value}
|
|
bt={bt}
|
|
isSelected={boardType === bt.value}
|
|
pal={BOARD_FAMILY_COLORS[bt.family]}
|
|
onClick={() => setBoardType(bt.value)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Board type tiles — Vesper: 3 col, Agnus: 2 col centered, Chronos: 2 col centered */}
|
|
<div>
|
|
<p className="text-sm font-medium mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
|
|
<div className="space-y-2">
|
|
{renderTileRow(vesperBoards, 3)}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
|
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
|
{agnusBoards.map((bt) => (
|
|
<BoardTypeTile
|
|
key={bt.value}
|
|
bt={bt}
|
|
isSelected={boardType === bt.value}
|
|
pal={BOARD_FAMILY_COLORS[bt.family]}
|
|
onClick={() => setBoardType(bt.value)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
|
|
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
|
{chronosBoards.map((bt) => (
|
|
<BoardTypeTile
|
|
key={bt.value}
|
|
bt={bt}
|
|
isSelected={boardType === bt.value}
|
|
pal={BOARD_FAMILY_COLORS[bt.family]}
|
|
onClick={() => setBoardType(bt.value)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Board revision (left) + Generate button (right) */}
|
|
<div className="flex items-end justify-between gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
|
Board Revision
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium" style={{ color: "var(--text-muted)" }}>Rev</span>
|
|
<input
|
|
type="text"
|
|
value={boardVersion}
|
|
onChange={(e) => setBoardVersion(e.target.value)}
|
|
placeholder="1.0"
|
|
className="px-3 py-2 rounded-md text-sm border w-32"
|
|
style={inputStyle()}
|
|
/>
|
|
</div>
|
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
|
Semantic versioning: 1.0, 1.1, 2.0…
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2">
|
|
<ErrorBox msg={error} />
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={!boardType || creating}
|
|
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
{creating ? "Creating…" : "Generate Serial & Continue →"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Shared info cell ─────────────────────────────────────────────────────────
|
|
|
|
function InfoCell({ label, value, mono = false }) {
|
|
return (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>{label}</p>
|
|
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>{value || "—"}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 = (
|
|
<div
|
|
className="rounded-lg border p-5 flex flex-col"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
>
|
|
{/* Header: title + COM status */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
|
Device to Flash
|
|
</h3>
|
|
<button
|
|
onClick={portConnected ? disconnectPort : undefined}
|
|
title={portConnected ? `${portName} — Click to disconnect` : "No port connected"}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-all ${portConnected ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
|
style={{
|
|
backgroundColor: portConnected ? "#0a2e1a" : "var(--bg-card-hover)",
|
|
color: portConnected ? "#4dd6c8" : "var(--text-muted)",
|
|
border: `1px solid ${portConnected ? "#4dd6c8" : "var(--border-primary)"}`,
|
|
}}
|
|
>
|
|
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: portConnected ? "#22c55e" : "#444" }} />
|
|
{portConnected ? portName || "Connected" : "No Port"}
|
|
{portConnected && <span className="ml-1 opacity-60">✕</span>}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
|
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
|
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
|
|
</div>
|
|
|
|
{!webSerialAvailable && (
|
|
<div className="text-sm rounded-md p-3 mb-3 border"
|
|
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}>
|
|
Web Serial API not available. Use Chrome or Edge on a desktop system.
|
|
</div>
|
|
)}
|
|
|
|
<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} />
|
|
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Spacer — pushes bottom bar to the actual bottom of the card */}
|
|
<div style={{ flex: 1 }} />
|
|
|
|
{/* Bottom bar: info (left) | buttons (right) */}
|
|
<div
|
|
className="flex items-end justify-between gap-3 pt-3 mt-2"
|
|
style={{ borderTop: "1px solid var(--border-secondary)" }}
|
|
>
|
|
{/* Left: status hint + tech info */}
|
|
<div>
|
|
{portConnected && !flashing && !done && log.length === 0 && (
|
|
<div className="flex items-center gap-2 text-sm mb-1.5" style={{ color: "#4dd6c8" }}>
|
|
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: "#22c55e" }} />
|
|
Ready to flash.
|
|
</div>
|
|
)}
|
|
{flashing && (
|
|
<p className="text-sm mb-1.5" style={{ color: "var(--text-muted)" }}>Flashing — do not disconnect…</p>
|
|
)}
|
|
<p className="text-xs" style={{ color: "var(--text-muted)", opacity: 0.6 }}>
|
|
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
|
|
</p>
|
|
</div>
|
|
|
|
{/* Right: action buttons */}
|
|
{!busy && (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{!portConnected && (
|
|
<button
|
|
onClick={handleConnectPort}
|
|
disabled={!webSerialAvailable}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
Select COM Port
|
|
</button>
|
|
)}
|
|
{portConnected && done && (
|
|
<button
|
|
onClick={handleStartFlash}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
>
|
|
Flash Again
|
|
</button>
|
|
)}
|
|
{done && (
|
|
<button
|
|
onClick={onFlashed}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
|
|
>
|
|
Proceed to Verify →
|
|
</button>
|
|
)}
|
|
{portConnected && !done && (
|
|
<button
|
|
onClick={handleStartFlash}
|
|
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
Start Flashing
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const FlashOutputPanel = (
|
|
<div className="rounded-lg border overflow-hidden flex flex-col" style={{ borderColor: "var(--border-primary)", height: 320 }}>
|
|
<div
|
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
|
>
|
|
Flash Output
|
|
</div>
|
|
<div
|
|
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
|
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)", flex: 1, minHeight: 0 }}
|
|
>
|
|
{log.length === 0 ? (
|
|
<span style={{ color: "var(--text-muted)", opacity: 0.5 }}>
|
|
{flashing ? "Connecting…" : "Output will appear here once flashing starts."}
|
|
</span>
|
|
) : (
|
|
log.map((line, i) => <div key={i}>{line}</div>)
|
|
)}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
{/* Row 1: Info | Flash Output */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, alignItems: "stretch" }}>
|
|
{InfoPanel}
|
|
{FlashOutputPanel}
|
|
</div>
|
|
|
|
{/* Row 2: Serial Output — full width, resizable by drag.
|
|
↓ EDIT THIS VALUE to adjust the serial monitor height ↓ */}
|
|
<div
|
|
className="rounded-lg border"
|
|
style={{
|
|
borderColor: "var(--border-primary)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 180,
|
|
height: 320, /* ← change this number to adjust height */
|
|
resize: "vertical",
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
<div
|
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b flex items-center justify-between"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)", flexShrink: 0 }}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span>Serial Output</span>
|
|
{serialActiveRef.current && (
|
|
<span className="flex items-center gap-1.5" style={{ color: "#4dd6c8" }}>
|
|
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "#4dd6c8" }} />
|
|
Live
|
|
</span>
|
|
)}
|
|
</div>
|
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Auto-scroll</span>
|
|
<span
|
|
onClick={() => handleSetSerialAutoScroll(!serialAutoScrollRef.current)}
|
|
className="relative inline-flex items-center"
|
|
style={{
|
|
width: 32, height: 18,
|
|
backgroundColor: serialAutoScroll ? "var(--accent)" : "var(--bg-card-hover)",
|
|
borderRadius: 9,
|
|
border: "1px solid var(--border-primary)",
|
|
cursor: "pointer",
|
|
transition: "background-color 0.2s",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
position: "absolute",
|
|
left: serialAutoScroll ? 14 : 2,
|
|
width: 14, height: 14,
|
|
borderRadius: "50%",
|
|
backgroundColor: "#fff",
|
|
transition: "left 0.15s",
|
|
}}
|
|
/>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<div
|
|
className="p-3 font-mono text-xs space-y-0.5"
|
|
style={{ backgroundColor: "var(--bg-primary)", color: "#a3e635", flex: 1, overflowY: "auto", minHeight: 0 }}
|
|
>
|
|
{serial.length === 0 ? (
|
|
<span style={{ color: "var(--text-muted)" }}>
|
|
{done ? "Waiting for device boot…" : "Available after flash completes."}
|
|
</span>
|
|
) : (
|
|
serial.map((line, i) => <div key={i}>{line}</div>)
|
|
)}
|
|
<div ref={serialEndRef} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div className="space-y-4">
|
|
{/* Fixed-height card so layout doesn't jump when data arrives */}
|
|
<div
|
|
className="rounded-lg border p-5"
|
|
style={{
|
|
backgroundColor: "var(--bg-card)",
|
|
borderColor: "var(--border-primary)",
|
|
minHeight: 280,
|
|
}}
|
|
>
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
|
|
Waiting for Device
|
|
</h3>
|
|
|
|
{/* Loading state */}
|
|
{polling && !verified && (
|
|
<div className="flex flex-col items-center py-6 gap-4">
|
|
<svg className="w-12 h-12 animate-spin" style={{ color: "var(--accent)" }} fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<p className="text-sm text-center" style={{ color: "var(--text-secondary)" }}>
|
|
Waiting for device to connect…
|
|
<br />
|
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
Power cycle the device and ensure it can reach the MQTT broker.
|
|
</span>
|
|
</p>
|
|
<button
|
|
onClick={stopPolling}
|
|
className="px-4 py-1.5 text-xs rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
>
|
|
Stop
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Verified state */}
|
|
{verified && heartbeatData && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: "#0a2e2a" }}>
|
|
<svg className="w-4 h-4" style={{ color: "#4dd6c8" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-sm font-semibold" style={{ color: "#4dd6c8" }}>Device is live!</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 rounded-md p-3" style={{ backgroundColor: "var(--bg-primary)" }}>
|
|
<InfoCell label="Firmware" value={heartbeatData.firmware_version || "—"} />
|
|
<InfoCell label="IP Address" value={heartbeatData.ip_address || "—"} />
|
|
<InfoCell label="Uptime" value={heartbeatData.uptime_display || "—"} />
|
|
<InfoCell label="Gateway" value={heartbeatData.gateway || "—"} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timed out state */}
|
|
{timedOut && !verified && (
|
|
<div className="py-4 space-y-4">
|
|
<div
|
|
className="text-sm rounded-md p-3 border text-center"
|
|
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}
|
|
>
|
|
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={startPolling}
|
|
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
Retry Verification
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && !timedOut && !verified && (
|
|
<div className="mt-3">
|
|
<ErrorBox msg={`Poll error (will retry): ${error}`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
|
|
|
|
function StepDone({ device, onProvisionNext }) {
|
|
const navigate = useNavigate();
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div
|
|
className="rounded-lg border p-6"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
>
|
|
<div className="flex flex-col items-center py-4 gap-4">
|
|
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ backgroundColor: "#0a2e2a" }}>
|
|
<svg className="w-8 h-8" style={{ color: "#4dd6c8" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<div className="text-center">
|
|
<h3 className="text-lg font-bold" style={{ color: "var(--text-heading)" }}>Device Provisioned</h3>
|
|
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
|
{device.serial_number} is live.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md mb-5" style={{ backgroundColor: "var(--bg-primary)" }}>
|
|
{/* Row 1: Serial Number (left) | Status (right) */}
|
|
<div className="grid grid-cols-2" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
|
<div className="p-4" style={{ borderRight: "1px solid var(--border-secondary)" }}>
|
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Serial Number</p>
|
|
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
|
|
</div>
|
|
<div className="p-4 flex flex-col items-end">
|
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Status</p>
|
|
<StatusBadge status={device.mfg_status} />
|
|
</div>
|
|
</div>
|
|
{/* Row 2: Board Type (left) | HW Version (right) */}
|
|
<div className="grid grid-cols-2">
|
|
<div className="p-4" style={{ borderRight: "1px solid var(--border-secondary)" }}>
|
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Board Type</p>
|
|
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</p>
|
|
</div>
|
|
<div className="p-4 flex flex-col items-end">
|
|
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>HW Version</p>
|
|
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{formatHwVersion(device.hw_version)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={onProvisionNext}
|
|
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
Provision Next Device
|
|
</button>
|
|
<button
|
|
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
|
|
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
>
|
|
View in Inventory
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>
|
|
{/* ── Sticky top bar ── */}
|
|
<div
|
|
style={{
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 50,
|
|
backgroundColor: "var(--bg-primary)",
|
|
borderBottom: "1px solid var(--border-primary)",
|
|
padding: "12px 24px",
|
|
}}
|
|
>
|
|
{/* Title + controls row */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
{/* Left: Title */}
|
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)", minWidth: 0, flexShrink: 0 }}>
|
|
Provisioning Wizard
|
|
</h1>
|
|
|
|
{/* Center: StepIndicator — always visible, current=step+1 to account for Begin as step 1 */}
|
|
<div style={{ flex: 1, display: "flex", justifyContent: "center", paddingBottom: 8 }}>
|
|
<StepIndicator current={step + 1} />
|
|
</div>
|
|
|
|
{/* Right: Back + Abort grouped together */}
|
|
<div className="flex items-center gap-2" style={{ flexShrink: 0 }}>
|
|
{showBack && (
|
|
<button
|
|
onClick={handleBack}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
|
>
|
|
← Back
|
|
</button>
|
|
)}
|
|
{showAbort && (
|
|
<button
|
|
onClick={handleAbort}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
|
|
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
|
|
>
|
|
Abort ✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Step content ── */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
padding: isFlashStep ? "24px" : "32px 24px",
|
|
...(isFlashStep
|
|
? {}
|
|
: { display: "flex", justifyContent: "center" }),
|
|
}}
|
|
>
|
|
<div style={isFlashStep ? {} : { width: "100%", maxWidth: 720 }}>
|
|
{step === 0 && <StepModePicker onPick={handleModePicked} />}
|
|
|
|
{step === 1 && mode === "existing" && (
|
|
<StepSelectExisting onSelected={handleDeviceSelected} />
|
|
)}
|
|
{step === 1 && mode === "new" && (
|
|
<StepDeployNew
|
|
onSelected={handleDeviceSelected}
|
|
onCreatedSn={(sn) => setCreatedSn(sn)}
|
|
/>
|
|
)}
|
|
|
|
{step === 2 && device && (
|
|
<StepFlash device={device} onFlashed={handleFlashed} />
|
|
)}
|
|
{step === 3 && device && (
|
|
<StepVerify device={device} onVerified={handleVerified} />
|
|
)}
|
|
{step === 4 && device && (
|
|
<StepDone device={device} onProvisionNext={handleProvisionNext} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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
|
|
}
|
|
}
|