4418594af0
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
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq, devices, type Db } from "@parking/db";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import type { AuxOutputDevice } from "@parking/devices";
|
|
import { ButtonLightController } from "./button-light.js";
|
|
import { deviceEvents } from "./device-events.js";
|
|
import { silentLogger } from "./test-helpers.js";
|
|
|
|
// ButtonLightController: 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";
|
|
const RADAR_INPUT = 2; // I2
|
|
const LAMP_RELAY = 3; // spare relay R3
|
|
|
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
|
return {
|
|
async setAux(channel: number, on: boolean): Promise<void> {
|
|
if (throwOnce.v) {
|
|
throwOnce.v = false;
|
|
throw new Error("UDP down");
|
|
}
|
|
record.push({ ch: channel, on });
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
({ db } = createTestDb());
|
|
vi.useFakeTimers();
|
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
|
db.insert(devices).values({
|
|
id: CONTROLLER,
|
|
category: "access",
|
|
driverId: "dingtian",
|
|
config: {
|
|
host: "10.0.0.5",
|
|
relays: [
|
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
|
{ relay: 2, direction: "exit" },
|
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
|
],
|
|
},
|
|
enabled: true,
|
|
}).run();
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/** Emit a radar (presence input) edge for the controller. */
|
|
function radar(present: boolean): void {
|
|
deviceEvents.emitInput({
|
|
driverId: "dingtian",
|
|
deviceId: CONTROLLER,
|
|
input: RADAR_INPUT,
|
|
edge: present ? "on" : "off",
|
|
at: new Date().toISOString(),
|
|
source: "poll",
|
|
});
|
|
}
|
|
|
|
/** Emit a lane status (entry busy/free). */
|
|
function lane(entryBusy: boolean): void {
|
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
|
}
|
|
|
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
|
async function flush(): Promise<void> {
|
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
}
|
|
|
|
describe("ButtonLightController truth table", () => {
|
|
it("OFF at start (no radar, no car)", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
|
ctl.start();
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
|
// device is confirmed OFF and at most one call was made.
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
|
ctl.stop();
|
|
});
|
|
|
|
it("radar present + lane busy -> SOLID on", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const aux = fakeAux(calls);
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
|
ctl.start();
|
|
await flush();
|
|
lane(true);
|
|
radar(true);
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
|
// Solid = no blinking: advancing time produces no further sends.
|
|
const n = calls.length;
|
|
vi.advanceTimersByTime(2000);
|
|
await flush();
|
|
expect(calls.length).toBe(n);
|
|
ctl.stop();
|
|
});
|
|
|
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const aux = fakeAux(calls);
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
|
ctl.start();
|
|
await flush();
|
|
radar(true); // lane still free
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
|
vi.advanceTimersByTime(500);
|
|
await flush();
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
|
vi.advanceTimersByTime(500);
|
|
await flush();
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
|
ctl.stop();
|
|
});
|
|
|
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const aux = fakeAux(calls);
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
|
ctl.start();
|
|
await flush();
|
|
radar(true); // blink
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
|
lane(true); // camera confirms
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
|
// No more toggles (blink torn down) — the device stays ON over time.
|
|
vi.advanceTimersByTime(2000);
|
|
await flush();
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
|
ctl.stop();
|
|
});
|
|
|
|
it("radar clears -> OFF", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const aux = fakeAux(calls);
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
|
ctl.start();
|
|
await flush();
|
|
lane(true);
|
|
radar(true); // solid
|
|
await flush();
|
|
radar(false); // car gone
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
|
ctl.stop();
|
|
});
|
|
|
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const aux = fakeAux(calls);
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
|
ctl.start();
|
|
await flush();
|
|
lane(true);
|
|
radar(true); // solid, on
|
|
await flush();
|
|
const n = calls.length;
|
|
radar(true); // same state — no new edge (present unchanged)
|
|
lane(true); // same lane — no change
|
|
await flush();
|
|
expect(calls.length).toBe(n);
|
|
ctl.stop();
|
|
});
|
|
|
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const throwOnce = { v: true };
|
|
const aux = fakeAux(calls, throwOnce);
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
|
// First write (initial off) throws — must be swallowed.
|
|
expect(() => ctl.start()).not.toThrow();
|
|
await flush();
|
|
// Subsequent writes work; driving to solid still converges to ON.
|
|
lane(true);
|
|
radar(true);
|
|
await flush();
|
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
|
ctl.stop();
|
|
});
|
|
|
|
it("ignores controllers without an alert relay", () => {
|
|
// A second controller, no alert relay.
|
|
db.insert(devices).values({
|
|
id: "ctl-2",
|
|
category: "access",
|
|
driverId: "dingtian",
|
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
|
enabled: true,
|
|
}).run();
|
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
|
ctl.start();
|
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
|
ctl.stop();
|
|
});
|
|
|
|
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);
|
|
// Replace the seeded controller with one that has the radar but no lamp.
|
|
db.update(devices)
|
|
.set({
|
|
config: {
|
|
host: "10.0.0.5",
|
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
|
},
|
|
})
|
|
.where(eq(devices.id, CONTROLLER))
|
|
.run();
|
|
ctl.start();
|
|
await flush();
|
|
// No lamp configured → an input does nothing.
|
|
radar(true);
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
|
expect(calls.length).toBe(0);
|
|
radar(false);
|
|
await flush();
|
|
|
|
// 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" },
|
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
|
],
|
|
},
|
|
})
|
|
.where(eq(devices.id, CONTROLLER))
|
|
.run();
|
|
|
|
// The very next radar edge reconciles + blinks (lane still free).
|
|
radar(true);
|
|
await flush();
|
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
|
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();
|
|
});
|
|
});
|