@camstack/addon-provider-hikvision 1.2.31 → 1.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +292 -40
  2. package/dist/addon.mjs +292 -40
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -6,7 +6,7 @@ let node_crypto = require("node:crypto");
6
6
  let node_http = require("node:http");
7
7
  let node_https = require("node:https");
8
8
  let node_os = require("node:os");
9
- //#region ../types/dist/event-category-XfKNtfCc.mjs
9
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
10
10
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
11
11
  EventCategory["SystemBoot"] = "system.boot";
12
12
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -22,6 +22,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
22
22
  */
23
23
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
24
24
  /**
25
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
26
+ * Emitted only when the material on disk actually changed, so an
27
+ * operator who trusted the old certificate by hand is told rather than
28
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
29
+ *
30
+ * Rule: docs/decisions/adr-0227-*.md
31
+ */
32
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
33
+ /**
25
34
  * A newer addon or server-root package version was found by the
26
35
  * authoritative registry check. Emitted once when any observed
27
36
  * `latestVersion` changes (or a package/node first appears behind);
@@ -19163,6 +19172,12 @@ var CameraStatusSchema = object({
19163
19172
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19164
19173
  fetchedAt: number()
19165
19174
  });
19175
+ var InferenceDeviceExclusionReasonSchema = _enum([
19176
+ "disabled",
19177
+ "unavailable",
19178
+ "cannot-host-camera-root",
19179
+ "accelerator-preferred"
19180
+ ]);
19166
19181
  var NodeInferenceDeviceSchema = object({
19167
19182
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19168
19183
  key: string(),
@@ -19193,7 +19208,17 @@ var NodeInferenceDeviceSchema = object({
19193
19208
  * available per format; this is the stored selection that becomes the
19194
19209
  * default for EVERY camera landing on this accelerator.
19195
19210
  */
19196
- steps: record(string(), DeviceStepConfigSchema).optional()
19211
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19212
+ /**
19213
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19214
+ * the reason the dispatcher drops it. Computed by the SAME
19215
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19216
+ * never disagree with the election — deriving it in the UI from
19217
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19218
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19219
+ * "an accelerator is serving" predicate).
19220
+ */
19221
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19197
19222
  });
19198
19223
  var NodeInferenceDevicesSchema = object({
19199
19224
  nodeId: string(),
@@ -24148,7 +24173,12 @@ var ListResultSchema = object({
24148
24173
  probedAt: number()
24149
24174
  });
24150
24175
  var PreferredSchema = LocalInterfaceSchema.nullable();
24151
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24176
+ /**
24177
+ * Candidate base URL for the SDK to race on connect. Order matters —
24178
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24179
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24180
+ */
24181
+ var ConnectionEndpointSchema = object({
24152
24182
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24153
24183
  label: string(),
24154
24184
  /** Fully-formed base URL with scheme + host + port. */
@@ -24191,7 +24221,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24191
24221
  * ordering between polls.
24192
24222
  */
24193
24223
  priority: number()
24194
- })).readonly() });
24224
+ });
24225
+ /**
24226
+ * Where the advertised local port came from. Ordered most → least
24227
+ * authoritative, and the whole point of returning it: a client must be able to
24228
+ * tell a FACT about the hub's socket from an echo of its own guess.
24229
+ */
24230
+ var LocalPortSourceEnum = _enum([
24231
+ "server-config",
24232
+ "server-env",
24233
+ "caller-hint",
24234
+ "default"
24235
+ ]);
24236
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24237
+ var AdvertisedLocalPortSchema = object({
24238
+ port: number().int().min(1).max(65535),
24239
+ source: LocalPortSourceEnum
24240
+ });
24241
+ var GetConnectionEndpointsResultSchema = object({
24242
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24243
+ /**
24244
+ * The port the hub built the LAN/loopback URLs with, and where that number
24245
+ * came from.
24246
+ *
24247
+ * Returned rather than merely applied, because "the URL is right" and "the
24248
+ * client can KNOW the URL is right" are different properties. A client that
24249
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24250
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24251
+ * race the candidate or discard it. With `source` it can: anything but
24252
+ * `caller-hint` is the hub's own socket.
24253
+ *
24254
+ * Absent on hubs predating this field — a client that finds it missing is
24255
+ * talking to an echoing hub and must degrade exactly as it does for
24256
+ * `caller-hint`.
24257
+ */
24258
+ localPort: AdvertisedLocalPortSchema
24259
+ });
24195
24260
  /**
24196
24261
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24197
24262
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24212,8 +24277,13 @@ var AllowedAddressesSchema = object({
24212
24277
  */
24213
24278
  addresses: array(string()).readonly() });
24214
24279
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24215
- /** Local hub HTTP port to use in base URLs. */
24216
- port: number().int().min(1).max(65535),
24280
+ /**
24281
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24282
+ * written against the echoing contract keep working; the hub uses it
24283
+ * only when it cannot read its own port, and says so via
24284
+ * `localPort.source === 'caller-hint'`.
24285
+ */
24286
+ port: number().int().min(1).max(65535).optional(),
24217
24287
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24218
24288
  * candidate. Default `true`. */
24219
24289
  includeLoopback: boolean().optional(),
@@ -30432,11 +30502,63 @@ function startReachabilityPoll(options) {
30432
30502
  } };
30433
30503
  }
30434
30504
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30505
+ /**
30506
+ * How long a bridge stops re-attempting a refresh that did not land.
30507
+ *
30508
+ * Sized against the failure it exists for: a camera whose control plane
30509
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30510
+ * neighbour never answers, up to the client's own timeout otherwise) on
30511
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30512
+ * without a cooldown the slice is permanently stale and EVERY read pays
30513
+ * that price. One attempt per minute is enough to notice the camera
30514
+ * coming back; per-read is enough to stall the viewer's first paint.
30515
+ */
30516
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30517
+ /**
30518
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30519
+ *
30520
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30521
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30522
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30523
+ * microphone on) change when an operator changes them, through a `setMask` /
30524
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30525
+ * A write is therefore its own invalidation: the window never delays an
30526
+ * operator's own change, however long it is.
30527
+ *
30528
+ * What the window really bounds is the ONE case we do not write: somebody
30529
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30530
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30531
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30532
+ * minute instead of 696
30533
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30534
+ *
30535
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30536
+ * On the awaiting default a window this long does not remove the stall, it
30537
+ * merely makes it rarer and just as long.
30538
+ */
30539
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30435
30540
  function createRuntimeStateBridge(params) {
30436
30541
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30437
30542
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30543
+ const staleRead = params.staleRead ?? "await-refresh";
30438
30544
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30439
30545
  let missCooldownUntil = 0;
30546
+ /**
30547
+ * The refresh this bridge currently has in the air, if any.
30548
+ *
30549
+ * Providers single-flight their own camera client, so this is not what stops
30550
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30551
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30552
+ * answered from the slice at once — even a cold, empty slice, which reports
30553
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30554
+ *
30555
+ * That distinction is the whole cost of an unreachable camera. Measured on
30556
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30557
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30558
+ * joined the wait and spent the caller's full 1.2s source budget — five
30559
+ * stalled polls per cooldown cycle, for one camera, forever.
30560
+ */
30561
+ let refreshInFlight = null;
30440
30562
  const readFetchedAt = () => {
30441
30563
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30442
30564
  return typeof value === "number" ? value : 0;
@@ -30457,14 +30579,14 @@ function createRuntimeStateBridge(params) {
30457
30579
  }
30458
30580
  });
30459
30581
  };
