@camstack/addon-provider-reolink 1.2.82 → 1.2.83

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.mjs CHANGED
@@ -7194,7 +7194,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7194
7194
  * still gives the event loop a chance to drain — useful for breaking
7195
7195
  * up tight async loops without changing call-site semantics.
7196
7196
  */
7197
- function sleep$1(ms) {
7197
+ function sleep$2(ms) {
7198
7198
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7199
7199
  }
7200
7200
  var EncodeProfileSchema = object({
@@ -12406,6 +12406,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
12406
12406
  filePath: string(),
12407
12407
  content: string()
12408
12408
  })) }), { auth: "admin" });
12409
+ /**
12410
+ * Identity — preserves literal types for downstream inference.
12411
+ *
12412
+ * The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
12413
+ * TypeScript does not widen each entry's literal `kind`/`auth` fields to
12414
+ * the broader unions declared on `CustomActionSpec`'s default generics.
12415
+ * Shape validity is enforced separately by the `customAction(...)` helper
12416
+ * whose return type is already a `CustomActionSpec<...>`.
12417
+ */
12418
+ function defineCustomActions(spec) {
12419
+ return spec;
12420
+ }
12421
+ function customAction(input, output, options) {
12422
+ return {
12423
+ input,
12424
+ output,
12425
+ kind: options?.kind ?? "query",
12426
+ auth: options?.auth ?? "protected",
12427
+ scope: options?.scope ?? { kind: "system" },
12428
+ ...options?.caller ? { caller: "required" } : {}
12429
+ };
12430
+ }
12409
12431
  function deviceCustomAction(input, output, options) {
12410
12432
  return {
12411
12433
  input,
@@ -13390,6 +13412,35 @@ var deviceProviderCapability = {
13390
13412
  name: string(),
13391
13413
  type: string()
13392
13414
  }))),
13415
+ /**
13416
+ * Tear down and reconstruct ONE device in place from its persisted rows —
13417
+ * touching no other device this provider owns.
13418
+ *
13419
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
13420
+ * migrated numbers: after `swapIds` the runner's live instance still
13421
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
13422
+ * registrations and its log tags), and a live object cannot be renumbered.
13423
+ * Before this method the only flush was restarting the whole owning addon
13424
+ * — which took every camera the provider owns down with it (28 devices
13425
+ * for one migrated camera, measured 2026-09-04, and the morning of the
13426
+ * same day ~27 devices' native caps did not come back on their own).
13427
+ *
13428
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
13429
+ * that changes. The reply carries the id the device answers on NOW.
13430
+ * Implemented once in `BaseDeviceProvider` — decommission the live
13431
+ * instance (if any), then re-create from the persisted row: the same
13432
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
13433
+ * An RPC, never an event: a dropped event would leave the runner writing
13434
+ * against the wrong camera (D8).
13435
+ *
13436
+ * Construction can dial hardware, and the migrated source is
13437
+ * characteristically dead — the timeout covers a full activate window
13438
+ * rather than the 60 s default.
13439
+ */
13440
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
13441
+ kind: "mutation",
13442
+ timeoutMs: 3 * 6e4
13443
+ }),
13393
13444
  supportsDiscovery: method(object({}), boolean()),
13394
13445
  /**
13395
13446
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13717,7 +13768,8 @@ method(object({
13717
13768
  targetId: number()
13718
13769
  }), MigrateDeviceResultSchema, {
13719
13770
  kind: "mutation",
13720
- auth: "admin"
13771
+ auth: "admin",
13772
+ timeoutMs: 12 * 6e4
13721
13773
  }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13722
13774
  deviceId: number(),
13723
13775
  name: string()
@@ -33517,6 +33569,147 @@ var BaseDevice = class {
33517
33569
  }
33518
33570
  };
33519
33571
  /**
33572
+ * Delays before retry rounds 1..N — the round count IS the bound.
33573
+ * 10 s catches "the hub was busy for a moment"; the full schedule
33574
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
33575
+ * per attempt) covers a device-manager lock held for minutes — the
33576
+ * 2026-09-04 outage's migration hold was ~3.5 min.
33577
+ */
33578
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
33579
+ 1e4,
33580
+ 3e4,
33581
+ 9e4
33582
+ ];
33583
+ /** Abortable sleep — resolves early (never rejects) on abort. */
33584
+ function sleep$1(ms, signal) {
33585
+ return new Promise((resolve) => {
33586
+ if (signal.aborted) {
33587
+ resolve();
33588
+ return;
33589
+ }
33590
+ const onAbort = () => {
33591
+ clearTimeout(timer);
33592
+ resolve();
33593
+ };
33594
+ const timer = setTimeout(() => {
33595
+ signal.removeEventListener("abort", onAbort);
33596
+ resolve();
33597
+ }, ms);
33598
+ timer.unref?.();
33599
+ signal.addEventListener("abort", onAbort, { once: true });
33600
+ });
33601
+ }
33602
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
33603
+ * not reject (callers wrap their own try/catch). */
33604
+ async function runWithConcurrency(items, width, fn) {
33605
+ const queue = [...items];
33606
+ const laneCount = Math.max(1, Math.min(width, queue.length));
33607
+ const lane = async () => {
33608
+ for (;;) {
33609
+ const item = queue.shift();
33610
+ if (item === void 0) return;
33611
+ await fn(item);
33612
+ }
33613
+ };
33614
+ await Promise.all(Array.from({ length: laneCount }, lane));
33615
+ }
33616
+ var DeviceRestoreRetryScheduler = class {
33617
+ #logger;
33618
+ #attempt;
33619
+ #onPermanentFailure;
33620
+ #delaysMs;
33621
+ #concurrency;
33622
+ #now;
33623
+ #abort = new AbortController();
33624
+ constructor(options) {
33625
+ this.#logger = options.logger;
33626
+ this.#attempt = options.attempt;
33627
+ this.#onPermanentFailure = options.onPermanentFailure;
33628
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
33629
+ this.#concurrency = options.concurrency ?? 4;
33630
+ this.#now = options.now ?? Date.now;
33631
+ }
33632
+ /** Stop retrying (shutdown). Pending entries are NOT marked
33633
+ * permanently failed — the next boot restores them from disk. */
33634
+ cancel() {
33635
+ this.#abort.abort();
33636
+ }
33637
+ /**
33638
+ * Run the bounded retry rounds. Resolves when every entry has either
33639
+ * restored, been marked permanently failed, or the scheduler was
33640
+ * cancelled. Never rejects.
33641
+ */
33642
+ async run(initialFailures) {
33643
+ let pending = initialFailures.map((failure) => ({
33644
+ saved: failure.saved,
33645
+ lastError: failure.error,
33646
+ attempts: 1
33647
+ }));
33648
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
33649
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
33650
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
33651
+ if (this.#abort.signal.aborted) break;
33652
+ pending = await this.#runRound(pending, round);
33653
+ }
33654
+ if (this.#abort.signal.aborted) return [];
33655
+ const terminal = pending.map((entry) => ({
33656
+ deviceId: entry.saved.id,
33657
+ stableId: entry.saved.stableId,
33658
+ type: String(entry.saved.type),
33659
+ attempts: entry.attempts,
33660
+ lastError: entry.lastError,
33661
+ failedAt: this.#now()
33662
+ }));
33663
+ for (const failure of terminal) this.#onPermanentFailure(failure);
33664
+ return terminal;
33665
+ }
33666
+ /** One retry round: parents first (phase 0), then hub-adopted
33667
+ * children (phase 1) — a child's attempt depends on its parent
33668
+ * having landed, exactly like the initial two-pass restore. */
33669
+ async #runRound(pending, round) {
33670
+ const next = [];
33671
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
33672
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
33673
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
33674
+ if (this.#abort.signal.aborted) {
33675
+ next.push(entry);
33676
+ return;
33677
+ }
33678
+ const attemptNo = entry.attempts + 1;
33679
+ try {
33680
+ await this.#attempt(entry.saved);
33681
+ this.#logger.info("Device restored on retry", {
33682
+ tags: {
33683
+ deviceId: entry.saved.id,
33684
+ stableId: entry.saved.stableId
33685
+ },
33686
+ meta: { attempt: attemptNo }
33687
+ });
33688
+ } catch (err) {
33689
+ const lastError = err instanceof Error ? err.message : String(err);
33690
+ const remainingRetries = this.#delaysMs.length - (round + 1);
33691
+ this.#logger.warn("Device restore retry failed", {
33692
+ tags: {
33693
+ deviceId: entry.saved.id,
33694
+ stableId: entry.saved.stableId
33695
+ },
33696
+ meta: {
33697
+ attempt: attemptNo,
33698
+ remainingRetries,
33699
+ error: lastError
33700
+ }
33701
+ });
33702
+ next.push({
33703
+ saved: entry.saved,
33704
+ lastError,
33705
+ attempts: attemptNo
33706
+ });
33707
+ }
33708
+ });
33709
+ return next;
33710
+ }
33711
+ };
33712
+ /**
33520
33713
  * Convert an IDevice to the flat DeviceSummary shape expected by the
33521
33714
  * device-provider cap router. Shared across all providers.
33522
33715
  */
@@ -33565,6 +33758,7 @@ var BaseDeviceProvider = class extends BaseAddon {
33565
33758
  }];
33566
33759
  }
