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:
@@ -62,11 +62,13 @@ export async function setupRoutes(
|
|||||||
const adminGuard = requireRole("admin");
|
const adminGuard = requireRole("admin");
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// 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 () => {
|
app.get("/api/setup/catalog", async () => {
|
||||||
const catalog = registry.catalog();
|
const catalog = registry.catalog();
|
||||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
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).
|
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function SetupWizard() {
|
|||||||
noun={noun}
|
noun={noun}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
|
pushCapableIds={catalog.pushCapable}
|
||||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
||||||
onChanged={reloadState}
|
onChanged={reloadState}
|
||||||
/>
|
/>
|
||||||
@@ -93,6 +94,7 @@ function CategorySection({
|
|||||||
noun,
|
noun,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
|
pushCapableIds,
|
||||||
assignments,
|
assignments,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
@@ -102,6 +104,7 @@ function CategorySection({
|
|||||||
noun: string;
|
noun: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
|
pushCapableIds: string[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
@@ -155,6 +158,7 @@ function CategorySection({
|
|||||||
category={category}
|
category={category}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
discoverableIds={discoverableIds}
|
discoverableIds={discoverableIds}
|
||||||
|
pushCapableIds={pushCapableIds}
|
||||||
onSaved={async (w) => {
|
onSaved={async (w) => {
|
||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
@@ -227,6 +231,7 @@ function DeviceForm({
|
|||||||
category,
|
category,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
|
pushCapableIds,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
@@ -234,12 +239,17 @@ function DeviceForm({
|
|||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
|
pushCapableIds: string[];
|
||||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
const selected = entries.find((e) => e.id === selectedId);
|
const selected = entries.find((e) => e.id === selectedId);
|
||||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
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).
|
// Config values (auto-filled by discovery, editable by hand).
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||||||
@@ -255,15 +265,16 @@ function DeviceForm({
|
|||||||
// Backend push IP: which of OUR addresses the device should call back on. We
|
// 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
|
// 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
|
// 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<BackendIpCandidate[] | null>(null);
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||||
const [backendIp, setBackendIp] = useState<string>("");
|
const [backendIp, setBackendIp] = useState<string>("");
|
||||||
|
|
||||||
// (Re)load backend-IP candidates whenever the device host changes after a
|
// (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 ?? "") : "";
|
const testedHost = tested ? String(mergedConfig().host ?? "") : "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!testedHost) {
|
if (!testedHost || !pushesToBackend) {
|
||||||
setBackendIps(null);
|
setBackendIps(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,7 +292,7 @@ function DeviceForm({
|
|||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [testedHost]);
|
}, [testedHost, pushesToBackend]);
|
||||||
|
|
||||||
function selectDriver(id: string) {
|
function selectDriver(id: string) {
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
|||||||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||||||
/** Driver ids that support LAN discovery. */
|
/** Driver ids that support LAN discovery. */
|
||||||
discoverable: string[];
|
discoverable: string[];
|
||||||
|
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||||||
|
pushCapable: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchCatalog(): Promise<Catalog> {
|
export function fetchCatalog(): Promise<Catalog> {
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
description:
|
description:
|
||||||
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
||||||
transports: ["udp"],
|
transports: ["udp"],
|
||||||
|
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
|
||||||
configFields: [
|
configFields: [
|
||||||
hostField,
|
hostField,
|
||||||
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
||||||
|
|||||||
@@ -1,57 +1,120 @@
|
|||||||
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
|
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 { 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
|
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||||
// referenced from the signed event as an independent fraud-control record.
|
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
||||||
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only.
|
// 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(
|
constructor(
|
||||||
readonly driverId: string,
|
readonly driverId: string,
|
||||||
protected readonly config: DeviceConfig,
|
config: DeviceConfig,
|
||||||
protected readonly snapshotPath: string,
|
/** Builds the snapshot path from the configured channel. */
|
||||||
) {}
|
private readonly snapshotPath: (channel: number) => string,
|
||||||
async connect(): Promise<void> {
|
) {
|
||||||
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`);
|
this.#host = String(config.host);
|
||||||
}
|
this.#port = Number(config.port ?? 80);
|
||||||
async disconnect(): Promise<void> {
|
this.#user = String(config.username ?? "");
|
||||||
stubLog(this.driverId, "disconnect");
|
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> {
|
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> {
|
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
|
||||||
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref.
|
const res = await this.#get();
|
||||||
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`);
|
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 {
|
return {
|
||||||
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`,
|
bytes: res.body,
|
||||||
contentType: "image/jpeg",
|
contentType: res.contentType || "image/jpeg",
|
||||||
capturedAt: new Date().toISOString(),
|
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 = {
|
export const hikvisionDriver: CameraDriver = {
|
||||||
id: "hikvision",
|
id: "hikvision",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Hikvision camera",
|
label: "Hikvision camera",
|
||||||
description: "Hikvision snapshot via ISAPI.",
|
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
configFields: cameraConfigFields,
|
||||||
// /ISAPI/Streaming/channels/<id>/picture
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
||||||
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"),
|
create: (c) =>
|
||||||
|
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dahuaDriver: CameraDriver = {
|
export const dahuaDriver: CameraDriver = {
|
||||||
id: "dahua",
|
id: "dahua",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Dahua camera",
|
label: "Dahua camera",
|
||||||
description: "Dahua snapshot via CGI.",
|
description: "Dahua snapshot via CGI (HTTP Digest).",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
configFields: cameraConfigFields,
|
||||||
// /cgi-bin/snapshot.cgi?channel=<n>
|
// Dahua channels are 0-based on the CGI; the admin enters 1-based.
|
||||||
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"),
|
create: (c) =>
|
||||||
|
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -179,10 +179,15 @@ export interface SnapshotContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
/** Storage reference for the captured image (file path / blob id). */
|
/** The captured image bytes. The DRIVER fetches them over the network; the
|
||||||
readonly imageRef: string;
|
* 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 contentType: string;
|
||||||
readonly capturedAt: string; // ISO-8601
|
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) -------------------------
|
// --- Printers (ticket dispenser / booth printer) -------------------------
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export interface DeviceDriver<T extends Device = Device> {
|
|||||||
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
|
||||||
readonly transports: readonly string[];
|
readonly transports: readonly string[];
|
||||||
readonly configFields: readonly ConfigField[];
|
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. */
|
/** Build a live adapter instance from validated config. */
|
||||||
create(config: DeviceConfig): T;
|
create(config: DeviceConfig): T;
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,11 @@ class DeviceRegistry {
|
|||||||
}
|
}
|
||||||
return byCategory;
|
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 {
|
export interface CatalogEntry {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-14
|
updated: 2026-06-15
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# 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
|
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.
|
should reconcile one-to-one; any mismatch is an anomaly.
|
||||||
- Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads.
|
- 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/<id>/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=<n>` (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).
|
||||||
|
|||||||
+41
@@ -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
|
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
|
||||||
device field); device provenance is in `identity`.
|
device field); device provenance is in `identity`.
|
||||||
- Updated [[append-only-event-chain]].
|
- 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 <subnet>/24 dev <nic> proto kernel scope link src <host-ip>`), 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/<ch>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]].
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user