@camstack/addon-provider-reolink 1.2.46 → 1.2.48

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 +218 -19
  2. package/dist/addon.mjs +218 -19
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -26,7 +26,7 @@ let fs_promises = require("fs/promises");
26
26
  fs_promises = require_chunk.__toESM(fs_promises, 1);
27
27
  let node_os = require("node:os");
28
28
  node_os = require_chunk.__toESM(node_os);
29
- //#region ../types/dist/event-category-XfKNtfCc.mjs
29
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
30
30
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
31
31
  EventCategory["SystemBoot"] = "system.boot";
32
32
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -42,6 +42,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
42
42
  */
43
43
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
44
44
  /**
45
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
46
+ * Emitted only when the material on disk actually changed, so an
47
+ * operator who trusted the old certificate by hand is told rather than
48
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
49
+ *
50
+ * Rule: docs/decisions/adr-0227-*.md
51
+ */
52
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
53
+ /**
45
54
  * A newer addon or server-root package version was found by the
46
55
  * authoritative registry check. Emitted once when any observed
47
56
  * `latestVersion` changes (or a package/node first appears behind);
@@ -19207,6 +19216,12 @@ var CameraStatusSchema = object({
19207
19216
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19208
19217
  fetchedAt: number()
19209
19218
  });
19219
+ var InferenceDeviceExclusionReasonSchema = _enum([
19220
+ "disabled",
19221
+ "unavailable",
19222
+ "cannot-host-camera-root",
19223
+ "accelerator-preferred"
19224
+ ]);
19210
19225
  var NodeInferenceDeviceSchema = object({
19211
19226
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19212
19227
  key: string(),
@@ -19237,7 +19252,17 @@ var NodeInferenceDeviceSchema = object({
19237
19252
  * available per format; this is the stored selection that becomes the
19238
19253
  * default for EVERY camera landing on this accelerator.
19239
19254
  */
19240
- steps: record(string(), DeviceStepConfigSchema).optional()
19255
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19256
+ /**
19257
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19258
+ * the reason the dispatcher drops it. Computed by the SAME
19259
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19260
+ * never disagree with the election — deriving it in the UI from
19261
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19262
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19263
+ * "an accelerator is serving" predicate).
19264
+ */
19265
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19241
19266
  });
19242
19267
  var NodeInferenceDevicesSchema = object({
19243
19268
  nodeId: string(),
@@ -24192,7 +24217,12 @@ var ListResultSchema = object({
24192
24217
  probedAt: number()
24193
24218
  });
24194
24219
  var PreferredSchema = LocalInterfaceSchema.nullable();
24195
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24220
+ /**
24221
+ * Candidate base URL for the SDK to race on connect. Order matters —
24222
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24223
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24224
+ */
24225
+ var ConnectionEndpointSchema = object({
24196
24226
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24197
24227
  label: string(),
24198
24228
  /** Fully-formed base URL with scheme + host + port. */
@@ -24235,7 +24265,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24235
24265
  * ordering between polls.
24236
24266
  */
24237
24267
  priority: number()
24238
- })).readonly() });
24268
+ });
24269
+ /**
24270
+ * Where the advertised local port came from. Ordered most → least
24271
+ * authoritative, and the whole point of returning it: a client must be able to
24272
+ * tell a FACT about the hub's socket from an echo of its own guess.
24273
+ */
24274
+ var LocalPortSourceEnum = _enum([
24275
+ "server-config",
24276
+ "server-env",
24277
+ "caller-hint",
24278
+ "default"
24279
+ ]);
24280
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24281
+ var AdvertisedLocalPortSchema = object({
24282
+ port: number().int().min(1).max(65535),
24283
+ source: LocalPortSourceEnum
24284
+ });
24285
+ var GetConnectionEndpointsResultSchema = object({
24286
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24287
+ /**
24288
+ * The port the hub built the LAN/loopback URLs with, and where that number
24289
+ * came from.
24290
+ *
24291
+ * Returned rather than merely applied, because "the URL is right" and "the
24292
+ * client can KNOW the URL is right" are different properties. A client that
24293
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24294
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24295
+ * race the candidate or discard it. With `source` it can: anything but
24296
+ * `caller-hint` is the hub's own socket.
24297
+ *
24298
+ * Absent on hubs predating this field — a client that finds it missing is
24299
+ * talking to an echoing hub and must degrade exactly as it does for
24300
+ * `caller-hint`.
24301
+ */
24302
+ localPort: AdvertisedLocalPortSchema
24303
+ });
24239
24304
  /**
24240
24305
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24241
24306
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24256,8 +24321,13 @@ var AllowedAddressesSchema = object({
24256
24321
  */
