@camstack/addon-provider-hikvision 1.2.31 → 1.2.32

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 +282 -39
  2. package/dist/addon.mjs +282 -39
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -19163,6 +19163,12 @@ var CameraStatusSchema = object({
19163
19163
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19164
19164
  fetchedAt: number()
19165
19165
  });
19166
+ var InferenceDeviceExclusionReasonSchema = _enum([
19167
+ "disabled",
19168
+ "unavailable",
19169
+ "cannot-host-camera-root",
19170
+ "accelerator-preferred"
19171
+ ]);
19166
19172
  var NodeInferenceDeviceSchema = object({
19167
19173
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19168
19174
  key: string(),
@@ -19193,7 +19199,17 @@ var NodeInferenceDeviceSchema = object({
19193
19199
  * available per format; this is the stored selection that becomes the
19194
19200
  * default for EVERY camera landing on this accelerator.
19195
19201
  */
19196
- steps: record(string(), DeviceStepConfigSchema).optional()
19202
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19203
+ /**
19204
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19205
+ * the reason the dispatcher drops it. Computed by the SAME
19206
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19207
+ * never disagree with the election — deriving it in the UI from
19208
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19209
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19210
+ * "an accelerator is serving" predicate).
19211
+ */
19212
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19197
19213
  });
19198
19214
  var NodeInferenceDevicesSchema = object({
19199
19215
  nodeId: string(),
@@ -24148,7 +24164,12 @@ var ListResultSchema = object({
24148
24164
  probedAt: number()
24149
24165
  });
24150
24166
  var PreferredSchema = LocalInterfaceSchema.nullable();
24151
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24167
+ /**
24168
+ * Candidate base URL for the SDK to race on connect. Order matters —
24169
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24170
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24171
+ */
24172
+ var ConnectionEndpointSchema = object({
24152
24173
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24153
24174
  label: string(),
24154
24175
  /** Fully-formed base URL with scheme + host + port. */
@@ -24191,7 +24212,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24191
24212
  * ordering between polls.
24192
24213
  */
24193
24214
  priority: number()
24194
- })).readonly() });
24215
+ });
24216
+ /**
24217
+ * Where the advertised local port came from. Ordered most → least
24218
+ * authoritative, and the whole point of returning it: a client must be able to
24219
+ * tell a FACT about the hub's socket from an echo of its own guess.
24220
+ */
24221
+ var LocalPortSourceEnum = _enum([
24222
+ "server-config",
24223
+ "server-env",
24224
+ "caller-hint",
24225
+ "default"
24226
+ ]);
24227
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24228
+ var AdvertisedLocalPortSchema = object({
24229
+ port: number().int().min(1).max(65535),
24230
+ source: LocalPortSourceEnum
24231
+ });
24232
+ var GetConnectionEndpointsResultSchema = object({
24233
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24234
+ /**
24235
+ * The port the hub built the LAN/loopback URLs with, and where that number
24236
+ * came from.
24237
+ *
24238
+ * Returned rather than merely applied, because "the URL is right" and "the
24239
+ * client can KNOW the URL is right" are different properties. A client that
24240
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24241
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24242
+ * race the candidate or discard it. With `source` it can: anything but
24243
+ * `caller-hint` is the hub's own socket.
24244
+ *
24245
+ * Absent on hubs predating this field — a client that finds it missing is
24246
+ * talking to an echoing hub and must degrade exactly as it does for
24247
+ * `caller-hint`.
24248
+ */
24249
+ localPort: AdvertisedLocalPortSchema
24250
+ });
24195
24251
  /**
24196
24252
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24197
24253
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24212,8 +24268,13 @@ var AllowedAddressesSchema = object({
24212
24268
  */
24213
24269
  addresses: array(string()).readonly() });
24214
24270
  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),
24271
+ /**
24272
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24273
+ * written against the echoing contract keep working; the hub uses it
24274
+ * only when it cannot read its own port, and says so via
24275
+ * `localPort.source === 'caller-hint'`.
24276
+ */
24277
+ port: number().int().min(1).max(65535).optional(),
24217
24278
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24218
24279
  * candidate. Default `true`. */
24219
24280
  includeLoopback: boolean().optional(),
@@ -30432,11 +30493,63 @@ function startReachabilityPoll(options) {
30432
30493
  } };
30433
30494
  }
30434
30495
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30496
+ /**
30497
+ * How long a bridge stops re-attempting a refresh that did not land.
30498
+ *
30499
+ * Sized against the failure it exists for: a camera whose control plane
30500
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30501
+ * neighbour never answers, up to the client's own timeout otherwise) on
30502
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30503
+ * without a cooldown the slice is permanently stale and EVERY read pays
30504
+ * that price. One attempt per minute is enough to notice the camera
30505
+ * coming back; per-read is enough to stall the viewer's first paint.
30506
+ */
30507
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30508
+ /**
30509
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30510
+ *
30511
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30512
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30513
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30514
+ * microphone on) change when an operator changes them, through a `setMask` /
30515
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30516
+ * A write is therefore its own invalidation: the window never delays an
30517
+ * operator's own change, however long it is.
30518
+ *
30519
+ * What the window really bounds is the ONE case we do not write: somebody
30520
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30521
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30522
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30523
+ * minute instead of 696
30524
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30525
+ *
30526
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30527
+ * On the awaiting default a window this long does not remove the stall, it
30528
+ * merely makes it rarer and just as long.
30529
+ */
30530
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30435
30531
  function createRuntimeStateBridge(params) {
30436
30532
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30437
30533
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30534
+ const staleRead = params.staleRead ?? "await-refresh";
30438
30535
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30439
30536
  let missCooldownUntil = 0;
30537
+ /**
30538
+ * The refresh this bridge currently has in the air, if any.
30539
+ *
30540
+ * Providers single-flight their own camera client, so this is not what stops
30541
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30542
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30543
+ * answered from the slice at once — even a cold, empty slice, which reports
30544
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30545
+ *
30546
+ * That distinction is the whole cost of an unreachable camera. Measured on
30547
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30548
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30549
+ * joined the wait and spent the caller's full 1.2s source budget — five
30550
+ * stalled polls per cooldown cycle, for one camera, forever.
30551
+ */
30552
+ let refreshInFlight = null;
30440
30553
  const readFetchedAt = () => {
30441
30554
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30442
30555
  return typeof value === "number" ? value : 0;
@@ -30457,14 +30570,14 @@ function createRuntimeStateBridge(params) {
30457
30570
  }
30458
30571
  });
