77606da2c9
Brought up the real UHPPOTE controller (serial 225088491, fw 09120) end to end and recorded a procurement-level blocker. Verified on hardware: - discovery (LAN scan), host-commanded openDoor on doors 1 & 2 (physically actuated; reason="remote open door"), and live button capture (reason="push button ok"). Driver/networking fixes (packages/devices/src/drivers/access-uhppote.ts): - broadcast to subnet-directed address (lib doesn't enable SO_BROADCAST for the global 255.255.255.255 -> EACCES); - Config broadcast must match the target's subnet for unicast reply routing (fixes the health-check timeout: 5s -> 24ms ready); - discover across all local subnets, dedupe by serial; - serialize all controller I/O (concurrent calls collided on UDP :60001). Server/UX: - load .env via node --env-file-if-exists (vars weren't being read before); - SETUP_AUTH_BYPASS hardened: env-gated, dev + loopback only, fails closed otherwise; surfaced as catalog.authBypass so the wizard drops the token field; - .env.example documents all vars; inline favicon stops a 404. - apps/server/scripts/: uhppote-listen (live events, restores prior listener) and uhppote-relay (guarded door-open test). BLOCKER (wiki/decisions/access-controller-button-flow.md): the controller's push-button input auto-opens the relay in firmware with no report-without-open mode, so ticket-first entry (button -> print -> open, fail-closed) is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a programmable aux input + PULL SDK but that's unverified and needs a new driver. Entry-lane hardware decision paused to focus on the business side. wiki: access-controller-button-flow (blocker), zkteco-controller (stub + assessment), uhppote-controller callout, index + log.
205 lines
6.9 KiB
TypeScript
205 lines
6.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import {
|
||
discoverDevices,
|
||
fetchCatalog,
|
||
type Catalog,
|
||
type CatalogEntry,
|
||
type DeviceCategory,
|
||
type DiscoveredDevice,
|
||
} from "./api.js";
|
||
|
||
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
||
// lane from the driver catalog and fills in its connection config. Drivers that
|
||
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
|
||
// devices; selecting one auto-fills the config. See wiki/concepts/first-run-setup.md
|
||
// and device-discovery.md.
|
||
//
|
||
// NOTE: discovery + assign require an admin token. Wiring the real login flow is
|
||
// a follow-up; for now a token is read from a field so the scan can be exercised.
|
||
|
||
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
|
||
{ key: "access", title: "Access controller" },
|
||
{ key: "reader", title: "Reader" },
|
||
{ key: "camera", title: "Camera (entry/exit snapshot)" },
|
||
{ key: "printer", title: "Printer" },
|
||
];
|
||
|
||
export function SetupWizard() {
|
||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||
const [lane, setLane] = useState(1);
|
||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||
const [token, setToken] = useState("");
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
|
||
}, []);
|
||
|
||
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
|
||
if (!catalog) return <p>Loading device catalog…</p>;
|
||
|
||
return (
|
||
<section>
|
||
<h2>First-run setup</h2>
|
||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||
<label>
|
||
Lane{" "}
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={lane}
|
||
onChange={(e) => setLane(Number(e.target.value))}
|
||
style={{ width: "4rem" }}
|
||
/>
|
||
</label>
|
||
{catalog.authBypass ? (
|
||
<span style={{ flex: 1, color: "#92400e" }}>
|
||
⚠️ auth bypass on (testing) — no token needed
|
||
</span>
|
||
) : (
|
||
<label style={{ flex: 1 }}>
|
||
Admin token{" "}
|
||
<input
|
||
type="password"
|
||
value={token}
|
||
onChange={(e) => setToken(e.target.value)}
|
||
placeholder="needed to scan / assign"
|
||
style={{ width: "60%" }}
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
{CATEGORIES.map(({ key, title }) => (
|
||
<CategoryPicker
|
||
key={key}
|
||
title={title}
|
||
entries={catalog[key]}
|
||
discoverableIds={catalog.discoverable}
|
||
token={token}
|
||
authBypass={catalog.authBypass}
|
||
selectedId={picked[key]}
|
||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||
/>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CategoryPicker({
|
||
title,
|
||
entries,
|
||
discoverableIds,
|
||
token,
|
||
authBypass,
|
||
selectedId,
|
||
onSelect,
|
||
}: {
|
||
title: string;
|
||
entries: CatalogEntry[];
|
||
discoverableIds: string[];
|
||
token: string;
|
||
authBypass: boolean;
|
||
selectedId: string | undefined;
|
||
onSelect: (id: string) => void;
|
||
}) {
|
||
const selected = entries.find((e) => e.id === selectedId);
|
||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||
|
||
// Config values (auto-filled by discovery, editable by hand).
|
||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||
const [scanning, setScanning] = useState(false);
|
||
const [scanError, setScanError] = useState<string | null>(null);
|
||
|
||
async function scan() {
|
||
if (!selected) return;
|
||
setScanning(true);
|
||
setScanError(null);
|
||
try {
|
||
setFound(await discoverDevices(token, selected.id));
|
||
} catch (e) {
|
||
setScanError((e as Error).message);
|
||
} finally {
|
||
setScanning(false);
|
||
}
|
||
}
|
||
|
||
function applyDiscovered(d: DiscoveredDevice) {
|
||
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) }));
|
||
}
|
||
|
||
return (
|
||
<fieldset style={{ marginTop: "1rem" }}>
|
||
<legend>{title}</legend>
|
||
{entries.length === 0 ? (
|
||
<em>No drivers registered.</em>
|
||
) : (
|
||
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
|
||
<option value="" disabled>
|
||
Choose a device…
|
||
</option>
|
||
{entries.map((e) => (
|
||
<option key={e.id} value={e.id}>
|
||
{e.label} ({e.transports.join(", ")})
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
|
||
{selected && (
|
||
<div style={{ marginTop: "0.5rem" }}>
|
||
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
||
|
||
{canDiscover && (
|
||
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
||
<button type="button" onClick={scan} disabled={scanning || (!authBypass && !token)}>
|
||
{scanning ? "Scanning…" : "Scan for controllers"}
|
||
</button>
|
||
{!authBypass && !token && (
|
||
<span style={{ marginLeft: 8, color: "#92400e" }}>enter an admin token to scan</span>
|
||
)}
|
||
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
||
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
||
{found && found.length > 0 && (
|
||
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}>
|
||
{found.map((d) => (
|
||
<li key={d.id} style={{ margin: "0.25rem 0" }}>
|
||
<button type="button" onClick={() => applyDiscovered(d)}>
|
||
Use
|
||
</button>{" "}
|
||
<strong>{d.label}</strong>{" "}
|
||
<HealthBadge status={d.health.status} />
|
||
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{selected.configFields.map((f) => (
|
||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||
<label>
|
||
{f.label}
|
||
{f.required ? " *" : ""}{" "}
|
||
<input
|
||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||
placeholder={f.help}
|
||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
function HealthBadge({ status }: { status: string }) {
|
||
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
||
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
||
}
|