Real Hikvision/Dahua camera driver; gate Backend-push-IP on capability

Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI
snapshots over client-side HTTP Digest (new drivers/http-digest.ts).
healthCheck() now pulls a real frame instead of returning ready/stub.
Snapshot carries bytes (driver fetches); storage/imageRef is the caller's
job, keeping the adapter free of storage deps.

Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver
(only Dingtian sets it), expose as pushCapable in the catalog, and gate the
wizard's backend-IP fetch + field on it so pull-only devices hide it.

Verified on hardware (Hikvision 10.0.10.121): healthCheck ready,
captureSnapshot returns a valid JPEG.
This commit is contained in:
2026-06-15 16:17:49 +02:00
parent 59bfe2013f
commit fa65b2df86
10 changed files with 343 additions and 34 deletions
@@ -721,6 +721,7 @@ export const dingtianDriver: AccessDriver = {
description:
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
transports: ["udp"],
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
configFields: [
hostField,
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
+88 -25
View File
@@ -1,57 +1,120 @@
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
import type { CameraDriver, DeviceConfig } from "../registry.js";
import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
import { digestGet } from "./http-digest.js";
// Camera drivers — entry/exit snapshot-on-event. The image is stored and
// referenced from the signed event as an independent fraud-control record.
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only.
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
// HTTP when an event fires; the bytes are stored and referenced from the signed
// event as an independent fraud-control record (the camera PULLS, it never pushes
// to us). Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL and
// channel encoding. Both use HTTP Digest auth (see ./http-digest.ts).
//
// VERIFIED on hardware (2026-06-15): a Hikvision unit at 10.0.10.121 returns a
// 2688×1520 JPEG from /ISAPI/Streaming/channels/101/picture with Digest auth.
// See wiki/entities/lpr-camera.md.
const DEFAULT_TIMEOUT_MS = 8000;
class HttpCamera implements CameraDevice {
readonly #host: string;
readonly #port: number;
readonly #user: string;
readonly #password: string;
readonly #channel: number;
readonly #timeout: number;
// Source outbound from the device-facing NIC on a multi-homed host (the
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
readonly #localAddress: string | undefined;
class StubCamera implements CameraDevice {
constructor(
readonly driverId: string,
protected readonly config: DeviceConfig,
protected readonly snapshotPath: string,
) {}
async connect(): Promise<void> {
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`);
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
config: DeviceConfig,
/** Builds the snapshot path from the configured channel. */
private readonly snapshotPath: (channel: number) => string,
) {
this.#host = String(config.host);
this.#port = Number(config.port ?? 80);
this.#user = String(config.username ?? "");
this.#password = String(config.password ?? "");
this.#channel = Number(config.channel ?? 1);
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
}
async connect(): Promise<void> {}
async disconnect(): Promise<void> {}
async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" };
// The only honest liveness probe for a snapshot camera is to actually pull a
// frame: it exercises reachability + auth + the path/channel in one shot.
try {
const res = await this.#get();
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
return { status: "degraded", detail: `HTTP ${res.status}` };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
}
}
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref.
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`);
const res = await this.#get();
if (res.status !== 200) {
throw new Error(
`${this.driverId} snapshot failed (lane=${ctx.lane} ${ctx.direction}): HTTP ${res.status}`,
);
}
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction} (${res.body.length} bytes)`);
return {
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`,
contentType: "image/jpeg",
bytes: res.body,
contentType: res.contentType || "image/jpeg",
capturedAt: new Date().toISOString(),
};
}
#get() {
return digestGet({
host: this.#host,
port: this.#port,
path: this.snapshotPath(this.#channel),
user: this.#user,
password: this.#password,
timeoutMs: this.#timeout,
localAddress: this.#localAddress,
});
}
}
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }];
const channelField: ConfigField = {
key: "channel",
label: "Channel",
type: "number",
required: false,
default: 1,
};
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
export const hikvisionDriver: CameraDriver = {
id: "hikvision",
category: "camera",
label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI.",
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
transports: ["tcp-ip"],
configFields: cameraConfigFields,
// /ISAPI/Streaming/channels/<id>/picture
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"),
// 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`),
};
export const dahuaDriver: CameraDriver = {
id: "dahua",
category: "camera",
label: "Dahua camera",
description: "Dahua snapshot via CGI.",
description: "Dahua snapshot via CGI (HTTP Digest).",
transports: ["tcp-ip"],
configFields: cameraConfigFields,
// /cgi-bin/snapshot.cgi?channel=<n>
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"),
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
create: (c) =>
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
};
+139
View File
@@ -0,0 +1,139 @@
import { createHash, randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import type { IncomingMessage } from "node:http";
// Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices
// that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras.
// (The server-side counterpart, which VERIFIES device→backend pushes, lives in
// apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a
// trusted TLS cert, so plain-HTTP Digest is the available auth: the password is
// never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md.
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
/** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */
function parseChallenge(header: string): Record<string, string> {
const out: Record<string, string> = {};
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
let m: RegExpExecArray | null;
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
return out;
}
/** Build the `Authorization: Digest …` response value for a challenge. */
function buildAuthHeader(
c: Record<string, string>,
user: string,
password: string,
method: string,
uri: string,
): string {
const realm = c.realm ?? "";
const nonce = c.nonce ?? "";
const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int"
const ha1 = md5(`${user}:${realm}:${password}`);
const ha2 = md5(`${method}:${uri}`);
const parts: string[] = [
`username="${user}"`,
`realm="${realm}"`,
`nonce="${nonce}"`,
`uri="${uri}"`,
];
let response: string;
if (qop === "auth") {
const cnonce = randomBytes(8).toString("hex");
const nc = "00000001";
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
} else {
// Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant.
response = md5(`${ha1}:${nonce}:${ha2}`);
}
parts.push(`response="${response}"`);
if (c.opaque) parts.push(`opaque="${c.opaque}"`);
return `Digest ${parts.join(", ")}`;
}
export interface DigestGetResult {
readonly status: number;
readonly contentType: string;
readonly body: Buffer;
}
export interface DigestGetOptions {
readonly host: string;
readonly port: number;
readonly path: string;
readonly user: string;
readonly password: string;
readonly timeoutMs: number;
/** Bind outbound to the device-facing NIC on a multi-homed host. */
readonly localAddress?: string;
}
function getOnce(
o: DigestGetOptions,
authHeader?: string,
): Promise<{ res: IncomingMessage; body: Buffer }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
if (authHeader) headers["authorization"] = authHeader;
const req = httpRequest(
{
host: o.host,
port: o.port,
path: o.path,
method: "GET",
timeout: o.timeoutMs,
localAddress: o.localAddress,
headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c as Buffer));
res.on("end", () => resolve({ res, body: Buffer.concat(chunks) }));
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
req.end();
});
}
/**
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
* the first request (no Authorization) draws a 401 + challenge, the second
* carries the computed response. If the server doesn't challenge (200 straight
* away, or no auth required), the first response is returned as-is.
*/
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
const first = await getOnce(o);
if (first.res.statusCode !== 401) {
return {
status: first.res.statusCode ?? 0,
contentType: String(first.res.headers["content-type"] ?? ""),
body: first.body,
};
}
const challengeHeader = String(first.res.headers["www-authenticate"] ?? "");
if (!/^digest/i.test(challengeHeader)) {
// 401 but not Digest (e.g. Basic-only) — surface it; caller decides.
return {
status: 401,
contentType: String(first.res.headers["content-type"] ?? ""),
body: first.body,
};
}
const challenge = parseChallenge(challengeHeader);
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
const second = await getOnce(o, auth);
return {
status: second.res.statusCode ?? 0,
contentType: String(second.res.headers["content-type"] ?? ""),
body: second.body,
};
}
+7 -2
View File
@@ -179,10 +179,15 @@ export interface SnapshotContext {
}
export interface Snapshot {
/** Storage reference for the captured image (file path / blob id). */
readonly imageRef: string;
/** The captured image bytes. The DRIVER fetches them over the network; the
* CALLER (entry/exit flow) owns storage and minting a durable reference —
* keeping the device adapter free of any filesystem/blob-store dependency. */
readonly bytes: Buffer;
readonly contentType: string;
readonly capturedAt: string; // ISO-8601
/** Storage reference (file path / blob id), set once the caller has stored
* the bytes. Absent on the value the driver returns. */
readonly imageRef?: string;
}
// --- Printers (ticket dispenser / booth printer) -------------------------
+12
View File
@@ -42,6 +42,13 @@ export interface DeviceDriver<T extends Device = Device> {
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
readonly transports: readonly string[];
readonly configFields: readonly ConfigField[];
/**
* True if the device calls BACK to our backend (HTTP push) and therefore needs
* a backend IP configured at assign time. Pull-only devices (cameras poll a
* snapshot, the relay is commanded) leave this false so the setup wizard hides
* the "Backend push IP" field. See wiki/concepts/device-input-flow.md.
*/
readonly pushesToBackend?: boolean;
/** Build a live adapter instance from validated config. */
create(config: DeviceConfig): T;
}
@@ -130,6 +137,11 @@ class DeviceRegistry {
}
return byCategory;
}
/** Driver ids that push to the backend (need a backend IP at assign time). */
pushCapable(): string[] {
return [...this.#drivers.values()].filter((d) => d.pushesToBackend).map((d) => d.id);
}
}
export interface CatalogEntry {