@camstack/addon-provider-reolink 1.2.28 → 1.2.29

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 +211 -10
  2. package/dist/addon.mjs +211 -10
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -21392,6 +21392,25 @@ var BatteryStatusSchema = object({
21392
21392
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
21393
21393
  lastUpdated: number(),
21394
21394
  /**
21395
+ * Ms epoch of the last time the device PROVED it was reachable — a
21396
+ * completed firmware round-trip, an observed wake, or an inbound push
21397
+ * (firmware event, email). `0`/absent = never since this slice was born.
21398
+ *
21399
+ * This is the ONLY input that separates "asleep" from "gone", and it is
21400
+ * fed exclusively by PASSIVE signals: nothing may write it by reaching
21401
+ * for the radio, because a poll that confirms reachability is the same
21402
+ * poll that drains the battery. See {@link deriveBatteryPresence} — the
21403
+ * single derivation every consumer must use; no surface computes its own.
21404
+ *
21405
+ * It is deliberately NOT a clock in the
21406
+ * `scripts/check-runtime-state-durability.ts` sense: it is the
21407
+ * observation itself, and it is the only thing a 30-hour silence is
21408
+ * visible in. Writers quantise it (see `CONTACT_WRITE_QUANTUM_MS` in the
21409
+ * Reolink provider) so a value that means "recently" cannot cost a
21410
+ * SQLite commit per round-trip.
21411
+ */
21412
+ lastContactAt: number().optional(),
21413
+ /**
21395
21414
  * True when the source is a BINARY low-battery indicator (HA
21396
21415
  * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
21397
21416
  * charge level — `percentage` is then a coarse stand-in (100 = normal,
@@ -27427,13 +27446,63 @@ var CamStreamDescriptorSchema = object({
27427
27446
  * set of stream descriptors it can offer for the device, synchronously, so the
27428
27447
  * broker can reconcile its registry against the authoritative provider state.
27429
27448
  */
27449
+ /**
27450
+ * The catalog as a DURABLE fact rather than a live answer.
27451
+ *
27452
+ * A battery camera's descriptors are profile-stable — they change when the
27453
+ * operator rewrites an encoder profile, not minute to minute — but building
27454
+ * them costs a Baichuan login, which on a sleeping Argus IS a wake. So the
27455
+ * provider is allowed to build them exactly once per profile and must serve
27456
+ * every later pull from a cache.
27457
+ *
27458
+ * Holding that cache only in RAM is what turned a restart into an outage. The
27459
+ * runner comes back with the camera asleep, `buildStreamCatalogUncached`
27460
+ * correctly refuses to wake it, the pull answers `[]`, the broker has no
27461
+ * cam-stream entry to build a broker from, and `webrtcSession.handleOffer`
27462
+ * fails with a flat "No broker for stream" — for as long as the camera sleeps,
27463
+ * which on a battery cam is most of the day. The camera was fine. The stream
27464
+ * was unreachable because the process had forgotten what the camera offers.
27465
+ *
27466
+ * Declaring it here puts it in `device-runtime-state`, the kernel's canonical
27467
+ * declared collection, with the same `restored` durability `battery` uses for
27468
+ * the same reason: the last known value is the only value there is while the
27469
+ * device is asleep. The broker's brokers are therefore always DEFINABLE — it
27470
+ * is the DIAL that wakes a camera, never the catalog (D173).
27471
+ */
27472
+ var StreamCatalogStateSchema = object({
27473
+ /** The descriptors as last built from a real camera response. Never a guess:
27474
+ * a failed or refused build writes NOTHING, so a restored catalog is always
27475
+ * one the camera itself once produced. */
27476
+ descriptors: array(CamStreamDescriptorSchema),
27477
+ /** Ms epoch of the build that produced {@link descriptors}. Lets the wake
27478
+ * path decide whether the camera's own awake window is worth spending on a
27479
+ * re-read. */
27480
+ lastFetchedAt: number()
27481
+ });
27430
27482
  var streamCatalogCapability = {
27431
27483
  name: "stream-catalog",
27432
27484
  scope: "device",
27433
27485
  deviceNative: true,
27434
27486
  mode: "singleton",
27435
27487
  deviceTypes: [DeviceType.Camera],
27436
- methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) }
27488
+ methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) },
27489
+ runtimeState: StreamCatalogStateSchema,
27490
+ /**
27491
+ * Runtime-state durability: **restored** — see the schema doc. A cold
27492
+ * catalog on a sleeping battery camera is not a slow first frame, it is a
27493
+ * camera that cannot be watched at all until it happens to wake.
27494
+ *
27495
+ * Churn is nil by construction: the slice is written only by a SUCCESSFUL
27496
+ * build, and a build only runs when there is no cached copy (or the copy is
27497
+ * a day old and the camera is awake anyway).
27498
+ *
27499
+ * See `RuntimeStateDurability`. Enforced by
27500
+ * `scripts/check-runtime-state-durability.ts`.
27501
+ */
27502
+ durability: "restored",
27503
+ /** Clock field: written, but excluded from the compare that decides whether
27504
+ * persisting is worth a SQLite commit — the descriptors are the value. */
27505
+ volatileStateFields: ["lastFetchedAt"]
27437
27506
  };
27438
27507
  /** One of the camera's stream profiles. */
27439
27508
  var StreamProfileSchema = _enum([
@@ -29297,6 +29366,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
29297
29366
  sceneMonitor: sceneMonitorCapability,
29298
29367
  scriptRunner: scriptRunnerCapability,
29299
29368
  smoke: smokeCapability,
29369
+ streamCatalog: streamCatalogCapability,
29300
29370
  streamParams: streamParamsCapability,
29301
29371
  switch: switchCapability,
29302
29372
  tamper: tamperCapability,
@@ -230171,6 +230241,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230171
230241
  * retries.
230172
230242
  */
230173
230243
  async onProbe() {
230244
+ if (this.isBattery && this.sleeping) {
230245
+ this.ctx.logger.info("onProbe skipped — battery cam is sleeping (no login, no wake)", {
230246
+ tags: { deviceId: this.id },
230247
+ meta: { probeRetriesAvoided: true }
230248
+ });
230249
+ return;
230250
+ }
230174
230251
  let api;
230175
230252
  try {
230176
230253
  api = await this.ensureApi();
@@ -230871,9 +230948,21 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230871
230948
  status: slice
230872
230949
  }));
230873
230950
  });
230874
- this.refreshBatteryFromApi();
230951
+ this.refreshBatteryFromApi("register");
230875
230952
  }
230876
- async refreshBatteryFromApi() {
230953
+ /**
230954
+ * @param reason - `'register'` and `'periodic'` are OUR initiative and are
230955
+ * refused while the camera sleeps; `'wake'` and `'demand'` run because
230956
+ * something already has the camera awake or is entitled to wake it.
230957
+ */
230958
+ async refreshBatteryFromApi(reason) {
230959
+ if (this.isBattery && this.sleeping && (reason === "register" || reason === "periodic")) {
230960
+ this.ctx.logger.debug("battery refresh skipped — cam sleeping, reading restored slice", {
230961
+ tags: { deviceId: this.id },
230962
+ meta: { reason }
230963
+ });
230964
+ return;
230965
+ }
230877
230966
  let api;
230878
230967
  try {
230879
230968
  api = await this.ensureApi();
@@ -231031,7 +231120,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
231031
231120
  return true;
231032
231121
  }
231033
231122
  updateBatteryCache(info) {
231034
- this.setCapSlice(batteryCapability, this.mapBatteryInfo(info));
231123
+ const mapped = this.mapBatteryInfo(info);
231124
+ const now = Date.now();
231125
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
231126
+ const previousContact = this.state.battery.lastContactAt ?? 0;
231127
+ this.setCapSlice(batteryCapability, {
231128
+ ...mapped,
231129
+ lastContactAt: Math.max(previousContact, quantised)
231130
+ });
231035
231131
  }
231036
231132
  /**
231037
231133
  * Battery cams require an explicit wake before cmd_id 109 will
@@ -233356,6 +233452,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233356
233452
  */
233357
233453
  async buildStreamCatalog() {
233358
233454
  if (this.cachedStreamDescriptors?.length) return this.withLiveNativeSdp(this.cachedStreamDescriptors);
233455
+ const restored = this.restoreStreamCatalogFromLedger();
233456
+ if (restored) return this.withLiveNativeSdp(restored);
233359
233457
  if (this.buildStreamCatalogInFlight) return this.withLiveNativeSdp(await this.buildStreamCatalogInFlight);
233360
233458
  const build = this.buildStreamCatalogUncached();
233361
233459
  this.buildStreamCatalogInFlight = build;
@@ -233528,6 +233626,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233528
233626
  autoEligible: e.autoEligible
233529
233627
  }));
233530
233628
  this.cachedStreamDescriptors = descriptors;
233629
+ this.persistStreamCatalogToLedger(descriptors);
233531
233630
  return descriptors;
233532
233631
  }
233533
233632
  /** Profile-stable stream descriptors, cached after the first successful
@@ -233535,6 +233634,66 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233535
233634
  * sleeping battery cam is never woken by a catalog poll. Invalidated by
233536
233635
  * `applyStreamProfilePatch` (codec/resolution may change). */
233537
233636
  cachedStreamDescriptors;
233637
+ /**
233638
+ * Write the just-built catalog to the durable `stream-catalog` slice, so a
233639
+ * restart with the camera asleep still has descriptors to serve (D173).
233640
+ *
233641
+ * Best-effort by design: the RAM copy is already advanced by the caller, and
233642
+ * losing this write costs one cold catalog after the next restart — never a
233643
+ * wrong catalog. A build that FAILED writes nothing at all and therefore
233644
+ * cannot demote a good stored copy (D49's failure direction).
233645
+ */
233646
+ persistStreamCatalogToLedger(descriptors) {
233647
+ if (descriptors.length === 0) return;
233648
+ try {
233649
+ const state = {
233650
+ descriptors: [...descriptors],
233651
+ lastFetchedAt: Date.now()
233652
+ };
233653
+ this.runtimeState.setCapState(streamCatalogCapability.name, state);
233654
+ this.ctx.logger.debug("stream catalog persisted to the durable slice", {
233655
+ tags: { deviceId: this.id },
233656
+ meta: { count: descriptors.length }
233657
+ });
233658
+ } catch (err) {
233659
+ this.ctx.logger.debug("stream catalog persist failed — RAM copy stands", {
233660
+ tags: { deviceId: this.id },
233661
+ meta: { error: err instanceof Error ? err.message : String(err) }
233662
+ });
233663
+ }
233664
+ }
233665
+ /**
233666
+ * Rehydrate `cachedStreamDescriptors` from the durable slice. Returns the
233667
+ * restored descriptors, or `null` when there is nothing to restore.
233668
+ *
233669
+ * Logged at `info` when it fires: "these descriptors came from before the
233670
+ * restart" must never be something a reader has to infer (the DurableLedger
233671
+ * contract, D132).
233672
+ */
233673
+ restoreStreamCatalogFromLedger() {
233674
+ const stored = this.runtimeState.getCapState(streamCatalogCapability.name);
233675
+ const descriptors = stored?.descriptors;
233676
+ if (!descriptors || descriptors.length === 0) return null;
233677
+ this.cachedStreamDescriptors = [...descriptors];
233678
+ this.ctx.logger.info("stream catalog restored from the durable slice (no camera contact)", {
233679
+ tags: { deviceId: this.id },
233680
+ meta: {
233681
+ count: descriptors.length,
233682
+ builtAt: stored?.lastFetchedAt ?? 0,
233683
+ ageMs: Date.now() - (stored?.lastFetchedAt ?? 0),
233684
+ sleeping: this.sleeping
233685
+ }
233686
+ });
233687
+ return this.cachedStreamDescriptors;
233688
+ }
233689
+ /**
233690
+ * How old a RESTORED catalog may get before a natural wake is worth spending
233691
+ * on a re-read. The catalog is profile-stable, so this is not about
233692
+ * freshness — it is the backstop for a profile changed by something that did
233693
+ * not invalidate the cache (a firmware update, an edit made on the Reolink
233694
+ * app). A day is several natural wakes on any camera that is working.
233695
+ */
233696
+ static CATALOG_REFRESH_ON_WAKE_MS = 1440 * 6e4;
233538
233697
  /** Single-flight guard for `buildStreamCatalog`. */
233539
233698
  buildStreamCatalogInFlight = null;
233540
233699
  /**
@@ -233997,7 +234156,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233997
234156
  auxAccessoryCount: this.auxAccessoryRefs.size
233998
234157
  }
233999
234158
  });
234000
- await this.refreshBatteryFromApi().catch(() => {});
234159
+ await this.refreshBatteryFromApi("periodic").catch(() => {});
234001
234160
  await this.alignAuxDevicesState("periodic").catch(() => {});
234002
234161
  await this.refreshParentSettingsSnapshot().catch(() => {});
234003
234162
  } finally {
@@ -234032,6 +234191,44 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234032
234191
  }
234033
234192
  }
234034
234193
  /**
234194
+ * Is this wake worth spending on a catalog re-read? See
234195
+ * `CATALOG_REFRESH_ON_WAKE_MS`. Held apart from `onWakeTransition` so the
234196
+ * decision is one expression a test can pin.
234197
+ */
234198
+ shouldRebuildCatalogOnWake() {
234199
+ if (!this.cachedStreamDescriptors?.length) {
234200
+ if (!this.restoreStreamCatalogFromLedger()) return true;
234201
+ }
234202
+ const builtAt = this.runtimeState.getCapState(streamCatalogCapability.name)?.lastFetchedAt ?? 0;
234203
+ if (builtAt <= 0) return true;
234204
+ return Date.now() - builtAt > ReolinkCamera.CATALOG_REFRESH_ON_WAKE_MS;
234205
+ }
234206
+ /**
234207
+ * A PASSIVE proof of reachability just arrived — stamp `battery.lastContactAt`
234208
+ * so `deriveBatteryPresence` can tell "asleep" from "gone" (D173).
234209
+ *
234210
+ * Callable only from paths where the evidence cost us nothing: an inbound
234211
+ * firmware push, an observed wake, a round-trip somebody else's demand
234212
+ * already paid for. Never from a poll issued to answer this question — that
234213
+ * poll is the wake it is trying to detect.
234214
+ *
234215
+ * Quantised to `CONTACT_WRITE_QUANTUM_MS`: the value means "recently", and
234216
+ * writing it at millisecond resolution would put a SQLite commit behind
234217
+ * every Baichuan reply on the hub's busiest write path (the exact cost
234218
+ * `scripts/check-runtime-state-durability.ts` exists to bound).
234219
+ */
234220
+ markPassiveContact() {
234221
+ if (!this.isBattery) return;
234222
+ const now = Date.now();
234223
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
234224
+ if (quantised <= (this.state.battery.lastContactAt ?? 0)) return;
234225
+ this.state.battery.lastContactAt = quantised;
234226
+ }
234227
+ /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
234228
+ * Bounds the commit rate this field can cost at 12/hour/device, and only
234229
+ * for a device something is actually reaching. */
234230
+ static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
234231
+ /**
234035
234232
  * Shared wake-transition handler invoked by both the simpleEvent
234036
234233
  * `awake` push (canonical fast path) and the sleep poll's
234037
234234
  * `sleeping → awake` flip (backstop). Mirrors Scrypted's
@@ -234056,7 +234253,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234056
234253
  isBattery: this.isBattery
234057
234254
  }
234058
234255
  });
234059
- if (!this.cachedStreamDescriptors?.length) try {
234256
+ if (this.shouldRebuildCatalogOnWake()) try {
234257
+ this.cachedStreamDescriptors = void 0;
234060
234258
  if ((await this.buildStreamCatalog()).length > 0) this.ctx.eventBus.emit(createEvent(EventCategory.StreamParamsChanged, this.eventSource(), { deviceId: this.id }));
234061
234259
  } catch (err) {
234062
234260
  this.ctx.logger.debug("onWakeTransition: stream catalog build failed — will retry on next wake", {
@@ -235769,7 +235967,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235769
235967
  this.startSleepPoll();
235770
235968
  this.startBatteryUpdatePolling();
235771
235969
  this.registerBatteryIfSupported();
235772
- this.refreshBatteryFromApi();
235970
+ this.refreshBatteryFromApi("demand");
235773
235971
  } else this.startAlignAuxPolling();
235774
235972
  this.resubscribeSimpleEvents(api, "adoptApi").catch((err) => {
235775
235973
  this.ctx.logger.debug("Reolink adoptApi: simple-event subscribe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -235881,7 +236079,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235881
236079
  this.startSleepPoll();
235882
236080
  this.startBatteryUpdatePolling();
235883
236081
  this.registerBatteryIfSupported();
235884
- this.refreshBatteryFromApi();
236082
+ this.refreshBatteryFromApi("demand");
235885
236083
  }
235886
236084
  this.startWatchdogs();
235887
236085
  this.probeAndPersistFeatures(api).catch((err) => {
@@ -235965,6 +236163,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235965
236163
  this.lastEventAt = Date.now();
235966
236164
  this.consecutiveStaleHealthChecks = 0;
235967
236165
  this.nextEventHealthCheckAt = 0;
236166
+ this.markPassiveContact();
235968
236167
  const eventSource = this.eventSource();
235969
236168
  if (event.type !== "battery") this.ctx.logger.info("Reolink simpleEvent received", { meta: {
235970
236169
  type: event.type,
@@ -236397,6 +236596,9 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236397
236596
  const data = event.data;
236398
236597
  if (data.parentDeviceId !== this.id) return;
236399
236598
  const cid = typeof data.deviceId === "number" ? data.deviceId : null;
236599
+ if (cid !== null) {
236600
+ for (const [ch, did] of this.channelToDeviceId.entries()) if (did === cid) this.channelToDeviceId.delete(ch);
236601
+ }
236400
236602
  this.ctx.logger.info("Reolink Hub: child unregistered externally — refreshing discovery", cid !== null ? { tags: { deviceId: cid } } : {});
236401
236603
  this.refreshDiscoveryFromCamera().catch(() => {});
236402
236604
  }));
@@ -236693,7 +236895,6 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236693
236895
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236694
236896
  } });
236695
236897
  const adoptedByChannel = await this.loadAdoptedChildrenByChannel();
236696
- this.channelToDeviceId.clear();
236697
236898
  for (const [channel, deviceId] of adoptedByChannel) this.channelToDeviceId.set(channel, deviceId);
236698
236899
  try {
236699
236900
  discovered = (await (await this.ensureApi()).getNvrChannelsSummary({
@@ -236701,7 +236902,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236701
236902
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236702
236903
  })).devices.map((d) => {
236703
236904
  const childNativeId = computeChildNativeId(this.stableId, d.channel, d.uid);
236704
- const adoptedDeviceId = adoptedByChannel.get(d.channel) ?? null;
236905
+ const adoptedDeviceId = this.channelToDeviceId.get(d.channel) ?? null;
236705
236906
  return {
236706
236907
  childNativeId,
236707
236908
  name: d.name ?? `Channel ${d.channel}`,
package/dist/addon.mjs CHANGED
@@ -21387,6 +21387,25 @@ var BatteryStatusSchema = object({
21387
21387
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
21388
21388
  lastUpdated: number(),
21389
21389
  /**
21390
+ * Ms epoch of the last time the device PROVED it was reachable — a
21391
+ * completed firmware round-trip, an observed wake, or an inbound push
21392
+ * (firmware event, email). `0`/absent = never since this slice was born.
21393
+ *
21394
+ * This is the ONLY input that separates "asleep" from "gone", and it is
21395
+ * fed exclusively by PASSIVE signals: nothing may write it by reaching
21396
+ * for the radio, because a poll that confirms reachability is the same
21397
+ * poll that drains the battery. See {@link deriveBatteryPresence} — the
21398
+ * single derivation every consumer must use; no surface computes its own.
21399
+ *
21400
+ * It is deliberately NOT a clock in the
21401
+ * `scripts/check-runtime-state-durability.ts` sense: it is the
21402
+ * observation itself, and it is the only thing a 30-hour silence is
21403
+ * visible in. Writers quantise it (see `CONTACT_WRITE_QUANTUM_MS` in the
21404
+ * Reolink provider) so a value that means "recently" cannot cost a
21405
+ * SQLite commit per round-trip.
21406
+ */
21407
+ lastContactAt: number().optional(),
21408
+ /**
21390
21409
  * True when the source is a BINARY low-battery indicator (HA
21391
21410
  * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
21392
21411
  * charge level — `percentage` is then a coarse stand-in (100 = normal,
@@ -27422,13 +27441,63 @@ var CamStreamDescriptorSchema = object({
27422
27441
  * set of stream descriptors it can offer for the device, synchronously, so the
27423
27442
  * broker can reconcile its registry against the authoritative provider state.
27424
27443
  */
27444
+ /**
27445
+ * The catalog as a DURABLE fact rather than a live answer.
27446
+ *
27447
+ * A battery camera's descriptors are profile-stable — they change when the
27448
+ * operator rewrites an encoder profile, not minute to minute — but building
27449
+ * them costs a Baichuan login, which on a sleeping Argus IS a wake. So the
27450
+ * provider is allowed to build them exactly once per profile and must serve
27451
+ * every later pull from a cache.
27452
+ *
27453
+ * Holding that cache only in RAM is what turned a restart into an outage. The
27454
+ * runner comes back with the camera asleep, `buildStreamCatalogUncached`
27455
+ * correctly refuses to wake it, the pull answers `[]`, the broker has no
27456
+ * cam-stream entry to build a broker from, and `webrtcSession.handleOffer`
27457
+ * fails with a flat "No broker for stream" — for as long as the camera sleeps,
27458
+ * which on a battery cam is most of the day. The camera was fine. The stream
27459
+ * was unreachable because the process had forgotten what the camera offers.
27460
+ *
27461
+ * Declaring it here puts it in `device-runtime-state`, the kernel's canonical
27462
+ * declared collection, with the same `restored` durability `battery` uses for
27463
+ * the same reason: the last known value is the only value there is while the
27464
+ * device is asleep. The broker's brokers are therefore always DEFINABLE — it
27465
+ * is the DIAL that wakes a camera, never the catalog (D173).
27466
+ */
27467
+ var StreamCatalogStateSchema = object({
27468
+ /** The descriptors as last built from a real camera response. Never a guess:
27469
+ * a failed or refused build writes NOTHING, so a restored catalog is always
27470
+ * one the camera itself once produced. */
27471
+ descriptors: array(CamStreamDescriptorSchema),
27472
+ /** Ms epoch of the build that produced {@link descriptors}. Lets the wake
27473
+ * path decide whether the camera's own awake window is worth spending on a
27474
+ * re-read. */
27475
+ lastFetchedAt: number()
27476
+ });
27425
27477
  var streamCatalogCapability = {
27426
27478
  name: "stream-catalog",
27427
27479
  scope: "device",
27428
27480
  deviceNative: true,
27429
27481
  mode: "singleton",
27430
27482
  deviceTypes: [DeviceType.Camera],
27431
- methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) }
27483
+ methods: { getCatalog: method(object({ deviceId: number().int().nonnegative() }), array(CamStreamDescriptorSchema).readonly()) },
27484
+ runtimeState: StreamCatalogStateSchema,
27485
+ /**
27486
+ * Runtime-state durability: **restored** — see the schema doc. A cold
27487
+ * catalog on a sleeping battery camera is not a slow first frame, it is a
27488
+ * camera that cannot be watched at all until it happens to wake.
27489
+ *
27490
+ * Churn is nil by construction: the slice is written only by a SUCCESSFUL
27491
+ * build, and a build only runs when there is no cached copy (or the copy is
27492
+ * a day old and the camera is awake anyway).
27493
+ *
27494
+ * See `RuntimeStateDurability`. Enforced by
27495
+ * `scripts/check-runtime-state-durability.ts`.
27496
+ */
27497
+ durability: "restored",
27498
+ /** Clock field: written, but excluded from the compare that decides whether
27499
+ * persisting is worth a SQLite commit — the descriptors are the value. */
27500
+ volatileStateFields: ["lastFetchedAt"]
27432
27501
  };
