fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll
The poll-until-confident loop (prev commit) opened a race: during its ~8s window a subscriber could scan their card/QR at the reader and exit immediately — but the ANPR loop kept polling and would ALSO emit a confident read a moment later, exiting the NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open). Guard it with the subscriber's open-occurrence count: the bridge identifies the subscription as soon as a frame reads the bound plate (identity needs no confidence), baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a credential closed/opened an occurrence mid-poll, the subscriber already transacted → abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite). +1 test (credential transacts mid-poll → no double-act); 170 server tests green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -68,6 +68,12 @@ function pollWindowMs(): number {
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export class AnprBridge {
|
||||
readonly #db: Db;
|
||||
readonly #vision: VisionClient | null;
|
||||
@@ -138,7 +144,15 @@ export class AnprBridge {
|
||||
// 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.
|
||||
// While polling, watch whether THIS subscriber transacts by another credential
|
||||
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||
let watchedSubId: string | null = null;
|
||||
let baselineOpen = 0;
|
||||
const deadline = Date.now() + this.#pollWindowMs;
|
||||
let attempts = 0;
|
||||
try {
|
||||
@@ -146,6 +160,25 @@ export class AnprBridge {
|
||||
attempts++;
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||
|
||||
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||
// and baseline their open count once — so we can detect a credential beating us.
|
||||
if (r?.plate?.text) {
|
||||
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||
if (m0 && watchedSubId == null) {
|
||||
watchedSubId = m0.subscriptionId;
|
||||
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||
}
|
||||
}
|
||||
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||
// they already transacted; stop polling and do NOT emit.
|
||||
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||
result = r;
|
||||
break;
|
||||
@@ -190,6 +223,13 @@ export class AnprBridge {
|
||||
return;
|
||||
}
|
||||
|
||||
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||
// camera-level gate re-emitting the SAME plate.
|
||||
const plateKey = `${deviceId}:${plate}`;
|
||||
|
||||
Reference in New Issue
Block a user