@camstack/addon-provider-wyze 0.2.63 → 0.2.64

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 +391 -24
  2. package/dist/addon.mjs +391 -24
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -12861,6 +12861,35 @@ var deviceProviderCapability = {
12861
12861
  name: string(),
12862
12862
  type: string()
12863
12863
  }))),
12864
+ /**
12865
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12866
+ * touching no other device this provider owns.
12867
+ *
12868
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12869
+ * migrated numbers: after `swapIds` the runner's live instance still
12870
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12871
+ * registrations and its log tags), and a live object cannot be renumbered.
12872
+ * Before this method the only flush was restarting the whole owning addon
12873
+ * — which took every camera the provider owns down with it (28 devices
12874
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12875
+ * same day ~27 devices' native caps did not come back on their own).
12876
+ *
12877
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12878
+ * that changes. The reply carries the id the device answers on NOW.
12879
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12880
+ * instance (if any), then re-create from the persisted row: the same
12881
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12882
+ * An RPC, never an event: a dropped event would leave the runner writing
12883
+ * against the wrong camera (D8).
12884
+ *
12885
+ * Construction can dial hardware, and the migrated source is
12886
+ * characteristically dead — the timeout covers a full activate window
12887
+ * rather than the 60 s default.
12888
+ */
12889
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12890
+ kind: "mutation",
12891
+ timeoutMs: 3 * 6e4
12892
+ }),
12864
12893
  supportsDiscovery: method(object({}), boolean()),
12865
12894
  /**
12866
12895
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13188,7 +13217,8 @@ method(object({
13188
13217
  targetId: number()
13189
13218
  }), MigrateDeviceResultSchema, {
13190
13219
  kind: "mutation",
13191
- auth: "admin"
13220
+ auth: "admin",
13221
+ timeoutMs: 12 * 6e4
13192
13222
  }), 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
13223
  deviceId: number(),
13194
13224
  name: string()
@@ -32656,6 +32686,147 @@ var BaseDevice = class {
32656
32686
  }
32657
32687
  };
32658
32688
  /**
32689
+ * Delays before retry rounds 1..N — the round count IS the bound.
32690
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32691
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32692
+ * per attempt) covers a device-manager lock held for minutes — the
32693
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32694
+ */
32695
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32696
+ 1e4,
32697
+ 3e4,
32698
+ 9e4
32699
+ ];
32700
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32701
+ function sleep$1(ms, signal) {
32702
+ return new Promise((resolve) => {
32703
+ if (signal.aborted) {
32704
+ resolve();
32705
+ return;
32706
+ }
32707
+ const onAbort = () => {
32708
+ clearTimeout(timer);
32709
+ resolve();
32710
+ };
32711
+ const timer = setTimeout(() => {
32712
+ signal.removeEventListener("abort", onAbort);
32713
+ resolve();
32714
+ }, ms);
32715
+ timer.unref?.();
32716
+ signal.addEventListener("abort", onAbort, { once: true });
32717
+ });
32718
+ }
32719
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32720
+ * not reject (callers wrap their own try/catch). */
32721
+ async function runWithConcurrency(items, width, fn) {
32722
+ const queue = [...items];
32723
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32724
+ const lane = async () => {
32725
+ for (;;) {
32726
+ const item = queue.shift();
32727
+ if (item === void 0) return;
32728
+ await fn(item);
32729
+ }
32730
+ };
32731
+ await Promise.all(Array.from({ length: laneCount }, lane));
32732
+ }
32733
+ var DeviceRestoreRetryScheduler = class {
32734
+ #logger;
32735
+ #attempt;
32736
+ #onPermanentFailure;
32737
+ #delaysMs;
32738
+ #concurrency;
32739
+ #now;
32740
+ #abort = new AbortController();
32741
+ constructor(options) {
32742
+ this.#logger = options.logger;
32743
+ this.#attempt = options.attempt;
32744
+ this.#onPermanentFailure = options.onPermanentFailure;
32745
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32746
+ this.#concurrency = options.concurrency ?? 4;
32747
+ this.#now = options.now ?? Date.now;
32748
+ }
32749
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32750
+ * permanently failed — the next boot restores them from disk. */
32751
+ cancel() {
32752
+ this.#abort.abort();
32753
+ }
32754
+ /**
32755
+ * Run the bounded retry rounds. Resolves when every entry has either
32756
+ * restored, been marked permanently failed, or the scheduler was
32757
+ * cancelled. Never rejects.
32758
+ */
32759
+ async run(initialFailures) {
32760
+ let pending = initialFailures.map((failure) => ({
32761
+ saved: failure.saved,
32762
+ lastError: failure.error,
32763
+ attempts: 1
32764
+ }));
32765
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32766
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32767
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32768
+ if (this.#abort.signal.aborted) break;
32769
+ pending = await this.#runRound(pending, round);
32770
+ }
32771
+ if (this.#abort.signal.aborted) return [];
32772
+ const terminal = pending.map((entry) => ({
32773
+ deviceId: entry.saved.id,
32774
+ stableId: entry.saved.stableId,
32775
+ type: String(entry.saved.type),
32776
+ attempts: entry.attempts,
32777
+ lastError: entry.lastError,
32778
+ failedAt: this.#now()
32779
+ }));
32780
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32781
+ return terminal;
32782
+ }
32783
+ /** One retry round: parents first (phase 0), then hub-adopted
32784
+ * children (phase 1) — a child's attempt depends on its parent
32785
+ * having landed, exactly like the initial two-pass restore. */
32786
+ async #runRound(pending, round) {
32787
+ const next = [];
32788
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32789
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32790
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32791
+ if (this.#abort.signal.aborted) {
32792
+ next.push(entry);
32793
+ return;
32794
+ }
32795
+ const attemptNo = entry.attempts + 1;
32796
+ try {
32797
+ await this.#attempt(entry.saved);
32798
+ this.#logger.info("Device restored on retry", {
32799
+ tags: {
32800
+ deviceId: entry.saved.id,
32801
+ stableId: entry.saved.stableId
32802
+ },
32803
+ meta: { attempt: attemptNo }
32804
+ });
32805
+ } catch (err) {
32806
+ const lastError = err instanceof Error ? err.message : String(err);
32807
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32808
+ this.#logger.warn("Device restore retry failed", {
32809
+ tags: {
32810
+ deviceId: entry.saved.id,
32811
+ stableId: entry.saved.stableId
32812
+ },
32813
+ meta: {
32814
+ attempt: attemptNo,
32815
+ remainingRetries,
32816
+ error: lastError
32817
+ }
32818
+ });
32819
+ next.push({
32820
+ saved: entry.saved,
32821
+ lastError,
32822
+ attempts: attemptNo
32823
+ });
32824
+ }
32825
+ });
32826
+ return next;
32827
+ }
32828
+ };
32829
+ /**
32659
32830
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32660
32831
  * device-provider cap router. Shared across all providers.
32661
32832
  */
