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({
|
||||
|
||||
@@ -286,9 +286,24 @@ export interface RelaySpec {
|
||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||
presenceInput?: number;
|
||||
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
|
||||
presenceKind?: "loop" | "radar";
|
||||
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
|
||||
* the button. Maps to the driver's per-input active-level override. */
|
||||
presenceActiveLow?: boolean;
|
||||
entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button light),
|
||||
* driven by the radar input vs. the camera lane status. */
|
||||
export interface ButtonLightSpec {
|
||||
/** 1-based spare relay the lamp is on. */
|
||||
relay: number;
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
blinkOnMs?: number;
|
||||
blinkOffMs?: number;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
health: { status: string; detail?: string };
|
||||
preconditions: {
|
||||
|
||||
@@ -361,12 +361,25 @@ export const en: Catalog = {
|
||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||
relay: "Relay",
|
||||
entryButtonTerminal: "Entry button on terminal",
|
||||
presenceInput: "Presence loop (terminal)",
|
||||
presenceInput: "Presence sensor (terminal)",
|
||||
presenceInputHint:
|
||||
"Input terminal the vehicle-presence loop / barrier feedback is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
||||
"Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
||||
entryCooldown: "Cooldown after ticket (s)",
|
||||
entryCooldownHint:
|
||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
||||
presenceKind: "Kind",
|
||||
presenceKindLoop: "Loop",
|
||||
presenceKindRadar: "Radar",
|
||||
presenceActiveLow: "Active-low",
|
||||
presenceActiveLowHint:
|
||||
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
|
||||
buttonLight: "Button light (spare relay)",
|
||||
buttonLightRelay: "Relay",
|
||||
buttonLightHint:
|
||||
"The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.",
|
||||
buttonLightBarrierWarn: "This relay is used by a barrier — pick a spare relay.",
|
||||
blinkOnMs: "Blink on (ms)",
|
||||
blinkOffMs: "Blink off (ms)",
|
||||
addRelay: "+ Add relay",
|
||||
anpr: "Plate recognition (ANPR)",
|
||||
anprHint:
|
||||
|
||||
@@ -376,6 +376,19 @@ export const sq = {
|
||||
entryCooldown: "Pritje pas biletës (sek)",
|
||||
entryCooldownHint:
|
||||
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
|
||||
presenceKind: "Lloji",
|
||||
presenceKindLoop: "Lak",
|
||||
presenceKindRadar: "Radar",
|
||||
presenceActiveLow: "Aktiv-ulët",
|
||||
presenceActiveLowHint:
|
||||
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
|
||||
buttonLight: "Drita e butonit (rele rezervë)",
|
||||
buttonLightRelay: "Rele",
|
||||
buttonLightHint:
|
||||
"Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.",
|
||||
buttonLightBarrierWarn: "Kjo rele përdoret nga një barrierë — zgjidh një rele rezervë.",
|
||||
blinkOnMs: "Pulsim ndezur (ms)",
|
||||
blinkOffMs: "Pulsim fikur (ms)",
|
||||
addRelay: "+ Shto rele",
|
||||
// Camera ANPR opt-in.
|
||||
anpr: "Njohja e targave (ANPR)",
|
||||
|
||||
Reference in New Issue
Block a user