@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.mjs CHANGED
@@ -10,6 +10,7 @@ import * as crypto$7 from "crypto";
10
10
  import crypto$1, { createHash as createHash$1, randomUUID } from "crypto";
11
11
  import { execFile, spawn } from "child_process";
12
12
  import { createServer } from "node:http";
13
+ import "node:zlib";
13
14
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
14
15
  import { join as join$1 } from "node:path";
15
16
  import { PassThrough } from "stream";
@@ -27,7 +28,7 @@ import { URL as URL$1 } from "url";
27
28
  import { spawn as spawn$1 } from "node:child_process";
28
29
  import os, { tmpdir } from "node:os";
29
30
  import { createReadStream } from "node:fs";
30
- //#region ../types/dist/event-category-DaIZi00R.mjs
31
+ //#region ../types/dist/event-category-C4hVnRo8.mjs
31
32
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
32
33
  EventCategory["SystemBoot"] = "system.boot";
33
34
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -246,7 +247,17 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
246
247
  EventCategory["RecordingStopped"] = "recording.stopped";
247
248
  EventCategory["RecordingError"] = "recording.error";
248
249
  EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
250
+ /** Recording enabled but no segment finalized for the NC stall window. */
251
+ EventCategory["RecordingHealthStalled"] = "recording.health.stalled";
252
+ /** Recovery from {@link RecordingHealthStalled}. */
253
+ EventCategory["RecordingHealthOk"] = "recording.health.ok";
249
254
  EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
255
+ /** Free space on a storage location crossed the warn bar. */
256
+ EventCategory["StorageLocationLow"] = "storage.location.low";
257
+ /** Free space at/under the critical bar, or capacity probe failed (`null`). */
258
+ EventCategory["StorageLocationCritical"] = "storage.location.critical";
259
+ /** Recovery from low/critical on a storage location. */
260
+ EventCategory["StorageLocationOk"] = "storage.location.ok";
250
261
  EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
251
262
  EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
252
263
  EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
@@ -5405,7 +5416,7 @@ var ZodIssueCode = {
5405
5416
  var ZodFirstPartyTypeKind;
5406
5417
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5407
5418
  //#endregion
5408
- //#region ../types/dist/sleep-DwAQ9sqX.mjs
5419
+ //#region ../types/dist/sleep-vWTnHq9G.mjs
5409
5420
  /**
5410
5421
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5411
5422
  * window to float samples (D455).
@@ -8010,6 +8021,21 @@ var ProfileRtspEntrySchema = object({
8010
8021
  codec: string().optional(),
8011
8022
  resolution: CamStreamResolutionSchema.optional()
8012
8023
  });
8024
+ /**
8025
+ * The model weight formats a catalog entry can ship. Schema-free on purpose:
8026
+ * `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
8027
+ * (the model downloader in `@camstack/system`) needs only the list and must
8028
+ * reach it through `@camstack/types/addon` without building the cap schema
8029
+ * graph (D28).
8030
+ */
8031
+ var MODEL_FORMATS = [
8032
+ "onnx",
8033
+ "coreml",
8034
+ "openvino",
8035
+ "tflite",
8036
+ "pt",
8037
+ "gguf"
8038
+ ];
8013
8039
  object({
8014
8040
  field: string().min(1),
8015
8041
  on: literal("advance")
@@ -8608,7 +8634,7 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
8608
8634
  return DeviceRole;
8609
8635
  }({});
8610
8636
  //#endregion
8611
- //#region ../types/dist/composition-Bzr7GwgP.mjs
8637
+ //#region ../types/dist/composition-C-WpVYTX.mjs
8612
8638
  /**
8613
8639
  * The ffmpeg build every CamStack node downloads, per platform and arch.
8614
8640
  *
@@ -8660,7 +8686,7 @@ var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFM
8660
8686
  function portable(target) {
8661
8687
  return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
8662
8688
  }
8663
- portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
8689
+ portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64"), `${BASE}${FFMPEG_RELEASE}`;
8664
8690
  /**
8665
8691
  * THE table "which boolean of this device type counts as HIGH", and the
8666
8692
  * pure evaluator of its EDGE.
@@ -12404,14 +12430,6 @@ var MASK_SUPPLY_STEP_IDS = ["segmentation"];
12404
12430
  ...CROP_SUPPLY_STEP_IDS,
12405
12431
  ...MASK_SUPPLY_STEP_IDS
12406
12432
  ];
12407
- var MODEL_FORMATS = [
12408
- "onnx",
12409
- "coreml",
12410
- "openvino",
12411
- "tflite",
12412
- "pt",
12413
- "gguf"
12414
- ];
12415
12433
  /**
12416
12434
  * Multi-file format payload.
12417
12435
  *
@@ -14235,7 +14253,14 @@ var BrokerProviderInfoSchema = object({
14235
14253
  /** Broker kinds this provider can create, with a display label. */
14236
14254
  kinds: array(object({
14237
14255
  kind: string(),
14238
- label: string()
14256
+ label: string(),
14257
+ /**
14258
+ * Relative hub path to start an interactive account link (OAuth popup).
14259
+ * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
14260
+ */
14261
+ interactiveConnectPath: string().min(1).optional(),
14262
+ /** Relative hub path to disconnect the linked account (same popup pattern). */
14263
+ interactiveDisconnectPath: string().min(1).optional()
14239
14264
  }))
14240
14265
  });
14241
14266
  var ListInputSchema = object({
@@ -21329,6 +21354,12 @@ var NcSystemEventKindSchema = _enum([
21329
21354
  "node-updating",
21330
21355
  "node-inference-unavailable",
21331
21356
  "detection-blind",
21357
+ "recording-stalled",
21358
+ "recording-healthy",
21359
+ "recording-degraded",
21360
+ "storage-location-low",
21361
+ "storage-location-critical",
21362
+ "storage-location-ok",
21332
21363
  "addon-crash-loop",
21333
21364
  "addon-update-available",
21334
21365
  "server-update-available",
@@ -33428,6 +33459,17 @@ var BatteryStatusSchema = object({
33428
33459
  */
33429
33460
  lastContactAt: number().optional(),
33430
33461
  /**
33462
+ * Ms epoch of the last COMPLETE scheduled refresh — every fact the provider
33463
+ * holds for the device (level, charging, link, device info, state slices) and
33464
+ * its cached snapshot, read together. Absent = never since this slice was
33465
+ * born. Unlike `lastUpdated` (any write, even a push of one field) this is
33466
+ * the staleness an operator can trust: the provider owns the device fact and
33467
+ * so owns the window on its age (D224, D847). Not a clock in the
33468
+ * runtime-state-durability sense: it moves once per window and must survive
33469
+ * a restart, or every restart would owe the camera a wake.
33470
+ */
33471
+ lastFullRefreshAt: number().optional(),
33472
+ /**
33431
33473
  * True when the source is a BINARY low-battery indicator (HA
33432
33474
  * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
33433
33475
  * charge level — `percentage` is then a coarse stand-in (100 = normal,
@@ -46462,17 +46504,6 @@ var BaseDeviceProvider = class extends BaseAddon {
46462
46504
  return toDeviceSummary(device, this.addonId);
46463
46505
  }
46464
46506
  };
46465
- /**
46466
- * Default silence budget before a sleeping battery camera is called gone.
46467
- *
46468
- * Sized off the mechanism that produces contact, not off taste: a battery cam
46469
- * on this deployment surfaces a firmware push (battery level, sleep/wake, or a
46470
- * motion email) on the order of hours even with no visitors, and the snapshot
46471
- * wrapper's own battery window is 1 hour. Six hours is therefore several
46472
- * missed opportunities, not one — so a single quiet night does not raise a
46473
- * fault, and a genuinely flat camera is named well inside a day.
46474
- */
46475
- var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
46476
46507
  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;
46477
46508
  new Set(Object.values(DeviceType));
46478
46509
  Object.freeze({
@@ -58480,6 +58511,9 @@ new Map(Object.entries({
58480
58511
  "stream-online": "device-events",
58481
58512
  "stream-offline": "device-events",
58482
58513
  "detection-blind": "device-events",
58514
+ "recording-stalled": "device-events",
58515
+ "recording-healthy": "device-events",
58516
+ "recording-degraded": "device-events",
58483
58517
  "node-online": "system",
58484
58518
  "node-offline": "system",
58485
58519
  "node-restarting": "system",
@@ -58493,6 +58527,9 @@ new Map(Object.entries({
58493
58527
  "wrapper-update-available": "system",
58494
58528
  "backup-completed": "system",
58495
58529
  "export-completed": "system",
58530
+ "storage-location-low": "system",
58531
+ "storage-location-critical": "system",
58532
+ "storage-location-ok": "system",
58496
58533
  "alarm-triggered": "alarm",
58497
58534
  "alarm-armed": "alarm",
58498
58535
  "alarm-disarmed": "alarm",
@@ -58554,6 +58591,16 @@ var NC_SYSTEM_EVENT_KIND_GROUP_SPECS = [
58554
58591
  label: "Detecting nothing",
58555
58592
  kinds: ["detection-blind"]
58556
58593
  },
58594
+ {
58595
+ id: "recording",
58596
+ family: "device-events",
58597
+ label: "Recording",
58598
+ kinds: [
58599
+ "recording-stalled",
58600
+ "recording-healthy",
58601
+ "recording-degraded"
58602
+ ]
58603
+ },
58557
58604
  {
58558
58605
  id: "nodes",
58559
58606
  family: "system",
@@ -58594,6 +58641,16 @@ var NC_SYSTEM_EVENT_KIND_GROUP_SPECS = [
58594
58641
  label: "Backups & exports",
58595
58642
  kinds: ["backup-completed", "export-completed"]
58596
58643
  },
58644
+ {
58645
+ id: "storage",
58646
+ family: "system",
58647
+ label: "Storage locations",
58648
+ kinds: [
58649
+ "storage-location-low",
58650
+ "storage-location-critical",
58651
+ "storage-location-ok"
58652
+ ]
58653
+ },
58597
58654
  {
58598
58655
  id: "alarm",
58599
58656
  family: "alarm",
@@ -58676,6 +58733,9 @@ var DEVICE_EVENTS = [
58676
58733
  "stream-online",
58677
58734
  "stream-offline",
58678
58735
  "detection-blind",
58736
+ "recording-stalled",
58737
+ "recording-healthy",
58738
+ "recording-degraded",
58679
58739
  ...ALARM
58680
58740
  ];
58681
58741
  /** Every kind a summary may speak about — the two summarisable families. */
@@ -58843,6 +58903,11 @@ var STATE_SUMMARY_VARS = [
58843
58903
  [...RULE_TEXT],
58844
58904
  [...RULE_TEXT],
58845
58905
  [...RULE_TEXT],
58906
+ [...RULE_TEXT],
58907
+ [...RULE_TEXT],
58908
+ [...RULE_TEXT],
58909
+ [...RULE_TEXT],
58910
+ [...RULE_TEXT],
58846
58911
  ([...RULE_TEXT], [...UPDATES]),
58847
58912
  ([...RULE_TEXT], [...PACKAGE_UPDATES]),
58848
58913
  ([...RULE_TEXT], [...PACKAGE_UPDATES]),
@@ -71514,7 +71579,7 @@ var require_snapshot_utils = /* @__PURE__ */ __commonJSMin(((exports, module) =>
71514
71579
  //#region ../../node_modules/undici/lib/mock/snapshot-recorder.js
71515
71580
  var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin(((exports, module) => {
71516
71581
  var { writeFile: writeFile$1, readFile: readFile$1, mkdir: mkdir$2 } = __require("node:fs/promises");
71517
- var { dirname: dirname$1, resolve } = __require("node:path");
71582
+ var { dirname: dirname$2, resolve: resolve$1 } = __require("node:path");
71518
71583
  var { setTimeout: setTimeout$1, clearTimeout: clearTimeout$1 } = __require("node:timers");
71519
71584
  var { InvalidArgumentError, UndiciError } = require_errors$1();
71520
71585
  var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
@@ -71779,7 +71844,7 @@ var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin(((exports, module)
71779
71844
  const path = filePath || this.#snapshotPath;
71780
71845
  if (!path) throw new InvalidArgumentError("Snapshot path is required");
71781
71846
  try {
71782
- const data = await readFile$1(resolve(path), "utf8");
71847
+ const data = await readFile$1(resolve$1(path), "utf8");
71783
71848
  const parsed = JSON.parse(data);
71784
71849
  if (Array.isArray(parsed)) {
71785
71850
  this.#snapshots.clear();
@@ -71799,8 +71864,8 @@ var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin(((exports, module)
71799
71864
  async saveSnapshots(filePath) {
71800
71865
  const path = filePath || this.#snapshotPath;
71801
71866
  if (!path) throw new InvalidArgumentError("Snapshot path is required");
71802
- const resolvedPath = resolve(path);
71803
- await mkdir$2(dirname$1(resolvedPath), { recursive: true });
71867
+ const resolvedPath = resolve$1(path);
71868
+ await mkdir$2(dirname$2(resolvedPath), { recursive: true });
71804
71869
  const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
71805
71870
  hash,
71806
71871
  snapshot
@@ -160437,6 +160502,7 @@ function buildInitialStatus2(config, enabled) {
160437
160502
  lastErrorMessage: void 0
160438
160503
  };
160439
160504
  }
160505
+ process.platform, process.env["SystemRoot"];
160440
160506
  /**
160441
160507
  * The spawn a one-shot ffmpeg site uses: the spec's seam when one is injected,
160442
160508
  * else the node's PINNED binary (D540), else `null` — and the site refuses by
@@ -161009,6 +161075,13 @@ var reolinkCameraSchema = object({
161009
161075
  */
161010
161076
  clipCatalogForcedWake: boolean().optional(),
161011
161077
  /**
161078
+ * Battery cameras only: the window, in minutes, in which the camera refreshes
161079
+ * ALL its metadata and its cached snapshot exactly once (D847). Absent means
161080
+ * the default (60); `0` turns the scheduled refresh off for this camera; a
161081
+ * value is clamped to 15..240 (below the 6 h presence budget).
161082
+ */
161083
+ batteryRefreshMinutes: number().int().min(0).max(240).optional(),
161084
+ /**
161012
161085
  * Per-AI-class sensitivity overrides — pushed via
161013
161086
  * `setAiDetection(aiType, sensitivity)` (cmdId=343). Reolink
161014
161087
  * range 0..100 — same scale the reolink_aio CGI exposes
@@ -167266,14 +167339,266 @@ function deriveChargingSource(adapterStatus, chargeStatus) {
167266
167339
  function reportsPowerSource(info) {
167267
167340
  return info.adapterStatus !== void 0 || info.chargeStatus !== void 0;
167268
167341
  }
167342
+ /** The settings key, per device. */
167343
+ var BATTERY_REFRESH_MINUTES_KEY = "batteryRefreshMinutes";
167344
+ /** How often the in-memory check runs. Costs nothing: no I/O unless it fires. */
167345
+ var BATTERY_REFRESH_TICK_MS = 6e4;
167346
+ /** The least time between two attempts in one window. */
167347
+ var RETRY_BACKOFF_MS = 10 * 6e4;
167348
+ var MAX_WAKE_LEAD_MS = 5 * 6e4;
167349
+ var STAGGER_SLOTS = 60;
167350
+ /** The operator's window in ms, or `0` for off. Out-of-range values clamp. */
167351
+ function resolveBatteryRefreshIntervalMs(configured) {
167352
+ if (typeof configured !== "number" || !Number.isFinite(configured)) return 60 * 6e4;
167353
+ if (configured <= 0) return 0;
167354
+ return Math.min(240, Math.max(15, Math.round(configured))) * 6e4;
167355
+ }
167356
+ /** Deterministic per-device offset so a fleet's windows do not align. */
167357
+ function staggerOffsetMs(deviceId, intervalMs) {
167358
+ const slot = Math.abs(Math.trunc(deviceId)) % STAGGER_SLOTS;
167359
+ return Math.floor(intervalMs / STAGGER_SLOTS * slot);
167360
+ }
167361
+ /** Which window a wall-clock instant falls in. */
167362
+ function windowIndex(at, intervalMs, offsetMs) {
167363
+ return Math.floor((at - offsetMs) / intervalMs);
167364
+ }
167365
+ /** How long before a window's end the wake is allowed to fire. */
167366
+ function wakeLeadMs(intervalMs) {
167367
+ return Math.min(MAX_WAKE_LEAD_MS, Math.floor(intervalMs / 10));
167368
+ }
167369
+ function decideBatteryRefresh(input) {
167370
+ if (input.intervalMs <= 0) return {
167371
+ kind: "skip",
167372
+ reason: "off"
167373
+ };
167374
+ const current = windowIndex(input.nowMs, input.intervalMs, input.offsetMs);
167375
+ if ((typeof input.lastFullRefreshAt === "number" && input.lastFullRefreshAt > 0 ? windowIndex(input.lastFullRefreshAt, input.intervalMs, input.offsetMs) : Number.NEGATIVE_INFINITY) >= current) return {
167376
+ kind: "skip",
167377
+ reason: "done"
167378
+ };
167379
+ if (input.attemptsInWindow >= 2) return {
167380
+ kind: "skip",
167381
+ reason: "attempts-exhausted"
167382
+ };
167383
+ if (input.lastAttemptAt !== void 0 && input.attemptsInWindow > 0 && input.nowMs - input.lastAttemptAt < 6e5) return {
167384
+ kind: "skip",
167385
+ reason: "backoff"
167386
+ };
167387
+ if (input.awake) return { kind: "refresh-awake" };
167388
+ const windowEnd = input.offsetMs + (current + 1) * input.intervalMs;
167389
+ if (input.nowMs < windowEnd - wakeLeadMs(input.intervalMs)) return {
167390
+ kind: "skip",
167391
+ reason: "asleep-before-deadline"
167392
+ };
167393
+ return { kind: "wake-then-refresh" };
167394
+ }
167395
+ var BatteryHourlyRefresh = class {
167396
+ deps;
167397
+ inFlight = null;
167398
+ attemptWindow = Number.NEGATIVE_INFINITY;
167399
+ attempts = 0;
167400
+ lastAttemptAt;
167401
+ constructor(deps) {
167402
+ this.deps = deps;
167403
+ }
167404
+ /**
167405
+ * The periodic check. Resolves `true` when a refresh landed in this call.
167406
+ * Never throws.
167407
+ */
167408
+ async tick() {
167409
+ return this.consider("tick");
167410
+ }
167411
+ /**
167412
+ * The camera was just observed coming up (event, sleep poll, hub summary, a
167413
+ * confirmed wake). Resolves `true` when this call refreshed everything, so
167414
+ * the caller can skip the parts of its own fan-out the refresh already did.
167415
+ */
167416
+ async noteAwake() {
167417
+ return this.consider("wake-transition");
167418
+ }
167419
+ async consider(trigger) {
167420
+ if (this.inFlight !== null) return this.inFlight;
167421
+ const nowMs = this.deps.now();
167422
+ const intervalMs = this.deps.intervalMs();
167423
+ const offsetMs = intervalMs > 0 ? staggerOffsetMs(this.deps.deviceId, intervalMs) : 0;
167424
+ const window = intervalMs > 0 ? windowIndex(nowMs, intervalMs, offsetMs) : 0;
167425
+ if (window !== this.attemptWindow) {
167426
+ this.attemptWindow = window;
167427
+ this.attempts = 0;
167428
+ this.lastAttemptAt = void 0;
167429
+ }
167430
+ const decision = decideBatteryRefresh({
167431
+ nowMs,
167432
+ intervalMs,
167433
+ offsetMs,
167434
+ lastFullRefreshAt: this.deps.lastFullRefreshAt(),
167435
+ awake: trigger === "wake-transition" || this.deps.isAwake(),
167436
+ attemptsInWindow: this.attempts,
167437
+ lastAttemptAt: this.lastAttemptAt
167438
+ });
167439
+ if (decision.kind === "skip") {
167440
+ this.logSkip(decision.reason, trigger);
167441
+ return false;
167442
+ }
167443
+ this.attempts += 1;
167444
+ this.lastAttemptAt = nowMs;
167445
+ const run = this.run(decision.kind, trigger).finally(() => {
167446
+ this.inFlight = null;
167447
+ });
167448
+ this.inFlight = run;
167449
+ return run;
167450
+ }
167451
+ async run(kind, trigger) {
167452
+ const { logger, deviceId } = this.deps;
167453
+ try {
167454
+ if (kind === "wake-then-refresh") {
167455
+ logger.info("battery hourly refresh: not awake in the window — waking once", {
167456
+ tags: { deviceId },
167457
+ meta: { attempt: this.attempts }
167458
+ });
167459
+ if (!await this.deps.wake()) {
167460
+ logger.warn("battery hourly refresh: the wake did not bring the camera up", {
167461
+ tags: { deviceId },
167462
+ meta: {
167463
+ attempt: this.attempts,
167464
+ retryAfterMs: RETRY_BACKOFF_MS
167465
+ }
167466
+ });
167467
+ return false;
167468
+ }
167469
+ if (this.windowSatisfied()) return true;
167470
+ } else logger.info("battery hourly refresh: camera is awake — refreshing now, no wake", {
167471
+ tags: { deviceId },
167472
+ meta: {
167473
+ trigger,
167474
+ attempt: this.attempts
167475
+ }
167476
+ });
167477
+ const outcome = await this.deps.refreshAll();
167478
+ if (outcome.failed.length > 0) {
167479
+ logger.warn("battery hourly refresh incomplete — not stamped, retried at the next chance", {
167480
+ tags: { deviceId },
167481
+ meta: {
167482
+ failed: outcome.failed,
167483
+ attempt: this.attempts,
167484
+ retryAfterMs: RETRY_BACKOFF_MS
167485
+ }
167486
+ });
167487
+ return false;
167488
+ }
167489
+ const at = this.deps.now();
167490
+ this.deps.stamp(at);
167491
+ logger.info("battery hourly refresh complete", {
167492
+ tags: { deviceId },
167493
+ meta: {
167494
+ kind,
167495
+ trigger,
167496
+ at
167497
+ }
167498
+ });
167499
+ return true;
167500
+ } catch (err) {
167501
+ logger.warn("battery hourly refresh failed — retried at the next chance", {
167502
+ tags: { deviceId },
167503
+ meta: {
167504
+ error: err instanceof Error ? err.message : String(err),
167505
+ attempt: this.attempts,
167506
+ retryAfterMs: RETRY_BACKOFF_MS
167507
+ }
167508
+ });
167509
+ return false;
167510
+ }
167511
+ }
167512
+ windowSatisfied() {
167513
+ const intervalMs = this.deps.intervalMs();
167514
+ if (intervalMs <= 0) return false;
167515
+ const offsetMs = staggerOffsetMs(this.deps.deviceId, intervalMs);
167516
+ const last = this.deps.lastFullRefreshAt();
167517
+ if (typeof last !== "number" || last <= 0) return false;
167518
+ return windowIndex(last, intervalMs, offsetMs) >= windowIndex(this.deps.now(), intervalMs, offsetMs);
167519
+ }
167520
+ logSkip(reason, trigger) {
167521
+ if (reason === "backoff" || reason === "attempts-exhausted") {
167522
+ this.deps.logger.info("battery hourly refresh skipped", {
167523
+ tags: { deviceId: this.deps.deviceId },
167524
+ meta: {
167525
+ reason,
167526
+ trigger,
167527
+ attempts: this.attempts
167528
+ }
167529
+ });
167530
+ return;
167531
+ }
167532
+ this.deps.logger.debug("battery hourly refresh not due", {
167533
+ tags: { deviceId: this.deps.deviceId },
167534
+ meta: {
167535
+ reason,
167536
+ trigger
167537
+ }
167538
+ });
167539
+ }
167540
+ };
167541
+ //#endregion
167542
+ //#region src/battery-refresh-settings.ts
167543
+ var BATTERY_REFRESH_SECTION_ID = "battery-refresh";
167544
+ 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).`;
167545
+ /** Battery cameras only: a mains camera keeps its facts fresh on its own socket. */
167546
+ function buildBatteryRefreshDeviceSection(input) {
167547
+ if (!input.isBattery) return [];
167548
+ return [{
167549
+ id: BATTERY_REFRESH_SECTION_ID,
167550
+ tab: "general",
167551
+ title: "Scheduled refresh",
167552
+ description: "How often this battery camera is guaranteed to have been read, so a battery level never goes stale unseen.",
167553
+ columns: 1,
167554
+ fields: [{
167555
+ type: "number",
167556
+ key: BATTERY_REFRESH_MINUTES_KEY,
167557
+ label: "Refresh window (min)",
167558
+ description: BATTERY_REFRESH_MINUTES_DESCRIPTION,
167559
+ default: 60,
167560
+ min: 0,
167561
+ max: 240,
167562
+ step: 5
167563
+ }]
167564
+ }];
167565
+ }
167269
167566
  //#endregion
167270
- //#region src/battery-contact-expiry.ts
167271
- function shouldWakeForContact(input) {
167272
- if (input.hubChild) return false;
167273
- if (!input.sleeping) return false;
167274
- const last = input.lastContactAt;
167275
- if (typeof last !== "number" || last <= 0) return false;
167276
- return input.nowMs - last + input.tickMs >= input.unreachableAfterMs;
167567
+ //#region src/battery-snapshot-refresh.ts
167568
+ /** The wrapper may run on another node; a stale frame is hours old, skew is seconds. */
167569
+ var CLOCK_SKEW_TOLERANCE_MS = 6e4;
167570
+ async function refreshSnapshotCacheOnce(input) {
167571
+ const { deviceId, api, logger } = input;
167572
+ const startedAt = input.now();
167573
+ if (await api.getSnapshot({
167574
+ deviceId,
167575
+ force: true
167576
+ }) === null) {
167577
+ logger.warn("battery hourly refresh: the snapshot came back empty", { tags: { deviceId } });
167578
+ return false;
167579
+ }
167580
+ let rows;
167581
+ try {
167582
+ rows = await api.getSnapshotOverview({ deviceIds: [deviceId] });
167583
+ } catch (err) {
167584
+ logger.debug("battery hourly refresh: snapshot overview unreadable — counting the image", {
167585
+ tags: { deviceId },
167586
+ meta: { error: err instanceof Error ? err.message : String(err) }
167587
+ });
167588
+ return true;
167589
+ }
167590
+ const capturedAt = rows.find((row) => row.deviceId === deviceId)?.lastCapturedAt ?? null;
167591
+ if (capturedAt === null || capturedAt < startedAt - CLOCK_SKEW_TOLERANCE_MS) {
167592
+ logger.warn("battery hourly refresh: the snapshot cache did not move (a stale frame was served)", {
167593
+ tags: { deviceId },
167594
+ meta: {
167595
+ capturedAt,
167596
+ startedAt
167597
+ }
167598
+ });
167599
+ return false;
167600
+ }
167601
+ return true;
167277
167602
  }
167278
167603
  //#endregion
167279
167604
  //#region src/log-channels.ts
@@ -171276,7 +171601,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
171276
171601
  * passively checks sleep state, and short-circuits if sleeping. */
171277
171602
  batteryUpdateTimer = null;
171278
171603
  /** Single-flight gate so two interval fires can't overlap. */
171279
- batteryUpdateInFlight = null;
171280
171604
  /** Aux accessory references registered by spawned children
171281
171605
  * (siren / floodlight / pir). The parent's centralised align
171282
171606
  * method iterates these to refresh their slices on a single
@@ -171614,7 +171938,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
171614
171938
  * snapshot also drives the dispatch's known-good baseline.
171615
171939
  */
171616
171940
  async refreshParentSettingsSnapshot(slices, reason = "background") {
171617
- if (this.isBattery && this.sleeping && reason !== "post-write") {
171941
+ if (this.isBattery && this.sleeping && reason !== "post-write" && reason !== "scheduled") {
171618
171942
  this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: {
171619
171943
  deviceId: this.id,
171620
171944
  reason
@@ -172560,7 +172884,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
172560
172884
  tags: { deviceId: this.id },
172561
172885
  meta: { reason }
172562
172886
  });
172563
- return;
172887
+ return false;
172564
172888
  }
172565
172889
  let api;
172566
172890
  try {
@@ -172570,17 +172894,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
172570
172894
  tags: { deviceId: this.id },
172571
172895
  meta: { error: err instanceof Error ? err.message : String(err) }
172572
172896
  });
172573
- return;
172897
+ return false;
172574
172898
  }
172575
172899
  try {
172576
172900
  const info = await api.getBatteryInfo(this.getChannel());
172577
172901
  this.updateBatteryCache(info);
172578
172902
  await this.refreshNetworkLinkFromApi(api, "battery");
172903
+ return true;
172579
172904
  } catch (err) {
172580
172905
  this.ctx.logger.debug("battery refresh failed", {
172581
172906
  tags: { deviceId: this.id },
172582
172907
  meta: { error: err instanceof Error ? err.message : String(err) }
172583
172908
  });
172909
+ return false;
172584
172910
  }
172585
172911
  }
172586
172912
  /**
@@ -173826,6 +174152,18 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
173826
174152
  * proactive gate lets it through because `sleeping` is false here.
173827
174153
  * Mirrors the reference's `updateBatteryAndSnapshot`.
173828
174154
  */
174155
+ /** The scheduled refresh's snapshot step: true only when the cache really moved. */
174156
+ refreshSnapshotCache() {
174157
+ return refreshSnapshotCacheOnce({
174158
+ deviceId: this.id,
174159
+ logger: this.ctx.logger,
174160
+ now: () => Date.now(),
174161
+ api: {
174162
+ getSnapshot: (input) => this.ctx.api.snapshot.getSnapshot.query(input),
174163
+ getSnapshotOverview: (input) => this.ctx.api.snapshot.getSnapshotOverview.query({ deviceIds: [...input.deviceIds] })
174164
+ }
174165
+ });
174166
+ }
173829
174167
  async warmSnapshotOnWake() {
173830
174168
  try {
173831
174169
  await this.ctx.api.snapshot.getSnapshot.query({
@@ -176261,119 +176599,84 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
176261
176599
  this.alignAuxTimer = null;
176262
176600
  }
176263
176601
  }
176264
- /** Run one cycle of the battery-cam periodic refresh. Single-flight
176265
- * via `batteryUpdateInFlight`. Never throws. */
176266
- async runBatteryUpdate() {
176267
- if (this.batteryUpdateInFlight) return this.batteryUpdateInFlight;
176268
- const cycle = (async () => {
176602
+ batteryRefresh = null;
176603
+ getBatteryRefresh() {
176604
+ if (this.batteryRefresh === null) this.batteryRefresh = new BatteryHourlyRefresh({
176605
+ deviceId: this.id,
176606
+ logger: this.ctx.logger,
176607
+ now: () => Date.now(),
176608
+ isAwake: () => !this.sleeping,
176609
+ intervalMs: () => resolveBatteryRefreshIntervalMs(this.config.get("batteryRefreshMinutes")),
176610
+ lastFullRefreshAt: () => this.state.battery.lastFullRefreshAt,
176611
+ wake: async () => {
176612
+ if (!this.canProactivelyWake("scheduled-refresh")) return false;
176613
+ this.markWakeIssued();
176614
+ return this.wakeIfSleeping();
176615
+ },
176616
+ refreshAll: () => this.refreshEverythingNow(),
176617
+ stamp: (at) => {
176618
+ this.state.battery.lastFullRefreshAt = at;
176619
+ }
176620
+ });
176621
+ return this.batteryRefresh;
176622
+ }
176623
+ /**
176624
+ * Every fact and the snapshot, read while the camera is up. SERIALIZED (one
176625
+ * Baichuan round-trip at a time on the single BCUDP stream), and each step
176626
+ * names itself in `failed` instead of throwing, so one dead read neither
176627
+ * hides the others nor stamps the window as done.
176628
+ */
176629
+ async refreshEverythingNow() {
176630
+ const failed = [];
176631
+ const step = async (name, run) => {
176269
176632
  try {
176270
- if (this.isBattery && this.sleeping) {
176271
- if (shouldWakeForContact({
176272
- nowMs: Date.now(),
176273
- lastContactAt: this.state.battery.lastContactAt,
176274
- sleeping: true,
176275
- hubChild: this.isHubChild(),
176276
- unreachableAfterMs: 216e5,
176277
- tickMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS
176278
- })) {
176279
- await this.wakeForContactRefresh();
176280
- return;
176281
- }
176282
- this.ctx.logger.debug("battery-update: cam sleeping — skipping cycle without connecting", { tags: { deviceId: this.id } });
176283
- return;
176284
- }
176285
- let api;
176286
- try {
176287
- api = await this.ensureApi();
176288
- } catch (err) {
176289
- this.ctx.logger.debug("battery-update: ensureApi failed — skipping cycle", {
176290
- tags: { deviceId: this.id },
176291
- meta: { error: err instanceof Error ? err.message : String(err) }
176292
- });
176293
- return;
176294
- }
176295
- let sleeping = false;
176296
- try {
176297
- sleeping = api.getSleepStatus({ channel: this.getChannel() }).state === "sleeping";
176298
- } catch (err) {
176299
- this.ctx.logger.debug("battery-update: getSleepStatus threw — skipping cycle", {
176300
- tags: { deviceId: this.id },
176301
- meta: { error: err instanceof Error ? err.message : String(err) }
176302
- });
176303
- return;
176304
- }
176305
- if (sleeping) {
176306
- this.ctx.logger.debug("battery-update: cam sleeping — skipping periodic refresh", { tags: { deviceId: this.id } });
176307
- return;
176308
- }
176309
- this.ctx.logger.info("battery-update: cam awake — running periodic refresh", {
176633
+ if (await run() === false) failed.push(name);
176634
+ } catch (err) {
176635
+ failed.push(name);
176636
+ this.ctx.logger.debug("battery hourly refresh: step threw", {
176310
176637
  tags: { deviceId: this.id },
176311
176638
  meta: {
176312
- isHubChild: this.isHubChild(),
176313
- auxAccessoryCount: this.auxAccessoryRefs.size
176639
+ step: name,
176640
+ error: err instanceof Error ? err.message : String(err)
176314
176641
  }
176315
176642
  });
176316
- await this.refreshBatteryFromApi("periodic").catch(() => {});
176317
- await this.alignAuxDevicesState("periodic").catch(() => {});
176318
- await this.refreshParentSettingsSnapshot().catch(() => {});
176319
- } finally {
176320
- this.batteryUpdateInFlight = null;
176321
176643
  }
176322
- })();
176323
- this.batteryUpdateInFlight = cycle;
176324
- return cycle;
176644
+ };
176645
+ await step("battery", () => this.refreshBatteryFromApi("demand"));
176646
+ await step("device-info", async () => {
176647
+ await this.populateMetadataFromFirmware(await this.ensureApi());
176648
+ });
176649
+ await step("cap-slices", async () => {
176650
+ await this.warmCapSlicesOnWake({ force: true });
176651
+ });
176652
+ await step("aux-accessories", () => this.alignAuxDevicesState("wake"));
176653
+ await step("settings-snapshot", () => this.refreshParentSettingsSnapshot(void 0, "scheduled"));
176654
+ await step("snapshot", () => this.refreshSnapshotCache());
176655
+ return { failed };
176325
176656
  }
176326
176657
  /**
176327
- * D355: wake a standalone battery camera ONCE because its last passive
176328
- * contact is about to expire, and refresh every slice while it is up.
176329
- * Bounded by `canProactivelyWake` (the shared cooldown) and by the fact
176330
- * that a wake that answers resets the silence. A wake that fails is left
176331
- * alone: the silence then reaches the budget and `deriveBatteryPresence`
176332
- * says `unreachable` — for the first time on evidence.
176658
+ * Run one scheduled-refresh check. Never throws; costs nothing unless a
176659
+ * refresh is due (see {@link decideBatteryRefresh}).
176333
176660
  */
176334
- async wakeForContactRefresh() {
176335
- if (!this.canProactivelyWake("contact-expiry")) return;
176336
- this.markWakeIssued();
176337
- const lastContactAt = this.state.battery.lastContactAt ?? 0;
176338
- const silenceMs = Date.now() - lastContactAt;
176339
- this.ctx.logger.info("battery contact expiring — waking once to refresh every slice (D355)", {
176340
- tags: { deviceId: this.id },
176341
- meta: {
176342
- silenceMs,
176343
- budgetMs: BATTERY_UNREACHABLE_AFTER_MS
176344
- }
176345
- });
176346
- try {
176347
- await this.ensureApi();
176348
- } catch (err) {
176349
- this.ctx.logger.warn("battery contact wake FAILED — silence continues and the camera will read unreachable at the budget", {
176350
- tags: { deviceId: this.id },
176351
- meta: {
176352
- silenceMs,
176353
- error: err instanceof Error ? err.message : String(err)
176354
- }
176355
- });
176356
- return;
176357
- }
176358
- await this.refreshBatteryFromApi("demand").catch(() => {});
176359
- await this.warmCapSlicesOnWake().catch(() => {});
176360
- await this.alignAuxDevicesState("wake").catch(() => {});
176361
- await this.warmSnapshotOnWake().catch(() => {});
176661
+ async runBatteryUpdate() {
176662
+ if (!this.isBattery) return;
176663
+ await this.getBatteryRefresh().tick();
176362
176664
  }
176363
- /** Arm the periodic battery-cam refresh timer. No-op for wired cams
176364
- * (their state is kept fresh by the 10 s aux-align poll and push
176365
- * events). Idempotent. */
176665
+ /** Arm the scheduled-refresh check. No-op for wired cams (their state is
176666
+ * kept fresh by the 10 s aux-align poll and push events). Idempotent. */
176366
176667
  startBatteryUpdatePolling() {
176367
176668
  if (this.batteryUpdateTimer) return;
176368
176669
  if (!this.isBattery) return;
176369
176670
  this.batteryUpdateTimer = setInterval(() => {
176370
176671
  this.runBatteryUpdate().catch(() => {});
176371
- }, ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS);
176672
+ }, BATTERY_REFRESH_TICK_MS);
176372
176673
  if (typeof this.batteryUpdateTimer.unref === "function") this.batteryUpdateTimer.unref();
176373
- this.ctx.logger.info("battery-update: periodic refresh armed", {
176674
+ this.ctx.logger.info("battery-update: scheduled full refresh armed", {
176374
176675
  tags: { deviceId: this.id },
176375
176676
  meta: {
176376
- intervalMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS,
176677
+ tickMs: BATTERY_REFRESH_TICK_MS,
176678
+ intervalMs: resolveBatteryRefreshIntervalMs(this.config.get("batteryRefreshMinutes")),
176679
+ lastFullRefreshAt: this.state.battery.lastFullRefreshAt ?? null,
176377
176680
  isHubChild: this.isHubChild()
176378
176681
  }
176379
176682
  });
@@ -176469,22 +176772,18 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
176469
176772
  meta: { error: err instanceof Error ? err.message : String(err) }
176470
176773
  });
176471
176774
  }
176472
- await this.warmCapSlicesOnWake();
176473
- await this.warmSnapshotOnWake();
176474
- await this.alignAuxDevicesState("wake");
176475
- await this.refreshParentSettingsSnapshot();
176775
+ if (!(this.isBattery ? await this.getBatteryRefresh().noteAwake().catch(() => false) : false)) {
176776
+ await this.warmCapSlicesOnWake();
176777
+ await this.warmSnapshotOnWake();
176778
+ await this.alignAuxDevicesState("wake");
176779
+ await this.refreshParentSettingsSnapshot();
176780
+ }
176476
176781
  await this.refreshClipCatalogOnWake();
176477
176782
  }
176478
176783
  static PING_INTERVAL_MS = 3e4;
176479
176784
  static PING_MAX_FAILURES = 3;
176480
176785
  static EVENT_CHECK_INTERVAL_MS = 6e4;
176481
176786
  static EVENT_STALE_THRESHOLD_MS = 10 * 6e4;
176482
- /** Battery-cam periodic refresh cadence. The timer fires on this
176483
- * interval, passively checks sleep state, and only runs the
176484
- * refresh when the cam is already awake — so it never forces a
176485
- * wake. Mirrors the reference's `batteryUpdateIntervalMinutes`
176486
- * default (60 minutes). */
176487
- static BATTERY_UPDATE_INTERVAL_MS = 60 * 6e4;
176488
176787
  /**
176489
176788
  * Arm the connection-liveness ping watchdog and the event-watchdog.
176490
176789
  * Battery cams skip the ping watchdog — they keep `setIdleDisconnect=true`
@@ -177668,7 +177967,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
177668
177967
  ...buildClipRefreshDeviceSection({
177669
177968
  isHubChild: this.isHubChild(),
177670
177969
  isBattery: this.isBattery
177671
- })
177970
+ }),
177971
+ ...buildBatteryRefreshDeviceSection({ isBattery: this.isBattery })
177672
177972
  ]
177673
177973
  };
177674
177974
  const emailSnap = cache?.emailConfigSnapshot;
@@ -177689,6 +177989,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
177689
177989
  emailScheduleEnabled: emailSnap?.taskEnabled ?? false,
177690
177990
  clipCatalogRefreshMinutes: this.config.get("clipCatalogRefreshMinutes"),
177691
177991
  clipCatalogForcedWake: this.config.get("clipCatalogForcedWake") ?? false,
177992
+ batteryRefreshMinutes: this.config.get("batteryRefreshMinutes"),
177692
177993
  debugGeneral: this.config.get("debugGeneral") ?? false,
177693
177994
  debugSocketLogs: this.config.get("debugSocketLogs") ?? [],
177694
177995
  motionEnabled: this.config.get("motionEnabled") ?? motionSnap.enabled ?? true,