@@ -32704,6 +32875,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32704
32875
  }];
32705
32876
  }
32706
32877
  async onShutdown() {
32878
+ this.cancelRestoreRetries();
32707
32879
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32708
32880
  for (const device of devices) try {
32709
32881
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32721,9 +32893,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32721
32893
  async start() {}
32722
32894
  async stop() {}
32723
32895
  async getStatus() {
32896
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32897
+ const summary = this.restoreFailureSummary();
32898
+ if (summary === null) return {
32899
+ connected: true,
32900
+ deviceCount: all.length
32901
+ };
32724
32902
  return {
32725
32903
  connected: true,
32726
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32904
+ deviceCount: all.length,
32905
+ error: summary
32727
32906
  };
32728
32907
  }
32729
32908
  async getDevices() {
@@ -32813,8 +32992,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32813
32992
  };
32814
32993
  }
32815
32994
  async restoreDevices(savedDevices) {
32816
- await this.onRestoreDevices(savedDevices);
32817
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32995
+ const report = await this.onRestoreDevices(savedDevices);
32996
+ if (savedDevices.length === 0) return;
32997
+ if (report && report.failedCount > 0) {
32998
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32999
+ return;
33000
+ }
33001
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33002
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33003
+ }
33004
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33005
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33006
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33007
+ * never re-stampede full-width while the initial pass does (D167). */
33008
+ restoreRetryConcurrency = 4;
33009
+ _restoreRetryScheduler = null;
33010
+ _restoreRetryCompletion = null;
33011
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33012
+ /** Settles when the background retry rounds finish (or `null` when
33013
+ * nothing failed). Exposed for tests and subclass diagnostics —
33014
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33015
+ * with the devices that restored, and a late success is announced
33016
+ * through the `native-cap-change` → `updateCaps` path. */
33017
+ get restoreRetryCompletion() {
33018
+ return this._restoreRetryCompletion;
33019
+ }
33020
+ /** Devices that exhausted the retry bound this process lifetime. */
33021
+ get permanentRestoreFailures() {
33022
+ return [...this._permanentRestoreFailures.values()];
33023
+ }
33024
+ /** One-line operator-facing summary for `getStatus().error`, or
33025
+ * `null` when every device restored. */
33026
+ restoreFailureSummary() {
33027
+ if (this._permanentRestoreFailures.size === 0) return null;
33028
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33029
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33030
+ }
33031
+ cancelRestoreRetries() {
33032
+ this._restoreRetryScheduler?.cancel();
33033
+ this._restoreRetryScheduler = null;
33034
+ }
33035
+ recordPermanentRestoreFailure(failure) {
33036
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33037
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33038
+ tags: {
33039
+ deviceId: failure.deviceId,
33040
+ stableId: failure.stableId
33041
+ },
33042
+ meta: {
33043
+ type: failure.type,
33044
+ attempts: failure.attempts,
33045
+ error: failure.lastError
33046
+ }
33047
+ });
33048
+ }
33049
+ scheduleRestoreRetries(failures, attempt) {
33050
+ const scheduler = new DeviceRestoreRetryScheduler({
33051
+ logger: this.ctx.logger,
33052
+ delaysMs: this.restoreRetryDelaysMs,
33053
+ concurrency: this.restoreRetryConcurrency,
33054
+ attempt,
33055
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33056
+ });
33057
+ this._restoreRetryScheduler = scheduler;
33058
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33059
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33060
+ });
33061
+ }
33062
+ /**
33063
+ * Tear down and reconstruct ONE device from its persisted rows — the
33064
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33065
+ * and no other device this provider owns is disturbed.
33066
+ *
33067
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33068
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33069
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33070
+ * whatever number the row carries NOW. The teardown is `decommission` —
33071
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33072
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33073
+ * the boot restore's own `create()` path, including its pass 2: first-class
33074
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33075
+ * parent by the cascade and must be re-created explicitly, because only
33076
+ * accessory children come back through `getAccessoryChildren()`.
33077
+ *
33078
+ * Reloading an accessory child directly is refused (no device class) —
33079
+ * reload its parent instead.
33080
+ */
33081
+ async reloadDevice(input) {
33082
+ const { stableId } = input;
33083
+ const devices = this.ctx.kernel.devices;
33084
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33085
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33086
+ if (live) await devices.decommission(live.id);
33087
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33088
+ addonId: this.addonId,
33089
+ stableId
33090
+ });
33091
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33092
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33093
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33094
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33095
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33096
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33097
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33098
+ for (const row of rows) {
33099
+ if (row.parentDeviceId !== id) continue;
33100
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33101
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33102
+ if (!ChildClass) continue;
33103
+ try {
33104
+ await devices.create(row.stableId, ChildClass, {}, id);
33105
+ } catch (err) {
33106
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33107
+ tags: {
33108
+ deviceId: row.id,
33109
+ stableId: row.stableId
33110
+ },
33111
+ meta: {
33112
+ parentDeviceId: id,
33113
+ error: err instanceof Error ? err.message : String(err)
33114
+ }
33115
+ });
33116
+ }
33117
+ }
33118
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33119
+ tags: { deviceId: id },
33120
+ meta: {
33121
+ stableId,
33122
+ type: meta.type
33123
+ }
33124
+ });
33125
+ return { deviceId: id };
32818
33126
  }
