Files
parking_solution/apps/web/src/SetupWizard.tsx
T
julian 40de8a7467 feat(setup): generate the camera's Alarm Server settings to paste
When a camera has Alarm Server push enabled, the setup form now shows the
camera's Alarm Settings (Destination IP / URL / Protocol / Port) ready to copy,
so the operator never hunts the deviceId or memorises the endpoint.

CRUCIAL: host/port come from the BACKEND address on the camera's subnet
(backendIpForDevice + the server's listen port — the same probe the push-IP
picker uses), NOT window.location.origin (the SPA's dev/proxy origin, which
would wrongly say localhost:5173). Verified live: matches the on-camera config
field-for-field (10.0.10.203 / …/event / HTTP / 3000). Shows a "save first"
(needs a deviceId) then "test first" (needs the resolved backend IP) hint.
i18n keys added to sq + en (parity enforced).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:47:02 +02:00

1253 lines
50 KiB
TypeScript

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<Direction, string> = {
entry: "setup.dirEntry",
exit: "setup.dirExit",
both: "setup.dirBoth",
};
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-prose">{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}
/>
{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>
);
}
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.
const isBound = category !== "access";
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-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
<ul className="mt-1 list-disc pl-5 text-[12px] 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-[12px] 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-[12px]">
<strong className="text-term-text">{assignment.driverId}</strong>
{host && <span className="tabular-nums text-term-muted">{host}</span>}
<DeviceSummary assignment={assignment} controllers={controllers} />
{!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>
);
}
/** 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>;
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
return (
<span className="flex flex-wrap gap-1.5">
{relays.map((r) => {
const presence = r.presenceInput
? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}`
: "";
return (
<DirectionBadge
key={r.relay}
direction={r.direction}
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`}
/>
);
})}
{bl?.relay != null && (
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
)}
</span>
);
}
// 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";
// 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<boolean>(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<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;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>(() =>
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<ButtonLightSpec | null>(() => {
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<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 [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) {
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 (
<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-[12px] 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-[12px] text-term-red">{scanError}</span>}
{found && found.length === 0 && <p className="mt-2 text-[12px] 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-[12px]">
<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-[12px]">
<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.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>
) : (
<input
className="input"
type={f.type === "secret" ? "password" : 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();
}}
/>
)}
</div>
),
)}
{/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
{isController && (
<OutputEditor
relays={relays}
onChange={setRelays}
buttonLight={buttonLight}
onButtonLightChange={setButtonLight}
pulseMs={config.pulseMs as number | undefined}
onPulseMsChange={(v) => {
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 && (
<InputEditor
relays={relays}
onChange={setRelays}
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
onInputsIdleHighChange={(v) => {
setConfig((c) => ({ ...c, inputRestingHigh: v }));
resetStatus();
}}
/>
)}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
<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-[12px]">
<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>
)}
{/* 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-[12px]">
<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-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
{tested && (
<div className="mt-2 text-[12px]">
<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-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
{anprResult &&
(anprResult.ok ? (
<div className="mt-2 text-[12px] 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-[12px] 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>
)}
{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-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
)}
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
</div>
)}
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
</div>
)}
</div>
);
}
// ── 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<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));
}
const barrierRelays = new Set(relays.map((r) => r.relay));
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] 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-[12px] 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>
{/* Barrier relays: number + direction. (Input 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-[12px] 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 Direction })}>
{(["entry", "exit", "both"] as Direction[]).map((d) => (
<option key={d} value={d}>
{t(DIRECTION_KEYS[d])}
</option>
))}
</select>
{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>
{/* 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). */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-term-border pt-2">
<span className="text-[12px] text-term-muted" title={t("setup.buttonLightHint")}>
{t("setup.buttonLight")}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.buttonLightRelay")}
<input
type="number"
min={1}
value={buttonLight?.relay ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
onButtonLightChange(e.target.value === "" ? null : { ...buttonLight, relay: Number(e.target.value) })
}
/>
</label>
{buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && (
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
)}
{buttonLight?.relay != null && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
</>
)}
</div>
</div>
);
}
// ── 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<RelaySpec>) {
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 (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
<strong className="text-[12px] 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-[12px] 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>
{entryRelays.length === 0 ? (
<p className="hint">{t("setup.inputsNoEntryRelay")}</p>
) : (
entryRelays.map(({ r, i }) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<span className="text-[11px] uppercase tracking-wider text-term-amber">
{t("setup.inputsForRelay", { relay: r.relay })}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.entryButtonTerminal")}
<input
type="number"
min={1}
value={r.button ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
{t("setup.presenceInput")}
<input
type="number"
min={1}
value={r.presenceInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
{/* Sensor kind + active-level — only once a presence terminal is set. */}
{!!r.presenceInput && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.presenceKind")}
<select
value={r.presenceKind ?? "loop"}
className="input input-sm w-24"
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
>
<option value="loop">{t("setup.presenceKindLoop")}</option>
<option value="radar">{t("setup.presenceKindRadar")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
<input
type="checkbox"
checked={!!r.presenceActiveLow}
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
/>
{t("setup.presenceActiveLow")}
</label>
</>
)}
{/* Cooldown fallback only when no presence sensor is wired. */}
{!r.presenceInput && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
min={0}
value={r.entryCooldownSec ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
</div>
))
)}
</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-[12px] 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-[12px] 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-[12px] 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>
{relays.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-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
)}
</div>
);
}
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 (
<span className={`rounded-term border px-1.5 text-[10px] 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>;
}