@camstack/addon-provider-homematic 1.2.60 → 1.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
@@ -12831,7 +12831,26 @@ var DiscoveryCandidateSchema = object({
12831
12831
  * identity ahead of adoption. Rendering metadata (unit, precision)
12832
12832
  * flows live through the cap STATUS SLICE after adoption.
12833
12833
  */
12834
- sourceInfo: SourceInfoSchema.optional()
12834
+ sourceInfo: SourceInfoSchema.optional(),
12835
+ /**
12836
+ * Set when this candidate is a device the provider ALREADY owns.
12837
+ *
12838
+ * A scan cannot generally produce the identity a device was onboarded under
12839
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
12840
+ * comparison never matches and an owned device looks addable. Re-adopting one
12841
+ * overwrites its config with scan-derived values — that is how a Home Hub's
12842
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
12843
+ * three child cameras offline for four hours.
12844
+ *
12845
+ * A provider that can recognise its own devices says so here. Absent means
12846
+ * "not recognised", which is not the same as "known to be new" — a provider
12847
+ * that cannot tell simply never sets it.
12848
+ */
12849
+ alreadyOnboarded: boolean().optional(),
12850
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
12851
+ onboardedDeviceId: number().optional(),
12852
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
12853
+ onboardedName: string().optional()
12835
12854
  });
12836
12855
  /**
12837
12856
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -12887,6 +12906,35 @@ var deviceProviderCapability = {
12887
12906
  name: string(),
12888
12907
  type: string()
12889
12908
  }))),
12909
+ /**
12910
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12911
+ * touching no other device this provider owns.
12912
+ *
12913
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12914
+ * migrated numbers: after `swapIds` the runner's live instance still
12915
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12916
+ * registrations and its log tags), and a live object cannot be renumbered.
12917
+ * Before this method the only flush was restarting the whole owning addon
12918
+ * — which took every camera the provider owns down with it (28 devices
12919
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12920
+ * same day ~27 devices' native caps did not come back on their own).
12921
+ *
12922
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12923
+ * that changes. The reply carries the id the device answers on NOW.
12924
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12925
+ * instance (if any), then re-create from the persisted row: the same
12926
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12927
+ * An RPC, never an event: a dropped event would leave the runner writing
12928
+ * against the wrong camera (D8).
12929
+ *
12930
+ * Construction can dial hardware, and the migrated source is
12931
+ * characteristically dead — the timeout covers a full activate window
12932
+ * rather than the 60 s default.
12933
+ */
12934
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12935
+ kind: "mutation",
12936
+ timeoutMs: 3 * 6e4
12937
+ }),
12890
12938
  supportsDiscovery: method(object({}), boolean()),
