Files
parking_solution/packages/devices/src/drivers/camera.ts
T
julian fa65b2df86 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.
2026-06-15 16:17:49 +02:00

121 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}`),
};