ecaaefd899
Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.
The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.
Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
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<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 {
|
|
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<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}`);
|
|
} 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<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}`);
|
|
}
|
|
}
|