@camstack/addon-provider-amcrest 0.2.60 → 0.2.62

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
@@ -12749,7 +12749,26 @@ var DiscoveryCandidateSchema = object({
12749
12749
  * identity ahead of adoption. Rendering metadata (unit, precision)
12750
12750
  * flows live through the cap STATUS SLICE after adoption.
12751
12751
  */
12752
- sourceInfo: SourceInfoSchema.optional()
12752
+ sourceInfo: SourceInfoSchema.optional(),
12753
+ /**
12754
+ * Set when this candidate is a device the provider ALREADY owns.
12755
+ *
12756
+ * A scan cannot generally produce the identity a device was onboarded under
12757
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
12758
+ * comparison never matches and an owned device looks addable. Re-adopting one
12759
+ * overwrites its config with scan-derived values — that is how a Home Hub's
12760
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
12761
+ * three child cameras offline for four hours.
12762
+ *
12763
+ * A provider that can recognise its own devices says so here. Absent means
12764
+ * "not recognised", which is not the same as "known to be new" — a provider
12765
+ * that cannot tell simply never sets it.
12766
+ */
12767
+ alreadyOnboarded: boolean().optional(),
12768
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
12769
+ onboardedDeviceId: number().optional(),
12770
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
12771
+ onboardedName: string().optional()
12753
12772
  });
12754
12773
  /**
12755
12774
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -12805,6 +12824,35 @@ var deviceProviderCapability = {
12805
12824
  name: string(),
12806
12825
  type: string()
12807
12826
  }))),
12827
+ /**
12828
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12829
+ * touching no other device this provider owns.
12830
+ *
12831
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12832
+ * migrated numbers: after `swapIds` the runner's live instance still
12833
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12834
+ * registrations and its log tags), and a live object cannot be renumbered.
12835
+ * Before this method the only flush was restarting the whole owning addon
12836
+ * — which took every camera the provider owns down with it (28 devices
12837
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12838
+ * same day ~27 devices' native caps did not come back on their own).
12839
+ *
12840
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12841
+ * that changes. The reply carries the id the device answers on NOW.
12842
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12843
+ * instance (if any), then re-create from the persisted row: the same
12844
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12845
+ * An RPC, never an event: a dropped event would leave the runner writing
12846
+ * against the wrong camera (D8).
12847
+ *
12848
+ * Construction can dial hardware, and the migrated source is
12849
+ * characteristically dead — the timeout covers a full activate window
12850
+ * rather than the 60 s default.
12851
+ */
12852
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12853
+ kind: "mutation",
12854
+ timeoutMs: 3 * 6e4
12855
+ }),
12808
12856
  supportsDiscovery: method(object({}), boolean()),
