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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user