27433
27502
  /** One of the camera's stream profiles. */
27434
27503
  var StreamProfileSchema = _enum([
@@ -29292,6 +29361,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
29292
29361
  sceneMonitor: sceneMonitorCapability,
29293
29362
  scriptRunner: scriptRunnerCapability,
29294
29363
  smoke: smokeCapability,
29364
+ streamCatalog: streamCatalogCapability,
29295
29365
  streamParams: streamParamsCapability,
29296
29366
  switch: switchCapability,
29297
29367
  tamper: tamperCapability,
@@ -230151,6 +230221,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230151
230221
  * retries.
230152
230222
  */
230153
230223
  async onProbe() {
230224
+ if (this.isBattery && this.sleeping) {
230225
+ this.ctx.logger.info("onProbe skipped — battery cam is sleeping (no login, no wake)", {
230226
+ tags: { deviceId: this.id },
230227
+ meta: { probeRetriesAvoided: true }
230228
+ });
230229
+ return;
230230
+ }
230154
230231
  let api;
230155
230232
  try {
230156
230233
  api = await this.ensureApi();
@@ -230851,9 +230928,21 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
230851
230928
  status: slice
230852
230929
  }));
230853
230930
  });
230854
- this.refreshBatteryFromApi();
230931
+ this.refreshBatteryFromApi("register");
230855
230932
  }
230856
- async refreshBatteryFromApi() {
230933
+ /**
230934
+ * @param reason - `'register'` and `'periodic'` are OUR initiative and are
230935
+ * refused while the camera sleeps; `'wake'` and `'demand'` run because
230936
+ * something already has the camera awake or is entitled to wake it.
230937
+ */
230938
+ async refreshBatteryFromApi(reason) {
230939
+ if (this.isBattery && this.sleeping && (reason === "register" || reason === "periodic")) {
230940
+ this.ctx.logger.debug("battery refresh skipped — cam sleeping, reading restored slice", {
230941
+ tags: { deviceId: this.id },
230942
+ meta: { reason }
230943
+ });
230944
+ return;
230945
+ }
230857
230946
  let api;
230858
230947
  try {
230859
230948
  api = await this.ensureApi();
@@ -231011,7 +231100,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
231011
231100
  return true;
231012
231101
  }
231013
231102
  updateBatteryCache(info) {
231014
- this.setCapSlice(batteryCapability, this.mapBatteryInfo(info));
231103
+ const mapped = this.mapBatteryInfo(info);
231104
+ const now = Date.now();
231105
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
231106
+ const previousContact = this.state.battery.lastContactAt ?? 0;
231107
+ this.setCapSlice(batteryCapability, {
231108
+ ...mapped,
231109
+ lastContactAt: Math.max(previousContact, quantised)
231110
+ });
231015
231111
  }
231016
231112
  /**
231017
231113
  * Battery cams require an explicit wake before cmd_id 109 will
@@ -233336,6 +233432,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233336
233432
  */
233337
233433
  async buildStreamCatalog() {
233338
233434
  if (this.cachedStreamDescriptors?.length) return this.withLiveNativeSdp(this.cachedStreamDescriptors);
233435
+ const restored = this.restoreStreamCatalogFromLedger();
233436
+ if (restored) return this.withLiveNativeSdp(restored);
233339
233437
  if (this.buildStreamCatalogInFlight) return this.withLiveNativeSdp(await this.buildStreamCatalogInFlight);
233340
233438
  const build = this.buildStreamCatalogUncached();
233341
233439
  this.buildStreamCatalogInFlight = build;
@@ -233508,6 +233606,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233508
233606
  autoEligible: e.autoEligible
233509
233607
  }));
233510
233608
  this.cachedStreamDescriptors = descriptors;
233609
+ this.persistStreamCatalogToLedger(descriptors);
233511
233610
  return descriptors;
233512
233611
  }
233513
233612
  /** Profile-stable stream descriptors, cached after the first successful
@@ -233515,6 +233614,66 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233515
233614
  * sleeping battery cam is never woken by a catalog poll. Invalidated by
233516
233615
  * `applyStreamProfilePatch` (codec/resolution may change). */
233517
233616
  cachedStreamDescriptors;
