From 25a72ff20a19ccee6637426754b4da88f6d3dabf Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 27 Jun 2026 23:52:16 +0200 Subject: [PATCH] feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/anpr-entry.test.ts | 25 ++++++++++++-- apps/server/src/anpr-entry.ts | 12 ++++++- apps/web/src/SetupWizard.tsx | 33 +++++++++++++++++-- apps/web/src/lib/i18n/en.ts | 5 ++- apps/web/src/lib/i18n/sq.ts | 5 ++- wiki/concepts/lane-presence-and-anpr-entry.md | 13 ++++++++ 6 files changed, 85 insertions(+), 8 deletions(-) diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts index a7e639f..43ad7f9 100644 --- a/apps/server/src/anpr-entry.test.ts +++ b/apps/server/src/anpr-entry.test.ts @@ -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 }); diff --git a/apps/server/src/anpr-entry.ts b/apps/server/src/anpr-entry.ts index b82aae3..8e5b1fc 100644 --- a/apps/server/src/anpr-entry.ts +++ b/apps/server/src/anpr-entry.ts @@ -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 diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 05e5840..27c740f 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -342,9 +342,16 @@ function DeviceForm({ const isController = category === "access"; const isCamera = category === "camera"; const isPrinter = category === "printer"; - // ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates - // (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. + // ANPR opt-in for a camera: when true, this camera's snapshots are run through the + // recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default. + // See wiki/entities/opencv-anpr-service.md. const [anpr, setAnpr] = useState(editCfg?.anpr === true); + // Auto-trigger: when true, THIS camera's vehicle detection may auto-open the barrier + // (subscriber entry/exit). Separate from `anpr` so a shared entry/exit lane can keep + // RECOGNITION on both cameras but disable auto-open on, e.g., the exit camera (whose + // back-plate read would otherwise phantom-exit the car that just entered). Defaults ON + // when anpr is on (back-compat). (config.anprAutoTrigger). + const [anprAuto, setAnprAuto] = useState(editCfg?.anprAutoTrigger !== false); // Pre-fill scalar config fields from the existing assignment when editing. // (relays/controllerId/relay are model fields handled by their own state below.) @@ -503,6 +510,10 @@ function DeviceForm({ } // Camera ANPR opt-in (only persisted when on, to keep configs minimal). if (isCamera && anpr) out.anpr = true; + // Auto-trigger flag — only meaningful when anpr is on. Persist it (true OR false) so a + // park can explicitly DISABLE auto-open on a camera (e.g. the exit cam of a shared lane) + // while keeping recognition. Absent ⇒ defaults ON (back-compat for existing cameras). + if (isCamera && anpr) out.anprAutoTrigger = anprAuto; return out; } @@ -797,6 +808,24 @@ function DeviceForm({ )} + {/* Auto-trigger is only meaningful with ANPR on. Off = this camera RECOGNISES plates + (evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where + the exit cam's back-plate read would phantom-exit a car that just entered. */} + {isCamera && anpr && ( + + )} + {/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings, ready to copy, so the operator never has to find the deviceId or memorise the endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 95f511a..c8779b4 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -395,7 +395,10 @@ export const en: Catalog = { addRelay: "+ Add relay", anpr: "Plate recognition (ANPR)", anprHint: - "Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.", + "Read plates on this camera: the vision service reads the plate from each snapshot and records it (both entry and exit). Requires the vision service running.", + anprAuto: "Auto open/close on subscriber plate", + anprAutoHint: + "Let THIS camera auto-open the barrier when it recognises a subscriber's plate. Turn OFF on a shared entry/exit lane's exit camera, so a car driving IN isn't auto-EXITed by its back plate (recognition still runs — only the auto-trigger is off).", testAnpr: "Test ANPR", anprTesting: "Testing ANPR…", testAnprHint: diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index acff006..7e8a48e 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -405,7 +405,10 @@ export const sq = { // Camera ANPR opt-in. anpr: "Njohja e targave (ANPR)", anprHint: - "Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.", + "Lexo targat në këtë aparat: shërbimi i vizionit lexon targën nga çdo pamje dhe e regjistron (hyrje dhe dalje). Kërkon shërbimin e vizionit aktiv.", + anprAuto: "Hapje/mbyllje automatike me targën e abonentit", + anprAutoHint: + "Lejo që KY aparat të hapë vetë barrierën kur njeh targën e një abonenti. ÇAKTIVIZOJE te aparati i daljes në një korsi të përbashkët hyrje/dalje, që një makinë që HYN të mos DALË automatikisht nga targa e pasme (njohja vazhdon — fiket vetëm hapja automatike).", testAnpr: "Testo ANPR", anprTesting: "Duke testuar ANPR…", testAnprHint: diff --git a/wiki/concepts/lane-presence-and-anpr-entry.md b/wiki/concepts/lane-presence-and-anpr-entry.md index 108351d..47e0a2b 100644 --- a/wiki/concepts/lane-presence-and-anpr-entry.md +++ b/wiki/concepts/lane-presence-and-anpr-entry.md @@ -64,6 +64,19 @@ any snapshot/vision error degrades to the subscriber's card/QR, never throws int Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by `anpr-entry.test.ts` + `hikvision-alarm.test.ts` wiring. +**Toggles — two levels (the auto-open is optional).** ANPR auto entry/exit can be turned off without +losing plate recognition: +- **Site-wide:** `site_config.anprEntryEnabled` (a Site Settings switch) gates the WHOLE bridge + (both directions); off ⇒ no camera auto-opens, recognition/lane-status unaffected. +- **Per-camera** (2026-06-27): `config.anprAutoTrigger` (absent ⇒ on when `anpr` is on). This + separates **recognition** (`config.anpr` — snapshots run through the recognizer, plate recorded, + BOTH directions) from **auto-open** (`anprAutoTrigger` — may this camera fire the barrier). The + case it solves: a **shared entry/exit lane** where ONE physical lane has both an entry and an exit + camera. A subscriber driving IN is admitted by the entry cam — but the exit cam sees the SAME car + leaving its frame and would **phantom-exit** the occurrence just opened (its back plate). Set the + exit cam's `anprAutoTrigger = false`: it still recognises plates for the record, but never + auto-opens. (The Setup checkbox "Auto open/close on subscriber plate" appears under ANPR.) + > **POLL-until-confident (2026-06-27).** The single-shot capture above was upgraded to a **poll > loop**. The camera fires its vehicle alarm the INSTANT motion starts — the car is still > APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a