24257
24322
  addresses: array(string()).readonly() });
24258
24323
  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),
24324
+ /**
24325
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24326
+ * written against the echoing contract keep working; the hub uses it
24327
+ * only when it cannot read its own port, and says so via
24328
+ * `localPort.source === 'caller-hint'`.
24329
+ */
24330
+ port: number().int().min(1).max(65535).optional(),
24261
24331
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24262
24332
  * candidate. Default `true`. */
24263
24333
  includeLoopback: boolean().optional(),
@@ -30364,11 +30434,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
30364
30434
  new Set(Object.values(DeviceType));
30365
30435
  DeviceFeature.BatteryOperated;
30366
30436
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30437
+ /**
30438
+ * How long a bridge stops re-attempting a refresh that did not land.
30439
+ *
30440
+ * Sized against the failure it exists for: a camera whose control plane
30441
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30442
+ * neighbour never answers, up to the client's own timeout otherwise) on
30443
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30444
+ * without a cooldown the slice is permanently stale and EVERY read pays
30445
+ * that price. One attempt per minute is enough to notice the camera
30446
+ * coming back; per-read is enough to stall the viewer's first paint.
30447
+ */
30448
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30449
+ /**
30450
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30451
+ *
30452
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30453
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30454
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30455
+ * microphone on) change when an operator changes them, through a `setMask` /
30456
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30457
+ * A write is therefore its own invalidation: the window never delays an
30458
+ * operator's own change, however long it is.
30459
+ *
30460
+ * What the window really bounds is the ONE case we do not write: somebody
30461
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30462
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30463
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30464
+ * minute instead of 696
30465
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30466
+ *
30467
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30468
+ * On the awaiting default a window this long does not remove the stall, it
30469
+ * merely makes it rarer and just as long.
30470
+ */
30471
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30367
30472
  function createRuntimeStateBridge(params) {
30368
30473
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30369
30474
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30475
+ const staleRead = params.staleRead ?? "await-refresh";
30370
30476
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30371
30477
  let missCooldownUntil = 0;
30478
+ /**
30479
+ * The refresh this bridge currently has in the air, if any.
30480
+ *
30481
+ * Providers single-flight their own camera client, so this is not what stops
30482
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30483
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30484
+ * answered from the slice at once — even a cold, empty slice, which reports
30485
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30486
+ *
30487
+ * That distinction is the whole cost of an unreachable camera. Measured on
30488
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30489
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30490
+ * joined the wait and spent the caller's full 1.2s source budget — five
30491
+ * stalled polls per cooldown cycle, for one camera, forever.
30492
+ */
30493
+ let refreshInFlight = null;
30372
30494
  const readFetchedAt = () => {
30373
30495
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30374
30496
  return typeof value === "number" ? value : 0;
@@ -30389,14 +30511,14 @@ function createRuntimeStateBridge(params) {
30389
30511
  }
30390
30512
  });
