feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
park-buzi field observation: after a power cut the Hikvision cameras reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI (which silently pushes the browser clock) — corrupting the snapshot OSD timestamps (the evidence trail) and ANPR push times meanwhile. The host is the site's time authority (offline-first, no NTP infra): - Device monitor triggers a sync at each camera's offline→ready edge — exactly the power-restored moment — plus a 24h backstop; the attempt is stamped before the async call so a failing camera retries at backstop cadence, never every poll. - HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual with the site wall-clock now WITH explicit utc offset (localIsoWithOffset), echoing the camera's timeZone verbatim — correct the clock, never fight its tz/DST config. - Jumps >1h (the power-cut signature) log warn (persisted to app_logs); small corrections info. Capability-guarded (isClockSyncable) — hikvision only; dahua's CGI has no such endpoint. - http-digest generalised to digestRequest (GET/PUT/POST + body); the handshake was already method-aware. digestGet delegates unchanged. 8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant + echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability, DST-both-sides pins on the offset formatter. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -7,7 +7,11 @@ import type { DigestGetResult } from "./http-digest.js";
|
||||
// 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) }));
|
||||
const digestRequest = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
||||
vi.mock("./http-digest.js", () => ({
|
||||
digestGet: (...a: unknown[]) => digestGet(...a),
|
||||
digestRequest: (...a: unknown[]) => digestRequest(...a),
|
||||
}));
|
||||
|
||||
// Import the driver AFTER the mock is registered.
|
||||
const { hikvisionDriver } = await import("./camera.js");
|
||||
@@ -22,6 +26,7 @@ function makeCamera() {
|
||||
|
||||
beforeEach(() => {
|
||||
digestGet.mockReset();
|
||||
digestRequest.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -143,3 +148,64 @@ describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05)
|
||||
expect(tiny.detail).toBe("snapshot <1 KB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hikvision syncClock — the 1970 power-cut recovery (ISAPI /System/time)", () => {
|
||||
const timeXml = (localTime: string) =>
|
||||
reply(
|
||||
200,
|
||||
`<?xml version="1.0"?><Time><timeMode>NTP</timeMode><localTime>${localTime}</localTime><timeZone>CST-2:00:00DST01:00:00</timeZone></Time>`,
|
||||
);
|
||||
const HOST_NOW = "2026-07-07T12:00:00+02:00";
|
||||
|
||||
function cam() {
|
||||
return makeCamera() as unknown as {
|
||||
syncClock(localIso: string, maxDriftSec: number): Promise<{ driftSeconds: number | null; synced: boolean }>;
|
||||
};
|
||||
}
|
||||
|
||||
it("in-sync camera: reads, does NOT set", async () => {
|
||||
digestRequest.mockResolvedValueOnce(timeXml("2026-07-07T12:00:10+02:00"));
|
||||
const r = await cam().syncClock(HOST_NOW, 60);
|
||||
expect(r).toEqual({ driftSeconds: 10, synced: false });
|
||||
expect(digestRequest).toHaveBeenCalledTimes(1); // GET only
|
||||
});
|
||||
|
||||
it("1970 camera: PUTs manual time with the host instant, echoing the camera's timeZone", async () => {
|
||||
digestRequest
|
||||
.mockResolvedValueOnce(timeXml("1970-01-01T03:12:44+01:00"))
|
||||
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
|
||||
const r = await cam().syncClock(HOST_NOW, 60);
|
||||
expect(r.synced).toBe(true);
|
||||
expect(r.driftSeconds).toBeGreaterThan(1_000_000_000); // ~56 years
|
||||
const put = digestRequest.mock.calls[1]![0] as { method: string; path: string; body: string };
|
||||
expect(put.method).toBe("PUT");
|
||||
expect(put.path).toBe("/ISAPI/System/time");
|
||||
expect(put.body).toContain("<timeMode>manual</timeMode>");
|
||||
expect(put.body).toContain(`<localTime>${HOST_NOW}</localTime>`);
|
||||
expect(put.body).toContain("<timeZone>CST-2:00:00DST01:00:00</timeZone>"); // echoed, never invented
|
||||
});
|
||||
|
||||
it("unparseable camera time = infinite drift → syncs", async () => {
|
||||
digestRequest
|
||||
.mockResolvedValueOnce(reply(200, "<Time><localTime>garbage</localTime></Time>"))
|
||||
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
|
||||
const r = await cam().syncClock(HOST_NOW, 60);
|
||||
expect(r).toEqual({ driftSeconds: null, synced: true });
|
||||
});
|
||||
|
||||
it("a failed set surfaces as an error (monitor logs it, backstop retries)", async () => {
|
||||
digestRequest
|
||||
.mockResolvedValueOnce(timeXml("1970-01-01T01:00:00+01:00"))
|
||||
.mockResolvedValueOnce(reply(403, "denied"));
|
||||
await expect(cam().syncClock(HOST_NOW, 60)).rejects.toThrow("clock set failed: HTTP 403");
|
||||
});
|
||||
|
||||
it("the dahua driver does NOT claim the capability (no ISAPI time endpoint)", async () => {
|
||||
const { dahuaDriver, hikvisionDriver } = await import("./camera.js");
|
||||
const { isClockSyncable } = await import("../interfaces.js");
|
||||
const mk = (d: typeof dahuaDriver) =>
|
||||
d.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
|
||||
expect(isClockSyncable(mk(dahuaDriver))).toBe(false);
|
||||
expect(isClockSyncable(mk(hikvisionDriver))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CameraDevice,
|
||||
ClockSyncResult,
|
||||
DeviceHealth,
|
||||
Snapshot,
|
||||
SnapshotContext,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
usernameField,
|
||||
stubLog,
|
||||
} from "./common.js";
|
||||
import { digestGet } from "./http-digest.js";
|
||||
import { digestGet, digestRequest } 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
|
||||
@@ -160,6 +161,73 @@ class HttpCamera implements CameraDevice {
|
||||
localAddress: this.#localAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/** Digest request against an arbitrary device path (ISAPI config reads/writes). */
|
||||
protected isapi(method: "GET" | "PUT", path: string, body?: string) {
|
||||
return digestRequest({
|
||||
host: this.#host,
|
||||
port: this.#port,
|
||||
path,
|
||||
method,
|
||||
body,
|
||||
user: this.#user,
|
||||
password: this.#password,
|
||||
timeoutMs: this.#timeout,
|
||||
localAddress: this.#localAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hikvision clock sync (ISAPI /System/time) ---------------------------------
|
||||
// These cameras lose their clock on a power cut (no/dead RTC battery): they reboot
|
||||
// at the 1970 epoch and stay there until a human logs into the web UI (which
|
||||
// silently pushes the browser clock). A wrong camera clock corrupts the OSD
|
||||
// timestamp burned into every snapshot — the evidence trail — and the times on
|
||||
// ANPR pushes. So the host (the site's time authority — offline-first, no NTP
|
||||
// dependency) re-syncs the camera over the same Digest-auth ISAPI used for
|
||||
// snapshots. The device monitor calls this at the offline→ready edge (the
|
||||
// power-restored moment) + a daily backstop. See wiki/entities/lpr-camera.md.
|
||||
|
||||
class HikvisionCamera extends HttpCamera {
|
||||
/**
|
||||
* Read the camera clock; when it drifts more than `maxDriftSec` from `localIso`
|
||||
* (the site's wall-clock now, WITH utc offset), set it via
|
||||
* PUT /ISAPI/System/time. The camera's own `timeZone` string is echoed back
|
||||
* verbatim — we correct the CLOCK, never fight the tz/DST config; `localIso`'s
|
||||
* explicit offset makes the instant unambiguous regardless of that config.
|
||||
*/
|
||||
async syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult> {
|
||||
const read = await this.isapi("GET", "/ISAPI/System/time");
|
||||
if (read.status !== 200) {
|
||||
throw new Error(`clock read failed: HTTP ${read.status}`);
|
||||
}
|
||||
const xml = read.body.toString("utf8");
|
||||
const cameraTime = xml.match(/<localTime>([^<]+)<\/localTime>/)?.[1]?.trim() ?? null;
|
||||
const timeZone = xml.match(/<timeZone>([^<]+)<\/timeZone>/)?.[1]?.trim() ?? "";
|
||||
|
||||
// Drift: parse both sides as instants. A camera reply without a UTC offset (or
|
||||
// otherwise unparseable) can't be trusted → treat as infinite drift and sync.
|
||||
const cameraMs = cameraTime ? Date.parse(cameraTime) : NaN;
|
||||
const hostMs = Date.parse(localIso);
|
||||
const driftSeconds = Number.isFinite(cameraMs)
|
||||
? Math.round(Math.abs(hostMs - cameraMs) / 1000)
|
||||
: null;
|
||||
if (driftSeconds != null && driftSeconds <= maxDriftSec) {
|
||||
return { driftSeconds, synced: false };
|
||||
}
|
||||
|
||||
const body =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<Time><timeMode>manual</timeMode><localTime>${localIso}</localTime>` +
|
||||
(timeZone ? `<timeZone>${timeZone}</timeZone>` : "") +
|
||||
`</Time>`;
|
||||
const put = await this.isapi("PUT", "/ISAPI/System/time", body);
|
||||
if (put.status !== 200) {
|
||||
throw new Error(`clock set failed: HTTP ${put.status}`);
|
||||
}
|
||||
stubLog(this.driverId, `clock synced (was ${driftSeconds ?? "unparseable"}s off)`);
|
||||
return { driftSeconds, synced: true };
|
||||
}
|
||||
}
|
||||
|
||||
const channelField: ConfigField = {
|
||||
@@ -250,7 +318,7 @@ export const hikvisionDriver: CameraDriver = {
|
||||
// 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) =>
|
||||
new HttpCamera(
|
||||
new HikvisionCamera(
|
||||
"hikvision",
|
||||
c,
|
||||
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||
|
||||
@@ -73,19 +73,33 @@ export interface DigestGetOptions {
|
||||
readonly localAddress?: string;
|
||||
}
|
||||
|
||||
function getOnce(
|
||||
o: DigestGetOptions,
|
||||
/** digestGet + a method and optional body — for ISAPI configuration writes
|
||||
* (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2
|
||||
* hashes the method), so this generalisation is the real one, not a shortcut. */
|
||||
export interface DigestRequestOptions extends DigestGetOptions {
|
||||
readonly method: "GET" | "PUT" | "POST";
|
||||
readonly body?: Buffer | string;
|
||||
readonly contentType?: string;
|
||||
}
|
||||
|
||||
function requestOnce(
|
||||
o: DigestRequestOptions,
|
||||
authHeader?: string,
|
||||
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8");
|
||||
const headers: Record<string, string> = {};
|
||||
if (authHeader) headers["authorization"] = authHeader;
|
||||
if (payload) {
|
||||
headers["content-type"] = o.contentType ?? "application/xml";
|
||||
headers["content-length"] = String(payload.length);
|
||||
}
|
||||
const req = httpRequest(
|
||||
{
|
||||
host: o.host,
|
||||
port: o.port,
|
||||
path: o.path,
|
||||
method: "GET",
|
||||
method: o.method,
|
||||
timeout: o.timeoutMs,
|
||||
localAddress: o.localAddress,
|
||||
headers,
|
||||
@@ -97,19 +111,21 @@ function getOnce(
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
|
||||
req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`)));
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
||||
* Request a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
||||
* the first request (no Authorization) draws a 401 + challenge, the second
|
||||
* carries the computed response. If the server doesn't challenge (200 straight
|
||||
* carries the computed response (the body is sent BOTH times — the challenge shot
|
||||
* needs the same request shape). If the server doesn't challenge (200 straight
|
||||
* away, or no auth required), the first response is returned as-is.
|
||||
*/
|
||||
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||
const first = await getOnce(o);
|
||||
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
|
||||
const first = await requestOnce(o);
|
||||
if (first.res.statusCode !== 401) {
|
||||
return {
|
||||
status: first.res.statusCode ?? 0,
|
||||
@@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||
}
|
||||
|
||||
const challenge = parseChallenge(challengeHeader);
|
||||
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
||||
const second = await getOnce(o, auth);
|
||||
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
|
||||
const second = await requestOnce(o, auth);
|
||||
return {
|
||||
status: second.res.statusCode ?? 0,
|
||||
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||
body: second.body,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET with Digest auth (the original entry point; snapshots and status reads). */
|
||||
export function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||
return digestRequest({ ...o, method: "GET" });
|
||||
}
|
||||
|
||||
@@ -194,6 +194,30 @@ export function isCamera(device: Device): device is Device & CameraDevice {
|
||||
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
||||
}
|
||||
|
||||
/** Outcome of a camera clock sync attempt (see ClockSyncDevice). */
|
||||
export interface ClockSyncResult {
|
||||
/** Camera-vs-host drift in whole seconds at check time; null = the camera's
|
||||
* reply was unparseable (treated as infinite drift → sync). */
|
||||
readonly driftSeconds: number | null;
|
||||
/** True when the camera clock was actually set (drift exceeded the threshold). */
|
||||
readonly synced: boolean;
|
||||
}
|
||||
|
||||
/** Optional capability: a device whose clock the HOST can read + set. Hikvision
|
||||
* cameras lose their clock on power cuts (no/dead RTC battery, reboot at the 1970
|
||||
* epoch) and only heal when a human logs into the web UI — so the device monitor
|
||||
* re-syncs them from the host clock at the offline→ready edge + a daily backstop.
|
||||
* See wiki/entities/lpr-camera.md (clock sync). */
|
||||
export interface ClockSyncDevice {
|
||||
/** Compare the device clock to `localIso` (the site's wall-clock now, WITH utc
|
||||
* offset) and set it when drift exceeds `maxDriftSec`. */
|
||||
syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult>;
|
||||
}
|
||||
|
||||
export function isClockSyncable(device: Device): device is Device & ClockSyncDevice {
|
||||
return typeof (device as Partial<ClockSyncDevice>).syncClock === "function";
|
||||
}
|
||||
|
||||
export interface SnapshotContext {
|
||||
readonly direction: "entry" | "exit";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user