@camstack/addon-provider-reolink 1.2.81 → 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.js CHANGED
@@ -7199,7 +7199,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7199
7199
  * still gives the event loop a chance to drain — useful for breaking
7200
7200
  * up tight async loops without changing call-site semantics.
7201
7201
  */
7202
- function sleep$1(ms) {
7202
+ function sleep$2(ms) {
7203
7203
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7204
7204
  }
7205
7205
  var EncodeProfileSchema = object({
@@ -11098,6 +11098,89 @@ var LocationStatSchema = object({
11098
11098
  fileCount: number(),
11099
11099
  present: boolean()
11100
11100
  });
11101
+ /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
11102
+ var BackupRunStateSchema = _enum([
11103
+ "queued",
11104
+ "running",
11105
+ "succeeded",
11106
+ "failed",
11107
+ "cancelled"
11108
+ ]);
11109
+ /**
11110
+ * Where a running backup currently is. `queued` before it starts,
11111
+ * `building` while the tar.gz is being staged, `uploading` during the
11112
+ * per-destination fan-out, `done` once terminal.
11113
+ */
11114
+ var BackupRunPhaseSchema = _enum([
11115
+ "queued",
11116
+ "building",
11117
+ "uploading",
11118
+ "done"
11119
+ ]);
11120
+ /**
11121
+ * Observable state of one backup run — readable WHILE it runs via
11122
+ * `backup.listRuns`. This is what makes the execution queue and
11123
+ * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
11124
+ * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
11125
+ * diagnosable with `du` because nothing reported that runs existed or
11126
+ * how large the staged archive had grown.
11127
+ */
11128
+ var BackupRunSchema = object({
11129
+ /** Stable run id — the handle `backup.cancel` takes. */
11130
+ id: string(),
11131
+ state: BackupRunStateSchema,
11132
+ phase: BackupRunPhaseSchema,
11133
+ /**
11134
+ * Resolved destination location ids. Empty while queued (targets are
11135
+ * resolved when the run starts, against the then-current policies).
11136
+ */
11137
+ destinationIds: array(string()).readonly(),
11138
+ label: string().optional(),
11139
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
11140
+ requestedAt: number(),
11141
+ /** ms-epoch when the run left the queue and started building. */
11142
+ startedAt: number().optional(),
11143
+ /** ms-epoch when the run reached a terminal state. */
11144
+ finishedAt: number().optional(),
11145
+ /** Compressed bytes of the staging archive written so far. */
11146
+ stagedBytes: number(),
11147
+ /** Final staged archive size, once the build phase completes. */
11148
+ archiveSizeBytes: number().optional(),
11149
+ /** Bytes pushed to the destination currently uploading. */
11150
+ uploadedBytes: number(),
11151
+ /** Destinations where the archive fully landed (uploaded + indexed). */
11152
+ completedDestinationIds: array(string()).readonly(),
11153
+ /** Destinations that failed during the fan-out. */
11154
+ failedDestinationIds: array(string()).readonly(),
11155
+ /** Failure message when `state === 'failed'`. */
11156
+ error: string().optional(),
11157
+ /**
11158
+ * 1-based place in the execution queue — 1 = runs next. Present only
11159
+ * while `state === 'queued'`. Stamped by the orchestrator from the
11160
+ * queue's OWN pending order, never derived from timestamps, so the
11161
+ * UI cannot show an order the executor will not honour.
11162
+ */
11163
+ queuePosition: number().int().min(1).optional()
11164
+ });
11165
+ /**
11166
+ * Result of `backup.trigger`. The call still resolves when the run
11167
+ * terminates (compat with schedule-driven runs and the admin UI), but
11168
+ * it now names the run and says whether it had to WAIT: a trigger that
11169
+ * arrives while another run is in flight is enqueued (or joined onto
11170
+ * an identical already-queued run), never started concurrently.
11171
+ */
11172
+ var BackupTriggerResultSchema = object({
11173
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
11174
+ runId: string(),
11175
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
11176
+ queued: boolean(),
11177
+ /** True when this trigger was coalesced onto an identical already-queued run. */
11178
+ joined: boolean(),
11179
+ /** True when the run was cancelled before completing every destination. */
11180
+ cancelled: boolean(),
11181
+ /** One entry per destination the archive landed at (partial on cancel). */
11182
+ entries: array(BackupEntrySchema).readonly()
11183
+ });
11101
11184
  /**
11102
11185
  * A backup schedule — the N:M "entry" that binds one cron cadence to a
11103
11186
  * SET of destination locations. Supersedes the per-location cron on
@@ -11145,7 +11228,10 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
11145
11228
  * retention (manual runs).
11146
11229
  */
11147
11230
  retentionCount: number().int().min(1).max(1e3).optional()
11148
- }).optional(), array(BackupEntrySchema).readonly(), {
11231
+ }).optional(), BackupTriggerResultSchema, {
11232
+ kind: "mutation",
11233
+ auth: "admin"
11234
+ }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
11149
11235
  kind: "mutation",
11150
11236
  auth: "admin"
11151
11237
  }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
@@ -11862,6 +11948,14 @@ method(object({
11862
11948
  }), object({ success: literal(true) }), {
11863
11949
  kind: "mutation",
11864
11950
  auth: "admin"
11951
+ }), method(object({ deviceId: number().int().nonnegative() }), object({
11952
+ derivedStreamsDeleted: array(string()).readonly(),
11953
+ assignmentsPurged: boolean(),
11954
+ probeSnapshotsDropped: number().int().nonnegative(),
11955
+ rtspTokenRowsDeleted: number().int().nonnegative()
11956
+ }), {
11957
+ kind: "mutation",
11958
+ auth: "admin"
11865
11959
  }), method(object({
11866
11960
  deviceId: number(),
11867
11961
  /** Absent = the LOWEST assigned profile — a notification attachment is
@@ -12317,6 +12411,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
12317
12411
  filePath: string(),
12318
12412
  content: string()
12319
12413
  })) }), { auth: "admin" });
12414
+ /**
12415
+ * Identity — preserves literal types for downstream inference.
12416
+ *
12417
+ * The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
12418
+ * TypeScript does not widen each entry's literal `kind`/`auth` fields to
12419
+ * the broader unions declared on `CustomActionSpec`'s default generics.
12420
+ * Shape validity is enforced separately by the `customAction(...)` helper
12421
+ * whose return type is already a `CustomActionSpec<...>`.
12422
+ */
12423
+ function defineCustomActions(spec) {
12424
+ return spec;
12425
+ }
12426
+ function customAction(input, output, options) {
12427
+ return {
12428
+ input,
12429
+ output,
12430
+ kind: options?.kind ?? "query",
12431
+ auth: options?.auth ?? "protected",
12432
+ scope: options?.scope ?? { kind: "system" },
12433
+ ...options?.caller ? { caller: "required" } : {}
12434
+ };
12435
+ }
12320
12436
  function deviceCustomAction(input, output, options) {
12321
12437
  return {
12322
12438
  input,
@@ -13301,6 +13417,35 @@ var deviceProviderCapability = {
13301
13417
  name: string(),
13302
13418
  type: string()
13303
13419
  }))),
13420
+ /**
13421
+ * Tear down and reconstruct ONE device in place from its persisted rows —
13422
+ * touching no other device this provider owns.
13423
+ *
13424
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
13425
+ * migrated numbers: after `swapIds` the runner's live instance still
13426
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
13427
+ * registrations and its log tags), and a live object cannot be renumbered.
13428
+ * Before this method the only flush was restarting the whole owning addon
13429
+ * — which took every camera the provider owns down with it (28 devices
13430
+ * for one migrated camera, measured 2026-09-04, and the morning of the
13431
+ * same day ~27 devices' native caps did not come back on their own).
13432
+ *
13433
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
13434
+ * that changes. The reply carries the id the device answers on NOW.
13435
+ * Implemented once in `BaseDeviceProvider` — decommission the live
13436
+ * instance (if any), then re-create from the persisted row: the same
13437
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
13438
+ * An RPC, never an event: a dropped event would leave the runner writing
13439
+ * against the wrong camera (D8).
13440
+ *
13441
+ * Construction can dial hardware, and the migrated source is
13442
+ * characteristically dead — the timeout covers a full activate window
13443
+ * rather than the 60 s default.
13444
+ */
13445
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
13446
+ kind: "mutation",
13447
+ timeoutMs: 3 * 6e4
13448
+ }),
13304
13449
  supportsDiscovery: method(object({}), boolean()),