33567
33760
  async onShutdown() {
33761
+ this.cancelRestoreRetries();
33568
33762
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
33569
33763
  for (const device of devices) try {
33570
33764
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -33582,9 +33776,16 @@ var BaseDeviceProvider = class extends BaseAddon {
33582
33776
  async start() {}
33583
33777
  async stop() {}
33584
33778
  async getStatus() {
33779
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
33780
+ const summary = this.restoreFailureSummary();
33781
+ if (summary === null) return {
33782
+ connected: true,
33783
+ deviceCount: all.length
33784
+ };
33585
33785
  return {
33586
33786
  connected: true,
33587
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
33787
+ deviceCount: all.length,
33788
+ error: summary
33588
33789
  };
33589
33790
  }
33590
33791
  async getDevices() {
@@ -33674,8 +33875,137 @@ var BaseDeviceProvider = class extends BaseAddon {
33674
33875
  };
33675
33876
  }
33676
33877
  async restoreDevices(savedDevices) {
33677
- await this.onRestoreDevices(savedDevices);
33678
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33878
+ const report = await this.onRestoreDevices(savedDevices);
33879
+ if (savedDevices.length === 0) return;
33880
+ if (report && report.failedCount > 0) {
33881
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33882
+ return;
33883
+ }
33884
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33885
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33886
+ }
33887
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33888
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33889
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33890
+ * never re-stampede full-width while the initial pass does (D167). */
33891
+ restoreRetryConcurrency = 4;
33892
+ _restoreRetryScheduler = null;
33893
+ _restoreRetryCompletion = null;
33894
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33895
+ /** Settles when the background retry rounds finish (or `null` when
33896
+ * nothing failed). Exposed for tests and subclass diagnostics —
33897
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33898
+ * with the devices that restored, and a late success is announced
33899
+ * through the `native-cap-change` → `updateCaps` path. */
33900
+ get restoreRetryCompletion() {
33901
+ return this._restoreRetryCompletion;
33902
+ }
33903
+ /** Devices that exhausted the retry bound this process lifetime. */
33904
+ get permanentRestoreFailures() {
33905
+ return [...this._permanentRestoreFailures.values()];
33906
+ }
33907
+ /** One-line operator-facing summary for `getStatus().error`, or
33908
+ * `null` when every device restored. */
33909
+ restoreFailureSummary() {
33910
+ if (this._permanentRestoreFailures.size === 0) return null;
33911
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33912
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33913
+ }
33914
+ cancelRestoreRetries() {
33915
+ this._restoreRetryScheduler?.cancel();
33916
+ this._restoreRetryScheduler = null;
33917
+ }
33918
+ recordPermanentRestoreFailure(failure) {
33919
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33920
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33921
+ tags: {
33922
+ deviceId: failure.deviceId,
33923
+ stableId: failure.stableId
33924
+ },
33925
+ meta: {
33926
+ type: failure.type,
33927
+ attempts: failure.attempts,
33928
+ error: failure.lastError
33929
+ }
33930
+ });
33931
+ }
33932
+ scheduleRestoreRetries(failures, attempt) {
33933
+ const scheduler = new DeviceRestoreRetryScheduler({
33934
+ logger: this.ctx.logger,
33935
+ delaysMs: this.restoreRetryDelaysMs,
33936
+ concurrency: this.restoreRetryConcurrency,
33937
+ attempt,
33938
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33939
+ });
33940
+ this._restoreRetryScheduler = scheduler;
33941
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33942
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33943
+ });
33944
+ }
33945
+ /**
33946
+ * Tear down and reconstruct ONE device from its persisted rows — the
33947
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33948
+ * and no other device this provider owns is disturbed.
33949
+ *
33950
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33951
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33952
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33953
+ * whatever number the row carries NOW. The teardown is `decommission` —
33954
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33955
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33956
+ * the boot restore's own `create()` path, including its pass 2: first-class
33957
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33958
+ * parent by the cascade and must be re-created explicitly, because only
33959
+ * accessory children come back through `getAccessoryChildren()`.
33960
+ *
33961
+ * Reloading an accessory child directly is refused (no device class) —
33962
+ * reload its parent instead.
33963
+ */
33964
+ async reloadDevice(input) {
33965
+ const { stableId } = input;
33966
+ const devices = this.ctx.kernel.devices;
33967
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33968
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33969
+ if (live) await devices.decommission(live.id);
33970
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33971
+ addonId: this.addonId,
33972
+ stableId
33973
+ });
33974
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33975
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33976
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33977
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33978
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33979
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33980
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33981
+ for (const row of rows) {
33982
+ if (row.parentDeviceId !== id) continue;
33983
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33984
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33985
+ if (!ChildClass) continue;
33986
+ try {
33987
+ await devices.create(row.stableId, ChildClass, {}, id);
33988
+ } catch (err) {
33989
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33990
+ tags: {
33991
+ deviceId: row.id,
33992
+ stableId: row.stableId
33993
+ },
33994
+ meta: {
33995
+ parentDeviceId: id,
33996
+ error: err instanceof Error ? err.message : String(err)
33997
+ }
33998
+ });
33999
+ }
34000
+ }
34001
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
34002
+ tags: { deviceId: id },
34003
+ meta: {
34004
+ stableId,
34005
+ type: meta.type
34006
+ }
34007
+ });
34008
+ return { deviceId: id };
33679
34009
  }
33680
34010
  /**
33681
34011
  * Restore devices from persisted state. Two-pass:
@@ -33701,55 +34031,108 @@ var BaseDeviceProvider = class extends BaseAddon {
33701
34031
  * accessory-spawn flow handles via the parent's
33702
34032
  * `getAccessoryChildren()`. Override only when the default doesn't
33703
34033
  * fit.
34034
+ *
34035
+ * A row that fails either pass is NOT terminal (D347): it is handed
34036
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
34037
+ * Only after the bound is exhausted is the device marked permanently
34038
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
34039
+ * `getStatus().error`.
33704
34040
  */
33705
34041
  async onRestoreDevices(savedDevices) {
33706
34042
  const restored = /* @__PURE__ */ new Set();
34043
+ const failures = [];
34044
+ const attemptRestore = async (saved) => {
34045
+ if (restored.has(saved.id)) return;
34046
+ const Class = this.deviceClasses[saved.type];
34047
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34048
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
34049
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
34050
+ restored.add(saved.id);
34051
+ };
33707
34052
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
33708
34053
  const restoreOne = async (saved) => {
33709
- const Class = this.deviceClasses[saved.type];
33710
- if (!Class) {
34054
+ if (!this.deviceClasses[saved.type]) {
33711
34055
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
33712
- tags: { stableId: saved.stableId },
34056
+ tags: {
34057
+ deviceId: saved.id,
34058
+ stableId: saved.stableId
34059
+ },
33713
34060
  meta: { type: saved.type }
33714
34061
  });
33715
34062
  return;
33716
34063
  }
33717
34064
  try {
33718
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
33719
- restored.add(saved.id);
34065
+ await attemptRestore(saved);
33720
34066
  } catch (err) {
33721
- this.ctx.logger.warn("Failed to restore device", {
33722
- tags: { stableId: saved.stableId },
34067
+ const error = err instanceof Error ? err.message : String(err);
34068
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
34069
+ tags: {
34070
+ deviceId: saved.id,
34071
+ stableId: saved.stableId
34072
+ },
33723
34073
  meta: {
33724
34074
  type: saved.type,
33725
- error: err instanceof Error ? err.message : String(err)
34075
+ attempt: 1,
34076
+ error
33726
34077
  }
33727
34078
  });
34079
+ failures.push({
34080
+ saved,
34081
+ error
34082
+ });
33728
34083
  }
33729
34084
  };
33730
34085
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
34086
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
33731
34087
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
33732
34088
  for (const saved of childRows) {
33733
- const Class = this.deviceClasses[saved.type];
33734
- if (!Class) continue;
34089
+ if (!this.deviceClasses[saved.type]) continue;
33735
34090
  if (saved.parentDeviceId === null) continue;
33736
- if (!restored.has(saved.parentDeviceId)) continue;
33737
- try {
33738
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33739
- restored.add(saved.id);
33740
- } catch (err) {
33741
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
34091
+ if (restored.has(saved.parentDeviceId)) {
34092
+ try {
34093
+ await attemptRestore(saved);
34094
+ } catch (err) {
34095
+ const error = err instanceof Error ? err.message : String(err);
34096
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
34097
+ tags: {
34098
+ deviceId: saved.id,
34099
+ stableId: saved.stableId,
34100
+ parentDeviceId: saved.parentDeviceId
34101
+ },
34102
+ meta: {
34103
+ type: saved.type,
34104
+ attempt: 1,
34105
+ error
34106
+ }
34107
+ });
34108
+ failures.push({
34109
+ saved,
34110
+ error
34111
+ });
34112
+ }
34113
+ continue;
34114
+ }
34115
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
34116
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
33742
34117
  tags: {
34118
+ deviceId: saved.id,
33743
34119
  stableId: saved.stableId,
33744
34120
  parentDeviceId: saved.parentDeviceId
33745
34121
  },
33746
- meta: {
33747
- type: saved.type,
33748
- error: err instanceof Error ? err.message : String(err)
33749
- }
34122
+ meta: { type: saved.type }
33750
34123
  });
34124
+ failures.push({
34125
+ saved,
34126
+ error: `parent device ${saved.parentDeviceId} not restored`
34127
+ });
34128
+ continue;
33751
34129
  }
33752
34130
  }
34131
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
34132
+ return {
34133
+ restoredCount: restored.size,
34134
+ failedCount: failures.length
34135
+ };
33753
34136
  }
