@camstack/addon-provider-reolink 1.2.183 → 1.2.184

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 +447 -146
  2. package/dist/addon.mjs +446 -145
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -11,6 +11,7 @@ let crypto$1 = require("crypto");
11
11
  crypto$1 = require_chunk.__toESM(crypto$1, 1);
12
12
  let child_process = require("child_process");
13
13
  let node_http = require("node:http");
14
+ require("node:zlib");
14
15
  let node_fs_promises = require("node:fs/promises");
15
16
  let node_path = require("node:path");
16
17
  node_path = require_chunk.__toESM(node_path, 1);
@@ -32,10 +33,10 @@ http = require_chunk.__toESM(http, 1);
32
33
  let url = require("url");
33
34
  let node_child_process = require("node:child_process");
34
35
  let node_os = require("node:os");
35
- node_os = require_chunk.__toESM(node_os);
36
+ node_os = require_chunk.__toESM(node_os, 1);
36
37
  let node_fs = require("node:fs");
37
38
  node_fs = require_chunk.__toESM(node_fs, 1);
38
- //#region ../types/dist/event-category-DaIZi00R.mjs
39
+ //#region ../types/dist/event-category-C4hVnRo8.mjs
39
40
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
40
41
  EventCategory["SystemBoot"] = "system.boot";
41
42
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -254,7 +255,17 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
254
255
  EventCategory["RecordingStopped"] = "recording.stopped";
255
256
  EventCategory["RecordingError"] = "recording.error";
256
257
  EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
258
+ /** Recording enabled but no segment finalized for the NC stall window. */
259
+ EventCategory["RecordingHealthStalled"] = "recording.health.stalled";
260
+ /** Recovery from {@link RecordingHealthStalled}. */
261
+ EventCategory["RecordingHealthOk"] = "recording.health.ok";
257
262
  EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
263
+ /** Free space on a storage location crossed the warn bar. */
264
+ EventCategory["StorageLocationLow"] = "storage.location.low";
265
+ /** Free space at/under the critical bar, or capacity probe failed (`null`). */
266
+ EventCategory["StorageLocationCritical"] = "storage.location.critical";
267
+ /** Recovery from low/critical on a storage location. */
268
+ EventCategory["StorageLocationOk"] = "storage.location.ok";
258
269
  EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
259
270
  EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
260
271
  EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
@@ -5413,7 +5424,7 @@ var ZodIssueCode = {
5413
5424
  var ZodFirstPartyTypeKind;
5414
5425
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5415
5426
  //#endregion
5416
- //#region ../types/dist/sleep-DwAQ9sqX.mjs
5427
+ //#region ../types/dist/sleep-vWTnHq9G.mjs
5417
5428
  /**
5418
5429
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5419
5430
  * window to float samples (D455).
@@ -8018,6 +8029,21 @@ var ProfileRtspEntrySchema = object({
8018
8029
  codec: string().optional(),
8019
8030
  resolution: CamStreamResolutionSchema.optional()
8020
8031
  });
8032
+ /**
8033
+ * The model weight formats a catalog entry can ship. Schema-free on purpose:
8034
+ * `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
8035
+ * (the model downloader in `@camstack/system`) needs only the list and must
8036
+ * reach it through `@camstack/types/addon` without building the cap schema
8037
+ * graph (D28).
8038
+ */
8039
+ var MODEL_FORMATS = [
8040
+ "onnx",
8041
+ "coreml",
8042
+ "openvino",
8043
+ "tflite",
8044
+ "pt",
8045
+ "gguf"
8046
+ ];
8021
8047
  object({
8022
8048
  field: string().min(1),
8023
8049
  on: literal("advance")
@@ -8616,7 +8642,7 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
8616
8642
  return DeviceRole;
8617
8643
  }({});
8618
8644
  //#endregion
8619
- //#region ../types/dist/composition-Bzr7GwgP.mjs
8645
+ //#region ../types/dist/composition-C-WpVYTX.mjs
8620
8646
  /**
8621
8647
  * The ffmpeg build every CamStack node downloads, per platform and arch.
8622
8648
  *
@@ -8668,7 +8694,7 @@ var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFM
8668
8694
  function portable(target) {
8669
8695
  return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
8670
8696
  }
8671
- portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
8697
+ portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64"), `${BASE}${FFMPEG_RELEASE}`;
8672
8698
  /**
8673
8699
  * THE table "which boolean of this device type counts as HIGH", and the
8674
8700
  * pure evaluator of its EDGE.
@@ -12412,14 +12438,6 @@ var MASK_SUPPLY_STEP_IDS = ["segmentation"];
12412
12438
  ...CROP_SUPPLY_STEP_IDS,
12413
12439
  ...MASK_SUPPLY_STEP_IDS
12414
12440
  ];
12415
- var MODEL_FORMATS = [
12416
- "onnx",
12417
- "coreml",
12418
- "openvino",
12419
- "tflite",
12420
- "pt",
12421
- "gguf"
12422
- ];
12423
12441
  /**
12424
12442
  * Multi-file format payload.
12425
12443
  *
@@ -14243,7 +14261,14 @@ var BrokerProviderInfoSchema = object({
14243
14261
  /** Broker kinds this provider can create, with a display label. */
14244
14262
  kinds: array(object({
14245
14263
  kind: string(),
14246
- label: string()
14264
+ label: string(),
14265
+ /**
14266
+ * Relative hub path to start an interactive account link (OAuth popup).
14267
+ * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
14268
+ */
14269
+ interactiveConnectPath: string().min(1).optional(),
14270
+ /** Relative hub path to disconnect the linked account (same popup pattern). */
14271
+ interactiveDisconnectPath: string().min(1).optional()
14247
14272
  }))
14248
14273
  });
