@camstack/addon-provider-reolink 1.2.28 → 1.2.30

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 +254 -11
  2. package/dist/addon.mjs +254 -11
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -19732,7 +19732,16 @@ targets: array(object({
19732
19732
  /** A sleeping battery camera: the frame is deliberately stale and will
19733
19733
  * NOT refresh in the background. A surface should say so rather than
19734
19734
  * present it as current. */
19735
- sleeping: boolean()
19735
+ sleeping: boolean(),
19736
+ /** Current device state rendered over the cached frame. State images
19737
+ * remain authoritative even when their photographic background is
19738
+ * old; null means the link must carry a current camera frame. */
19739
+ stateReason: _enum([
19740
+ "disabled",
19741
+ "sleeping",
19742
+ "unreachable",
19743
+ "waking"
19744
+ ]).nullable()
19736
19745
  })))
19737
19746
  },
19738
19747
  status: {
@@ -21392,6 +21401,25 @@ var BatteryStatusSchema = object({
21392
21401
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
21393
21402
  lastUpdated: number(),
21394
21403
  /**
21404
+ * Ms epoch of the last time the device PROVED it was reachable — a
21405
+ * completed firmware round-trip, an observed wake, or an inbound push
21406
+ * (firmware event, email). `0`/absent = never since this slice was born.
21407
+ *
21408
+ * This is the ONLY input that separates "asleep" from "gone", and it is
21409
+ * fed exclusively by PASSIVE signals: nothing may write it by reaching
21410
+ * for the radio, because a poll that confirms reachability is the same
21411
+ * poll that drains the battery. See {@link deriveBatteryPresence} — the
21412
+ * single derivation every consumer must use; no surface computes its own.
21413
+ *
21414
+ * It is deliberately NOT a clock in the
21415
+ * `scripts/check-runtime-state-durability.ts` sense: it is the
21416
+ * observation itself, and it is the only thing a 30-hour silence is
21417
+ * visible in. Writers quantise it (see `CONTACT_WRITE_QUANTUM_MS` in the
21418
+ * Reolink provider) so a value that means "recently" cannot cost a
21419
+ * SQLite commit per round-trip.
21420
+ */
21421
+ lastContactAt: number().optional(),
21422
+ /**
21395
21423
  * True when the source is a BINARY low-battery indicator (HA
21396
21424
  * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
21397
21425
  * charge level — `percentage` is then a coarse stand-in (100 = normal,
@@ -27427,13 +27455,63 @@ var CamStreamDescriptorSchema = object({
27427
27455
  * set of stream descriptors it can offer for the device, synchronously, so the
27428
27456
  * broker can reconcile its registry against the authoritative provider state.
27429
27457
  */
27458
+ /**
27459
+ * The catalog as a DURABLE fact rather than a live answer.
27460
+ *
27461
+ * A battery camera's descriptors are profile-stable — they change when the
27462
+ * operator rewrites an encoder profile, not minute to minute — but building
27463
+ * them costs a Baichuan login, which on a sleeping Argus IS a wake. So the
27464
+ * provider is allowed to build them exactly once per profile and must serve
27465
+ * every later pull from a cache.
27466
+ *
27467
+ * Holding that cache only in RAM is what turned a restart into an outage. The
27468
+ * runner comes back with the camera asleep, `buildStreamCatalogUncached`
27469
+ * correctly refuses to wake it, the pull answers `[]`, the broker has no
27470
+ * cam-stream entry to build a broker from, and `webrtcSession.handleOffer`
27471
+ * fails with a flat "No broker for stream" — for as long as the camera sleeps,
27472
+ * which on a battery cam is most of the day. The camera was fine. The stream
27473
+ * was unreachable because the process had forgotten what the camera offers.
27474
+ *
27475
+ * Declaring it here puts it in `device-runtime-state`, the kernel's canonical
27476
+ * declared collection, with the same `restored` durability `battery` uses for
27477
+ * the same reason: the last known value is the only value there is while the
27478
+ * device is asleep. The broker's brokers are therefore always DEFINABLE — it
27479
+ * is the DIAL that wakes a camera, never the catalog (D173).
27480
+ */
27481
+ var StreamCatalogStateSchema = object({
27482
+ /** The descriptors as last built from a real camera response. Never a guess:
27483
+ * a failed or refused build writes NOTHING, so a restored catalog is always
27484
+ * one the camera itself once produced. */
27485
+ descriptors: array(CamStreamDescriptorSchema),
27486
+ /** Ms epoch of the build that produced {@link descriptors}. Lets the wake
27487
+ * path decide whether the camera's own awake window is worth spending on a
27488
+ * re-read. */
27489
+ lastFetchedAt: number()
27490
+ });
27430
27491
  var streamCatalogCapability = {
27431
27492
  name: "stream-catalog",
27432
27493
  scope: "device",
27433
27494
  deviceNative: true,
27434
27495
  mode: "singleton",
27435
27496
  deviceTypes: [DeviceType.Camera],
27436
- methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) }
27497
+ methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) },
27498
+ runtimeState: StreamCatalogStateSchema,
27499
+ /**
27500
+ * Runtime-state durability: **restored** — see the schema doc. A cold
27501
+ * catalog on a sleeping battery camera is not a slow first frame, it is a
27502
+ * camera that cannot be watched at all until it happens to wake.
27503
+ *
27504
+ * Churn is nil by construction: the slice is written only by a SUCCESSFUL
27505
+ * build, and a build only runs when there is no cached copy (or the copy is
27506
+ * a day old and the camera is awake anyway).
27507
+ *
27508
+ * See `RuntimeStateDurability`. Enforced by
27509
+ * `scripts/check-runtime-state-durability.ts`.
27510
+ */
27511
+ durability: "restored",
27512
+ /** Clock field: written, but excluded from the compare that decides whether
27513
+ * persisting is worth a SQLite commit — the descriptors are the value. */
27514
+ volatileStateFields: ["lastFetchedAt"]
27437
27515
  };
27438
27516
  /** One of the camera's stream profiles. */
27439
27517
  var StreamProfileSchema = _enum([
@@ -29297,6 +29375,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
29297
29375
  sceneMonitor: sceneMonitorCapability,
29298
29376
  scriptRunner: scriptRunnerCapability,
29299
29377
  smoke: smokeCapability,
29378
+ streamCatalog: streamCatalogCapability,
29300
29379
  streamParams: streamParamsCapability,
29301
29380
  switch: switchCapability,
29302
29381
  tamper: tamperCapability,
@@ -230171,6 +230250,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230171
230250
  * retries.
230172
230251
  */
230173
230252
  async onProbe() {
230253
+ if (this.isBattery && this.sleeping) {
230254
+ this.ctx.logger.info("onProbe skipped — battery cam is sleeping (no login, no wake)", {
230255
+ tags: { deviceId: this.id },
230256
+ meta: { probeRetriesAvoided: true }
230257
+ });
230258
+ return;
230259
+ }
230174
230260
  let api;
230175
230261
  try {
230176
230262
  api = await this.ensureApi();
@@ -230839,6 +230925,39 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230839
230925
  });
230840
230926
  await sleep$1(1500);
230841
230927
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
230928
+ const CONFIRM_TIMEOUT_MS = 1e4;
230929
+ const CONFIRM_POLL_MS = 1e3;
230930
+ const confirmDeadline = Date.now() + CONFIRM_TIMEOUT_MS;
230931
+ let confirmed = false;
230932
+ while (Date.now() < confirmDeadline) {
230933
+ let state = null;
230934
+ try {
230935
+ state = api.getSleepStatus({ channel: this.getChannel() }).state;
230936
+ } catch {
230937
+ state = null;
230938
+ }
230939
+ if (state === "awake") {
230940
+ confirmed = true;
230941
+ break;
230942
+ }
230943
+ await sleep$1(CONFIRM_POLL_MS);
230944
+ }
230945
+ if (confirmed && this.commitSleepState(false, "sleep-poll")) {
230946
+ this.ctx.logger.info("battery wakeForStream: wake confirmed — driving wake transition", {
230947
+ tags: { deviceId: this.id },
230948
+ meta: { confirmMs: Date.now() - startedAt }
230949
+ });
230950
+ this.onWakeTransition("sleep-poll").catch(() => {});
230951
+ } else if (!confirmed) {
230952
+ this.ctx.logger.warn("battery wakeForStream: ACK received but no awake evidence inside the confirm window", {
230953
+ tags: { deviceId: this.id },
230954
+ meta: { confirmTimeoutMs: CONFIRM_TIMEOUT_MS }
230955
+ });
230956
+ return {
230957
+ awoke: false,
230958
+ durationMs: Date.now() - startedAt
230959
+ };
230960
+ }
230842
230961
  return {
230843
230962
  awoke: true,
230844
230963
  durationMs: Date.now() - startedAt
@@ -230871,9 +230990,21 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230871
230990
  status: slice
230872
230991
  }));
230873
230992
  });
230874
- this.refreshBatteryFromApi();
230993
+ this.refreshBatteryFromApi("register");
230875
230994
  }
230876
- async refreshBatteryFromApi() {
230995
+ /**
230996
+ * @param reason - `'register'` and `'periodic'` are OUR initiative and are
230997
+ * refused while the camera sleeps; `'wake'` and `'demand'` run because
230998
+ * something already has the camera awake or is entitled to wake it.
230999
+ */
231000
+ async refreshBatteryFromApi(reason) {
231001
+ if (this.isBattery && this.sleeping && (reason === "register" || reason === "periodic")) {
231002
+ this.ctx.logger.debug("battery refresh skipped — cam sleeping, reading restored slice", {
231003
+ tags: { deviceId: this.id },
231004
+ meta: { reason }
231005
+ });
231006
+ return;
231007
+ }
230877
231008
  let api;
230878
231009
  try {
230879
231010
  api = await this.ensureApi();
@@ -231031,7 +231162,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
231031
231162
  return true;
231032
231163
  }
231033
231164
  updateBatteryCache(info) {
231034
- this.setCapSlice(batteryCapability, this.mapBatteryInfo(info));
231165
+ const mapped = this.mapBatteryInfo(info);
231166
+ const now = Date.now();
231167
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
231168
+ const previousContact = this.state.battery.lastContactAt ?? 0;
231169
+ this.setCapSlice(batteryCapability, {
231170
+ ...mapped,
231171
+ lastContactAt: Math.max(previousContact, quantised)
231172
+ });
231035
231173
  }
231036
231174
  /**
231037
231175
  * Battery cams require an explicit wake before cmd_id 109 will
@@ -233356,6 +233494,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233356
233494
  */
233357
233495
  async buildStreamCatalog() {
233358
233496
  if (this.cachedStreamDescriptors?.length) return this.withLiveNativeSdp(this.cachedStreamDescriptors);
233497
+ const restored = this.restoreStreamCatalogFromLedger();
233498
+ if (restored) return this.withLiveNativeSdp(restored);
233359
233499
  if (this.buildStreamCatalogInFlight) return this.withLiveNativeSdp(await this.buildStreamCatalogInFlight);
233360
233500
  const build = this.buildStreamCatalogUncached();
233361
233501
  this.buildStreamCatalogInFlight = build;
@@ -233528,6 +233668,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233528
233668
  autoEligible: e.autoEligible
233529
233669
  }));
233530
233670
  this.cachedStreamDescriptors = descriptors;
233671
+ this.persistStreamCatalogToLedger(descriptors);
233531
233672
  return descriptors;
233532
233673
  }
233533
233674
  /** Profile-stable stream descriptors, cached after the first successful
@@ -233535,6 +233676,66 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233535
233676
  * sleeping battery cam is never woken by a catalog poll. Invalidated by
233536
233677
  * `applyStreamProfilePatch` (codec/resolution may change). */
233537
233678
  cachedStreamDescriptors;
233679
+ /**
233680
+ * Write the just-built catalog to the durable `stream-catalog` slice, so a
233681
+ * restart with the camera asleep still has descriptors to serve (D173).
233682
+ *
233683
+ * Best-effort by design: the RAM copy is already advanced by the caller, and
233684
+ * losing this write costs one cold catalog after the next restart — never a
233685
+ * wrong catalog. A build that FAILED writes nothing at all and therefore
233686
+ * cannot demote a good stored copy (D49's failure direction).
233687
+ */
233688
+ persistStreamCatalogToLedger(descriptors) {
233689
+ if (descriptors.length === 0) return;
233690
+ try {
233691
+ const state = {
233692
+ descriptors: [...descriptors],
233693
+ lastFetchedAt: Date.now()
233694
+ };
233695
+ this.runtimeState.setCapState(streamCatalogCapability.name, state);
233696
+ this.ctx.logger.debug("stream catalog persisted to the durable slice", {
233697
+ tags: { deviceId: this.id },
233698
+ meta: { count: descriptors.length }
233699
+ });
233700
+ } catch (err) {
233701
+ this.ctx.logger.debug("stream catalog persist failed — RAM copy stands", {
233702
+ tags: { deviceId: this.id },
233703
+ meta: { error: err instanceof Error ? err.message : String(err) }
233704
+ });
233705
+ }
233706
+ }
233707
+ /**
233708
+ * Rehydrate `cachedStreamDescriptors` from the durable slice. Returns the
233709
+ * restored descriptors, or `null` when there is nothing to restore.
233710
+ *
233711
+ * Logged at `info` when it fires: "these descriptors came from before the
233712
+ * restart" must never be something a reader has to infer (the DurableLedger
233713
+ * contract, D132).
233714
+ */
233715
+ restoreStreamCatalogFromLedger() {
233716
+ const stored = this.runtimeState.getCapState(streamCatalogCapability.name);
233717
+ const descriptors = stored?.descriptors;
233718
+ if (!descriptors || descriptors.length === 0) return null;
233719
+ this.cachedStreamDescriptors = [...descriptors];
233720
+ this.ctx.logger.info("stream catalog restored from the durable slice (no camera contact)", {
233721
+ tags: { deviceId: this.id },
233722
+ meta: {
233723
+ count: descriptors.length,
233724
+ builtAt: stored?.lastFetchedAt ?? 0,
233725
+ ageMs: Date.now() - (stored?.lastFetchedAt ?? 0),
233726
+ sleeping: this.sleeping
233727
+ }
233728
+ });
233729
+ return this.cachedStreamDescriptors;
233730
+ }
233731
+ /**
233732
+ * How old a RESTORED catalog may get before a natural wake is worth spending
233733
+ * on a re-read. The catalog is profile-stable, so this is not about
233734
+ * freshness — it is the backstop for a profile changed by something that did
233735
+ * not invalidate the cache (a firmware update, an edit made on the Reolink
233736
+ * app). A day is several natural wakes on any camera that is working.
233737
+ */
233738
+ static CATALOG_REFRESH_ON_WAKE_MS = 1440 * 6e4;
233538
233739
  /** Single-flight guard for `buildStreamCatalog`. */
233539
233740
  buildStreamCatalogInFlight = null;
