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; /** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is * on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a * shared entry/exit lane. */ readonly anprAutoTrigger?: 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; } /** A single alarm fires the INSTANT motion starts — the car is still approaching, so the * first frame often has a small/blurry/absent plate (a low-confidence misread). But the car * then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed * moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and * re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */ function pollMs(): number { const raw = Number(process.env.ANPR_POLL_MS ?? 1000); return Number.isFinite(raw) && raw > 0 ? raw : 1000; } /** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new * push for the camera extends the deadline by this much from now — so a loop started by a * far/early car keeps pulling fresh frames as the REAL car arrives and settles at the * barrier (the loop tracks "whoever is here now", not the car that started it). */ function pollWindowMs(): number { const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000); return Number.isFinite(raw) && raw > 0 ? raw : 8000; } /** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never * stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */ function pollMaxMs(): number { const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000); return Number.isFinite(raw) && raw > 0 ? raw : 30_000; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the * subscriber by plate during the poll loop without re-building the whole event. */ function baseRead(row: { driverId: string }, deviceId: string): Omit { return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() }; } export class AnprBridge { readonly #db: Db; readonly #vision: VisionClient | null; readonly #subscription: SubscriptionFlow; readonly #logger: FastifyBaseLogger; readonly #entryMinConfidence: number; readonly #debounceMs: number; readonly #pollMs: number; readonly #pollWindowMs: number; readonly #pollMaxMs: 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(); /** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz * while the car sits) must NOT start a second concurrent loop on the same camera. */ readonly #polling = new Set(); /** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop * bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling * fresh frames while cars keep arriving — tracking whoever settles at the barrier. */ readonly #pollDeadline = 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(); this.#pollMs = pollMs(); this.#pollWindowMs = pollWindowMs(); this.#pollMaxMs = pollMaxMs(); } /** * 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; const cfg = row.config as CameraConfig; if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path) // Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on; // this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane // sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the // car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger). if (cfg.anprAutoTrigger === false) return; // Post-success debounce: once we've emitted a read for this camera, ignore the // ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the // window is a new presentation and may start a new poll loop. if (this.#debounced(deviceId)) return; // One poll loop per camera. A push that arrives while a loop runs JOINs it — and // SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive // so it tracks whoever's at the barrier now, instead of giving up on the early car). const now = Date.now(); if (this.#polling.has(deviceId)) { const cur = this.#pollDeadline.get(deviceId) ?? now; // Slide to lastPush + window, but never past the per-loop hard ceiling (set at start). this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs)); return; } this.#polling.add(deviceId); // Initial deadline; the hard ceiling (start + max) is enforced in the loop below. this.#pollDeadline.set(deviceId, now + this.#pollWindowMs); const camera = buildCamera(row); if (!camera) { this.#polling.delete(deviceId); 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"; // POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent // plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the // barrier — the stationary, well-framed moment the manual test reads at ~100%. So we // pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the // #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each // tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame. // While polling, watch whether THIS subscriber transacts by another credential // (card/QR at the reader). If their open-occurrence count drops mid-poll, the // subscriber already exited/entered — the bridge must NOT also emit (it would act on // the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the // subscription as soon as a frame reads the bound plate (identity needs no confidence), // snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved. let result: Awaited> = null; let watchedSubId: string | null = null; let baselineOpen = 0; // Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining // pushes) is read from #pollDeadline each tick but never allowed past this cap. const hardCap = Date.now() + this.#pollMaxMs; let attempts = 0; try { // DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the // host — a plain while could zero-iterate if the window elapsed between setting // the deadline and reaching the loop (seen as a CI flake with the tests' 5ms // window; on a busy booth it would silently drop a real car's detection). Exit // is via the breaks below (confident read, or next tick would pass the deadline). do { attempts++; const shot = await camera.captureSnapshot({ direction }); const r = await this.#vision.analyze(shot.bytes, shot.contentType); // Identify the subscriber from ANY readable plate (even below the barrier floor), // and baseline their open count once — so we can detect a credential beating us. if (r?.plate?.text) { const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() }); if (m0 && watchedSubId == null) { watchedSubId = m0.subscriptionId; baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId); } } // A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll → // they already transacted; stop polling and do NOT emit. if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) { this.#logger.info( `anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`, ); return; } if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) { result = r; break; } if (r?.plate) { this.#logger.info( `anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` + `${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`, ); } // Stop if the next tick would land past the (possibly slid) deadline or the cap. const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap); if (Date.now() + this.#pollMs >= effDeadline) break; await sleep(this.#pollMs); } while (true); } finally { this.#polling.delete(deviceId); this.#pollDeadline.delete(deviceId); } if (!result || !result.plate) { this.#logger.info( `anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` + `in ${this.#pollWindowMs}ms — gave up`, ); 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; } // Final guard against the credential-mid-poll race: if the subscriber transacted between // our baseline and now (e.g. a card scan in the last tick), don't double-act. if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) { this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`); 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); // Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz // re-fires (and any new poll loop) for #debounceMs. this.#stamp(deviceId); 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 };