32819
33127
  /**
32820
33128
  * Restore devices from persisted state. Two-pass:
@@ -32840,55 +33148,108 @@ var BaseDeviceProvider = class extends BaseAddon {
32840
33148
  * accessory-spawn flow handles via the parent's
32841
33149
  * `getAccessoryChildren()`. Override only when the default doesn't
32842
33150
  * fit.
33151
+ *
33152
+ * A row that fails either pass is NOT terminal (D347): it is handed
33153
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33154
+ * Only after the bound is exhausted is the device marked permanently
33155
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33156
+ * `getStatus().error`.
32843
33157
  */
32844
33158
  async onRestoreDevices(savedDevices) {
32845
33159
  const restored = /* @__PURE__ */ new Set();
33160
+ const failures = [];
33161
+ const attemptRestore = async (saved) => {
33162
+ if (restored.has(saved.id)) return;
33163
+ const Class = this.deviceClasses[saved.type];
33164
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33165
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33166
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33167
+ restored.add(saved.id);
33168
+ };
32846
33169
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32847
33170
  const restoreOne = async (saved) => {
32848
- const Class = this.deviceClasses[saved.type];
32849
- if (!Class) {
33171
+ if (!this.deviceClasses[saved.type]) {
32850
33172
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32851
- tags: { stableId: saved.stableId },
33173
+ tags: {
33174
+ deviceId: saved.id,
33175
+ stableId: saved.stableId
33176
+ },
32852
33177
  meta: { type: saved.type }
32853
33178
  });
32854
33179
  return;
32855
33180
  }
32856
33181
  try {
32857
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32858
- restored.add(saved.id);
33182
+ await attemptRestore(saved);
32859
33183
  } catch (err) {
32860
- this.ctx.logger.warn("Failed to restore device", {
32861
- tags: { stableId: saved.stableId },
33184
+ const error = err instanceof Error ? err.message : String(err);
33185
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33186
+ tags: {
33187
+ deviceId: saved.id,
33188
+ stableId: saved.stableId
33189
+ },
32862
33190
  meta: {
32863
33191
  type: saved.type,
32864
- error: err instanceof Error ? err.message : String(err)
33192
+ attempt: 1,
33193
+ error
32865
33194
  }
32866
33195
  });
33196
+ failures.push({
33197
+ saved,
33198
+ error
33199
+ });
32867
33200
  }
32868
33201
  };
