@camstack/addon-provider-wyze 0.2.62 → 0.2.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +509 -25
  2. package/dist/addon.mjs +509 -25
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -10559,6 +10559,89 @@ var LocationStatSchema = object({
10559
10559
  fileCount: number(),
10560
10560
  present: boolean()
10561
10561
  });
10562
+ /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
10563
+ var BackupRunStateSchema = _enum([
10564
+ "queued",
10565
+ "running",
10566
+ "succeeded",
10567
+ "failed",
10568
+ "cancelled"
10569
+ ]);
10570
+ /**
10571
+ * Where a running backup currently is. `queued` before it starts,
10572
+ * `building` while the tar.gz is being staged, `uploading` during the
10573
+ * per-destination fan-out, `done` once terminal.
10574
+ */
10575
+ var BackupRunPhaseSchema = _enum([
10576
+ "queued",
10577
+ "building",
10578
+ "uploading",
10579
+ "done"
10580
+ ]);
10581
+ /**
10582
+ * Observable state of one backup run — readable WHILE it runs via
10583
+ * `backup.listRuns`. This is what makes the execution queue and
10584
+ * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
10585
+ * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
10586
+ * diagnosable with `du` because nothing reported that runs existed or
10587
+ * how large the staged archive had grown.
10588
+ */
10589
+ var BackupRunSchema = object({
10590
+ /** Stable run id — the handle `backup.cancel` takes. */
10591
+ id: string(),
10592
+ state: BackupRunStateSchema,
10593
+ phase: BackupRunPhaseSchema,
10594
+ /**
10595
+ * Resolved destination location ids. Empty while queued (targets are
10596
+ * resolved when the run starts, against the then-current policies).
10597
+ */
10598
+ destinationIds: array(string()).readonly(),
10599
+ label: string().optional(),
10600
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
10601
+ requestedAt: number(),
10602
+ /** ms-epoch when the run left the queue and started building. */
10603
+ startedAt: number().optional(),
10604
+ /** ms-epoch when the run reached a terminal state. */
10605
+ finishedAt: number().optional(),
10606
+ /** Compressed bytes of the staging archive written so far. */
10607
+ stagedBytes: number(),
10608
+ /** Final staged archive size, once the build phase completes. */
10609
+ archiveSizeBytes: number().optional(),
10610
+ /** Bytes pushed to the destination currently uploading. */
10611
+ uploadedBytes: number(),
10612
+ /** Destinations where the archive fully landed (uploaded + indexed). */
10613
+ completedDestinationIds: array(string()).readonly(),
10614
+ /** Destinations that failed during the fan-out. */
10615
+ failedDestinationIds: array(string()).readonly(),
10616
+ /** Failure message when `state === 'failed'`. */
10617
+ error: string().optional(),
10618
+ /**
10619
+ * 1-based place in the execution queue — 1 = runs next. Present only
10620
+ * while `state === 'queued'`. Stamped by the orchestrator from the
10621
+ * queue's OWN pending order, never derived from timestamps, so the
10622
+ * UI cannot show an order the executor will not honour.
10623
+ */
10624
+ queuePosition: number().int().min(1).optional()
10625
+ });
10626
+ /**
10627
+ * Result of `backup.trigger`. The call still resolves when the run
10628
+ * terminates (compat with schedule-driven runs and the admin UI), but
10629
+ * it now names the run and says whether it had to WAIT: a trigger that
10630
+ * arrives while another run is in flight is enqueued (or joined onto
10631
+ * an identical already-queued run), never started concurrently.
10632
+ */
10633
+ var BackupTriggerResultSchema = object({
10634
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
10635
+ runId: string(),
10636
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
10637
+ queued: boolean(),
10638
+ /** True when this trigger was coalesced onto an identical already-queued run. */
10639
+ joined: boolean(),
10640
+ /** True when the run was cancelled before completing every destination. */
10641
+ cancelled: boolean(),
10642
+ /** One entry per destination the archive landed at (partial on cancel). */
10643
+ entries: array(BackupEntrySchema).readonly()
10644
+ });
10562
10645
  /**
10563
10646
  * A backup schedule — the N:M "entry" that binds one cron cadence to a
10564
10647
  * SET of destination locations. Supersedes the per-location cron on
@@ -10606,7 +10689,10 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
10606
10689
  * retention (manual runs).
10607
10690
  */
10608
10691
  retentionCount: number().int().min(1).max(1e3).optional()
10609
- }).optional(), array(BackupEntrySchema).readonly(), {
10692
+ }).optional(), BackupTriggerResultSchema, {
10693
+ kind: "mutation",
10694
+ auth: "admin"
10695
+ }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
10610
10696
  kind: "mutation",
10611
10697
  auth: "admin"
10612
10698
  }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
