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
+32
View File
@@ -33,9 +33,16 @@ beforeEach(() => {
captureSnapshot.mockClear();
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
delete process.env.ANPR_DEBOUNCE_MS;
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
// bridge reads these in its constructor, so set them before `new AnprBridge`.
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "5";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.ANPR_POLL_MS;
delete process.env.ANPR_POLL_WINDOW_MS;
});
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
@@ -128,6 +135,31 @@ describe("AnprBridge", () => {
expect(reads).toEqual([]);
});
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
// must re-pull until one frame clears the floor, not give up on the first bad frame.
const cam = seedCamera({ anpr: true });
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
const confs = [0.2, 0.2, 0.97];
let i = 0;
const vision = {
enabled: true,
analyze: vi.fn(async () => ({
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
modelVersion: "test",
tookMs: 1,
})),
} as unknown as VisionClient;
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });