import { useState, useEffect, useCallback, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { assignDevice, editDevice, discoverDevices, fetchBackendIps, fetchCatalog, fetchState, testAnpr, testDevice, testPrint, 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, } 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 = { 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 = { 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(null); const [assignments, setAssignments] = useState(null); const [error, setError] = useState(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

{t("setup.failedToLoad", { error })}

; if (!catalog || !assignments) return

{t("setup.loadingCatalog")}

; // Controllers are needed before binding readers/cameras (they pick a controller relay). const controllers = assignments.filter((a) => a.category === "access"); return (

{t("setup.title")}

{t("setup.intro")}

{BOUND.map(({ key, titleKey, nounKey }) => ( a.category === key)} onChanged={reloadState} /> ))}
); } 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; }) { 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(null); const [warnings, setWarnings] = useState([]); // Binding categories need a controller to point at first. const isBound = category !== "access"; const blockedNoController = isBound && controllers.length === 0; const editing = formFor && formFor !== "new" ? formFor : undefined; return (
{title} {warnings.length > 0 && (
{t("setup.warnTitle")}
    {warnings.map((w, i) => (
  • {w}
  • ))}
)} {assignments.length > 0 && (
    {assignments.map((a) => ( setFormFor(a)} /> ))}
)} {blockedNoController ? (

{t("setup.needControllerFirst", { noun })}

) : ( )} {/* Add/edit form — popped out. One modal per category; the device list stays in the page behind it. */} setFormFor(null)} title={editing ? t("setup.editTitle", { noun }) : t("setup.addTitle", { noun })} width="max-w-2xl" > {formFor != null && ( { setWarnings(w); await onChanged(); setFormFor(null); }} onCancel={() => setFormFor(null)} /> )}
); } function AssignmentRow({ assignment, controllers, onChanged, onEdit, }: { assignment: Assignment; controllers: Assignment[]; onChanged: () => Promise | void; onEdit: () => void; }) { const { t } = useTranslation(); const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); const cfg = assignment.config as Record; 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 (
  • {assignment.driverId} {host && {host}} {!assignment.enabled && {t("setup.disabled")}} {error && {error}}
  • ); } /** 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; if (assignment.category === "access") { const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; if (relays.length === 0) return {t("setup.noRelaysSet")}; // 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 ( {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 ; })} ); } // 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 {t("setup.unbound")}; const controller = controllers.find((c) => c.id === controllerId); const spec = controller ? (((controller.config as Record).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay) : undefined; return ( ); } 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; 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 | undefined; const [selectedId, setSelectedId] = useState(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(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(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>(() => { if (!editCfg) return {}; const out: Record = {}; for (const [k, v] of Object.entries(editCfg)) { if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v; } return out; }); // 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(() => 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(() => { 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( typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "", ); const [boundRelay, setBoundRelay] = useState( typeof editCfg?.relay === "number" ? editCfg.relay : "", ); const [tested, setTested] = useState(null); const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); // ANPR probe (camera + anpr on): snapshot → vision analyze, reported below. const [alarmUrlCopied, setAlarmUrlCopied] = useState(false); const [anprResult, setAnprResult] = useState(null); const [anprTesting, setAnprTesting] = useState(false); const [anprError, setAnprError] = useState(null); const [printResult, setPrintResult] = useState(null); const [printTesting, setPrintTesting] = useState(false); const [printError, setPrintError] = useState(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>({}); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const [backendIps, setBackendIps] = useState(null); const [backendIp, setBackendIp] = useState(""); // 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(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) })); resetStatus(); } /** Scalar config the user entered, merged over driver defaults (for test/push-IP). */ function mergedScalarConfig(): Record { const out: Record = {}; 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 (controllerId && boundRelay !== "") { 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). if (!isController && (!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 (
    {entries.length === 0 ? ( {t("setup.noDrivers")} ) : ( // Driver is locked when editing — changing the kind of device is a // remove + re-add, not an in-place edit. )} {selected && (

    {selected.description}

    {canDiscover && (
    {scanError && {scanError}} {found && found.length === 0 &&

    {t("setup.noControllersFound")}

    } {found && found.length > 0 && (
      {found.map((d) => (
    • {d.label} {d.info?.firmware && · fw {d.info.firmware}}
    • ))}
    )}
    )} {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. ) : (
    {f.type === "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.
    { const v = e.target.value; setConfig((c) => ({ ...c, [f.key]: v })); resetStatus(); }} />
    ) : ( { const v = e.target.value; setConfig((c) => ({ ...c, [f.key]: v })); resetStatus(); }} /> )}
    ), )} {/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */} {isController && ( { 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 && ( { setConfig((c) => ({ ...c, inputRestingHigh: v })); resetStatus(); }} /> )} {/* BOUND device: which controller + relay it sits at. */} {!isController && ( { setControllerId(id); setBoundRelay(""); }} onRelayChange={setBoundRelay} /> )} {/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */} {isCamera && ( )} {/* 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 && ( )} {/* 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) && (
    {t("setup.alarmUrlTitle")}
    {!editing?.id ? (

    {t("setup.alarmUrlSaveFirst")}

    ) : !backendIp || backendPort == null ? (

    {t("setup.alarmUrlTestFirst")}

    ) : ( (() => { 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 ( <>
    {fields.map(([k, v]) => ( {k} {v} ))}

    {t("setup.alarmUrlHint")}

    ); })() )}
    )} {/* Test (no save/no device change) then Save (configures + persists). */}
    {onCancel && ( )}
    {testError &&

    {t("setup.testFailed", { error: testError })}

    } {tested && (
    {t("setup.deviceLabel")} {tested.health.detail && — {tested.health.detail}}
    {tested.preconditions.ok ? (
    {t("setup.preconditionsOk")}
    ) : ( tested.preconditions.issues.map((i) => (
    ⚠ {i.message} {i.fixable && {t("setup.autoFixedOnSave")}}
    )) )}
    )} {/* 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 && (

    {t("setup.testAnprHint")}

    {anprError &&

    {t("setup.testFailed", { error: anprError })}

    } {anprResult && (anprResult.ok ? (
    {t("setup.anprOk", { plate: anprResult.plate, confidence: Math.round(anprResult.confidence * 100), ms: anprResult.tookMs, })} {anprResult.lowConfidence && ( {t("setup.anprLowConfidence")} )}
    ) : (
    ⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })} {anprResult.detail && — {anprResult.detail}} {anprResult.tookMs != null && ( ({t("setup.anprTookMs", { ms: anprResult.tookMs })}) )}
    ))}
    )} {/* PRINTER: push a real test slip so the admin can confirm it physically prints (healthCheck only opens the transport / USB node). */} {isPrinter && (

    {t("setup.testPrintHint")}

    {printError &&

    {t("setup.testFailed", { error: printError })}

    } {printResult && (printResult.ok ? (
    {t("setup.printOk", { ms: printResult.tookMs })}
    ) : (
    ⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })} {printResult.detail && — {printResult.detail}}
    ))}
    )} {backendIps && backendIps.length > 0 && (
    {!backendIps.some((c) => c.onDeviceSubnet) && ( {t("setup.noNicOnSubnet")} )}

    {t("setup.backendIpHint")}

    )} {saveError &&

    {t("setup.saveFailed", { error: saveError })}

    }
    )}
    ); } // ── 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) { 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 (
    {t("setup.outputsTitle")}

    {t("setup.outputsHint")}

    {/* Pulse-open time applies to every barrier relay (how long it's held open). */} {/* 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) => (
    {/* Alert relay: which input fires the blink + the blink cadence. */} {r.direction === "radarAlert" && ( <> )} {relays.length > 1 && ( )}
    ))}
    ); } // ── 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) { 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 (
    {t("setup.inputsTitle")}

    {t("setup.inputsHint")}

    {/* Board-wide resting level (idle HIGH vs LOW) — an input property. */} {inputs.map((row, i) => { const choice = choiceOf(row); const isPresence = row.role === "presence"; const isButton = row.role === "button"; return (
    {/* Which barrier this input serves — button/presence only (alert triggers a lamp). */} {row.role !== "alertTrigger" && ( )} {/* Presence: active-low (a radar wired opposite the button). */} {isPresence && ( )} {/* Button cooldown fallback — only when no presence sensor serves this relay. */} {isButton && !hasPresenceFor(row.relay) && ( )}
    ); })}
    ); } /** 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).relays as RelaySpec[]) ?? []) : []; const chosen = relays.find((r) => r.relay === relay); return (
    {t("setup.whichBarrier")}
    {chosen && }
    {controller && relays.length === 0 && (

    {t("setup.noRelaysConfigured")}

    )}
    ); } 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 ( {label ?? direction} ); } function HealthBadge({ status }: { status: string }) { const cls = status === "ready" ? "text-term-green" : status === "degraded" ? "text-term-amber" : "text-term-red"; return ● {status}; }