refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s

The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).

Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
  barrier; 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 just a relays[] row
  with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
  replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
  machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
  so several alert lamps on one controller run independently. Every barrier
  resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).

Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
  "+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
  name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
  legacy relays[].button/presenceInput/... fields, so relayForButton /
  relayForPresence resolve identically from either shape — zero-downtime, no
  migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
  the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
  lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
  camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
  relays[].presenceActiveLow, and the inputActiveLow escape hatch.

UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.

Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).

Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-28 11:23:15 +02:00
parent 25a72ff20a
commit 4418594af0
15 changed files with 970 additions and 444 deletions
+109 -14
View File
@@ -7,12 +7,13 @@ import { ButtonLightController } from "./button-light.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { silentLogger } from "./test-helpers.js"; import { silentLogger } from "./test-helpers.js";
// ButtonLightController: the entry-button lamp on a spare relay, driven by the RADAR // ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
// input vs. the camera lane status. Truth table: // relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
// radar present + lane busy -> SOLID on // trigger active + lane busy -> SOLID on
// radar present + lane free -> BLINK (~1 Hz) // trigger active + lane free -> BLINK (~1 Hz)
// otherwise -> OFF // otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. // 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; let db: Db;
const CONTROLLER = "ctl-1"; const CONTROLLER = "ctl-1";
@@ -45,8 +46,8 @@ beforeEach(() => {
relays: [ relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" }, { relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: 2, direction: "exit" }, { 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, enabled: true,
}).run(); }).run();
@@ -199,8 +200,8 @@ describe("ButtonLightController truth table", () => {
ctl.stop(); ctl.stop();
}); });
it("ignores controllers without a buttonLight config", () => { it("ignores controllers without an alert relay", () => {
// A second controller, no lamp. // A second controller, no alert relay.
db.insert(devices).values({ db.insert(devices).values({
id: "ctl-2", id: "ctl-2",
category: "access", category: "access",
@@ -215,8 +216,8 @@ describe("ButtonLightController truth table", () => {
ctl.stop(); ctl.stop();
}); });
it("picks up a button light ADDED after start() (no restart needed)", async () => { it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO buttonLight yet. // Fresh controller with a radar input but NO alert relay yet.
const calls: Array<{ ch: number; on: boolean }> = []; const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls); const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux); const ctl = new ButtonLightController(db, silentLogger(), () => aux);
@@ -240,13 +241,15 @@ describe("ButtonLightController truth table", () => {
radar(false); radar(false);
await flush(); 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) db.update(devices)
.set({ .set({
config: { config: {
host: "10.0.0.5", host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }], relays: [
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 }, { 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)) .where(eq(devices.id, CONTROLLER))
@@ -259,4 +262,96 @@ describe("ButtonLightController truth table", () => {
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop(); 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();
});
}); });
+99 -67
View File
@@ -2,28 +2,33 @@ import { eq, devices, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices"; import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js"; 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 // Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
// zone" signal (the existing advisory lane-status). A disagreement indicator: // light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
// radar present + lane busy (camera confirms a car) → SOLID on // input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
// radar present + lane free (radar sees something, no car) → BLINK (~1 Hz) // 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 // otherwise → OFF
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine // 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 // — 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 // 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"; type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500; 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 { interface LampState {
/** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the /** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
* admin changes the button-light config without a restart. */ readonly controllerId: string;
spec: ButtonLightSpec; /** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
/** Is the radar (presence input on an entry relay) currently active? */ * 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; present: boolean;
/** The high-level state we're rendering (to avoid restarting a running blink). */ /** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null; rendered: LightState | null;
@@ -50,10 +55,13 @@ export class ButtonLightController {
readonly #db: Db; readonly #db: Db;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
readonly #resolveAux: AuxResolver; 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<string, LampState>(); readonly #lamps = new Map<string, LampState>();
/** 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; #entryBusy = false;
#exitBusy = false;
/** Controllers we've already warned lack the aux-output capability (warn once). */ /** Controllers we've already warned lack the aux-output capability (warn once). */
readonly #warned = new Set<string>(); readonly #warned = new Set<string>();
#unsubInput: (() => void) | null = null; #unsubInput: (() => void) | null = null;
@@ -69,7 +77,7 @@ export class ButtonLightController {
start(): void { start(): void {
this.#reconcile(); this.#reconcile();
// All lamps start OFF (known-safe baseline) regardless of prior device state. // 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.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s)); this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
@@ -86,34 +94,36 @@ export class ButtonLightController {
const seen = new Set<string>(); const seen = new Set<string>();
for (const row of rows) { for (const row of rows) {
if (!row.enabled) continue; if (!row.enabled) continue;
const spec = buttonLightOf(row); for (const spec of alertRelaysOf(row)) {
if (!spec) continue; const key = lampKey(row.id, spec.relay);
seen.add(row.id); seen.add(key);
const existing = this.#lamps.get(row.id); const existing = this.#lamps.get(key);
if (existing) { if (existing) {
existing.spec = spec; // pick up a changed relay # / blink cadence existing.spec = spec; // pick up a changed trigger input / blink cadence
} else { } else {
this.#lamps.set(row.id, { this.#lamps.set(key, {
spec, controllerId: row.id,
present: false, spec,
rendered: null, present: false,
blink: null, rendered: null,
blinkOn: false, blink: null,
desiredOn: false, blinkOn: false,
confirmedOn: null, desiredOn: false,
sending: false, confirmedOn: null,
}); sending: false,
});
}
} }
} }
// Drop lamps whose controller no longer declares one (or was disabled/removed). // Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [id, lamp] of this.#lamps) { for (const [key, lamp] of this.#lamps) {
if (seen.has(id)) continue; if (seen.has(key)) continue;
if (lamp.blink) { if (lamp.blink) {
clearInterval(lamp.blink); clearInterval(lamp.blink);
lamp.blink = null; lamp.blink = null;
} }
this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(id); this.#lamps.delete(key);
} }
} }
@@ -123,28 +133,36 @@ export class ButtonLightController {
#onInput(e: DeviceInputEvent): void { #onInput(e: DeviceInputEvent): void {
// Reconcile first so a lamp added/changed since boot (no restart) is picked up. // Reconcile first so a lamp added/changed since boot (no restart) is picked up.
this.#reconcile(); 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"; const present = e.edge === "on";
if (present === lamp.present) return; for (const lamp of this.#lamps.values()) {
lamp.present = present; if (lamp.controllerId !== e.deviceId) continue;
this.#apply(e.deviceId, lamp); // 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 { #onLane(s: LaneStatusEvent): void {
if (s.entry === this.#entryBusy) return; if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
this.#entryBusy = s.entry; this.#entryBusy = s.entry;
// Re-render every lamp (the camera signal is site-wide entry status). this.#exitBusy = s.exit;
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp); // 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 /** 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). */ * timer/state machine is synchronous; the UDP write resolves on its own). */
#apply(controllerId: string, lamp: LampState): void { #apply(lamp: LampState): void {
const target: LightState = !lamp.present ? "off" : this.#entryBusy ? "solid" : "blink"; // 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 if (target === lamp.rendered) return; // already rendering this state
// Tear down any running blink before switching states. // Tear down any running blink before switching states.
@@ -156,10 +174,10 @@ export class ButtonLightController {
if (target === "off") { if (target === "off") {
lamp.desiredOn = false; lamp.desiredOn = false;
this.#pump(controllerId, lamp); this.#pump(lamp);
} else if (target === "solid") { } else if (target === "solid") {
lamp.desiredOn = true; lamp.desiredOn = true;
this.#pump(controllerId, lamp); this.#pump(lamp);
} else { } else {
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual // 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 // (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
@@ -172,7 +190,7 @@ export class ButtonLightController {
const tick = () => { const tick = () => {
lamp.blinkOn = !lamp.blinkOn; lamp.blinkOn = !lamp.blinkOn;
lamp.desiredOn = lamp.blinkOn; lamp.desiredOn = lamp.blinkOn;
this.#pump(controllerId, lamp); this.#pump(lamp);
if (onMs !== offMs && lamp.blink) { if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink); clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs); lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
@@ -181,7 +199,7 @@ export class ButtonLightController {
}; };
lamp.blink = setInterval(tick, onMs); lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.(); 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 * 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 — * (`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. */ * 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.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 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; if (!aux) return;
const target = lamp.desiredOn; const target = lamp.desiredOn;
lamp.sending = true; lamp.sending = true;
@@ -204,13 +222,13 @@ export class ButtonLightController {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates. // 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(() => { .finally(() => {
lamp.sending = false; lamp.sending = false;
// Desired state may have changed (or the send failed) while we were busy — // 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. // 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.#unsubLane?.();
this.#unsubInput = null; this.#unsubInput = null;
this.#unsubLane = null; this.#unsubLane = null;
for (const [controllerId, lamp] of this.#lamps) { for (const lamp of this.#lamps.values()) {
if (lamp.blink) { if (lamp.blink) {
clearInterval(lamp.blink); clearInterval(lamp.blink);
lamp.blink = null; lamp.blink = null;
} }
// Best-effort fail-OFF on shutdown. // 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 /** 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 * OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. */ * in-flight send — it converges to OFF. */
#finalOff(controllerId: string, lamp: LampState): void { #finalOff(lamp: LampState): void {
lamp.desiredOn = false; lamp.desiredOn = false;
this.#pump(controllerId, lamp); this.#pump(lamp);
} }
/** Test seam: current high-level state being rendered for a controller. */ /** Test seam: current high-level state being rendered for a lamp (controller + relay).
stateOf(controllerId: string): LightState | null { * `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
return this.#lamps.get(controllerId)?.rendered ?? null; 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 /** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
* successful send). null = unknown / nothing sent yet. */ * send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
confirmedOf(controllerId: string): boolean | null { confirmedOf(controllerId: string, relay?: number): boolean | null {
return this.#lamps.get(controllerId)?.confirmedOn ?? 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). */ /** Build a controller row's live aux device (exported for reuse/tests). */
+6 -1
View File
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
return d; return d;
} }
case "access": { 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 === 0) return null;
if (dirs.size > 1) return "mixed"; if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both const only = [...dirs][0]; // entry | exit | both
+91
View File
@@ -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<string, unknown>): 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();
});
});
+129 -62
View File
@@ -10,55 +10,74 @@ export type Direction = "entry" | "exit" | "both";
/** A concrete flow a credential/button drives (never "both"). */ /** A concrete flow a credential/button drives (never "both"). */
export type FlowDirection = "entry" | "exit"; export type FlowDirection = "entry" | "exit";
/** One relay on an access controller: which barrier it opens, in which direction, /** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
* and (optionally) the input terminals its entry button + presence loop are wired to. */ * `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 { export interface RelaySpec {
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */ /** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
readonly relay: number; readonly relay: number;
readonly direction: Direction; /** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
/** 1-based input terminal of the entry button that fires this relay (transient * drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */ 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; 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; 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"; 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 presenceActiveLow?: boolean;
readonly entryCooldownSec?: number; readonly entryCooldownSec?: number;
}
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's // ── radarAlert-only (direction === "radarAlert") ──
* 12 V light). Driven by the server LightController off the radar + lane status — // A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */ // 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
export interface ButtonLightSpec { // camera lane status: blink while the trigger is active + lane free, SOLID once the
/** 1-based spare relay channel the lamp is wired to. */ // camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
readonly relay: number; /** 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. */ /** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
readonly blinkOnMs?: number; readonly blinkOnMs?: number;
readonly blinkOffMs?: number; readonly blinkOffMs?: number;
} }
/** Access controller config (the `relays[]` map + connection fields). */ /** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
interface AccessConfig { interface AccessConfig {
readonly relays?: RelaySpec[]; readonly relays?: RelaySpec[];
/** Optional button-lamp output on a spare relay. */ readonly inputs?: InputSpec[];
readonly buttonLight?: ButtonLightSpec;
readonly [k: string]: unknown; 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 * The INPUT terminals declared on an access controller — the back-compat keystone. Returns
* deviceId, and the relay whose `button` terminal matches the pressed input. Only * `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise. * 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 { export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db 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"))) .where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get(); .get();
if (!row || !row.enabled) return null; if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.button === terminal); const inputs = inputsOf(row);
if (!spec) return null; const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
if (spec.direction !== "entry" && spec.direction !== "both") return null; 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 { return {
controller: row, controller: row,
relay: spec.relay, relay: relay.relay,
direction: spec.direction, direction: relay.direction,
presenceInput: spec.presenceInput, presenceInput: presence?.input,
presenceKind: spec.presenceKind ?? "loop", presenceKind: presence?.kind ?? "loop",
entryCooldownSec: spec.entryCooldownSec, entryCooldownSec: btn.cooldownSec,
}; };
} }
/** /**
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with * Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input. * deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
* Lets the entry flow track "a car is physically at this entry barrier" so it issues * flow track "a car is physically at this entry barrier" so it issues exactly one ticket
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise. * per car. Only entry/both relays gate transient entry. Null otherwise.
*/ */
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null { export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
const row = db 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"))) .where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
.get(); .get();
if (!row || !row.enabled) return null; if (!row || !row.enabled) return null;
const spec = relaysOf(row).find((r) => r.presenceInput === terminal); const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
if (!spec) return null; if (!presence) return null;
if (spec.direction !== "entry" && spec.direction !== "both") return null; const relay = barrierForInput(row, presence);
if (!relay) return null;
return { return {
controller: row, controller: row,
relay: spec.relay, relay: relay.relay,
direction: spec.direction, direction: relay.direction,
presenceInput: spec.presenceInput, presenceInput: presence.input,
presenceKind: spec.presenceKind ?? "loop", presenceKind: presence.kind ?? "loop",
}; };
} }
/** The button-lamp output declared on an access controller, or null. */ /** The alert (radarAlert) relay rows declared on an access controller — the lamps the
export function buttonLightOf(row: DeviceRow): ButtonLightSpec | null { * ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
const cfg = row.config as AccessConfig; export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
const bl = cfg.buttonLight; return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
return bl && typeof bl.relay === "number" ? bl : null;
} }
/** /**
@@ -180,7 +243,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
.get(); .get();
if (controller && controller.enabled) { if (controller && controller.enabled) {
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay); 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; return null;
} }
@@ -200,7 +266,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null { export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
for (const controller of accessRows(db)) { for (const controller of accessRows(db)) {
const spec = relaysOf(controller).find( 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 }; if (spec) return { controller, relay: spec.relay, direction: spec.direction };
} }
+278 -187
View File
@@ -15,13 +15,15 @@ import {
type PrintTestResult, type PrintTestResult,
type Assignment, type Assignment,
type BackendIpCandidate, type BackendIpCandidate,
type ButtonLightSpec,
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
type DeviceConfig, type DeviceConfig,
type Direction, type Direction,
type DiscoveredDevice, type DiscoveredDevice,
type InputRole,
type InputSpec,
type RelayEvent,
type RelaySpec, type RelaySpec,
type TestResult, type TestResult,
} from "./api.js"; } from "./api.js";
@@ -49,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" }, { key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
]; ];
// Translated direction label (relay direction / inherited binding). // Translated relay-event label (barrier direction, inherited binding, or alert).
const DIRECTION_KEYS: Record<Direction, string> = { const DIRECTION_KEYS: Record<RelayEvent, string> = {
entry: "setup.dirEntry", entry: "setup.dirEntry",
exit: "setup.dirExit", exit: "setup.dirExit",
both: "setup.dirBoth", 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<InputChoice, string> = {
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() { export function SetupWizard() {
const { t } = useTranslation(); const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null); const [catalog, setCatalog] = useState<Catalog | null>(null);
@@ -82,7 +134,7 @@ export function SetupWizard() {
return ( return (
<section className="px-4 py-6"> <section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p> <p className="hint mb-4 max-w">{t("setup.intro")}</p>
<CategorySection <CategorySection
category={CONTROLLER.key} category={CONTROLLER.key}
@@ -273,24 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
if (assignment.category === "access") { if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : []; const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>; if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
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 ( return (
<span className="flex flex-wrap gap-1.5"> <span className="flex flex-wrap gap-1.5">
{relays.map((r) => { {relays.map((r) => {
const presence = r.presenceInput // Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}` let wiring = "";
: ""; if (r.direction === "radarAlert") {
return ( if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
<DirectionBadge if (r.lockLane === "exit") wiring += "·lockExit";
key={r.relay} } else {
direction={r.direction} const served = inputs.filter((x) => x.relay === r.relay);
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`} 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 <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
})} })}
{bl?.relay != null && (
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
)}
</span> </span>
); );
} }
@@ -365,14 +418,19 @@ function DeviceForm({
} }
return out; 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<RelaySpec[]>(() => const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }], 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. // Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
const [buttonLight, setButtonLight] = useState<ButtonLightSpec | null>(() => { // the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
const bl = editCfg?.buttonLight as ButtonLightSpec | undefined; // legacy per-relay button/presence fields so an existing controller opens populated.
return bl && typeof bl.relay === "number" ? bl : null; const [inputs, setInputs] = useState<InputSpec[]>(() => {
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. // Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>( const [controllerId, setControllerId] = useState<string>(
@@ -487,23 +545,31 @@ function DeviceForm({
function mergedConfig(): DeviceConfig { function mergedConfig(): DeviceConfig {
const out: DeviceConfig = { ...mergedScalarConfig() }; const out: DeviceConfig = { ...mergedScalarConfig() };
if (isController) { if (isController) {
out.relays = relays.map((r) => ({ // Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
relay: r.relay, out.relays = relays.map((r) =>
direction: r.direction, r.direction === "radarAlert"
...(r.button ? { button: r.button } : {}), ? {
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}), // Alert lamp: trigger input + lock lane + blink cadence.
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}), relay: r.relay,
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}), direction: r.direction,
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}), ...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
})); ...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
// Button-lamp output (a spare relay), persisted only when a relay is chosen. ...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
if (buttonLight && buttonLight.relay) { ...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
out.buttonLight = { }
relay: buttonLight.relay, : { relay: r.relay, direction: r.direction },
...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}), );
...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}), // 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 !== "") { } else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId; out.controllerId = controllerId;
out.relay = boundRelay; 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 && ( {isController && (
<OutputEditor <OutputEditor
relays={relays} relays={relays}
onChange={setRelays} onChange={setRelays}
buttonLight={buttonLight}
onButtonLightChange={setButtonLight}
pulseMs={config.pulseMs as number | undefined} pulseMs={config.pulseMs as number | undefined}
onPulseMsChange={(v) => { onPulseMsChange={(v) => {
setConfig((c) => ({ ...c, pulseMs: v })); setConfig((c) => ({ ...c, pulseMs: v }));
@@ -764,12 +828,13 @@ function DeviceForm({
/> />
)} )}
{/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound {/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
to the output relay it drives. Separated from the outputs above. */} each naming the relay it serves. Separated from the outputs above. */}
{isController && ( {isController && (
<InputEditor <InputEditor
inputs={inputs}
onChange={setInputs}
relays={relays} relays={relays}
onChange={setRelays}
inputsIdleHigh={config.inputRestingHigh as boolean | undefined} inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
onInputsIdleHighChange={(v) => { onInputsIdleHighChange={(v) => {
setConfig((c) => ({ ...c, inputRestingHigh: v })); setConfig((c) => ({ ...c, inputRestingHigh: v }));
@@ -1008,24 +1073,21 @@ function DeviceForm({
} }
// ── Controller OUTPUTS (relays) ──────────────────────────────────────────── // ── Controller OUTPUTS (relays) ────────────────────────────────────────────
// A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section // A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
// owns relay number + direction, the pulse-open time (relay hold ms), and the lamp // BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
// relay. The INPUT terminals wired to these relays live in InputEditor below — the two // the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
// are deliberately separated (a controller's inputs and outputs are distinct things). // 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({ function OutputEditor({
relays, relays,
onChange, onChange,
buttonLight,
onButtonLightChange,
pulseMs, pulseMs,
onPulseMsChange, onPulseMsChange,
}: { }: {
relays: RelaySpec[]; relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void; onChange: (r: RelaySpec[]) => void;
buttonLight: ButtonLightSpec | null;
onButtonLightChange: (v: ButtonLightSpec | null) => void;
pulseMs: number | undefined; pulseMs: number | undefined;
onPulseMsChange: (v: number) => void; onPulseMsChange: (v: number) => void;
}) { }) {
@@ -1040,7 +1102,6 @@ function OutputEditor({
function remove(i: number) { function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i)); onChange(relays.filter((_, idx) => idx !== i));
} }
const barrierRelays = new Set(relays.map((r) => r.relay));
return ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
@@ -1060,7 +1121,8 @@ function OutputEditor({
/> />
</label> </label>
{/* 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) => ( {relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2"> <div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
@@ -1073,13 +1135,68 @@ function OutputEditor({
onChange={(e) => update(i, { relay: Number(e.target.value) })} onChange={(e) => update(i, { relay: Number(e.target.value) })}
/> />
</label> </label>
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}> <select
{(["entry", "exit", "both"] as Direction[]).map((d) => ( className="select input-sm w-auto"
value={r.direction}
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
>
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
<option key={d} value={d}> <option key={d} value={d}>
{t(DIRECTION_KEYS[d])} {t(DIRECTION_KEYS[d])}
</option> </option>
))} ))}
</select> </select>
{/* Alert relay: which input fires the blink + the blink cadence. */}
{r.direction === "radarAlert" && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
{t("setup.triggerInput")}
<input
type="number"
min={1}
value={r.triggerInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
{t("setup.lockLane")}
<select
className="select input-sm w-auto"
value={r.lockLane ?? "entry"}
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
>
<option value="entry">{t("setup.lockLaneEntry")}</option>
<option value="exit">{t("setup.lockLaneExit")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={r.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={r.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
</>
)}
{relays.length > 1 && ( {relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}> <button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕ ✕
@@ -1090,92 +1207,47 @@ function OutputEditor({
<button type="button" className="btn btn-sm mt-1" onClick={add}> <button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addRelay")} {t("setup.addRelay")}
</button> </button>
{/* 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). */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-term-border pt-2">
<span className="text-[12px] text-term-muted" title={t("setup.buttonLightHint")}>
{t("setup.buttonLight")}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.buttonLightRelay")}
<input
type="number"
min={1}
value={buttonLight?.relay ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
onButtonLightChange(e.target.value === "" ? null : { ...buttonLight, relay: Number(e.target.value) })
}
/>
</label>
{buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && (
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
)}
{buttonLight?.relay != null && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
</>
)}
</div>
</div> </div>
); );
} }
// ── Controller INPUTS (terminals) ────────────────────────────────────────── // ── Controller INPUTS (terminals) ──────────────────────────────────────────
// An input is a TERMINAL the host READS: the entry button, the presence/radar sensor. // An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
// Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we // list above): each row is a terminal + a ROLE (entry button / presence loop / presence
// render one block per entry/both relay, labelled with the output relay it drives. The // 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 // button never SETS a pulse — its electrical pulse is the device's to report — so no timing
// timing field lives here (pulse-open is an OUTPUT setting, in OutputEditor). // 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({ function InputEditor({
relays, inputs,
onChange, onChange,
relays,
inputsIdleHigh, inputsIdleHigh,
onInputsIdleHighChange, onInputsIdleHighChange,
}: { }: {
inputs: InputSpec[];
onChange: (v: InputSpec[]) => void;
relays: RelaySpec[]; relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
inputsIdleHigh: boolean | undefined; inputsIdleHigh: boolean | undefined;
onInputsIdleHighChange: (v: boolean) => void; onInputsIdleHighChange: (v: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) { function update(i: number, patch: Partial<InputSpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
} }
// Inputs only matter for entry/both relays (transient entry). Keep each row's real function add() {
// index so updates target the right relay. const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
const entryRelays = relays onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
.map((r, i) => ({ r, i })) }
.filter(({ r }) => r.direction === "entry" || r.direction === "both"); 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 ( return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2"> <div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
@@ -1196,77 +1268,91 @@ function InputEditor({
</span> </span>
</label> </label>
{entryRelays.length === 0 ? ( {inputs.map((row, i) => {
<p className="hint">{t("setup.inputsNoEntryRelay")}</p> const choice = choiceOf(row);
) : ( const isPresence = row.role === "presence";
entryRelays.map(({ r, i }) => ( const isButton = row.role === "button";
return (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2"> <div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<span className="text-[11px] uppercase tracking-wider text-term-amber">
{t("setup.inputsForRelay", { relay: r.relay })}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.entryButtonTerminal")} {t("setup.inputTerminal")}
<input <input
type="number" type="number"
min={1} min={1}
value={r.button ?? ""} value={row.input}
placeholder="—"
className="input input-sm w-16" className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { input: Number(e.target.value) })}
/> />
</label> </label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}> <select
{t("setup.presenceInput")} className="select input-sm w-auto"
<input value={choice}
type="number" onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
min={1} >
value={r.presenceInput ?? ""} {(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
placeholder="—" <option key={c} value={c}>
className="input input-sm w-16" {t(INPUT_CHOICE_KEYS[c])}
onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })} </option>
/> ))}
</label> </select>
{/* 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" && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted"> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.presenceKind")} {t("setup.inputServesRelay")}
<select <select
value={r.presenceKind ?? "loop"} className="select input-sm w-auto"
className="input input-sm w-24" value={row.relay ?? ""}
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })} onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
> >
<option value="loop">{t("setup.presenceKindLoop")}</option> <option value="" disabled>
<option value="radar">{t("setup.presenceKindRadar")}</option> {t("setup.choose")}
</select> </option>
</label> {barrierRelays.map((r) => (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}> <option key={r.relay} value={r.relay}>
<input {t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
type="checkbox" </option>
checked={!!r.presenceActiveLow} ))}
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })} </select>
/> </label>
{t("setup.presenceActiveLow")}
</label>
</>
)} )}
{/* Cooldown fallback only when no presence sensor is wired. */}
{!r.presenceInput && ( {/* Presence: active-low (a radar wired opposite the button). */}
{isPresence && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
<input
type="checkbox"
checked={!!row.activeLow}
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
/>
{t("setup.activeLow")}
</label>
)}
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
{isButton && !hasPresenceFor(row.relay) && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}> <label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")} {t("setup.entryCooldown")}
<input <input
type="number" type="number"
min={0} min={0}
value={r.entryCooldownSec ?? ""} value={row.cooldownSec ?? ""}
placeholder="—" placeholder="—"
className="input input-sm w-16" className="input input-sm w-16"
onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })} onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
/> />
</label> </label>
)} )}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
</div> </div>
)) );
)} })}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addInput")}
</button>
</div> </div>
); );
} }
@@ -1325,11 +1411,14 @@ function BindingPicker({
<option value="" disabled> <option value="" disabled>
{t("setup.choose")} {t("setup.choose")}
</option> </option>
{relays.map((r) => ( {/* Only barrier relays are bindable — an alert lamp opens nothing. */}
<option key={r.relay} value={r.relay}> {relays
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })} .filter((r) => r.direction !== "radarAlert")
</option> .map((r) => (
))} <option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select> </select>
</label> </label>
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />} {chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
@@ -1341,14 +1430,16 @@ function BindingPicker({
); );
} }
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) { function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette. // entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
const cls = const cls =
direction === "entry" direction === "entry"
? "border-term-green text-term-green" ? "border-term-green text-term-green"
: direction === "exit" : direction === "exit"
? "border-term-amber text-term-amber" ? "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 ( return (
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}> <span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
{label ?? direction} {label ?? direction}
+40 -21
View File
@@ -275,30 +275,49 @@ export type DeviceConfig = Record<string, ConfigValue>;
/** Direction a barrier/relay (or a device bound to it) serves. */ /** Direction a barrier/relay (or a device bound to it) serves. */
export type Direction = "entry" | "exit" | "both"; export type Direction = "entry" | "exit" | "both";
/** One relay on an access controller: which barrier it opens, in which direction, /** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
* and (optionally) the input terminal its entry button is wired to. */ * non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
export interface RelaySpec { * confirms a car). The action is implied by the event. */
relay: number; export type RelayEvent = Direction | "radarAlert";
direction: Direction;
/** Input terminal of the entry button that fires this relay (transient entry). */ /** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
button?: number; * `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle export type InputRole = "button" | "presence" | "alertTrigger";
* 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. */ /** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
presenceInput?: number; * `presence` row serving the exit relay. */
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */ export interface InputSpec {
presenceKind?: "loop" | "radar"; input: number;
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite role: InputRole;
* the button. Maps to the driver's per-input active-level override. */ /** The barrier relay this input serves (required for button/presence; optional for
presenceActiveLow?: boolean; * alertTrigger). */
entryCooldownSec?: number; 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), /** One relay on an access controller: the event it reacts to. Input wiring lives in
* driven by the radar input vs. the camera lane status. */ * `config.inputs[]`; the legacy per-relay button/presence fields are still read for
export interface ButtonLightSpec { * back-compat but no longer written. */
/** 1-based spare relay the lamp is on. */ export interface RelaySpec {
relay: number; 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. */ /** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
blinkOnMs?: number; blinkOnMs?: number;
blinkOffMs?: number; blinkOffMs?: number;
+19 -16
View File
@@ -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.", "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", inputsIdleHigh: "Inputs idle HIGH",
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.", 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", relay: "Relay",
entryButtonTerminal: "Entry button on terminal", // Generic input rows: terminal + role + the relay it serves.
presenceInput: "Presence sensor (terminal)", inputTerminal: "Terminal",
presenceInputHint: inputServesRelay: "Serves relay",
"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.", roleButton: "Entry button",
rolePresenceLoop: "Presence (loop)",
rolePresenceRadar: "Presence (radar)",
roleAlertTrigger: "Alert trigger",
addInput: "+ Add input",
entryCooldown: "Cooldown after ticket (s)", entryCooldown: "Cooldown after ticket (s)",
entryCooldownHint: 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.", "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", activeLow: "Active-low",
presenceKindLoop: "Loop", activeLowHint:
presenceKindRadar: "Radar",
presenceActiveLow: "Active-low",
presenceActiveLowHint:
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.", "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)", eventRadarAlert: "Radar alert (lamp)",
buttonLightRelay: "Relay", triggerInput: "Trigger input",
buttonLightHint: triggerInputHint:
"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.", "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.",
buttonLightBarrierWarn: "This relay is used by a barrier — pick a spare relay.", 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)", blinkOnMs: "Blink on (ms)",
blinkOffMs: "Blink off (ms)", blinkOffMs: "Blink off (ms)",
addRelay: "+ Add relay", addRelay: "+ Add relay",
+19 -16
View File
@@ -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.", "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", inputsIdleHigh: "Hyrjet në pushim HIGH",
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.", 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", relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin", // Generic input rows: terminal + role + the relay it serves.
presenceInput: "Sensori i pranisë (terminali)", inputTerminal: "Terminali",
presenceInputHint: inputServesRelay: "I shërben reles",
"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.", roleButton: "Butoni i hyrjes",
rolePresenceLoop: "Prania (lak induktiv)",
rolePresenceRadar: "Prania (radar)",
roleAlertTrigger: "Trigger alarmi",
addInput: "+ Shto hyrje",
entryCooldown: "Pritje pas biletës (sek)", entryCooldown: "Pritje pas biletës (sek)",
entryCooldownHint: 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.", "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", activeLow: "Aktiv-ulët",
presenceKindLoop: "Lak", activeLowHint:
presenceKindRadar: "Radar",
presenceActiveLow: "Aktiv-ulët",
presenceActiveLowHint:
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.", "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ë)", eventRadarAlert: "Alarm radar (dritë)",
buttonLightRelay: "Rele", triggerInput: "Trigger input",
buttonLightHint: triggerInputHint:
"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.", "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.",
buttonLightBarrierWarn: "Kjo rele përdoret nga një barrierë — zgjidh një rele rezervë.", 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)", blinkOnMs: "Pulsim ndezur (ms)",
blinkOffMs: "Pulsim fikur (ms)", blinkOffMs: "Pulsim fikur (ms)",
addRelay: "+ Shto rele", addRelay: "+ Shto rele",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 // 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 // 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 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]);
});
});
+28 -16
View File
@@ -183,6 +183,33 @@ export function inputActive(
return activeLow.has(channel1Based) ? !high : high !== restingHigh; 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<string, unknown>): Set<number> {
const set = new Set<number>();
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<Record<string, unknown>>) {
if (i?.role === "presence" && i?.activeLow === true) add(i.input);
}
}
if (Array.isArray(config.relays)) {
for (const r of config.relays as Array<Record<string, unknown>>) {
if (r?.presenceActiveLow === true) add(r.presenceInput);
}
}
return set;
}
const INPUT_LINK_ISSUE = { const INPUT_LINK_ISSUE = {
key: "input_link_relay", key: "input_link_relay",
message: message:
@@ -294,22 +321,7 @@ class DingtianController
this.#channels = config.channels ? Number(config.channels) : 4; this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW. // This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false; this.#restingHigh = config.inputRestingHigh !== false;
// Per-input active-LOW overrides (1-based). Source of truth is each entry relay's this.#inputActiveLow = activeLowFrom(config);
// `presenceActiveLow` flag (a radar terminal wired opposite the button); an explicit
// top-level `inputActiveLow` array is also honoured as an escape hatch. Both merged.
this.#inputActiveLow = new Set<number>();
if (Array.isArray(config.inputActiveLow)) {
for (const n of (config.inputActiveLow as unknown[]).map(Number)) {
if (Number.isInteger(n) && n > 0) this.#inputActiveLow.add(n);
}
}
if (Array.isArray(config.relays)) {
for (const r of config.relays as Array<Record<string, unknown>>) {
if (r?.presenceActiveLow === true && Number.isInteger(Number(r.presenceInput))) {
this.#inputActiveLow.add(Number(r.presenceInput));
}
}
}
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500; this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
this.#webUser = config.webUser ? String(config.webUser) : "admin"; this.#webUser = config.webUser ? String(config.webUser) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates). // webPassword = the DESIRED login (admin's choice; blank → harden generates).
+43 -26
View File
@@ -1,46 +1,59 @@
--- ---
type: concept 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: [] sources: []
updated: 2026-06-24 updated: 2026-06-28
status: settled 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 A relay on the [[dingtian-relay|Dingtian]] controller is uniformly **"when EVENT X happens, do
[[dingtian-relay|Dingtian]] controller as a 3-state indicator that combines the **[[hikvision-radar| action Y"** — see [[entry-exit-points|relays carry an event]]. The barrier events (`entry`/`exit`/
radar]]** input with the **camera "car in zone"** signal: `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) | | active | **free** — no car confirmed | **BLINK** (~1 Hz) |
| detecting | **busy** — camera confirms a car | **SOLID on** | | active | **busy** — camera confirms a car | **SOLID on** |
| clear | — | **OFF** | | inactive | — | **OFF** |
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a 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. 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 ## Signals
- **Radar** = the presence input edge on the entry relay (`relays[].presenceInput`, the same edge - **Trigger** = the alert relay's own `triggerInput` edge (the [[hikvision-radar|radar]]). When
the [[entry-double-press|one-car-one-ticket]] gate observes — so the lamp and the gate always unset, it falls back to the controller's entry-relay `presenceInput` — the same edge the
agree on "a car is here"). [[entry-double-press|one-car-one-ticket]] gate observes, so the lamp and the gate agree on "a car
- **Camera "car in zone"** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent` entry is here".
busy/free, from camera vehicle detection). Already advisory; already drives the booth's barrier - **Lock (camera "car in zone")** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent`,
lights. No new camera plumbing. 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 ## Config
A controller-level `config.buttonLight = { relay, blinkOnMs?, blinkOffMs? }` (the operator picks a An alert lamp is a `config.relays[]` row with `direction: "radarAlert"`, carrying
**spare** relay — not a barrier relay; the setup UI warns if it overlaps one). Blink defaults to `{ relay, triggerInput?, blinkOnMs?, blinkOffMs? }`. No separate `buttonLight` block (that was the
500 ms / 500 ms. 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 ## Implementation
`apps/server/src/button-light.ts` — `ButtonLightController` subscribes to `deviceEvents.onInput` `apps/server/src/button-light.ts` — `ButtonLightController` reads the `radarAlert` rows
(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the lamp via (`alertRelaysOf()` in `device-resolve.ts`), subscribes to `deviceEvents.onInput` (radar) +
a **device-agnostic aux-output** capability. `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 - **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** 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 ## Status
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay); the serialized-send 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 + hot-reload fixes landed the same day after the lamp stuck on/off on hardware. **Reframed
`apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the device's 2026-06-28**: the dedicated `config.buttonLight` block was folded into the unified `relays[]` list as
*confirmed* state, fail-OFF, de-dupe, and a lamp-added-after-start reconcile case). 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]], Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
[[barrier-not-a-door]]. [[entry-exit-points]], [[barrier-not-a-door]].
+16 -8
View File
@@ -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: 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) ### 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 [[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"` be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a
— a label; the gate behaviour is identical). A radar wired to idle opposite the button needs 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**: `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. - 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** - 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 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. 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` - Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to (`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy
the entry relay it gates. `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` - `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open). telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
+22 -9
View File
@@ -56,18 +56,31 @@ web/config API is on a configurable HTTP port (default **80**), distinct from th
### Spare relays + aux outputs (`setAux`) ### Spare relays + aux outputs (`setAux`)
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive 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]]). **non-barrier indicators** — e.g. the entry button's 12 V lamp. Every relay is a `config.relays[]`
Business logic drives them through the device-agnostic `AuxOutputDevice.setAux(channel, on)` (a row carrying the **event** it reacts to (`direction`): the barrier events (`entry`/`exit`/`both`)
latch), **never** the barrier `pulseOpen`. The [[barrier-not-a-door]] rule doesn't apply to an aux pulse, while a **`radarAlert`** row is an [[button-light-indicator|alert relay]] (blink + camera-lock).
output (it never gates a vehicle), so holding/blinking it is fine. 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. 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 — a [[hikvision-radar|radar]]) idles **opposite** the button, mark its terminal active-LOW — sourced
sourced from each relay's `presenceActiveLow`, merged into the driver's `inputActiveLow` set — so from `inputs[].activeLow` (and the legacy `relays[].presenceActiveLow`, plus an explicit top-level
that one input is read inverted while the button keeps the board default. (`inputActive()` is the `inputActiveLow[]` escape hatch), all merged by `activeLowFrom()` into the driver's `inputActiveLow`
pure helper; push-mode uses the device's own `ilu.active_level` instead.) 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 ### Precondition: input_link_relay must be OFF
+41
View File
@@ -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 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 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. 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`.