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
This commit is contained in:
2026-06-26 16:46:46 +02:00
parent 40ffa90dac
commit f0fd15bb88
2 changed files with 236 additions and 19 deletions
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DigestGetResult } from "./http-digest.js";
// The HTTP layer is mocked so the camera driver's RETRY logic is tested without a
// network. Hikvision returns 503 "Device Busy" (sometimes 500) transiently when its
// snapshot encoder is occupied — captureSnapshot must retry those and succeed, but
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
// Import the driver AFTER the mock is registered.
const { hikvisionDriver } = await import("./camera.js");
function reply(status: number, body = "jpeg-bytes"): DigestGetResult {
return { status, contentType: "image/jpeg", body: Buffer.from(body) };
}
function makeCamera() {
return hikvisionDriver.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
}
beforeEach(() => {
digestGet.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("hikvision captureSnapshot — 503 Device Busy retry", () => {
it("retries a transient 503 and succeeds", async () => {
digestGet
.mockResolvedValueOnce(reply(503))
.mockResolvedValueOnce(reply(503))
.mockResolvedValueOnce(reply(200, "the-frame"));
const cam = makeCamera();
const p = cam.captureSnapshot({ direction: "entry" });
await vi.runAllTimersAsync(); // let the backoff sleeps fire
const shot = await p;
expect(shot.bytes.toString()).toBe("the-frame");
expect(digestGet).toHaveBeenCalledTimes(3); // 503, 503, 200
});
it("also retries a transient 500", async () => {
digestGet.mockResolvedValueOnce(reply(500)).mockResolvedValueOnce(reply(200));
const cam = makeCamera();
const p = cam.captureSnapshot({ direction: "entry" });
await vi.runAllTimersAsync();
await p;
expect(digestGet).toHaveBeenCalledTimes(2);
});
it("gives up after the attempt cap, naming it 'device busy'", async () => {
digestGet.mockResolvedValue(reply(503)); // always busy
const cam = makeCamera();
// Attach the rejection assertion BEFORE flushing timers so the rejection always
// has a handler (no unhandled-rejection noise), then drive the backoff sleeps.
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 503 \(device busy\)/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(4); // SNAPSHOT_MAX_ATTEMPTS
});
it("does NOT retry a 401 (auth error self-won't-heal) — fails fast", async () => {
digestGet.mockResolvedValue(reply(401));
const cam = makeCamera();
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 401/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(1); // no retry
});
it("does NOT retry a 404 (wrong path/channel) — fails fast", async () => {
digestGet.mockResolvedValue(reply(404));
const cam = makeCamera();
const assertion = expect(cam.captureSnapshot({ direction: "entry" })).rejects.toThrow(/HTTP 404/);
await vi.runAllTimersAsync();
await assertion;
expect(digestGet).toHaveBeenCalledTimes(1);
});
it("succeeds first try with no retry on a clean 200", async () => {
digestGet.mockResolvedValue(reply(200));
const cam = makeCamera();
const shot = await cam.captureSnapshot({ direction: "entry" });
expect(shot.contentType).toBe("image/jpeg");
expect(digestGet).toHaveBeenCalledTimes(1);
});
});
describe("hikvision snapshot stream selection (main vs sub)", () => {
function pathFor(config: Record<string, unknown>): string {
digestGet.mockReset();
digestGet.mockResolvedValue(reply(200));
hikvisionDriver.create(config as never).captureSnapshot({ direction: "entry" });
return String((digestGet.mock.calls[0]![0] as { path: string }).path);
}
it("defaults to the MAIN stream (…/channels/101/picture) — back-compat", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
it("stream=2 selects the SUB stream (…/channels/102/picture) — the G3H 503 fix", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 2 })).toBe("/ISAPI/Streaming/channels/102/picture");
});
it("honours the channel number with the stream (ch2 sub = 202)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 2, stream: 2 })).toBe("/ISAPI/Streaming/channels/202/picture");
});
it("an invalid stream falls back to main (1)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
});