30459
30572
  };
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;
30573
+ /**
30574
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30575
+ *
30576
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30577
+ * evidence the bridge has that the refresh persisted
30578
+ * anything, since providers swallow their own camera errors.
30579
+ */
30580
+ const runRefresh = async (fetchedAt) => {
30468
30581
  try {
30469
30582
  await refresh();
30470
30583
  } catch (err) {
@@ -30480,6 +30593,41 @@ function createRuntimeStateBridge(params) {
30480
30593
  }
30481
30594
  openMissCooldown(void 0);
30482
30595
  };
30596
+ /**
30597
+ * Start a refresh and remember it, at most one at a time.
30598
+ *
30599
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30600
+ * from the slice either way, so a floating rejection would take the process
30601
+ * down for a fault the miss cooldown has already recorded and logged.
30602
+ */
30603
+ const startRefresh = (fetchedAt) => {
30604
+ const existing = refreshInFlight;
30605
+ if (existing !== null) return existing;
30606
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30607
+ refreshInFlight = null;
30608
+ });
30609
+ refreshInFlight = started;
30610
+ return started;
30611
+ };
30612
+ const ensureFresh = async () => {
30613
+ const slice = runtimeState.getCapState(cap.name);
30614
+ const fetchedAt = readFetchedAt();
30615
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30616
+ missCooldownUntil = 0;
30617
+ return;
30618
+ }
30619
+ if (Date.now() < missCooldownUntil) return;
30620
+ if (staleRead === "serve-and-revalidate") {
30621
+ if (refreshInFlight !== null) return;
30622
+ if (slice && fetchedAt > 0) {
30623
+ startRefresh(fetchedAt);
30624
+ return;
30625
+ }
30626
+ await startRefresh(fetchedAt);
30627
+ return;
30628
+ }
30629
+ await runRefresh(fetchedAt);
30630
+ };
30483
30631
  const projectStatus = () => {
30484
30632
  const slice = runtimeState.getCapState(cap.name);
30485
30633
  if (!slice) return empty();
@@ -42336,6 +42484,29 @@ var HikvisionDeviceCacheSchema = object({
42336
42484
  * `DeviceFeature.TwoWayAudio` + the `intercom` cap registration.
42337
42485
  */
42338
42486
  hasIntercom: boolean().optional(),
42487
+ /**
42488
+ * The `privacy-mask` cap's per-camera AVAILABILITY facts, as the last
42489
+ * successful read saw them.
42490
+ *
42491
+ * Persisted for the same reason Reolink persists its `capOptionsSnapshot`:
42492
+ * `privacyMask.getOptions` sits on a POLLED path (the admin Cameras badge
42493
+ * asks it per camera every 5s), and these describe what the model can do —
42494
+ * they change on a firmware update, not during operation. Reading them from
42495
+ * here costs no ISAPI round trip, and it survives an addon restart, which
42496
+ * an in-process value does not.
42497
+ *
42498
+ * They come out of the SAME two reads that build the cap's status slice, so
42499
+ * they cannot drift from it.
42500
+ */
42501
+ privacyMaskOptions: object({
42502
+ /** Zones the firmware advertises. */
42503
+ maxRegions: number().int().nonnegative(),
42504
+ /** True only when a streaming channel reported an audio flag we can
42505
+ * patch — a switch that writes nothing must never be offered. */
42506
+ supportsAudioMute: boolean(),
42507
+ /** Epoch ms of the read that produced this. */
42508
+ fetchedAt: number()
42509
+ }).optional(),
42339
42510
  /** ISAPI codec the firmware reports for the talk channel (e.g.
42340
42511
  * `G.711ulaw`, `G.711alaw`). Persisted so the intercom session
42341
42512
  * doesn't have to re-discover on every open. Optional — discovery
@@ -44338,7 +44509,68 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44338
44509
  registerPrivacyMaskCap(cameraNumber) {
44339
44510
  const isapi = this.ensureClient();
44340
44511
  const CAP_NAME = "privacy-mask";
44341
- const STALE_MS = 1e4;
44512
+ /**
44513
+ * The per-camera AVAILABILITY facts, as the last successful read saw them.
44514
+ *
44515
+ * `getOptions` used to make its own pair of ISAPI calls on every
44516
+ * invocation — and `getCameraStatuses` calls it once per camera per 5s
44517
+ * poll, so on this fleet (20 of 29 cameras are Hikvision) it was the
44518
+ * single largest source of camera traffic on the badge path.
44519
+ *
44520
+ * It is not a second copy of anything: `maxRegions` and the audio-channel
44521
+ * census live nowhere else, and they come out of the SAME two reads that
44522
+ * build the status slice, so they cannot disagree with it. They are kept in
44523
+ * the persisted `deviceCache` rather than only in memory because they must
44524
+ * survive an addon restart — the status slice does (`durability:
44525
+ * 'restored'`), and an options read that did not would be the one caller
44526
+ * still dialling every camera after every deploy.
44527
+ */
44528
+ const readPersistedOptions = () => {
44529
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44530
+ if (!stored) return null;
44531
+ return {
44532
+ maxRegions: stored.maxRegions,
44533
+ supportedShapes: ["rect", "polygon"],
44534
+ polygonVertices: {
44535
+ min: 4,
44536
+ max: 4
44537
+ },
44538
+ supportsAudioMute: stored.supportsAudioMute
44539
+ };
44540
+ };
44541
+ /**
44542
+ * Epoch ms until which a COLD options probe is not re-attempted.
44543
+ *
44544
+ * Only the cold path below can dial, and only on a camera that has never
44545
+ * produced a descriptor — but "never" and "unreachable" are the same state,
44546
+ * so without this an unreachable camera would be dialled once per poll
44547
+ * forever. Same constant, same reasoning as the bridge's own miss cooldown.
44548
+ */
44549
+ let optionsProbeCooldownUntil = 0;
44550
+ /** Persist the descriptor, but only when it actually MOVED — this runs on
44551
+ * every refresh and a per-refresh SQLite commit for an unchanged model
44552
+ * capability is pure churn. Best-effort: a failed write costs the next
44553
+ * restart one probe. */
44554
+ const persistOptions = async (next) => {
44555
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44556
+ if (stored && stored.maxRegions === next.maxRegions && stored.supportsAudioMute === next.supportsAudioMute) return;
44557
+ try {
44558
+ const previous = this.config.get("deviceCache") ?? {};
44559
+ await this.config.setAll({ deviceCache: {
44560
+ ...previous,
44561
+ privacyMaskOptions: {
44562
+ maxRegions: next.maxRegions,
44563
+ supportsAudioMute: next.supportsAudioMute,
44564
+ fetchedAt: Date.now()
44565
+ }
44566
+ } });
44567
+ } catch (err) {
44568
+ this.ctx.logger.debug("hikvision privacy-mask options persist failed", {
44569
+ tags: { deviceId: this.id },
44570
+ meta: { error: err instanceof Error ? err.message : String(err) }
44571
+ });
44572
+ }
44573
+ };
44342
44574
  /** Round-trip the privacy-mask config → cap regions, write slice. */
44343
44575
  const refreshFromCamera = async () => {
44344
44576
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
@@ -44346,6 +44578,15 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44346
44578
  try {
44347
44579
  const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44348
44580
  if (!mask) return;
44581
+ await persistOptions({
44582
+ maxRegions: mask.maxRegions > 0 ? mask.maxRegions : 4,
44583
+ supportedShapes: ["rect", "polygon"],
44584
+ polygonVertices: {
44585
+ min: 4,
44586
+ max: 4
44587
+ },
44588
+ supportsAudioMute: audio.length > 0
44589
+ });
44349
44590
  const regions = mask.regions.map((region, index) => ({
44350
44591
  id: index,
44351
44592
  enabled: true,
@@ -44372,22 +44613,25 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44372
44613
  this.privacyMaskRefreshInFlight = null;
44373
44614
  }
44374
44615
  };
44616
+ const bridge = createRuntimeStateBridge({
44617
+ runtimeState: this.runtimeState,
44618
+ cap: privacyMaskCapability,
44619
+ ownDeviceId: this.id,
44620
+ refresh: refreshFromCamera,
44621
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
44622
+ staleRead: "serve-and-revalidate",
44623
+ logger: this.ctx.logger,
44624
+ empty: () => ({
44625
+ enabled: false,
44626
+ regions: [],
44627
+ audioEnabled: null,
44628
+ lastFetchedAt: 0
44629
+ })
44630
+ });
44375
44631
  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,
44632
+ getStatus: bridge.getStatus,
44389
44633
  getOptions: async ({ deviceId }) => {
44390
- const cold = {
44634
+ if (deviceId !== this.id) return {
44391
44635
  maxRegions: 4,
44392
44636
  supportedShapes: ["rect", "polygon"],
44393
44637
  polygonVertices: {
@@ -44396,17 +44640,16 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44396
44640
  },
44397
44641
  supportsAudioMute: false
44398
44642
  };
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
- };
44643
+ await bridge.ensureFresh();
44644
+ const warm = readPersistedOptions();
44645
+ if (warm !== null) return warm;
44646
+ if (Date.now() >= optionsProbeCooldownUntil) {
44647
+ optionsProbeCooldownUntil = Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS;
44648
+ await refreshFromCamera();
44649
+ const cold = readPersistedOptions();
44650
+ if (cold !== null) return cold;
44651
+ }
44652
+ throw new Error(`device ${String(this.id)}: privacy-mask options unknown — the camera has not answered`);
44410
44653
  },
44411
44654
  setMask: async ({ deviceId, patch }) => {
44412
44655
  if (deviceId !== this.id) return;
package/dist/addon.mjs CHANGED
@@ -19164,6 +19164,12 @@ var CameraStatusSchema = object({
19164
19164
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19165
19165
  fetchedAt: number()
19166
19166
  });
19167
+ var InferenceDeviceExclusionReasonSchema = _enum([
19168
+ "disabled",
19169
+ "unavailable",
19170
+ "cannot-host-camera-root",
19171
+ "accelerator-preferred"
19172
+ ]);
19167
19173
  var NodeInferenceDeviceSchema = object({
19168
19174
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19169
19175
  key: string(),
@@ -19194,7 +19200,17 @@ var NodeInferenceDeviceSchema = object({
19194
19200
  * available per format; this is the stored selection that becomes the
19195
19201
  * default for EVERY camera landing on this accelerator.
19196
19202
  */
19197
- steps: record(string(), DeviceStepConfigSchema).optional()
19203
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19204
+ /**
19205
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19206
+ * the reason the dispatcher drops it. Computed by the SAME
19207
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19208
+ * never disagree with the election — deriving it in the UI from
19209
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19210
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19211
+ * "an accelerator is serving" predicate).
19212
+ */
19213
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19198
19214
  });
19199
19215
  var NodeInferenceDevicesSchema = object({
19200
19216
  nodeId: string(),
@@ -24149,7 +24165,12 @@ var ListResultSchema = object({
24149
24165
  probedAt: number()
24150
24166
  });
24151
24167
  var PreferredSchema = LocalInterfaceSchema.nullable();
24152
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24168
+ /**
24169
+ * Candidate base URL for the SDK to race on connect. Order matters —
24170
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24171
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24172
+ */
24173
+ var ConnectionEndpointSchema = object({
24153
24174
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24154
24175
  label: string(),
24155
24176
  /** Fully-formed base URL with scheme + host + port. */
@@ -24192,7 +24213,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24192
24213
  * ordering between polls.
24193
24214
  */
24194
24215
  priority: number()
24195
- })).readonly() });
24216
+ });
24217
+ /**
24218
+ * Where the advertised local port came from. Ordered most → least
24219
+ * authoritative, and the whole point of returning it: a client must be able to
24220
+ * tell a FACT about the hub's socket from an echo of its own guess.
24221
+ */
24222
+ var LocalPortSourceEnum = _enum([
24223
+ "server-config",
24224
+ "server-env",
24225
+ "caller-hint",
24226
+ "default"
24227
+ ]);
24228
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24229
+ var AdvertisedLocalPortSchema = object({
24230
+ port: number().int().min(1).max(65535),
24231
+ source: LocalPortSourceEnum
24232
+ });
24233
+ var GetConnectionEndpointsResultSchema = object({
24234
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24235
+ /**
24236
+ * The port the hub built the LAN/loopback URLs with, and where that number
24237
+ * came from.
24238
+ *
24239
+ * Returned rather than merely applied, because "the URL is right" and "the
24240
+ * client can KNOW the URL is right" are different properties. A client that
24241
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24242
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24243
+ * race the candidate or discard it. With `source` it can: anything but
24244
+ * `caller-hint` is the hub's own socket.
24245
+ *
24246
+ * Absent on hubs predating this field — a client that finds it missing is
24247
+ * talking to an echoing hub and must degrade exactly as it does for
24248
+ * `caller-hint`.
24249
+ */
24250
+ localPort: AdvertisedLocalPortSchema
24251
+ });
24196
24252
  /**
24197
24253
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24198
24254
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24213,8 +24269,13 @@ var AllowedAddressesSchema = object({
24213
24269
  */
24214
24270
  addresses: array(string()).readonly() });
24215
24271
  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),
24272
+ /**
24273
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24274
+ * written against the echoing contract keep working; the hub uses it
24275
+ * only when it cannot read its own port, and says so via
24276
+ * `localPort.source === 'caller-hint'`.
24277
+ */
24278
+ port: number().int().min(1).max(65535).optional(),
24218
24279
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24219
24280
  * candidate. Default `true`. */
24220
24281
  includeLoopback: boolean().optional(),
@@ -30433,11 +30494,63 @@ function startReachabilityPoll(options) {
30433
30494
  } };
30434
30495
  }
30435
30496
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30497
+ /**
30498
+ * How long a bridge stops re-attempting a refresh that did not land.
30499
+ *
30500
+ * Sized against the failure it exists for: a camera whose control plane
30501
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30502
+ * neighbour never answers, up to the client's own timeout otherwise) on
30503
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30504
+ * without a cooldown the slice is permanently stale and EVERY read pays
30505
+ * that price. One attempt per minute is enough to notice the camera
30506
+ * coming back; per-read is enough to stall the viewer's first paint.
30507
+ */
30508
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30509
+ /**
30510
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30511
+ *
30512
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30513
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30514
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30515
+ * microphone on) change when an operator changes them, through a `setMask` /
30516
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30517
+ * A write is therefore its own invalidation: the window never delays an
30518
+ * operator's own change, however long it is.
30519
+ *
30520
+ * What the window really bounds is the ONE case we do not write: somebody
30521
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30522
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30523
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30524
+ * minute instead of 696
30525
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30526
+ *
30527
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30528
+ * On the awaiting default a window this long does not remove the stall, it
30529
+ * merely makes it rarer and just as long.
30530
+ */
30531
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30436
30532
  function createRuntimeStateBridge(params) {
30437
30533
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30438
30534
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30535
+ const staleRead = params.staleRead ?? "await-refresh";
30439
30536
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30440
30537
  let missCooldownUntil = 0;
30538
+ /**
30539
+ * The refresh this bridge currently has in the air, if any.
30540
+ *
30541
+ * Providers single-flight their own camera client, so this is not what stops
30542
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30543
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30544
+ * answered from the slice at once — even a cold, empty slice, which reports
30545
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30546
+ *
30547
+ * That distinction is the whole cost of an unreachable camera. Measured on
30548
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30549
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30550
+ * joined the wait and spent the caller's full 1.2s source budget — five
30551
+ * stalled polls per cooldown cycle, for one camera, forever.
30552
+ */
30553
+ let refreshInFlight = null;
30441
30554
  const readFetchedAt = () => {
30442
30555
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30443
30556
  return typeof value === "number" ? value : 0;
@@ -30458,14 +30571,14 @@ function createRuntimeStateBridge(params) {
30458
30571
  }
30459
30572
  });
30460
30573
  };
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;
30574
+ /**
30575
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30576
+ *
30577
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30578
+ * evidence the bridge has that the refresh persisted
30579
+ * anything, since providers swallow their own camera errors.
30580
+ */
30581
+ const runRefresh = async (fetchedAt) => {
30469
30582
  try {
30470
30583
  await refresh();
30471
30584
  } catch (err) {
@@ -30481,6 +30594,41 @@ function createRuntimeStateBridge(params) {
30481
30594
  }
30482
30595
  openMissCooldown(void 0);
30483
30596
  };
30597
+ /**
30598
+ * Start a refresh and remember it, at most one at a time.
30599
+ *
30600
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30601
+ * from the slice either way, so a floating rejection would take the process
30602
+ * down for a fault the miss cooldown has already recorded and logged.
30603
+ */
30604
+ const startRefresh = (fetchedAt) => {
30605
+ const existing = refreshInFlight;
30606
+ if (existing !== null) return existing;
30607
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30608
+ refreshInFlight = null;
30609
+ });
30610
+ refreshInFlight = started;
30611
+ return started;
30612
+ };
30613
+ const ensureFresh = async () => {
30614
+ const slice = runtimeState.getCapState(cap.name);
30615
+ const fetchedAt = readFetchedAt();
30616
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30617
+ missCooldownUntil = 0;
30618
+ return;
30619
+ }
30620
+ if (Date.now() < missCooldownUntil) return;
30621
+ if (staleRead === "serve-and-revalidate") {
30622
+ if (refreshInFlight !== null) return;
30623
+ if (slice && fetchedAt > 0) {
30624
+ startRefresh(fetchedAt);
30625
+ return;
30626
+ }
30627
+ await startRefresh(fetchedAt);
30628
+ return;
30629
+ }
30630
+ await runRefresh(fetchedAt);
30631
+ };
30484
30632
  const projectStatus = () => {
30485
30633
  const slice = runtimeState.getCapState(cap.name);
30486
30634
  if (!slice) return empty();
@@ -42337,6 +42485,29 @@ var HikvisionDeviceCacheSchema = object({
42337
42485
  * `DeviceFeature.TwoWayAudio` + the `intercom` cap registration.
42338
42486
  */
42339
42487
  hasIntercom: boolean().optional(),
42488
+ /**
42489
+ * The `privacy-mask` cap's per-camera AVAILABILITY facts, as the last
42490
+ * successful read saw them.
42491
+ *
42492
+ * Persisted for the same reason Reolink persists its `capOptionsSnapshot`:
42493
+ * `privacyMask.getOptions` sits on a POLLED path (the admin Cameras badge
42494
+ * asks it per camera every 5s), and these describe what the model can do —
42495
+ * they change on a firmware update, not during operation. Reading them from
42496
+ * here costs no ISAPI round trip, and it survives an addon restart, which
42497
+ * an in-process value does not.
42498
+ *
42499
+ * They come out of the SAME two reads that build the cap's status slice, so
42500
+ * they cannot drift from it.
42501
+ */
42502
+ privacyMaskOptions: object({
42503
+ /** Zones the firmware advertises. */
42504
+ maxRegions: number().int().nonnegative(),
42505
+ /** True only when a streaming channel reported an audio flag we can
42506
+ * patch — a switch that writes nothing must never be offered. */
42507
+ supportsAudioMute: boolean(),
42508
+ /** Epoch ms of the read that produced this. */
42509
+ fetchedAt: number()
42510
+ }).optional(),
42340
42511
  /** ISAPI codec the firmware reports for the talk channel (e.g.
42341
42512
  * `G.711ulaw`, `G.711alaw`). Persisted so the intercom session
42342
42513
  * doesn't have to re-discover on every open. Optional — discovery
@@ -44339,7 +44510,68 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44339
44510
  registerPrivacyMaskCap(cameraNumber) {
44340
44511
  const isapi = this.ensureClient();
44341
44512
  const CAP_NAME = "privacy-mask";
44342
- const STALE_MS = 1e4;
44513
+ /**
44514
+ * The per-camera AVAILABILITY facts, as the last successful read saw them.
44515
+ *
44516
+ * `getOptions` used to make its own pair of ISAPI calls on every
44517
+ * invocation — and `getCameraStatuses` calls it once per camera per 5s
44518
+ * poll, so on this fleet (20 of 29 cameras are Hikvision) it was the
44519
+ * single largest source of camera traffic on the badge path.
44520
+ *
44521
+ * It is not a second copy of anything: `maxRegions` and the audio-channel
44522
+ * census live nowhere else, and they come out of the SAME two reads that
44523
+ * build the status slice, so they cannot disagree with it. They are kept in
44524
+ * the persisted `deviceCache` rather than only in memory because they must
44525
+ * survive an addon restart — the status slice does (`durability:
44526
+ * 'restored'`), and an options read that did not would be the one caller
44527
+ * still dialling every camera after every deploy.
44528
+ */
44529
+ const readPersistedOptions = () => {
44530
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44531
+ if (!stored) return null;
44532
+ return {
44533
+ maxRegions: stored.maxRegions,
44534
+ supportedShapes: ["rect", "polygon"],
44535
+ polygonVertices: {
44536
+ min: 4,
44537
+ max: 4
44538
+ },
44539
+ supportsAudioMute: stored.supportsAudioMute
44540
+ };
44541
+ };
44542
+ /**
44543
+ * Epoch ms until which a COLD options probe is not re-attempted.
44544
+ *
44545
+ * Only the cold path below can dial, and only on a camera that has never
44546
+ * produced a descriptor — but "never" and "unreachable" are the same state,
44547
+ * so without this an unreachable camera would be dialled once per poll
44548
+ * forever. Same constant, same reasoning as the bridge's own miss cooldown.
44549
+ */
44550
+ let optionsProbeCooldownUntil = 0;
44551
+ /** Persist the descriptor, but only when it actually MOVED — this runs on
44552
+ * every refresh and a per-refresh SQLite commit for an unchanged model
44553
+ * capability is pure churn. Best-effort: a failed write costs the next
44554
+ * restart one probe. */
44555
+ const persistOptions = async (next) => {
44556
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44557
+ if (stored && stored.maxRegions === next.maxRegions && stored.supportsAudioMute === next.supportsAudioMute) return;
44558
+ try {
44559
+ const previous = this.config.get("deviceCache") ?? {};
44560
+ await this.config.setAll({ deviceCache: {
44561
+ ...previous,
44562
+ privacyMaskOptions: {
44563
+ maxRegions: next.maxRegions,
44564
+ supportsAudioMute: next.supportsAudioMute,
44565
+ fetchedAt: Date.now()
44566
+ }
44567
+ } });
44568
+ } catch (err) {
44569
+ this.ctx.logger.debug("hikvision privacy-mask options persist failed", {
44570
+ tags: { deviceId: this.id },
44571
+ meta: { error: err instanceof Error ? err.message : String(err) }
44572
+ });
44573
+ }
44574
+ };
44343
44575
  /** Round-trip the privacy-mask config → cap regions, write slice. */
44344
44576
  const refreshFromCamera = async () => {
44345
44577
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
@@ -44347,6 +44579,15 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44347
44579
  try {
44348
44580
  const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44349
44581
  if (!mask) return;
44582
+ await persistOptions({
44583
+ maxRegions: mask.maxRegions > 0 ? mask.maxRegions : 4,
44584
+ supportedShapes: ["rect", "polygon"],
44585
+ polygonVertices: {
44586
+ min: 4,
44587
+ max: 4
44588
+ },
44589
+ supportsAudioMute: audio.length > 0
44590
+ });
44350
44591
  const regions = mask.regions.map((region, index) => ({
44351
44592
  id: index,
44352
44593
  enabled: true,
@@ -44373,22 +44614,25 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44373
44614
  this.privacyMaskRefreshInFlight = null;
44374
44615
  }
44375
44616
  };
44617
+ const bridge = createRuntimeStateBridge({
44618
+ runtimeState: this.runtimeState,
44619
+ cap: privacyMaskCapability,
44620
+ ownDeviceId: this.id,
44621
+ refresh: refreshFromCamera,
44622
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
44623
+ staleRead: "serve-and-revalidate",
44624
+ logger: this.ctx.logger,
44625
+ empty: () => ({
44626
+ enabled: false,
44627
+ regions: [],
44628
+ audioEnabled: null,
44629
+ lastFetchedAt: 0
44630
+ })
44631
+ });
44376
44632
  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,
44633
+ getStatus: bridge.getStatus,
44390
44634
  getOptions: async ({ deviceId }) => {
44391
- const cold = {
44635
+ if (deviceId !== this.id) return {
44392
44636
  maxRegions: 4,
44393
44637
  supportedShapes: ["rect", "polygon"],
44394
44638
  polygonVertices: {
@@ -44397,17 +44641,16 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44397
44641
  },
44398
44642
  supportsAudioMute: false
44399
44643
  };
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
- };
44644
+ await bridge.ensureFresh();
44645
+ const warm = readPersistedOptions();
44646
+ if (warm !== null) return warm;
44647
+ if (Date.now() >= optionsProbeCooldownUntil) {
44648
+ optionsProbeCooldownUntil = Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS;
44649
+ await refreshFromCamera();
44650
+ const cold = readPersistedOptions();
44651
+ if (cold !== null) return cold;
44652
+ }
44653
+ throw new Error(`device ${String(this.id)}: privacy-mask options unknown — the camera has not answered`);
44411
44654
  },
44412
44655
  setMask: async ({ deviceId, patch }) => {
44413
44656
  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.32",
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",