fa65b2df86
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.
121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.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 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;
|
||
|
||
constructor(
|
||
readonly driverId: string,
|
||
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> {
|
||
// 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> {
|
||
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 {
|
||
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 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 (HTTP Digest).",
|
||
transports: ["tcp-ip"],
|
||
configFields: cameraConfigFields,
|
||
// 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 (HTTP Digest).",
|
||
transports: ["tcp-ip"],
|
||
configFields: cameraConfigFields,
|
||
// 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)}`),
|
||
};
|