12809
12857
  /**
12810
12858
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13132,7 +13180,8 @@ method(object({
13132
13180
  targetId: number()
13133
13181
  }), MigrateDeviceResultSchema, {
13134
13182
  kind: "mutation",
13135
- auth: "admin"
13183
+ auth: "admin",
13184
+ timeoutMs: 12 * 6e4
13136
13185
  }), 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({
13137
13186
  deviceId: number(),
13138
13187
  name: string()
@@ -32878,6 +32927,147 @@ var BaseDevice = class {
32878
32927
  }
32879
32928
  };
32880
32929
  /**
32930
+ * Delays before retry rounds 1..N — the round count IS the bound.
32931
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32932
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32933
+ * per attempt) covers a device-manager lock held for minutes — the
32934
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32935
+ */
32936
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32937
+ 1e4,
32938
+ 3e4,
32939
+ 9e4
32940
+ ];
32941
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32942
+ function sleep$1(ms, signal) {
32943
+ return new Promise((resolve) => {
32944
+ if (signal.aborted) {
32945
+ resolve();
32946
+ return;
32947
+ }
32948
+ const onAbort = () => {
32949
+ clearTimeout(timer);
32950
+ resolve();
32951
+ };
32952
+ const timer = setTimeout(() => {
32953
+ signal.removeEventListener("abort", onAbort);
32954
+ resolve();
32955
+ }, ms);
32956
+ timer.unref?.();
32957
+ signal.addEventListener("abort", onAbort, { once: true });
32958
+ });
32959
+ }
32960
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32961
+ * not reject (callers wrap their own try/catch). */
32962
+ async function runWithConcurrency(items, width, fn) {
32963
+ const queue = [...items];
32964
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32965
+ const lane = async () => {
32966
+ for (;;) {
32967
+ const item = queue.shift();
32968
+ if (item === void 0) return;
32969
+ await fn(item);
32970
+ }
32971
+ };
32972
+ await Promise.all(Array.from({ length: laneCount }, lane));
32973
+ }
32974
+ var DeviceRestoreRetryScheduler = class {
32975
+ #logger;
32976
+ #attempt;
32977
+ #onPermanentFailure;
32978
+ #delaysMs;
32979
+ #concurrency;
32980
+ #now;
32981
+ #abort = new AbortController();
32982
+ constructor(options) {
32983
+ this.#logger = options.logger;
32984
+ this.#attempt = options.attempt;
32985
+ this.#onPermanentFailure = options.onPermanentFailure;
32986
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32987
+ this.#concurrency = options.concurrency ?? 4;
32988
+ this.#now = options.now ?? Date.now;
32989
+ }
32990
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32991
+ * permanently failed — the next boot restores them from disk. */
32992
+ cancel() {
32993
+ this.#abort.abort();
32994
+ }
32995
+ /**
32996
+ * Run the bounded retry rounds. Resolves when every entry has either
32997
+ * restored, been marked permanently failed, or the scheduler was
32998
+ * cancelled. Never rejects.
32999
+ */
33000
+ async run(initialFailures) {
33001
+ let pending = initialFailures.map((failure) => ({
33002
+ saved: failure.saved,
33003
+ lastError: failure.error,
33004
+ attempts: 1
33005
+ }));
33006
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
33007
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
33008
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
33009
+ if (this.#abort.signal.aborted) break;
33010
+ pending = await this.#runRound(pending, round);
33011
+ }
33012
+ if (this.#abort.signal.aborted) return [];
33013
+ const terminal = pending.map((entry) => ({
33014
+ deviceId: entry.saved.id,
33015
+ stableId: entry.saved.stableId,
33016
+ type: String(entry.saved.type),
33017
+ attempts: entry.attempts,
33018
+ lastError: entry.lastError,
33019
+ failedAt: this.#now()
33020
+ }));
33021
+ for (const failure of terminal) this.#onPermanentFailure(failure);
33022
+ return terminal;
33023
+ }
33024
+ /** One retry round: parents first (phase 0), then hub-adopted
33025
+ * children (phase 1) — a child's attempt depends on its parent
33026
+ * having landed, exactly like the initial two-pass restore. */
33027
+ async #runRound(pending, round) {
33028
+ const next = [];
33029
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
33030
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
33031
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
33032
+ if (this.#abort.signal.aborted) {
33033
+ next.push(entry);
33034
+ return;
33035
+ }
33036
+ const attemptNo = entry.attempts + 1;
33037
+ try {
33038
+ await this.#attempt(entry.saved);
33039
+ this.#logger.info("Device restored on retry", {
33040
+ tags: {
33041
+ deviceId: entry.saved.id,
33042
+ stableId: entry.saved.stableId
33043
+ },
33044
+ meta: { attempt: attemptNo }
33045
+ });
33046
+ } catch (err) {
33047
+ const lastError = err instanceof Error ? err.message : String(err);
33048
+ const remainingRetries = this.#delaysMs.length - (round + 1);
33049
+ this.#logger.warn("Device restore retry failed", {
33050
+ tags: {
33051
+ deviceId: entry.saved.id,
33052
+ stableId: entry.saved.stableId
33053
+ },
33054
+ meta: {
33055
+ attempt: attemptNo,
33056
+ remainingRetries,
33057
+ error: lastError
33058
+ }
33059
+ });
33060
+ next.push({
33061
+ saved: entry.saved,
33062
+ lastError,
33063
+ attempts: attemptNo
33064
+ });
33065
+ }
33066
+ });
33067
+ return next;
33068
+ }
33069
+ };
33070
+ /**
32881
33071
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32882
33072
  * device-provider cap router. Shared across all providers.
32883
33073
  */
@@ -32926,6 +33116,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32926
33116
  }];
32927
33117
  }
