@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.mjs CHANGED
@@ -7194,7 +7194,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7194
7194
  * still gives the event loop a chance to drain — useful for breaking
7195
7195
  * up tight async loops without changing call-site semantics.
7196
7196
  */
7197
- function sleep$1(ms) {
7197
+ function sleep$2(ms) {
7198
7198
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7199
7199
  }
7200
7200
  var EncodeProfileSchema = object({
@@ -12406,6 +12406,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
12406
12406
  filePath: string(),
12407
12407
  content: string()
12408
12408
  })) }), { auth: "admin" });
12409
+ /**
12410
+ * Identity — preserves literal types for downstream inference.
12411
+ *
12412
+ * The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
12413
+ * TypeScript does not widen each entry's literal `kind`/`auth` fields to
12414
+ * the broader unions declared on `CustomActionSpec`'s default generics.
12415
+ * Shape validity is enforced separately by the `customAction(...)` helper
12416
+ * whose return type is already a `CustomActionSpec<...>`.
12417
+ */
12418
+ function defineCustomActions(spec) {
12419
+ return spec;
12420
+ }
12421
+ function customAction(input, output, options) {
12422
+ return {
12423
+ input,
12424
+ output,
12425
+ kind: options?.kind ?? "query",
12426
+ auth: options?.auth ?? "protected",
12427
+ scope: options?.scope ?? { kind: "system" },
12428
+ ...options?.caller ? { caller: "required" } : {}
12429
+ };
12430
+ }
12409
12431
  function deviceCustomAction(input, output, options) {
12410
12432
  return {
12411
12433
  input,
@@ -13334,7 +13356,26 @@ var DiscoveryCandidateSchema = object({
13334
13356
  * identity ahead of adoption. Rendering metadata (unit, precision)
13335
13357
  * flows live through the cap STATUS SLICE after adoption.
13336
13358
  */
13337
- sourceInfo: SourceInfoSchema.optional()
13359
+ sourceInfo: SourceInfoSchema.optional(),
13360
+ /**
13361
+ * Set when this candidate is a device the provider ALREADY owns.
13362
+ *
13363
+ * A scan cannot generally produce the identity a device was onboarded under
13364
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
13365
+ * comparison never matches and an owned device looks addable. Re-adopting one
13366
+ * overwrites its config with scan-derived values — that is how a Home Hub's
13367
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
13368
+ * three child cameras offline for four hours.
13369
+ *
13370
+ * A provider that can recognise its own devices says so here. Absent means
13371
+ * "not recognised", which is not the same as "known to be new" — a provider
13372
+ * that cannot tell simply never sets it.
13373
+ */
13374
+ alreadyOnboarded: boolean().optional(),
13375
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
13376
+ onboardedDeviceId: number().optional(),
13377
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
13378
+ onboardedName: string().optional()
13338
13379
  });
13339
13380
  /**
13340
13381
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -13390,6 +13431,35 @@ var deviceProviderCapability = {
13390
13431
  name: string(),
13391
13432
  type: string()
13392
13433
  }))),
13434
+ /**
13435
+ * Tear down and reconstruct ONE device in place from its persisted rows —
13436
+ * touching no other device this provider owns.
13437
+ *
13438
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
13439
+ * migrated numbers: after `swapIds` the runner's live instance still
13440
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
13441
+ * registrations and its log tags), and a live object cannot be renumbered.
13442
+ * Before this method the only flush was restarting the whole owning addon
13443
+ * — which took every camera the provider owns down with it (28 devices
13444
+ * for one migrated camera, measured 2026-09-04, and the morning of the
13445
+ * same day ~27 devices' native caps did not come back on their own).
13446
+ *
13447
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
13448
+ * that changes. The reply carries the id the device answers on NOW.
13449
+ * Implemented once in `BaseDeviceProvider` — decommission the live
13450
+ * instance (if any), then re-create from the persisted row: the same
13451
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
13452
+ * An RPC, never an event: a dropped event would leave the runner writing
13453
+ * against the wrong camera (D8).
13454
+ *
13455
+ * Construction can dial hardware, and the migrated source is
13456
+ * characteristically dead — the timeout covers a full activate window
13457
+ * rather than the 60 s default.
13458
+ */
13459
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
13460
+ kind: "mutation",
13461
+ timeoutMs: 3 * 6e4
13462
+ }),
13393
13463
  supportsDiscovery: method(object({}), boolean()),
13394
13464
  /**
13395
13465
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13717,7 +13787,8 @@ method(object({
13717
13787
  targetId: number()
13718
13788
  }), MigrateDeviceResultSchema, {
13719
13789
  kind: "mutation",
13720
- auth: "admin"
13790
+ auth: "admin",
13791
+ timeoutMs: 12 * 6e4
13721
13792
  }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13722
13793
  deviceId: number(),
13723
13794
  name: string()
@@ -33517,6 +33588,147 @@ var BaseDevice = class {
33517
33588
  }
33518
33589
  };
33519
33590
  /**
33591
+ * Delays before retry rounds 1..N — the round count IS the bound.
33592
+ * 10 s catches "the hub was busy for a moment"; the full schedule
33593
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
33594
+ * per attempt) covers a device-manager lock held for minutes — the
33595
+ * 2026-09-04 outage's migration hold was ~3.5 min.
33596
+ */
33597
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
33598
+ 1e4,
33599
+ 3e4,
33600
+ 9e4
33601
+ ];
33602
+ /** Abortable sleep — resolves early (never rejects) on abort. */
33603
+ function sleep$1(ms, signal) {
33604
+ return new Promise((resolve) => {
33605
+ if (signal.aborted) {
33606
+ resolve();
33607
+ return;
33608
+ }
33609
+ const onAbort = () => {
33610
+ clearTimeout(timer);
33611
+ resolve();
33612
+ };
33613
+ const timer = setTimeout(() => {
33614
+ signal.removeEventListener("abort", onAbort);
33615
+ resolve();
33616
+ }, ms);
33617
+ timer.unref?.();
33618
+ signal.addEventListener("abort", onAbort, { once: true });
33619
+ });
33620
+ }
33621
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
33622
+ * not reject (callers wrap their own try/catch). */
33623
+ async function runWithConcurrency(items, width, fn) {
33624
+ const queue = [...items];
33625
+ const laneCount = Math.max(1, Math.min(width, queue.length));
33626
+ const lane = async () => {
33627
+ for (;;) {
33628
+ const item = queue.shift();
33629
+ if (item === void 0) return;
33630
+ await fn(item);
33631
+ }
33632
+ };
33633
+ await Promise.all(Array.from({ length: laneCount }, lane));
33634
+ }
33635
+ var DeviceRestoreRetryScheduler = class {
33636
+ #logger;
33637
+ #attempt;
33638
+ #onPermanentFailure;
33639
+ #delaysMs;
33640
+ #concurrency;
33641
+ #now;
33642
+ #abort = new AbortController();
33643
+ constructor(options) {
33644
+ this.#logger = options.logger;
33645
+ this.#attempt = options.attempt;
33646
+ this.#onPermanentFailure = options.onPermanentFailure;
33647
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
33648
+ this.#concurrency = options.concurrency ?? 4;
33649
+ this.#now = options.now ?? Date.now;
33650
+ }
33651
+ /** Stop retrying (shutdown). Pending entries are NOT marked
33652
+ * permanently failed — the next boot restores them from disk. */
33653
+ cancel() {
33654
+ this.#abort.abort();
33655
+ }
33656
+ /**
33657
+ * Run the bounded retry rounds. Resolves when every entry has either
33658
+ * restored, been marked permanently failed, or the scheduler was
33659
+ * cancelled. Never rejects.
33660
+ */
33661
+ async run(initialFailures) {
33662
+ let pending = initialFailures.map((failure) => ({
33663
+ saved: failure.saved,
33664
+ lastError: failure.error,
33665
+ attempts: 1
33666
+ }));
33667
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
33668
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
33669
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
33670
+ if (this.#abort.signal.aborted) break;
33671
+ pending = await this.#runRound(pending, round);
33672
+ }
33673
+ if (this.#abort.signal.aborted) return [];
33674
+ const terminal = pending.map((entry) => ({
33675
+ deviceId: entry.saved.id,
33676
+ stableId: entry.saved.stableId,
33677
+ type: String(entry.saved.type),
33678
+ attempts: entry.attempts,
33679
+ lastError: entry.lastError,
33680
+ failedAt: this.#now()
33681
+ }));
33682
+ for (const failure of terminal) this.#onPermanentFailure(failure);
33683
+ return terminal;
33684
+ }
33685
+ /** One retry round: parents first (phase 0), then hub-adopted
33686
+ * children (phase 1) — a child's attempt depends on its parent
33687
+ * having landed, exactly like the initial two-pass restore. */
33688
+ async #runRound(pending, round) {
33689
+ const next = [];
33690
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
33691
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
33692
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
33693
+ if (this.#abort.signal.aborted) {
33694
+ next.push(entry);
33695
+ return;
33696
+ }
33697
+ const attemptNo = entry.attempts + 1;
33698
+ try {
33699
+ await this.#attempt(entry.saved);
33700
+ this.#logger.info("Device restored on retry", {
33701
+ tags: {
33702
+ deviceId: entry.saved.id,
33703
+ stableId: entry.saved.stableId
33704
+ },
33705
+ meta: { attempt: attemptNo }
33706
+ });
33707
+ } catch (err) {
33708
+ const lastError = err instanceof Error ? err.message : String(err);
33709
+ const remainingRetries = this.#delaysMs.length - (round + 1);
33710
+ this.#logger.warn("Device restore retry failed", {
33711
+ tags: {
33712
+ deviceId: entry.saved.id,
33713
+ stableId: entry.saved.stableId
33714
+ },
33715
+ meta: {
33716
+ attempt: attemptNo,
33717
+ remainingRetries,
33718
+ error: lastError
33719
+ }
33720
+ });
33721
+ next.push({
33722
+ saved: entry.saved,
33723
+ lastError,
33724
+ attempts: attemptNo
33725
+ });
33726
+ }
33727
+ });
33728
+ return next;
33729
+ }
33730
+ };
33731
+ /**
33520
33732
  * Convert an IDevice to the flat DeviceSummary shape expected by the
33521
33733
  * device-provider cap router. Shared across all providers.
33522
33734
  */
@@ -33565,6 +33777,7 @@ var BaseDeviceProvider = class extends BaseAddon {
33565
33777
  }];
33566
33778
  }
