import { randomUUID } from "node:crypto"; import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import { deviceEvents, type DeviceReadEvent } from "./device-events.js"; import { directionOf, type FlowDirection } from "./device-resolve.js"; import { buildCamera } from "./snapshot.js"; import type { SubscriptionFlow } from "./subscription-flow.js"; import type { VisionClient } from "./vision-client.js"; // The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through // the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between // the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service. // // On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge: // pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the // plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched. // The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow // (active / window / blocklist / car-count), which signs the entry/exit and opens the relay. // // INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md): // - Advisory, never sole authority: the bridge only emitRead()s — the signed decision + // barrier open stay inside the existing flow. A spoofed printed plate is just another // credential through the same gate. // - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the // transient plate-as-ticket exit flow. // - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path; // never throws into the push handler, never awaited on the camera's 200 response. // - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits). /** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */ interface CameraConfig { readonly anpr?: boolean; readonly [k: string]: unknown; } /** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss * read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit. * Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */ function entryMinConfidence(): number { const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85); return Number.isFinite(raw) && raw > 0 ? raw : 0.85; } /** Same plate/camera within this window = ONE credential presentation. The camera re-fires * ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet * sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */ function debounceMs(): number { const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000); return Number.isFinite(raw) && raw > 0 ? raw : 12_000; } export class AnprBridge { readonly #db: Db; readonly #vision: VisionClient | null; readonly #subscription: SubscriptionFlow; readonly #logger: FastifyBaseLogger; readonly #entryMinConfidence: number; readonly #debounceMs: number; /** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by * `deviceId:plate` (post-match) — both gated against #debounceMs. */ readonly #lastFire = new Map(); constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) { this.#db = db; this.#vision = vision; this.#subscription = subscription; this.#logger = logger; this.#entryMinConfidence = entryMinConfidence(); this.#debounceMs = debounceMs(); } /** * A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the * plate, and — only if it matches a subscription — emit a plate read onto the bus. * Fire-and-forget; fail-soft. Never throws (the push handler must always 200). */ async onVehicleDetected(deviceId: string): Promise { try { if (!this.#vision?.enabled) return; // no recognizer configured // Admin master switch (read LIVE so toggling in Site Settings takes effect with no // restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane // busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default). const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (site && site.anprEntryEnabled === false) return; const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get(); if (!row || !row.enabled || row.category !== "camera") return; if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only // Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a // snapshot + analyze every second. if (this.#debounced(deviceId)) return; this.#stamp(deviceId); const camera = buildCamera(row); if (!camera) { this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`); return; } // "both" collapses to entry purely for the capture hint (it doesn't pick the lane — // the gated flow infers the verb from the camera's bound relay direction). const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry"; const shot = await camera.captureSnapshot({ direction }); const result = await this.#vision.analyze(shot.bytes, shot.contentType); if (!result || !result.plate) return; // nothing read // Entry floor — stricter than the advisory floor (analyze() still returns the plate // object with its confidence even when its own lowConfidence flag is set). if (result.plate.confidence < this.#entryMinConfidence) { this.#logger.info( `anpr-bridge: plate '${result.plate.text}' below entry floor ` + `(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`, ); return; } const plate = result.plate.text.trim().toUpperCase(); if (!plate) return; const e: DeviceReadEvent = { driverId: row.driverId, deviceId, value: plate, kind: "plate", at: new Date().toISOString(), }; // MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory // telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow. const match = this.#subscription.match(e); if (!match) { this.#recordSkip(deviceId, plate, result.plate.confidence); return; } // Plate-level debounce — belt-and-suspenders against a gap that slips the // camera-level gate re-emitting the SAME plate. const plateKey = `${deviceId}:${plate}`; if (this.#debounced(plateKey)) return; this.#stamp(plateKey); this.#logger.info( `anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`, ); deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow } catch (err) { // Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane. this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`); } } #debounced(key: string): boolean { const last = this.#lastFire.get(key); return last != null && Date.now() - last < this.#debounceMs; } #stamp(key: string): void { this.#lastFire.set(key, Date.now()); } /** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a * read on the bus — just a breadcrumb so the operator can see ANPR is working. */ #recordSkip(deviceId: string, plate: string, confidence: number): void { this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`); try { this.#db .insert(deviceEventsTable) .values({ id: randomUUID(), deviceId, category: "camera", kind: "anpr-skip", detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" }, occurredAt: new Date().toISOString(), }) .run(); } catch (err) { this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`); } } } // DeviceRow is re-exported for the test's seed typing convenience. export type { DeviceRow };