33754
34137
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
33755
34138
  toSummary(device) {
@@ -35664,6 +36047,12 @@ Object.freeze({
35664
36047
  addonId: null,
35665
36048
  access: "view"
35666
36049
  },
36050
+ "deviceProvider.reloadDevice": {
36051
+ capName: "device-provider",
36052
+ capScope: "system",
36053
+ addonId: null,
36054
+ access: "create"
36055
+ },
35667
36056
  "deviceProvider.start": {
35668
36057
  capName: "device-provider",
35669
36058
  capScope: "system",
@@ -179088,7 +179477,7 @@ ${xml}`);
179088
179477
  * @returns Test results for all stream types and profiles
179089
179478
  */
179090
179479
  async testChannelStreams(channel, logger) {
179091
- const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs");
179480
+ const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
179092
179481
  return await testChannelStreams({
179093
179482
  api: this,
179094
179483
  channel: this.normalizeChannel(channel),
@@ -179104,7 +179493,7 @@ ${xml}`);
179104
179493
  * @returns Complete diagnostics for all channels and streams
179105
179494
  */
179106
179495
  async collectMultifocalDiagnostics(logger) {
179107
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs");
179496
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
179108
179497
  return await collectMultifocalDiagnostics({
179109
179498
  api: this,
179110
179499
  logger
@@ -230672,6 +231061,453 @@ var IntercomFailureReport = class {
230672
231061
  /** The process-wide instance every camera in this addon notes into. */
230673
231062
  var intercomFailureReport = new IntercomFailureReport();
230674
231063
  //#endregion
231064
+ //#region src/snapshot-freshness.ts
231065
+ /**
231066
+ * The snapshot groups the freshness panel reports on, with the Baichuan
231067
+ * read behind each. Order is the display order.
231068
+ */
231069
+ var SNAPSHOT_GROUPS = [
231070
+ {
231071
+ key: "imageSnapshot",
231072
+ label: "Image (getVideoInput)"
231073
+ },
231074
+ {
231075
+ key: "motionSnapshot",
231076
+ label: "Motion (getMotionAlarm)"
231077
+ },
231078
+ {
231079
+ key: "aiSensitivitySnapshot",
231080
+ label: "AI sensitivity (getAiDetectionFull)"
231081
+ },
231082
+ {
231083
+ key: "encSnapshot",
231084
+ label: "Encoder (getEnc)"
231085
+ },
231086
+ {
231087
+ key: "encOptionsSnapshot",
231088
+ label: "Encoder options (getEncOptions)"
231089
+ },
231090
+ {
231091
+ key: "maskSnapshot",
231092
+ label: "Privacy mask (getMask)"
231093
+ },
231094
+ {
231095
+ key: "audioNoiseSnapshot",
231096
+ label: "Audio noise (getAudioNoise)"
231097
+ },
231098
+ {
231099
+ key: "autoFocusSnapshot",
231100
+ label: "Auto-focus (getAutoFocus)"
231101
+ },
231102
+ {
231103
+ key: "netPortSnapshot",
231104
+ label: "Network ports (getNetPort)"
231105
+ },
231106
+ {
231107
+ key: "ntpSnapshot",
231108
+ label: "NTP (getNtp)"
231109
+ },
231110
+ {
231111
+ key: "systemGeneralSnapshot",
231112
+ label: "System general (getSystemGeneral)"
231113
+ },
231114
+ {
231115
+ key: "osdSnapshot",
231116
+ label: "OSD overlay (getOsd)"
231117
+ },
231118
+ {
231119
+ key: "ledSnapshot",
231120
+ label: "LEDs (getIrLights)"
231121
+ },
231122
+ {
231123
+ key: "pirSnapshot",
231124
+ label: "PIR (getPirInfo)"
231125
+ },
231126
+ {
231127
+ key: "autoRebootSnapshot",
231128
+ label: "Auto reboot (getAutoReboot)"
231129
+ },
231130
+ {
231131
+ key: "emailConfigSnapshot",
231132
+ label: "Email/SMTP (getEmail)"
231133
+ },
231134
+ {
231135
+ key: "capOptionsSnapshot",
231136
+ label: "Cap option probes (getOptions)"
231137
+ }
231138
+ ];
231139
+ /**
231140
+ * Merge freshness stamps for the snapshot keys a persist actually wrote.
231141
+ * Returns a NEW map (immutability) — previous stamps for untouched groups
231142
+ * survive, written groups are stamped `now`. Call this from the same
231143
+ * `setAll` that writes the snapshots, with exactly the keys being written:
231144
+ * a failed probe writes no snapshot and therefore gets no stamp.
231145
+ */
231146
+ function stampSnapshotFreshness(previous, writtenKeys, now) {
231147
+ const stamped = { ...previous };
231148
+ for (const key of writtenKeys) stamped[key] = now;
231149
+ return stamped;
231150
+ }
231151
+ /**
231152
+ * Resolve the age of one snapshot group from the cache. Sources, in order:
231153
+ * 1. `snapshotFetchedAt[key]` — the generic stamp map;
231154
+ * 2. a group-embedded stamp where one already existed before the map
231155
+ * (`osdSnapshot.fetchedAt`, `emailConfigSnapshot.lastReadAt`,
231156
+ * newest `capOptionsSnapshot[*].fetchedAt`);
231157
+ * 3. otherwise: the group is present but of unknown age.
231158
+ * An absent group is `never` — not-yet-read must never look like read.
231159
+ */
231160
+ function resolveSnapshotAge(cache, key, now) {
231161
+ if ((cache === void 0 ? void 0 : Reflect.get(cache, key)) === void 0) return { state: "never" };
231162
+ const mapStamp = cache?.snapshotFetchedAt?.[key];
231163
+ const stamp = typeof mapStamp === "number" ? mapStamp : embeddedStamp(cache, key);
231164
+ if (typeof stamp !== "number") return { state: "unknown" };
231165
+ return {
231166
+ state: "known",
231167
+ fetchedAt: stamp,
231168
+ ageMs: Math.max(0, now - stamp)
231169
+ };
231170
+ }
231171
+ /** Pre-map stamps some groups already carried; kept as fallback so a legacy
231172
+ * cache written by today's OSD fix still reports a real age. */
231173
+ function embeddedStamp(cache, key) {
231174
+ if (key === "osdSnapshot") {
231175
+ const v = cache?.osdSnapshot?.fetchedAt;
231176
+ return typeof v === "number" ? v : void 0;
231177
+ }
231178
+ if (key === "emailConfigSnapshot") {
231179
+ const v = cache?.emailConfigSnapshot?.lastReadAt;
231180
+ return typeof v === "number" ? v : void 0;
231181
+ }
231182
+ if (key === "capOptionsSnapshot") {
231183
+ const stamps = Object.values(cache?.capOptionsSnapshot ?? {}).map((e) => e?.fetchedAt).filter((v) => typeof v === "number");
231184
+ return stamps.length > 0 ? Math.max(...stamps) : void 0;
231185
+ }
231186
+ }
231187
+ /** Human age: "12 s ago", "3 m ago", "5 h ago", "12 d ago". */
231188
+ function formatSnapshotAge(ageMs) {
231189
+ const s = Math.floor(ageMs / 1e3);
231190
+ if (s < 60) return `${s} s ago`;
231191
+ const m = Math.floor(s / 60);
231192
+ if (m < 60) return `${m} m ago`;
231193
+ const h = Math.floor(m / 60);
231194
+ if (h < 48) return `${h} h ago`;
231195
+ return `${Math.floor(h / 24)} d ago`;
231196
+ }
231197
+ /** One display line for a group. Unknown age is SAID, never smoothed over. */
231198
+ function formatSnapshotAgeLine(label, age) {
231199
+ switch (age.state) {
231200
+ case "never": return `${label}: never read`;
231201
+ case "unknown": return `${label}: age unknown (recorded before per-snapshot freshness tracking)`;
231202
+ case "known": return `${label}: read ${formatSnapshotAge(age.ageMs)} (${new Date(age.fetchedAt).toLocaleString()})`;
231203
+ }
231204
+ }
231205
+ /**
231206
+ * Read-only "Snapshot freshness" section (advanced tab, next to Debug).
231207
+ * Lists every snapshot group with its own age so "is CamStack's belief
231208
+ * current?" is answerable per fact, not per cache. Purely informational:
231209
+ * it triggers no reads — refresh stays on the existing operator-triggered
231210
+ * "Refresh from camera" action and the event-driven paths.
231211
+ */
231212
+ function buildSnapshotFreshnessSection(cache, opts) {
231213
+ const lines = SNAPSHOT_GROUPS.map(({ key, label }) => formatSnapshotAgeLine(label, resolveSnapshotAge(cache, key, opts.now)));
231214
+ const probedAt = cache?.probedAt;
231215
+ const header = typeof probedAt === "number" ? `Feature probe: ${new Date(probedAt).toLocaleString()}` : "Feature probe: never recorded";
231216
+ return {
231217
+ id: "snapshotFreshness",
231218
+ tab: "advanced",
231219
+ title: "Snapshot freshness",
231220
+ description: "When each cached camera reading was last fetched. Groups without a stamp were persisted before per-snapshot tracking — their age is unknown, not fresh. Use \"Refresh from camera\" (General) to re-read an awake camera.",
231221
+ columns: 1,
231222
+ fields: [{
231223
+ type: "info",
231224
+ key: "snapshotFreshness",
231225
+ label: "Per-snapshot read times",
231226
+ content: `${opts.sleeping ? "Camera is asleep — no group can refresh until it wakes; a sleeping battery camera is never woken to read settings.\n" : ""}${header}\n${lines.join("\n")}`
231227
+ }]
231228
+ };
231229
+ }
231230
+ var RawReadSliceSchema = _enum([
231231
+ "image",
231232
+ "motion",
231233
+ "ai",
231234
+ "enc",
231235
+ "encOptions",
231236
+ "mask",
231237
+ "audioNoise",
231238
+ "autofocus",
231239
+ "netPort",
231240
+ "ntp",
231241
+ "systemGeneral",
231242
+ "osd",
231243
+ "led",
231244
+ "pir",
231245
+ "autoReboot"
231246
+ ]);
231247
+ /**
231248
+ * The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
231249
+ * in lockstep in both directions. Anything not in this map — email/SMTP
231250
+ * (`getEmail` echoes the camera's SMTP credentials), users, sessions, any
231251
+ * `set*` — cannot be requested.
231252
+ */
231253
+ var RAW_READ_CATALOG = {
231254
+ image: {
231255
+ command: "getVideoInput",
231256
+ snapshotKey: "imageSnapshot",
231257
+ invoke: (api, channel) => api.getVideoInput(channel)
231258
+ },
231259
+ motion: {
231260
+ command: "getMotionAlarm",
231261
+ snapshotKey: "motionSnapshot",
231262
+ invoke: (api, channel) => api.getMotionAlarm(channel)
231263
+ },
231264
+ ai: {
231265
+ command: "getAiDetectTypes + getAiDetectionFull (per type)",
231266
+ snapshotKey: "aiSensitivitySnapshot",
231267
+ invoke: async (api, channel) => {
231268
+ const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
231269
+ const perType = {};
231270
+ for (const aiType of detectTypes ?? []) try {
231271
+ perType[aiType] = await api.getAiDetectionFull(channel, aiType);
231272
+ } catch (err) {
231273
+ perType[aiType] = { error: err instanceof Error ? err.message : String(err) };
231274
+ }
231275
+ return {
231276
+ detectTypes: detectTypes ?? [],
231277
+ perType
231278
+ };
231279
+ }
231280
+ },
231281
+ enc: {
231282
+ command: "getEnc",
231283
+ snapshotKey: "encSnapshot",
231284
+ invoke: (api, channel) => api.getEnc(channel)
231285
+ },
231286
+ encOptions: {
231287
+ command: "getEncOptions",
231288
+ snapshotKey: "encOptionsSnapshot",
231289
+ invoke: (api, channel) => api.getEncOptions(channel)
231290
+ },
231291
+ mask: {
231292
+ command: "getMask",
231293
+ snapshotKey: "maskSnapshot",
231294
+ invoke: (api, channel) => api.getMask(channel)
231295
+ },
231296
+ audioNoise: {
231297
+ command: "getAudioNoise",
231298
+ snapshotKey: "audioNoiseSnapshot",
231299
+ invoke: (api, channel) => api.getAudioNoise(channel)
231300
+ },
231301
+ autofocus: {
231302
+ command: "getAutoFocus",
231303
+ snapshotKey: "autoFocusSnapshot",
231304
+ invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
231305
+ },
231306
+ netPort: {
231307
+ command: "getNetPort",
231308
+ snapshotKey: "netPortSnapshot",
231309
+ invoke: (api) => api.getNetPort()
231310
+ },
231311
+ ntp: {
231312
+ command: "getNtp",
231313
+ snapshotKey: "ntpSnapshot",
231314
+ invoke: (api) => api.getNtp()
231315
+ },
231316
+ systemGeneral: {
231317
+ command: "getSystemGeneral",
231318
+ snapshotKey: "systemGeneralSnapshot",
231319
+ invoke: (api) => api.getSystemGeneral()
231320
+ },
231321
+ osd: {
231322
+ command: "getOsd",
231323
+ snapshotKey: "osdSnapshot",
231324
+ invoke: (api, channel) => api.getOsd(channel)
231325
+ },
231326
+ led: {
231327
+ command: "getIrLights",
231328
+ snapshotKey: "ledSnapshot",
231329
+ invoke: (api, channel) => api.getIrLights(channel)
231330
+ },
231331
+ pir: {
231332
+ command: "getPirInfo",
231333
+ snapshotKey: "pirSnapshot",
231334
+ invoke: (api, channel) => api.getPirInfo(channel)
231335
+ },
231336
+ autoReboot: {
231337
+ command: "getAutoReboot",
231338
+ snapshotKey: "autoRebootSnapshot",
231339
+ invoke: (api) => api.getAutoReboot()
231340
+ }
231341
+ };
231342
+ /** What CamStack currently believes about the slice, with its own age. */
231343
+ var BelievedStateSchema = object({
231344
+ /** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
231345
+ snapshot: unknown(),
231346
+ /** deviceCache field the projection lives in. */
231347
+ snapshotKey: string(),
231348
+ /** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
231349
+ age: union([
231350
+ object({ state: literal("never") }),
231351
+ object({ state: literal("unknown") }),
231352
+ object({
231353
+ state: literal("known"),
231354
+ fetchedAt: number().int(),
231355
+ ageMs: number().int().nonnegative()
231356
+ })
231357
+ ])
231358
+ });
231359
+ var RawReadResultSchema = discriminatedUnion("ok", [object({
231360
+ ok: literal(true),
231361
+ deviceId: number().int(),
231362
+ slice: RawReadSliceSchema,
231363
+ command: string(),
231364
+ readAt: number().int(),
231365
+ /** The library's response, unprojected, as plain JSON. */
231366
+ camera: unknown(),
231367
+ believed: BelievedStateSchema
231368
+ }), object({
231369
+ ok: literal(false),
231370
+ deviceId: number().int(),
231371
+ slice: RawReadSliceSchema,
231372
+ reason: _enum([
231373
+ "sleeping",
231374
+ "login-failed",
231375
+ "read-failed"
231376
+ ]),
231377
+ message: string(),
231378
+ /** The believed state is still reported — a refusal must not hide
231379
+ * what CamStack is currently serving. */
231380
+ believed: BelievedStateSchema
231381
+ })]);
231382
+ var RawReadInputSchema = object({
231383
+ deviceId: number().int().nonnegative(),
231384
+ slice: RawReadSliceSchema
231385
+ });
231386
+ function believedState(cache, entry, now) {
231387
+ const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
231388
+ return {
231389
+ snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
231390
+ snapshotKey: entry.snapshotKey,
231391
+ age
231392
+ };
231393
+ }
231394
+ /** Force a lib response to plain JSON: drops functions/undefined/prototypes,
231395
+ * guarantees the payload is serializable across the tRPC boundary. */
231396
+ function toPlainJson(value) {
231397
+ if (value === void 0) return null;
231398
+ return JSON.parse(JSON.stringify(value));
231399
+ }
231400
+ /**
231401
+ * Execute one raw read. Order matters:
231402
+ * 1. sleep gate (refuse loudly — logging in would BE the wake);
231403
+ * 2. login;
231404
+ * 3. the allow-listed read;
231405
+ * and every outcome — refusal included — carries the believed state so the
231406
+ * operator always sees both sides of the comparison.
231407
+ */
231408
+ async function performRawRead(slice, deps) {
231409
+ const entry = RAW_READ_CATALOG[slice];
231410
+ const believed = believedState(deps.cache, entry, deps.now);
231411
+ if (deps.sleeping) {
231412
+ deps.logger.info("reolink raw read refused — battery cam is sleeping", {
231413
+ tags: { deviceId: deps.deviceId },
231414
+ meta: { slice }
231415
+ });
231416
+ return {
231417
+ ok: false,
231418
+ deviceId: deps.deviceId,
231419
+ slice,
231420
+ reason: "sleeping",
231421
+ message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231422
+ believed
231423
+ };
231424
+ }
231425
+ let api;
231426
+ try {
231427
+ api = await deps.getApi();
231428
+ } catch (err) {
231429
+ const message = err instanceof Error ? err.message : String(err);
231430
+ deps.logger.info("reolink raw read login failed", {
231431
+ tags: { deviceId: deps.deviceId },
231432
+ meta: {
231433
+ slice,
231434
+ error: message
231435
+ }
231436
+ });
231437
+ return {
231438
+ ok: false,
231439
+ deviceId: deps.deviceId,
231440
+ slice,
231441
+ reason: "login-failed",
231442
+ message,
231443
+ believed
231444
+ };
231445
+ }
231446
+ try {
231447
+ const payload = await entry.invoke(api, deps.channel);
231448
+ deps.logger.info("reolink raw read served", {
231449
+ tags: { deviceId: deps.deviceId },
231450
+ meta: {
231451
+ slice,
231452
+ command: entry.command
231453
+ }
231454
+ });
231455
+ return {
231456
+ ok: true,
231457
+ deviceId: deps.deviceId,
231458
+ slice,
231459
+ command: entry.command,
231460
+ readAt: deps.now,
231461
+ camera: toPlainJson(payload),
231462
+ believed
231463
+ };
231464
+ } catch (err) {
231465
+ const message = err instanceof Error ? err.message : String(err);
231466
+ deps.logger.info("reolink raw read failed", {
231467
+ tags: { deviceId: deps.deviceId },
231468
+ meta: {
231469
+ slice,
231470
+ command: entry.command,
231471
+ error: message
231472
+ }
231473
+ });
231474
+ return {
231475
+ ok: false,
231476
+ deviceId: deps.deviceId,
231477
+ slice,
231478
+ reason: "read-failed",
231479
+ message,
231480
+ believed
231481
+ };
231482
+ }
231483
+ }
231484
+ //#endregion
231485
+ //#region src/debug-actions.ts
231486
+ /**
231487
+ * provider-reolink — customActions catalog (admin-only debug surface).
231488
+ *
231489
+ * Dispatched via `POST addons.custom
231490
+ * {addonId:'provider-reolink', action:'debugRawRead', input:{deviceId, slice}}`.
231491
+ *
231492
+ * Why an addon custom action and not a device action: `deviceManager.
231493
+ * runDeviceAction` (the `refresh-settings` / `refresh-sessions` shape) is
231494
+ * mounted `protected` and its dispatcher does not enforce the per-action
231495
+ * `auth` — any authenticated user could call it. `addons.custom` is the one
231496
+ * operator surface that enforces per-action `auth: 'admin'` server-side
231497
+ * (`ensureCustomActionAuth`) AND validates the addon's output against this
231498
+ * catalog. A debug surface that returns raw camera payloads is admin-only,
231499
+ * so it lives here (same wiring as `addon-benchmark` / `addon-notifiers`).
231500
+ *
231501
+ * `kind: 'query'` states the contract — the handler is read-only by
231502
+ * construction (see `raw-read.ts`: the slice enum maps onto an allow-list
231503
+ * of lib `get*` calls; no write is reachable). The `addons.custom` mount
231504
+ * itself is a single mutation procedure, so callers still POST.
231505
+ */
231506
+ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawReadInputSchema, RawReadResultSchema, {
231507
+ kind: "query",
231508
+ auth: "admin"
231509
+ }) });
231510
+ //#endregion
230675
231511
  //#region src/log-channels.ts
230676
231512
  /**
230677
231513
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230884,6 +231720,130 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
230884
231720
  });
230885
231721
  }
230886
231722
  //#endregion
231723
+ //#region src/osd-settings-section.ts
231724
+ /**
231725
+ * Camera snapshot → UNKNOWN (`null`). The last arm is the point: `?? true`
231726
+ * here is what rendered "the camera did not tell us" as an enabled overlay.
231727
+ * The snapshot is the ONLY input — the config keeps no copy to consult.
231728
+ */
231729
+ function resolveOsdValues(snapshot) {
231730
+ return {
231731
+ osdChannelEnabled: snapshot?.channelEnabled ?? null,
231732
+ osdChannelName: snapshot?.channelName ?? "",
231733
+ osdTimeEnabled: snapshot?.timeEnabled ?? null,
231734
+ osdWatermark: snapshot?.watermark ?? null
231735
+ };
231736
+ }
231737
+ /**
231738
+ * Reader-side staleness (D224). A snapshot persisted before freshness
231739
+ * tracking has no `fetchedAt` and is treated as stale — that is exactly the
231740
+ * adoption-frozen reading this module exists to retire.
231741
+ */
231742
+ function isOsdSnapshotStale(snapshot, now) {
231743
+ return now - (snapshot?.fetchedAt ?? 0) > OPERATOR_WRITTEN_STALE_MS;
231744
+ }
231745
+ var OSD_UNKNOWN_DESCRIPTION = "Not reported by the camera yet — the current state is unknown.";
231746
+ var NEVER_WOKEN_SUFFIX = "a sleeping battery camera is never woken to read settings.";
231747
+ /**
231748
+ * A boolean overlay toggle. Unknown (`null`) renders disabled with an honest
231749
+ * description — the switch component shows `Boolean(null)` = off, and the
231750
+ * disabled + "not reported" pairing keeps that from reading as a claim.
231751
+ */
231752
+ function osdToggle(key, label, value, baseDescription) {
231753
+ const unknown = value === null;
231754
+ const description = unknown ? baseDescription ? `${baseDescription} ${OSD_UNKNOWN_DESCRIPTION}` : OSD_UNKNOWN_DESCRIPTION : baseDescription;
231755
+ return {
231756
+ type: "boolean",
231757
+ key,
231758
+ label,
231759
+ default: value,
231760
+ style: "switch",
231761
+ ...description !== void 0 ? { description } : {},
231762
+ ...unknown ? { disabled: true } : {}
231763
+ };
231764
+ }
231765
+ /**
231766
+ * State banner shown when the operator is NOT looking at a current reading:
231767
+ * - camera asleep and the mirror is stale → say what is shown and when it
231768
+ * was read, and that the camera is not woken for this;
231769
+ * - no reading has ever landed → say the toggles are unknown.
231770
+ * A fresh mirror on an awake camera renders no banner — serve-and-revalidate
231771
+ * keeps it honest silently.
231772
+ */
231773
+ function buildOsdStateBanner(snapshot, opts) {
231774
+ const stale = isOsdSnapshotStale(snapshot, opts.now);
231775
+ if (opts.sleeping && stale) return {
231776
+ type: "info",
231777
+ key: "osdSnapshotState",
231778
+ label: "OSD state",
231779
+ variant: "warning",
231780
+ content: snapshot === void 0 ? `Camera is asleep and its OSD state has never been read — the toggles below are unknown until it wakes; ${NEVER_WOKEN_SUFFIX}` : snapshot.fetchedAt !== void 0 ? `Camera is asleep — showing the OSD state last read ${new Date(snapshot.fetchedAt).toLocaleString()}. It refreshes when the camera wakes; ${NEVER_WOKEN_SUFFIX}` : `Camera is asleep — showing the last known OSD state (age unknown). It refreshes when the camera wakes; ${NEVER_WOKEN_SUFFIX}`
231781
+ };
231782
+ if (snapshot === void 0) return {
231783
+ type: "info",
231784
+ key: "osdSnapshotState",
231785
+ label: "OSD state",
231786
+ variant: "warning",
231787
+ content: "OSD state has not been read from this camera yet — unknown toggles are disabled until a read succeeds."
231788
+ };
231789
+ return null;
231790
+ }
231791
+ /**
231792
+ * A position value for display. Verbatim in quotes when the camera reported
231793
+ * one — an empty string IS a report and shows as `""` — and "not reported"
231794
+ * only when `getOsd` genuinely carried no string (tri-state, D337).
231795
+ */
231796
+ function formatObservedPos(pos) {
231797
+ return typeof pos === "string" ? `"${pos}"` : "not reported";
231798
+ }
231799
+ /**
231800
+ * Read-only view of the overlay positions the camera reported. Deliberately
231801
+ * NOT a control: the `pos` vocabulary is unknown (loose string, no observed
231802
+ * values yet), so this field exists to make it observable per camera. A
231803
+ * position control can be designed once real values have been collected —
231804
+ * see the "osd overlay positions observed" info log in the probe.
231805
+ */
231806
+ function buildOsdPositionsField(snapshot) {
231807
+ return {
231808
+ type: "info",
231809
+ key: "osdPositions",
231810
+ label: "Overlay positions",
231811
+ content: `Positions are kept exactly as configured on the camera and are read-only here.\nChannel name: ${formatObservedPos(snapshot?.channelPos)}\nTimestamp: ${formatObservedPos(snapshot?.timePos)}`
231812
+ };
231813
+ }
231814
+ /**
231815
+ * The "OSD overlay" section (writable via `setOsd`, cmd_id 25). One
231816
+ * read-modify-write `setOsd(OsdConfig)` push covers all four fields — the
231817
+ * dispatcher reads the current `OsdConfig` (`getOsd`) first so the stored
231818
+ * overlay positions (`pos`) survive untouched. Position itself is
231819
+ * camera-pixel/preset specific and only OBSERVED here, never written.
231820
+ */
231821
+ function buildOsdSection(snapshot, values, opts) {
231822
+ const banner = buildOsdStateBanner(snapshot, opts);
231823
+ return {
231824
+ id: "osd",
231825
+ tab: "image",
231826
+ title: "OSD overlay",
231827
+ description: "On-screen overlays burned into the video by the camera. Pushed via `SetOsd` (cmd_id 25). Overlay positions are kept as configured on the camera — only the toggles, channel-name text, and watermark change here.",
231828
+ columns: 2,
231829
+ fields: [
231830
+ ...banner ? [banner] : [],
231831
+ osdToggle("osdChannelEnabled", "Channel name overlay", values.osdChannelEnabled, void 0),
231832
+ {
231833
+ type: "text",
231834
+ key: "osdChannelName",
231835
+ label: "Channel name",
231836
+ description: "Text shown in the channel-name overlay.",
231837
+ default: values.osdChannelName,
231838
+ placeholder: "Front door"
231839
+ },
231840
+ osdToggle("osdTimeEnabled", "Timestamp overlay", values.osdTimeEnabled, void 0),
231841
+ osdToggle("osdWatermark", "Watermark", values.osdWatermark, "The Reolink logo watermark overlay."),
231842
+ buildOsdPositionsField(snapshot)
231843
+ ]
231844
+ };
231845
+ }
231846
+ //#endregion
230887
231847
  //#region src/raw-state.ts
230888
231848
  /**
230889
231849
  * Source tag for every raw-state blob this provider emits.
@@ -231118,7 +232078,7 @@ var SirenAccessory = class extends BaseDevice {
231118
232078
  this.ctx.logger.info("siren onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231119
232079
  try {
231120
232080
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231121
- await sleep$1(1e3);
232081
+ await sleep$2(1e3);
231122
232082
  } catch (err) {
231123
232083
  this.ctx.logger.warn("siren wake before initial probe failed — proceeding anyway", {
231124
232084
  tags: { deviceId: this.id },
@@ -231540,7 +232500,7 @@ var FloodlightAccessory = class extends BaseDevice {
231540
232500
  this.ctx.logger.info("floodlight onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231541
232501
  try {
231542
232502
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231543
- await sleep$1(1e3);
232503
+ await sleep$2(1e3);
231544
232504
  } catch (err) {
231545
232505
  this.ctx.logger.warn("floodlight wake before initial probe failed — proceeding anyway", {
231546
232506
  tags: { deviceId: this.id },
@@ -231937,7 +232897,7 @@ var PirAccessory = class extends BaseDevice {
231937
232897
  this.ctx.logger.info("pir onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231938
232898
  try {
231939
232899
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231940
- await sleep$1(1e3);
232900
+ await sleep$2(1e3);
231941
232901
  } catch (err) {
231942
232902
  this.ctx.logger.warn("pir wake before initial probe failed — proceeding anyway", {
231943
232903
  tags: { deviceId: this.id },
@@ -233611,7 +234571,24 @@ var reolinkCameraSchema = object({
233611
234571
  channelEnabled: boolean().nullable().optional(),
233612
234572
  channelName: string().optional(),
233613
234573
  timeEnabled: boolean().nullable().optional(),
233614
- watermark: boolean().nullable().optional()
234574
+ watermark: boolean().nullable().optional(),
234575
+ /**
234576
+ * Overlay positions exactly as `getOsd` reported them — READ-ONLY
234577
+ * observations, never written (the `setOsd` read-modify-write
234578
+ * preserves the camera's stored `pos`). Tri-state: `null` means
234579
+ * the camera did not report a string; an empty string is a real
234580
+ * report. Captured so a vocabulary of live values can be
234581
+ * collected before any position control is designed.
234582
+ */
234583
+ channelPos: string().nullable().optional(),
234584
+ timePos: string().nullable().optional(),
234585
+ /**
234586
+ * Wall-clock ms when this slice last landed from the camera.
234587
+ * Absent on snapshots persisted before freshness tracking —
234588
+ * `isOsdSnapshotStale` treats those as stale, which retires the
234589
+ * adoption-frozen readings this stamp was added for (D224).
234590
+ */
234591
+ fetchedAt: number().int().optional()
233615
234592
  }).optional(),