30460
- const ensureFresh = async () => {
30461
- const slice = runtimeState.getCapState(cap.name);
30462
- const fetchedAt = readFetchedAt();
30463
- if (slice && Date.now() - fetchedAt <= staleMs) {
30464
- missCooldownUntil = 0;
30465
- return;
30466
- }
30467
- if (Date.now() < missCooldownUntil) return;
30582
+ /**
30583
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30584
+ *
30585
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30586
+ * evidence the bridge has that the refresh persisted
30587
+ * anything, since providers swallow their own camera errors.
30588
+ */
30589
+ const runRefresh = async (fetchedAt) => {
30468
30590
  try {
30469
30591
  await refresh();
30470
30592
  } catch (err) {
@@ -30480,6 +30602,41 @@ function createRuntimeStateBridge(params) {
30480
30602
  }
30481
30603
  openMissCooldown(void 0);
30482
30604
  };
30605
+ /**
30606
+ * Start a refresh and remember it, at most one at a time.
30607
+ *
30608
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30609
+ * from the slice either way, so a floating rejection would take the process
30610
+ * down for a fault the miss cooldown has already recorded and logged.
30611
+ */
30612
+ const startRefresh = (fetchedAt) => {
30613
+ const existing = refreshInFlight;
30614
+ if (existing !== null) return existing;
30615
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30616
+ refreshInFlight = null;
30617
+ });
30618
+ refreshInFlight = started;
30619
+ return started;
30620
+ };
30621
+ const ensureFresh = async () => {
30622
+ const slice = runtimeState.getCapState(cap.name);
30623
+ const fetchedAt = readFetchedAt();
30624
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30625
+ missCooldownUntil = 0;
30626
+ return;
30627
+ }
30628
+ if (Date.now() < missCooldownUntil) return;
30629
+ if (staleRead === "serve-and-revalidate") {
30630
+ if (refreshInFlight !== null) return;
30631
+ if (slice && fetchedAt > 0) {
30632
+ startRefresh(fetchedAt);
30633
+ return;
30634
+ }
30635
+ await startRefresh(fetchedAt);
30636
+ return;
30637
+ }
30638
+ await runRefresh(fetchedAt);
30639
+ };
30483
30640
  const projectStatus = () => {
30484
30641
  const slice = runtimeState.getCapState(cap.name);
30485
30642
  if (!slice) return empty();
@@ -42336,6 +42493,29 @@ var HikvisionDeviceCacheSchema = object({
42336
42493
  * `DeviceFeature.TwoWayAudio` + the `intercom` cap registration.
42337
42494
  */
42338
42495
  hasIntercom: boolean().optional(),
42496
+ /**
42497
+ * The `privacy-mask` cap's per-camera AVAILABILITY facts, as the last
42498
+ * successful read saw them.
42499
+ *
42500
+ * Persisted for the same reason Reolink persists its `capOptionsSnapshot`:
42501
+ * `privacyMask.getOptions` sits on a POLLED path (the admin Cameras badge
42502
+ * asks it per camera every 5s), and these describe what the model can do —
42503
+ * they change on a firmware update, not during operation. Reading them from
42504
+ * here costs no ISAPI round trip, and it survives an addon restart, which
42505
+ * an in-process value does not.
42506
+ *
42507
+ * They come out of the SAME two reads that build the cap's status slice, so
42508
+ * they cannot drift from it.
42509
+ */
42510
+ privacyMaskOptions: object({
42511
+ /** Zones the firmware advertises. */
42512
+ maxRegions: number().int().nonnegative(),
42513
+ /** True only when a streaming channel reported an audio flag we can
42514
+ * patch — a switch that writes nothing must never be offered. */
42515
+ supportsAudioMute: boolean(),
42516
+ /** Epoch ms of the read that produced this. */
42517
+ fetchedAt: number()
42518
+ }).optional(),
42339
42519
  /** ISAPI codec the firmware reports for the talk channel (e.g.
42340
42520
  * `G.711ulaw`, `G.711alaw`). Persisted so the intercom session
42341
42521
  * doesn't have to re-discover on every open. Optional — discovery
@@ -44338,7 +44518,68 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44338
44518
  registerPrivacyMaskCap(cameraNumber) {
44339
44519
  const isapi = this.ensureClient();
44340
44520
  const CAP_NAME = "privacy-mask";
44341
- const STALE_MS = 1e4;
44521
+ /**
44522
+ * The per-camera AVAILABILITY facts, as the last successful read saw them.
44523
+ *
44524
+ * `getOptions` used to make its own pair of ISAPI calls on every
44525
+ * invocation — and `getCameraStatuses` calls it once per camera per 5s
44526
+ * poll, so on this fleet (20 of 29 cameras are Hikvision) it was the
44527
+ * single largest source of camera traffic on the badge path.
44528
+ *
44529
+ * It is not a second copy of anything: `maxRegions` and the audio-channel
44530
+ * census live nowhere else, and they come out of the SAME two reads that
44531
+ * build the status slice, so they cannot disagree with it. They are kept in
44532
+ * the persisted `deviceCache` rather than only in memory because they must
44533
+ * survive an addon restart — the status slice does (`durability:
44534
+ * 'restored'`), and an options read that did not would be the one caller
44535
+ * still dialling every camera after every deploy.
44536
+ */
44537
+ const readPersistedOptions = () => {
44538
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44539
+ if (!stored) return null;
44540
+ return {
44541
+ maxRegions: stored.maxRegions,
44542
+ supportedShapes: ["rect", "polygon"],
44543
+ polygonVertices: {
44544
+ min: 4,
44545
+ max: 4
44546
+ },
44547
+ supportsAudioMute: stored.supportsAudioMute
44548
+ };
44549
+ };
44550
+ /**
44551
+ * Epoch ms until which a COLD options probe is not re-attempted.
44552
+ *
44553
+ * Only the cold path below can dial, and only on a camera that has never
44554
+ * produced a descriptor — but "never" and "unreachable" are the same state,
44555
+ * so without this an unreachable camera would be dialled once per poll
44556
+ * forever. Same constant, same reasoning as the bridge's own miss cooldown.
44557
+ */
44558
+ let optionsProbeCooldownUntil = 0;
44559
+ /** Persist the descriptor, but only when it actually MOVED — this runs on
44560
+ * every refresh and a per-refresh SQLite commit for an unchanged model
44561
+ * capability is pure churn. Best-effort: a failed write costs the next
44562
+ * restart one probe. */
44563
+ const persistOptions = async (next) => {
44564
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44565
+ if (stored && stored.maxRegions === next.maxRegions && stored.supportsAudioMute === next.supportsAudioMute) return;
44566
+ try {
44567
+ const previous = this.config.get("deviceCache") ?? {};
44568
+ await this.config.setAll({ deviceCache: {
44569
+ ...previous,
44570
+ privacyMaskOptions: {
44571
+ maxRegions: next.maxRegions,
44572
+ supportsAudioMute: next.supportsAudioMute,
44573
+ fetchedAt: Date.now()
44574
+ }
44575
+ } });
44576
+ } catch (err) {
44577
+ this.ctx.logger.debug("hikvision privacy-mask options persist failed", {
44578
+ tags: { deviceId: this.id },
44579
+ meta: { error: err instanceof Error ? err.message : String(err) }
44580
+ });
44581
+ }
44582
+ };
44342
44583
  /** Round-trip the privacy-mask config → cap regions, write slice. */
44343
44584
  const refreshFromCamera = async () => {
44344
44585
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
@@ -44346,6 +44587,15 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44346
44587
  try {
44347
44588
  const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44348
44589
  if (!mask) return;
44590
+ await persistOptions({
44591
+ maxRegions: mask.maxRegions > 0 ? mask.maxRegions : 4,
44592
+ supportedShapes: ["rect", "polygon"],
44593
+ polygonVertices: {
44594
+ min: 4,
44595
+ max: 4
44596
+ },
44597
+ supportsAudioMute: audio.length > 0
44598
+ });
44349
44599
  const regions = mask.regions.map((region, index) => ({
44350
44600
  id: index,
44351
44601
  enabled: true,
@@ -44372,22 +44622,25 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44372
44622
  this.privacyMaskRefreshInFlight = null;
44373
44623
  }
44374
44624
  };
44625
+ const bridge = createRuntimeStateBridge({
44626
+ runtimeState: this.runtimeState,
44627
+ cap: privacyMaskCapability,
44628
+ ownDeviceId: this.id,
44629
+ refresh: refreshFromCamera,
44630
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
44631
+ staleRead: "serve-and-revalidate",
44632
+ logger: this.ctx.logger,
44633
+ empty: () => ({
44634
+ enabled: false,
44635
+ regions: [],
44636
+ audioEnabled: null,
44637
+ lastFetchedAt: 0
44638
+ })
44639
+ });
44375
44640
  const provider = {
44376
- getStatus: createRuntimeStateBridge({
44377
- runtimeState: this.runtimeState,
44378
- cap: privacyMaskCapability,
44379
- ownDeviceId: this.id,
44380
- refresh: refreshFromCamera,
44381
- staleMs: STALE_MS,
44382
- empty: () => ({
44383
- enabled: false,
44384
- regions: [],
44385
- audioEnabled: null,
44386
- lastFetchedAt: 0
44387
- })
44388
- }).getStatus,
44641
+ getStatus: bridge.getStatus,
44389
44642
  getOptions: async ({ deviceId }) => {
44390
- const cold = {
44643
+ if (deviceId !== this.id) return {
44391
44644
  maxRegions: 4,
44392
44645
  supportedShapes: ["rect", "polygon"],
44393
44646
  polygonVertices: {
@@ -44396,17 +44649,16 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44396
44649
  },
44397
44650
  supportsAudioMute: false
44398
44651
  };
44399
- if (deviceId !== this.id) return cold;
44400
- const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44401
- return {
44402
- maxRegions: mask && mask.maxRegions > 0 ? mask.maxRegions : 4,
44403
- supportedShapes: ["rect", "polygon"],
44404
- polygonVertices: {
44405
- min: 4,
44406
- max: 4
44407
- },
44408
- supportsAudioMute: audio.length > 0
44409
- };
44652
+ await bridge.ensureFresh();
44653
+ const warm = readPersistedOptions();
44654
+ if (warm !== null) return warm;
44655
+ if (Date.now() >= optionsProbeCooldownUntil) {
44656
+ optionsProbeCooldownUntil = Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS;
44657
+ await refreshFromCamera();
44658
+ const cold = readPersistedOptions();
44659
+ if (cold !== null) return cold;
44660
+ }
44661
+ throw new Error(`device ${String(this.id)}: privacy-mask options unknown — the camera has not answered`);
44410
44662
  },
44411
44663
  setMask: async ({ deviceId, patch }) => {
44412
44664
  if (deviceId !== this.id) return;
package/dist/addon.mjs CHANGED
@@ -7,7 +7,7 @@ import { networkInterfaces } from "node:os";
7
7
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
8
8
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
9
9
  //#endregion
10
- //#region ../types/dist/event-category-XfKNtfCc.mjs
10
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
11
11
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
12
12
  EventCategory["SystemBoot"] = "system.boot";
13
13
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -23,6 +23,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
23
23
  */
24
24
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
25
25
  /**
26
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
27
+ * Emitted only when the material on disk actually changed, so an
28
+ * operator who trusted the old certificate by hand is told rather than
29
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
30
+ *
31
+ * Rule: docs/decisions/adr-0227-*.md
32
+ */
33
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
34
+ /**
26
35
  * A newer addon or server-root package version was found by the
27
36
  * authoritative registry check. Emitted once when any observed
28
37
  * `latestVersion` changes (or a package/node first appears behind);
@@ -19164,6 +19173,12 @@ var CameraStatusSchema = object({
19164
19173
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19165
19174
  fetchedAt: number()
19166
19175
  });
19176
+ var InferenceDeviceExclusionReasonSchema = _enum([
19177
+ "disabled",
19178
+ "unavailable",
19179
+ "cannot-host-camera-root",
19180
+ "accelerator-preferred"
19181
+ ]);
19167
19182
  var NodeInferenceDeviceSchema = object({
19168
19183
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19169
19184
  key: string(),
@@ -19194,7 +19209,17 @@ var NodeInferenceDeviceSchema = object({
19194
19209
  * available per format; this is the stored selection that becomes the
19195
19210
  * default for EVERY camera landing on this accelerator.
19196
19211
  */
19197
- steps: record(string(), DeviceStepConfigSchema).optional()
19212
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19213
+ /**
19214
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19215
+ * the reason the dispatcher drops it. Computed by the SAME
19216
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19217
+ * never disagree with the election — deriving it in the UI from
19218
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19219
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19220
+ * "an accelerator is serving" predicate).
19221
+ */
19222
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19198
19223
  });
19199
19224
  var NodeInferenceDevicesSchema = object({
19200
19225
  nodeId: string(),
@@ -24149,7 +24174,12 @@ var ListResultSchema = object({
24149
24174
  probedAt: number()
24150
24175
  });
24151
24176
  var PreferredSchema = LocalInterfaceSchema.nullable();
24152
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24177
+ /**
24178
+ * Candidate base URL for the SDK to race on connect. Order matters —
24179
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24180
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24181
+ */
24182
+ var ConnectionEndpointSchema = object({
24153
24183
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24154
24184
  label: string(),
24155
24185
  /** Fully-formed base URL with scheme + host + port. */
@@ -24192,7 +24222,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24192
24222
  * ordering between polls.
24193
24223
  */
24194
24224
  priority: number()
24195
- })).readonly() });
24225
+ });
24226
+ /**
24227
+ * Where the advertised local port came from. Ordered most → least
24228
+ * authoritative, and the whole point of returning it: a client must be able to
24229
+ * tell a FACT about the hub's socket from an echo of its own guess.
24230
+ */
24231
+ var LocalPortSourceEnum = _enum([
24232
+ "server-config",
24233
+ "server-env",
24234
+ "caller-hint",
24235
+ "default"
24236
+ ]);
24237
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24238
+ var AdvertisedLocalPortSchema = object({
24239
+ port: number().int().min(1).max(65535),
24240
+ source: LocalPortSourceEnum
24241
+ });
24242
+ var GetConnectionEndpointsResultSchema = object({
24243
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24244
+ /**
24245
+ * The port the hub built the LAN/loopback URLs with, and where that number
24246
+ * came from.
24247
+ *
24248
+ * Returned rather than merely applied, because "the URL is right" and "the
24249
+ * client can KNOW the URL is right" are different properties. A client that
24250
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24251
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24252
+ * race the candidate or discard it. With `source` it can: anything but
24253
+ * `caller-hint` is the hub's own socket.
24254
+ *
24255
+ * Absent on hubs predating this field — a client that finds it missing is
24256
+ * talking to an echoing hub and must degrade exactly as it does for
24257
+ * `caller-hint`.
24258
+ */
24259
+ localPort: AdvertisedLocalPortSchema
24260
+ });
24196
24261
  /**
24197
24262
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24198
24263
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24213,8 +24278,13 @@ var AllowedAddressesSchema = object({
24213
24278
  */
24214
24279
  addresses: array(string()).readonly() });
24215
24280
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24216
- /** Local hub HTTP port to use in base URLs. */
24217
- port: number().int().min(1).max(65535),
24281
+ /**
24282
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24283
+ * written against the echoing contract keep working; the hub uses it
24284
+ * only when it cannot read its own port, and says so via
24285
+ * `localPort.source === 'caller-hint'`.
24286
+ */
24287
+ port: number().int().min(1).max(65535).optional(),
24218
24288
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24219
24289
  * candidate. Default `true`. */
24220
24290
  includeLoopback: boolean().optional(),
@@ -30433,11 +30503,63 @@ function startReachabilityPoll(options) {
30433
30503
  } };
30434
30504
  }
