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:
2026-06-22 10:02:35 +02:00
parent 7680d9a0ed
commit 6133923094
6 changed files with 399 additions and 2 deletions
@@ -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);
});
});
+165
View File
@@ -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 });
}
}
+6
View File
@@ -24,6 +24,7 @@ import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js"; import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js"; import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.js"; import { reportRoutes } from "./routes/reports.js";
import { recycleBinRoutes } from "./routes/recycle-bin.js"; import { recycleBinRoutes } from "./routes/recycle-bin.js";
@@ -114,6 +115,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// the device's lane_devices config (written on assign). // the device's lane_devices config (written on assign).
await deviceRoutes(app, db); await deviceRoutes(app, db);
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
// payload as a `kind:"alarm"` device_event (discovery-first). See routes/hikvision-alarm.ts.
await hikvisionAlarmRoutes(app, db);
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and // Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the // pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md. // built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
+36 -2
View File
@@ -95,13 +95,47 @@ const channelField: ConfigField = {
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField]; const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
// Alarm Server) HTTP-POSTs an EventNotificationAlert to a URL we host every time the
// chosen target is detected — same shape as the Dingtian Input Link push. When enabled,
// the admin points the camera's Alarm Server at /api/devices/hikvision/:deviceId/event
// and we record what it sends. See routes/hikvision-alarm.ts, wiki/entities/lpr-camera.md.
const alarmPushFields: ConfigField[] = [
{
key: "alarmPushEnabled",
label: "Alarm Server push (Event → vehicle)",
type: "boolean",
required: false,
default: false,
help: "The camera POSTs each detected event to us (set its Alarm Settings → Alarm Server to this backend). No polling.",
},
{
key: "pushUser",
label: "Alarm push username (optional)",
type: "string",
required: false,
help: "Only if the camera's Alarm Server is set to authenticate (HTTP Digest). Leave blank to accept by source-IP only.",
},
{
key: "pushPassword",
label: "Alarm push password (optional)",
type: "secret",
required: false,
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.",
},
];
export const hikvisionDriver: CameraDriver = { export const hikvisionDriver: CameraDriver = {
id: "hikvision", id: "hikvision",
category: "camera", category: "camera",
label: "Hikvision camera", label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI (HTTP Digest).", description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, // The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true,
configFields: [...cameraConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201. // ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) => create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`), new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
+28
View File
@@ -58,3 +58,31 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
correct JPEG magic. Digest handshake works through `HttpCamera`. correct JPEG magic. Digest handshake works through `HttpCamera`.
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`, - Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap). threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
us. Under **Event → Smart/VCA** (e.g. line crossing / intrusion / "Vehicle Detection") the unit
exposes **Detection Target: Human / Vehicle** — selecting **Vehicle** + **Notify Surveillance
Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST an
`EventNotificationAlert`** to a URL we host on each detection. Same machine-call shape as the
[[dingtian-relay]] Input Link push — no polling.
- **Ingress:** `POST /api/devices/hikvision/:deviceId/event` (`apps/server/src/routes/hikvision-alarm.ts`).
**Source-IP guarded** (must come from the device's configured `host`) + **optional HTTP Digest**
(some firmware can't authenticate the Alarm Server call → source-IP only). NOT behind the SPA
cookie/CSRF (it's a device call), exactly like the Dingtian push.
- **Config:** added to the `hikvision` driver — `alarmPushEnabled` (bool), `pushUser`/`pushPassword`
(optional Digest). The driver is now `pushesToBackend: true`, so first-run setup offers the backend
push IP. Point the camera's Alarm Server at `http://<backend-ip>:<port>/api/devices/hikvision/<deviceId>/event`.
- **Discovery-first:** the endpoint is **permissive** — accepts ANY content-type as raw bytes (event
XML, multipart-with-JPEG, or JSON; Hik's format varies by model/firmware), records the **verbatim
body** as a `kind:"alarm"` device_event, and best-effort extracts `eventType` / `target` / `plate`
/ `dateTime` / `channelID`. The point of this first cut is to **see exactly what a given camera
sends** (inspect via `GET /api/events` or the server log) before wiring it to the read bus.
- **Not yet a barrier trigger.** It records + breadcrumbs only; it does NOT emit a `DeviceReadEvent`
or open anything. A plate read is **advisory, never the sole reason** a barrier opens
([[append-only-event-chain]], [[opencv-anpr-service]]) — the entry/exit wiring is a deliberate
next step once the real payload is known. If the camera emits its own plate (`<plateNumber>`), we
can use it as an advisory read directly; otherwise the server hands the attached/pulled frame to
the [[opencv-anpr-service|vision service]] for ANPR.
+14
View File
@@ -1370,3 +1370,17 @@ rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-casc
Web: a Recycle bin tab under Setup (RecycleBin.tsx). Tests: recycle-bin.test.ts (9 unit) + Web: a Recycle bin tab under Setup (RecycleBin.tsx). Tests: recycle-bin.test.ts (9 unit) +
recycle-bin-routes.test.ts (4 integration: delete→can't-login→restore→login, purge, gating, 409 recycle-bin-routes.test.ts (4 integration: delete→can't-login→restore→login, purge, gating, 409
reuse); server 103/103, build+lint 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]]. reuse); server 103/103, build+lint 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].
## [2026-06-22] feat | Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware (Event → Smart/VCA "Detection Target: Human/Vehicle" + Notify Surveillance
Center + Alarm Settings → Alarm Server) HTTP-POSTs an EventNotificationAlert on each detection.
Added POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts) — same machine-push
pattern as the Dingtian Input Link: source-IP guarded + OPTIONAL Digest, not behind SPA cookie/CSRF.
Permissive/discovery-first: a wildcard content-type parser takes ANY body as raw bytes (XML,
multipart+JPEG, JSON — Hik varies by firmware), stores it verbatim as a kind:"alarm" device_event,
and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + log line. The
hikvision DRIVER gained alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (so
setup offers the backend push IP). NOT yet a barrier trigger or DeviceReadEvent — records only; the
read-bus/ANPR wiring is the next step once the real payload is captured (advisory-only rule still
governs). 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. See [[lpr-camera]].