feat(devices): radar presence input + button-light output on the controller

Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe),
access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green
(158 server tests). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-24 11:45:22 +02:00
parent 215a3ac405
commit 2915d141aa
17 changed files with 916 additions and 23 deletions
+133 -4
View File
@@ -13,6 +13,7 @@ import {
type AnprTestResult,
type Assignment,
type BackendIpCandidate,
type ButtonLightSpec,
type Catalog,
type CatalogEntry,
type DeviceCategory,
@@ -270,11 +271,24 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
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 gap-1.5">
{relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))}
<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>
);
}
@@ -345,6 +359,11 @@ function DeviceForm({
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 : "",
@@ -442,8 +461,18 @@ function DeviceForm({
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;
@@ -631,6 +660,11 @@ function DeviceForm({
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
{/* CONTROLLER: optional button-lamp output on a spare relay (radar + camera driven). */}
{isController && (
<ButtonLightEditor relays={relays} value={buttonLight} onChange={setButtonLight} />
)}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
<BindingPicker
@@ -826,6 +860,30 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
/>
</label>
)}
{/* Presence sensor kind + active-level — only meaningful once a terminal is set. */}
{(r.direction === "entry" || r.direction === "both") && !!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>
</>
)}
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
@@ -855,6 +913,77 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
);
}
/** Button-lamp output: the entry button's 12 V light on a SPARE relay, driven by the
* radar + camera (blink = radar-only, solid = car confirmed, off otherwise). Optional.
* The relay picker offers every relay number on this controller; the operator picks a
* spare one (not a barrier relay). See wiki/concepts/button-light-indicator.md. */
function ButtonLightEditor({
relays,
value,
onChange,
}: {
relays: RelaySpec[];
value: ButtonLightSpec | null;
onChange: (v: ButtonLightSpec | null) => void;
}) {
const { t } = useTranslation();
// Relay numbers in use as barriers — shown as a hint so the operator avoids them.
const barrierRelays = new Set(relays.map((r) => r.relay));
return (
<div className="mt-2 flex flex-wrap items-center gap-3 rounded-term border border-term-border p-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={value?.relay ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
onChange(e.target.value === "" ? null : { ...value, relay: Number(e.target.value) })
}
/>
</label>
{value?.relay != null && barrierRelays.has(value.relay) && (
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
)}
{value?.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={value.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onChange({ ...value, 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={value.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onChange({ ...value, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
</>
)}
</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({