fix(anpr): sliding poll window so a car arriving mid-loop isn't lost

A loop started by a far/early car would (a) give up before the REAL car settled at
the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them).
So a confident-but-wrong far-car plate could win, or the intended car get debounced
out after the loop ended — wrong car acted on, right car blocked.

Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush +
ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS
(30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH
frame, so the loop tracks whoever is at the barrier NOW, not the car that started it.
Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally).

+1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server
tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the
two concurrency guards written up in lane-presence-and-anpr-entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 23:30:41 +02:00
parent a888125eca
commit c2a861208f
4 changed files with 92 additions and 11 deletions
+37
View File
@@ -43,6 +43,7 @@ afterEach(() => {
vi.restoreAllMocks();
delete process.env.ANPR_POLL_MS;
delete process.env.ANPR_POLL_WINDOW_MS;
delete process.env.ANPR_POLL_MAX_MS;
});
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
@@ -173,6 +174,42 @@ describe("AnprBridge", () => {
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
});
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
// extends the deadline, so the loop keeps polling and reads the car that settles at the
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
// the start bumps the deadline so attempt 5's confident read still lands.
const cam = seedCamera({ anpr: true });
const confs = [0.2, 0.2, 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;
process.env.ANPR_POLL_MS = "5";
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(async () => {
const loop = bridge.onVehicleDetected(cam); // starts the loop
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
for (let k = 0; k < 5; k++) {
await new Promise((r) => setTimeout(r, 5));
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
}
await loop;
});
expect(reads).toHaveLength(1);
expect(reads[0]).toMatchObject({ value: "AA111BB" });
});
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
+37 -8
View File
@@ -59,13 +59,22 @@ function pollMs(): number {
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. */
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
* push for the camera extends the deadline by this much from now — so a loop started by a
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
* barrier (the loop tracks "whoever is here now", not the car that started it). */
function pollWindowMs(): number {
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
}
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
function pollMaxMs(): number {
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
@@ -83,12 +92,17 @@ export class AnprBridge {
readonly #debounceMs: number;
readonly #pollMs: number;
readonly #pollWindowMs: number;
readonly #pollMaxMs: 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>();
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
readonly #pollDeadline = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
@@ -99,6 +113,7 @@ export class AnprBridge {
this.#debounceMs = debounceMs();
this.#pollMs = pollMs();
this.#pollWindowMs = pollWindowMs();
this.#pollMaxMs = pollMaxMs();
}
/**
@@ -122,10 +137,19 @@ export class AnprBridge {
// ~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;
// 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;
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
const now = Date.now();
if (this.#polling.has(deviceId)) {
const cur = this.#pollDeadline.get(deviceId) ?? now;
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
return;
}
this.#polling.add(deviceId);
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
const camera = buildCamera(row);
if (!camera) {
@@ -153,10 +177,12 @@ export class AnprBridge {
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
const deadline = Date.now() + this.#pollWindowMs;
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < deadline) {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
@@ -189,11 +215,14 @@ export class AnprBridge {
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
);
}
if (Date.now() + this.#pollMs >= deadline) break;
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
}
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);
}
if (!result || !result.plate) {
+5 -3
View File
@@ -73,9 +73,11 @@ WS_ALLOWED_ORIGINS=
# # read below this is ignored (falls back to card/QR).
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
# ANPR_POLL_WINDOW_MS=8000 # ...for up to this long while the car sits at the
# # barrier. RAISE if a slow barrier means the car
# # waits >8s before the plate reads clean.
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
# # a car arriving mid-loop extends it). RAISE if a slow
# # barrier means the car waits >8s before reading clean.
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
# # busy lane can't slide the window forever)
# ════════════════════════════════════════════════════════════════════════════
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
@@ -79,6 +79,19 @@ Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
> opens on a low-confidence read.
>
> **Two concurrency guards on the loop** (the edges a single push-triggered loop creates):
> 1. **Credential-mid-poll abort.** If the subscriber scans their card/QR at the reader DURING the
> loop, they've already transacted — the bridge watches their `openOccurrenceCount` (baselined
> once a frame reads the bound plate) and **aborts without emitting** if it moves, so it never
> double-acts (which would exit the NEXT open occurrence — bad for a fleet sub).
> 2. **SLIDING window for a different car arriving mid-poll.** A loop started by a far/early car
> must not (a) give up before the REAL car settles, nor (b) swallow the real car's pushes. So a
> push that joins a running loop **extends the deadline** (`lastPush + ANPR_POLL_WINDOW_MS`),
> capped at `start + ANPR_POLL_MAX_MS` (30s) so a continuously-busy lane can't slide forever.
> Because each tick pulls a FRESH frame, the loop naturally tracks whoever is at the barrier
> *now*, not the car that started it. (Knobs: `ANPR_POLL_MS`, `ANPR_POLL_WINDOW_MS`,
> `ANPR_POLL_MAX_MS`.)
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers