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;
|
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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user