Files
parking_solution/apps/web/src/SetupWizard.tsx
T
julian cd3b534e51
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
feat(setup): USB printer discovery — pick a real /dev/usb device
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:

- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
  compose bind-mount) and enriches each with the printer's self-reported
  make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
  ("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
  first real device; a saved-but-unplugged path stays selectable,
  flagged "saved — not present now"; zero found falls back to free text
  + a check-the-cable hint.
- Transport option label no longer hardcodes lp0.

Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 11:35:25 +02:00

1640 lines
69 KiB
TypeScript

import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "react-i18next";
import {
assignDevice,
editDevice,
discoverDevices,
fetchBackendIps,
fetchCatalog,
fetchSiteConfig,
fetchState,
updatePresenceBypass,
testAnpr,
testDevice,
testPrint,
testRelay,
unassignDevice,
type AnprTestResult,
type PrintTestResult,
type Assignment,
type BackendIpCandidate,
type Catalog,
type CatalogEntry,
type DeviceCategory,
type DeviceConfig,
type Direction,
type DiscoveredDevice,
type InputRole,
type InputSpec,
type RelayEvent,
type RelaySpec,
type TestResult,
fetchUsbPrinters,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
// declares its relays = entry/exit/both + which input terminal the entry button is
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
// Direction is a property of the relay, inherited by bound devices. The data model
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
// Categories carry i18n KEYS (resolved at render via t()), not literal copy.
// `titleKey` is the section heading; `nounKey` resolves to the singular noun used in
// the add/edit buttons, modal titles and confirm prompts.
const CONTROLLER: { key: DeviceCategory; titleKey: string; nounKey: string } = {
key: "access",
titleKey: "setup.catControllers",
nounKey: "setup.nounController",
};
// Categories that BIND to a controller relay (direction inherited from the relay).
const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "reader", titleKey: "setup.catReaders", nounKey: "setup.nounReader" },
{ key: "camera", titleKey: "setup.catCameras", nounKey: "setup.nounCamera" },
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
];
// Translated relay-event label (barrier direction, inherited binding, or alert).
const DIRECTION_KEYS: Record<RelayEvent, string> = {
entry: "setup.dirEntry",
exit: "setup.dirExit",
both: "setup.dirBoth",
radarAlert: "setup.eventRadarAlert",
};
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
button: "setup.roleButton",
presenceLoop: "setup.rolePresenceLoop",
presenceRadar: "setup.rolePresenceRadar",
alertTrigger: "setup.roleAlertTrigger",
};
function choiceOf(i: InputSpec): InputChoice {
if (i.role === "button") return "button";
if (i.role === "alertTrigger") return "alertTrigger";
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
}
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
switch (choice) {
case "button":
return { role: "button" };
case "alertTrigger":
return { role: "alertTrigger" };
case "presenceLoop":
return { role: "presence", kind: "loop" };
case "presenceRadar":
return { role: "presence", kind: "radar" };
}
}
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
* server's `inputsOf()` back-compat fold. */
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
const out: InputSpec[] = [];
for (const r of relays) {
if (typeof r.button === "number") {
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
}
if (typeof r.presenceInput === "number") {
out.push({
input: r.presenceInput,
role: "presence",
relay: r.relay,
kind: r.presenceKind ?? "loop",
activeLow: r.presenceActiveLow,
});
}
}
return out;
}
export function SetupWizard() {
const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [error, setError] = useState<string | null>(null);
const reloadState = useCallback(() => {
return fetchState()
.then((s) => setAssignments(s.assignments))
.catch((e: Error) => setError(e.message));
}, []);
useEffect(() => {
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
reloadState();
}, [reloadState]);
if (error) return <p className="px-4 py-6 text-term-red">{t("setup.failedToLoad", { error })}</p>;
if (!catalog || !assignments) return <p className="px-4 py-6 text-term-muted">{t("setup.loadingCatalog")}</p>;
// Controllers are needed before binding readers/cameras (they pick a controller relay).
const controllers = assignments.filter((a) => a.category === "access");
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p className="hint mb-4 max-w">{t("setup.intro")}</p>
<CategorySection
category={CONTROLLER.key}
title={t(CONTROLLER.titleKey)}
noun={t(CONTROLLER.nounKey)}
entries={catalog[CONTROLLER.key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={controllers}
onChanged={reloadState}
/>
<PresenceGatePanel />
{BOUND.map(({ key, titleKey, nounKey }) => (
<CategorySection
key={key}
category={key}
title={t(titleKey)}
noun={t(nounKey)}
entries={catalog[key]}
discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
controllers={controllers}
assignments={assignments.filter((a) => a.category === key)}
onChanged={reloadState}
/>
))}
</section>
);
}
/** Admin control (in the controller section) to BYPASS a presence signal when its device is
* faulty. The entry button normally needs radar/loop AND camera; a dead device blocks legit
* transient entry. Dropping a signal is signed (config_change) + flags every ticket issued
* while bypassed. Persists until turned off. See wiki/concepts/entry-presence-bypass.md. */
function PresenceGatePanel() {
const { t } = useTranslation();
const [radar, setRadar] = useState<boolean | null>(null);
const [camera, setCamera] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSiteConfig()
.then((c) => {
setRadar(c.bypassPresenceRadar);
setCamera(c.bypassPresenceCamera);
})
.catch((e) => setError((e as Error).message));
}, []);
async function toggle(signal: "radar" | "camera", next: boolean) {
setBusy(true);
setError(null);
try {
const c = await updatePresenceBypass({ [signal]: next });
setRadar(c.bypassPresenceRadar);
setCamera(c.bypassPresenceCamera);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (radar == null || camera == null) return null;
const active = radar || camera;
return (
<div className="mb-6 rounded-term border border-term-border/60 px-4 py-3">
<h3 className="mb-1 text-sm font-semibold text-term-text">{t("setup.presenceGateTitle")}</h3>
<p className="hint mb-3 max-w">{t("setup.presenceGateHint")}</p>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-[0.8125rem]">
<input type="checkbox" checked={radar} disabled={busy} onChange={(e) => toggle("radar", e.target.checked)} />
{t("setup.presenceBypassRadar")}
</label>
<label className="flex items-center gap-2 text-[0.8125rem]">
<input type="checkbox" checked={camera} disabled={busy} onChange={(e) => toggle("camera", e.target.checked)} />
{t("setup.presenceBypassCamera")}
</label>
</div>
{active && <p className="mt-2 text-[0.75rem] text-term-amber">⚠ {t("setup.presenceBypassActive")}</p>}
{error && <p className="mt-2 text-[0.75rem] text-term-red">{error}</p>}
</div>
);
}
function CategorySection({
category,
title,
noun,
entries,
discoverableIds,
pushCapableIds,
controllers,
assignments,
onChanged,
}: {
category: DeviceCategory;
title: string;
noun: string;
entries: CatalogEntry[];
discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
assignments: Assignment[];
onChanged: () => Promise<void> | void;
}) {
const { t } = useTranslation();
// The form is popped out in a Modal. `formFor` selects what it edits:
// - "new" → the add form
// - an Assignment → edit that device in place
// - null → closed.
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
// Binding categories need a controller to point at first. Printers do NOT bind
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
const isBound = category !== "access" && category !== "printer";
const blockedNoController = isBound && controllers.length === 0;
const editing = formFor && formFor !== "new" ? formFor : undefined;
return (
<fieldset className="card mt-4 p-4">
<legend className="px-1 text-h6 font-semibold uppercase tracking-wider text-term-text">{title}</legend>
{warnings.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
<strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<button type="button" className="btn btn-sm mt-2" onClick={() => setWarnings([])}>
{t("setup.dismiss")}
</button>
</div>
)}
{assignments.length > 0 && (
<ul className="mb-3 list-none p-0">
{assignments.map((a) => (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => setFormFor(a)}
/>
))}
</ul>
)}
{blockedNoController ? (
<p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
) : (
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
</button>
)}
{/* Add/edit form — popped out. One modal per category; the device list stays
in the page behind it. */}
<Modal
open={formFor != null}
onClose={() => setFormFor(null)}
title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })}
width="max-w-2xl"
>
{formFor != null && (
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
editing={editing}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setFormFor(null);
}}
onCancel={() => setFormFor(null)}
/>
)}
</Modal>
</fieldset>
);
}
function AssignmentRow({
assignment,
controllers,
onChanged,
onEdit,
}: {
assignment: Assignment;
controllers: Assignment[];
onChanged: () => Promise<void> | void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
const cfg = assignment.config as Record<string, unknown>;
const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() {
if (!confirm(t("setup.confirmRemove", { driver: assignment.driverId }))) return;
setRemoving(true);
setError(null);
try {
await unassignDevice(assignment.id);
await onChanged();
} catch (e) {
setError((e as Error).message);
setRemoving(false);
}
}
return (
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
<strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{assignment.category === "access" && <RelayTester assignment={assignment} />}
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
<span className="flex-1" />
{error && <span className="text-term-red">{error}</span>}
<button type="button" className="btn btn-ghost btn-sm" onClick={onEdit} disabled={removing}>
{t("setup.edit")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={remove} disabled={removing}>
{removing ? t("setup.removing") : t("setup.remove")}
</button>
</li>
);
}
/** Per-relay "Test" control on a SAVED controller row. Pulses a BARRIER relay to prove
* the wiring — this physically opens the barrier, so it confirms first, and the server
* signs the deliberate open into the ledger (reason setup.relayTest). radarAlert relays
* are lamps, not barriers — excluded (pulsing one is meaningless/wrong). */
function RelayTester({ assignment }: { assignment: Assignment }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>;
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
const barriers = relays.filter((r) => r.direction !== "radarAlert");
const [busyRelay, setBusyRelay] = useState<number | null>(null);
const [result, setResult] = useState<{ relay: number; ok: boolean; detail?: string } | null>(null);
if (barriers.length === 0) return null;
async function testRelayNow(relay: number) {
if (!confirm(t("setup.confirmRelayTest", { relay }))) return;
setBusyRelay(relay);
setResult(null);
try {
const res = await testRelay(assignment.id, relay);
setResult({ relay, ok: res.ok, detail: res.ok ? undefined : res.detail ?? res.reason });
} catch (e) {
setResult({ relay, ok: false, detail: (e as Error).message });
} finally {
setBusyRelay(null);
}
}
return (
<span className="flex flex-wrap items-center gap-1">
{barriers.map((r) => (
<button
key={r.relay}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => testRelayNow(r.relay)}
disabled={busyRelay != null}
title={t("setup.testRelayTitle", { relay: r.relay })}
>
{busyRelay === r.relay ? t("setup.relayTesting") : t("setup.testRelay", { relay: r.relay })}
</button>
))}
{result && (
<span className={result.ok ? "text-term-green" : "text-term-red"}>
{result.ok
? t("setup.relayTestOk", { relay: result.relay })
: t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })}
</span>
)}
</span>
);
}
/** Inline summary of an assignment's direction/binding for the list. */
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
const { t } = useTranslation();
const cfg = assignment.config as Record<string, unknown>;
if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
return (
<span className="flex flex-wrap gap-1.5">
{relays.map((r) => {
// Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
let wiring = "";
if (r.direction === "radarAlert") {
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
if (r.lockLane === "exit") wiring += "·lockExit";
} else {
const served = inputs.filter((x) => x.relay === r.relay);
const btn = served.find((x) => x.role === "button");
const pres = served.find((x) => x.role === "presence");
if (btn) wiring += `·btn${btn.input}`;
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
}
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
})}
</span>
);
}
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
// role instead of a bogus "unbound" warning.
if (assignment.category === "printer") {
const role = typeof cfg.role === "string" ? cfg.role : null;
return role ? (
<span className="text-term-muted">
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
</span>
) : null;
}
// Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
if (!controllerId || relay == null) return <em className="text-term-amber">{t("setup.unbound")}</em>;
const controller = controllers.find((c) => c.id === controllerId);
const spec = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
: undefined;
return (
<DirectionBadge
direction={spec?.direction ?? "both"}
label={`${controller ? controller.driverId : "?"} · R${relay}`}
/>
);
}
function DeviceForm({
category,
entries,
discoverableIds,
pushCapableIds,
controllers,
editing,
onSaved,
onCancel,
}: {
category: DeviceCategory;
entries: CatalogEntry[];
discoverableIds: string[];
pushCapableIds: string[];
controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
// On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
const isCamera = category === "camera";
const isPrinter = category === "printer";
// ANPR opt-in for a camera: when true, this camera's snapshots are run through the
// recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
// See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
// Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier
// (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep
// RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose
// back-plate read would otherwise phantom-exit the car that just entered). Defaults ON
// when anpr is on (back-compat). (config.anprAutoTrigger).
const [anprAuto, setAnprAuto] = useState<boolean>(editCfg?.anprAutoTrigger !== false);
// Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
// Booleans are kept as real booleans (a checkbox field) — older saved configs may
// have stored a boolean as the string "true"/"false"; normalize those on load.
const [config, setConfig] = useState<Record<string, string | number | boolean>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
}
return out;
});
// USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
// printer form is on the USB transport, so devicePath becomes a SELECT of real
// devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
useEffect(() => {
if (!usbTransport) return;
let alive = true;
fetchUsbPrinters()
.then((r) => {
if (!alive) return;
setUsbPrinters(r.printers);
// Fresh form with no explicit path yet → preselect the first REAL device.
if (r.printers.length > 0) {
setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
}
})
.catch(() => alive && setUsbPrinters([]));
return () => {
alive = false;
};
}, [usbTransport]);
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
// legacy per-relay button/presence fields so an existing controller opens populated.
const [inputs, setInputs] = useState<InputSpec[]>(() => {
const stored = editCfg?.inputs;
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
});
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null);
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
const [printTesting, setPrintTesting] = useState(false);
const [printError, setPrintError] = useState<string | null>(null);
// Which `secret` fields are currently unmasked. The device web password is an
// operational credential the admin legitimately needs (to reach the device's web
// UI) — it's stored + sent to this admin-only view; a per-field reveal toggle just
// makes the already-present value readable. (Machine secrets — relay/push pw — are
// redacted server-side and never reach here, so there's nothing to reveal.)
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>("");
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
// Comes from the same /api/setup/backend-ips probe as the IPs.
const [backendPort, setBackendPort] = useState<number | null>(null);
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
useEffect(() => {
if (!testedHost || !pushesToBackend) {
setBackendIps(null);
setBackendPort(null);
return;
}
let live = true;
fetchBackendIps(testedHost)
.then(({ candidates, port }) => {
if (!live) return;
setBackendIps(candidates);
setBackendPort(port);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
})
.catch(() => {
if (live) {
setBackendIps(null);
setBackendPort(null);
}
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost, pushesToBackend]);
function selectDriver(id: string) {
setSelectedId(id);
setConfig({});
setFound(null);
resetStatus();
}
async function scan() {
if (!selected) return;
setScanning(true);
setScanError(null);
try {
setFound(await discoverDevices(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>) }));
resetStatus();
}
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
function mergedScalarConfig(): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
for (const f of selected?.configFields ?? []) {
// Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF
// on an edit actually writes false), defaulting to the field default or false.
if (f.type === "boolean") {
const cur = config[f.key];
out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false);
continue;
}
const v = config[f.key] ?? (f.default as string | number | undefined);
if (v !== undefined && v !== "") out[f.key] = v;
}
return out;
}
/** Full config to persist: scalars + the model's direction/binding fields. */
function mergedConfig(): DeviceConfig {
const out: DeviceConfig = { ...mergedScalarConfig() };
if (isController) {
// Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
out.relays = relays.map((r) =>
r.direction === "radarAlert"
? {
// Alert lamp: trigger input + lock lane + blink cadence.
relay: r.relay,
direction: r.direction,
...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
}
: { relay: r.relay, direction: r.direction },
);
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
out.inputs = inputs
.filter((i) => typeof i.input === "number" && i.input > 0)
.map((i) => ({
input: i.input,
role: i.role,
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
}));
} else if (!isPrinter && controllerId && boundRelay !== "") {
// Readers/cameras bind to a controller relay (which barrier a scan opens +
// inherited direction). Printers do NOT — routing is role+failoverRank only,
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
// off on the next edit).
out.controllerId = controllerId;
out.relay = boundRelay;
}
// Camera ANPR opt-in (only persisted when on, to keep configs minimal).
if (isCamera && anpr) out.anpr = true;
// Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a
// park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane)
// while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras).
if (isCamera && anpr) out.anprAutoTrigger = anprAuto;
return out;
}
function resetStatus() {
setTested(null);
setTestError(null);
setSaveError(null);
setAnprResult(null);
setAnprError(null);
}
async function test() {
if (!selected) return;
setTesting(true);
setTestError(null);
setTested(null);
try {
setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) {
setTestError((e as Error).message);
} finally {
setTesting(false);
}
}
// End-to-end ANPR probe: capture a frame off this camera and run the vision service
// on it, reporting plate + time (or the failure stage). Only meaningful for an
// ANPR-enabled camera; never blocks save.
async function testAnprNow() {
if (!selected) return;
setAnprTesting(true);
setAnprError(null);
setAnprResult(null);
try {
setAnprResult(await testAnpr(selected.id, mergedScalarConfig()));
} catch (e) {
setAnprError((e as Error).message);
} finally {
setAnprTesting(false);
}
}
// Push a real test slip to the printer — proves it physically prints (healthCheck
// only opens the transport). Passes editing?.id so an edited network printer's
// stored secrets re-merge. Never blocks save.
async function testPrintNow() {
if (!selected) return;
setPrintTesting(true);
setPrintError(null);
setPrintResult(null);
try {
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) {
setPrintError((e as Error).message);
} finally {
setPrintTesting(false);
}
}
async function save() {
if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the
// model with a fallback, but the wizard guides the admin to bind explicitly).
// Printers are exempt: nothing consumes a printer's binding — their routing is
// role + failoverRank (see printer-routing.ts).
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
setSaveError("Pick the controller and relay this device sits at.");
return;
}
setSaving(true);
setSaveError(null);
try {
const result = editing
? await editDevice(editing.id, {
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
})
: await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<div>
{entries.length === 0 ? (
<em className="text-term-muted">{t("setup.noDrivers")}</em>
) : (
// Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select className="select w-auto min-w-64" value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled>
{t("setup.chooseDevice")}
</option>
{entries.map((e) => (
<option key={e.id} value={e.id}>
{e.label} ({e.transports.join(", ")})
</option>
))}
</select>
)}
{selected && (
<div className="mt-3">
<p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
{canDiscover && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
{scanning ? t("setup.scanning") : t("setup.scan")}
</button>
{scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
{found && found.length > 0 && (
<ul className="mt-2 list-none p-0">
{found.map((d) => (
<li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
{t("setup.use")}
</button>
<strong className="text-term-text">{d.label}</strong>
<HealthBadge status={d.health.status} />
{d.info?.firmware && <span className="text-term-muted"> · fw {d.info.firmware}</span>}
</li>
))}
</ul>
)}
</div>
)}
{selected.configFields
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
// sections below (a relay setting and an input setting, respectively), so
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
// Printer transport is exclusive: when Connection = USB the network fields
// (host/port/status-page) don't apply, and vice-versa the USB device path
// doesn't. Hide the irrelevant side so the form can't mislead (e.g. a USB
// path lingering under a Network printer). Driven by config.transport.
.filter((f) => {
const transport = String(config.transport ?? "tcp-ip");
if (transport === "usb") return !["host", "port", "httpPort"].includes(f.key);
return f.key !== "devicePath";
})
.map((f) =>
f.type === "boolean" ? (
// Boolean config field → a real checkbox (stores a true/false boolean, not
// the string "true"). The label sits beside the box, with the help below.
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
checked={Boolean(config[f.key] ?? f.default ?? false)}
onChange={(e) => {
const v = e.target.checked;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<span>
<span className="font-semibold text-term-text">{f.label}</span>
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
</span>
</label>
) : (
<div key={f.key} className="field my-2 max-w-sm">
<label className="label">
{f.label}
{f.required ? " *" : ""}
</label>
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
// Real devices found → a select (path + self-reported model). A saved
// path that is NOT currently present stays selectable, flagged.
<select
className="select"
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, devicePath: v }));
resetStatus();
}}
>
{(() => {
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
const missing = cur && !usbPrinters.some((u) => u.path === cur);
return [
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
...usbPrinters,
].map((u) => (
<option key={u.path} value={u.path}>
{u.description ? `${u.path} — ${u.description}` : u.path}
</option>
));
})()}
</select>
) : f.type === "select" ? (
<select
className="select"
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
>
{f.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : f.type === "secret" ? (
// Secret field with a reveal toggle: the device web password is shown
// here (admin-only view) so an admin can read/copy it to reach the
// device's own web UI. Masked by default; click the eye to reveal.
<div className="flex gap-1">
<input
className="input flex-1"
type={revealed[f.key] ? "text" : "password"}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
<button
type="button"
className="btn btn-sm"
aria-label={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
title={revealed[f.key] ? t("setup.hideSecret") : t("setup.revealSecret")}
onClick={() => setRevealed((r) => ({ ...r, [f.key]: !r[f.key] }))}
>
{revealed[f.key] ? "🙈" : "👁"}
</button>
</div>
) : (
<input
className="input"
type={f.type === "number" || f.type === "port" ? "number" : "text"}
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
placeholder={f.help}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/>
)}
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
)}
</div>
),
)}
{/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
{isController && (
<OutputEditor
relays={relays}
onChange={setRelays}
pulseMs={config.pulseMs as number | undefined}
onPulseMsChange={(v) => {
setConfig((c) => ({ ...c, pulseMs: v }));
resetStatus();
}}
/>
)}
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
each naming the relay it serves. Separated from the outputs above. */}
{isController && (
<InputEditor
inputs={inputs}
onChange={setInputs}
relays={relays}
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
onInputsIdleHighChange={(v) => {
setConfig((c) => ({ ...c, inputRestingHigh: v }));
resetStatus();
}}
/>
)}
{/* BOUND device: which controller + relay it sits at. Not printers —
nothing consumes a printer binding (role+rank routes print jobs). */}
{!isController && !isPrinter && (
<BindingPicker
controllers={controllers}
controllerId={controllerId}
relay={boundRelay}
onControllerChange={(id) => {
setControllerId(id);
setBoundRelay("");
}}
onRelayChange={setBoundRelay}
/>
)}
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
{isCamera && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
checked={anpr}
onChange={(e) => setAnpr(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.anpr")}</span>
<span className="hint mt-0.5 block">{t("setup.anprHint")}</span>
</span>
</label>
)}
{/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
the exit cam's back-plate read would phantom-exit a car that just entered. */}
{isCamera && anpr && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<input
type="checkbox"
className="mt-0.5"
checked={anprAuto}
onChange={(e) => setAnprAuto(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
</span>
</label>
)}
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
ready to copy, so the operator never has to find the deviceId or memorise the
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
origin — so host/port are the BACKEND address (backendIp on the camera's
subnet + the server's listen port), resolved by the same probe the push-IP
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
a Test connection first. We surface each field separately, matching the
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
{isCamera && Boolean(config.alarmPushEnabled) && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
{!editing?.id ? (
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
) : !backendIp || backendPort == null ? (
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
) : (
(() => {
const path = `/api/devices/hikvision/${editing.id}/event`;
// What the operator pastes into the camera's Alarm Settings form.
const fields: [string, string][] = [
[t("setup.alarmFieldHost"), backendIp],
[t("setup.alarmFieldUrl"), path],
[t("setup.alarmFieldProtocol"), "HTTP"],
[t("setup.alarmFieldPort"), String(backendPort)],
];
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
return (
<>
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{fields.map(([k, v]) => (
<Fragment key={k}>
<span className="text-term-muted">{k}</span>
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
</Fragment>
))}
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
className="btn btn-sm"
onClick={() => {
void navigator.clipboard?.writeText(copyText);
setAlarmUrlCopied(true);
setTimeout(() => setAlarmUrlCopied(false), 2000);
}}
>
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
</button>
</div>
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
</>
);
})()
)}
</div>
)}
{/* Test (no save/no device change) then Save (configures + persists). */}
<div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
{testing ? t("setup.testing") : t("setup.test")}
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={save} disabled={saving}>
{saving ? t("setup.saving") : editing ? t("setup.saveChanges") : t("setup.saveConfigure")}
</button>
{onCancel && (
<button type="button" className="btn btn-ghost btn-sm" onClick={onCancel} disabled={saving}>
{t("setup.cancel")}
</button>
)}
</div>
{testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && (
<div className="mt-2 text-[0.75rem]">
<div className="text-term-text">
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
</div>
{tested.preconditions.ok ? (
<div className="text-term-green">{t("setup.preconditionsOk")}</div>
) : (
tested.preconditions.issues.map((i) => (
<div key={i.key} className="text-term-amber">
⚠ {i.message}
{i.fixable && <span className="text-term-muted"> {t("setup.autoFixedOnSave")}</span>}
</div>
))
)}
</div>
)}
{/* CAMERA + ANPR on: a bottom-of-modal end-to-end probe — capture a frame and
run the vision service on it, reporting the plate read + how long it took. */}
{isCamera && anpr && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testAnprNow} disabled={anprTesting}>
{anprTesting ? t("setup.anprTesting") : t("setup.testAnpr")}
</button>
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
{anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprResult &&
(anprResult.ok ? (
<div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.anprOk", {
plate: anprResult.plate,
confidence: Math.round(anprResult.confidence * 100),
ms: anprResult.tookMs,
})}
{anprResult.lowConfidence && (
<span className="ml-1 text-term-amber">{t("setup.anprLowConfidence")}</span>
)}
</div>
) : (
<div className="mt-2 text-[0.75rem] text-term-amber">
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
{anprResult.tookMs != null && (
<span className="text-term-muted"> ({t("setup.anprTookMs", { ms: anprResult.tookMs })})</span>
)}
</div>
))}
</div>
)}
{/* PRINTER: push a real test slip so the admin can confirm it physically
prints (healthCheck only opens the transport / USB node). */}
{isPrinter && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
</button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printResult &&
(printResult.ok ? (
<div className="mt-2 text-[0.75rem] text-term-green">
{t("setup.printOk", { ms: printResult.tookMs })}
</div>
) : (
<div className="mt-2 text-[0.75rem] text-term-amber">
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && (
<div className="mt-3">
<div className="field max-w-md">
<label className="label">{t("setup.backendPushIp")}</label>
<select className="select" value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<option value="" disabled>
{t("setup.chooseAddress")}
</option>
)}
{backendIps.map((c) => (
<option key={c.ip} value={c.ip}>
{c.ip} ({c.iface}){c.onDeviceSubnet ? ` ${t("setup.onDeviceSubnet")}` : ""}
</option>
))}
</select>
</div>
{!backendIps.some((c) => c.onDeviceSubnet) && (
<span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
)}
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
</div>
)}
{saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div>
)}
</div>
);
}
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
// live in InputEditor below; the two are deliberately separated.
/** Relays = the unified event→action outputs + the pulse-open hold time. */
function OutputEditor({
relays,
onChange,
pulseMs,
onPulseMsChange,
}: {
relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
pulseMs: number | undefined;
onPulseMsChange: (v: number) => void;
}) {
const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function add() {
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
onChange([...relays, { relay: nextRelay, direction: "both" }]);
}
function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i));
}
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
{t("setup.pulseOpenMs")}
<input
type="number"
min={100}
value={pulseMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => onPulseMsChange(Number(e.target.value))}
/>
</label>
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
barriers pulse (their button/presence terminals are in the Inputs section). */}
{relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.relay")}
<input
type="number"
min={1}
value={r.relay}
className="input input-sm w-16"
onChange={(e) => update(i, { relay: Number(e.target.value) })}
/>
</label>
<select
className="select input-sm w-auto"
value={r.direction}
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
>
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
<option key={d} value={d}>
{t(DIRECTION_KEYS[d])}
</option>
))}
</select>
{/* Alert relay: which input fires the blink + the blink cadence. */}
{r.direction === "radarAlert" && (
<>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
{t("setup.triggerInput")}
<input
type="number"
min={1}
value={r.triggerInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
{t("setup.lockLane")}
<select
className="select input-sm w-auto"
value={r.lockLane ?? "entry"}
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
>
<option value="entry">{t("setup.lockLaneEntry")}</option>
<option value="exit">{t("setup.lockLaneExit")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={r.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={r.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
</>
)}
{relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
)}
</div>
))}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addRelay")}
</button>
</div>
);
}
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
/** Generic controller-input list: terminal + role + the relay it serves. */
function InputEditor({
inputs,
onChange,
relays,
inputsIdleHigh,
onInputsIdleHighChange,
}: {
inputs: InputSpec[];
onChange: (v: InputSpec[]) => void;
relays: RelaySpec[];
inputsIdleHigh: boolean | undefined;
onInputsIdleHighChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
function update(i: number, patch: Partial<InputSpec>) {
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
}
function add() {
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
}
function remove(i: number) {
onChange(inputs.filter((_, idx) => idx !== i));
}
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
// A button row shows its cooldown fallback only if no presence row serves the same relay.
const hasPresenceFor = (relay?: number) =>
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
<input
type="checkbox"
className="mt-0.5"
checked={inputsIdleHigh ?? true}
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
</span>
</label>
{inputs.map((row, i) => {
const choice = choiceOf(row);
const isPresence = row.role === "presence";
const isButton = row.role === "button";
return (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.inputTerminal")}
<input
type="number"
min={1}
value={row.input}
className="input input-sm w-16"
onChange={(e) => update(i, { input: Number(e.target.value) })}
/>
</label>
<select
className="select input-sm w-auto"
value={choice}
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
>
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
<option key={c} value={c}>
{t(INPUT_CHOICE_KEYS[c])}
</option>
))}
</select>
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
{row.role !== "alertTrigger" && (
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.inputServesRelay")}
<select
className="select input-sm w-auto"
value={row.relay ?? ""}
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
>
<option value="" disabled>
{t("setup.choose")}
</option>
{barrierRelays.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select>
</label>
)}
{/* Presence: active-low (a radar wired opposite the button). */}
{isPresence && (
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
<input
type="checkbox"
checked={!!row.activeLow}
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
/>
{t("setup.activeLow")}
</label>
)}
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
{isButton && !hasPresenceFor(row.relay) && (
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
min={0}
value={row.cooldownSec ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
</div>
);
})}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addInput")}
</button>
</div>
);
}
/** Binding picker for readers/cameras/printers: choose the controller + relay this
* device sits at. Direction is inherited from the chosen relay (shown). */
function BindingPicker({
controllers,
controllerId,
relay,
onControllerChange,
onRelayChange,
}: {
controllers: Assignment[];
controllerId: string;
relay: number | "";
onControllerChange: (id: string) => void;
onRelayChange: (relay: number) => void;
}) {
const { t } = useTranslation();
const controller = controllers.find((c) => c.id === controllerId);
const relays: RelaySpec[] = controller
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
: [];
const chosen = relays.find((r) => r.relay === relay);
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.controller")}
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
<option value="" disabled>
{t("setup.choose")}
</option>
{controllers.map((c) => {
const host = (c.config as Record<string, unknown>).host;
return (
<option key={c.id} value={c.id}>
{c.driverId}
{typeof host === "string" ? ` (${host})` : ""}
</option>
);
})}
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
{t("setup.relay")}
<select
className="select input-sm w-auto"
value={relay === "" ? "" : String(relay)}
disabled={!controller}
onChange={(e) => onRelayChange(Number(e.target.value))}
>
<option value="" disabled>
{t("setup.choose")}
</option>
{/* Only barrier relays are bindable — an alert lamp opens nothing. */}
{relays
.filter((r) => r.direction !== "radarAlert")
.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select>
</label>
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
</div>
{controller && relays.length === 0 && (
<p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
)}
</div>
);
}
function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
// entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
const cls =
direction === "entry"
? "border-term-green text-term-green"
: direction === "exit"
? "border-term-amber text-term-amber"
: direction === "radarAlert"
? "border-term-red text-term-red"
: "border-term-muted text-term-muted";
return (
<span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
{label ?? direction}
</span>
);
}
function HealthBadge({ status }: { status: string }) {
const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red";
return <span className={`font-semibold ${cls}`}>● {status}</span>;
}