Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa |
@@ -0,0 +1,262 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: 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.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
buttonLight: { relay: LAMP_RELAY, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// Subsequent writes work; driving to solid still converges to ON.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without a buttonLight config", () => {
|
||||||
|
// A second controller, no lamp.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up a button light ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO buttonLight yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves a button light (relay 3) — 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 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
/** Per-controller live state for the lamp rule. */
|
||||||
|
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? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-controller state, keyed by controller deviceId. */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status (entry busy = a camera-confirmed car in the entry zone). */
|
||||||
|
#entryBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [id, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(id)) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: entry busy = a camera-confirmed car in the entry zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy) 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(controllerId, 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
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(controllerId, lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* 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 {
|
||||||
|
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);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
})
|
||||||
|
.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}`);
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const [controllerId, lamp] of this.#lamps) {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(controllerId, 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 {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(controllerId, 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: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,12 +33,32 @@ export interface RelaySpec {
|
|||||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||||
*/
|
*/
|
||||||
readonly presenceInput?: number;
|
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;
|
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;
|
||||||
|
/** 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[]` map + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
/** Optional button-lamp output on a spare relay. */
|
||||||
|
readonly buttonLight?: ButtonLightSpec;
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +80,11 @@ export interface ResolvedRelay {
|
|||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +124,7 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
relay: spec.relay,
|
relay: spec.relay,
|
||||||
direction: spec.direction,
|
direction: spec.direction,
|
||||||
presenceInput: spec.presenceInput,
|
presenceInput: spec.presenceInput,
|
||||||
|
presenceKind: spec.presenceKind ?? "loop",
|
||||||
entryCooldownSec: spec.entryCooldownSec,
|
entryCooldownSec: spec.entryCooldownSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,7 +145,20 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
|||||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: spec.relay,
|
||||||
|
direction: spec.direction,
|
||||||
|
presenceInput: spec.presenceInput,
|
||||||
|
presenceKind: spec.presenceKind ?? "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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { storedSecrets } from "./setup.js";
|
||||||
|
|
||||||
|
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||||
|
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||||
|
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||||
|
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||||
|
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const ID = "ctl-secret";
|
||||||
|
const HOST = "10.0.10.5";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: ID,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storedSecrets identity guard", () => {
|
||||||
|
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
expect(out.pushPassword).toBe("p-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", {});
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when a control port is changed", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||||
|
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an unknown device id", () => {
|
||||||
|
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,11 @@ interface AssignBody {
|
|||||||
interface TestBody {
|
interface TestBody {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||||
|
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||||
|
* this, testing an edited device would send no relay password → the device ignores
|
||||||
|
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
@@ -54,6 +59,41 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
|
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||||
|
// UDP + CGI targets; serial covers serial-bound readers.
|
||||||
|
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||||
|
|
||||||
|
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||||
|
* when the submitted config addresses the SAME device — same driver, and every
|
||||||
|
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||||
|
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||||
|
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||||
|
* redirected test from exfiltrating the secret to an attacker host. */
|
||||||
|
export function storedSecrets(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
driverId: string,
|
||||||
|
submitted: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row || row.driverId !== driverId) return {};
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||||
|
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||||
|
for (const k of IDENTITY_KEYS) {
|
||||||
|
const sent = submitted[k];
|
||||||
|
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
type ConfigureOutcome =
|
type ConfigureOutcome =
|
||||||
@@ -249,13 +289,30 @@ export async function setupRoutes(
|
|||||||
"/api/setup/test",
|
"/api/setup/test",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { driverId, config } = req.body;
|
const { driverId, config, id } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||||
|
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||||
|
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||||
|
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||||
|
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||||
|
// only fill a secret from the store when the form didn't send a real one.
|
||||||
|
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||||
|
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||||
|
// the secret to an attacker host. Submitted values still win.
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
device = registry.create(driverId, config);
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
@@ -188,6 +189,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||||
|
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||||
|
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||||
|
const buttonLight = new ButtonLightController(db, app.log);
|
||||||
|
buttonLight.start();
|
||||||
|
app.addHook("onClose", async () => buttonLight.stop());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
|
|||||||
+274
-45
@@ -13,6 +13,7 @@ import {
|
|||||||
type AnprTestResult,
|
type AnprTestResult,
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
|
type ButtonLightSpec,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
@@ -270,11 +271,24 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
if (assignment.category === "access") {
|
if (assignment.category === "access") {
|
||||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||||
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
|
||||||
|
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
{relays.map((r) => (
|
{relays.map((r) => {
|
||||||
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
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}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{bl?.relay != null && (
|
||||||
|
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -345,6 +359,11 @@ function DeviceForm({
|
|||||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
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;
|
||||||
|
});
|
||||||
// Bound devices: which controller + relay this device sits at.
|
// Bound devices: which controller + relay this device sits at.
|
||||||
const [controllerId, setControllerId] = useState<string>(
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||||
@@ -442,8 +461,18 @@ function DeviceForm({
|
|||||||
direction: r.direction,
|
direction: r.direction,
|
||||||
...(r.button ? { button: r.button } : {}),
|
...(r.button ? { button: r.button } : {}),
|
||||||
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
|
||||||
|
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}),
|
||||||
|
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}),
|
||||||
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
|
...(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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
} else if (controllerId && boundRelay !== "") {
|
} else if (controllerId && boundRelay !== "") {
|
||||||
out.controllerId = controllerId;
|
out.controllerId = controllerId;
|
||||||
out.relay = boundRelay;
|
out.relay = boundRelay;
|
||||||
@@ -467,7 +496,7 @@ function DeviceForm({
|
|||||||
setTestError(null);
|
setTestError(null);
|
||||||
setTested(null);
|
setTested(null);
|
||||||
try {
|
try {
|
||||||
setTested(await testDevice(selected.id, mergedScalarConfig()));
|
setTested(await testDevice(selected.id, mergedScalarConfig(), editing?.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestError((e as Error).message);
|
setTestError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -569,7 +598,12 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.configFields.map((f) =>
|
{selected.configFields
|
||||||
|
// pulseMs + inputRestingHigh are surfaced in the Outputs / Inputs model
|
||||||
|
// sections below (a relay setting and an input setting, respectively), so
|
||||||
|
// skip them here to avoid rendering them twice. See OutputEditor/InputEditor.
|
||||||
|
.filter((f) => !(isController && (f.key === "pulseMs" || f.key === "inputRestingHigh")))
|
||||||
|
.map((f) =>
|
||||||
f.type === "boolean" ? (
|
f.type === "boolean" ? (
|
||||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||||
// the string "true"). The label sits beside the box, with the help below.
|
// the string "true"). The label sits beside the box, with the help below.
|
||||||
@@ -628,8 +662,34 @@ function DeviceForm({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
{/* CONTROLLER — OUTPUTS: the relays (barriers + the button lamp) + pulse time. */}
|
||||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
{isController && (
|
||||||
|
<OutputEditor
|
||||||
|
relays={relays}
|
||||||
|
onChange={setRelays}
|
||||||
|
buttonLight={buttonLight}
|
||||||
|
onButtonLightChange={setButtonLight}
|
||||||
|
pulseMs={config.pulseMs as number | undefined}
|
||||||
|
onPulseMsChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, pulseMs: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONTROLLER — INPUTS: the terminals (entry button, presence/radar), each bound
|
||||||
|
to the output relay it drives. Separated from the outputs above. */}
|
||||||
|
{isController && (
|
||||||
|
<InputEditor
|
||||||
|
relays={relays}
|
||||||
|
onChange={setRelays}
|
||||||
|
inputsIdleHigh={config.inputRestingHigh as boolean | undefined}
|
||||||
|
onInputsIdleHighChange={(v) => {
|
||||||
|
setConfig((c) => ({ ...c, inputRestingHigh: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. */}
|
||||||
{!isController && (
|
{!isController && (
|
||||||
@@ -760,9 +820,28 @@ function DeviceForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
// ── Controller OUTPUTS (relays) ────────────────────────────────────────────
|
||||||
* the input terminal its entry button is wired to. */
|
// A relay is an OUTPUT: it opens a barrier (or drives the button lamp). This section
|
||||||
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
// 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).
|
||||||
|
|
||||||
|
/** Relays = outputs (barriers + lamp) + 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;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
function update(i: number, patch: Partial<RelaySpec>) {
|
function update(i: number, patch: Partial<RelaySpec>) {
|
||||||
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
@@ -774,11 +853,27 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
function remove(i: number) {
|
function remove(i: number) {
|
||||||
onChange(relays.filter((_, idx) => idx !== i));
|
onChange(relays.filter((_, idx) => idx !== i));
|
||||||
}
|
}
|
||||||
|
const barrierRelays = new Set(relays.map((r) => r.relay));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.relaysTitle")}</strong>
|
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||||
<p className="hint mt-0.5 mb-2">{t("setup.relaysHint")}</p>
|
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||||
|
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||||
|
{t("setup.pulseOpenMs")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
value={pulseMs ?? ""}
|
||||||
|
placeholder="500"
|
||||||
|
className="input input-sm w-20"
|
||||||
|
onChange={(e) => onPulseMsChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Barrier relays: number + direction. (Input terminals are in the Inputs section.) */}
|
||||||
{relays.map((r, i) => (
|
{relays.map((r, i) => (
|
||||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
<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">
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
@@ -798,7 +893,130 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
{relays.length > 1 && (
|
||||||
|
<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.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).
|
||||||
|
|
||||||
|
/** Per-entry-relay input terminals: the entry button + the presence/radar sensor. */
|
||||||
|
function InputEditor({
|
||||||
|
relays,
|
||||||
|
onChange,
|
||||||
|
inputsIdleHigh,
|
||||||
|
onInputsIdleHighChange,
|
||||||
|
}: {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||||
|
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||||
|
|
||||||
|
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||||
|
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={inputsIdleHigh ?? true}
|
||||||
|
onChange={(e) => onInputsIdleHighChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{t("setup.inputsIdleHigh")}</span>
|
||||||
|
<span className="hint mt-0.5 block">{t("setup.inputsIdleHighHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{entryRelays.length === 0 ? (
|
||||||
|
<p className="hint">{t("setup.inputsNoEntryRelay")}</p>
|
||||||
|
) : (
|
||||||
|
entryRelays.map(({ r, i }) => (
|
||||||
|
<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">
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
{t("setup.entryButtonTerminal")}
|
{t("setup.entryButtonTerminal")}
|
||||||
<input
|
<input
|
||||||
@@ -810,8 +1028,6 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
{(r.direction === "entry" || r.direction === "both") && (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceInputHint")}>
|
||||||
{t("setup.presenceInput")}
|
{t("setup.presenceInput")}
|
||||||
<input
|
<input
|
||||||
@@ -820,37 +1036,50 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
|
|||||||
value={r.presenceInput ?? ""}
|
value={r.presenceInput ?? ""}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
className="input input-sm w-16"
|
className="input input-sm w-16"
|
||||||
onChange={(e) =>
|
onChange={(e) => update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
update(i, { presenceInput: e.target.value === "" ? undefined : Number(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
{/* Sensor kind + active-level — only once a presence terminal is set. */}
|
||||||
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
|
{!!r.presenceInput && (
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
<>
|
||||||
{t("setup.entryCooldown")}
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||||
<input
|
{t("setup.presenceKind")}
|
||||||
type="number"
|
<select
|
||||||
min={0}
|
value={r.presenceKind ?? "loop"}
|
||||||
value={r.entryCooldownSec ?? ""}
|
className="input input-sm w-24"
|
||||||
placeholder="—"
|
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
|
||||||
className="input input-sm w-16"
|
>
|
||||||
onChange={(e) =>
|
<option value="loop">{t("setup.presenceKindLoop")}</option>
|
||||||
update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })
|
<option value="radar">{t("setup.presenceKindRadar")}</option>
|
||||||
}
|
</select>
|
||||||
/>
|
</label>
|
||||||
</label>
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
|
||||||
)}
|
<input
|
||||||
{relays.length > 1 && (
|
type="checkbox"
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(i)}>
|
checked={!!r.presenceActiveLow}
|
||||||
✕
|
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
|
||||||
</button>
|
/>
|
||||||
)}
|
{t("setup.presenceActiveLow")}
|
||||||
</div>
|
</label>
|
||||||
))}
|
</>
|
||||||
<button type="button" className="btn btn-sm mt-1" onClick={add}>
|
)}
|
||||||
{t("setup.addRelay")}
|
{/* Cooldown fallback only when no presence sensor is wired. */}
|
||||||
</button>
|
{!r.presenceInput && (
|
||||||
|
<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 ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="input input-sm w-16"
|
||||||
|
onChange={(e) => update(i, { entryCooldownSec: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -286,9 +286,24 @@ export interface RelaySpec {
|
|||||||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
* 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. */
|
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||||||
presenceInput?: number;
|
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;
|
entryCooldownSec?: 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. */
|
||||||
|
relay: number;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
blinkOnMs?: number;
|
||||||
|
blinkOffMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
health: { status: string; detail?: string };
|
health: { status: string; detail?: string };
|
||||||
preconditions: {
|
preconditions: {
|
||||||
@@ -297,11 +312,13 @@ export interface TestResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test a device config (reachability + preconditions) without saving. */
|
/** Test a device config (reachability + preconditions) without saving. Pass the
|
||||||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
* device `id` when editing an existing one so the server re-merges its stored
|
||||||
|
* machine secrets (e.g. the relay password redacted from the client). */
|
||||||
|
export function testDevice(driverId: string, config: DeviceConfig, id?: string): Promise<TestResult> {
|
||||||
return apiFetch<TestResult>("/api/setup/test", {
|
return apiFetch<TestResult>("/api/setup/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ driverId, config }),
|
body: JSON.stringify({ driverId, config, ...(id ? { id } : {}) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -359,14 +359,39 @@ export const en: Catalog = {
|
|||||||
relaysTitle: "Relays on this controller",
|
relaysTitle: "Relays on this controller",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
|
||||||
|
outputsTitle: "Outputs — relays (barriers + lamp)",
|
||||||
|
outputsHint:
|
||||||
|
"Relays are OUTPUTS: each opens a barrier (or drives the button lamp). Set the relay number and direction. The input terminals (button, sensor) are in the Inputs section below.",
|
||||||
|
pulseOpenMs: "Pulse open (ms)",
|
||||||
|
pulseOpenHint: "How long a barrier relay is held open (jog). Applies to all barrier relays.",
|
||||||
|
inputsTitle: "Inputs — terminals (button, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"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",
|
relay: "Relay",
|
||||||
entryButtonTerminal: "Entry button on terminal",
|
entryButtonTerminal: "Entry button on terminal",
|
||||||
presenceInput: "Presence loop (terminal)",
|
presenceInput: "Presence sensor (terminal)",
|
||||||
presenceInputHint:
|
presenceInputHint:
|
||||||
"Input terminal the vehicle-presence loop / barrier feedback 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 loop clears (the car drove in) and a new car re-occupies it. Preferred mode.",
|
"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.",
|
||||||
entryCooldown: "Cooldown after ticket (s)",
|
entryCooldown: "Cooldown after ticket (s)",
|
||||||
entryCooldownHint:
|
entryCooldownHint:
|
||||||
"When there's no presence loop: repeat button presses are suppressed for this many seconds after a ticket. A fallback (not a guarantee) — a determined abuser can wait it out.",
|
"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:
|
||||||
|
"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.",
|
||||||
|
blinkOnMs: "Blink on (ms)",
|
||||||
|
blinkOffMs: "Blink off (ms)",
|
||||||
addRelay: "+ Add relay",
|
addRelay: "+ Add relay",
|
||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
|
|||||||
@@ -368,6 +368,18 @@ export const sq = {
|
|||||||
relaysTitle: "Relet në këtë kontrollues",
|
relaysTitle: "Relet në këtë kontrollues",
|
||||||
relaysHint:
|
relaysHint:
|
||||||
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
"Çdo rele hap një barrierë. Cakto drejtimin e saj; për hyrje kalimtare, cakto në cilin terminal hyrës është lidhur butoni i hyrjes.",
|
||||||
|
outputsTitle: "Daljet — relet (barrierat + drita)",
|
||||||
|
outputsHint:
|
||||||
|
"Relet janë DALJE: secila hap një barrierë (ose ndez dritën e butonit). Cakto numrin e relesë dhe drejtimin. Terminalet hyrëse (butoni, sensori) janë te seksioni Hyrjet më poshtë.",
|
||||||
|
pulseOpenMs: "Kohëzgjatja e hapjes (ms)",
|
||||||
|
pulseOpenHint: "Sa kohë mbahet rele e barrierës e hapur (jog). Vlen për të gjitha relet e barrierave.",
|
||||||
|
inputsTitle: "Hyrjet — terminalet (buton, sensor)",
|
||||||
|
inputsHint:
|
||||||
|
"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",
|
relay: "Rele",
|
||||||
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
entryButtonTerminal: "Butoni i hyrjes në terminalin",
|
||||||
presenceInput: "Sensori i pranisë (terminali)",
|
presenceInput: "Sensori i pranisë (terminali)",
|
||||||
@@ -376,6 +388,19 @@ export const sq = {
|
|||||||
entryCooldown: "Pritje pas biletës (sek)",
|
entryCooldown: "Pritje pas biletës (sek)",
|
||||||
entryCooldownHint:
|
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.",
|
"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:
|
||||||
|
"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ë.",
|
||||||
|
blinkOnMs: "Pulsim ndezur (ms)",
|
||||||
|
blinkOffMs: "Pulsim fikur (ms)",
|
||||||
addRelay: "+ Shto rele",
|
addRelay: "+ Shto rele",
|
||||||
// Camera ANPR opt-in.
|
// Camera ANPR opt-in.
|
||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { 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
|
||||||
|
// input so "present" reads correctly. See wiki/entities/hikvision-radar.md.
|
||||||
|
|
||||||
|
describe("inputActive (per-input active-level)", () => {
|
||||||
|
const none = new Set<number>();
|
||||||
|
const radarOnI2 = new Set<number>([2]);
|
||||||
|
|
||||||
|
it("default board (resting HIGH): a pull LOW is active, HIGH is rest", () => {
|
||||||
|
// Button on I1, board idles HIGH → active when LOW.
|
||||||
|
expect(inputActive(false, 1, true, none)).toBe(true); // LOW = pressed
|
||||||
|
expect(inputActive(true, 1, true, none)).toBe(false); // HIGH = rest
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resting LOW board: a pull HIGH is active", () => {
|
||||||
|
expect(inputActive(true, 1, false, none)).toBe(true);
|
||||||
|
expect(inputActive(false, 1, false, none)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active-low override inverts ONLY the listed input", () => {
|
||||||
|
// Board idles HIGH (button on I1), radar on I2 idles HIGH and goes LOW on detect →
|
||||||
|
// mark I2 active-low so detection (LOW) reads active.
|
||||||
|
// I1 (button) keeps the board default:
|
||||||
|
expect(inputActive(false, 1, true, radarOnI2)).toBe(true); // button LOW = active
|
||||||
|
expect(inputActive(true, 1, true, radarOnI2)).toBe(false);
|
||||||
|
// I2 (radar) overridden to active-low: active when LOW.
|
||||||
|
expect(inputActive(false, 2, true, radarOnI2)).toBe(true); // radar LOW = detecting
|
||||||
|
expect(inputActive(true, 2, true, radarOnI2)).toBe(false); // radar HIGH = clear
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { createSocket } from "node:dgram";
|
|||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import type {
|
import type {
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
HardenableDevice,
|
HardenableDevice,
|
||||||
HardenResult,
|
HardenResult,
|
||||||
@@ -166,6 +167,22 @@ interface DingtianStatus {
|
|||||||
channels: number;
|
channels: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise one input line to "active". `high` = the line is currently HIGH. An input
|
||||||
|
* whose 1-based channel is in `activeLow` is active when LOW (idles HIGH), overriding
|
||||||
|
* the board-wide `restingHigh`; otherwise active = differs from the resting level. This
|
||||||
|
* is the seam that lets a radar (wired opposite the button) read correctly. Exported for
|
||||||
|
* unit testing the bit logic without a UDP socket. See wiki/entities/hikvision-radar.md.
|
||||||
|
*/
|
||||||
|
export function inputActive(
|
||||||
|
high: boolean,
|
||||||
|
channel1Based: number,
|
||||||
|
restingHigh: boolean,
|
||||||
|
activeLow: ReadonlySet<number>,
|
||||||
|
): boolean {
|
||||||
|
return activeLow.has(channel1Based) ? !high : high !== restingHigh;
|
||||||
|
}
|
||||||
|
|
||||||
const INPUT_LINK_ISSUE = {
|
const INPUT_LINK_ISSUE = {
|
||||||
key: "input_link_relay",
|
key: "input_link_relay",
|
||||||
message:
|
message:
|
||||||
@@ -223,6 +240,7 @@ function configApi(
|
|||||||
class DingtianController
|
class DingtianController
|
||||||
implements
|
implements
|
||||||
AccessControlDevice,
|
AccessControlDevice,
|
||||||
|
AuxOutputDevice,
|
||||||
InputDevice,
|
InputDevice,
|
||||||
PreconditionDevice,
|
PreconditionDevice,
|
||||||
PushConfigurableDevice,
|
PushConfigurableDevice,
|
||||||
@@ -242,6 +260,13 @@ class DingtianController
|
|||||||
readonly #channels: number;
|
readonly #channels: number;
|
||||||
/** Input level at rest; an input is "active" when it differs from this. */
|
/** Input level at rest; an input is "active" when it differs from this. */
|
||||||
readonly #restingHigh: boolean;
|
readonly #restingHigh: boolean;
|
||||||
|
/** 1-based input terminals whose ACTIVE level is LOW, overriding the board-wide
|
||||||
|
* #restingHigh for just those inputs. A button and a radar can idle oppositely:
|
||||||
|
* the button (NO-to-GND) pulls LOW on press while the board idles HIGH, but a
|
||||||
|
* radar's dry contact may idle LOW and go HIGH on detection. Listing the radar's
|
||||||
|
* terminal here flips its edge so "active" still means "detecting". See
|
||||||
|
* wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly #inputActiveLow: Set<number>;
|
||||||
readonly #pulseMs: number;
|
readonly #pulseMs: number;
|
||||||
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
||||||
readonly #webUser: string;
|
readonly #webUser: string;
|
||||||
@@ -269,6 +294,22 @@ class DingtianController
|
|||||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
this.#restingHigh = config.inputRestingHigh !== false;
|
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.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||||||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||||||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||||||
@@ -319,6 +360,14 @@ class DingtianController
|
|||||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AuxOutputDevice: latch a NON-barrier output (e.g. a button lamp) on a spare
|
||||||
|
* relay. Same wire op as setRelay — separated so business logic drives indicators
|
||||||
|
* through the aux capability, never the barrier relay methods. Holding/blinking an
|
||||||
|
* aux output is allowed (it is not a barrier). See button-light-indicator.md. */
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
await this.setRelay(channel, on);
|
||||||
|
}
|
||||||
|
|
||||||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||||
this.#assertChannel(doorId);
|
this.#assertChannel(doorId);
|
||||||
const { relays } = await this.#status();
|
const { relays } = await this.#status();
|
||||||
@@ -672,8 +721,10 @@ class DingtianController
|
|||||||
for (let i = 0; i < this.#channels; i++) {
|
for (let i = 0; i < this.#channels; i++) {
|
||||||
const high = (inputVal & (1 << i)) !== 0;
|
const high = (inputVal & (1 << i)) !== 0;
|
||||||
relays.push((relayVal & (1 << i)) !== 0);
|
relays.push((relayVal & (1 << i)) !== 0);
|
||||||
// active = differs from the resting level (a press pulls the line).
|
// active = differs from the resting level (a press pulls the line); a terminal in
|
||||||
inputs.push(high !== this.#restingHigh);
|
// inputActiveLow is read inverted (active when LOW) — so a radar wired opposite the
|
||||||
|
// button reads right. See inputActive().
|
||||||
|
inputs.push(inputActive(high, i + 1, this.#restingHigh, this.#inputActiveLow));
|
||||||
}
|
}
|
||||||
return { relays, inputs, channels: this.#channels };
|
return { relays, inputs, channels: this.#channels };
|
||||||
}
|
}
|
||||||
@@ -728,6 +779,19 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||||||
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
||||||
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
||||||
|
{
|
||||||
|
// relay_pw — the BINARY-protocol control/status password (NOT the web-UI login
|
||||||
|
// below). Every relay command + the status read embeds it; with the wrong/no
|
||||||
|
// value the device silently ignores the packet → healthCheck times out → the
|
||||||
|
// controller shows "offline" even though it pings. Redacted from the client
|
||||||
|
// (SECRET_CONFIG_KEYS), so it renders as a secret: blank KEEPS the stored value
|
||||||
|
// (the server re-merges it on test/save); type a value to set/change it.
|
||||||
|
key: "relayPassword",
|
||||||
|
label: "Relay control password",
|
||||||
|
type: "secret",
|
||||||
|
required: false,
|
||||||
|
help: "Binary-protocol relay password (relay_pw). Leave blank to keep the current one; a wrong/missing value makes the device ignore commands (Test connection times out).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "pulseMs",
|
key: "pulseMs",
|
||||||
label: "Pulse open (ms)",
|
label: "Pulse open (ms)",
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
|
|||||||
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
getDoorStatus(doorId: number): Promise<"open" | "closed">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auxiliary outputs (non-barrier latched signals) ---------------------
|
||||||
|
// Optional capability for controllers with SPARE relays wired to something that
|
||||||
|
// is NOT a barrier — a button lamp, a "wait"/"go" sign. setAux LATCHES the output
|
||||||
|
// on or off and holds it (unlike pulseOpen, which is momentary). The
|
||||||
|
// barrier-not-a-door rule does NOT apply here: this output never gates a vehicle,
|
||||||
|
// so holding/blinking it is fine. Business logic drives indicators through THIS,
|
||||||
|
// never the driver's own relay methods. See wiki/concepts/button-light-indicator.md.
|
||||||
|
export interface AuxOutputDevice {
|
||||||
|
/** Latch an auxiliary output on/off. 1-based channel (a spare relay). */
|
||||||
|
setAux(channel: number, on: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feature-detect the aux-output capability on a built device adapter. */
|
||||||
|
export function hasAuxOutput(d: unknown): d is AuxOutputDevice {
|
||||||
|
return typeof (d as Partial<AuxOutputDevice>)?.setAux === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inputs (buttons / dry contacts) -------------------------------------
|
// --- Inputs (buttons / dry contacts) -------------------------------------
|
||||||
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
// Optional capability for controllers that expose host-readable inputs SEPARATE
|
||||||
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
// from their relays — e.g. the Dingtian board. This is what enables host-in-the-
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-24
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Button-light indicator (radar × camera disagreement lamp)
|
||||||
|
|
||||||
|
The entry button has a **12 V light**. It is driven by the host on a **spare relay** of the
|
||||||
|
[[dingtian-relay|Dingtian]] controller as a 3-state indicator that combines the **[[hikvision-radar|
|
||||||
|
radar]]** input with the **camera "car in zone"** signal:
|
||||||
|
|
||||||
|
| Radar input | Camera (lane entry busy) | Button light |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| detecting | **free** — no car confirmed | **BLINK** (~1 Hz) |
|
||||||
|
| detecting | **busy** — camera confirms a car | **SOLID on** |
|
||||||
|
| clear | — | **OFF** |
|
||||||
|
|
||||||
|
It is a **disagreement indicator**: the radar sees *something* but the camera hasn't confirmed a
|
||||||
|
real vehicle → blink (attention / "pull forward"); both agree → solid; nothing there → off.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
- **Radar** = the presence input edge on the entry relay (`relays[].presenceInput`, the same edge
|
||||||
|
the [[entry-double-press|one-car-one-ticket]] gate observes — so the lamp and the gate always
|
||||||
|
agree on "a car is here").
|
||||||
|
- **Camera "car in zone"** = the existing **[[lpr-camera|lane status]]** (`LaneStatusEvent` entry
|
||||||
|
busy/free, from camera vehicle detection). Already advisory; already drives the booth's barrier
|
||||||
|
lights. No new camera plumbing.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
A controller-level `config.buttonLight = { relay, blinkOnMs?, blinkOffMs? }` (the operator picks a
|
||||||
|
**spare** relay — not a barrier relay; the setup UI warns if it overlaps one). Blink defaults to
|
||||||
|
500 ms / 500 ms.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
`apps/server/src/button-light.ts` — `ButtonLightController` subscribes to `deviceEvents.onInput`
|
||||||
|
(radar) + `onLaneStatus` (camera), computes the target state per controller, and drives the lamp via
|
||||||
|
a **device-agnostic aux-output** capability.
|
||||||
|
|
||||||
|
- **Aux-output capability.** `AuxOutputDevice { setAux(channel, on) }` on the device interface (the
|
||||||
|
Dingtian driver implements it as a latch). Business logic drives the lamp through this — **never**
|
||||||
|
the driver's barrier methods.
|
||||||
|
- **Barrier-not-a-door is preserved.** The lamp is **not a barrier**, so holding / blinking it on a
|
||||||
|
timer is fine — the [[barrier-not-a-door]] rule forbids timing a *barrier* closed, and barriers
|
||||||
|
still only ever `pulseOpen`. The lamp uses the separate `setAux` latch.
|
||||||
|
- **Fails OFF.** On host loss, shutdown, or a `setAux` error the lamp defaults OFF — a dead lamp is
|
||||||
|
"no hint", never a misleading solid "go". SOLID is only ever held while busy + present is actively
|
||||||
|
true (never latched on through a crash path).
|
||||||
|
- **De-duped.** Only writes when the effective output changes, so the 50 ms input poll doesn't spam
|
||||||
|
the controller over UDP.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay). Covered by
|
||||||
|
`apps/server/src/button-light.test.ts` (the truth table + blink toggling + fail-OFF + de-dupe).
|
||||||
|
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||||
|
[[barrier-not-a-door]].
|
||||||
@@ -26,10 +26,12 @@ press → print → press again issued a second ticket immediately. That is not
|
|||||||
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
|
||||||
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
|
||||||
|
|
||||||
### PRESENCE mode (preferred — when a vehicle loop is wired)
|
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
|
||||||
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
|
`relays[].presenceInput` = the 1-based input terminal of a **vehicle-presence sensor** on the same
|
||||||
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
|
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
|
||||||
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
|
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`relays[].presenceKind: "loop"|"radar"`
|
||||||
|
— a label; the gate behaviour is identical). A radar wired to idle opposite the button needs
|
||||||
|
`presenceActiveLow: true` so its edge reads correctly. The rule makes one-car-one-ticket **physical**:
|
||||||
|
|
||||||
- A press prints **only while a car is present** on the loop.
|
- A press prints **only while a car is present** on the loop.
|
||||||
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
|
||||||
|
|||||||
@@ -46,11 +46,28 @@ see [[dingtian-vs-mqtt]].
|
|||||||
|
|
||||||
## Driver & config API
|
## Driver & config API
|
||||||
|
|
||||||
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
The `dingtian` driver ([[device-registry]]) implements:
|
||||||
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
`AccessControlDevice` (relay pulse/latch over UDP), `AuxOutputDevice` (latch a NON-barrier output —
|
||||||
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
|
see below), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and
|
||||||
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
|
`PreconditionDevice` (below). Config fields include a separate **`httpPort`** — the device's
|
||||||
distinct from the UDP control port 60001.
|
web/config API is on a configurable HTTP port (default **80**), distinct from the UDP control port
|
||||||
|
60001.
|
||||||
|
|
||||||
|
### Spare relays + aux outputs (`setAux`)
|
||||||
|
|
||||||
|
A 4-input board typically has spare relays once the entry/exit barriers are wired. These drive
|
||||||
|
**non-barrier indicators** — e.g. the entry button's 12 V lamp (see [[button-light-indicator]]).
|
||||||
|
Business logic drives them through the device-agnostic `AuxOutputDevice.setAux(channel, on)` (a
|
||||||
|
latch), **never** the barrier `pulseOpen`. The [[barrier-not-a-door]] rule doesn't apply to an aux
|
||||||
|
output (it never gates a vehicle), so holding/blinking it is fine.
|
||||||
|
|
||||||
|
### Per-input active level (`presenceActiveLow` / `inputActiveLow`)
|
||||||
|
|
||||||
|
Inputs are normalised against ONE board-wide resting level (`inputRestingHigh`). When a sensor (e.g.
|
||||||
|
a [[hikvision-radar|radar]]) idles **opposite** the button, list its terminal as active-LOW —
|
||||||
|
sourced from each relay's `presenceActiveLow`, merged into the driver's `inputActiveLow` set — so
|
||||||
|
that one input is read inverted while the button keeps the board default. (`inputActive()` is the
|
||||||
|
pure helper; push-mode uses the device's own `ilu.active_level` instead.)
|
||||||
|
|
||||||
### Precondition: input_link_relay must be OFF
|
### Precondition: input_link_relay must be OFF
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
type: entity
|
||||||
|
tags: [parking, device, sensor, radar, entry, presence]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-24
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hikvision Radar (vehicle-presence sensor)
|
||||||
|
|
||||||
|
A radar mounted at an entry barrier that **closes a dry-contact relay when it detects something in
|
||||||
|
its vicinity** (a vehicle approaching the barrier). Wired to a **[[dingtian-relay|Dingtian]] input
|
||||||
|
terminal**, it acts as the vehicle-**presence** signal for the entry flow — functionally the same
|
||||||
|
role as an induction loop, just a different sensor.
|
||||||
|
|
||||||
|
## Where it sits in the model
|
||||||
|
|
||||||
|
The radar is a **child of the access controller config**, not a standalone device. On the entry
|
||||||
|
relay's spec (`config.relays[]`):
|
||||||
|
- `presenceInput` = the 1-based input terminal the radar's contact is wired to (e.g. **I2**).
|
||||||
|
- `presenceKind: "radar"` = a label (vs. `"loop"`) for the UI + telemetry; the **gate behaviour is
|
||||||
|
identical** either way.
|
||||||
|
- `presenceActiveLow` = set when the radar idles HIGH and pulls LOW on detection (see below).
|
||||||
|
|
||||||
|
The booth's wiring (first install): **button on I1, radar on I2**, both on the same 4-input Dingtian.
|
||||||
|
|
||||||
|
## Its job: the one-car-one-ticket gate (advisory, never opens a barrier)
|
||||||
|
|
||||||
|
The radar feeds the **[[entry-double-press|one car = one ticket]]** gate exactly as a loop does: the
|
||||||
|
entry button prints a ticket **only while the radar shows a vehicle present**, and **no second
|
||||||
|
ticket** issues until the radar **clears** (the car drove in) and a new car re-occupies the zone.
|
||||||
|
|
||||||
|
> The radar is **advisory**. A detection NEVER opens a barrier on its own — it only *gates* the
|
||||||
|
> button press. Entry still requires the physical press (and the capacity gate). This is the
|
||||||
|
> [[threat-model]] rule: a sensor reading is never the sole reason a barrier opens. (Distinct from
|
||||||
|
> the [[lane-presence-and-anpr-entry|ANPR bridge]], which admits *subscribers* through the gated
|
||||||
|
> subscription flow — also never a transient open.)
|
||||||
|
|
||||||
|
## The active-level gotcha (why `presenceActiveLow` exists)
|
||||||
|
|
||||||
|
The Dingtian normalises **all** inputs against one board-wide resting level (`inputRestingHigh`).
|
||||||
|
The booth's **button** (NO contact to GND) idles HIGH and pulls LOW on press. A **radar's dry
|
||||||
|
contact may idle the opposite way** — and if it does, the controller would read "vehicle present"
|
||||||
|
exactly when the zone is *clear*, inverting the gate (and the [[button-light-indicator|button
|
||||||
|
lamp]]).
|
||||||
|
|
||||||
|
Fix: mark the radar's terminal **active-LOW** (`presenceActiveLow: true` on the relay spec). The
|
||||||
|
driver then reads just that input inverted (active when LOW), leaving the button on the board
|
||||||
|
default. Implemented as a per-input override in `access-dingtian.ts` (`inputActive()` +
|
||||||
|
`inputActiveLow` set, derived from each relay's `presenceActiveLow`). Push-mode (the
|
||||||
|
`/input/:n/:edge` HTTP path) relies instead on the device's own `ilu.active_level`; the override is
|
||||||
|
the **poll-mode** equivalent.
|
||||||
|
|
||||||
|
## Also drives the button light
|
||||||
|
|
||||||
|
The same radar present/clear signal, combined with the camera's lane status, drives the entry
|
||||||
|
button's 12 V lamp on a spare relay — see [[button-light-indicator]].
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Modelled 2026-06-24 (button I1 + radar I2 on the first booth's Dingtian). Gate behaviour reuses the
|
||||||
|
existing presence path; only the label + active-level override were added. Related:
|
||||||
|
[[dingtian-relay]], [[entry-double-press]], [[lpr-camera]], [[entry-exit-points]].
|
||||||
+4
-2
@@ -42,7 +42,8 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||||
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
- [[gee-qr-er80]] — QR access reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
||||||
|
- [[hikvision-radar]] — vehicle-presence radar on a Dingtian input; the entry presence gate (per-input active-level caveat).
|
||||||
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
|
||||||
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
|
||||||
|
|
||||||
@@ -76,7 +77,8 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
- [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay).
|
||||||
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
|
||||||
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
- [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots.
|
||||||
- [[entry-double-press]] — one car = one ticket: per-relay presence-loop gate (preferred) or cooldown fallback; suppressed press = telemetry.
|
- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry.
|
||||||
|
- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF.
|
||||||
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
|
||||||
|
|
||||||
## Concepts — business domain
|
## Concepts — business domain
|
||||||
|
|||||||
+18
@@ -1552,3 +1552,21 @@ username chip links to it), `email` added to the session view + `SessionUser`. 7
|
|||||||
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
||||||
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||||
[[desktop-shell-tauri]] "Desktop in CI".
|
[[desktop-shell-tauri]] "Desktop in CI".
|
||||||
|
|
||||||
|
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
|
||||||
|
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
|
||||||
|
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
|
||||||
|
children of the access controller config — no new device category. (1) The radar reuses the existing
|
||||||
|
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
|
||||||
|
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
|
||||||
|
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
|
||||||
|
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
|
||||||
|
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
|
||||||
|
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
|
||||||
|
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
|
||||||
|
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
|
||||||
|
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
|
||||||
|
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
|
||||||
|
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
|
||||||
|
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
|
||||||
|
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
|
||||||
|
|||||||
Reference in New Issue
Block a user