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 {} async disconnect(): Promise {} async healthCheck(): Promise { // 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 { 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: , 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)}`), };