Files
parking_solution/packages/devices/src/drivers/camera.ts
T
julian f0fd15bb88 fix(camera): selectable snapshot stream + retry transient 503 Device Busy
A Hikvision DS-2CD1047G3H-LIU returned HTTP 503 (statusCode 2 / deviceBusy)
on EVERY main-stream snapshot — its main encoder is persistently saturated.
Probed on hardware: channels/101/picture → 503 on 5 consecutive tries, while
channels/102/picture (sub stream) → 200 clean JPEG every time. A retry loop
can't fix a persistent busy; the real fix is stream selection.

- Add a `stream` config field to the Hikvision driver (1=main, default for
  back-compat; 2=sub). ISAPI channel id is <channel><stream> (101 main, 102 sub).
  Verified live: setting the G3H to Sub flips its status degraded→ready (14.7KB
  JPEG in ~87ms).
- captureSnapshot also retries the TRANSIENT case (503/500, linear backoff
  250/500/750ms ×4) then fails naming it "(device busy)"; does NOT retry 401/404
  (config errors won't self-heal). Complements captureSnapshotShared (concurrent
  de-dup). healthCheck still reports a live 503 as degraded (surfaces a saturated
  main stream rather than hiding it).

Tests: camera.test.ts (10) — retry behaviour + main/sub path selection.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-26 16:46:46 +02:00

265 lines
9.7 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;
// Hikvision returns HTTP 503 (statusCode 2 / "Device Busy" / subStatus deviceBusy) —
// and occasionally 500 — when its snapshot encoder is momentarily occupied (another
// snapshot in flight, a stream starting, on-camera VCA). It is TRANSIENT: a retry a
// few hundred ms later succeeds. The newer G3H sensors (e.g. DS-2CD1047G3H) hit it
// more readily. So a standalone capture retries a few times before giving up; we do
// NOT retry config errors (401 auth, 404 path/channel) — those won't self-heal.
// (Concurrent same-camera hits are separately de-duped by captureSnapshotShared in
// the server.) See wiki/entities/lpr-camera.md ("503 Device Busy").
const SNAPSHOT_RETRY_STATUSES = new Set([500, 503]);
const SNAPSHOT_MAX_ATTEMPTS = 4;
const SNAPSHOT_RETRY_BASE_MS = 250;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
class HttpCamera implements CameraDevice {
readonly #host: string;
readonly #port: number;
readonly #user: string;
readonly #password: string;
readonly #channel: number;
/** Hikvision stream within the channel: 1 = main (high-res), 2 = sub (lighter).
* Some models (e.g. the G3H) keep the MAIN encoder saturated and return a
* persistent 503 deviceBusy on the main-stream snapshot, while the sub-stream
* serves fine — so this is selectable. Ignored by drivers (Dahua) that don't
* encode a stream in the path. See wiki/entities/lpr-camera.md ("503 Device Busy"). */
readonly #stream: 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 + stream (1=main, 2=sub). */
private readonly snapshotPath: (channel: number, stream: 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);
// 1 = main, 2 = sub. Clamp to those two; default main for back-compat.
this.#stream = Number(config.stream) === 2 ? 2 : 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> {
// Retry transient "Device Busy" (503/500); a config error (401/404) fails fast.
let res = await this.#get();
for (
let attempt = 1;
res.status !== 200 &&
SNAPSHOT_RETRY_STATUSES.has(res.status) &&
attempt < SNAPSHOT_MAX_ATTEMPTS;
attempt++
) {
// Linear backoff (250/500/750ms) — the encoder frees within a frame or two.
await sleep(SNAPSHOT_RETRY_BASE_MS * attempt);
stubLog(
this.driverId,
`captureSnapshot ${ctx.direction} retry ${attempt} (was HTTP ${res.status})`,
);
res = await this.#get();
}
if (res.status !== 200) {
// Name the busy case so the operator/telemetry can tell "camera busy" from a
// real fault (offline / auth / wrong path).
const busy = SNAPSHOT_RETRY_STATUSES.has(res.status)
? " (device busy)"
: "";
throw new Error(
`${this.driverId} snapshot failed (${ctx.direction}): HTTP ${res.status}${busy}`,
);
}
stubLog(
this.driverId,
`captureSnapshot ${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, this.#stream),
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,
};
// Hikvision stream-within-channel for the snapshot: main (01) is full-res; sub (02)
// is lighter. Default MAIN (back-compat). Switch to SUB when the main encoder is
// saturated and returns a persistent 503 deviceBusy (seen on DS-2CD1047G3H-LIU) —
// the sub-stream is also the better fit for snapshot/ANPR (smaller, faster, doesn't
// contend with live-view/recording). See wiki/entities/lpr-camera.md.
const streamField: ConfigField = {
key: "stream",
label: "Snapshot stream",
type: "select",
required: false,
default: "1",
options: [
{ value: "1", label: "Main (01)" },
{ value: "2", label: "Sub (02)" },
],
};
// Dahua has no stream selector (its CGI snapshot isn't stream-encoded in the path).
const cameraConfigFields = [
hostField,
portField(80),
usernameField,
passwordField,
channelField,
];
const hikvisionConfigFields = [...cameraConfigFields, streamField];
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
// Alarm Server) HTTP-POSTs an EventNotificationAlert to a URL we host every time the
// chosen target is detected — same shape as the Dingtian Input Link push. When enabled,
// the admin points the camera's Alarm Server at /api/devices/hikvision/:deviceId/event
// and we record what it sends. See routes/hikvision-alarm.ts, wiki/entities/lpr-camera.md.
const alarmPushFields: ConfigField[] = [
{
key: "alarmPushEnabled",
label: "Alarm Server push (Event → vehicle)",
type: "boolean",
required: false,
default: false,
help: "The camera POSTs each detected event to us (set its Alarm Settings → Alarm Server to this backend). No polling.",
},
{
key: "pushUser",
label: "Alarm push username (optional)",
type: "string",
required: false,
help: "Only if the camera's Alarm Server is set to authenticate (HTTP Digest). Leave blank to accept by source-IP only.",
},
{
key: "pushPassword",
label: "Alarm push password (optional)",
type: "secret",
required: false,
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.",
},
{
key: "skipSourceIpCheck",
label: "Don't verify push source IP",
type: "boolean",
required: false,
default: false,
help: "Accept pushes regardless of the source IP. Needed when the network rewrites the inbound source address (e.g. WSL mirrored mode reports the host's own IP, not the camera's), which would otherwise reject every push. Leave OFF on a normal LAN.",
},
];
export const hikvisionDriver: CameraDriver = {
id: "hikvision",
category: "camera",
label: "Hikvision camera",
description:
"Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
transports: ["tcp-ip"],
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
// so it may need the backend push IP at assign time (like the Dingtian).
pushesToBackend: true,
configFields: [...hikvisionConfigFields, ...alarmPushFields],
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
// stream 1 → "01" (main), 2 → "02" (sub).
create: (c) => {
console.log(c);
return new HttpCamera(
"hikvision",
c,
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/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. No stream in the
// path (the second arg is ignored — Dahua has no main/sub snapshot distinction here).
create: (c) =>
new HttpCamera(
"dahua",
c,
(ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`,
),
};