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:
@@ -10,55 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
||||
/** A concrete flow a credential/button drives (never "both"). */
|
||||
export type FlowDirection = "entry" | "exit";
|
||||
|
||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
||||
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||
export type RelayEvent = Direction | "radarAlert";
|
||||
|
||||
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||
|
||||
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||
* An exit radar is just another `presence` row serving the exit relay. */
|
||||
export interface InputSpec {
|
||||
/** 1-based input terminal the host reads. */
|
||||
readonly input: number;
|
||||
readonly role: InputRole;
|
||||
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||
readonly relay?: number;
|
||||
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||
readonly kind?: "loop" | "radar";
|
||||
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||
readonly activeLow?: boolean;
|
||||
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||
readonly cooldownSec?: number;
|
||||
}
|
||||
|
||||
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||
* (back-compat) but no longer written by the UI. */
|
||||
export interface RelaySpec {
|
||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||
readonly direction: RelayEvent;
|
||||
|
||||
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||
readonly button?: number;
|
||||
/**
|
||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
||||
* controller. A press prints only while a car is present, and no second ticket
|
||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
||||
* makes one-car-one-ticket physical.
|
||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||
*/
|
||||
readonly presenceInput?: number;
|
||||
/** What kind of sensor is on `presenceInput` — an induction LOOP or a RADAR. Label
|
||||
* only (the gate behaviour is identical); drives UI copy + telemetry. Default loop. */
|
||||
readonly presenceKind?: "loop" | "radar";
|
||||
/** The presence terminal's ACTIVE level is LOW (idles HIGH). Maps to the driver's
|
||||
* per-input `inputActiveLow` override so a radar wired opposite the button reads
|
||||
* right. See wiki/entities/hikvision-radar.md. */
|
||||
readonly presenceActiveLow?: boolean;
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's
|
||||
* 12 V light). Driven by the server LightController off the radar + lane status —
|
||||
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */
|
||||
export interface ButtonLightSpec {
|
||||
/** 1-based spare relay channel the lamp is wired to. */
|
||||
readonly relay: number;
|
||||
// ── radarAlert-only (direction === "radarAlert") ──
|
||||
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||
readonly triggerInput?: number;
|
||||
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||
readonly lockLane?: FlowDirection;
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
readonly blinkOnMs?: number;
|
||||
readonly blinkOffMs?: number;
|
||||
}
|
||||
|
||||
/** Access controller config (the `relays[]` map + connection fields). */
|
||||
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||
interface AccessConfig {
|
||||
readonly relays?: RelaySpec[];
|
||||
/** Optional button-lamp output on a spare relay. */
|
||||
readonly buttonLight?: ButtonLightSpec;
|
||||
readonly inputs?: InputSpec[];
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -105,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a button press to the relay it fires: the access controller with this
|
||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
||||
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||
*/
|
||||
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||
const cfg = row.config as AccessConfig;
|
||||
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||
const synth: InputSpec[] = [];
|
||||
for (const r of relaysOf(row)) {
|
||||
if (typeof r.button === "number") {
|
||||
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||
}
|
||||
if (typeof r.presenceInput === "number") {
|
||||
synth.push({
|
||||
input: r.presenceInput,
|
||||
role: "presence",
|
||||
relay: r.relay,
|
||||
kind: r.presenceKind ?? "loop",
|
||||
activeLow: r.presenceActiveLow,
|
||||
});
|
||||
}
|
||||
}
|
||||
return synth;
|
||||
}
|
||||
|
||||
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||
if (typeof spec.relay !== "number") return null;
|
||||
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||
if (!relay) return null;
|
||||
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||
return { ...relay, direction: relay.direction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||
* Returns null otherwise.
|
||||
*/
|
||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
@@ -116,24 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
const inputs = inputsOf(row);
|
||||
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||
if (!btn) return null;
|
||||
const relay = barrierForInput(row, btn);
|
||||
if (!relay) return null;
|
||||
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||
return {
|
||||
controller: row,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
presenceKind: spec.presenceKind ?? "loop",
|
||||
entryCooldownSec: spec.entryCooldownSec,
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence?.input,
|
||||
presenceKind: presence?.kind ?? "loop",
|
||||
entryCooldownSec: btn.cooldownSec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
*/
|
||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
@@ -142,23 +205,23 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||
if (!presence) return null;
|
||||
const relay = barrierForInput(row, presence);
|
||||
if (!relay) return null;
|
||||
return {
|
||||
controller: row,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
presenceKind: spec.presenceKind ?? "loop",
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence.input,
|
||||
presenceKind: presence.kind ?? "loop",
|
||||
};
|
||||
}
|
||||
|
||||
/** The button-lamp output declared on an access controller, or null. */
|
||||
export function buttonLightOf(row: DeviceRow): ButtonLightSpec | null {
|
||||
const cfg = row.config as AccessConfig;
|
||||
const bl = cfg.buttonLight;
|
||||
return bl && typeof bl.relay === "number" ? bl : null;
|
||||
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +243,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
||||
.get();
|
||||
if (controller && controller.enabled) {
|
||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||
if (spec && spec.direction !== "radarAlert") {
|
||||
return { controller, relay: spec.relay, direction: spec.direction };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -200,7 +266,8 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||
for (const controller of accessRows(db)) {
|
||||
const spec = relaysOf(controller).find(
|
||||
(r) => r.direction === direction || r.direction === "both",
|
||||
(r): r is RelaySpec & { direction: Direction } =>
|
||||
r.direction === direction || r.direction === "both",
|
||||
);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user