@@ -11323,6 +11409,14 @@ method(object({
11323
11409
  }), object({ success: literal(true) }), {
11324
11410
  kind: "mutation",
11325
11411
  auth: "admin"
11412
+ }), method(object({ deviceId: number().int().nonnegative() }), object({
11413
+ derivedStreamsDeleted: array(string()).readonly(),
11414
+ assignmentsPurged: boolean(),
11415
+ probeSnapshotsDropped: number().int().nonnegative(),
11416
+ rtspTokenRowsDeleted: number().int().nonnegative()
11417
+ }), {
11418
+ kind: "mutation",
11419
+ auth: "admin"
11326
11420
  }), method(object({
11327
11421
  deviceId: number(),
11328
11422
  /** Absent = the LOWEST assigned profile — a notification attachment is
@@ -12767,6 +12861,35 @@ var deviceProviderCapability = {
12767
12861
  name: string(),
12768
12862
  type: string()
12769
12863
  }))),
12864
+ /**
12865
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12866
+ * touching no other device this provider owns.
12867
+ *
12868
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12869
+ * migrated numbers: after `swapIds` the runner's live instance still
12870
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12871
+ * registrations and its log tags), and a live object cannot be renumbered.
12872
+ * Before this method the only flush was restarting the whole owning addon
12873
+ * — which took every camera the provider owns down with it (28 devices
12874
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12875
+ * same day ~27 devices' native caps did not come back on their own).
12876
+ *
12877
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12878
+ * that changes. The reply carries the id the device answers on NOW.
12879
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12880
+ * instance (if any), then re-create from the persisted row: the same
12881
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12882
+ * An RPC, never an event: a dropped event would leave the runner writing
12883
+ * against the wrong camera (D8).
12884
+ *
12885
+ * Construction can dial hardware, and the migrated source is
12886
+ * characteristically dead — the timeout covers a full activate window
12887
+ * rather than the 60 s default.
12888
+ */
12889
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12890
+ kind: "mutation",
12891
+ timeoutMs: 3 * 6e4
12892
+ }),
12770
12893
  supportsDiscovery: method(object({}), boolean()),
12771
12894
  /**
12772
12895
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13094,7 +13217,8 @@ method(object({
13094
13217
  targetId: number()
13095
13218
  }), MigrateDeviceResultSchema, {
13096
13219
  kind: "mutation",
13097
- auth: "admin"
13220
+ auth: "admin",
13221
+ timeoutMs: 12 * 6e4
13098
13222
  }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13099
13223
  deviceId: number(),
13100
13224
  name: string()
@@ -32562,6 +32686,147 @@ var BaseDevice = class {
32562
32686
  }
32563
32687
  };
32564
32688
  /**
32689
+ * Delays before retry rounds 1..N — the round count IS the bound.
32690
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32691
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32692
+ * per attempt) covers a device-manager lock held for minutes — the
32693
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32694
+ */
32695
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32696
+ 1e4,
32697
+ 3e4,
32698
+ 9e4
32699
+ ];
32700
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32701
+ function sleep$1(ms, signal) {
32702
+ return new Promise((resolve) => {
32703
+ if (signal.aborted) {
32704
+ resolve();
32705
+ return;
32706
+ }
32707
+ const onAbort = () => {
32708
+ clearTimeout(timer);
32709
+ resolve();
32710
+ };
32711
+ const timer = setTimeout(() => {
32712
+ signal.removeEventListener("abort", onAbort);
32713
+ resolve();
32714
+ }, ms);
32715
+ timer.unref?.();
32716
+ signal.addEventListener("abort", onAbort, { once: true });
32717
+ });
32718
+ }
32719
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32720
+ * not reject (callers wrap their own try/catch). */
32721
+ async function runWithConcurrency(items, width, fn) {
32722
+ const queue = [...items];
32723
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32724
+ const lane = async () => {
32725
+ for (;;) {
32726
+ const item = queue.shift();
32727
+ if (item === void 0) return;
32728
+ await fn(item);
32729
+ }
32730
+ };
32731
+ await Promise.all(Array.from({ length: laneCount }, lane));
32732
+ }
32733
+ var DeviceRestoreRetryScheduler = class {
32734
+ #logger;
32735
+ #attempt;
32736
+ #onPermanentFailure;
32737
+ #delaysMs;
32738
+ #concurrency;
32739
+ #now;
32740
+ #abort = new AbortController();
32741
+ constructor(options) {
32742
+ this.#logger = options.logger;
32743
+ this.#attempt = options.attempt;
32744
+ this.#onPermanentFailure = options.onPermanentFailure;
32745
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32746
+ this.#concurrency = options.concurrency ?? 4;
32747
+ this.#now = options.now ?? Date.now;
32748
+ }
32749
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32750
+ * permanently failed — the next boot restores them from disk. */
32751
+ cancel() {
32752
+ this.#abort.abort();
32753
+ }
32754
+ /**
32755
+ * Run the bounded retry rounds. Resolves when every entry has either
32756
+ * restored, been marked permanently failed, or the scheduler was
32757
+ * cancelled. Never rejects.
32758
+ */
32759
+ async run(initialFailures) {
32760
+ let pending = initialFailures.map((failure) => ({
32761
+ saved: failure.saved,
32762
+ lastError: failure.error,
32763
+ attempts: 1
32764
+ }));
32765
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32766
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32767
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32768
+ if (this.#abort.signal.aborted) break;
32769
+ pending = await this.#runRound(pending, round);
32770
+ }
32771
+ if (this.#abort.signal.aborted) return [];
32772
+ const terminal = pending.map((entry) => ({
32773
+ deviceId: entry.saved.id,
32774
+ stableId: entry.saved.stableId,
32775
+ type: String(entry.saved.type),
32776
+ attempts: entry.attempts,
32777
+ lastError: entry.lastError,
32778
+ failedAt: this.#now()
32779
+ }));
32780
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32781
+ return terminal;
32782
+ }
32783
+ /** One retry round: parents first (phase 0), then hub-adopted
32784
+ * children (phase 1) — a child's attempt depends on its parent
32785
+ * having landed, exactly like the initial two-pass restore. */
32786
+ async #runRound(pending, round) {
32787
+ const next = [];
32788
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32789
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32790
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32791
+ if (this.#abort.signal.aborted) {
32792
+ next.push(entry);
32793
+ return;
32794
+ }
32795
+ const attemptNo = entry.attempts + 1;
32796
+ try {
32797
+ await this.#attempt(entry.saved);
32798
+ this.#logger.info("Device restored on retry", {
32799
+ tags: {
32800
+ deviceId: entry.saved.id,
32801
+ stableId: entry.saved.stableId
32802
+ },
32803
+ meta: { attempt: attemptNo }
32804
+ });
32805
+ } catch (err) {
32806
+ const lastError = err instanceof Error ? err.message : String(err);
32807
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32808
+ this.#logger.warn("Device restore retry failed", {
32809
+ tags: {
32810
+ deviceId: entry.saved.id,
32811
+ stableId: entry.saved.stableId
32812
+ },
32813
+ meta: {
32814
+ attempt: attemptNo,
32815
+ remainingRetries,
32816
+ error: lastError
32817
+ }
32818
+ });
32819
+ next.push({
32820
+ saved: entry.saved,
32821
+ lastError,
32822
+ attempts: attemptNo
32823
+ });
32824
+ }
32825
+ });
32826
+ return next;
32827
+ }
32828
+ };
32829
+ /**
32565
32830
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32566
32831
  * device-provider cap router. Shared across all providers.
32567
32832
  */
