diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts index 8b33b36..a7e639f 100644 --- a/apps/server/src/anpr-entry.test.ts +++ b/apps/server/src/anpr-entry.test.ts @@ -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 diff --git a/apps/server/src/anpr-entry.ts b/apps/server/src/anpr-entry.ts index 00f891d..b82aae3 100644 --- a/apps/server/src/anpr-entry.ts +++ b/apps/server/src/anpr-entry.ts @@ -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((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(); /** 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(); + /** 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(); 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> = 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) { diff --git a/komodo/.env.komodo.example b/komodo/.env.komodo.example index 0d7b9e6..439e4eb 100644 --- a/komodo/.env.komodo.example +++ b/komodo/.env.komodo.example @@ -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_) diff --git a/wiki/concepts/lane-presence-and-anpr-entry.md b/wiki/concepts/lane-presence-and-anpr-entry.md index 10ec41d..108351d 100644 --- a/wiki/concepts/lane-presence-and-anpr-entry.md +++ b/wiki/concepts/lane-presence-and-anpr-entry.md @@ -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