feat(anpr): poll snapshots until a confident plate, so auto-exit works

The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).

Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
  running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
  would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
  for ANPR_DEBOUNCE_MS once we've acted), not before the loop.

VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 22:54:49 +02:00
parent e4a17efd97
commit f77ed11782
5 changed files with 225 additions and 16 deletions
+72 -14
View File
@@ -3,7 +3,7 @@ import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, ty
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 { buildCamera } from "./snapshot.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import type { VisionClient } from "./vision-client.js";
@@ -49,6 +49,25 @@ function debounceMs(): number {
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;
}
/** Total time to keep polling for a confident read before giving up (the car drove off, or
* it's a non-subscriber). Bounded so a stray car can't loop forever. */
function pollWindowMs(): number {
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export class AnprBridge {
readonly #db: Db;
readonly #vision: VisionClient | null;
@@ -56,9 +75,14 @@ export class AnprBridge {
readonly #logger: FastifyBaseLogger;
readonly #entryMinConfidence: number;
readonly #debounceMs: number;
readonly #pollMs: number;
readonly #pollWindowMs: 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>();
/** 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<string>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
@@ -67,6 +91,8 @@ export class AnprBridge {
this.#logger = logger;
this.#entryMinConfidence = entryMinConfidence();
this.#debounceMs = debounceMs();
this.#pollMs = pollMs();
this.#pollWindowMs = pollWindowMs();
}
/**
@@ -86,13 +112,18 @@ export class AnprBridge {
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.
// 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;
this.#stamp(deviceId);
// One poll loop per camera: the camera pushes the SAME alarm ~1Hz while the car
// sits at the barrier — those re-fires must JOIN the running loop, not spawn N of them.
if (this.#polling.has(deviceId)) return;
this.#polling.add(deviceId);
const camera = buildCamera(row);
if (!camera) {
this.#polling.delete(deviceId);
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
return;
}
@@ -100,18 +131,42 @@ export class AnprBridge {
// "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) {
// 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.
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
const deadline = Date.now() + this.#pollWindowMs;
let attempts = 0;
try {
while (Date.now() < deadline) {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
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})`,
);
}
if (Date.now() + this.#pollMs >= deadline) break;
await sleep(this.#pollMs);
}
} finally {
this.#polling.delete(deviceId);
}
if (!result || !result.plate) {
this.#logger.info(
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
`in ${this.#pollWindowMs}ms — gave up`,
);
return;
}
@@ -140,6 +195,9 @@ export class AnprBridge {
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`,