65328b8c11
CI / check (push) Failing after 15s
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
192 lines
7.9 KiB
TypeScript
192 lines
7.9 KiB
TypeScript
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);
|
|
});
|
|
});
|