feat(devices): radar presence input + button-light output on the controller

Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

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). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-24 11:45:22 +02:00
parent 215a3ac405
commit 2915d141aa
17 changed files with 916 additions and 23 deletions
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { 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 });
}
describe("ButtonLightController truth table", () => {
it("OFF at start (no radar, no car)", () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
ctl.start();
expect(ctl.stateOf(CONTROLLER)).toBe("off");
// Initial apply drives the lamp off (false). It may de-dupe to no call since
// lastOn starts null -> false IS a change, so exactly one off write.
expect(calls).toEqual([{ ch: LAMP_RELAY, on: false }]);
ctl.stop();
});
it("radar present + lane busy -> SOLID on", () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
calls.length = 0;
lane(true);
radar(true);
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
// Solid = no blinking: advancing time produces no further toggles.
const n = calls.length;
vi.advanceTimersByTime(2000);
expect(calls.length).toBe(n);
ctl.stop();
});
it("radar present + lane free -> BLINK (toggles over time)", () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
calls.length = 0;
radar(true); // lane still free
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // on now
vi.advanceTimersByTime(500);
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false }); // toggled off
vi.advanceTimersByTime(500);
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true }); // toggled on
ctl.stop();
});
it("blink -> solid when the camera confirms a car (lane busy)", () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
radar(true); // blink
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
lane(true); // camera confirms
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: true });
// No more toggles (blink torn down).
const n = calls.length;
vi.advanceTimersByTime(2000);
expect(calls.length).toBe(n);
ctl.stop();
});
it("radar clears -> OFF", () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
lane(true);
radar(true); // solid
calls.length = 0;
radar(false); // car gone
expect(ctl.stateOf(CONTROLLER)).toBe("off");
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: false });
ctl.stop();
});
it("de-dupes redundant writes (no spam on repeat events)", () => {
const calls: Array<{ ch: number; on: boolean }> = [];
const aux = fakeAux(calls);
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
ctl.start();
lane(true);
radar(true); // solid, on
const n = calls.length;
radar(true); // same state — no new edge (present unchanged)
lane(true); // same lane — no change
expect(calls.length).toBe(n);
ctl.stop();
});
it("fails OFF: a setAux error does not throw or escalate", () => {
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();
// Subsequent writes work; driving to solid still succeeds.
lane(true);
radar(true);
expect(calls.at(-1)).toEqual({ ch: LAMP_RELAY, on: 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();
});
});
+220
View File
@@ -0,0 +1,220 @@
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 {
readonly spec: ButtonLightSpec;
/** Is the radar (presence input on an entry relay) currently active? */
present: boolean;
/** The output we last commanded (de-dupe — avoid UDP spam at the 50ms input poll). */
lastOn: boolean | null;
/** 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;
}
/** 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.#loadLamps();
// 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));
}
/** (Re)build the lamp map from the current device config. Each enabled access
* controller with a `buttonLight` gets a lamp; others are skipped. */
#loadLamps(): void {
this.#lamps.clear();
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
for (const row of rows) {
if (!row.enabled) continue;
const spec = buttonLightOf(row);
if (!spec) continue;
this.#lamps.set(row.id, {
spec,
present: false,
lastOn: null,
rendered: null,
blink: null,
blinkOn: false,
});
}
}
/** 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 {
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") {
void this.#drive(controllerId, lamp, false);
} else if (target === "solid") {
void this.#drive(controllerId, lamp, true);
} else {
// BLINK: arm the toggle timer SYNCHRONOUSLY (it must not wait on a UDP write), then
// drive the first "on". A symmetric cadence uses one interval; an asymmetric one
// re-arms each phase with its own duration.
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;
const tick = () => {
lamp.blinkOn = !lamp.blinkOn;
void this.#drive(controllerId, lamp, lamp.blinkOn);
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?.();
void this.#drive(controllerId, lamp, true);
}
}
/** Latch the lamp's relay via the device's aux-output capability. De-duped + fail-OFF:
* an error logs and leaves `lastOn` unchanged so the next compute retries. */
async #drive(controllerId: string, lamp: LampState, on: boolean): Promise<void> {
if (lamp.lastOn === on) return; // no redundant UDP writes
const aux = this.#resolveAux(controllerId);
if (!aux) return;
try {
await aux.setAux(lamp.spec.relay, on);
lamp.lastOn = on;
} catch (err) {
this.#logger.error(`button-light setAux failed (${controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
// Leave lastOn unchanged → retried on the next state compute. Never escalates.
}
}
/** 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.
void this.#drive(controllerId, lamp, false);
}
}
/** Test seam: current high-level state being rendered for a controller. */
stateOf(controllerId: string): LightState | null {
return this.#lamps.get(controllerId)?.rendered ?? 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;
}
}
+39 -3
View File
@@ -33,12 +33,32 @@ export interface RelaySpec {
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
*/
readonly presenceInput?: number;
/** What kind of sensor is on `presenceInput` — an induction LOOP or a RADAR. Label
* only (the gate behaviour is identical); drives UI copy + telemetry. Default loop. */
readonly presenceKind?: "loop" | "radar";
/** The presence terminal's ACTIVE level is LOW (idles HIGH). Maps to the driver's
* per-input `inputActiveLow` override so a radar wired opposite the button reads
* right. See wiki/entities/hikvision-radar.md. */
readonly presenceActiveLow?: boolean;
readonly entryCooldownSec?: number;
}
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's
* 12 V light). Driven by the server LightController off the radar + lane status —
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */
export interface ButtonLightSpec {
/** 1-based spare relay channel the lamp is wired to. */
readonly relay: number;
/** 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). */
interface AccessConfig {
readonly relays?: RelaySpec[];
/** Optional button-lamp output on a spare relay. */
readonly buttonLight?: ButtonLightSpec;
readonly [k: string]: unknown;
}
@@ -60,9 +80,11 @@ export interface ResolvedRelay {
readonly controller: DeviceRow;
readonly relay: number;
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;
/** 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;
}
@@ -102,6 +124,7 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
relay: spec.relay,
direction: spec.direction,
presenceInput: spec.presenceInput,
presenceKind: spec.presenceKind ?? "loop",
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);
if (!spec) 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;
}
/**
+8
View File
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
import { deviceEvents } from "./device-events.js";
import { ButtonLightController } from "./button-light.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js";
@@ -188,6 +189,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
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
// 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,
+133 -4
View File
@@ -13,6 +13,7 @@ import {
type AnprTestResult,
type Assignment,
type BackendIpCandidate,
type ButtonLightSpec,
type Catalog,
type CatalogEntry,
type DeviceCategory,
@@ -270,11 +271,24 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
if (assignment.category === "access") {
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
if (relays.length === 0) return <em className="text-term-amber">{t("setup.noRelaysSet")}</em>;
const bl = cfg.buttonLight as ButtonLightSpec | undefined;
return (
<span className="flex gap-1.5">
{relays.map((r) => (
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
))}
<span className="flex flex-wrap gap-1.5">
{relays.map((r) => {
const presence = r.presenceInput
? `·${r.presenceKind === "radar" ? "radar" : "loop"}${r.presenceInput}`
: "";
return (
<DirectionBadge
key={r.relay}
direction={r.direction}
label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}${presence}`}
/>
);
})}
{bl?.relay != null && (
<DirectionBadge direction="both" label={`lamp·R${bl.relay}`} />
)}
</span>
);
}
@@ -345,6 +359,11 @@ function DeviceForm({
const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Controller-level button-lamp output (a spare relay), driven by the radar + camera.
const [buttonLight, setButtonLight] = useState<ButtonLightSpec | null>(() => {
const bl = editCfg?.buttonLight as ButtonLightSpec | undefined;
return bl && typeof bl.relay === "number" ? bl : null;
});
// Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
@@ -442,8 +461,18 @@ function DeviceForm({
direction: r.direction,
...(r.button ? { button: r.button } : {}),
...(r.presenceInput ? { presenceInput: r.presenceInput } : {}),
...(r.presenceInput && r.presenceKind ? { presenceKind: r.presenceKind } : {}),
...(r.presenceInput && r.presenceActiveLow ? { presenceActiveLow: true } : {}),
...(r.entryCooldownSec ? { entryCooldownSec: r.entryCooldownSec } : {}),
}));
// Button-lamp output (a spare relay), persisted only when a relay is chosen.
if (buttonLight && buttonLight.relay) {
out.buttonLight = {
relay: buttonLight.relay,
...(buttonLight.blinkOnMs ? { blinkOnMs: buttonLight.blinkOnMs } : {}),
...(buttonLight.blinkOffMs ? { blinkOffMs: buttonLight.blinkOffMs } : {}),
};
}
} else if (controllerId && boundRelay !== "") {
out.controllerId = controllerId;
out.relay = boundRelay;
@@ -631,6 +660,11 @@ function DeviceForm({
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
{/* CONTROLLER: optional button-lamp output on a spare relay (radar + camera driven). */}
{isController && (
<ButtonLightEditor relays={relays} value={buttonLight} onChange={setButtonLight} />
)}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
<BindingPicker
@@ -826,6 +860,30 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
/>
</label>
)}
{/* Presence sensor kind + active-level — only meaningful once a terminal is set. */}
{(r.direction === "entry" || r.direction === "both") && !!r.presenceInput && (
<>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
{t("setup.presenceKind")}
<select
value={r.presenceKind ?? "loop"}
className="input input-sm w-24"
onChange={(e) => update(i, { presenceKind: e.target.value as "loop" | "radar" })}
>
<option value="loop">{t("setup.presenceKindLoop")}</option>
<option value="radar">{t("setup.presenceKindRadar")}</option>
</select>
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.presenceActiveLowHint")}>
<input
type="checkbox"
checked={!!r.presenceActiveLow}
onChange={(e) => update(i, { presenceActiveLow: e.target.checked || undefined })}
/>
{t("setup.presenceActiveLow")}
</label>
</>
)}
{(r.direction === "entry" || r.direction === "both") && !r.presenceInput && (
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
{t("setup.entryCooldown")}
@@ -855,6 +913,77 @@ function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r:
);
}
/** Button-lamp output: the entry button's 12 V light on a SPARE relay, driven by the
* radar + camera (blink = radar-only, solid = car confirmed, off otherwise). Optional.
* The relay picker offers every relay number on this controller; the operator picks a
* spare one (not a barrier relay). See wiki/concepts/button-light-indicator.md. */
function ButtonLightEditor({
relays,
value,
onChange,
}: {
relays: RelaySpec[];
value: ButtonLightSpec | null;
onChange: (v: ButtonLightSpec | null) => void;
}) {
const { t } = useTranslation();
// Relay numbers in use as barriers — shown as a hint so the operator avoids them.
const barrierRelays = new Set(relays.map((r) => r.relay));
return (
<div className="mt-2 flex flex-wrap items-center gap-3 rounded-term border border-term-border p-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={value?.relay ?? ""}
placeholder="—"
className="input input-sm w-16"
onChange={(e) =>
onChange(e.target.value === "" ? null : { ...value, relay: Number(e.target.value) })
}
/>
</label>
{value?.relay != null && barrierRelays.has(value.relay) && (
<span className="text-[11px] text-term-amber">{t("setup.buttonLightBarrierWarn")}</span>
)}
{value?.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={value.blinkOnMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onChange({ ...value, 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={value.blinkOffMs ?? ""}
placeholder="500"
className="input input-sm w-20"
onChange={(e) =>
onChange({ ...value, blinkOffMs: e.target.value === "" ? undefined : Number(e.target.value) })
}
/>
</label>
</>
)}
</div>
);
}
/** Binding picker for readers/cameras/printers: choose the controller + relay this
* device sits at. Direction is inherited from the chosen relay (shown). */
function BindingPicker({
+15
View File
@@ -286,9 +286,24 @@ export interface RelaySpec {
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
presenceInput?: number;
/** Sensor on the presence input: induction LOOP or a RADAR (label only). */
presenceKind?: "loop" | "radar";
/** The presence terminal is active-LOW (idles HIGH) — e.g. a radar wired opposite
* the button. Maps to the driver's per-input active-level override. */
presenceActiveLow?: boolean;
entryCooldownSec?: number;
}
/** 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 {
health: { status: string; detail?: string };
preconditions: {
+16 -3
View File
@@ -361,12 +361,25 @@ export const en: Catalog = {
"Each relay opens one barrier. Set its direction; for transient entry, set which input terminal the entry button is wired to.",
relay: "Relay",
entryButtonTerminal: "Entry button on terminal",
presenceInput: "Presence loop (terminal)",
presenceInput: "Presence sensor (terminal)",
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)",
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",
anpr: "Plate recognition (ANPR)",
anprHint:
+13
View File
@@ -376,6 +376,19 @@ export const sq = {
entryCooldown: "Pritje pas biletës (sek)",
entryCooldownHint:
"Kur nuk ka sensor pranie: shtypjet e përsëritura të butonit shtypen për kaq sekonda pas një bilete. Zgjidhje rezervë (jo garanci) — një abuzues mund ta presë afatin.",
presenceKind: "Lloji",
presenceKindLoop: "Lak",
presenceKindRadar: "Radar",
presenceActiveLow: "Aktiv-ulët",
presenceActiveLowHint:
"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",
// Camera ANPR opt-in.
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 type {
AccessControlDevice,
AuxOutputDevice,
DeviceHealth,
HardenableDevice,
HardenResult,
@@ -166,6 +167,22 @@ interface DingtianStatus {
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 = {
key: "input_link_relay",
message:
@@ -223,6 +240,7 @@ function configApi(
class DingtianController
implements
AccessControlDevice,
AuxOutputDevice,
InputDevice,
PreconditionDevice,
PushConfigurableDevice,
@@ -242,6 +260,13 @@ class DingtianController
readonly #channels: number;
/** Input level at rest; an input is "active" when it differs from this. */
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;
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string;
@@ -269,6 +294,22 @@ class DingtianController
this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false;
// Per-input active-LOW overrides (1-based). Source of truth is each entry relay's
// `presenceActiveLow` flag (a radar terminal wired opposite the button); an explicit
// top-level `inputActiveLow` array is also honoured as an escape hatch. Both merged.
this.#inputActiveLow = new Set<number>();
if (Array.isArray(config.inputActiveLow)) {
for (const n of (config.inputActiveLow as unknown[]).map(Number)) {
if (Number.isInteger(n) && n > 0) this.#inputActiveLow.add(n);
}
}
if (Array.isArray(config.relays)) {
for (const r of config.relays as Array<Record<string, unknown>>) {
if (r?.presenceActiveLow === true && Number.isInteger(Number(r.presenceInput))) {
this.#inputActiveLow.add(Number(r.presenceInput));
}
}
}
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
this.#webUser = config.webUser ? String(config.webUser) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
@@ -319,6 +360,14 @@ class DingtianController
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"> {
this.#assertChannel(doorId);
const { relays } = await this.#status();
@@ -672,8 +721,10 @@ class DingtianController
for (let i = 0; i < this.#channels; i++) {
const high = (inputVal & (1 << i)) !== 0;
relays.push((relayVal & (1 << i)) !== 0);
// active = differs from the resting level (a press pulls the line).
inputs.push(high !== this.#restingHigh);
// active = differs from the resting level (a press pulls the line); a terminal in
// 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 };
}
+17
View File
@@ -35,6 +35,23 @@ export interface AccessControlDevice extends Device {
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) -------------------------------------
// 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-
+62
View File
@@ -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]].
+6 -4
View File
@@ -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
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)
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
### PRESENCE mode (preferred — when a vehicle-presence sensor is wired)
`relays[].presenceInput` = the 1-based input terminal of a **vehicle-presence sensor** on the same
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
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.
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
+22 -5
View File
@@ -46,11 +46,28 @@ see [[dingtian-vs-mqtt]].
## Driver & config API
The `dingtian` driver ([[device-registry]]) implements three capabilities:
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
distinct from the UDP control port 60001.
The `dingtian` driver ([[device-registry]]) implements:
`AccessControlDevice` (relay pulse/latch over UDP), `AuxOutputDevice` (latch a NON-barrier output —
see below), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and
`PreconditionDevice` (below). Config fields include a separate **`httpPort`** — the device's
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
+63
View File
@@ -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
View File
@@ -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.
- [[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.
- [[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.
- [[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).
- [[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-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.
## Concepts — business domain
+18
View File
@@ -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
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
[[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]].