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
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { inputActive } from "./access-dingtian.js";
// Per-input active-level normalisation. The board has ONE resting level, but a radar
// can idle opposite the button — listing its terminal in `activeLow` inverts just that
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
describe("inputActive (per-input active-level)", () => {
const none = new Set<number>();
const radarOnI2 = new Set<number>([2]);
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
// Button on I1, board idles HIGH → active when LOW.
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
});
it("resting LOW board: a pull HIGH is active", () => {
expect(inputActive(true, 1, false, none)).toBe(true);
expect(inputActive(false, 1, false, none)).toBe(false);
});
it("active-low override inverts ONLY the listed input", () => {
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
// mark I2 active-low so detection (LOW) reads active.
// I1 (button) keeps the board default:
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
// I2 (radar) overridden to active-low: active when LOW.
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
});
});
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
import { request as httpRequest } from "node:http";
import type {
AccessControlDevice,
AuxOutputDevice,
DeviceHealth,
HardenableDevice,
HardenResult,
@@ -166,6 +167,22 @@ interface DingtianStatus {
channels: number;
}
/**
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
*/
export function inputActive(
high: boolean,
channel1Based: number,
restingHigh: boolean,
activeLow: ReadonlySet<number>,
): boolean {
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
}
const INPUT_LINK_ISSUE = {
key: "input_link_relay",
message:
@@ -223,6 +240,7 @@ function configApi(
class DingtianController
implements
AccessControlDevice,
AuxOutputDevice,
InputDevice,
PreconditionDevice,
PushConfigurableDevice,
@@ -242,6 +260,13 @@ class DingtianController
readonly #channels: number;
/** Input level at rest; an input is "active" when it differs from this. */
readonly #restingHigh: boolean;
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
* terminal here flips its edge so "active" still means "detecting". See
* wiki/entities/hikvision-radar.md. */
readonly #inputActiveLow: Set<number>;
readonly #pulseMs: number;
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string;
@@ -269,6 +294,22 @@ class DingtianController
this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false;
// Per-input active-LOW overrides (1-based). Source of truth is each entry relay's
// `presenceActiveLow` flag (a radar terminal wired opposite the button); an explicit
// top-level `inputActiveLow` array is also honoured as an escape hatch. Both merged.
this.#inputActiveLow = new Set<number>();
if (Array.isArray(config.inputActiveLow)) {
for (const n of (config.inputActiveLow as unknown[]).map(Number)) {
if (Number.isInteger(n) && n > 0) this.#inputActiveLow.add(n);
}
}
if (Array.isArray(config.relays)) {
for (const r of config.relays as Array<Record<string, unknown>>) {
if (r?.presenceActiveLow === true && Number.isInteger(Number(r.presenceInput))) {
this.#inputActiveLow.add(Number(r.presenceInput));
}
}
}
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
this.#webUser = config.webUser ? String(config.webUser) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
@@ -319,6 +360,14 @@ class DingtianController
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
}
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
* relay. Same wire op as setRelay — separated so business logic drives indicators
* through the aux capability, never the barrier relay methods. Holding/blinking an
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
async setAux(channel: number, on: boolean): Promise<void> {
await this.setRelay(channel, on);
}
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
this.#assertChannel(doorId);
const { relays } = await this.#status();
@@ -672,8 +721,10 @@ class DingtianController
for (let i = 0; i < this.#channels; i++) {
const high = (inputVal & (1 << i)) !== 0;
relays.push((relayVal & (1 << i)) !== 0);
// active = differs from the resting level (a press pulls the line).
inputs.push(high !== this.#restingHigh);
// active = differs from the resting level (a press pulls the line); a terminal in
// inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
// button reads right. See inputActive().
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
}
return { relays, inputs, channels: this.#channels };
}
+17
View File
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
getDoorStatus(doorId: number): Promise<"open" | "closed">;
}
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
// Optional capability for controllers with SPARE relays wired to something that
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
// on or off and holds it (unlike pulseOpen, which is momentary). The
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
// so holding/blinking it is fine. Business logic drives indicators through THIS,
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
export interface AuxOutputDevice {
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
setAux(channel: number, on: boolean): Promise<void>;
}
/** Feature-detect the aux-output capability on a built device adapter. */
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
}
// --- Inputs (buttons / dry contacts) -------------------------------------
// Optional capability for controllers that expose host-readable inputs SEPARATE
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-