From fa65b2df86ca0e0bc28b13aca0080a40e126f040 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 15 Jun 2026 16:17:49 +0200 Subject: [PATCH] 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. --- apps/server/src/routes/setup.ts | 6 +- apps/web/src/SetupWizard.tsx | 19 ++- apps/web/src/api.ts | 2 + .../devices/src/drivers/access-dingtian.ts | 1 + packages/devices/src/drivers/camera.ts | 113 ++++++++++---- packages/devices/src/drivers/http-digest.ts | 139 ++++++++++++++++++ packages/devices/src/interfaces.ts | 9 +- packages/devices/src/registry.ts | 12 ++ wiki/entities/lpr-camera.md | 35 ++++- wiki/log.md | 41 ++++++ 10 files changed, 343 insertions(+), 34 deletions(-) create mode 100644 packages/devices/src/drivers/http-digest.ts diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 50d65d6..d8c29ad 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -62,11 +62,13 @@ export async function setupRoutes( const adminGuard = requireRole("admin"); // Catalog of selectable drivers per category (no secrets — schema only). - // `discoverable` flags drivers that can scan the LAN. + // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags + // drivers that push to the backend (and thus need a backend IP at assign time). app.get("/api/setup/catalog", async () => { const catalog = registry.catalog(); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); - return { ...catalog, discoverable }; + const pushCapable = registry.pushCapable(); + return { ...catalog, discoverable, pushCapable }; }); // Scan the LAN for devices a driver can discover (UDP broadcast, etc). diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 3f9912e..7cd0802 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -78,6 +78,7 @@ export function SetupWizard() { noun={noun} entries={catalog[key]} discoverableIds={catalog.discoverable} + pushCapableIds={catalog.pushCapable} assignments={assignments.filter((a) => a.category === key && a.lane === lane)} onChanged={reloadState} /> @@ -93,6 +94,7 @@ function CategorySection({ noun, entries, discoverableIds, + pushCapableIds, assignments, onChanged, }: { @@ -102,6 +104,7 @@ function CategorySection({ noun: string; entries: CatalogEntry[]; discoverableIds: string[]; + pushCapableIds: string[]; assignments: Assignment[]; onChanged: () => Promise | void; }) { @@ -155,6 +158,7 @@ function CategorySection({ category={category} entries={entries} discoverableIds={discoverableIds} + pushCapableIds={pushCapableIds} onSaved={async (w) => { setWarnings(w); await onChanged(); @@ -227,6 +231,7 @@ function DeviceForm({ category, entries, discoverableIds, + pushCapableIds, onSaved, onCancel, }: { @@ -234,12 +239,17 @@ function DeviceForm({ category: DeviceCategory; entries: CatalogEntry[]; discoverableIds: string[]; + pushCapableIds: string[]; onSaved: (warnings: string[]) => Promise | void; onCancel?: () => void; }) { const [selectedId, setSelectedId] = useState(""); const selected = entries.find((e) => e.id === selectedId); const canDiscover = selected != null && discoverableIds.includes(selected.id); + // Only push-capable drivers (e.g. the Dingtian relay) call back to the + // backend and need a backend IP. Pull-only devices (cameras, commanded relays) + // must NOT show the field. See wiki/concepts/device-input-flow.md. + const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); // Config values (auto-filled by discovery, editable by hand). const [config, setConfig] = useState>({}); @@ -255,15 +265,16 @@ function DeviceForm({ // Backend push IP: which of OUR addresses the device should call back on. We // auto-pick the NIC on the device's subnet, but surface it editable here so a // multi-NIC host can be corrected (the chosen IP is baked into the device on - // save). Only relevant for drivers that push (the field hides if no candidates). + // save). Only relevant for drivers that push back to us (pushesToBackend). const [backendIps, setBackendIps] = useState(null); const [backendIp, setBackendIp] = useState(""); // (Re)load backend-IP candidates whenever the device host changes after a - // successful test (the test confirms the host is real + reachable). + // successful test (the test confirms the host is real + reachable) — but only + // for push-capable drivers; a pull-only device never calls back. const testedHost = tested ? String(mergedConfig().host ?? "") : ""; useEffect(() => { - if (!testedHost) { + if (!testedHost || !pushesToBackend) { setBackendIps(null); return; } @@ -281,7 +292,7 @@ function DeviceForm({ live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [testedHost]); + }, [testedHost, pushesToBackend]); function selectDriver(id: string) { setSelectedId(id); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 22be382..068e9c1 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -96,6 +96,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer"; export type Catalog = Record & { /** Driver ids that support LAN discovery. */ discoverable: string[]; + /** Driver ids that push to the backend (need a backend IP at assign time). */ + pushCapable: string[]; }; export function fetchCatalog(): Promise { diff --git a/packages/devices/src/drivers/access-dingtian.ts b/packages/devices/src/drivers/access-dingtian.ts index 23ee206..cc5503f 100644 --- a/packages/devices/src/drivers/access-dingtian.ts +++ b/packages/devices/src/drivers/access-dingtian.ts @@ -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)." }, diff --git a/packages/devices/src/drivers/camera.ts b/packages/devices/src/drivers/camera.ts index 1a2f2c3..0c44daa 100644 --- a/packages/devices/src/drivers/camera.ts +++ b/packages/devices/src/drivers/camera.ts @@ -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 { - stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`); - } - async disconnect(): Promise { - 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 {} + async disconnect(): Promise {} + async healthCheck(): Promise { - 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 { - // 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//picture - create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"), + // 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.", + description: "Dahua snapshot via CGI (HTTP Digest).", transports: ["tcp-ip"], configFields: cameraConfigFields, - // /cgi-bin/snapshot.cgi?channel= - 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)}`), }; diff --git a/packages/devices/src/drivers/http-digest.ts b/packages/devices/src/drivers/http-digest.ts new file mode 100644 index 0000000..7c30a25 --- /dev/null +++ b/packages/devices/src/drivers/http-digest.ts @@ -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 { + const out: Record = {}; + 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, + 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 = {}; + 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 { + 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, + }; +} diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 9729d0c..a552e51 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -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) ------------------------- diff --git a/packages/devices/src/registry.ts b/packages/devices/src/registry.ts index 5bb6c9f..f6f632d 100644 --- a/packages/devices/src/registry.ts +++ b/packages/devices/src/registry.ts @@ -42,6 +42,13 @@ export interface DeviceDriver { /** 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 { diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md index 343fbf7..43dbe50 100644 --- a/wiki/entities/lpr-camera.md +++ b/wiki/entities/lpr-camera.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, readers, offline-first] sources: [parking-system-architecture] -updated: 2026-06-14 +updated: 2026-06-15 --- # LPR Camera @@ -20,3 +20,36 @@ License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that should reconcile one-to-one; any mismatch is an anomaly. - Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads. + +## Snapshot driver (entry/exit fraud-control record) + +Separate from edge-AI LPR: the camera driver (`packages/devices/src/drivers/camera.ts`) does +**snapshot-on-event** — the host pulls a still over HTTP when an entry/exit fires and stores it, +referenced from the signed [[append-only-event-chain]] entry as an independent record. The camera +**pulls, it does not push** — so it is NOT `pushesToBackend` and the setup wizard correctly hides +the "Backend push IP" field for it (gated on the driver's `pushesToBackend` flag; only +[[dingtian-relay]] sets it). + +- **Hikvision** uses **ISAPI**: `GET /ISAPI/Streaming/channels//picture` (`101` = ch1 main + stream) with **HTTP Digest** auth. The "Enable Hikvision-CGI" toggle (Network → Advanced → + Integration Protocol) is a *different* legacy CGI surface — **not** needed for ISAPI. +- **Dahua** uses CGI: `GET /cgi-bin/snapshot.cgi?channel=` (0-based channel; the wizard's + 1-based channel is decremented). + +**Driver / storage boundary:** the driver FETCHES the image bytes (client-side HTTP Digest in +`drivers/http-digest.ts`) and returns them on `Snapshot.bytes`; **storage is the caller's job** +(the future entry/exit flow stores the bytes + mints a durable `imageRef`). This keeps the device +adapter free of any filesystem/blob-store dependency. `healthCheck()` is honest — it actually pulls +a frame (exercising reachability + auth + path/channel in one shot), not a fake `ready/stub`. + +### Verified on hardware (2026-06-15) + +A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.10.121`, creds +`admin` / `admin123` (Digest), TCP 80: + +- Initial `curl` test confirmed the ISAPI path returns a 2688×1520 JPEG (~306 KB). +- The **real driver** (no longer a stub) was then run end to end against it: + `healthCheck()` → `ready` (pulled a frame), `captureSnapshot()` → valid `image/jpeg`, ~322 KB, + 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). diff --git a/wiki/log.md b/wiki/log.md index be19acc..8041c81 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -297,3 +297,44 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). - Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a device field); device provenance is in `identity`. - Updated [[append-only-event-chain]]. + +## [2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap +- Pulled a real snapshot from a Hikvision camera on the bench: `GET + http://10.0.10.121/ISAPI/Streaming/channels/101/picture`, Digest auth, admin/admin123 → HTTP 200, + 2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's + "Enable Hikvision-CGI" toggle is a *different* legacy CGI API and is NOT needed. +- Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ● + preconditions OK" contacts nothing; cameras have no preconditions (only [[dingtian-relay]] + implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't + push; field should gate on a `pushesToBackend` capability). +- LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one + NIC (`192.168.1.123` + `10.0.10.203` on eth1), Linux picked the WRONG source address for + `10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on the connected + route (`ip route change /24 dev proto kernel scope link src `), or force + source per-call (`ping -I` / `curl --interface`). Devices arrive on assorted static `/24`s; the + host carries one IP per subnet — this trap is the recurring cost of that. +- Decision context: production is a dedicated hardened **Linux appliance** (this WSL2 box is a dev + stand-in). Multi-subnet config + `src` pinning is an appliance deployment concern (made + persistent via networkd/netplan), riding on [[network-isolation]]; long-term answer is to re-IP + devices onto one planned parking subnet at install. +- Updated [[lpr-camera]] (snapshot driver + verified-on-hardware section), [[wsl-dev-networking]] + (multi-subnet source-address trap + appliance pattern). + +## [2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated +- Replaced the camera STUB with a real `HttpCamera` (`packages/devices/src/drivers/camera.ts`): + Hikvision ISAPI (`/ISAPI/Streaming/channels/01/picture`) + Dahua CGI (0-based channel), both + over client-side HTTP Digest (new `drivers/http-digest.ts`, two-shot 401→challenge→response, + qop=auth MD5 — the client counterpart to the server's digest-auth.ts). `healthCheck()` now + actually pulls a frame instead of returning `ready/stub`. Added `localAddress` + `timeoutMs` + + `channel` config; threads the device-facing NIC for the multi-subnet trap. +- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is + optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing + consumed captureSnapshot yet, so no migration needed. +- Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a `pushesToBackend` + flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog + (mirrors `discoverable`), and gated both the wizard's backend-IP fetch and the field on it. + Cameras/printers/readers no longer show it. +- VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at + 10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic). +- Updated [[lpr-camera]]. +