@camstack/addon-provider-reolink 1.2.46 → 1.2.47

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 +208 -18
  2. package/dist/addon.mjs +208 -18
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -19207,6 +19207,12 @@ var CameraStatusSchema = object({
19207
19207
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19208
19208
  fetchedAt: number()
19209
19209
  });
19210
+ var InferenceDeviceExclusionReasonSchema = _enum([
19211
+ "disabled",
19212
+ "unavailable",
19213
+ "cannot-host-camera-root",
19214
+ "accelerator-preferred"
19215
+ ]);
19210
19216
  var NodeInferenceDeviceSchema = object({
19211
19217
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19212
19218
  key: string(),
@@ -19237,7 +19243,17 @@ var NodeInferenceDeviceSchema = object({
19237
19243
  * available per format; this is the stored selection that becomes the
19238
19244
  * default for EVERY camera landing on this accelerator.
19239
19245
  */
19240
- steps: record(string(), DeviceStepConfigSchema).optional()
19246
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19247
+ /**
19248
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19249
+ * the reason the dispatcher drops it. Computed by the SAME
19250
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19251
+ * never disagree with the election — deriving it in the UI from
19252
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19253
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19254
+ * "an accelerator is serving" predicate).
19255
+ */
19256
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19241
19257
  });
19242
19258
  var NodeInferenceDevicesSchema = object({
19243
19259
  nodeId: string(),
@@ -24192,7 +24208,12 @@ var ListResultSchema = object({
24192
24208
  probedAt: number()
24193
24209
  });
24194
24210
  var PreferredSchema = LocalInterfaceSchema.nullable();
24195
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24211
+ /**
24212
+ * Candidate base URL for the SDK to race on connect. Order matters —
24213
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24214
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24215
+ */
24216
+ var ConnectionEndpointSchema = object({
24196
24217
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24197
24218
  label: string(),
24198
24219
  /** Fully-formed base URL with scheme + host + port. */
@@ -24235,7 +24256,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24235
24256
  * ordering between polls.
24236
24257
  */
24237
24258
  priority: number()
24238
- })).readonly() });
24259
+ });
24260
+ /**
24261
+ * Where the advertised local port came from. Ordered most → least
24262
+ * authoritative, and the whole point of returning it: a client must be able to
24263
+ * tell a FACT about the hub's socket from an echo of its own guess.
24264
+ */
24265
+ var LocalPortSourceEnum = _enum([
24266
+ "server-config",
24267
+ "server-env",
24268
+ "caller-hint",
24269
+ "default"
24270
+ ]);
24271
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24272
+ var AdvertisedLocalPortSchema = object({
24273
+ port: number().int().min(1).max(65535),
24274
+ source: LocalPortSourceEnum
24275
+ });
24276
+ var GetConnectionEndpointsResultSchema = object({
24277
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24278
+ /**
24279
+ * The port the hub built the LAN/loopback URLs with, and where that number
24280
+ * came from.
24281
+ *
24282
+ * Returned rather than merely applied, because "the URL is right" and "the
24283
+ * client can KNOW the URL is right" are different properties. A client that
24284
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24285
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24286
+ * race the candidate or discard it. With `source` it can: anything but
24287
+ * `caller-hint` is the hub's own socket.
24288
+ *
24289
+ * Absent on hubs predating this field — a client that finds it missing is
24290
+ * talking to an echoing hub and must degrade exactly as it does for
24291
+ * `caller-hint`.
24292
+ */
24293
+ localPort: AdvertisedLocalPortSchema
24294
+ });
24239
24295
  /**
24240
24296
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24241
24297
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24256,8 +24312,13 @@ var AllowedAddressesSchema = object({
24256
24312
  */
24257
24313
  addresses: array(string()).readonly() });
