f0fd15bb88
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
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
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");
|
|
});
|
|
});
|