@camstack/addon-provider-wyze 0.2.63 → 0.2.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +428 -25
  2. package/dist/addon.mjs +428 -25
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -12805,7 +12805,26 @@ var DiscoveryCandidateSchema = object({
12805
12805
  * identity ahead of adoption. Rendering metadata (unit, precision)
12806
12806
  * flows live through the cap STATUS SLICE after adoption.
12807
12807
  */
12808
- sourceInfo: SourceInfoSchema.optional()
12808
+ sourceInfo: SourceInfoSchema.optional(),
12809
+ /**
12810
+ * Set when this candidate is a device the provider ALREADY owns.
12811
+ *
12812
+ * A scan cannot generally produce the identity a device was onboarded under
12813
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
12814
+ * comparison never matches and an owned device looks addable. Re-adopting one
12815
+ * overwrites its config with scan-derived values — that is how a Home Hub's
12816
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
12817
+ * three child cameras offline for four hours.
12818
+ *
12819
+ * A provider that can recognise its own devices says so here. Absent means
12820
+ * "not recognised", which is not the same as "known to be new" — a provider
12821
+ * that cannot tell simply never sets it.
12822
+ */
12823
+ alreadyOnboarded: boolean().optional(),
12824
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
12825
+ onboardedDeviceId: number().optional(),
12826
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
12827
+ onboardedName: string().optional()
12809
12828
  });
12810
12829
  /**
12811
12830
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -12861,6 +12880,35 @@ var deviceProviderCapability = {
12861
12880
  name: string(),
12862
12881
  type: string()
12863
12882
  }))),
12883
+ /**
12884
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12885
+ * touching no other device this provider owns.
12886
+ *
12887
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12888
+ * migrated numbers: after `swapIds` the runner's live instance still
12889
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12890
+ * registrations and its log tags), and a live object cannot be renumbered.
12891
+ * Before this method the only flush was restarting the whole owning addon
12892
+ * — which took every camera the provider owns down with it (28 devices
12893
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12894
+ * same day ~27 devices' native caps did not come back on their own).
12895
+ *
12896
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12897
+ * that changes. The reply carries the id the device answers on NOW.
12898
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12899
+ * instance (if any), then re-create from the persisted row: the same
12900
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12901
+ * An RPC, never an event: a dropped event would leave the runner writing
12902
+ * against the wrong camera (D8).
12903
+ *
12904
+ * Construction can dial hardware, and the migrated source is
12905
+ * characteristically dead — the timeout covers a full activate window
12906
+ * rather than the 60 s default.
12907
+ */
12908
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12909
+ kind: "mutation",
12910
+ timeoutMs: 3 * 6e4
12911
+ }),
12864
12912
  supportsDiscovery: method(object({}), boolean()),