233617
+ /**
233618
+ * Write the just-built catalog to the durable `stream-catalog` slice, so a
233619
+ * restart with the camera asleep still has descriptors to serve (D173).
233620
+ *
233621
+ * Best-effort by design: the RAM copy is already advanced by the caller, and
233622
+ * losing this write costs one cold catalog after the next restart — never a
233623
+ * wrong catalog. A build that FAILED writes nothing at all and therefore
233624
+ * cannot demote a good stored copy (D49's failure direction).
233625
+ */
233626
+ persistStreamCatalogToLedger(descriptors) {
233627
+ if (descriptors.length === 0) return;
233628
+ try {
233629
+ const state = {
233630
+ descriptors: [...descriptors],
233631
+ lastFetchedAt: Date.now()
233632
+ };
233633
+ this.runtimeState.setCapState(streamCatalogCapability.name, state);
233634
+ this.ctx.logger.debug("stream catalog persisted to the durable slice", {
233635
+ tags: { deviceId: this.id },
233636
+ meta: { count: descriptors.length }
233637
+ });
233638
+ } catch (err) {
233639
+ this.ctx.logger.debug("stream catalog persist failed — RAM copy stands", {
233640
+ tags: { deviceId: this.id },
233641
+ meta: { error: err instanceof Error ? err.message : String(err) }
233642
+ });
233643
+ }
233644
+ }
233645
+ /**
233646
+ * Rehydrate `cachedStreamDescriptors` from the durable slice. Returns the
233647
+ * restored descriptors, or `null` when there is nothing to restore.
233648
+ *
233649
+ * Logged at `info` when it fires: "these descriptors came from before the
233650
+ * restart" must never be something a reader has to infer (the DurableLedger
233651
+ * contract, D132).
233652
+ */
233653
+ restoreStreamCatalogFromLedger() {
233654
+ const stored = this.runtimeState.getCapState(streamCatalogCapability.name);
233655
+ const descriptors = stored?.descriptors;
233656
+ if (!descriptors || descriptors.length === 0) return null;
233657
+ this.cachedStreamDescriptors = [...descriptors];
233658
+ this.ctx.logger.info("stream catalog restored from the durable slice (no camera contact)", {
233659
+ tags: { deviceId: this.id },
233660
+ meta: {
233661
+ count: descriptors.length,
233662
+ builtAt: stored?.lastFetchedAt ?? 0,
233663
+ ageMs: Date.now() - (stored?.lastFetchedAt ?? 0),
233664
+ sleeping: this.sleeping
233665
+ }
233666
+ });
233667
+ return this.cachedStreamDescriptors;
233668
+ }
233669
+ /**
233670
+ * How old a RESTORED catalog may get before a natural wake is worth spending
233671
+ * on a re-read. The catalog is profile-stable, so this is not about
233672
+ * freshness — it is the backstop for a profile changed by something that did
233673
+ * not invalidate the cache (a firmware update, an edit made on the Reolink
233674
+ * app). A day is several natural wakes on any camera that is working.
233675
+ */
233676
+ static CATALOG_REFRESH_ON_WAKE_MS = 1440 * 6e4;
233518
233677
  /** Single-flight guard for `buildStreamCatalog`. */
233519
233678
  buildStreamCatalogInFlight = null;
