From 65328b8c11a8d0e0236d2f98989e9d5bf7734a87 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 22 Jun 2026 19:49:18 +0200 Subject: [PATCH] feat(anpr): subscriber-entry bridge + admin disable toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/.env.example | 8 +- apps/server/src/anpr-entry.test.ts | 191 ++++++++++++++++++ apps/server/src/anpr-entry.ts | 184 +++++++++++++++++ .../server/src/routes/hikvision-alarm.test.ts | 67 +++++- apps/server/src/routes/hikvision-alarm.ts | 24 ++- apps/server/src/routes/site.ts | 11 + apps/server/src/server.ts | 22 +- apps/server/src/snapshot.ts | 5 +- apps/web/src/SiteSettings.tsx | 15 ++ apps/web/src/api.ts | 2 + apps/web/src/lib/i18n/en.ts | 2 + apps/web/src/lib/i18n/sq.ts | 2 + .../db/drizzle/0013_anpr_entry_toggle.sql | 5 + packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 10 + wiki/concepts/lane-presence-and-anpr-entry.md | 23 ++- wiki/entities/lpr-camera.md | 7 +- wiki/index.md | 2 +- wiki/log.md | 15 ++ 19 files changed, 579 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/anpr-entry.test.ts create mode 100644 apps/server/src/anpr-entry.ts create mode 100644 packages/db/drizzle/0013_anpr_entry_toggle.sql diff --git a/apps/server/.env.example b/apps/server/.env.example index b2eeab5..b5073e0 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -56,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos # VISION_ENABLED=1 # master switch — nothing runs without it # VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT # VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane -# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service +# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service +# +# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane +# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per +# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay. +# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR) +# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz) diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts new file mode 100644 index 0000000..e921e2d --- /dev/null +++ b/apps/server/src/anpr-entry.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { deviceEvents, type DeviceReadEvent } from "./device-events.js"; +import { silentLogger } from "./test-helpers.js"; +import type { VisionClient, VisionResult } from "./vision-client.js"; +import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js"; + +// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a +// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real +// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's +// own logic only. See anpr-entry.ts. + +// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub +// (no registry, no network). The factory returns a fresh shot each call. +const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" })); +vi.mock("./snapshot.js", () => ({ + buildCamera: () => ({ captureSnapshot }), +})); + +// Import AFTER the mock is registered. +const { AnprBridge } = await import("./anpr-entry.js"); + +let db: Db; +beforeEach(() => { + ({ db } = createTestDb()); + captureSnapshot.mockClear(); + delete process.env.VISION_ENTRY_MIN_CONFIDENCE; + delete process.env.ANPR_DEBOUNCE_MS; +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */ +function seedCamera(opts: { anpr?: boolean } = {}): string { + const controllerId = randomUUID(); + db.insert(devices).values({ + id: controllerId, + category: "access", + driverId: "dingtian", + config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] }, + enabled: true, + }).run(); + const camId = randomUUID(); + db.insert(devices).values({ + id: camId, + category: "camera", + driverId: "hikvision", + config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) }, + enabled: true, + }).run(); + return camId; +} + +/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */ +function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient { + const enabled = opts.enabled ?? true; + const result: VisionResult | null = + opts.plate == null + ? null + : { + plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 }, + plates: [], + lowConfidence: false, + modelVersion: "test", + tookMs: 1, + }; + return { + enabled, + analyze: vi.fn(async () => (enabled ? result : null)), + } as unknown as VisionClient; +} + +/** 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; +} + +const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" }; + +/** Capture read events emitted during `fn` (async). */ +async function captureReads(fn: () => Promise): Promise { + const got: DeviceReadEvent[] = []; + const off = deviceEvents.onRead((e) => got.push(e)); + try { + await fn(); + } finally { + off(); + } + return got; +} + +describe("AnprBridge", () => { + it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => { + const cam = seedCamera({ anpr: false }); + const vision = fakeVision({ plate: "AA111BB" }); + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toEqual([]); + expect(vision.analyze).not.toHaveBeenCalled(); + expect(captureSnapshot).not.toHaveBeenCalled(); + }); + + it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => { + const cam = seedCamera({ anpr: true }); + const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 }); + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toHaveLength(1); + expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" }); + }); + + it("ignores a plate below the entry confidence floor", async () => { + const cam = seedCamera({ anpr: true }); + const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85 + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toEqual([]); + }); + + 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 }); + const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toEqual([]); + + const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all(); + expect(skips).toHaveLength(1); + expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ"); + }); + + it("debounces: two vehicle events within the window analyze/emit at most once", async () => { + const cam = seedCamera({ anpr: true }); + const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 }); + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(async () => { + await bridge.onVehicleDetected(cam); + await bridge.onVehicleDetected(cam); // within the 12s window → suppressed + }); + expect(reads).toHaveLength(1); + expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot + }); + + it("is a no-op (no throw) when vision is disabled or reads nothing", async () => { + const cam = seedCamera({ anpr: true }); + const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger()); + const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(async () => { + await disabled.onVehicleDetected(cam); + await noPlate.onVehicleDetected(cam); + }); + expect(reads).toEqual([]); + }); + + it("never throws on an unknown device id", async () => { + const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger()); + await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined(); + }); + + it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => { + const cam = seedCamera({ anpr: true }); + db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run(); + const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 }); + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toEqual([]); + // The flag is checked FIRST — no snapshot, no analyze, no match attempt. + expect(captureSnapshot).not.toHaveBeenCalled(); + expect(vision.analyze).not.toHaveBeenCalled(); + }); + + it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => { + const cam = seedCamera({ anpr: true }); + db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run(); + const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 }); + const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); + + const reads = await captureReads(() => bridge.onVehicleDetected(cam)); + expect(reads).toHaveLength(1); + }); +}); diff --git a/apps/server/src/anpr-entry.ts b/apps/server/src/anpr-entry.ts new file mode 100644 index 0000000..f669388 --- /dev/null +++ b/apps/server/src/anpr-entry.ts @@ -0,0 +1,184 @@ +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; + 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; +} + +export class AnprBridge { + readonly #db: Db; + readonly #vision: VisionClient | null; + readonly #subscription: SubscriptionFlow; + readonly #logger: FastifyBaseLogger; + readonly #entryMinConfidence: number; + readonly #debounceMs: 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(); + + 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(); + } + + /** + * 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 { + 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; + if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only + + // Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a + // snapshot + analyze every second. + if (this.#debounced(deviceId)) return; + this.#stamp(deviceId); + + const camera = buildCamera(row); + if (!camera) { + 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"; + const shot = await camera.captureSnapshot({ direction }); + const result = await this.#vision.analyze(shot.bytes, shot.contentType); + if (!result || !result.plate) return; // nothing read + + // Entry floor — stricter than the advisory floor (analyze() still returns the plate + // object with its confidence even when its own lowConfidence flag is set). + if (result.plate.confidence < this.#entryMinConfidence) { + this.#logger.info( + `anpr-bridge: plate '${result.plate.text}' below entry floor ` + + `(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`, + ); + 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; + } + + // 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); + + 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 }; diff --git a/apps/server/src/routes/hikvision-alarm.test.ts b/apps/server/src/routes/hikvision-alarm.test.ts index 746c6fc..6a674cd 100644 --- a/apps/server/src/routes/hikvision-alarm.test.ts +++ b/apps/server/src/routes/hikvision-alarm.test.ts @@ -1,8 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Fastify, { type FastifyInstance as RawFastify } from "fastify"; import { createTestDb } from "@parking/db/testing"; import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import type { FastifyInstance } from "fastify"; import { buildServer } from "../server.js"; +import { hikvisionAlarmRoutes } from "./hikvision-alarm.js"; +import type { AnprBridge } from "../anpr-entry.js"; import { seedUser, login } from "../test-helpers.js"; // Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle- @@ -222,3 +225,65 @@ describe("Hikvision Alarm Server push", () => { expect(res.statusCode).toBe(401); }); }); + +// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the +// routes on a bare instance with a SPY bridge to assert exactly when it's invoked — +// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in +// anpr-entry.test.ts.) +describe("Hikvision Alarm Server → ANPR bridge wiring", () => { + let rawApp: RawFastify; + let rawDb: Db; + let rawClose: () => void; + let onVehicleDetected: ReturnType; + + beforeEach(async () => { + const t = createTestDb(); + rawDb = t.db; + rawClose = t.close; + onVehicleDetected = vi.fn(async () => {}); + const bridge = { onVehicleDetected } as unknown as AnprBridge; + rawApp = Fastify(); + await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge); + await rawApp.ready(); + rawDb.insert(devices).values({ + id: CAM_ID, + category: "camera", + driverId: "hikvision", + config: { host: CAM_IP, alarmPushEnabled: true }, + enabled: true, + }).run(); + }); + afterEach(async () => { + await rawApp.close(); + rawClose(); + }); + + async function post(payload: string) { + return rawApp.inject({ + method: "POST", + url: `/api/devices/hikvision/${CAM_ID}/event`, + headers: { "content-type": "application/xml" }, + payload, + remoteAddress: CAM_IP, + }); + } + + it("hands a vehicle (active) detection to the bridge", async () => { + const res = await post(VEHICLE_XML); + expect(res.statusCode).toBe(200); + expect(onVehicleDetected).toHaveBeenCalledTimes(1); + expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID); + }); + + it("does NOT call the bridge for a human target", async () => { + const human = VEHICLE_XML.replace("vehicle", "human"); + await post(human); + expect(onVehicleDetected).not.toHaveBeenCalled(); + }); + + it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => { + const leave = VEHICLE_XML.replace("active", "inactive"); + await post(leave); + expect(onVehicleDetected).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/routes/hikvision-alarm.ts b/apps/server/src/routes/hikvision-alarm.ts index 15753ff..04aa29d 100644 --- a/apps/server/src/routes/hikvision-alarm.ts +++ b/apps/server/src/routes/hikvision-alarm.ts @@ -5,6 +5,7 @@ import { deviceEvents } from "../device-events.js"; import { requirePermission } from "../auth.js"; import { verifyDigest } from "../digest-auth.js"; import type { LaneStatus } from "../lane-status.js"; +import type { AnprBridge } from "../anpr-entry.js"; // Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event → // Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm @@ -95,7 +96,12 @@ function summarize(body: string): AlarmSummary { }; } -export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db, laneStatus?: LaneStatus): Promise { +export async function hikvisionAlarmRoutes( + app: FastifyInstance, + db: Db, + laneStatus?: LaneStatus, + anprBridge?: AnprBridge, +): Promise { // Accept ANY content-type as a raw Buffer (the camera may POST application/xml, // multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415 // or empty these — we want the bytes verbatim. Scoped to THIS app instance via a @@ -199,14 +205,22 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db, laneSta // for the booth barrier lights). Only on a vehicle target that's `active` — an // `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a // timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent. - if ( - laneStatus && + const isVehicleActive = (summary.target ?? "").toLowerCase() === "vehicle" && - (summary.eventState ?? "active").toLowerCase() !== "inactive" - ) { + (summary.eventState ?? "active").toLowerCase() !== "inactive"; + if (laneStatus && isVehicleActive) { laneStatus.vehicleDetected(deviceId); } + // ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr), + // pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read + // onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit + + // barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get + // a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts. + if (anprBridge && isVehicleActive) { + void anprBridge.onVehicleDetected(deviceId); + } + // Surface on the in-process bus as a generic breadcrumb so a live listener can show // "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving // entry/exit) is the deliberate next step once we know the real payload. diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index 83ed407..ea2d60e 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -32,6 +32,9 @@ interface SiteConfigBody extends Partial> { /** Reserve a spot in occupancy for each active subscriber's car(s), even when not * parked — so transients see "full" sooner and the subscriber's spot is held. */ reserveSubscriberSpots?: boolean; + /** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's + * plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */ + anprEntryEnabled?: boolean; } /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default @@ -41,6 +44,7 @@ type SiteConfig = { exitVoucherDefault: boolean; subscriptionMonthlyPriceMinor: number | null; reserveSubscriberSpots: boolean; + anprEntryEnabled: boolean; } & Record; function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { @@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf exitVoucherDefault: row?.exitVoucherDefault ?? false, subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null, reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false, + anprEntryEnabled: row?.anprEntryEnabled ?? true, } as SiteConfig; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; return out; @@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise { } patch.reserveSubscriberSpots = body.reserveSubscriberSpots; } + if ("anprEntryEnabled" in body) { + if (typeof body.anprEntryEnabled !== "boolean") { + return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" }); + } + patch.anprEntryEnabled = body.anprEntryEnabled; + } for (const f of TEXT_FIELDS) { if (f in body) patch[f] = normText(body[f]); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 558d15f..280505a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js"; import { LaneStatus } from "./lane-status.js"; +import { AnprBridge } from "./anpr-entry.js"; import { eventRoutes } from "./routes/events.js"; import { reportRoutes } from "./routes/reports.js"; import { recycleBinRoutes } from "./routes/recycle-bin.js"; @@ -121,11 +122,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise laneStatus.stop()); - // Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on - // each detected target (vehicle). Source-IP guarded + optional Digest; records the raw - // payload as a `kind:"alarm"` device_event AND drives lane busy/free for vehicles. - // See routes/hikvision-alarm.ts. - await hikvisionAlarmRoutes(app, db, laneStatus); + // NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read + // flows are constructed — because the ANPR bridge they carry depends on the + // SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring. // Live printer-status monitor: polls printers (paper/cover/cutter/offline) and // pushes changes to the booth UI. setupRoutes() has already registered the @@ -199,6 +198,19 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeRead()); + // ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection, + // admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a + // plate read onto the bus, which the dispatcher above turns into a gated entry/exit). + // Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs + // the subscriptionFlow constructed just above. See anpr-entry.ts. + const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log); + + // Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on + // each detected target (vehicle). Source-IP guarded + optional Digest; records the raw + // payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle + // detection to the ANPR bridge above. See routes/hikvision-alarm.ts. + await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge); + // Credential capture ("enroll a card"): lets the operator present an RFID card to a // CHOSEN reader to populate a subscription credential, without blocking the other // reader's live flow. Single-shot + TTL. See credential-capture.ts. diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index b507210..9de4d61 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -141,8 +141,9 @@ async function recognizePlate( } } -/** Build a live camera adapter from a resolved devices row, or null. */ -function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null { +/** Build a live camera adapter from a resolved devices row, or null. Exported so the + * ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */ +export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx index 91bf474..ade8d49 100644 --- a/apps/web/src/SiteSettings.tsx +++ b/apps/web/src/SiteSettings.tsx @@ -26,6 +26,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { const [meta, setMeta] = useState>({}); const [exitVoucherDefault, setExitVoucherDefault] = useState(false); const [reserveSubs, setReserveSubs] = useState(false); + const [anprEntry, setAnprEntry] = useState(true); const [msg, setMsg] = useState(null); function reload() { @@ -38,6 +39,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { setCapInput(c.capacity == null ? "" : String(c.capacity)); setExitVoucherDefault(c.exitVoucherDefault); setReserveSubs(c.reserveSubscriberSpots); + setAnprEntry(c.anprEntryEnabled); const m: Record = {}; for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); setMeta(m); @@ -52,6 +54,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { capacity: raw === "" ? null : Math.round(Number(raw)), exitVoucherDefault, reserveSubscriberSpots: reserveSubs, + anprEntryEnabled: anprEntry, }; // Send each metadata field; "" → null is applied server-side. for (const { key } of META_FIELDS) (patch as Record)[key] = meta[key] ?? ""; @@ -112,6 +115,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { {t("site.reserveSubsHint")} +
{t("site.parkDetails")}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 106e0e4..5400a68 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -943,6 +943,8 @@ export interface SiteConfig { subscriptionMonthlyPriceMinor: number | null; /** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */ reserveSubscriberSpots: boolean; + /** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */ + anprEntryEnabled: boolean; parkName: string | null; operatorName: string | null; /** NIUS — Albanian tax/identification number. */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index b37a9f6..362a990 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -532,6 +532,8 @@ export const en: Catalog = { printExitHint: "(booth far from exit → customer self-exits with a voucher)", reserveSubs: "Reserve subscriber spots", reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).", + anprEntry: "Auto-open for subscriber plates (ANPR)", + anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.", parkDetails: "Park details (optional — shown on tickets/receipts)", save: "Save", saved: "Saved.", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 13dd07e..c35868a 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -543,6 +543,8 @@ export const sq = { printExitHint: "(klienti skanon biletën në dalje)", reserveSubs: "Rezervo vendet e abonentëve", reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).", + anprEntry: "Hapje automatike për targat e abonentëve (ANPR)", + anprEntryHint: "Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.", parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)", save: "Ruaj", saved: "U ruajt.", diff --git a/packages/db/drizzle/0013_anpr_entry_toggle.sql b/packages/db/drizzle/0013_anpr_entry_toggle.sql new file mode 100644 index 0000000..cb73ac5 --- /dev/null +++ b/packages/db/drizzle/0013_anpr_entry_toggle.sql @@ -0,0 +1,5 @@ +-- Site master switch for the ANPR subscriber-entry bridge (anpr-entry.ts). Additive +-- ALTER ADD COLUMN — backward-compatible. Default 1 (ON) so existing installs keep the +-- now-live auto-open-for-subscriber-plates behaviour after upgrade. The toggle gates ONLY +-- the barrier-driving bridge; advisory snapshot-ANPR + lane busy/free are unaffected. +ALTER TABLE `site_config` ADD `anpr_entry_enabled` integer DEFAULT 1 NOT NULL; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 6c6c556..db1a248 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1781885500000, "tag": "0012_soft_delete", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1781885600000, + "tag": "0013_anpr_entry_toggle", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d654a84..e17365b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -236,6 +236,16 @@ export const siteConfig = sqliteTable("site_config", { reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" }) .notNull() .default(false), + /** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON + * (default), a subscriber's plate read off a lane camera's vehicle detection opens the + * barrier through the normal gated subscription flow. When OFF, the bridge emits no read + * (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge — + * advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event + * so toggling takes effect with no restart. Default ON because the feature is already + * live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */ + anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" }) + .notNull() + .default(true), /** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a * tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into * each published tariff version's structure.tz so the windows are frozen/immutable diff --git a/wiki/concepts/lane-presence-and-anpr-entry.md b/wiki/concepts/lane-presence-and-anpr-entry.md index 71f110a..c5a6745 100644 --- a/wiki/concepts/lane-presence-and-anpr-entry.md +++ b/wiki/concepts/lane-presence-and-anpr-entry.md @@ -13,8 +13,8 @@ Two related things a camera's vehicle detection feeds, worked out over a long fi detection area" conclusion): 1. **Lane busy/free** — BUILT. An advisory barrier light on the booth. -2. **ANPR subscriber entry** — PLANNED. A subscriber's plate, read from the lane camera, drives - their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below. +2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera, + drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below. The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a `targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything @@ -46,11 +46,24 @@ nothing** (never blocks a ticket or opens a barrier; the standing rule). also clears promptly after departure. - A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`). -## 2. ANPR subscriber entry — THE BRIDGE (PLANNED, not built) +## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22) -> **"Bridge" = a HANDLER FUNCTION in `apps/server` (≈40 lines, e.g. `anpr-entry.ts`). NOT a new +> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new > service / container / app.** It is in-process glue that calls things that ALREADY exist. +**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in +camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the +camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via +the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor +(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE +emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory +`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()` +then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered +above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout — +any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler. +Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by +`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3). + 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 ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating. @@ -64,7 +77,7 @@ Almost everything already exists; the bridge is the one missing wire: | Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) | | Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) | | Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) | -| **Emit the plate onto the read bus** | ❌ **the bridge** — today the snapshot ANPR only RECORDS the plate as advisory telemetry; it does NOT `emitRead`. hik-alarm.ts literally says "NOT a DeviceReadEvent yet". | +| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) | **The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`), snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md index f9807ca..d5ba5a6 100644 --- a/wiki/entities/lpr-camera.md +++ b/wiki/entities/lpr-camera.md @@ -84,9 +84,10 @@ Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST or open anything. A plate read is **advisory, never the sole reason** a barrier opens ([[append-only-event-chain]], [[opencv-anpr-service]]). Two consumers were since designed off this same vehicle event — see **[[lane-presence-and-anpr-entry]]**: (a) BUILT — advisory lane busy/free - booth lights; (b) PLANNED — the ANPR "bridge" that snapshots → ANPR → emits a `kind:"plate"` read - for a SUBSCRIBER match through the existing gated flow (a small `apps/server` handler, not a - service). If the camera ever emits its own `` we'd use it directly; this `DS-2CD1043G2` + booth lights; (b) BUILT (2026-06-22) — the ANPR "bridge" (`anpr-entry.ts`) that snapshots → ANPR → + emits a `kind:"plate"` read for a SUBSCRIBER match through the existing gated flow (a small + `apps/server` handler class, not a service). If the camera ever emits its own `` we'd + use it directly; this `DS-2CD1043G2` does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]]. ### Gotchas learned the hard way (2026-06-22 field session) diff --git a/wiki/index.md b/wiki/index.md index b248430..9cc4399 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -99,7 +99,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records. - [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope. - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.) - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline. -- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (PLANNED) the ANPR "bridge": a subscriber's plate read at the lane admits them via the existing gated subscription flow. Measured camera limits; rejected the queue-tracking/livestream ideas. +- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas. - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. ## Concepts — frontend / operator UI diff --git a/wiki/log.md b/wiki/log.md index 8d50f25..0bcd55b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1431,3 +1431,18 @@ tracking/make-model (needs a vehicle detector the plate-only vision lacks + appl measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD — booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]], [[subscription]], index. + +## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT +Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a +vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId +to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces +(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs +`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key +safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a +subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a +non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never +reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the +read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`, +`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full +server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 + +table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).