12865
12913
  /**
12866
12914
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13188,7 +13236,8 @@ method(object({
13188
13236
  targetId: number()
13189
13237
  }), MigrateDeviceResultSchema, {
13190
13238
  kind: "mutation",
13191
- auth: "admin"
13239
+ auth: "admin",
13240
+ timeoutMs: 12 * 6e4
13192
13241
  }), 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({
13193
13242
  deviceId: number(),
13194
13243
  name: string()
@@ -32656,6 +32705,147 @@ var BaseDevice = class {
32656
32705
  }
32657
32706
  };
32658
32707
  /**
32708
+ * Delays before retry rounds 1..N — the round count IS the bound.
32709
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32710
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32711
+ * per attempt) covers a device-manager lock held for minutes — the
32712
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32713
+ */
32714
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32715
+ 1e4,
32716
+ 3e4,
32717
+ 9e4
32718
+ ];
32719
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32720
+ function sleep$1(ms, signal) {
32721
+ return new Promise((resolve) => {
32722
+ if (signal.aborted) {
32723
+ resolve();
32724
+ return;
32725
+ }
32726
+ const onAbort = () => {
32727
+ clearTimeout(timer);
32728
+ resolve();
32729
+ };
32730
+ const timer = setTimeout(() => {
32731
+ signal.removeEventListener("abort", onAbort);
32732
+ resolve();
32733
+ }, ms);
32734
+ timer.unref?.();
32735
+ signal.addEventListener("abort", onAbort, { once: true });
32736
+ });
32737
+ }
32738
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32739
+ * not reject (callers wrap their own try/catch). */
32740
+ async function runWithConcurrency(items, width, fn) {
32741
+ const queue = [...items];
32742
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32743
+ const lane = async () => {
32744
+ for (;;) {
32745
+ const item = queue.shift();
32746
+ if (item === void 0) return;
32747
+ await fn(item);
32748
+ }
32749
+ };
32750
+ await Promise.all(Array.from({ length: laneCount }, lane));
32751
+ }
32752
+ var DeviceRestoreRetryScheduler = class {
32753
+ #logger;
32754
+ #attempt;
32755
+ #onPermanentFailure;
32756
+ #delaysMs;
32757
+ #concurrency;
32758
+ #now;
32759
+ #abort = new AbortController();
32760
+ constructor(options) {
32761
+ this.#logger = options.logger;
32762
+ this.#attempt = options.attempt;
32763
+ this.#onPermanentFailure = options.onPermanentFailure;
32764
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32765
+ this.#concurrency = options.concurrency ?? 4;
32766
+ this.#now = options.now ?? Date.now;
32767
+ }
32768
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32769
+ * permanently failed — the next boot restores them from disk. */
32770
+ cancel() {
32771
+ this.#abort.abort();
32772
+ }
32773
+ /**
32774
+ * Run the bounded retry rounds. Resolves when every entry has either
32775
+ * restored, been marked permanently failed, or the scheduler was
32776
+ * cancelled. Never rejects.
32777
+ */
32778
+ async run(initialFailures) {
32779
+ let pending = initialFailures.map((failure) => ({
32780
+ saved: failure.saved,
32781
+ lastError: failure.error,
32782
+ attempts: 1
32783
+ }));
32784
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32785
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32786
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32787
+ if (this.#abort.signal.aborted) break;
32788
+ pending = await this.#runRound(pending, round);
32789
+ }
32790
+ if (this.#abort.signal.aborted) return [];
32791
+ const terminal = pending.map((entry) => ({
32792
+ deviceId: entry.saved.id,
32793
+ stableId: entry.saved.stableId,
32794
+ type: String(entry.saved.type),
32795
+ attempts: entry.attempts,
32796
+ lastError: entry.lastError,
32797
+ failedAt: this.#now()
32798
+ }));
32799
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32800
+ return terminal;
32801
+ }
32802
+ /** One retry round: parents first (phase 0), then hub-adopted
32803
+ * children (phase 1) — a child's attempt depends on its parent
32804
+ * having landed, exactly like the initial two-pass restore. */
32805
+ async #runRound(pending, round) {
32806
+ const next = [];
32807
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32808
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32809
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32810
+ if (this.#abort.signal.aborted) {
32811
+ next.push(entry);
32812
+ return;
32813
+ }
32814
+ const attemptNo = entry.attempts + 1;
32815
+ try {
32816
+ await this.#attempt(entry.saved);
32817
+ this.#logger.info("Device restored on retry", {
32818
+ tags: {
32819
+ deviceId: entry.saved.id,
32820
+ stableId: entry.saved.stableId
32821
+ },
32822
+ meta: { attempt: attemptNo }
32823
+ });
32824
+ } catch (err) {
32825
+ const lastError = err instanceof Error ? err.message : String(err);
32826
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32827
+ this.#logger.warn("Device restore retry failed", {
32828
+ tags: {
32829
+ deviceId: entry.saved.id,
32830
+ stableId: entry.saved.stableId
32831
+ },
32832
+ meta: {
32833
+ attempt: attemptNo,
32834
+ remainingRetries,
32835
+ error: lastError
32836
+ }
32837
+ });
32838
+ next.push({
32839
+ saved: entry.saved,
32840
+ lastError,
32841
+ attempts: attemptNo
32842
+ });
32843
+ }
32844
+ });
32845
+ return next;
32846
+ }
32847
+ };
32848
+ /**
32659
32849
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32660
32850
  * device-provider cap router. Shared across all providers.
32661
32851
  */
@@ -32704,6 +32894,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32704
32894
  }];
32705
32895
  }
32706
32896
  async onShutdown() {
32897
+ this.cancelRestoreRetries();
32707
32898
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32708
32899
  for (const device of devices) try {
32709
32900
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32721,9 +32912,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32721
32912
  async start() {}
32722
32913
  async stop() {}
32723
32914
  async getStatus() {
32915
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32916
+ const summary = this.restoreFailureSummary();
32917
+ if (summary === null) return {
32918
+ connected: true,
32919
+ deviceCount: all.length
32920
+ };
32724
32921
  return {
32725
32922
  connected: true,
32726
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32923
+ deviceCount: all.length,
32924
+ error: summary
32727
32925
  };
32728
32926
  }
32729
32927
  async getDevices() {
@@ -32813,8 +33011,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32813
33011
  };
32814
33012
  }
32815
33013
  async restoreDevices(savedDevices) {
32816
- await this.onRestoreDevices(savedDevices);
32817
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33014
+ const report = await this.onRestoreDevices(savedDevices);
33015
+ if (savedDevices.length === 0) return;
33016
+ if (report && report.failedCount > 0) {
33017
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33018
+ return;
33019
+ }
33020
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33021
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33022
+ }
33023
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33024
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33025
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33026
+ * never re-stampede full-width while the initial pass does (D167). */
33027
+ restoreRetryConcurrency = 4;
33028
+ _restoreRetryScheduler = null;
33029
+ _restoreRetryCompletion = null;
33030
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33031
+ /** Settles when the background retry rounds finish (or `null` when
33032
+ * nothing failed). Exposed for tests and subclass diagnostics —
33033
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33034
+ * with the devices that restored, and a late success is announced
33035
+ * through the `native-cap-change` → `updateCaps` path. */
33036
+ get restoreRetryCompletion() {
33037
+ return this._restoreRetryCompletion;
33038
+ }
33039
+ /** Devices that exhausted the retry bound this process lifetime. */
33040
+ get permanentRestoreFailures() {
33041
+ return [...this._permanentRestoreFailures.values()];
33042
+ }
33043
+ /** One-line operator-facing summary for `getStatus().error`, or
33044
+ * `null` when every device restored. */
33045
+ restoreFailureSummary() {
33046
+ if (this._permanentRestoreFailures.size === 0) return null;
33047
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33048
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33049
+ }
33050
+ cancelRestoreRetries() {
33051
+ this._restoreRetryScheduler?.cancel();
33052
+ this._restoreRetryScheduler = null;
33053
+ }
33054
+ recordPermanentRestoreFailure(failure) {
33055
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33056
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33057
+ tags: {
33058
+ deviceId: failure.deviceId,
33059
+ stableId: failure.stableId
33060
+ },
33061
+ meta: {
33062
+ type: failure.type,
33063
+ attempts: failure.attempts,
33064
+ error: failure.lastError
33065
+ }
33066
+ });
33067
+ }
33068
+ scheduleRestoreRetries(failures, attempt) {
33069
+ const scheduler = new DeviceRestoreRetryScheduler({
33070
+ logger: this.ctx.logger,
33071
+ delaysMs: this.restoreRetryDelaysMs,
33072
+ concurrency: this.restoreRetryConcurrency,
33073
+ attempt,
33074
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33075
+ });
33076
+ this._restoreRetryScheduler = scheduler;
33077
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33078
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33079
+ });
33080
+ }
33081
+ /**
33082
+ * Tear down and reconstruct ONE device from its persisted rows — the
33083
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33084
+ * and no other device this provider owns is disturbed.
33085
+ *
33086
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33087
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33088
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33089
+ * whatever number the row carries NOW. The teardown is `decommission` —
33090
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33091
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33092
+ * the boot restore's own `create()` path, including its pass 2: first-class
33093
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33094
+ * parent by the cascade and must be re-created explicitly, because only
33095
+ * accessory children come back through `getAccessoryChildren()`.
33096
+ *
33097
+ * Reloading an accessory child directly is refused (no device class) —
33098
+ * reload its parent instead.
33099
+ */
33100
+ async reloadDevice(input) {
33101
+ const { stableId } = input;
33102
+ const devices = this.ctx.kernel.devices;
33103
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33104
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33105
+ if (live) await devices.decommission(live.id);
33106
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33107
+ addonId: this.addonId,
33108
+ stableId
33109
+ });
33110
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33111
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33112
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33113
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33114
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33115
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33116
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33117
+ for (const row of rows) {
33118
+ if (row.parentDeviceId !== id) continue;
33119
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33120
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33121
+ if (!ChildClass) continue;
33122
+ try {
33123
+ await devices.create(row.stableId, ChildClass, {}, id);
33124
+ } catch (err) {
33125
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33126
+ tags: {
33127
+ deviceId: row.id,
33128
+ stableId: row.stableId
33129
+ },
33130
+ meta: {
33131
+ parentDeviceId: id,
33132
+ error: err instanceof Error ? err.message : String(err)
33133
+ }
33134
+ });
33135
+ }
33136
+ }
33137
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33138
+ tags: { deviceId: id },
33139
+ meta: {
33140
+ stableId,
33141
+ type: meta.type
33142
+ }
33143
+ });
33144
+ return { deviceId: id };
32818
33145
  }
32819
33146
  /**
32820
33147
  * Restore devices from persisted state. Two-pass:
@@ -32840,55 +33167,125 @@ var BaseDeviceProvider = class extends BaseAddon {
32840
33167
  * accessory-spawn flow handles via the parent's
32841
33168
  * `getAccessoryChildren()`. Override only when the default doesn't
32842
33169
  * fit.
33170
+ *
33171
+ * A row that fails either pass is NOT terminal (D347): it is handed
33172
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33173
+ * Only after the bound is exhausted is the device marked permanently
33174
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33175
+ * `getStatus().error`.
32843
33176
  */
33177
+ /**
33178
+ * Repair a row's PERSISTED config blob immediately before it is restored.
33179
+ * Default: no-op — most providers have nothing to heal.
33180
+ *
33181
+ * This exists because a restored device self-hydrates from the DB: `create()`
33182
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
33183
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
33184
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
33185
+ * been emptied failed all four bounded attempts against fields
33186
+ * (`host`, `password`) it inherits from its parent and never dials itself.
33187
+ *
33188
+ * Implementations get every saved row, so a child can read its parent's blob.
33189
+ * A heal that throws is treated like any other restore failure: retried under
33190
+ * the bound, then reported — never swallowed.
33191
+ */
33192
+ async healSavedConfig(_saved, _allSaved) {}
32844
33193
  async onRestoreDevices(savedDevices) {
32845
33194
  const restored = /* @__PURE__ */ new Set();
33195
+ const failures = [];
33196
+ const attemptRestore = async (saved) => {
33197
+ if (restored.has(saved.id)) return;
33198
+ const Class = this.deviceClasses[saved.type];
33199
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33200
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33201
+ await this.healSavedConfig(saved, savedDevices);
33202
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33203
+ restored.add(saved.id);
33204
+ };
32846
33205
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32847
33206
  const restoreOne = async (saved) => {
32848
- const Class = this.deviceClasses[saved.type];
32849
- if (!Class) {
33207
+ if (!this.deviceClasses[saved.type]) {
32850
33208
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32851
- tags: { stableId: saved.stableId },
33209
+ tags: {
33210
+ deviceId: saved.id,
33211
+ stableId: saved.stableId
33212
+ },
32852
33213
  meta: { type: saved.type }
32853
33214
  });
32854
33215
  return;
32855
33216
  }
32856
33217
  try {
32857
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32858
- restored.add(saved.id);
33218
+ await attemptRestore(saved);
32859
33219
  } catch (err) {
32860
- this.ctx.logger.warn("Failed to restore device", {
32861
- tags: { stableId: saved.stableId },
33220
+ const error = err instanceof Error ? err.message : String(err);
33221
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33222
+ tags: {
33223
+ deviceId: saved.id,
33224
+ stableId: saved.stableId
33225
+ },
32862
33226
  meta: {
32863
33227
  type: saved.type,
32864
- error: err instanceof Error ? err.message : String(err)
33228
+ attempt: 1,
33229
+ error
32865
33230
  }
32866
33231
  });
33232
+ failures.push({
33233
+ saved,
33234
+ error
33235
+ });
32867
33236
  }
32868
33237
  };
32869
33238
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33239
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32870
33240
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32871
33241
  for (const saved of childRows) {
32872
- const Class = this.deviceClasses[saved.type];
32873
- if (!Class) continue;
33242
+ if (!this.deviceClasses[saved.type]) continue;
32874
33243
  if (saved.parentDeviceId === null) continue;
32875
- if (!restored.has(saved.parentDeviceId)) continue;
32876
- try {
32877
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32878
- restored.add(saved.id);
32879
- } catch (err) {
32880
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33244
+ if (restored.has(saved.parentDeviceId)) {
33245
+ try {
33246
+ await attemptRestore(saved);
33247
+ } catch (err) {
33248
+ const error = err instanceof Error ? err.message : String(err);
33249
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33250
+ tags: {
33251
+ deviceId: saved.id,
33252
+ stableId: saved.stableId,
33253
+ parentDeviceId: saved.parentDeviceId
33254
+ },
33255
+ meta: {
33256
+ type: saved.type,
33257
+ attempt: 1,
33258
+ error
33259
+ }
33260
+ });
33261
+ failures.push({
33262
+ saved,
33263
+ error
33264
+ });
33265
+ }
33266
+ continue;
33267
+ }
33268
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33269
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32881
33270
  tags: {
33271
+ deviceId: saved.id,
32882
33272
  stableId: saved.stableId,
32883
33273
  parentDeviceId: saved.parentDeviceId
32884
33274
  },
32885
- meta: {
32886
- type: saved.type,
32887
- error: err instanceof Error ? err.message : String(err)
32888
- }
33275
+ meta: { type: saved.type }
32889
33276
  });
33277
+ failures.push({
33278
+ saved,
33279
+ error: `parent device ${saved.parentDeviceId} not restored`
33280
+ });
33281
+ continue;
32890
33282
  }
32891
33283
  }
33284
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33285
+ return {
33286
+ restoredCount: restored.size,
33287
+ failedCount: failures.length
33288
+ };
32892
33289
  }
32893
33290
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32894
33291
  toSummary(device) {
@@ -34651,6 +35048,12 @@ Object.freeze({
34651
35048
  addonId: null,
34652
35049
  access: "view"
34653
35050
  },
35051
+ "deviceProvider.reloadDevice": {
35052
+ capName: "device-provider",
35053
+ capScope: "system",
35054
+ addonId: null,
35055
+ access: "create"
35056
+ },
34654
35057
  "deviceProvider.start": {
34655
35058
  capName: "device-provider",
34656
35059
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -12784,7 +12784,26 @@ var DiscoveryCandidateSchema = object({
12784
12784
  * identity ahead of adoption. Rendering metadata (unit, precision)
12785
12785
  * flows live through the cap STATUS SLICE after adoption.
12786
12786
  */
12787
- sourceInfo: SourceInfoSchema.optional()
12787
+ sourceInfo: SourceInfoSchema.optional(),
12788
+ /**
12789
+ * Set when this candidate is a device the provider ALREADY owns.
12790
+ *
12791
+ * A scan cannot generally produce the identity a device was onboarded under
12792
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
12793
+ * comparison never matches and an owned device looks addable. Re-adopting one
12794
+ * overwrites its config with scan-derived values — that is how a Home Hub's
12795
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
12796
+ * three child cameras offline for four hours.
12797
+ *
12798
+ * A provider that can recognise its own devices says so here. Absent means
12799
+ * "not recognised", which is not the same as "known to be new" — a provider
12800
+ * that cannot tell simply never sets it.
12801
+ */
12802
+ alreadyOnboarded: boolean().optional(),
12803
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
12804
+ onboardedDeviceId: number().optional(),
12805
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
12806
+ onboardedName: string().optional()
12788
12807
  });
12789
12808
  /**
12790
12809
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -12840,6 +12859,35 @@ var deviceProviderCapability = {
12840
12859
  name: string(),
12841
12860
  type: string()
12842
12861
  }))),
12862
+ /**
12863
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12864
+ * touching no other device this provider owns.
12865
+ *
12866
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12867
+ * migrated numbers: after `swapIds` the runner's live instance still
12868
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12869
+ * registrations and its log tags), and a live object cannot be renumbered.
12870
+ * Before this method the only flush was restarting the whole owning addon
12871
+ * — which took every camera the provider owns down with it (28 devices
12872
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12873
+ * same day ~27 devices' native caps did not come back on their own).
12874
+ *
12875
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12876
+ * that changes. The reply carries the id the device answers on NOW.
12877
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12878
+ * instance (if any), then re-create from the persisted row: the same
12879
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12880
+ * An RPC, never an event: a dropped event would leave the runner writing
12881
+ * against the wrong camera (D8).
12882
+ *
12883
+ * Construction can dial hardware, and the migrated source is
12884
+ * characteristically dead — the timeout covers a full activate window
12885
+ * rather than the 60 s default.
12886
+ */
12887
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12888
+ kind: "mutation",
12889
+ timeoutMs: 3 * 6e4
12890
+ }),
12843
12891
  supportsDiscovery: method(object({}), boolean()),
12844
12892
  /**
12845
12893
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13167,7 +13215,8 @@ method(object({
13167
13215
  targetId: number()
13168
13216
  }), MigrateDeviceResultSchema, {
13169
13217
  kind: "mutation",
13170
- auth: "admin"
13218
+ auth: "admin",
13219
+ timeoutMs: 12 * 6e4
13171
13220
  }), 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({
13172
13221
  deviceId: number(),
13173
13222
  name: string()
@@ -32635,6 +32684,147 @@ var BaseDevice = class {
32635
32684
  }
32636
32685
  };
32637
32686
  /**
32687
+ * Delays before retry rounds 1..N — the round count IS the bound.
32688
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32689
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32690
+ * per attempt) covers a device-manager lock held for minutes — the
32691
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32692
+ */
32693
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32694
+ 1e4,
32695
+ 3e4,
32696
+ 9e4
32697
+ ];
32698
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32699
+ function sleep$1(ms, signal) {
32700
+ return new Promise((resolve) => {
32701
+ if (signal.aborted) {
32702
+ resolve();
32703
+ return;
32704
+ }
32705
+ const onAbort = () => {
32706
+ clearTimeout(timer);
32707
+ resolve();
32708
+ };
32709
+ const timer = setTimeout(() => {
32710
+ signal.removeEventListener("abort", onAbort);
32711
+ resolve();
32712
+ }, ms);
32713
+ timer.unref?.();
32714
+ signal.addEventListener("abort", onAbort, { once: true });
32715
+ });
32716
+ }
32717
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32718
+ * not reject (callers wrap their own try/catch). */
32719
+ async function runWithConcurrency(items, width, fn) {
32720
+ const queue = [...items];
32721
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32722
+ const lane = async () => {
32723
+ for (;;) {
32724
+ const item = queue.shift();
32725
+ if (item === void 0) return;
32726
+ await fn(item);
32727
+ }
32728
+ };
32729
+ await Promise.all(Array.from({ length: laneCount }, lane));
32730
+ }
32731
+ var DeviceRestoreRetryScheduler = class {
32732
+ #logger;
32733
+ #attempt;
32734
+ #onPermanentFailure;
32735
+ #delaysMs;
32736
+ #concurrency;
32737
+ #now;
32738
+ #abort = new AbortController();
32739
+ constructor(options) {
32740
+ this.#logger = options.logger;
32741
+ this.#attempt = options.attempt;
32742
+ this.#onPermanentFailure = options.onPermanentFailure;
32743
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32744
+ this.#concurrency = options.concurrency ?? 4;
32745
+ this.#now = options.now ?? Date.now;
32746
+ }
32747
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32748
+ * permanently failed — the next boot restores them from disk. */
32749
+ cancel() {
32750
+ this.#abort.abort();
32751
+ }
32752
+ /**
32753
+ * Run the bounded retry rounds. Resolves when every entry has either
32754
+ * restored, been marked permanently failed, or the scheduler was
32755
+ * cancelled. Never rejects.
32756
+ */
32757
+ async run(initialFailures) {
32758
+ let pending = initialFailures.map((failure) => ({
32759
+ saved: failure.saved,
32760
+ lastError: failure.error,
32761
+ attempts: 1
32762
+ }));
32763
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32764
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32765
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32766
+ if (this.#abort.signal.aborted) break;
32767
+ pending = await this.#runRound(pending, round);
32768
+ }
32769
+ if (this.#abort.signal.aborted) return [];
32770
+ const terminal = pending.map((entry) => ({
32771
+ deviceId: entry.saved.id,
32772
+ stableId: entry.saved.stableId,
32773
+ type: String(entry.saved.type),
32774
+ attempts: entry.attempts,
32775
+ lastError: entry.lastError,
32776
+ failedAt: this.#now()
32777
+ }));
32778
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32779
+ return terminal;
32780
+ }
32781
+ /** One retry round: parents first (phase 0), then hub-adopted
32782
+ * children (phase 1) — a child's attempt depends on its parent
32783
+ * having landed, exactly like the initial two-pass restore. */
32784
+ async #runRound(pending, round) {
32785
+ const next = [];
32786
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32787
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32788
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32789
+ if (this.#abort.signal.aborted) {
32790
+ next.push(entry);
32791
+ return;
32792
+ }
32793
+ const attemptNo = entry.attempts + 1;
32794
+ try {
32795
+ await this.#attempt(entry.saved);
32796
+ this.#logger.info("Device restored on retry", {
32797
+ tags: {
32798
+ deviceId: entry.saved.id,
32799
+ stableId: entry.saved.stableId
32800
+ },
32801
+ meta: { attempt: attemptNo }
32802
+ });
32803
+ } catch (err) {
32804
+ const lastError = err instanceof Error ? err.message : String(err);
32805
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32806
+ this.#logger.warn("Device restore retry failed", {
32807
+ tags: {
32808
+ deviceId: entry.saved.id,
32809
+ stableId: entry.saved.stableId
32810
+ },
32811
+ meta: {
32812
+ attempt: attemptNo,
32813
+ remainingRetries,
32814
+ error: lastError
32815
+ }
32816
+ });
32817
+ next.push({
32818
+ saved: entry.saved,
32819
+ lastError,
32820
+ attempts: attemptNo
32821
+ });
32822
+ }
32823
+ });
32824
+ return next;
32825
+ }
32826
+ };
32827
+ /**
32638
32828
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32639
32829
  * device-provider cap router. Shared across all providers.
32640
32830
  */
