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
+220
View File
@@ -0,0 +1,220 @@
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
import { buttonLightOf, relayForPresence, type ButtonLightSpec } from "./device-resolve.js";
// The entry button's 12 V light, driven by the RADAR input vs. the camera "car in
// zone" signal (the existing advisory lane-status). A disagreement indicator:
// radar present + lane busy (camera confirms a car) → SOLID on
// radar present + lane free (radar sees something, no car) → BLINK (~1 Hz)
// otherwise → OFF
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
// misleading solid "go". See wiki/concepts/button-light-indicator.md.
type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500;
/** Per-controller live state for the lamp rule. */
interface LampState {
readonly spec: ButtonLightSpec;
/** Is the radar (presence input on an entry relay) currently active? */
present: boolean;
/** The output we last commanded (de-dupe — avoid UDP spam at the 50ms input poll). */
lastOn: boolean | null;
/** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null;
/** Active blink timer, if blinking. */
blink: ReturnType<typeof setInterval> | null;
/** Blink phase (true = currently on). */
blinkOn: boolean;
}
/** Resolves a controller's live aux-output adapter. The default goes through the
* driver registry; tests inject a spy. Returns null when the controller has no
* aux-output capability (or won't build). */
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
export class ButtonLightController {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #resolveAux: AuxResolver;
/** Per-controller state, keyed by controller deviceId. */
readonly #lamps = new Map<string, LampState>();
/** Latest lane status (entry busy = a camera-confirmed car in the entry zone). */
#entryBusy = false;
/** Controllers we've already warned lack the aux-output capability (warn once). */
readonly #warned = new Set<string>();
#unsubInput: (() => void) | null = null;
#unsubLane: (() => void) | null = null;
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
this.#db = db;
this.#logger = logger;
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
}
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
start(): void {
this.#loadLamps();
// All lamps start OFF (known-safe baseline) regardless of prior device state.
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
}
/** (Re)build the lamp map from the current device config. Each enabled access
* controller with a `buttonLight` gets a lamp; others are skipped. */
#loadLamps(): void {
this.#lamps.clear();
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
for (const row of rows) {
if (!row.enabled) continue;
const spec = buttonLightOf(row);
if (!spec) continue;
this.#lamps.set(row.id, {
spec,
present: false,
lastOn: null,
rendered: null,
blink: null,
blinkOn: false,
});
}
}
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
#onInput(e: DeviceInputEvent): void {
const lamp = this.#lamps.get(e.deviceId);
if (!lamp) return; // no lamp on this controller
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (!presence) return; // not the presence/radar terminal
const present = e.edge === "on";
if (present === lamp.present) return;
lamp.present = present;
this.#apply(e.deviceId, lamp);
}
/** Lane status changed: entry busy = a camera-confirmed car in the entry zone. */
#onLane(s: LaneStatusEvent): void {
if (s.entry === this.#entryBusy) return;
this.#entryBusy = s.entry;
// Re-render every lamp (the camera signal is site-wide entry status).
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
}
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
* timer/state machine is synchronous; the UDP write resolves on its own). */
#apply(controllerId: string, lamp: LampState): void {
const target: LightState = !lamp.present ? "off" : this.#entryBusy ? "solid" : "blink";
if (target === lamp.rendered) return; // already rendering this state
// Tear down any running blink before switching states.
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
lamp.rendered = target;
if (target === "off") {
void this.#drive(controllerId, lamp, false);
} else if (target === "solid") {
void this.#drive(controllerId, lamp, true);
} else {
// BLINK: arm the toggle timer SYNCHRONOUSLY (it must not wait on a UDP write), then
// drive the first "on". A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration.
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
lamp.blinkOn = true;
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
void this.#drive(controllerId, lamp, lamp.blinkOn);
if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
lamp.blink.unref?.();
}
};
lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.();
void this.#drive(controllerId, lamp, true);
}
}
/** Latch the lamp's relay via the device's aux-output capability. De-duped + fail-OFF:
* an error logs and leaves `lastOn` unchanged so the next compute retries. */
async #drive(controllerId: string, lamp: LampState, on: boolean): Promise<void> {
if (lamp.lastOn === on) return; // no redundant UDP writes
const aux = this.#resolveAux(controllerId);
if (!aux) return;
try {
await aux.setAux(lamp.spec.relay, on);
lamp.lastOn = on;
} catch (err) {
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
// Leave lastOn unchanged → retried on the next state compute. Never escalates.
}
}
/** Build the live aux-output adapter for a controller, or null (logged once). */
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
if (!row) return null;
const driver = registry.get(row.driverId);
if (!driver) return null;
let device: unknown;
try {
device = driver.create(row.config as never);
} catch {
return null;
}
if (!hasAuxOutput(device)) {
if (!this.#warned.has(controllerId)) {
this.#warned.add(controllerId);
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
}
return null;
}
return device;
}
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
stop(): void {
this.#unsubInput?.();
this.#unsubLane?.();
this.#unsubInput = null;
this.#unsubLane = null;
for (const [controllerId, lamp] of this.#lamps) {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
// Best-effort fail-OFF on shutdown.
void this.#drive(controllerId, lamp, false);
}
}
/** Test seam: current high-level state being rendered for a controller. */
stateOf(controllerId: string): LightState | null {
return this.#lamps.get(controllerId)?.rendered ?? null;
}
}
/** Build a controller row's live aux device (exported for reuse/tests). */
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
const device = driver.create(row.config as never);
return hasAuxOutput(device) ? device : null;
} catch {
return null;
}
}