cd3b534e51
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
1640 lines
69 KiB
TypeScript
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>;
|
|
}
|