233540
233741
  /**
@@ -233997,7 +234198,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233997
234198
  auxAccessoryCount: this.auxAccessoryRefs.size
233998
234199
  }
233999
234200
  });
234000
- await this.refreshBatteryFromApi().catch(() => {});
234201
+ await this.refreshBatteryFromApi("periodic").catch(() => {});
234001
234202
  await this.alignAuxDevicesState("periodic").catch(() => {});
234002
234203
  await this.refreshParentSettingsSnapshot().catch(() => {});
234003
234204
  } finally {
@@ -234032,6 +234233,44 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234032
234233
  }
234033
234234
  }
234034
234235
  /**
234236
+ * Is this wake worth spending on a catalog re-read? See
234237
+ * `CATALOG_REFRESH_ON_WAKE_MS`. Held apart from `onWakeTransition` so the
234238
+ * decision is one expression a test can pin.
234239
+ */
234240
+ shouldRebuildCatalogOnWake() {
234241
+ if (!this.cachedStreamDescriptors?.length) {
234242
+ if (!this.restoreStreamCatalogFromLedger()) return true;
234243
+ }
234244
+ const builtAt = this.runtimeState.getCapState(streamCatalogCapability.name)?.lastFetchedAt ?? 0;
234245
+ if (builtAt <= 0) return true;
234246
+ return Date.now() - builtAt > ReolinkCamera.CATALOG_REFRESH_ON_WAKE_MS;
234247
+ }
234248
+ /**
234249
+ * A PASSIVE proof of reachability just arrived — stamp `battery.lastContactAt`
234250
+ * so `deriveBatteryPresence` can tell "asleep" from "gone" (D173).
234251
+ *
234252
+ * Callable only from paths where the evidence cost us nothing: an inbound
234253
+ * firmware push, an observed wake, a round-trip somebody else's demand
234254
+ * already paid for. Never from a poll issued to answer this question — that
234255
+ * poll is the wake it is trying to detect.
234256
+ *
234257
+ * Quantised to `CONTACT_WRITE_QUANTUM_MS`: the value means "recently", and
234258
+ * writing it at millisecond resolution would put a SQLite commit behind
234259
+ * every Baichuan reply on the hub's busiest write path (the exact cost
234260
+ * `scripts/check-runtime-state-durability.ts` exists to bound).
234261
+ */
234262
+ markPassiveContact() {
234263
+ if (!this.isBattery) return;
234264
+ const now = Date.now();
234265
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
234266
+ if (quantised <= (this.state.battery.lastContactAt ?? 0)) return;
234267
+ this.state.battery.lastContactAt = quantised;
234268
+ }
234269
+ /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
234270
+ * Bounds the commit rate this field can cost at 12/hour/device, and only
234271
+ * for a device something is actually reaching. */
234272
+ static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
234273
+ /**
234035
234274
  * Shared wake-transition handler invoked by both the simpleEvent
234036
234275
  * `awake` push (canonical fast path) and the sleep poll's
234037
234276
  * `sleeping → awake` flip (backstop). Mirrors Scrypted's
@@ -234056,7 +234295,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234056
234295
  isBattery: this.isBattery
234057
234296
  }
234058
234297
  });
234059
- if (!this.cachedStreamDescriptors?.length) try {
234298
+ if (this.shouldRebuildCatalogOnWake()) try {
234299
+ this.cachedStreamDescriptors = void 0;
234060
234300
  if ((await this.buildStreamCatalog()).length > 0) this.ctx.eventBus.emit(createEvent(EventCategory.StreamParamsChanged, this.eventSource(), { deviceId: this.id }));
234061
234301
  } catch (err) {
234062
234302
  this.ctx.logger.debug("onWakeTransition: stream catalog build failed — will retry on next wake", {
@@ -235769,7 +236009,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235769
236009
  this.startSleepPoll();
235770
236010
  this.startBatteryUpdatePolling();
235771
236011
  this.registerBatteryIfSupported();
235772
- this.refreshBatteryFromApi();
236012
+ this.refreshBatteryFromApi("demand");
235773
236013
  } else this.startAlignAuxPolling();
235774
236014
  this.resubscribeSimpleEvents(api, "adoptApi").catch((err) => {
235775
236015
  this.ctx.logger.debug("Reolink adoptApi: simple-event subscribe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -235881,7 +236121,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235881
236121
  this.startSleepPoll();
235882
236122
  this.startBatteryUpdatePolling();
235883
236123
  this.registerBatteryIfSupported();
235884
- this.refreshBatteryFromApi();
236124
+ this.refreshBatteryFromApi("demand");
235885
236125
  }
235886
236126
  this.startWatchdogs();
235887
236127
  this.probeAndPersistFeatures(api).catch((err) => {
@@ -235965,6 +236205,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235965
236205
  this.lastEventAt = Date.now();
235966
236206
  this.consecutiveStaleHealthChecks = 0;
235967
236207
  this.nextEventHealthCheckAt = 0;
236208
+ this.markPassiveContact();
235968
236209
  const eventSource = this.eventSource();
235969
236210
  if (event.type !== "battery") this.ctx.logger.info("Reolink simpleEvent received", { meta: {
235970
236211
  type: event.type,
@@ -236397,6 +236638,9 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236397
236638
  const data = event.data;
236398
236639
  if (data.parentDeviceId !== this.id) return;
236399
236640
  const cid = typeof data.deviceId === "number" ? data.deviceId : null;
236641
+ if (cid !== null) {
236642
+ for (const [ch, did] of this.channelToDeviceId.entries()) if (did === cid) this.channelToDeviceId.delete(ch);
236643
+ }
236400
236644
  this.ctx.logger.info("Reolink Hub: child unregistered externally — refreshing discovery", cid !== null ? { tags: { deviceId: cid } } : {});
236401
236645
  this.refreshDiscoveryFromCamera().catch(() => {});
236402
236646
  }));
@@ -236693,7 +236937,6 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236693
236937
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236694
236938
  } });
236695
236939
  const adoptedByChannel = await this.loadAdoptedChildrenByChannel();
236696
- this.channelToDeviceId.clear();
236697
236940
  for (const [channel, deviceId] of adoptedByChannel) this.channelToDeviceId.set(channel, deviceId);
236698
236941
  try {
236699
236942
  discovered = (await (await this.ensureApi()).getNvrChannelsSummary({
@@ -236701,7 +236944,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236701
236944
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236702
236945
  })).devices.map((d) => {
236703
236946
  const childNativeId = computeChildNativeId(this.stableId, d.channel, d.uid);
236704
- const adoptedDeviceId = adoptedByChannel.get(d.channel) ?? null;
236947
+ const adoptedDeviceId = this.channelToDeviceId.get(d.channel) ?? null;
236705
236948
  return {
236706
236949
  childNativeId,
236707
236950
  name: d.name ?? `Channel ${d.channel}`,
package/dist/addon.mjs CHANGED
@@ -19727,7 +19727,16 @@ targets: array(object({
19727
19727
  /** A sleeping battery camera: the frame is deliberately stale and will
19728
19728
  * NOT refresh in the background. A surface should say so rather than
19729
19729
  * present it as current. */