@@ -32610,6 +32875,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32610
32875
  }];
32611
32876
  }
32612
32877
  async onShutdown() {
32878
+ this.cancelRestoreRetries();
32613
32879
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32614
32880
  for (const device of devices) try {
32615
32881
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32627,9 +32893,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32627
32893
  async start() {}
32628
32894
  async stop() {}
32629
32895
  async getStatus() {
32896
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32897
+ const summary = this.restoreFailureSummary();
32898
+ if (summary === null) return {
32899
+ connected: true,
32900
+ deviceCount: all.length
32901
+ };
32630
32902
  return {
32631
32903
  connected: true,
32632
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32904
+ deviceCount: all.length,
32905
+ error: summary
32633
32906
  };
32634
32907
  }
32635
32908
  async getDevices() {
@@ -32719,8 +32992,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32719
32992
  };
32720
32993
  }
32721
32994
  async restoreDevices(savedDevices) {
32722
- await this.onRestoreDevices(savedDevices);
32723
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32995
+ const report = await this.onRestoreDevices(savedDevices);
32996
+ if (savedDevices.length === 0) return;
32997
+ if (report && report.failedCount > 0) {
32998
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32999
+ return;
33000
+ }
33001
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33002
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33003
+ }
33004
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33005
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33006
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33007
+ * never re-stampede full-width while the initial pass does (D167). */
33008
+ restoreRetryConcurrency = 4;
33009
+ _restoreRetryScheduler = null;
33010
+ _restoreRetryCompletion = null;
33011
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33012
+ /** Settles when the background retry rounds finish (or `null` when
33013
+ * nothing failed). Exposed for tests and subclass diagnostics —
33014
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33015
+ * with the devices that restored, and a late success is announced
33016
+ * through the `native-cap-change` → `updateCaps` path. */
33017
+ get restoreRetryCompletion() {
33018
+ return this._restoreRetryCompletion;
33019
+ }
33020
+ /** Devices that exhausted the retry bound this process lifetime. */
33021
+ get permanentRestoreFailures() {
33022
+ return [...this._permanentRestoreFailures.values()];
33023
+ }
33024
+ /** One-line operator-facing summary for `getStatus().error`, or
33025
+ * `null` when every device restored. */
33026
+ restoreFailureSummary() {
33027
+ if (this._permanentRestoreFailures.size === 0) return null;
33028
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33029
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33030
+ }
33031
+ cancelRestoreRetries() {
33032
+ this._restoreRetryScheduler?.cancel();
33033
+ this._restoreRetryScheduler = null;
33034
+ }
33035
+ recordPermanentRestoreFailure(failure) {
33036
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33037
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33038
+ tags: {
33039
+ deviceId: failure.deviceId,
33040
+ stableId: failure.stableId
33041
+ },
33042
+ meta: {
33043
+ type: failure.type,
33044
+ attempts: failure.attempts,
33045
+ error: failure.lastError
33046
+ }
33047
+ });
33048
+ }
33049
+ scheduleRestoreRetries(failures, attempt) {
33050
+ const scheduler = new DeviceRestoreRetryScheduler({
33051
+ logger: this.ctx.logger,
33052
+ delaysMs: this.restoreRetryDelaysMs,
33053
+ concurrency: this.restoreRetryConcurrency,
33054
+ attempt,
33055
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33056
+ });
33057
+ this._restoreRetryScheduler = scheduler;
33058
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33059
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33060
+ });
33061
+ }
33062
+ /**
33063
+ * Tear down and reconstruct ONE device from its persisted rows — the
33064
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33065
+ * and no other device this provider owns is disturbed.
33066
+ *
33067
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33068
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33069
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33070
+ * whatever number the row carries NOW. The teardown is `decommission` —
33071
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33072
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33073
+ * the boot restore's own `create()` path, including its pass 2: first-class
33074
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33075
+ * parent by the cascade and must be re-created explicitly, because only
33076
+ * accessory children come back through `getAccessoryChildren()`.
33077
+ *
33078
+ * Reloading an accessory child directly is refused (no device class) —
33079
+ * reload its parent instead.
33080
+ */
33081
+ async reloadDevice(input) {
33082
+ const { stableId } = input;
33083
+ const devices = this.ctx.kernel.devices;
33084
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33085
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33086
+ if (live) await devices.decommission(live.id);
33087
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33088
+ addonId: this.addonId,
33089
+ stableId
33090
+ });
33091
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33092
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33093
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33094
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33095
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33096
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33097
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33098
+ for (const row of rows) {
33099
+ if (row.parentDeviceId !== id) continue;
33100
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33101
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33102
+ if (!ChildClass) continue;
33103
+ try {
33104
+ await devices.create(row.stableId, ChildClass, {}, id);
33105
+ } catch (err) {
33106
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33107
+ tags: {
33108
+ deviceId: row.id,
33109
+ stableId: row.stableId
33110
+ },
33111
+ meta: {
33112
+ parentDeviceId: id,
33113
+ error: err instanceof Error ? err.message : String(err)
33114
+ }
33115
+ });
33116
+ }
33117
+ }
33118
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33119
+ tags: { deviceId: id },
33120
+ meta: {
33121
+ stableId,
33122
+ type: meta.type
33123
+ }
33124
+ });
33125
+ return { deviceId: id };
32724
33126
  }
32725
33127
  /**
32726
33128
  * Restore devices from persisted state. Two-pass:
@@ -32746,55 +33148,108 @@ var BaseDeviceProvider = class extends BaseAddon {
32746
33148
  * accessory-spawn flow handles via the parent's
32747
33149
  * `getAccessoryChildren()`. Override only when the default doesn't
32748
33150
  * fit.
33151
+ *
33152
+ * A row that fails either pass is NOT terminal (D347): it is handed
33153
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33154
+ * Only after the bound is exhausted is the device marked permanently
33155
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33156
+ * `getStatus().error`.
32749
33157
  */