30391
30513
  };
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;
30514
+ /**
30515
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30516
+ *
30517
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30518
+ * evidence the bridge has that the refresh persisted
30519
+ * anything, since providers swallow their own camera errors.
30520
+ */
30521
+ const runRefresh = async (fetchedAt) => {
30400
30522
  try {
30401
30523
  await refresh();
30402
30524
  } catch (err) {
@@ -30412,6 +30534,41 @@ function createRuntimeStateBridge(params) {
30412
30534
  }
30413
30535
  openMissCooldown(void 0);
30414
30536
  };
30537
+ /**
30538
+ * Start a refresh and remember it, at most one at a time.
30539
+ *
30540
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30541
+ * from the slice either way, so a floating rejection would take the process
30542
+ * down for a fault the miss cooldown has already recorded and logged.
30543
+ */
30544
+ const startRefresh = (fetchedAt) => {
30545
+ const existing = refreshInFlight;
30546
+ if (existing !== null) return existing;
30547
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30548
+ refreshInFlight = null;
30549
+ });
30550
+ refreshInFlight = started;
30551
+ return started;
30552
+ };
30553
+ const ensureFresh = async () => {
30554
+ const slice = runtimeState.getCapState(cap.name);
30555
+ const fetchedAt = readFetchedAt();
30556
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30557
+ missCooldownUntil = 0;
30558
+ return;
30559
+ }
30560
+ if (Date.now() < missCooldownUntil) return;
30561
+ if (staleRead === "serve-and-revalidate") {
30562
+ if (refreshInFlight !== null) return;
30563
+ if (slice && fetchedAt > 0) {
30564
+ startRefresh(fetchedAt);
30565
+ return;
30566
+ }
30567
+ await startRefresh(fetchedAt);
30568
+ return;
30569
+ }
30570
+ await runRefresh(fetchedAt);
30571
+ };
30415
30572
  const projectStatus = () => {
30416
30573
  const slice = runtimeState.getCapState(cap.name);
30417
30574
  if (!slice) return empty();
@@ -232395,6 +232552,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232395
232552
  * within a day while removing the per-poll round-trip entirely. */
232396
232553
  static CAP_OPTIONS_TTL_MS = 360 * 6e4;
232397
232554
  /**
232555
+ * Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
232556
+ *
232557
+ * The same discipline `createRuntimeStateBridge` applies to a refresh that
232558
+ * did not land, for the same reason and against the same measurement. A probe
232559
+ * that fails persists nothing, so without this the next caller probes again —
232560
+ * and on a camera that is merely unreachable each attempt burns the full
232561
+ * connect give-up. Measured on the live hub (2026-08-21, device 3629, a
232562
+ * battery Reolink the registry already calls offline): `getOptions` took
232563
+ * **23.1s on every single call**, while its `getStatus` sibling answered in
232564
+ * 8ms because the bridge had already opened its cooldown. The status side was
232565
+ * bounded and the options side was not, and only one of them was visible.
232566
+ */
232567
+ capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
232568
+ /**
232569
+ * Per-cap in-flight options probe, so concurrent callers share ONE.
232570
+ *
232571
+ * The cooldown above only opens once a probe has FAILED — and on an
232572
+ * unreachable camera that takes the full connect give-up (23.1s measured on
232573
+ * device 3629). Every poll arriving inside that window used to start its own
232574
+ * probe, so the cooldown never got a chance to bound anything: the fix for
232575
+ * "one dial per poll" has to cover the dial that is still in the air.
232576
+ *
232577
+ * A caller that finds one here is SERVED, not queued behind it — this method
232578
+ * sits on a polled path, and waiting for a probe that is not coming back is
232579
+ * how one unreachable camera stalls every poll it overlaps.
232580
+ */
232581
+ capOptionsInFlight = /* @__PURE__ */ new Map();
232582
+ /**
232398
232583
  * Read a persisted `getOptions` descriptor, validated with the cap's
232399
232584
  * OWN Zod schema. Validation (not a cast) is what makes the
232400
232585
  * `z.unknown()` blob type-safe: an entry written by an older addon
@@ -232452,7 +232637,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232452
232637
  async resolveCapOptions(params) {
232453
232638
  const { capName, schema, probe, fallback } = params;
232454
232639
  const cached = this.readCapOptionsCache(capName, schema);
232455
- if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) return cached.value;
232640
+ if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
232641
+ if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
232642
+ this.capOptionsMissCooldownUntil.delete(capName);
232643
+ return cached.value;
232644
+ }
232456
232645
  if (this.isBattery && this.sleeping) {
232457
232646
  this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
232458
232647
  tags: { deviceId: this.id },
@@ -232463,19 +232652,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232463
232652
  });
232464
232653
  return cached?.value ?? fallback();
232465
232654
  }
232655
+ const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
232656
+ if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
232657
+ const started = probe();
232658
+ this.capOptionsInFlight.set(capName, started);
232466
232659
  try {
232467
- const value = await probe();
232660
+ const value = await started;
232468
232661
  await this.persistCapOptions(capName, value);
232662
+ this.capOptionsMissCooldownUntil.delete(capName);
232469
232663
  return value;
232470
232664
  } catch (err) {
232471
- this.ctx.logger.debug("cap options probe failed — serving cache/fallback", {
232665
+ this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
232666
+ this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
232472
232667
  tags: { deviceId: this.id },
232473
232668
  meta: {
232474
232669
  capName,
232670
+ cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
232671
+ hasCache: cached !== null,
232475
232672
  error: err instanceof Error ? err.message : String(err)
232476
232673
  }
232477
232674
  });
232478
232675
  return cached?.value ?? fallback();
232676
+ } finally {
232677
+ if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
232479
232678
  }
232480
232679
  }
232481
232680
  /**
@@ -232916,7 +233115,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232916
233115
  registerPrivacyMaskCap() {
232917
233116
  const channel = this.getChannel();
232918
233117
  const CAP_NAME = "privacy-mask";
232919
- const STALE_MS = 1e4;
232920
233118
  const refreshFromCamera = async () => {
232921
233119
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
232922
233120
  const promise = (async () => {
@@ -232969,7 +233167,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232969
233167
  cap: privacyMaskCapability,
232970
233168
  ownDeviceId: this.id,
232971
233169
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
232972
- staleMs: STALE_MS,
233170
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
233171
+ staleRead: "serve-and-revalidate",
232973
233172
  empty: () => ({
232974
233173
  enabled: false,
232975
233174
  regions: [],
package/dist/addon.mjs CHANGED
@@ -21,7 +21,7 @@ import netImpl from "net";
21
21
  import { fileURLToPath } from "url";
22
22
  import { mkdir } from "fs/promises";
23
23
  import os from "node:os";
24
- //#region ../types/dist/event-category-XfKNtfCc.mjs
24
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
25
25
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
26
26
  EventCategory["SystemBoot"] = "system.boot";
27
27
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -37,6 +37,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
37
37
  */
38
38
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
39
39
  /**
40
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
41
+ * Emitted only when the material on disk actually changed, so an
42
+ * operator who trusted the old certificate by hand is told rather than
43
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
44
+ *
45
+ * Rule: docs/decisions/adr-0227-*.md
46
+ */
47
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
48
+ /**
40
49
  * A newer addon or server-root package version was found by the
41
50
  * authoritative registry check. Emitted once when any observed
42
51
  * `latestVersion` changes (or a package/node first appears behind);
@@ -19202,6 +19211,12 @@ var CameraStatusSchema = object({
19202
19211
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19203
19212
  fetchedAt: number()
19204
19213
  });
19214
+ var InferenceDeviceExclusionReasonSchema = _enum([
19215
+ "disabled",
19216
+ "unavailable",
19217
+ "cannot-host-camera-root",
19218
+ "accelerator-preferred"
19219
+ ]);
19205
19220
  var NodeInferenceDeviceSchema = object({
19206
19221
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19207
19222
  key: string(),
@@ -19232,7 +19247,17 @@ var NodeInferenceDeviceSchema = object({
19232
19247
  * available per format; this is the stored selection that becomes the
19233
19248
  * default for EVERY camera landing on this accelerator.
19234
19249
  */
19235
- steps: record(string(), DeviceStepConfigSchema).optional()
19250
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19251
+ /**
19252
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19253
+ * the reason the dispatcher drops it. Computed by the SAME
19254
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19255
+ * never disagree with the election — deriving it in the UI from
19256
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19257
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19258
+ * "an accelerator is serving" predicate).
19259
+ */
19260
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19236
19261
  });