33567
33779
  async onShutdown() {
33780
+ this.cancelRestoreRetries();
33568
33781
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
33569
33782
  for (const device of devices) try {
33570
33783
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -33582,9 +33795,16 @@ var BaseDeviceProvider = class extends BaseAddon {
33582
33795
  async start() {}
33583
33796
  async stop() {}
33584
33797
  async getStatus() {
33798
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
33799
+ const summary = this.restoreFailureSummary();
33800
+ if (summary === null) return {
33801
+ connected: true,
33802
+ deviceCount: all.length
33803
+ };
33585
33804
  return {
33586
33805
  connected: true,
33587
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
33806
+ deviceCount: all.length,
33807
+ error: summary
33588
33808
  };
33589
33809
  }
33590
33810
  async getDevices() {
@@ -33674,8 +33894,137 @@ var BaseDeviceProvider = class extends BaseAddon {
33674
33894
  };
33675
33895
  }
33676
33896
  async restoreDevices(savedDevices) {
33677
- await this.onRestoreDevices(savedDevices);
33678
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33897
+ const report = await this.onRestoreDevices(savedDevices);
33898
+ if (savedDevices.length === 0) return;
33899
+ if (report && report.failedCount > 0) {
33900
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33901
+ return;
33902
+ }
33903
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33904
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33905
+ }
33906
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33907
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33908
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33909
+ * never re-stampede full-width while the initial pass does (D167). */
33910
+ restoreRetryConcurrency = 4;
33911
+ _restoreRetryScheduler = null;
33912
+ _restoreRetryCompletion = null;
33913
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33914
+ /** Settles when the background retry rounds finish (or `null` when
33915
+ * nothing failed). Exposed for tests and subclass diagnostics —
33916
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33917
+ * with the devices that restored, and a late success is announced
33918
+ * through the `native-cap-change` → `updateCaps` path. */
33919
+ get restoreRetryCompletion() {
33920
+ return this._restoreRetryCompletion;
33921
+ }
33922
+ /** Devices that exhausted the retry bound this process lifetime. */
33923
+ get permanentRestoreFailures() {
33924
+ return [...this._permanentRestoreFailures.values()];
33925
+ }
33926
+ /** One-line operator-facing summary for `getStatus().error`, or
33927
+ * `null` when every device restored. */
33928
+ restoreFailureSummary() {
33929
+ if (this._permanentRestoreFailures.size === 0) return null;
33930
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33931
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33932
+ }
33933
+ cancelRestoreRetries() {
33934
+ this._restoreRetryScheduler?.cancel();
33935
+ this._restoreRetryScheduler = null;
33936
+ }
33937
+ recordPermanentRestoreFailure(failure) {
33938
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33939
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33940
+ tags: {
33941
+ deviceId: failure.deviceId,
33942
+ stableId: failure.stableId
33943
+ },
33944
+ meta: {
33945
+ type: failure.type,
33946
+ attempts: failure.attempts,
33947
+ error: failure.lastError
33948
+ }
33949
+ });
33950
+ }
33951
+ scheduleRestoreRetries(failures, attempt) {
33952
+ const scheduler = new DeviceRestoreRetryScheduler({
33953
+ logger: this.ctx.logger,
33954
+ delaysMs: this.restoreRetryDelaysMs,
33955
+ concurrency: this.restoreRetryConcurrency,
33956
+ attempt,
33957
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33958
+ });
33959
+ this._restoreRetryScheduler = scheduler;
33960
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33961
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33962
+ });
33963
+ }
33964
+ /**
33965
+ * Tear down and reconstruct ONE device from its persisted rows — the
33966
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33967
+ * and no other device this provider owns is disturbed.
33968
+ *
33969
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33970
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33971
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33972
+ * whatever number the row carries NOW. The teardown is `decommission` —
33973
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33974
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33975
+ * the boot restore's own `create()` path, including its pass 2: first-class
33976
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33977
+ * parent by the cascade and must be re-created explicitly, because only
33978
+ * accessory children come back through `getAccessoryChildren()`.
33979
+ *
33980
+ * Reloading an accessory child directly is refused (no device class) —
33981
+ * reload its parent instead.
33982
+ */
33983
+ async reloadDevice(input) {
33984
+ const { stableId } = input;
33985
+ const devices = this.ctx.kernel.devices;
33986
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33987
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33988
+ if (live) await devices.decommission(live.id);
33989
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33990
+ addonId: this.addonId,
33991
+ stableId
33992
+ });
33993
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33994
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33995
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33996
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33997
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33998
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33999
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
34000
+ for (const row of rows) {
34001
+ if (row.parentDeviceId !== id) continue;
34002
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
34003
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
34004
+ if (!ChildClass) continue;
34005
+ try {
34006
+ await devices.create(row.stableId, ChildClass, {}, id);
34007
+ } catch (err) {
34008
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
34009
+ tags: {
34010
+ deviceId: row.id,
34011
+ stableId: row.stableId
34012
+ },
34013
+ meta: {
34014
+ parentDeviceId: id,
34015
+ error: err instanceof Error ? err.message : String(err)
34016
+ }
34017
+ });
34018
+ }
34019
+ }
34020
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
34021
+ tags: { deviceId: id },
34022
+ meta: {
34023
+ stableId,
34024
+ type: meta.type
34025
+ }
34026
+ });
34027
+ return { deviceId: id };
33679
34028
  }
33680
34029
  /**
33681
34030
  * Restore devices from persisted state. Two-pass:
@@ -33701,55 +34050,125 @@ var BaseDeviceProvider = class extends BaseAddon {
33701
34050
  * accessory-spawn flow handles via the parent's
33702
34051
  * `getAccessoryChildren()`. Override only when the default doesn't
33703
34052
  * fit.
34053
+ *
34054
+ * A row that fails either pass is NOT terminal (D347): it is handed
34055
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
34056
+ * Only after the bound is exhausted is the device marked permanently
34057
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
34058
+ * `getStatus().error`.
33704
34059
  */
34060
+ /**
34061
+ * Repair a row's PERSISTED config blob immediately before it is restored.
34062
+ * Default: no-op — most providers have nothing to heal.
34063
+ *
34064
+ * This exists because a restored device self-hydrates from the DB: `create()`
34065
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
34066
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
34067
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
34068
+ * been emptied failed all four bounded attempts against fields
34069
+ * (`host`, `password`) it inherits from its parent and never dials itself.
34070
+ *
34071
+ * Implementations get every saved row, so a child can read its parent's blob.
34072
+ * A heal that throws is treated like any other restore failure: retried under
34073
+ * the bound, then reported — never swallowed.
34074
+ */
34075
+ async healSavedConfig(_saved, _allSaved) {}
33705
34076
  async onRestoreDevices(savedDevices) {
33706
34077
  const restored = /* @__PURE__ */ new Set();
34078
+ const failures = [];
34079
+ const attemptRestore = async (saved) => {
34080
+ if (restored.has(saved.id)) return;
34081
+ const Class = this.deviceClasses[saved.type];
34082
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34083
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
34084
+ await this.healSavedConfig(saved, savedDevices);
34085
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
34086
+ restored.add(saved.id);
34087
+ };
33707
34088
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
33708
34089
  const restoreOne = async (saved) => {
33709
- const Class = this.deviceClasses[saved.type];
33710
- if (!Class) {
34090
+ if (!this.deviceClasses[saved.type]) {
33711
34091
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
33712
- tags: { stableId: saved.stableId },
34092
+ tags: {
34093
+ deviceId: saved.id,
34094
+ stableId: saved.stableId
34095
+ },
33713
34096
  meta: { type: saved.type }
33714
34097
  });
33715
34098
  return;
33716
34099
  }
33717
34100
  try {
33718
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
33719
- restored.add(saved.id);
34101
+ await attemptRestore(saved);
33720
34102
  } catch (err) {
33721
- this.ctx.logger.warn("Failed to restore device", {
33722
- tags: { stableId: saved.stableId },
34103
+ const error = err instanceof Error ? err.message : String(err);
34104
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
34105
+ tags: {
34106
+ deviceId: saved.id,
34107
+ stableId: saved.stableId
34108
+ },
33723
34109
  meta: {
33724
34110
  type: saved.type,
33725
- error: err instanceof Error ? err.message : String(err)
34111
+ attempt: 1,
34112
+ error
33726
34113
  }
33727
34114
  });
34115
+ failures.push({
34116
+ saved,
34117
+ error
34118
+ });
33728
34119
  }
33729
34120
  };
33730
34121
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
34122
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
33731
34123
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
33732
34124
  for (const saved of childRows) {
33733
- const Class = this.deviceClasses[saved.type];
33734
- if (!Class) continue;
34125
+ if (!this.deviceClasses[saved.type]) continue;
33735
34126
  if (saved.parentDeviceId === null) continue;
33736
- if (!restored.has(saved.parentDeviceId)) continue;
33737
- try {
33738
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33739
- restored.add(saved.id);
33740
- } catch (err) {
33741
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
34127
+ if (restored.has(saved.parentDeviceId)) {
34128
+ try {
34129
+ await attemptRestore(saved);
34130
+ } catch (err) {
34131
+ const error = err instanceof Error ? err.message : String(err);
34132
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
34133
+ tags: {
34134
+ deviceId: saved.id,
34135
+ stableId: saved.stableId,
34136
+ parentDeviceId: saved.parentDeviceId
34137
+ },
34138
+ meta: {
34139
+ type: saved.type,
34140
+ attempt: 1,
34141
+ error
34142
+ }
34143
+ });
34144
+ failures.push({
34145
+ saved,
34146
+ error
34147
+ });
34148
+ }
34149
+ continue;
34150
+ }
34151
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
34152
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
33742
34153
  tags: {
34154
+ deviceId: saved.id,
33743
34155
  stableId: saved.stableId,
33744
34156
  parentDeviceId: saved.parentDeviceId
33745
34157
  },
33746
- meta: {
33747
- type: saved.type,
33748
- error: err instanceof Error ? err.message : String(err)
33749
- }
34158
+ meta: { type: saved.type }
34159
+ });
34160
+ failures.push({
34161
+ saved,
34162
+ error: `parent device ${saved.parentDeviceId} not restored`
33750
34163
  });
34164
+ continue;
33751
34165
  }
33752
34166
  }
34167
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
34168
+ return {
34169
+ restoredCount: restored.size,
34170
+ failedCount: failures.length
34171
+ };
33753
34172
  }