32928
33118
  async onShutdown() {
33119
+ this.cancelRestoreRetries();
32929
33120
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32930
33121
  for (const device of devices) try {
32931
33122
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32943,9 +33134,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32943
33134
  async start() {}
32944
33135
  async stop() {}
32945
33136
  async getStatus() {
33137
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
33138
+ const summary = this.restoreFailureSummary();
33139
+ if (summary === null) return {
33140
+ connected: true,
33141
+ deviceCount: all.length
33142
+ };
32946
33143
  return {
32947
33144
  connected: true,
32948
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
33145
+ deviceCount: all.length,
33146
+ error: summary
32949
33147
  };
32950
33148
  }
32951
33149
  async getDevices() {
@@ -33035,8 +33233,137 @@ var BaseDeviceProvider = class extends BaseAddon {
33035
33233
  };
33036
33234
  }
33037
33235
  async restoreDevices(savedDevices) {
33038
- await this.onRestoreDevices(savedDevices);
33039
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33236
+ const report = await this.onRestoreDevices(savedDevices);
33237
+ if (savedDevices.length === 0) return;
33238
+ if (report && report.failedCount > 0) {
33239
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33240
+ return;
33241
+ }
33242
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33243
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33244
+ }
33245
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33246
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33247
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33248
+ * never re-stampede full-width while the initial pass does (D167). */
33249
+ restoreRetryConcurrency = 4;
33250
+ _restoreRetryScheduler = null;
33251
+ _restoreRetryCompletion = null;
33252
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33253
+ /** Settles when the background retry rounds finish (or `null` when
33254
+ * nothing failed). Exposed for tests and subclass diagnostics —
33255
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33256
+ * with the devices that restored, and a late success is announced
33257
+ * through the `native-cap-change` → `updateCaps` path. */
33258
+ get restoreRetryCompletion() {
33259
+ return this._restoreRetryCompletion;
33260
+ }
33261
+ /** Devices that exhausted the retry bound this process lifetime. */
33262
+ get permanentRestoreFailures() {
33263
+ return [...this._permanentRestoreFailures.values()];
33264
+ }
33265
+ /** One-line operator-facing summary for `getStatus().error`, or
33266
+ * `null` when every device restored. */
33267
+ restoreFailureSummary() {
33268
+ if (this._permanentRestoreFailures.size === 0) return null;
33269
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33270
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33271
+ }
33272
+ cancelRestoreRetries() {
33273
+ this._restoreRetryScheduler?.cancel();
33274
+ this._restoreRetryScheduler = null;
33275
+ }
33276
+ recordPermanentRestoreFailure(failure) {
33277
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33278
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33279
+ tags: {
33280
+ deviceId: failure.deviceId,
33281
+ stableId: failure.stableId
33282
+ },
33283
+ meta: {
33284
+ type: failure.type,
33285
+ attempts: failure.attempts,
33286
+ error: failure.lastError
33287
+ }
33288
+ });
33289
+ }
33290
+ scheduleRestoreRetries(failures, attempt) {
33291
+ const scheduler = new DeviceRestoreRetryScheduler({
33292
+ logger: this.ctx.logger,
33293
+ delaysMs: this.restoreRetryDelaysMs,
33294
+ concurrency: this.restoreRetryConcurrency,
33295
+ attempt,
33296
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33297
+ });
33298
+ this._restoreRetryScheduler = scheduler;
33299
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33300
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33301
+ });
33302
+ }
33303
+ /**
33304
+ * Tear down and reconstruct ONE device from its persisted rows — the
33305
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33306
+ * and no other device this provider owns is disturbed.
33307
+ *
33308
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33309
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33310
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33311
+ * whatever number the row carries NOW. The teardown is `decommission` —
33312
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33313
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33314
+ * the boot restore's own `create()` path, including its pass 2: first-class
33315
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33316
+ * parent by the cascade and must be re-created explicitly, because only
33317
+ * accessory children come back through `getAccessoryChildren()`.
33318
+ *
33319
+ * Reloading an accessory child directly is refused (no device class) —
33320
+ * reload its parent instead.
33321
+ */
33322
+ async reloadDevice(input) {
33323
+ const { stableId } = input;
33324
+ const devices = this.ctx.kernel.devices;
33325
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33326
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33327
+ if (live) await devices.decommission(live.id);
33328
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33329
+ addonId: this.addonId,
33330
+ stableId
33331
+ });
33332
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33333
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33334
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33335
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33336
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33337
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33338
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33339
+ for (const row of rows) {
33340
+ if (row.parentDeviceId !== id) continue;
33341
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33342
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33343
+ if (!ChildClass) continue;
33344
+ try {
33345
+ await devices.create(row.stableId, ChildClass, {}, id);
33346
+ } catch (err) {
33347
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33348
+ tags: {
33349
+ deviceId: row.id,
33350
+ stableId: row.stableId
33351
+ },
33352
+ meta: {
33353
+ parentDeviceId: id,
33354
+ error: err instanceof Error ? err.message : String(err)
33355
+ }
33356
+ });
33357
+ }
33358
+ }
33359
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33360
+ tags: { deviceId: id },
33361
+ meta: {
33362
+ stableId,
33363
+ type: meta.type
33364
+ }
33365
+ });
33366
+ return { deviceId: id };
33040
33367
  }
33041
33368
  /**
33042
33369
  * Restore devices from persisted state. Two-pass:
@@ -33062,55 +33389,125 @@ var BaseDeviceProvider = class extends BaseAddon {
33062
33389
  * accessory-spawn flow handles via the parent's
33063
33390
  * `getAccessoryChildren()`. Override only when the default doesn't
33064
33391
  * fit.
33392
+ *
33393
+ * A row that fails either pass is NOT terminal (D347): it is handed
33394
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33395
+ * Only after the bound is exhausted is the device marked permanently
33396
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33397
+ * `getStatus().error`.
33398
+ */
33399
+ /**
33400
+ * Repair a row's PERSISTED config blob immediately before it is restored.
33401
+ * Default: no-op — most providers have nothing to heal.
33402
+ *
33403
+ * This exists because a restored device self-hydrates from the DB: `create()`
33404
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
33405
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
33406
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
33407
+ * been emptied failed all four bounded attempts against fields
33408
+ * (`host`, `password`) it inherits from its parent and never dials itself.
33409
+ *
33410
+ * Implementations get every saved row, so a child can read its parent's blob.
33411
+ * A heal that throws is treated like any other restore failure: retried under
33412
+ * the bound, then reported — never swallowed.
33065
33413
  */
33414
+ async healSavedConfig(_saved, _allSaved) {}
33066
33415
  async onRestoreDevices(savedDevices) {
33067
33416
  const restored = /* @__PURE__ */ new Set();
33417
+ const failures = [];
33418
+ const attemptRestore = async (saved) => {
33419
+ if (restored.has(saved.id)) return;
33420
+ const Class = this.deviceClasses[saved.type];
33421
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33422
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33423
+ await this.healSavedConfig(saved, savedDevices);
33424
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33425
+ restored.add(saved.id);
33426
+ };
33068
33427
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
33069
33428
  const restoreOne = async (saved) => {
33070
- const Class = this.deviceClasses[saved.type];
33071
- if (!Class) {
33429
+ if (!this.deviceClasses[saved.type]) {
33072
33430
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
33073
- tags: { stableId: saved.stableId },
33431
+ tags: {
33432
+ deviceId: saved.id,
33433
+ stableId: saved.stableId
33434
+ },
33074
33435
  meta: { type: saved.type }
33075
33436
  });
33076
33437
  return;
33077
33438
  }
33078
33439
  try {
33079
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
33080
- restored.add(saved.id);
33440
+ await attemptRestore(saved);
33081
33441
  } catch (err) {
33082
- this.ctx.logger.warn("Failed to restore device", {
33083
- tags: { stableId: saved.stableId },
33442
+ const error = err instanceof Error ? err.message : String(err);
33443
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33444
+ tags: {
33445
+ deviceId: saved.id,
33446
+ stableId: saved.stableId
33447
+ },
33084
33448
  meta: {
33085
33449
  type: saved.type,
33086
- error: err instanceof Error ? err.message : String(err)
33450
+ attempt: 1,
33451
+ error
33087
33452
  }
33088
33453
  });
33454
+ failures.push({
33455
+ saved,
33456
+ error
33457
+ });
33089
33458
  }
33090
33459
  };
33091
33460
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33461
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
33092
33462
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
33093
33463
  for (const saved of childRows) {
33094
- const Class = this.deviceClasses[saved.type];
33095
- if (!Class) continue;
33464
+ if (!this.deviceClasses[saved.type]) continue;
33096
33465
  if (saved.parentDeviceId === null) continue;
33097
- if (!restored.has(saved.parentDeviceId)) continue;
33098
- try {
33099
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33100
- restored.add(saved.id);
33101
- } catch (err) {
33102
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33466
+ if (restored.has(saved.parentDeviceId)) {
33467
+ try {
33468
+ await attemptRestore(saved);
33469
+ } catch (err) {
33470
+ const error = err instanceof Error ? err.message : String(err);
33471
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33472
+ tags: {
33473
+ deviceId: saved.id,
33474
+ stableId: saved.stableId,
33475
+ parentDeviceId: saved.parentDeviceId
33476
+ },
33477
+ meta: {
33478
+ type: saved.type,
33479
+ attempt: 1,
33480
+ error
33481
+ }
33482
+ });
33483
+ failures.push({
33484
+ saved,
33485
+ error
33486
+ });
33487
+ }
33488
+ continue;
33489
+ }
33490
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33491
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
33103
33492
  tags: {
33493
+ deviceId: saved.id,
33104
33494
  stableId: saved.stableId,
33105
33495
  parentDeviceId: saved.parentDeviceId
33106
33496
  },
33107
- meta: {
33108
- type: saved.type,
33109
- error: err instanceof Error ? err.message : String(err)
33110
- }
33497
+ meta: { type: saved.type }
33498
+ });
33499
+ failures.push({
33500
+ saved,
33501
+ error: `parent device ${saved.parentDeviceId} not restored`
33111
33502
  });
33503
+ continue;
33112
33504
  }
33113
33505
  }
33506
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33507
+ return {
33508
+ restoredCount: restored.size,
33509
+ failedCount: failures.length
33510
+ };
33114
33511
  }