12891
12939
  /**
12892
12940
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13214,7 +13262,8 @@ method(object({
13214
13262
  targetId: number()
13215
13263
  }), MigrateDeviceResultSchema, {
13216
13264
  kind: "mutation",
13217
- auth: "admin"
13265
+ auth: "admin",
13266
+ timeoutMs: 12 * 6e4
13218
13267
  }), 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({
13219
13268
  deviceId: number(),
13220
13269
  name: string()
@@ -32570,6 +32619,147 @@ var BaseDevice = class {
32570
32619
  }
32571
32620
  };
32572
32621
  /**
32622
+ * Delays before retry rounds 1..N — the round count IS the bound.
32623
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32624
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32625
+ * per attempt) covers a device-manager lock held for minutes — the
32626
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32627
+ */
32628
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32629
+ 1e4,
32630
+ 3e4,
32631
+ 9e4
32632
+ ];
32633
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32634
+ function sleep$1(ms, signal) {
32635
+ return new Promise((resolve) => {
32636
+ if (signal.aborted) {
32637
+ resolve();
32638
+ return;
32639
+ }
32640
+ const onAbort = () => {
32641
+ clearTimeout(timer);
32642
+ resolve();
32643
+ };
32644
+ const timer = setTimeout(() => {
32645
+ signal.removeEventListener("abort", onAbort);
32646
+ resolve();
32647
+ }, ms);
32648
+ timer.unref?.();
32649
+ signal.addEventListener("abort", onAbort, { once: true });
32650
+ });
32651
+ }
32652
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32653
+ * not reject (callers wrap their own try/catch). */
32654
+ async function runWithConcurrency(items, width, fn) {
32655
+ const queue = [...items];
32656
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32657
+ const lane = async () => {
32658
+ for (;;) {
32659
+ const item = queue.shift();
32660
+ if (item === void 0) return;
32661
+ await fn(item);
32662
+ }
32663
+ };
32664
+ await Promise.all(Array.from({ length: laneCount }, lane));
32665
+ }
32666
+ var DeviceRestoreRetryScheduler = class {
32667
+ #logger;
32668
+ #attempt;
32669
+ #onPermanentFailure;
32670
+ #delaysMs;
32671
+ #concurrency;
32672
+ #now;
32673
+ #abort = new AbortController();
32674
+ constructor(options) {
32675
+ this.#logger = options.logger;
32676
+ this.#attempt = options.attempt;
32677
+ this.#onPermanentFailure = options.onPermanentFailure;
32678
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32679
+ this.#concurrency = options.concurrency ?? 4;
32680
+ this.#now = options.now ?? Date.now;
32681
+ }
32682
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32683
+ * permanently failed — the next boot restores them from disk. */
32684
+ cancel() {
32685
+ this.#abort.abort();
32686
+ }
32687
+ /**
32688
+ * Run the bounded retry rounds. Resolves when every entry has either
32689
+ * restored, been marked permanently failed, or the scheduler was
32690
+ * cancelled. Never rejects.
32691
+ */
32692
+ async run(initialFailures) {
32693
+ let pending = initialFailures.map((failure) => ({
32694
+ saved: failure.saved,
32695
+ lastError: failure.error,
32696
+ attempts: 1
32697
+ }));
32698
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32699
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32700
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32701
+ if (this.#abort.signal.aborted) break;
32702
+ pending = await this.#runRound(pending, round);
32703
+ }
32704
+ if (this.#abort.signal.aborted) return [];
32705
+ const terminal = pending.map((entry) => ({
32706
+ deviceId: entry.saved.id,
32707
+ stableId: entry.saved.stableId,
32708
+ type: String(entry.saved.type),
32709
+ attempts: entry.attempts,
32710
+ lastError: entry.lastError,
32711
+ failedAt: this.#now()
32712
+ }));
32713
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32714
+ return terminal;
32715
+ }
32716
+ /** One retry round: parents first (phase 0), then hub-adopted
32717
+ * children (phase 1) — a child's attempt depends on its parent
32718
+ * having landed, exactly like the initial two-pass restore. */
32719
+ async #runRound(pending, round) {
32720
+ const next = [];
32721
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32722
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32723
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32724
+ if (this.#abort.signal.aborted) {
32725
+ next.push(entry);
32726
+ return;
32727
+ }
32728
+ const attemptNo = entry.attempts + 1;
32729
+ try {
32730
+ await this.#attempt(entry.saved);
32731
+ this.#logger.info("Device restored on retry", {
32732
+ tags: {
32733
+ deviceId: entry.saved.id,
32734
+ stableId: entry.saved.stableId
32735
+ },
32736
+ meta: { attempt: attemptNo }
32737
+ });
32738
+ } catch (err) {
32739
+ const lastError = err instanceof Error ? err.message : String(err);
32740
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32741
+ this.#logger.warn("Device restore retry failed", {
32742
+ tags: {
32743
+ deviceId: entry.saved.id,
32744
+ stableId: entry.saved.stableId
32745
+ },
32746
+ meta: {
32747
+ attempt: attemptNo,
32748
+ remainingRetries,
32749
+ error: lastError
32750
+ }
32751
+ });
32752
+ next.push({
32753
+ saved: entry.saved,
32754
+ lastError,
32755
+ attempts: attemptNo
32756
+ });
32757
+ }
32758
+ });
32759
+ return next;
32760
+ }
32761
+ };
32762
+ /**
32573
32763
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32574
32764
  * device-provider cap router. Shared across all providers.
32575
32765
  */
@@ -32618,6 +32808,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32618
32808
  }];
32619
32809
  }
32620
32810
  async onShutdown() {
32811
+ this.cancelRestoreRetries();
32621
32812
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32622
32813
  for (const device of devices) try {
32623
32814
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32635,9 +32826,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32635
32826
  async start() {}
32636
32827
  async stop() {}
32637
32828
  async getStatus() {
32829
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32830
+ const summary = this.restoreFailureSummary();
32831
+ if (summary === null) return {
32832
+ connected: true,
32833
+ deviceCount: all.length
32834
+ };
32638
32835
  return {
32639
32836
  connected: true,
32640
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32837
+ deviceCount: all.length,
32838
+ error: summary
32641
32839
  };
32642
32840
  }
32643
32841
  async getDevices() {
@@ -32727,8 +32925,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32727
32925
  };
32728
32926
  }
32729
32927
  async restoreDevices(savedDevices) {
32730
- await this.onRestoreDevices(savedDevices);
32731
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32928
+ const report = await this.onRestoreDevices(savedDevices);
32929
+ if (savedDevices.length === 0) return;
32930
+ if (report && report.failedCount > 0) {
32931
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32932
+ return;
32933
+ }
32934
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
32935
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
32936
+ }
32937
+ /** Retry schedule. Overridable (tests use millisecond delays). */
32938
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
32939
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
32940
+ * never re-stampede full-width while the initial pass does (D167). */
32941
+ restoreRetryConcurrency = 4;
32942
+ _restoreRetryScheduler = null;
32943
+ _restoreRetryCompletion = null;
32944
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
32945
+ /** Settles when the background retry rounds finish (or `null` when
32946
+ * nothing failed). Exposed for tests and subclass diagnostics —
32947
+ * boot NEVER awaits this: the runner's post-init handshake goes out
32948
+ * with the devices that restored, and a late success is announced
32949
+ * through the `native-cap-change` → `updateCaps` path. */
32950
+ get restoreRetryCompletion() {
32951
+ return this._restoreRetryCompletion;
32952
+ }
32953
+ /** Devices that exhausted the retry bound this process lifetime. */
32954
+ get permanentRestoreFailures() {
32955
+ return [...this._permanentRestoreFailures.values()];
32956
+ }
32957
+ /** One-line operator-facing summary for `getStatus().error`, or
32958
+ * `null` when every device restored. */
32959
+ restoreFailureSummary() {
32960
+ if (this._permanentRestoreFailures.size === 0) return null;
32961
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
32962
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
32963
+ }
32964
+ cancelRestoreRetries() {
32965
+ this._restoreRetryScheduler?.cancel();
32966
+ this._restoreRetryScheduler = null;
32967
+ }
32968
+ recordPermanentRestoreFailure(failure) {
32969
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
32970
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
32971
+ tags: {
32972
+ deviceId: failure.deviceId,
32973
+ stableId: failure.stableId
32974
+ },
32975
+ meta: {
32976
+ type: failure.type,
32977
+ attempts: failure.attempts,
32978
+ error: failure.lastError
32979
+ }
32980
+ });
32981
+ }
32982
+ scheduleRestoreRetries(failures, attempt) {
32983
+ const scheduler = new DeviceRestoreRetryScheduler({
32984
+ logger: this.ctx.logger,
32985
+ delaysMs: this.restoreRetryDelaysMs,
32986
+ concurrency: this.restoreRetryConcurrency,
32987
+ attempt,
32988
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
32989
+ });
32990
+ this._restoreRetryScheduler = scheduler;
32991
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
32992
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
32993
+ });
32994
+ }
32995
+ /**
32996
+ * Tear down and reconstruct ONE device from its persisted rows — the
32997
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
32998
+ * and no other device this provider owns is disturbed.
32999
+ *
33000
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33001
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33002
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33003
+ * whatever number the row carries NOW. The teardown is `decommission` —
33004
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33005
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33006
+ * the boot restore's own `create()` path, including its pass 2: first-class
33007
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33008
+ * parent by the cascade and must be re-created explicitly, because only
33009
+ * accessory children come back through `getAccessoryChildren()`.
33010
+ *
33011
+ * Reloading an accessory child directly is refused (no device class) —
33012
+ * reload its parent instead.
33013
+ */
33014
+ async reloadDevice(input) {
33015
+ const { stableId } = input;
33016
+ const devices = this.ctx.kernel.devices;
33017
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33018
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33019
+ if (live) await devices.decommission(live.id);
33020
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33021
+ addonId: this.addonId,
33022
+ stableId
33023
+ });
33024
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33025
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33026
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33027
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33028
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33029
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33030
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33031
+ for (const row of rows) {
33032
+ if (row.parentDeviceId !== id) continue;
33033
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33034
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33035
+ if (!ChildClass) continue;
33036
+ try {
33037
+ await devices.create(row.stableId, ChildClass, {}, id);
33038
+ } catch (err) {
33039
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33040
+ tags: {
33041
+ deviceId: row.id,
33042
+ stableId: row.stableId
33043
+ },
33044
+ meta: {
33045
+ parentDeviceId: id,
33046
+ error: err instanceof Error ? err.message : String(err)
33047
+ }
33048
+ });
33049
+ }
33050
+ }
33051
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33052
+ tags: { deviceId: id },
33053
+ meta: {
33054
+ stableId,
33055
+ type: meta.type
33056
+ }
33057
+ });
33058
+ return { deviceId: id };
32732
33059
  }
32733
33060
  /**
32734
33061
  * Restore devices from persisted state. Two-pass:
@@ -32754,55 +33081,125 @@ var BaseDeviceProvider = class extends BaseAddon {
32754
33081
  * accessory-spawn flow handles via the parent's
32755
33082
  * `getAccessoryChildren()`. Override only when the default doesn't
32756
33083
  * fit.
33084
+ *
33085
+ * A row that fails either pass is NOT terminal (D347): it is handed
33086
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33087
+ * Only after the bound is exhausted is the device marked permanently
33088
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33089
+ * `getStatus().error`.
32757
33090
  */