@@ -32683,6 +32873,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32683
32873
  }];
32684
32874
  }
32685
32875
  async onShutdown() {
32876
+ this.cancelRestoreRetries();
32686
32877
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32687
32878
  for (const device of devices) try {
32688
32879
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32700,9 +32891,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32700
32891
  async start() {}
32701
32892
  async stop() {}
32702
32893
  async getStatus() {
32894
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32895
+ const summary = this.restoreFailureSummary();
32896
+ if (summary === null) return {
32897
+ connected: true,
32898
+ deviceCount: all.length
32899
+ };
32703
32900
  return {
32704
32901
  connected: true,
32705
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32902
+ deviceCount: all.length,
32903
+ error: summary
32706
32904
  };
32707
32905
  }
32708
32906
  async getDevices() {
@@ -32792,8 +32990,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32792
32990
  };
32793
32991
  }
32794
32992
  async restoreDevices(savedDevices) {
32795
- await this.onRestoreDevices(savedDevices);
32796
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32993
+ const report = await this.onRestoreDevices(savedDevices);
32994
+ if (savedDevices.length === 0) return;
32995
+ if (report && report.failedCount > 0) {
32996
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32997
+ return;
32998
+ }
32999
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33000
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33001
+ }
33002
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33003
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33004
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33005
+ * never re-stampede full-width while the initial pass does (D167). */
33006
+ restoreRetryConcurrency = 4;
33007
+ _restoreRetryScheduler = null;
33008
+ _restoreRetryCompletion = null;
33009
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33010
+ /** Settles when the background retry rounds finish (or `null` when
33011
+ * nothing failed). Exposed for tests and subclass diagnostics —
33012
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33013
+ * with the devices that restored, and a late success is announced
33014
+ * through the `native-cap-change` → `updateCaps` path. */
33015
+ get restoreRetryCompletion() {
33016
+ return this._restoreRetryCompletion;
33017
+ }
33018
+ /** Devices that exhausted the retry bound this process lifetime. */
33019
+ get permanentRestoreFailures() {
33020
+ return [...this._permanentRestoreFailures.values()];
33021
+ }
33022
+ /** One-line operator-facing summary for `getStatus().error`, or
33023
+ * `null` when every device restored. */
33024
+ restoreFailureSummary() {
33025
+ if (this._permanentRestoreFailures.size === 0) return null;
33026
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33027
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33028
+ }
33029
+ cancelRestoreRetries() {
33030
+ this._restoreRetryScheduler?.cancel();
33031
+ this._restoreRetryScheduler = null;
33032
+ }
33033
+ recordPermanentRestoreFailure(failure) {
33034
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33035
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33036
+ tags: {
33037
+ deviceId: failure.deviceId,
33038
+ stableId: failure.stableId
33039
+ },
33040
+ meta: {
33041
+ type: failure.type,
33042
+ attempts: failure.attempts,
33043
+ error: failure.lastError
33044
+ }
33045
+ });
33046
+ }
33047
+ scheduleRestoreRetries(failures, attempt) {
33048
+ const scheduler = new DeviceRestoreRetryScheduler({
33049
+ logger: this.ctx.logger,
33050
+ delaysMs: this.restoreRetryDelaysMs,
33051
+ concurrency: this.restoreRetryConcurrency,
33052
+ attempt,
33053
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33054
+ });
33055
+ this._restoreRetryScheduler = scheduler;
33056
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33057
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33058
+ });
33059
+ }
33060
+ /**
33061
+ * Tear down and reconstruct ONE device from its persisted rows — the
33062
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33063
+ * and no other device this provider owns is disturbed.
33064
+ *
33065
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33066
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33067
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33068
+ * whatever number the row carries NOW. The teardown is `decommission` —
33069
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33070
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33071
+ * the boot restore's own `create()` path, including its pass 2: first-class
33072
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33073
+ * parent by the cascade and must be re-created explicitly, because only
33074
+ * accessory children come back through `getAccessoryChildren()`.
33075
+ *
33076
+ * Reloading an accessory child directly is refused (no device class) —
33077
+ * reload its parent instead.
33078
+ */
33079
+ async reloadDevice(input) {
33080
+ const { stableId } = input;
33081
+ const devices = this.ctx.kernel.devices;
33082
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33083
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33084
+ if (live) await devices.decommission(live.id);
33085
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33086
+ addonId: this.addonId,
33087
+ stableId
33088
+ });
33089
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33090
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33091
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33092
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33093
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33094
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33095
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33096
+ for (const row of rows) {
33097
+ if (row.parentDeviceId !== id) continue;
33098
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33099
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33100
+ if (!ChildClass) continue;
33101
+ try {
33102
+ await devices.create(row.stableId, ChildClass, {}, id);
33103
+ } catch (err) {
33104
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33105
+ tags: {
33106
+ deviceId: row.id,
33107
+ stableId: row.stableId
33108
+ },
33109
+ meta: {
33110
+ parentDeviceId: id,
33111
+ error: err instanceof Error ? err.message : String(err)
33112
+ }
33113
+ });
33114
+ }
33115
+ }
33116
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33117
+ tags: { deviceId: id },
33118
+ meta: {
33119
+ stableId,
33120
+ type: meta.type
33121
+ }
33122
+ });
33123
+ return { deviceId: id };
32797
33124
  }
