refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user