32869
33202
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33203
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32870
33204
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32871
33205
  for (const saved of childRows) {
32872
- const Class = this.deviceClasses[saved.type];
32873
- if (!Class) continue;
33206
+ if (!this.deviceClasses[saved.type]) continue;
32874
33207
  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", {
33208
+ if (restored.has(saved.parentDeviceId)) {
33209
+ try {
33210
+ await attemptRestore(saved);
33211
+ } catch (err) {
33212
+ const error = err instanceof Error ? err.message : String(err);
33213
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33214
+ tags: {
33215
+ deviceId: saved.id,
33216
+ stableId: saved.stableId,
33217
+ parentDeviceId: saved.parentDeviceId
33218
+ },
33219
+ meta: {
33220
+ type: saved.type,
33221
+ attempt: 1,
33222
+ error
33223
+ }
33224
+ });
33225
+ failures.push({
33226
+ saved,
33227
+ error
33228
+ });
33229
+ }
33230
+ continue;
33231
+ }
33232
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33233
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32881
33234
  tags: {
33235
+ deviceId: saved.id,
32882
33236
  stableId: saved.stableId,
32883
33237
  parentDeviceId: saved.parentDeviceId
32884
33238
  },
32885
- meta: {
32886
- type: saved.type,
32887
- error: err instanceof Error ? err.message : String(err)
32888
- }
33239
+ meta: { type: saved.type }
32889
33240
  });
33241
+ failures.push({
33242
+ saved,
33243
+ error: `parent device ${saved.parentDeviceId} not restored`
33244
+ });
33245
+ continue;
32890
33246
  }
32891
33247
  }
33248
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33249
+ return {
33250
+ restoredCount: restored.size,
33251
+ failedCount: failures.length
33252
+ };
32892
33253
  }
32893
33254
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32894
33255
  toSummary(device) {
@@ -34651,6 +35012,12 @@ Object.freeze({
34651
35012
  addonId: null,
34652
35013
  access: "view"
34653
35014
  },
35015
+ "deviceProvider.reloadDevice": {
35016
+ capName: "device-provider",
35017
+ capScope: "system",
35018
+ addonId: null,
35019
+ access: "create"
35020
+ },
34654
35021
  "deviceProvider.start": {
34655
35022
  capName: "device-provider",
34656
35023
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -12840,6 +12840,35 @@ var deviceProviderCapability = {
12840
12840
  name: string(),
12841
12841
  type: string()
12842
12842
  }))),
12843
+ /**
12844
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12845
+ * touching no other device this provider owns.
12846
+ *
12847
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12848
+ * migrated numbers: after `swapIds` the runner's live instance still
12849
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12850
+ * registrations and its log tags), and a live object cannot be renumbered.
12851
+ * Before this method the only flush was restarting the whole owning addon
12852
+ * — which took every camera the provider owns down with it (28 devices
12853
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12854
+ * same day ~27 devices' native caps did not come back on their own).
12855
+ *
12856
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12857
+ * that changes. The reply carries the id the device answers on NOW.
12858
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12859
+ * instance (if any), then re-create from the persisted row: the same
12860
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12861
+ * An RPC, never an event: a dropped event would leave the runner writing
12862
+ * against the wrong camera (D8).
12863
+ *
12864
+ * Construction can dial hardware, and the migrated source is
12865
+ * characteristically dead — the timeout covers a full activate window
12866
+ * rather than the 60 s default.
12867
+ */
12868
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12869
+ kind: "mutation",
12870
+ timeoutMs: 3 * 6e4
12871
+ }),
12843
12872
  supportsDiscovery: method(object({}), boolean()),
12844
12873
  /**
12845
12874
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13167,7 +13196,8 @@ method(object({
13167
13196
  targetId: number()
13168
13197
  }), MigrateDeviceResultSchema, {
13169
13198
  kind: "mutation",
13170
- auth: "admin"
13199
+ auth: "admin",
13200
+ timeoutMs: 12 * 6e4
13171
13201
  }), 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
13202
  deviceId: number(),
13173
13203
  name: string()
@@ -32635,6 +32665,147 @@ var BaseDevice = class {
32635
32665
  }
32636
32666
  };
32637
32667
  /**
32668
+ * Delays before retry rounds 1..N — the round count IS the bound.
32669
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32670
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32671
+ * per attempt) covers a device-manager lock held for minutes — the
32672
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32673
+ */
32674
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32675
+ 1e4,
32676
+ 3e4,
32677
+ 9e4
32678
+ ];
32679
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32680
+ function sleep$1(ms, signal) {
32681
+ return new Promise((resolve) => {
32682
+ if (signal.aborted) {
32683
+ resolve();
32684
+ return;
32685
+ }
32686
+ const onAbort = () => {
32687
+ clearTimeout(timer);
32688
+ resolve();
32689
+ };
32690
+ const timer = setTimeout(() => {
32691
+ signal.removeEventListener("abort", onAbort);
32692
+ resolve();
32693
+ }, ms);
32694
+ timer.unref?.();
32695
+ signal.addEventListener("abort", onAbort, { once: true });
32696
+ });
32697
+ }
32698
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32699
+ * not reject (callers wrap their own try/catch). */
32700
+ async function runWithConcurrency(items, width, fn) {
32701
+ const queue = [...items];
32702
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32703
+ const lane = async () => {
32704
+ for (;;) {
32705
+ const item = queue.shift();
32706
+ if (item === void 0) return;
32707
+ await fn(item);
32708
+ }
32709
+ };
32710
+ await Promise.all(Array.from({ length: laneCount }, lane));
32711
+ }
32712
+ var DeviceRestoreRetryScheduler = class {
32713
+ #logger;
32714
+ #attempt;
32715
+ #onPermanentFailure;
32716
+ #delaysMs;
32717
+ #concurrency;
32718
+ #now;
32719
+ #abort = new AbortController();
32720
+ constructor(options) {
32721
+ this.#logger = options.logger;
32722
+ this.#attempt = options.attempt;
32723
+ this.#onPermanentFailure = options.onPermanentFailure;
32724
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32725
+ this.#concurrency = options.concurrency ?? 4;
32726
+ this.#now = options.now ?? Date.now;
32727
+ }
32728
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32729
+ * permanently failed — the next boot restores them from disk. */
32730
+ cancel() {
32731
+ this.#abort.abort();
32732
+ }
32733
+ /**
32734
+ * Run the bounded retry rounds. Resolves when every entry has either
32735
+ * restored, been marked permanently failed, or the scheduler was
32736
+ * cancelled. Never rejects.
32737
+ */
32738
+ async run(initialFailures) {
32739
+ let pending = initialFailures.map((failure) => ({
32740
+ saved: failure.saved,
32741
+ lastError: failure.error,
32742
+ attempts: 1
32743
+ }));
32744
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32745
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32746
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32747
+ if (this.#abort.signal.aborted) break;
32748
+ pending = await this.#runRound(pending, round);
32749
+ }
32750
+ if (this.#abort.signal.aborted) return [];
32751
+ const terminal = pending.map((entry) => ({
32752
+ deviceId: entry.saved.id,
32753
+ stableId: entry.saved.stableId,
32754
+ type: String(entry.saved.type),
32755
+ attempts: entry.attempts,
32756
+ lastError: entry.lastError,
32757
+ failedAt: this.#now()
32758
+ }));
32759
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32760
+ return terminal;
32761
+ }
32762
+ /** One retry round: parents first (phase 0), then hub-adopted
32763
+ * children (phase 1) — a child's attempt depends on its parent
32764
+ * having landed, exactly like the initial two-pass restore. */
32765
+ async #runRound(pending, round) {
32766
+ const next = [];
32767
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32768
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32769
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32770
+ if (this.#abort.signal.aborted) {
32771
+ next.push(entry);
32772
+ return;
32773
+ }
32774
+ const attemptNo = entry.attempts + 1;
32775
+ try {
32776
+ await this.#attempt(entry.saved);
32777
+ this.#logger.info("Device restored on retry", {
32778
+ tags: {
32779
+ deviceId: entry.saved.id,
32780
+ stableId: entry.saved.stableId
32781
+ },
32782
+ meta: { attempt: attemptNo }
32783
+ });
32784
+ } catch (err) {
32785
+ const lastError = err instanceof Error ? err.message : String(err);
32786
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32787
+ this.#logger.warn("Device restore retry failed", {
32788
+ tags: {
32789
+ deviceId: entry.saved.id,
32790
+ stableId: entry.saved.stableId
32791
+ },
32792
+ meta: {
32793
+ attempt: attemptNo,
32794
+ remainingRetries,
32795
+ error: lastError
32796
+ }
32797
+ });
32798
+ next.push({
32799
+ saved: entry.saved,
32800
+ lastError,
32801
+ attempts: attemptNo
32802
+ });
32803
+ }
32804
+ });
32805
+ return next;
32806
+ }
32807
+ };
32808
+ /**
32638
32809
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32639
32810
  * device-provider cap router. Shared across all providers.
32640
32811
  */