30435
30505
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30506
+ /**
30507
+ * How long a bridge stops re-attempting a refresh that did not land.
30508
+ *
30509
+ * Sized against the failure it exists for: a camera whose control plane
30510
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30511
+ * neighbour never answers, up to the client's own timeout otherwise) on
30512
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30513
+ * without a cooldown the slice is permanently stale and EVERY read pays
30514
+ * that price. One attempt per minute is enough to notice the camera
30515
+ * coming back; per-read is enough to stall the viewer's first paint.
30516
+ */
30517
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30518
+ /**
30519
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30520
+ *
30521
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30522
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30523
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30524
+ * microphone on) change when an operator changes them, through a `setMask` /
30525
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30526
+ * A write is therefore its own invalidation: the window never delays an
30527
+ * operator's own change, however long it is.
30528
+ *
30529
+ * What the window really bounds is the ONE case we do not write: somebody
30530
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30531
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30532
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30533
+ * minute instead of 696
30534
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30535
+ *
30536
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30537
+ * On the awaiting default a window this long does not remove the stall, it
30538
+ * merely makes it rarer and just as long.
30539
+ */
30540
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30436
30541
  function createRuntimeStateBridge(params) {
30437
30542
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30438
30543
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30544
+ const staleRead = params.staleRead ?? "await-refresh";
30439
30545
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30440
30546
  let missCooldownUntil = 0;
30547
+ /**
30548
+ * The refresh this bridge currently has in the air, if any.
30549
+ *
30550
+ * Providers single-flight their own camera client, so this is not what stops
30551
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30552
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30553
+ * answered from the slice at once — even a cold, empty slice, which reports
30554
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30555
+ *
30556
+ * That distinction is the whole cost of an unreachable camera. Measured on
30557
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30558
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30559
+ * joined the wait and spent the caller's full 1.2s source budget — five
30560
+ * stalled polls per cooldown cycle, for one camera, forever.
30561
+ */
30562
+ let refreshInFlight = null;
30441
30563
  const readFetchedAt = () => {
30442
30564
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30443
30565
  return typeof value === "number" ? value : 0;
@@ -30458,14 +30580,14 @@ function createRuntimeStateBridge(params) {
30458
30580
  }
30459
30581
  });
