Files
parking_solution/apps/server/src/snapshot.ts
T
julian 6734e9815e fix(booth): backfill the live-feed plate + make plate search work
Two booth feed fixes:

- Plate not showing until refresh. Plate recognition is async/advisory
  (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session
  identity), so it lands AFTER the entry/exit event already shipped over the WS
  without a plate; a refresh re-fetched via the bulk enrich path and showed it.
  Added a `plate-recognized` bus event (device-events.ts) emitted when the read
  is written; ws.ts forwards it; the client patchPlate(identity, plate)
  (live-store) backfills the already-rendered feed row in place and invalidates
  the Query-owned active-sessions list. No refresh.

- Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions
  (ActiveSessions) search haystacks matched the wrong field — the displayed plate
  is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate
  (the plate is unsigned, never in the signed payload). Switched the haystacks to
  the displayed field.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 12:24:46 +02:00

262 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
import { deviceEvents } from "./device-events.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<string[]> {
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<string | null> => {
const camera = buildCamera(row);
if (!camera) {
recordFailure(db, direction, row.id, identity, "camera config won't build", logger);
return null;
}
try {
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
const shot = await captureSnapshotShared(row.id, camera, { 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<void> {
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}`);
// The session's entry/exit event already shipped without this (async) plate — tell the
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction });
} catch (err) {
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
}
}
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
export 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;
}
}
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
/** How long a fresh capture is reused for the same camera. A car is one event for a
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
const SNAPSHOT_TTL_MS = 1500;
interface CacheEntry {
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
inflight?: Promise<Snapshot>;
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
last?: { shot: Snapshot; at: number };
}
const snapshotCache = new Map<string, CacheEntry>();
/**
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
*/
export function captureSnapshotShared(
deviceId: string,
camera: CameraDevice,
ctx: { direction: FlowDirection },
): Promise<Snapshot> {
const now = Date.now();
let entry = snapshotCache.get(deviceId);
if (!entry) {
entry = {};
snapshotCache.set(deviceId, entry);
}
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
return Promise.resolve(entry.last.shot);
}
// A capture is already running → join it (this is what prevents the 503 collision).
if (entry.inflight) return entry.inflight;
// Otherwise issue the single real pull; record it as the in-flight promise.
const pull = camera
.captureSnapshot(ctx)
.then((shot) => {
entry.last = { shot, at: Date.now() };
return shot;
})
.finally(() => {
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
if (entry.inflight === pull) entry.inflight = undefined;
});
entry.inflight = pull;
return pull;
}
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<string, unknown>,
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}`);
}
}