32750
33158
  async onRestoreDevices(savedDevices) {
32751
33159
  const restored = /* @__PURE__ */ new Set();
33160
+ const failures = [];
33161
+ const attemptRestore = async (saved) => {
33162
+ if (restored.has(saved.id)) return;
33163
+ const Class = this.deviceClasses[saved.type];
33164
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33165
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33166
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33167
+ restored.add(saved.id);
33168
+ };
32752
33169
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32753
33170
  const restoreOne = async (saved) => {
32754
- const Class = this.deviceClasses[saved.type];
32755
- if (!Class) {
33171
+ if (!this.deviceClasses[saved.type]) {
32756
33172
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32757
- tags: { stableId: saved.stableId },
33173
+ tags: {
33174
+ deviceId: saved.id,
33175
+ stableId: saved.stableId
33176
+ },
32758
33177
  meta: { type: saved.type }
32759
33178
  });
32760
33179
  return;
32761
33180
  }
32762
33181
  try {
32763
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32764
- restored.add(saved.id);
33182
+ await attemptRestore(saved);
32765
33183
  } catch (err) {
32766
- this.ctx.logger.warn("Failed to restore device", {
32767
- tags: { stableId: saved.stableId },
33184
+ const error = err instanceof Error ? err.message : String(err);
33185
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33186
+ tags: {
33187
+ deviceId: saved.id,
33188
+ stableId: saved.stableId
33189
+ },
32768
33190
  meta: {
32769
33191
  type: saved.type,
32770
- error: err instanceof Error ? err.message : String(err)
33192
+ attempt: 1,
33193
+ error
32771
33194
  }
32772
33195
  });
33196
+ failures.push({
33197
+ saved,
33198
+ error
33199
+ });
32773
33200
  }
32774
33201
  };
32775
33202
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33203
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32776
33204
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32777
33205
  for (const saved of childRows) {
32778
- const Class = this.deviceClasses[saved.type];
32779
- if (!Class) continue;
33206
+ if (!this.deviceClasses[saved.type]) continue;
32780
33207
  if (saved.parentDeviceId === null) continue;
32781
- if (!restored.has(saved.parentDeviceId)) continue;
32782
- try {
32783
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32784
- restored.add(saved.id);
32785
- } catch (err) {
32786
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33208
+ if (restored.has(saved.parentDeviceId)) {
33209
+ try {
33210
+ await attemptRestore(saved);
33211
+ } catch (err) {
33212
+ const error = err instanceof Error ? err.message : String(err);
33213
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33214
+ tags: {
33215
+ deviceId: saved.id,
33216
+ stableId: saved.stableId,
33217
+ parentDeviceId: saved.parentDeviceId
33218
+ },
33219
+ meta: {
33220
+ type: saved.type,
33221
+ attempt: 1,
33222
+ error
33223
+ }
33224
+ });
33225
+ failures.push({
33226
+ saved,
33227
+ error
33228
+ });
33229
+ }
33230
+ continue;
33231
+ }
33232
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33233
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32787
33234
  tags: {
33235
+ deviceId: saved.id,
32788
33236
  stableId: saved.stableId,
32789
33237
  parentDeviceId: saved.parentDeviceId
32790
33238
  },
32791
- meta: {
32792
- type: saved.type,
32793
- error: err instanceof Error ? err.message : String(err)
32794
- }
33239
+ meta: { type: saved.type }
33240
+ });
33241
+ failures.push({
33242
+ saved,
33243
+ error: `parent device ${saved.parentDeviceId} not restored`
32795
33244
  });
33245
+ continue;
32796
33246
  }
32797
33247
  }
33248
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33249
+ return {
33250
+ restoredCount: restored.size,
33251
+ failedCount: failures.length
33252
+ };
32798
33253
  }
