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
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
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();
|
|
});
|
|
});
|