diff --git a/apps/server/src/button-light.test.ts b/apps/server/src/button-light.test.ts index 1b4bdb4..90d7e93 100644 --- a/apps/server/src/button-light.test.ts +++ b/apps/server/src/button-light.test.ts @@ -7,12 +7,13 @@ 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. +// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare +// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table: +// trigger active + lane busy -> SOLID on +// trigger active + lane free -> BLINK (~1 Hz) +// otherwise -> OFF +// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may +// carry several alert relays (each its own row + trigger input), keyed independently. let db: Db; const CONTROLLER = "ctl-1"; @@ -45,8 +46,8 @@ beforeEach(() => { relays: [ { relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" }, { relay: 2, direction: "exit" }, + { relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 }, ], - buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 }, }, enabled: true, }).run(); @@ -199,8 +200,8 @@ describe("ButtonLightController truth table", () => { ctl.stop(); }); - it("ignores controllers without a buttonLight config", () => { - // A second controller, no lamp. + it("ignores controllers without an alert relay", () => { + // A second controller, no alert relay. db.insert(devices).values({ id: "ctl-2", category: "access", @@ -215,8 +216,8 @@ describe("ButtonLightController truth table", () => { ctl.stop(); }); - it("picks up a button light ADDED after start() (no restart needed)", async () => { - // Fresh controller with a radar input but NO buttonLight yet. + it("picks up an alert relay ADDED after start() (no restart needed)", async () => { + // Fresh controller with a radar input but NO alert relay yet. const calls: Array<{ ch: number; on: boolean }> = []; const aux = fakeAux(calls); const ctl = new ButtonLightController(db, silentLogger(), () => aux); @@ -240,13 +241,15 @@ describe("ButtonLightController truth table", () => { radar(false); await flush(); - // Admin saves a button light (relay 3) — without restarting the server. + // Admin saves an alert relay (relay 3, trigger I2) — without restarting the server. db.update(devices) .set({ config: { host: "10.0.0.5", - relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }], - buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 }, + relays: [ + { relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }, + { relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 }, + ], }, }) .where(eq(devices.id, CONTROLLER)) @@ -259,4 +262,96 @@ describe("ButtonLightController truth table", () => { expect(ctl.confirmedOf(CONTROLLER)).toBe(true); ctl.stop(); }); + + it("drives two alert relays on one controller independently", async () => { + const R3 = 3; + const R4 = 4; + const I2 = 2; + const I3 = 3; + // Controller with two alert lamps, each on its own trigger input. + db.update(devices) + .set({ + config: { + host: "10.0.0.5", + relays: [ + { relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" }, + { relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 }, + { relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 }, + ], + }, + }) + .where(eq(devices.id, CONTROLLER)) + .run(); + const calls: Array<{ ch: number; on: boolean }> = []; + const aux = fakeAux(calls); + const ctl = new ButtonLightController(db, silentLogger(), () => aux); + ctl.start(); + await flush(); + expect(ctl.stateOf(CONTROLLER, R3)).toBe("off"); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("off"); + + // I2 active → only R3 blinks; R4 stays off (different trigger). + deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" }); + await flush(); + expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink"); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("off"); + + // I3 active → R4 blinks too, independently. + deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" }); + await flush(); + expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink"); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink"); + + // Camera confirms a car → BOTH lock solid (lane-busy is site-wide). + lane(true); + await flush(); + expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid"); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid"); + + // I2 clears → R3 off, R4 still solid (its trigger still active). + deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" }); + await flush(); + expect(ctl.stateOf(CONTROLLER, R3)).toBe("off"); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid"); + ctl.stop(); + }); + + it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => { + const R4 = 4; + const I5 = 5; // exit radar + db.update(devices) + .set({ + config: { + host: "10.0.0.5", + relays: [ + { relay: 1, direction: "entry" }, + { relay: 2, direction: "exit" }, + // Exit alert lamp: triggers on the exit radar, locks on the EXIT camera. + { relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 }, + ], + }, + }) + .where(eq(devices.id, CONTROLLER)) + .run(); + const aux = fakeAux([]); + const ctl = new ButtonLightController(db, silentLogger(), () => aux); + ctl.start(); + await flush(); + + // Exit radar active → blink. + deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" }); + await flush(); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink"); + + // ENTRY camera busy must NOT lock this exit lamp — it still blinks. + deviceEvents.emitLaneStatus({ entry: true, exit: false }); + await flush(); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink"); + + // EXIT camera busy → SOLID. + deviceEvents.emitLaneStatus({ entry: true, exit: true }); + await flush(); + expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid"); + ctl.stop(); + }); }); diff --git a/apps/server/src/button-light.ts b/apps/server/src/button-light.ts index 62ecdaf..c413cca 100644 --- a/apps/server/src/button-light.ts +++ b/apps/server/src/button-light.ts @@ -2,28 +2,33 @@ 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"; +import { alertRelaysOf, relayForPresence, type RelaySpec } 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) +// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V +// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger +// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement +// indicator: +// trigger active + lane busy (camera confirms a car) → SOLID on +// trigger active + 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. +// misleading solid "go". A controller may have several alert relays (each its own row + +// trigger input), keyed independently. 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. */ +/** Per-lamp live state for the alert rule (one per radarAlert relay). */ interface LampState { - /** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the - * admin changes the button-light config without a restart. */ - spec: ButtonLightSpec; - /** Is the radar (presence input on an entry relay) currently active? */ + /** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */ + readonly controllerId: string; + /** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in + * place when the admin changes the alert config without a restart. */ + spec: RelaySpec; + /** Is the lamp's trigger input (the radar) currently active? */ present: boolean; /** The high-level state we're rendering (to avoid restarting a running blink). */ rendered: LightState | null; @@ -50,10 +55,13 @@ export class ButtonLightController { readonly #db: Db; readonly #logger: FastifyBaseLogger; readonly #resolveAux: AuxResolver; - /** Per-controller state, keyed by controller deviceId. */ + /** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */ readonly #lamps = new Map(); - /** Latest lane status (entry busy = a camera-confirmed car in the entry zone). */ + /** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks + * SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the + * exit camera, not the entry one. */ #entryBusy = false; + #exitBusy = false; /** Controllers we've already warned lack the aux-output capability (warn once). */ readonly #warned = new Set(); #unsubInput: (() => void) | null = null; @@ -69,7 +77,7 @@ export class ButtonLightController { start(): void { this.#reconcile(); // All lamps start OFF (known-safe baseline) regardless of prior device state. - for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp); + for (const lamp of this.#lamps.values()) this.#apply(lamp); this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e)); this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s)); @@ -86,34 +94,36 @@ export class ButtonLightController { const seen = new Set(); for (const row of rows) { if (!row.enabled) continue; - const spec = buttonLightOf(row); - if (!spec) continue; - seen.add(row.id); - const existing = this.#lamps.get(row.id); - if (existing) { - existing.spec = spec; // pick up a changed relay # / blink cadence - } else { - this.#lamps.set(row.id, { - spec, - present: false, - rendered: null, - blink: null, - blinkOn: false, - desiredOn: false, - confirmedOn: null, - sending: false, - }); + for (const spec of alertRelaysOf(row)) { + const key = lampKey(row.id, spec.relay); + seen.add(key); + const existing = this.#lamps.get(key); + if (existing) { + existing.spec = spec; // pick up a changed trigger input / blink cadence + } else { + this.#lamps.set(key, { + controllerId: row.id, + spec, + present: false, + rendered: null, + blink: null, + blinkOn: false, + desiredOn: false, + confirmedOn: null, + sending: false, + }); + } } } // Drop lamps whose controller no longer declares one (or was disabled/removed). - for (const [id, lamp] of this.#lamps) { - if (seen.has(id)) continue; + for (const [key, lamp] of this.#lamps) { + if (seen.has(key)) continue; if (lamp.blink) { clearInterval(lamp.blink); lamp.blink = null; } - this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it - this.#lamps.delete(id); + this.#finalOff(lamp); // best-effort fail-OFF before forgetting it + this.#lamps.delete(key); } } @@ -123,28 +133,36 @@ export class ButtonLightController { #onInput(e: DeviceInputEvent): void { // Reconcile first so a lamp added/changed since boot (no restart) is picked up. this.#reconcile(); - 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); + for (const lamp of this.#lamps.values()) { + if (lamp.controllerId !== e.deviceId) continue; + // A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's + // entry-relay presence terminal (resolved the SAME way the entry flow does) so the + // lamp and the one-car-one-ticket gate always agree on "a car is here". + const trigger = + lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput; + if (trigger !== e.input) continue; // not this lamp's trigger terminal + if (present === lamp.present) continue; + lamp.present = present; + this.#apply(lamp); + } } - /** Lane status changed: entry busy = a camera-confirmed car in the entry zone. */ + /** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */ #onLane(s: LaneStatusEvent): void { - if (s.entry === this.#entryBusy) return; + if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) 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); + this.#exitBusy = s.exit; + // Re-render every lamp (each picks its own lane's camera in #apply). + for (const lamp of this.#lamps.values()) this.#apply(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"; + #apply(lamp: LampState): void { + // SOLID only once THIS lamp's lane camera confirms a car (default entry). + const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy; + const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink"; if (target === lamp.rendered) return; // already rendering this state // Tear down any running blink before switching states. @@ -156,10 +174,10 @@ export class ButtonLightController { if (target === "off") { lamp.desiredOn = false; - this.#pump(controllerId, lamp); + this.#pump(lamp); } else if (target === "solid") { lamp.desiredOn = true; - this.#pump(controllerId, lamp); + this.#pump(lamp); } else { // BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual // (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one @@ -172,7 +190,7 @@ export class ButtonLightController { const tick = () => { lamp.blinkOn = !lamp.blinkOn; lamp.desiredOn = lamp.blinkOn; - this.#pump(controllerId, lamp); + this.#pump(lamp); if (onMs !== offMs && lamp.blink) { clearInterval(lamp.blink); lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs); @@ -181,7 +199,7 @@ export class ButtonLightController { }; lamp.blink = setInterval(tick, onMs); lamp.blink.unref?.(); - this.#pump(controllerId, lamp); + this.#pump(lamp); } } @@ -190,10 +208,10 @@ export class ButtonLightController { * the relay stuck on a stale packet. Here a single in-flight send is guaranteed * (`sending` guard); when it resolves, if the desired state moved on we send again — * so the LAST desired state is always the one finally asserted on the device. */ - #pump(controllerId: string, lamp: LampState): void { + #pump(lamp: LampState): void { if (lamp.sending) return; // a send is already in flight; it'll re-check on completion if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP - const aux = this.#resolveAux(controllerId); + const aux = this.#resolveAux(lamp.controllerId); if (!aux) return; const target = lamp.desiredOn; lamp.sending = true; @@ -204,13 +222,13 @@ export class ButtonLightController { }) .catch((err: unknown) => { // Leave confirmedOn unchanged so the next pump retries this state. Never escalates. - this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`); + this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`); }) .finally(() => { lamp.sending = false; // Desired state may have changed (or the send failed) while we were busy — // re-pump to converge. This is what makes the final state authoritative. - if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(controllerId, lamp); + if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp); }); } @@ -242,34 +260,48 @@ export class ButtonLightController { this.#unsubLane?.(); this.#unsubInput = null; this.#unsubLane = null; - for (const [controllerId, lamp] of this.#lamps) { + for (const lamp of this.#lamps.values()) { if (lamp.blink) { clearInterval(lamp.blink); lamp.blink = null; } // Best-effort fail-OFF on shutdown. - this.#finalOff(controllerId, lamp); + this.#finalOff(lamp); } } /** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired * OFF and pump. The serialized worker still applies, so this can't collide with an * in-flight send — it converges to OFF. */ - #finalOff(controllerId: string, lamp: LampState): void { + #finalOff(lamp: LampState): void { lamp.desiredOn = false; - this.#pump(controllerId, lamp); + this.#pump(lamp); } - /** Test seam: current high-level state being rendered for a controller. */ - stateOf(controllerId: string): LightState | null { - return this.#lamps.get(controllerId)?.rendered ?? null; + /** Test seam: current high-level state being rendered for a lamp (controller + relay). + * `relay` defaults to the controller's only/first alert relay for single-lamp tests. */ + stateOf(controllerId: string, relay?: number): LightState | null { + return this.#lamp(controllerId, relay)?.rendered ?? null; } - /** Test seam: the state last CONFIRMED on the device for a controller (after a - * successful send). null = unknown / nothing sent yet. */ - confirmedOf(controllerId: string): boolean | null { - return this.#lamps.get(controllerId)?.confirmedOn ?? null; + /** Test seam: the state last CONFIRMED on the device for a lamp (after a successful + * send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */ + confirmedOf(controllerId: string, relay?: number): boolean | null { + return this.#lamp(controllerId, relay)?.confirmedOn ?? null; } + + /** Resolve a lamp by controller + relay. When `relay` is omitted, returns the + * controller's single lamp (the common single-alert case); ambiguous if several. */ + #lamp(controllerId: string, relay?: number): LampState | undefined { + if (relay != null) return this.#lamps.get(lampKey(controllerId, relay)); + for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp; + return undefined; + } +} + +/** Composite key for the lamp map (a controller may carry several alert relays). */ +function lampKey(controllerId: string, relay: number): string { + return `${controllerId}:${relay}`; } /** Build a controller row's live aux device (exported for reuse/tests). */ diff --git a/apps/server/src/device-monitor.ts b/apps/server/src/device-monitor.ts index 46dbab4..0652532 100644 --- a/apps/server/src/device-monitor.ts +++ b/apps/server/src/device-monitor.ts @@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { return d; } case "access": { - const dirs = new Set(relaysOf(row).map((r) => r.direction)); + // Only barrier relays carry a role direction; alert (radarAlert) relays don't. + const dirs = new Set( + relaysOf(row) + .map((r) => r.direction) + .filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"), + ); if (dirs.size === 0) return null; if (dirs.size > 1) return "mixed"; const only = [...dirs][0]; // entry | exit | both diff --git a/apps/server/src/device-resolve.test.ts b/apps/server/src/device-resolve.test.ts new file mode 100644 index 0000000..9a2d4c9 --- /dev/null +++ b/apps/server/src/device-resolve.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { devices, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js"; + +// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class +// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay +// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from +// either shape, so an exit radar = just another presence row. + +let db: Db; +const CTL = "ctl-1"; + +function seed(config: Record): void { + ({ db } = createTestDb()); + db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run(); +} + +describe("inputsOf back-compat synth", () => { + it("synthesizes inputs[] from legacy relay button/presence fields", () => { + seed({ + relays: [ + { relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true }, + { relay: 2, direction: "exit" }, + ], + }); + const row = db.select().from(devices).get()!; + const inputs = inputsOf(row); + expect(inputs).toEqual([ + { input: 1, role: "button", relay: 1, cooldownSec: undefined }, + { input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true }, + ]); + }); + + it("prefers an explicit inputs[] over the legacy fields", () => { + seed({ + relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }], + inputs: [{ input: 1, role: "button", relay: 1 }], + }); + const row = db.select().from(devices).get()!; + expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]); + }); +}); + +describe("relayForButton / relayForPresence", () => { + it("resolves a button + presence from inputs[]", () => { + seed({ + relays: [{ relay: 1, direction: "entry" }], + inputs: [ + { input: 1, role: "button", relay: 1 }, + { input: 2, role: "presence", relay: 1, kind: "radar" }, + ], + }); + const byBtn = relayForButton(db, CTL, 1); + expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" }); + const byPres = relayForPresence(db, CTL, 2); + expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 }); + }); + + it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => { + seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] }); + expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" }); + expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 }); + }); + + it("resolves an EXIT presence row to the exit relay (the exit radar)", () => { + seed({ + relays: [ + { relay: 1, direction: "entry" }, + { relay: 2, direction: "exit" }, + ], + inputs: [ + { input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar + { input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar + ], + }); + // NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar + // resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid + // inputs[] row the lamp can trigger on. The entry radar resolves to relay 1. + expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 }); + expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate + }); + + it("a button on an exit-only relay is not a transient-entry trigger", () => { + seed({ + relays: [{ relay: 2, direction: "exit" }], + inputs: [{ input: 1, role: "button", relay: 2 }], + }); + expect(relayForButton(db, CTL, 1)).toBeNull(); + }); +}); diff --git a/apps/server/src/device-resolve.ts b/apps/server/src/device-resolve.ts index 0ff39a2..5f29ef0 100644 --- a/apps/server/src/device-resolve.ts +++ b/apps/server/src/device-resolve.ts @@ -10,55 +10,74 @@ export type Direction = "entry" | "exit" | "both"; /** A concrete flow a credential/button drives (never "both"). */ export type FlowDirection = "entry" | "exit"; -/** One relay on an access controller: which barrier it opens, in which direction, - * and (optionally) the input terminals its entry button + presence loop are wired to. */ +/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the + * `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is + * active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" — + * the action is implied by the event. See wiki/concepts/button-light-indicator.md. */ +export type RelayEvent = Direction | "radarAlert"; + +/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence` + * = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that + * starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */ +export type InputRole = "button" | "presence" | "alertTrigger"; + +/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec). + * An exit radar is just another `presence` row serving the exit relay. */ +export interface InputSpec { + /** 1-based input terminal the host reads. */ + readonly input: number; + readonly role: InputRole; + /** The barrier relay this input serves. Required for `button`/`presence` (the gate is + * keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */ + readonly relay?: number; + /** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */ + readonly kind?: "loop" | "radar"; + /** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button. + * Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */ + readonly activeLow?: boolean; + /** `button` only — presence-less fallback: suppress repeat presses for N seconds after a + * ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */ + readonly cooldownSec?: number; +} + +/** One relay on an access controller: the event it reacts to. Input wiring (button, + * presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read + * (back-compat) but no longer written by the UI. */ export interface RelaySpec { /** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */ readonly relay: number; - readonly direction: Direction; - /** 1-based input terminal of the entry button that fires this relay (transient - * entry). Absent = no button at this barrier (subscriber/reader-driven only). */ + /** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → + * drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */ + readonly direction: RelayEvent; + + // ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ── + // Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()` + // synthesizes InputSpec rows from these when a controller has no `inputs[]` yet. readonly button?: number; - /** - * Anti-double-press for the transient entry button (one car must yield ONE ticket). - * Two modes, chosen by what barrier feedback exists at this lane: - * - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the - * 1-based input terminal of an induction loop / barrier presence signal on THIS - * controller. A press prints only while a car is present, and no second ticket - * issues until the loop CLEARS (car drove in) and a new car re-occupies it. This - * makes one-car-one-ticket physical. - * - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses - * on this relay for N seconds after a ticket prints. A pure timer — mitigation, - * not a guarantee. Used when `presenceInput` is unset (or as a secondary guard). - * 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; + // ── radarAlert-only (direction === "radarAlert") ── + // A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's + // 12 V light. Driven by the server ButtonLightController off its trigger input vs. the + // camera lane status: blink while the trigger is active + lane free, SOLID once the + // camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen). + /** 1-based input terminal whose active edge starts the blink (the radar). */ + readonly triggerInput?: number; + /** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default + * "entry". An exit radar's lamp must lock on the EXIT camera. */ + readonly lockLane?: FlowDirection; /** 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). */ +/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */ interface AccessConfig { readonly relays?: RelaySpec[]; - /** Optional button-lamp output on a spare relay. */ - readonly buttonLight?: ButtonLightSpec; + readonly inputs?: InputSpec[]; readonly [k: string]: unknown; } @@ -105,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] { } /** - * Resolve a button press to the relay it fires: the access controller with this - * deviceId, and the relay whose `button` terminal matches the pressed input. Only - * an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise. + * The INPUT terminals declared on an access controller — the back-compat keystone. Returns + * `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY + * per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a + * `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads + * inputs goes through here, so the legacy fold lives in exactly one place. + */ +export function inputsOf(row: DeviceRow): InputSpec[] { + const cfg = row.config as AccessConfig; + if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs; + const synth: InputSpec[] = []; + for (const r of relaysOf(row)) { + if (typeof r.button === "number") { + synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec }); + } + if (typeof r.presenceInput === "number") { + synth.push({ + input: r.presenceInput, + role: "presence", + relay: r.relay, + kind: r.presenceKind ?? "loop", + activeLow: r.presenceActiveLow, + }); + } + } + return synth; +} + +/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null — + * only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */ +function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null { + if (typeof spec.relay !== "number") return null; + const relay = relaysOf(row).find((r) => r.relay === spec.relay); + if (!relay) return null; + if (relay.direction !== "entry" && relay.direction !== "both") return null; + return { ...relay, direction: relay.direction }; +} + +/** + * Resolve a button press to the relay it fires: the access controller with this deviceId, + * and the relay served by the `button` input on this terminal (via inputsOf). Only an + * ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket + * config (presence input + cooldown) for that relay so the entry flow can enforce it. + * Returns null otherwise. */ export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { const row = db @@ -116,24 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number): .where(and(eq(devices.id, controllerId), eq(devices.category, "access"))) .get(); if (!row || !row.enabled) return null; - const spec = relaysOf(row).find((r) => r.button === terminal); - if (!spec) return null; - if (spec.direction !== "entry" && spec.direction !== "both") return null; + const inputs = inputsOf(row); + const btn = inputs.find((i) => i.role === "button" && i.input === terminal); + if (!btn) return null; + const relay = barrierForInput(row, btn); + if (!relay) return null; + // The presence sensor (if any) serving the SAME relay supplies the gate. + const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay); return { controller: row, - relay: spec.relay, - direction: spec.direction, - presenceInput: spec.presenceInput, - presenceKind: spec.presenceKind ?? "loop", - entryCooldownSec: spec.entryCooldownSec, + relay: relay.relay, + direction: relay.direction, + presenceInput: presence?.input, + presenceKind: presence?.kind ?? "loop", + entryCooldownSec: btn.cooldownSec, }; } /** - * Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with - * this deviceId, and the relay whose `presenceInput` terminal matches the fired input. - * Lets the entry flow track "a car is physically at this entry barrier" so it issues - * exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise. + * Resolve a PRESENCE input edge to the entry relay it gates: the controller with this + * deviceId, and the relay served by the `presence` input on this terminal. Lets the entry + * flow track "a car is physically at this entry barrier" so it issues exactly one ticket + * per car. Only entry/both relays gate transient entry. Null otherwise. */ export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { const row = db @@ -142,23 +205,23 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number) .where(and(eq(devices.id, controllerId), eq(devices.category, "access"))) .get(); if (!row || !row.enabled) return null; - const spec = relaysOf(row).find((r) => r.presenceInput === terminal); - if (!spec) return null; - if (spec.direction !== "entry" && spec.direction !== "both") return null; + const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal); + if (!presence) return null; + const relay = barrierForInput(row, presence); + if (!relay) return null; return { controller: row, - relay: spec.relay, - direction: spec.direction, - presenceInput: spec.presenceInput, - presenceKind: spec.presenceKind ?? "loop", + relay: relay.relay, + direction: relay.direction, + presenceInput: presence.input, + presenceKind: presence.kind ?? "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; +/** The alert (radarAlert) relay rows declared on an access controller — the lamps the + * ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */ +export function alertRelaysOf(row: DeviceRow): RelaySpec[] { + return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number"); } /** @@ -180,7 +243,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu .get(); if (controller && controller.enabled) { const spec = relaysOf(controller).find((r) => r.relay === cfg.relay); - if (spec) return { controller, relay: spec.relay, direction: spec.direction }; + // Only a barrier relay opens; an alert (radarAlert) relay is never a barrier. + if (spec && spec.direction !== "radarAlert") { + return { controller, relay: spec.relay, direction: spec.direction }; + } } return null; } @@ -200,7 +266,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null { for (const controller of accessRows(db)) { const spec = relaysOf(controller).find( - (r) => r.direction === direction || r.direction === "both", + (r): r is RelaySpec & { direction: Direction } => + r.direction === direction || r.direction === "both", ); if (spec) return { controller, relay: spec.relay, direction: spec.direction }; } diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 27c740f..d154102 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -15,13 +15,15 @@ import { type PrintTestResult, type Assignment, type BackendIpCandidate, - type ButtonLightSpec, type Catalog, type CatalogEntry, type DeviceCategory, type DeviceConfig, type Direction, type DiscoveredDevice, + type InputRole, + type InputSpec, + type RelayEvent, type RelaySpec, type TestResult, } from "./api.js"; @@ -49,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [ { key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" }, ]; -// Translated direction label (relay direction / inherited binding). -const DIRECTION_KEYS: Record = { +// Translated relay-event label (barrier direction, inherited binding, or alert). +const DIRECTION_KEYS: Record = { entry: "setup.dirEntry", exit: "setup.dirExit", both: "setup.dirBoth", + radarAlert: "setup.eventRadarAlert", }; +// The input-role dropdown folds presence `kind` into the choice: one select offers Button, +// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair. +type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger"; +const INPUT_CHOICE_KEYS: Record = { + button: "setup.roleButton", + presenceLoop: "setup.rolePresenceLoop", + presenceRadar: "setup.rolePresenceRadar", + alertTrigger: "setup.roleAlertTrigger", +}; +function choiceOf(i: InputSpec): InputChoice { + if (i.role === "button") return "button"; + if (i.role === "alertTrigger") return "alertTrigger"; + return i.kind === "radar" ? "presenceRadar" : "presenceLoop"; +} +function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } { + switch (choice) { + case "button": + return { role: "button" }; + case "alertTrigger": + return { role: "alertTrigger" }; + case "presenceLoop": + return { role: "presence", kind: "loop" }; + case "presenceRadar": + return { role: "presence", kind: "radar" }; + } +} + +/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an + * existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the + * server's `inputsOf()` back-compat fold. */ +function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] { + const out: InputSpec[] = []; + for (const r of relays) { + if (typeof r.button === "number") { + out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec }); + } + if (typeof r.presenceInput === "number") { + out.push({ + input: r.presenceInput, + role: "presence", + relay: r.relay, + kind: r.presenceKind ?? "loop", + activeLow: r.presenceActiveLow, + }); + } + } + return out; +} + export function SetupWizard() { const { t } = useTranslation(); const [catalog, setCatalog] = useState(null); @@ -82,7 +134,7 @@ export function SetupWizard() { return (

