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;
});
/** 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 });
+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). */
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
+31 -2
View File
@@ -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<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.
// (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({
</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,
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
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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: