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:
@@ -1,5 +1,5 @@
|
||||
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
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +183,33 @@ export function inputActive(
|
||||
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 = {
|
||||
key: "input_link_relay",
|
||||
message:
|
||||
@@ -294,22 +321,7 @@ class DingtianController
|
||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||
this.#restingHigh = config.inputRestingHigh !== false;
|
||||
// Per-input active-LOW overrides (1-based). Source of truth is each entry relay's
|
||||
// `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.#inputActiveLow = activeLowFrom(config);
|
||||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||||
|
||||
Reference in New Issue
Block a user