fix(devices): bucket camera health-check detail — stop per-frame log/status churn

The device monitor logs + re-emits a status only when state OR detail
changes, but the camera probe's detail was the exact snapshot byte count,
which differs on every JPEG frame — so healthy cameras "changed" on
nearly every poll, writing a log line + websocket event each time
(inflating the freshly budgeted container logs). The detail is now a
stable power-of-two bucket ("snapshot ≈16 KB" / "≈256 KB") that moves
only on a real shift (stream/resolution change); an empty-ish 200 body
is flagged as "<1 KB" rather than bucketed away. Failure details
(auth/HTTP/timeout) unchanged. 3 tests pin the no-flap behavior.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-05 16:37:53 +02:00
parent 0180394c45
commit a5e54a8b93
2 changed files with 44 additions and 1 deletions
@@ -113,3 +113,33 @@ describe("hikvision snapshot stream selection (main vs sub)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
});
describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05)", () => {
// The device monitor logs + re-emits whenever the detail string changes. JPEG
// frame size differs on every frame, so an exact byte count made healthy cameras
// "change" on nearly every poll. The detail must stay identical across ordinary
// frame-size jitter and only move on a real shift (different stream/res, tiny body).
it("frames of similar size land in the same bucket", async () => {
const cam = makeCamera();
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(16_716)));
const a = await cam.healthCheck();
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(17_902)));
const b = await cam.healthCheck();
expect(a).toEqual({ status: "ready", detail: "snapshot ≈16 KB" });
expect(b.detail).toBe(a.detail); // jitter does NOT change the detail
});
it("a genuinely different size (sub vs main stream) lands in a different bucket", async () => {
const cam = makeCamera();
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(299_395)));
const big = await cam.healthCheck();
expect(big.detail).toBe("snapshot ≈256 KB");
});
it("an empty-ish 200 body is flagged, not bucketed away", async () => {
const cam = makeCamera();
digestGet.mockResolvedValueOnce(reply(200, "xx"));
const tiny = await cam.healthCheck();
expect(tiny.detail).toBe("snapshot <1 KB");
});
});
+14 -1
View File
@@ -40,6 +40,19 @@ const SNAPSHOT_RETRY_BASE_MS = 250;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Coarse, STABLE size label for the health-check detail: nearest power-of-two KB
* (`≈16 KB`, `≈256 KB`). JPEG frame size varies with every frame, and the device
* monitor logs + re-emits a status whenever the detail string changes — an exact
* byte count made every healthy camera "change" on nearly every poll, spamming the
* rotated container logs. A pow-2 bucket keeps the diagnostic value (a suddenly
* tiny frame still shows) while flapping only on a real scene/stream shift. */
function sizeBucket(bytes: number): string {
const kb = bytes / 1024;
if (kb < 1) return "<1 KB"; // empty-ish 200 body — suspicious, worth seeing as-is
const pow = Math.round(Math.log2(kb));
return `≈${2 ** pow} KB`;
}
class HttpCamera implements CameraDevice {
readonly #host: string;
readonly #port: number;
@@ -85,7 +98,7 @@ class HttpCamera implements CameraDevice {
try {
const res = await this.#get();
if (res.status === 200)
return { status: "ready", detail: `${res.body.length} bytes` };
return { status: "ready", detail: `snapshot ${sizeBucket(res.body.length)}` };
if (res.status === 401)
return {
status: "degraded",