19237
19262
  var NodeInferenceDevicesSchema = object({
19238
19263
  nodeId: string(),
@@ -24187,7 +24212,12 @@ var ListResultSchema = object({
24187
24212
  probedAt: number()
24188
24213
  });
24189
24214
  var PreferredSchema = LocalInterfaceSchema.nullable();
24190
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24215
+ /**
24216
+ * Candidate base URL for the SDK to race on connect. Order matters —
24217
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24218
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24219
+ */
24220
+ var ConnectionEndpointSchema = object({
24191
24221
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24192
24222
  label: string(),
24193
24223
  /** Fully-formed base URL with scheme + host + port. */
@@ -24230,7 +24260,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24230
24260
  * ordering between polls.
24231
24261
  */
24232
24262
  priority: number()
24233
- })).readonly() });
24263
+ });
24264
+ /**
24265
+ * Where the advertised local port came from. Ordered most → least
24266
+ * authoritative, and the whole point of returning it: a client must be able to
24267
+ * tell a FACT about the hub's socket from an echo of its own guess.
24268
+ */
24269
+ var LocalPortSourceEnum = _enum([
24270
+ "server-config",
24271
+ "server-env",
24272
+ "caller-hint",
24273
+ "default"
24274
+ ]);
24275
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24276
+ var AdvertisedLocalPortSchema = object({
24277
+ port: number().int().min(1).max(65535),
24278
+ source: LocalPortSourceEnum
24279
+ });
24280
+ var GetConnectionEndpointsResultSchema = object({
24281
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24282
+ /**
24283
+ * The port the hub built the LAN/loopback URLs with, and where that number
24284
+ * came from.
24285
+ *
24286
+ * Returned rather than merely applied, because "the URL is right" and "the
24287
+ * client can KNOW the URL is right" are different properties. A client that
24288
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24289
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24290
+ * race the candidate or discard it. With `source` it can: anything but
24291
+ * `caller-hint` is the hub's own socket.
24292
+ *
24293
+ * Absent on hubs predating this field — a client that finds it missing is
24294
+ * talking to an echoing hub and must degrade exactly as it does for
24295
+ * `caller-hint`.
24296
+ */
24297
+ localPort: AdvertisedLocalPortSchema
24298
+ });
24234
24299
  /**
24235
24300
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24236
24301
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24251,8 +24316,13 @@ var AllowedAddressesSchema = object({
24251
24316
  */
