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 { 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<FastifyInsta
|
||||
// the device's lane_devices config (written on assign).
|
||||
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
|
||||
// pushes changes to the booth UI. setupRoutes() has already registered the
|
||||
// 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];
|
||||
|
||||
// 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 = {
|
||||
id: "hikvision",
|
||||
category: "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"],
|
||||
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.
|
||||
create: (c) =>
|
||||
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`.
|
||||
- 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://<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) +
|
||||
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]].
|
||||
|
||||
Reference in New Issue
Block a user