@camstack/addon-provider-rtsp 1.2.58 → 1.2.60

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
@@ -10583,6 +10583,89 @@ var LocationStatSchema = object({
10583
10583
  fileCount: number(),
10584
10584
  present: boolean()
10585
10585
  });
10586
+ /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
10587
+ var BackupRunStateSchema = _enum([
10588
+ "queued",
10589
+ "running",
10590
+ "succeeded",
10591
+ "failed",
10592
+ "cancelled"
10593
+ ]);
10594
+ /**
10595
+ * Where a running backup currently is. `queued` before it starts,
10596
+ * `building` while the tar.gz is being staged, `uploading` during the
10597
+ * per-destination fan-out, `done` once terminal.
10598
+ */
10599
+ var BackupRunPhaseSchema = _enum([
10600
+ "queued",
10601
+ "building",
10602
+ "uploading",
10603
+ "done"
10604
+ ]);
10605
+ /**
10606
+ * Observable state of one backup run — readable WHILE it runs via
10607
+ * `backup.listRuns`. This is what makes the execution queue and
10608
+ * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
10609
+ * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
10610
+ * diagnosable with `du` because nothing reported that runs existed or
10611
+ * how large the staged archive had grown.
10612
+ */
10613
+ var BackupRunSchema = object({
10614
+ /** Stable run id — the handle `backup.cancel` takes. */
10615
+ id: string(),
10616
+ state: BackupRunStateSchema,
10617
+ phase: BackupRunPhaseSchema,
10618
+ /**
10619
+ * Resolved destination location ids. Empty while queued (targets are
10620
+ * resolved when the run starts, against the then-current policies).
10621
+ */
10622
+ destinationIds: array(string()).readonly(),
10623
+ label: string().optional(),
10624
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
10625
+ requestedAt: number(),
10626
+ /** ms-epoch when the run left the queue and started building. */
10627
+ startedAt: number().optional(),
10628
+ /** ms-epoch when the run reached a terminal state. */
10629
+ finishedAt: number().optional(),
10630
+ /** Compressed bytes of the staging archive written so far. */
10631
+ stagedBytes: number(),
10632
+ /** Final staged archive size, once the build phase completes. */
10633
+ archiveSizeBytes: number().optional(),
10634
+ /** Bytes pushed to the destination currently uploading. */
10635
+ uploadedBytes: number(),
10636
+ /** Destinations where the archive fully landed (uploaded + indexed). */
10637
+ completedDestinationIds: array(string()).readonly(),
10638
+ /** Destinations that failed during the fan-out. */
10639
+ failedDestinationIds: array(string()).readonly(),
10640
+ /** Failure message when `state === 'failed'`. */
10641
+ error: string().optional(),
10642
+ /**
10643
+ * 1-based place in the execution queue — 1 = runs next. Present only
10644
+ * while `state === 'queued'`. Stamped by the orchestrator from the
10645
+ * queue's OWN pending order, never derived from timestamps, so the
10646
+ * UI cannot show an order the executor will not honour.
10647
+ */
10648
+ queuePosition: number().int().min(1).optional()
10649
+ });
10650
+ /**
10651
+ * Result of `backup.trigger`. The call still resolves when the run
10652
+ * terminates (compat with schedule-driven runs and the admin UI), but
10653
+ * it now names the run and says whether it had to WAIT: a trigger that
10654
+ * arrives while another run is in flight is enqueued (or joined onto
10655
+ * an identical already-queued run), never started concurrently.
10656
+ */
10657
+ var BackupTriggerResultSchema = object({
10658
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
10659
+ runId: string(),
10660
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
10661
+ queued: boolean(),
10662
+ /** True when this trigger was coalesced onto an identical already-queued run. */
10663
+ joined: boolean(),
10664
+ /** True when the run was cancelled before completing every destination. */
10665
+ cancelled: boolean(),
10666
+ /** One entry per destination the archive landed at (partial on cancel). */
10667
+ entries: array(BackupEntrySchema).readonly()
10668
+ });
10586
10669
  /**
10587
10670
  * A backup schedule — the N:M "entry" that binds one cron cadence to a
10588
10671
  * SET of destination locations. Supersedes the per-location cron on
@@ -10630,7 +10713,10 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
10630
10713
  * retention (manual runs).
10631
10714
  */
10632
10715
  retentionCount: number().int().min(1).max(1e3).optional()
10633
- }).optional(), array(BackupEntrySchema).readonly(), {
10716
+ }).optional(), BackupTriggerResultSchema, {
10717
+ kind: "mutation",
10718
+ auth: "admin"
10719
+ }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
10634
10720
  kind: "mutation",
10635
10721
  auth: "admin"
10636
10722
  }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
@@ -11347,6 +11433,14 @@ method(object({
11347
11433
  }), object({ success: literal(true) }), {
11348
11434
  kind: "mutation",
11349
11435
  auth: "admin"
11436
+ }), method(object({ deviceId: number().int().nonnegative() }), object({
11437
+ derivedStreamsDeleted: array(string()).readonly(),
11438
+ assignmentsPurged: boolean(),
11439
+ probeSnapshotsDropped: number().int().nonnegative(),
11440
+ rtspTokenRowsDeleted: number().int().nonnegative()
11441
+ }), {
11442
+ kind: "mutation",
11443
+ auth: "admin"
11350
11444
  }), method(object({
11351
11445
  deviceId: number(),
11352
11446
  /** Absent = the LOWEST assigned profile — a notification attachment is
@@ -12774,6 +12868,35 @@ var deviceProviderCapability = {
12774
12868
  name: string(),
12775
12869
  type: string()
12776
12870
  }))),
12871
+ /**
12872
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12873
+ * touching no other device this provider owns.
12874
+ *
12875
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12876
+ * migrated numbers: after `swapIds` the runner's live instance still
12877
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12878
+ * registrations and its log tags), and a live object cannot be renumbered.
12879
+ * Before this method the only flush was restarting the whole owning addon
12880
+ * — which took every camera the provider owns down with it (28 devices
12881
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12882
+ * same day ~27 devices' native caps did not come back on their own).
12883
+ *
12884
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12885
+ * that changes. The reply carries the id the device answers on NOW.
12886
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12887
+ * instance (if any), then re-create from the persisted row: the same
12888
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12889
+ * An RPC, never an event: a dropped event would leave the runner writing
12890
+ * against the wrong camera (D8).
12891
+ *
12892
+ * Construction can dial hardware, and the migrated source is
12893
+ * characteristically dead — the timeout covers a full activate window
12894
+ * rather than the 60 s default.
12895
+ */
12896
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12897
+ kind: "mutation",
12898
+ timeoutMs: 3 * 6e4
12899
+ }),
12777
12900
  supportsDiscovery: method(object({}), boolean()),
12778
12901
  /**
12779
12902
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13101,7 +13224,8 @@ method(object({
13101
13224
  targetId: number()
13102
13225
  }), MigrateDeviceResultSchema, {
13103
13226
  kind: "mutation",
13104
- auth: "admin"
13227
+ auth: "admin",
13228
+ timeoutMs: 12 * 6e4
13105
13229
  }), 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({
13106
13230
  deviceId: number(),
13107
13231
  name: string()
@@ -32569,6 +32693,147 @@ var BaseDevice = class {
32569
32693
  }
32570
32694
  };
32571
32695
  /**
32696
+ * Delays before retry rounds 1..N — the round count IS the bound.
32697
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32698
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32699
+ * per attempt) covers a device-manager lock held for minutes — the
32700
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32701
+ */
32702
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32703
+ 1e4,
32704
+ 3e4,
32705
+ 9e4
32706
+ ];
32707
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32708
+ function sleep$1(ms, signal) {
32709
+ return new Promise((resolve) => {
32710
+ if (signal.aborted) {
32711
+ resolve();
32712
+ return;
32713
+ }
32714
+ const onAbort = () => {
32715
+ clearTimeout(timer);
32716
+ resolve();
32717
+ };
32718
+ const timer = setTimeout(() => {
32719
+ signal.removeEventListener("abort", onAbort);
32720
+ resolve();
32721
+ }, ms);
32722
+ timer.unref?.();
32723
+ signal.addEventListener("abort", onAbort, { once: true });
32724
+ });
32725
+ }
32726
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32727
+ * not reject (callers wrap their own try/catch). */
32728
+ async function runWithConcurrency(items, width, fn) {
32729
+ const queue = [...items];
32730
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32731
+ const lane = async () => {
32732
+ for (;;) {
32733
+ const item = queue.shift();
32734
+ if (item === void 0) return;
32735
+ await fn(item);
32736
+ }
32737
+ };
32738
+ await Promise.all(Array.from({ length: laneCount }, lane));
32739
+ }
32740
+ var DeviceRestoreRetryScheduler = class {
32741
+ #logger;
32742
+ #attempt;
32743
+ #onPermanentFailure;
32744
+ #delaysMs;
32745
+ #concurrency;
32746
+ #now;
32747
+ #abort = new AbortController();
32748
+ constructor(options) {
32749
+ this.#logger = options.logger;
32750
+ this.#attempt = options.attempt;
32751
+ this.#onPermanentFailure = options.onPermanentFailure;
32752
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32753
+ this.#concurrency = options.concurrency ?? 4;
32754
+ this.#now = options.now ?? Date.now;
32755
+ }
32756
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32757
+ * permanently failed — the next boot restores them from disk. */
32758
+ cancel() {
32759
+ this.#abort.abort();
32760
+ }
32761
+ /**
32762
+ * Run the bounded retry rounds. Resolves when every entry has either
32763
+ * restored, been marked permanently failed, or the scheduler was
32764
+ * cancelled. Never rejects.
32765
+ */
32766
+ async run(initialFailures) {
32767
+ let pending = initialFailures.map((failure) => ({
32768
+ saved: failure.saved,
32769
+ lastError: failure.error,
32770
+ attempts: 1
32771
+ }));
32772
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32773
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32774
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32775
+ if (this.#abort.signal.aborted) break;
32776
+ pending = await this.#runRound(pending, round);
32777
+ }
32778
+ if (this.#abort.signal.aborted) return [];
32779
+ const terminal = pending.map((entry) => ({
32780
+ deviceId: entry.saved.id,
32781
+ stableId: entry.saved.stableId,
32782
+ type: String(entry.saved.type),
32783
+ attempts: entry.attempts,
32784
+ lastError: entry.lastError,
32785
+ failedAt: this.#now()
32786
+ }));
32787
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32788
+ return terminal;
32789
+ }
32790
+ /** One retry round: parents first (phase 0), then hub-adopted
32791
+ * children (phase 1) — a child's attempt depends on its parent
32792
+ * having landed, exactly like the initial two-pass restore. */
32793
+ async #runRound(pending, round) {
32794
+ const next = [];
32795
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32796
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32797
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32798
+ if (this.#abort.signal.aborted) {
32799
+ next.push(entry);
32800
+ return;
32801
+ }
32802
+ const attemptNo = entry.attempts + 1;
32803
+ try {
32804
+ await this.#attempt(entry.saved);
32805
+ this.#logger.info("Device restored on retry", {
32806
+ tags: {
32807
+ deviceId: entry.saved.id,
32808
+ stableId: entry.saved.stableId
32809
+ },
32810
+ meta: { attempt: attemptNo }
32811
+ });
32812
+ } catch (err) {
32813
+ const lastError = err instanceof Error ? err.message : String(err);
32814
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32815
+ this.#logger.warn("Device restore retry failed", {
32816
+ tags: {
32817
+ deviceId: entry.saved.id,
32818
+ stableId: entry.saved.stableId
32819
+ },
32820
+ meta: {
32821
+ attempt: attemptNo,
32822
+ remainingRetries,
32823
+ error: lastError
32824
+ }
32825
+ });
32826
+ next.push({
32827
+ saved: entry.saved,
32828
+ lastError,
32829
+ attempts: attemptNo
32830
+ });
32831
+ }
32832
+ });
32833
+ return next;
32834
+ }
32835
+ };
32836
+ /**
32572
32837
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32573
32838
  * device-provider cap router. Shared across all providers.
32574
32839
  */
@@ -32617,6 +32882,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32617
32882
  }];
32618
32883
  }
32619
32884
  async onShutdown() {
32885
+ this.cancelRestoreRetries();
32620
32886
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32621
32887
  for (const device of devices) try {
32622
32888
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32634,9 +32900,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32634
32900
  async start() {}
32635
32901
  async stop() {}
32636
32902
  async getStatus() {
32903
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32904
+ const summary = this.restoreFailureSummary();
32905
+ if (summary === null) return {
32906
+ connected: true,
32907
+ deviceCount: all.length
32908
+ };
32637
32909
  return {
32638
32910
  connected: true,
32639
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32911
+ deviceCount: all.length,
32912
+ error: summary
32640
32913
  };
32641
32914
  }
32642
32915
  async getDevices() {
@@ -32726,8 +32999,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32726
32999
  };
32727
33000
  }
32728
33001
  async restoreDevices(savedDevices) {
32729
- await this.onRestoreDevices(savedDevices);
32730
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
33002
+ const report = await this.onRestoreDevices(savedDevices);
33003
+ if (savedDevices.length === 0) return;
33004
+ if (report && report.failedCount > 0) {
33005
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
33006
+ return;
33007
+ }
33008
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
33009
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
33010
+ }
33011
+ /** Retry schedule. Overridable (tests use millisecond delays). */
33012
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
33013
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
33014
+ * never re-stampede full-width while the initial pass does (D167). */
33015
+ restoreRetryConcurrency = 4;
33016
+ _restoreRetryScheduler = null;
33017
+ _restoreRetryCompletion = null;
33018
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
33019
+ /** Settles when the background retry rounds finish (or `null` when
33020
+ * nothing failed). Exposed for tests and subclass diagnostics —
33021
+ * boot NEVER awaits this: the runner's post-init handshake goes out
33022
+ * with the devices that restored, and a late success is announced
33023
+ * through the `native-cap-change` → `updateCaps` path. */
33024
+ get restoreRetryCompletion() {
33025
+ return this._restoreRetryCompletion;
33026
+ }
33027
+ /** Devices that exhausted the retry bound this process lifetime. */
33028
+ get permanentRestoreFailures() {
33029
+ return [...this._permanentRestoreFailures.values()];
33030
+ }
33031
+ /** One-line operator-facing summary for `getStatus().error`, or
33032
+ * `null` when every device restored. */
33033
+ restoreFailureSummary() {
33034
+ if (this._permanentRestoreFailures.size === 0) return null;
33035
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33036
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33037
+ }
33038
+ cancelRestoreRetries() {
33039
+ this._restoreRetryScheduler?.cancel();
33040
+ this._restoreRetryScheduler = null;
33041
+ }
33042
+ recordPermanentRestoreFailure(failure) {
33043
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33044
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33045
+ tags: {
33046
+ deviceId: failure.deviceId,
33047
+ stableId: failure.stableId
33048
+ },
33049
+ meta: {
33050
+ type: failure.type,
33051
+ attempts: failure.attempts,
33052
+ error: failure.lastError
33053
+ }
33054
+ });
33055
+ }
33056
+ scheduleRestoreRetries(failures, attempt) {
33057
+ const scheduler = new DeviceRestoreRetryScheduler({
33058
+ logger: this.ctx.logger,
33059
+ delaysMs: this.restoreRetryDelaysMs,
33060
+ concurrency: this.restoreRetryConcurrency,
33061
+ attempt,
33062
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33063
+ });
33064
+ this._restoreRetryScheduler = scheduler;
33065
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33066
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33067
+ });
33068
+ }
33069
+ /**
33070
+ * Tear down and reconstruct ONE device from its persisted rows — the
33071
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33072
+ * and no other device this provider owns is disturbed.
33073
+ *
33074
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33075
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33076
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33077
+ * whatever number the row carries NOW. The teardown is `decommission` —
33078
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33079
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33080
+ * the boot restore's own `create()` path, including its pass 2: first-class
33081
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33082
+ * parent by the cascade and must be re-created explicitly, because only
33083
+ * accessory children come back through `getAccessoryChildren()`.
33084
+ *
33085
+ * Reloading an accessory child directly is refused (no device class) —
33086
+ * reload its parent instead.
33087
+ */
33088
+ async reloadDevice(input) {
33089
+ const { stableId } = input;
33090
+ const devices = this.ctx.kernel.devices;
33091
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33092
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33093
+ if (live) await devices.decommission(live.id);
33094
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33095
+ addonId: this.addonId,
33096
+ stableId
33097
+ });
33098
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33099
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33100
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33101
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33102
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33103
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33104
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33105
+ for (const row of rows) {
33106
+ if (row.parentDeviceId !== id) continue;
33107
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33108
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33109
+ if (!ChildClass) continue;
33110
+ try {
33111
+ await devices.create(row.stableId, ChildClass, {}, id);
33112
+ } catch (err) {
33113
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33114
+ tags: {
33115
+ deviceId: row.id,
33116
+ stableId: row.stableId
33117
+ },
33118
+ meta: {
33119
+ parentDeviceId: id,
33120
+ error: err instanceof Error ? err.message : String(err)
33121
+ }
33122
+ });
33123
+ }
33124
+ }
33125
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33126
+ tags: { deviceId: id },
33127
+ meta: {
33128
+ stableId,
33129
+ type: meta.type
33130
+ }
33131
+ });
33132
+ return { deviceId: id };
32731
33133
  }
32732
33134
  /**
32733
33135
  * Restore devices from persisted state. Two-pass:
@@ -32753,55 +33155,108 @@ var BaseDeviceProvider = class extends BaseAddon {
32753
33155
  * accessory-spawn flow handles via the parent's
32754
33156
  * `getAccessoryChildren()`. Override only when the default doesn't
32755
33157
  * fit.
33158
+ *
33159
+ * A row that fails either pass is NOT terminal (D347): it is handed
33160
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33161
+ * Only after the bound is exhausted is the device marked permanently
33162
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33163
+ * `getStatus().error`.
32756
33164
  */
32757
33165
  async onRestoreDevices(savedDevices) {
32758
33166
  const restored = /* @__PURE__ */ new Set();
33167
+ const failures = [];
33168
+ const attemptRestore = async (saved) => {
33169
+ if (restored.has(saved.id)) return;
33170
+ const Class = this.deviceClasses[saved.type];
33171
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33172
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33173
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33174
+ restored.add(saved.id);
33175
+ };
32759
33176
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32760
33177
  const restoreOne = async (saved) => {
32761
- const Class = this.deviceClasses[saved.type];
32762
- if (!Class) {
33178
+ if (!this.deviceClasses[saved.type]) {
32763
33179
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32764
- tags: { stableId: saved.stableId },
33180
+ tags: {
33181
+ deviceId: saved.id,
33182
+ stableId: saved.stableId
33183
+ },
32765
33184
  meta: { type: saved.type }
32766
33185
  });
32767
33186
  return;
32768
33187
  }
32769
33188
  try {
32770
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32771
- restored.add(saved.id);
33189
+ await attemptRestore(saved);
32772
33190
  } catch (err) {
32773
- this.ctx.logger.warn("Failed to restore device", {
32774
- tags: { stableId: saved.stableId },
33191
+ const error = err instanceof Error ? err.message : String(err);
33192
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33193
+ tags: {
33194
+ deviceId: saved.id,
33195
+ stableId: saved.stableId
33196
+ },
32775
33197
  meta: {
32776
33198
  type: saved.type,
32777
- error: err instanceof Error ? err.message : String(err)
33199
+ attempt: 1,
33200
+ error
32778
33201
  }
32779
33202
  });
33203
+ failures.push({
33204
+ saved,
33205
+ error
33206
+ });
32780
33207
  }
32781
33208
  };
32782
33209
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33210
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32783
33211
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32784
33212
  for (const saved of childRows) {
32785
- const Class = this.deviceClasses[saved.type];
32786
- if (!Class) continue;
33213
+ if (!this.deviceClasses[saved.type]) continue;
32787
33214
  if (saved.parentDeviceId === null) continue;
32788
- if (!restored.has(saved.parentDeviceId)) continue;
32789
- try {
32790
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32791
- restored.add(saved.id);
32792
- } catch (err) {
32793
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33215
+ if (restored.has(saved.parentDeviceId)) {
33216
+ try {
33217
+ await attemptRestore(saved);
33218
+ } catch (err) {
33219
+ const error = err instanceof Error ? err.message : String(err);
33220
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33221
+ tags: {
33222
+ deviceId: saved.id,
33223
+ stableId: saved.stableId,
33224
+ parentDeviceId: saved.parentDeviceId
33225
+ },
33226
+ meta: {
33227
+ type: saved.type,
33228
+ attempt: 1,
33229
+ error
33230
+ }
33231
+ });
33232
+ failures.push({
33233
+ saved,
33234
+ error
33235
+ });
33236
+ }
33237
+ continue;
33238
+ }
33239
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33240
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32794
33241
  tags: {
33242
+ deviceId: saved.id,
32795
33243
  stableId: saved.stableId,
32796
33244
  parentDeviceId: saved.parentDeviceId
32797
33245
  },
32798
- meta: {
32799
- type: saved.type,
32800
- error: err instanceof Error ? err.message : String(err)
32801
- }
33246
+ meta: { type: saved.type }
33247
+ });
33248
+ failures.push({
33249
+ saved,
33250
+ error: `parent device ${saved.parentDeviceId} not restored`
32802
33251
  });
33252
+ continue;
32803
33253
  }
32804
33254
  }
33255
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33256
+ return {
33257
+ restoredCount: restored.size,
33258
+ failedCount: failures.length
33259
+ };
32805
33260
  }
32806
33261
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32807
33262
  toSummary(device) {
@@ -33376,6 +33831,12 @@ Object.freeze({
33376
33831
  addonId: null,
33377
33832
  access: "create"
33378
33833
  },
33834
+ "backup.cancel": {
33835
+ capName: "backup",
33836
+ capScope: "system",
33837
+ addonId: null,
33838
+ access: "create"
33839
+ },
33379
33840
  "backup.delete": {
33380
33841
  capName: "backup",
33381
33842
  capScope: "system",
@@ -33418,6 +33879,12 @@ Object.freeze({
33418
33879
  addonId: null,
33419
33880
  access: "view"
33420
33881
  },
33882
+ "backup.listRuns": {
33883
+ capName: "backup",
33884
+ capScope: "system",
33885
+ addonId: null,
33886
+ access: "view"
33887
+ },
33421
33888
  "backup.listSchedules": {
33422
33889
  capName: "backup",
33423
33890
  capScope: "system",
@@ -34618,6 +35085,12 @@ Object.freeze({
34618
35085
  addonId: null,
34619
35086
  access: "view"
34620
35087
  },
35088
+ "deviceProvider.reloadDevice": {
35089
+ capName: "device-provider",
35090
+ capScope: "system",
35091
+ addonId: null,
35092
+ access: "create"
35093
+ },
34621
35094
  "deviceProvider.start": {
34622
35095
  capName: "device-provider",
34623
35096
  capScope: "system",
@@ -37984,6 +38457,12 @@ Object.freeze({
37984
38457
  addonId: null,
37985
38458
  access: "create"
37986
38459
  },
38460
+ "streamBroker.forgetDeviceHardware": {
38461
+ capName: "stream-broker",
38462
+ capScope: "system",
38463
+ addonId: null,
38464
+ access: "delete"
38465
+ },
37987
38466
  "streamBroker.getAllRtspEntries": {
37988
38467
  capName: "stream-broker",
37989
38468
  capScope: "system",
@@ -40442,6 +40921,11 @@ Object.freeze({
40442
40921
  form: "single",
40443
40922
  optional: false
40444
40923
  }],
40924
+ "streamBroker.forgetDeviceHardware": [{
40925
+ name: "deviceId",
40926
+ form: "single",
40927
+ optional: false
40928
+ }],
40445
40929
  "streamBroker.getDeviceAudioMute": [{
40446
40930
  name: "deviceId",
40447
40931
  form: "single",
package/dist/addon.mjs 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
@@ -12750,6 +12844,35 @@ var deviceProviderCapability = {
12750
12844
  name: string(),
12751
12845
  type: string()
12752
12846
  }))),
12847
+ /**
12848
+ * Tear down and reconstruct ONE device in place from its persisted rows —
12849
+ * touching no other device this provider owns.
12850
+ *
12851
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
12852
+ * migrated numbers: after `swapIds` the runner's live instance still
12853
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
12854
+ * registrations and its log tags), and a live object cannot be renumbered.
12855
+ * Before this method the only flush was restarting the whole owning addon
12856
+ * — which took every camera the provider owns down with it (28 devices
12857
+ * for one migrated camera, measured 2026-09-04, and the morning of the
12858
+ * same day ~27 devices' native caps did not come back on their own).
12859
+ *
12860
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
12861
+ * that changes. The reply carries the id the device answers on NOW.
12862
+ * Implemented once in `BaseDeviceProvider` — decommission the live
12863
+ * instance (if any), then re-create from the persisted row: the same
12864
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
12865
+ * An RPC, never an event: a dropped event would leave the runner writing
12866
+ * against the wrong camera (D8).
12867
+ *
12868
+ * Construction can dial hardware, and the migrated source is
12869
+ * characteristically dead — the timeout covers a full activate window
12870
+ * rather than the 60 s default.
12871
+ */
12872
+ reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
12873
+ kind: "mutation",
12874
+ timeoutMs: 3 * 6e4
12875
+ }),
12753
12876
  supportsDiscovery: method(object({}), boolean()),
12754
12877
  /**
12755
12878
  * Run a network scan. `params` carries optional provider-specific scan
@@ -13077,7 +13200,8 @@ method(object({
13077
13200
  targetId: number()
13078
13201
  }), MigrateDeviceResultSchema, {
13079
13202
  kind: "mutation",
13080
- auth: "admin"
13203
+ auth: "admin",
13204
+ timeoutMs: 12 * 6e4
13081
13205
  }), 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({
13082
13206
  deviceId: number(),
13083
13207
  name: string()
@@ -32545,6 +32669,147 @@ var BaseDevice = class {
32545
32669
  }
32546
32670
  };
32547
32671
  /**
32672
+ * Delays before retry rounds 1..N — the round count IS the bound.
32673
+ * 10 s catches "the hub was busy for a moment"; the full schedule
32674
+ * (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
32675
+ * per attempt) covers a device-manager lock held for minutes — the
32676
+ * 2026-09-04 outage's migration hold was ~3.5 min.
32677
+ */
32678
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
32679
+ 1e4,
32680
+ 3e4,
32681
+ 9e4
32682
+ ];
32683
+ /** Abortable sleep — resolves early (never rejects) on abort. */
32684
+ function sleep$1(ms, signal) {
32685
+ return new Promise((resolve) => {
32686
+ if (signal.aborted) {
32687
+ resolve();
32688
+ return;
32689
+ }
32690
+ const onAbort = () => {
32691
+ clearTimeout(timer);
32692
+ resolve();
32693
+ };
32694
+ const timer = setTimeout(() => {
32695
+ signal.removeEventListener("abort", onAbort);
32696
+ resolve();
32697
+ }, ms);
32698
+ timer.unref?.();
32699
+ signal.addEventListener("abort", onAbort, { once: true });
32700
+ });
32701
+ }
32702
+ /** Drain `items` through at most `width` concurrent lanes. `fn` must
32703
+ * not reject (callers wrap their own try/catch). */
32704
+ async function runWithConcurrency(items, width, fn) {
32705
+ const queue = [...items];
32706
+ const laneCount = Math.max(1, Math.min(width, queue.length));
32707
+ const lane = async () => {
32708
+ for (;;) {
32709
+ const item = queue.shift();
32710
+ if (item === void 0) return;
32711
+ await fn(item);
32712
+ }
32713
+ };
32714
+ await Promise.all(Array.from({ length: laneCount }, lane));
32715
+ }
32716
+ var DeviceRestoreRetryScheduler = class {
32717
+ #logger;
32718
+ #attempt;
32719
+ #onPermanentFailure;
32720
+ #delaysMs;
32721
+ #concurrency;
32722
+ #now;
32723
+ #abort = new AbortController();
32724
+ constructor(options) {
32725
+ this.#logger = options.logger;
32726
+ this.#attempt = options.attempt;
32727
+ this.#onPermanentFailure = options.onPermanentFailure;
32728
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
32729
+ this.#concurrency = options.concurrency ?? 4;
32730
+ this.#now = options.now ?? Date.now;
32731
+ }
32732
+ /** Stop retrying (shutdown). Pending entries are NOT marked
32733
+ * permanently failed — the next boot restores them from disk. */
32734
+ cancel() {
32735
+ this.#abort.abort();
32736
+ }
32737
+ /**
32738
+ * Run the bounded retry rounds. Resolves when every entry has either
32739
+ * restored, been marked permanently failed, or the scheduler was
32740
+ * cancelled. Never rejects.
32741
+ */
32742
+ async run(initialFailures) {
32743
+ let pending = initialFailures.map((failure) => ({
32744
+ saved: failure.saved,
32745
+ lastError: failure.error,
32746
+ attempts: 1
32747
+ }));
32748
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
32749
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
32750
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
32751
+ if (this.#abort.signal.aborted) break;
32752
+ pending = await this.#runRound(pending, round);
32753
+ }
32754
+ if (this.#abort.signal.aborted) return [];
32755
+ const terminal = pending.map((entry) => ({
32756
+ deviceId: entry.saved.id,
32757
+ stableId: entry.saved.stableId,
32758
+ type: String(entry.saved.type),
32759
+ attempts: entry.attempts,
32760
+ lastError: entry.lastError,
32761
+ failedAt: this.#now()
32762
+ }));
32763
+ for (const failure of terminal) this.#onPermanentFailure(failure);
32764
+ return terminal;
32765
+ }
32766
+ /** One retry round: parents first (phase 0), then hub-adopted
32767
+ * children (phase 1) — a child's attempt depends on its parent
32768
+ * having landed, exactly like the initial two-pass restore. */
32769
+ async #runRound(pending, round) {
32770
+ const next = [];
32771
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
32772
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
32773
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
32774
+ if (this.#abort.signal.aborted) {
32775
+ next.push(entry);
32776
+ return;
32777
+ }
32778
+ const attemptNo = entry.attempts + 1;
32779
+ try {
32780
+ await this.#attempt(entry.saved);
32781
+ this.#logger.info("Device restored on retry", {
32782
+ tags: {
32783
+ deviceId: entry.saved.id,
32784
+ stableId: entry.saved.stableId
32785
+ },
32786
+ meta: { attempt: attemptNo }
32787
+ });
32788
+ } catch (err) {
32789
+ const lastError = err instanceof Error ? err.message : String(err);
32790
+ const remainingRetries = this.#delaysMs.length - (round + 1);
32791
+ this.#logger.warn("Device restore retry failed", {
32792
+ tags: {
32793
+ deviceId: entry.saved.id,
32794
+ stableId: entry.saved.stableId
32795
+ },
32796
+ meta: {
32797
+ attempt: attemptNo,
32798
+ remainingRetries,
32799
+ error: lastError
32800
+ }
32801
+ });
32802
+ next.push({
32803
+ saved: entry.saved,
32804
+ lastError,
32805
+ attempts: attemptNo
32806
+ });
32807
+ }
32808
+ });
32809
+ return next;
32810
+ }
32811
+ };
32812
+ /**
32548
32813
  * Convert an IDevice to the flat DeviceSummary shape expected by the
32549
32814
  * device-provider cap router. Shared across all providers.
32550
32815
  */
@@ -32593,6 +32858,7 @@ var BaseDeviceProvider = class extends BaseAddon {
32593
32858
  }];
32594
32859
  }
32595
32860
  async onShutdown() {
32861
+ this.cancelRestoreRetries();
32596
32862
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
32597
32863
  for (const device of devices) try {
32598
32864
  await this.ctx.kernel.devices?.decommission(device.id);
@@ -32610,9 +32876,16 @@ var BaseDeviceProvider = class extends BaseAddon {
32610
32876
  async start() {}
32611
32877
  async stop() {}
32612
32878
  async getStatus() {
32879
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
32880
+ const summary = this.restoreFailureSummary();
32881
+ if (summary === null) return {
32882
+ connected: true,
32883
+ deviceCount: all.length
32884
+ };
32613
32885
  return {
32614
32886
  connected: true,
32615
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
32887
+ deviceCount: all.length,
32888
+ error: summary
32616
32889
  };
32617
32890
  }
32618
32891
  async getDevices() {
@@ -32702,8 +32975,137 @@ var BaseDeviceProvider = class extends BaseAddon {
32702
32975
  };
32703
32976
  }
32704
32977
  async restoreDevices(savedDevices) {
32705
- await this.onRestoreDevices(savedDevices);
32706
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
32978
+ const report = await this.onRestoreDevices(savedDevices);
32979
+ if (savedDevices.length === 0) return;
32980
+ if (report && report.failedCount > 0) {
32981
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
32982
+ return;
32983
+ }
32984
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
32985
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
32986
+ }
32987
+ /** Retry schedule. Overridable (tests use millisecond delays). */
32988
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
32989
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
32990
+ * never re-stampede full-width while the initial pass does (D167). */
32991
+ restoreRetryConcurrency = 4;
32992
+ _restoreRetryScheduler = null;
32993
+ _restoreRetryCompletion = null;
32994
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
32995
+ /** Settles when the background retry rounds finish (or `null` when
32996
+ * nothing failed). Exposed for tests and subclass diagnostics —
32997
+ * boot NEVER awaits this: the runner's post-init handshake goes out
32998
+ * with the devices that restored, and a late success is announced
32999
+ * through the `native-cap-change` → `updateCaps` path. */
33000
+ get restoreRetryCompletion() {
33001
+ return this._restoreRetryCompletion;
33002
+ }
33003
+ /** Devices that exhausted the retry bound this process lifetime. */
33004
+ get permanentRestoreFailures() {
33005
+ return [...this._permanentRestoreFailures.values()];
33006
+ }
33007
+ /** One-line operator-facing summary for `getStatus().error`, or
33008
+ * `null` when every device restored. */
33009
+ restoreFailureSummary() {
33010
+ if (this._permanentRestoreFailures.size === 0) return null;
33011
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
33012
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
33013
+ }
33014
+ cancelRestoreRetries() {
33015
+ this._restoreRetryScheduler?.cancel();
33016
+ this._restoreRetryScheduler = null;
33017
+ }
33018
+ recordPermanentRestoreFailure(failure) {
33019
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
33020
+ this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
33021
+ tags: {
33022
+ deviceId: failure.deviceId,
33023
+ stableId: failure.stableId
33024
+ },
33025
+ meta: {
33026
+ type: failure.type,
33027
+ attempts: failure.attempts,
33028
+ error: failure.lastError
33029
+ }
33030
+ });
33031
+ }
33032
+ scheduleRestoreRetries(failures, attempt) {
33033
+ const scheduler = new DeviceRestoreRetryScheduler({
33034
+ logger: this.ctx.logger,
33035
+ delaysMs: this.restoreRetryDelaysMs,
33036
+ concurrency: this.restoreRetryConcurrency,
33037
+ attempt,
33038
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
33039
+ });
33040
+ this._restoreRetryScheduler = scheduler;
33041
+ this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
33042
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
33043
+ });
33044
+ }
33045
+ /**
33046
+ * Tear down and reconstruct ONE device from its persisted rows — the
33047
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
33048
+ * and no other device this provider owns is disturbed.
33049
+ *
33050
+ * Keyed by `stableId` because the caller's whole reason to be here is that
33051
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
33052
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
33053
+ * whatever number the row carries NOW. The teardown is `decommission` —
33054
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
33055
+ * unregisters native caps, drops the registry entry) — and the rebuild is
33056
+ * the boot restore's own `create()` path, including its pass 2: first-class
33057
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
33058
+ * parent by the cascade and must be re-created explicitly, because only
33059
+ * accessory children come back through `getAccessoryChildren()`.
33060
+ *
33061
+ * Reloading an accessory child directly is refused (no device class) —
33062
+ * reload its parent instead.
33063
+ */
33064
+ async reloadDevice(input) {
33065
+ const { stableId } = input;
33066
+ const devices = this.ctx.kernel.devices;
33067
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
33068
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
33069
+ if (live) await devices.decommission(live.id);
33070
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
33071
+ addonId: this.addonId,
33072
+ stableId
33073
+ });
33074
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
33075
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
33076
+ const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
33077
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
33078
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
33079
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
33080
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
33081
+ for (const row of rows) {
33082
+ if (row.parentDeviceId !== id) continue;
33083
+ const childType = Object.values(DeviceType).find((t) => t === row.type);
33084
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
33085
+ if (!ChildClass) continue;
33086
+ try {
33087
+ await devices.create(row.stableId, ChildClass, {}, id);
33088
+ } catch (err) {
33089
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
33090
+ tags: {
33091
+ deviceId: row.id,
33092
+ stableId: row.stableId
33093
+ },
33094
+ meta: {
33095
+ parentDeviceId: id,
33096
+ error: err instanceof Error ? err.message : String(err)
33097
+ }
33098
+ });
33099
+ }
33100
+ }
33101
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
33102
+ tags: { deviceId: id },
33103
+ meta: {
33104
+ stableId,
33105
+ type: meta.type
33106
+ }
33107
+ });
33108
+ return { deviceId: id };
32707
33109
  }
32708
33110
  /**
32709
33111
  * Restore devices from persisted state. Two-pass:
@@ -32729,55 +33131,108 @@ var BaseDeviceProvider = class extends BaseAddon {
32729
33131
  * accessory-spawn flow handles via the parent's
32730
33132
  * `getAccessoryChildren()`. Override only when the default doesn't
32731
33133
  * fit.
33134
+ *
33135
+ * A row that fails either pass is NOT terminal (D347): it is handed
33136
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
33137
+ * Only after the bound is exhausted is the device marked permanently
33138
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
33139
+ * `getStatus().error`.
32732
33140
  */
32733
33141
  async onRestoreDevices(savedDevices) {
32734
33142
  const restored = /* @__PURE__ */ new Set();
33143
+ const failures = [];
33144
+ const attemptRestore = async (saved) => {
33145
+ if (restored.has(saved.id)) return;
33146
+ const Class = this.deviceClasses[saved.type];
33147
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
33148
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
33149
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
33150
+ restored.add(saved.id);
33151
+ };
32735
33152
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
32736
33153
  const restoreOne = async (saved) => {
32737
- const Class = this.deviceClasses[saved.type];
32738
- if (!Class) {
33154
+ if (!this.deviceClasses[saved.type]) {
32739
33155
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
32740
- tags: { stableId: saved.stableId },
33156
+ tags: {
33157
+ deviceId: saved.id,
33158
+ stableId: saved.stableId
33159
+ },
32741
33160
  meta: { type: saved.type }
32742
33161
  });
32743
33162
  return;
32744
33163
  }
32745
33164
  try {
32746
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
32747
- restored.add(saved.id);
33165
+ await attemptRestore(saved);
32748
33166
  } catch (err) {
32749
- this.ctx.logger.warn("Failed to restore device", {
32750
- tags: { stableId: saved.stableId },
33167
+ const error = err instanceof Error ? err.message : String(err);
33168
+ this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
33169
+ tags: {
33170
+ deviceId: saved.id,
33171
+ stableId: saved.stableId
33172
+ },
32751
33173
  meta: {
32752
33174
  type: saved.type,
32753
- error: err instanceof Error ? err.message : String(err)
33175
+ attempt: 1,
33176
+ error
32754
33177
  }
32755
33178
  });
33179
+ failures.push({
33180
+ saved,
33181
+ error
33182
+ });
32756
33183
  }
32757
33184
  };