24252
24317
  addresses: array(string()).readonly() });
24253
24318
  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),
24319
+ /**
24320
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24321
+ * written against the echoing contract keep working; the hub uses it
24322
+ * only when it cannot read its own port, and says so via
24323
+ * `localPort.source === 'caller-hint'`.
24324
+ */
24325
+ port: number().int().min(1).max(65535).optional(),
24256
24326
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24257
24327
  * candidate. Default `true`. */
24258
24328
  includeLoopback: boolean().optional(),
@@ -30359,11 +30429,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
30359
30429
  new Set(Object.values(DeviceType));
30360
30430
  DeviceFeature.BatteryOperated;
30361
30431
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30432
+ /**
30433
+ * How long a bridge stops re-attempting a refresh that did not land.
30434
+ *
30435
+ * Sized against the failure it exists for: a camera whose control plane
30436
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30437
+ * neighbour never answers, up to the client's own timeout otherwise) on
30438
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30439
+ * without a cooldown the slice is permanently stale and EVERY read pays
30440
+ * that price. One attempt per minute is enough to notice the camera
30441
+ * coming back; per-read is enough to stall the viewer's first paint.
30442
+ */
30443
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30444
+ /**
30445
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30446
+ *
30447
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30448
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30449
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30450
+ * microphone on) change when an operator changes them, through a `setMask` /
30451
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30452
+ * A write is therefore its own invalidation: the window never delays an
30453
+ * operator's own change, however long it is.
30454
+ *
30455
+ * What the window really bounds is the ONE case we do not write: somebody
30456
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30457
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30458
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30459
+ * minute instead of 696
30460
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30461
+ *
30462
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30463
+ * On the awaiting default a window this long does not remove the stall, it
30464
+ * merely makes it rarer and just as long.
30465
+ */
30466
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30362
30467
  function createRuntimeStateBridge(params) {
30363
30468
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30364
30469
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30470
+ const staleRead = params.staleRead ?? "await-refresh";
30365
30471
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30366
30472
  let missCooldownUntil = 0;
30473
+ /**
30474
+ * The refresh this bridge currently has in the air, if any.
30475
+ *
30476
+ * Providers single-flight their own camera client, so this is not what stops
30477
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30478
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30479
+ * answered from the slice at once — even a cold, empty slice, which reports
30480
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30481
+ *
30482
+ * That distinction is the whole cost of an unreachable camera. Measured on
30483
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30484
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30485
+ * joined the wait and spent the caller's full 1.2s source budget — five
30486
+ * stalled polls per cooldown cycle, for one camera, forever.
30487
+ */
30488
+ let refreshInFlight = null;
30367
30489
  const readFetchedAt = () => {
30368
30490
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30369
30491
  return typeof value === "number" ? value : 0;
@@ -30384,14 +30506,14 @@ function createRuntimeStateBridge(params) {
30384
30506
  }
30385
30507
  });
