diff --git a/apps/server/src/routes/hikvision-alarm.test.ts b/apps/server/src/routes/hikvision-alarm.test.ts new file mode 100644 index 0000000..972a344 --- /dev/null +++ b/apps/server/src/routes/hikvision-alarm.test.ts @@ -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 = {}) { + 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 = ` + + 10.0.10.121 + 1 + 2026-06-22T10:15:30+02:00 + fielddetection + active + + vehicle + +`; + +function alarmEvents(): { detail: Record }[] { + return db + .select() + .from(deviceEventsTable) + .where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm"))) + .all() as { detail: Record }[]; +} + +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 = `ANPR + AA123BB`; + 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); + }); +}); diff --git a/apps/server/src/routes/hikvision-alarm.ts b/apps/server/src/routes/hikvision-alarm.ts new file mode 100644 index 0000000..68bfe61 --- /dev/null +++ b/apps/server/src/routes/hikvision-alarm.ts @@ -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 / 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 , + * , ; smart/ANPR events add target/plate tags whose exact names + * vary by firmware (, , , ). + * 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>/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>/i), + channelId: pick(body, /([^<]+)<\/channelID>/i) ?? pick(body, /([^<]+)<\/channelId>/i), + }; +} + +export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promise { + // 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 }); + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0ff5af1..5dffe1f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -24,6 +24,7 @@ import { authRoutes } from "./routes/auth.js"; import { userRoutes } from "./routes/users.js"; import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; +import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js"; import { eventRoutes } from "./routes/events.js"; import { reportRoutes } from "./routes/reports.js"; import { recycleBinRoutes } from "./routes/recycle-bin.js"; @@ -114,6 +115,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise, e.g. ch1 main = 101, ch2 main = 201. create: (c) => new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`), diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md index f690d29..3df7fdc 100644 --- a/wiki/entities/lpr-camera.md +++ b/wiki/entities/lpr-camera.md @@ -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`. - 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). + +## 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://:/api/devices/hikvision//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 (``), 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. diff --git a/wiki/log.md b/wiki/log.md index 78b0f5d..8abb1ad 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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) + 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]]. + +## [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]].