33115
33512
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
33116
33513
  toSummary(device) {
@@ -35079,6 +35476,12 @@ Object.freeze({
35079
35476
  addonId: null,
35080
35477
  access: "view"
35081
35478
  },
35479
+ "deviceProvider.reloadDevice": {
35480
+ capName: "device-provider",
35481
+ capScope: "system",
35482
+ addonId: null,
35483
+ access: "create"
35484
+ },
35082
35485
  "deviceProvider.start": {
35083
35486
  capName: "device-provider",
35084
35487
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -12750,7 +12750,26 @@ var DiscoveryCandidateSchema = object({
12750
12750
  * identity ahead of adoption. Rendering metadata (unit, precision)
12751
12751
  * flows live through the cap STATUS SLICE after adoption.
12752
12752
  */
12753
- sourceInfo: SourceInfoSchema.optional()
12753
+ sourceInfo: SourceInfoSchema.optional(),
12754
+ /**
12755
+ * Set when this candidate is a device the provider ALREADY owns.
12756
+ *
12757
+ * A scan cannot generally produce the identity a device was onboarded under
12758
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
12759
+ * comparison never matches and an owned device looks addable. Re-adopting one
12760
+ * overwrites its config with scan-derived values — that is how a Home Hub's
12761
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
12762
+ * three child cameras offline for four hours.
12763
+ *
12764
+ * A provider that can recognise its own devices says so here. Absent means
12765
+ * "not recognised", which is not the same as "known to be new" — a provider
12766
+ * that cannot tell simply never sets it.
12767
+ */
12768
+ alreadyOnboarded: boolean().optional(),
12769
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
12770
+ onboardedDeviceId: number().optional(),
12771
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
12772
+ onboardedName: string().optional()
12754
12773
  });
12755
12774
  /**
12756
12775
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -12806,6 +12825,35 @@ var deviceProviderCapability = {
12806
12825
  name: string(),
12807
12826
  type: string()
12808
12827
  }))),
12828
+ /**
12829
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12830
+ * touching no other device this provider owns.
12831
+ *
12832
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12833
+ * migrated numbers: after `swapIds` the runner's live instance still
12834
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12835
+ * registrations and its log tags), and a live object cannot be renumbered.
12836
+ * Before this method the only flush was restarting the whole owning addon
12837
+ * — which took every camera the provider owns down with it (28 devices
12838
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12839
+ * same day ~27 devices' native caps did not come back on their own).
12840
+ *
12841
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12842
+ * that changes. The reply carries the id the device answers on NOW.
12843
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12844
+ * instance (if any), then re-create from the persisted row: the same
12845
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12846
+ * An RPC, never an event: a dropped event would leave the runner writing
12847
+ * against the wrong camera (D8).
12848
+ *
12849
+ * Construction can dial hardware, and the migrated source is
12850
+ * characteristically dead — the timeout covers a full activate window
12851
+ * rather than the 60 s default.
12852
+ */
12853
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12854
+ kind: "mutation",
12855
+ timeoutMs: 3 * 6e4
12856
+ }),
12809
12857
  supportsDiscovery: method(object({}), boolean()),
12810
12858
  /**
12811
12859
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13133,7 +13181,8 @@ method(object({
13133
13181
  targetId: number()
13134
13182
  }), MigrateDeviceResultSchema, {
13135
13183
  kind: "mutation",
13136
- auth: "admin"
13184
+ auth: "admin",
13185
+ timeoutMs: 12 * 6e4
13137
13186
  }), 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({
13138
13187
  deviceId: number(),
13139
13188
  name: string()
@@ -32879,6 +32928,147 @@ var BaseDevice = class {
32879
32928
  }
32880
32929
  };
32881
32930
  /**
32931
+ * Delays before retry rounds 1..N — the round count IS the bound.
32932
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32933
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32934
+ * per attempt) covers a device-manager lock held for minutes — the
32935
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32936
+ */
32937
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32938
+ 1e4,
32939
+ 3e4,
32940
+ 9e4
32941
+ ];
32942
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32943
+ function sleep$1(ms, signal) {
32944
+ return new Promise((resolve) => {
32945
+ if (signal.aborted) {
32946
+ resolve();
32947
+ return;
32948
+ }
32949
+ const onAbort = () => {
32950
+ clearTimeout(timer);
32951
+ resolve();
32952
+ };
32953
+ const timer = setTimeout(() => {
32954
+ signal.removeEventListener("abort", onAbort);
32955
+ resolve();
32956
+ }, ms);
32957
+ timer.unref?.();
32958
+ signal.addEventListener("abort", onAbort, { once: true });
32959
+ });
32960
+ }
32961
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32962
+ * not reject (callers wrap their own try/catch). */
32963
+ async function runWithConcurrency(items, width, fn) {
32964
+ const queue = [...items];
32965
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32966
+ const lane = async () => {
32967
+ for (;;) {
32968
+ const item = queue.shift();
32969
+ if (item === void 0) return;
32970
+ await fn(item);
32971
+ }
32972
+ };
32973
+ await Promise.all(Array.from({ length: laneCount }, lane));
32974
+ }
32975
+ var DeviceRestoreRetryScheduler = class {
32976
+ #logger;
32977
+ #attempt;
32978
+ #onPermanentFailure;
32979
+ #delaysMs;
32980
+ #concurrency;
32981
+ #now;
32982
+ #abort = new AbortController();
32983
+ constructor(options) {
32984
+ this.#logger = options.logger;
32985
+ this.#attempt = options.attempt;
32986
+ this.#onPermanentFailure = options.onPermanentFailure;
32987
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32988
+ this.#concurrency = options.concurrency ?? 4;
32989
+ this.#now = options.now ?? Date.now;
32990
+ }
32991
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32992
+ * permanently failed — the next boot restores them from disk. */
32993
+ cancel() {
32994
+ this.#abort.abort();
32995
+ }
32996
+ /**
32997
+ * Run the bounded retry rounds. Resolves when every entry has either
32998
+ * restored, been marked permanently failed, or the scheduler was
32999
+ * cancelled. Never rejects.
33000
+ */
33001
+ async run(initialFailures) {
33002
+ let pending = initialFailures.map((failure) => ({
33003
+ saved: failure.saved,
33004
+ lastError: failure.error,
33005
+ attempts: 1
33006
+ }));
33007
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
33008
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
33009
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
33010
+ if (this.#abort.signal.aborted) break;
33011
+ pending = await this.#runRound(pending, round);
33012
+ }
33013
+ if (this.#abort.signal.aborted) return [];
33014
+ const terminal = pending.map((entry) => ({
33015
+ deviceId: entry.saved.id,
33016
+ stableId: entry.saved.stableId,
33017
+ type: String(entry.saved.type),
33018
+ attempts: entry.attempts,
33019
+ lastError: entry.lastError,
33020
+ failedAt: this.#now()
33021
+ }));
33022
+ for (const failure of terminal) this.#onPermanentFailure(failure);
33023
+ return terminal;
33024
+ }
33025
+ /** One retry round: parents first (phase 0), then hub-adopted
33026
+ * children (phase 1) — a child's attempt depends on its parent
33027
+ * having landed, exactly like the initial two-pass restore. */
33028
+ async #runRound(pending, round) {
33029
+ const next = [];
33030
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
33031
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
33032
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
33033
+ if (this.#abort.signal.aborted) {
33034
+ next.push(entry);
33035
+ return;
33036
+ }
33037
+ const attemptNo = entry.attempts + 1;
33038
+ try {
33039
+ await this.#attempt(entry.saved);
33040
+ this.#logger.info("Device restored on retry", {
33041
+ tags: {
33042
+ deviceId: entry.saved.id,
33043
+ stableId: entry.saved.stableId
33044
+ },
33045
+ meta: { attempt: attemptNo }
33046
+ });
33047
+ } catch (err) {
33048
+ const lastError = err instanceof Error ? err.message : String(err);
33049
+ const remainingRetries = this.#delaysMs.length - (round + 1);
33050
+ this.#logger.warn("Device restore retry failed", {
33051
+ tags: {
33052
+ deviceId: entry.saved.id,
33053
+ stableId: entry.saved.stableId
33054
+ },
33055
+ meta: {
33056
+ attempt: attemptNo,
33057
+ remainingRetries,
33058
+ error: lastError
33059
+ }
33060
+ });
33061
+ next.push({
33062
+ saved: entry.saved,
33063
+ lastError,
33064
+ attempts: attemptNo
33065
+ });
33066
+ }
33067
+ });
33068
+ return next;
33069
+ }
33070
+ };
33071
+ /**
32882
33072
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32883
33073
  * device-provider cap router. Shared across all providers.
32884
33074
  */
@@ -32927,6 +33117,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32927
33117
  }];
32928
33118
  }
32929
33119
  async onShutdown() {
33120
+ this.cancelRestoreRetries();
32930
33121
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32931
33122
  for (const device of devices) try {
32932
33123
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32944,9 +33135,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32944
33135
  async start() {}
32945
33136
  async stop() {}
32946
33137
  async getStatus() {
33138
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
33139
+ const summary = this.restoreFailureSummary();
33140
+ if (summary === null) return {
33141
+ connected: true,
33142
+ deviceCount: all.length
33143
+ };
32947
33144
  return {
32948
33145
  connected: true,
32949
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
33146
+ deviceCount: all.length,
33147
+ error: summary
32950
33148
  };
32951
33149
  }
32952
33150
  async getDevices() {
@@ -33036,8 +33234,137 @@ var BaseDeviceProvider = class extends BaseAddon {
33036
33234
  };
33037
33235
  }
33038
33236
  async restoreDevices(savedDevices) {
33039
- await this.onRestoreDevices(savedDevices);
33040
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33237
+ const report = await this.onRestoreDevices(savedDevices);
33238
+ if (savedDevices.length === 0) return;
33239
+ if (report && report.failedCount > 0) {
33240
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33241
+ return;
33242
+ }
33243
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33244
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33245
+ }
33246
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33247
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33248
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33249
+ * never re-stampede full-width while the initial pass does (D167). */
33250
+ restoreRetryConcurrency = 4;
33251
+ _restoreRetryScheduler = null;
33252
+ _restoreRetryCompletion = null;
33253
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33254
+ /** Settles when the background retry rounds finish (or `null` when
33255
+ * nothing failed). Exposed for tests and subclass diagnostics —
33256
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33257
+ * with the devices that restored, and a late success is announced
33258
+ * through the `native-cap-change` → `updateCaps` path. */
33259
+ get restoreRetryCompletion() {
33260
+ return this._restoreRetryCompletion;
33261
+ }
33262
+ /** Devices that exhausted the retry bound this process lifetime. */
33263
+ get permanentRestoreFailures() {
33264
+ return [...this._permanentRestoreFailures.values()];
33265
+ }
33266
+ /** One-line operator-facing summary for `getStatus().error`, or
33267
+ * `null` when every device restored. */
33268
+ restoreFailureSummary() {
33269
+ if (this._permanentRestoreFailures.size === 0) return null;
33270
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33271
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33272
+ }
33273
+ cancelRestoreRetries() {
33274
+ this._restoreRetryScheduler?.cancel();
33275
+ this._restoreRetryScheduler = null;
33276
+ }
33277
+ recordPermanentRestoreFailure(failure) {
33278
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33279
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33280
+ tags: {
33281
+ deviceId: failure.deviceId,
33282
+ stableId: failure.stableId
33283
+ },
33284
+ meta: {
33285
+ type: failure.type,
33286
+ attempts: failure.attempts,
33287
+ error: failure.lastError
33288
+ }
33289
+ });
33290
+ }
33291
+ scheduleRestoreRetries(failures, attempt) {
33292
+ const scheduler = new DeviceRestoreRetryScheduler({
33293
+ logger: this.ctx.logger,
33294
+ delaysMs: this.restoreRetryDelaysMs,
33295
+ concurrency: this.restoreRetryConcurrency,
33296
+ attempt,
33297
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33298
+ });
33299
+ this._restoreRetryScheduler = scheduler;
33300
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33301
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33302
+ });
33303
+ }
33304
+ /**
33305
+ * Tear down and reconstruct ONE device from its persisted rows — the
33306
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33307
+ * and no other device this provider owns is disturbed.
33308
+ *
33309
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33310
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33311
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33312
+ * whatever number the row carries NOW. The teardown is `decommission` —
33313
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33314
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33315
+ * the boot restore's own `create()` path, including its pass 2: first-class
33316
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33317
+ * parent by the cascade and must be re-created explicitly, because only
33318
+ * accessory children come back through `getAccessoryChildren()`.
33319
+ *
33320
+ * Reloading an accessory child directly is refused (no device class) —
33321
+ * reload its parent instead.
33322
+ */
33323
+ async reloadDevice(input) {
33324
+ const { stableId } = input;
33325
+ const devices = this.ctx.kernel.devices;
33326
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33327
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33328
+ if (live) await devices.decommission(live.id);
33329
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33330
+ addonId: this.addonId,
33331
+ stableId
33332
+ });
33333
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33334
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33335
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33336
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33337
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33338
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33339
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33340
+ for (const row of rows) {
33341
+ if (row.parentDeviceId !== id) continue;
33342
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33343
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33344
+ if (!ChildClass) continue;
33345
+ try {
33346
+ await devices.create(row.stableId, ChildClass, {}, id);
33347
+ } catch (err) {
33348
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33349
+ tags: {
33350
+ deviceId: row.id,
33351
+ stableId: row.stableId
33352
+ },
33353
+ meta: {
33354
+ parentDeviceId: id,
33355
+ error: err instanceof Error ? err.message : String(err)
33356
+ }
33357
+ });
33358
+ }
33359
+ }
33360
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33361
+ tags: { deviceId: id },
33362
+ meta: {
33363
+ stableId,
33364
+ type: meta.type
33365
+ }
33366
+ });
33367
+ return { deviceId: id };
33041
33368
  }
