feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m43s
CI / check (push) Successful in 37s

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:
2026-06-27 23:52:16 +02:00
parent c2a861208f
commit 25a72ff20a
6 changed files with 85 additions and 8 deletions
+22 -3
View File
@@ -46,8 +46,9 @@ afterEach(() => {
delete process.env.ANPR_POLL_MAX_MS; delete process.env.ANPR_POLL_MAX_MS;
}); });
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */ /** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
function seedCamera(opts: { anpr?: boolean } = {}): string { * per-camera auto-open gate (absent ⇒ defaults on). */
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
const controllerId = randomUUID(); const controllerId = randomUUID();
db.insert(devices).values({ db.insert(devices).values({
id: controllerId, id: controllerId,
@@ -61,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
id: camId, id: camId,
category: "camera", category: "camera",
driverId: "hikvision", 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, enabled: true,
}).run(); }).run();
return camId; return camId;
@@ -126,6 +133,18 @@ describe("AnprBridge", () => {
expect(captureSnapshot).not.toHaveBeenCalled(); 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 () => { it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
const cam = seedCamera({ anpr: true }); const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 }); const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
+11 -1
View File
@@ -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). */ /** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
interface CameraConfig { interface CameraConfig {
readonly anpr?: boolean; 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; readonly [k: string]: unknown;
} }
@@ -131,7 +135,13 @@ export class AnprBridge {
if (site && site.anprEntryEnabled === false) return; if (site && site.anprEntryEnabled === false) return;
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get(); const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!row || !row.enabled || row.category !== "camera") return; 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 // 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 // ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
+31 -2
View File
@@ -342,9 +342,16 @@ function DeviceForm({
const isController = category === "access"; const isController = category === "access";
const isCamera = category === "camera"; const isCamera = category === "camera";
const isPrinter = category === "printer"; const isPrinter = category === "printer";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates // ANPR opt-in for a camera: when true, this camera's snapshots are run through the
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md. // recognizer (plate recorded as evidence, both directions). (config.anpr). Off by default.
// See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true); const [anpr, setAnpr] = useState<boolean>(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<boolean>(editCfg?.anprAutoTrigger !== false);
// Pre-fill scalar config fields from the existing assignment when editing. // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.) // (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). // Camera ANPR opt-in (only persisted when on, to keep configs minimal).
if (isCamera && anpr) out.anpr = true; 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; return out;
} }
@@ -797,6 +808,24 @@ function DeviceForm({
</label> </label>
)} )}
{/* 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 && (
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<input
type="checkbox"
className="mt-0.5"
checked={anprAuto}
onChange={(e) => setAnprAuto(e.target.checked)}
/>
<span>
<span className="font-semibold text-term-text">{t("setup.anprAuto")}</span>
<span className="hint mt-0.5 block">{t("setup.anprAutoHint")}</span>
</span>
</label>
)}
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings, {/* 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 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 endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
+4 -1
View File
@@ -395,7 +395,10 @@ export const en: Catalog = {
addRelay: "+ Add relay", addRelay: "+ Add relay",
anpr: "Plate recognition (ANPR)", anpr: "Plate recognition (ANPR)",
anprHint: 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", testAnpr: "Test ANPR",
anprTesting: "Testing ANPR…", anprTesting: "Testing ANPR…",
testAnprHint: testAnprHint:
+4 -1
View File
@@ -405,7 +405,10 @@ export const sq = {
// Camera ANPR opt-in. // Camera ANPR opt-in.
anpr: "Njohja e targave (ANPR)", anpr: "Njohja e targave (ANPR)",
anprHint: 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", testAnpr: "Testo ANPR",
anprTesting: "Duke testuar ANPR…", anprTesting: "Duke testuar ANPR…",
testAnprHint: testAnprHint:
@@ -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 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. `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 > **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 > 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 > APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a