Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -56,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
|||||||
# VISION_ENABLED=1 # master switch — nothing runs without it
|
# VISION_ENABLED=1 # master switch — nothing runs without it
|
||||||
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
|
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
|
||||||
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
|
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
|
||||||
# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service
|
# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
|
||||||
|
#
|
||||||
|
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
|
||||||
|
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
|
||||||
|
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
||||||
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
||||||
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
import type { VisionClient, VisionResult } from "./vision-client.js";
|
||||||
|
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
|
||||||
|
|
||||||
|
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
|
||||||
|
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
|
||||||
|
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
|
||||||
|
// own logic only. See anpr-entry.ts.
|
||||||
|
|
||||||
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||||
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
vi.mock("./snapshot.js", () => ({
|
||||||
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER the mock is registered.
|
||||||
|
const { AnprBridge } = await import("./anpr-entry.js");
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
captureSnapshot.mockClear();
|
||||||
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||||
|
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||||
|
const controllerId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: controllerId,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const camId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: camId,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
return camId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
|
||||||
|
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
|
||||||
|
const enabled = opts.enabled ?? true;
|
||||||
|
const result: VisionResult | null =
|
||||||
|
opts.plate == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
analyze: vi.fn(async () => (enabled ? result : null)),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
|
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
||||||
|
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
|
|
||||||
|
/** Capture read events emitted during `fn` (async). */
|
||||||
|
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
|
||||||
|
const got: DeviceReadEvent[] = [];
|
||||||
|
const off = deviceEvents.onRead((e) => got.push(e));
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AnprBridge", () => {
|
||||||
|
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
|
||||||
|
const cam = seedCamera({ anpr: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB" });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(vision.analyze).not.toHaveBeenCalled();
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
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({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a plate below the entry confidence floor", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
|
||||||
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||||
|
expect(skips).toHaveLength(1);
|
||||||
|
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
await bridge.onVehicleDetected(cam);
|
||||||
|
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
await disabled.onVehicleDetected(cam);
|
||||||
|
await noPlate.onVehicleDetected(cam);
|
||||||
|
});
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on an unknown device id", async () => {
|
||||||
|
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(vision.analyze).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { buildCamera } from "./snapshot.js";
|
||||||
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
|
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
|
||||||
|
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
|
||||||
|
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
|
||||||
|
//
|
||||||
|
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
|
||||||
|
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
|
||||||
|
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
|
||||||
|
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
|
||||||
|
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
|
||||||
|
//
|
||||||
|
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
|
||||||
|
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
|
||||||
|
// barrier open stay inside the existing flow. A spoofed printed plate is just another
|
||||||
|
// credential through the same gate.
|
||||||
|
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
|
||||||
|
// transient plate-as-ticket exit flow.
|
||||||
|
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
|
||||||
|
// never throws into the push handler, never awaited on the camera's 200 response.
|
||||||
|
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
|
||||||
|
|
||||||
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
|
interface CameraConfig {
|
||||||
|
readonly anpr?: boolean;
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
|
||||||
|
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
|
||||||
|
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
|
||||||
|
function entryMinConfidence(): number {
|
||||||
|
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
|
||||||
|
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
|
||||||
|
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
|
||||||
|
function debounceMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnprBridge {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
readonly #subscription: SubscriptionFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #entryMinConfidence: number;
|
||||||
|
readonly #debounceMs: number;
|
||||||
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#vision = vision;
|
||||||
|
this.#subscription = subscription;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
|
this.#debounceMs = debounceMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
|
||||||
|
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
|
||||||
|
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
|
||||||
|
*/
|
||||||
|
async onVehicleDetected(deviceId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.#vision?.enabled) return; // no recognizer configured
|
||||||
|
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
|
||||||
|
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
|
||||||
|
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
|
||||||
|
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
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
|
||||||
|
|
||||||
|
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
||||||
|
// snapshot + analyze every second.
|
||||||
|
if (this.#debounced(deviceId)) return;
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
|
const camera = buildCamera(row);
|
||||||
|
if (!camera) {
|
||||||
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "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).
|
||||||
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
|
const shot = await camera.captureSnapshot({ 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
|
||||||
|
// object with its confidence even when its own lowConfidence flag is set).
|
||||||
|
if (result.plate.confidence < this.#entryMinConfidence) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
||||||
|
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plate = result.plate.text.trim().toUpperCase();
|
||||||
|
if (!plate) return;
|
||||||
|
|
||||||
|
const e: DeviceReadEvent = {
|
||||||
|
driverId: row.driverId,
|
||||||
|
deviceId,
|
||||||
|
value: plate,
|
||||||
|
kind: "plate",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
|
||||||
|
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
|
||||||
|
const match = this.#subscription.match(e);
|
||||||
|
if (!match) {
|
||||||
|
this.#recordSkip(deviceId, plate, result.plate.confidence);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
|
// camera-level gate re-emitting the SAME plate.
|
||||||
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
|
if (this.#debounced(plateKey)) return;
|
||||||
|
this.#stamp(plateKey);
|
||||||
|
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
);
|
||||||
|
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
|
||||||
|
} catch (err) {
|
||||||
|
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
|
||||||
|
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#debounced(key: string): boolean {
|
||||||
|
const last = this.#lastFire.get(key);
|
||||||
|
return last != null && Date.now() - last < this.#debounceMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stamp(key: string): void {
|
||||||
|
this.#lastFire.set(key, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
|
||||||
|
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
|
||||||
|
#recordSkip(deviceId: string, plate: string, confidence: number): void {
|
||||||
|
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
category: "camera",
|
||||||
|
kind: "anpr-skip",
|
||||||
|
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRow is re-exported for the test's seed typing convenience.
|
||||||
|
export type { DeviceRow };
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { buildServer } from "../server.js";
|
import { buildServer } from "../server.js";
|
||||||
|
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
|
||||||
|
import type { AnprBridge } from "../anpr-entry.js";
|
||||||
import { seedUser, login } from "../test-helpers.js";
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
|
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
|
||||||
@@ -222,3 +225,65 @@ describe("Hikvision Alarm Server push", () => {
|
|||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
|
||||||
|
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
|
||||||
|
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
|
||||||
|
// anpr-entry.test.ts.)
|
||||||
|
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
|
||||||
|
let rawApp: RawFastify;
|
||||||
|
let rawDb: Db;
|
||||||
|
let rawClose: () => void;
|
||||||
|
let onVehicleDetected: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
rawDb = t.db;
|
||||||
|
rawClose = t.close;
|
||||||
|
onVehicleDetected = vi.fn(async () => {});
|
||||||
|
const bridge = { onVehicleDetected } as unknown as AnprBridge;
|
||||||
|
rawApp = Fastify();
|
||||||
|
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
|
||||||
|
await rawApp.ready();
|
||||||
|
rawDb.insert(devices).values({
|
||||||
|
id: CAM_ID,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: CAM_IP, alarmPushEnabled: true },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rawApp.close();
|
||||||
|
rawClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function post(payload: string) {
|
||||||
|
return rawApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hands a vehicle (active) detection to the bridge", async () => {
|
||||||
|
const res = await post(VEHICLE_XML);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call the bridge for a human target", async () => {
|
||||||
|
const human = VEHICLE_XML.replace("vehicle", "human");
|
||||||
|
await post(human);
|
||||||
|
expect(onVehicleDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
|
||||||
|
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
|
||||||
|
await post(leave);
|
||||||
|
expect(onVehicleDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { deviceEvents } from "../device-events.js";
|
|||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { verifyDigest } from "../digest-auth.js";
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
import type { LaneStatus } from "../lane-status.js";
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { AnprBridge } from "../anpr-entry.js";
|
||||||
|
|
||||||
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
|
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
|
||||||
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
|
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
|
||||||
@@ -95,7 +96,12 @@ function summarize(body: string): AlarmSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db, laneStatus?: LaneStatus): Promise<void> {
|
export async function hikvisionAlarmRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
laneStatus?: LaneStatus,
|
||||||
|
anprBridge?: AnprBridge,
|
||||||
|
): Promise<void> {
|
||||||
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
|
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
|
||||||
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
|
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
|
||||||
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
|
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
|
||||||
@@ -199,14 +205,22 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db, laneSta
|
|||||||
// for the booth barrier lights). Only on a vehicle target that's `active` — an
|
// for the booth barrier lights). Only on a vehicle target that's `active` — an
|
||||||
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
|
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
|
||||||
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
|
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
|
||||||
if (
|
const isVehicleActive =
|
||||||
laneStatus &&
|
|
||||||
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
||||||
(summary.eventState ?? "active").toLowerCase() !== "inactive"
|
(summary.eventState ?? "active").toLowerCase() !== "inactive";
|
||||||
) {
|
if (laneStatus && isVehicleActive) {
|
||||||
laneStatus.vehicleDetected(deviceId);
|
laneStatus.vehicleDetected(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
|
||||||
|
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
|
||||||
|
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
|
||||||
|
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
|
||||||
|
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
|
||||||
|
if (anprBridge && isVehicleActive) {
|
||||||
|
void anprBridge.onVehicleDetected(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
|
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
|
||||||
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
|
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
|
||||||
// entry/exit) is the deliberate next step once we know the real payload.
|
// entry/exit) is the deliberate next step once we know the real payload.
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
||||||
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||||
reserveSubscriberSpots?: boolean;
|
reserveSubscriberSpots?: boolean;
|
||||||
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
||||||
|
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
||||||
|
anprEntryEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
@@ -41,6 +44,7 @@ type SiteConfig = {
|
|||||||
exitVoucherDefault: boolean;
|
exitVoucherDefault: boolean;
|
||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
|
anprEntryEnabled: boolean;
|
||||||
} & Record<TextField, string | null>;
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
@@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||||
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
} as SiteConfig;
|
} as SiteConfig;
|
||||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
return out;
|
return out;
|
||||||
@@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
||||||
}
|
}
|
||||||
|
if ("anprEntryEnabled" in body) {
|
||||||
|
if (typeof body.anprEntryEnabled !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.anprEntryEnabled = body.anprEntryEnabled;
|
||||||
|
}
|
||||||
for (const f of TEXT_FIELDS) {
|
for (const f of TEXT_FIELDS) {
|
||||||
if (f in body) patch[f] = normText(body[f]);
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { roleRoutes } from "./routes/roles.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
import { LaneStatus } from "./lane-status.js";
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
import { recycleBinRoutes } from "./routes/recycle-bin.js";
|
import { recycleBinRoutes } from "./routes/recycle-bin.js";
|
||||||
@@ -121,11 +122,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const laneStatus = new LaneStatus(db, app.log);
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
app.addHook("onClose", async () => laneStatus.stop());
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
// payload as a `kind:"alarm"` device_event AND drives lane busy/free for vehicles.
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
// See routes/hikvision-alarm.ts.
|
|
||||||
await hikvisionAlarmRoutes(app, db, laneStatus);
|
|
||||||
|
|
||||||
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
||||||
// pushes changes to the booth UI. setupRoutes() has already registered the
|
// pushes changes to the booth UI. setupRoutes() has already registered the
|
||||||
@@ -199,6 +198,19 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeRead());
|
app.addHook("onClose", async () => unsubscribeRead());
|
||||||
|
|
||||||
|
// ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection,
|
||||||
|
// admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a
|
||||||
|
// plate read onto the bus, which the dispatcher above turns into a gated entry/exit).
|
||||||
|
// Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs
|
||||||
|
// the subscriptionFlow constructed just above. See anpr-entry.ts.
|
||||||
|
const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log);
|
||||||
|
|
||||||
|
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
|
||||||
|
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
|
||||||
|
// payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle
|
||||||
|
// detection to the ANPR bridge above. See routes/hikvision-alarm.ts.
|
||||||
|
await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge);
|
||||||
|
|
||||||
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
|
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
|
||||||
// CHOSEN reader to populate a subscription credential, without blocking the other
|
// CHOSEN reader to populate a subscription credential, without blocking the other
|
||||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
|
|||||||
@@ -141,8 +141,9 @@ async function recognizePlate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a live camera adapter from a resolved devices row, or null. */
|
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
||||||
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
||||||
|
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) return null;
|
if (!driver) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||||
const [reserveSubs, setReserveSubs] = useState(false);
|
const [reserveSubs, setReserveSubs] = useState(false);
|
||||||
|
const [anprEntry, setAnprEntry] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -38,6 +39,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
setExitVoucherDefault(c.exitVoucherDefault);
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
setReserveSubs(c.reserveSubscriberSpots);
|
setReserveSubs(c.reserveSubscriberSpots);
|
||||||
|
setAnprEntry(c.anprEntryEnabled);
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -52,6 +54,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
capacity: raw === "" ? null : Math.round(Number(raw)),
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||||
exitVoucherDefault,
|
exitVoucherDefault,
|
||||||
reserveSubscriberSpots: reserveSubs,
|
reserveSubscriberSpots: reserveSubs,
|
||||||
|
anprEntryEnabled: anprEntry,
|
||||||
};
|
};
|
||||||
// Send each metadata field; "" → null is applied server-side.
|
// Send each metadata field; "" → null is applied server-side.
|
||||||
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||||
@@ -112,6 +115,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 accent-term-amber"
|
||||||
|
checked={anprEntry}
|
||||||
|
onChange={(e) => setAnprEntry(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("site.anprEntry")}
|
||||||
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -943,6 +943,8 @@ export interface SiteConfig {
|
|||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
|
||||||
|
anprEntryEnabled: boolean;
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
|
|||||||
@@ -532,6 +532,8 @@ export const en: Catalog = {
|
|||||||
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||||
reserveSubs: "Reserve subscriber spots",
|
reserveSubs: "Reserve subscriber spots",
|
||||||
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
|
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
|
||||||
|
anprEntry: "Auto-open for subscriber plates (ANPR)",
|
||||||
|
anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.",
|
||||||
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
|
|||||||
@@ -543,6 +543,8 @@ export const sq = {
|
|||||||
printExitHint: "(klienti skanon biletën në dalje)",
|
printExitHint: "(klienti skanon biletën në dalje)",
|
||||||
reserveSubs: "Rezervo vendet e abonentëve",
|
reserveSubs: "Rezervo vendet e abonentëve",
|
||||||
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
|
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
|
||||||
|
anprEntry: "Hapje automatike për targat e abonentëve (ANPR)",
|
||||||
|
anprEntryHint: "Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.",
|
||||||
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Site master switch for the ANPR subscriber-entry bridge (anpr-entry.ts). Additive
|
||||||
|
-- ALTER ADD COLUMN — backward-compatible. Default 1 (ON) so existing installs keep the
|
||||||
|
-- now-live auto-open-for-subscriber-plates behaviour after upgrade. The toggle gates ONLY
|
||||||
|
-- the barrier-driving bridge; advisory snapshot-ANPR + lane busy/free are unaffected.
|
||||||
|
ALTER TABLE `site_config` ADD `anpr_entry_enabled` integer DEFAULT 1 NOT NULL;
|
||||||
@@ -92,6 +92,13 @@
|
|||||||
"when": 1781885500000,
|
"when": 1781885500000,
|
||||||
"tag": "0012_soft_delete",
|
"tag": "0012_soft_delete",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885600000,
|
||||||
|
"tag": "0013_anpr_entry_toggle",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -236,6 +236,16 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
|
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
|
||||||
|
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
|
||||||
|
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
|
||||||
|
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
|
||||||
|
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
|
||||||
|
* so toggling takes effect with no restart. Default ON because the feature is already
|
||||||
|
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
|
||||||
|
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
||||||
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
||||||
* each published tariff version's structure.tz so the windows are frozen/immutable
|
* each published tariff version's structure.tz so the windows are frozen/immutable
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ Two related things a camera's vehicle detection feeds, worked out over a long fi
|
|||||||
detection area" conclusion):
|
detection area" conclusion):
|
||||||
|
|
||||||
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
|
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
|
||||||
2. **ANPR subscriber entry** — PLANNED. A subscriber's plate, read from the lane camera, drives
|
2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera,
|
||||||
their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
|
drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
|
||||||
|
|
||||||
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
|
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
|
||||||
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
|
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
|
||||||
@@ -46,11 +46,24 @@ nothing** (never blocks a ticket or opens a barrier; the standing rule).
|
|||||||
also clears promptly after departure.
|
also clears promptly after departure.
|
||||||
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
|
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
|
||||||
|
|
||||||
## 2. ANPR subscriber entry — THE BRIDGE (PLANNED, not built)
|
## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22)
|
||||||
|
|
||||||
> **"Bridge" = a HANDLER FUNCTION in `apps/server` (≈40 lines, e.g. `anpr-entry.ts`). NOT a new
|
> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new
|
||||||
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
|
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
|
||||||
|
|
||||||
|
**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in
|
||||||
|
camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the
|
||||||
|
camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via
|
||||||
|
the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor
|
||||||
|
(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE
|
||||||
|
emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory
|
||||||
|
`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()`
|
||||||
|
then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered
|
||||||
|
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.
|
||||||
|
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).
|
||||||
|
|
||||||
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
|
||||||
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
|
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
|
||||||
@@ -64,7 +77,7 @@ Almost everything already exists; the bridge is the one missing wire:
|
|||||||
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
|
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
|
||||||
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
|
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
|
||||||
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
|
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
|
||||||
| **Emit the plate onto the read bus** | ❌ **the bridge** — today the snapshot ANPR only RECORDS the plate as advisory telemetry; it does NOT `emitRead`. hik-alarm.ts literally says "NOT a DeviceReadEvent yet". |
|
| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) |
|
||||||
|
|
||||||
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
|
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
|
||||||
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
|
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST
|
|||||||
or open anything. A plate read is **advisory, never the sole reason** a barrier opens
|
or open anything. A plate read is **advisory, never the sole reason** a barrier opens
|
||||||
([[append-only-event-chain]], [[opencv-anpr-service]]). Two consumers were since designed off this
|
([[append-only-event-chain]], [[opencv-anpr-service]]). Two consumers were since designed off this
|
||||||
same vehicle event — see **[[lane-presence-and-anpr-entry]]**: (a) BUILT — advisory lane busy/free
|
same vehicle event — see **[[lane-presence-and-anpr-entry]]**: (a) BUILT — advisory lane busy/free
|
||||||
booth lights; (b) PLANNED — the ANPR "bridge" that snapshots → ANPR → emits a `kind:"plate"` read
|
booth lights; (b) BUILT (2026-06-22) — the ANPR "bridge" (`anpr-entry.ts`) that snapshots → ANPR →
|
||||||
for a SUBSCRIBER match through the existing gated flow (a small `apps/server` handler, not a
|
emits a `kind:"plate"` read for a SUBSCRIBER match through the existing gated flow (a small
|
||||||
service). If the camera ever emits its own `<plateNumber>` we'd use it directly; this `DS-2CD1043G2`
|
`apps/server` handler class, not a service). If the camera ever emits its own `<plateNumber>` we'd
|
||||||
|
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]].
|
||||||
|
|
||||||
### Gotchas learned the hard way (2026-06-22 field session)
|
### Gotchas learned the hard way (2026-06-22 field session)
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
|
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
|
||||||
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
|
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
|
||||||
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
|
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
|
||||||
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (PLANNED) the ANPR "bridge": a subscriber's plate read at the lane admits them via the existing gated subscription flow. Measured camera limits; rejected the queue-tracking/livestream ideas.
|
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas.
|
||||||
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
||||||
|
|
||||||
## Concepts — frontend / operator UI
|
## Concepts — frontend / operator UI
|
||||||
|
|||||||
+15
@@ -1431,3 +1431,18 @@ tracking/make-model (needs a vehicle detector the plate-only vision lacks + appl
|
|||||||
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
|
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
|
||||||
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
|
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
|
||||||
[[subscription]], index.
|
[[subscription]], index.
|
||||||
|
|
||||||
|
## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT
|
||||||
|
Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a
|
||||||
|
vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId
|
||||||
|
to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces
|
||||||
|
(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs
|
||||||
|
`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key
|
||||||
|
safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a
|
||||||
|
subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a
|
||||||
|
non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never
|
||||||
|
reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the
|
||||||
|
read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`,
|
||||||
|
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
||||||
|
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
||||||
|
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
||||||
|
|||||||
Reference in New Issue
Block a user