33091
+ /**
33092
+ * Repair a row's PERSISTED config blob immediately before it is restored.
33093
+ * Default: no-op — most providers have nothing to heal.
33094
+ *
33095
+ * This exists because a restored device self-hydrates from the DB: `create()`
33096
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
33097
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
33098
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
33099
+ * been emptied failed all four bounded attempts against fields
33100
+ * (`host`, `password`) it inherits from its parent and never dials itself.
33101
+ *
33102
+ * Implementations get every saved row, so a child can read its parent's blob.
33103
+ * A heal that throws is treated like any other restore failure: retried under
33104
+ * the bound, then reported — never swallowed.
33105
+ */
33106
+ async healSavedConfig(_saved, _allSaved) {}
32758
33107
  async onRestoreDevices(savedDevices) {
32759
33108
  const restored = /* @__PURE__ */ new Set();
33109
+ const failures = [];
33110
+ const attemptRestore = async (saved) => {
33111
+ if (restored.has(saved.id)) return;
33112
+ const Class = this.deviceClasses[saved.type];
33113
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33114
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33115
+ await this.healSavedConfig(saved, savedDevices);
33116
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33117
+ restored.add(saved.id);
33118
+ };
32760
33119
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32761
33120
  const restoreOne = async (saved) => {
32762
- const Class = this.deviceClasses[saved.type];
32763
- if (!Class) {
33121
+ if (!this.deviceClasses[saved.type]) {
32764
33122
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32765
- tags: { stableId: saved.stableId },
33123
+ tags: {
33124
+ deviceId: saved.id,
33125
+ stableId: saved.stableId
33126
+ },
32766
33127
  meta: { type: saved.type }
32767
33128
  });
32768
33129
  return;
32769
33130
  }
32770
33131
  try {
32771
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32772
- restored.add(saved.id);
33132
+ await attemptRestore(saved);
32773
33133
  } catch (err) {
32774
- this.ctx.logger.warn("Failed to restore device", {
32775
- tags: { stableId: saved.stableId },
33134
+ const error = err instanceof Error ? err.message : String(err);
33135
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33136
+ tags: {
33137
+ deviceId: saved.id,
33138
+ stableId: saved.stableId
33139
+ },
32776
33140
  meta: {
32777
33141
  type: saved.type,
32778
- error: err instanceof Error ? err.message : String(err)
33142
+ attempt: 1,
33143
+ error
32779
33144
  }
32780
33145
  });
33146
+ failures.push({
33147
+ saved,
33148
+ error
33149
+ });
32781
33150
  }
32782
33151
  };
32783
33152
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33153
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32784
33154
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32785
33155
  for (const saved of childRows) {
32786
- const Class = this.deviceClasses[saved.type];
32787
- if (!Class) continue;
33156
+ if (!this.deviceClasses[saved.type]) continue;
32788
33157
  if (saved.parentDeviceId === null) continue;
32789
- if (!restored.has(saved.parentDeviceId)) continue;
32790
- try {
32791
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32792
- restored.add(saved.id);
32793
- } catch (err) {
32794
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33158
+ if (restored.has(saved.parentDeviceId)) {
33159
+ try {
33160
+ await attemptRestore(saved);
33161
+ } catch (err) {
33162
+ const error = err instanceof Error ? err.message : String(err);
33163
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33164
+ tags: {
33165
+ deviceId: saved.id,
33166
+ stableId: saved.stableId,
33167
+ parentDeviceId: saved.parentDeviceId
33168
+ },
33169
+ meta: {
33170
+ type: saved.type,
33171
+ attempt: 1,
33172
+ error
33173
+ }
33174
+ });
33175
+ failures.push({
33176
+ saved,
33177
+ error
33178
+ });
33179
+ }
33180
+ continue;
33181
+ }
33182
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33183
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32795
33184
  tags: {
33185
+ deviceId: saved.id,
32796
33186
  stableId: saved.stableId,
32797
33187
  parentDeviceId: saved.parentDeviceId
32798
33188
  },
32799
- meta: {
32800
- type: saved.type,
32801
- error: err instanceof Error ? err.message : String(err)
32802
- }
33189
+ meta: { type: saved.type }
32803
33190
  });
33191
+ failures.push({
33192
+ saved,
33193
+ error: `parent device ${saved.parentDeviceId} not restored`
33194
+ });
33195
+ continue;
32804
33196
  }
32805
33197
  }
33198
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33199
+ return {
33200
+ restoredCount: restored.size,
33201
+ failedCount: failures.length
33202
+ };
32806
33203
  }
32807
33204
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32808
33205
  toSummary(device) {
@@ -34565,6 +34962,12 @@ Object.freeze({
34565
34962
  addonId: null,
34566
34963
  access: "view"
34567
34964
  },
34965
+ "deviceProvider.reloadDevice": {
34966
+ capName: "device-provider",
34967
+ capScope: "system",
34968
+ addonId: null,
34969
+ access: "create"
34970
+ },
34568
34971
  "deviceProvider.start": {
34569
34972
  capName: "device-provider",
34570
34973
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -12832,7 +12832,26 @@ var DiscoveryCandidateSchema = object({
12832
12832
  * identity ahead of adoption. Rendering metadata (unit, precision)
12833
12833
  * flows live through the cap STATUS SLICE after adoption.
12834
12834
  */
12835
- sourceInfo: SourceInfoSchema.optional()
12835
+ sourceInfo: SourceInfoSchema.optional(),
12836
+ /**
12837
+ * Set when this candidate is a device the provider ALREADY owns.
12838
+ *
12839
+ * A scan cannot generally produce the identity a device was onboarded under
12840
+ * (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
12841
+ * comparison never matches and an owned device looks addable. Re-adopting one
12842
+ * overwrites its config with scan-derived values — that is how a Home Hub's
12843
+ * Baichuan port was overwritten with its ONVIF port, taking the hub and its
12844
+ * three child cameras offline for four hours.
12845
+ *
12846
+ * A provider that can recognise its own devices says so here. Absent means
12847
+ * "not recognised", which is not the same as "known to be new" — a provider
12848
+ * that cannot tell simply never sets it.
12849
+ */
12850
+ alreadyOnboarded: boolean().optional(),
12851
+ /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
12852
+ onboardedDeviceId: number().optional(),
12853
+ /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
12854
+ onboardedName: string().optional()
12836
12855
  });
12837
12856
  /**
12838
12857
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -12888,6 +12907,35 @@ var deviceProviderCapability = {
12888
12907
  name: string(),
12889
12908
  type: string()
12890
12909
  }))),
12910
+ /**
12911
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12912
+ * touching no other device this provider owns.
12913
+ *
12914
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12915
+ * migrated numbers: after `swapIds` the runner's live instance still
12916
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12917
+ * registrations and its log tags), and a live object cannot be renumbered.
12918
+ * Before this method the only flush was restarting the whole owning addon
12919
+ * — which took every camera the provider owns down with it (28 devices
12920
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12921
+ * same day ~27 devices' native caps did not come back on their own).
12922
+ *
12923
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12924
+ * that changes. The reply carries the id the device answers on NOW.
12925
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12926
+ * instance (if any), then re-create from the persisted row: the same
12927
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12928
+ * An RPC, never an event: a dropped event would leave the runner writing
12929
+ * against the wrong camera (D8).
12930
+ *
12931
+ * Construction can dial hardware, and the migrated source is
12932
+ * characteristically dead — the timeout covers a full activate window
12933
+ * rather than the 60 s default.
12934
+ */
12935
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12936
+ kind: "mutation",
12937
+ timeoutMs: 3 * 6e4
12938
+ }),
12891
12939
  supportsDiscovery: method(object({}), boolean()),
12892
12940
  /**
12893
12941
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13215,7 +13263,8 @@ method(object({
13215
13263
  targetId: number()
13216
13264
  }), MigrateDeviceResultSchema, {
13217
13265
  kind: "mutation",
13218
- auth: "admin"
13266
+ auth: "admin",
13267
+ timeoutMs: 12 * 6e4
13219
13268
  }), 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({
13220
13269
  deviceId: number(),
13221
13270
  name: string()
@@ -32571,6 +32620,147 @@ var BaseDevice = class {
32571
32620
  }
32572
32621
  };
32573
32622
  /**
32623
+ * Delays before retry rounds 1..N — the round count IS the bound.
32624
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32625
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32626
+ * per attempt) covers a device-manager lock held for minutes — the
32627
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32628
+ */
32629
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32630
+ 1e4,
32631
+ 3e4,
32632
+ 9e4
32633
+ ];
32634
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32635
+ function sleep$1(ms, signal) {
32636
+ return new Promise((resolve) => {
32637
+ if (signal.aborted) {
32638
+ resolve();
32639
+ return;
32640
+ }
32641
+ const onAbort = () => {
32642
+ clearTimeout(timer);
32643
+ resolve();
32644
+ };
32645
+ const timer = setTimeout(() => {
32646
+ signal.removeEventListener("abort", onAbort);
32647
+ resolve();
32648
+ }, ms);
32649
+ timer.unref?.();
32650
+ signal.addEventListener("abort", onAbort, { once: true });
32651
+ });
32652
+ }
32653
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32654
+ * not reject (callers wrap their own try/catch). */
32655
+ async function runWithConcurrency(items, width, fn) {
32656
+ const queue = [...items];
32657
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32658
+ const lane = async () => {
32659
+ for (;;) {
32660
+ const item = queue.shift();
32661
+ if (item === void 0) return;
32662
+ await fn(item);
32663
+ }
32664
+ };
32665
+ await Promise.all(Array.from({ length: laneCount }, lane));
32666
+ }
32667
+ var DeviceRestoreRetryScheduler = class {
32668
+ #logger;
32669
+ #attempt;
32670
+ #onPermanentFailure;
32671
+ #delaysMs;
32672
+ #concurrency;
32673
+ #now;
32674
+ #abort = new AbortController();
32675
+ constructor(options) {
32676
+ this.#logger = options.logger;
32677
+ this.#attempt = options.attempt;
32678
+ this.#onPermanentFailure = options.onPermanentFailure;
32679
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32680
+ this.#concurrency = options.concurrency ?? 4;
32681
+ this.#now = options.now ?? Date.now;
32682
+ }
32683
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32684
+ * permanently failed — the next boot restores them from disk. */
32685
+ cancel() {
32686
+ this.#abort.abort();
32687
+ }
32688
+ /**
32689
+ * Run the bounded retry rounds. Resolves when every entry has either
32690
+ * restored, been marked permanently failed, or the scheduler was
32691
+ * cancelled. Never rejects.
32692
+ */
32693
+ async run(initialFailures) {
32694
+ let pending = initialFailures.map((failure) => ({
32695
+ saved: failure.saved,
32696
+ lastError: failure.error,
32697
+ attempts: 1
32698
+ }));
32699
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32700
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32701
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32702
+ if (this.#abort.signal.aborted) break;
32703
+ pending = await this.#runRound(pending, round);
32704
+ }
32705
+ if (this.#abort.signal.aborted) return [];
32706
+ const terminal = pending.map((entry) => ({
32707
+ deviceId: entry.saved.id,
32708
+ stableId: entry.saved.stableId,
32709
+ type: String(entry.saved.type),
32710
+ attempts: entry.attempts,
32711
+ lastError: entry.lastError,
32712
+ failedAt: this.#now()
32713
+ }));
32714
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32715
+ return terminal;
32716
+ }
32717
+ /** One retry round: parents first (phase 0), then hub-adopted
32718
+ * children (phase 1) — a child's attempt depends on its parent
32719
+ * having landed, exactly like the initial two-pass restore. */
32720
+ async #runRound(pending, round) {
32721
+ const next = [];
32722
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32723
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32724
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32725
+ if (this.#abort.signal.aborted) {
32726
+ next.push(entry);
32727
+ return;
32728
+ }
32729
+ const attemptNo = entry.attempts + 1;
32730
+ try {
32731
+ await this.#attempt(entry.saved);
32732
+ this.#logger.info("Device restored on retry", {
32733
+ tags: {
32734
+ deviceId: entry.saved.id,
32735
+ stableId: entry.saved.stableId
32736
+ },
32737
+ meta: { attempt: attemptNo }
32738
+ });
32739
+ } catch (err) {
32740
+ const lastError = err instanceof Error ? err.message : String(err);
32741
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32742
+ this.#logger.warn("Device restore retry failed", {
32743
+ tags: {
32744
+ deviceId: entry.saved.id,
32745
+ stableId: entry.saved.stableId
32746
+ },
32747
+ meta: {
32748
+ attempt: attemptNo,
32749
+ remainingRetries,
32750
+ error: lastError
32751
+ }
32752
+ });
32753
+ next.push({
32754
+ saved: entry.saved,
32755
+ lastError,
32756
+ attempts: attemptNo
32757
+ });
32758
+ }
32759
+ });
32760
+ return next;
32761
+ }
32762
+ };
32763
+ /**
32574
32764
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32575
32765
  * device-provider cap router. Shared across all providers.
32576
32766
  */
