@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.
- package/dist/addon.js +254 -11
- package/dist/addon.mjs +254 -11
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
236927
|
+
const adoptedDeviceId = this.channelToDeviceId.get(d.channel) ?? null;
|
|
236685
236928
|
return {
|
|
236686
236929
|
childNativeId,
|
|
236687
236930
|
name: d.name ?? `Channel ${d.channel}`,
|