c03ef2a34b
CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory anpr-skip"): the poll-until-confident loop was a plain `while (Date.now() < deadline)` — zero iterations were possible when the window elapsed between deadline-set and loop-entry (the tests run a 5ms window; a slow runner loses that race). Zero attempts → no frame analyzed → "gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in the recent merges touched this path; the race existed since the poll loop was built. The invariant is real beyond tests: on a sufficiently loaded booth the old loop could silently drop a real car's detection the same way. The loop is now do-while (exit via the existing breaks: confident read, or next tick past the slid deadline/hard cap), so a detection ALWAYS analyzes at least one frame. New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made deterministic) and asserts exactly one capture attempt + the recorded skip. Suite 283 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
329 lines
17 KiB
TypeScript
329 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
|
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
|
import { buildCamera } from "./snapshot.js";
|
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
|
import type { VisionClient } from "./vision-client.js";
|
|
|
|
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
|
|
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
|
|
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
|
|
//
|
|
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
|
|
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
|
|
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
|
|
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
|
|
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
|
|
//
|
|
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
|
|
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
|
|
// barrier open stay inside the existing flow. A spoofed printed plate is just another
|
|
// credential through the same gate.
|
|
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
|
|
// transient plate-as-ticket exit flow.
|
|
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
|
|
// never throws into the push handler, never awaited on the camera's 200 response.
|
|
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
|
|
|
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
|
interface CameraConfig {
|
|
readonly anpr?: boolean;
|
|
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
|
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
|
* shared entry/exit lane. */
|
|
readonly anprAutoTrigger?: boolean;
|
|
readonly [k: string]: unknown;
|
|
}
|
|
|
|
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
|
|
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
|
|
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
|
|
function entryMinConfidence(): number {
|
|
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
|
|
}
|
|
|
|
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
|
|
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
|
|
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
|
|
function debounceMs(): number {
|
|
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
|
}
|
|
|
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
|
function pollMs(): number {
|
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
|
}
|
|
|
|
/** 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
|
|
* 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;
|
|
readonly #subscription: SubscriptionFlow;
|
|
readonly #logger: FastifyBaseLogger;
|
|
readonly #entryMinConfidence: number;
|
|
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;
|
|
this.#vision = vision;
|
|
this.#subscription = subscription;
|
|
this.#logger = logger;
|
|
this.#entryMinConfidence = entryMinConfidence();
|
|
this.#debounceMs = debounceMs();
|
|
this.#pollMs = pollMs();
|
|
this.#pollWindowMs = pollWindowMs();
|
|
this.#pollMaxMs = pollMaxMs();
|
|
}
|
|
|
|
/**
|
|
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
|
|
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
|
|
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
|
|
*/
|
|
async onVehicleDetected(deviceId: string): Promise<void> {
|
|
try {
|
|
if (!this.#vision?.enabled) return; // no recognizer configured
|
|
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
|
|
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
|
|
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
|
|
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
if (site && site.anprEntryEnabled === false) return;
|
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
|
if (!row || !row.enabled || row.category !== "camera") return;
|
|
const cfg = row.config as CameraConfig;
|
|
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
|
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
|
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
|
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
|
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
|
if (cfg.anprAutoTrigger === false) return;
|
|
|
|
// Post-success debounce: once we've emitted a read for this camera, ignore 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.
|
|
if (this.#debounced(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) {
|
|
this.#polling.delete(deviceId);
|
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
|
return;
|
|
}
|
|
|
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
|
// the gated flow infers the verb from the camera's bound relay direction).
|
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
|
|
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
|
// 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;
|
|
// 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 {
|
|
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
|
|
// host — a plain while could zero-iterate if the window elapsed between setting
|
|
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
|
|
// window; on a busy booth it would silently drop a real car's detection). Exit
|
|
// is via the breaks below (confident read, or next tick would pass the deadline).
|
|
do {
|
|
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;
|
|
}
|
|
if (r?.plate) {
|
|
this.#logger.info(
|
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
|
);
|
|
}
|
|
// 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);
|
|
} while (true);
|
|
} finally {
|
|
this.#polling.delete(deviceId);
|
|
this.#pollDeadline.delete(deviceId);
|
|
}
|
|
|
|
if (!result || !result.plate) {
|
|
this.#logger.info(
|
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
|
`in ${this.#pollWindowMs}ms — gave up`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const plate = result.plate.text.trim().toUpperCase();
|
|
if (!plate) return;
|
|
|
|
const e: DeviceReadEvent = {
|
|
driverId: row.driverId,
|
|
deviceId,
|
|
value: plate,
|
|
kind: "plate",
|
|
at: new Date().toISOString(),
|
|
};
|
|
|
|
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
|
|
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
|
|
const match = this.#subscription.match(e);
|
|
if (!match) {
|
|
this.#recordSkip(deviceId, plate, result.plate.confidence);
|
|
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}`;
|
|
if (this.#debounced(plateKey)) return;
|
|
this.#stamp(plateKey);
|
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
|
// re-fires (and any new poll loop) for #debounceMs.
|
|
this.#stamp(deviceId);
|
|
|
|
this.#logger.info(
|
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
|
);
|
|
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
|
|
} catch (err) {
|
|
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
|
|
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
#debounced(key: string): boolean {
|
|
const last = this.#lastFire.get(key);
|
|
return last != null && Date.now() - last < this.#debounceMs;
|
|
}
|
|
|
|
#stamp(key: string): void {
|
|
this.#lastFire.set(key, Date.now());
|
|
}
|
|
|
|
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
|
|
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
|
|
#recordSkip(deviceId: string, plate: string, confidence: number): void {
|
|
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
|
|
try {
|
|
this.#db
|
|
.insert(deviceEventsTable)
|
|
.values({
|
|
id: randomUUID(),
|
|
deviceId,
|
|
category: "camera",
|
|
kind: "anpr-skip",
|
|
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
|
|
occurredAt: new Date().toISOString(),
|
|
})
|
|
.run();
|
|
} catch (err) {
|
|
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// DeviceRow is re-exported for the test's seed typing convenience.
|
|
export type { DeviceRow };
|