@@ -32619,6 +32809,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32619
32809
  }];
32620
32810
  }
32621
32811
  async onShutdown() {
32812
+ this.cancelRestoreRetries();
32622
32813
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32623
32814
  for (const device of devices) try {
32624
32815
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32636,9 +32827,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32636
32827
  async start() {}
32637
32828
  async stop() {}
32638
32829
  async getStatus() {
32830
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32831
+ const summary = this.restoreFailureSummary();
32832
+ if (summary === null) return {
32833
+ connected: true,
32834
+ deviceCount: all.length
32835
+ };
32639
32836
  return {
32640
32837
  connected: true,
32641
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32838
+ deviceCount: all.length,
32839
+ error: summary
32642
32840
  };
32643
32841
  }
32644
32842
  async getDevices() {
@@ -32728,8 +32926,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32728
32926
  };
32729
32927
  }
32730
32928
  async restoreDevices(savedDevices) {
32731
- await this.onRestoreDevices(savedDevices);
32732
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32929
+ const report = await this.onRestoreDevices(savedDevices);
32930
+ if (savedDevices.length === 0) return;
32931
+ if (report && report.failedCount > 0) {
32932
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32933
+ return;
32934
+ }
32935
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
32936
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
32937
+ }
32938
+ /** Retry schedule. Overridable (tests use millisecond delays). */
32939
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
32940
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
32941
+ * never re-stampede full-width while the initial pass does (D167). */
32942
+ restoreRetryConcurrency = 4;
32943
+ _restoreRetryScheduler = null;
32944
+ _restoreRetryCompletion = null;
32945
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
32946
+ /** Settles when the background retry rounds finish (or `null` when
32947
+ * nothing failed). Exposed for tests and subclass diagnostics —
32948
+ * boot NEVER awaits this: the runner's post-init handshake goes out
32949
+ * with the devices that restored, and a late success is announced
32950
+ * through the `native-cap-change` → `updateCaps` path. */
32951
+ get restoreRetryCompletion() {
32952
+ return this._restoreRetryCompletion;
32953
+ }
32954
+ /** Devices that exhausted the retry bound this process lifetime. */
32955
+ get permanentRestoreFailures() {
32956
+ return [...this._permanentRestoreFailures.values()];
32957
+ }
32958
+ /** One-line operator-facing summary for `getStatus().error`, or
32959
+ * `null` when every device restored. */
32960
+ restoreFailureSummary() {
32961
+ if (this._permanentRestoreFailures.size === 0) return null;
32962
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
32963
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
32964
+ }
32965
+ cancelRestoreRetries() {
32966
+ this._restoreRetryScheduler?.cancel();
32967
+ this._restoreRetryScheduler = null;
32968
+ }
32969
+ recordPermanentRestoreFailure(failure) {
32970
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
32971
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
32972
+ tags: {
32973
+ deviceId: failure.deviceId,
32974
+ stableId: failure.stableId
32975
+ },
32976
+ meta: {
32977
+ type: failure.type,
32978
+ attempts: failure.attempts,
32979
+ error: failure.lastError
32980
+ }
32981
+ });
32982
+ }
32983
+ scheduleRestoreRetries(failures, attempt) {
32984
+ const scheduler = new DeviceRestoreRetryScheduler({
32985
+ logger: this.ctx.logger,
32986
+ delaysMs: this.restoreRetryDelaysMs,
32987
+ concurrency: this.restoreRetryConcurrency,
32988
+ attempt,
32989
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
32990
+ });
32991
+ this._restoreRetryScheduler = scheduler;
32992
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
32993
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
32994
+ });
32995
+ }
32996
+ /**
32997
+ * Tear down and reconstruct ONE device from its persisted rows — the
32998
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
32999
+ * and no other device this provider owns is disturbed.
33000
+ *
33001
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33002
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33003
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33004
+ * whatever number the row carries NOW. The teardown is `decommission` —
33005
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33006
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33007
+ * the boot restore's own `create()` path, including its pass 2: first-class
33008
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33009
+ * parent by the cascade and must be re-created explicitly, because only
33010
+ * accessory children come back through `getAccessoryChildren()`.
33011
+ *
33012
+ * Reloading an accessory child directly is refused (no device class) —
33013
+ * reload its parent instead.
33014
+ */
33015
+ async reloadDevice(input) {
33016
+ const { stableId } = input;
33017
+ const devices = this.ctx.kernel.devices;
33018
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33019
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33020
+ if (live) await devices.decommission(live.id);
33021
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33022
+ addonId: this.addonId,
33023
+ stableId
33024
+ });
33025
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33026
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33027
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33028
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33029
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33030
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33031
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33032
+ for (const row of rows) {
33033
+ if (row.parentDeviceId !== id) continue;
33034
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33035
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33036
+ if (!ChildClass) continue;
33037
+ try {
33038
+ await devices.create(row.stableId, ChildClass, {}, id);
33039
+ } catch (err) {
33040
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33041
+ tags: {
33042
+ deviceId: row.id,
33043
+ stableId: row.stableId
33044
+ },
33045
+ meta: {
33046
+ parentDeviceId: id,
33047
+ error: err instanceof Error ? err.message : String(err)
33048
+ }
33049
+ });
33050
+ }
33051
+ }
33052
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33053
+ tags: { deviceId: id },
33054
+ meta: {
33055
+ stableId,
33056
+ type: meta.type
33057
+ }
33058
+ });
33059
+ return { deviceId: id };
32733
33060
  }