32798
33125
  /**
32799
33126
  * Restore devices from persisted state. Two-pass:
@@ -32819,55 +33146,125 @@ var BaseDeviceProvider = class extends BaseAddon {
32819
33146
  * accessory-spawn flow handles via the parent's
32820
33147
  * `getAccessoryChildren()`. Override only when the default doesn't
32821
33148
  * fit.
33149
+ *
33150
+ * A row that fails either pass is NOT terminal (D347): it is handed
33151
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33152
+ * Only after the bound is exhausted is the device marked permanently
33153
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33154
+ * `getStatus().error`.
32822
33155
  */
33156
+ /**
33157
+ * Repair a row's PERSISTED config blob immediately before it is restored.
33158
+ * Default: no-op — most providers have nothing to heal.
33159
+ *
33160
+ * This exists because a restored device self-hydrates from the DB: `create()`
33161
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
33162
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
33163
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
33164
+ * been emptied failed all four bounded attempts against fields
33165
+ * (`host`, `password`) it inherits from its parent and never dials itself.
33166
+ *
33167
+ * Implementations get every saved row, so a child can read its parent's blob.
33168
+ * A heal that throws is treated like any other restore failure: retried under
33169
+ * the bound, then reported — never swallowed.
33170
+ */
33171
+ async healSavedConfig(_saved, _allSaved) {}
32823
33172
  async onRestoreDevices(savedDevices) {
32824
33173
  const restored = /* @__PURE__ */ new Set();
33174
+ const failures = [];
33175
+ const attemptRestore = async (saved) => {
33176
+ if (restored.has(saved.id)) return;
33177
+ const Class = this.deviceClasses[saved.type];
33178
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33179
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33180
+ await this.healSavedConfig(saved, savedDevices);
33181
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33182
+ restored.add(saved.id);
33183
+ };
32825
33184
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32826
33185
  const restoreOne = async (saved) => {
32827
- const Class = this.deviceClasses[saved.type];
32828
- if (!Class) {
33186
+ if (!this.deviceClasses[saved.type]) {
32829
33187
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32830
- tags: { stableId: saved.stableId },
33188
+ tags: {
33189
+ deviceId: saved.id,
33190
+ stableId: saved.stableId
33191
+ },
32831
33192
  meta: { type: saved.type }
32832
33193
  });
32833
33194
  return;
32834
33195
  }
32835
33196
  try {
32836
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32837
- restored.add(saved.id);
33197
+ await attemptRestore(saved);
32838
33198
  } catch (err) {
32839
- this.ctx.logger.warn("Failed to restore device", {
32840
- tags: { stableId: saved.stableId },
33199
+ const error = err instanceof Error ? err.message : String(err);
33200
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33201
+ tags: {
33202
+ deviceId: saved.id,
33203
+ stableId: saved.stableId
33204
+ },
32841
33205
  meta: {
32842
33206
  type: saved.type,
32843
- error: err instanceof Error ? err.message : String(err)
33207
+ attempt: 1,
33208
+ error
32844
33209
  }
32845
33210
  });
33211
+ failures.push({
33212
+ saved,
33213
+ error
33214
+ });
32846
33215
  }
32847
33216
  };
