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