19730
- sleeping: boolean()
19730
+ sleeping: boolean(),
19731
+ /** Current device state rendered over the cached frame. State images
19732
+ * remain authoritative even when their photographic background is
19733
+ * old; null means the link must carry a current camera frame. */
19734
+ stateReason: _enum([
19735
+ "disabled",
19736
+ "sleeping",
19737
+ "unreachable",
19738
+ "waking"
19739
+ ]).nullable()
19731
19740
  })))
19732
19741
  },
19733
19742
  status: {
@@ -21387,6 +21396,25 @@ var BatteryStatusSchema = object({
21387
21396
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
21388
21397
  lastUpdated: number(),
21389
21398
  /**
21399
+ * Ms epoch of the last time the device PROVED it was reachable — a
21400
+ * completed firmware round-trip, an observed wake, or an inbound push
21401
+ * (firmware event, email). `0`/absent = never since this slice was born.
21402
+ *
21403
+ * This is the ONLY input that separates "asleep" from "gone", and it is
21404
+ * fed exclusively by PASSIVE signals: nothing may write it by reaching
21405
+ * for the radio, because a poll that confirms reachability is the same
21406
+ * poll that drains the battery. See {@link deriveBatteryPresence} — the
21407
+ * single derivation every consumer must use; no surface computes its own.
21408
+ *
21409
+ * It is deliberately NOT a clock in the
21410
+ * `scripts/check-runtime-state-durability.ts` sense: it is the
21411
+ * observation itself, and it is the only thing a 30-hour silence is
21412
+ * visible in. Writers quantise it (see `CONTACT_WRITE_QUANTUM_MS` in the
21413
+ * Reolink provider) so a value that means "recently" cannot cost a
21414
+ * SQLite commit per round-trip.
21415
+ */
21416
+ lastContactAt: number().optional(),
21417
+ /**
21390
21418
  * True when the source is a BINARY low-battery indicator (HA
21391
21419
  * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
21392
21420
  * charge level — `percentage` is then a coarse stand-in (100 = normal,
@@ -27422,13 +27450,63 @@ var CamStreamDescriptorSchema = object({
27422
27450
  * set of stream descriptors it can offer for the device, synchronously, so the
27423
27451
  * broker can reconcile its registry against the authoritative provider state.
27424
27452
  */
27453
+ /**
27454
+ * The catalog as a DURABLE fact rather than a live answer.
27455
+ *
27456
+ * A battery camera's descriptors are profile-stable — they change when the
27457
+ * operator rewrites an encoder profile, not minute to minute — but building
27458
+ * them costs a Baichuan login, which on a sleeping Argus IS a wake. So the
27459
+ * provider is allowed to build them exactly once per profile and must serve
27460
+ * every later pull from a cache.
27461
+ *
27462
+ * Holding that cache only in RAM is what turned a restart into an outage. The
27463
+ * runner comes back with the camera asleep, `buildStreamCatalogUncached`
27464
+ * correctly refuses to wake it, the pull answers `[]`, the broker has no
27465
+ * cam-stream entry to build a broker from, and `webrtcSession.handleOffer`
27466
+ * fails with a flat "No broker for stream" — for as long as the camera sleeps,
27467
+ * which on a battery cam is most of the day. The camera was fine. The stream
27468
+ * was unreachable because the process had forgotten what the camera offers.
27469
+ *
27470
+ * Declaring it here puts it in `device-runtime-state`, the kernel's canonical
27471
+ * declared collection, with the same `restored` durability `battery` uses for
27472
+ * the same reason: the last known value is the only value there is while the
27473
+ * device is asleep. The broker's brokers are therefore always DEFINABLE — it
27474
+ * is the DIAL that wakes a camera, never the catalog (D173).
27475
+ */
27476
+ var StreamCatalogStateSchema = object({
27477
+ /** The descriptors as last built from a real camera response. Never a guess:
27478
+ * a failed or refused build writes NOTHING, so a restored catalog is always
27479
+ * one the camera itself once produced. */
27480
+ descriptors: array(CamStreamDescriptorSchema),
27481
+ /** Ms epoch of the build that produced {@link descriptors}. Lets the wake
27482
+ * path decide whether the camera's own awake window is worth spending on a
27483
+ * re-read. */
27484
+ lastFetchedAt: number()
27485
+ });
27425
27486
  var streamCatalogCapability = {
27426
27487
  name: "stream-catalog",
27427
27488
  scope: "device",
27428
27489
  deviceNative: true,
27429
27490
  mode: "singleton",
27430
27491
  deviceTypes: [DeviceType.Camera],
27431
- methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) }
27492
+ methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) },
27493
+ runtimeState: StreamCatalogStateSchema,
27494
+ /**
27495
+ * Runtime-state durability: **restored** — see the schema doc. A cold
27496
+ * catalog on a sleeping battery camera is not a slow first frame, it is a
27497
+ * camera that cannot be watched at all until it happens to wake.
27498
+ *
27499
+ * Churn is nil by construction: the slice is written only by a SUCCESSFUL
27500
+ * build, and a build only runs when there is no cached copy (or the copy is
27501
+ * a day old and the camera is awake anyway).
27502
+ *
27503
+ * See `RuntimeStateDurability`. Enforced by
27504
+ * `scripts/check-runtime-state-durability.ts`.
27505
+ */
27506
+ durability: "restored",
27507
+ /** Clock field: written, but excluded from the compare that decides whether
27508
+ * persisting is worth a SQLite commit — the descriptors are the value. */
27509
+ volatileStateFields: ["lastFetchedAt"]
27432
27510
  };
