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 { silentLogger } from "./test-helpers.js";
// ButtonLightController: the entry-button lamp on a spare relay, driven by the RADAR
// input vs. the camera lane status. Truth table:
// radar present + lane busy -> SOLID on
// radar present + lane free -> BLINK (~1 Hz)
// otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes.
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
// trigger active + lane busy -> SOLID on
// trigger active + lane free -> BLINK (~1 Hz)
// otherwise -> OFF
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
// carry several alert relays (each its own row + trigger input), keyed independently.
let db: Db;
const CONTROLLER = "ctl-1";
@@ -45,8 +46,8 @@ beforeEach(() => {
relays: [
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: 2, direction: "exit" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
},
enabled: true,
}).run();
@@ -199,8 +200,8 @@ describe("ButtonLightController truth table", () => {
ctl.stop();
});
it("ignores controllers without a buttonLight config", () => {
// A second controller, no lamp.
it("ignores controllers without an alert relay", () => {
// A second controller, no alert relay.
db.insert(devices).values({
id: "ctl-2",
category: "access",
@@ -215,8 +216,8 @@ describe("ButtonLightController truth table", () => {
ctl.stop();
});
it("picks up a button light ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO buttonLight yet.
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
// Fresh controller with a radar input but NO alert relay yet.
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
@@ -240,13 +241,15 @@ describe("ButtonLightController truth table", () => {
radar(false);
await flush();
// Admin saves a button light (relay 3) — without restarting the server.
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
relays: [
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
@@ -259,4 +262,96 @@ describe("ButtonLightController truth table", () => {
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
ctl.stop();
});
it("drives two alert relays on one controller independently", async () => {
const R3 = 3;
const R4 = 4;
const I2 = 2;
const I3 = 3;
// Controller with two alert lamps, each on its own trigger input.
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I2 active → only R3 blinks; R4 stays off (different trigger).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
// I3 active → R4 blinks too, independently.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
lane(true);
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
// I2 clears → R3 off, R4 still solid (its trigger still active).
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
const R4 = 4;
const I5 = 5; // exit radar
db.update(devices)
.set({
config: {
host: "10.0.0.5",
relays: [
{ relay: 1, direction: "entry" },
{ relay: 2, direction: "exit" },
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
],
},
})
.where(eq(devices.id, CONTROLLER))
.run();
const aux = fakeAux([]);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
await flush();
// Exit radar active → blink.
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
deviceEvents.emitLaneStatus({ entry: true, exit: false });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
// EXIT camera busy → SOLID.
deviceEvents.emitLaneStatus({ entry: true, exit: true });
await flush();
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
ctl.stop();
});
});