24258
24314
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24259
- /** Local hub HTTP port to use in base URLs. */
24260
- port: number().int().min(1).max(65535),
24315
+ /**
24316
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24317
+ * written against the echoing contract keep working; the hub uses it
24318
+ * only when it cannot read its own port, and says so via
24319
+ * `localPort.source === 'caller-hint'`.
24320
+ */
24321
+ port: number().int().min(1).max(65535).optional(),
24261
24322
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24262
24323
  * candidate. Default `true`. */
24263
24324
  includeLoopback: boolean().optional(),
@@ -30364,11 +30425,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
30364
30425
  new Set(Object.values(DeviceType));
30365
30426
  DeviceFeature.BatteryOperated;
30366
30427
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30428
+ /**
30429
+ * How long a bridge stops re-attempting a refresh that did not land.
30430
+ *
30431
+ * Sized against the failure it exists for: a camera whose control plane
30432
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30433
+ * neighbour never answers, up to the client's own timeout otherwise) on
30434
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30435
+ * without a cooldown the slice is permanently stale and EVERY read pays
30436
+ * that price. One attempt per minute is enough to notice the camera
30437
+ * coming back; per-read is enough to stall the viewer's first paint.
30438
+ */
30439
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30440
+ /**
30441
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30442
+ *
30443
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30444
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30445
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30446
+ * microphone on) change when an operator changes them, through a `setMask` /
30447
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30448
+ * A write is therefore its own invalidation: the window never delays an
30449
+ * operator's own change, however long it is.
30450
+ *
30451
+ * What the window really bounds is the ONE case we do not write: somebody
30452
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30453
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30454
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30455
+ * minute instead of 696
30456
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30457
+ *
30458
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30459
+ * On the awaiting default a window this long does not remove the stall, it
30460
+ * merely makes it rarer and just as long.
30461
+ */
30462
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30367
30463
  function createRuntimeStateBridge(params) {
30368
30464
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30369
30465
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30466
+ const staleRead = params.staleRead ?? "await-refresh";
30370
30467
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30371
30468
  let missCooldownUntil = 0;
30469
+ /**
30470
+ * The refresh this bridge currently has in the air, if any.
30471
+ *
30472
+ * Providers single-flight their own camera client, so this is not what stops
30473
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30474
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30475
+ * answered from the slice at once — even a cold, empty slice, which reports
30476
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30477
+ *
30478
+ * That distinction is the whole cost of an unreachable camera. Measured on
30479
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30480
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30481
+ * joined the wait and spent the caller's full 1.2s source budget — five
30482
+ * stalled polls per cooldown cycle, for one camera, forever.
30483
+ */
30484
+ let refreshInFlight = null;
30372
30485
  const readFetchedAt = () => {
30373
30486
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30374
30487
  return typeof value === "number" ? value : 0;
@@ -30389,14 +30502,14 @@ function createRuntimeStateBridge(params) {
30389
30502
  }
30390
30503
  });
30391
30504
  };
