Compare commits
7 Commits
e4a17efd97
...
25a72ff20a
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 |
@@ -33,13 +33,22 @@ beforeEach(() => {
|
|||||||
captureSnapshot.mockClear();
|
captureSnapshot.mockClear();
|
||||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
delete process.env.ANPR_DEBOUNCE_MS;
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||||
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||||
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.ANPR_POLL_MS;
|
||||||
|
delete process.env.ANPR_POLL_WINDOW_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,
|
||||||
@@ -53,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;
|
||||||
@@ -79,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
function fakeSubFlow(
|
||||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
match: SubscriptionMatch | null,
|
||||||
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||||
|
// credential closing an occurrence mid-poll → count changes).
|
||||||
|
openCounts: number | number[] = 1,
|
||||||
|
): SubscriptionFlow {
|
||||||
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||||
|
return {
|
||||||
|
match: vi.fn(() => match),
|
||||||
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||||
|
} as unknown as SubscriptionFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
@@ -109,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 });
|
||||||
@@ -128,6 +164,85 @@ describe("AnprBridge", () => {
|
|||||||
expect(reads).toEqual([]);
|
expect(reads).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||||
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||||
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||||
|
const confs = [0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||||
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||||
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||||
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||||
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||||
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||||
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
process.env.ANPR_POLL_MS = "5";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||||
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||||
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||||
|
for (let k = 0; k < 5; k++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||||
|
}
|
||||||
|
await loop;
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||||
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||||
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||||
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||||
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||||
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||||
|
});
|
||||||
|
|
||||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
|||||||
+152
-15
@@ -3,7 +3,7 @@ import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, ty
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
||||||
import { buildCamera, captureSnapshotShared } from "./snapshot.js";
|
import { buildCamera } from "./snapshot.js";
|
||||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||||
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||||
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||||
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||||
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||||
|
function pollMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
|
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||||
|
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||||
|
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||||
|
function pollWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||||
|
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||||
|
function pollMaxMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
|
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||||
|
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||||
|
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export class AnprBridge {
|
export class AnprBridge {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
|||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
readonly #entryMinConfidence: number;
|
readonly #entryMinConfidence: number;
|
||||||
readonly #debounceMs: number;
|
readonly #debounceMs: number;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
readonly #lastFire = new Map<string, number>();
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||||
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
|
readonly #polling = new Set<string>();
|
||||||
|
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||||
|
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||||
|
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||||
|
readonly #pollDeadline = new Map<string, number>();
|
||||||
|
|
||||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#entryMinConfidence = entryMinConfidence();
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
this.#debounceMs = debounceMs();
|
this.#debounceMs = debounceMs();
|
||||||
|
this.#pollMs = pollMs();
|
||||||
|
this.#pollWindowMs = pollWindowMs();
|
||||||
|
this.#pollMaxMs = pollMaxMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,15 +135,35 @@ 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;
|
||||||
|
|
||||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||||
// snapshot + analyze every second.
|
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||||
|
// window is a new presentation and may start a new poll loop.
|
||||||
if (this.#debounced(deviceId)) return;
|
if (this.#debounced(deviceId)) return;
|
||||||
this.#stamp(deviceId);
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
|
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.#polling.has(deviceId)) {
|
||||||
|
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||||
|
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||||
|
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#polling.add(deviceId);
|
||||||
|
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||||
|
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||||
|
|
||||||
const camera = buildCamera(row);
|
const camera = buildCamera(row);
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,18 +171,74 @@ export class AnprBridge {
|
|||||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
// the gated flow infers the verb from the camera's bound relay direction).
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
// Shared capture (deviceId-keyed): coalesces with the advisory snapshotAsync for
|
|
||||||
// the SAME vehicle so the single-threaded camera isn't hit twice (→ HTTP 503).
|
|
||||||
const shot = await captureSnapshotShared(deviceId, camera, { direction });
|
|
||||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
|
||||||
if (!result || !result.plate) return; // nothing read
|
|
||||||
|
|
||||||
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||||
// object with its confidence even when its own lowConfidence flag is set).
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||||
|
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||||
|
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||||
|
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||||
|
// While polling, watch whether THIS subscriber transacts by another credential
|
||||||
|
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||||
|
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||||
|
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||||
|
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||||
|
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||||
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
|
let watchedSubId: string | null = null;
|
||||||
|
let baselineOpen = 0;
|
||||||
|
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||||
|
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||||
|
const hardCap = Date.now() + this.#pollMaxMs;
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||||
|
attempts++;
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
|
||||||
|
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||||
|
// and baseline their open count once — so we can detect a credential beating us.
|
||||||
|
if (r?.plate?.text) {
|
||||||
|
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||||
|
if (m0 && watchedSubId == null) {
|
||||||
|
watchedSubId = m0.subscriptionId;
|
||||||
|
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||||
|
// they already transacted; stop polling and do NOT emit.
|
||||||
|
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||||
|
result = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r?.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||||
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||||
|
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||||
|
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||||
|
await sleep(this.#pollMs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || !result.plate) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
`in ${this.#pollWindowMs}ms — gave up`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,11 +262,21 @@ export class AnprBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||||
|
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||||
|
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||||
|
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
// camera-level gate re-emitting the SAME plate.
|
// camera-level gate re-emitting the SAME plate.
|
||||||
const plateKey = `${deviceId}:${plate}`;
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
if (this.#debounced(plateKey)) return;
|
if (this.#debounced(plateKey)) return;
|
||||||
this.#stamp(plateKey);
|
this.#stamp(plateKey);
|
||||||
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||||
|
// re-fires (and any new poll loop) for #debounceMs.
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
|||||||
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
|
|||||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||||
*/
|
*/
|
||||||
|
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
|
||||||
|
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
|
||||||
|
* the count drops while it's polling, the subscriber already transacted and the bridge must
|
||||||
|
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
|
||||||
|
* a fleet sub). See anpr-entry.ts. */
|
||||||
|
openOccurrenceCount(subscriptionId: string): number {
|
||||||
|
return this.#openOccurrences(subscriptionId).length;
|
||||||
|
}
|
||||||
|
|
||||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
# Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
|
||||||
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
|
||||||
#
|
#
|
||||||
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
# RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
|
||||||
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
# dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
|
||||||
# appliance's local Fastify address. Not a secret — committed for reproducible
|
# origin here would point the browser at the wrong host. This matches the deploy
|
||||||
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
# (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
|
||||||
#
|
#
|
||||||
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
# DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
|
||||||
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
# not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
|
||||||
VITE_API_BASE=http://127.0.0.1:3000
|
# DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
|
||||||
|
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
|
||||||
|
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
|
||||||
|
# exported override), NOT here.
|
||||||
|
VITE_API_BASE=
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+89
-14
@@ -1,24 +1,99 @@
|
|||||||
# Komodo Stack environment — reference of what a booth Stack needs and WHERE it comes
|
# Komodo Stack environment — the COMPLETE reference of every env the booth stack reads:
|
||||||
# from. Under Komodo, plain env lives in the Stack definition (komodo/resources.toml);
|
# required, image-selection, set-by-compose (don't override), and the optional tunables
|
||||||
# the two SECRETS come from Komodo Core's secret store, PER BOOTH and UNIQUE. This file
|
# with their code defaults. Under Komodo, plain env lives in the Stack definition
|
||||||
# is documentation only — do NOT fill in real secrets here (it would be the same leak
|
# (komodo/resources.toml); the two SECRETS come from Core's secret store, PER BOOTH and
|
||||||
# we're avoiding). See wiki/decisions/fleet-deployment-komodo.md.
|
# UNIQUE. This file is DOCUMENTATION — never fill in real secrets here. See
|
||||||
|
# wiki/decisions/fleet-deployment-komodo.md.
|
||||||
|
#
|
||||||
|
# A minimal working Stack only needs: REGISTRY, TAG, the two secrets, COOKIE_SECURE=0,
|
||||||
|
# VISION_ENABLED=1, WS_ALLOWED_ORIGINS. Everything under "OPTIONAL TUNABLES" has a safe
|
||||||
|
# default in code — set one only to override it.
|
||||||
|
|
||||||
# --- plain Stack env (lives in resources.toml; safe in git) -------------------
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# IMAGE SELECTION (picks which container image to pull — not server runtime env)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
# IMMUTABLE per-commit tag. Manual + pinned. Bump per deploy. Never the moving `dev`
|
||||||
# on a production booth.
|
# on a PRODUCTION booth (a staging booth may track `dev`).
|
||||||
TAG=dev-830993b
|
TAG=dev-830993b
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# REQUIRED (no safe default — the server refuses to boot / login breaks without)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Login signing secret (>=32 chars, no change-me/insecure/dev-only). openssl rand -hex 32.
|
||||||
|
JWT_SECRET=[[booth_<name>_jwt_secret]]
|
||||||
|
# Ledger-signing HMAC key — the anti-fraud root. DISTINCT per booth; never reuse. If unset
|
||||||
|
# it falls back to JWT_SECRET (warned). openssl rand -hex 32.
|
||||||
|
EVENT_SIGNING_KEY=[[booth_<name>_event_signing_key]]
|
||||||
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, so
|
||||||
|
# without =0 the auth cookie never sends and operators CANNOT log in. Set 1 only behind TLS.
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# COMMONLY SET (have defaults, but you usually want these explicit on a booth)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Turn the ANPR/vision call on. Default "" (off-ish). Set 1 to enable. (Prod compose also
|
||||||
|
# forces VISION_RECOGNIZER=fast_alpr on the vision container.)
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
|
# Extra origins the booth WebSocket (/api/ws) accepts beyond same-origin. Comma-separated,
|
||||||
|
# e.g. http://parksystems.msai.al. Blank = only the same-origin booth URL. Default "".
|
||||||
WS_ALLOWED_ORIGINS=
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# --- secrets (Core secret store, referenced by name in resources.toml) --------
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
# Generated PER BOOTH (openssl rand -hex 32), registered in Core under booth-scoped
|
# SET BY COMPOSE — do NOT put these in the Stack (the compose files own them)
|
||||||
# names, never reused across sites. The user generates these; they are never typed on
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
# a CLI or committed.
|
# VISION_URL=http://127.0.0.1:8089 # prod override (host-net server → loopback vision)
|
||||||
|
# DATABASE_URL=/data/parking.sqlite # the mounted volume (the signed ledger)
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # prod override on the vision container
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# OPTIONAL TUNABLES — all have code defaults; set only to override. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# --- networking / process ---
|
||||||
|
# PORT=3000 # server listen port
|
||||||
|
# HOST=0.0.0.0 # bind interface (127.0.0.1 = loopback only)
|
||||||
|
# BACKEND_HOST_IP= # override the auto-picked IP devices push back to
|
||||||
|
# # (multi-NIC hosts; usually auto-detected fine)
|
||||||
|
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
|
||||||
|
# --- logging ---
|
||||||
|
# LOG_LEVEL=info # debug|info|warn|error
|
||||||
|
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
|
||||||
|
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
|
||||||
|
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
|
||||||
|
# --- device monitor / lane ---
|
||||||
|
# DEVICE_POLL_MS=8000 # device health poll interval
|
||||||
|
# PRINTER_POLL_MS=5000 # printer status poll interval
|
||||||
|
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
|
||||||
|
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
|
||||||
|
# --- ANPR / vision (server side) ---
|
||||||
|
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
|
||||||
|
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # advisory floor (telemetry/lane); below = low_confidence
|
||||||
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # STRICT barrier-driving floor (auto entry/exit). A
|
||||||
|
# # read below this is ignored (falls back to card/QR).
|
||||||
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this = one presentation
|
||||||
|
# ANPR_POLL_MS=1000 # poll-until-confident: re-pull a fresh frame every N ms
|
||||||
|
# ANPR_POLL_WINDOW_MS=8000 # ...for this long AFTER THE LAST vehicle push (sliding:
|
||||||
|
# # a car arriving mid-loop extends it). RAISE if a slow
|
||||||
|
# # barrier means the car waits >8s before reading clean.
|
||||||
|
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
|
||||||
|
# # busy lane can't slide the window forever)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
|
||||||
|
# Mostly compose-set; documented here for completeness. (defaults shown)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# VISION_RECOGNIZER=fast_alpr # stub | fast_alpr (prod compose forces fast_alpr)
|
||||||
|
# VISION_HOST=0.0.0.0 # bind (prod publishes 127.0.0.1 only — see compose)
|
||||||
|
# VISION_PORT=8089
|
||||||
|
# VISION_DETECTOR_MODEL=yolo-v9-t-384-license-plate-end2end
|
||||||
|
# VISION_OCR_MODEL=cct-xs-v2-global-model
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # the Python service's own floor (keep ~in sync w/ server)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECRETS — referenced by name in resources.toml, stored in Core (never inline here)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
|
# JWT_SECRET -> [[booth_<name>_jwt_secret]] (login)
|
||||||
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
# EVENT_SIGNING_KEY -> [[booth_<name>_event_signing_key]] (ledger signing — fraud root)
|
||||||
# Periphery/registry auth also live in Core:
|
# periphery passkey -> [[periphery_passkey_booth_<name>]] (agent onboarding)
|
||||||
# periphery passkey -> [[periphery_passkey_booth_<name>]]
|
# registry account -> [[gitea_registry_account]] (image pull)
|
||||||
# registry account -> [[gitea_registry_account]]
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
|
||||||
|
|
||||||
|
|
||||||
|
class Sink(BaseHTTPRequestHandler):
|
||||||
|
def _log(self, method: str) -> None:
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
src = self.client_address[0]
|
||||||
|
clen = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
|
body = self.rfile.read(clen) if clen else b""
|
||||||
|
ctype = self.headers.get("Content-Type", "-")
|
||||||
|
print(f"\n=== {ts} {method} {self.path} from {src} ===", flush=True)
|
||||||
|
print(f" Content-Type: {ctype} ({clen} bytes)", flush=True)
|
||||||
|
text = body.decode("utf-8", "replace")
|
||||||
|
cut = text.find('Content-Type: image/jpeg')
|
||||||
|
if cut != -1:
|
||||||
|
text = text[:cut] + "\n [...JPEG image part omitted...]"
|
||||||
|
print(" body:\n" + text, flush=True)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", "2")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"OK")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._log("POST")
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._log("GET")
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._log("PUT")
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"camera-event post test listening on 0.0.0.0:{PORT}", flush=True)
|
||||||
|
print("Ctrl-C to stop.\n", flush=True)
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", PORT), Sink).serve_forever()
|
||||||
@@ -62,7 +62,49 @@ then does the gated entry/exit + barrier open. Constructed in `server.ts` (the f
|
|||||||
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
||||||
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
||||||
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` (7) + `hikvision-alarm.test.ts` wiring (3).
|
`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
|
||||||
|
> low-confidence misread (observed live on the exit lane: `'111'`@0.20, `'AE18671'`@0.19 …, while
|
||||||
|
> the manual `test-anpr` on the SAME stopped car read `AA890XX`@1.00). The car then STOPS at the
|
||||||
|
> barrier waiting for it to open — the stationary, well-framed moment the test reads at ~100%. So
|
||||||
|
> the bridge now **pulls a FRESH frame every `ANPR_POLL_MS` (1000) and re-runs ANPR until one clears
|
||||||
|
> the floor, or `ANPR_POLL_WINDOW_MS` (8000) elapses** (car drove off / non-subscriber → give up
|
||||||
|
> cleanly). One loop per camera (`#polling` set) so the ~1Hz alarm re-fires JOIN it, not spawn N;
|
||||||
|
> each tick is a fresh `camera.captureSnapshot` (NOT `captureSnapshotShared`, whose TTL would
|
||||||
|
> re-serve the same bad frame). VERIFIED on hardware: 7 garbage approach frames → `AA890XX`@0.999
|
||||||
|
> at the barrier → signed `vehicle_exit`. This is what made subscriber **auto-exit** actually work
|
||||||
|
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
|
||||||
|
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
|
||||||
|
> opens on a low-confidence read.
|
||||||
|
>
|
||||||
|
> **Two concurrency guards on the loop** (the edges a single push-triggered loop creates):
|
||||||
|
> 1. **Credential-mid-poll abort.** If the subscriber scans their card/QR at the reader DURING the
|
||||||
|
> loop, they've already transacted — the bridge watches their `openOccurrenceCount` (baselined
|
||||||
|
> once a frame reads the bound plate) and **aborts without emitting** if it moves, so it never
|
||||||
|
> double-acts (which would exit the NEXT open occurrence — bad for a fleet sub).
|
||||||
|
> 2. **SLIDING window for a different car arriving mid-poll.** A loop started by a far/early car
|
||||||
|
> must not (a) give up before the REAL car settles, nor (b) swallow the real car's pushes. So a
|
||||||
|
> push that joins a running loop **extends the deadline** (`lastPush + ANPR_POLL_WINDOW_MS`),
|
||||||
|
> capped at `start + ANPR_POLL_MAX_MS` (30s) so a continuously-busy lane can't slide forever.
|
||||||
|
> Because each tick pulls a FRESH frame, the loop naturally tracks whoever is at the barrier
|
||||||
|
> *now*, not the car that started it. (Knobs: `ANPR_POLL_MS`, `ANPR_POLL_WINDOW_MS`,
|
||||||
|
> `ANPR_POLL_MAX_MS`.)
|
||||||
|
|
||||||
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
||||||
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-26
|
updated: 2026-06-27
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# LPR Camera
|
||||||
@@ -74,6 +74,21 @@ fixes — **don't assume it's a momentary blip**:
|
|||||||
status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
|
status); the main encoder was simply never free. A retry loop **cannot** fix this — it just delays
|
||||||
the failure.
|
the failure.
|
||||||
|
|
||||||
|
> **Sharper finding (2026-06-27, same DS-2CD1047G3H-LIU).** Re-probed `10.0.10.13` directly after a
|
||||||
|
> camera reboot with every web/live-view connection closed. **Sub (102): 10/10 rapid back-to-back
|
||||||
|
> pulls → 200** (~50 ms, ~14.7 KB) — flawless even with NO delay, harder than the live ANPR cadence.
|
||||||
|
> **Main (101): 3/3 → 503 in ~20 ms** — an *instant* reject, not a timeout. So main isn't merely
|
||||||
|
> "saturated/busy" on this model — its snapshot endpoint is **structurally unavailable**; **sub (102)
|
||||||
|
> is mandatory**, not just preferable. SEPARATELY, the camera 503s on **any** stream when its
|
||||||
|
> **connection slots are exhausted** — a human holding the web UI / live-view, or parallel
|
||||||
|
> main-stream experiments, consume slots; a **reboot clears stuck slots**. This was the actual cause
|
||||||
|
> of the **2026-06-27 "subscribers auto-enter but don't auto-exit"** scare: manual main-stream
|
||||||
|
> testing held the exit camera's slots → the system's sub-stream snapshot pulls got "Device Busy" →
|
||||||
|
> the ANPR exit read never got a frame → no auto-exit. **NOT a code/flow bug** — the subscription
|
||||||
|
> exit logic, camera→relay binding, and `stream: "2"` config were all correct (auto-exit/entry pairs
|
||||||
|
> were clean before the test storm and after the reboot). (Also recorded as the LLM memory
|
||||||
|
> `g3h-main-stream-snapshot-503`.)
|
||||||
|
|
||||||
**The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
|
**The fix that actually works: snapshot from the SUB stream.** The Hikvision ISAPI channel id is
|
||||||
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream`
|
`<channel><stream>` (e.g. ch1 main = `101`, ch1 **sub = `102`**). The driver now has a **`stream`
|
||||||
config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in
|
config field** (`1` = main, default for back-compat; `2` = sub). Set the G3H camera to **Sub (02)** in
|
||||||
@@ -126,6 +141,58 @@ Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST
|
|||||||
use it directly; this `DS-2CD1043G2`
|
use it directly; this `DS-2CD1043G2`
|
||||||
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
|
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
|
||||||
|
|
||||||
|
### "Subscribers auto-enter but don't auto-exit" — a 4-layer CAMERA fault, NOT our code (2026-06-27)
|
||||||
|
|
||||||
|
A long debugging session on the **DS-2CD1047G3H-LIU** exit camera (`10.0.10.13`, exit-lane). The
|
||||||
|
symptom: subscribers (e.g. Caca) auto-entered via ANPR fine but **never auto-exited**. **Every
|
||||||
|
assumption about *our* code was wrong; all four real causes were camera-side.** Method that finally
|
||||||
|
cracked it: a **dumb HTTP sink** (`scratch-camera-sink.py`) the camera's Alarm Server was pointed
|
||||||
|
at, to see — verbatim — what the camera actually sends, independent of our app's parsing/acceptance.
|
||||||
|
|
||||||
|
The wrong turns, and what was actually true:
|
||||||
|
|
||||||
|
1. **Wrong assumption: "the exit camera 503s, so harden the snapshot retry / reduce load."** The 503
|
||||||
|
storm in the data was mostly **manual main-stream testing**: on this G3H, `channels/101/picture`
|
||||||
|
(MAIN) **503s instantly every time** — structurally unavailable, not "busy" — while `102` (SUB)
|
||||||
|
serves 10/10 rapid pulls cleanly. AND the camera **503s on *any* stream when its connection slots
|
||||||
|
are exhausted** (a held web UI / live-view, parallel experiments); a **reboot clears stuck slots**.
|
||||||
|
So the snapshot retry/flow code was fine. See [[#HTTP 503 "Device Busy"]] + the
|
||||||
|
`g3h-main-stream-snapshot-503` memory. (The exit/subscription FLOW logic, camera→relay binding,
|
||||||
|
and `stream:"2"` config were all correct the whole time — verified: clean entry/exit pairs before
|
||||||
|
the test storm, and after the reboot.)
|
||||||
|
|
||||||
|
2. **The actual blocker #1 — the exit camera never POSTed at all.** `alarmPushEnabled=true` in our
|
||||||
|
config, but `10.0.10.13` had sent **ZERO** alarms ever (entry cam `.12`: 1126). The sink received
|
||||||
|
nothing from `.13`; our `/event` endpoint logged no rejections either → the camera wasn't sending.
|
||||||
|
Cause found in the camera's own **Diagnose Information** dump: **`Main Db is broken` /
|
||||||
|
`db_restore failed` / `Going to reset cfg`** — the camera's internal config DB (`ipc_db`) was
|
||||||
|
**CORRUPT**, plus repeated reboots. A broken config DB means the event→linkage→push pipeline can't
|
||||||
|
reliably read its own config, so it silently never POSTs. **Fix: factory-reset the camera** (rebuilds
|
||||||
|
`ipc_db`), then reconfigure. (If corruption returns after a clean reset → failing flash → RMA.)
|
||||||
|
|
||||||
|
3. **Wrong assumption: "missing gateway/DNS blocks the push."** A documented Hikvision note says a
|
||||||
|
gateway is needed even same-subnet — but here it was **inverted**: the WORKING cam `.12` has NO
|
||||||
|
gateway/DNS; the broken `.13` HAD both. Red herring. Gateway/DNS was not the cause.
|
||||||
|
|
||||||
|
4. **The actual blocker #2 — after reset, the camera pushed PLAIN MOTION, not vehicle.** Post-reset
|
||||||
|
`.13` POSTed `<eventType>VMD</eventType>` with **no target tag**. The backend gates on
|
||||||
|
`target == "vehicle"` (`hikvision-alarm.ts` `isVehicleActive`, matches `<targetType>` /
|
||||||
|
`<detectionTarget>` / `<objectType>`), so a plain-motion push is **ignored** → bridge never fires.
|
||||||
|
The working `.12` sends `eventType=VMD` **with `target=vehicle`**. The difference is the AcuSense
|
||||||
|
**Detection-Target = Vehicle** filter ON the Motion event — defaulted OFF after factory reset.
|
||||||
|
**Fix: enable Vehicle target classification** on `.13`'s Motion Detection. Confirmed live: a real
|
||||||
|
drive-through then POSTed `<eventType>VMD</eventType> … <targetType>vehicle</targetType>` — exactly
|
||||||
|
what the backend needs. (So "VMD/Motion" IS the right event for this camera class; it's the *target
|
||||||
|
filter* that matters, not switching to a different event type.)
|
||||||
|
|
||||||
|
**Takeaways:** (a) a camera that's silently not-pushing looks identical to "fine" in our logs — the
|
||||||
|
sink-to-prove-it-sends method is the fastest disambiguator; flagged as an observability gap (a
|
||||||
|
`alarmPushEnabled=true` camera with 0 pushes ever should be a surfaced condition, like the
|
||||||
|
[[device-status-monitoring|reader-liveness]] fix). (b) For ANPR the camera must send a **vehicle
|
||||||
|
`targetType`** — verify the push body, not just the UI toggles. (c) Hikvision config-DB corruption
|
||||||
|
is real; factory reset is the cure. None of this was a code bug. See
|
||||||
|
[[lane-presence-and-anpr-entry]] for the push→bridge→exit path.
|
||||||
|
|
||||||
### Gotchas learned the hard way (2026-06-22 field session)
|
### Gotchas learned the hard way (2026-06-22 field session)
|
||||||
|
|
||||||
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
|
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
|
||||||
|
|||||||
+36
@@ -1717,3 +1717,39 @@ Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `c
|
|||||||
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
|
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
|
||||||
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
|
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
|
||||||
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
|
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
|
||||||
|
|
||||||
|
## [2026-06-27] query | "Subscribers auto-enter but don't auto-exit" → NOT a bug; G3H main-stream snapshot is structurally dead
|
||||||
|
|
||||||
|
Investigated via a read-only VACUUM copy of the dev DB. Caca subscriber's ledger: clean
|
||||||
|
entry/exit pairs until ~16:38, then 6 entries + 0 exits. Traced to the **exit camera 10.0.10.13
|
||||||
|
(DS-2CD1047G3H-LIU)** producing only 2 reads ever (vs 60 on the entry cam) — every exit-direction
|
||||||
|
snapshot after 16:38 was **HTTP 503 "device busy"**, so the ANPR exit read never got a frame →
|
||||||
|
no `emitRead` → no auto-exit. The subscription-flow exit logic, camera→relay binding (relay 2 =
|
||||||
|
exit, anpr on), and `stream: "2"` config were all **correct**. Direct hardware re-probe after a
|
||||||
|
camera reboot + closing web connections: **sub (102) 10/10 rapid → 200 (~50 ms)**, **main (101)
|
||||||
|
3/3 → 503 in ~20 ms (instant reject)**. So main-stream snapshots are **structurally unavailable**
|
||||||
|
on this model (sub mandatory), and the 503 storm was **connection-slot exhaustion from manual
|
||||||
|
main-stream testing** holding the camera's slots (reboot clears). Recorded in the
|
||||||
|
the `g3h-main-stream-snapshot-503` LLM memory + a 2026-06-27 sharper-finding note in [[lpr-camera]]
|
||||||
|
("503 Device Busy"). No code changed — diagnosis only.
|
||||||
|
|
||||||
|
## [2026-06-27] query | "Auto-exit" RESOLVED — a 4-layer CAMERA fault on the G3H, never our code
|
||||||
|
|
||||||
|
Continuation of the above. Drove the full diagnosis to ground using a **dumb HTTP sink**
|
||||||
|
(`scratch-camera-sink.py`) the exit camera's Alarm Server was pointed at, to capture the verbatim
|
||||||
|
push. Every assumption about *our* code was wrong; all real causes were camera-side on the
|
||||||
|
**DS-2CD1047G3H-LIU** (`10.0.10.13`):
|
||||||
|
(1) 503s were mostly **manual main-stream testing** (main 101 = instant 503 structurally; sub 102 =
|
||||||
|
perfect) + connection-slot exhaustion (reboot clears) — NOT a retry/flow bug.
|
||||||
|
(2) **Blocker #1:** the camera never POSTed at all (0 alarms ever vs 1126 on the entry cam); its
|
||||||
|
**Diagnose dump showed a CORRUPT config DB** (`Main Db is broken`/`db_restore failed`/`reset cfg`) —
|
||||||
|
factory-reset fixed it.
|
||||||
|
(3) Gateway/DNS was a **red herring** (working cam had none; broken cam had both).
|
||||||
|
(4) **Blocker #2:** post-reset it pushed **plain `VMD` with no target** → backend gates on
|
||||||
|
`target=="vehicle"` (`hikvision-alarm.ts` reads `<targetType>`/`<detectionTarget>`/`<objectType>`)
|
||||||
|
→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry
|
||||||
|
`<targetType>vehicle</targetType>` (confirmed live on a drive-through). "VMD/Motion" is the right
|
||||||
|
event for this class — it's the *target filter* that matters.
|
||||||
|
Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be
|
||||||
|
a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a
|
||||||
|
new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.
|
||||||
|
|||||||
Reference in New Issue
Block a user