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
281 lines
14 KiB
TypeScript
281 lines
14 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
|
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
|
|
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
|
|
// time the chosen target is detected. This is the same machine-call pattern as the
|
|
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
|
|
// cookie/CSRF.
|
|
//
|
|
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
|
|
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
|
|
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
|
|
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
|
|
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
|
|
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
|
|
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
|
|
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
|
|
//
|
|
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
|
|
|
|
interface HikDeviceConfig {
|
|
host?: string;
|
|
alarmPushEnabled?: boolean | string | number;
|
|
pushUser?: string;
|
|
pushPassword?: string;
|
|
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
|
|
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
|
|
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
|
|
* real IP never survives and a strict check rejects every push. When pushUser/
|
|
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
|
|
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
|
|
skipSourceIpCheck?: boolean | string | number;
|
|
}
|
|
|
|
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
|
|
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
|
|
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
|
|
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
|
|
function isOn(v: unknown): boolean {
|
|
if (v === true) return true;
|
|
if (typeof v === "number") return v === 1;
|
|
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
|
|
return false;
|
|
}
|
|
|
|
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
|
|
* event detail + the log line. Absent fields just mean "not found in this firmware's
|
|
* payload" — the raw body is always stored so nothing is lost. */
|
|
interface AlarmSummary {
|
|
eventType?: string;
|
|
/** `active` (target entered the region) | `inactive` (target left). The edge that
|
|
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
|
|
eventState?: string;
|
|
target?: string;
|
|
plate?: string;
|
|
dateTime?: string;
|
|
channelId?: string;
|
|
}
|
|
|
|
function clientIp(req: FastifyRequest): string {
|
|
return req.ip.replace(/^::ffff:/, "");
|
|
}
|
|
|
|
/** First capture group of `re` in `s`, trimmed, or undefined. */
|
|
function pick(s: string, re: RegExp): string | undefined {
|
|
const m = re.exec(s);
|
|
return m?.[1]?.trim() || undefined;
|
|
}
|
|
|
|
/**
|
|
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
|
|
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
|
|
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
|
|
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
|
|
* scanned for the same keys.
|
|
*/
|
|
function summarize(body: string): AlarmSummary {
|
|
return {
|
|
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
|
|
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
|
|
target:
|
|
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
|
|
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
|
|
plate:
|
|
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
|
|
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
|
|
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
|
|
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
|
|
};
|
|
}
|
|
|
|
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
|
|
// wildcard parser; a 10 MB cap covers an event + an attached frame.
|
|
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
|
|
done(null, body);
|
|
});
|
|
|
|
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
|
|
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
|
|
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
|
|
function record(args: {
|
|
deviceId: string;
|
|
method: string;
|
|
accepted: boolean;
|
|
reason?: string;
|
|
ip: string;
|
|
contentType: string;
|
|
raw: Buffer;
|
|
summary: AlarmSummary;
|
|
}): void {
|
|
try {
|
|
db.insert(deviceEventsTable)
|
|
.values({
|
|
id: randomUUID(),
|
|
deviceId: args.deviceId,
|
|
category: "camera",
|
|
kind: args.accepted ? "alarm" : "alarm-rejected",
|
|
detail: {
|
|
source: "hikvision-alarm-server",
|
|
accepted: args.accepted,
|
|
method: args.method,
|
|
...(args.reason ? { reason: args.reason } : {}),
|
|
ip: args.ip,
|
|
contentType: args.contentType,
|
|
bytes: args.raw.length,
|
|
...args.summary,
|
|
// Readable head verbatim (the XML part); truncated to keep the row small.
|
|
rawHead: args.raw.toString("utf8").slice(0, 8000),
|
|
},
|
|
occurredAt: new Date().toISOString(),
|
|
})
|
|
.run();
|
|
} catch (err) {
|
|
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
|
|
const { deviceId } = req.params;
|
|
const method = req.method;
|
|
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
|
const cfg = row?.config as HikDeviceConfig | undefined;
|
|
const ip = clientIp(req);
|
|
const contentType = String(req.headers["content-type"] ?? "");
|
|
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
|
|
const summary = summarize(raw.toString("utf8"));
|
|
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
|
|
// that gets rejected is visible in the dev log the instant it arrives.
|
|
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
|
|
|
|
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
|
|
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
|
|
// On rejection we STILL record it (with the precise reason) so a push that reached us
|
|
// never silently disappears — that's what makes "is it coming?" answerable.
|
|
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
|
|
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
|
|
// to the host's own address), so a strict match can never pass. Digest auth (when set)
|
|
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
|
|
const skipIp = isOn(cfg?.skipSourceIpCheck);
|
|
let reason: string | null = null;
|
|
if (!row || !cfg) reason = "unknown device id";
|
|
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
|
|
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
|
|
else if (!cfg.host) reason = "device has no host IP configured";
|
|
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
|
|
|
|
if (reason) {
|
|
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
|
|
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
|
|
return reply.code(404).send({ error: "not found", reason });
|
|
}
|
|
|
|
// Optional Digest auth — only when the admin configured push creds (some firmware
|
|
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
|
|
if (cfg!.pushUser && cfg!.pushPassword) {
|
|
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
|
|
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
|
|
return; // 401 challenge already sent
|
|
}
|
|
}
|
|
|
|
// Loud log so the operator can SEE the payload during testing.
|
|
app.log.info(
|
|
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
|
|
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
|
|
);
|
|
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
|
|
|
|
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
|
|
// 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.
|
|
const isVehicleActive =
|
|
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
|
(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.
|
|
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
|
|
|
|
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
|
|
return reply.code(200).send({ ok: true });
|
|
};
|
|
|
|
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
|
|
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
|
|
// Fastify's generic 404, which the camera reads as "service available" while our
|
|
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
|
|
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
|
|
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
|
|
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
|
|
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
|
|
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
|
|
}
|
|
|
|
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
|
|
// can SEE in the browser whether events are arriving and why any were refused, instead
|
|
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
|
|
app.get<{ Querystring: { limit?: string } }>(
|
|
"/api/devices/hikvision/alarms",
|
|
{ preHandler: requirePermission("device:read") },
|
|
async (req) => {
|
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
|
const rows = db
|
|
.select()
|
|
.from(deviceEventsTable)
|
|
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
|
|
.orderBy(desc(deviceEventsTable.occurredAt))
|
|
.limit(limit)
|
|
.all();
|
|
const alarms = rows.map((r) => {
|
|
const d = (r.detail ?? {}) as Record<string, unknown>;
|
|
return {
|
|
at: r.occurredAt,
|
|
deviceId: r.deviceId,
|
|
accepted: d.accepted === true,
|
|
method: (d.method as string) ?? null,
|
|
reason: (d.reason as string) ?? null,
|
|
ip: (d.ip as string) ?? null,
|
|
contentType: (d.contentType as string) ?? null,
|
|
bytes: (d.bytes as number) ?? 0,
|
|
eventType: (d.eventType as string) ?? null,
|
|
eventState: (d.eventState as string) ?? null,
|
|
target: (d.target as string) ?? null,
|
|
plate: (d.plate as string) ?? null,
|
|
rawHead: (d.rawHead as string) ?? null,
|
|
};
|
|
});
|
|
return { count: alarms.length, alarms };
|
|
},
|
|
);
|
|
}
|