30392
- const ensureFresh = async () => {
30393
- const slice = runtimeState.getCapState(cap.name);
30394
- const fetchedAt = readFetchedAt();
30395
- if (slice && Date.now() - fetchedAt <= staleMs) {
30396
- missCooldownUntil = 0;
30397
- return;
30398
- }
30399
- if (Date.now() < missCooldownUntil) return;
30505
+ /**
30506
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30507
+ *
30508
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30509
+ * evidence the bridge has that the refresh persisted
30510
+ * anything, since providers swallow their own camera errors.
30511
+ */
30512
+ const runRefresh = async (fetchedAt) => {
30400
30513
  try {
30401
30514
  await refresh();
30402
30515
  } catch (err) {
@@ -30412,6 +30525,41 @@ function createRuntimeStateBridge(params) {
30412
30525
  }
30413
30526
  openMissCooldown(void 0);
30414
30527
  };
30528
+ /**
30529
+ * Start a refresh and remember it, at most one at a time.
30530
+ *
30531
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30532
+ * from the slice either way, so a floating rejection would take the process
30533
+ * down for a fault the miss cooldown has already recorded and logged.
30534
+ */
30535
+ const startRefresh = (fetchedAt) => {
30536
+ const existing = refreshInFlight;
30537
+ if (existing !== null) return existing;
30538
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30539
+ refreshInFlight = null;
30540
+ });
30541
+ refreshInFlight = started;
30542
+ return started;
30543
+ };
30544
+ const ensureFresh = async () => {
30545
+ const slice = runtimeState.getCapState(cap.name);
30546
+ const fetchedAt = readFetchedAt();
30547
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30548
+ missCooldownUntil = 0;
30549
+ return;
30550
+ }
30551
+ if (Date.now() < missCooldownUntil) return;
30552
+ if (staleRead === "serve-and-revalidate") {
30553
+ if (refreshInFlight !== null) return;
30554
+ if (slice && fetchedAt > 0) {
30555
+ startRefresh(fetchedAt);
30556
+ return;
30557
+ }
30558
+ await startRefresh(fetchedAt);
30559
+ return;
30560
+ }
30561
+ await runRefresh(fetchedAt);
30562
+ };
30415
30563
  const projectStatus = () => {
30416
30564
  const slice = runtimeState.getCapState(cap.name);
30417
30565
  if (!slice) return empty();
@@ -232395,6 +232543,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232395
232543
  * within a day while removing the per-poll round-trip entirely. */
232396
232544
  static CAP_OPTIONS_TTL_MS = 360 * 6e4;
232397
232545
  /**
232546
+ * Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
232547
+ *
232548
+ * The same discipline `createRuntimeStateBridge` applies to a refresh that
232549
+ * did not land, for the same reason and against the same measurement. A probe
232550
+ * that fails persists nothing, so without this the next caller probes again —
232551
+ * and on a camera that is merely unreachable each attempt burns the full
232552
+ * connect give-up. Measured on the live hub (2026-08-21, device 3629, a
232553
+ * battery Reolink the registry already calls offline): `getOptions` took
232554
+ * **23.1s on every single call**, while its `getStatus` sibling answered in
232555
+ * 8ms because the bridge had already opened its cooldown. The status side was
232556
+ * bounded and the options side was not, and only one of them was visible.
232557
+ */
232558
+ capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
232559
+ /**
232560
+ * Per-cap in-flight options probe, so concurrent callers share ONE.
232561
+ *
232562
+ * The cooldown above only opens once a probe has FAILED — and on an
232563
+ * unreachable camera that takes the full connect give-up (23.1s measured on
232564
+ * device 3629). Every poll arriving inside that window used to start its own
232565
+ * probe, so the cooldown never got a chance to bound anything: the fix for
232566
+ * "one dial per poll" has to cover the dial that is still in the air.
232567
+ *
232568
+ * A caller that finds one here is SERVED, not queued behind it — this method
232569
+ * sits on a polled path, and waiting for a probe that is not coming back is
232570
+ * how one unreachable camera stalls every poll it overlaps.
232571
+ */
232572
+ capOptionsInFlight = /* @__PURE__ */ new Map();
232573
+ /**
232398
232574
  * Read a persisted `getOptions` descriptor, validated with the cap's
232399
232575
  * OWN Zod schema. Validation (not a cast) is what makes the
232400
232576
  * `z.unknown()` blob type-safe: an entry written by an older addon
@@ -232452,7 +232628,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232452
232628
  async resolveCapOptions(params) {
232453
232629
  const { capName, schema, probe, fallback } = params;
232454
232630
  const cached = this.readCapOptionsCache(capName, schema);
232455
- if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) return cached.value;
232631
+ if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
232632
+ if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
232633
+ this.capOptionsMissCooldownUntil.delete(capName);
232634
+ return cached.value;
232635
+ }
232456
232636
  if (this.isBattery && this.sleeping) {
232457
232637
  this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
232458
232638
  tags: { deviceId: this.id },
@@ -232463,19 +232643,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232463
232643
  });
232464
232644
  return cached?.value ?? fallback();
232465
232645
  }
232646
+ const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
232647
+ if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
232648
+ const started = probe();
232649
+ this.capOptionsInFlight.set(capName, started);
232466
232650
  try {
232467
- const value = await probe();
232651
+ const value = await started;
232468
232652
  await this.persistCapOptions(capName, value);
232653
+ this.capOptionsMissCooldownUntil.delete(capName);
232469
232654
  return value;
232470
232655
  } catch (err) {
232471
- this.ctx.logger.debug("cap options probe failed — serving cache/fallback", {
232656
+ this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
232657
+ this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
232472
232658
  tags: { deviceId: this.id },
232473
232659
  meta: {
232474
232660
  capName,
232661
+ cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
232662
+ hasCache: cached !== null,
232475
232663
  error: err instanceof Error ? err.message : String(err)
232476
232664
  }
232477
232665
  });
232478
232666
  return cached?.value ?? fallback();
232667
+ } finally {
232668
+ if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
232479
232669
  }
232480
232670
  }
232481
232671
  /**
@@ -232916,7 +233106,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232916
233106
  registerPrivacyMaskCap() {
232917
233107
  const channel = this.getChannel();
232918
233108
  const CAP_NAME = "privacy-mask";
232919
- const STALE_MS = 1e4;
232920
233109
  const refreshFromCamera = async () => {
232921
233110
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
232922
233111
  const promise = (async () => {
@@ -232969,7 +233158,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232969
233158
  cap: privacyMaskCapability,
232970
233159
  ownDeviceId: this.id,
232971
233160
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
232972
- staleMs: STALE_MS,
233161
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
233162
+ staleRead: "serve-and-revalidate",
232973
233163
  empty: () => ({
232974
233164
  enabled: false,
232975
233165
  regions: [],
package/dist/addon.mjs CHANGED
@@ -19202,6 +19202,12 @@ var CameraStatusSchema = object({
19202
19202
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19203
19203
  fetchedAt: number()
19204
19204
  });
19205
+ var InferenceDeviceExclusionReasonSchema = _enum([
19206
+ "disabled",
19207
+ "unavailable",
19208
+ "cannot-host-camera-root",
19209
+ "accelerator-preferred"
19210
+ ]);
19205
19211
  var NodeInferenceDeviceSchema = object({
19206
19212
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19207
19213
  key: string(),
@@ -19232,7 +19238,17 @@ var NodeInferenceDeviceSchema = object({
19232
19238
  * available per format; this is the stored selection that becomes the
19233
19239
  * default for EVERY camera landing on this accelerator.
19234
19240
  */
19235
- steps: record(string(), DeviceStepConfigSchema).optional()
19241
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19242
+ /**
19243
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19244
+ * the reason the dispatcher drops it. Computed by the SAME
19245
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19246
+ * never disagree with the election — deriving it in the UI from
19247
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19248
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19249
+ * "an accelerator is serving" predicate).
19250
+ */
19251
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19236
19252
  });
