import { useState, useEffect, useCallback, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { assignDevice, editDevice, discoverDevices, fetchBackendIps, fetchCatalog, fetchState, testAnpr, testDevice, unassignDevice, type AnprTestResult, type Assignment, type BackendIpCandidate, type ButtonLightSpec, type Catalog, type CatalogEntry, type DeviceCategory, type DeviceConfig, type Direction, type DiscoveredDevice, 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 direction label (relay direction / inherited binding). const DIRECTION_KEYS: Record = { entry: "setup.dirEntry", exit: "setup.dirExit", both: "setup.dirBoth", }; 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")}; const bl = cfg.buttonLight as ButtonLightSpec | undefined; return ( {relays.map((r) => { const presence = r.presenceInput ? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}` : ""; return ( ); })} {bl?.relay != 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 {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"; // ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates // (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. const [anpr, setAnpr] = useState(editCfg?.anpr === true); // 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 relay map (which relay = entry/exit/both, + entry button terminal). const [relays, setRelays] = useState(() => Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }], ); // Controller-level button-lamp output (a spare relay), driven by the radar + camera. const [buttonLight, setButtonLight] = useState(() => { const bl = editCfg?.buttonLight as ButtonLightSpec | undefined; return bl && typeof bl.relay === "number" ? bl : null; }); // 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 [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) { out.relays = relays.map((r) => ({ relay: r.relay, direction: r.direction, ...(r.button ? { button: r.button } : {}), ...(r.presenceInput ? { presenceInput: r.presenceInput } : {}), ...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}), ...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}), ...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}), })); // Button-lamp output (a spare relay), persisted only when a relay is chosen. if (buttonLight && buttonLight.relay) { out.buttonLight = { relay: buttonLight.relay, ...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}), ...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}), }; } } 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; 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); } } 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" ? ( ) : ( { const v = e.target.value; setConfig((c) => ({ ...c, [f.key]: v })); resetStatus(); }} /> )}
    ), )} {/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */} {isController && ( { setConfig((c) => ({ ...c, pulseMs: v })); resetStatus(); }} /> )} {/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound to the output relay it drives. 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 && ( )} {/* 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 })}) )}
    ))}
    )} {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: it opens a barrier (or drives the button lamp). This section // owns relay number + direction, the pulse-open time (relay hold ms), and the lamp // relay. The INPUT terminals wired to these relays live in InputEditor below — the two // are deliberately separated (a controller's inputs and outputs are distinct things). /** Relays = outputs (barriers + lamp) + the pulse-open hold time. */ function OutputEditor({ relays, onChange, buttonLight, onButtonLightChange, pulseMs, onPulseMsChange, }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void; buttonLight: ButtonLightSpec | null; onButtonLightChange: (v: ButtonLightSpec | null) => 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)); } const barrierRelays = new Set(relays.map((r) => r.relay)); return (
    {t("setup.outputsTitle")}

    {t("setup.outputsHint")}

    {/* Pulse-open time applies to every barrier relay (how long it's held open). */} {/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */} {relays.map((r, i) => (
    {relays.length > 1 && ( )}
    ))} {/* Button-lamp output (a spare relay) — an OUTPUT, so it lives here. Driven by the radar + camera (blink = radar-only, solid = car confirmed, off otherwise). */}
    {t("setup.buttonLight")} {buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && ( {t("setup.buttonLightBarrierWarn")} )} {buttonLight?.relay != null && ( <> )}
    ); } // ── Controller INPUTS (terminals) ────────────────────────────────────────── // An input is a TERMINAL the host READS: the entry button, the presence/radar sensor. // Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we // render one block per entry/both relay, labelled with the output relay it drives. 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). /** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */ function InputEditor({ relays, onChange, inputsIdleHigh, onInputsIdleHighChange, }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void; inputsIdleHigh: boolean | undefined; onInputsIdleHighChange: (v: boolean) => void; }) { const { t } = useTranslation(); function update(i: number, patch: Partial) { onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); } // Inputs only matter for entry/both relays (transient entry). Keep each row's real // index so updates target the right relay. const entryRelays = relays .map((r, i) => ({ r, i })) .filter(({ r }) => r.direction === "entry" || r.direction === "both"); return (
    {t("setup.inputsTitle")}

    {t("setup.inputsHint")}

    {/* Board-wide resting level (idle HIGH vs LOW) — an input property. */} {entryRelays.length === 0 ? (

    {t("setup.inputsNoEntryRelay")}

    ) : ( entryRelays.map(({ r, i }) => (
    {t("setup.inputsForRelay", { relay: r.relay })} {/* Sensor kind + active-level — only once a presence terminal is set. */} {!!r.presenceInput && ( <> )} {/* Cooldown fallback only when no presence sensor is wired. */} {!r.presenceInput && ( )}
    )) )}
    ); } /** 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: Direction; label?: string }) { // entry=green, exit=amber, both=muted — aligned to the terminal accent palette. const cls = direction === "entry" ? "border-term-green text-term-green" : direction === "exit" ? "border-term-amber text-term-amber" : "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}; }