32734
33061
  /**
32735
33062
  * Restore devices from persisted state. Two-pass:
@@ -32755,55 +33082,125 @@ var BaseDeviceProvider = class extends BaseAddon {
32755
33082
  * accessory-spawn flow handles via the parent's
32756
33083
  * `getAccessoryChildren()`. Override only when the default doesn't
32757
33084
  * fit.
33085
+ *
33086
+ * A row that fails either pass is NOT terminal (D347): it is handed
33087
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33088
+ * Only after the bound is exhausted is the device marked permanently
33089
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33090
+ * `getStatus().error`.
32758
33091
  */
33092
+ /**
33093
+ * Repair a row's PERSISTED config blob immediately before it is restored.
33094
+ * Default: no-op — most providers have nothing to heal.
33095
+ *
33096
+ * This exists because a restored device self-hydrates from the DB: `create()`
33097
+ * passes `{}` and `BaseDevice` parses the stored blob against the device
33098
+ * schema. A blob that lost a REQUIRED field therefore fails restore forever,
33099
+ * and no later pass revisits it — a hub-adopted Reolink camera whose blob had
33100
+ * been emptied failed all four bounded attempts against fields
33101
+ * (`host`, `password`) it inherits from its parent and never dials itself.
33102
+ *
33103
+ * Implementations get every saved row, so a child can read its parent's blob.
33104
+ * A heal that throws is treated like any other restore failure: retried under
33105
+ * the bound, then reported — never swallowed.
33106
+ */
33107
+ async healSavedConfig(_saved, _allSaved) {}
32759
33108
  async onRestoreDevices(savedDevices) {
32760
33109
  const restored = /* @__PURE__ */ new Set();
33110
+ const failures = [];
33111
+ const attemptRestore = async (saved) => {
33112
+ if (restored.has(saved.id)) return;
33113
+ const Class = this.deviceClasses[saved.type];
33114
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33115
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33116
+ await this.healSavedConfig(saved, savedDevices);
33117
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33118
+ restored.add(saved.id);
33119
+ };
32761
33120
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32762
33121
  const restoreOne = async (saved) => {
32763
- const Class = this.deviceClasses[saved.type];
32764
- if (!Class) {
33122
+ if (!this.deviceClasses[saved.type]) {
32765
33123
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32766
- tags: { stableId: saved.stableId },
33124
+ tags: {
33125
+ deviceId: saved.id,
33126
+ stableId: saved.stableId
33127
+ },
32767
33128
  meta: { type: saved.type }
32768
33129
  });
32769
33130
  return;
32770
33131
  }
32771
33132
  try {
32772
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32773
- restored.add(saved.id);
33133
+ await attemptRestore(saved);
32774
33134
  } catch (err) {
32775
- this.ctx.logger.warn("Failed to restore device", {
32776
- tags: { stableId: saved.stableId },
33135
+ const error = err instanceof Error ? err.message : String(err);
33136
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33137
+ tags: {
33138
+ deviceId: saved.id,
33139
+ stableId: saved.stableId
33140
+ },
32777
33141
  meta: {
32778
33142
  type: saved.type,
32779
- error: err instanceof Error ? err.message : String(err)
33143
+ attempt: 1,
33144
+ error
32780
33145
  }
32781
33146
  });
33147
+ failures.push({
33148
+ saved,
33149
+ error
33150
+ });
32782
33151
  }
32783
33152
  };
