refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 38s

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:
2026-06-28 11:23:15 +02:00
parent 25a72ff20a
commit 4418594af0
15 changed files with 970 additions and 444 deletions
+109 -14
View File
@@ -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();
});
});
+99 -67
View File
@@ -2,28 +2,33 @@ import { eq, devices, type Db, type DeviceRow } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
import { buttonLightOf, relayForPresence, type ButtonLightSpec } from "./device-resolve.js";
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
// The entry button's 12 V light, driven by the RADAR input vs. the camera "car in
// zone" signal (the existing advisory lane-status). A disagreement indicator:
// radar present + lane busy (camera confirms a car) → SOLID on
// radar present + lane free (radar sees something, no car) → BLINK (~1 Hz)
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
// indicator:
// trigger active + lane busy (camera confirms a car) → SOLID on
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
// otherwise → OFF
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
// misleading solid "go". See wiki/concepts/button-light-indicator.md.
// misleading solid "go". A controller may have several alert relays (each its own row +
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500;
/** Per-controller live state for the lamp rule. */
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
interface LampState {
/** Lamp config (relay #, blink ms). Mutable: #reconcile updates it in place when the
* admin changes the button-light config without a restart. */
spec: ButtonLightSpec;
/** Is the radar (presence input on an entry relay) currently active? */
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
readonly controllerId: string;
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
* place when the admin changes the alert config without a restart. */
spec: RelaySpec;
/** Is the lamp's trigger input (the radar) currently active? */
present: boolean;
/** The high-level state we're rendering (to avoid restarting a running blink). */
rendered: LightState | null;
@@ -50,10 +55,13 @@ export class ButtonLightController {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #resolveAux: AuxResolver;
/** Per-controller state, keyed by controller deviceId. */
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
readonly #lamps = new Map<string, LampState>();
/** Latest lane status (entry busy = a camera-confirmed car in the entry zone). */
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
* exit camera, not the entry one. */
#entryBusy = false;
#exitBusy = false;
/** Controllers we've already warned lack the aux-output capability (warn once). */
readonly #warned = new Set<string>();
#unsubInput: (() => void) | null = null;
@@ -69,7 +77,7 @@ export class ButtonLightController {
start(): void {
this.#reconcile();
// All lamps start OFF (known-safe baseline) regardless of prior device state.
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
for (const lamp of this.#lamps.values()) this.#apply(lamp);
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
@@ -86,34 +94,36 @@ export class ButtonLightController {
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
const spec = buttonLightOf(row);
if (!spec) continue;
seen.add(row.id);
const existing = this.#lamps.get(row.id);
if (existing) {
existing.spec = spec; // pick up a changed relay # / blink cadence
} else {
this.#lamps.set(row.id, {
spec,
present: false,
rendered: null,
blink: null,
blinkOn: false,
desiredOn: false,
confirmedOn: null,
sending: false,
});
for (const spec of alertRelaysOf(row)) {
const key = lampKey(row.id, spec.relay);
seen.add(key);
const existing = this.#lamps.get(key);
if (existing) {
existing.spec = spec; // pick up a changed trigger input / blink cadence
} else {
this.#lamps.set(key, {
controllerId: row.id,
spec,
present: false,
rendered: null,
blink: null,
blinkOn: false,
desiredOn: false,
confirmedOn: null,
sending: false,
});
}
}
}
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [id, lamp] of this.#lamps) {
if (seen.has(id)) continue;
for (const [key, lamp] of this.#lamps) {
if (seen.has(key)) continue;
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#finalOff(id, lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(id);
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(key);
}
}
@@ -123,28 +133,36 @@ export class ButtonLightController {
#onInput(e: DeviceInputEvent): void {
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
this.#reconcile();
const lamp = this.#lamps.get(e.deviceId);
if (!lamp) return; // no lamp on this controller
const presence = relayForPresence(this.#db, e.deviceId, e.input);
if (!presence) return; // not the presence/radar terminal
const present = e.edge === "on";
if (present === lamp.present) return;
lamp.present = present;
this.#apply(e.deviceId, lamp);
for (const lamp of this.#lamps.values()) {
if (lamp.controllerId !== e.deviceId) continue;
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
// lamp and the one-car-one-ticket gate always agree on "a car is here".
const trigger =
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
if (trigger !== e.input) continue; // not this lamp's trigger terminal
if (present === lamp.present) continue;
lamp.present = present;
this.#apply(lamp);
}
}
/** Lane status changed: entry busy = a camera-confirmed car in the entry zone. */
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
#onLane(s: LaneStatusEvent): void {
if (s.entry === this.#entryBusy) return;
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
this.#entryBusy = s.entry;
// Re-render every lamp (the camera signal is site-wide entry status).
for (const [controllerId, lamp] of this.#lamps) this.#apply(controllerId, lamp);
this.#exitBusy = s.exit;
// Re-render every lamp (each picks its own lane's camera in #apply).
for (const lamp of this.#lamps.values()) this.#apply(lamp);
}
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
* timer/state machine is synchronous; the UDP write resolves on its own). */
#apply(controllerId: string, lamp: LampState): void {
const target: LightState = !lamp.present ? "off" : this.#entryBusy ? "solid" : "blink";
#apply(lamp: LampState): void {
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
if (target === lamp.rendered) return; // already rendering this state
// Tear down any running blink before switching states.
@@ -156,10 +174,10 @@ export class ButtonLightController {
if (target === "off") {
lamp.desiredOn = false;
this.#pump(controllerId, lamp);
this.#pump(lamp);
} else if (target === "solid") {
lamp.desiredOn = true;
this.#pump(controllerId, lamp);
this.#pump(lamp);
} else {
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
@@ -172,7 +190,7 @@ export class ButtonLightController {
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
lamp.desiredOn = lamp.blinkOn;
this.#pump(controllerId, lamp);
this.#pump(lamp);
if (onMs !== offMs && lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
@@ -181,7 +199,7 @@ export class ButtonLightController {
};
lamp.blink = setInterval(tick, onMs);
lamp.blink.unref?.();
this.#pump(controllerId, lamp);
this.#pump(lamp);
}
}
@@ -190,10 +208,10 @@ export class ButtonLightController {
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
* (`sending` guard); when it resolves, if the desired state moved on we send again —
* so the LAST desired state is always the one finally asserted on the device. */
#pump(controllerId: string, lamp: LampState): void {
#pump(lamp: LampState): void {
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
const aux = this.#resolveAux(controllerId);
const aux = this.#resolveAux(lamp.controllerId);
if (!aux) return;
const target = lamp.desiredOn;
lamp.sending = true;
@@ -204,13 +222,13 @@ export class ButtonLightController {
})
.catch((err: unknown) => {
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
})
.finally(() => {
lamp.sending = false;
// Desired state may have changed (or the send failed) while we were busy —
// re-pump to converge. This is what makes the final state authoritative.
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(controllerId, lamp);
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
});
}
@@ -242,34 +260,48 @@ export class ButtonLightController {
this.#unsubLane?.();
this.#unsubInput = null;
this.#unsubLane = null;
for (const [controllerId, lamp] of this.#lamps) {
for (const lamp of this.#lamps.values()) {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
// Best-effort fail-OFF on shutdown.
this.#finalOff(controllerId, lamp);
this.#finalOff(lamp);
}
}
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
* OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. */
#finalOff(controllerId: string, lamp: LampState): void {
#finalOff(lamp: LampState): void {
lamp.desiredOn = false;
this.#pump(controllerId, lamp);
this.#pump(lamp);
}
/** Test seam: current high-level state being rendered for a controller. */
stateOf(controllerId: string): LightState | null {
return this.#lamps.get(controllerId)?.rendered ?? null;
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
stateOf(controllerId: string, relay?: number): LightState | null {
return this.#lamp(controllerId, relay)?.rendered ?? null;
}
/** Test seam: the state last CONFIRMED on the device for a controller (after a
* successful send). null = unknown / nothing sent yet. */
confirmedOf(controllerId: string): boolean | null {
return this.#lamps.get(controllerId)?.confirmedOn ?? null;
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
confirmedOf(controllerId: string, relay?: number): boolean | null {
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
}
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
* controller's single lamp (the common single-alert case); ambiguous if several. */
#lamp(controllerId: string, relay?: number): LampState | undefined {
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
return undefined;
}
}
/** Composite key for the lamp map (a controller may carry several alert relays). */
function lampKey(controllerId: string, relay: number): string {
return `${controllerId}:${relay}`;
}
/** Build a controller row's live aux device (exported for reuse/tests). */
+6 -1
View File
@@ -39,7 +39,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
return d;
}
case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction));
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
const dirs = new Set(
relaysOf(row)
.map((r) => r.direction)
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
);
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
+91
View File
@@ -0,0 +1,91 @@
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();
});
});
+129 -62
View File
@@ -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 };
}
+278 -187
View File
@@ -15,13 +15,15 @@ import {
type PrintTestResult,
type Assignment,
type BackendIpCandidate,
type ButtonLightSpec,
type Catalog,
type CatalogEntry,
type DeviceCategory,
type DeviceConfig,
type Direction,
type DiscoveredDevice,
type InputRole,
type InputSpec,
type RelayEvent,
type RelaySpec,
type TestResult,
} from "./api.js";
@@ -49,13 +51,63 @@ const BOUND: { key: DeviceCategory; titleKey: string; nounKey: string }[] = [
{ key: "printer", titleKey: "setup.catPrinters", nounKey: "setup.nounPrinter" },
];
// Translated direction label (relay direction / inherited binding).
const DIRECTION_KEYS: Record<Direction, string> = {
// Translated relay-event label (barrier direction, inherited binding, or alert).
const DIRECTION_KEYS: Record<RelayEvent, string> = {
entry: "setup.dirEntry",
exit: "setup.dirExit",
both: "setup.dirBoth",
radarAlert: "setup.eventRadarAlert",
};
// The input-role dropdown folds presence `kind` into the choice: one select offers Button,
// Presence (loop), Presence (radar), Alert trigger. Each maps to a {role, kind} pair.
type InputChoice = "button" | "presenceLoop" | "presenceRadar" | "alertTrigger";
const INPUT_CHOICE_KEYS: Record<InputChoice, string> = {
button: "setup.roleButton",
presenceLoop: "setup.rolePresenceLoop",
presenceRadar: "setup.rolePresenceRadar",
alertTrigger: "setup.roleAlertTrigger",
};
function choiceOf(i: InputSpec): InputChoice {
if (i.role === "button") return "button";
if (i.role === "alertTrigger") return "alertTrigger";
return i.kind === "radar" ? "presenceRadar" : "presenceLoop";
}
function applyChoice(choice: InputChoice): { role: InputRole; kind?: "loop" | "radar" } {
switch (choice) {
case "button":
return { role: "button" };
case "alertTrigger":
return { role: "alertTrigger" };
case "presenceLoop":
return { role: "presence", kind: "loop" };
case "presenceRadar":
return { role: "presence", kind: "radar" };
}
}
/** Synthesize an inputs[] list from the LEGACY per-relay button/presence fields, so an
* existing controller (saved before inputs[]) opens with its inputs populated. Mirrors the
* server's `inputsOf()` back-compat fold. */
function synthInputsFromRelays(relays: RelaySpec[]): InputSpec[] {
const out: InputSpec[] = [];
for (const r of relays) {
if (typeof r.button === "number") {
out.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
}
if (typeof r.presenceInput === "number") {
out.push({
input: r.presenceInput,
role: "presence",
relay: r.relay,
kind: r.presenceKind ?? "loop",
activeLow: r.presenceActiveLow,
});
}
}
return out;
}
export function SetupWizard() {
const { t } = useTranslation();
const [catalog, setCatalog] = useState<Catalog | null>(null);
@@ -82,7 +134,7 @@ export function SetupWizard() {
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("setup.title")}</h2>
<p className="hint mb-4 max-w-prose">{t("setup.intro")}</p>
<p className="hint mb-4 max-w">{t("setup.intro")}</p>
<CategorySection
category={CONTROLLER.key}
@@ -273,24 +325,25 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
// Effective inputs: config.inputs[] if present, else synthesized from legacy relay fields.
const inputs = Array.isArray(cfg.inputs) ? (cfg.inputs as InputSpec[]) : synthInputsFromRelays(relays);
return (
<span className="flex flex-wrap gap-1.5">
{relays.map((r) => {
const presence = r.presenceInput
? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}`
: "";
return (
<DirectionBadge
key={r.relay}
direction={r.direction}
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`}
/>
);
// Alert relay: trigger input + lock lane. Barrier: its button + presence inputs.
let wiring = "";
if (r.direction === "radarAlert") {
if (r.triggerInput) wiring += `·trig${r.triggerInput}`;
if (r.lockLane === "exit") wiring += "·lockExit";
} else {
const served = inputs.filter((x) => x.relay === r.relay);
const btn = served.find((x) => x.role === "button");
const pres = served.find((x) => x.role === "presence");
if (btn) wiring += `·btn${btn.input}`;
if (pres) wiring += `·${pres.kind === "radar" ? "radar" : "loop"}${pres.input}`;
}
return <DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${wiring}`} />;
})}
{bl?.relay != null && (
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
)}
</span>
);
}
@@ -365,14 +418,19 @@ function DeviceForm({
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Controller-level button-lamp output (a spare relay), driven by the radar + camera.
const [buttonLight, setButtonLight] = useState<ButtonLightSpec | null>(() => {
const bl = editCfg?.buttonLight as ButtonLightSpec | undefined;
return bl && typeof bl.relay === "number" ? bl : null;
// Controller INPUTS — a first-class list (button / presence / alertTrigger), each naming
// the relay it serves. Seed from config.inputs[] if present, else SYNTHESIZE from the
// legacy per-relay button/presence fields so an existing controller opens populated.
const [inputs, setInputs] = useState<InputSpec[]>(() => {
const stored = editCfg?.inputs;
if (Array.isArray(stored) && stored.length > 0) return stored as InputSpec[];
return synthInputsFromRelays(Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : []);
});
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(
@@ -487,23 +545,31 @@ function DeviceForm({
function mergedConfig(): DeviceConfig {
const out: DeviceConfig = { ...mergedScalarConfig() };
if (isController) {
out.relays = relays.map((r) => ({
relay: r.relay,
direction: r.direction,
...(r.button ? { button: r.button } : {}),
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}),
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}),
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
}));
// Button-lamp output (a spare relay), persisted only when a relay is chosen.
if (buttonLight && buttonLight.relay) {
out.buttonLight = {
relay: buttonLight.relay,
...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}),
...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}),
};
}
// Relays carry ONLY the event (+ alert fields). Input wiring lives in out.inputs.
out.relays = relays.map((r) =>
r.direction === "radarAlert"
? {
// Alert lamp: trigger input + lock lane + blink cadence.
relay: r.relay,
direction: r.direction,
...(r.triggerInput ? { triggerInput: r.triggerInput } : {}),
...(r.lockLane && r.lockLane !== "entry" ? { lockLane: r.lockLane } : {}),
...(r.blinkOnMs ? { blinkOnMs: r.blinkOnMs } : {}),
...(r.blinkOffMs ? { blinkOffMs: r.blinkOffMs } : {}),
}
: { relay: r.relay, direction: r.direction },
);
// Inputs: a button/presence row needs its relay; alertTrigger may be standalone.
out.inputs = inputs
.filter((i) => typeof i.input === "number" && i.input > 0)
.map((i) => ({
input: i.input,
role: i.role,
...(typeof i.relay === "number" ? { relay: i.relay } : {}),
...(i.role === "presence" && i.kind ? { kind: i.kind } : {}),
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
}));
} else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId;
out.relay = boundRelay;
@@ -749,13 +815,11 @@ function DeviceForm({
),
)}
{/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
{/* CONTROLLER — OUTPUTS: the unified relays (barriers pulse, alert relays blink). */}
{isController && (
<OutputEditor
relays={relays}
onChange={setRelays}
buttonLight={buttonLight}
onButtonLightChange={setButtonLight}
pulseMs={config.pulseMs as number | undefined}
onPulseMsChange={(v) => {
setConfig((c) => ({ ...c, pulseMs: v }));
@@ -764,12 +828,13 @@ function DeviceForm({
/>
)}
{/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound
to the output relay it drives. Separated from the outputs above. */}
{/* CONTROLLER — INPUTS: a generic terminal list (button / presence / alert trigger),
each naming the relay it serves. Separated from the outputs above. */}
{isController && (
<InputEditor
inputs={inputs}
onChange={setInputs}
relays={relays}
onChange={setRelays}
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
onInputsIdleHighChange={(v) => {
setConfig((c) => ({ ...c, inputRestingHigh: v }));
@@ -1008,24 +1073,21 @@ function DeviceForm({
}
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
// A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section
// owns relay number + direction, the pulse-open time (relay hold ms), and the lamp
// relay. The INPUT terminals wired to these relays live in InputEditor below — the two
// are deliberately separated (a controller's inputs and outputs are distinct things).
// A relay is an OUTPUT reacting to an EVENT: entry/exit/both PULSE a barrier; radarAlert
// BLINKS an indicator lamp (and a camera-confirmed car locks it solid). This section owns
// the relay number + event, the pulse-open hold time (barriers), and — for alert relays —
// the trigger input + blink cadence. The barrier INPUT terminals (entry button, presence)
// live in InputEditor below; the two are deliberately separated.
/** Relays = outputs (barriers + lamp) + the pulse-open hold time. */
/** Relays = the unified event→action outputs + the pulse-open hold time. */
function OutputEditor({
relays,
onChange,
buttonLight,
onButtonLightChange,
pulseMs,
onPulseMsChange,
}: {
relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
buttonLight: ButtonLightSpec | null;
onButtonLightChange: (v: ButtonLightSpec | null) => void;
pulseMs: number | undefined;
onPulseMsChange: (v: number) => void;
}) {
@@ -1040,7 +1102,6 @@ function OutputEditor({
function remove(i: number) {
onChange(relays.filter((_, idx) => idx !== i));
}
const barrierRelays = new Set(relays.map((r) => r.relay));
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
@@ -1060,7 +1121,8 @@ function OutputEditor({
/>
</label>
{/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */}
{/* Each relay: number + event. radarAlert reveals its trigger input + blink cadence;
barriers pulse (their button/presence terminals are in the Inputs section). */}
{relays.map((r, i) => (
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
@@ -1073,13 +1135,68 @@ function OutputEditor({
onChange={(e) => update(i, { relay: Number(e.target.value) })}
/>
</label>
<select className="select input-sm w-auto" value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
{(["entry", "exit", "both"] as Direction[]).map((d) => (
<select
className="select input-sm w-auto"
value={r.direction}
onChange={(e) => update(i, { direction: e.target.value as RelayEvent })}
>
{(["entry", "exit", "both", "radarAlert"] as RelayEvent[]).map((d) => (
<option key={d} value={d}>
{t(DIRECTION_KEYS[d])}
</option>
))}
</select>
{/* Alert relay: which input fires the blink + the blink cadence. */}
{r.direction === "radarAlert" && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
{t("setup.triggerInput")}
<input
type="number"
min={1}
value={r.triggerInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
{t("setup.lockLane")}
<select
className="select input-sm w-auto"
value={r.lockLane ?? "entry"}
onChange={(e) => update(i, { lockLane: e.target.value as "entry" | "exit" })}
>
<option value="entry">{t("setup.lockLaneEntry")}</option>
<option value="exit">{t("setup.lockLaneExit")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={r.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={r.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) => update(i, { blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
</>
)}
{relays.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
@@ -1090,92 +1207,47 @@ function OutputEditor({
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addRelay")}
</button>
{/* Button-lamp output (a spare relay) — an OUTPUT, so it lives here. Driven by the
radar + camera (blink = radar-only, solid = car confirmed, off otherwise). */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-term-border pt-2">
<span className="text-[12px] text-term-muted" title={t("setup.buttonLightHint")}>
{t("setup.buttonLight")}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.buttonLightRelay")}
<input
type="number"
min={1}
value={buttonLight?.relay ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
onButtonLightChange(e.target.value === "" ? null : { ...buttonLight, relay: Number(e.target.value) })
}
/>
</label>
{buttonLight?.relay != null && barrierRelays.has(buttonLight.relay) && (
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
)}
{buttonLight?.relay != null && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOnMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.blinkOffMs")}
<input
type="number"
min={50}
value={buttonLight.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onButtonLightChange({ ...buttonLight, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
</>
)}
</div>
</div>
);
}
// ── Controller INPUTS (terminals) ──────────────────────────────────────────
// An input is a TERMINAL the host READS: the entry button, the presence/radar sensor.
// Each input belongs to an entry barrier (it triggers/gates that relay's entry), so we
// render one block per entry/both relay, labelled with the output relay it drives. The
// button never SETS a pulse — its electrical pulse is the device's to report — so no
// timing field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
// An input is a TERMINAL the host READS. It's a first-class list (the twin of the relays
// list above): each row is a terminal + a ROLE (entry button / presence loop / presence
// radar / alert trigger) + the relay it serves. Adding an exit radar = adding a row. The
// button never SETS a pulse — its electrical pulse is the device's to report — so no timing
// field lives here (pulse-open is an OUTPUT setting, in OutputEditor).
/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */
/** Generic controller-input list: terminal + role + the relay it serves. */
function InputEditor({
relays,
inputs,
onChange,
relays,
inputsIdleHigh,
onInputsIdleHighChange,
}: {
inputs: InputSpec[];
onChange: (v: InputSpec[]) => void;
relays: RelaySpec[];
onChange: (r: RelaySpec[]) => void;
inputsIdleHigh: boolean | undefined;
onInputsIdleHighChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
function update(i: number, patch: Partial<RelaySpec>) {
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
function update(i: number, patch: Partial<InputSpec>) {
onChange(inputs.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
}
// Inputs only matter for entry/both relays (transient entry). Keep each row's real
// index so updates target the right relay.
const entryRelays = relays
.map((r, i) => ({ r, i }))
.filter(({ r }) => r.direction === "entry" || r.direction === "both");
function add() {
const firstEntry = relays.find((r) => r.direction === "entry" || r.direction === "both");
onChange([...inputs, { input: 1, role: "button", relay: firstEntry?.relay }]);
}
function remove(i: number) {
onChange(inputs.filter((_, idx) => idx !== i));
}
// Barrier relays an input can serve (button/presence gate a barrier; alert triggers don't).
const barrierRelays = relays.filter((r) => r.direction !== "radarAlert");
// A button row shows its cooldown fallback only if no presence row serves the same relay.
const hasPresenceFor = (relay?: number) =>
relay != null && inputs.some((x) => x.role === "presence" && x.relay === relay);
return (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
@@ -1196,77 +1268,91 @@ function InputEditor({
</span>
</label>
{entryRelays.length === 0 ? (
<p className="hint">{t("setup.inputsNoEntryRelay")}</p>
) : (
entryRelays.map(({ r, i }) => (
{inputs.map((row, i) => {
const choice = choiceOf(row);
const isPresence = row.role === "presence";
const isButton = row.role === "button";
return (
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
<span className="text-[11px] uppercase tracking-wider text-term-amber">
{t("setup.inputsForRelay", { relay: r.relay })}
</span>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.entryButtonTerminal")}
{t("setup.inputTerminal")}
<input
type="number"
min={1}
value={r.button ?? ""}
placeholder="—"
value={row.input}
className="input input-sm w-16"
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
onChange={(e) => update(i, { input: Number(e.target.value) })}
/>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
{t("setup.presenceInput")}
<input
type="number"
min={1}
value={r.presenceInput ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
{/* Sensor kind + active-level — only once a presence terminal is set. */}
{!!r.presenceInput && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.presenceKind")}
<select
value={r.presenceKind ?? "loop"}
className="input input-sm w-24"
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
>
<option value="loop">{t("setup.presenceKindLoop")}</option>
<option value="radar">{t("setup.presenceKindRadar")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
<input
type="checkbox"
checked={!!r.presenceActiveLow}
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
/>
{t("setup.presenceActiveLow")}
</label>
</>
<select
className="select input-sm w-auto"
value={choice}
onChange={(e) => update(i, applyChoice(e.target.value as InputChoice))}
>
{(["button", "presenceLoop", "presenceRadar", "alertTrigger"] as InputChoice[]).map((c) => (
<option key={c} value={c}>
{t(INPUT_CHOICE_KEYS[c])}
</option>
))}
</select>
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
{row.role !== "alertTrigger" && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.inputServesRelay")}
<select
className="select input-sm w-auto"
value={row.relay ?? ""}
onChange={(e) => update(i, { relay: e.target.value === "" ? undefined : Number(e.target.value) })}
>
<option value="" disabled>
{t("setup.choose")}
</option>
{barrierRelays.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select>
</label>
)}
{/* Cooldown fallback only when no presence sensor is wired. */}
{!r.presenceInput && (
{/* Presence: active-low (a radar wired opposite the button). */}
{isPresence && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
<input
type="checkbox"
checked={!!row.activeLow}
onChange={(e) => update(i, { activeLow: e.target.checked || undefined })}
/>
{t("setup.activeLow")}
</label>
)}
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
{isButton && !hasPresenceFor(row.relay) && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
<input
type="number"
min={0}
value={r.entryCooldownSec ?? ""}
value={row.cooldownSec ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
onChange={(e) => update(i, { cooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</label>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
✕
</button>
</div>
))
)}
);
})}
<button type="button" className="btn btn-sm mt-1" onClick={add}>
{t("setup.addInput")}
</button>
</div>
);
}
@@ -1325,11 +1411,14 @@ function BindingPicker({
<option value="" disabled>
{t("setup.choose")}
</option>
{relays.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
{/* Only barrier relays are bindable — an alert lamp opens nothing. */}
{relays
.filter((r) => r.direction !== "radarAlert")
.map((r) => (
<option key={r.relay} value={r.relay}>
{t("setup.relayLabel", { relay: r.relay, direction: t(DIRECTION_KEYS[r.direction]) })}
</option>
))}
</select>
</label>
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
@@ -1341,14 +1430,16 @@ function BindingPicker({
);
}
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
// entry=green, exit=amber, both=muted — aligned to the terminal accent palette.
function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: string }) {
// entry=green, exit=amber, radarAlert=red (an alert), both=muted — terminal accents.
const cls =
direction === "entry"
? "border-term-green text-term-green"
: direction === "exit"
? "border-term-amber text-term-amber"
: "border-term-muted text-term-muted";
: direction === "radarAlert"
? "border-term-red text-term-red"
: "border-term-muted text-term-muted";
return (
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
{label ?? direction}
+40 -21
View File
@@ -275,30 +275,49 @@ export type DeviceConfig = Record<string, ConfigValue>;
/** Direction a barrier/relay (or a device bound to it) serves. */
export type Direction = "entry" | "exit" | "both";
/** One relay on an access controller: which barrier it opens, in which direction,
* and (optionally) the input terminal its entry button is wired to. */
export interface RelaySpec {
relay: number;
direction: Direction;
/** Input terminal of the entry button that fires this relay (transient entry). */
button?: number;
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
presenceInput?: number;
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
presenceKind?: "loop" | "radar";
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
* the button. Maps to the driver's per-input active-level override. */
presenceActiveLow?: boolean;
entryCooldownSec?: number;
/** The EVENT a relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` → drive a
* non-barrier alert lamp (blink while its trigger input is active, SOLID once the camera
* confirms a car). The action is implied by the event. */
export type RelayEvent = Direction | "radarAlert";
/** What a controller input terminal means: a transient-entry `button`, a one-car-one-ticket
* `presence` sensor (loop/radar), or an `alertTrigger` for a radarAlert lamp. */
export type InputRole = "button" | "presence" | "alertTrigger";
/** One INPUT terminal the host reads (the twin of RelaySpec). An exit radar is just another
* `presence` row serving the exit relay. */
export interface InputSpec {
input: number;
role: InputRole;
/** The barrier relay this input serves (required for button/presence; optional for
* alertTrigger). */
relay?: number;
/** presence only — induction LOOP or RADAR (label only). */
kind?: "loop" | "radar";
/** This terminal idles HIGH / is active-LOW (e.g. a radar wired opposite the button). */
activeLow?: boolean;
/** button only — presence-less fallback cooldown (seconds). */
cooldownSec?: number;
}
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button light),
* driven by the radar input vs. the camera lane status. */
export interface ButtonLightSpec {
/** 1-based spare relay the lamp is on. */
/** One relay on an access controller: the event it reacts to. Input wiring lives in
* `config.inputs[]`; the legacy per-relay button/presence fields are still read for
* back-compat but no longer written. */
export interface RelaySpec {
relay: number;
/** The event this relay reacts to (UI label: "Event"). */
direction: RelayEvent;
// ── legacy input fields (read-only back-compat; superseded by config.inputs[]) ──
button?: number;
presenceInput?: number;
presenceKind?: "loop" | "radar";
presenceActiveLow?: boolean;
entryCooldownSec?: number;
// ── radarAlert-only ──
/** Input terminal whose active edge starts the blink (the radar). */
triggerInput?: number;
/** Which lane's camera locks this lamp SOLID (default entry). An exit radar locks on exit. */
lockLane?: "entry" | "exit";
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
blinkOnMs?: number;
blinkOffMs?: number;
+19 -16
View File
@@ -369,27 +369,30 @@ export const en: Catalog = {
"Inputs are TERMINALS the host READS: the entry button and the presence/radar sensor. Each belongs to an entry barrier — it triggers or gates that relay.",
inputsIdleHigh: "Inputs idle HIGH",
inputsIdleHighHint: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
inputsForRelay: "For relay {{relay}}",
inputsNoEntryRelay: "No entry relay — add an 'Entry' or 'Entry + exit' relay in Outputs to assign terminals.",
relay: "Relay",
entryButtonTerminal: "Entry button on terminal",
presenceInput: "Presence sensor (terminal)",
presenceInputHint:
"Input terminal the vehicle-presence sensor (induction loop or radar) is wired to. When set, exactly ONE ticket issues per car: the button prints only while a car is present, and no second ticket issues until the sensor clears (the car drove in) and a new car re-occupies it. Preferred mode.",
// Generic input rows: terminal + role + the relay it serves.
inputTerminal: "Terminal",
inputServesRelay: "Serves relay",
roleButton: "Entry button",
rolePresenceLoop: "Presence (loop)",
rolePresenceRadar: "Presence (radar)",
roleAlertTrigger: "Alert trigger",
addInput: "+ Add input",
entryCooldown: "Cooldown after ticket (s)",
entryCooldownHint:
"When there's no presence sensor: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
presenceKind: "Kind",
presenceKindLoop: "Loop",
presenceKindRadar: "Radar",
presenceActiveLow: "Active-low",
presenceActiveLowHint:
activeLow: "Active-low",
activeLowHint:
"Tick if the presence sensor (e.g. a radar) idles HIGH and goes LOW on detection — the opposite of the button. This inverts that terminal's reading so 'present' is read correctly.",
buttonLight: "Button light (spare relay)",
buttonLightRelay: "Relay",
buttonLightHint:
"The button's 12 V light on a spare relay. Blinks when the radar detects but the camera doesn't confirm a car; solid on when both confirm; off otherwise.",
buttonLightBarrierWarn: "This relay is used by a barrier — pick a spare relay.",
eventRadarAlert: "Radar alert (lamp)",
triggerInput: "Trigger input",
triggerInputHint:
"The input terminal (the radar) that starts this relay blinking. Blinks while the trigger is active but the camera doesn't confirm a car; solid on once the camera confirms; off otherwise.",
lockLane: "Lock from",
lockLaneHint:
"Which camera locks the lamp solid: the entry or the exit camera. An exit radar must lock on the EXIT camera.",
lockLaneEntry: "Entry camera",
lockLaneExit: "Exit camera",
blinkOnMs: "Blink on (ms)",
blinkOffMs: "Blink off (ms)",
addRelay: "+ Add relay",
+19 -16
View File
@@ -378,27 +378,30 @@ export const sq = {
"Hyrjet janë TERMINALE që hosti i LEXON: butoni i hyrjes dhe sensori i pranisë/radari. Secila i përket një barriere hyrëse — e gateron ose e nis atë rele.",
inputsIdleHigh: "Hyrjet në pushim HIGH",
inputsIdleHighHint: "Kjo pllakë i mban hyrjet HIGH në pushim (statusi 1111); një shtypje e ul në LOW.",
inputsForRelay: "Për rele {{relay}}",
inputsNoEntryRelay: "Asnjë rele hyrëse — shto një rele 'Hyrje' ose 'Hyrje + dalje' te Daljet që të caktosh terminalet.",
relay: "Rele",
entryButtonTerminal: "Butoni i hyrjes në terminalin",
presenceInput: "Sensori i pranisë (terminali)",
presenceInputHint:
"Terminali hyrës ku është lidhur sensori/laku i pranisë së automjetit. Kur vendoset, lëshohet vetëm NJË biletë për automjet: butoni printon vetëm kur ka makinë, dhe nuk lëshon biletë të dytë derisa laku të lirohet (makina hyri) dhe një makinë e re ta zërë. Mënyra e preferuar.",
// Generic input rows: terminal + role + the relay it serves.
inputTerminal: "Terminali",
inputServesRelay: "I shërben reles",
roleButton: "Butoni i hyrjes",
rolePresenceLoop: "Prania (lak induktiv)",
rolePresenceRadar: "Prania (radar)",
roleAlertTrigger: "Trigger alarmi",
addInput: "+ Shto hyrje",
entryCooldown: "Pritje pas biletës (sek)",
entryCooldownHint:
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
presenceKind: "Lloji",
presenceKindLoop: "Lak",
presenceKindRadar: "Radar",
presenceActiveLow: "Aktiv-ulët",
presenceActiveLowHint:
activeLow: "Aktiv-ulët",
activeLowHint:
"Shëno nëse sensori i pranisë (p.sh. radari) qëndron HIGH në pushim dhe shkon LOW kur detekton — e kundërta e butonit. Kjo përmbys leximin e atij terminali që 'prania' të lexohet saktë.",
buttonLight: "Drita e butonit (rele rezervë)",
buttonLightRelay: "Rele",
buttonLightHint:
"Drita 12V e butonit e lidhur në një rele rezervë. Pulson kur radari detekton por kamera s'konfirmon makinë; ndizet fiks kur të dy konfirmojnë; përndryshe fiket.",
buttonLightBarrierWarn: "Kjo rele përdoret nga një barrierë — zgjidh një rele rezervë.",
eventRadarAlert: "Alarm radar (dritë)",
triggerInput: "Trigger input",
triggerInputHint:
"Terminali i hyrjes (radari) që nis pulsimin e kësaj rele. Pulson kur Trigger input është aktiv por kamera s'konfirmon makinë; ndizet fiks kur kamera konfirmon; përndryshe fiket.",
lockLane: "Bllokimi nga",
lockLaneHint:
"Cila kamerë e ndez dritën fiks: hyrja apo dalja. Një radar i daljes duhet të bllokohet nga kamera e DALJES.",
lockLaneEntry: "Kamera e hyrjes",
lockLaneExit: "Kamera e daljes",
blinkOnMs: "Pulsim ndezur (ms)",
blinkOffMs: "Pulsim fikur (ms)",
addRelay: "+ Shto rele",