32799
33254
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32800
33255
  toSummary(device) {
@@ -33303,6 +33758,12 @@ Object.freeze({
33303
33758
  addonId: null,
33304
33759
  access: "create"
33305
33760
  },
33761
+ "backup.cancel": {
33762
+ capName: "backup",
33763
+ capScope: "system",
33764
+ addonId: null,
33765
+ access: "create"
33766
+ },
33306
33767
  "backup.delete": {
33307
33768
  capName: "backup",
33308
33769
  capScope: "system",
@@ -33345,6 +33806,12 @@ Object.freeze({
33345
33806
  addonId: null,
33346
33807
  access: "view"
33347
33808
  },
33809
+ "backup.listRuns": {
33810
+ capName: "backup",
33811
+ capScope: "system",
33812
+ addonId: null,
33813
+ access: "view"
33814
+ },
33348
33815
  "backup.listSchedules": {
33349
33816
  capName: "backup",
33350
33817
  capScope: "system",
@@ -34545,6 +35012,12 @@ Object.freeze({
34545
35012
  addonId: null,
34546
35013
  access: "view"
34547
35014
  },
35015
+ "deviceProvider.reloadDevice": {
35016
+ capName: "device-provider",
35017
+ capScope: "system",
35018
+ addonId: null,
35019
+ access: "create"
35020
+ },
34548
35021
  "deviceProvider.start": {
34549
35022
  capName: "device-provider",
34550
35023
  capScope: "system",
@@ -37911,6 +38384,12 @@ Object.freeze({
37911
38384
  addonId: null,
37912
38385
  access: "create"
37913
38386
  },
38387
+ "streamBroker.forgetDeviceHardware": {
38388
+ capName: "stream-broker",
38389
+ capScope: "system",
38390
+ addonId: null,
38391
+ access: "delete"
38392
+ },
37914
38393
  "streamBroker.getAllRtspEntries": {
37915
38394
  capName: "stream-broker",
37916
38395
  capScope: "system",
@@ -40369,6 +40848,11 @@ Object.freeze({
40369
40848
  form: "single",
40370
40849
  optional: false
40371
40850
  }],
40851
+ "streamBroker.forgetDeviceHardware": [{
40852
+ name: "deviceId",
40853
+ form: "single",
40854
+ optional: false
40855
+ }],
40372
40856
  "streamBroker.getDeviceAudioMute": [{
40373
40857
  name: "deviceId",
40374
40858
  form: "single",
package/dist/addon.mjs CHANGED
@@ -10538,6 +10538,89 @@ var LocationStatSchema = object({
10538
10538
  fileCount: number(),
10539
10539
  present: boolean()
10540
10540
  });
10541
+ /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
10542
+ var BackupRunStateSchema = _enum([
10543
+ "queued",
10544
+ "running",
10545
+ "succeeded",
10546
+ "failed",
10547
+ "cancelled"
10548
+ ]);
10549
+ /**
10550
+ * Where a running backup currently is. `queued` before it starts,
10551
+ * `building` while the tar.gz is being staged, `uploading` during the
10552
+ * per-destination fan-out, `done` once terminal.
10553
+ */
10554
+ var BackupRunPhaseSchema = _enum([
10555
+ "queued",
10556
+ "building",
10557
+ "uploading",
10558
+ "done"
10559
+ ]);
10560
+ /**
10561
+ * Observable state of one backup run — readable WHILE it runs via
10562
+ * `backup.listRuns`. This is what makes the execution queue and
10563
+ * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
10564
+ * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
10565
+ * diagnosable with `du` because nothing reported that runs existed or
10566
+ * how large the staged archive had grown.
10567
+ */
10568
+ var BackupRunSchema = object({
10569
+ /** Stable run id — the handle `backup.cancel` takes. */
10570
+ id: string(),
10571
+ state: BackupRunStateSchema,
10572
+ phase: BackupRunPhaseSchema,
10573
+ /**
10574
+ * Resolved destination location ids. Empty while queued (targets are
10575
+ * resolved when the run starts, against the then-current policies).
10576
+ */
10577
+ destinationIds: array(string()).readonly(),
10578
+ label: string().optional(),
10579
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
10580
+ requestedAt: number(),
10581
+ /** ms-epoch when the run left the queue and started building. */
10582
+ startedAt: number().optional(),
10583
+ /** ms-epoch when the run reached a terminal state. */
10584
+ finishedAt: number().optional(),
10585
+ /** Compressed bytes of the staging archive written so far. */
10586
+ stagedBytes: number(),
10587
+ /** Final staged archive size, once the build phase completes. */
10588
+ archiveSizeBytes: number().optional(),
10589
+ /** Bytes pushed to the destination currently uploading. */
10590
+ uploadedBytes: number(),
10591
+ /** Destinations where the archive fully landed (uploaded + indexed). */
10592
+ completedDestinationIds: array(string()).readonly(),
10593
+ /** Destinations that failed during the fan-out. */
10594
+ failedDestinationIds: array(string()).readonly(),
10595
+ /** Failure message when `state === 'failed'`. */
10596
+ error: string().optional(),
10597
+ /**
10598
+ * 1-based place in the execution queue — 1 = runs next. Present only
10599
+ * while `state === 'queued'`. Stamped by the orchestrator from the
10600
+ * queue's OWN pending order, never derived from timestamps, so the
10601
+ * UI cannot show an order the executor will not honour.
10602
+ */
10603
+ queuePosition: number().int().min(1).optional()
10604
+ });
10605
+ /**
10606
+ * Result of `backup.trigger`. The call still resolves when the run
10607
+ * terminates (compat with schedule-driven runs and the admin UI), but
10608
+ * it now names the run and says whether it had to WAIT: a trigger that
10609
+ * arrives while another run is in flight is enqueued (or joined onto
10610
+ * an identical already-queued run), never started concurrently.
10611
+ */
10612
+ var BackupTriggerResultSchema = object({
10613
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
10614
+ runId: string(),
10615
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
10616
+ queued: boolean(),
10617
+ /** True when this trigger was coalesced onto an identical already-queued run. */
10618
+ joined: boolean(),
10619
+ /** True when the run was cancelled before completing every destination. */
10620
+ cancelled: boolean(),
10621
+ /** One entry per destination the archive landed at (partial on cancel). */
10622
+ entries: array(BackupEntrySchema).readonly()
10623
+ });
10541
10624
  /**
10542
10625
  * A backup schedule — the N:M "entry" that binds one cron cadence to a
10543
10626
  * SET of destination locations. Supersedes the per-location cron on
@@ -10585,7 +10668,10 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
10585
10668
  * retention (manual runs).
10586
10669
  */
10587
10670
  retentionCount: number().int().min(1).max(1e3).optional()
10588
- }).optional(), array(BackupEntrySchema).readonly(), {
10671
+ }).optional(), BackupTriggerResultSchema, {
10672
+ kind: "mutation",
10673
+ auth: "admin"
10674
+ }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
10589
10675
  kind: "mutation",
10590
10676
  auth: "admin"
10591
10677
  }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
@@ -11302,6 +11388,14 @@ method(object({
11302
11388
  }), object({ success: literal(true) }), {
11303
11389
  kind: "mutation",
11304
11390
  auth: "admin"
11391
+ }), method(object({ deviceId: number().int().nonnegative() }), object({
11392
+ derivedStreamsDeleted: array(string()).readonly(),
11393
+ assignmentsPurged: boolean(),
11394
+ probeSnapshotsDropped: number().int().nonnegative(),
11395
+ rtspTokenRowsDeleted: number().int().nonnegative()
11396
+ }), {
11397
+ kind: "mutation",
11398
+ auth: "admin"
11305
11399
  }), method(object({
11306
11400
  deviceId: number(),
11307
11401
  /** Absent = the LOWEST assigned profile — a notification attachment is
@@ -12746,6 +12840,35 @@ var deviceProviderCapability = {
12746
12840
  name: string(),
12747
12841
  type: string()
12748
12842
  }))),
12843
+ /**
12844
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12845
+ * touching no other device this provider owns.
12846
+ *
12847
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12848
+ * migrated numbers: after `swapIds` the runner's live instance still
12849
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12850
+ * registrations and its log tags), and a live object cannot be renumbered.
12851
+ * Before this method the only flush was restarting the whole owning addon
12852
+ * — which took every camera the provider owns down with it (28 devices
12853
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12854
+ * same day ~27 devices' native caps did not come back on their own).
12855
+ *
12856
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12857
+ * that changes. The reply carries the id the device answers on NOW.
12858
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12859
+ * instance (if any), then re-create from the persisted row: the same
12860
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12861
+ * An RPC, never an event: a dropped event would leave the runner writing
12862
+ * against the wrong camera (D8).
12863
+ *
12864
+ * Construction can dial hardware, and the migrated source is
12865
+ * characteristically dead — the timeout covers a full activate window
12866
+ * rather than the 60 s default.
12867
+ */
12868
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12869
+ kind: "mutation",
12870
+ timeoutMs: 3 * 6e4
12871
+ }),
12749
12872
  supportsDiscovery: method(object({}), boolean()),
12750
12873
  /**
12751
12874
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13073,7 +13196,8 @@ method(object({
13073
13196
  targetId: number()
13074
13197
  }), MigrateDeviceResultSchema, {
13075
13198
  kind: "mutation",
13076
- auth: "admin"
13199
+ auth: "admin",
13200
+ timeoutMs: 12 * 6e4
13077
13201
  }), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
13078
13202
  deviceId: number(),
13079
13203
  name: string()
@@ -32541,6 +32665,147 @@ var BaseDevice = class {
32541
32665
  }
32542
32666
  };
32543
32667
  /**
32668
+ * Delays before retry rounds 1..N — the round count IS the bound.
32669
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32670
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32671
+ * per attempt) covers a device-manager lock held for minutes — the
32672
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32673
+ */
32674
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32675
+ 1e4,
32676
+ 3e4,
32677
+ 9e4
32678
+ ];
32679
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32680
+ function sleep$1(ms, signal) {
32681
+ return new Promise((resolve) => {
32682
+ if (signal.aborted) {
32683
+ resolve();
32684
+ return;
32685
+ }
32686
+ const onAbort = () => {
32687
+ clearTimeout(timer);
32688
+ resolve();
32689
+ };
32690
+ const timer = setTimeout(() => {
32691
+ signal.removeEventListener("abort", onAbort);
32692
+ resolve();
32693
+ }, ms);
32694
+ timer.unref?.();
32695
+ signal.addEventListener("abort", onAbort, { once: true });
32696
+ });
32697
+ }
32698
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32699
+ * not reject (callers wrap their own try/catch). */
32700
+ async function runWithConcurrency(items, width, fn) {
32701
+ const queue = [...items];
32702
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32703
+ const lane = async () => {
32704
+ for (;;) {
32705
+ const item = queue.shift();
32706
+ if (item === void 0) return;
32707
+ await fn(item);
32708
+ }
32709
+ };
32710
+ await Promise.all(Array.from({ length: laneCount }, lane));
32711
+ }
32712
+ var DeviceRestoreRetryScheduler = class {
32713
+ #logger;
32714
+ #attempt;
32715
+ #onPermanentFailure;
32716
+ #delaysMs;
32717
+ #concurrency;
32718
+ #now;
32719
+ #abort = new AbortController();
32720
+ constructor(options) {
32721
+ this.#logger = options.logger;
32722
+ this.#attempt = options.attempt;
32723
+ this.#onPermanentFailure = options.onPermanentFailure;
32724
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32725
+ this.#concurrency = options.concurrency ?? 4;
32726
+ this.#now = options.now ?? Date.now;
32727
+ }
32728
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32729
+ * permanently failed — the next boot restores them from disk. */
32730
+ cancel() {
32731
+ this.#abort.abort();
32732
+ }
32733
+ /**
32734
+ * Run the bounded retry rounds. Resolves when every entry has either
32735
+ * restored, been marked permanently failed, or the scheduler was
32736
+ * cancelled. Never rejects.
32737
+ */
32738
+ async run(initialFailures) {
32739
+ let pending = initialFailures.map((failure) => ({
32740
+ saved: failure.saved,
32741
+ lastError: failure.error,
32742
+ attempts: 1
32743
+ }));
32744
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32745
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32746
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32747
+ if (this.#abort.signal.aborted) break;
32748
+ pending = await this.#runRound(pending, round);
32749
+ }
32750
+ if (this.#abort.signal.aborted) return [];
32751
+ const terminal = pending.map((entry) => ({
32752
+ deviceId: entry.saved.id,
32753
+ stableId: entry.saved.stableId,
32754
+ type: String(entry.saved.type),
32755
+ attempts: entry.attempts,
32756
+ lastError: entry.lastError,
32757
+ failedAt: this.#now()
32758
+ }));
32759
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32760
+ return terminal;
32761
+ }
32762
+ /** One retry round: parents first (phase 0), then hub-adopted
32763
+ * children (phase 1) — a child's attempt depends on its parent
32764
+ * having landed, exactly like the initial two-pass restore. */
32765
+ async #runRound(pending, round) {
32766
+ const next = [];
32767
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32768
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32769
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32770
+ if (this.#abort.signal.aborted) {
32771
+ next.push(entry);
32772
+ return;
32773
+ }
32774
+ const attemptNo = entry.attempts + 1;
32775
+ try {
32776
+ await this.#attempt(entry.saved);
32777
+ this.#logger.info("Device restored on retry", {
32778
+ tags: {
32779
+ deviceId: entry.saved.id,
32780
+ stableId: entry.saved.stableId
32781
+ },
32782
+ meta: { attempt: attemptNo }
32783
+ });
32784
+ } catch (err) {
32785
+ const lastError = err instanceof Error ? err.message : String(err);
32786
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32787
+ this.#logger.warn("Device restore retry failed", {
32788
+ tags: {
32789
+ deviceId: entry.saved.id,
32790
+ stableId: entry.saved.stableId
32791
+ },
32792
+ meta: {
32793
+ attempt: attemptNo,
32794
+ remainingRetries,
32795
+ error: lastError
32796
+ }
32797
+ });
32798
+ next.push({
32799
+ saved: entry.saved,
32800
+ lastError,
32801
+ attempts: attemptNo
32802
+ });
32803
+ }
32804
+ });
32805
+ return next;
32806
+ }
32807
+ };
32808
+ /**
32544
32809
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32545
32810
  * device-provider cap router. Shared across all providers.
32546
32811
  */
@@ -32589,6 +32854,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32589
32854
  }];
32590
32855
  }
32591
32856
  async onShutdown() {
32857
+ this.cancelRestoreRetries();
32592
32858
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32593
32859
  for (const device of devices) try {
32594
32860
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32606,9 +32872,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32606
32872
  async start() {}
32607
32873
  async stop() {}
32608
32874
  async getStatus() {
32875
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32876
+ const summary = this.restoreFailureSummary();
32877
+ if (summary === null) return {
32878
+ connected: true,
32879
+ deviceCount: all.length
32880
+ };
32609
32881
  return {
32610
32882
  connected: true,
32611
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32883
+ deviceCount: all.length,
32884
+ error: summary
32612
32885
  };
32613
32886
  }
32614
32887
  async getDevices() {
@@ -32698,8 +32971,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32698
32971
  };
32699
32972
  }
32700
32973
  async restoreDevices(savedDevices) {
32701
- await this.onRestoreDevices(savedDevices);
32702
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32974
+ const report = await this.onRestoreDevices(savedDevices);
32975
+ if (savedDevices.length === 0) return;
32976
+ if (report && report.failedCount > 0) {
32977
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32978
+ return;
32979
+ }
32980
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
32981
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
32982
+ }
32983
+ /** Retry schedule. Overridable (tests use millisecond delays). */
32984
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
32985
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
32986
+ * never re-stampede full-width while the initial pass does (D167). */
32987
+ restoreRetryConcurrency = 4;
32988
+ _restoreRetryScheduler = null;
32989
+ _restoreRetryCompletion = null;
32990
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
32991
+ /** Settles when the background retry rounds finish (or `null` when
32992
+ * nothing failed). Exposed for tests and subclass diagnostics —
32993
+ * boot NEVER awaits this: the runner's post-init handshake goes out
32994
+ * with the devices that restored, and a late success is announced
32995
+ * through the `native-cap-change` → `updateCaps` path. */
32996
+ get restoreRetryCompletion() {
32997
+ return this._restoreRetryCompletion;
32998
+ }
32999
+ /** Devices that exhausted the retry bound this process lifetime. */
33000
+ get permanentRestoreFailures() {
33001
+ return [...this._permanentRestoreFailures.values()];
33002
+ }
33003
+ /** One-line operator-facing summary for `getStatus().error`, or
33004
+ * `null` when every device restored. */
33005
+ restoreFailureSummary() {
33006
+ if (this._permanentRestoreFailures.size === 0) return null;
33007
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33008
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33009
+ }
33010
+ cancelRestoreRetries() {
33011
+ this._restoreRetryScheduler?.cancel();
33012
+ this._restoreRetryScheduler = null;
33013
+ }
33014
+ recordPermanentRestoreFailure(failure) {
33015
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33016
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33017
+ tags: {
33018
+ deviceId: failure.deviceId,
33019
+ stableId: failure.stableId
33020
+ },
33021
+ meta: {
33022
+ type: failure.type,
33023
+ attempts: failure.attempts,
33024
+ error: failure.lastError
33025
+ }
33026
+ });
33027
+ }
33028
+ scheduleRestoreRetries(failures, attempt) {
33029
+ const scheduler = new DeviceRestoreRetryScheduler({
33030
+ logger: this.ctx.logger,
33031
+ delaysMs: this.restoreRetryDelaysMs,
33032
+ concurrency: this.restoreRetryConcurrency,
33033
+ attempt,
33034
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33035
+ });
33036
+ this._restoreRetryScheduler = scheduler;
33037
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33038
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33039
+ });
33040
+ }
33041
+ /**
33042
+ * Tear down and reconstruct ONE device from its persisted rows — the
33043
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33044
+ * and no other device this provider owns is disturbed.
33045
+ *
33046
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33047
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33048
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33049
+ * whatever number the row carries NOW. The teardown is `decommission` —
33050
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33051
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33052
+ * the boot restore's own `create()` path, including its pass 2: first-class
33053
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33054
+ * parent by the cascade and must be re-created explicitly, because only
33055
+ * accessory children come back through `getAccessoryChildren()`.
33056
+ *
33057
+ * Reloading an accessory child directly is refused (no device class) —
33058
+ * reload its parent instead.
33059
+ */
33060
+ async reloadDevice(input) {
33061
+ const { stableId } = input;
33062
+ const devices = this.ctx.kernel.devices;
33063
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33064
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33065
+ if (live) await devices.decommission(live.id);
33066
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33067
+ addonId: this.addonId,
33068
+ stableId
33069
+ });
33070
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33071
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33072
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33073
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33074
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33075
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33076
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33077
+ for (const row of rows) {
33078
+ if (row.parentDeviceId !== id) continue;
33079
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33080
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33081
+ if (!ChildClass) continue;
33082
+ try {
33083
+ await devices.create(row.stableId, ChildClass, {}, id);
33084
+ } catch (err) {
33085
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33086
+ tags: {
33087
+ deviceId: row.id,
33088
+ stableId: row.stableId
33089
+ },
33090
+ meta: {
33091
+ parentDeviceId: id,
33092
+ error: err instanceof Error ? err.message : String(err)
33093
+ }
33094
+ });
33095
+ }
33096
+ }
33097
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33098
+ tags: { deviceId: id },
33099
+ meta: {
33100
+ stableId,
33101
+ type: meta.type
33102
+ }
33103
+ });
33104
+ return { deviceId: id };
32703
33105
  }