27433
27511
  /** One of the camera's stream profiles. */
27434
27512
  var StreamProfileSchema = _enum([
@@ -29292,6 +29370,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
29292
29370
  sceneMonitor: sceneMonitorCapability,
29293
29371
  scriptRunner: scriptRunnerCapability,
29294
29372
  smoke: smokeCapability,
29373
+ streamCatalog: streamCatalogCapability,
29295
29374
  streamParams: streamParamsCapability,
29296
29375
  switch: switchCapability,
29297
29376
  tamper: tamperCapability,
@@ -230151,6 +230230,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230151
230230
  * retries.
230152
230231
  */
230153
230232
  async onProbe() {
230233
+ if (this.isBattery && this.sleeping) {
230234
+ this.ctx.logger.info("onProbe skipped — battery cam is sleeping (no login, no wake)", {
230235
+ tags: { deviceId: this.id },
230236
+ meta: { probeRetriesAvoided: true }
230237
+ });
230238
+ return;
230239
+ }
230154
230240
  let api;
230155
230241
  try {
230156
230242
  api = await this.ensureApi();
@@ -230819,6 +230905,39 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230819
230905
  });
230820
230906
  await sleep$1(1500);
230821
230907
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
230908
+ const CONFIRM_TIMEOUT_MS = 1e4;
230909
+ const CONFIRM_POLL_MS = 1e3;
230910
+ const confirmDeadline = Date.now() + CONFIRM_TIMEOUT_MS;
230911
+ let confirmed = false;
230912
+ while (Date.now() < confirmDeadline) {
230913
+ let state = null;
230914
+ try {
230915
+ state = api.getSleepStatus({ channel: this.getChannel() }).state;
230916
+ } catch {
230917
+ state = null;
230918
+ }
230919
+ if (state === "awake") {
230920
+ confirmed = true;
230921
+ break;
230922
+ }
230923
+ await sleep$1(CONFIRM_POLL_MS);
230924
+ }
230925
+ if (confirmed && this.commitSleepState(false, "sleep-poll")) {
230926
+ this.ctx.logger.info("battery wakeForStream: wake confirmed — driving wake transition", {
230927
+ tags: { deviceId: this.id },
230928
+ meta: { confirmMs: Date.now() - startedAt }
230929
+ });
230930
+ this.onWakeTransition("sleep-poll").catch(() => {});
230931
+ } else if (!confirmed) {
230932
+ this.ctx.logger.warn("battery wakeForStream: ACK received but no awake evidence inside the confirm window", {
230933
+ tags: { deviceId: this.id },
230934
+ meta: { confirmTimeoutMs: CONFIRM_TIMEOUT_MS }
230935
+ });
230936
+ return {
230937
+ awoke: false,
230938
+ durationMs: Date.now() - startedAt
230939
+ };
230940
+ }
230822
230941
  return {
230823
230942
  awoke: true,
230824
230943
  durationMs: Date.now() - startedAt
@@ -230851,9 +230970,21 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230851
230970
  status: slice
230852
230971
  }));
230853
230972
  });
230854
- this.refreshBatteryFromApi();
230973
+ this.refreshBatteryFromApi("register");
230855
230974
  }
230856
- async refreshBatteryFromApi() {
230975
+ /**
230976
+ * @param reason - `'register'` and `'periodic'` are OUR initiative and are
230977
+ * refused while the camera sleeps; `'wake'` and `'demand'` run because
230978
+ * something already has the camera awake or is entitled to wake it.
230979
+ */
230980
+ async refreshBatteryFromApi(reason) {
230981
+ if (this.isBattery && this.sleeping && (reason === "register" || reason === "periodic")) {
230982
+ this.ctx.logger.debug("battery refresh skipped — cam sleeping, reading restored slice", {
230983
+ tags: { deviceId: this.id },
230984
+ meta: { reason }
230985
+ });
230986
+ return;
230987
+ }
230857
230988
  let api;
230858
230989
  try {
230859
230990
  api = await this.ensureApi();
@@ -231011,7 +231142,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
231011
231142
  return true;
231012
231143
  }
231013
231144
  updateBatteryCache(info) {
231014
- this.setCapSlice(batteryCapability, this.mapBatteryInfo(info));
231145
+ const mapped = this.mapBatteryInfo(info);
231146
+ const now = Date.now();
231147
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
231148
+ const previousContact = this.state.battery.lastContactAt ?? 0;
231149
+ this.setCapSlice(batteryCapability, {
231150
+ ...mapped,
231151
+ lastContactAt: Math.max(previousContact, quantised)
231152
+ });
231015
231153
  }
231016
231154
  /**
231017
231155
  * Battery cams require an explicit wake before cmd_id 109 will
@@ -233336,6 +233474,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233336
233474
  */
233337
233475
  async buildStreamCatalog() {
233338
233476
  if (this.cachedStreamDescriptors?.length) return this.withLiveNativeSdp(this.cachedStreamDescriptors);
233477
+ const restored = this.restoreStreamCatalogFromLedger();
233478
+ if (restored) return this.withLiveNativeSdp(restored);
233339
233479
  if (this.buildStreamCatalogInFlight) return this.withLiveNativeSdp(await this.buildStreamCatalogInFlight);
233340
233480
  const build = this.buildStreamCatalogUncached();
233341
233481
  this.buildStreamCatalogInFlight = build;
@@ -233508,6 +233648,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233508
233648
  autoEligible: e.autoEligible
233509
233649
  }));