30386
30508
  };
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;
30509
+ /**
30510
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30511
+ *
30512
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30513
+ * evidence the bridge has that the refresh persisted
30514
+ * anything, since providers swallow their own camera errors.
30515
+ */
30516
+ const runRefresh = async (fetchedAt) => {
30395
30517
  try {
30396
30518
  await refresh();
30397
30519
  } catch (err) {
@@ -30407,6 +30529,41 @@ function createRuntimeStateBridge(params) {
30407
30529
  }
30408
30530
  openMissCooldown(void 0);
30409
30531
  };
30532
+ /**
30533
+ * Start a refresh and remember it, at most one at a time.
30534
+ *
30535
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30536
+ * from the slice either way, so a floating rejection would take the process
30537
+ * down for a fault the miss cooldown has already recorded and logged.
30538
+ */
30539
+ const startRefresh = (fetchedAt) => {
30540
+ const existing = refreshInFlight;
30541
+ if (existing !== null) return existing;
30542
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30543
+ refreshInFlight = null;
30544
+ });
30545
+ refreshInFlight = started;
30546
+ return started;
30547
+ };
30548
+ const ensureFresh = async () => {
30549
+ const slice = runtimeState.getCapState(cap.name);
30550
+ const fetchedAt = readFetchedAt();
30551
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30552
+ missCooldownUntil = 0;
30553
+ return;
30554
+ }
30555
+ if (Date.now() < missCooldownUntil) return;
30556
+ if (staleRead === "serve-and-revalidate") {
30557
+ if (refreshInFlight !== null) return;
30558
+ if (slice && fetchedAt > 0) {
30559
+ startRefresh(fetchedAt);
30560
+ return;
30561
+ }
30562
+ await startRefresh(fetchedAt);
30563
+ return;
30564
+ }
30565
+ await runRefresh(fetchedAt);
30566
+ };
30410
30567
  const projectStatus = () => {
30411
30568
  const slice = runtimeState.getCapState(cap.name);
30412
30569
  if (!slice) return empty();
@@ -232375,6 +232532,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232375
232532
  * within a day while removing the per-poll round-trip entirely. */
232376
232533
  static CAP_OPTIONS_TTL_MS = 360 * 6e4;
232377
232534
  /**
232535
+ * Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
232536
+ *
232537
+ * The same discipline `createRuntimeStateBridge` applies to a refresh that
232538
+ * did not land, for the same reason and against the same measurement. A probe
232539
+ * that fails persists nothing, so without this the next caller probes again —
232540
+ * and on a camera that is merely unreachable each attempt burns the full
232541
+ * connect give-up. Measured on the live hub (2026-08-21, device 3629, a
232542
+ * battery Reolink the registry already calls offline): `getOptions` took
232543
+ * **23.1s on every single call**, while its `getStatus` sibling answered in
232544
+ * 8ms because the bridge had already opened its cooldown. The status side was
232545
+ * bounded and the options side was not, and only one of them was visible.
232546
+ */
232547
+ capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
232548
+ /**
232549
+ * Per-cap in-flight options probe, so concurrent callers share ONE.
232550
+ *
232551
+ * The cooldown above only opens once a probe has FAILED — and on an
232552
+ * unreachable camera that takes the full connect give-up (23.1s measured on
232553
+ * device 3629). Every poll arriving inside that window used to start its own
232554
+ * probe, so the cooldown never got a chance to bound anything: the fix for
232555
+ * "one dial per poll" has to cover the dial that is still in the air.
232556
+ *
232557
+ * A caller that finds one here is SERVED, not queued behind it — this method
232558
+ * sits on a polled path, and waiting for a probe that is not coming back is
232559
+ * how one unreachable camera stalls every poll it overlaps.
232560
+ */
232561
+ capOptionsInFlight = /* @__PURE__ */ new Map();
232562
+ /**
232378
232563
  * Read a persisted `getOptions` descriptor, validated with the cap's
232379
232564
  * OWN Zod schema. Validation (not a cast) is what makes the
232380
232565
  * `z.unknown()` blob type-safe: an entry written by an older addon
@@ -232432,7 +232617,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232432
232617
  async resolveCapOptions(params) {
232433
232618
  const { capName, schema, probe, fallback } = params;
232434
232619
  const cached = this.readCapOptionsCache(capName, schema);
232435
- if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) return cached.value;
232620
+ if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
232621
+ if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
232622
+ this.capOptionsMissCooldownUntil.delete(capName);
232623
+ return cached.value;
232624
+ }
232436
232625
  if (this.isBattery && this.sleeping) {
232437
232626
  this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
232438
232627
  tags: { deviceId: this.id },
@@ -232443,19 +232632,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232443
232632
  });
232444
232633
  return cached?.value ?? fallback();
232445
232634
  }
232635
+ const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
232636
+ if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
232637
+ const started = probe();
232638
+ this.capOptionsInFlight.set(capName, started);
232446
232639
  try {
232447
- const value = await probe();
232640
+ const value = await started;
232448
232641
  await this.persistCapOptions(capName, value);
232642
+ this.capOptionsMissCooldownUntil.delete(capName);
232449
232643
  return value;
232450
232644
  } catch (err) {
232451
- this.ctx.logger.debug("cap options probe failed — serving cache/fallback", {
232645
+ this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
232646
+ this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
232452
232647
  tags: { deviceId: this.id },
232453
232648
  meta: {
232454
232649
  capName,
232650
+ cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
232651
+ hasCache: cached !== null,
232455
232652
  error: err instanceof Error ? err.message : String(err)
232456
232653
  }
232457
232654
  });
232458
232655
  return cached?.value ?? fallback();
232656
+ } finally {
232657
+ if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
232459
232658
  }
232460
232659
  }
232461
232660
  /**
@@ -232896,7 +233095,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232896
233095
  registerPrivacyMaskCap() {
232897
233096
  const channel = this.getChannel();
232898
233097
  const CAP_NAME = "privacy-mask";
232899
- const STALE_MS = 1e4;
232900
233098
  const refreshFromCamera = async () => {
232901
233099
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
232902
233100
  const promise = (async () => {
@@ -232949,7 +233147,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232949
233147
  cap: privacyMaskCapability,
232950
233148
  ownDeviceId: this.id,
232951
233149
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
232952
- staleMs: STALE_MS,
233150
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
233151
+ staleRead: "serve-and-revalidate",
232953
233152
  empty: () => ({
232954
233153
  enabled: false,
232955
233154
  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.48",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",