32704
33106
  /**
32705
33107
  * Restore devices from persisted state. Two-pass:
@@ -32725,55 +33127,108 @@ var BaseDeviceProvider = class extends BaseAddon {
32725
33127
  * accessory-spawn flow handles via the parent's
32726
33128
  * `getAccessoryChildren()`. Override only when the default doesn't
32727
33129
  * fit.
33130
+ *
33131
+ * A row that fails either pass is NOT terminal (D347): it is handed
33132
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33133
+ * Only after the bound is exhausted is the device marked permanently
33134
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33135
+ * `getStatus().error`.
32728
33136
  */
32729
33137
  async onRestoreDevices(savedDevices) {
32730
33138
  const restored = /* @__PURE__ */ new Set();
33139
+ const failures = [];
33140
+ const attemptRestore = async (saved) => {
33141
+ if (restored.has(saved.id)) return;
33142
+ const Class = this.deviceClasses[saved.type];
33143
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33144
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33145
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33146
+ restored.add(saved.id);
33147
+ };
32731
33148
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32732
33149
  const restoreOne = async (saved) => {
32733
- const Class = this.deviceClasses[saved.type];
32734
- if (!Class) {
33150
+ if (!this.deviceClasses[saved.type]) {
32735
33151
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32736
- tags: { stableId: saved.stableId },
33152
+ tags: {
33153
+ deviceId: saved.id,
33154
+ stableId: saved.stableId
33155
+ },
32737
33156
  meta: { type: saved.type }
32738
33157
  });
32739
33158
  return;
32740
33159
  }
32741
33160
  try {
32742
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32743
- restored.add(saved.id);
33161
+ await attemptRestore(saved);
32744
33162
  } catch (err) {
32745
- this.ctx.logger.warn("Failed to restore device", {
32746
- tags: { stableId: saved.stableId },
33163
+ const error = err instanceof Error ? err.message : String(err);
33164
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33165
+ tags: {
33166
+ deviceId: saved.id,
33167
+ stableId: saved.stableId
33168
+ },
32747
33169
  meta: {
32748
33170
  type: saved.type,
32749
- error: err instanceof Error ? err.message : String(err)
33171
+ attempt: 1,
33172
+ error
32750
33173
  }
32751
33174
  });
33175
+ failures.push({
33176
+ saved,
33177
+ error
33178
+ });
32752
33179
  }
32753
33180
  };
