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:
@@ -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 { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
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";
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 { verifyDigest } from "../digest-auth.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 →
|
||||
// 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,
|
||||
// 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
|
||||
@@ -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
|
||||
// `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.
|
||||
if (
|
||||
laneStatus &&
|
||||
const isVehicleActive =
|
||||
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
||||
(summary.eventState ?? "active").toLowerCase() !== "inactive"
|
||||
) {
|
||||
(summary.eventState ?? "active").toLowerCase() !== "inactive";
|
||||
if (laneStatus && isVehicleActive) {
|
||||
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
|
||||
// "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.
|
||||
|
||||
@@ -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
|
||||
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||
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
|
||||
@@ -41,6 +44,7 @@ type SiteConfig = {
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
reserveSubscriberSpots: boolean;
|
||||
anprEntryEnabled: boolean;
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
@@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
@@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
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) {
|
||||
if (f in body) patch[f] = normText(body[f]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user