233520
233679
  /**
@@ -233977,7 +234136,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
233977
234136
  auxAccessoryCount: this.auxAccessoryRefs.size
233978
234137
  }
233979
234138
  });
233980
- await this.refreshBatteryFromApi().catch(() => {});
234139
+ await this.refreshBatteryFromApi("periodic").catch(() => {});
233981
234140
  await this.alignAuxDevicesState("periodic").catch(() => {});
233982
234141
  await this.refreshParentSettingsSnapshot().catch(() => {});
233983
234142
  } finally {
@@ -234012,6 +234171,44 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234012
234171
  }
234013
234172
  }
234014
234173
  /**
234174
+ * Is this wake worth spending on a catalog re-read? See
234175
+ * `CATALOG_REFRESH_ON_WAKE_MS`. Held apart from `onWakeTransition` so the
234176
+ * decision is one expression a test can pin.
234177
+ */
234178
+ shouldRebuildCatalogOnWake() {
234179
+ if (!this.cachedStreamDescriptors?.length) {
234180
+ if (!this.restoreStreamCatalogFromLedger()) return true;
234181
+ }
234182
+ const builtAt = this.runtimeState.getCapState(streamCatalogCapability.name)?.lastFetchedAt ?? 0;
234183
+ if (builtAt <= 0) return true;
234184
+ return Date.now() - builtAt > ReolinkCamera.CATALOG_REFRESH_ON_WAKE_MS;
234185
+ }
234186
+ /**
234187
+ * A PASSIVE proof of reachability just arrived — stamp `battery.lastContactAt`
234188
+ * so `deriveBatteryPresence` can tell "asleep" from "gone" (D173).
234189
+ *
234190
+ * Callable only from paths where the evidence cost us nothing: an inbound
234191
+ * firmware push, an observed wake, a round-trip somebody else's demand
234192
+ * already paid for. Never from a poll issued to answer this question — that
234193
+ * poll is the wake it is trying to detect.
234194
+ *
234195
+ * Quantised to `CONTACT_WRITE_QUANTUM_MS`: the value means "recently", and
234196
+ * writing it at millisecond resolution would put a SQLite commit behind
234197
+ * every Baichuan reply on the hub's busiest write path (the exact cost
234198
+ * `scripts/check-runtime-state-durability.ts` exists to bound).
234199
+ */
234200
+ markPassiveContact() {
234201
+ if (!this.isBattery) return;
234202
+ const now = Date.now();
234203
+ const quantised = now - now % ReolinkCamera.CONTACT_WRITE_QUANTUM_MS;
234204
+ if (quantised <= (this.state.battery.lastContactAt ?? 0)) return;
234205
+ this.state.battery.lastContactAt = quantised;
234206
+ }
234207
+ /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
234208
+ * Bounds the commit rate this field can cost at 12/hour/device, and only
234209
+ * for a device something is actually reaching. */
234210
+ static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
234211
+ /**
234015
234212
  * Shared wake-transition handler invoked by both the simpleEvent
234016
234213
  * `awake` push (canonical fast path) and the sleep poll's
234017
234214
  * `sleeping → awake` flip (backstop). Mirrors Scrypted's
@@ -234036,7 +234233,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234036
234233
  isBattery: this.isBattery
234037
234234
  }
234038
234235
  });
234039
- if (!this.cachedStreamDescriptors?.length) try {
234236
+ if (this.shouldRebuildCatalogOnWake()) try {
234237
+ this.cachedStreamDescriptors = void 0;
234040
234238
  if ((await this.buildStreamCatalog()).length > 0) this.ctx.eventBus.emit(createEvent(EventCategory.StreamParamsChanged, this.eventSource(), { deviceId: this.id }));
234041
234239
  } catch (err) {
234042
234240
  this.ctx.logger.debug("onWakeTransition: stream catalog build failed — will retry on next wake", {
@@ -235749,7 +235947,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235749
235947
  this.startSleepPoll();
235750
235948
  this.startBatteryUpdatePolling();
235751
235949
  this.registerBatteryIfSupported();
235752
- this.refreshBatteryFromApi();
235950
+ this.refreshBatteryFromApi("demand");
235753
235951
  } else this.startAlignAuxPolling();
235754
235952
  this.resubscribeSimpleEvents(api, "adoptApi").catch((err) => {
235755
235953
  this.ctx.logger.debug("Reolink adoptApi: simple-event subscribe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -235861,7 +236059,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235861
236059
  this.startSleepPoll();
235862
236060
  this.startBatteryUpdatePolling();
235863
236061
  this.registerBatteryIfSupported();
235864
- this.refreshBatteryFromApi();
236062
+ this.refreshBatteryFromApi("demand");
235865
236063
  }
235866
236064
  this.startWatchdogs();
235867
236065
  this.probeAndPersistFeatures(api).catch((err) => {
@@ -235945,6 +236143,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235945
236143
  this.lastEventAt = Date.now();
235946
236144
  this.consecutiveStaleHealthChecks = 0;
235947
236145
  this.nextEventHealthCheckAt = 0;
236146
+ this.markPassiveContact();
235948
236147
  const eventSource = this.eventSource();
235949
236148
  if (event.type !== "battery") this.ctx.logger.info("Reolink simpleEvent received", { meta: {
235950
236149
  type: event.type,
@@ -236377,6 +236576,9 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236377
236576
  const data = event.data;
236378
236577
  if (data.parentDeviceId !== this.id) return;
236379
236578
  const cid = typeof data.deviceId === "number" ? data.deviceId : null;
236579
+ if (cid !== null) {
236580
+ for (const [ch, did] of this.channelToDeviceId.entries()) if (did === cid) this.channelToDeviceId.delete(ch);
236581
+ }
236380
236582
  this.ctx.logger.info("Reolink Hub: child unregistered externally — refreshing discovery", cid !== null ? { tags: { deviceId: cid } } : {});
236381
236583
  this.refreshDiscoveryFromCamera().catch(() => {});
236382
236584
  }));
@@ -236673,7 +236875,6 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236673
236875
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236674
236876
  } });
236675
236877
  const adoptedByChannel = await this.loadAdoptedChildrenByChannel();
236676
- this.channelToDeviceId.clear();
236677
236878
  for (const [channel, deviceId] of adoptedByChannel) this.channelToDeviceId.set(channel, deviceId);
236678
236879
  try {
236679
236880
  discovered = (await (await this.ensureApi()).getNvrChannelsSummary({
@@ -236681,7 +236882,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
236681
236882
  timeoutMs: HUB_DISCOVERY_REFRESH_TIMEOUT_MS
236682
236883
  })).devices.map((d) => {
236683
236884
  const childNativeId = computeChildNativeId(this.stableId, d.channel, d.uid);
236684
- const adoptedDeviceId = adoptedByChannel.get(d.channel) ?? null;
236885
+ const adoptedDeviceId = this.channelToDeviceId.get(d.channel) ?? null;
236685
236886
  return {
236686
236887
  childNativeId,
236687
236888
  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.29",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",