32754
33181
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33182
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32755
33183
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32756
33184
  for (const saved of childRows) {
32757
- const Class = this.deviceClasses[saved.type];
32758
- if (!Class) continue;
33185
+ if (!this.deviceClasses[saved.type]) continue;
32759
33186
  if (saved.parentDeviceId === null) continue;
32760
- if (!restored.has(saved.parentDeviceId)) continue;
32761
- try {
32762
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32763
- restored.add(saved.id);
32764
- } catch (err) {
32765
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33187
+ if (restored.has(saved.parentDeviceId)) {
33188
+ try {
33189
+ await attemptRestore(saved);
33190
+ } catch (err) {
33191
+ const error = err instanceof Error ? err.message : String(err);
33192
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33193
+ tags: {
33194
+ deviceId: saved.id,
33195
+ stableId: saved.stableId,
33196
+ parentDeviceId: saved.parentDeviceId
33197
+ },
33198
+ meta: {
33199
+ type: saved.type,
33200
+ attempt: 1,
33201
+ error
33202
+ }
33203
+ });
33204
+ failures.push({
33205
+ saved,
33206
+ error
33207
+ });
33208
+ }
33209
+ continue;
33210
+ }
33211
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33212
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32766
33213
  tags: {
33214
+ deviceId: saved.id,
32767
33215
  stableId: saved.stableId,
32768
33216
  parentDeviceId: saved.parentDeviceId
32769
33217
  },
32770
- meta: {
32771
- type: saved.type,
32772
- error: err instanceof Error ? err.message : String(err)
32773
- }
33218
+ meta: { type: saved.type }
33219
+ });
33220
+ failures.push({
33221
+ saved,
33222
+ error: `parent device ${saved.parentDeviceId} not restored`
32774
33223
  });
33224
+ continue;
32775
33225
  }
32776
33226
  }
33227
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33228
+ return {
33229
+ restoredCount: restored.size,
33230
+ failedCount: failures.length
33231
+ };
32777
33232
  }
32778
33233
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32779
33234
  toSummary(device) {
@@ -33282,6 +33737,12 @@ Object.freeze({
33282
33737
  addonId: null,
33283
33738
  access: "create"
33284
33739
  },
33740
+ "backup.cancel": {
33741
+ capName: "backup",
33742
+ capScope: "system",
33743
+ addonId: null,
33744
+ access: "create"
33745
+ },
33285
33746
  "backup.delete": {
33286
33747
  capName: "backup",
33287
33748
  capScope: "system",
@@ -33324,6 +33785,12 @@ Object.freeze({
33324
33785
  addonId: null,
33325
33786
  access: "view"
33326
33787
  },
33788
+ "backup.listRuns": {
33789
+ capName: "backup",
33790
+ capScope: "system",
33791
+ addonId: null,
33792
+ access: "view"
33793
+ },
33327
33794
  "backup.listSchedules": {
33328
33795
  capName: "backup",
33329
33796
  capScope: "system",
@@ -34524,6 +34991,12 @@ Object.freeze({
34524
34991
  addonId: null,
34525
34992
  access: "view"
34526
34993
  },
34994
+ "deviceProvider.reloadDevice": {
34995
+ capName: "device-provider",
34996
+ capScope: "system",
34997
+ addonId: null,
34998
+ access: "create"
34999
+ },
34527
35000
  "deviceProvider.start": {
34528
35001
  capName: "device-provider",
34529
35002
  capScope: "system",
@@ -37890,6 +38363,12 @@ Object.freeze({
37890
38363
  addonId: null,
37891
38364
  access: "create"
37892
38365
  },
38366
+ "streamBroker.forgetDeviceHardware": {
38367
+ capName: "stream-broker",
38368
+ capScope: "system",
38369
+ addonId: null,
38370
+ access: "delete"
38371
+ },
37893
38372
  "streamBroker.getAllRtspEntries": {
37894
38373
  capName: "stream-broker",
37895
38374
  capScope: "system",
@@ -40348,6 +40827,11 @@ Object.freeze({
40348
40827
  form: "single",
40349
40828
  optional: false
40350
40829
  }],
40830
+ "streamBroker.forgetDeviceHardware": [{
40831
+ name: "deviceId",
40832
+ form: "single",
40833
+ optional: false
40834
+ }],
40351
40835
  "streamBroker.getDeviceAudioMute": [{
40352
40836
  name: "deviceId",
40353
40837
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-wyze",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "description": "Wyze camera device-provider addon for CamStack — wraps the @apocaliss92/wyze-bridge-js P2P/DTLS client, feeding the stream-broker via the pull-rfc4571 lazy-publish path (a structural twin of addon-provider-reolink)",
5
5
  "keywords": [
6
6
  "camstack",