@@ -32683,6 +32854,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32683
32854
  }];
32684
32855
  }
32685
32856
  async onShutdown() {
32857
+ this.cancelRestoreRetries();
32686
32858
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32687
32859
  for (const device of devices) try {
32688
32860
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32700,9 +32872,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32700
32872
  async start() {}
32701
32873
  async stop() {}
32702
32874
  async getStatus() {
32875
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32876
+ const summary = this.restoreFailureSummary();
32877
+ if (summary === null) return {
32878
+ connected: true,
32879
+ deviceCount: all.length
32880
+ };
32703
32881
  return {
32704
32882
  connected: true,
32705
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32883
+ deviceCount: all.length,
32884
+ error: summary
32706
32885
  };
32707
32886
  }
32708
32887
  async getDevices() {
@@ -32792,8 +32971,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32792
32971
  };
32793
32972
  }
32794
32973
  async restoreDevices(savedDevices) {
32795
- await this.onRestoreDevices(savedDevices);
32796
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32974
+ const report = await this.onRestoreDevices(savedDevices);
32975
+ if (savedDevices.length === 0) return;
32976
+ if (report && report.failedCount > 0) {
32977
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32978
+ return;
32979
+ }
32980
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
32981
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
32982
+ }
32983
+ /** Retry schedule. Overridable (tests use millisecond delays). */
32984
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
32985
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
32986
+ * never re-stampede full-width while the initial pass does (D167). */
32987
+ restoreRetryConcurrency = 4;
32988
+ _restoreRetryScheduler = null;
32989
+ _restoreRetryCompletion = null;
32990
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
32991
+ /** Settles when the background retry rounds finish (or `null` when
32992
+ * nothing failed). Exposed for tests and subclass diagnostics —
32993
+ * boot NEVER awaits this: the runner's post-init handshake goes out
32994
+ * with the devices that restored, and a late success is announced
32995
+ * through the `native-cap-change` → `updateCaps` path. */
32996
+ get restoreRetryCompletion() {
32997
+ return this._restoreRetryCompletion;
32998
+ }
32999
+ /** Devices that exhausted the retry bound this process lifetime. */
33000
+ get permanentRestoreFailures() {
33001
+ return [...this._permanentRestoreFailures.values()];
33002
+ }
33003
+ /** One-line operator-facing summary for `getStatus().error`, or
33004
+ * `null` when every device restored. */
33005
+ restoreFailureSummary() {
33006
+ if (this._permanentRestoreFailures.size === 0) return null;
33007
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33008
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33009
+ }
33010
+ cancelRestoreRetries() {
33011
+ this._restoreRetryScheduler?.cancel();
33012
+ this._restoreRetryScheduler = null;
33013
+ }
33014
+ recordPermanentRestoreFailure(failure) {
33015
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33016
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33017
+ tags: {
33018
+ deviceId: failure.deviceId,
33019
+ stableId: failure.stableId
33020
+ },
33021
+ meta: {
33022
+ type: failure.type,
33023
+ attempts: failure.attempts,
33024
+ error: failure.lastError
33025
+ }
33026
+ });
33027
+ }
33028
+ scheduleRestoreRetries(failures, attempt) {
33029
+ const scheduler = new DeviceRestoreRetryScheduler({
33030
+ logger: this.ctx.logger,
33031
+ delaysMs: this.restoreRetryDelaysMs,
33032
+ concurrency: this.restoreRetryConcurrency,
33033
+ attempt,
33034
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33035
+ });
33036
+ this._restoreRetryScheduler = scheduler;
33037
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33038
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33039
+ });
33040
+ }
33041
+ /**
33042
+ * Tear down and reconstruct ONE device from its persisted rows — the
33043
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33044
+ * and no other device this provider owns is disturbed.
33045
+ *
33046
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33047
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33048
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33049
+ * whatever number the row carries NOW. The teardown is `decommission` —
33050
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33051
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33052
+ * the boot restore's own `create()` path, including its pass 2: first-class
33053
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33054
+ * parent by the cascade and must be re-created explicitly, because only
33055
+ * accessory children come back through `getAccessoryChildren()`.
33056
+ *
33057
+ * Reloading an accessory child directly is refused (no device class) —
33058
+ * reload its parent instead.
33059
+ */
33060
+ async reloadDevice(input) {
33061
+ const { stableId } = input;
33062
+ const devices = this.ctx.kernel.devices;
33063
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33064
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33065
+ if (live) await devices.decommission(live.id);
33066
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33067
+ addonId: this.addonId,
33068
+ stableId
33069
+ });
33070
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33071
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33072
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33073
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33074
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33075
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33076
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33077
+ for (const row of rows) {
33078
+ if (row.parentDeviceId !== id) continue;
33079
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33080
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33081
+ if (!ChildClass) continue;
33082
+ try {
33083
+ await devices.create(row.stableId, ChildClass, {}, id);
33084
+ } catch (err) {
33085
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33086
+ tags: {
33087
+ deviceId: row.id,
33088
+ stableId: row.stableId
33089
+ },
33090
+ meta: {
33091
+ parentDeviceId: id,
33092
+ error: err instanceof Error ? err.message : String(err)
33093
+ }
33094
+ });
33095
+ }
33096
+ }
33097
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33098
+ tags: { deviceId: id },
33099
+ meta: {
33100
+ stableId,
33101
+ type: meta.type
33102
+ }
33103
+ });
33104
+ return { deviceId: id };
32797
33105
  }