33042
33369
  /**
33043
33370
  * Restore devices from persisted state. Two-pass:
@@ -33063,55 +33390,125 @@ var BaseDeviceProvider = class extends BaseAddon {
33063
33390
  * accessory-spawn flow handles via the parent's
33064
33391
  * `getAccessoryChildren()`. Override only when the default doesn't
33065
33392
  * fit.
33393
+ *
33394
+ * A row that fails either pass is NOT terminal (D347): it is handed
33395
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33396
+ * Only after the bound is exhausted is the device marked permanently
33397
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33398
+ * `getStatus().error`.
33399
+ */
33400
+ /**
33401
+ * Repair a row's PERSISTED config blob immediately before it is restored.
33402
+ * Default: no-op — most providers have nothing to heal.
33403
+ *
33404
+ * This exists because a restored device self-hydrates from the DB: `create()`
33405
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
33406
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
33407
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
33408
+ * been emptied failed all four bounded attempts against fields
33409
+ * (`host`, `password`) it inherits from its parent and never dials itself.
33410
+ *
33411
+ * Implementations get every saved row, so a child can read its parent's blob.
33412
+ * A heal that throws is treated like any other restore failure: retried under
33413
+ * the bound, then reported — never swallowed.
33066
33414
  */
33415
+ async healSavedConfig(_saved, _allSaved) {}
33067
33416
  async onRestoreDevices(savedDevices) {
33068
33417
  const restored = /* @__PURE__ */ new Set();
33418
+ const failures = [];
33419
+ const attemptRestore = async (saved) => {
33420
+ if (restored.has(saved.id)) return;
33421
+ const Class = this.deviceClasses[saved.type];
33422
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33423
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33424
+ await this.healSavedConfig(saved, savedDevices);
33425
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33426
+ restored.add(saved.id);
33427
+ };
33069
33428
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
33070
33429
  const restoreOne = async (saved) => {
33071
- const Class = this.deviceClasses[saved.type];
33072
- if (!Class) {
33430
+ if (!this.deviceClasses[saved.type]) {
33073
33431
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
33074
- tags: { stableId: saved.stableId },
33432
+ tags: {
33433
+ deviceId: saved.id,
33434
+ stableId: saved.stableId
33435
+ },
33075
33436
  meta: { type: saved.type }
33076
33437
  });
33077
33438
  return;
33078
33439
  }
33079
33440
  try {
33080
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
33081
- restored.add(saved.id);
33441
+ await attemptRestore(saved);
33082
33442
  } catch (err) {
33083
- this.ctx.logger.warn("Failed to restore device", {
33084
- tags: { stableId: saved.stableId },
33443
+ const error = err instanceof Error ? err.message : String(err);
33444
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33445
+ tags: {
33446
+ deviceId: saved.id,
33447
+ stableId: saved.stableId
33448
+ },
33085
33449
  meta: {
33086
33450
  type: saved.type,
33087
- error: err instanceof Error ? err.message : String(err)
33451
+ attempt: 1,
33452
+ error
33088
33453
  }
33089
33454
  });
33455
+ failures.push({
33456
+ saved,
33457
+ error
33458
+ });
33090
33459
  }
33091
33460
  };
