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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user