30460
30582
  };
30461
- const ensureFresh = async () => {
30462
- const slice = runtimeState.getCapState(cap.name);
30463
- const fetchedAt = readFetchedAt();
30464
- if (slice && Date.now() - fetchedAt <= staleMs) {
30465
- missCooldownUntil = 0;
30466
- return;
30467
- }
30468
- if (Date.now() < missCooldownUntil) return;
30583
+ /**
30584
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30585
+ *
30586
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30587
+ * evidence the bridge has that the refresh persisted
30588
+ * anything, since providers swallow their own camera errors.
30589
+ */
30590
+ const runRefresh = async (fetchedAt) => {
30469
30591
  try {
30470
30592
  await refresh();
30471
30593
  } catch (err) {
@@ -30481,6 +30603,41 @@ function createRuntimeStateBridge(params) {
30481
30603
  }
30482
30604
  openMissCooldown(void 0);
30483
30605
  };
30606
+ /**
30607
+ * Start a refresh and remember it, at most one at a time.
30608
+ *
30609
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30610
+ * from the slice either way, so a floating rejection would take the process
30611
+ * down for a fault the miss cooldown has already recorded and logged.
30612
+ */
30613
+ const startRefresh = (fetchedAt) => {
30614
+ const existing = refreshInFlight;
30615
+ if (existing !== null) return existing;
30616
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30617
+ refreshInFlight = null;
30618
+ });
30619
+ refreshInFlight = started;
30620
+ return started;
30621
+ };
30622
+ const ensureFresh = async () => {
30623
+ const slice = runtimeState.getCapState(cap.name);
30624
+ const fetchedAt = readFetchedAt();
30625
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30626
+ missCooldownUntil = 0;
30627
+ return;
30628
+ }
30629
+ if (Date.now() < missCooldownUntil) return;
30630
+ if (staleRead === "serve-and-revalidate") {
30631
+ if (refreshInFlight !== null) return;
30632
+ if (slice && fetchedAt > 0) {
30633
+ startRefresh(fetchedAt);
30634
+ return;
30635
+ }
30636
+ await startRefresh(fetchedAt);
30637
+ return;
30638
+ }
30639
+ await runRefresh(fetchedAt);
30640
+ };
30484
30641
  const projectStatus = () => {
30485
30642
  const slice = runtimeState.getCapState(cap.name);
30486
30643
  if (!slice) return empty();
@@ -42337,6 +42494,29 @@ var HikvisionDeviceCacheSchema = object({
42337
42494
  * `DeviceFeature.TwoWayAudio` + the `intercom` cap registration.
42338
42495
  */
42339
42496
  hasIntercom: boolean().optional(),
42497
+ /**
42498
+ * The `privacy-mask` cap's per-camera AVAILABILITY facts, as the last
42499
+ * successful read saw them.
42500
+ *
42501
+ * Persisted for the same reason Reolink persists its `capOptionsSnapshot`:
42502
+ * `privacyMask.getOptions` sits on a POLLED path (the admin Cameras badge
42503
+ * asks it per camera every 5s), and these describe what the model can do —
42504
+ * they change on a firmware update, not during operation. Reading them from
42505
+ * here costs no ISAPI round trip, and it survives an addon restart, which
42506
+ * an in-process value does not.
42507
+ *
42508
+ * They come out of the SAME two reads that build the cap's status slice, so
42509
+ * they cannot drift from it.
42510
+ */
42511
+ privacyMaskOptions: object({
42512
+ /** Zones the firmware advertises. */
42513
+ maxRegions: number().int().nonnegative(),
42514
+ /** True only when a streaming channel reported an audio flag we can
42515
+ * patch — a switch that writes nothing must never be offered. */
42516
+ supportsAudioMute: boolean(),
42517
+ /** Epoch ms of the read that produced this. */
42518
+ fetchedAt: number()
42519
+ }).optional(),
42340
42520
  /** ISAPI codec the firmware reports for the talk channel (e.g.
42341
42521
  * `G.711ulaw`, `G.711alaw`). Persisted so the intercom session
42342
42522
  * doesn't have to re-discover on every open. Optional — discovery
@@ -44339,7 +44519,68 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44339
44519
  registerPrivacyMaskCap(cameraNumber) {
44340
44520
  const isapi = this.ensureClient();
44341
44521
  const CAP_NAME = "privacy-mask";
44342
- const STALE_MS = 1e4;
44522
+ /**
44523
+ * The per-camera AVAILABILITY facts, as the last successful read saw them.
44524
+ *
44525
+ * `getOptions` used to make its own pair of ISAPI calls on every
44526
+ * invocation — and `getCameraStatuses` calls it once per camera per 5s
44527
+ * poll, so on this fleet (20 of 29 cameras are Hikvision) it was the
44528
+ * single largest source of camera traffic on the badge path.
44529
+ *
44530
+ * It is not a second copy of anything: `maxRegions` and the audio-channel
44531
+ * census live nowhere else, and they come out of the SAME two reads that
44532
+ * build the status slice, so they cannot disagree with it. They are kept in
44533
+ * the persisted `deviceCache` rather than only in memory because they must
44534
+ * survive an addon restart — the status slice does (`durability:
44535
+ * 'restored'`), and an options read that did not would be the one caller
44536
+ * still dialling every camera after every deploy.
44537
+ */
44538
+ const readPersistedOptions = () => {
44539
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44540
+ if (!stored) return null;
44541
+ return {
44542
+ maxRegions: stored.maxRegions,
44543
+ supportedShapes: ["rect", "polygon"],
44544
+ polygonVertices: {
44545
+ min: 4,
44546
+ max: 4
44547
+ },
44548
+ supportsAudioMute: stored.supportsAudioMute
44549
+ };
44550
+ };
44551
+ /**
44552
+ * Epoch ms until which a COLD options probe is not re-attempted.
44553
+ *
44554
+ * Only the cold path below can dial, and only on a camera that has never
44555
+ * produced a descriptor — but "never" and "unreachable" are the same state,
44556
+ * so without this an unreachable camera would be dialled once per poll
44557
+ * forever. Same constant, same reasoning as the bridge's own miss cooldown.
44558
+ */
44559
+ let optionsProbeCooldownUntil = 0;
44560
+ /** Persist the descriptor, but only when it actually MOVED — this runs on
44561
+ * every refresh and a per-refresh SQLite commit for an unchanged model
44562
+ * capability is pure churn. Best-effort: a failed write costs the next
44563
+ * restart one probe. */
44564
+ const persistOptions = async (next) => {
44565
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44566
+ if (stored && stored.maxRegions === next.maxRegions && stored.supportsAudioMute === next.supportsAudioMute) return;
44567
+ try {
44568
+ const previous = this.config.get("deviceCache") ?? {};
44569
+ await this.config.setAll({ deviceCache: {
44570
+ ...previous,
44571
+ privacyMaskOptions: {
44572
+ maxRegions: next.maxRegions,
44573
+ supportsAudioMute: next.supportsAudioMute,
44574
+ fetchedAt: Date.now()
44575
+ }
44576
+ } });
44577
+ } catch (err) {
44578
+ this.ctx.logger.debug("hikvision privacy-mask options persist failed", {
44579
+ tags: { deviceId: this.id },
44580
+ meta: { error: err instanceof Error ? err.message : String(err) }
44581
+ });
44582
+ }
44583
+ };
44343
44584
  /** Round-trip the privacy-mask config → cap regions, write slice. */
44344
44585
  const refreshFromCamera = async () => {
44345
44586
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
@@ -44347,6 +44588,15 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44347
44588
  try {
44348
44589
  const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44349
44590
  if (!mask) return;
44591
+ await persistOptions({
44592
+ maxRegions: mask.maxRegions > 0 ? mask.maxRegions : 4,
44593
+ supportedShapes: ["rect", "polygon"],
44594
+ polygonVertices: {
44595
+ min: 4,
44596
+ max: 4
44597
+ },
44598
+ supportsAudioMute: audio.length > 0
44599
+ });
44350
44600
  const regions = mask.regions.map((region, index) => ({
44351
44601
  id: index,
44352
44602
  enabled: true,
@@ -44373,22 +44623,25 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44373
44623
  this.privacyMaskRefreshInFlight = null;
44374
44624
  }
44375
44625
  };
44626
+ const bridge = createRuntimeStateBridge({
44627
+ runtimeState: this.runtimeState,
44628
+ cap: privacyMaskCapability,
44629
+ ownDeviceId: this.id,
44630
+ refresh: refreshFromCamera,
44631
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
44632
+ staleRead: "serve-and-revalidate",
44633
+ logger: this.ctx.logger,
44634
+ empty: () => ({
44635
+ enabled: false,
44636
+ regions: [],
44637
+ audioEnabled: null,
44638
+ lastFetchedAt: 0
44639
+ })
44640
+ });
44376
44641
  const provider = {
44377
- getStatus: createRuntimeStateBridge({
44378
- runtimeState: this.runtimeState,
44379
- cap: privacyMaskCapability,
44380
- ownDeviceId: this.id,
44381
- refresh: refreshFromCamera,
44382
- staleMs: STALE_MS,
44383
- empty: () => ({
44384
- enabled: false,
44385
- regions: [],
44386
- audioEnabled: null,
44387
- lastFetchedAt: 0
44388
- })
44389
- }).getStatus,
44642
+ getStatus: bridge.getStatus,
44390
44643
  getOptions: async ({ deviceId }) => {
44391
- const cold = {
44644
+ if (deviceId !== this.id) return {
44392
44645
  maxRegions: 4,
44393
44646
  supportedShapes: ["rect", "polygon"],
44394
44647
  polygonVertices: {
@@ -44397,17 +44650,16 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44397
44650
  },
44398
44651
  supportsAudioMute: false
44399
44652
  };
44400
- if (deviceId !== this.id) return cold;
44401
- const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44402
- return {
44403
- maxRegions: mask && mask.maxRegions > 0 ? mask.maxRegions : 4,
44404
- supportedShapes: ["rect", "polygon"],
44405
- polygonVertices: {
44406
- min: 4,
44407
- max: 4
44408
- },
44409
- supportsAudioMute: audio.length > 0
44410
- };
44653
+ await bridge.ensureFresh();
44654
+ const warm = readPersistedOptions();
44655
+ if (warm !== null) return warm;
44656
+ if (Date.now() >= optionsProbeCooldownUntil) {
44657
+ optionsProbeCooldownUntil = Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS;
44658
+ await refreshFromCamera();
44659
+ const cold = readPersistedOptions();
44660
+ if (cold !== null) return cold;
44661
+ }
44662
+ throw new Error(`device ${String(this.id)}: privacy-mask options unknown — the camera has not answered`);
44411
44663
  },
44412
44664
  setMask: async ({ deviceId, patch }) => {
44413
44665
  if (deviceId !== this.id) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-hikvision",
3
- "version": "1.2.31",
3
+ "version": "1.2.33",
4
4
  "description": "Hikvision camera device provider addon for CamStack — ISAPI over HTTP(S) with digest auth (snapshot, alarm stream, RTSP discovery)",
5
5
  "keywords": [
6
6
  "camstack",