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:
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { localIsoWithOffset } from "./device-monitor.js";
|
||||||
|
|
||||||
|
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||||
|
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||||
|
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||||
|
|
||||||
|
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||||
|
it("Tirane summer = +02:00 (CEST)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T12:00:00+02:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("Tirane winter = +01:00 (CET)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||||
|
"2026-01-15T11:00:00+01:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("UTC = +00:00", () => {
|
||||||
|
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T10:00:00+00:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { isMonitorable, registry } from "@parking/devices";
|
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
/** Synthetic device id for the vision service in the status footer (it's a service,
|
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||||
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||||
@@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service";
|
|||||||
|
|
||||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||||
|
|
||||||
|
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||||
|
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||||
|
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||||
|
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||||
|
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||||
|
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||||
|
|
||||||
|
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||||
|
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||||
|
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||||
|
const wallAsUtcMs = Date.UTC(
|
||||||
|
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||||
|
Number(p.hour), Number(p.minute), Number(p.second),
|
||||||
|
);
|
||||||
|
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||||
|
const sign = offMin < 0 ? "-" : "+";
|
||||||
|
const abs = Math.abs(offMin);
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||||
|
const mm = String(abs % 60).padStart(2, "0");
|
||||||
|
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||||
* tokens the client localises next to the category:
|
* tokens the client localises next to the category:
|
||||||
@@ -139,6 +174,7 @@ export class DeviceMonitor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let next: DeviceStatusEvent;
|
let next: DeviceStatusEvent;
|
||||||
|
let device: Device | null = null;
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
// Configured against a driver that's no longer registered — surface it,
|
// Configured against a driver that's no longer registered — surface it,
|
||||||
@@ -146,7 +182,7 @@ export class DeviceMonitor {
|
|||||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const device = driver.create(cfg as never);
|
device = driver.create(cfg as never);
|
||||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||||
if (isMonitorable(device)) {
|
if (isMonitorable(device)) {
|
||||||
@@ -163,6 +199,33 @@ export class DeviceMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||||
|
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||||
|
// camera is retried at backstop cadence, never every poll.
|
||||||
|
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||||
|
const prev = this.#latest.get(row.id);
|
||||||
|
const cameBack = !prev || prev.state === "offline";
|
||||||
|
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||||
|
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||||
|
this.#clockSyncedAt.set(row.id, Date.now());
|
||||||
|
const cam = device;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||||
|
if (r.synced) {
|
||||||
|
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||||
|
// the reboot stays visible; a small correction is routine info.
|
||||||
|
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||||
|
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||||
|
else this.#log.info(msg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#publish(row.id, next);
|
this.#publish(row.id, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +246,9 @@ export class DeviceMonitor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||||
|
readonly #clockSyncedAt = new Map<string, number>();
|
||||||
|
|
||||||
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||||
#publish(id: string, next: DeviceStatusEvent): void {
|
#publish(id: string, next: DeviceStatusEvent): void {
|
||||||
const prev = this.#latest.get(id);
|
const prev = this.#latest.get(id);
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import type { DigestGetResult } from "./http-digest.js";
|
|||||||
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
||||||
|
|
||||||
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
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.
|
// Import the driver AFTER the mock is registered.
|
||||||
const { hikvisionDriver } = await import("./camera.js");
|
const { hikvisionDriver } = await import("./camera.js");
|
||||||
@@ -22,6 +26,7 @@ function makeCamera() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
digestGet.mockReset();
|
digestGet.mockReset();
|
||||||
|
digestRequest.mockReset();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
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");
|
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 {
|
import type {
|
||||||
CameraDevice,
|
CameraDevice,
|
||||||
|
ClockSyncResult,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotContext,
|
SnapshotContext,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
usernameField,
|
usernameField,
|
||||||
stubLog,
|
stubLog,
|
||||||
} from "./common.js";
|
} 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
|
// 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
|
// 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,
|
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 = {
|
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.
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||||
// stream 1 → "01" (main), 2 → "02" (sub).
|
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera(
|
new HikvisionCamera(
|
||||||
"hikvision",
|
"hikvision",
|
||||||
c,
|
c,
|
||||||
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||||
|
|||||||
@@ -73,19 +73,33 @@ export interface DigestGetOptions {
|
|||||||
readonly localAddress?: string;
|
readonly localAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOnce(
|
/** digestGet + a method and optional body — for ISAPI configuration writes
|
||||||
o: DigestGetOptions,
|
* (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,
|
authHeader?: string,
|
||||||
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||||
return new Promise((resolve, reject) => {
|
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> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (authHeader) headers["authorization"] = authHeader;
|
if (authHeader) headers["authorization"] = authHeader;
|
||||||
|
if (payload) {
|
||||||
|
headers["content-type"] = o.contentType ?? "application/xml";
|
||||||
|
headers["content-length"] = String(payload.length);
|
||||||
|
}
|
||||||
const req = httpRequest(
|
const req = httpRequest(
|
||||||
{
|
{
|
||||||
host: o.host,
|
host: o.host,
|
||||||
port: o.port,
|
port: o.port,
|
||||||
path: o.path,
|
path: o.path,
|
||||||
method: "GET",
|
method: o.method,
|
||||||
timeout: o.timeoutMs,
|
timeout: o.timeoutMs,
|
||||||
localAddress: o.localAddress,
|
localAddress: o.localAddress,
|
||||||
headers,
|
headers,
|
||||||
@@ -97,19 +111,21 @@ function getOnce(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
req.on("error", reject);
|
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();
|
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
|
* 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.
|
* away, or no auth required), the first response is returned as-is.
|
||||||
*/
|
*/
|
||||||
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
|
||||||
const first = await getOnce(o);
|
const first = await requestOnce(o);
|
||||||
if (first.res.statusCode !== 401) {
|
if (first.res.statusCode !== 401) {
|
||||||
return {
|
return {
|
||||||
status: first.res.statusCode ?? 0,
|
status: first.res.statusCode ?? 0,
|
||||||
@@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const challenge = parseChallenge(challengeHeader);
|
const challenge = parseChallenge(challengeHeader);
|
||||||
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
|
||||||
const second = await getOnce(o, auth);
|
const second = await requestOnce(o, auth);
|
||||||
return {
|
return {
|
||||||
status: second.res.statusCode ?? 0,
|
status: second.res.statusCode ?? 0,
|
||||||
contentType: String(second.res.headers["content-type"] ?? ""),
|
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||||
body: second.body,
|
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";
|
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 {
|
export interface SnapshotContext {
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-27
|
updated: 2026-07-07
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# LPR Camera
|
||||||
@@ -110,6 +110,34 @@ Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the
|
|||||||
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||||
saturated main stream rather than hiding it behind a retry).
|
saturated main stream rather than hiding it behind a retry).
|
||||||
|
|
||||||
|
## Clock sync — the 1970 power-cut reset (built 2026-07-07)
|
||||||
|
|
||||||
|
Field observation (park-buzi): after a power cut these cameras come back with their clock at the
|
||||||
|
**1970 epoch** (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI —
|
||||||
|
Hikvision's web login 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.
|
||||||
|
|
||||||
|
Built: the HOST is the site's time authority (offline-first — no NTP infra dependency), and the
|
||||||
|
device monitor re-syncs each Hikvision camera over the same Digest-auth ISAPI used for snapshots:
|
||||||
|
|
||||||
|
- **Trigger:** the camera's **offline → ready transition** (exactly the power-restored moment) +
|
||||||
|
a **24h backstop**; the attempt timestamp is stamped BEFORE the async call, so a failing camera
|
||||||
|
retries at backstop cadence, never every 8s poll.
|
||||||
|
- **Mechanics** (`HikvisionCamera.syncClock`): `GET /ISAPI/System/time`, parse `localTime`; drift ≤
|
||||||
|
60s → leave alone. Beyond that → `PUT /ISAPI/System/time` with `timeMode=manual`, the site's
|
||||||
|
wall-clock now WITH explicit utc offset (`localIsoWithOffset(siteTz)`, e.g.
|
||||||
|
`2026-07-07T15:30:22+02:00` — the offset makes the instant unambiguous), and the camera's own
|
||||||
|
`timeZone` string **echoed back verbatim** (we correct the clock, never fight its tz/DST config).
|
||||||
|
An unparseable camera reply counts as infinite drift → sync.
|
||||||
|
- **Visibility:** a sync after a big jump (>1h — the power-cut signature) logs at **warn**
|
||||||
|
(persisted to app_logs); small corrections log info. Failures log warn.
|
||||||
|
- **Scope:** Hikvision only (`isClockSyncable` capability guard); the Dahua driver's CGI has no
|
||||||
|
ISAPI time endpoint — a Dahua clock sync would be its own driver work.
|
||||||
|
- Rejected alternative: camera-side **NTP against the booth** (chrony on the appliance). More
|
||||||
|
standard, but adds a provisioning dependency per booth and the camera polls NTP on ITS schedule
|
||||||
|
— a freshly power-cycled camera could still sit at 1970 for a while, which is precisely the
|
||||||
|
moment that matters.
|
||||||
|
|
||||||
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
||||||
|
|
||||||
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
||||||
|
|||||||
+11
@@ -2505,3 +2505,14 @@ GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/mod
|
|||||||
devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
|
devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
|
||||||
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
|
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
|
||||||
longer hardcodes lp0. Details on [[printer-usb-transport]].
|
longer hardcodes lp0. Details on [[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-07-07] update | Camera clock sync via ISAPI — the 1970 power-cut reset healed
|
||||||
|
|
||||||
|
park-buzi observation: power-cut Hik cameras reboot at the 1970 epoch (no RTC battery, no NTP)
|
||||||
|
until a web-UI login pushes the browser clock — corrupting snapshot OSD timestamps (evidence) and
|
||||||
|
ANPR push times meanwhile. Built host-as-time-authority sync (details on [[lpr-camera]] §Clock
|
||||||
|
sync): device monitor triggers at the offline→ready edge + 24h backstop; HikvisionCamera.syncClock
|
||||||
|
GETs /ISAPI/System/time, and beyond 60s drift PUTs manual time with the site's wall-clock + explicit
|
||||||
|
offset, echoing the camera's timeZone verbatim; >1h jumps log warn (persisted). digest client
|
||||||
|
generalised GET→GET/PUT/POST with body (the handshake was already method-aware). Capability-guarded
|
||||||
|
(isClockSyncable — hikvision only). 8 new tests (5 devices, 3 tz-offset).
|
||||||
|
|||||||
Reference in New Issue
Block a user