b3cb67188e
On a vehicle entry, two paths captured the SAME Hikvision camera within ~1s — the ANPR bridge (barrier-driving) and the advisory snapshotAsync (evidence/ telemetry) — each from a separate adapter instance. Hikvision serves snapshots single-threaded, so the second concurrent GET returned HTTP 503; the bridge then fail-softed and burned its 12s debounce, producing a ~74s "slow" subscriber entry (observed 2026-06-25, Qazim Mulleti / AB816NN — plate read was instant at conf 1.000; the delay was the 503/debounce churn, not recognition). Add captureSnapshotShared() in snapshot.ts: a module-level, deviceId-keyed cache that both paths call. It coalesces in-flight captures (the 2nd caller awaits the 1st's pull → no concurrent 503), serves a brief freshness window (1500ms) so the bridge→advisory sequence for one vehicle reuses one frame, never caches a failure (next caller retries), and keys by deviceId (no cross-camera/stale-vehicle reuse). Wired into anpr-entry.ts (bridge) and snapshot.ts (advisory). Tests: snapshot.test.ts (concurrent coalescing, TTL reuse, TTL-lapse re-pull, failure-not-cached, per-camera keying); anpr-entry.test.ts mock updated. 168 server tests green. NOTE: this removes the latency (the 503 collision). The separate double-entry (two signed vehicle_entry for one car) — debounce-too-short / stamp-before- success — is still open; less likely now but not eliminated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
187 lines
8.6 KiB
TypeScript
187 lines
8.6 KiB
TypeScript
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, captureSnapshotShared } 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<string, number>();
|
|
|
|
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<void> {
|
|
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";
|
|
// Shared capture (deviceId-keyed): coalesces with the advisory snapshotAsync for
|
|
// the SAME vehicle so the single-threaded camera isn't hit twice (→ HTTP 503).
|
|
const shot = await captureSnapshotShared(deviceId, camera, { 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 };
|