13305
13450
  /**
13306
13451
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13628,7 +13773,8 @@ method(object({
13628
13773
  targetId: number()
13629
13774
  }), MigrateDeviceResultSchema, {
13630
13775
  kind: "mutation",
13631
- auth: "admin"
13776
+ auth: "admin",
13777
+ timeoutMs: 12 * 6e4
13632
13778
  }), 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({
13633
13779
  deviceId: number(),
13634
13780
  name: string()
@@ -33428,6 +33574,147 @@ var BaseDevice = class {
33428
33574
  }
33429
33575
  };
33430
33576
  /**
33577
+ * Delays before retry rounds 1..N — the round count IS the bound.
33578
+ * 10 s catches "the hub was busy for a moment"; the full schedule
33579
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
33580
+ * per attempt) covers a device-manager lock held for minutes — the
33581
+ * 2026-09-04 outage's migration hold was ~3.5 min.
33582
+ */
33583
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
33584
+ 1e4,
33585
+ 3e4,
33586
+ 9e4
33587
+ ];
33588
+ /** Abortable sleep — resolves early (never rejects) on abort. */
33589
+ function sleep$1(ms, signal) {
33590
+ return new Promise((resolve) => {
33591
+ if (signal.aborted) {
33592
+ resolve();
33593
+ return;
33594
+ }
33595
+ const onAbort = () => {
33596
+ clearTimeout(timer);
33597
+ resolve();
33598
+ };
33599
+ const timer = setTimeout(() => {
33600
+ signal.removeEventListener("abort", onAbort);
33601
+ resolve();
33602
+ }, ms);
33603
+ timer.unref?.();
33604
+ signal.addEventListener("abort", onAbort, { once: true });
33605
+ });
33606
+ }
33607
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
33608
+ * not reject (callers wrap their own try/catch). */
33609
+ async function runWithConcurrency(items, width, fn) {
33610
+ const queue = [...items];
33611
+ const laneCount = Math.max(1, Math.min(width, queue.length));
33612
+ const lane = async () => {
33613
+ for (;;) {
33614
+ const item = queue.shift();
33615
+ if (item === void 0) return;
33616
+ await fn(item);
33617
+ }
33618
+ };
33619
+ await Promise.all(Array.from({ length: laneCount }, lane));
33620
+ }
33621
+ var DeviceRestoreRetryScheduler = class {
33622
+ #logger;
33623
+ #attempt;
33624
+ #onPermanentFailure;
33625
+ #delaysMs;
33626
+ #concurrency;
33627
+ #now;
33628
+ #abort = new AbortController();
33629
+ constructor(options) {
33630
+ this.#logger = options.logger;
33631
+ this.#attempt = options.attempt;
33632
+ this.#onPermanentFailure = options.onPermanentFailure;
33633
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
33634
+ this.#concurrency = options.concurrency ?? 4;
33635
+ this.#now = options.now ?? Date.now;
33636
+ }
33637
+ /** Stop retrying (shutdown). Pending entries are NOT marked
33638
+ * permanently failed — the next boot restores them from disk. */
33639
+ cancel() {
33640
+ this.#abort.abort();
33641
+ }
33642
+ /**
33643
+ * Run the bounded retry rounds. Resolves when every entry has either
33644
+ * restored, been marked permanently failed, or the scheduler was
33645
+ * cancelled. Never rejects.
33646
+ */
33647
+ async run(initialFailures) {
33648
+ let pending = initialFailures.map((failure) => ({
33649
+ saved: failure.saved,
33650
+ lastError: failure.error,
33651
+ attempts: 1
33652
+ }));
33653
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
33654
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
33655
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
33656
+ if (this.#abort.signal.aborted) break;
33657
+ pending = await this.#runRound(pending, round);
33658
+ }
33659
+ if (this.#abort.signal.aborted) return [];
33660
+ const terminal = pending.map((entry) => ({
33661
+ deviceId: entry.saved.id,
33662
+ stableId: entry.saved.stableId,
33663
+ type: String(entry.saved.type),
33664
+ attempts: entry.attempts,
33665
+ lastError: entry.lastError,
33666
+ failedAt: this.#now()
33667
+ }));
33668
+ for (const failure of terminal) this.#onPermanentFailure(failure);
33669
+ return terminal;
33670
+ }
33671
+ /** One retry round: parents first (phase 0), then hub-adopted
33672
+ * children (phase 1) — a child's attempt depends on its parent
33673
+ * having landed, exactly like the initial two-pass restore. */
33674
+ async #runRound(pending, round) {
33675
+ const next = [];
33676
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
33677
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
33678
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
33679
+ if (this.#abort.signal.aborted) {
33680
+ next.push(entry);
33681
+ return;
33682
+ }
33683
+ const attemptNo = entry.attempts + 1;
33684
+ try {
33685
+ await this.#attempt(entry.saved);
33686
+ this.#logger.info("Device restored on retry", {
33687
+ tags: {
33688
+ deviceId: entry.saved.id,
33689
+ stableId: entry.saved.stableId
33690
+ },
33691
+ meta: { attempt: attemptNo }
33692
+ });
33693
+ } catch (err) {
33694
+ const lastError = err instanceof Error ? err.message : String(err);
33695
+ const remainingRetries = this.#delaysMs.length - (round + 1);
33696
+ this.#logger.warn("Device restore retry failed", {
33697
+ tags: {
33698
+ deviceId: entry.saved.id,
33699
+ stableId: entry.saved.stableId
33700
+ },
33701
+ meta: {
33702
+ attempt: attemptNo,
33703
+ remainingRetries,
33704
+ error: lastError
33705
+ }
33706
+ });
33707
+ next.push({
33708
+ saved: entry.saved,
33709
+ lastError,
33710
+ attempts: attemptNo
33711
+ });
33712
+ }
33713
+ });
33714
+ return next;
33715
+ }
33716
+ };
33717
+ /**
33431
33718
  * Convert an IDevice to the flat DeviceSummary shape expected by the
33432
33719
  * device-provider cap router. Shared across all providers.
33433
33720
  */
@@ -33476,6 +33763,7 @@ var BaseDeviceProvider = class extends BaseAddon {
33476
33763
  }];
33477
33764
  }
33478
33765
  async onShutdown() {
33766
+ this.cancelRestoreRetries();
33479
33767
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
33480
33768
  for (const device of devices) try {
33481
33769
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -33493,9 +33781,16 @@ var BaseDeviceProvider = class extends BaseAddon {
33493
33781
  async start() {}
33494
33782
  async stop() {}
33495
33783
  async getStatus() {
33784
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
33785
+ const summary = this.restoreFailureSummary();
33786
+ if (summary === null) return {
33787
+ connected: true,
33788
+ deviceCount: all.length
33789
+ };
33496
33790
  return {
33497
33791
  connected: true,
33498
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
33792
+ deviceCount: all.length,
33793
+ error: summary
33499
33794
  };
33500
33795
  }
33501
33796
  async getDevices() {
@@ -33585,8 +33880,137 @@ var BaseDeviceProvider = class extends BaseAddon {
33585
33880
  };
33586
33881
  }
33587
33882
  async restoreDevices(savedDevices) {
33588
- await this.onRestoreDevices(savedDevices);
33589
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33883
+ const report = await this.onRestoreDevices(savedDevices);
33884
+ if (savedDevices.length === 0) return;
33885
+ if (report && report.failedCount > 0) {
33886
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33887
+ return;
33888
+ }
33889
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33890
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33891
+ }
33892
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33893
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33894
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33895
+ * never re-stampede full-width while the initial pass does (D167). */
33896
+ restoreRetryConcurrency = 4;
33897
+ _restoreRetryScheduler = null;
33898
+ _restoreRetryCompletion = null;
33899
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33900
+ /** Settles when the background retry rounds finish (or `null` when
33901
+ * nothing failed). Exposed for tests and subclass diagnostics —
33902
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33903
+ * with the devices that restored, and a late success is announced
33904
+ * through the `native-cap-change` → `updateCaps` path. */
33905
+ get restoreRetryCompletion() {
33906
+ return this._restoreRetryCompletion;
33907
+ }
33908
+ /** Devices that exhausted the retry bound this process lifetime. */
33909
+ get permanentRestoreFailures() {
33910
+ return [...this._permanentRestoreFailures.values()];
33911
+ }
33912
+ /** One-line operator-facing summary for `getStatus().error`, or
33913
+ * `null` when every device restored. */
33914
+ restoreFailureSummary() {
33915
+ if (this._permanentRestoreFailures.size === 0) return null;
33916
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33917
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33918
+ }
33919
+ cancelRestoreRetries() {
33920
+ this._restoreRetryScheduler?.cancel();
33921
+ this._restoreRetryScheduler = null;
33922
+ }
33923
+ recordPermanentRestoreFailure(failure) {
33924
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33925
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33926
+ tags: {
33927
+ deviceId: failure.deviceId,
33928
+ stableId: failure.stableId
33929
+ },
33930
+ meta: {
33931
+ type: failure.type,
33932
+ attempts: failure.attempts,
33933
+ error: failure.lastError
33934
+ }
33935
+ });
33936
+ }
33937
+ scheduleRestoreRetries(failures, attempt) {
33938
+ const scheduler = new DeviceRestoreRetryScheduler({
33939
+ logger: this.ctx.logger,
33940
+ delaysMs: this.restoreRetryDelaysMs,
33941
+ concurrency: this.restoreRetryConcurrency,
33942
+ attempt,
33943
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33944
+ });
33945
+ this._restoreRetryScheduler = scheduler;
33946
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33947
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33948
+ });
33949
+ }
33950
+ /**
33951
+ * Tear down and reconstruct ONE device from its persisted rows — the
33952
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33953
+ * and no other device this provider owns is disturbed.
33954
+ *
33955
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33956
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33957
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33958
+ * whatever number the row carries NOW. The teardown is `decommission` —
33959
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33960
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33961
+ * the boot restore's own `create()` path, including its pass 2: first-class
33962
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33963
+ * parent by the cascade and must be re-created explicitly, because only
33964
+ * accessory children come back through `getAccessoryChildren()`.
33965
+ *
33966
+ * Reloading an accessory child directly is refused (no device class) —
33967
+ * reload its parent instead.
33968
+ */
33969
+ async reloadDevice(input) {
33970
+ const { stableId } = input;
33971
+ const devices = this.ctx.kernel.devices;
33972
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33973
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33974
+ if (live) await devices.decommission(live.id);
33975
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33976
+ addonId: this.addonId,
33977
+ stableId
33978
+ });
33979
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33980
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33981
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33982
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33983
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33984
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33985
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33986
+ for (const row of rows) {
33987
+ if (row.parentDeviceId !== id) continue;
33988
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33989
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33990
+ if (!ChildClass) continue;
33991
+ try {
33992
+ await devices.create(row.stableId, ChildClass, {}, id);
33993
+ } catch (err) {
33994
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33995
+ tags: {
33996
+ deviceId: row.id,
33997
+ stableId: row.stableId
33998
+ },
33999
+ meta: {
34000
+ parentDeviceId: id,
34001
+ error: err instanceof Error ? err.message : String(err)
34002
+ }
34003
+ });
34004
+ }
34005
+ }
34006
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
34007
+ tags: { deviceId: id },
34008
+ meta: {
34009
+ stableId,
34010
+ type: meta.type
34011
+ }
34012
+ });
34013
+ return { deviceId: id };
33590
34014
  }
33591
34015
  /**
33592
34016
  * Restore devices from persisted state. Two-pass:
@@ -33612,55 +34036,108 @@ var BaseDeviceProvider = class extends BaseAddon {
33612
34036
  * accessory-spawn flow handles via the parent's
33613
34037
  * `getAccessoryChildren()`. Override only when the default doesn't
33614
34038
  * fit.
34039
+ *
34040
+ * A row that fails either pass is NOT terminal (D347): it is handed
34041
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
34042
+ * Only after the bound is exhausted is the device marked permanently
34043
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
34044
+ * `getStatus().error`.
33615
34045
  */