14249
14274
  var ListInputSchema = object({
@@ -21337,6 +21362,12 @@ var NcSystemEventKindSchema = _enum([
21337
21362
  "node-updating",
21338
21363
  "node-inference-unavailable",
21339
21364
  "detection-blind",
21365
+ "recording-stalled",
21366
+ "recording-healthy",
21367
+ "recording-degraded",
21368
+ "storage-location-low",
21369
+ "storage-location-critical",
21370
+ "storage-location-ok",
21340
21371
  "addon-crash-loop",
21341
21372
  "addon-update-available",
21342
21373
  "server-update-available",
@@ -33436,6 +33467,17 @@ var BatteryStatusSchema = object({
33436
33467
  */
33437
33468
  lastContactAt: number().optional(),
33438
33469
  /**
33470
+ * Ms epoch of the last COMPLETE scheduled refresh — every fact the provider
33471
+ * holds for the device (level, charging, link, device info, state slices) and
33472
+ * its cached snapshot, read together. Absent = never since this slice was
33473
+ * born. Unlike `lastUpdated` (any write, even a push of one field) this is
33474
+ * the staleness an operator can trust: the provider owns the device fact and
33475
+ * so owns the window on its age (D224, D847). Not a clock in the
33476
+ * runtime-state-durability sense: it moves once per window and must survive
33477
+ * a restart, or every restart would owe the camera a wake.
33478
+ */
33479
+ lastFullRefreshAt: number().optional(),
33480
+ /**
33439
33481
  * True when the source is a BINARY low-battery indicator (HA
33440
33482
  * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
33441
33483
  * charge level — `percentage` is then a coarse stand-in (100 = normal,
@@ -46470,17 +46512,6 @@ var BaseDeviceProvider = class extends BaseAddon {
46470
46512
  return toDeviceSummary(device, this.addonId);
46471
46513
  }
46472
46514
  };
46473
- /**
46474
- * Default silence budget before a sleeping battery camera is called gone.
46475
- *
46476
- * Sized off the mechanism that produces contact, not off taste: a battery cam
46477
- * on this deployment surfaces a firmware push (battery level, sleep/wake, or a
46478
- * motion email) on the order of hours even with no visitors, and the snapshot
46479
- * wrapper's own battery window is 1 hour. Six hours is therefore several
46480
- * missed opportunities, not one — so a single quiet night does not raise a
46481
- * fault, and a genuinely flat camera is named well inside a day.
46482
- */
46483
- var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
46484
46515
  DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
46485
46516
  new Set(Object.values(DeviceType));
46486
46517
  Object.freeze({
@@ -58488,6 +58519,9 @@ new Map(Object.entries({
58488
58519
  "stream-online": "device-events",
58489
58520
  "stream-offline": "device-events",
58490
58521
  "detection-blind": "device-events",
58522
+ "recording-stalled": "device-events",
58523
+ "recording-healthy": "device-events",
58524
+ "recording-degraded": "device-events",
58491
58525
  "node-online": "system",
58492
58526
  "node-offline": "system",
58493
58527
  "node-restarting": "system",
@@ -58501,6 +58535,9 @@ new Map(Object.entries({
58501
58535
  "wrapper-update-available": "system",
58502
58536
  "backup-completed": "system",
58503
58537
  "export-completed": "system",
58538
+ "storage-location-low": "system",
58539
+ "storage-location-critical": "system",
58540
+ "storage-location-ok": "system",
58504
58541
  "alarm-triggered": "alarm",
58505
58542
  "alarm-armed": "alarm",
58506
58543
  "alarm-disarmed": "alarm",
@@ -58562,6 +58599,16 @@ var NC_SYSTEM_EVENT_KIND_GROUP_SPECS = [
58562
58599
  label: "Detecting nothing",
58563
58600
  kinds: ["detection-blind"]
58564
58601
  },
58602
+ {
58603
+ id: "recording",
58604
+ family: "device-events",
58605
+ label: "Recording",
58606
+ kinds: [
58607
+ "recording-stalled",
58608
+ "recording-healthy",
58609
+ "recording-degraded"
58610
+ ]
58611
+ },
58565
58612
  {
58566
58613
  id: "nodes",
58567
58614
  family: "system",
@@ -58602,6 +58649,16 @@ var NC_SYSTEM_EVENT_KIND_GROUP_SPECS = [
58602
58649
  label: "Backups & exports",
58603
58650
  kinds: ["backup-completed", "export-completed"]
58604
58651
  },
58652
+ {
58653
+ id: "storage",
58654
+ family: "system",
58655
+ label: "Storage locations",
58656
+ kinds: [
58657
+ "storage-location-low",
58658
+ "storage-location-critical",
58659
+ "storage-location-ok"
58660
+ ]
58661
+ },
58605
58662
  {
58606
58663
  id: "alarm",
58607
58664
  family: "alarm",
@@ -58684,6 +58741,9 @@ var DEVICE_EVENTS = [
58684
58741
  "stream-online",
58685
58742
  "stream-offline",
58686
58743
  "detection-blind",
58744
+ "recording-stalled",
58745
+ "recording-healthy",
58746
+ "recording-degraded",
58687
58747
  ...ALARM
58688
58748
  ];
58689
58749
  /** Every kind a summary may speak about — the two summarisable families. */
@@ -58851,6 +58911,11 @@ var STATE_SUMMARY_VARS = [
58851
58911
  [...RULE_TEXT],
58852
58912
  [...RULE_TEXT],
58853
58913
  [...RULE_TEXT],
58914
+ [...RULE_TEXT],
58915
+ [...RULE_TEXT],
58916
+ [...RULE_TEXT],
58917
+ [...RULE_TEXT],
58918
+ [...RULE_TEXT],
58854
58919
  ([...RULE_TEXT], [...UPDATES]),
58855
58920
  ([...RULE_TEXT], [...PACKAGE_UPDATES]),
58856
58921
  ([...RULE_TEXT], [...PACKAGE_UPDATES]),
@@ -71522,7 +71587,7 @@ var require_snapshot_utils = /* @__PURE__ */ require_chunk.__commonJSMin(((expor
71522
71587
  //#region ../../node_modules/undici/lib/mock/snapshot-recorder.js
71523
71588
  var require_snapshot_recorder = /* @__PURE__ */ require_chunk.__commonJSMin(((exports, module) => {
71524
71589
  var { writeFile: writeFile$2, readFile: readFile$3, mkdir: mkdir$2 } = require("node:fs/promises");
71525
- var { dirname: dirname$1, resolve } = require("node:path");
71590
+ var { dirname: dirname$2, resolve: resolve$1 } = require("node:path");
71526
71591
  var { setTimeout: setTimeout$1, clearTimeout: clearTimeout$1 } = require("node:timers");
71527
71592
  var { InvalidArgumentError, UndiciError } = require_errors$1();
71528
71593
  var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
@@ -71787,7 +71852,7 @@ var require_snapshot_recorder = /* @__PURE__ */ require_chunk.__commonJSMin(((ex
71787
71852
  const path = filePath || this.#snapshotPath;
71788
71853
  if (!path) throw new InvalidArgumentError("Snapshot path is required");
71789
71854
  try {
71790
- const data = await readFile$3(resolve(path), "utf8");
71855
+ const data = await readFile$3(resolve$1(path), "utf8");
71791
71856
  const parsed = JSON.parse(data);
71792
71857
  if (Array.isArray(parsed)) {
71793
71858
  this.#snapshots.clear();
@@ -71807,8 +71872,8 @@ var require_snapshot_recorder = /* @__PURE__ */ require_chunk.__commonJSMin(((ex
71807
71872
  async saveSnapshots(filePath) {
71808
71873
  const path = filePath || this.#snapshotPath;
71809
71874
  if (!path) throw new InvalidArgumentError("Snapshot path is required");
71810
- const resolvedPath = resolve(path);
71811
- await mkdir$2(dirname$1(resolvedPath), { recursive: true });
71875
+ const resolvedPath = resolve$1(path);
71876
+ await mkdir$2(dirname$2(resolvedPath), { recursive: true });
71812
71877
  const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
71813
71878
  hash,
71814
71879
  snapshot
@@ -160445,6 +160510,7 @@ function buildInitialStatus2(config, enabled) {
160445
160510
  lastErrorMessage: void 0
160446
160511
  };
160447
160512
  }
160513
+ process.platform, process.env["SystemRoot"];
160448
160514
  /**
160449
160515
  * The spawn a one-shot ffmpeg site uses: the spec's seam when one is injected,
160450
160516
  * else the node's PINNED binary (D540), else `null` — and the site refuses by
@@ -161017,6 +161083,13 @@ var reolinkCameraSchema = object({
161017
161083
  */
161018
161084
  clipCatalogForcedWake: boolean().optional(),
161019
161085
  /**
161086
+ * Battery cameras only: the window, in minutes, in which the camera refreshes
161087
+ * ALL its metadata and its cached snapshot exactly once (D847). Absent means
161088
+ * the default (60); `0` turns the scheduled refresh off for this camera; a
161089
+ * value is clamped to 15..240 (below the 6 h presence budget).
161090
+ */
161091
+ batteryRefreshMinutes: number().int().min(0).max(240).optional(),
161092
+ /**
161020
161093
  * Per-AI-class sensitivity overrides — pushed via
161021
161094
  * `setAiDetection(aiType, sensitivity)` (cmdId=343). Reolink
161022
161095
  * range 0..100 — same scale the reolink_aio CGI exposes
@@ -167274,14 +167347,266 @@ function deriveChargingSource(adapterStatus, chargeStatus) {
167274
167347
  function reportsPowerSource(info) {
167275
167348
  return info.adapterStatus !== void 0 || info.chargeStatus !== void 0;
167276
167349
  }
167350
+ /** The settings key, per device. */
167351
+ var BATTERY_REFRESH_MINUTES_KEY = "batteryRefreshMinutes";
167352
+ /** How often the in-memory check runs. Costs nothing: no I/O unless it fires. */
167353
+ var BATTERY_REFRESH_TICK_MS = 6e4;
167354
+ /** The least time between two attempts in one window. */
167355
+ var RETRY_BACKOFF_MS = 10 * 6e4;
167356
+ var MAX_WAKE_LEAD_MS = 5 * 6e4;
167357
+ var STAGGER_SLOTS = 60;
167358
+ /** The operator's window in ms, or `0` for off. Out-of-range values clamp. */
167359
+ function resolveBatteryRefreshIntervalMs(configured) {
167360
+ if (typeof configured !== "number" || !Number.isFinite(configured)) return 60 * 6e4;
167361
+ if (configured <= 0) return 0;
167362
+ return Math.min(240, Math.max(15, Math.round(configured))) * 6e4;
167363
+ }
167364
+ /** Deterministic per-device offset so a fleet's windows do not align. */
167365
+ function staggerOffsetMs(deviceId, intervalMs) {
167366
+ const slot = Math.abs(Math.trunc(deviceId)) % STAGGER_SLOTS;
167367
+ return Math.floor(intervalMs / STAGGER_SLOTS * slot);
167368
+ }
167369
+ /** Which window a wall-clock instant falls in. */
167370
+ function windowIndex(at, intervalMs, offsetMs) {
167371
+ return Math.floor((at - offsetMs) / intervalMs);
167372
+ }
167373
+ /** How long before a window's end the wake is allowed to fire. */
167374
+ function wakeLeadMs(intervalMs) {
167375
+ return Math.min(MAX_WAKE_LEAD_MS, Math.floor(intervalMs / 10));
167376
+ }
167377
+ function decideBatteryRefresh(input) {
167378
+ if (input.intervalMs <= 0) return {
167379
+ kind: "skip",
167380
+ reason: "off"
167381
+ };
167382
+ const current = windowIndex(input.nowMs, input.intervalMs, input.offsetMs);
167383
+ if ((typeof input.lastFullRefreshAt === "number" && input.lastFullRefreshAt > 0 ? windowIndex(input.lastFullRefreshAt, input.intervalMs, input.offsetMs) : Number.NEGATIVE_INFINITY) >= current) return {
167384
+ kind: "skip",
167385
+ reason: "done"
167386
+ };
167387
+ if (input.attemptsInWindow >= 2) return {
167388
+ kind: "skip",
167389
+ reason: "attempts-exhausted"
167390
+ };
167391
+ if (input.lastAttemptAt !== void 0 && input.attemptsInWindow > 0 && input.nowMs - input.lastAttemptAt < 6e5) return {
167392
+ kind: "skip",
167393
+ reason: "backoff"
167394
+ };
167395
+ if (input.awake) return { kind: "refresh-awake" };
167396
+ const windowEnd = input.offsetMs + (current + 1) * input.intervalMs;
167397
+ if (input.nowMs < windowEnd - wakeLeadMs(input.intervalMs)) return {
167398
+ kind: "skip",
167399
+ reason: "asleep-before-deadline"
167400
+ };
167401
+ return { kind: "wake-then-refresh" };
167402
+ }
167403
+ var BatteryHourlyRefresh = class {
167404
+ deps;
167405
+ inFlight = null;
167406
+ attemptWindow = Number.NEGATIVE_INFINITY;
167407
+ attempts = 0;
167408
+ lastAttemptAt;
167409
+ constructor(deps) {
167410
+ this.deps = deps;
167411
+ }
167412
+ /**
167413
+ * The periodic check. Resolves `true` when a refresh landed in this call.
167414
+ * Never throws.
167415
+ */
167416
+ async tick() {
167417
+ return this.consider("tick");
167418
+ }
167419
+ /**
167420
+ * The camera was just observed coming up (event, sleep poll, hub summary, a
167421
+ * confirmed wake). Resolves `true` when this call refreshed everything, so
167422
+ * the caller can skip the parts of its own fan-out the refresh already did.
167423
+ */
167424
+ async noteAwake() {
167425
+ return this.consider("wake-transition");
167426
+ }
167427
+ async consider(trigger) {
167428
+ if (this.inFlight !== null) return this.inFlight;
167429
+ const nowMs = this.deps.now();
167430
+ const intervalMs = this.deps.intervalMs();
167431
+ const offsetMs = intervalMs > 0 ? staggerOffsetMs(this.deps.deviceId, intervalMs) : 0;
167432
+ const window = intervalMs > 0 ? windowIndex(nowMs, intervalMs, offsetMs) : 0;
167433
+ if (window !== this.attemptWindow) {
167434
+ this.attemptWindow = window;
167435
+ this.attempts = 0;
167436
+ this.lastAttemptAt = void 0;
167437
+ }
167438
+ const decision = decideBatteryRefresh({
167439
+ nowMs,
167440
+ intervalMs,
167441
+ offsetMs,
167442
+ lastFullRefreshAt: this.deps.lastFullRefreshAt(),
167443
+ awake: trigger === "wake-transition" || this.deps.isAwake(),
167444
+ attemptsInWindow: this.attempts,
167445
+ lastAttemptAt: this.lastAttemptAt
167446
+ });
167447
+ if (decision.kind === "skip") {
167448
+ this.logSkip(decision.reason, trigger);
167449
+ return false;
167450
+ }
167451
+ this.attempts += 1;
167452
+ this.lastAttemptAt = nowMs;
167453
+ const run = this.run(decision.kind, trigger).finally(() => {
167454
+ this.inFlight = null;
167455
+ });
167456
+ this.inFlight = run;
167457
+ return run;
167458
+ }
167459
+ async run(kind, trigger) {
167460
+ const { logger, deviceId } = this.deps;
167461
+ try {
167462
+ if (kind === "wake-then-refresh") {
167463
+ logger.info("battery hourly refresh: not awake in the window — waking once", {
167464
+ tags: { deviceId },
167465
+ meta: { attempt: this.attempts }
167466
+ });
167467
+ if (!await this.deps.wake()) {
167468
+ logger.warn("battery hourly refresh: the wake did not bring the camera up", {
167469
+ tags: { deviceId },
167470
+ meta: {
167471
+ attempt: this.attempts,
167472
+ retryAfterMs: RETRY_BACKOFF_MS
167473
+ }
167474
+ });
167475
+ return false;
167476
+ }
167477
+ if (this.windowSatisfied()) return true;
167478
+ } else logger.info("battery hourly refresh: camera is awake — refreshing now, no wake", {
167479
+ tags: { deviceId },
167480
+ meta: {
167481
+ trigger,
167482
+ attempt: this.attempts
167483
+ }
167484
+ });
167485
+ const outcome = await this.deps.refreshAll();
167486
+ if (outcome.failed.length > 0) {
167487
+ logger.warn("battery hourly refresh incomplete — not stamped, retried at the next chance", {
167488
+ tags: { deviceId },
167489
+ meta: {
167490
+ failed: outcome.failed,
167491
+ attempt: this.attempts,
167492
+ retryAfterMs: RETRY_BACKOFF_MS
167493
+ }
167494
+ });
167495
+ return false;
167496
+ }
167497
+ const at = this.deps.now();
167498
+ this.deps.stamp(at);
167499
+ logger.info("battery hourly refresh complete", {
167500
+ tags: { deviceId },
167501
+ meta: {
167502
+ kind,
167503
+ trigger,
167504
+ at
167505
+ }
167506
+ });
167507
+ return true;
167508
+ } catch (err) {
167509
+ logger.warn("battery hourly refresh failed — retried at the next chance", {
167510
+ tags: { deviceId },
167511
+ meta: {
167512
+ error: err instanceof Error ? err.message : String(err),
167513
+ attempt: this.attempts,
167514
+ retryAfterMs: RETRY_BACKOFF_MS
167515
+ }
167516
+ });
167517
+ return false;
167518
+ }
167519
+ }
167520
+ windowSatisfied() {
167521
+ const intervalMs = this.deps.intervalMs();
167522
+ if (intervalMs <= 0) return false;
167523
+ const offsetMs = staggerOffsetMs(this.deps.deviceId, intervalMs);
167524
+ const last = this.deps.lastFullRefreshAt();
167525
+ if (typeof last !== "number" || last <= 0) return false;
167526
+ return windowIndex(last, intervalMs, offsetMs) >= windowIndex(this.deps.now(), intervalMs, offsetMs);
167527
+ }
167528
+ logSkip(reason, trigger) {
167529
+ if (reason === "backoff" || reason === "attempts-exhausted") {
167530
+ this.deps.logger.info("battery hourly refresh skipped", {
167531
+ tags: { deviceId: this.deps.deviceId },
167532
+ meta: {
167533
+ reason,
167534
+ trigger,
167535
+ attempts: this.attempts
167536
+ }
167537
+ });
167538
+ return;
167539
+ }
167540
+ this.deps.logger.debug("battery hourly refresh not due", {
167541
+ tags: { deviceId: this.deps.deviceId },
167542
+ meta: {
167543
+ reason,
167544
+ trigger
167545
+ }
167546
+ });
167547
+ }
167548
+ };
167549
+ //#endregion
167550
+ //#region src/battery-refresh-settings.ts
167551
+ var BATTERY_REFRESH_SECTION_ID = "battery-refresh";
167552
+ var BATTERY_REFRESH_MINUTES_DESCRIPTION = `Once per window this camera refreshes everything CamStack knows about it — battery level and charging, signal, device info, every state panel and the cached snapshot. If the camera is awake at some point in the window (a motion event, a live view, anything that woke it) it is refreshed then and not woken again; if the whole window passes with the camera asleep, it is woken ONCE near the end of the window, refreshed, and left to go back to sleep. A camera that does not answer is retried at most twice per window. 15–240 minutes, default 60; 0 turns the scheduled refresh off (the battery level then ages until the camera next wakes by itself).`;
167553
+ /** Battery cameras only: a mains camera keeps its facts fresh on its own socket. */
167554
+ function buildBatteryRefreshDeviceSection(input) {
167555
+ if (!input.isBattery) return [];
167556
+ return [{
167557
+ id: BATTERY_REFRESH_SECTION_ID,
167558
+ tab: "general",
167559
+ title: "Scheduled refresh",
167560
+ description: "How often this battery camera is guaranteed to have been read, so a battery level never goes stale unseen.",
167561
+ columns: 1,
167562
+ fields: [{
167563
+ type: "number",
167564
+ key: BATTERY_REFRESH_MINUTES_KEY,
167565
+ label: "Refresh window (min)",
167566
+ description: BATTERY_REFRESH_MINUTES_DESCRIPTION,
167567
+ default: 60,
167568
+ min: 0,
167569
+ max: 240,
167570
+ step: 5
167571
+ }]
167572
+ }];
167573
+ }
167277
167574
  //#endregion
167278
- //#region src/battery-contact-expiry.ts
167279
- function shouldWakeForContact(input) {
167280
- if (input.hubChild) return false;
167281
- if (!input.sleeping) return false;
167282
- const last = input.lastContactAt;
167283
- if (typeof last !== "number" || last <= 0) return false;
167284
- return input.nowMs - last + input.tickMs >= input.unreachableAfterMs;
167575
+ //#region src/battery-snapshot-refresh.ts
167576
+ /** The wrapper may run on another node; a stale frame is hours old, skew is seconds. */
167577
+ var CLOCK_SKEW_TOLERANCE_MS = 6e4;
167578
+ async function refreshSnapshotCacheOnce(input) {
167579
+ const { deviceId, api, logger } = input;
167580
+ const startedAt = input.now();
167581
+ if (await api.getSnapshot({
167582
+ deviceId,
167583
+ force: true
167584
+ }) === null) {
167585
+ logger.warn("battery hourly refresh: the snapshot came back empty", { tags: { deviceId } });
167586
+ return false;
167587
+ }
167588
+ let rows;
167589
+ try {
167590
+ rows = await api.getSnapshotOverview({ deviceIds: [deviceId] });
167591
+ } catch (err) {
167592
+ logger.debug("battery hourly refresh: snapshot overview unreadable — counting the image", {
167593
+ tags: { deviceId },
167594
+ meta: { error: err instanceof Error ? err.message : String(err) }
167595
+ });
167596
+ return true;
167597
+ }
167598
+ const capturedAt = rows.find((row) => row.deviceId === deviceId)?.lastCapturedAt ?? null;
167599
+ if (capturedAt === null || capturedAt < startedAt - CLOCK_SKEW_TOLERANCE_MS) {
167600
+ logger.warn("battery hourly refresh: the snapshot cache did not move (a stale frame was served)", {
167601
+ tags: { deviceId },
167602
+ meta: {
167603
+ capturedAt,
167604
+ startedAt
167605
+ }
167606
+ });
167607
+ return false;
167608
+ }
167609
+ return true;
167285
167610
  }
167286
167611
  //#endregion
167287
167612
  //#region src/log-channels.ts
@@ -171284,7 +171609,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
171284
171609
  * passively checks sleep state, and short-circuits if sleeping. */
171285
171610
  batteryUpdateTimer = null;
171286
171611
  /** Single-flight gate so two interval fires can't overlap. */
171287
- batteryUpdateInFlight = null;
171288
171612
  /** Aux accessory references registered by spawned children
171289
171613
  * (siren / floodlight / pir). The parent's centralised align
171290
171614
  * method iterates these to refresh their slices on a single
@@ -171622,7 +171946,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
171622
171946
  * snapshot also drives the dispatch's known-good baseline.
171623
171947
  */
171624
171948
  async refreshParentSettingsSnapshot(slices, reason = "background") {
171625
- if (this.isBattery && this.sleeping && reason !== "post-write") {
171949
+ if (this.isBattery && this.sleeping && reason !== "post-write" && reason !== "scheduled") {
171626
171950
  this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: {
171627
171951
  deviceId: this.id,
171628
171952
  reason
@@ -172568,7 +172892,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
172568
172892
  tags: { deviceId: this.id },
172569
172893
  meta: { reason }
172570
172894
  });
172571
- return;
172895
+ return false;
172572
172896
  }
172573
172897
  let api;
172574
172898
  try {
@@ -172578,17 +172902,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
172578
172902
  tags: { deviceId: this.id },
172579
172903
  meta: { error: err instanceof Error ? err.message : String(err) }
172580
172904
  });
172581
- return;
172905
+ return false;
172582
172906
  }
172583
172907
  try {
172584
172908
  const info = await api.getBatteryInfo(this.getChannel());
172585
172909
  this.updateBatteryCache(info);
172586
172910
  await this.refreshNetworkLinkFromApi(api, "battery");
172911
+ return true;
172587
172912
  } catch (err) {
172588
172913
  this.ctx.logger.debug("battery refresh failed", {
172589
172914
  tags: { deviceId: this.id },
172590
172915
  meta: { error: err instanceof Error ? err.message : String(err) }
172591
172916
  });
172917
+ return false;
172592
172918
  }
172593
172919
  }
172594
172920
  /**
@@ -173834,6 +174160,18 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
173834
174160
  * proactive gate lets it through because `sleeping` is false here.
173835
174161
  * Mirrors the reference's `updateBatteryAndSnapshot`.
173836
174162
  */
174163
+ /** The scheduled refresh's snapshot step: true only when the cache really moved. */
174164
+ refreshSnapshotCache() {
174165
+ return refreshSnapshotCacheOnce({
174166
+ deviceId: this.id,
174167
+ logger: this.ctx.logger,
174168
+ now: () => Date.now(),
174169
+ api: {
174170
+ getSnapshot: (input) => this.ctx.api.snapshot.getSnapshot.query(input),
174171
+ getSnapshotOverview: (input) => this.ctx.api.snapshot.getSnapshotOverview.query({ deviceIds: [...input.deviceIds] })
174172
+ }
174173
+ });
174174
+ }
173837
174175
  async warmSnapshotOnWake() {
173838
174176
  try {
173839
174177
  await this.ctx.api.snapshot.getSnapshot.query({
@@ -176269,119 +176607,84 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
176269
176607
  this.alignAuxTimer = null;
176270
176608
  }
176271
176609
  }
176272
- /** Run one cycle of the battery-cam periodic refresh. Single-flight
176273
- * via `batteryUpdateInFlight`. Never throws. */
176274
- async runBatteryUpdate() {
176275
- if (this.batteryUpdateInFlight) return this.batteryUpdateInFlight;
176276
- const cycle = (async () => {
176610
+ batteryRefresh = null;
176611
+ getBatteryRefresh() {
176612
+ if (this.batteryRefresh === null) this.batteryRefresh = new BatteryHourlyRefresh({
176613
+ deviceId: this.id,
176614
+ logger: this.ctx.logger,
176615
+ now: () => Date.now(),
176616
+ isAwake: () => !this.sleeping,
176617
+ intervalMs: () => resolveBatteryRefreshIntervalMs(this.config.get("batteryRefreshMinutes")),
176618
+ lastFullRefreshAt: () => this.state.battery.lastFullRefreshAt,
176619
+ wake: async () => {
176620
+ if (!this.canProactivelyWake("scheduled-refresh")) return false;
176621
+ this.markWakeIssued();
176622
+ return this.wakeIfSleeping();
176623
+ },
176624
+ refreshAll: () => this.refreshEverythingNow(),
176625
+ stamp: (at) => {
176626
+ this.state.battery.lastFullRefreshAt = at;
176627
+ }
176628
+ });
176629
+ return this.batteryRefresh;
176630
+ }
176631
+ /**
176632
+ * Every fact and the snapshot, read while the camera is up. SERIALIZED (one
176633
+ * Baichuan round-trip at a time on the single BCUDP stream), and each step
176634
+ * names itself in `failed` instead of throwing, so one dead read neither
176635
+ * hides the others nor stamps the window as done.
176636
+ */
176637
+ async refreshEverythingNow() {
176638
+ const failed = [];
176639
+ const step = async (name, run) => {
176277
176640
  try {
176278
- if (this.isBattery && this.sleeping) {
176279
- if (shouldWakeForContact({
176280
- nowMs: Date.now(),
176281
- lastContactAt: this.state.battery.lastContactAt,
176282
- sleeping: true,
176283
- hubChild: this.isHubChild(),
176284
- unreachableAfterMs: 216e5,
176285
- tickMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS
176286
- })) {
176287
- await this.wakeForContactRefresh();
176288
- return;
176289
- }
176290
- this.ctx.logger.debug("battery-update: cam sleeping — skipping cycle without connecting", { tags: { deviceId: this.id } });
176291
- return;
176292
- }
176293
- let api;
176294
- try {
176295
- api = await this.ensureApi();
176296
- } catch (err) {
176297
- this.ctx.logger.debug("battery-update: ensureApi failed — skipping cycle", {
176298
- tags: { deviceId: this.id },
176299
- meta: { error: err instanceof Error ? err.message : String(err) }
176300
- });
176301
- return;
176302
- }
176303
- let sleeping = false;
176304
- try {
176305
- sleeping = api.getSleepStatus({ channel: this.getChannel() }).state === "sleeping";
176306
- } catch (err) {
176307
- this.ctx.logger.debug("battery-update: getSleepStatus threw — skipping cycle", {
176308
- tags: { deviceId: this.id },
176309
- meta: { error: err instanceof Error ? err.message : String(err) }
176310
- });
176311
- return;
176312
- }
176313
- if (sleeping) {
176314
- this.ctx.logger.debug("battery-update: cam sleeping — skipping periodic refresh", { tags: { deviceId: this.id } });
176315
- return;
176316
- }
176317
- this.ctx.logger.info("battery-update: cam awake — running periodic refresh", {
176641
+ if (await run() === false) failed.push(name);
176642
+ } catch (err) {
176643
+ failed.push(name);
176644
+ this.ctx.logger.debug("battery hourly refresh: step threw", {
176318
176645
  tags: { deviceId: this.id },
176319
176646
  meta: {
176320
- isHubChild: this.isHubChild(),
176321
- auxAccessoryCount: this.auxAccessoryRefs.size
176647
+ step: name,
176648
+ error: err instanceof Error ? err.message : String(err)
176322
176649
  }
176323
176650
  });
176324
- await this.refreshBatteryFromApi("periodic").catch(() => {});
176325
- await this.alignAuxDevicesState("periodic").catch(() => {});
176326
- await this.refreshParentSettingsSnapshot().catch(() => {});
176327
- } finally {
176328
- this.batteryUpdateInFlight = null;
176329
176651
  }
176330
- })();
176331
- this.batteryUpdateInFlight = cycle;
176332
- return cycle;
176652
+ };
176653
+ await step("battery", () => this.refreshBatteryFromApi("demand"));
176654
+ await step("device-info", async () => {
176655
+ await this.populateMetadataFromFirmware(await this.ensureApi());
176656
+ });
176657
+ await step("cap-slices", async () => {
176658
+ await this.warmCapSlicesOnWake({ force: true });
176659
+ });
176660
+ await step("aux-accessories", () => this.alignAuxDevicesState("wake"));
176661
+ await step("settings-snapshot", () => this.refreshParentSettingsSnapshot(void 0, "scheduled"));
176662
+ await step("snapshot", () => this.refreshSnapshotCache());
176663
+ return { failed };
176333
176664
  }
176334
176665
  /**
176335
- * D355: wake a standalone battery camera ONCE because its last passive
176336
- * contact is about to expire, and refresh every slice while it is up.
176337
- * Bounded by `canProactivelyWake` (the shared cooldown) and by the fact
176338
- * that a wake that answers resets the silence. A wake that fails is left
176339
- * alone: the silence then reaches the budget and `deriveBatteryPresence`
176340
- * says `unreachable` — for the first time on evidence.
176666
+ * Run one scheduled-refresh check. Never throws; costs nothing unless a
176667
+ * refresh is due (see {@link decideBatteryRefresh}).
176341
176668
  */
176342
- async wakeForContactRefresh() {
176343
- if (!this.canProactivelyWake("contact-expiry")) return;
176344
- this.markWakeIssued();
176345
- const lastContactAt = this.state.battery.lastContactAt ?? 0;
176346
- const silenceMs = Date.now() - lastContactAt;
176347
- this.ctx.logger.info("battery contact expiring — waking once to refresh every slice (D355)", {
176348
- tags: { deviceId: this.id },
176349
- meta: {
176350
- silenceMs,
176351
- budgetMs: BATTERY_UNREACHABLE_AFTER_MS
176352
- }
176353
- });
176354
- try {
176355
- await this.ensureApi();
176356
- } catch (err) {
176357
- this.ctx.logger.warn("battery contact wake FAILED — silence continues and the camera will read unreachable at the budget", {
176358
- tags: { deviceId: this.id },
176359
- meta: {
176360
- silenceMs,
176361
- error: err instanceof Error ? err.message : String(err)
176362
- }
176363
- });
176364
- return;
176365
- }
176366
- await this.refreshBatteryFromApi("demand").catch(() => {});
176367
- await this.warmCapSlicesOnWake().catch(() => {});
176368
- await this.alignAuxDevicesState("wake").catch(() => {});
176369
- await this.warmSnapshotOnWake().catch(() => {});
176669
+ async runBatteryUpdate() {
176670
+ if (!this.isBattery) return;
176671
+ await this.getBatteryRefresh().tick();
176370
176672
  }
176371
- /** Arm the periodic battery-cam refresh timer. No-op for wired cams
176372
- * (their state is kept fresh by the 10 s aux-align poll and push
176373
- * events). Idempotent. */
176673
+ /** Arm the scheduled-refresh check. No-op for wired cams (their state is
176674
+ * kept fresh by the 10 s aux-align poll and push events). Idempotent. */
176374
176675
  startBatteryUpdatePolling() {
176375
176676
  if (this.batteryUpdateTimer) return;
176376
176677
  if (!this.isBattery) return;
176377
176678
  this.batteryUpdateTimer = setInterval(() => {
176378
176679
  this.runBatteryUpdate().catch(() => {});
176379
- }, ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS);
176680
+ }, BATTERY_REFRESH_TICK_MS);
176380
176681
  if (typeof this.batteryUpdateTimer.unref === "function") this.batteryUpdateTimer.unref();
176381
- this.ctx.logger.info("battery-update: periodic refresh armed", {
176682
+ this.ctx.logger.info("battery-update: scheduled full refresh armed", {
176382
176683
  tags: { deviceId: this.id },
176383
176684
  meta: {
176384
- intervalMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS,
176685
+ tickMs: BATTERY_REFRESH_TICK_MS,
176686
+ intervalMs: resolveBatteryRefreshIntervalMs(this.config.get("batteryRefreshMinutes")),
176687
+ lastFullRefreshAt: this.state.battery.lastFullRefreshAt ?? null,
176385
176688
  isHubChild: this.isHubChild()
176386
176689
  }
176387
176690
  });
@@ -176477,22 +176780,18 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
176477
176780
  meta: { error: err instanceof Error ? err.message : String(err) }
176478
176781
  });
176479
176782
  }
176480
- await this.warmCapSlicesOnWake();
176481
- await this.warmSnapshotOnWake();
176482
- await this.alignAuxDevicesState("wake");
176483
- await this.refreshParentSettingsSnapshot();
176783
+ if (!(this.isBattery ? await this.getBatteryRefresh().noteAwake().catch(() => false) : false)) {
176784
+ await this.warmCapSlicesOnWake();
176785
+ await this.warmSnapshotOnWake();
176786
+ await this.alignAuxDevicesState("wake");
176787
+ await this.refreshParentSettingsSnapshot();
176788
+ }
176484
176789
  await this.refreshClipCatalogOnWake();
176485
176790
  }
176486
176791
  static PING_INTERVAL_MS = 3e4;
176487
176792
  static PING_MAX_FAILURES = 3;
176488
176793
  static EVENT_CHECK_INTERVAL_MS = 6e4;
176489
176794
  static EVENT_STALE_THRESHOLD_MS = 10 * 6e4;
176490
- /** Battery-cam periodic refresh cadence. The timer fires on this
176491
- * interval, passively checks sleep state, and only runs the
176492
- * refresh when the cam is already awake — so it never forces a
176493
- * wake. Mirrors the reference's `batteryUpdateIntervalMinutes`
176494
- * default (60 minutes). */
176495
- static BATTERY_UPDATE_INTERVAL_MS = 60 * 6e4;
176496
176795
  /**
176497
176796
  * Arm the connection-liveness ping watchdog and the event-watchdog.
176498
176797
  * Battery cams skip the ping watchdog — they keep `setIdleDisconnect=true`
@@ -177676,7 +177975,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
177676
177975
  ...buildClipRefreshDeviceSection({
177677
177976
  isHubChild: this.isHubChild(),
177678
177977
  isBattery: this.isBattery
177679
- })
177978
+ }),
177979
+ ...buildBatteryRefreshDeviceSection({ isBattery: this.isBattery })
177680
177980
  ]
177681
177981
  };
177682
177982
  const emailSnap = cache?.emailConfigSnapshot;
@@ -177697,6 +177997,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
177697
177997
  emailScheduleEnabled: emailSnap?.taskEnabled ?? false,
177698
177998
  clipCatalogRefreshMinutes: this.config.get("clipCatalogRefreshMinutes"),
177699
177999
  clipCatalogForcedWake: this.config.get("clipCatalogForcedWake") ?? false,
178000
+ batteryRefreshMinutes: this.config.get("batteryRefreshMinutes"),
177700
178001
  debugGeneral: this.config.get("debugGeneral") ?? false,
177701
178002
  debugSocketLogs: this.config.get("debugSocketLogs") ?? [],
177702
178003
  motionEnabled: this.config.get("motionEnabled") ?? motionSnap.enabled ?? true,