{t("setup.title")}

-

{t("setup.intro")}

+

{t("setup.intro")}

{t("setup.noRelaysSet")}; - const bl = cfg.buttonLight as ButtonLightSpec | undefined; + // Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields. + const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays); return ( {relays.map((r) => { - const presence = r.presenceInput - ? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}` - : ""; - return ( - - ); + // Alert relay: trigger input + lock lane. Barrier: its button + presence inputs. + let wiring = ""; + if (r.direction === "radarAlert") { + if (r.triggerInput) wiring += `·trig${r.triggerInput}`; + if (r.lockLane === "exit") wiring += "·lockExit"; + } else { + const served = inputs.filter((x) => x.relay === r.relay); + const btn = served.find((x) => x.role === "button"); + const pres = served.find((x) => x.role === "presence"); + if (btn) wiring += `·btn${btn.input}`; + if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`; + } + return ; })} - {bl?.relay != null && ( - - )} ); } @@ -365,14 +418,19 @@ function DeviceForm({ } return out; }); - // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). + // Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both + // (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger + // input + blink cadence; barriers carry no input wiring (that lives in `inputs` below). 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; + // Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming + // the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the + // legacy per-relay button/presence fields so an existing controller opens populated. + const [inputs, setInputs] = useState(() => { + const stored = editCfg?.inputs; + if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[]; + return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []); }); // Bound devices: which controller + relay this device sits at. const [controllerId, setControllerId] = useState( @@ -487,23 +545,31 @@ function DeviceForm({ function mergedConfig(): DeviceConfig { const out: DeviceConfig = { ...mergedScalarConfig() }; if (isController) { - out.relays = relays.map((r) => ({ - relay: r.relay, - 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 } : {}), - }; - } + // Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs. + out.relays = relays.map((r) => + r.direction === "radarAlert" + ? { + // Alert lamp: trigger input + lock lane + blink cadence. + relay: r.relay, + direction: r.direction, + ...(r.triggerInput ? { triggerInput: r.triggerInput } : {}), + ...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}), + ...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}), + ...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}), + } + : { relay: r.relay, direction: r.direction }, + ); + // Inputs: a button/presence row needs its relay; alertTrigger may be standalone. + out.inputs = inputs + .filter((i) => typeof i.input === "number" && i.input > 0) + .map((i) => ({ + input: i.input, + role: i.role, + ...(typeof i.relay === "number" ? { relay: i.relay } : {}), + ...(i.role === "presence" && i.kind ? { kind: i.kind } : {}), + ...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}), + ...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}), + })); } else if (controllerId && boundRelay !== "") { out.controllerId = controllerId; out.relay = boundRelay; @@ -749,13 +815,11 @@ function DeviceForm({ ), )} - {/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */} + {/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */} {isController && ( { setConfig((c) => ({ ...c, pulseMs: v })); @@ -764,12 +828,13 @@ function DeviceForm({ /> )} - {/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound - to the output relay it drives. Separated from the outputs above. */} + {/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger), + each naming the relay it serves. Separated from the outputs above. */} {isController && ( { setConfig((c) => ({ ...c, inputRestingHigh: v })); @@ -1008,24 +1073,21 @@ function DeviceForm({ } // ── Controller OUTPUTS (relays) ──────────────────────────────────────────── -// A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section -// owns relay number + direction, the pulse-open time (relay hold ms), and the lamp -// relay. The INPUT terminals wired to these relays live in InputEditor below — the two -// are deliberately separated (a controller's inputs and outputs are distinct things). +// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert +// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns +// the relay number + event, the pulse-open hold time (barriers), and — for alert relays — +// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence) +// live in InputEditor below; the two are deliberately separated. -/** Relays = outputs (barriers + lamp) + the pulse-open hold time. */ +/** Relays = the unified event→action outputs + the pulse-open hold time. */ function OutputEditor({ relays, onChange, - buttonLight, - onButtonLightChange, pulseMs, onPulseMsChange, }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void; - buttonLight: ButtonLightSpec | null; - onButtonLightChange: (v: ButtonLightSpec | null) => void; pulseMs: number | undefined; onPulseMsChange: (v: number) => void; }) { @@ -1040,7 +1102,6 @@ function OutputEditor({ function remove(i: number) { onChange(relays.filter((_, idx) => idx !== i)); } - const barrierRelays = new Set(relays.map((r) => r.relay)); return (
@@ -1060,7 +1121,8 @@ function OutputEditor({ /> - {/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */} + {/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence; + barriers pulse (their button/presence terminals are in the Inputs section). */} {relays.map((r, i) => (
- update(i, { direction: e.target.value as RelayEvent })} + > + {(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => ( ))} + + {/* Alert relay: which input fires the blink + the blink cadence. */} + {r.direction === "radarAlert" && ( + <> + + + + + + )} + {relays.length > 1 && ( - - {/* Button-lamp output (a spare relay) — an OUTPUT, so it lives here. Driven by the - radar + camera (blink = radar-only, solid = car confirmed, off otherwise). */} -
- - {t("setup.buttonLight")} - - - {buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && ( - {t("setup.buttonLightBarrierWarn")} - )} - {buttonLight?.relay != null && ( - <> - - - - )} -
); } // ── Controller INPUTS (terminals) ────────────────────────────────────────── -// An input is a TERMINAL the host READS: the entry button, the presence/radar sensor. -// Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we -// render one block per entry/both relay, labelled with the output relay it drives. The -// button never SETS a pulse — its electrical pulse is the device's to report — so no -// timing field lives here (pulse-open is an OUTPUT setting, in OutputEditor). +// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays +// list above): each row is a terminal + a ROLE (entry button / presence loop / presence +// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The +// button never SETS a pulse — its electrical pulse is the device's to report — so no timing +// field lives here (pulse-open is an OUTPUT setting, in OutputEditor). -/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */ +/** Generic controller-input list: terminal + role + the relay it serves. */ function InputEditor({ - relays, + inputs, onChange, + relays, inputsIdleHigh, onInputsIdleHighChange, }: { + inputs: InputSpec[]; + onChange: (v: InputSpec[]) => void; relays: RelaySpec[]; - onChange: (r: RelaySpec[]) => void; inputsIdleHigh: boolean | undefined; onInputsIdleHighChange: (v: boolean) => void; }) { const { t } = useTranslation(); - function update(i: number, patch: Partial) { - onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + function update(i: number, patch: Partial) { + onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row))); } - // Inputs only matter for entry/both relays (transient entry). Keep each row's real - // index so updates target the right relay. - const entryRelays = relays - .map((r, i) => ({ r, i })) - .filter(({ r }) => r.direction === "entry" || r.direction === "both"); + function add() { + const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both"); + onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]); + } + function remove(i: number) { + onChange(inputs.filter((_, idx) => idx !== i)); + } + // Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't). + const barrierRelays = relays.filter((r) => r.direction !== "radarAlert"); + // A button row shows its cooldown fallback only if no presence row serves the same relay. + const hasPresenceFor = (relay?: number) => + relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay); return (
@@ -1196,77 +1268,91 @@ function InputEditor({ - {entryRelays.length === 0 ? ( -

{t("setup.inputsNoEntryRelay")}

- ) : ( - entryRelays.map(({ r, i }) => ( + {inputs.map((row, i) => { + const choice = choiceOf(row); + const isPresence = row.role === "presence"; + const isButton = row.role === "button"; + return (
- - {t("setup.inputsForRelay", { relay: r.relay })} - - - {/* Sensor kind + active-level — only once a presence terminal is set. */} - {!!r.presenceInput && ( - <> - - - + + + {/* Which barrier this input serves — button/presence only (alert triggers a lamp). */} + {row.role !== "alertTrigger" && ( + )} - {/* Cooldown fallback only when no presence sensor is wired. */} - {!r.presenceInput && ( + + {/* Presence: active-low (a radar wired opposite the button). */} + {isPresence && ( + + )} + + {/* Button cooldown fallback — only when no presence sensor serves this relay. */} + {isButton && !hasPresenceFor(row.relay) && ( )} + +
- )) - )} + ); + })} +
); } @@ -1325,11 +1411,14 @@ function BindingPicker({ - {relays.map((r) => ( - - ))} + {/* Only barrier relays are bindable — an alert lamp opens nothing. */} + {relays + .filter((r) => r.direction !== "radarAlert") + .map((r) => ( + + ))} {chosen && } @@ -1341,14 +1430,16 @@ function BindingPicker({ ); } -function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) { - // entry=green, exit=amber, both=muted — aligned to the terminal accent palette. +function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) { + // entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents. const cls = direction === "entry" ? "border-term-green text-term-green" : direction === "exit" ? "border-term-amber text-term-amber" - : "border-term-muted text-term-muted"; + : direction === "radarAlert" + ? "border-term-red text-term-red" + : "border-term-muted text-term-muted"; return ( {label ?? direction} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 09f6acf..f46473f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -275,30 +275,49 @@ export type DeviceConfig = Record; /** Direction a barrier/relay (or a device bound to it) serves. */ export type Direction = "entry" | "exit" | "both"; -/** One relay on an access controller: which barrier it opens, in which direction, - * and (optionally) the input terminal its entry button is wired to. */ -export interface RelaySpec { - relay: number; - direction: Direction; - /** Input terminal of the entry button that fires this relay (transient entry). */ - button?: number; - /** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle - * 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; +/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a + * non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera + * confirms a car). The action is implied by the event. */ +export type RelayEvent = Direction | "radarAlert"; + +/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket + * `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */ +export type InputRole = "button" | "presence" | "alertTrigger"; + +/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another + * `presence` row serving the exit relay. */ +export interface InputSpec { + input: number; + role: InputRole; + /** The barrier relay this input serves (required for button/presence; optional for + * alertTrigger). */ + relay?: number; + /** presence only — induction LOOP or RADAR (label only). */ + kind?: "loop" | "radar"; + /** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */ + activeLow?: boolean; + /** button only — presence-less fallback cooldown (seconds). */ + cooldownSec?: 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. */ +/** One relay on an access controller: the event it reacts to. Input wiring lives in + * `config.inputs[]`; the legacy per-relay button/presence fields are still read for + * back-compat but no longer written. */ +export interface RelaySpec { relay: number; + /** The event this relay reacts to (UI label: "Event"). */ + direction: RelayEvent; + // ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ── + button?: number; + presenceInput?: number; + presenceKind?: "loop" | "radar"; + presenceActiveLow?: boolean; + entryCooldownSec?: number; + // ── radarAlert-only ── + /** Input terminal whose active edge starts the blink (the radar). */ + triggerInput?: number; + /** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */ + lockLane?: "entry" | "exit"; /** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */ blinkOnMs?: number; blinkOffMs?: number; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index c8779b4..2fef6d6 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -369,27 +369,30 @@ export const en: Catalog = { "Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.", inputsIdleHigh: "Inputs idle HIGH", inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.", - inputsForRelay: "For relay {{relay}}", - inputsNoEntryRelay: "No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.", relay: "Relay", - entryButtonTerminal: "Entry button on terminal", - presenceInput: "Presence sensor (terminal)", - presenceInputHint: - "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.", + // Generic input rows: terminal + role + the relay it serves. + inputTerminal: "Terminal", + inputServesRelay: "Serves relay", + roleButton: "Entry button", + rolePresenceLoop: "Presence (loop)", + rolePresenceRadar: "Presence (radar)", + roleAlertTrigger: "Alert trigger", + addInput: "+ Add input", entryCooldown: "Cooldown after ticket (s)", entryCooldownHint: "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: + activeLow: "Active-low", + activeLowHint: "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.", + eventRadarAlert: "Radar alert (lamp)", + triggerInput: "Trigger input", + triggerInputHint: + "The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.", + lockLane: "Lock from", + lockLaneHint: + "Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.", + lockLaneEntry: "Entry camera", + lockLaneExit: "Exit camera", blinkOnMs: "Blink on (ms)", blinkOffMs: "Blink off (ms)", addRelay: "+ Add relay", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 7e8a48e..21be3be 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -378,27 +378,30 @@ export const sq = { "Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.", inputsIdleHigh: "Hyrjet në pushim HIGH", inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.", - inputsForRelay: "Për rele {{relay}}", - inputsNoEntryRelay: "Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.", relay: "Rele", - entryButtonTerminal: "Butoni i hyrjes në terminalin", - presenceInput: "Sensori i pranisë (terminali)", - presenceInputHint: - "Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.", + // Generic input rows: terminal + role + the relay it serves. + inputTerminal: "Terminali", + inputServesRelay: "I shërben reles", + roleButton: "Butoni i hyrjes", + rolePresenceLoop: "Prania (lak induktiv)", + rolePresenceRadar: "Prania (radar)", + roleAlertTrigger: "Trigger alarmi", + addInput: "+ Shto hyrje", 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: + activeLow: "Aktiv-ulët", + activeLowHint: "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ë.", + eventRadarAlert: "Alarm radar (dritë)", + triggerInput: "Trigger input", + triggerInputHint: + "Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.", + lockLane: "Bllokimi nga", + lockLaneHint: + "Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.", + lockLaneEntry: "Kamera e hyrjes", + lockLaneExit: "Kamera e daljes", blinkOnMs: "Pulsim ndezur (ms)", blinkOffMs: "Pulsim fikur (ms)", addRelay: "+ Shto rele", diff --git a/packages/devices/src/drivers/access-dingtian.test.ts b/packages/devices/src/drivers/access-dingtian.test.ts index 41d619a..188e452 100644 --- a/packages/devices/src/drivers/access-dingtian.test.ts +++ b/packages/devices/src/drivers/access-dingtian.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { inputActive } from "./access-dingtian.js"; +import { activeLowFrom, 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 @@ -31,3 +31,32 @@ describe("inputActive (per-input active-level)", () => { expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear }); }); + +describe("activeLowFrom (config → active-low terminal set)", () => { + it("reads a config.inputs[] presence row with activeLow", () => { + const set = activeLowFrom({ + inputs: [ + { input: 1, role: "button", relay: 1 }, + { input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true }, + { input: 5, role: "presence", relay: 2, kind: "radar", activeLow: true }, // exit radar + ], + }); + expect([...set].sort()).toEqual([2, 5]); // both radars inverted; the button is not + }); + + it("still reads the LEGACY relays[].presenceActiveLow (back-compat)", () => { + const set = activeLowFrom({ + relays: [{ relay: 1, direction: "entry", presenceInput: 2, presenceActiveLow: true }], + }); + expect([...set]).toEqual([2]); + }); + + it("honours an explicit top-level inputActiveLow escape hatch + merges all sources", () => { + const set = activeLowFrom({ + inputActiveLow: [3], + inputs: [{ input: 2, role: "presence", activeLow: true }], + relays: [{ relay: 1, direction: "entry", presenceInput: 4, presenceActiveLow: true }], + }); + expect([...set].sort()).toEqual([2, 3, 4]); + }); +}); diff --git a/packages/devices/src/drivers/access-dingtian.ts b/packages/devices/src/drivers/access-dingtian.ts index 417c4cd..5b913a9 100644 --- a/packages/devices/src/drivers/access-dingtian.ts +++ b/packages/devices/src/drivers/access-dingtian.ts @@ -183,6 +183,33 @@ export function inputActive( return activeLow.has(channel1Based) ? !high : high !== restingHigh; } +/** Build the set of 1-based ACTIVE-LOW input terminals from a controller config. Three + * sources, all merged: (a) `config.inputs[]` presence rows with `activeLow:true` (the + * first-class model); (b) LEGACY per-relay `presenceActiveLow` (pre-inputs[] configs); + * (c) an explicit top-level `inputActiveLow` array (escape hatch). A radar terminal wired + * opposite the button idles HIGH, so it must be read inverted. */ +export function activeLowFrom(config: Record): Set { + const set = new Set(); + const add = (n: unknown) => { + const v = Number(n); + if (Number.isInteger(v) && v > 0) set.add(v); + }; + if (Array.isArray(config.inputActiveLow)) { + for (const n of config.inputActiveLow as unknown[]) add(n); + } + if (Array.isArray(config.inputs)) { + for (const i of config.inputs as Array>) { + if (i?.role === "presence" && i?.activeLow === true) add(i.input); + } + } + if (Array.isArray(config.relays)) { + for (const r of config.relays as Array>) { + if (r?.presenceActiveLow === true) add(r.presenceInput); + } + } + return set; +} + const INPUT_LINK_ISSUE = { key: "input_link_relay", message: @@ -294,22 +321,7 @@ 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(); - 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>) { - if (r?.presenceActiveLow === true && Number.isInteger(Number(r.presenceInput))) { - this.#inputActiveLow.add(Number(r.presenceInput)); - } - } - } + this.#inputActiveLow = activeLowFrom(config); 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). diff --git a/wiki/concepts/button-light-indicator.md b/wiki/concepts/button-light-indicator.md index 90eeaed..5a346db 100644 --- a/wiki/concepts/button-light-indicator.md +++ b/wiki/concepts/button-light-indicator.md @@ -1,46 +1,59 @@ --- type: concept -tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door] +tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay] sources: [] -updated: 2026-06-24 +updated: 2026-06-28 status: settled --- -# Button-light indicator (radar × camera disagreement lamp) +# Alert relays (radar × camera disagreement lamp) -The entry button has a **12 V light**. It is driven by the host on a **spare relay** of the -[[dingtian-relay|Dingtian]] controller as a 3-state indicator that combines the **[[hikvision-radar| -radar]]** input with the **camera "car in zone"** signal: +A relay on the [[dingtian-relay|Dingtian]] controller is uniformly **"when EVENT X happens, do +action Y"** — see [[entry-exit-points|relays carry an event]]. The barrier events (`entry`/`exit`/ +`both`) **pulse** a barrier; the **`radarAlert`** event drives a non-barrier **indicator lamp** +(blink + camera-lock) on a spare relay. The entry button's **12 V light** is the canonical alert +relay, a 3-state indicator that combines a **[[hikvision-radar|radar]]** trigger input with the +**camera "car in zone"** signal: -| Radar input | Camera (lane entry busy) | Button light | +| Trigger input (radar) | Camera (lane entry busy) | Alert lamp | | --- | --- | --- | -| detecting | **free** — no car confirmed | **BLINK** (~1 Hz) | -| detecting | **busy** — camera confirms a car | **SOLID on** | -| clear | — | **OFF** | +| active | **free** — no car confirmed | **BLINK** (~1 Hz) | +| active | **busy** — camera confirms a car | **SOLID on** | +| inactive | — | **OFF** | It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off. +Because it's just another relay row, a controller can carry **several** alert relays (e.g. R3 and a +future R4), each with its own trigger input — no new config shape, no code change. + ## Signals -- **Radar** = the presence input edge on the entry relay (`relays[].presenceInput`, the same edge - the [[entry-double-press|one-car-one-ticket]] gate observes — so the lamp and the gate always - agree on "a car is here"). -- **Camera "car in zone"** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent` entry - busy/free, from camera vehicle detection). Already advisory; already drives the booth's barrier - lights. No new camera plumbing. +- **Trigger** = the alert relay's own `triggerInput` edge (the [[hikvision-radar|radar]]). When + unset, it falls back to the controller's entry-relay `presenceInput` — the same edge the + [[entry-double-press|one-car-one-ticket]] gate observes, so the lamp and the gate agree on "a car + is here". +- **Lock (camera "car in zone")** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent`, + from camera vehicle detection). Already advisory; already drives the booth's barrier lights. Each + lamp picks **which lane's camera** locks it via `relays[].lockLane: "entry"|"exit"` (default + entry) — so an **exit radar's lamp locks on the EXIT camera**, not the entry one. (Lane-busy is the + only lock *kind* wired today; the model leaves room for others later.) ## Config -A controller-level `config.buttonLight = { relay, blinkOnMs?, blinkOffMs? }` (the operator picks a -**spare** relay — not a barrier relay; the setup UI warns if it overlaps one). Blink defaults to -500 ms / 500 ms. +An alert lamp is a `config.relays[]` row with `direction: "radarAlert"`, carrying +`{ relay, triggerInput?, blinkOnMs?, blinkOffMs? }`. No separate `buttonLight` block (that was the +pre-2026-06-28 shape — barriers and the lamp were two different configs; now they're one list). +Blink defaults to 500 ms / 500 ms. The operator picks a **spare** relay (an alert relay never opens +a barrier; every barrier resolver skips `radarAlert` rows). ## Implementation -`apps/server/src/button-light.ts` — `ButtonLightController` subscribes to `deviceEvents.onInput` -(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the lamp via -a **device-agnostic aux-output** capability. +`apps/server/src/button-light.ts` — `ButtonLightController` reads the `radarAlert` rows +(`alertRelaysOf()` in `device-resolve.ts`), subscribes to `deviceEvents.onInput` (radar) + +`onLaneStatus` (camera), computes the target state **per lamp** (keyed `controllerId:relay`, so +several alert relays on one controller are independent), and drives each lamp via a **device-agnostic +aux-output** capability. - **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never** @@ -68,8 +81,12 @@ a **device-agnostic aux-output** capability. ## Status Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send -+ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. Covered by -`apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the device's -*confirmed* state, fail-OFF, de-dupe, and a lamp-added-after-start reconcile case). ++ hot-reload fixes landed the same day after the lamp stuck on/off on hardware. **Reframed +2026-06-28**: the dedicated `config.buttonLight` block was folded into the unified `relays[]` list as +a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary +event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged. +Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the +device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, and two independent +alert relays on one controller). Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]], -[[barrier-not-a-door]]. +[[entry-exit-points]], [[barrier-not-a-door]]. diff --git a/wiki/concepts/entry-double-press.md b/wiki/concepts/entry-double-press.md index ec10a7f..5898ca2 100644 --- a/wiki/concepts/entry-double-press.md +++ b/wiki/concepts/entry-double-press.md @@ -27,11 +27,16 @@ The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exi whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes: ### PRESENCE mode (preferred — when a vehicle-presence sensor is wired) -`relays[].presenceInput` = the 1-based input terminal of a **vehicle-presence sensor** on the same +Inputs are a first-class `config.inputs[]` list (the twin of `relays[]`): each row is a terminal + +a **role** (`button` / `presence` / `alertTrigger`) + the `relay` it serves. A **presence** row = +the 1-based input terminal of a **vehicle-presence sensor** serving an entry/both relay on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may -be an **induction loop** OR a **[[hikvision-radar|radar]]** (`relays[].presenceKind: "loop"|"radar"` -— a label; the gate behaviour is identical). A radar wired to idle opposite the button needs -`presenceActiveLow: true` so its edge reads correctly. The rule makes one-car-one-ticket **physical**: +be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a +label; the gate behaviour is identical). A radar wired to idle opposite the button needs +`inputs[].activeLow: true` so its edge reads correctly. **Multiple radars (entry + exit) are just +multiple presence rows** — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this +on `relays[].presenceInput/presenceKind/presenceActiveLow`; the resolvers still read those as +back-compat, synthesizing inputs[] from them.) The rule makes one-car-one-ticket **physical**: - A press prints **only while a car is present** on the loop. - After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS** @@ -68,11 +73,14 @@ in telemetry if ever needed. host (single-writer); it is derived from live input edges, never the source of truth. A restart starts armed (the first press after a restart works), which is the safe default. -## As-built (2026-06-19) +## As-built (2026-06-19; inputs[] 2026-06-28) -- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton` - carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to - the entry relay it gates. +- Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]` + (`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy + `relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat + shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve + through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever + gate entry/both relays. An exit radar = a `presence` row on the exit relay. - `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge` tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the telemetry. The guard disarms + stamps the cooldown on **print success** (not on open). diff --git a/wiki/entities/dingtian-relay.md b/wiki/entities/dingtian-relay.md index 05bd920..81186c7 100644 --- a/wiki/entities/dingtian-relay.md +++ b/wiki/entities/dingtian-relay.md @@ -56,18 +56,31 @@ web/config API is on a configurable HTTP port (default **80**), distinct from th ### Spare relays + aux outputs (`setAux`) A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive -**non-barrier indicators** — e.g. the entry button's 12 V lamp (see [[button-light-indicator]]). -Business logic drives them through the device-agnostic `AuxOutputDevice.setAux(channel, on)` (a -latch), **never** the barrier `pulseOpen`. The [[barrier-not-a-door]] rule doesn't apply to an aux -output (it never gates a vehicle), so holding/blinking it is fine. +**non-barrier indicators** — e.g. the entry button's 12 V lamp. Every relay is a `config.relays[]` +row carrying the **event** it reacts to (`direction`): the barrier events (`entry`/`exit`/`both`) +pulse, while a **`radarAlert`** row is an [[button-light-indicator|alert relay]] (blink + camera-lock). +Business logic drives alert relays through the device-agnostic `AuxOutputDevice.setAux(channel, on)` +(a latch), **never** the barrier `pulseOpen`; every barrier resolver skips `radarAlert` rows. The +[[barrier-not-a-door]] rule doesn't apply to an aux output (it never gates a vehicle), so +holding/blinking it is fine. -### Per-input active level (`presenceActiveLow` / `inputActiveLow`) +### Inputs are a first-class list (`config.inputs[]`) + +Input wiring lives in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]` +— the twin of `relays[]`. `role` ∈ `button` | `presence` | `alertTrigger`; a button/presence row +names the `relay` it serves; presence rows carry `kind` (loop/radar) + `activeLow`. Adding an exit +radar is adding a `presence` row. (Pre-2026-06-28 configs wired this on the relay itself — +`relays[].button/presenceInput/...`; `inputsOf()` synthesizes inputs[] from those for back-compat, +so old configs keep working until re-saved.) + +### Per-input active level (`inputs[].activeLow` / `inputActiveLow`) Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g. -a [[hikvision-radar|radar]]) idles **opposite** the button, list its terminal as active-LOW — -sourced from each relay's `presenceActiveLow`, merged into the driver's `inputActiveLow` set — so -that one input is read inverted while the button keeps the board default. (`inputActive()` is the -pure helper; push-mode uses the device's own `ilu.active_level` instead.) +a [[hikvision-radar|radar]]) idles **opposite** the button, mark its terminal active-LOW — sourced +from `inputs[].activeLow` (and the legacy `relays[].presenceActiveLow`, plus an explicit top-level +`inputActiveLow[]` escape hatch), all merged by `activeLowFrom()` into the driver's `inputActiveLow` +set — so that one input is read inverted while the button keeps the board default. (`inputActive()` +is the pure helper; push-mode uses the device's own `ilu.active_level` instead.) ### Precondition: input_link_relay must be OFF diff --git a/wiki/log.md b/wiki/log.md index eef4d98..f086644 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1753,3 +1753,44 @@ event for this class — it's the *target filter* that matters. Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only. + +## [2026-06-28] refactor | Unified controller relays into one event→action list (drop config.buttonLight) +Reframed the controller "Outputs — relays" model with the user: **Entry / Exit / Both are EVENTS**, +not a "direction" — a relay is uniformly *"when EVENT X happens, do action Y"*. Barrier events +(`entry`/`exit`/`both`) `pulseOpen`; a new **`radarAlert`** event drives a non-barrier alert lamp +(blink while its trigger input is active, SOLID once the camera confirms a car). **Dropped the +separate `config.buttonLight` block** — the lamp is now just another `config.relays[]` row +(`direction:"radarAlert"`, carrying `triggerInput` + blink cadence). One list, one editor, one shape; +a future "R4 alert" is just another row with its own trigger input — no new config, no code change. +The proven `ButtonLightController` 3-state machine (serialized UDP, fail-OFF, hot-reload) is kept +verbatim — only its source changed from `buttonLightOf()` to `alertRelaysOf()`, keyed per +`controllerId:relay` so several alert relays on one controller run independently. Every barrier +resolver skips `radarAlert` rows (no auto-open; barrier-not-a-door intact). Touched +`device-resolve.ts`, `button-light.ts`, `device-monitor.ts`, web `api.ts` + `SetupWizard.tsx` (the +dropdown gained a "Radar alert" option that reveals trigger/blink inputs), i18n sq+en. Tests: +rewrote `button-light.test.ts` to the `radarAlert` row + added a two-independent-alert-relays case; +full workspace `build lint test` green (173 server tests). Updated [[button-light-indicator]], +[[dingtian-relay]], memory `access-direction-is-per-relay`. + +## [2026-06-28] refactor | Generic controller inputs (config.inputs[]) — the twin of unified relays[] +After unifying OUTPUTS into one event→action `relays[]`, did the same for INPUTS — the user hit the +wall that **there was no way to add a free-standing input** (e.g. an EXIT radar): inputs were fields +bolted onto an entry barrier relay (`relays[].button/presenceInput/...`) and the UI only rendered a +button+presence block per entry/both relay. Now a first-class **`config.inputs[]`** list — each row +`{ input, role: "button"|"presence"|"alertTrigger", relay?, kind?, activeLow?, cooldownSec? }` — with +a "+ Add input" button. An exit radar = just another `presence` row serving the exit relay. **Keystone: +`inputsOf(row)`** returns `config.inputs[]` or SYNTHESIZES it from the legacy per-relay fields, so +`relayForButton`/`relayForPresence` resolve identically from either shape — **zero-downtime, no DB +migration** (old configs keep working until re-saved; the UI seeds its editor from the synth). +`entry-flow.ts` is unchanged (resolves through the same functions). Also fixed a latent bug this +exposed: the alert lamp's camera **lock** was hardcoded to the ENTRY camera — added +`relays[].lockLane: "entry"|"exit"` (button-light tracks both `#entryBusy`/`#exitBusy`; a lamp goes +SOLID off its own lane's camera), so an exit radar's lamp locks on the EXIT camera. Driver: extracted +`activeLowFrom(config)` (merges inputs[] `activeLow` + legacy `presenceActiveLow` + the `inputActiveLow` +escape hatch). Touched `device-resolve.ts`, `button-light.ts`, `access-dingtian.ts`, web `api.ts` + +`SetupWizard.tsx` (InputEditor rewritten to a generic list; role select folds loop/radar; OutputEditor +radarAlert row gained a lock-lane select), i18n sq+en. Tests: new `device-resolve.test.ts` (inputs[] +resolution + legacy-fallback identical + exit-radar resolves to the exit relay), exit-lamp lockLane +case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace +`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]], +memory `access-direction-is-per-relay`.