diff --git a/apps/server/src/button-light.test.ts b/apps/server/src/button-light.test.ts new file mode 100644 index 0000000..54ead90 --- /dev/null +++ b/apps/server/src/button-light.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/server/src/button-light.ts b/apps/server/src/button-light.ts new file mode 100644 index 0000000..5ec7b47 --- /dev/null +++ b/apps/server/src/button-light.ts @@ -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 | 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(); + /** 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(); + #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 { + 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; + } +} diff --git a/apps/server/src/device-resolve.ts b/apps/server/src/device-resolve.ts index 13f270a..0ff39a2 100644 --- a/apps/server/src/device-resolve.ts +++ b/apps/server/src/device-resolve.ts @@ -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; } /** diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f5f8d9c..9d0e353 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 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, diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 6836c4b..d92b6db 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -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 {t("setup.noRelaysSet")}; + const bl = cfg.buttonLight as ButtonLightSpec | undefined; return ( - - {relays.map((r) => ( - - ))} + + {relays.map((r) => { + const presence = r.presenceInput + ? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}` + : ""; + return ( + + ); + })} + {bl?.relay != null && ( + + )} ); } @@ -345,6 +359,11 @@ function DeviceForm({ const [relays, setRelays] = useState(() => 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(() => { + 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( 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 && } + {/* CONTROLLER: optional button-lamp output on a spare relay (radar + camera driven). */} + {isController && ( + + )} + {/* BOUND device: which controller + relay it sits at. */} {!isController && ( )} + {/* Presence sensor kind + active-level — only meaningful once a terminal is set. */} + {(r.direction === "entry" || r.direction === "both") && !!r.presenceInput && ( + <> + + + + )} {(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (