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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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
@@ -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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user