2915d141aa
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
238 lines
9.8 KiB
TypeScript
238 lines
9.8 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";
|
|
|
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
|
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
|
export interface RelaySpec {
|
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
|
readonly relay: number;
|
|
readonly direction: Direction;
|
|
/** 1-based input terminal of the entry button that fires this relay (transient
|
|
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
|
readonly button?: number;
|
|
/**
|
|
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
|
* Two modes, chosen by what barrier feedback exists at this lane:
|
|
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
|
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
|
* controller. A press prints only while a car is present, and no second ticket
|
|
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
|
* makes one-car-one-ticket physical.
|
|
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
|
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
|
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
|
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
|
*/
|
|
readonly presenceInput?: number;
|
|
/** What kind of sensor is on `presenceInput` — an induction LOOP or a RADAR. Label
|
|
* only (the gate behaviour is identical); drives UI copy + telemetry. Default loop. */
|
|
readonly presenceKind?: "loop" | "radar";
|
|
/** The presence terminal's ACTIVE level is LOW (idles HIGH). Maps to the driver's
|
|
* per-input `inputActiveLow` override so a radar wired opposite the button reads
|
|
* right. See wiki/entities/hikvision-radar.md. */
|
|
readonly presenceActiveLow?: boolean;
|
|
readonly entryCooldownSec?: number;
|
|
}
|
|
|
|
/** A non-barrier indicator lamp wired to a spare relay (e.g. the entry button's
|
|
* 12 V light). Driven by the server LightController off the radar + lane status —
|
|
* NOT a barrier. See wiki/concepts/button-light-indicator.md. */
|
|
export interface ButtonLightSpec {
|
|
/** 1-based spare relay channel the lamp is wired to. */
|
|
readonly relay: number;
|
|
/** 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;
|
|
}
|
|
|
|
/** 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 : [];
|
|
}
|
|
|
|
/**
|
|
* Resolve a button press to the relay it fires: the access controller with this
|
|
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
|
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
|
*/
|
|
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 spec = relaysOf(row).find((r) => r.button === terminal);
|
|
if (!spec) return null;
|
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
|
return {
|
|
controller: row,
|
|
relay: spec.relay,
|
|
direction: spec.direction,
|
|
presenceInput: spec.presenceInput,
|
|
presenceKind: spec.presenceKind ?? "loop",
|
|
entryCooldownSec: spec.entryCooldownSec,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
|
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
|
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
|
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
|
*/
|
|
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 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,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
if (spec) 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.direction === direction || r.direction === "both",
|
|
);
|
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
|
}
|
|
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";
|
|
}
|