feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
A shared entry/exit lane has both an entry and an exit camera on ONE lane: a subscriber driving IN is admitted by the entry cam, but the exit cam sees the same car leaving its frame and phantom-EXITs the occurrence just opened (its back plate). Separate RECOGNITION from AUTO-OPEN per camera: - config.anpr (unchanged) = run snapshots through the recognizer, record the plate (evidence), BOTH directions — stays on. - config.anprAutoTrigger (new, absent ⇒ on when anpr is on) = may THIS camera auto-open the barrier. Set false on the shared-lane exit cam: it still recognises plates but never auto-triggers. The bridge gates on it (anpr-entry.ts), before the poll loop. UI: a "Auto open/close on subscriber plate" checkbox under ANPR in the camera setup (shown when anpr is on); persisted true/false so a park can explicitly disable it. i18n sq+en (also corrected the now-stale anprHint "never opens a barrier" wording — it does, via the bridge). +1 server test (anprAutoTrigger=false → no snapshot, no read); 172 green. Documented the two toggle levels (site-wide + per-camera) in lane-presence-and-anpr-entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -46,8 +46,9 @@ afterEach(() => {
|
||||
delete process.env.ANPR_POLL_MAX_MS;
|
||||
});
|
||||
|
||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||
const controllerId = randomUUID();
|
||||
db.insert(devices).values({
|
||||
id: controllerId,
|
||||
@@ -61,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||
id: camId,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
||||
config: {
|
||||
host: "10.0.0.9",
|
||||
controllerId,
|
||||
relay: 1,
|
||||
...(opts.anpr ? { anpr: true } : {}),
|
||||
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
return camId;
|
||||
@@ -126,6 +133,18 @@ describe("AnprBridge", () => {
|
||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -131,7 +135,13 @@ export class AnprBridge {
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user