19237
19253
  var NodeInferenceDevicesSchema = object({
19238
19254
  nodeId: string(),
@@ -24187,7 +24203,12 @@ var ListResultSchema = object({
24187
24203
  probedAt: number()
24188
24204
  });
24189
24205
  var PreferredSchema = LocalInterfaceSchema.nullable();
24190
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24206
+ /**
24207
+ * Candidate base URL for the SDK to race on connect. Order matters —
24208
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24209
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24210
+ */
24211
+ var ConnectionEndpointSchema = object({
24191
24212
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24192
24213
  label: string(),
24193
24214
  /** Fully-formed base URL with scheme + host + port. */
@@ -24230,7 +24251,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24230
24251
  * ordering between polls.
24231
24252
  */
24232
24253
  priority: number()
24233
- })).readonly() });
24254
+ });
24255
+ /**
24256
+ * Where the advertised local port came from. Ordered most → least
24257
+ * authoritative, and the whole point of returning it: a client must be able to
24258
+ * tell a FACT about the hub's socket from an echo of its own guess.
24259
+ */
24260
+ var LocalPortSourceEnum = _enum([
24261
+ "server-config",
24262
+ "server-env",
24263
+ "caller-hint",
24264
+ "default"
24265
+ ]);
24266
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24267
+ var AdvertisedLocalPortSchema = object({
24268
+ port: number().int().min(1).max(65535),
24269
+ source: LocalPortSourceEnum
24270
+ });
24271
+ var GetConnectionEndpointsResultSchema = object({
24272
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24273
+ /**
24274
+ * The port the hub built the LAN/loopback URLs with, and where that number
24275
+ * came from.
24276
+ *
24277
+ * Returned rather than merely applied, because "the URL is right" and "the
24278
+ * client can KNOW the URL is right" are different properties. A client that
24279
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24280
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24281
+ * race the candidate or discard it. With `source` it can: anything but
24282
+ * `caller-hint` is the hub's own socket.
24283
+ *
24284
+ * Absent on hubs predating this field — a client that finds it missing is
24285
+ * talking to an echoing hub and must degrade exactly as it does for
24286
+ * `caller-hint`.
24287
+ */
24288
+ localPort: AdvertisedLocalPortSchema
24289
+ });
24234
24290
  /**
24235
24291
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24236
24292
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24251,8 +24307,13 @@ var AllowedAddressesSchema = object({
24251
24307
  */