33754
34173
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
33755
34174
  toSummary(device) {
@@ -35664,6 +36083,12 @@ Object.freeze({
35664
36083
  addonId: null,
35665
36084
  access: "view"
35666
36085
  },
36086
+ "deviceProvider.reloadDevice": {
36087
+ capName: "device-provider",
36088
+ capScope: "system",
36089
+ addonId: null,
36090
+ access: "create"
36091
+ },
35667
36092
  "deviceProvider.start": {
35668
36093
  capName: "device-provider",
35669
36094
  capScope: "system",
@@ -179088,7 +179513,7 @@ ${xml}`);
179088
179513
  * @returns Test results for all stream types and profiles
179089
179514
  */
179090
179515
  async testChannelStreams(channel, logger) {
179091
- const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs");
179516
+ const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
179092
179517
  return await testChannelStreams({
179093
179518
  api: this,
179094
179519
  channel: this.normalizeChannel(channel),
@@ -179104,7 +179529,7 @@ ${xml}`);
179104
179529
  * @returns Complete diagnostics for all channels and streams
179105
179530
  */
179106
179531
  async collectMultifocalDiagnostics(logger) {
179107
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs");
179532
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
179108
179533
  return await collectMultifocalDiagnostics({
179109
179534
  api: this,
179110
179535
  logger
@@ -230672,6 +231097,453 @@ var IntercomFailureReport = class {
230672
231097
  /** The process-wide instance every camera in this addon notes into. */
230673
231098
  var intercomFailureReport = new IntercomFailureReport();
230674
231099
  //#endregion
231100
+ //#region src/snapshot-freshness.ts
231101
+ /**
231102
+ * The snapshot groups the freshness panel reports on, with the Baichuan
231103
+ * read behind each. Order is the display order.
231104
+ */
231105
+ var SNAPSHOT_GROUPS = [
231106
+ {
231107
+ key: "imageSnapshot",
231108
+ label: "Image (getVideoInput)"
231109
+ },
231110
+ {
231111
+ key: "motionSnapshot",
231112
+ label: "Motion (getMotionAlarm)"
231113
+ },
231114
+ {
231115
+ key: "aiSensitivitySnapshot",
231116
+ label: "AI sensitivity (getAiDetectionFull)"
231117
+ },
231118
+ {
231119
+ key: "encSnapshot",
231120
+ label: "Encoder (getEnc)"
231121
+ },
231122
+ {
231123
+ key: "encOptionsSnapshot",
231124
+ label: "Encoder options (getEncOptions)"
231125
+ },
231126
+ {
231127
+ key: "maskSnapshot",
231128
+ label: "Privacy mask (getMask)"
231129
+ },
231130
+ {
231131
+ key: "audioNoiseSnapshot",
231132
+ label: "Audio noise (getAudioNoise)"
231133
+ },
231134
+ {
231135
+ key: "autoFocusSnapshot",
231136
+ label: "Auto-focus (getAutoFocus)"
231137
+ },
231138
+ {
231139
+ key: "netPortSnapshot",
231140
+ label: "Network ports (getNetPort)"
231141
+ },
231142
+ {
231143
+ key: "ntpSnapshot",
231144
+ label: "NTP (getNtp)"
231145
+ },
231146
+ {
231147
+ key: "systemGeneralSnapshot",
231148
+ label: "System general (getSystemGeneral)"
231149
+ },
231150
+ {
231151
+ key: "osdSnapshot",
231152
+ label: "OSD overlay (getOsd)"
231153
+ },
231154
+ {
231155
+ key: "ledSnapshot",
231156
+ label: "LEDs (getIrLights)"
231157
+ },
231158
+ {
231159
+ key: "pirSnapshot",
231160
+ label: "PIR (getPirInfo)"
231161
+ },
231162
+ {
231163
+ key: "autoRebootSnapshot",
231164
+ label: "Auto reboot (getAutoReboot)"
231165
+ },
231166
+ {
231167
+ key: "emailConfigSnapshot",
231168
+ label: "Email/SMTP (getEmail)"
231169
+ },
231170
+ {
231171
+ key: "capOptionsSnapshot",
231172
+ label: "Cap option probes (getOptions)"
231173
+ }
231174
+ ];
231175
+ /**
231176
+ * Merge freshness stamps for the snapshot keys a persist actually wrote.
231177
+ * Returns a NEW map (immutability) — previous stamps for untouched groups
231178
+ * survive, written groups are stamped `now`. Call this from the same
231179
+ * `setAll` that writes the snapshots, with exactly the keys being written:
231180
+ * a failed probe writes no snapshot and therefore gets no stamp.
231181
+ */
231182
+ function stampSnapshotFreshness(previous, writtenKeys, now) {
231183
+ const stamped = { ...previous };
231184
+ for (const key of writtenKeys) stamped[key] = now;
231185
+ return stamped;
231186
+ }
231187
+ /**
231188
+ * Resolve the age of one snapshot group from the cache. Sources, in order:
231189
+ * 1. `snapshotFetchedAt[key]` — the generic stamp map;
231190
+ * 2. a group-embedded stamp where one already existed before the map
231191
+ * (`osdSnapshot.fetchedAt`, `emailConfigSnapshot.lastReadAt`,
231192
+ * newest `capOptionsSnapshot[*].fetchedAt`);
231193
+ * 3. otherwise: the group is present but of unknown age.
231194
+ * An absent group is `never` — not-yet-read must never look like read.
231195
+ */
231196
+ function resolveSnapshotAge(cache, key, now) {
231197
+ if ((cache === void 0 ? void 0 : Reflect.get(cache, key)) === void 0) return { state: "never" };
231198
+ const mapStamp = cache?.snapshotFetchedAt?.[key];
231199
+ const stamp = typeof mapStamp === "number" ? mapStamp : embeddedStamp(cache, key);
231200
+ if (typeof stamp !== "number") return { state: "unknown" };
231201
+ return {
231202
+ state: "known",
231203
+ fetchedAt: stamp,
231204
+ ageMs: Math.max(0, now - stamp)
231205
+ };
231206
+ }
231207
+ /** Pre-map stamps some groups already carried; kept as fallback so a legacy
231208
+ * cache written by today's OSD fix still reports a real age. */
231209
+ function embeddedStamp(cache, key) {
231210
+ if (key === "osdSnapshot") {
231211
+ const v = cache?.osdSnapshot?.fetchedAt;
231212
+ return typeof v === "number" ? v : void 0;
231213
+ }
231214
+ if (key === "emailConfigSnapshot") {
231215
+ const v = cache?.emailConfigSnapshot?.lastReadAt;
231216
+ return typeof v === "number" ? v : void 0;
231217
+ }
231218
+ if (key === "capOptionsSnapshot") {
231219
+ const stamps = Object.values(cache?.capOptionsSnapshot ?? {}).map((e) => e?.fetchedAt).filter((v) => typeof v === "number");
231220
+ return stamps.length > 0 ? Math.max(...stamps) : void 0;
231221
+ }
231222
+ }
231223
+ /** Human age: "12 s ago", "3 m ago", "5 h ago", "12 d ago". */
231224
+ function formatSnapshotAge(ageMs) {
231225
+ const s = Math.floor(ageMs / 1e3);
231226
+ if (s < 60) return `${s} s ago`;
231227
+ const m = Math.floor(s / 60);
231228
+ if (m < 60) return `${m} m ago`;
231229
+ const h = Math.floor(m / 60);
231230
+ if (h < 48) return `${h} h ago`;
231231
+ return `${Math.floor(h / 24)} d ago`;
231232
+ }
231233
+ /** One display line for a group. Unknown age is SAID, never smoothed over. */
231234
+ function formatSnapshotAgeLine(label, age) {
231235
+ switch (age.state) {
231236
+ case "never": return `${label}: never read`;
231237
+ case "unknown": return `${label}: age unknown (recorded before per-snapshot freshness tracking)`;
231238
+ case "known": return `${label}: read ${formatSnapshotAge(age.ageMs)} (${new Date(age.fetchedAt).toLocaleString()})`;
231239
+ }
231240
+ }
231241
+ /**
231242
+ * Read-only "Snapshot freshness" section (advanced tab, next to Debug).
231243
+ * Lists every snapshot group with its own age so "is CamStack's belief
231244
+ * current?" is answerable per fact, not per cache. Purely informational:
231245
+ * it triggers no reads — refresh stays on the existing operator-triggered
231246
+ * "Refresh from camera" action and the event-driven paths.
231247
+ */
231248
+ function buildSnapshotFreshnessSection(cache, opts) {
231249
+ const lines = SNAPSHOT_GROUPS.map(({ key, label }) => formatSnapshotAgeLine(label, resolveSnapshotAge(cache, key, opts.now)));
231250
+ const probedAt = cache?.probedAt;
231251
+ const header = typeof probedAt === "number" ? `Feature probe: ${new Date(probedAt).toLocaleString()}` : "Feature probe: never recorded";
231252
+ return {
231253
+ id: "snapshotFreshness",
231254
+ tab: "advanced",
231255
+ title: "Snapshot freshness",
231256
+ 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.",
231257
+ columns: 1,
231258
+ fields: [{
231259
+ type: "info",
231260
+ key: "snapshotFreshness",
231261
+ label: "Per-snapshot read times",
231262
+ 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")}`
231263
+ }]
231264
+ };
231265
+ }
231266
+ var RawReadSliceSchema = _enum([
231267
+ "image",
231268
+ "motion",
231269
+ "ai",
231270
+ "enc",
231271
+ "encOptions",
231272
+ "mask",
231273
+ "audioNoise",
231274
+ "autofocus",
231275
+ "netPort",
231276
+ "ntp",
231277
+ "systemGeneral",
231278
+ "osd",
231279
+ "led",
231280
+ "pir",
231281
+ "autoReboot"
231282
+ ]);
231283
+ /**
231284
+ * The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
231285
+ * in lockstep in both directions. Anything not in this map — email/SMTP
231286
+ * (`getEmail` echoes the camera's SMTP credentials), users, sessions, any
231287
+ * `set*` — cannot be requested.
231288
+ */
231289
+ var RAW_READ_CATALOG = {
231290
+ image: {
231291
+ command: "getVideoInput",
231292
+ snapshotKey: "imageSnapshot",
231293
+ invoke: (api, channel) => api.getVideoInput(channel)
231294
+ },
231295
+ motion: {
231296
+ command: "getMotionAlarm",
231297
+ snapshotKey: "motionSnapshot",
231298
+ invoke: (api, channel) => api.getMotionAlarm(channel)
231299
+ },
231300
+ ai: {
231301
+ command: "getAiDetectTypes + getAiDetectionFull (per type)",
231302
+ snapshotKey: "aiSensitivitySnapshot",
231303
+ invoke: async (api, channel) => {
231304
+ const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
231305
+ const perType = {};
231306
+ for (const aiType of detectTypes ?? []) try {
231307
+ perType[aiType] = await api.getAiDetectionFull(channel, aiType);
231308
+ } catch (err) {
231309
+ perType[aiType] = { error: err instanceof Error ? err.message : String(err) };
231310
+ }
231311
+ return {
231312
+ detectTypes: detectTypes ?? [],
231313
+ perType
231314
+ };
231315
+ }
231316
+ },
231317
+ enc: {
231318
+ command: "getEnc",
231319
+ snapshotKey: "encSnapshot",
231320
+ invoke: (api, channel) => api.getEnc(channel)
231321
+ },
231322
+ encOptions: {
231323
+ command: "getEncOptions",
231324
+ snapshotKey: "encOptionsSnapshot",
231325
+ invoke: (api, channel) => api.getEncOptions(channel)
231326
+ },
231327
+ mask: {
231328
+ command: "getMask",
231329
+ snapshotKey: "maskSnapshot",
231330
+ invoke: (api, channel) => api.getMask(channel)
231331
+ },
231332
+ audioNoise: {
231333
+ command: "getAudioNoise",
231334
+ snapshotKey: "audioNoiseSnapshot",
231335
+ invoke: (api, channel) => api.getAudioNoise(channel)
231336
+ },
231337
+ autofocus: {
231338
+ command: "getAutoFocus",
231339
+ snapshotKey: "autoFocusSnapshot",
231340
+ invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
231341
+ },
231342
+ netPort: {
231343
+ command: "getNetPort",
231344
+ snapshotKey: "netPortSnapshot",
231345
+ invoke: (api) => api.getNetPort()
231346
+ },
231347
+ ntp: {
231348
+ command: "getNtp",
231349
+ snapshotKey: "ntpSnapshot",
231350
+ invoke: (api) => api.getNtp()
231351
+ },
231352
+ systemGeneral: {
231353
+ command: "getSystemGeneral",
231354
+ snapshotKey: "systemGeneralSnapshot",
231355
+ invoke: (api) => api.getSystemGeneral()
231356
+ },
231357
+ osd: {
231358
+ command: "getOsd",
231359
+ snapshotKey: "osdSnapshot",
231360
+ invoke: (api, channel) => api.getOsd(channel)
231361
+ },
231362
+ led: {
231363
+ command: "getIrLights",
231364
+ snapshotKey: "ledSnapshot",
231365
+ invoke: (api, channel) => api.getIrLights(channel)
231366
+ },
231367
+ pir: {
231368
+ command: "getPirInfo",
231369
+ snapshotKey: "pirSnapshot",
231370
+ invoke: (api, channel) => api.getPirInfo(channel)
231371
+ },
231372
+ autoReboot: {
231373
+ command: "getAutoReboot",
231374
+ snapshotKey: "autoRebootSnapshot",
231375
+ invoke: (api) => api.getAutoReboot()
231376
+ }
231377
+ };
231378
+ /** What CamStack currently believes about the slice, with its own age. */
231379
+ var BelievedStateSchema = object({
231380
+ /** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
231381
+ snapshot: unknown(),
231382
+ /** deviceCache field the projection lives in. */
231383
+ snapshotKey: string(),
231384
+ /** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
231385
+ age: union([
231386
+ object({ state: literal("never") }),
231387
+ object({ state: literal("unknown") }),
231388
+ object({
231389
+ state: literal("known"),
231390
+ fetchedAt: number().int(),
231391
+ ageMs: number().int().nonnegative()
231392
+ })
231393
+ ])
231394
+ });
231395
+ var RawReadResultSchema = discriminatedUnion("ok", [object({
231396
+ ok: literal(true),
231397
+ deviceId: number().int(),
231398
+ slice: RawReadSliceSchema,
231399
+ command: string(),
231400
+ readAt: number().int(),
231401
+ /** The library's response, unprojected, as plain JSON. */
231402
+ camera: unknown(),
231403
+ believed: BelievedStateSchema
231404
+ }), object({
231405
+ ok: literal(false),
231406
+ deviceId: number().int(),
231407
+ slice: RawReadSliceSchema,
231408
+ reason: _enum([
231409
+ "sleeping",
231410
+ "login-failed",
231411
+ "read-failed"
231412
+ ]),
231413
+ message: string(),
231414
+ /** The believed state is still reported — a refusal must not hide
231415
+ * what CamStack is currently serving. */
231416
+ believed: BelievedStateSchema
231417
+ })]);
231418
+ var RawReadInputSchema = object({
231419
+ deviceId: number().int().nonnegative(),
231420
+ slice: RawReadSliceSchema
231421
+ });
231422
+ function believedState(cache, entry, now) {
231423
+ const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
231424
+ return {
231425
+ snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
231426
+ snapshotKey: entry.snapshotKey,
231427
+ age
231428
+ };
231429
+ }
231430
+ /** Force a lib response to plain JSON: drops functions/undefined/prototypes,
231431
+ * guarantees the payload is serializable across the tRPC boundary. */
231432
+ function toPlainJson(value) {
231433
+ if (value === void 0) return null;
231434
+ return JSON.parse(JSON.stringify(value));
231435
+ }
231436
+ /**
231437
+ * Execute one raw read. Order matters:
231438
+ * 1. sleep gate (refuse loudly — logging in would BE the wake);
231439
+ * 2. login;
231440
+ * 3. the allow-listed read;
231441
+ * and every outcome — refusal included — carries the believed state so the
231442
+ * operator always sees both sides of the comparison.
231443
+ */
231444
+ async function performRawRead(slice, deps) {
231445
+ const entry = RAW_READ_CATALOG[slice];
231446
+ const believed = believedState(deps.cache, entry, deps.now);
231447
+ if (deps.sleeping) {
231448
+ deps.logger.info("reolink raw read refused — battery cam is sleeping", {
231449
+ tags: { deviceId: deps.deviceId },
231450
+ meta: { slice }
231451
+ });
231452
+ return {
231453
+ ok: false,
231454
+ deviceId: deps.deviceId,
231455
+ slice,
231456
+ reason: "sleeping",
231457
+ message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231458
+ believed
231459
+ };
231460
+ }
231461
+ let api;
231462
+ try {
231463
+ api = await deps.getApi();
231464
+ } catch (err) {
231465
+ const message = err instanceof Error ? err.message : String(err);
231466
+ deps.logger.info("reolink raw read login failed", {
231467
+ tags: { deviceId: deps.deviceId },
231468
+ meta: {
231469
+ slice,
231470
+ error: message
231471
+ }
231472
+ });
231473
+ return {
231474
+ ok: false,
231475
+ deviceId: deps.deviceId,
231476
+ slice,
231477
+ reason: "login-failed",
231478
+ message,
231479
+ believed
231480
+ };
231481
+ }
231482
+ try {
231483
+ const payload = await entry.invoke(api, deps.channel);
231484
+ deps.logger.info("reolink raw read served", {
231485
+ tags: { deviceId: deps.deviceId },
231486
+ meta: {
231487
+ slice,
231488
+ command: entry.command
231489
+ }
231490
+ });
231491
+ return {
231492
+ ok: true,
231493
+ deviceId: deps.deviceId,
231494
+ slice,
231495
+ command: entry.command,
231496
+ readAt: deps.now,
231497
+ camera: toPlainJson(payload),
231498
+ believed
231499
+ };
231500
+ } catch (err) {
231501
+ const message = err instanceof Error ? err.message : String(err);
231502
+ deps.logger.info("reolink raw read failed", {
231503
+ tags: { deviceId: deps.deviceId },
231504
+ meta: {
231505
+ slice,
231506
+ command: entry.command,
231507
+ error: message
231508
+ }
231509
+ });
231510
+ return {
231511
+ ok: false,
231512
+ deviceId: deps.deviceId,
231513
+ slice,
231514
+ reason: "read-failed",
231515
+ message,
231516
+ believed
231517
+ };
231518
+ }
231519
+ }
231520
+ //#endregion
231521
+ //#region src/debug-actions.ts
231522
+ /**
231523
+ * provider-reolink — customActions catalog (admin-only debug surface).
231524
+ *
231525
+ * Dispatched via `POST addons.custom
231526
+ * {addonId:'provider-reolink', action:'debugRawRead', input:{deviceId, slice}}`.
231527
+ *
231528
+ * Why an addon custom action and not a device action: `deviceManager.
231529
+ * runDeviceAction` (the `refresh-settings` / `refresh-sessions` shape) is
231530
+ * mounted `protected` and its dispatcher does not enforce the per-action
231531
+ * `auth` — any authenticated user could call it. `addons.custom` is the one
231532
+ * operator surface that enforces per-action `auth: 'admin'` server-side
231533
+ * (`ensureCustomActionAuth`) AND validates the addon's output against this
231534
+ * catalog. A debug surface that returns raw camera payloads is admin-only,
231535
+ * so it lives here (same wiring as `addon-benchmark` / `addon-notifiers`).
231536
+ *
231537
+ * `kind: 'query'` states the contract — the handler is read-only by
231538
+ * construction (see `raw-read.ts`: the slice enum maps onto an allow-list
231539
+ * of lib `get*` calls; no write is reachable). The `addons.custom` mount
231540
+ * itself is a single mutation procedure, so callers still POST.
231541
+ */
231542
+ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawReadInputSchema, RawReadResultSchema, {
231543
+ kind: "query",
231544
+ auth: "admin"
231545
+ }) });
231546
+ //#endregion
230675
231547
  //#region src/log-channels.ts
230676
231548
  /**
230677
231549
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230884,6 +231756,130 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
230884
231756
  });
230885
231757
  }
230886
231758
  //#endregion
231759
+ //#region src/osd-settings-section.ts
231760
+ /**
231761
+ * Camera snapshot → UNKNOWN (`null`). The last arm is the point: `?? true`
231762
+ * here is what rendered "the camera did not tell us" as an enabled overlay.
231763
+ * The snapshot is the ONLY input — the config keeps no copy to consult.
231764
+ */
231765
+ function resolveOsdValues(snapshot) {
231766
+ return {
231767
+ osdChannelEnabled: snapshot?.channelEnabled ?? null,
231768
+ osdChannelName: snapshot?.channelName ?? "",
231769
+ osdTimeEnabled: snapshot?.timeEnabled ?? null,
231770
+ osdWatermark: snapshot?.watermark ?? null
231771
+ };
231772
+ }
231773
+ /**
231774
+ * Reader-side staleness (D224). A snapshot persisted before freshness
231775
+ * tracking has no `fetchedAt` and is treated as stale — that is exactly the
231776
+ * adoption-frozen reading this module exists to retire.
231777
+ */
231778
+ function isOsdSnapshotStale(snapshot, now) {
231779
+ return now - (snapshot?.fetchedAt ?? 0) > OPERATOR_WRITTEN_STALE_MS;
231780
+ }
231781
+ var OSD_UNKNOWN_DESCRIPTION = "Not reported by the camera yet — the current state is unknown.";
231782
+ var NEVER_WOKEN_SUFFIX = "a sleeping battery camera is never woken to read settings.";
231783
+ /**
231784
+ * A boolean overlay toggle. Unknown (`null`) renders disabled with an honest
231785
+ * description — the switch component shows `Boolean(null)` = off, and the
231786
+ * disabled + "not reported" pairing keeps that from reading as a claim.
231787
+ */
231788
+ function osdToggle(key, label, value, baseDescription) {
231789
+ const unknown = value === null;
231790
+ const description = unknown ? baseDescription ? `${baseDescription} ${OSD_UNKNOWN_DESCRIPTION}` : OSD_UNKNOWN_DESCRIPTION : baseDescription;
231791
+ return {
231792
+ type: "boolean",
231793
+ key,
231794
+ label,
231795
+ default: value,
231796
+ style: "switch",
231797
+ ...description !== void 0 ? { description } : {},
231798
+ ...unknown ? { disabled: true } : {}
231799
+ };
231800
+ }
231801
+ /**
231802
+ * State banner shown when the operator is NOT looking at a current reading:
231803
+ * - camera asleep and the mirror is stale → say what is shown and when it
231804
+ * was read, and that the camera is not woken for this;
231805
+ * - no reading has ever landed → say the toggles are unknown.
231806
+ * A fresh mirror on an awake camera renders no banner — serve-and-revalidate
231807
+ * keeps it honest silently.
231808
+ */
231809
+ function buildOsdStateBanner(snapshot, opts) {
231810
+ const stale = isOsdSnapshotStale(snapshot, opts.now);
231811
+ if (opts.sleeping && stale) return {
231812
+ type: "info",
231813
+ key: "osdSnapshotState",
231814
+ label: "OSD state",
231815
+ variant: "warning",
231816
+ 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}`
231817
+ };
231818
+ if (snapshot === void 0) return {
231819
+ type: "info",
231820
+ key: "osdSnapshotState",
231821
+ label: "OSD state",
231822
+ variant: "warning",
231823
+ content: "OSD state has not been read from this camera yet — unknown toggles are disabled until a read succeeds."
231824
+ };
231825
+ return null;
231826
+ }
231827
+ /**
231828
+ * A position value for display. Verbatim in quotes when the camera reported
231829
+ * one — an empty string IS a report and shows as `""` — and "not reported"
231830
+ * only when `getOsd` genuinely carried no string (tri-state, D337).
231831
+ */
231832
+ function formatObservedPos(pos) {
231833
+ return typeof pos === "string" ? `"${pos}"` : "not reported";
231834
+ }
231835
+ /**
231836
+ * Read-only view of the overlay positions the camera reported. Deliberately
231837
+ * NOT a control: the `pos` vocabulary is unknown (loose string, no observed
231838
+ * values yet), so this field exists to make it observable per camera. A
231839
+ * position control can be designed once real values have been collected —
231840
+ * see the "osd overlay positions observed" info log in the probe.
231841
+ */
231842
+ function buildOsdPositionsField(snapshot) {
231843
+ return {
231844
+ type: "info",
231845
+ key: "osdPositions",
231846
+ label: "Overlay positions",
231847
+ content: `Positions are kept exactly as configured on the camera and are read-only here.\nChannel name: ${formatObservedPos(snapshot?.channelPos)}\nTimestamp: ${formatObservedPos(snapshot?.timePos)}`
231848
+ };
231849
+ }
231850
+ /**
231851
+ * The "OSD overlay" section (writable via `setOsd`, cmd_id 25). One
231852
+ * read-modify-write `setOsd(OsdConfig)` push covers all four fields — the
231853
+ * dispatcher reads the current `OsdConfig` (`getOsd`) first so the stored
231854
+ * overlay positions (`pos`) survive untouched. Position itself is
231855
+ * camera-pixel/preset specific and only OBSERVED here, never written.
231856
+ */
231857
+ function buildOsdSection(snapshot, values, opts) {
231858
+ const banner = buildOsdStateBanner(snapshot, opts);
231859
+ return {
231860
+ id: "osd",
231861
+ tab: "image",
231862
+ title: "OSD overlay",
231863
+ 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.",
231864
+ columns: 2,
231865
+ fields: [
231866
+ ...banner ? [banner] : [],
231867
+ osdToggle("osdChannelEnabled", "Channel name overlay", values.osdChannelEnabled, void 0),
231868
+ {
231869
+ type: "text",
231870
+ key: "osdChannelName",
231871
+ label: "Channel name",
231872
+ description: "Text shown in the channel-name overlay.",
231873
+ default: values.osdChannelName,
231874
+ placeholder: "Front door"
231875
+ },
231876
+ osdToggle("osdTimeEnabled", "Timestamp overlay", values.osdTimeEnabled, void 0),
231877
+ osdToggle("osdWatermark", "Watermark", values.osdWatermark, "The Reolink logo watermark overlay."),
231878
+ buildOsdPositionsField(snapshot)
231879
+ ]
231880
+ };
231881
+ }
231882
+ //#endregion
230887
231883
  //#region src/raw-state.ts
230888
231884
  /**
230889
231885
  * Source tag for every raw-state blob this provider emits.
@@ -231118,7 +232114,7 @@ var SirenAccessory = class extends BaseDevice {
231118
232114
  this.ctx.logger.info("siren onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231119
232115
  try {
231120
232116
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231121
- await sleep$1(1e3);
232117
+ await sleep$2(1e3);
231122
232118
  } catch (err) {
231123
232119
  this.ctx.logger.warn("siren wake before initial probe failed — proceeding anyway", {
231124
232120
  tags: { deviceId: this.id },
@@ -231540,7 +232536,7 @@ var FloodlightAccessory = class extends BaseDevice {
231540
232536
  this.ctx.logger.info("floodlight onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231541
232537
  try {
231542
232538
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231543
- await sleep$1(1e3);
232539
+ await sleep$2(1e3);
231544
232540
  } catch (err) {
231545
232541
  this.ctx.logger.warn("floodlight wake before initial probe failed — proceeding anyway", {
231546
232542
  tags: { deviceId: this.id },
@@ -231937,7 +232933,7 @@ var PirAccessory = class extends BaseDevice {
231937
232933
  this.ctx.logger.info("pir onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
231938
232934
  try {
231939
232935
  await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
231940
- await sleep$1(1e3);
232936
+ await sleep$2(1e3);
231941
232937
  } catch (err) {
231942
232938
  this.ctx.logger.warn("pir wake before initial probe failed — proceeding anyway", {
231943
232939
  tags: { deviceId: this.id },
@@ -233611,7 +234607,24 @@ var reolinkCameraSchema = object({
233611
234607
  channelEnabled: boolean().nullable().optional(),
233612
234608
  channelName: string().optional(),
233613
234609
  timeEnabled: boolean().nullable().optional(),
233614
- watermark: boolean().nullable().optional()
234610
+ watermark: boolean().nullable().optional(),
234611
+ /**
234612
+ * Overlay positions exactly as `getOsd` reported them — READ-ONLY
234613
+ * observations, never written (the `setOsd` read-modify-write
234614
+ * preserves the camera's stored `pos`). Tri-state: `null` means
234615
+ * the camera did not report a string; an empty string is a real
234616
+ * report. Captured so a vocabulary of live values can be
234617
+ * collected before any position control is designed.
234618
+ */
234619
+ channelPos: string().nullable().optional(),
234620
+ timePos: string().nullable().optional(),
234621
+ /**
234622
+ * Wall-clock ms when this slice last landed from the camera.
234623
+ * Absent on snapshots persisted before freshness tracking —
234624
+ * `isOsdSnapshotStale` treats those as stale, which retires the
234625
+ * adoption-frozen readings this stamp was added for (D224).
234626
+ */
234627
+ fetchedAt: number().int().optional()
233615
234628
  }).optional(),
233616
234629
  /**
233617
234630
  * Snapshot of the camera's status + doorbell LED state from
@@ -233650,7 +234663,18 @@ var reolinkCameraSchema = object({
233650
234663
  hour: number().int().nullable().optional(),
233651
234664
  minute: number().int().nullable().optional(),
233652
234665
  supported: boolean().optional()
233653
- }).optional()
234666
+ }).optional(),
234667
+ /**
234668
+ * Per-snapshot freshness stamps (D224 generalised, D346): wall-clock
234669
+ * ms when each `*Snapshot` group in this cache was last WRITTEN from
234670
+ * a camera read, keyed by the group's field name (`encSnapshot`,
234671
+ * `osdSnapshot`, …). Written only by the persist sites that write
234672
+ * the group itself (`stampSnapshotFreshness`) — a failed probe
234673
+ * writes no snapshot and gets no stamp. A group with no entry here
234674
+ * (legacy persist) is of UNKNOWN age and must never read as fresh;
234675
+ * `resolveSnapshotAge` owns the tri-state.
234676
+ */
234677
+ snapshotFetchedAt: record(string(), number().int()).optional()
233654
234678
  }).loose().optional(),
233655
234679
  /**
233656
234680
  * Generic Baichuan debug logs. Forwarded as `DebugOptions.general`
@@ -233808,18 +234832,6 @@ var reolinkCameraSchema = object({
233808
234832
  statusLedEnabled: boolean().optional(),
233809
234833
  doorbellLedEnabled: boolean().optional(),
233810
234834
  /**
233811
- * On-screen display (OSD) overlay — pushed via `setOsd` (cmd_id 25,
233812
- * read via 26). One read-modify-write `OsdConfig` push covers all four
233813
- * fields so the camera keeps its stored overlay positions (`pos`)
233814
- * untouched — only the enable flags, channel name text, and watermark
233815
- * toggle change. Position is camera-pixel/preset specific (`pos` is a
233816
- * loose string, not a clean enum), so it is intentionally NOT exposed.
233817
- */
233818
- osdChannelEnabled: boolean().optional(),
233819
- osdChannelName: string().max(64).optional(),
233820
- osdTimeEnabled: boolean().optional(),
233821
- osdWatermark: boolean().optional(),
233822
- /**
233823
234835
  * Audio output volume — pushed via `setAudioCfg` (cmd_id=265,
233824
234836
  * read via 264). Reolink-spec range 0..100.
233825
234837
  */
@@ -235018,6 +236030,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235018
236030
  * legacy firmware that doesn't support some endpoints). */
235019
236031
  lastSettingsSnapshotRetryAt = 0;
235020
236032
  static SETTINGS_SNAPSHOT_RETRY_MIN_MS = 6e4;
236033
+ /** Debounce timestamp for the OSD serve-and-revalidate kick from
236034
+ * `getSettingsUISchema` (D224). Separate from
236035
+ * `lastSettingsSnapshotRetryAt` so an incomplete-cache retry and an
236036
+ * OSD staleness revalidate never suppress each other. */
236037
+ lastOsdRevalidateKickAt = 0;
235021
236038
  /** True when any settings-snapshot field that drives a UI section
235022
236039
  * is missing from the persisted cache. Drives the on-demand retry
235023
236040
  * in `getSettingsUISchema`. */
@@ -235027,16 +236044,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235027
236044
  return cache.encSnapshot === void 0 || cache.encOptionsSnapshot === void 0 || cache.maskSnapshot === void 0 || cache.audioNoiseSnapshot === void 0 || cache.autoFocusSnapshot === void 0;
235028
236045
  }
235029
236046
  /**
235030
- * Probe `getVideoInput` + `getMotionAlarm` and persist into the
235031
- * `deviceCache` snapshots. Fires once on `onCreated`; future
235032
- * settings opens read straight from the persisted snapshot. Image
235033
- * is readonly in the UI (lib lacks `setVideoInput`); motion is
235034
- * writable via `setMotionAlarm` so its snapshot also drives the
235035
- * dispatch's known-good baseline.
236047
+ * Probe the parent-settings endpoints (`getVideoInput`, `getMotionAlarm`,
236048
+ * `getOsd`, …) and persist into the `deviceCache` snapshots. Runs on
236049
+ * activation, on battery wake transitions, after a settings save (scoped
236050
+ * to the changed slices), via the manual "Refresh from camera" action,
236051
+ * and from `getSettingsUISchema`'s serve-and-revalidate kicks settings
236052
+ * opens serve the persisted snapshot immediately and revalidate stale
236053
+ * slices behind the form (D224). Image is readonly in the UI (lib lacks
236054
+ * `setVideoInput`); motion is writable via `setMotionAlarm` so its
236055
+ * snapshot also drives the dispatch's known-good baseline.
235036
236056
  */
235037
236057
  async refreshParentSettingsSnapshot(slices) {
235038
236058
  if (this.isBattery && this.sleeping) {
235039
- this.ctx.logger.debug("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
236059
+ this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
235040
236060
  return;
235041
236061
  }
235042
236062
  let api;
@@ -235189,14 +236209,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235189
236209
  }
235190
236210
  if (want("osd")) try {
235191
236211
  const osd = await api.getOsd(channel);
236212
+ const channelPos = typeof osd.osdChannel?.pos === "string" ? osd.osdChannel.pos : null;
236213
+ const timePos = typeof osd.osdTime?.pos === "string" ? osd.osdTime.pos : null;
236214
+ const prevOsdSnapshot = this.config.get("deviceCache")?.osdSnapshot;
236215
+ if (prevOsdSnapshot?.channelPos !== channelPos || prevOsdSnapshot?.timePos !== timePos) this.ctx.logger.info("reolink osd overlay positions observed", {
236216
+ tags: { deviceId: this.id },
236217
+ meta: {
236218
+ channelPos,
236219
+ timePos
236220
+ }
236221
+ });
235192
236222
  cacheUpdate.osdSnapshot = {
235193
236223
  channelEnabled: typeof osd.osdChannel?.enable === "number" ? osd.osdChannel.enable === 1 : null,
235194
236224
  channelName: typeof osd.osdChannel?.name === "string" ? osd.osdChannel.name : void 0,
235195
236225
  timeEnabled: typeof osd.osdTime?.enable === "number" ? osd.osdTime.enable === 1 : null,
235196
- watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null
236226
+ watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null,
236227
+ channelPos,
236228
+ timePos,
236229
+ fetchedAt: Date.now()
235197
236230
  };
235198
236231
  } catch (err) {
235199
- this.ctx.logger.debug("reolink getOsd probe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
236232
+ this.ctx.logger.info("reolink getOsd probe failed OSD snapshot left stale", {
236233
+ tags: { deviceId: this.id },
236234
+ meta: { error: err instanceof Error ? err.message : String(err) }
236235
+ });
235200
236236
  }
235201
236237
  if (want("led")) try {
235202
236238
  const ledState = (await api.getIrLights(channel))?.body?.LedState;
@@ -235253,6 +236289,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235253
236289
  }
235254
236290
  if (Object.keys(cacheUpdate).length === 0) return;
235255
236291
  const current = this.config.get("deviceCache") ?? {};
236292
+ const writtenSnapshotKeys = Object.keys(cacheUpdate).filter((k) => k.endsWith("Snapshot"));
236293
+ if (writtenSnapshotKeys.length > 0) cacheUpdate.snapshotFetchedAt = stampSnapshotFreshness(current.snapshotFetchedAt, writtenSnapshotKeys, Date.now());
235256
236294
  try {
235257
236295
  await this.config.setAll({ deviceCache: {
235258
236296
  ...current,
@@ -235266,6 +236304,28 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235266
236304
  });
235267
236305
  }
235268
236306
  /**
236307
+ * Admin-only raw-read debug surface (D346): return the library's response
236308
+ * for one snapshot slice UNPROJECTED, next to the persisted snapshot and
236309
+ * its freshness, so "what does the camera report vs what does CamStack
236310
+ * believe" is answerable without Baichuan tracing. Read-only by
236311
+ * construction (the slice enum maps onto an allow-list of lib `get*`
236312
+ * calls — see `raw-read.ts`), writes nothing back to the cache, and the
236313
+ * sleep gate runs BEFORE any login: a sleeping battery cam refuses loudly
236314
+ * (logging in IS the wake) and still reports the believed state.
236315
+ * Dispatched by the provider's `debugRawRead` custom action.
236316
+ */
236317
+ async debugRawRead(slice) {
236318
+ return performRawRead(slice, {
236319
+ deviceId: this.id,
236320
+ channel: this.getChannel(),
236321
+ sleeping: this.isBattery && this.sleeping,
236322
+ getApi: () => this.ensureApi(),
236323
+ cache: this.config.get("deviceCache"),
236324
+ logger: this.ctx.logger,
236325
+ now: Date.now()
236326
+ });
236327
+ }
236328
+ /**
235269
236329
  * Declare on-camera accessory child devices the kernel should
235270
236330
  * auto-spawn after `onCreated`. Each entry maps directly to a
235271
236331
  * concrete accessory class via the existing `createAccessoryDevice`
@@ -235349,7 +236409,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235349
236409
  waitAfterWakeMs: 2500,
235350
236410
  attempts: 2
235351
236411
  });
235352
- await sleep$1(1500);
236412
+ await sleep$2(1500);
235353
236413
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
235354
236414
  return true;
235355
236415
  } catch (err) {
@@ -235464,7 +236524,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235464
236524
  sendNickname: email.sendNickname,
235465
236525
  ...task ? { taskEnabled: task.enable === 1 } : {},
235466
236526
  lastReadAt: Date.now()
235467
- }
236527
+ },
236528
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["emailConfigSnapshot"], Date.now())
235468
236529
  } });
235469
236530
  this.ctx.logger.info("email-push: read camera email config", {
235470
236531
  tags: { deviceId: this.id },
@@ -235646,7 +236707,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235646
236707
  waitAfterWakeMs: 2500,
235647
236708
  attempts: 2
235648
236709
  });
235649
- await sleep$1(1500);
236710
+ await sleep$2(1500);
235650
236711
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
235651
236712
  const CONFIRM_TIMEOUT_MS = 1e4;
235652
236713
  const CONFIRM_POLL_MS = 1e3;
@@ -235676,7 +236737,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235676
236737
  break;
235677
236738
  }
235678
236739
  }
235679
- await sleep$1(CONFIRM_POLL_MS);
236740
+ await sleep$2(CONFIRM_POLL_MS);
235680
236741
  }
235681
236742
  const confirmSource = parent !== null ? "hub-summary" : "sleep-poll";
235682
236743
  if (observedAwake && this.commitSleepState(false, confirmSource)) {
@@ -235908,7 +236969,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
235908
236969
  async watchHubChildAwake(parent) {
235909
236970
  const deadline = Date.now() + 45e3;
235910
236971
  while (Date.now() < deadline) {
235911
- await sleep$1(5e3);
236972
+ await sleep$2(5e3);
235912
236973
  try {
235913
236974
  if ((await (await parent.getApi()).getNvrChannelsSummary({ channels: [this.getChannel()] })).devices.find((d) => d.channel === this.getChannel())?.sleeping === false) {
235914
236975
  if (this.commitSleepState(false, "hub-summary")) {
@@ -236871,7 +237932,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236871
237932
  value,
236872
237933
  fetchedAt: Date.now()
236873
237934
  }
236874
- }
237935
+ },
237936
+ snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["capOptionsSnapshot"], Date.now())
236875
237937
  } });
236876
237938
  } catch (err) {
236877
237939
  this.ctx.logger.debug("cap options persist failed", {
@@ -237936,7 +238998,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237936
238998
  this.ctx.logger.info("intercom: cam sleeping — waking up before talk session", { tags: { deviceId: this.id } });
237937
238999
  try {
237938
239000
  await api.wakeUp(channel, { waitAfterWakeMs: 2e3 });
237939
- await sleep$1(1e3);
239001
+ await sleep$2(1e3);
237940
239002
  } catch (err) {
237941
239003
  this.ctx.logger.warn("intercom: wakeUp failed — proceeding anyway", { meta: { error: err instanceof Error ? err.message : String(err) } });
237942
239004
  }
@@ -238236,13 +239298,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
238236
239298
  await api.setAutoFocus(channel, enabled ? 0 : 1);
238237
239299
  try {
238238
239300
  const a = (await api.getAutoFocus(channel, { timeoutMs: 1500 }))?.body?.AutoFocus;
238239
- if (a) await this.config.setAll({ deviceCache: {
238240
- ...this.config.get("deviceCache"),
238241
- autoFocusSnapshot: {
238242
- enabled: typeof a.disable === "number" ? a.disable === 0 : null,
238243
- supported: true
238244
- }
238245
- } });
239301
+ if (a) {
239302
+ const afCurrent = this.config.get("deviceCache");
239303
+ await this.config.setAll({ deviceCache: {
239304
+ ...afCurrent,
239305
+ autoFocusSnapshot: {
239306
+ enabled: typeof a.disable === "number" ? a.disable === 0 : null,
239307
+ supported: true
239308
+ },
239309
+ snapshotFetchedAt: stampSnapshotFreshness(afCurrent?.snapshotFetchedAt, ["autoFocusSnapshot"], Date.now())
239310
+ } });
239311
+ }
238246
239312
  } catch {}
238247
239313
  }
238248
239314
  };
@@ -239607,13 +240673,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
239607
240673
  const imgSnap = cache?.imageSnapshot ?? {};
239608
240674
  const netSnap = cache?.netPortSnapshot ?? {};
239609
240675
  const ntpSnap = cache?.ntpSnapshot ?? {};
240676
+ let kickedFullSnapshotRefresh = false;
239610
240677
  if (this.hasIncompleteSettingsCache()) {
239611
240678
  const now = Date.now();
239612
240679
  if (now - this.lastSettingsSnapshotRetryAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
239613
240680
  this.lastSettingsSnapshotRetryAt = now;
240681
+ kickedFullSnapshotRefresh = true;
239614
240682
  this.refreshParentSettingsSnapshot().catch(() => {});
239615
240683
  }
239616
240684
  }
240685
+ const osdSnap = cache?.osdSnapshot;
240686
+ if (!kickedFullSnapshotRefresh && isOsdSnapshotStale(osdSnap, Date.now())) {
240687
+ const now = Date.now();
240688
+ if (now - this.lastOsdRevalidateKickAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
240689
+ this.lastOsdRevalidateKickAt = now;
240690
+ this.refreshParentSettingsSnapshot(new Set(["osd"])).catch(() => {});
240691
+ }
240692
+ }
240693
+ const osdValues = resolveOsdValues(osdSnap);
239617
240694
  const sessSnap = this.sessionsSnapshot;
239618
240695
  const sessStale = sessSnap === null || Date.now() - sessSnap.ts > 6e4;
239619
240696
  if (!this.isBattery && sessStale) this.refreshSessionsSnapshot().catch((err) => {
@@ -240160,45 +241237,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240160
241237
  }] : []
240161
241238
  ]
240162
241239
  },
240163
- {
240164
- id: "osd",
240165
- tab: "image",
240166
- title: "OSD overlay",
240167
- description: "On-screen overlays burned into the video by the camera. Pushed via `SetOsd` (cmd_id 25). Overlay positions are kept as configured on the camera — only the toggles, channel-name text, and watermark change here.",
240168
- columns: 2,
240169
- fields: [
240170
- {
240171
- type: "boolean",
240172
- key: "osdChannelEnabled",
240173
- label: "Channel name overlay",
240174
- default: cache?.osdSnapshot?.channelEnabled ?? true,
240175
- style: "switch"
240176
- },
240177
- {
240178
- type: "text",
240179
- key: "osdChannelName",
240180
- label: "Channel name",
240181
- description: "Text shown in the channel-name overlay.",
240182
- default: cache?.osdSnapshot?.channelName ?? "",
240183
- placeholder: "Front door"
240184
- },
240185
- {
240186
- type: "boolean",
240187
- key: "osdTimeEnabled",
240188
- label: "Timestamp overlay",
240189
- default: cache?.osdSnapshot?.timeEnabled ?? true,
240190
- style: "switch"
240191
- },
240192
- {
240193
- type: "boolean",
240194
- key: "osdWatermark",
240195
- label: "Watermark",
240196
- description: "The Reolink logo watermark overlay.",
240197
- default: cache?.osdSnapshot?.watermark ?? false,
240198
- style: "switch"
240199
- }
240200
- ]
240201
- },
241240
+ buildOsdSection(osdSnap, osdValues, {
241241
+ sleeping: this.isBattery && this.sleeping,
241242
+ now: Date.now()
241243
+ }),
240202
241244
  {
240203
241245
  id: "privacy-mask",
240204
241246
  tab: "image",
@@ -240488,6 +241530,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240488
241530
  ]
240489
241531
  }]
240490
241532
  },
241533
+ buildSnapshotFreshnessSection(cache, {
241534
+ sleeping: this.isBattery && this.sleeping,
241535
+ now: Date.now()
241536
+ }),
240491
241537
  ...this.buildSessionsTabSections(),
240492
241538
  ...this.buildEmailPushSection(),
240493
241539
  ...this.buildEmailTabSections()
@@ -240554,10 +241600,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240554
241600
  irLightsBrightness: this.config.get("irLightsBrightness") ?? 128,
240555
241601
  statusLedEnabled: this.config.get("statusLedEnabled") ?? cache?.ledSnapshot?.statusEnabled ?? true,
240556
241602
  doorbellLedEnabled: this.config.get("doorbellLedEnabled") ?? cache?.ledSnapshot?.doorbellEnabled ?? true,
240557
- osdChannelEnabled: this.config.get("osdChannelEnabled") ?? cache?.osdSnapshot?.channelEnabled ?? true,
240558
- osdChannelName: this.config.get("osdChannelName") ?? cache?.osdSnapshot?.channelName ?? "",
240559
- osdTimeEnabled: this.config.get("osdTimeEnabled") ?? cache?.osdSnapshot?.timeEnabled ?? true,
240560
- osdWatermark: this.config.get("osdWatermark") ?? cache?.osdSnapshot?.watermark ?? false,
241603
+ osdChannelEnabled: osdValues.osdChannelEnabled,
241604
+ osdChannelName: osdValues.osdChannelName,
241605
+ osdTimeEnabled: osdValues.osdTimeEnabled,
241606
+ osdWatermark: osdValues.osdWatermark,
240561
241607
  audioVolume: this.config.get("audioVolume") ?? 50,
240562
241608
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
240563
241609
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
@@ -240576,7 +241622,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240576
241622
  });
240577
241623
  }
240578
241624
  async applySettingsPatch(patch) {
240579
- const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, ...rest } = patch;
241625
+ const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdTimeEnabled, osdWatermark, ...rest } = patch;
240580
241626
  const emailFields = {
240581
241627
  emailSmtpServer,
240582
241628
  emailSmtpPort,
@@ -240595,8 +241641,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240595
241641
  meta: { error: err instanceof Error ? err.message : String(err) }
240596
241642
  });
240597
241643
  });
240598
- if (Object.keys(rest).length === 0) return;
240599
- await this.config.setAll(rest);
241644
+ const hasOsdPatch = [
241645
+ osdChannelEnabled,
241646
+ osdChannelName,
241647
+ osdTimeEnabled,
241648
+ osdWatermark
241649
+ ].some((v) => v !== void 0);
241650
+ if (Object.keys(rest).length === 0 && !hasOsdPatch) return;
241651
+ if (Object.keys(rest).length > 0) await this.config.setAll(rest);
240600
241652
  const typedPatch = patch;
240601
241653
  if (typedPatch.host || typedPatch.port || typedPatch.username || typedPatch.password) {
240602
241654
  await this.disconnectAll();
@@ -240789,12 +241841,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240789
241841
  } catch (err) {
240790
241842
  this.ctx.logger.warn("ir-lights push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
240791
241843
  }
240792
- if ([
240793
- "osdChannelEnabled",
240794
- "osdChannelName",
240795
- "osdTimeEnabled",
240796
- "osdWatermark"
240797
- ].some((k) => k in patch)) try {
241844
+ if (hasOsdPatch) try {
240798
241845
  const api = await this.ensureApi();
240799
241846
  const channel = this.getChannel();
240800
241847
  const current = await api.getOsd(channel);
@@ -240812,13 +241859,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240812
241859
  watermark: current.watermark ?? 0
240813
241860
  };
240814
241861
  if (current.bgcolor !== void 0) next.bgcolor = current.bgcolor;
240815
- if (typeof typedPatch.osdChannelEnabled === "boolean") next.osdChannel.enable = typedPatch.osdChannelEnabled ? 1 : 0;
240816
- if (typeof typedPatch.osdChannelName === "string") next.osdChannel.name = typedPatch.osdChannelName;
240817
- if (typeof typedPatch.osdTimeEnabled === "boolean") next.osdTime.enable = typedPatch.osdTimeEnabled ? 1 : 0;
240818
- if (typeof typedPatch.osdWatermark === "boolean") next.watermark = typedPatch.osdWatermark ? 1 : 0;
241862
+ if (typeof osdChannelEnabled === "boolean") next.osdChannel.enable = osdChannelEnabled ? 1 : 0;
241863
+ if (typeof osdChannelName === "string") next.osdChannel.name = osdChannelName;
241864
+ if (typeof osdTimeEnabled === "boolean") next.osdTime.enable = osdTimeEnabled ? 1 : 0;
241865
+ if (typeof osdWatermark === "boolean") next.watermark = osdWatermark ? 1 : 0;
240819
241866
  await api.setOsd(channel, next);
240820
241867
  } catch (err) {
240821
- this.ctx.logger.warn("osd push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
241868
+ this.ctx.logger.warn("osd push failed camera keeps its current overlay state", {
241869
+ tags: { deviceId: this.id },
241870
+ meta: { error: err instanceof Error ? err.message : String(err) }
241871
+ });
240822
241872
  }
240823
241873
  if ([
240824
241874
  "audioVolume",
@@ -240959,7 +242009,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240959
242009
  }
240960
242010
  const changedSlices = slicesForPatch(patch);
240961
242011
  if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
240962
- this.ctx.logger.debug("reolink targeted settings refresh failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
242012
+ this.ctx.logger.info("reolink targeted settings refresh failed snapshots left stale", {
242013
+ tags: { deviceId: this.id },
242014
+ meta: {
242015
+ slices: [...changedSlices],
242016
+ error: err instanceof Error ? err.message : String(err)
242017
+ }
242018
+ });
240963
242019
  });
240964
242020
  }
240965
242021
  /**
@@ -241553,6 +242609,43 @@ function computeHealthCheckBackoffMs(consecutive) {
241553
242609
  return 15 * 6e4;
241554
242610
  }
241555
242611
  //#endregion
242612
+ //#region src/hub-channel-reconcile.ts
242613
+ /**
242614
+ * Match adopted children to the hub's own channel list by IDENTITY, then report the
242615
+ * ones whose persisted `channel` is missing or wrong.
242616
+ *
242617
+ * A child's stableId is `${hubStableId}-${childNativeId}`, and `childNativeId` is built
242618
+ * from the camera's UID — so identity survives a channel move, while `channel` does not.
242619
+ * The hub is the authority on which slot a UID currently occupies.
242620
+ *
242621
+ * Why this exists: `loadAdoptedChildrenByChannel` keys the adopted set on the persisted
242622
+ * `channel` alone. A child that lost it is absent from that map, so the discovery panel
242623
+ * renders it `alreadyAdopted: false` — one click away from being adopted a SECOND time —
242624
+ * and `routeSimpleEvent` silently drops every push for its slot. Observed live on
242625
+ * 2026-09-04 with an Argus MagiCam that was actually on channel 3.
242626
+ *
242627
+ * Pure: no I/O. A discovered entry with no channel, or with no adopted child, is skipped —
242628
+ * this repairs what exists, it never adopts.
242629
+ */
242630
+ function planChannelReconciliation(hubStableId, discovered, children) {
242631
+ const byStableId = /* @__PURE__ */ new Map();
242632
+ for (const child of children) byStableId.set(child.stableId, child);
242633
+ const repairs = [];
242634
+ for (const entry of discovered) {
242635
+ if (typeof entry.rtspChannel !== "number") continue;
242636
+ const child = byStableId.get(`${hubStableId}-${entry.childNativeId}`);
242637
+ if (child === void 0) continue;
242638
+ if (child.channel === entry.rtspChannel) continue;
242639
+ repairs.push({
242640
+ deviceId: child.deviceId,
242641
+ stableId: child.stableId,
242642
+ from: child.channel ?? null,
242643
+ to: entry.rtspChannel
242644
+ });
242645
+ }
242646
+ return repairs;
242647
+ }
242648
+ //#endregion
241556
242649
  //#region src/simple-event-dispatch-trace.ts
241557
242650
  /**
241558
242651
  * Gate for hub-side simpleEvent traces.
@@ -242049,6 +243142,7 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
242049
243142
  lastFetchedAt: Date.now()
242050
243143
  };
242051
243144
  this.runtimeState.setCapState(deviceDiscoveryCapability.name, slice);
243145
+ if (lastError === null) await this.reconcileChildChannels(discovered);
242052
243146
  if (lastError === null) {
242053
243147
  this.knownUnadoptedChannels.clear();
242054
243148
  for (const d of discovered) {
@@ -242221,6 +243315,64 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
242221
243315
  * the channel index. Returns a `channel → kernel deviceId` map.
242222
243316
  * Used to gate the `alreadyAdopted` flag in the discovery list.
242223
243317
  */
243318
+ /**
243319
+ * Re-derive each adopted child's `channel` from the hub's own channel list, matching by
243320
+ * IDENTITY (stableId carries the camera UID) rather than by the channel itself.
243321
+ *
243322
+ * `loadAdoptedChildrenByChannel` keys the adopted set on the persisted `channel` alone,
243323
+ * so a child that lost it is invisible: the discovery panel renders it available (one
243324
+ * click from a second adoption of the same camera) and `routeSimpleEvent` drops every
243325
+ * push for its slot. A channel move produces the same damage under a wrong number.
243326
+ *
243327
+ * Writes through the live device so its in-memory config and the DB stay in step.
243328
+ * A child that is not live is skipped — it is repaired on its next successful restore.
243329
+ */
243330
+ async reconcileChildChannels(discovered) {
243331
+ const children = await this.ctx.devices.getChildren(this.id);
243332
+ const adopted = children.map((child) => {
243333
+ const channel = child instanceof ReolinkCamera ? child.config.values.channel : void 0;
243334
+ return {
243335
+ deviceId: child.id,
243336
+ stableId: child.stableId,
243337
+ ...typeof channel === "number" ? { channel } : {}
243338
+ };
243339
+ });
243340
+ const repairs = planChannelReconciliation(this.stableId, discovered.map((d) => ({
243341
+ childNativeId: d.childNativeId,
243342
+ ...typeof d.metadata.rtspChannel === "number" ? { rtspChannel: d.metadata.rtspChannel } : {}
243343
+ })), adopted);
243344
+ if (repairs.length === 0) return;
243345
+ for (const repair of repairs) {
243346
+ const child = children.find((c) => c.id === repair.deviceId);
243347
+ if (!(child instanceof ReolinkCamera)) continue;
243348
+ try {
243349
+ await child.config.setAll({ channel: repair.to });
243350
+ this.channelToDeviceId.set(repair.to, repair.deviceId);
243351
+ this.ctx.logger.warn("Reolink Hub: repaired a child channel from the hub channel list", {
243352
+ tags: {
243353
+ deviceId: repair.deviceId,
243354
+ stableId: repair.stableId
243355
+ },
243356
+ meta: {
243357
+ from: repair.from,
243358
+ to: repair.to
243359
+ }
243360
+ });
243361
+ } catch (err) {
243362
+ this.ctx.logger.warn("Reolink Hub: child channel repair failed", {
243363
+ tags: {
243364
+ deviceId: repair.deviceId,
243365
+ stableId: repair.stableId
243366
+ },
243367
+ meta: {
243368
+ from: repair.from,
243369
+ to: repair.to,
243370
+ error: err instanceof Error ? err.message : String(err)
243371
+ }
243372
+ });
243373
+ }
243374
+ }
243375
+ }
242224
243376
  async loadAdoptedChildrenByChannel() {
242225
243377
  const result = /* @__PURE__ */ new Map();
242226
243378
  const children = await this.ctx.devices.getChildren(this.id);
@@ -242685,6 +243837,53 @@ function buildCreationFormSchema() {
242685
243837
  ] };
242686
243838
  }
242687
243839
  //#endregion
243840
+ //#region src/child-config-heal.ts
243841
+ /** Connection fields a hub-adopted child inherits from its parent at adoption time. */
243842
+ var INHERITED_CONNECTION_KEYS = [
243843
+ "host",
243844
+ "port",
243845
+ "username",
243846
+ "password",
243847
+ "transport"
243848
+ ];
243849
+ /** A stored value counts as present only when it is a usable string/number. */
243850
+ function isPresent(value) {
243851
+ if (typeof value === "string") return value !== "";
243852
+ return typeof value === "number";
243853
+ }
243854
+ /**
243855
+ * Work out what a hub-adopted child's persisted blob is missing from the connection
243856
+ * fields it inherited from its parent when it was adopted.
243857
+ *
243858
+ * `ReolinkCamera` with a parent uses `parent.api` and never dials its own login — but
243859
+ * `reolinkCameraSchema` still requires `host` and `password` as strings, so a blob that
243860
+ * lost them fails to parse and the camera cannot be restored at all. Observed live on
243861
+ * 2026-09-04: an Argus MagiCam under a Home Hub carried `{}` and burned all four bounded
243862
+ * restore attempts on `host`/`password` — fields it would never have used.
243863
+ *
243864
+ * Only ABSENT fields are filled. A child that deliberately differs from its parent (a
243865
+ * per-child credential, a non-default port) keeps what it has.
243866
+ */
243867
+ function planChildConfigHeal(childConfig, parentConfig) {
243868
+ const patch = {};
243869
+ const missingKeys = [];
243870
+ for (const key of INHERITED_CONNECTION_KEYS) {
243871
+ if (isPresent(childConfig[key])) continue;
243872
+ if (!isPresent(parentConfig[key])) continue;
243873
+ patch[key] = parentConfig[key];
243874
+ missingKeys.push(key);
243875
+ }
243876
+ return {
243877
+ patch,
243878
+ missingKeys
243879
+ };
243880
+ }
243881
+ /** Find the saved row a child names as its parent. */
243882
+ function findParentRow(child, allSaved) {
243883
+ if (child.parentDeviceId === null) return void 0;
243884
+ return allSaved.find((row) => row.id === child.parentDeviceId);
243885
+ }
243886
+ //#endregion
242688
243887
  //#region src/reolink-discovery-map.ts
242689
243888
  /**
242690
243889
  * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
@@ -242694,21 +243893,44 @@ function slugifyReolinkHost(host) {
242694
243893
  return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
242695
243894
  }
242696
243895
  /**
243896
+ * Decide whether a discovered host is a device this provider already owns.
243897
+ *
243898
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
243899
+ * wins even when the host differs, because the address is the thing that moves.
243900
+ */
243901
+ function findOnboardedMatch(discovered, onboarded) {
243902
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
243903
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
243904
+ if (byUid !== void 0) return byUid;
243905
+ }
243906
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
243907
+ }
243908
+ /**
242697
243909
  * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
242698
243910
  * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
242699
243911
  * first responder for a host wins. Pure: no I/O.
242700
243912
  *
243913
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
243914
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
243915
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
243916
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
243917
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
243918
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
243919
+ * never a scan result.
243920
+ *
242701
243921
  * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
242702
- * can't produce — so a re-scan of a MAC-keyed camera may still show as addable. The `host-` key still
242703
- * lets host-added cameras be detected as onboarded on re-scan.
243922
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
243923
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
243924
+ * in the first place, so a scan says so instead of offering it again.
242704
243925
  */
242705
- function mapReolinkDiscoveryToCandidates(devices, credentials) {
243926
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = []) {
242706
243927
  const username = credentials.username?.trim() ?? "";
242707
243928
  const password = credentials.password ?? "";
242708
243929
  const byHost = /* @__PURE__ */ new Map();
242709
243930
  for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
242710
243931
  return [...byHost.values()].map((d) => {
242711
243932
  const displayName = d.name ?? d.model ?? d.host;
243933
+ const match = findOnboardedMatch(d, onboarded);
242712
243934
  return {
242713
243935
  stableId: `host-${slugifyReolinkHost(d.host)}`,
242714
243936
  type: DeviceType.Camera,
@@ -242717,11 +243939,15 @@ function mapReolinkDiscoveryToCandidates(devices, credentials) {
242717
243939
  name: displayName,
242718
243940
  host: d.host,
242719
243941
  transport: "auto",
242720
- ...d.httpPort !== void 0 ? { port: d.httpPort } : {},
242721
243942
  ...d.uid ? { uid: d.uid } : {},
242722
243943
  ...username ? { username } : {},
242723
243944
  ...password ? { password } : {}
242724
- }
243945
+ },
243946
+ ...match !== void 0 ? {
243947
+ alreadyOnboarded: true,
243948
+ onboardedDeviceId: match.deviceId,
243949
+ onboardedName: match.name
243950
+ } : {}
242725
243951
  };
242726
243952
  });
242727
243953
  }
@@ -243370,6 +244596,61 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243370
244596
  super({});
243371
244597
  }
243372
244598
  /**
244599
+ * Reduce this addon's live devices to the identities a network scan can produce, so
244600
+ * {@link mapReolinkDiscoveryToCandidates} can recognise a device it already owns.
244601
+ *
244602
+ * Hubs are included: the incident this exists for was a Home Hub re-offered by a scan and
244603
+ * re-added, which overwrote its Baichuan port with the ONVIF port the scan reported.
244604
+ */
244605
+ /**
244606
+ * Re-inherit the parent's connection fields into a hub-adopted child whose persisted
244607
+ * blob lost them, so the child can be restored at all.
244608
+ *
244609
+ * `adoptDiscoveredChild` copies `host`/`port`/`username`/`password`/`transport` down
244610
+ * from the Hub at adoption. Nothing re-applied them afterwards, so a child whose blob
244611
+ * was emptied failed `reolinkCameraSchema` on every restore — on fields a child never
244612
+ * dials, because a child with a parent uses `parent.api`. Only ABSENT fields are
244613
+ * filled; a child that deliberately differs keeps what it has.
244614
+ */
244615
+ async healSavedConfig(saved, allSaved) {
244616
+ const parent = findParentRow(saved, allSaved);
244617
+ if (parent === void 0) return;
244618
+ const { patch, missingKeys } = planChildConfigHeal(saved.config, parent.config);
244619
+ if (missingKeys.length === 0) return;
244620
+ await this.ctx.kernel.devices?.persistInitialConfig(saved.stableId, {
244621
+ ...saved.config,
244622
+ ...patch
244623
+ });
244624
+ this.ctx.logger.warn("Reolink child config healed from its parent before restore", {
244625
+ tags: {
244626
+ deviceId: saved.id,
244627
+ stableId: saved.stableId
244628
+ },
244629
+ meta: {
244630
+ parentDeviceId: parent.id,
244631
+ restoredKeys: [...missingKeys]
244632
+ }
244633
+ });
244634
+ }
244635
+ listOnboardedForDiscovery() {
244636
+ const all = this.ctx.kernel.deviceRegistry?.getAll() ?? [];
244637
+ const onboarded = [];
244638
+ for (const d of all) {
244639
+ if (!(d instanceof ReolinkCamera) && !(d instanceof ReolinkHub)) continue;
244640
+ const values = d.config.values;
244641
+ const host = typeof values.host === "string" && values.host !== "" ? values.host : void 0;
244642
+ const uid = typeof values.uid === "string" && values.uid !== "" ? values.uid : void 0;
244643
+ if (host === void 0 && uid === void 0) continue;
244644
+ onboarded.push({
244645
+ deviceId: d.id,
244646
+ name: d.name,
244647
+ ...host !== void 0 ? { host } : {},
244648
+ ...uid !== void 0 ? { uid } : {}
244649
+ });
244650
+ }
244651
+ return onboarded;
244652
+ }
244653
+ /**
243373
244654
  * Enumerate this addon's live `ReolinkCamera` instances from the
243374
244655
  * global device registry. Used by the email-push server to resolve
243375
244656
  * recipients + route inbound motion. Returns `[]` when the registry
@@ -243562,6 +244843,44 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243562
244843
  return regs;
243563
244844
  }
243564
244845
  /**
244846
+ * Compose the provider registrations from `onInitialize()` with this
244847
+ * addon's customActions catalog. `BaseDeviceProvider.onInitialize` is
244848
+ * typed `ProviderRegistration[]` (eleven sibling providers push onto it),
244849
+ * so the catalog joins at the `initialize()` seam instead — the runner
244850
+ * consumes the merged `AddonInitResult` exactly as it does for
244851
+ * addon-benchmark / addon-notifiers.
244852
+ *
244853
+ * Admin-only debug surface (D346): `debugRawRead` returns the lib's
244854
+ * response for one snapshot slice UNPROJECTED, next to the persisted
244855
+ * snapshot + its freshness. Registered as an addon customAction because
244856
+ * `addons.custom` is the one operator surface that enforces the
244857
+ * per-action `auth: 'admin'` server-side and validates output —
244858
+ * `deviceManager.runDeviceAction` does neither. The hub reads the static
244859
+ * catalog from this bundle's `customActions` export (see `index.ts`);
244860
+ * the child registers the handlers returned here.
244861
+ */
244862
+ async initialize(context) {
244863
+ const base = await super.initialize(context);
244864
+ return {
244865
+ providers: base && base.providers ? base.providers : [],
244866
+ customActions: reolinkDebugActions,
244867
+ actionHandlers: { debugRawRead: (input) => this.debugRawRead(input) }
244868
+ };
244869
+ }
244870
+ /**
244871
+ * Route a `debugRawRead` custom action to the owning camera. Covers both
244872
+ * standalone cameras and NVR-adopted children — every live ReolinkCamera
244873
+ * in this runner is in the kernel device registry. Read-only end to end
244874
+ * (see `raw-read.ts`); a hub device or an unknown id refuses with a
244875
+ * message that names what it looked for.
244876
+ */
244877
+ async debugRawRead(input) {
244878
+ const dev = this.ctx.kernel.deviceRegistry?.getById(input.deviceId);
244879
+ if (dev === void 0 || dev === null) throw new Error(`debugRawRead: device ${input.deviceId} not found in the reolink runner's registry`);
244880
+ 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`);
244881
+ return dev.debugRawRead(input.slice);
244882
+ }
244883
+ /**
243565
244884
  * Handle a broker-issued source-refresh request. With the lazy-publish
243566
244885
  * model the broker always emits this on first dial of a
243567
244886
  * `lazy:rfc4571:` placeholder URL — and re-emits it whenever the
@@ -243614,10 +244933,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243614
244933
  networkCidr: networkCidr || "local",
243615
244934
  enableOnvif
243616
244935
  } });
243617
- return mapReolinkDiscoveryToCandidates(devices, {
244936
+ const onboarded = this.listOnboardedForDiscovery();
244937
+ const candidates = mapReolinkDiscoveryToCandidates(devices, {
243618
244938
  username,
243619
244939
  password
243620
- });
244940
+ }, onboarded);
244941
+ const owned = candidates.filter((c) => c.alreadyOnboarded === true).length;
244942
+ if (owned > 0) this.ctx.logger.info("Reolink discovery: candidates already owned by this provider", { meta: {
244943
+ owned,
244944
+ total: candidates.length
244945
+ } });
244946
+ return candidates;
243621
244947
  }
243622
244948
  async adoptDiscoveredDevice(input) {
243623
244949
  return this.createDevice({
@@ -243839,4 +245165,4 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
243839
245165
  }
243840
245166
  };
243841
245167
  //#endregion
243842
- export { ReolinkProviderAddon, collectNativeDiagnostics as a, runAllDiagnosticsConsecutively as c, testChannelStreams as d, collectMultifocalDiagnostics as i, runMultifocalDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNvrDiagnostics as o, collectCgiDiagnostics as r, createDiagnosticsBundle as s, ReolinkCamera as t, sampleStreams as u };
245168
+ export { ReolinkProviderAddon, collectMultifocalDiagnostics as a, createDiagnosticsBundle as c, sampleStreams as d, testChannelStreams as f, collectCgiDiagnostics as i, runAllDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNativeDiagnostics as o, reolinkDebugActions as r, collectNvrDiagnostics as s, ReolinkCamera as t, runMultifocalDiagnosticsConsecutively as u };