32798
33106
  /**
32799
33107
  * Restore devices from persisted state. Two-pass:
@@ -32819,55 +33127,108 @@ var BaseDeviceProvider = class extends BaseAddon {
32819
33127
  * accessory-spawn flow handles via the parent's
32820
33128
  * `getAccessoryChildren()`. Override only when the default doesn't
32821
33129
  * fit.
33130
+ *
33131
+ * A row that fails either pass is NOT terminal (D347): it is handed
33132
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33133
+ * Only after the bound is exhausted is the device marked permanently
33134
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33135
+ * `getStatus().error`.
32822
33136
  */
32823
33137
  async onRestoreDevices(savedDevices) {
32824
33138
  const restored = /* @__PURE__ */ new Set();
33139
+ const failures = [];
33140
+ const attemptRestore = async (saved) => {
33141
+ if (restored.has(saved.id)) return;
33142
+ const Class = this.deviceClasses[saved.type];
33143
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33144
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33145
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33146
+ restored.add(saved.id);
33147
+ };
32825
33148
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32826
33149
  const restoreOne = async (saved) => {
32827
- const Class = this.deviceClasses[saved.type];
32828
- if (!Class) {
33150
+ if (!this.deviceClasses[saved.type]) {
32829
33151
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32830
- tags: { stableId: saved.stableId },
33152
+ tags: {
33153
+ deviceId: saved.id,
33154
+ stableId: saved.stableId
33155
+ },
32831
33156
  meta: { type: saved.type }
32832
33157
  });
32833
33158
  return;
32834
33159
  }
32835
33160
  try {
32836
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32837
- restored.add(saved.id);
33161
+ await attemptRestore(saved);
32838
33162
  } catch (err) {
32839
- this.ctx.logger.warn("Failed to restore device", {
32840
- tags: { stableId: saved.stableId },
33163
+ const error = err instanceof Error ? err.message : String(err);
33164
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33165
+ tags: {
33166
+ deviceId: saved.id,
33167
+ stableId: saved.stableId
33168
+ },
32841
33169
  meta: {
32842
33170
  type: saved.type,
32843
- error: err instanceof Error ? err.message : String(err)
33171
+ attempt: 1,
33172
+ error
32844
33173
  }
32845
33174
  });
33175
+ failures.push({
33176
+ saved,
33177
+ error
33178
+ });
32846
33179
  }
32847
33180
  };