233510
233650
  this.cachedStreamDescriptors = descriptors;
233651
+ this.persistStreamCatalogToLedger(descriptors);
233511
233652
  return descriptors;
233512
233653
  }
233513
233654
  /** Profile-stable stream descriptors, cached after the first successful
@@ -233515,6 +233656,66 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233515
233656
  * sleeping battery cam is never woken by a catalog poll. Invalidated by
233516
233657
  * `applyStreamProfilePatch` (codec/resolution may change). */
233517
233658
  cachedStreamDescriptors;
233659
+ /**
233660
+ * Write the just-built catalog to the durable `stream-catalog` slice, so a
233661
+ * restart with the camera asleep still has descriptors to serve (D173).
233662
+ *
233663
+ * Best-effort by design: the RAM copy is already advanced by the caller, and
233664
+ * losing this write costs one cold catalog after the next restart — never a
233665
+ * wrong catalog. A build that FAILED writes nothing at all and therefore
233666
+ * cannot demote a good stored copy (D49's failure direction).
233667
+ */
233668
+ persistStreamCatalogToLedger(descriptors) {
233669
+ if (descriptors.length === 0) return;
233670
+ try {
233671
+ const state = {
233672
+ descriptors: [...descriptors],
233673
+ lastFetchedAt: Date.now()
233674
+ };
233675
+ this.runtimeState.setCapState(streamCatalogCapability.name, state);
233676
+ this.ctx.logger.debug("stream catalog persisted to the durable slice", {
233677
+ tags: { deviceId: this.id },
233678
+ meta: { count: descriptors.length }
233679
+ });
233680
+ } catch (err) {
233681
+ this.ctx.logger.debug("stream catalog persist failed — RAM copy stands", {
233682
+ tags: { deviceId: this.id },
233683
+ meta: { error: err instanceof Error ? err.message : String(err) }
233684
+ });
233685
+ }
233686
+ }
233687
+ /**
233688
+ * Rehydrate `cachedStreamDescriptors` from the durable slice. Returns the
233689
+ * restored descriptors, or `null` when there is nothing to restore.
233690
+ *
233691
+ * Logged at `info` when it fires: "these descriptors came from before the
233692
+ * restart" must never be something a reader has to infer (the DurableLedger
233693
+ * contract, D132).
233694
+ */
233695
+ restoreStreamCatalogFromLedger() {
233696
+ const stored = this.runtimeState.getCapState(streamCatalogCapability.name);
233697
+ const descriptors = stored?.descriptors;
233698
+ if (!descriptors || descriptors.length === 0) return null;
233699
+ this.cachedStreamDescriptors = [...descriptors];
233700
+ this.ctx.logger.info("stream catalog restored from the durable slice (no camera contact)", {
233701
+ tags: { deviceId: this.id },
233702
+ meta: {
233703
+ count: descriptors.length,
233704
+ builtAt: stored?.lastFetchedAt ?? 0,
233705
+ ageMs: Date.now() - (stored?.lastFetchedAt ?? 0),
233706
+ sleeping: this.sleeping
233707
+ }
233708
+ });
233709
+ return this.cachedStreamDescriptors;
233710
+ }
233711
+ /**
233712
+ * How old a RESTORED catalog may get before a natural wake is worth spending
233713
+ * on a re-read. The catalog is profile-stable, so this is not about
233714
+ * freshness — it is the backstop for a profile changed by something that did
233715
+ * not invalidate the cache (a firmware update, an edit made on the Reolink
233716
+ * app). A day is several natural wakes on any camera that is working.
233717
+ */
233718
+ static CATALOG_REFRESH_ON_WAKE_MS = 1440 * 6e4;
233518
233719
  /** Single-flight guard for `buildStreamCatalog`. */
233519
233720
  buildStreamCatalogInFlight = null;
