@camstack/addon-provider-reolink 1.2.82 → 1.2.84

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({
@@ -12411,6 +12411,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
12411
12411
  filePath: string(),
12412
12412
  content: string()
12413
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
+ }
12414
12436
  function deviceCustomAction(input, output, options) {
12415
12437
  return {
12416
12438
  input,
@@ -13339,7 +13361,26 @@ var DiscoveryCandidateSchema = object({
13339
13361
  * identity ahead of adoption. Rendering metadata (unit, precision)
13340
13362
  * flows live through the cap STATUS SLICE after adoption.
13341
13363
  */
13342
- sourceInfo: SourceInfoSchema.optional()
13364
+ sourceInfo: SourceInfoSchema.optional(),
13365
+ /**
13366
+ * Set when this candidate is a device the provider ALREADY owns.
13367
+ *
13368
+ * A scan cannot generally produce the identity a device was onboarded under
13369
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
13370
+ * comparison never matches and an owned device looks addable. Re-adopting one
13371
+ * overwrites its config with scan-derived values — that is how a Home Hub's
13372
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
13373
+ * three child cameras offline for four hours.
13374
+ *
13375
+ * A provider that can recognise its own devices says so here. Absent means
13376
+ * "not recognised", which is not the same as "known to be new" — a provider
13377
+ * that cannot tell simply never sets it.
13378
+ */
13379
+ alreadyOnboarded: boolean().optional(),
13380
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
13381
+ onboardedDeviceId: number().optional(),
13382
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
13383
+ onboardedName: string().optional()
13343
13384
  });
13344
13385
  /**
13345
13386
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -13395,6 +13436,35 @@ var deviceProviderCapability = {
13395
13436
  name: string(),
13396
13437
  type: string()
13397
13438
  }))),
13439
+ /**
13440
+ * Tear down and reconstruct ONE device in place from its persisted rows —
13441
+ * touching no other device this provider owns.
13442
+ *
13443
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
13444
+ * migrated numbers: after `swapIds` the runner's live instance still
13445
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
13446
+ * registrations and its log tags), and a live object cannot be renumbered.
13447
+ * Before this method the only flush was restarting the whole owning addon
13448
+ * — which took every camera the provider owns down with it (28 devices
13449
+ * for one migrated camera, measured 2026-09-04, and the morning of the
13450
+ * same day ~27 devices' native caps did not come back on their own).
13451
+ *
13452
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
13453
+ * that changes. The reply carries the id the device answers on NOW.
13454
+ * Implemented once in `BaseDeviceProvider` — decommission the live
13455
+ * instance (if any), then re-create from the persisted row: the same
13456
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
13457
+ * An RPC, never an event: a dropped event would leave the runner writing
13458
+ * against the wrong camera (D8).
13459
+ *
13460
+ * Construction can dial hardware, and the migrated source is
13461
+ * characteristically dead — the timeout covers a full activate window
13462
+ * rather than the 60 s default.
13463
+ */
13464
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
13465
+ kind: "mutation",
13466
+ timeoutMs: 3 * 6e4
13467
+ }),
13398
13468
  supportsDiscovery: method(object({}), boolean()),
13399
13469
  /**
13400
13470
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13722,7 +13792,8 @@ method(object({
13722
13792
  targetId: number()
13723
13793
  }), MigrateDeviceResultSchema, {
13724
13794
  kind: "mutation",
13725
- auth: "admin"
13795
+ auth: "admin",
13796
+ timeoutMs: 12 * 6e4
13726
13797
  }), 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({
13727
13798
  deviceId: number(),
13728
13799
  name: string()
@@ -33522,6 +33593,147 @@ var BaseDevice = class {
33522
33593
  }
33523
33594
  };
33524
33595
  /**
33596
+ * Delays before retry rounds 1..N — the round count IS the bound.
33597
+ * 10 s catches "the hub was busy for a moment"; the full schedule
33598
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
33599
+ * per attempt) covers a device-manager lock held for minutes — the
33600
+ * 2026-09-04 outage's migration hold was ~3.5 min.
33601
+ */
33602
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
33603
+ 1e4,
33604
+ 3e4,
33605
+ 9e4
33606
+ ];
33607
+ /** Abortable sleep — resolves early (never rejects) on abort. */
33608
+ function sleep$1(ms, signal) {
33609
+ return new Promise((resolve) => {
33610
+ if (signal.aborted) {
33611
+ resolve();
33612
+ return;
33613
+ }
33614
+ const onAbort = () => {
33615
+ clearTimeout(timer);
33616
+ resolve();
33617
+ };
33618
+ const timer = setTimeout(() => {
33619
+ signal.removeEventListener("abort", onAbort);
33620
+ resolve();
33621
+ }, ms);
33622
+ timer.unref?.();
33623
+ signal.addEventListener("abort", onAbort, { once: true });
33624
+ });
33625
+ }
33626
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
33627
+ * not reject (callers wrap their own try/catch). */
33628
+ async function runWithConcurrency(items, width, fn) {
33629
+ const queue = [...items];
33630
+ const laneCount = Math.max(1, Math.min(width, queue.length));
33631
+ const lane = async () => {
33632
+ for (;;) {
33633
+ const item = queue.shift();
33634
+ if (item === void 0) return;
33635
+ await fn(item);
33636
+ }
33637
+ };
33638
+ await Promise.all(Array.from({ length: laneCount }, lane));
33639
+ }
33640
+ var DeviceRestoreRetryScheduler = class {
33641
+ #logger;
33642
+ #attempt;
33643
+ #onPermanentFailure;
33644
+ #delaysMs;
33645
+ #concurrency;
33646
+ #now;
33647
+ #abort = new AbortController();
33648
+ constructor(options) {
33649
+ this.#logger = options.logger;
33650
+ this.#attempt = options.attempt;
33651
+ this.#onPermanentFailure = options.onPermanentFailure;
33652
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
33653
+ this.#concurrency = options.concurrency ?? 4;
33654
+ this.#now = options.now ?? Date.now;
33655
+ }
33656
+ /** Stop retrying (shutdown). Pending entries are NOT marked
33657
+ * permanently failed — the next boot restores them from disk. */
33658
+ cancel() {
33659
+ this.#abort.abort();
33660
+ }
33661
+ /**
33662
+ * Run the bounded retry rounds. Resolves when every entry has either
33663
+ * restored, been marked permanently failed, or the scheduler was
33664
+ * cancelled. Never rejects.
33665
+ */
33666
+ async run(initialFailures) {
33667
+ let pending = initialFailures.map((failure) => ({
33668
+ saved: failure.saved,
33669
+ lastError: failure.error,
33670
+ attempts: 1
33671
+ }));
33672
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
33673
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
33674
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
33675
+ if (this.#abort.signal.aborted) break;
33676
+ pending = await this.#runRound(pending, round);
33677
+ }
33678
+ if (this.#abort.signal.aborted) return [];
33679
+ const terminal = pending.map((entry) => ({
33680
+ deviceId: entry.saved.id,
33681
+ stableId: entry.saved.stableId,
33682
+ type: String(entry.saved.type),
33683
+ attempts: entry.attempts,
33684
+ lastError: entry.lastError,
33685
+ failedAt: this.#now()
33686
+ }));
33687
+ for (const failure of terminal) this.#onPermanentFailure(failure);
33688
+ return terminal;
33689
+ }
33690
+ /** One retry round: parents first (phase 0), then hub-adopted
33691
+ * children (phase 1) — a child's attempt depends on its parent
33692
+ * having landed, exactly like the initial two-pass restore. */
33693
+ async #runRound(pending, round) {
33694
+ const next = [];
33695
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
33696
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
33697
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
33698
+ if (this.#abort.signal.aborted) {
33699
+ next.push(entry);
33700
+ return;
33701
+ }
33702
+ const attemptNo = entry.attempts + 1;
33703
+ try {
33704
+ await this.#attempt(entry.saved);
33705
+ this.#logger.info("Device restored on retry", {
33706
+ tags: {
33707
+ deviceId: entry.saved.id,
33708
+ stableId: entry.saved.stableId
33709
+ },
33710
+ meta: { attempt: attemptNo }
33711
+ });
33712
+ } catch (err) {
33713
+ const lastError = err instanceof Error ? err.message : String(err);
33714
+ const remainingRetries = this.#delaysMs.length - (round + 1);
33715
+ this.#logger.warn("Device restore retry failed", {
33716
+ tags: {
33717
+ deviceId: entry.saved.id,
33718
+ stableId: entry.saved.stableId
33719
+ },
33720
+ meta: {
33721
+ attempt: attemptNo,
33722
+ remainingRetries,
33723
+ error: lastError
33724
+ }
33725
+ });
33726
+ next.push({
33727
+ saved: entry.saved,
33728
+ lastError,
33729
+ attempts: attemptNo
33730
+ });
33731
+ }
33732
+ });
33733
+ return next;
33734
+ }
33735
+ };
33736
+ /**
33525
33737
  * Convert an IDevice to the flat DeviceSummary shape expected by the
33526
33738
  * device-provider cap router. Shared across all providers.
33527
33739
  */
@@ -33570,6 +33782,7 @@ var BaseDeviceProvider = class extends BaseAddon {
33570
33782
  }];
33571
33783
  }
33572
33784
  async onShutdown() {
33785
+ this.cancelRestoreRetries();
33573
33786
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
33574
33787
  for (const device of devices) try {
33575
33788
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -33587,9 +33800,16 @@ var BaseDeviceProvider = class extends BaseAddon {
33587
33800
  async start() {}
33588
33801
  async stop() {}
33589
33802
  async getStatus() {
33803
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
33804
+ const summary = this.restoreFailureSummary();
33805
+ if (summary === null) return {
33806
+ connected: true,
33807
+ deviceCount: all.length
33808
+ };
33590
33809
  return {
33591
33810
  connected: true,
33592
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
33811
+ deviceCount: all.length,
33812
+ error: summary
33593
33813
  };
33594
33814
  }
33595
33815
  async getDevices() {
@@ -33679,8 +33899,137 @@ var BaseDeviceProvider = class extends BaseAddon {
33679
33899
  };
33680
33900
  }
33681
33901
  async restoreDevices(savedDevices) {
33682
- await this.onRestoreDevices(savedDevices);
33683
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33902
+ const report = await this.onRestoreDevices(savedDevices);
33903
+ if (savedDevices.length === 0) return;
33904
+ if (report && report.failedCount > 0) {
33905
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33906
+ return;
33907
+ }
33908
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33909
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33910
+ }
33911
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33912
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33913
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33914
+ * never re-stampede full-width while the initial pass does (D167). */
33915
+ restoreRetryConcurrency = 4;
33916
+ _restoreRetryScheduler = null;
33917
+ _restoreRetryCompletion = null;
33918
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33919
+ /** Settles when the background retry rounds finish (or `null` when
33920
+ * nothing failed). Exposed for tests and subclass diagnostics —
33921
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33922
+ * with the devices that restored, and a late success is announced
33923
+ * through the `native-cap-change` → `updateCaps` path. */
33924
+ get restoreRetryCompletion() {
33925
+ return this._restoreRetryCompletion;
33926
+ }
33927
+ /** Devices that exhausted the retry bound this process lifetime. */
33928
+ get permanentRestoreFailures() {
33929
+ return [...this._permanentRestoreFailures.values()];
33930
+ }
33931
+ /** One-line operator-facing summary for `getStatus().error`, or
33932
+ * `null` when every device restored. */
33933
+ restoreFailureSummary() {
33934
+ if (this._permanentRestoreFailures.size === 0) return null;
33935
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33936
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33937
+ }
33938
+ cancelRestoreRetries() {
33939
+ this._restoreRetryScheduler?.cancel();
33940
+ this._restoreRetryScheduler = null;
33941
+ }
33942
+ recordPermanentRestoreFailure(failure) {
33943
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33944
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33945
+ tags: {
33946
+ deviceId: failure.deviceId,
33947
+ stableId: failure.stableId
33948
+ },
33949
+ meta: {
33950
+ type: failure.type,
33951
+ attempts: failure.attempts,
33952
+ error: failure.lastError
33953
+ }
33954
+ });
33955
+ }
33956
+ scheduleRestoreRetries(failures, attempt) {
33957
+ const scheduler = new DeviceRestoreRetryScheduler({
33958
+ logger: this.ctx.logger,
33959
+ delaysMs: this.restoreRetryDelaysMs,
33960
+ concurrency: this.restoreRetryConcurrency,
33961
+ attempt,
33962
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33963
+ });
33964
+ this._restoreRetryScheduler = scheduler;
33965
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33966
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33967
+ });
33968
+ }
33969
+ /**
33970
+ * Tear down and reconstruct ONE device from its persisted rows — the
33971
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33972
+ * and no other device this provider owns is disturbed.
33973
+ *
33974
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33975
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33976
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33977
+ * whatever number the row carries NOW. The teardown is `decommission` —
33978
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33979
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33980
+ * the boot restore's own `create()` path, including its pass 2: first-class
33981
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33982
+ * parent by the cascade and must be re-created explicitly, because only
33983
+ * accessory children come back through `getAccessoryChildren()`.
33984
+ *
33985
+ * Reloading an accessory child directly is refused (no device class) —
33986
+ * reload its parent instead.
33987
+ */
33988
+ async reloadDevice(input) {
33989
+ const { stableId } = input;
33990
+ const devices = this.ctx.kernel.devices;
33991
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33992
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33993
+ if (live) await devices.decommission(live.id);
33994
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33995
+ addonId: this.addonId,
33996
+ stableId
33997
+ });
33998
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33999
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
34000
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
34001
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
34002
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
34003
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
34004
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
34005
+ for (const row of rows) {
34006
+ if (row.parentDeviceId !== id) continue;
34007
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
34008
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
34009
+ if (!ChildClass) continue;
34010
+ try {
34011
+ await devices.create(row.stableId, ChildClass, {}, id);
34012
+ } catch (err) {
34013
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
34014
+ tags: {
34015
+ deviceId: row.id,
34016
+ stableId: row.stableId
34017
+ },
34018
+ meta: {
34019
+ parentDeviceId: id,
34020
+ error: err instanceof Error ? err.message : String(err)
34021
+ }
34022
+ });
34023
+ }
34024
+ }
34025
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
34026
+ tags: { deviceId: id },
34027
+ meta: {
34028
+ stableId,
34029
+ type: meta.type
34030
+ }
34031
+ });
34032
+ return { deviceId: id };
33684
34033
  }
33685
34034
  /**
33686
34035
  * Restore devices from persisted state. Two-pass:
@@ -33706,55 +34055,125 @@ var BaseDeviceProvider = class extends BaseAddon {
33706
34055
  * accessory-spawn flow handles via the parent's
33707
34056
  * `getAccessoryChildren()`. Override only when the default doesn't
33708
34057
  * fit.
34058
+ *
34059
+ * A row that fails either pass is NOT terminal (D347): it is handed
34060
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
34061
+ * Only after the bound is exhausted is the device marked permanently
34062
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
34063
+ * `getStatus().error`.
33709
34064
  */
34065
+ /**
34066
+ * Repair a row's PERSISTED config blob immediately before it is restored.
34067
+ * Default: no-op — most providers have nothing to heal.
34068
+ *
34069
+ * This exists because a restored device self-hydrates from the DB: `create()`
34070
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
34071
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
34072
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
34073
+ * been emptied failed all four bounded attempts against fields
34074
+ * (`host`, `password`) it inherits from its parent and never dials itself.
34075
+ *
34076
+ * Implementations get every saved row, so a child can read its parent's blob.
34077
+ * A heal that throws is treated like any other restore failure: retried under
34078
+ * the bound, then reported — never swallowed.
34079
+ */
34080
+ async healSavedConfig(_saved, _allSaved) {}
33710
34081
  async onRestoreDevices(savedDevices) {
33711
34082
  const restored = /* @__PURE__ */ new Set();
34083
+ const failures = [];
34084
+ const attemptRestore = async (saved) => {
34085
+ if (restored.has(saved.id)) return;
34086
+ const Class = this.deviceClasses[saved.type];
34087
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34088
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
34089
+ await this.healSavedConfig(saved, savedDevices);
34090
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
34091
+ restored.add(saved.id);
34092
+ };
33712
34093
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
33713
34094
  const restoreOne = async (saved) => {
33714
- const Class = this.deviceClasses[saved.type];
33715
- if (!Class) {
34095
+ if (!this.deviceClasses[saved.type]) {
33716
34096
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
33717
- tags: { stableId: saved.stableId },
34097
+ tags: {
34098
+ deviceId: saved.id,
34099
+ stableId: saved.stableId
34100
+ },
33718
34101
  meta: { type: saved.type }
33719
34102
  });
33720
34103
  return;
33721
34104
  }
33722
34105
  try {
33723
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
33724
- restored.add(saved.id);
34106
+ await attemptRestore(saved);
33725
34107
  } catch (err) {
33726
- this.ctx.logger.warn("Failed to restore device", {
33727
- tags: { stableId: saved.stableId },
34108
+ const error = err instanceof Error ? err.message : String(err);
34109
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
34110
+ tags: {
34111
+ deviceId: saved.id,
34112
+ stableId: saved.stableId
34113
+ },
33728
34114
  meta: {
33729
34115
  type: saved.type,
33730
- error: err instanceof Error ? err.message : String(err)
34116
+ attempt: 1,
34117
+ error
33731
34118
  }
33732
34119
  });
34120
+ failures.push({
34121
+ saved,
34122
+ error
34123
+ });
33733
34124
  }
33734
34125
  };
33735
34126
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
34127
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
33736
34128
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
33737
34129
  for (const saved of childRows) {
33738
- const Class = this.deviceClasses[saved.type];
33739
- if (!Class) continue;
34130
+ if (!this.deviceClasses[saved.type]) continue;
33740
34131
  if (saved.parentDeviceId === null) continue;
33741
- if (!restored.has(saved.parentDeviceId)) continue;
33742
- try {
33743
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33744
- restored.add(saved.id);
33745
- } catch (err) {
33746
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
34132
+ if (restored.has(saved.parentDeviceId)) {
34133
+ try {
34134
+ await attemptRestore(saved);
34135
+ } catch (err) {
34136
+ const error = err instanceof Error ? err.message : String(err);
34137
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
34138
+ tags: {
34139
+ deviceId: saved.id,
34140
+ stableId: saved.stableId,
34141
+ parentDeviceId: saved.parentDeviceId
34142
+ },
34143
+ meta: {
34144
+ type: saved.type,
34145
+ attempt: 1,
34146
+ error
34147
+ }
34148
+ });
34149
+ failures.push({
34150
+ saved,
34151
+ error
34152
+ });
34153
+ }
34154
+ continue;
34155
+ }
34156
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
34157
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
33747
34158
  tags: {
34159
+ deviceId: saved.id,
33748
34160
  stableId: saved.stableId,
33749
34161
  parentDeviceId: saved.parentDeviceId
33750
34162
  },
33751
- meta: {
33752
- type: saved.type,
33753
- error: err instanceof Error ? err.message : String(err)
33754
- }
34163
+ meta: { type: saved.type }
34164
+ });
34165
+ failures.push({
34166
+ saved,
34167
+ error: `parent device ${saved.parentDeviceId} not restored`
33755
34168
  });
34169
+ continue;
33756
34170
  }
33757
34171
  }
34172
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
34173
+ return {
34174
+ restoredCount: restored.size,
34175
+ failedCount: failures.length
34176
+ };
33758
34177
  }
33759
34178
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
33760
34179
  toSummary(device) {
@@ -35669,6 +36088,12 @@ Object.freeze({
35669
36088
  addonId: null,
35670
36089
  access: "view"
35671
36090
  },
36091
+ "deviceProvider.reloadDevice": {
36092
+ capName: "device-provider",
36093
+ capScope: "system",
36094
+ addonId: null,
36095
+ access: "create"
36096
+ },
35672
36097
  "deviceProvider.start": {
35673
36098
  capName: "device-provider",
35674
36099
  capScope: "system",
@@ -230692,6 +231117,453 @@ var IntercomFailureReport = class {
230692
231117
  /** The process-wide instance every camera in this addon notes into. */
230693
231118
  var intercomFailureReport = new IntercomFailureReport();
230694
231119
  //#endregion
231120
+ //#region src/snapshot-freshness.ts
231121
+ /**
231122
+ * The snapshot groups the freshness panel reports on, with the Baichuan
231123
+ * read behind each. Order is the display order.
231124
+ */
231125
+ var SNAPSHOT_GROUPS = [
231126
+ {
231127
+ key: "imageSnapshot",
231128
+ label: "Image (getVideoInput)"
231129
+ },
231130
+ {
231131
+ key: "motionSnapshot",
231132
+ label: "Motion (getMotionAlarm)"
231133
+ },
231134
+ {
231135
+ key: "aiSensitivitySnapshot",
231136
+ label: "AI sensitivity (getAiDetectionFull)"
231137
+ },
231138
+ {
231139
+ key: "encSnapshot",
231140
+ label: "Encoder (getEnc)"
231141
+ },
231142
+ {
231143
+ key: "encOptionsSnapshot",
231144
+ label: "Encoder options (getEncOptions)"
231145
+ },
231146
+ {
231147
+ key: "maskSnapshot",
231148
+ label: "Privacy mask (getMask)"
231149
+ },
231150
+ {
231151
+ key: "audioNoiseSnapshot",
231152
+ label: "Audio noise (getAudioNoise)"
231153
+ },
231154
+ {
231155
+ key: "autoFocusSnapshot",
231156
+ label: "Auto-focus (getAutoFocus)"
231157
+ },
231158
+ {
231159
+ key: "netPortSnapshot",
231160
+ label: "Network ports (getNetPort)"
231161
+ },
231162
+ {
231163
+ key: "ntpSnapshot",
231164
+ label: "NTP (getNtp)"
231165
+ },
231166
+ {
231167
+ key: "systemGeneralSnapshot",
231168
+ label: "System general (getSystemGeneral)"
231169
+ },
231170
+ {
231171
+ key: "osdSnapshot",
231172
+ label: "OSD overlay (getOsd)"
231173
+ },
231174
+ {
231175
+ key: "ledSnapshot",
231176
+ label: "LEDs (getIrLights)"
231177
+ },
231178
+ {
231179
+ key: "pirSnapshot",
231180
+ label: "PIR (getPirInfo)"
231181
+ },
231182
+ {
231183
+ key: "autoRebootSnapshot",
231184
+ label: "Auto reboot (getAutoReboot)"
231185
+ },
231186
+ {
231187
+ key: "emailConfigSnapshot",
231188
+ label: "Email/SMTP (getEmail)"
231189
+ },
231190
+ {
231191
+ key: "capOptionsSnapshot",
231192
+ label: "Cap option probes (getOptions)"
231193
+ }
231194
+ ];
231195
+ /**
231196
+ * Merge freshness stamps for the snapshot keys a persist actually wrote.
231197
+ * Returns a NEW map (immutability) — previous stamps for untouched groups
231198
+ * survive, written groups are stamped `now`. Call this from the same
231199
+ * `setAll` that writes the snapshots, with exactly the keys being written:
231200
+ * a failed probe writes no snapshot and therefore gets no stamp.
231201
+ */
231202
+ function stampSnapshotFreshness(previous, writtenKeys, now) {
231203
+ const stamped = { ...previous };
231204
+ for (const key of writtenKeys) stamped[key] = now;
231205
+ return stamped;
231206
+ }
231207
+ /**
231208
+ * Resolve the age of one snapshot group from the cache. Sources, in order:
231209
+ * 1. `snapshotFetchedAt[key]` — the generic stamp map;
231210
+ * 2. a group-embedded stamp where one already existed before the map
231211
+ * (`osdSnapshot.fetchedAt`, `emailConfigSnapshot.lastReadAt`,
231212
+ * newest `capOptionsSnapshot[*].fetchedAt`);
231213
+ * 3. otherwise: the group is present but of unknown age.
231214
+ * An absent group is `never` — not-yet-read must never look like read.
231215
+ */
231216
+ function resolveSnapshotAge(cache, key, now) {
231217
+ if ((cache === void 0 ? void 0 : Reflect.get(cache, key)) === void 0) return { state: "never" };
231218
+ const mapStamp = cache?.snapshotFetchedAt?.[key];
231219
+ const stamp = typeof mapStamp === "number" ? mapStamp : embeddedStamp(cache, key);
231220
+ if (typeof stamp !== "number") return { state: "unknown" };
231221
+ return {
231222
+ state: "known",
231223
+ fetchedAt: stamp,
231224
+ ageMs: Math.max(0, now - stamp)
231225
+ };
231226
+ }
231227
+ /** Pre-map stamps some groups already carried; kept as fallback so a legacy
231228
+ * cache written by today's OSD fix still reports a real age. */
231229
+ function embeddedStamp(cache, key) {
231230
+ if (key === "osdSnapshot") {
231231
+ const v = cache?.osdSnapshot?.fetchedAt;
231232
+ return typeof v === "number" ? v : void 0;
231233
+ }
231234
+ if (key === "emailConfigSnapshot") {
231235
+ const v = cache?.emailConfigSnapshot?.lastReadAt;
231236
+ return typeof v === "number" ? v : void 0;
231237
+ }
231238
+ if (key === "capOptionsSnapshot") {
231239
+ const stamps = Object.values(cache?.capOptionsSnapshot ?? {}).map((e) => e?.fetchedAt).filter((v) => typeof v === "number");
231240
+ return stamps.length > 0 ? Math.max(...stamps) : void 0;
231241
+ }
231242
+ }
231243
+ /** Human age: "12 s ago", "3 m ago", "5 h ago", "12 d ago". */
231244
+ function formatSnapshotAge(ageMs) {
231245
+ const s = Math.floor(ageMs / 1e3);
231246
+ if (s < 60) return `${s} s ago`;
231247
+ const m = Math.floor(s / 60);
231248
+ if (m < 60) return `${m} m ago`;
231249
+ const h = Math.floor(m / 60);
231250
+ if (h < 48) return `${h} h ago`;
231251
+ return `${Math.floor(h / 24)} d ago`;
231252
+ }
231253
+ /** One display line for a group. Unknown age is SAID, never smoothed over. */
231254
+ function formatSnapshotAgeLine(label, age) {
231255
+ switch (age.state) {
231256
+ case "never": return `${label}: never read`;
231257
+ case "unknown": return `${label}: age unknown (recorded before per-snapshot freshness tracking)`;
231258
+ case "known": return `${label}: read ${formatSnapshotAge(age.ageMs)} (${new Date(age.fetchedAt).toLocaleString()})`;
231259
+ }
231260
+ }
231261
+ /**
231262
+ * Read-only "Snapshot freshness" section (advanced tab, next to Debug).
231263
+ * Lists every snapshot group with its own age so "is CamStack's belief
231264
+ * current?" is answerable per fact, not per cache. Purely informational:
231265
+ * it triggers no reads — refresh stays on the existing operator-triggered
231266
+ * "Refresh from camera" action and the event-driven paths.
231267
+ */
231268
+ function buildSnapshotFreshnessSection(cache, opts) {
231269
+ const lines = SNAPSHOT_GROUPS.map(({ key, label }) => formatSnapshotAgeLine(label, resolveSnapshotAge(cache, key, opts.now)));
231270
+ const probedAt = cache?.probedAt;
231271
+ const header = typeof probedAt === "number" ? `Feature probe: ${new Date(probedAt).toLocaleString()}` : "Feature probe: never recorded";
231272
+ return {
231273
+ id: "snapshotFreshness",
231274
+ tab: "advanced",
231275
+ title: "Snapshot freshness",
231276
+ 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.",
231277
+ columns: 1,
231278
+ fields: [{
231279
+ type: "info",
231280
+ key: "snapshotFreshness",
231281
+ label: "Per-snapshot read times",
231282
+ 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")}`
231283
+ }]
231284
+ };
231285
+ }
231286
+ var RawReadSliceSchema = _enum([
231287
+ "image",
231288
+ "motion",
231289
+ "ai",
231290
+ "enc",
231291
+ "encOptions",
231292
+ "mask",
231293
+ "audioNoise",
231294
+ "autofocus",
231295
+ "netPort",
231296
+ "ntp",
231297
+ "systemGeneral",
231298
+ "osd",
231299
+ "led",
231300
+ "pir",
231301
+ "autoReboot"
231302
+ ]);
231303
+ /**
231304
+ * The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
231305
+ * in lockstep in both directions. Anything not in this map — email/SMTP
231306
+ * (`getEmail` echoes the camera's SMTP credentials), users, sessions, any
231307
+ * `set*` — cannot be requested.
231308
+ */
231309
+ var RAW_READ_CATALOG = {
231310
+ image: {
231311
+ command: "getVideoInput",
231312
+ snapshotKey: "imageSnapshot",
231313
+ invoke: (api, channel) => api.getVideoInput(channel)
231314
+ },
231315
+ motion: {
231316
+ command: "getMotionAlarm",
231317
+ snapshotKey: "motionSnapshot",
231318
+ invoke: (api, channel) => api.getMotionAlarm(channel)
231319
+ },
231320
+ ai: {
231321
+ command: "getAiDetectTypes + getAiDetectionFull (per type)",
231322
+ snapshotKey: "aiSensitivitySnapshot",
231323
+ invoke: async (api, channel) => {
231324
+ const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
231325
+ const perType = {};
231326
+ for (const aiType of detectTypes ?? []) try {
231327
+ perType[aiType] = await api.getAiDetectionFull(channel, aiType);
231328
+ } catch (err) {
231329
+ perType[aiType] = { error: err instanceof Error ? err.message : String(err) };
231330
+ }
231331
+ return {
231332
+ detectTypes: detectTypes ?? [],
231333
+ perType
231334
+ };
231335
+ }
231336
+ },
231337
+ enc: {
231338
+ command: "getEnc",
231339
+ snapshotKey: "encSnapshot",
231340
+ invoke: (api, channel) => api.getEnc(channel)
231341
+ },
231342
+ encOptions: {
231343
+ command: "getEncOptions",
231344
+ snapshotKey: "encOptionsSnapshot",
231345
+ invoke: (api, channel) => api.getEncOptions(channel)
231346
+ },
231347
+ mask: {
231348
+ command: "getMask",
231349
+ snapshotKey: "maskSnapshot",
231350
+ invoke: (api, channel) => api.getMask(channel)
231351
+ },
231352
+ audioNoise: {
231353
+ command: "getAudioNoise",
231354
+ snapshotKey: "audioNoiseSnapshot",
231355
+ invoke: (api, channel) => api.getAudioNoise(channel)
231356
+ },
231357
+ autofocus: {
231358
+ command: "getAutoFocus",
231359
+ snapshotKey: "autoFocusSnapshot",
231360
+ invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
231361
+ },
231362
+ netPort: {
231363
+ command: "getNetPort",
231364
+ snapshotKey: "netPortSnapshot",
231365
+ invoke: (api) => api.getNetPort()
231366
+ },
231367
+ ntp: {
231368
+ command: "getNtp",
231369
+ snapshotKey: "ntpSnapshot",
231370
+ invoke: (api) => api.getNtp()
231371
+ },
231372
+ systemGeneral: {
231373
+ command: "getSystemGeneral",
231374
+ snapshotKey: "systemGeneralSnapshot",
231375
+ invoke: (api) => api.getSystemGeneral()
231376
+ },
231377
+ osd: {
231378
+ command: "getOsd",
231379
+ snapshotKey: "osdSnapshot",
231380
+ invoke: (api, channel) => api.getOsd(channel)
231381
+ },
231382
+ led: {
231383
+ command: "getIrLights",
231384
+ snapshotKey: "ledSnapshot",
231385
+ invoke: (api, channel) => api.getIrLights(channel)
231386
+ },
231387
+ pir: {
231388
+ command: "getPirInfo",
231389
+ snapshotKey: "pirSnapshot",
231390
+ invoke: (api, channel) => api.getPirInfo(channel)
231391
+ },
231392
+ autoReboot: {
231393
+ command: "getAutoReboot",
231394
+ snapshotKey: "autoRebootSnapshot",
231395
+ invoke: (api) => api.getAutoReboot()
231396
+ }
231397
+ };
231398
+ /** What CamStack currently believes about the slice, with its own age. */
231399
+ var BelievedStateSchema = object({
231400
+ /** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
231401
+ snapshot: unknown(),
231402
+ /** deviceCache field the projection lives in. */
231403
+ snapshotKey: string(),
231404
+ /** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
231405
+ age: union([
231406
+ object({ state: literal("never") }),
231407
+ object({ state: literal("unknown") }),
231408
+ object({
231409
+ state: literal("known"),
231410
+ fetchedAt: number().int(),
231411
+ ageMs: number().int().nonnegative()
231412
+ })
231413
+ ])
231414
+ });
231415
+ var RawReadResultSchema = discriminatedUnion("ok", [object({
231416
+ ok: literal(true),
231417
+ deviceId: number().int(),
231418
+ slice: RawReadSliceSchema,
231419
+ command: string(),
231420
+ readAt: number().int(),
231421
+ /** The library's response, unprojected, as plain JSON. */
231422
+ camera: unknown(),
231423
+ believed: BelievedStateSchema
231424
+ }), object({
231425
+ ok: literal(false),
231426
+ deviceId: number().int(),
231427
+ slice: RawReadSliceSchema,
231428
+ reason: _enum([
231429
+ "sleeping",
231430
+ "login-failed",
231431
+ "read-failed"
231432
+ ]),
231433
+ message: string(),
231434
+ /** The believed state is still reported — a refusal must not hide
231435
+ * what CamStack is currently serving. */
231436
+ believed: BelievedStateSchema
231437
+ })]);
231438
+ var RawReadInputSchema = object({
231439
+ deviceId: number().int().nonnegative(),
231440
+ slice: RawReadSliceSchema
231441
+ });
231442
+ function believedState(cache, entry, now) {
231443
+ const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
231444
+ return {
231445
+ snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
231446
+ snapshotKey: entry.snapshotKey,
231447
+ age
231448
+ };
231449
+ }
231450
+ /** Force a lib response to plain JSON: drops functions/undefined/prototypes,
231451
+ * guarantees the payload is serializable across the tRPC boundary. */
231452
+ function toPlainJson(value) {
231453
+ if (value === void 0) return null;
231454
+ return JSON.parse(JSON.stringify(value));
231455
+ }
231456
+ /**
231457
+ * Execute one raw read. Order matters:
231458
+ * 1. sleep gate (refuse loudly — logging in would BE the wake);
231459
+ * 2. login;
231460
+ * 3. the allow-listed read;
231461
+ * and every outcome — refusal included — carries the believed state so the
231462
+ * operator always sees both sides of the comparison.
231463
+ */
231464
+ async function performRawRead(slice, deps) {
231465
+ const entry = RAW_READ_CATALOG[slice];
231466
+ const believed = believedState(deps.cache, entry, deps.now);
231467
+ if (deps.sleeping) {
231468
+ deps.logger.info("reolink raw read refused — battery cam is sleeping", {
231469
+ tags: { deviceId: deps.deviceId },
231470
+ meta: { slice }
231471
+ });
231472
+ return {
231473
+ ok: false,
231474
+ deviceId: deps.deviceId,
231475
+ slice,
231476
+ reason: "sleeping",
231477
+ message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231478
+ believed
231479
+ };
231480
+ }
231481
+ let api;
231482
+ try {
231483
+ api = await deps.getApi();
231484
+ } catch (err) {
231485
+ const message = err instanceof Error ? err.message : String(err);
231486
+ deps.logger.info("reolink raw read login failed", {
231487
+ tags: { deviceId: deps.deviceId },
231488
+ meta: {
231489
+ slice,
231490
+ error: message
231491
+ }
231492
+ });
231493
+ return {
231494
+ ok: false,
231495
+ deviceId: deps.deviceId,
231496
+ slice,
231497
+ reason: "login-failed",
231498
+ message,
231499
+ believed
231500
+ };
231501
+ }
231502
+ try {
231503
+ const payload = await entry.invoke(api, deps.channel);
231504
+ deps.logger.info("reolink raw read served", {
231505
+ tags: { deviceId: deps.deviceId },
231506
+ meta: {
231507
+ slice,
231508
+ command: entry.command
231509
+ }
231510
+ });
231511
+ return {
231512
+ ok: true,
231513
+ deviceId: deps.deviceId,
231514
+ slice,
231515
+ command: entry.command,
231516
+ readAt: deps.now,
231517
+ camera: toPlainJson(payload),
231518
+ believed
231519
+ };
231520
+ } catch (err) {
231521
+ const message = err instanceof Error ? err.message : String(err);
231522
+ deps.logger.info("reolink raw read failed", {
231523
+ tags: { deviceId: deps.deviceId },
231524
+ meta: {
231525
+ slice,
231526
+ command: entry.command,
231527
+ error: message
231528
+ }
231529
+ });
231530
+ return {
231531
+ ok: false,
231532
+ deviceId: deps.deviceId,
231533
+ slice,
231534
+ reason: "read-failed",
231535
+ message,
231536
+ believed
231537
+ };
231538
+ }
231539
+ }
231540
+ //#endregion
231541
+ //#region src/debug-actions.ts
231542
+ /**
231543
+ * provider-reolink — customActions catalog (admin-only debug surface).
231544
+ *
231545
+ * Dispatched via `POST addons.custom
231546
+ * {addonId:'provider-reolink', action:'debugRawRead', input:{deviceId, slice}}`.
231547
+ *
231548
+ * Why an addon custom action and not a device action: `deviceManager.
231549
+ * runDeviceAction` (the `refresh-settings` / `refresh-sessions` shape) is
231550
+ * mounted `protected` and its dispatcher does not enforce the per-action
231551
+ * `auth` — any authenticated user could call it. `addons.custom` is the one
231552
+ * operator surface that enforces per-action `auth: 'admin'` server-side
231553
+ * (`ensureCustomActionAuth`) AND validates the addon's output against this
231554
+ * catalog. A debug surface that returns raw camera payloads is admin-only,
231555
+ * so it lives here (same wiring as `addon-benchmark` / `addon-notifiers`).
231556
+ *
231557
+ * `kind: 'query'` states the contract — the handler is read-only by
231558
+ * construction (see `raw-read.ts`: the slice enum maps onto an allow-list
231559
+ * of lib `get*` calls; no write is reachable). The `addons.custom` mount
231560
+ * itself is a single mutation procedure, so callers still POST.
231561
+ */
231562
+ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawReadInputSchema, RawReadResultSchema, {
231563
+ kind: "query",
231564
+ auth: "admin"
231565
+ }) });
231566
+ //#endregion
230695
231567
  //#region src/log-channels.ts
230696
231568
  /**
230697
231569
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230904,6 +231776,130 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
230904
231776
  });
230905
231777
  }
230906
231778
  //#endregion
231779
+ //#region src/osd-settings-section.ts
231780
+ /**
231781
+ * Camera snapshot → UNKNOWN (`null`). The last arm is the point: `?? true`
231782
+ * here is what rendered "the camera did not tell us" as an enabled overlay.
231783
+ * The snapshot is the ONLY input — the config keeps no copy to consult.
231784
+ */
231785
+ function resolveOsdValues(snapshot) {
231786
+ return {
231787
+ osdChannelEnabled: snapshot?.channelEnabled ?? null,
231788
+ osdChannelName: snapshot?.channelName ?? "",
231789
+ osdTimeEnabled: snapshot?.timeEnabled ?? null,
231790
+ osdWatermark: snapshot?.watermark ?? null
231791
+ };
231792
+ }
231793
+ /**
231794
+ * Reader-side staleness (D224). A snapshot persisted before freshness
231795
+ * tracking has no `fetchedAt` and is treated as stale — that is exactly the
231796
+ * adoption-frozen reading this module exists to retire.
231797
+ */
231798
+ function isOsdSnapshotStale(snapshot, now) {
231799
+ return now - (snapshot?.fetchedAt ?? 0) > OPERATOR_WRITTEN_STALE_MS;
231800
+ }
231801
+ var OSD_UNKNOWN_DESCRIPTION = "Not reported by the camera yet — the current state is unknown.";
231802
+ var NEVER_WOKEN_SUFFIX = "a sleeping battery camera is never woken to read settings.";
231803
+ /**
231804
+ * A boolean overlay toggle. Unknown (`null`) renders disabled with an honest
231805
+ * description — the switch component shows `Boolean(null)` = off, and the
231806
+ * disabled + "not reported" pairing keeps that from reading as a claim.
231807
+ */
231808
+ function osdToggle(key, label, value, baseDescription) {
231809
+ const unknown = value === null;
231810
+ const description = unknown ? baseDescription ? `${baseDescription} ${OSD_UNKNOWN_DESCRIPTION}` : OSD_UNKNOWN_DESCRIPTION : baseDescription;
231811
+ return {
231812
+ type: "boolean",
231813
+ key,
231814
+ label,
231815
+ default: value,
231816
+ style: "switch",
231817
+ ...description !== void 0 ? { description } : {},
231818
+ ...unknown ? { disabled: true } : {}
231819
+ };
231820
+ }
231821
+ /**
231822
+ * State banner shown when the operator is NOT looking at a current reading:
231823
+ * - camera asleep and the mirror is stale → say what is shown and when it
231824
+ * was read, and that the camera is not woken for this;
231825
+ * - no reading has ever landed → say the toggles are unknown.
231826
+ * A fresh mirror on an awake camera renders no banner — serve-and-revalidate
231827
+ * keeps it honest silently.
231828
+ */
231829
+ function buildOsdStateBanner(snapshot, opts) {
231830
+ const stale = isOsdSnapshotStale(snapshot, opts.now);
231831
+ if (opts.sleeping && stale) return {
231832
+ type: "info",
231833
+ key: "osdSnapshotState",
231834
+ label: "OSD state",
231835
+ variant: "warning",
231836
+ 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}`
231837
+ };
231838
+ if (snapshot === void 0) return {
231839
+ type: "info",
231840
+ key: "osdSnapshotState",
231841
+ label: "OSD state",
231842
+ variant: "warning",
231843
+ content: "OSD state has not been read from this camera yet — unknown toggles are disabled until a read succeeds."
231844
+ };
231845
+ return null;
231846
+ }
231847
+ /**
231848
+ * A position value for display. Verbatim in quotes when the camera reported
231849
+ * one — an empty string IS a report and shows as `""` — and "not reported"
231850
+ * only when `getOsd` genuinely carried no string (tri-state, D337).
231851
+ */
231852
+ function formatObservedPos(pos) {
231853
+ return typeof pos === "string" ? `"${pos}"` : "not reported";
231854
+ }
231855
+ /**
231856
+ * Read-only view of the overlay positions the camera reported. Deliberately
231857
+ * NOT a control: the `pos` vocabulary is unknown (loose string, no observed
231858
+ * values yet), so this field exists to make it observable per camera. A
231859
+ * position control can be designed once real values have been collected —
231860
+ * see the "osd overlay positions observed" info log in the probe.
231861
+ */
231862
+ function buildOsdPositionsField(snapshot) {
231863
+ return {
231864
+ type: "info",
231865
+ key: "osdPositions",
231866
+ label: "Overlay positions",
231867
+ content: `Positions are kept exactly as configured on the camera and are read-only here.\nChannel name: ${formatObservedPos(snapshot?.channelPos)}\nTimestamp: ${formatObservedPos(snapshot?.timePos)}`
231868
+ };
231869
+ }
231870
+ /**
231871
+ * The "OSD overlay" section (writable via `setOsd`, cmd_id 25). One
231872
+ * read-modify-write `setOsd(OsdConfig)` push covers all four fields — the
231873
+ * dispatcher reads the current `OsdConfig` (`getOsd`) first so the stored
231874
+ * overlay positions (`pos`) survive untouched. Position itself is
231875
+ * camera-pixel/preset specific and only OBSERVED here, never written.
231876
+ */
231877
+ function buildOsdSection(snapshot, values, opts) {
231878
+ const banner = buildOsdStateBanner(snapshot, opts);
231879
+ return {
231880
+ id: "osd",
231881
+ tab: "image",
231882
+ title: "OSD overlay",
231883
+ 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.",
231884
+ columns: 2,
231885
+ fields: [
231886
+ ...banner ? [banner] : [],
231887
+ osdToggle("osdChannelEnabled", "Channel name overlay", values.osdChannelEnabled, void 0),
231888
+ {
231889
+ type: "text",
231890
+ key: "osdChannelName",
231891
+ label: "Channel name",
231892
+ description: "Text shown in the channel-name overlay.",
231893
+ default: values.osdChannelName,
231894
+ placeholder: "Front door"
231895
+ },
231896
+ osdToggle("osdTimeEnabled", "Timestamp overlay", values.osdTimeEnabled, void 0),
231897
+ osdToggle("osdWatermark", "Watermark", values.osdWatermark, "The Reolink logo watermark overlay."),
231898
+ buildOsdPositionsField(snapshot)
231899
+ ]
231900
+ };
231901
+ }
231902
+ //#endregion
230907
231903
  //#region src/raw-state.ts
230908
231904
  /**
230909
231905
  * Source tag for every raw-state blob this provider emits.
@@ -231138,7 +232134,7 @@ var SirenAccessory = class extends BaseDevice {
231138
232134
  this.ctx.logger.info("siren onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231139
232135
  try {
231140
232136
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231141
- await sleep$1(1e3);
232137
+ await sleep$2(1e3);
231142
232138
  } catch (err) {
231143
232139
  this.ctx.logger.warn("siren wake before initial probe failed — proceeding anyway", {
231144
232140
  tags: { deviceId: this.id },
@@ -231560,7 +232556,7 @@ var FloodlightAccessory = class extends BaseDevice {
231560
232556
  this.ctx.logger.info("floodlight onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231561
232557
  try {
231562
232558
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231563
- await sleep$1(1e3);
232559
+ await sleep$2(1e3);
231564
232560
  } catch (err) {
231565
232561
  this.ctx.logger.warn("floodlight wake before initial probe failed — proceeding anyway", {
231566
232562
  tags: { deviceId: this.id },
@@ -231957,7 +232953,7 @@ var PirAccessory = class extends BaseDevice {
231957
232953
  this.ctx.logger.info("pir onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231958
232954
  try {
231959
232955
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231960
- await sleep$1(1e3);
232956
+ await sleep$2(1e3);
231961
232957
  } catch (err) {
231962
232958
  this.ctx.logger.warn("pir wake before initial probe failed — proceeding anyway", {
231963
232959
  tags: { deviceId: this.id },
@@ -233631,7 +234627,24 @@ var reolinkCameraSchema = object({
233631
234627
  channelEnabled: boolean().nullable().optional(),
233632
234628
  channelName: string().optional(),
233633
234629
  timeEnabled: boolean().nullable().optional(),
233634
- watermark: boolean().nullable().optional()
234630
+ watermark: boolean().nullable().optional(),
234631
+ /**
234632
+ * Overlay positions exactly as `getOsd` reported them — READ-ONLY
234633
+ * observations, never written (the `setOsd` read-modify-write
234634
+ * preserves the camera's stored `pos`). Tri-state: `null` means
234635
+ * the camera did not report a string; an empty string is a real
234636
+ * report. Captured so a vocabulary of live values can be
234637
+ * collected before any position control is designed.
234638
+ */
234639
+ channelPos: string().nullable().optional(),
234640
+ timePos: string().nullable().optional(),
234641
+ /**
234642
+ * Wall-clock ms when this slice last landed from the camera.
234643
+ * Absent on snapshots persisted before freshness tracking —
234644
+ * `isOsdSnapshotStale` treats those as stale, which retires the
234645
+ * adoption-frozen readings this stamp was added for (D224).
234646
+ */
234647
+ fetchedAt: number().int().optional()
233635
234648
  }).optional(),
233636
234649
  /**
233637
234650
  * Snapshot of the camera's status + doorbell LED state from
@@ -233670,7 +234683,18 @@ var reolinkCameraSchema = object({
233670
234683
  hour: number().int().nullable().optional(),
233671
234684
  minute: number().int().nullable().optional(),
233672
234685
  supported: boolean().optional()
233673
- }).optional()
234686
+ }).optional(),
234687
+ /**
234688
+ * Per-snapshot freshness stamps (D224 generalised, D346): wall-clock
234689
+ * ms when each `*Snapshot` group in this cache was last WRITTEN from
234690
+ * a camera read, keyed by the group's field name (`encSnapshot`,
234691
+ * `osdSnapshot`, …). Written only by the persist sites that write
234692
+ * the group itself (`stampSnapshotFreshness`) — a failed probe
234693
+ * writes no snapshot and gets no stamp. A group with no entry here
234694
+ * (legacy persist) is of UNKNOWN age and must never read as fresh;
234695
+ * `resolveSnapshotAge` owns the tri-state.
234696
+ */
234697
+ snapshotFetchedAt: record(string(), number().int()).optional()
233674
234698
  }).loose().optional(),
233675
234699
  /**
233676
234700
  * Generic Baichuan debug logs. Forwarded as `DebugOptions.general`
@@ -233828,18 +234852,6 @@ var reolinkCameraSchema = object({
233828
234852
  statusLedEnabled: boolean().optional(),
233829
234853
  doorbellLedEnabled: boolean().optional(),
233830
234854
  /**
233831
- * On-screen display (OSD) overlay — pushed via `setOsd` (cmd_id 25,
233832
- * read via 26). One read-modify-write `OsdConfig` push covers all four
233833
- * fields so the camera keeps its stored overlay positions (`pos`)
233834
- * untouched — only the enable flags, channel name text, and watermark
233835
- * toggle change. Position is camera-pixel/preset specific (`pos` is a
233836
- * loose string, not a clean enum), so it is intentionally NOT exposed.
233837
- */
233838
- osdChannelEnabled: boolean().optional(),
233839
- osdChannelName: string().max(64).optional(),
233840
- osdTimeEnabled: boolean().optional(),
233841
- osdWatermark: boolean().optional(),
233842
- /**
233843
234855
  * Audio output volume — pushed via `setAudioCfg` (cmd_id=265,
233844
234856
  * read via 264). Reolink-spec range 0..100.
233845
234857
  */
@@ -235038,6 +236050,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235038
236050
  * legacy firmware that doesn't support some endpoints). */
235039
236051
  lastSettingsSnapshotRetryAt = 0;
235040
236052
  static SETTINGS_SNAPSHOT_RETRY_MIN_MS = 6e4;
236053
+ /** Debounce timestamp for the OSD serve-and-revalidate kick from
236054
+ * `getSettingsUISchema` (D224). Separate from
236055
+ * `lastSettingsSnapshotRetryAt` so an incomplete-cache retry and an
236056
+ * OSD staleness revalidate never suppress each other. */
236057
+ lastOsdRevalidateKickAt = 0;
235041
236058
  /** True when any settings-snapshot field that drives a UI section
235042
236059
  * is missing from the persisted cache. Drives the on-demand retry
235043
236060
  * in `getSettingsUISchema`. */
@@ -235047,16 +236064,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235047
236064
  return cache.encSnapshot === void 0 || cache.encOptionsSnapshot === void 0 || cache.maskSnapshot === void 0 || cache.audioNoiseSnapshot === void 0 || cache.autoFocusSnapshot === void 0;
235048
236065
  }
235049
236066
  /**
235050
- * Probe `getVideoInput` + `getMotionAlarm` and persist into the
235051
- * `deviceCache` snapshots. Fires once on `onCreated`; future
235052
- * settings opens read straight from the persisted snapshot. Image
235053
- * is readonly in the UI (lib lacks `setVideoInput`); motion is
235054
- * writable via `setMotionAlarm` so its snapshot also drives the
235055
- * dispatch's known-good baseline.
236067
+ * Probe the parent-settings endpoints (`getVideoInput`, `getMotionAlarm`,
236068
+ * `getOsd`, …) and persist into the `deviceCache` snapshots. Runs on
236069
+ * activation, on battery wake transitions, after a settings save (scoped
236070
+ * to the changed slices), via the manual "Refresh from camera" action,
236071
+ * and from `getSettingsUISchema`'s serve-and-revalidate kicks settings
236072
+ * opens serve the persisted snapshot immediately and revalidate stale
236073
+ * slices behind the form (D224). Image is readonly in the UI (lib lacks
236074
+ * `setVideoInput`); motion is writable via `setMotionAlarm` so its
236075
+ * snapshot also drives the dispatch's known-good baseline.
235056
236076
  */
235057
236077
  async refreshParentSettingsSnapshot(slices) {
235058
236078
  if (this.isBattery && this.sleeping) {
235059
- this.ctx.logger.debug("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
236079
+ this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
235060
236080
  return;
235061
236081
  }
235062
236082
  let api;
@@ -235209,14 +236229,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235209
236229
  }
235210
236230
  if (want("osd")) try {
235211
236231
  const osd = await api.getOsd(channel);
236232
+ const channelPos = typeof osd.osdChannel?.pos === "string" ? osd.osdChannel.pos : null;
236233
+ const timePos = typeof osd.osdTime?.pos === "string" ? osd.osdTime.pos : null;
236234
+ const prevOsdSnapshot = this.config.get("deviceCache")?.osdSnapshot;
236235
+ if (prevOsdSnapshot?.channelPos !== channelPos || prevOsdSnapshot?.timePos !== timePos) this.ctx.logger.info("reolink osd overlay positions observed", {
236236
+ tags: { deviceId: this.id },
236237
+ meta: {
236238
+ channelPos,
236239
+ timePos
236240
+ }
236241
+ });
235212
236242
  cacheUpdate.osdSnapshot = {
235213
236243
  channelEnabled: typeof osd.osdChannel?.enable === "number" ? osd.osdChannel.enable === 1 : null,
235214
236244
  channelName: typeof osd.osdChannel?.name === "string" ? osd.osdChannel.name : void 0,
235215
236245
  timeEnabled: typeof osd.osdTime?.enable === "number" ? osd.osdTime.enable === 1 : null,
235216
- watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null
236246
+ watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null,
236247
+ channelPos,
236248
+ timePos,
236249
+ fetchedAt: Date.now()
235217
236250
  };
235218
236251
  } catch (err) {
235219
- this.ctx.logger.debug("reolink getOsd probe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
236252
+ this.ctx.logger.info("reolink getOsd probe failed OSD snapshot left stale", {
236253
+ tags: { deviceId: this.id },
236254
+ meta: { error: err instanceof Error ? err.message : String(err) }
236255
+ });
235220
236256
  }
235221
236257
  if (want("led")) try {
235222
236258
  const ledState = (await api.getIrLights(channel))?.body?.LedState;
@@ -235273,6 +236309,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235273
236309
  }
235274
236310
  if (Object.keys(cacheUpdate).length === 0) return;
235275
236311
  const current = this.config.get("deviceCache") ?? {};
236312
+ const writtenSnapshotKeys = Object.keys(cacheUpdate).filter((k) => k.endsWith("Snapshot"));
236313
+ if (writtenSnapshotKeys.length > 0) cacheUpdate.snapshotFetchedAt = stampSnapshotFreshness(current.snapshotFetchedAt, writtenSnapshotKeys, Date.now());
235276
236314
  try {
235277
236315
  await this.config.setAll({ deviceCache: {
235278
236316
  ...current,
@@ -235286,6 +236324,28 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235286
236324
  });
235287
236325
  }
235288
236326
  /**
236327
+ * Admin-only raw-read debug surface (D346): return the library's response
236328
+ * for one snapshot slice UNPROJECTED, next to the persisted snapshot and
236329
+ * its freshness, so "what does the camera report vs what does CamStack
236330
+ * believe" is answerable without Baichuan tracing. Read-only by
236331
+ * construction (the slice enum maps onto an allow-list of lib `get*`
236332
+ * calls — see `raw-read.ts`), writes nothing back to the cache, and the
236333
+ * sleep gate runs BEFORE any login: a sleeping battery cam refuses loudly
236334
+ * (logging in IS the wake) and still reports the believed state.
236335
+ * Dispatched by the provider's `debugRawRead` custom action.
236336
+ */
236337
+ async debugRawRead(slice) {
236338
+ return performRawRead(slice, {
236339
+ deviceId: this.id,
236340
+ channel: this.getChannel(),
236341
+ sleeping: this.isBattery && this.sleeping,
236342
+ getApi: () => this.ensureApi(),
236343
+ cache: this.config.get("deviceCache"),
236344
+ logger: this.ctx.logger,
236345
+ now: Date.now()
236346
+ });
236347
+ }
236348
+ /**
235289
236349
  * Declare on-camera accessory child devices the kernel should
235290
236350
  * auto-spawn after `onCreated`. Each entry maps directly to a
235291
236351
  * concrete accessory class via the existing `createAccessoryDevice`
@@ -235369,7 +236429,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235369
236429
  waitAfterWakeMs: 2500,
235370
236430
  attempts: 2
235371
236431
  });
235372
- await sleep$1(1500);
236432
+ await sleep$2(1500);
235373
236433
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
235374
236434
  return true;
235375
236435
  } catch (err) {
@@ -235484,7 +236544,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235484
236544
  sendNickname: email.sendNickname,
235485
236545
  ...task ? { taskEnabled: task.enable === 1 } : {},
235486
236546
  lastReadAt: Date.now()
235487
- }
236547
+ },
236548
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["emailConfigSnapshot"], Date.now())
235488
236549
  } });
235489
236550
  this.ctx.logger.info("email-push: read camera email config", {
235490
236551
  tags: { deviceId: this.id },
@@ -235666,7 +236727,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235666
236727
  waitAfterWakeMs: 2500,
235667
236728
  attempts: 2
235668
236729
  });
235669
- await sleep$1(1500);
236730
+ await sleep$2(1500);
235670
236731
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
235671
236732
  const CONFIRM_TIMEOUT_MS = 1e4;
235672
236733
  const CONFIRM_POLL_MS = 1e3;
@@ -235696,7 +236757,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235696
236757
  break;
235697
236758
  }
235698
236759
  }
235699
- await sleep$1(CONFIRM_POLL_MS);
236760
+ await sleep$2(CONFIRM_POLL_MS);
235700
236761
  }
235701
236762
  const confirmSource = parent !== null ? "hub-summary" : "sleep-poll";
235702
236763
  if (observedAwake && this.commitSleepState(false, confirmSource)) {
@@ -235928,7 +236989,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235928
236989
  async watchHubChildAwake(parent) {
235929
236990
  const deadline = Date.now() + 45e3;
235930
236991
  while (Date.now() < deadline) {
235931
- await sleep$1(5e3);
236992
+ await sleep$2(5e3);
235932
236993
  try {
235933
236994
  if ((await (await parent.getApi()).getNvrChannelsSummary({ channels: [this.getChannel()] })).devices.find((d) => d.channel === this.getChannel())?.sleeping === false) {
235934
236995
  if (this.commitSleepState(false, "hub-summary")) {
@@ -236891,7 +237952,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236891
237952
  value,
236892
237953
  fetchedAt: Date.now()
236893
237954
  }
236894
- }
237955
+ },
237956
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["capOptionsSnapshot"], Date.now())
236895
237957
  } });
236896
237958
  } catch (err) {
236897
237959
  this.ctx.logger.debug("cap options persist failed", {
@@ -237956,7 +239018,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237956
239018
  this.ctx.logger.info("intercom: cam sleeping — waking up before talk session", { tags: { deviceId: this.id } });
237957
239019
  try {
237958
239020
  await api.wakeUp(channel, { waitAfterWakeMs: 2e3 });
237959
- await sleep$1(1e3);
239021
+ await sleep$2(1e3);
237960
239022
  } catch (err) {
237961
239023
  this.ctx.logger.warn("intercom: wakeUp failed — proceeding anyway", { meta: { error: err instanceof Error ? err.message : String(err) } });
237962
239024
  }
@@ -238256,13 +239318,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
238256
239318
  await api.setAutoFocus(channel, enabled ? 0 : 1);
238257
239319
  try {
238258
239320
  const a = (await api.getAutoFocus(channel, { timeoutMs: 1500 }))?.body?.AutoFocus;
238259
- if (a) await this.config.setAll({ deviceCache: {
238260
- ...this.config.get("deviceCache"),
238261
- autoFocusSnapshot: {
238262
- enabled: typeof a.disable === "number" ? a.disable === 0 : null,
238263
- supported: true
238264
- }
238265
- } });
239321
+ if (a) {
239322
+ const afCurrent = this.config.get("deviceCache");
239323
+ await this.config.setAll({ deviceCache: {
239324
+ ...afCurrent,
239325
+ autoFocusSnapshot: {
239326
+ enabled: typeof a.disable === "number" ? a.disable === 0 : null,
239327
+ supported: true
239328
+ },
239329
+ snapshotFetchedAt: stampSnapshotFreshness(afCurrent?.snapshotFetchedAt, ["autoFocusSnapshot"], Date.now())
239330
+ } });
239331
+ }
238266
239332
  } catch {}
238267
239333
  }
238268
239334
  };
@@ -239627,13 +240693,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
239627
240693
  const imgSnap = cache?.imageSnapshot ?? {};
239628
240694
  const netSnap = cache?.netPortSnapshot ?? {};
239629
240695
  const ntpSnap = cache?.ntpSnapshot ?? {};
240696
+ let kickedFullSnapshotRefresh = false;
239630
240697
  if (this.hasIncompleteSettingsCache()) {
239631
240698
  const now = Date.now();
239632
240699
  if (now - this.lastSettingsSnapshotRetryAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
239633
240700
  this.lastSettingsSnapshotRetryAt = now;
240701
+ kickedFullSnapshotRefresh = true;
239634
240702
  this.refreshParentSettingsSnapshot().catch(() => {});
239635
240703
  }
239636
240704
  }
240705
+ const osdSnap = cache?.osdSnapshot;
240706
+ if (!kickedFullSnapshotRefresh && isOsdSnapshotStale(osdSnap, Date.now())) {
240707
+ const now = Date.now();
240708
+ if (now - this.lastOsdRevalidateKickAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
240709
+ this.lastOsdRevalidateKickAt = now;
240710
+ this.refreshParentSettingsSnapshot(new Set(["osd"])).catch(() => {});
240711
+ }
240712
+ }
240713
+ const osdValues = resolveOsdValues(osdSnap);
239637
240714
  const sessSnap = this.sessionsSnapshot;
239638
240715
  const sessStale = sessSnap === null || Date.now() - sessSnap.ts > 6e4;
239639
240716
  if (!this.isBattery && sessStale) this.refreshSessionsSnapshot().catch((err) => {
@@ -240180,45 +241257,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240180
241257
  }] : []
240181
241258
  ]
240182
241259
  },
240183
- {
240184
- id: "osd",
240185
- tab: "image",
240186
- title: "OSD overlay",
240187
- 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.",
240188
- columns: 2,
240189
- fields: [
240190
- {
240191
- type: "boolean",
240192
- key: "osdChannelEnabled",
240193
- label: "Channel name overlay",
240194
- default: cache?.osdSnapshot?.channelEnabled ?? true,
240195
- style: "switch"
240196
- },
240197
- {
240198
- type: "text",
240199
- key: "osdChannelName",
240200
- label: "Channel name",
240201
- description: "Text shown in the channel-name overlay.",
240202
- default: cache?.osdSnapshot?.channelName ?? "",
240203
- placeholder: "Front door"
240204
- },
240205
- {
240206
- type: "boolean",
240207
- key: "osdTimeEnabled",
240208
- label: "Timestamp overlay",
240209
- default: cache?.osdSnapshot?.timeEnabled ?? true,
240210
- style: "switch"
240211
- },
240212
- {
240213
- type: "boolean",
240214
- key: "osdWatermark",
240215
- label: "Watermark",
240216
- description: "The Reolink logo watermark overlay.",
240217
- default: cache?.osdSnapshot?.watermark ?? false,
240218
- style: "switch"
240219
- }
240220
- ]
240221
- },
241260
+ buildOsdSection(osdSnap, osdValues, {
241261
+ sleeping: this.isBattery && this.sleeping,
241262
+ now: Date.now()
241263
+ }),
240222
241264
  {
240223
241265
  id: "privacy-mask",
240224
241266
  tab: "image",
@@ -240508,6 +241550,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240508
241550
  ]
240509
241551
  }]
240510
241552
  },
241553
+ buildSnapshotFreshnessSection(cache, {
241554
+ sleeping: this.isBattery && this.sleeping,
241555
+ now: Date.now()
241556
+ }),
240511
241557
  ...this.buildSessionsTabSections(),
240512
241558
  ...this.buildEmailPushSection(),
240513
241559
  ...this.buildEmailTabSections()
@@ -240574,10 +241620,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240574
241620
  irLightsBrightness: this.config.get("irLightsBrightness") ?? 128,
240575
241621
  statusLedEnabled: this.config.get("statusLedEnabled") ?? cache?.ledSnapshot?.statusEnabled ?? true,
240576
241622
  doorbellLedEnabled: this.config.get("doorbellLedEnabled") ?? cache?.ledSnapshot?.doorbellEnabled ?? true,
240577
- osdChannelEnabled: this.config.get("osdChannelEnabled") ?? cache?.osdSnapshot?.channelEnabled ?? true,
240578
- osdChannelName: this.config.get("osdChannelName") ?? cache?.osdSnapshot?.channelName ?? "",
240579
- osdTimeEnabled: this.config.get("osdTimeEnabled") ?? cache?.osdSnapshot?.timeEnabled ?? true,
240580
- osdWatermark: this.config.get("osdWatermark") ?? cache?.osdSnapshot?.watermark ?? false,
241623
+ osdChannelEnabled: osdValues.osdChannelEnabled,
241624
+ osdChannelName: osdValues.osdChannelName,
241625
+ osdTimeEnabled: osdValues.osdTimeEnabled,
241626
+ osdWatermark: osdValues.osdWatermark,
240581
241627
  audioVolume: this.config.get("audioVolume") ?? 50,
240582
241628
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
240583
241629
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
@@ -240596,7 +241642,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240596
241642
  });
240597
241643
  }
240598
241644
  async applySettingsPatch(patch) {
240599
- const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, ...rest } = patch;
241645
+ const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdTimeEnabled, osdWatermark, ...rest } = patch;
240600
241646
  const emailFields = {
240601
241647
  emailSmtpServer,
240602
241648
  emailSmtpPort,
@@ -240615,8 +241661,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240615
241661
  meta: { error: err instanceof Error ? err.message : String(err) }
240616
241662
  });
240617
241663
  });
240618
- if (Object.keys(rest).length === 0) return;
240619
- await this.config.setAll(rest);
241664
+ const hasOsdPatch = [
241665
+ osdChannelEnabled,
241666
+ osdChannelName,
241667
+ osdTimeEnabled,
241668
+ osdWatermark
241669
+ ].some((v) => v !== void 0);
241670
+ if (Object.keys(rest).length === 0 && !hasOsdPatch) return;
241671
+ if (Object.keys(rest).length > 0) await this.config.setAll(rest);
240620
241672
  const typedPatch = patch;
240621
241673
  if (typedPatch.host || typedPatch.port || typedPatch.username || typedPatch.password) {
240622
241674
  await this.disconnectAll();
@@ -240809,12 +241861,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240809
241861
  } catch (err) {
240810
241862
  this.ctx.logger.warn("ir-lights push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
240811
241863
  }
240812
- if ([
240813
- "osdChannelEnabled",
240814
- "osdChannelName",
240815
- "osdTimeEnabled",
240816
- "osdWatermark"
240817
- ].some((k) => k in patch)) try {
241864
+ if (hasOsdPatch) try {
240818
241865
  const api = await this.ensureApi();
240819
241866
  const channel = this.getChannel();
240820
241867
  const current = await api.getOsd(channel);
@@ -240832,13 +241879,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240832
241879
  watermark: current.watermark ?? 0
240833
241880
  };
240834
241881
  if (current.bgcolor !== void 0) next.bgcolor = current.bgcolor;
240835
- if (typeof typedPatch.osdChannelEnabled === "boolean") next.osdChannel.enable = typedPatch.osdChannelEnabled ? 1 : 0;
240836
- if (typeof typedPatch.osdChannelName === "string") next.osdChannel.name = typedPatch.osdChannelName;
240837
- if (typeof typedPatch.osdTimeEnabled === "boolean") next.osdTime.enable = typedPatch.osdTimeEnabled ? 1 : 0;
240838
- if (typeof typedPatch.osdWatermark === "boolean") next.watermark = typedPatch.osdWatermark ? 1 : 0;
241882
+ if (typeof osdChannelEnabled === "boolean") next.osdChannel.enable = osdChannelEnabled ? 1 : 0;
241883
+ if (typeof osdChannelName === "string") next.osdChannel.name = osdChannelName;
241884
+ if (typeof osdTimeEnabled === "boolean") next.osdTime.enable = osdTimeEnabled ? 1 : 0;
241885
+ if (typeof osdWatermark === "boolean") next.watermark = osdWatermark ? 1 : 0;
240839
241886
  await api.setOsd(channel, next);
240840
241887
  } catch (err) {
240841
- this.ctx.logger.warn("osd push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
241888
+ this.ctx.logger.warn("osd push failed camera keeps its current overlay state", {
241889
+ tags: { deviceId: this.id },
241890
+ meta: { error: err instanceof Error ? err.message : String(err) }
241891
+ });
240842
241892
  }
240843
241893
  if ([
240844
241894
  "audioVolume",
@@ -240979,7 +242029,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240979
242029
  }
240980
242030
  const changedSlices = slicesForPatch(patch);
240981
242031
  if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
240982
- this.ctx.logger.debug("reolink targeted settings refresh failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
242032
+ this.ctx.logger.info("reolink targeted settings refresh failed snapshots left stale", {
242033
+ tags: { deviceId: this.id },
242034
+ meta: {
242035
+ slices: [...changedSlices],
242036
+ error: err instanceof Error ? err.message : String(err)
242037
+ }
242038
+ });
240983
242039
  });
240984
242040
  }
240985
242041
  /**
@@ -241573,6 +242629,43 @@ function computeHealthCheckBackoffMs(consecutive) {
241573
242629
  return 15 * 6e4;
241574
242630
  }
241575
242631
  //#endregion
242632
+ //#region src/hub-channel-reconcile.ts
242633
+ /**
242634
+ * Match adopted children to the hub's own channel list by IDENTITY, then report the
242635
+ * ones whose persisted `channel` is missing or wrong.
242636
+ *
242637
+ * A child's stableId is `${hubStableId}-${childNativeId}`, and `childNativeId` is built
242638
+ * from the camera's UID — so identity survives a channel move, while `channel` does not.
242639
+ * The hub is the authority on which slot a UID currently occupies.
242640
+ *
242641
+ * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
242642
+ * `channel` alone. A child that lost it is absent from that map, so the discovery panel
242643
+ * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
242644
+ * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
242645
+ * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
242646
+ *
242647
+ * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
242648
+ * this repairs what exists, it never adopts.
242649
+ */
242650
+ function planChannelReconciliation(hubStableId, discovered, children) {
242651
+ const byStableId = /* @__PURE__ */ new Map();
242652
+ for (const child of children) byStableId.set(child.stableId, child);
242653
+ const repairs = [];
242654
+ for (const entry of discovered) {
242655
+ if (typeof entry.rtspChannel !== "number") continue;
242656
+ const child = byStableId.get(`${hubStableId}-${entry.childNativeId}`);
242657
+ if (child === void 0) continue;
242658
+ if (child.channel === entry.rtspChannel) continue;
242659
+ repairs.push({
242660
+ deviceId: child.deviceId,
242661
+ stableId: child.stableId,
242662
+ from: child.channel ?? null,
242663
+ to: entry.rtspChannel
242664
+ });
242665
+ }
242666
+ return repairs;
242667
+ }
242668
+ //#endregion
241576
242669
  //#region src/simple-event-dispatch-trace.ts
241577
242670
  /**
241578
242671
  * Gate for hub-side simpleEvent traces.
@@ -242069,6 +243162,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
242069
243162
  lastFetchedAt: Date.now()
242070
243163
  };
242071
243164
  this.runtimeState.setCapState(deviceDiscoveryCapability.name, slice);
243165
+ if (lastError === null) await this.reconcileChildChannels(discovered);
242072
243166
  if (lastError === null) {
242073
243167
  this.knownUnadoptedChannels.clear();
242074
243168
  for (const d of discovered) {
@@ -242241,6 +243335,64 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
242241
243335
  * the channel index. Returns a `channel → kernel deviceId` map.
242242
243336
  * Used to gate the `alreadyAdopted` flag in the discovery list.
242243
243337
  */
243338
+ /**
243339
+ * Re-derive each adopted child's `channel` from the hub's own channel list, matching by
243340
+ * IDENTITY (stableId carries the camera UID) rather than by the channel itself.
243341
+ *
243342
+ * `loadAdoptedChildrenByChannel` keys the adopted set on the persisted `channel` alone,
243343
+ * so a child that lost it is invisible: the discovery panel renders it available (one
243344
+ * click from a second adoption of the same camera) and `routeSimpleEvent` drops every
243345
+ * push for its slot. A channel move produces the same damage under a wrong number.
243346
+ *
243347
+ * Writes through the live device so its in-memory config and the DB stay in step.
243348
+ * A child that is not live is skipped — it is repaired on its next successful restore.
243349
+ */
243350
+ async reconcileChildChannels(discovered) {
243351
+ const children = await this.ctx.devices.getChildren(this.id);
243352
+ const adopted = children.map((child) => {
243353
+ const channel = child instanceof ReolinkCamera ? child.config.values.channel : void 0;
243354
+ return {
243355
+ deviceId: child.id,
243356
+ stableId: child.stableId,
243357
+ ...typeof channel === "number" ? { channel } : {}
243358
+ };
243359
+ });
243360
+ const repairs = planChannelReconciliation(this.stableId, discovered.map((d) => ({
243361
+ childNativeId: d.childNativeId,
243362
+ ...typeof d.metadata.rtspChannel === "number" ? { rtspChannel: d.metadata.rtspChannel } : {}
243363
+ })), adopted);
243364
+ if (repairs.length === 0) return;
243365
+ for (const repair of repairs) {
243366
+ const child = children.find((c) => c.id === repair.deviceId);
243367
+ if (!(child instanceof ReolinkCamera)) continue;
243368
+ try {
243369
+ await child.config.setAll({ channel: repair.to });
243370
+ this.channelToDeviceId.set(repair.to, repair.deviceId);
243371
+ this.ctx.logger.warn("Reolink Hub: repaired a child channel from the hub channel list", {
243372
+ tags: {
243373
+ deviceId: repair.deviceId,
243374
+ stableId: repair.stableId
243375
+ },
243376
+ meta: {
243377
+ from: repair.from,
243378
+ to: repair.to
243379
+ }
243380
+ });
243381
+ } catch (err) {
243382
+ this.ctx.logger.warn("Reolink Hub: child channel repair failed", {
243383
+ tags: {
243384
+ deviceId: repair.deviceId,
243385
+ stableId: repair.stableId
243386
+ },
243387
+ meta: {
243388
+ from: repair.from,
243389
+ to: repair.to,
243390
+ error: err instanceof Error ? err.message : String(err)
243391
+ }
243392
+ });
243393
+ }
243394
+ }
243395
+ }
242244
243396
  async loadAdoptedChildrenByChannel() {
242245
243397
  const result = /* @__PURE__ */ new Map();
242246
243398
  const children = await this.ctx.devices.getChildren(this.id);
@@ -242705,6 +243857,53 @@ function buildCreationFormSchema() {
242705
243857
  ] };
242706
243858
  }
242707
243859
  //#endregion
243860
+ //#region src/child-config-heal.ts
243861
+ /** Connection fields a hub-adopted child inherits from its parent at adoption time. */
243862
+ var INHERITED_CONNECTION_KEYS = [
243863
+ "host",
243864
+ "port",
243865
+ "username",
243866
+ "password",
243867
+ "transport"
243868
+ ];
243869
+ /** A stored value counts as present only when it is a usable string/number. */
243870
+ function isPresent(value) {
243871
+ if (typeof value === "string") return value !== "";
243872
+ return typeof value === "number";
243873
+ }
243874
+ /**
243875
+ * Work out what a hub-adopted child's persisted blob is missing from the connection
243876
+ * fields it inherited from its parent when it was adopted.
243877
+ *
243878
+ * `ReolinkCamera` with a parent uses `parent.api` and never dials its own login — but
243879
+ * `reolinkCameraSchema` still requires `host` and `password` as strings, so a blob that
243880
+ * lost them fails to parse and the camera cannot be restored at all. Observed live on
243881
+ * 2026-09-04: an Argus MagiCam under a Home Hub carried `{}` and burned all four bounded
243882
+ * restore attempts on `host`/`password` — fields it would never have used.
243883
+ *
243884
+ * Only ABSENT fields are filled. A child that deliberately differs from its parent (a
243885
+ * per-child credential, a non-default port) keeps what it has.
243886
+ */
243887
+ function planChildConfigHeal(childConfig, parentConfig) {
243888
+ const patch = {};
243889
+ const missingKeys = [];
243890
+ for (const key of INHERITED_CONNECTION_KEYS) {
243891
+ if (isPresent(childConfig[key])) continue;
243892
+ if (!isPresent(parentConfig[key])) continue;
243893
+ patch[key] = parentConfig[key];
243894
+ missingKeys.push(key);
243895
+ }
243896
+ return {
243897
+ patch,
243898
+ missingKeys
243899
+ };
243900
+ }
243901
+ /** Find the saved row a child names as its parent. */
243902
+ function findParentRow(child, allSaved) {
243903
+ if (child.parentDeviceId === null) return void 0;
243904
+ return allSaved.find((row) => row.id === child.parentDeviceId);
243905
+ }
243906
+ //#endregion
242708
243907
  //#region src/reolink-discovery-map.ts
242709
243908
  /**
242710
243909
  * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
@@ -242714,21 +243913,44 @@ function slugifyReolinkHost(host) {
242714
243913
  return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
242715
243914
  }
242716
243915
  /**
243916
+ * Decide whether a discovered host is a device this provider already owns.
243917
+ *
243918
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
243919
+ * wins even when the host differs, because the address is the thing that moves.
243920
+ */
243921
+ function findOnboardedMatch(discovered, onboarded) {
243922
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
243923
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
243924
+ if (byUid !== void 0) return byUid;
243925
+ }
243926
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
243927
+ }
243928
+ /**
242717
243929
  * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
242718
243930
  * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
242719
243931
  * first responder for a host wins. Pure: no I/O.
242720
243932
  *
243933
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
243934
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
243935
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
243936
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
243937
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
243938
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
243939
+ * never a scan result.
243940
+ *
242721
243941
  * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
242722
- * can't produce — so a re-scan of a MAC-keyed camera may still show as addable. The `host-` key still
242723
- * lets host-added cameras be detected as onboarded on re-scan.
243942
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
243943
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
243944
+ * in the first place, so a scan says so instead of offering it again.
242724
243945
  */
242725
- function mapReolinkDiscoveryToCandidates(devices, credentials) {
243946
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = []) {
242726
243947
  const username = credentials.username?.trim() ?? "";
242727
243948
  const password = credentials.password ?? "";
242728
243949
  const byHost = /* @__PURE__ */ new Map();
242729
243950
  for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
242730
243951
  return [...byHost.values()].map((d) => {
242731
243952
  const displayName = d.name ?? d.model ?? d.host;
243953
+ const match = findOnboardedMatch(d, onboarded);
242732
243954
  return {
242733
243955
  stableId: `host-${slugifyReolinkHost(d.host)}`,
242734
243956
  type: DeviceType.Camera,
@@ -242737,11 +243959,15 @@ function mapReolinkDiscoveryToCandidates(devices, credentials) {
242737
243959
  name: displayName,
242738
243960
  host: d.host,
242739
243961
  transport: "auto",
242740
- ...d.httpPort !== void 0 ? { port: d.httpPort } : {},
242741
243962
  ...d.uid ? { uid: d.uid } : {},
242742
243963
  ...username ? { username } : {},
242743
243964
  ...password ? { password } : {}
242744
- }
243965
+ },
243966
+ ...match !== void 0 ? {
243967
+ alreadyOnboarded: true,
243968
+ onboardedDeviceId: match.deviceId,
243969
+ onboardedName: match.name
243970
+ } : {}
242745
243971
  };
242746
243972
  });
242747
243973
  }
@@ -243390,6 +244616,61 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243390
244616
  super({});
243391
244617
  }
243392
244618
  /**
244619
+ * Reduce this addon's live devices to the identities a network scan can produce, so
244620
+ * {@link mapReolinkDiscoveryToCandidates} can recognise a device it already owns.
244621
+ *
244622
+ * Hubs are included: the incident this exists for was a Home Hub re-offered by a scan and
244623
+ * re-added, which overwrote its Baichuan port with the ONVIF port the scan reported.
244624
+ */
244625
+ /**
244626
+ * Re-inherit the parent's connection fields into a hub-adopted child whose persisted
244627
+ * blob lost them, so the child can be restored at all.
244628
+ *
244629
+ * `adoptDiscoveredChild` copies `host`/`port`/`username`/`password`/`transport` down
244630
+ * from the Hub at adoption. Nothing re-applied them afterwards, so a child whose blob
244631
+ * was emptied failed `reolinkCameraSchema` on every restore — on fields a child never
244632
+ * dials, because a child with a parent uses `parent.api`. Only ABSENT fields are
244633
+ * filled; a child that deliberately differs keeps what it has.
244634
+ */
244635
+ async healSavedConfig(saved, allSaved) {
244636
+ const parent = findParentRow(saved, allSaved);
244637
+ if (parent === void 0) return;
244638
+ const { patch, missingKeys } = planChildConfigHeal(saved.config, parent.config);
244639
+ if (missingKeys.length === 0) return;
244640
+ await this.ctx.kernel.devices?.persistInitialConfig(saved.stableId, {
244641
+ ...saved.config,
244642
+ ...patch
244643
+ });
244644
+ this.ctx.logger.warn("Reolink child config healed from its parent before restore", {
244645
+ tags: {
244646
+ deviceId: saved.id,
244647
+ stableId: saved.stableId
244648
+ },
244649
+ meta: {
244650
+ parentDeviceId: parent.id,
244651
+ restoredKeys: [...missingKeys]
244652
+ }
244653
+ });
244654
+ }
244655
+ listOnboardedForDiscovery() {
244656
+ const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
244657
+ const onboarded = [];
244658
+ for (const d of all) {
244659
+ if (!(d instanceof ReolinkCamera) && !(d instanceof ReolinkHub)) continue;
244660
+ const values = d.config.values;
244661
+ const host = typeof values.host === "string" && values.host !== "" ? values.host : void 0;
244662
+ const uid = typeof values.uid === "string" && values.uid !== "" ? values.uid : void 0;
244663
+ if (host === void 0 && uid === void 0) continue;
244664
+ onboarded.push({
244665
+ deviceId: d.id,
244666
+ name: d.name,
244667
+ ...host !== void 0 ? { host } : {},
244668
+ ...uid !== void 0 ? { uid } : {}
244669
+ });
244670
+ }
244671
+ return onboarded;
244672
+ }
244673
+ /**
243393
244674
  * Enumerate this addon's live `ReolinkCamera` instances from the
243394
244675
  * global device registry. Used by the email-push server to resolve
243395
244676
  * recipients + route inbound motion. Returns `[]` when the registry
@@ -243582,6 +244863,44 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243582
244863
  return regs;
243583
244864
  }
243584
244865
  /**
244866
+ * Compose the provider registrations from `onInitialize()` with this
244867
+ * addon's customActions catalog. `BaseDeviceProvider.onInitialize` is
244868
+ * typed `ProviderRegistration[]` (eleven sibling providers push onto it),
244869
+ * so the catalog joins at the `initialize()` seam instead — the runner
244870
+ * consumes the merged `AddonInitResult` exactly as it does for
244871
+ * addon-benchmark / addon-notifiers.
244872
+ *
244873
+ * Admin-only debug surface (D346): `debugRawRead` returns the lib's
244874
+ * response for one snapshot slice UNPROJECTED, next to the persisted
244875
+ * snapshot + its freshness. Registered as an addon customAction because
244876
+ * `addons.custom` is the one operator surface that enforces the
244877
+ * per-action `auth: 'admin'` server-side and validates output —
244878
+ * `deviceManager.runDeviceAction` does neither. The hub reads the static
244879
+ * catalog from this bundle's `customActions` export (see `index.ts`);
244880
+ * the child registers the handlers returned here.
244881
+ */
244882
+ async initialize(context) {
244883
+ const base = await super.initialize(context);
244884
+ return {
244885
+ providers: base && base.providers ? base.providers : [],
244886
+ customActions: reolinkDebugActions,
244887
+ actionHandlers: { debugRawRead: (input) => this.debugRawRead(input) }
244888
+ };
244889
+ }
244890
+ /**
244891
+ * Route a `debugRawRead` custom action to the owning camera. Covers both
244892
+ * standalone cameras and NVR-adopted children — every live ReolinkCamera
244893
+ * in this runner is in the kernel device registry. Read-only end to end
244894
+ * (see `raw-read.ts`); a hub device or an unknown id refuses with a
244895
+ * message that names what it looked for.
244896
+ */
244897
+ async debugRawRead(input) {
244898
+ const dev = this.ctx.kernel.deviceRegistry?.getById(input.deviceId);
244899
+ if (dev === void 0 || dev === null) throw new Error(`debugRawRead: device ${input.deviceId} not found in the reolink runner's registry`);
244900
+ 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`);
244901
+ return dev.debugRawRead(input.slice);
244902
+ }
244903
+ /**
243585
244904
  * Handle a broker-issued source-refresh request. With the lazy-publish
243586
244905
  * model the broker always emits this on first dial of a
243587
244906
  * `lazy:rfc4571:` placeholder URL — and re-emits it whenever the
@@ -243634,10 +244953,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243634
244953
  networkCidr: networkCidr || "local",
243635
244954
  enableOnvif
243636
244955
  } });
243637
- return mapReolinkDiscoveryToCandidates(devices, {
244956
+ const onboarded = this.listOnboardedForDiscovery();
244957
+ const candidates = mapReolinkDiscoveryToCandidates(devices, {
243638
244958
  username,
243639
244959
  password
243640
- });
244960
+ }, onboarded);
244961
+ const owned = candidates.filter((c) => c.alreadyOnboarded === true).length;
244962
+ if (owned > 0) this.ctx.logger.info("Reolink discovery: candidates already owned by this provider", { meta: {
244963
+ owned,
244964
+ total: candidates.length
244965
+ } });
244966
+ return candidates;
243641
244967
  }
243642
244968
  async adoptDiscoveredDevice(input) {
243643
244969
  return this.createDevice({
@@ -243867,6 +245193,7 @@ exports.collectNativeDiagnostics = collectNativeDiagnostics;
243867
245193
  exports.collectNvrDiagnostics = collectNvrDiagnostics;
243868
245194
  exports.createDiagnosticsBundle = createDiagnosticsBundle;
243869
245195
  exports.reolinkCameraSchema = reolinkCameraSchema;
245196
+ exports.reolinkDebugActions = reolinkDebugActions;
243870
245197
  exports.runAllDiagnosticsConsecutively = runAllDiagnosticsConsecutively;
243871
245198
  exports.runMultifocalDiagnosticsConsecutively = runMultifocalDiagnosticsConsecutively;
243872
245199
  exports.sampleStreams = sampleStreams;