32848
33181
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33182
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32849
33183
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32850
33184
  for (const saved of childRows) {
32851
- const Class = this.deviceClasses[saved.type];
32852
- if (!Class) continue;
33185
+ if (!this.deviceClasses[saved.type]) continue;
32853
33186
  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", {
33187
+ if (restored.has(saved.parentDeviceId)) {
33188
+ try {
33189
+ await attemptRestore(saved);
33190
+ } catch (err) {
33191
+ const error = err instanceof Error ? err.message : String(err);
33192
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33193
+ tags: {
33194
+ deviceId: saved.id,
33195
+ stableId: saved.stableId,
33196
+ parentDeviceId: saved.parentDeviceId
33197
+ },
33198
+ meta: {
33199
+ type: saved.type,
33200
+ attempt: 1,
33201
+ error
33202
+ }
33203
+ });
33204
+ failures.push({
33205
+ saved,
33206
+ error
33207
+ });
33208
+ }
33209
+ continue;
33210
+ }
33211
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33212
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32860
33213
  tags: {
33214
+ deviceId: saved.id,
32861
33215
  stableId: saved.stableId,
32862
33216
  parentDeviceId: saved.parentDeviceId
32863
33217
  },
32864
- meta: {
32865
- type: saved.type,
32866
- error: err instanceof Error ? err.message : String(err)
32867
- }
33218
+ meta: { type: saved.type }
32868
33219
  });
33220
+ failures.push({
33221
+ saved,
33222
+ error: `parent device ${saved.parentDeviceId} not restored`
33223
+ });
33224
+ continue;
32869
33225
  }
32870
33226
  }
33227
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33228
+ return {
33229
+ restoredCount: restored.size,
33230
+ failedCount: failures.length
33231
+ };
32871
33232
  }
32872
33233
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32873
33234
  toSummary(device) {
@@ -34630,6 +34991,12 @@ Object.freeze({
34630
34991
  addonId: null,
34631
34992
  access: "view"
34632
34993
  },
34994
+ "deviceProvider.reloadDevice": {
34995
+ capName: "device-provider",
34996
+ capScope: "system",
34997
+ addonId: null,
34998
+ access: "create"
34999
+ },
34633
35000
  "deviceProvider.start": {
34634
35001
  capName: "device-provider",
34635
35002
  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.64",
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",