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:
@@ -43,6 +43,7 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
delete process.env.ANPR_POLL_MS;
|
delete process.env.ANPR_POLL_MS;
|
||||||
delete process.env.ANPR_POLL_WINDOW_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. */
|
/** 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
|
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 () => {
|
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
|
// 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
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
|||||||
@@ -59,13 +59,22 @@ function pollMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : 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
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
* it's a non-subscriber). Bounded so a stray car can't loop forever. */
|
* 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 {
|
function pollWindowMs(): number {
|
||||||
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : 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));
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
@@ -83,12 +92,17 @@ export class AnprBridge {
|
|||||||
readonly #debounceMs: number;
|
readonly #debounceMs: number;
|
||||||
readonly #pollMs: number;
|
readonly #pollMs: number;
|
||||||
readonly #pollWindowMs: number;
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
readonly #lastFire = new Map<string, number>();
|
readonly #lastFire = new Map<string, number>();
|
||||||
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
/** 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. */
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
readonly #polling = new Set<string>();
|
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) {
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -99,6 +113,7 @@ export class AnprBridge {
|
|||||||
this.#debounceMs = debounceMs();
|
this.#debounceMs = debounceMs();
|
||||||
this.#pollMs = pollMs();
|
this.#pollMs = pollMs();
|
||||||
this.#pollWindowMs = pollWindowMs();
|
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
|
// ~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.
|
// window is a new presentation and may start a new poll loop.
|
||||||
if (this.#debounced(deviceId)) return;
|
if (this.#debounced(deviceId)) return;
|
||||||
// One poll loop per camera: the camera pushes the SAME alarm ~1Hz while the car
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
// sits at the barrier — those re-fires must JOIN the running loop, not spawn N of them.
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
if (this.#polling.has(deviceId)) return;
|
// 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);
|
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);
|
const camera = buildCamera(row);
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
@@ -153,10 +177,12 @@ export class AnprBridge {
|
|||||||
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
let watchedSubId: string | null = null;
|
let watchedSubId: string | null = null;
|
||||||
let baselineOpen = 0;
|
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;
|
let attempts = 0;
|
||||||
try {
|
try {
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||||
attempts++;
|
attempts++;
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
@@ -189,11 +215,14 @@ export class AnprBridge {
|
|||||||
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
`${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);
|
await sleep(this.#pollMs);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.#polling.delete(deviceId);
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result || !result.plate) {
|
if (!result || !result.plate) {
|
||||||
|
|||||||
@@ -73,9 +73,11 @@ WS_ALLOWED_ORIGINS=
|
|||||||
# # read below this is ignored (falls back to card/QR).
|
# # read below this is ignored (falls back to card/QR).
|
||||||
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
|
# 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_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
|
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
|
||||||
# # barrier. RAISE if a slow barrier means the car
|
# # a car arriving mid-loop extends it). RAISE if a slow
|
||||||
# # waits >8s before the plate reads clean.
|
# # 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_)
|
# 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
|
> 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
|
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
|
||||||
> opens on a low-confidence read.
|
> 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
|
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
|
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||||||
|
|||||||
Reference in New Issue
Block a user