24252
24308
  addresses: array(string()).readonly() });
24253
24309
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24254
- /** Local hub HTTP port to use in base URLs. */
24255
- port: number().int().min(1).max(65535),
24310
+ /**
24311
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24312
+ * written against the echoing contract keep working; the hub uses it
24313
+ * only when it cannot read its own port, and says so via
24314
+ * `localPort.source === 'caller-hint'`.
24315
+ */
24316
+ port: number().int().min(1).max(65535).optional(),
24256
24317
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24257
24318
  * candidate. Default `true`. */
24258
24319
  includeLoopback: boolean().optional(),
@@ -30359,11 +30420,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
30359
30420
  new Set(Object.values(DeviceType));
30360
30421
  DeviceFeature.BatteryOperated;
30361
30422
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30423
+ /**
30424
+ * How long a bridge stops re-attempting a refresh that did not land.
30425
+ *
30426
+ * Sized against the failure it exists for: a camera whose control plane
30427
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30428
+ * neighbour never answers, up to the client's own timeout otherwise) on
30429
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30430
+ * without a cooldown the slice is permanently stale and EVERY read pays
30431
+ * that price. One attempt per minute is enough to notice the camera
30432
+ * coming back; per-read is enough to stall the viewer's first paint.
30433
+ */
30434
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30435
+ /**
30436
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30437
+ *
30438
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30439
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30440
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30441
+ * microphone on) change when an operator changes them, through a `setMask` /
30442
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30443
+ * A write is therefore its own invalidation: the window never delays an
30444
+ * operator's own change, however long it is.
30445
+ *
30446
+ * What the window really bounds is the ONE case we do not write: somebody
30447
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30448
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30449
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30450
+ * minute instead of 696
30451
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30452
+ *
30453
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30454
+ * On the awaiting default a window this long does not remove the stall, it
30455
+ * merely makes it rarer and just as long.
30456
+ */
30457
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30362
30458
  function createRuntimeStateBridge(params) {
30363
30459
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30364
30460
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30461
+ const staleRead = params.staleRead ?? "await-refresh";
30365
30462
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30366
30463
  let missCooldownUntil = 0;
30464
+ /**
30465
+ * The refresh this bridge currently has in the air, if any.
30466
+ *
30467
+ * Providers single-flight their own camera client, so this is not what stops
30468
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30469
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30470
+ * answered from the slice at once — even a cold, empty slice, which reports
30471
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30472
+ *
30473
+ * That distinction is the whole cost of an unreachable camera. Measured on
30474
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30475
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30476
+ * joined the wait and spent the caller's full 1.2s source budget — five
30477
+ * stalled polls per cooldown cycle, for one camera, forever.
30478
+ */
30479
+ let refreshInFlight = null;
30367
30480
  const readFetchedAt = () => {
30368
30481
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30369
30482
  return typeof value === "number" ? value : 0;
@@ -30384,14 +30497,14 @@ function createRuntimeStateBridge(params) {
30384
30497
  }
30385
30498
  });
