feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with "Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm Settings -> Alarm Server makes the camera HTTP-POST an EventNotificationAlert on each detection. - New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts): same machine-push pattern as the Dingtian Input Link — source-IP guarded + optional HTTP Digest, not behind the SPA cookie/CSRF. - Discovery-first / permissive: a wildcard content-type parser accepts ANY body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by firmware), records it verbatim as a kind:"alarm" device_event, and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + a loud log line. The point is to SEE exactly what a camera sends before wiring it further. - hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (setup offers the backend push IP). - NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read is advisory, never the sole reason a barrier opens; the read-bus/ANPR wiring is a deliberate next step once the real payload is known. Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109; build+lint 14/14. Wiki: lpr-camera.md + log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { and, eq, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
|
||||
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
|
||||
// detection POST from the camera's configured IP is accepted, summarized (eventType /
|
||||
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
|
||||
// device_event — while a wrong source IP or a push-disabled device is refused.
|
||||
|
||||
const CAM_IP = "10.0.10.121";
|
||||
const CAM_ID = "cam-1";
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
function seedHikCamera(cfg: Record<string, unknown> = {}) {
|
||||
db.insert(devices).values({
|
||||
id: CAM_ID,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
|
||||
enabled: true,
|
||||
}).run();
|
||||
}
|
||||
|
||||
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
|
||||
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
|
||||
* best-effort summary extraction over a plausible shape. */
|
||||
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
|
||||
<ipAddress>10.0.10.121</ipAddress>
|
||||
<channelID>1</channelID>
|
||||
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
|
||||
<eventType>fielddetection</eventType>
|
||||
<eventState>active</eventState>
|
||||
<DetectionRegionList>
|
||||
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
|
||||
</DetectionRegionList>
|
||||
</EventNotificationAlert>`;
|
||||
|
||||
function alarmEvents(): { detail: Record<string, unknown> }[] {
|
||||
return db
|
||||
.select()
|
||||
.from(deviceEventsTable)
|
||||
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
|
||||
.all() as { detail: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
describe("Hikvision Alarm Server push", () => {
|
||||
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
|
||||
seedHikCamera();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const events = alarmEvents();
|
||||
expect(events).toHaveLength(1);
|
||||
const d = events[0]!.detail;
|
||||
expect(d.source).toBe("hikvision-alarm-server");
|
||||
expect(d.eventType).toBe("fielddetection");
|
||||
expect(d.target).toBe("vehicle");
|
||||
expect(d.ip).toBe(CAM_IP);
|
||||
// The raw body is kept verbatim for inspection.
|
||||
expect(String(d.rawHead)).toContain("EventNotificationAlert");
|
||||
});
|
||||
|
||||
it("pulls a plate out of an ANPR-style payload when present", async () => {
|
||||
seedHikCamera();
|
||||
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
|
||||
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: anpr,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
|
||||
});
|
||||
|
||||
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
|
||||
seedHikCamera();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
|
||||
});
|
||||
|
||||
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
|
||||
seedHikCamera();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: "10.0.10.200", // not the camera
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(alarmEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects when alarm push is disabled on the device", async () => {
|
||||
seedHikCamera({ alarmPushEnabled: false });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown device id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/nope/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { verifyDigest } from "../digest-auth.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;
|
||||
pushUser?: string;
|
||||
pushPassword?: string;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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),
|
||||
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): 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);
|
||||
});
|
||||
|
||||
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
|
||||
const { deviceId } = req.params;
|
||||
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
const cfg = row?.config as HikDeviceConfig | undefined;
|
||||
const ip = clientIp(req);
|
||||
|
||||
// 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).
|
||||
if (!row || row.driverId !== "hikvision" || !cfg?.alarmPushEnabled || !cfg.host || ip !== cfg.host) {
|
||||
app.log.warn(`rejected hik alarm push: device=${deviceId} ip=${ip} (unknown/disabled/ip-mismatch)`);
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
|
||||
// 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 })) {
|
||||
return; // 401 challenge already sent
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = String(req.headers["content-type"] ?? "");
|
||||
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
|
||||
// Decode as text for summary + storage. Multipart bodies have a binary image part;
|
||||
// we keep the readable head (the XML part lives at the top) and note the full size.
|
||||
const text = raw.toString("utf8");
|
||||
const summary = summarize(text);
|
||||
|
||||
// Loud log so the operator can SEE the payload during testing.
|
||||
app.log.info(
|
||||
`[hik-alarm:${deviceId}] ${ip} ${contentType} ${raw.length}B ` +
|
||||
`event=${summary.eventType ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
|
||||
);
|
||||
|
||||
// Record verbatim as telemetry (unsigned, prunable). The whole point of this first
|
||||
// cut: capture exactly what arrives so we can design the real handler. We cap the
|
||||
// stored body so a giant multipart frame doesn't bloat the row (the head holds the
|
||||
// XML); the summary carries the parsed fields.
|
||||
try {
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId,
|
||||
category: "camera",
|
||||
kind: "alarm",
|
||||
detail: {
|
||||
source: "hikvision-alarm-server",
|
||||
ip,
|
||||
contentType,
|
||||
bytes: raw.length,
|
||||
...summary,
|
||||
// Store the readable head verbatim (XML part); truncate to keep the row small.
|
||||
rawHead: text.slice(0, 8000),
|
||||
},
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Also surface on the in-process bus as a generic input breadcrumb so any live
|
||||
// listener (e.g. the booth feed) can show "camera saw a vehicle" during testing.
|
||||
// NOTE: deliberately NOT emitted as a DeviceReadEvent yet — that (a plate identity
|
||||
// driving entry/exit) is the next, separate step once we know the 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 });
|
||||
};
|
||||
|
||||
// Hik posts to a single configured URL; accept POST (and GET, for a quick manual probe).
|
||||
for (const method of ["POST", "GET"] as const) {
|
||||
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user