233520
233721
  /**
@@ -233977,7 +234178,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233977
234178
  auxAccessoryCount: this.auxAccessoryRefs.size
233978
234179
  }
233979
234180
  });
233980
- await this.refreshBatteryFromApi().catch(() => {});
234181
+ await this.refreshBatteryFromApi("periodic").catch(() => {});
233981
234182
  await this.alignAuxDevicesState("periodic").catch(() => {});
233982
234183
  await this.refreshParentSettingsSnapshot().catch(() => {});
233983
234184
  } finally {
@@ -234012,6 +234213,44 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234012
234213
  }
234013
234214
  }
234014
234215
  /**
234216
+ * Is this wake worth spending on a catalog re-read? See
234217
+ * `CATALOG_REFRESH_ON_WAKE_MS`. Held apart from `onWakeTransition` so the
234218
+ * decision is one expression a test can pin.
234219
+ */
234220
+ shouldRebuildCatalogOnWake() {
234221
+ if (!this.cachedStreamDescriptors?.length) {
234222
+ if (!this.restoreStreamCatalogFromLedger()) return true;
234223
+ }
234224
+ const builtAt = this.runtimeState.getCapState(streamCatalogCapability.name)?.lastFetchedAt ?? 0;
234225
+ if (builtAt <= 0) return true;
234226
+ return Date.now() - builtAt > ReolinkCamera.CATALOG_REFRESH_ON_WAKE_MS;
234227
+ }
234228
+ /**
234229
+ * A PASSIVE proof of reachability just arrived — stamp `battery.lastContactAt`
234230
+ * so `deriveBatteryPresence` can tell "asleep" from "gone" (D173).
234231
+ *
234232
+ * Callable only from paths where the evidence cost us nothing: an inbound
234233
+ * firmware push, an observed wake, a round-trip somebody else's demand
234234
+ * already paid for. Never from a poll issued to answer this question — that
234235
+ * poll is the wake it is trying to detect.
234236
+ *
234237
+ * Quantised to `CONTACT_WRITE_QUANTUM_MS`: the value means "recently", and
234238
+ * writing it at millisecond resolution would put a SQLite commit behind
234239
+ * every Baichuan reply on the hub's busiest write path (the exact cost
234240
+ * `scripts/check-runtime-state-durability.ts` exists to bound).
234241
+ */
234242
+ markPassiveContact() {
234243
+ if (!this.isBattery) return;
234244
+ const now = Date.now();
234245
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
234246
+ if (quantised <= (this.state.battery.lastContactAt ?? 0)) return;
234247
+ this.state.battery.lastContactAt = quantised;
234248
+ }
234249
+ /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
234250
+ * Bounds the commit rate this field can cost at 12/hour/device, and only
234251
+ * for a device something is actually reaching. */
234252
+ static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
234253
+ /**
234015
234254
  * Shared wake-transition handler invoked by both the simpleEvent
234016
234255
  * `awake` push (canonical fast path) and the sleep poll's
234017
234256
  * `sleeping → awake` flip (backstop). Mirrors Scrypted's
@@ -234036,7 +234275,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234036
234275
  isBattery: this.isBattery
234037
234276
  }
234038
234277
  });
234039
- if (!this.cachedStreamDescriptors?.length) try {
234278
+ if (this.shouldRebuildCatalogOnWake()) try {
234279
+ this.cachedStreamDescriptors = void 0;
234040
234280
  if ((await this.buildStreamCatalog()).length > 0) this.ctx.eventBus.emit(createEvent(EventCategory.StreamParamsChanged, this.eventSource(), { deviceId: this.id }));
234041
234281
  } catch (err) {
234042
234282
  this.ctx.logger.debug("onWakeTransition: stream catalog build failed — will retry on next wake", {
@@ -235749,7 +235989,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235749
235989
  this.startSleepPoll();
235750
235990
  this.startBatteryUpdatePolling();
235751
235991
  this.registerBatteryIfSupported();
235752
- this.refreshBatteryFromApi();
235992
+ this.refreshBatteryFromApi("demand");
235753
235993
  } else this.startAlignAuxPolling();
235754
235994
  this.resubscribeSimpleEvents(api, "adoptApi").catch((err) => {
235755
235995
  this.ctx.logger.debug("Reolink adoptApi: simple-event subscribe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -235861,7 +236101,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235861
236101
  this.startSleepPoll();
235862
236102
  this.startBatteryUpdatePolling();
235863
236103
  this.registerBatteryIfSupported();
235864
- this.refreshBatteryFromApi();
236104
+ this.refreshBatteryFromApi("demand");
235865
236105
  }
235866
236106
  this.startWatchdogs();
235867
236107
  this.probeAndPersistFeatures(api).catch((err) => {
@@ -235945,6 +236185,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235945
236185
  this.lastEventAt = Date.now();
235946
236186
  this.consecutiveStaleHealthChecks = 0;
235947
236187
  this.nextEventHealthCheckAt = 0;
236188
+ this.markPassiveContact();
235948
236189
  const eventSource = this.eventSource();
235949
236190
  if (event.type !== "battery") this.ctx.logger.info("Reolink simpleEvent received", { meta: {
235950
236191
  type: event.type,
@@ -236377,6 +236618,9 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236377
236618
  const data = event.data;
236378
236619
  if (data.parentDeviceId !== this.id) return;
236379
236620
  const cid = typeof data.deviceId === "number" ? data.deviceId : null;
236621
+ if (cid !== null) {
236622
+ for (const [ch, did] of this.channelToDeviceId.entries()) if (did === cid) this.channelToDeviceId.delete(ch);
236623
+ }
236380
236624
  this.ctx.logger.info("Reolink Hub: child unregistered externally — refreshing discovery", cid !== null ? { tags: { deviceId: cid } } : {});
236381
236625
  this.refreshDiscoveryFromCamera().catch(() => {});
236382
236626
  }));
@@ -236673,7 +236917,6 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236673
236917
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236674
236918
  } });
236675
236919
  const adoptedByChannel = await this.loadAdoptedChildrenByChannel();
236676
- this.channelToDeviceId.clear();
236677
236920
  for (const [channel, deviceId] of adoptedByChannel) this.channelToDeviceId.set(channel, deviceId);
236678
236921
  try {
236679
236922
  discovered = (await (await this.ensureApi()).getNvrChannelsSummary({
@@ -236681,7 +236924,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236681
236924
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236682
236925
  })).devices.map((d) => {
236683
236926
  const childNativeId = computeChildNativeId(this.stableId, d.channel, d.uid);
236684
- const adoptedDeviceId = adoptedByChannel.get(d.channel) ?? null;
236927
+ const adoptedDeviceId = this.channelToDeviceId.get(d.channel) ?? null;
236685
236928
  return {
236686
236929
  childNativeId,
236687
236930
  name: d.name ?? `Channel ${d.channel}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.28",
3
+ "version": "1.2.30",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",