32848
33217
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33218
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32849
33219
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32850
33220
  for (const saved of childRows) {
32851
- const Class = this.deviceClasses[saved.type];
32852
- if (!Class) continue;
33221
+ if (!this.deviceClasses[saved.type]) continue;
32853
33222
  if (saved.parentDeviceId === null) continue;
32854
- if (!restored.has(saved.parentDeviceId)) continue;
32855
- try {
32856
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32857
- restored.add(saved.id);
32858
- } catch (err) {
32859
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33223
+ if (restored.has(saved.parentDeviceId)) {
33224
+ try {
33225
+ await attemptRestore(saved);
33226
+ } catch (err) {
33227
+ const error = err instanceof Error ? err.message : String(err);
33228
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33229
+ tags: {
33230
+ deviceId: saved.id,
33231
+ stableId: saved.stableId,
33232
+ parentDeviceId: saved.parentDeviceId
33233
+ },
33234
+ meta: {
33235
+ type: saved.type,
33236
+ attempt: 1,
33237
+ error
33238
+ }
33239
+ });
33240
+ failures.push({
33241
+ saved,
33242
+ error
33243
+ });
33244
+ }
33245
+ continue;
33246
+ }
33247
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33248
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32860
33249
  tags: {
33250
+ deviceId: saved.id,
32861
33251
  stableId: saved.stableId,
32862
33252
  parentDeviceId: saved.parentDeviceId
32863
33253
  },
32864
- meta: {
32865
- type: saved.type,
32866
- error: err instanceof Error ? err.message : String(err)
32867
- }
33254
+ meta: { type: saved.type }
32868
33255
  });
33256
+ failures.push({
33257
+ saved,
33258
+ error: `parent device ${saved.parentDeviceId} not restored`
33259
+ });
33260
+ continue;
32869
33261
  }
32870
33262
  }
33263
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33264
+ return {
33265
+ restoredCount: restored.size,
33266
+ failedCount: failures.length
33267
+ };
32871
33268
  }
32872
33269
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32873
33270
  toSummary(device) {
@@ -34630,6 +35027,12 @@ Object.freeze({
34630
35027
  addonId: null,
34631
35028
  access: "view"
34632
35029
  },
35030
+ "deviceProvider.reloadDevice": {
35031
+ capName: "device-provider",
35032
+ capScope: "system",
35033
+ addonId: null,
35034
+ access: "create"
35035
+ },
34633
35036
  "deviceProvider.start": {
34634
35037
  capName: "device-provider",
34635
35038
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-wyze",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "description": "Wyze camera device-provider addon for CamStack — wraps the @apocaliss92/wyze-bridge-js P2P/DTLS client, feeding the stream-broker via the pull-rfc4571 lazy-publish path (a structural twin of addon-provider-reolink)",
5
5
  "keywords": [
6
6
  "camstack",