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