233616
234593
  /**
233617
234594
  * Snapshot of the camera's status + doorbell LED state from
@@ -233650,7 +234627,18 @@ var reolinkCameraSchema = object({
233650
234627
  hour: number().int().nullable().optional(),
233651
234628
  minute: number().int().nullable().optional(),
233652
234629
  supported: boolean().optional()
233653
- }).optional()
234630
+ }).optional(),
234631
+ /**
234632
+ * Per-snapshot freshness stamps (D224 generalised, D346): wall-clock
234633
+ * ms when each `*Snapshot` group in this cache was last WRITTEN from
234634
+ * a camera read, keyed by the group's field name (`encSnapshot`,
234635
+ * `osdSnapshot`, …). Written only by the persist sites that write
234636
+ * the group itself (`stampSnapshotFreshness`) — a failed probe
234637
+ * writes no snapshot and gets no stamp. A group with no entry here
234638
+ * (legacy persist) is of UNKNOWN age and must never read as fresh;
234639
+ * `resolveSnapshotAge` owns the tri-state.
234640
+ */
234641
+ snapshotFetchedAt: record(string(), number().int()).optional()
233654
234642
  }).loose().optional(),
233655
234643
  /**
233656
234644
  * Generic Baichuan debug logs. Forwarded as `DebugOptions.general`
@@ -233808,18 +234796,6 @@ var reolinkCameraSchema = object({
233808
234796
  statusLedEnabled: boolean().optional(),
233809
234797
  doorbellLedEnabled: boolean().optional(),
233810
234798
  /**
233811
- * On-screen display (OSD) overlay — pushed via `setOsd` (cmd_id 25,
233812
- * read via 26). One read-modify-write `OsdConfig` push covers all four
233813
- * fields so the camera keeps its stored overlay positions (`pos`)
233814
- * untouched — only the enable flags, channel name text, and watermark
233815
- * toggle change. Position is camera-pixel/preset specific (`pos` is a
233816
- * loose string, not a clean enum), so it is intentionally NOT exposed.
233817
- */
233818
- osdChannelEnabled: boolean().optional(),
233819
- osdChannelName: string().max(64).optional(),
233820
- osdTimeEnabled: boolean().optional(),
233821
- osdWatermark: boolean().optional(),
233822
- /**
233823
234799
  * Audio output volume — pushed via `setAudioCfg` (cmd_id=265,
233824
234800
  * read via 264). Reolink-spec range 0..100.
233825
234801
  */
