import { eq, devices, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import { deviceEvents, type LaneStatusEvent } from "./device-events.js"; import { directionOf } from "./device-resolve.js"; // Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection // is a hint the booth shows as barrier lights; it never gates a ticket or opens a // barrier (see wiki/entities/lpr-camera.md, the advisory-only rule). // // A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and // (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so // "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone // (each refreshing the timer); once the car leaves, the actives stop and the lane // flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes. /** How long after the last vehicle detection a lane stays "busy" before clearing. * Must exceed the camera's `active` re-fire interval so a still-present car keeps the * lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is * MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching * to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car * gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes * silent within a second of the car leaving), so 30s clears promptly after departure * while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */ export function busyTtlMs(): number { const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000); return Number.isFinite(raw) && raw > 0 ? raw : 30_000; } export class LaneStatus { readonly #db: Db; readonly #logger: FastifyBaseLogger; readonly #ttlMs: number; #entry = false; #exit = false; #entryTimer: ReturnType | null = null; #exitTimer: ReturnType | null = null; constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) { this.#db = db; this.#logger = logger; this.#ttlMs = ttlMs; } /** Current snapshot (for the WS hello). */ snapshot(): LaneStatusEvent { return { entry: this.#entry, exit: this.#exit }; } /** * A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction * and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera * or a non-vehicle caller is the caller's concern — this only handles a confirmed * vehicle detection. Emits a lane-status change only when the state actually flips. */ vehicleDetected(deviceId: string): void { const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get(); if (!row) return; const dir = directionOf(this.#db, row); if (dir === "entry" || dir === "both") this.#mark("entry"); if (dir === "exit" || dir === "both") this.#mark("exit"); } #mark(lane: "entry" | "exit"): void { const was = lane === "entry" ? this.#entry : this.#exit; if (lane === "entry") this.#entry = true; else this.#exit = true; // (Re)arm the auto-clear — each detection pushes the free-flip further out. const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer; if (existing) clearTimeout(existing); const timer = setTimeout(() => this.#clear(lane), this.#ttlMs); timer.unref?.(); // never hold the process open if (lane === "entry") this.#entryTimer = timer; else this.#exitTimer = timer; if (!was) { this.#logger.info(`lane-status: ${lane} -> busy`); this.#emit(); } } #clear(lane: "entry" | "exit"): void { if (lane === "entry") { this.#entry = false; this.#entryTimer = null; } else { this.#exit = false; this.#exitTimer = null; } this.#logger.info(`lane-status: ${lane} -> free`); this.#emit(); } #emit(): void { deviceEvents.emitLaneStatus(this.snapshot()); } /** Clear timers on shutdown. */ stop(): void { if (this.#entryTimer) clearTimeout(this.#entryTimer); if (this.#exitTimer) clearTimeout(this.#exitTimer); } }