6505a4a73b
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:
- Granular: bypass radar and camera independently (Setup → controller
section). A dead camera drops only the camera check; a dead radar only
radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
site:update) appends a signed config_change {setting, value, prev,
operator} per actually-changed signal — new ledger type. No-op toggles
sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
booth entry light treats a bypassed signal as satisfied (server
re-checks authoritatively). Physical-button path falls through to the
cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).
Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).
10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
337 lines
15 KiB
TypeScript
337 lines
15 KiB
TypeScript
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
|
|
|
|
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
|
|
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
|
|
// inside an access controller, and readers/cameras BIND to a (controller, relay).
|
|
// See wiki/concepts/entry-exit-points.md.
|
|
|
|
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
|
|
export type Direction = "entry" | "exit" | "both";
|
|
/** A concrete flow a credential/button drives (never "both"). */
|
|
export type FlowDirection = "entry" | "exit";
|
|
|
|
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
|
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
|
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
|
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
|
export type RelayEvent = Direction | "radarAlert";
|
|
|
|
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
|
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
|
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
|
|
|
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
|
* An exit radar is just another `presence` row serving the exit relay. */
|
|
export interface InputSpec {
|
|
/** 1-based input terminal the host reads. */
|
|
readonly input: number;
|
|
readonly role: InputRole;
|
|
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
|
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
|
readonly relay?: number;
|
|
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
|
readonly kind?: "loop" | "radar";
|
|
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
|
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
|
readonly activeLow?: boolean;
|
|
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
|
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
|
readonly cooldownSec?: number;
|
|
}
|
|
|
|
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
|
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
|
* (back-compat) but no longer written by the UI. */
|
|
export interface RelaySpec {
|
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
|
readonly relay: number;
|
|
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
|
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
|
readonly direction: RelayEvent;
|
|
|
|
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
|
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
|
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
|
readonly button?: number;
|
|
readonly presenceInput?: number;
|
|
readonly presenceKind?: "loop" | "radar";
|
|
readonly presenceActiveLow?: boolean;
|
|
readonly entryCooldownSec?: number;
|
|
|
|
// ── radarAlert-only (direction === "radarAlert") ──
|
|
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
|
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
|
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
|
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
|
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
|
readonly triggerInput?: number;
|
|
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
|
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
|
readonly lockLane?: FlowDirection;
|
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
|
readonly blinkOnMs?: number;
|
|
readonly blinkOffMs?: number;
|
|
}
|
|
|
|
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
|
interface AccessConfig {
|
|
readonly relays?: RelaySpec[];
|
|
readonly inputs?: InputSpec[];
|
|
readonly [k: string]: unknown;
|
|
}
|
|
|
|
/** Reader/camera config: optional binding to a controller relay. */
|
|
interface BoundConfig {
|
|
/** The access `devices.id` this reader/camera sits at. */
|
|
readonly controllerId?: string;
|
|
/** The relay on that controller it opens. */
|
|
readonly relay?: number;
|
|
/** Fallback direction when not bound to a relay. */
|
|
readonly direction?: Direction;
|
|
readonly [k: string]: unknown;
|
|
}
|
|
|
|
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
|
|
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
|
|
* from a button press, so the entry flow can enforce one-car-one-ticket. */
|
|
export interface ResolvedRelay {
|
|
readonly controller: DeviceRow;
|
|
readonly relay: number;
|
|
readonly direction: Direction;
|
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
|
readonly presenceInput?: number;
|
|
/** 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;
|
|
}
|
|
|
|
/** All enabled access controller rows. */
|
|
function accessRows(db: Db): DeviceRow[] {
|
|
return db
|
|
.select()
|
|
.from(devices)
|
|
.where(eq(devices.category, "access"))
|
|
.all()
|
|
.filter((r) => r.enabled);
|
|
}
|
|
|
|
/** The relay specs declared on an access controller (defaults to none). */
|
|
export function relaysOf(row: DeviceRow): RelaySpec[] {
|
|
const cfg = row.config as AccessConfig;
|
|
return Array.isArray(cfg.relays) ? cfg.relays : [];
|
|
}
|
|
|
|
/**
|
|
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
|
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
|
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
|
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
|
* inputs goes through here, so the legacy fold lives in exactly one place.
|
|
*/
|
|
export function inputsOf(row: DeviceRow): InputSpec[] {
|
|
const cfg = row.config as AccessConfig;
|
|
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
|
const synth: InputSpec[] = [];
|
|
for (const r of relaysOf(row)) {
|
|
if (typeof r.button === "number") {
|
|
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
|
}
|
|
if (typeof r.presenceInput === "number") {
|
|
synth.push({
|
|
input: r.presenceInput,
|
|
role: "presence",
|
|
relay: r.relay,
|
|
kind: r.presenceKind ?? "loop",
|
|
activeLow: r.presenceActiveLow,
|
|
});
|
|
}
|
|
}
|
|
return synth;
|
|
}
|
|
|
|
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
|
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
|
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
|
if (typeof spec.relay !== "number") return null;
|
|
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
|
if (!relay) return null;
|
|
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
|
return { ...relay, direction: relay.direction };
|
|
}
|
|
|
|
/**
|
|
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
|
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
|
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
|
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
|
* Returns null otherwise.
|
|
*/
|
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
|
const row = db
|
|
.select()
|
|
.from(devices)
|
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
|
.get();
|
|
if (!row || !row.enabled) return null;
|
|
const inputs = inputsOf(row);
|
|
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
|
if (!btn) return null;
|
|
const relay = barrierForInput(row, btn);
|
|
if (!relay) return null;
|
|
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
|
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
|
return {
|
|
controller: row,
|
|
relay: relay.relay,
|
|
direction: relay.direction,
|
|
presenceInput: presence?.input,
|
|
presenceKind: presence?.kind ?? "loop",
|
|
entryCooldownSec: btn.cooldownSec,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
|
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
|
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
|
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
|
*/
|
|
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
|
const row = db
|
|
.select()
|
|
.from(devices)
|
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
|
.get();
|
|
if (!row || !row.enabled) return null;
|
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
|
if (!presence) return null;
|
|
const relay = barrierForInput(row, presence);
|
|
if (!relay) return null;
|
|
return {
|
|
controller: row,
|
|
relay: relay.relay,
|
|
direction: relay.direction,
|
|
presenceInput: presence.input,
|
|
presenceKind: presence.kind ?? "loop",
|
|
};
|
|
}
|
|
|
|
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
|
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
|
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
|
}
|
|
|
|
/**
|
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
|
*/
|
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
|
const row = db
|
|
.select()
|
|
.from(devices)
|
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
|
.get();
|
|
if (!row || !row.enabled) return null;
|
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
|
if (!presence || typeof presence.relay !== "number") return null;
|
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
|
if (!relay) return null;
|
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
|
}
|
|
|
|
/**
|
|
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
|
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
|
* spec. Fallback (unbound): the device's config.direction + the first relay site-
|
|
* wide matching that direction — keeps the single-barrier case trivial. Null if
|
|
* nothing resolves (no barrier to open).
|
|
*/
|
|
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
|
|
const cfg = deviceRow.config as BoundConfig;
|
|
|
|
// Bound: follow controllerId + relay to the exact barrier.
|
|
if (cfg.controllerId && typeof cfg.relay === "number") {
|
|
const controller = db
|
|
.select()
|
|
.from(devices)
|
|
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
|
|
.get();
|
|
if (controller && controller.enabled) {
|
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
|
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
|
if (spec && spec.direction !== "radarAlert") {
|
|
return { controller, relay: spec.relay, direction: spec.direction };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Unbound: fall back to the device's declared direction + first matching relay.
|
|
const want = cfg.direction;
|
|
if (want === "entry" || want === "exit" || want === "both") {
|
|
return firstRelayByDirection(db, want === "both" ? "entry" : want);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The first relay site-wide serving a direction ("both" relays match either).
|
|
* Used as the unbound fallback and where a flow only needs "an exit barrier".
|
|
*/
|
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
|
for (const controller of accessRows(db)) {
|
|
const spec = relaysOf(controller).find(
|
|
(r): r is RelaySpec & { direction: Direction } =>
|
|
r.direction === direction || r.direction === "both",
|
|
);
|
|
if (spec) {
|
|
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
|
|
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
|
|
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
|
|
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
|
|
return {
|
|
controller,
|
|
relay: spec.relay,
|
|
direction: spec.direction,
|
|
presenceInput: presence?.input,
|
|
presenceKind: presence?.kind ?? "loop",
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Enabled devices of a category whose direction matches `want` (or is "both").
|
|
* Direction is inherited from each device's bound relay, else its config fallback.
|
|
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
|
|
export function devicesByDirection(
|
|
db: Db,
|
|
category: DeviceRow["category"],
|
|
want: FlowDirection,
|
|
): DeviceRow[] {
|
|
return db
|
|
.select()
|
|
.from(devices)
|
|
.where(eq(devices.category, category))
|
|
.all()
|
|
.filter((r) => {
|
|
if (!r.enabled) return false;
|
|
const d = directionOf(db, r);
|
|
return d === want || d === "both";
|
|
});
|
|
}
|
|
|
|
/** The direction a reader/camera operates in (inherited from its bound relay, or
|
|
* its config fallback). "both" when undetermined → the flow infers. */
|
|
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
|
|
const resolved = relayForDevice(db, deviceRow);
|
|
if (resolved) return resolved.direction;
|
|
const cfg = deviceRow.config as BoundConfig;
|
|
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
|
|
}
|