32758
33185
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
33186
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
32759
33187
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
32760
33188
  for (const saved of childRows) {
32761
- const Class = this.deviceClasses[saved.type];
32762
- if (!Class) continue;
33189
+ if (!this.deviceClasses[saved.type]) continue;
32763
33190
  if (saved.parentDeviceId === null) continue;
32764
- if (!restored.has(saved.parentDeviceId)) continue;
32765
- try {
32766
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
32767
- restored.add(saved.id);
32768
- } catch (err) {
32769
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
33191
+ if (restored.has(saved.parentDeviceId)) {
33192
+ try {
33193
+ await attemptRestore(saved);
33194
+ } catch (err) {
33195
+ const error = err instanceof Error ? err.message : String(err);
33196
+ this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
33197
+ tags: {
33198
+ deviceId: saved.id,
33199
+ stableId: saved.stableId,
33200
+ parentDeviceId: saved.parentDeviceId
33201
+ },
33202
+ meta: {
33203
+ type: saved.type,
33204
+ attempt: 1,
33205
+ error
33206
+ }
33207
+ });
33208
+ failures.push({
33209
+ saved,
33210
+ error
33211
+ });
33212
+ }
33213
+ continue;
33214
+ }
33215
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
33216
+ this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
32770
33217
  tags: {
33218
+ deviceId: saved.id,
32771
33219
  stableId: saved.stableId,
32772
33220
  parentDeviceId: saved.parentDeviceId
32773
33221
  },
32774
- meta: {
32775
- type: saved.type,
32776
- error: err instanceof Error ? err.message : String(err)
32777
- }
33222
+ meta: { type: saved.type }
33223
+ });
33224
+ failures.push({
33225
+ saved,
33226
+ error: `parent device ${saved.parentDeviceId} not restored`
32778
33227
  });
33228
+ continue;
32779
33229
  }
32780
33230
  }
33231
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
33232
+ return {
33233
+ restoredCount: restored.size,
33234
+ failedCount: failures.length
33235
+ };
32781
33236
  }
32782
33237
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
32783
33238
  toSummary(device) {
@@ -33352,6 +33807,12 @@ Object.freeze({
33352
33807
  addonId: null,
33353
33808
  access: "create"
33354
33809
  },
33810
+ "backup.cancel": {
33811
+ capName: "backup",
33812
+ capScope: "system",
33813
+ addonId: null,
33814
+ access: "create"
33815
+ },
33355
33816
  "backup.delete": {
33356
33817
  capName: "backup",
33357
33818
  capScope: "system",
@@ -33394,6 +33855,12 @@ Object.freeze({
33394
33855
  addonId: null,
33395
33856
  access: "view"
33396
33857
  },
33858
+ "backup.listRuns": {
33859
+ capName: "backup",
33860
+ capScope: "system",
33861
+ addonId: null,
33862
+ access: "view"
33863
+ },
33397
33864
  "backup.listSchedules": {
33398
33865
  capName: "backup",
33399
33866
  capScope: "system",
@@ -34594,6 +35061,12 @@ Object.freeze({
34594
35061
  addonId: null,
34595
35062
  access: "view"
34596
35063
  },
35064
+ "deviceProvider.reloadDevice": {
35065
+ capName: "device-provider",
35066
+ capScope: "system",
35067
+ addonId: null,
35068
+ access: "create"
35069
+ },
34597
35070
  "deviceProvider.start": {
34598
35071
  capName: "device-provider",
34599
35072
  capScope: "system",
@@ -37960,6 +38433,12 @@ Object.freeze({
37960
38433
  addonId: null,
37961
38434
  access: "create"
37962
38435
  },
38436
+ "streamBroker.forgetDeviceHardware": {
38437
+ capName: "stream-broker",
38438
+ capScope: "system",
38439
+ addonId: null,
38440
+ access: "delete"
38441
+ },
37963
38442
  "streamBroker.getAllRtspEntries": {
37964
38443
  capName: "stream-broker",
37965
38444
  capScope: "system",
@@ -40418,6 +40897,11 @@ Object.freeze({
40418
40897
  form: "single",
40419
40898
  optional: false
40420
40899
  }],
40900
+ "streamBroker.forgetDeviceHardware": [{
40901
+ name: "deviceId",
40902
+ form: "single",
40903
+ optional: false
40904
+ }],
40421
40905
  "streamBroker.getDeviceAudioMute": [{
40422
40906
  name: "deviceId",
40423
40907
  form: "single",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-rtsp",
3
- "version": "1.2.58",
3
+ "version": "1.2.60",
4
4
  "description": "Generic RTSP camera device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",