diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts index 0c36640..8b33b36 100644 --- a/apps/server/src/anpr-entry.test.ts +++ b/apps/server/src/anpr-entry.test.ts @@ -86,8 +86,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb } /** A fake SubscriptionFlow: only `match()` is called by the bridge. */ -function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow { - return { match: vi.fn(() => match) } as unknown as SubscriptionFlow; +function fakeSubFlow( + match: SubscriptionMatch | null, + // openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a + // credential closing an occurrence mid-poll → count changes). + openCounts: number | number[] = 1, +): SubscriptionFlow { + const seq = Array.isArray(openCounts) ? [...openCounts] : null; + return { + match: vi.fn(() => match), + openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))), + } as unknown as SubscriptionFlow; } const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" }; @@ -152,6 +161,10 @@ describe("AnprBridge", () => { tookMs: 1, })), } as unknown as VisionClient; + // Generous window so all 3 escalation attempts run deterministically under suite load + // (the global beforeEach sets a tiny 5ms window for the give-up cases). + process.env.ANPR_POLL_MS = "1"; + process.env.ANPR_POLL_WINDOW_MS = "2000"; const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); const reads = await captureReads(() => bridge.onVehicleDetected(cam)); @@ -160,6 +173,20 @@ describe("AnprBridge", () => { expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames }); + 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 + // emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet). + const cam = seedCamera({ anpr: true }); + const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor + // openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check. + const sub = fakeSubFlow(SUB_MATCH, [1, 0]); + const bridge = new AnprBridge(db, vision, sub, silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toEqual([]); // aborted — the credential already handled it + }); + 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 }); diff --git a/apps/server/src/anpr-entry.ts b/apps/server/src/anpr-entry.ts index 4aa20a1..00f891d 100644 --- a/apps/server/src/anpr-entry.ts +++ b/apps/server/src/anpr-entry.ts @@ -68,6 +68,12 @@ function pollWindowMs(): number { const sleep = (ms: number) => new Promise((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 { + 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> = 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}`; diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index 927c10c..405ce69 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -310,6 +310,15 @@ export class SubscriptionFlow { * subscription, (b) pick which occurrence a read closes, and (c) enforce * `maxConcurrent`. The on-chain field is `permitId`, so we match against that. */ + /** How many occurrences this subscription currently has OPEN (entries not yet exited). + * Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if + * the count drops while it's polling, the subscriber already transacted and the bridge must + * NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for + * a fleet sub). See anpr-entry.ts. */ + openOccurrenceCount(subscriptionId: string): number { + return this.#openOccurrences(subscriptionId).length; + } + #openOccurrences(subscriptionId: string): { identity: string; index: number }[] { const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); // Net entries−exits per occurrence identity, keeping the entry order (oldest first).