feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
Build desktop / desktop (push) Successful in 4m18s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m51s

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:
2026-07-07 12:56:51 +02:00
parent 7f42805e8d
commit 6ceaadfbf2
8 changed files with 324 additions and 16 deletions
+24
View File
@@ -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",
);
});
});
+68 -2
View File
@@ -1,9 +1,10 @@
import type { FastifyBaseLogger } from "fastify";
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 { directionOf, relaysOf } from "./device-resolve.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,
* 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);
// 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
* tokens the client localises next to the category:
@@ -139,6 +174,7 @@ export class DeviceMonitor {
};
let next: DeviceStatusEvent;
let device: Device | null = null;
const driver = registry.get(row.driverId);
if (!driver) {
// 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() };
} else {
try {
const device = driver.create(cfg as never);
device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
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);
}
@@ -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). */
#publish(id: string, next: DeviceStatusEvent): void {
const prev = this.#latest.get(id);
+67 -1
View File
@@ -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);
});
});
+70 -2
View File
@@ -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`,
+31 -10
View File
@@ -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" });
}
+24
View File
@@ -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";
}
+29 -1
View File
@@ -2,7 +2,7 @@
type: entity
tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture]
updated: 2026-06-27
updated: 2026-07-07
---
# 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
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)
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
+11
View File
@@ -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-
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
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).