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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user