@@ -235018,6 +235994,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235018
235994
  * legacy firmware that doesn't support some endpoints). */
235019
235995
  lastSettingsSnapshotRetryAt = 0;
235020
235996
  static SETTINGS_SNAPSHOT_RETRY_MIN_MS = 6e4;
235997
+ /** Debounce timestamp for the OSD serve-and-revalidate kick from
235998
+ * `getSettingsUISchema` (D224). Separate from
235999
+ * `lastSettingsSnapshotRetryAt` so an incomplete-cache retry and an
236000
+ * OSD staleness revalidate never suppress each other. */
236001
+ lastOsdRevalidateKickAt = 0;
235021
236002
  /** True when any settings-snapshot field that drives a UI section
235022
236003
  * is missing from the persisted cache. Drives the on-demand retry
235023
236004
  * in `getSettingsUISchema`. */
@@ -235027,16 +236008,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235027
236008
  return cache.encSnapshot === void 0 || cache.encOptionsSnapshot === void 0 || cache.maskSnapshot === void 0 || cache.audioNoiseSnapshot === void 0 || cache.autoFocusSnapshot === void 0;
235028
236009
  }
235029
236010
  /**
235030
- * Probe `getVideoInput` + `getMotionAlarm` and persist into the
235031
- * `deviceCache` snapshots. Fires once on `onCreated`; future
235032
- * settings opens read straight from the persisted snapshot. Image
235033
- * is readonly in the UI (lib lacks `setVideoInput`); motion is
235034
- * writable via `setMotionAlarm` so its snapshot also drives the
235035
- * dispatch's known-good baseline.
236011
+ * Probe the parent-settings endpoints (`getVideoInput`, `getMotionAlarm`,
236012
+ * `getOsd`, …) and persist into the `deviceCache` snapshots. Runs on
236013
+ * activation, on battery wake transitions, after a settings save (scoped
236014
+ * to the changed slices), via the manual "Refresh from camera" action,
236015
+ * and from `getSettingsUISchema`'s serve-and-revalidate kicks settings
236016
+ * opens serve the persisted snapshot immediately and revalidate stale
236017
+ * slices behind the form (D224). Image is readonly in the UI (lib lacks
236018
+ * `setVideoInput`); motion is writable via `setMotionAlarm` so its
236019
+ * snapshot also drives the dispatch's known-good baseline.
235036
236020
  */