33616
34046
  async onRestoreDevices(savedDevices) {
33617
34047
  const restored = /* @__PURE__ */ new Set();
34048
+ const failures = [];
34049
+ const attemptRestore = async (saved) => {
34050
+ if (restored.has(saved.id)) return;
34051
+ const Class = this.deviceClasses[saved.type];
34052
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34053
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
34054
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
34055
+ restored.add(saved.id);
34056
+ };
33618
34057
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
33619
34058
  const restoreOne = async (saved) => {
33620
- const Class = this.deviceClasses[saved.type];
33621
- if (!Class) {
34059
+ if (!this.deviceClasses[saved.type]) {
33622
34060
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
33623
- tags: { stableId: saved.stableId },
34061
+ tags: {
34062
+ deviceId: saved.id,
34063
+ stableId: saved.stableId
34064
+ },
33624
34065
  meta: { type: saved.type }
33625
34066
  });
33626
34067
  return;
33627
34068
  }
33628
34069
  try {
33629
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
33630
- restored.add(saved.id);
34070
+ await attemptRestore(saved);
33631
34071
  } catch (err) {
33632
- this.ctx.logger.warn("Failed to restore device", {
33633
- tags: { stableId: saved.stableId },
34072
+ const error = err instanceof Error ? err.message : String(err);
34073
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
34074
+ tags: {
34075
+ deviceId: saved.id,
34076
+ stableId: saved.stableId
34077
+ },
33634
34078
  meta: {
33635
34079
  type: saved.type,
33636
- error: err instanceof Error ? err.message : String(err)
34080
+ attempt: 1,
34081
+ error
33637
34082
  }
33638
34083
  });
34084
+ failures.push({
34085
+ saved,
34086
+ error
34087
+ });
33639
34088
  }
33640
34089
  };
33641
34090
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
34091
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
33642
34092
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
33643
34093
  for (const saved of childRows) {
33644
- const Class = this.deviceClasses[saved.type];
33645
- if (!Class) continue;
34094
+ if (!this.deviceClasses[saved.type]) continue;
33646
34095
  if (saved.parentDeviceId === null) continue;
33647
- if (!restored.has(saved.parentDeviceId)) continue;
33648
- try {
33649
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33650
- restored.add(saved.id);
33651
- } catch (err) {
33652
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
34096
+ if (restored.has(saved.parentDeviceId)) {
34097
+ try {
34098
+ await attemptRestore(saved);
34099
+ } catch (err) {
34100
+ const error = err instanceof Error ? err.message : String(err);
34101
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
34102
+ tags: {
34103
+ deviceId: saved.id,
34104
+ stableId: saved.stableId,
34105
+ parentDeviceId: saved.parentDeviceId
34106
+ },
34107
+ meta: {
34108
+ type: saved.type,
34109
+ attempt: 1,
34110
+ error
34111
+ }
34112
+ });
34113
+ failures.push({
34114
+ saved,
34115
+ error
34116
+ });
34117
+ }
34118
+ continue;
34119
+ }
34120
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
34121
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
33653
34122
  tags: {
34123
+ deviceId: saved.id,
33654
34124
  stableId: saved.stableId,
33655
34125
  parentDeviceId: saved.parentDeviceId
33656
34126
  },
33657
- meta: {
33658
- type: saved.type,
33659
- error: err instanceof Error ? err.message : String(err)
33660
- }
34127
+ meta: { type: saved.type }
34128
+ });
34129
+ failures.push({
34130
+ saved,
34131
+ error: `parent device ${saved.parentDeviceId} not restored`
33661
34132
  });
34133
+ continue;
33662
34134
  }
33663
34135
  }
34136
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
34137
+ return {
34138
+ restoredCount: restored.size,
34139
+ failedCount: failures.length
34140
+ };
33664
34141
  }