30386
30499
  };
30387
- const ensureFresh = async () => {
30388
- const slice = runtimeState.getCapState(cap.name);
30389
- const fetchedAt = readFetchedAt();
30390
- if (slice && Date.now() - fetchedAt <= staleMs) {
30391
- missCooldownUntil = 0;
30392
- return;
30393
- }
30394
- if (Date.now() < missCooldownUntil) return;
30500
+ /**
30501
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30502
+ *
30503
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30504
+ * evidence the bridge has that the refresh persisted
30505
+ * anything, since providers swallow their own camera errors.
30506
+ */
30507
+ const runRefresh = async (fetchedAt) => {
30395
30508
  try {
30396
30509
  await refresh();
30397
30510
  } catch (err) {
@@ -30407,6 +30520,41 @@ function createRuntimeStateBridge(params) {
30407
30520
  }
30408
30521
  openMissCooldown(void 0);
30409
30522
  };
30523
+ /**
30524
+ * Start a refresh and remember it, at most one at a time.
30525
+ *
30526
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30527
+ * from the slice either way, so a floating rejection would take the process
30528
+ * down for a fault the miss cooldown has already recorded and logged.
30529
+ */
30530
+ const startRefresh = (fetchedAt) => {
30531
+ const existing = refreshInFlight;
30532
+ if (existing !== null) return existing;
30533
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30534
+ refreshInFlight = null;
30535
+ });
30536
+ refreshInFlight = started;
30537
+ return started;
30538
+ };
30539
+ const ensureFresh = async () => {
30540
+ const slice = runtimeState.getCapState(cap.name);
30541
+ const fetchedAt = readFetchedAt();
30542
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30543
+ missCooldownUntil = 0;
30544
+ return;
30545
+ }
30546
+ if (Date.now() < missCooldownUntil) return;
30547
+ if (staleRead === "serve-and-revalidate") {
30548
+ if (refreshInFlight !== null) return;
30549
+ if (slice && fetchedAt > 0) {
30550
+ startRefresh(fetchedAt);
30551
+ return;
30552
+ }
30553
+ await startRefresh(fetchedAt);
30554
+ return;
30555
+ }
30556
+ await runRefresh(fetchedAt);
30557
+ };
30410
30558
  const projectStatus = () => {
30411
30559
  const slice = runtimeState.getCapState(cap.name);
30412
30560
  if (!slice) return empty();
@@ -232375,6 +232523,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232375
232523
  * within a day while removing the per-poll round-trip entirely. */
232376
232524
  static CAP_OPTIONS_TTL_MS = 360 * 6e4;
232377
232525
  /**
232526
+ * Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
232527
+ *
232528
+ * The same discipline `createRuntimeStateBridge` applies to a refresh that
232529
+ * did not land, for the same reason and against the same measurement. A probe
232530
+ * that fails persists nothing, so without this the next caller probes again —
232531
+ * and on a camera that is merely unreachable each attempt burns the full
232532
+ * connect give-up. Measured on the live hub (2026-08-21, device 3629, a
232533
+ * battery Reolink the registry already calls offline): `getOptions` took
232534
+ * **23.1s on every single call**, while its `getStatus` sibling answered in
232535
+ * 8ms because the bridge had already opened its cooldown. The status side was
232536
+ * bounded and the options side was not, and only one of them was visible.
232537
+ */
232538
+ capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
232539
+ /**
232540
+ * Per-cap in-flight options probe, so concurrent callers share ONE.
232541
+ *
232542
+ * The cooldown above only opens once a probe has FAILED — and on an
232543
+ * unreachable camera that takes the full connect give-up (23.1s measured on
232544
+ * device 3629). Every poll arriving inside that window used to start its own
232545
+ * probe, so the cooldown never got a chance to bound anything: the fix for
232546
+ * "one dial per poll" has to cover the dial that is still in the air.
232547
+ *
232548
+ * A caller that finds one here is SERVED, not queued behind it — this method
232549
+ * sits on a polled path, and waiting for a probe that is not coming back is
232550
+ * how one unreachable camera stalls every poll it overlaps.
232551
+ */
232552
+ capOptionsInFlight = /* @__PURE__ */ new Map();
232553
+ /**
232378
232554
  * Read a persisted `getOptions` descriptor, validated with the cap's
232379
232555
  * OWN Zod schema. Validation (not a cast) is what makes the
232380
232556
  * `z.unknown()` blob type-safe: an entry written by an older addon
@@ -232432,7 +232608,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232432
232608
  async resolveCapOptions(params) {
232433
232609
  const { capName, schema, probe, fallback } = params;
232434
232610
  const cached = this.readCapOptionsCache(capName, schema);
232435
- if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) return cached.value;
232611
+ if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
232612
+ if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
232613
+ this.capOptionsMissCooldownUntil.delete(capName);
232614
+ return cached.value;
232615
+ }
232436
232616
  if (this.isBattery && this.sleeping) {
232437
232617
  this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
232438
232618
  tags: { deviceId: this.id },
@@ -232443,19 +232623,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232443
232623
  });
232444
232624
  return cached?.value ?? fallback();
232445
232625
  }
232626
+ const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
232627
+ if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
232628
+ const started = probe();
232629
+ this.capOptionsInFlight.set(capName, started);
232446
232630
  try {
232447
- const value = await probe();
232631
+ const value = await started;
232448
232632
  await this.persistCapOptions(capName, value);
232633
+ this.capOptionsMissCooldownUntil.delete(capName);
232449
232634
  return value;
232450
232635
  } catch (err) {
232451
- this.ctx.logger.debug("cap options probe failed — serving cache/fallback", {
232636
+ this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
232637
+ this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
232452
232638
  tags: { deviceId: this.id },
232453
232639
  meta: {
232454
232640
  capName,
232641
+ cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
232642
+ hasCache: cached !== null,
232455
232643
  error: err instanceof Error ? err.message : String(err)
232456
232644
  }
232457
232645
  });
232458
232646
  return cached?.value ?? fallback();
232647
+ } finally {
232648
+ if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
232459
232649
  }
232460
232650
  }
232461
232651
  /**
@@ -232896,7 +233086,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232896
233086
  registerPrivacyMaskCap() {
232897
233087
  const channel = this.getChannel();
232898
233088
  const CAP_NAME = "privacy-mask";
232899
- const STALE_MS = 1e4;
232900
233089
  const refreshFromCamera = async () => {
232901
233090
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
232902
233091
  const promise = (async () => {
@@ -232949,7 +233138,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232949
233138
  cap: privacyMaskCapability,
232950
233139
  ownDeviceId: this.id,
232951
233140
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
232952
- staleMs: STALE_MS,
233141
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
233142
+ staleRead: "serve-and-revalidate",
232953
233143
  empty: () => ({
232954
233144
  enabled: false,
232955
233145
  regions: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.46",
3
+ "version": "1.2.47",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",