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; readonly entryCooldownSec?: number; } /** Access controller config (the `relays[]` map + connection fields). */ interface AccessConfig { readonly relays?: RelaySpec[]; 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-loop input gating this relay's entry (when wired). */ readonly presenceInput?: number; /** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */ 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, 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 }; } /** * 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"; }