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"; }