32784
33153
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33154
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32785
33155
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32786
33156
  for (const saved of childRows) {
32787
- const Class = this.deviceClasses[saved.type];
32788
- if (!Class) continue;
33157
+ if (!this.deviceClasses[saved.type]) continue;
32789
33158
  if (saved.parentDeviceId === null) continue;
32790
- if (!restored.has(saved.parentDeviceId)) continue;
32791
- try {
32792
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32793
- restored.add(saved.id);
32794
- } catch (err) {
32795
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33159
+ if (restored.has(saved.parentDeviceId)) {
33160
+ try {
33161
+ await attemptRestore(saved);
33162
+ } catch (err) {
33163
+ const error = err instanceof Error ? err.message : String(err);
33164
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33165
+ tags: {
33166
+ deviceId: saved.id,
33167
+ stableId: saved.stableId,
33168
+ parentDeviceId: saved.parentDeviceId
33169
+ },
33170
+ meta: {
33171
+ type: saved.type,
33172
+ attempt: 1,
33173
+ error
33174
+ }
33175
+ });
33176
+ failures.push({
33177
+ saved,
33178
+ error
33179
+ });
33180
+ }
33181
+ continue;
33182
+ }
33183
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33184
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32796
33185
  tags: {
33186
+ deviceId: saved.id,
32797
33187
  stableId: saved.stableId,
32798
33188
  parentDeviceId: saved.parentDeviceId
32799
33189
  },
32800
- meta: {
32801
- type: saved.type,
32802
- error: err instanceof Error ? err.message : String(err)
32803
- }
33190
+ meta: { type: saved.type }
32804
33191
  });
33192
+ failures.push({
33193
+ saved,
33194
+ error: `parent device ${saved.parentDeviceId} not restored`
33195
+ });
33196
+ continue;
32805
33197
  }
32806
33198
  }
33199
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33200
+ return {
33201
+ restoredCount: restored.size,
33202
+ failedCount: failures.length
33203
+ };
32807
33204
  }
32808
33205
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32809
33206
  toSummary(device) {
@@ -34566,6 +34963,12 @@ Object.freeze({
34566
34963
  addonId: null,
34567
34964
  access: "view"
34568
34965
  },
34966
+ "deviceProvider.reloadDevice": {
34967
+ capName: "device-provider",
34968
+ capScope: "system",
34969
+ addonId: null,
34970
+ access: "create"
34971
+ },
34569
34972
  "deviceProvider.start": {
34570
34973
  capName: "device-provider",
34571
34974
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homematic",
3
- "version": "1.2.60",
3
+ "version": "1.2.62",
4
4
  "description": "Homematic / HomematicIP (CCU3 / RaspberryMatic) device-provider addon for CamStack — wraps the nodehomematic library",
5
5
  "keywords": [
6
6
  "camstack",