import { randomUUID } from "node:crypto"; import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db"; import { registry, type CameraDevice } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import { devicesByDirection, type FlowDirection } from "./device-resolve.js"; import type { VisionClient } from "./vision-client.js"; // Camera snapshot capture, fired AFTER the barrier opens and never awaited on the // open path (decision 2026-06-16): a snapshot is EVIDENCE, not a gate. A camera // failure must never delay or prevent an open — the signed ledger is the decision, // the image is an independent, prunable record stored as a BLOB in `snapshots`. // See wiki/concepts/entry-exit-points.md and append-only-event-chain.md. // // Every camera serving the firing direction (entry/exit, or both) snapshots. Each // capture is independent — one camera down doesn't stop the others. A captured image // → a `snapshots` row + a `kind:"snapshot"` telemetry device_event; a failure → a // telemetry device_event only. The caller passes the session `identity` so the image // links to the signed vehicle_entry/exit. // // ANPR rides this snapshot (2026-06-19). A transient button-press or a subscriber // QR/RFID read triggers the entry/exit, which fires THIS snapshot — that is exactly the // moment to recognize the plate, off the SAME image, tied to the SAME session identity. // So when a `vision` client is passed AND the camera opts in (config.anpr), each stored // snapshot is sent to the vision service and the extracted plate is RECORDED against the // session (a `kind:"read"` device_event with plate/confidence/snapshotId). ADVISORY + // fire-and-forget: it never blocks the open and never changes the entry/exit decision — // it's a record ("session X entered on plate AA558EE"). No polling; recognition only // happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md. interface SnapshotJob { readonly db: Db; readonly direction: FlowDirection; /** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */ readonly identity: string; readonly logger: FastifyBaseLogger; /** Optional vision client — when present, ANPR runs on each captured image from an * `anpr`-enabled camera and records the plate against `identity`. Advisory only. */ readonly vision?: VisionClient | null; } /** Camera config flag opting it into snapshot-triggered ANPR. */ interface CameraConfig { readonly anpr?: boolean; readonly [k: string]: unknown; } /** * Fire snapshots for the directional camera set. Returns immediately with a promise * the caller MAY ignore (fire-and-forget) — it resolves to the captured snapshot ids. * The caller must NOT block its open path on this. */ export function snapshotAsync(job: SnapshotJob): Promise { const { db, direction, identity, logger, vision } = job; const rows = devicesByDirection(db, "camera", direction); if (rows.length === 0) return Promise.resolve([]); return Promise.all( rows.map(async (row): Promise => { const camera = buildCamera(row); if (!camera) { recordFailure(db, direction, row.id, identity, "camera config won't build", logger); return null; } try { const shot = await camera.captureSnapshot({ direction }); const id: string = randomUUID(); db.insert(snapshots) .values({ id, direction, deviceId: row.id, identity, contentType: shot.contentType, bytes: shot.bytes, capturedAt: shot.capturedAt, }) .run(); // Telemetry breadcrumb pointing at the stored image (NOT the bytes). recordEvent(db, direction, row.id, identity, { snapshotId: id, ok: true }, logger); // ANPR off the SAME image, tied to the SAME session — when vision is enabled // and this camera opts in. Fire-and-forget: never delays the open path. if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) { void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger); } return id; } catch (err) { recordFailure(db, direction, row.id, identity, (err as Error).message, logger); return null; } }), ).then((ids) => ids.filter((id): id is string => id != null)); } /** * Recognize the plate off a captured entry/exit image and RECORD it against the session * `identity` — an unsigned `kind:"read"` device_event carrying the plate, confidence, * region, and the `snapshotId` it was read from. Advisory: this records the plate * observed for the session; it does NOT feed the access decision (the flow already * decided). A low-confidence/no-plate result records nothing (a shaky read isn't a fact). * Best-effort + fail-soft — a vision error never surfaces on the (already-open) path. */ async function recognizePlate( db: Db, vision: VisionClient, deviceId: string, direction: FlowDirection, identity: string, snapshotId: string, shot: { bytes: Buffer; contentType: string }, logger: FastifyBaseLogger, ): Promise { try { const result = await vision.analyze(shot.bytes, shot.contentType); if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record const plate = result.plate.text.trim().toUpperCase(); if (!plate) return; db.insert(deviceEventsTable) .values({ id: randomUUID(), deviceId, category: "camera", kind: "read", // `identity` ties the plate to the session; `snapshotId` to the evidence image. detail: { identity, direction, plate, confidence: result.plate.confidence, region: result.plate.region ?? null, modelVersion: result.modelVersion, snapshotId, source: "entry-exit-snapshot", }, occurredAt: new Date().toISOString(), }) .run(); logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`); } catch (err) { logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`); } } /** Build a live camera adapter from a resolved devices row, or null. */ function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { return driver.create(row.config as never) as CameraDevice; } catch { return null; } } function recordFailure( db: Db, direction: FlowDirection, deviceId: string, identity: string, error: string, logger: FastifyBaseLogger, ): void { logger.warn(`snapshot failed (${direction}, ${identity}): ${error}`); recordEvent(db, direction, deviceId, identity, { ok: false, error }, logger); } function recordEvent( db: Db, direction: FlowDirection, deviceId: string, identity: string, detail: Record, logger: FastifyBaseLogger, ): void { try { db.insert(deviceEventsTable) .values({ id: randomUUID(), deviceId, category: "camera", kind: "snapshot", detail: { ...detail, direction, identity }, occurredAt: new Date().toISOString(), }) .run(); } catch (err) { // Telemetry is best-effort; never let it surface on the (already-open) path. logger.error(`snapshot device-event insert failed: ${(err as Error).message}`); } }