235037
236021
  async refreshParentSettingsSnapshot(slices) {
235038
236022
  if (this.isBattery && this.sleeping) {
235039
- this.ctx.logger.debug("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
236023
+ this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
235040
236024
  return;
235041
236025
  }
235042
236026
  let api;
@@ -235189,14 +236173,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235189
236173
  }
235190
236174
  if (want("osd")) try {
235191
236175
  const osd = await api.getOsd(channel);
236176
+ const channelPos = typeof osd.osdChannel?.pos === "string" ? osd.osdChannel.pos : null;
236177
+ const timePos = typeof osd.osdTime?.pos === "string" ? osd.osdTime.pos : null;
236178
+ const prevOsdSnapshot = this.config.get("deviceCache")?.osdSnapshot;
236179
+ if (prevOsdSnapshot?.channelPos !== channelPos || prevOsdSnapshot?.timePos !== timePos) this.ctx.logger.info("reolink osd overlay positions observed", {
236180
+ tags: { deviceId: this.id },
236181
+ meta: {
236182
+ channelPos,
236183
+ timePos
236184
+ }
236185
+ });
235192
236186
  cacheUpdate.osdSnapshot = {
235193
236187
  channelEnabled: typeof osd.osdChannel?.enable === "number" ? osd.osdChannel.enable === 1 : null,
235194
236188
  channelName: typeof osd.osdChannel?.name === "string" ? osd.osdChannel.name : void 0,
235195
236189
  timeEnabled: typeof osd.osdTime?.enable === "number" ? osd.osdTime.enable === 1 : null,
235196
- watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null
236190
+ watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null,
236191
+ channelPos,
236192
+ timePos,
236193
+ fetchedAt: Date.now()
235197
236194
  };
235198
236195
  } catch (err) {
235199
- this.ctx.logger.debug("reolink getOsd probe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
236196
+ this.ctx.logger.info("reolink getOsd probe failed OSD snapshot left stale", {
236197
+ tags: { deviceId: this.id },
236198
+ meta: { error: err instanceof Error ? err.message : String(err) }
236199
+ });
235200
236200
  }
235201
236201
  if (want("led")) try {
235202
236202
  const ledState = (await api.getIrLights(channel))?.body?.LedState;
@@ -235253,6 +236253,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235253
236253
  }
235254
236254
  if (Object.keys(cacheUpdate).length === 0) return;
235255
236255
  const current = this.config.get("deviceCache") ?? {};
236256
+ const writtenSnapshotKeys = Object.keys(cacheUpdate).filter((k) => k.endsWith("Snapshot"));
236257
+ if (writtenSnapshotKeys.length > 0) cacheUpdate.snapshotFetchedAt = stampSnapshotFreshness(current.snapshotFetchedAt, writtenSnapshotKeys, Date.now());
235256
236258
  try {
235257
236259
  await this.config.setAll({ deviceCache: {
235258
236260
  ...current,
@@ -235266,6 +236268,28 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235266
236268
  });
235267
236269
  }
235268
236270
  /**
236271
+ * Admin-only raw-read debug surface (D346): return the library's response
236272
+ * for one snapshot slice UNPROJECTED, next to the persisted snapshot and
236273
+ * its freshness, so "what does the camera report vs what does CamStack
236274
+ * believe" is answerable without Baichuan tracing. Read-only by
236275
+ * construction (the slice enum maps onto an allow-list of lib `get*`
236276
+ * calls — see `raw-read.ts`), writes nothing back to the cache, and the
236277
+ * sleep gate runs BEFORE any login: a sleeping battery cam refuses loudly
236278
+ * (logging in IS the wake) and still reports the believed state.
236279
+ * Dispatched by the provider's `debugRawRead` custom action.
236280
+ */
236281
+ async debugRawRead(slice) {
236282
+ return performRawRead(slice, {
236283
+ deviceId: this.id,
236284
+ channel: this.getChannel(),
236285
+ sleeping: this.isBattery && this.sleeping,
236286
+ getApi: () => this.ensureApi(),
236287
+ cache: this.config.get("deviceCache"),
236288
+ logger: this.ctx.logger,
236289
+ now: Date.now()
236290
+ });
236291
+ }
236292
+ /**
235269
236293
  * Declare on-camera accessory child devices the kernel should
235270
236294
  * auto-spawn after `onCreated`. Each entry maps directly to a
235271
236295
  * concrete accessory class via the existing `createAccessoryDevice`
@@ -235349,7 +236373,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235349
236373
  waitAfterWakeMs: 2500,
235350
236374
  attempts: 2
235351
236375
  });
235352
- await sleep$1(1500);
236376
+ await sleep$2(1500);
235353
236377
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
235354
236378
  return true;
235355
236379
  } catch (err) {
@@ -235464,7 +236488,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235464
236488
  sendNickname: email.sendNickname,
235465
236489
  ...task ? { taskEnabled: task.enable === 1 } : {},
235466
236490
  lastReadAt: Date.now()
235467
- }
236491
+ },
236492
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["emailConfigSnapshot"], Date.now())
235468
236493
  } });
235469
236494
  this.ctx.logger.info("email-push: read camera email config", {
235470
236495
  tags: { deviceId: this.id },
@@ -235646,7 +236671,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235646
236671
  waitAfterWakeMs: 2500,
235647
236672
  attempts: 2
235648
236673
  });
235649
- await sleep$1(1500);
236674
+ await sleep$2(1500);
235650
236675
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
235651
236676
  const CONFIRM_TIMEOUT_MS = 1e4;
235652
236677
  const CONFIRM_POLL_MS = 1e3;
@@ -235676,7 +236701,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235676
236701
  break;
235677
236702
  }
235678
236703
  }
235679
- await sleep$1(CONFIRM_POLL_MS);
236704
+ await sleep$2(CONFIRM_POLL_MS);
235680
236705
  }
235681
236706
  const confirmSource = parent !== null ? "hub-summary" : "sleep-poll";
235682
236707
  if (observedAwake && this.commitSleepState(false, confirmSource)) {
@@ -235908,7 +236933,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235908
236933
  async watchHubChildAwake(parent) {
235909
236934
  const deadline = Date.now() + 45e3;
235910
236935
  while (Date.now() < deadline) {
235911
- await sleep$1(5e3);
236936
+ await sleep$2(5e3);
235912
236937
  try {
235913
236938
  if ((await (await parent.getApi()).getNvrChannelsSummary({ channels: [this.getChannel()] })).devices.find((d) => d.channel === this.getChannel())?.sleeping === false) {
235914
236939
  if (this.commitSleepState(false, "hub-summary")) {
@@ -236871,7 +237896,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236871
237896
  value,
236872
237897
  fetchedAt: Date.now()
236873
237898
  }
236874
- }
237899
+ },
237900
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["capOptionsSnapshot"], Date.now())
236875
237901
  } });
236876
237902
  } catch (err) {
236877
237903
  this.ctx.logger.debug("cap options persist failed", {
@@ -237936,7 +238962,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237936
238962
  this.ctx.logger.info("intercom: cam sleeping — waking up before talk session", { tags: { deviceId: this.id } });
237937
238963
  try {
237938
238964
  await api.wakeUp(channel, { waitAfterWakeMs: 2e3 });
237939
- await sleep$1(1e3);
238965
+ await sleep$2(1e3);
237940
238966
  } catch (err) {
237941
238967
  this.ctx.logger.warn("intercom: wakeUp failed — proceeding anyway", { meta: { error: err instanceof Error ? err.message : String(err) } });
237942
238968
  }
@@ -238236,13 +239262,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
238236
239262
  await api.setAutoFocus(channel, enabled ? 0 : 1);
238237
239263
  try {
238238
239264
  const a = (await api.getAutoFocus(channel, { timeoutMs: 1500 }))?.body?.AutoFocus;
238239
- if (a) await this.config.setAll({ deviceCache: {
238240
- ...this.config.get("deviceCache"),
238241
- autoFocusSnapshot: {
238242
- enabled: typeof a.disable === "number" ? a.disable === 0 : null,
238243
- supported: true
238244
- }
238245
- } });
239265
+ if (a) {
239266
+ const afCurrent = this.config.get("deviceCache");
239267
+ await this.config.setAll({ deviceCache: {
239268
+ ...afCurrent,
239269
+ autoFocusSnapshot: {
239270
+ enabled: typeof a.disable === "number" ? a.disable === 0 : null,
239271
+ supported: true
239272
+ },
239273
+ snapshotFetchedAt: stampSnapshotFreshness(afCurrent?.snapshotFetchedAt, ["autoFocusSnapshot"], Date.now())
239274
+ } });
239275
+ }
238246
239276
  } catch {}
238247
239277
  }
238248
239278
  };
@@ -239607,13 +240637,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
239607
240637
  const imgSnap = cache?.imageSnapshot ?? {};
239608
240638
  const netSnap = cache?.netPortSnapshot ?? {};
239609
240639
  const ntpSnap = cache?.ntpSnapshot ?? {};
240640
+ let kickedFullSnapshotRefresh = false;
239610
240641
  if (this.hasIncompleteSettingsCache()) {
239611
240642
  const now = Date.now();
239612
240643
  if (now - this.lastSettingsSnapshotRetryAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
239613
240644
  this.lastSettingsSnapshotRetryAt = now;
240645
+ kickedFullSnapshotRefresh = true;
239614
240646
  this.refreshParentSettingsSnapshot().catch(() => {});
239615
240647
  }
239616
240648
  }
240649
+ const osdSnap = cache?.osdSnapshot;
240650
+ if (!kickedFullSnapshotRefresh && isOsdSnapshotStale(osdSnap, Date.now())) {
240651
+ const now = Date.now();
240652
+ if (now - this.lastOsdRevalidateKickAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
240653
+ this.lastOsdRevalidateKickAt = now;
240654
+ this.refreshParentSettingsSnapshot(new Set(["osd"])).catch(() => {});
240655
+ }
240656
+ }
240657
+ const osdValues = resolveOsdValues(osdSnap);
239617
240658
  const sessSnap = this.sessionsSnapshot;
239618
240659
  const sessStale = sessSnap === null || Date.now() - sessSnap.ts > 6e4;
239619
240660
  if (!this.isBattery && sessStale) this.refreshSessionsSnapshot().catch((err) => {
@@ -240160,45 +241201,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240160
241201
  }] : []
240161
241202
  ]
240162
241203
  },
240163
- {
240164
- id: "osd",
240165
- tab: "image",
240166
- title: "OSD overlay",
240167
- description: "On-screen overlays burned into the video by the camera. Pushed via `SetOsd` (cmd_id 25). Overlay positions are kept as configured on the camera — only the toggles, channel-name text, and watermark change here.",
240168
- columns: 2,
240169
- fields: [
240170
- {
240171
- type: "boolean",
240172
- key: "osdChannelEnabled",
240173
- label: "Channel name overlay",
240174
- default: cache?.osdSnapshot?.channelEnabled ?? true,
240175
- style: "switch"
240176
- },
240177
- {
240178
- type: "text",
240179
- key: "osdChannelName",
240180
- label: "Channel name",
240181
- description: "Text shown in the channel-name overlay.",
240182
- default: cache?.osdSnapshot?.channelName ?? "",
240183
- placeholder: "Front door"
240184
- },
240185
- {
240186
- type: "boolean",
240187
- key: "osdTimeEnabled",
240188
- label: "Timestamp overlay",
240189
- default: cache?.osdSnapshot?.timeEnabled ?? true,
240190
- style: "switch"
240191
- },
240192
- {
240193
- type: "boolean",
240194
- key: "osdWatermark",
240195
- label: "Watermark",
240196
- description: "The Reolink logo watermark overlay.",
240197
- default: cache?.osdSnapshot?.watermark ?? false,
240198
- style: "switch"
240199
- }
240200
- ]
240201
- },
241204
+ buildOsdSection(osdSnap, osdValues, {
241205
+ sleeping: this.isBattery && this.sleeping,
241206
+ now: Date.now()
241207
+ }),
240202
241208
  {
240203
241209
  id: "privacy-mask",
240204
241210
  tab: "image",
@@ -240488,6 +241494,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240488
241494
  ]
240489
241495
  }]
240490
241496
  },
241497
+ buildSnapshotFreshnessSection(cache, {
241498
+ sleeping: this.isBattery && this.sleeping,
241499
+ now: Date.now()
241500
+ }),
240491
241501
  ...this.buildSessionsTabSections(),
240492
241502
  ...this.buildEmailPushSection(),
240493
241503
  ...this.buildEmailTabSections()
@@ -240554,10 +241564,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240554
241564
  irLightsBrightness: this.config.get("irLightsBrightness") ?? 128,
240555
241565
  statusLedEnabled: this.config.get("statusLedEnabled") ?? cache?.ledSnapshot?.statusEnabled ?? true,
240556
241566
  doorbellLedEnabled: this.config.get("doorbellLedEnabled") ?? cache?.ledSnapshot?.doorbellEnabled ?? true,
240557
- osdChannelEnabled: this.config.get("osdChannelEnabled") ?? cache?.osdSnapshot?.channelEnabled ?? true,
240558
- osdChannelName: this.config.get("osdChannelName") ?? cache?.osdSnapshot?.channelName ?? "",
240559
- osdTimeEnabled: this.config.get("osdTimeEnabled") ?? cache?.osdSnapshot?.timeEnabled ?? true,
240560
- osdWatermark: this.config.get("osdWatermark") ?? cache?.osdSnapshot?.watermark ?? false,
241567
+ osdChannelEnabled: osdValues.osdChannelEnabled,
241568
+ osdChannelName: osdValues.osdChannelName,
241569
+ osdTimeEnabled: osdValues.osdTimeEnabled,
241570
+ osdWatermark: osdValues.osdWatermark,
240561
241571
  audioVolume: this.config.get("audioVolume") ?? 50,
240562
241572
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
240563
241573
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
@@ -240576,7 +241586,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240576
241586
  });
240577
241587
  }
240578
241588
  async applySettingsPatch(patch) {
240579
- const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, ...rest } = patch;
241589
+ const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdTimeEnabled, osdWatermark, ...rest } = patch;
240580
241590
  const emailFields = {
240581
241591
  emailSmtpServer,
240582
241592
  emailSmtpPort,
@@ -240595,8 +241605,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240595
241605
  meta: { error: err instanceof Error ? err.message : String(err) }
240596
241606
  });
240597
241607
  });
240598
- if (Object.keys(rest).length === 0) return;
240599
- await this.config.setAll(rest);
241608
+ const hasOsdPatch = [
241609
+ osdChannelEnabled,
241610
+ osdChannelName,
241611
+ osdTimeEnabled,
241612
+ osdWatermark
241613
+ ].some((v) => v !== void 0);
241614
+ if (Object.keys(rest).length === 0 && !hasOsdPatch) return;
241615
+ if (Object.keys(rest).length > 0) await this.config.setAll(rest);
240600
241616
  const typedPatch = patch;
240601
241617
  if (typedPatch.host || typedPatch.port || typedPatch.username || typedPatch.password) {
240602
241618
  await this.disconnectAll();
@@ -240789,12 +241805,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240789
241805
  } catch (err) {
240790
241806
  this.ctx.logger.warn("ir-lights push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
240791
241807
  }
240792
- if ([
240793
- "osdChannelEnabled",
240794
- "osdChannelName",
240795
- "osdTimeEnabled",
240796
- "osdWatermark"
240797
- ].some((k) => k in patch)) try {
241808
+ if (hasOsdPatch) try {
240798
241809
  const api = await this.ensureApi();
240799
241810
  const channel = this.getChannel();
240800
241811
  const current = await api.getOsd(channel);
@@ -240812,13 +241823,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240812
241823
  watermark: current.watermark ?? 0
240813
241824
  };
240814
241825
  if (current.bgcolor !== void 0) next.bgcolor = current.bgcolor;
240815
- if (typeof typedPatch.osdChannelEnabled === "boolean") next.osdChannel.enable = typedPatch.osdChannelEnabled ? 1 : 0;
240816
- if (typeof typedPatch.osdChannelName === "string") next.osdChannel.name = typedPatch.osdChannelName;
240817
- if (typeof typedPatch.osdTimeEnabled === "boolean") next.osdTime.enable = typedPatch.osdTimeEnabled ? 1 : 0;
240818
- if (typeof typedPatch.osdWatermark === "boolean") next.watermark = typedPatch.osdWatermark ? 1 : 0;
241826
+ if (typeof osdChannelEnabled === "boolean") next.osdChannel.enable = osdChannelEnabled ? 1 : 0;
241827
+ if (typeof osdChannelName === "string") next.osdChannel.name = osdChannelName;
241828
+ if (typeof osdTimeEnabled === "boolean") next.osdTime.enable = osdTimeEnabled ? 1 : 0;
241829
+ if (typeof osdWatermark === "boolean") next.watermark = osdWatermark ? 1 : 0;
240819
241830
  await api.setOsd(channel, next);
240820
241831
  } catch (err) {
240821
- this.ctx.logger.warn("osd push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
241832
+ this.ctx.logger.warn("osd push failed camera keeps its current overlay state", {
241833
+ tags: { deviceId: this.id },
241834
+ meta: { error: err instanceof Error ? err.message : String(err) }
241835
+ });
240822
241836
  }
240823
241837
  if ([
240824
241838
  "audioVolume",
@@ -240959,7 +241973,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240959
241973
  }
240960
241974
  const changedSlices = slicesForPatch(patch);
240961
241975
  if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
240962
- this.ctx.logger.debug("reolink targeted settings refresh failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
241976
+ this.ctx.logger.info("reolink targeted settings refresh failed snapshots left stale", {
241977
+ tags: { deviceId: this.id },
241978
+ meta: {
241979
+ slices: [...changedSlices],
241980
+ error: err instanceof Error ? err.message : String(err)
241981
+ }
241982
+ });
240963
241983
  });
240964
241984
  }
240965
241985
  /**
@@ -243562,6 +244582,44 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243562
244582
  return regs;
243563
244583
  }
243564
244584
  /**
244585
+ * Compose the provider registrations from `onInitialize()` with this
244586
+ * addon's customActions catalog. `BaseDeviceProvider.onInitialize` is
244587
+ * typed `ProviderRegistration[]` (eleven sibling providers push onto it),
244588
+ * so the catalog joins at the `initialize()` seam instead — the runner
244589
+ * consumes the merged `AddonInitResult` exactly as it does for
244590
+ * addon-benchmark / addon-notifiers.
244591
+ *
244592
+ * Admin-only debug surface (D346): `debugRawRead` returns the lib's
244593
+ * response for one snapshot slice UNPROJECTED, next to the persisted
244594
+ * snapshot + its freshness. Registered as an addon customAction because
244595
+ * `addons.custom` is the one operator surface that enforces the
244596
+ * per-action `auth: 'admin'` server-side and validates output —
244597
+ * `deviceManager.runDeviceAction` does neither. The hub reads the static
244598
+ * catalog from this bundle's `customActions` export (see `index.ts`);
244599
+ * the child registers the handlers returned here.
244600
+ */
244601
+ async initialize(context) {
244602
+ const base = await super.initialize(context);
244603
+ return {
244604
+ providers: base && base.providers ? base.providers : [],
244605
+ customActions: reolinkDebugActions,
244606
+ actionHandlers: { debugRawRead: (input) => this.debugRawRead(input) }
244607
+ };
244608
+ }
244609
+ /**
244610
+ * Route a `debugRawRead` custom action to the owning camera. Covers both
244611
+ * standalone cameras and NVR-adopted children — every live ReolinkCamera
244612
+ * in this runner is in the kernel device registry. Read-only end to end
244613
+ * (see `raw-read.ts`); a hub device or an unknown id refuses with a
244614
+ * message that names what it looked for.
244615
+ */
244616
+ async debugRawRead(input) {
244617
+ const dev = this.ctx.kernel.deviceRegistry?.getById(input.deviceId);
244618
+ if (dev === void 0 || dev === null) throw new Error(`debugRawRead: device ${input.deviceId} not found in the reolink runner's registry`);
244619
+ if (!(dev instanceof ReolinkCamera)) throw new Error(`debugRawRead: device ${input.deviceId} is not a ReolinkCamera (got ${dev.constructor.name}) — raw reads target cameras, not hubs/accessories`);
244620
+ return dev.debugRawRead(input.slice);
244621
+ }
244622
+ /**
243565
244623
  * Handle a broker-issued source-refresh request. With the lazy-publish
243566
244624
  * model the broker always emits this on first dial of a
243567
244625
  * `lazy:rfc4571:` placeholder URL — and re-emits it whenever the
@@ -243839,4 +244897,4 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243839
244897
  }
243840
244898
  };
243841
244899
  //#endregion
243842
- export { ReolinkProviderAddon, collectNativeDiagnostics as a, runAllDiagnosticsConsecutively as c, testChannelStreams as d, collectMultifocalDiagnostics as i, runMultifocalDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNvrDiagnostics as o, collectCgiDiagnostics as r, createDiagnosticsBundle as s, ReolinkCamera as t, sampleStreams as u };
244900
+ export { ReolinkProviderAddon, collectMultifocalDiagnostics as a, createDiagnosticsBundle as c, sampleStreams as d, testChannelStreams as f, collectCgiDiagnostics as i, runAllDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNativeDiagnostics as o, reolinkDebugActions as r, collectNvrDiagnostics as s, ReolinkCamera as t, runMultifocalDiagnosticsConsecutively as u };