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,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { AuxOutputDevice } from "@parking/devices";
|
||||
import { ButtonLightController } from "./button-light.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// ButtonLightController: the entry-button lamp on a spare relay, driven by the RADAR
|
||||
// input vs. the camera lane status. Truth table:
|
||||
// radar present + lane busy -> SOLID on
|
||||
// radar present + lane free -> BLINK (~1 Hz)
|
||||
// otherwise -> OFF
|
||||
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes.
|
||||
|
||||
let db: Db;
|
||||
const CONTROLLER = "ctl-1";
|
||||
const RADAR_INPUT = 2; // I2
|
||||
const LAMP_RELAY = 3; // spare relay R3
|
||||
|
||||
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||
return {
|
||||
async setAux(channel: number, on: boolean): Promise<void> {
|
||||
if (throwOnce.v) {
|
||||
throwOnce.v = false;
|
||||
throw new Error("UDP down");
|
||||
}
|
||||
record.push({ ch: channel, on });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
vi.useFakeTimers();
|
||||
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||
db.insert(devices).values({
|
||||
id: CONTROLLER,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Emit a radar (presence input) edge for the controller. */
|
||||
function radar(present: boolean): void {
|
||||
deviceEvents.emitInput({
|
||||
driverId: "dingtian",
|
||||
deviceId: CONTROLLER,
|
||||
input: RADAR_INPUT,
|
||||
edge: present ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
/** Emit a lane status (entry busy/free). */
|
||||
function lane(entryBusy: boolean): void {
|
||||
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||
}
|
||||
|
||||
describe("ButtonLightController truth table", () => {
|
||||
it("OFF at start (no radar, no car)", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||
ctl.start();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||
// Initial apply drives the lamp off (false). It may de-dupe to no call since
|
||||
// lastOn starts null -> false IS a change, so exactly one off write.
|
||||
expect(calls).toEqual([{ ch: LAMP_RELAY, on: false }]);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar present + lane busy -> SOLID on", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
calls.length = 0;
|
||||
lane(true);
|
||||
radar(true);
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
|
||||
// Solid = no blinking: advancing time produces no further toggles.
|
||||
const n = calls.length;
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar present + lane free -> BLINK (toggles over time)", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
calls.length = 0;
|
||||
radar(true); // lane still free
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // on now
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false }); // toggled off
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // toggled on
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("blink -> solid when the camera confirms a car (lane busy)", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
radar(true); // blink
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
lane(true); // camera confirms
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
|
||||
// No more toggles (blink torn down).
|
||||
const n = calls.length;
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar clears -> OFF", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
lane(true);
|
||||
radar(true); // solid
|
||||
calls.length = 0;
|
||||
radar(false); // car gone
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false });
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("de-dupes redundant writes (no spam on repeat events)", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
lane(true);
|
||||
radar(true); // solid, on
|
||||
const n = calls.length;
|
||||
radar(true); // same state — no new edge (present unchanged)
|
||||
lane(true); // same lane — no change
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("fails OFF: a setAux error does not throw or escalate", () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const throwOnce = { v: true };
|
||||
const aux = fakeAux(calls, throwOnce);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
// First write (initial off) throws — must be swallowed.
|
||||
expect(() => ctl.start()).not.toThrow();
|
||||
// Subsequent writes work; driving to solid still succeeds.
|
||||
lane(true);
|
||||
radar(true);
|
||||
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("ignores controllers without a buttonLight config", () => {
|
||||
// A second controller, no lamp.
|
||||
db.insert(devices).values({
|
||||
id: "ctl-2",
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||
ctl.start();
|
||||
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||
ctl.stop();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,32 @@ export interface RelaySpec {
|
||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||
*/
|
||||
readonly presenceInput?: number;
|
||||
/** What kind of sensor is on `presenceInput` — an induction LOOP or a RADAR. Label
|
||||
* only (the gate behaviour is identical); drives UI copy + telemetry. Default loop. */
|
||||
readonly presenceKind?: "loop" | "radar";
|
||||
/** The presence terminal's ACTIVE level is LOW (idles HIGH). Maps to the driver's
|
||||
* per-input `inputActiveLow` override so a radar wired opposite the button reads
|
||||
* right. See wiki/entities/hikvision-radar.md. */
|
||||
readonly presenceActiveLow?: boolean;
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's
|
||||
* 12 V light). Driven by the server LightController off the radar + lane status —
|
||||
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */
|
||||
export interface ButtonLightSpec {
|
||||
/** 1-based spare relay channel the lamp is wired to. */
|
||||
readonly relay: number;
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
readonly blinkOnMs?: number;
|
||||
readonly blinkOffMs?: number;
|
||||
}
|
||||
|
||||
/** Access controller config (the `relays[]` map + connection fields). */
|
||||
interface AccessConfig {
|
||||
readonly relays?: RelaySpec[];
|
||||
/** Optional button-lamp output on a spare relay. */
|
||||
readonly buttonLight?: ButtonLightSpec;
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -60,9 +80,11 @@ export interface ResolvedRelay {
|
||||
readonly controller: DeviceRow;
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
||||
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||
readonly presenceInput?: number;
|
||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
||||
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||
readonly presenceKind?: "loop" | "radar";
|
||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
@@ -102,6 +124,7 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
presenceKind: spec.presenceKind ?? "loop",
|
||||
entryCooldownSec: spec.entryCooldownSec,
|
||||
};
|
||||
}
|
||||
@@ -122,7 +145,20 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||
return {
|
||||
controller: row,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
presenceKind: spec.presenceKind ?? "loop",
|
||||
};
|
||||
}
|
||||
|
||||
/** The button-lamp output declared on an access controller, or null. */
|
||||
export function buttonLightOf(row: DeviceRow): ButtonLightSpec | null {
|
||||
const cfg = row.config as AccessConfig;
|
||||
const bl = cfg.buttonLight;
|
||||
return bl && typeof bl.relay === "number" ? bl : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { ButtonLightController } from "./button-light.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
@@ -188,6 +189,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
|
||||
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||
const buttonLight = new ButtonLightController(db, app.log);
|
||||
buttonLight.start();
|
||||
app.addHook("onClose", async () => buttonLight.stop());
|
||||
|
||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||
|
||||
@@ -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