33092
33461
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33462
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
33093
33463
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
33094
33464
  for (const saved of childRows) {
33095
- const Class = this.deviceClasses[saved.type];
33096
- if (!Class) continue;
33465
+ if (!this.deviceClasses[saved.type]) continue;
33097
33466
  if (saved.parentDeviceId === null) continue;
33098
- if (!restored.has(saved.parentDeviceId)) continue;
33099
- try {
33100
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33101
- restored.add(saved.id);
33102
- } catch (err) {
33103
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33467
+ if (restored.has(saved.parentDeviceId)) {
33468
+ try {
33469
+ await attemptRestore(saved);
33470
+ } catch (err) {
33471
+ const error = err instanceof Error ? err.message : String(err);
33472
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33473
+ tags: {
33474
+ deviceId: saved.id,
33475
+ stableId: saved.stableId,
33476
+ parentDeviceId: saved.parentDeviceId
33477
+ },
33478
+ meta: {
33479
+ type: saved.type,
33480
+ attempt: 1,
33481
+ error
33482
+ }
33483
+ });
33484
+ failures.push({
33485
+ saved,
33486
+ error
33487
+ });
33488
+ }
33489
+ continue;
33490
+ }
33491
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33492
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
33104
33493
  tags: {
33494
+ deviceId: saved.id,
33105
33495
  stableId: saved.stableId,
33106
33496
  parentDeviceId: saved.parentDeviceId
33107
33497
  },
33108
- meta: {
33109
- type: saved.type,
33110
- error: err instanceof Error ? err.message : String(err)
33111
- }
33498
+ meta: { type: saved.type }
33499
+ });
33500
+ failures.push({
33501
+ saved,
33502
+ error: `parent device ${saved.parentDeviceId} not restored`
33112
33503
  });
33504
+ continue;
33113
33505
  }
33114
33506
  }
33507
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33508
+ return {
33509
+ restoredCount: restored.size,
33510
+ failedCount: failures.length
33511
+ };
33115
33512
  }
33116
33513
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
33117
33514
  toSummary(device) {
@@ -35080,6 +35477,12 @@ Object.freeze({
35080
35477
  addonId: null,
35081
35478
  access: "view"
35082
35479
  },
35480
+ "deviceProvider.reloadDevice": {
35481
+ capName: "device-provider",
35482
+ capScope: "system",
35483
+ addonId: null,
35484
+ access: "create"
35485
+ },
35083
35486
  "deviceProvider.start": {
35084
35487
  capName: "device-provider",
35085
35488
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-amcrest",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "description": "Amcrest/Dahua camera device provider addon for CamStack — Dahua CGI over HTTP(S) with digest auth (snapshot, RTSP catalog, PTZ, image/day-night config)",
5
5
  "keywords": [
6
6
  "camstack",