33665
34142
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
33666
34143
  toSummary(device) {
@@ -34321,6 +34798,12 @@ Object.freeze({
34321
34798
  addonId: null,
34322
34799
  access: "create"
34323
34800
  },
34801
+ "backup.cancel": {
34802
+ capName: "backup",
34803
+ capScope: "system",
34804
+ addonId: null,
34805
+ access: "create"
34806
+ },
34324
34807
  "backup.delete": {
34325
34808
  capName: "backup",
34326
34809
  capScope: "system",
@@ -34363,6 +34846,12 @@ Object.freeze({
34363
34846
  addonId: null,
34364
34847
  access: "view"
34365
34848
  },
34849
+ "backup.listRuns": {
34850
+ capName: "backup",
34851
+ capScope: "system",
34852
+ addonId: null,
34853
+ access: "view"
34854
+ },
34366
34855
  "backup.listSchedules": {
34367
34856
  capName: "backup",
34368
34857
  capScope: "system",
@@ -35563,6 +36052,12 @@ Object.freeze({
35563
36052
  addonId: null,
35564
36053
  access: "view"
35565
36054
  },
36055
+ "deviceProvider.reloadDevice": {
36056
+ capName: "device-provider",
36057
+ capScope: "system",
36058
+ addonId: null,
36059
+ access: "create"
36060
+ },
35566
36061
  "deviceProvider.start": {
35567
36062
  capName: "device-provider",
35568
36063
  capScope: "system",
@@ -38929,6 +39424,12 @@ Object.freeze({
38929
39424
  addonId: null,
38930
39425
  access: "create"
38931
39426
  },
39427
+ "streamBroker.forgetDeviceHardware": {
39428
+ capName: "stream-broker",
39429
+ capScope: "system",
39430
+ addonId: null,
39431
+ access: "delete"
39432
+ },
38932
39433
  "streamBroker.getAllRtspEntries": {
38933
39434
  capName: "stream-broker",
38934
39435
  capScope: "system",
@@ -41387,6 +41888,11 @@ Object.freeze({
41387
41888
  form: "single",
41388
41889
  optional: false
41389
41890
  }],
41891
+ "streamBroker.forgetDeviceHardware": [{
41892
+ name: "deviceId",
41893
+ form: "single",
41894
+ optional: false
41895
+ }],
41390
41896
  "streamBroker.getDeviceAudioMute": [{
41391
41897
  name: "deviceId",
41392
41898
  form: "single",
@@ -230575,6 +231081,453 @@ var IntercomFailureReport = class {
230575
231081
  /** The process-wide instance every camera in this addon notes into. */
230576
231082
  var intercomFailureReport = new IntercomFailureReport();
230577
231083
  //#endregion
231084
+ //#region src/snapshot-freshness.ts
231085
+ /**
231086
+ * The snapshot groups the freshness panel reports on, with the Baichuan
231087
+ * read behind each. Order is the display order.
231088
+ */
231089
+ var SNAPSHOT_GROUPS = [
231090
+ {
231091
+ key: "imageSnapshot",
231092
+ label: "Image (getVideoInput)"
231093
+ },
231094
+ {
231095
+ key: "motionSnapshot",
231096
+ label: "Motion (getMotionAlarm)"
231097
+ },
231098
+ {
231099
+ key: "aiSensitivitySnapshot",
231100
+ label: "AI sensitivity (getAiDetectionFull)"
231101
+ },
231102
+ {
231103
+ key: "encSnapshot",
231104
+ label: "Encoder (getEnc)"
231105
+ },
231106
+ {
231107
+ key: "encOptionsSnapshot",
231108
+ label: "Encoder options (getEncOptions)"
231109
+ },
231110
+ {
231111
+ key: "maskSnapshot",
231112
+ label: "Privacy mask (getMask)"
231113
+ },
231114
+ {
231115
+ key: "audioNoiseSnapshot",
231116
+ label: "Audio noise (getAudioNoise)"
231117
+ },
231118
+ {
231119
+ key: "autoFocusSnapshot",
231120
+ label: "Auto-focus (getAutoFocus)"
231121
+ },
231122
+ {
231123
+ key: "netPortSnapshot",
231124
+ label: "Network ports (getNetPort)"
231125
+ },
231126
+ {
231127
+ key: "ntpSnapshot",
231128
+ label: "NTP (getNtp)"
231129
+ },
231130
+ {
231131
+ key: "systemGeneralSnapshot",
231132
+ label: "System general (getSystemGeneral)"
231133
+ },
231134
+ {
231135
+ key: "osdSnapshot",
231136
+ label: "OSD overlay (getOsd)"
231137
+ },
231138
+ {
231139
+ key: "ledSnapshot",
231140
+ label: "LEDs (getIrLights)"
231141
+ },
231142
+ {
231143
+ key: "pirSnapshot",
231144
+ label: "PIR (getPirInfo)"
231145
+ },
231146
+ {
231147
+ key: "autoRebootSnapshot",
231148
+ label: "Auto reboot (getAutoReboot)"
231149
+ },
231150
+ {
231151
+ key: "emailConfigSnapshot",
231152
+ label: "Email/SMTP (getEmail)"
231153
+ },
231154
+ {
231155
+ key: "capOptionsSnapshot",
231156
+ label: "Cap option probes (getOptions)"
231157
+ }
231158
+ ];
231159
+ /**
231160
+ * Merge freshness stamps for the snapshot keys a persist actually wrote.
231161
+ * Returns a NEW map (immutability) — previous stamps for untouched groups
231162
+ * survive, written groups are stamped `now`. Call this from the same
231163
+ * `setAll` that writes the snapshots, with exactly the keys being written:
231164
+ * a failed probe writes no snapshot and therefore gets no stamp.
231165
+ */
231166
+ function stampSnapshotFreshness(previous, writtenKeys, now) {
231167
+ const stamped = { ...previous };
231168
+ for (const key of writtenKeys) stamped[key] = now;
231169
+ return stamped;
231170
+ }
231171
+ /**
231172
+ * Resolve the age of one snapshot group from the cache. Sources, in order:
231173
+ * 1. `snapshotFetchedAt[key]` — the generic stamp map;
231174
+ * 2. a group-embedded stamp where one already existed before the map
231175
+ * (`osdSnapshot.fetchedAt`, `emailConfigSnapshot.lastReadAt`,
231176
+ * newest `capOptionsSnapshot[*].fetchedAt`);
231177
+ * 3. otherwise: the group is present but of unknown age.
231178
+ * An absent group is `never` — not-yet-read must never look like read.
231179
+ */
231180
+ function resolveSnapshotAge(cache, key, now) {
231181
+ if ((cache === void 0 ? void 0 : Reflect.get(cache, key)) === void 0) return { state: "never" };
231182
+ const mapStamp = cache?.snapshotFetchedAt?.[key];
231183
+ const stamp = typeof mapStamp === "number" ? mapStamp : embeddedStamp(cache, key);
231184
+ if (typeof stamp !== "number") return { state: "unknown" };
231185
+ return {
231186
+ state: "known",
231187
+ fetchedAt: stamp,
231188
+ ageMs: Math.max(0, now - stamp)
231189
+ };
231190
+ }
231191
+ /** Pre-map stamps some groups already carried; kept as fallback so a legacy
231192
+ * cache written by today's OSD fix still reports a real age. */
231193
+ function embeddedStamp(cache, key) {
231194
+ if (key === "osdSnapshot") {
231195
+ const v = cache?.osdSnapshot?.fetchedAt;
231196
+ return typeof v === "number" ? v : void 0;
231197
+ }
231198
+ if (key === "emailConfigSnapshot") {
231199
+ const v = cache?.emailConfigSnapshot?.lastReadAt;
231200
+ return typeof v === "number" ? v : void 0;
231201
+ }
231202
+ if (key === "capOptionsSnapshot") {
231203
+ const stamps = Object.values(cache?.capOptionsSnapshot ?? {}).map((e) => e?.fetchedAt).filter((v) => typeof v === "number");
231204
+ return stamps.length > 0 ? Math.max(...stamps) : void 0;
231205
+ }
231206
+ }
231207
+ /** Human age: "12 s ago", "3 m ago", "5 h ago", "12 d ago". */
231208
+ function formatSnapshotAge(ageMs) {
231209
+ const s = Math.floor(ageMs / 1e3);
231210
+ if (s < 60) return `${s} s ago`;
231211
+ const m = Math.floor(s / 60);
231212
+ if (m < 60) return `${m} m ago`;
231213
+ const h = Math.floor(m / 60);
231214
+ if (h < 48) return `${h} h ago`;
231215
+ return `${Math.floor(h / 24)} d ago`;
231216
+ }
231217
+ /** One display line for a group. Unknown age is SAID, never smoothed over. */
231218
+ function formatSnapshotAgeLine(label, age) {
231219
+ switch (age.state) {
231220
+ case "never": return `${label}: never read`;
231221
+ case "unknown": return `${label}: age unknown (recorded before per-snapshot freshness tracking)`;
231222
+ case "known": return `${label}: read ${formatSnapshotAge(age.ageMs)} (${new Date(age.fetchedAt).toLocaleString()})`;
231223
+ }
231224
+ }
231225
+ /**
231226
+ * Read-only "Snapshot freshness" section (advanced tab, next to Debug).
231227
+ * Lists every snapshot group with its own age so "is CamStack's belief
231228
+ * current?" is answerable per fact, not per cache. Purely informational:
231229
+ * it triggers no reads — refresh stays on the existing operator-triggered
231230
+ * "Refresh from camera" action and the event-driven paths.
231231
+ */
231232
+ function buildSnapshotFreshnessSection(cache, opts) {
231233
+ const lines = SNAPSHOT_GROUPS.map(({ key, label }) => formatSnapshotAgeLine(label, resolveSnapshotAge(cache, key, opts.now)));
231234
+ const probedAt = cache?.probedAt;
231235
+ const header = typeof probedAt === "number" ? `Feature probe: ${new Date(probedAt).toLocaleString()}` : "Feature probe: never recorded";
231236
+ return {
231237
+ id: "snapshotFreshness",
231238
+ tab: "advanced",
231239
+ title: "Snapshot freshness",
231240
+ 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.",
231241
+ columns: 1,
231242
+ fields: [{
231243
+ type: "info",
231244
+ key: "snapshotFreshness",
231245
+ label: "Per-snapshot read times",
231246
+ 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")}`
231247
+ }]
231248
+ };
231249
+ }
231250
+ var RawReadSliceSchema = _enum([
231251
+ "image",
231252
+ "motion",
231253
+ "ai",
231254
+ "enc",
231255
+ "encOptions",
231256
+ "mask",
231257
+ "audioNoise",
231258
+ "autofocus",
231259
+ "netPort",
231260
+ "ntp",
231261
+ "systemGeneral",
231262
+ "osd",
231263
+ "led",
231264
+ "pir",
231265
+ "autoReboot"
231266
+ ]);
231267
+ /**
231268
+ * The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
231269
+ * in lockstep in both directions. Anything not in this map — email/SMTP
231270
+ * (`getEmail` echoes the camera's SMTP credentials), users, sessions, any
231271
+ * `set*` — cannot be requested.
231272
+ */
231273
+ var RAW_READ_CATALOG = {
231274
+ image: {
231275
+ command: "getVideoInput",
231276
+ snapshotKey: "imageSnapshot",
231277
+ invoke: (api, channel) => api.getVideoInput(channel)
231278
+ },
231279
+ motion: {
231280
+ command: "getMotionAlarm",
231281
+ snapshotKey: "motionSnapshot",
231282
+ invoke: (api, channel) => api.getMotionAlarm(channel)
231283
+ },
231284
+ ai: {
231285
+ command: "getAiDetectTypes + getAiDetectionFull (per type)",
231286
+ snapshotKey: "aiSensitivitySnapshot",
231287
+ invoke: async (api, channel) => {
231288
+ const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
231289
+ const perType = {};
231290
+ for (const aiType of detectTypes ?? []) try {
231291
+ perType[aiType] = await api.getAiDetectionFull(channel, aiType);
231292
+ } catch (err) {
231293
+ perType[aiType] = { error: err instanceof Error ? err.message : String(err) };
231294
+ }
231295
+ return {
231296
+ detectTypes: detectTypes ?? [],
231297
+ perType
231298
+ };
231299
+ }
231300
+ },
231301
+ enc: {
231302
+ command: "getEnc",
231303
+ snapshotKey: "encSnapshot",
231304
+ invoke: (api, channel) => api.getEnc(channel)
231305
+ },
231306
+ encOptions: {
231307
+ command: "getEncOptions",
231308
+ snapshotKey: "encOptionsSnapshot",
231309
+ invoke: (api, channel) => api.getEncOptions(channel)
231310
+ },
231311
+ mask: {
231312
+ command: "getMask",
231313
+ snapshotKey: "maskSnapshot",
231314
+ invoke: (api, channel) => api.getMask(channel)
231315
+ },
231316
+ audioNoise: {
231317
+ command: "getAudioNoise",
231318
+ snapshotKey: "audioNoiseSnapshot",
231319
+ invoke: (api, channel) => api.getAudioNoise(channel)
231320
+ },
231321
+ autofocus: {
231322
+ command: "getAutoFocus",
231323
+ snapshotKey: "autoFocusSnapshot",
231324
+ invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
231325
+ },
231326
+ netPort: {
231327
+ command: "getNetPort",
231328
+ snapshotKey: "netPortSnapshot",
231329
+ invoke: (api) => api.getNetPort()
231330
+ },
231331
+ ntp: {
231332
+ command: "getNtp",
231333
+ snapshotKey: "ntpSnapshot",
231334
+ invoke: (api) => api.getNtp()
231335
+ },
231336
+ systemGeneral: {
231337
+ command: "getSystemGeneral",
231338
+ snapshotKey: "systemGeneralSnapshot",
231339
+ invoke: (api) => api.getSystemGeneral()
231340
+ },
231341
+ osd: {
231342
+ command: "getOsd",
231343
+ snapshotKey: "osdSnapshot",
231344
+ invoke: (api, channel) => api.getOsd(channel)
231345
+ },
231346
+ led: {
231347
+ command: "getIrLights",
231348
+ snapshotKey: "ledSnapshot",
231349
+ invoke: (api, channel) => api.getIrLights(channel)
231350
+ },
231351
+ pir: {
231352
+ command: "getPirInfo",
231353
+ snapshotKey: "pirSnapshot",
231354
+ invoke: (api, channel) => api.getPirInfo(channel)
231355
+ },
231356
+ autoReboot: {
231357
+ command: "getAutoReboot",
231358
+ snapshotKey: "autoRebootSnapshot",
231359
+ invoke: (api) => api.getAutoReboot()
231360
+ }
231361
+ };
231362
+ /** What CamStack currently believes about the slice, with its own age. */
231363
+ var BelievedStateSchema = object({
231364
+ /** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
231365
+ snapshot: unknown(),
231366
+ /** deviceCache field the projection lives in. */
231367
+ snapshotKey: string(),
231368
+ /** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
231369
+ age: union([
231370
+ object({ state: literal("never") }),
231371
+ object({ state: literal("unknown") }),
231372
+ object({
231373
+ state: literal("known"),
231374
+ fetchedAt: number().int(),
231375
+ ageMs: number().int().nonnegative()
231376
+ })
231377
+ ])
231378
+ });
231379
+ var RawReadResultSchema = discriminatedUnion("ok", [object({
231380
+ ok: literal(true),
231381
+ deviceId: number().int(),
231382
+ slice: RawReadSliceSchema,
231383
+ command: string(),
231384
+ readAt: number().int(),
231385
+ /** The library's response, unprojected, as plain JSON. */
231386
+ camera: unknown(),
231387
+ believed: BelievedStateSchema
231388
+ }), object({
231389
+ ok: literal(false),
231390
+ deviceId: number().int(),
231391
+ slice: RawReadSliceSchema,
231392
+ reason: _enum([
231393
+ "sleeping",
231394
+ "login-failed",
231395
+ "read-failed"
231396
+ ]),
231397
+ message: string(),
231398
+ /** The believed state is still reported — a refusal must not hide
231399
+ * what CamStack is currently serving. */
231400
+ believed: BelievedStateSchema
231401
+ })]);
231402
+ var RawReadInputSchema = object({
231403
+ deviceId: number().int().nonnegative(),
231404
+ slice: RawReadSliceSchema
231405
+ });
231406
+ function believedState(cache, entry, now) {
231407
+ const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
231408
+ return {
231409
+ snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
231410
+ snapshotKey: entry.snapshotKey,
231411
+ age
231412
+ };
231413
+ }
231414
+ /** Force a lib response to plain JSON: drops functions/undefined/prototypes,
231415
+ * guarantees the payload is serializable across the tRPC boundary. */
231416
+ function toPlainJson(value) {
231417
+ if (value === void 0) return null;
231418
+ return JSON.parse(JSON.stringify(value));
231419
+ }
231420
+ /**
231421
+ * Execute one raw read. Order matters:
231422
+ * 1. sleep gate (refuse loudly — logging in would BE the wake);
231423
+ * 2. login;
231424
+ * 3. the allow-listed read;
231425
+ * and every outcome — refusal included — carries the believed state so the
231426
+ * operator always sees both sides of the comparison.
231427
+ */
231428
+ async function performRawRead(slice, deps) {
231429
+ const entry = RAW_READ_CATALOG[slice];
231430
+ const believed = believedState(deps.cache, entry, deps.now);
231431
+ if (deps.sleeping) {
231432
+ deps.logger.info("reolink raw read refused — battery cam is sleeping", {
231433
+ tags: { deviceId: deps.deviceId },
231434
+ meta: { slice }
231435
+ });
231436
+ return {
231437
+ ok: false,
231438
+ deviceId: deps.deviceId,
231439
+ slice,
231440
+ reason: "sleeping",
231441
+ message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231442
+ believed
231443
+ };
231444
+ }
231445
+ let api;
231446
+ try {
231447
+ api = await deps.getApi();
231448
+ } catch (err) {
231449
+ const message = err instanceof Error ? err.message : String(err);
231450
+ deps.logger.info("reolink raw read login failed", {
231451
+ tags: { deviceId: deps.deviceId },
231452
+ meta: {
231453
+ slice,
231454
+ error: message
231455
+ }
231456
+ });
231457
+ return {
231458
+ ok: false,
231459
+ deviceId: deps.deviceId,
231460
+ slice,
231461
+ reason: "login-failed",
231462
+ message,
231463
+ believed
231464
+ };
231465
+ }
231466
+ try {
231467
+ const payload = await entry.invoke(api, deps.channel);
231468
+ deps.logger.info("reolink raw read served", {
231469
+ tags: { deviceId: deps.deviceId },
231470
+ meta: {
231471
+ slice,
231472
+ command: entry.command
231473
+ }
231474
+ });
231475
+ return {
231476
+ ok: true,
231477
+ deviceId: deps.deviceId,
231478
+ slice,
231479
+ command: entry.command,
231480
+ readAt: deps.now,
231481
+ camera: toPlainJson(payload),
231482
+ believed
231483
+ };
231484
+ } catch (err) {
231485
+ const message = err instanceof Error ? err.message : String(err);
231486
+ deps.logger.info("reolink raw read failed", {
231487
+ tags: { deviceId: deps.deviceId },
231488
+ meta: {
231489
+ slice,
231490
+ command: entry.command,
231491
+ error: message
231492
+ }
231493
+ });
231494
+ return {
231495
+ ok: false,
231496
+ deviceId: deps.deviceId,
231497
+ slice,
231498
+ reason: "read-failed",
231499
+ message,
231500
+ believed
231501
+ };
231502
+ }
231503
+ }
231504
+ //#endregion
231505
+ //#region src/debug-actions.ts
231506
+ /**
231507
+ * provider-reolink — customActions catalog (admin-only debug surface).
231508
+ *
231509
+ * Dispatched via `POST addons.custom
231510
+ * {addonId:'provider-reolink', action:'debugRawRead', input:{deviceId, slice}}`.
231511
+ *
231512
+ * Why an addon custom action and not a device action: `deviceManager.
231513
+ * runDeviceAction` (the `refresh-settings` / `refresh-sessions` shape) is
231514
+ * mounted `protected` and its dispatcher does not enforce the per-action
231515
+ * `auth` — any authenticated user could call it. `addons.custom` is the one
231516
+ * operator surface that enforces per-action `auth: 'admin'` server-side
231517
+ * (`ensureCustomActionAuth`) AND validates the addon's output against this
231518
+ * catalog. A debug surface that returns raw camera payloads is admin-only,
231519
+ * so it lives here (same wiring as `addon-benchmark` / `addon-notifiers`).
231520
+ *
231521
+ * `kind: 'query'` states the contract — the handler is read-only by
231522
+ * construction (see `raw-read.ts`: the slice enum maps onto an allow-list
231523
+ * of lib `get*` calls; no write is reachable). The `addons.custom` mount
231524
+ * itself is a single mutation procedure, so callers still POST.
231525
+ */
231526
+ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawReadInputSchema, RawReadResultSchema, {
231527
+ kind: "query",
231528
+ auth: "admin"
231529
+ }) });
231530
+ //#endregion
230578
231531
  //#region src/log-channels.ts
230579
231532
  /**
230580
231533
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230787,6 +231740,130 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
230787
231740
  });
230788
231741
  }
230789
231742
  //#endregion
231743
+ //#region src/osd-settings-section.ts
231744
+ /**
231745
+ * Camera snapshot → UNKNOWN (`null`). The last arm is the point: `?? true`
231746
+ * here is what rendered "the camera did not tell us" as an enabled overlay.
231747
+ * The snapshot is the ONLY input — the config keeps no copy to consult.
231748
+ */
231749
+ function resolveOsdValues(snapshot) {
231750
+ return {
231751
+ osdChannelEnabled: snapshot?.channelEnabled ?? null,
231752
+ osdChannelName: snapshot?.channelName ?? "",
231753
+ osdTimeEnabled: snapshot?.timeEnabled ?? null,
231754
+ osdWatermark: snapshot?.watermark ?? null
231755
+ };
231756
+ }
231757
+ /**
231758
+ * Reader-side staleness (D224). A snapshot persisted before freshness
231759
+ * tracking has no `fetchedAt` and is treated as stale — that is exactly the
231760
+ * adoption-frozen reading this module exists to retire.
231761
+ */
231762
+ function isOsdSnapshotStale(snapshot, now) {
231763
+ return now - (snapshot?.fetchedAt ?? 0) > OPERATOR_WRITTEN_STALE_MS;
231764
+ }
231765
+ var OSD_UNKNOWN_DESCRIPTION = "Not reported by the camera yet — the current state is unknown.";
231766
+ var NEVER_WOKEN_SUFFIX = "a sleeping battery camera is never woken to read settings.";
231767
+ /**
231768
+ * A boolean overlay toggle. Unknown (`null`) renders disabled with an honest
231769
+ * description — the switch component shows `Boolean(null)` = off, and the
231770
+ * disabled + "not reported" pairing keeps that from reading as a claim.
231771
+ */
231772
+ function osdToggle(key, label, value, baseDescription) {
231773
+ const unknown = value === null;
231774
+ const description = unknown ? baseDescription ? `${baseDescription} ${OSD_UNKNOWN_DESCRIPTION}` : OSD_UNKNOWN_DESCRIPTION : baseDescription;
231775
+ return {
231776
+ type: "boolean",
231777
+ key,
231778
+ label,
231779
+ default: value,
231780
+ style: "switch",
231781
+ ...description !== void 0 ? { description } : {},
231782
+ ...unknown ? { disabled: true } : {}
231783
+ };
231784
+ }
231785
+ /**
231786
+ * State banner shown when the operator is NOT looking at a current reading:
231787
+ * - camera asleep and the mirror is stale → say what is shown and when it
231788
+ * was read, and that the camera is not woken for this;
231789
+ * - no reading has ever landed → say the toggles are unknown.
231790
+ * A fresh mirror on an awake camera renders no banner — serve-and-revalidate
231791
+ * keeps it honest silently.
231792
+ */
231793
+ function buildOsdStateBanner(snapshot, opts) {
231794
+ const stale = isOsdSnapshotStale(snapshot, opts.now);
231795
+ if (opts.sleeping && stale) return {
231796
+ type: "info",
231797
+ key: "osdSnapshotState",
231798
+ label: "OSD state",
231799
+ variant: "warning",
231800
+ 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}`
231801
+ };
231802
+ if (snapshot === void 0) return {
231803
+ type: "info",
231804
+ key: "osdSnapshotState",
231805
+ label: "OSD state",
231806
+ variant: "warning",
231807
+ content: "OSD state has not been read from this camera yet — unknown toggles are disabled until a read succeeds."
231808
+ };
231809
+ return null;
231810
+ }
231811
+ /**
231812
+ * A position value for display. Verbatim in quotes when the camera reported
231813
+ * one — an empty string IS a report and shows as `""` — and "not reported"
231814
+ * only when `getOsd` genuinely carried no string (tri-state, D337).
231815
+ */
231816
+ function formatObservedPos(pos) {
231817
+ return typeof pos === "string" ? `"${pos}"` : "not reported";
231818
+ }
231819
+ /**
231820
+ * Read-only view of the overlay positions the camera reported. Deliberately
231821
+ * NOT a control: the `pos` vocabulary is unknown (loose string, no observed
231822
+ * values yet), so this field exists to make it observable per camera. A
231823
+ * position control can be designed once real values have been collected —
231824
+ * see the "osd overlay positions observed" info log in the probe.
231825
+ */
231826
+ function buildOsdPositionsField(snapshot) {
231827
+ return {
231828
+ type: "info",
231829
+ key: "osdPositions",
231830
+ label: "Overlay positions",
231831
+ content: `Positions are kept exactly as configured on the camera and are read-only here.\nChannel name: ${formatObservedPos(snapshot?.channelPos)}\nTimestamp: ${formatObservedPos(snapshot?.timePos)}`
231832
+ };
231833
+ }
231834
+ /**
231835
+ * The "OSD overlay" section (writable via `setOsd`, cmd_id 25). One
231836
+ * read-modify-write `setOsd(OsdConfig)` push covers all four fields — the
231837
+ * dispatcher reads the current `OsdConfig` (`getOsd`) first so the stored
231838
+ * overlay positions (`pos`) survive untouched. Position itself is
231839
+ * camera-pixel/preset specific and only OBSERVED here, never written.
231840
+ */
231841
+ function buildOsdSection(snapshot, values, opts) {
231842
+ const banner = buildOsdStateBanner(snapshot, opts);
231843
+ return {
231844
+ id: "osd",
231845
+ tab: "image",
231846
+ title: "OSD overlay",
231847
+ 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.",
231848
+ columns: 2,
231849
+ fields: [
231850
+ ...banner ? [banner] : [],
231851
+ osdToggle("osdChannelEnabled", "Channel name overlay", values.osdChannelEnabled, void 0),
231852
+ {
231853
+ type: "text",
231854
+ key: "osdChannelName",
231855
+ label: "Channel name",
231856
+ description: "Text shown in the channel-name overlay.",
231857
+ default: values.osdChannelName,
231858
+ placeholder: "Front door"
231859
+ },
231860
+ osdToggle("osdTimeEnabled", "Timestamp overlay", values.osdTimeEnabled, void 0),
231861
+ osdToggle("osdWatermark", "Watermark", values.osdWatermark, "The Reolink logo watermark overlay."),
231862
+ buildOsdPositionsField(snapshot)
231863
+ ]
231864
+ };
231865
+ }
231866
+ //#endregion
230790
231867
  //#region src/raw-state.ts
230791
231868
  /**
230792
231869
  * Source tag for every raw-state blob this provider emits.
@@ -231021,7 +232098,7 @@ var SirenAccessory = class extends BaseDevice {
231021
232098
  this.ctx.logger.info("siren onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231022
232099
  try {
231023
232100
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231024
- await sleep$1(1e3);
232101
+ await sleep$2(1e3);
231025
232102
  } catch (err) {
231026
232103
  this.ctx.logger.warn("siren wake before initial probe failed — proceeding anyway", {
231027
232104
  tags: { deviceId: this.id },
@@ -231443,7 +232520,7 @@ var FloodlightAccessory = class extends BaseDevice {
231443
232520
  this.ctx.logger.info("floodlight onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231444
232521
  try {
231445
232522
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231446
- await sleep$1(1e3);
232523
+ await sleep$2(1e3);
231447
232524
  } catch (err) {
231448
232525
  this.ctx.logger.warn("floodlight wake before initial probe failed — proceeding anyway", {
231449
232526
  tags: { deviceId: this.id },
@@ -231840,7 +232917,7 @@ var PirAccessory = class extends BaseDevice {
231840
232917
  this.ctx.logger.info("pir onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231841
232918
  try {
231842
232919
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231843
- await sleep$1(1e3);
232920
+ await sleep$2(1e3);
231844
232921
  } catch (err) {
231845
232922
  this.ctx.logger.warn("pir wake before initial probe failed — proceeding anyway", {
231846
232923
  tags: { deviceId: this.id },
@@ -233514,7 +234591,24 @@ var reolinkCameraSchema = object({
233514
234591
  channelEnabled: boolean().nullable().optional(),
233515
234592
  channelName: string().optional(),
233516
234593
  timeEnabled: boolean().nullable().optional(),
233517
- watermark: boolean().nullable().optional()
234594
+ watermark: boolean().nullable().optional(),
234595
+ /**
234596
+ * Overlay positions exactly as `getOsd` reported them — READ-ONLY
234597
+ * observations, never written (the `setOsd` read-modify-write
234598
+ * preserves the camera's stored `pos`). Tri-state: `null` means
234599
+ * the camera did not report a string; an empty string is a real
234600
+ * report. Captured so a vocabulary of live values can be
234601
+ * collected before any position control is designed.
234602
+ */
234603
+ channelPos: string().nullable().optional(),
234604
+ timePos: string().nullable().optional(),
234605
+ /**
234606
+ * Wall-clock ms when this slice last landed from the camera.
234607
+ * Absent on snapshots persisted before freshness tracking —
234608
+ * `isOsdSnapshotStale` treats those as stale, which retires the
234609
+ * adoption-frozen readings this stamp was added for (D224).
234610
+ */
234611
+ fetchedAt: number().int().optional()
233518
234612
  }).optional(),
233519
234613
  /**
233520
234614
  * Snapshot of the camera's status + doorbell LED state from
@@ -233553,7 +234647,18 @@ var reolinkCameraSchema = object({
233553
234647
  hour: number().int().nullable().optional(),
233554
234648
  minute: number().int().nullable().optional(),
233555
234649
  supported: boolean().optional()
233556
- }).optional()
234650
+ }).optional(),
234651
+ /**
234652
+ * Per-snapshot freshness stamps (D224 generalised, D346): wall-clock
234653
+ * ms when each `*Snapshot` group in this cache was last WRITTEN from
234654
+ * a camera read, keyed by the group's field name (`encSnapshot`,
234655
+ * `osdSnapshot`, …). Written only by the persist sites that write
234656
+ * the group itself (`stampSnapshotFreshness`) — a failed probe
234657
+ * writes no snapshot and gets no stamp. A group with no entry here
234658
+ * (legacy persist) is of UNKNOWN age and must never read as fresh;
234659
+ * `resolveSnapshotAge` owns the tri-state.
234660
+ */
234661
+ snapshotFetchedAt: record(string(), number().int()).optional()
233557
234662
  }).loose().optional(),
233558
234663
  /**
233559
234664
  * Generic Baichuan debug logs. Forwarded as `DebugOptions.general`
@@ -233711,18 +234816,6 @@ var reolinkCameraSchema = object({
233711
234816
  statusLedEnabled: boolean().optional(),
233712
234817
  doorbellLedEnabled: boolean().optional(),
233713
234818
  /**
233714
- * On-screen display (OSD) overlay — pushed via `setOsd` (cmd_id 25,
233715
- * read via 26). One read-modify-write `OsdConfig` push covers all four
233716
- * fields so the camera keeps its stored overlay positions (`pos`)
233717
- * untouched — only the enable flags, channel name text, and watermark
233718
- * toggle change. Position is camera-pixel/preset specific (`pos` is a
233719
- * loose string, not a clean enum), so it is intentionally NOT exposed.
233720
- */
233721
- osdChannelEnabled: boolean().optional(),
233722
- osdChannelName: string().max(64).optional(),
233723
- osdTimeEnabled: boolean().optional(),
233724
- osdWatermark: boolean().optional(),
233725
- /**
233726
234819
  * Audio output volume — pushed via `setAudioCfg` (cmd_id=265,
233727
234820
  * read via 264). Reolink-spec range 0..100.
233728
234821
  */
@@ -234921,6 +236014,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234921
236014
  * legacy firmware that doesn't support some endpoints). */
234922
236015
  lastSettingsSnapshotRetryAt = 0;
234923
236016
  static SETTINGS_SNAPSHOT_RETRY_MIN_MS = 6e4;
236017
+ /** Debounce timestamp for the OSD serve-and-revalidate kick from
236018
+ * `getSettingsUISchema` (D224). Separate from
236019
+ * `lastSettingsSnapshotRetryAt` so an incomplete-cache retry and an
236020
+ * OSD staleness revalidate never suppress each other. */
236021
+ lastOsdRevalidateKickAt = 0;
234924
236022
  /** True when any settings-snapshot field that drives a UI section
234925
236023
  * is missing from the persisted cache. Drives the on-demand retry
234926
236024
  * in `getSettingsUISchema`. */
@@ -234930,16 +236028,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234930
236028
  return cache.encSnapshot === void 0 || cache.encOptionsSnapshot === void 0 || cache.maskSnapshot === void 0 || cache.audioNoiseSnapshot === void 0 || cache.autoFocusSnapshot === void 0;
234931
236029
  }
234932
236030
  /**
234933
- * Probe `getVideoInput` + `getMotionAlarm` and persist into the
234934
- * `deviceCache` snapshots. Fires once on `onCreated`; future
234935
- * settings opens read straight from the persisted snapshot. Image
234936
- * is readonly in the UI (lib lacks `setVideoInput`); motion is
234937
- * writable via `setMotionAlarm` so its snapshot also drives the
234938
- * dispatch's known-good baseline.
236031
+ * Probe the parent-settings endpoints (`getVideoInput`, `getMotionAlarm`,
236032
+ * `getOsd`, …) and persist into the `deviceCache` snapshots. Runs on
236033
+ * activation, on battery wake transitions, after a settings save (scoped
236034
+ * to the changed slices), via the manual "Refresh from camera" action,
236035
+ * and from `getSettingsUISchema`'s serve-and-revalidate kicks settings
236036
+ * opens serve the persisted snapshot immediately and revalidate stale
236037
+ * slices behind the form (D224). Image is readonly in the UI (lib lacks
236038
+ * `setVideoInput`); motion is writable via `setMotionAlarm` so its
236039
+ * snapshot also drives the dispatch's known-good baseline.
234939
236040
  */
234940
236041
  async refreshParentSettingsSnapshot(slices) {
234941
236042
  if (this.isBattery && this.sleeping) {
234942
- this.ctx.logger.debug("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
236043
+ this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
234943
236044
  return;
234944
236045
  }
234945
236046
  let api;
@@ -235092,14 +236193,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235092
236193
  }
235093
236194
  if (want("osd")) try {
235094
236195
  const osd = await api.getOsd(channel);
236196
+ const channelPos = typeof osd.osdChannel?.pos === "string" ? osd.osdChannel.pos : null;
236197
+ const timePos = typeof osd.osdTime?.pos === "string" ? osd.osdTime.pos : null;
236198
+ const prevOsdSnapshot = this.config.get("deviceCache")?.osdSnapshot;
236199
+ if (prevOsdSnapshot?.channelPos !== channelPos || prevOsdSnapshot?.timePos !== timePos) this.ctx.logger.info("reolink osd overlay positions observed", {
236200
+ tags: { deviceId: this.id },
236201
+ meta: {
236202
+ channelPos,
236203
+ timePos
236204
+ }
236205
+ });
235095
236206
  cacheUpdate.osdSnapshot = {
235096
236207
  channelEnabled: typeof osd.osdChannel?.enable === "number" ? osd.osdChannel.enable === 1 : null,
235097
236208
  channelName: typeof osd.osdChannel?.name === "string" ? osd.osdChannel.name : void 0,
235098
236209
  timeEnabled: typeof osd.osdTime?.enable === "number" ? osd.osdTime.enable === 1 : null,
235099
- watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null
236210
+ watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null,
236211
+ channelPos,
236212
+ timePos,
236213
+ fetchedAt: Date.now()
235100
236214
  };
235101
236215
  } catch (err) {
235102
- this.ctx.logger.debug("reolink getOsd probe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
236216
+ this.ctx.logger.info("reolink getOsd probe failed OSD snapshot left stale", {
236217
+ tags: { deviceId: this.id },
236218
+ meta: { error: err instanceof Error ? err.message : String(err) }
236219
+ });
235103
236220
  }
235104
236221
  if (want("led")) try {
235105
236222
  const ledState = (await api.getIrLights(channel))?.body?.LedState;
@@ -235156,6 +236273,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235156
236273
  }
235157
236274
  if (Object.keys(cacheUpdate).length === 0) return;
235158
236275
  const current = this.config.get("deviceCache") ?? {};
236276
+ const writtenSnapshotKeys = Object.keys(cacheUpdate).filter((k) => k.endsWith("Snapshot"));
236277
+ if (writtenSnapshotKeys.length > 0) cacheUpdate.snapshotFetchedAt = stampSnapshotFreshness(current.snapshotFetchedAt, writtenSnapshotKeys, Date.now());
235159
236278
  try {
235160
236279
  await this.config.setAll({ deviceCache: {
235161
236280
  ...current,
@@ -235169,6 +236288,28 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235169
236288
  });
235170
236289
  }
235171
236290
  /**
236291
+ * Admin-only raw-read debug surface (D346): return the library's response
236292
+ * for one snapshot slice UNPROJECTED, next to the persisted snapshot and
236293
+ * its freshness, so "what does the camera report vs what does CamStack
236294
+ * believe" is answerable without Baichuan tracing. Read-only by
236295
+ * construction (the slice enum maps onto an allow-list of lib `get*`
236296
+ * calls — see `raw-read.ts`), writes nothing back to the cache, and the
236297
+ * sleep gate runs BEFORE any login: a sleeping battery cam refuses loudly
236298
+ * (logging in IS the wake) and still reports the believed state.
236299
+ * Dispatched by the provider's `debugRawRead` custom action.
236300
+ */
236301
+ async debugRawRead(slice) {
236302
+ return performRawRead(slice, {
236303
+ deviceId: this.id,
236304
+ channel: this.getChannel(),
236305
+ sleeping: this.isBattery && this.sleeping,
236306
+ getApi: () => this.ensureApi(),
236307
+ cache: this.config.get("deviceCache"),
236308
+ logger: this.ctx.logger,
236309
+ now: Date.now()
236310
+ });
236311
+ }
236312
+ /**
235172
236313
  * Declare on-camera accessory child devices the kernel should
235173
236314
  * auto-spawn after `onCreated`. Each entry maps directly to a
235174
236315
  * concrete accessory class via the existing `createAccessoryDevice`
@@ -235252,7 +236393,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235252
236393
  waitAfterWakeMs: 2500,
235253
236394
  attempts: 2
235254
236395
  });
235255
- await sleep$1(1500);
236396
+ await sleep$2(1500);
235256
236397
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
235257
236398
  return true;
235258
236399
  } catch (err) {
@@ -235367,7 +236508,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235367
236508
  sendNickname: email.sendNickname,
235368
236509
  ...task ? { taskEnabled: task.enable === 1 } : {},
235369
236510
  lastReadAt: Date.now()
235370
- }
236511
+ },
236512
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["emailConfigSnapshot"], Date.now())
235371
236513
  } });
235372
236514
  this.ctx.logger.info("email-push: read camera email config", {
235373
236515
  tags: { deviceId: this.id },
@@ -235549,7 +236691,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235549
236691
  waitAfterWakeMs: 2500,
235550
236692
  attempts: 2
235551
236693
  });
235552
- await sleep$1(1500);
236694
+ await sleep$2(1500);
235553
236695
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
235554
236696
  const CONFIRM_TIMEOUT_MS = 1e4;
235555
236697
  const CONFIRM_POLL_MS = 1e3;
@@ -235579,7 +236721,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235579
236721
  break;
235580
236722
  }
235581
236723
  }
235582
- await sleep$1(CONFIRM_POLL_MS);
236724
+ await sleep$2(CONFIRM_POLL_MS);
235583
236725
  }
235584
236726
  const confirmSource = parent !== null ? "hub-summary" : "sleep-poll";
235585
236727
  if (observedAwake && this.commitSleepState(false, confirmSource)) {
@@ -235811,7 +236953,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235811
236953
  async watchHubChildAwake(parent) {
235812
236954
  const deadline = Date.now() + 45e3;
235813
236955
  while (Date.now() < deadline) {
235814
- await sleep$1(5e3);
236956
+ await sleep$2(5e3);
235815
236957
  try {
235816
236958
  if ((await (await parent.getApi()).getNvrChannelsSummary({ channels: [this.getChannel()] })).devices.find((d) => d.channel === this.getChannel())?.sleeping === false) {
235817
236959
  if (this.commitSleepState(false, "hub-summary")) {
@@ -236774,7 +237916,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236774
237916
  value,
236775
237917
  fetchedAt: Date.now()
236776
237918
  }
236777
- }
237919
+ },
237920
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["capOptionsSnapshot"], Date.now())
236778
237921
  } });
236779
237922
  } catch (err) {
236780
237923
  this.ctx.logger.debug("cap options persist failed", {
@@ -237839,7 +238982,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237839
238982
  this.ctx.logger.info("intercom: cam sleeping — waking up before talk session", { tags: { deviceId: this.id } });
237840
238983
  try {
237841
238984
  await api.wakeUp(channel, { waitAfterWakeMs: 2e3 });
237842
- await sleep$1(1e3);
238985
+ await sleep$2(1e3);
237843
238986
  } catch (err) {
237844
238987
  this.ctx.logger.warn("intercom: wakeUp failed — proceeding anyway", { meta: { error: err instanceof Error ? err.message : String(err) } });
237845
238988
  }
@@ -238139,13 +239282,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
238139
239282
  await api.setAutoFocus(channel, enabled ? 0 : 1);
238140
239283
  try {
238141
239284
  const a = (await api.getAutoFocus(channel, { timeoutMs: 1500 }))?.body?.AutoFocus;
238142
- if (a) await this.config.setAll({ deviceCache: {
238143
- ...this.config.get("deviceCache"),
238144
- autoFocusSnapshot: {
238145
- enabled: typeof a.disable === "number" ? a.disable === 0 : null,
238146
- supported: true
238147
- }
238148
- } });
239285
+ if (a) {
239286
+ const afCurrent = this.config.get("deviceCache");
239287
+ await this.config.setAll({ deviceCache: {
239288
+ ...afCurrent,
239289
+ autoFocusSnapshot: {
239290
+ enabled: typeof a.disable === "number" ? a.disable === 0 : null,
239291
+ supported: true
239292
+ },
239293
+ snapshotFetchedAt: stampSnapshotFreshness(afCurrent?.snapshotFetchedAt, ["autoFocusSnapshot"], Date.now())
239294
+ } });
239295
+ }
238149
239296
  } catch {}
238150
239297
  }
238151
239298
  };
@@ -239510,13 +240657,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
239510
240657
  const imgSnap = cache?.imageSnapshot ?? {};
239511
240658
  const netSnap = cache?.netPortSnapshot ?? {};
239512
240659
  const ntpSnap = cache?.ntpSnapshot ?? {};
240660
+ let kickedFullSnapshotRefresh = false;
239513
240661
  if (this.hasIncompleteSettingsCache()) {
239514
240662
  const now = Date.now();
239515
240663
  if (now - this.lastSettingsSnapshotRetryAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
239516
240664
  this.lastSettingsSnapshotRetryAt = now;
240665
+ kickedFullSnapshotRefresh = true;
239517
240666
  this.refreshParentSettingsSnapshot().catch(() => {});
239518
240667
  }
239519
240668
  }
240669
+ const osdSnap = cache?.osdSnapshot;
240670
+ if (!kickedFullSnapshotRefresh && isOsdSnapshotStale(osdSnap, Date.now())) {
240671
+ const now = Date.now();
240672
+ if (now - this.lastOsdRevalidateKickAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
240673
+ this.lastOsdRevalidateKickAt = now;
240674
+ this.refreshParentSettingsSnapshot(new Set(["osd"])).catch(() => {});
240675
+ }
240676
+ }
240677
+ const osdValues = resolveOsdValues(osdSnap);
239520
240678
  const sessSnap = this.sessionsSnapshot;
239521
240679
  const sessStale = sessSnap === null || Date.now() - sessSnap.ts > 6e4;
239522
240680
  if (!this.isBattery && sessStale) this.refreshSessionsSnapshot().catch((err) => {
@@ -240063,45 +241221,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240063
241221
  }] : []
240064
241222
  ]
240065
241223
  },
240066
- {
240067
- id: "osd",
240068
- tab: "image",
240069
- title: "OSD overlay",
240070
- 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.",
240071
- columns: 2,
240072
- fields: [
240073
- {
240074
- type: "boolean",
240075
- key: "osdChannelEnabled",
240076
- label: "Channel name overlay",
240077
- default: cache?.osdSnapshot?.channelEnabled ?? true,
240078
- style: "switch"
240079
- },
240080
- {
240081
- type: "text",
240082
- key: "osdChannelName",
240083
- label: "Channel name",
240084
- description: "Text shown in the channel-name overlay.",
240085
- default: cache?.osdSnapshot?.channelName ?? "",
240086
- placeholder: "Front door"
240087
- },
240088
- {
240089
- type: "boolean",
240090
- key: "osdTimeEnabled",
240091
- label: "Timestamp overlay",
240092
- default: cache?.osdSnapshot?.timeEnabled ?? true,
240093
- style: "switch"
240094
- },
240095
- {
240096
- type: "boolean",
240097
- key: "osdWatermark",
240098
- label: "Watermark",
240099
- description: "The Reolink logo watermark overlay.",
240100
- default: cache?.osdSnapshot?.watermark ?? false,
240101
- style: "switch"
240102
- }
240103
- ]
240104
- },
241224
+ buildOsdSection(osdSnap, osdValues, {
241225
+ sleeping: this.isBattery && this.sleeping,
241226
+ now: Date.now()
241227
+ }),
240105
241228
  {
240106
241229
  id: "privacy-mask",
240107
241230
  tab: "image",
@@ -240391,6 +241514,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240391
241514
  ]
240392
241515
  }]
240393
241516
  },
241517
+ buildSnapshotFreshnessSection(cache, {
241518
+ sleeping: this.isBattery && this.sleeping,
241519
+ now: Date.now()
241520
+ }),
240394
241521
  ...this.buildSessionsTabSections(),
240395
241522
  ...this.buildEmailPushSection(),
240396
241523
  ...this.buildEmailTabSections()
@@ -240457,10 +241584,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240457
241584
  irLightsBrightness: this.config.get("irLightsBrightness") ?? 128,
240458
241585
  statusLedEnabled: this.config.get("statusLedEnabled") ?? cache?.ledSnapshot?.statusEnabled ?? true,
240459
241586
  doorbellLedEnabled: this.config.get("doorbellLedEnabled") ?? cache?.ledSnapshot?.doorbellEnabled ?? true,
240460
- osdChannelEnabled: this.config.get("osdChannelEnabled") ?? cache?.osdSnapshot?.channelEnabled ?? true,
240461
- osdChannelName: this.config.get("osdChannelName") ?? cache?.osdSnapshot?.channelName ?? "",
240462
- osdTimeEnabled: this.config.get("osdTimeEnabled") ?? cache?.osdSnapshot?.timeEnabled ?? true,
240463
- osdWatermark: this.config.get("osdWatermark") ?? cache?.osdSnapshot?.watermark ?? false,
241587
+ osdChannelEnabled: osdValues.osdChannelEnabled,
241588
+ osdChannelName: osdValues.osdChannelName,
241589
+ osdTimeEnabled: osdValues.osdTimeEnabled,
241590
+ osdWatermark: osdValues.osdWatermark,
240464
241591
  audioVolume: this.config.get("audioVolume") ?? 50,
240465
241592
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
240466
241593
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
@@ -240479,7 +241606,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240479
241606
  });
240480
241607
  }
240481
241608
  async applySettingsPatch(patch) {
240482
- const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, ...rest } = patch;
241609
+ const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdTimeEnabled, osdWatermark, ...rest } = patch;
240483
241610
  const emailFields = {
240484
241611
  emailSmtpServer,
240485
241612
  emailSmtpPort,
@@ -240498,8 +241625,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240498
241625
  meta: { error: err instanceof Error ? err.message : String(err) }
240499
241626
  });
240500
241627
  });
240501
- if (Object.keys(rest).length === 0) return;
240502
- await this.config.setAll(rest);
241628
+ const hasOsdPatch = [
241629
+ osdChannelEnabled,
241630
+ osdChannelName,
241631
+ osdTimeEnabled,
241632
+ osdWatermark
241633
+ ].some((v) => v !== void 0);
241634
+ if (Object.keys(rest).length === 0 && !hasOsdPatch) return;
241635
+ if (Object.keys(rest).length > 0) await this.config.setAll(rest);
240503
241636
  const typedPatch = patch;
240504
241637
  if (typedPatch.host || typedPatch.port || typedPatch.username || typedPatch.password) {
240505
241638
  await this.disconnectAll();
@@ -240692,12 +241825,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240692
241825
  } catch (err) {
240693
241826
  this.ctx.logger.warn("ir-lights push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
240694
241827
  }
240695
- if ([
240696
- "osdChannelEnabled",
240697
- "osdChannelName",
240698
- "osdTimeEnabled",
240699
- "osdWatermark"
240700
- ].some((k) => k in patch)) try {
241828
+ if (hasOsdPatch) try {
240701
241829
  const api = await this.ensureApi();
240702
241830
  const channel = this.getChannel();
240703
241831
  const current = await api.getOsd(channel);
@@ -240715,13 +241843,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240715
241843
  watermark: current.watermark ?? 0
240716
241844
  };
240717
241845
  if (current.bgcolor !== void 0) next.bgcolor = current.bgcolor;
240718
- if (typeof typedPatch.osdChannelEnabled === "boolean") next.osdChannel.enable = typedPatch.osdChannelEnabled ? 1 : 0;
240719
- if (typeof typedPatch.osdChannelName === "string") next.osdChannel.name = typedPatch.osdChannelName;
240720
- if (typeof typedPatch.osdTimeEnabled === "boolean") next.osdTime.enable = typedPatch.osdTimeEnabled ? 1 : 0;
240721
- if (typeof typedPatch.osdWatermark === "boolean") next.watermark = typedPatch.osdWatermark ? 1 : 0;
241846
+ if (typeof osdChannelEnabled === "boolean") next.osdChannel.enable = osdChannelEnabled ? 1 : 0;
241847
+ if (typeof osdChannelName === "string") next.osdChannel.name = osdChannelName;
241848
+ if (typeof osdTimeEnabled === "boolean") next.osdTime.enable = osdTimeEnabled ? 1 : 0;
241849
+ if (typeof osdWatermark === "boolean") next.watermark = osdWatermark ? 1 : 0;
240722
241850
  await api.setOsd(channel, next);
240723
241851
  } catch (err) {
240724
- this.ctx.logger.warn("osd push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
241852
+ this.ctx.logger.warn("osd push failed camera keeps its current overlay state", {
241853
+ tags: { deviceId: this.id },
241854
+ meta: { error: err instanceof Error ? err.message : String(err) }
241855
+ });
240725
241856
  }
240726
241857
  if ([
240727
241858
  "audioVolume",
@@ -240862,7 +241993,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240862
241993
  }
240863
241994
  const changedSlices = slicesForPatch(patch);
240864
241995
  if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
240865
- this.ctx.logger.debug("reolink targeted settings refresh failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
241996
+ this.ctx.logger.info("reolink targeted settings refresh failed snapshots left stale", {
241997
+ tags: { deviceId: this.id },
241998
+ meta: {
241999
+ slices: [...changedSlices],
242000
+ error: err instanceof Error ? err.message : String(err)
242001
+ }
242002
+ });
240866
242003
  });
240867
242004
  }
240868
242005
  /**
@@ -243465,6 +244602,44 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243465
244602
  return regs;
243466
244603
  }
243467
244604
  /**
244605
+ * Compose the provider registrations from `onInitialize()` with this
244606
+ * addon's customActions catalog. `BaseDeviceProvider.onInitialize` is
244607
+ * typed `ProviderRegistration[]` (eleven sibling providers push onto it),
244608
+ * so the catalog joins at the `initialize()` seam instead — the runner
244609
+ * consumes the merged `AddonInitResult` exactly as it does for
244610
+ * addon-benchmark / addon-notifiers.
244611
+ *
244612
+ * Admin-only debug surface (D346): `debugRawRead` returns the lib's
244613
+ * response for one snapshot slice UNPROJECTED, next to the persisted
244614
+ * snapshot + its freshness. Registered as an addon customAction because
244615
+ * `addons.custom` is the one operator surface that enforces the
244616
+ * per-action `auth: 'admin'` server-side and validates output —
244617
+ * `deviceManager.runDeviceAction` does neither. The hub reads the static
244618
+ * catalog from this bundle's `customActions` export (see `index.ts`);
244619
+ * the child registers the handlers returned here.
244620
+ */
244621
+ async initialize(context) {
244622
+ const base = await super.initialize(context);
244623
+ return {
244624
+ providers: base && base.providers ? base.providers : [],
244625
+ customActions: reolinkDebugActions,
244626
+ actionHandlers: { debugRawRead: (input) => this.debugRawRead(input) }
244627
+ };
244628
+ }
244629
+ /**
244630
+ * Route a `debugRawRead` custom action to the owning camera. Covers both
244631
+ * standalone cameras and NVR-adopted children — every live ReolinkCamera
244632
+ * in this runner is in the kernel device registry. Read-only end to end
244633
+ * (see `raw-read.ts`); a hub device or an unknown id refuses with a
244634
+ * message that names what it looked for.
244635
+ */
244636
+ async debugRawRead(input) {
244637
+ const dev = this.ctx.kernel.deviceRegistry?.getById(input.deviceId);
244638
+ if (dev === void 0 || dev === null) throw new Error(`debugRawRead: device ${input.deviceId} not found in the reolink runner's registry`);
244639
+ 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`);
244640
+ return dev.debugRawRead(input.slice);
244641
+ }
244642
+ /**
243468
244643
  * Handle a broker-issued source-refresh request. With the lazy-publish
243469
244644
  * model the broker always emits this on first dial of a
243470
244645
  * `lazy:rfc4571:` placeholder URL — and re-emits it whenever the
@@ -243750,6 +244925,7 @@ exports.collectNativeDiagnostics = collectNativeDiagnostics;
243750
244925
  exports.collectNvrDiagnostics = collectNvrDiagnostics;
243751
244926
  exports.createDiagnosticsBundle = createDiagnosticsBundle;
243752
244927
  exports.reolinkCameraSchema = reolinkCameraSchema;
244928
+ exports.reolinkDebugActions = reolinkDebugActions;
243753
244929
  exports.runAllDiagnosticsConsecutively = runAllDiagnosticsConsecutively;
243754
244930
  exports.runMultifocalDiagnosticsConsecutively = runMultifocalDiagnosticsConsecutively;
243755
244931
  exports.sampleStreams = sampleStreams;