@camstack/addon-provider-unraid 0.2.152 → 0.2.154

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.
package/CHANGELOG.md CHANGED
@@ -7,11 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.154] - 2026-11-01
11
+
12
+ ### Changed
13
+
14
+ - An integration deleted while its provider was down now has its orphan devices removed after two concordant reads of the integration list
15
+
16
+ ## [0.2.153] - 2026-11-01
17
+
18
+ ### Changed
19
+
20
+ - Declares the minimum CamStack server it needs; the Addons page shows a held update as requires server >= X
21
+ - Integration reconcile no longer removes devices when the integration list or settings read fails or is empty; devices are removed only on an explicit Deleted/Disabled event
22
+
10
23
  ## [0.2.152] - 2026-11-01
11
24
 
12
25
  ### Changed
13
26
 
14
27
  - Unraid is now added as an integration: the server host and API key are the integration's settings, and the server appears as a Gateway next to its instance device instead of a Hub.
28
+ - Declares the minimum CamStack server it needs; the Addons page shows a held update as requires server >= X
15
29
 
16
30
  ## [0.2.151] - 2026-10-28
17
31
 
package/dist/addon.js CHANGED
@@ -5831,7 +5831,7 @@ var ZodIssueCode = {
5831
5831
  var ZodFirstPartyTypeKind;
5832
5832
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5833
5833
  //#endregion
5834
- //#region ../types/dist/sleep-BHS4ImRX.mjs
5834
+ //#region ../types/dist/sleep-CSYuxssh.mjs
5835
5835
  /**
5836
5836
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5837
5837
  * window to float samples (D455).
@@ -9114,7 +9114,7 @@ var ProfileRtspEntrySchema = object({
9114
9114
  /**
9115
9115
  * The model weight formats a catalog entry can ship. Schema-free on purpose:
9116
9116
  * `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
9117
- * (the model downloader in `@camstack/system`) needs only the list and must
9117
+ * (the model downloader in `@camstack/server/system`) needs only the list and must
9118
9118
  * reach it through `@camstack/types/addon` without building the cap schema
9119
9119
  * graph (D28).
9120
9120
  */
@@ -12489,6 +12489,71 @@ var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
12489
12489
  var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
12490
12490
  new Set(EVENT_OWNER_TYPES);
12491
12491
  /**
12492
+ * What a published package needs from the rest of the install (D989).
12493
+ *
12494
+ * One published framework package (`@camstack/server`) carries the kernel, the
12495
+ * builtins and the capability contracts, so "which addon runs on which
12496
+ * framework" is a question of version, and the answer is DECLARED by the
12497
+ * package that knows it:
12498
+ *
12499
+ * - an addon says the oldest server it runs on (`requires.server: ">=X"`);
12500
+ * - the server says the oldest addon it works with
12501
+ * (`requires.addons: { "@camstack/addon-x": ">=Y" }`) and the oldest shell
12502
+ * around it (`requires.wrapper: { desktop, image }`);
12503
+ * - a wrapper (the desktop app, the container starter) says which server
12504
+ * range it can host, and reports its own kind and version.
12505
+ *
12506
+ * The publish workflow STAMPS these after the version bump
12507
+ * (`scripts/ci/stamp-requires.mjs`); nobody types a number by hand.
12508
+ *
12509
+ * A requirement that cannot be checked is NOT met: a shell that reports no
12510
+ * version counts as below the minimum, and an unreadable running version is
12511
+ * named in the result (a failed read is never a pass).
12512
+ */
12513
+ /** `>=MAJOR.MINOR.PATCH[-prerelease]` — the only range shape a requirement uses. */
12514
+ var MinVersionSchema = string().regex(/^>=\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, "a minimum is written \">=X.Y.Z\"");
12515
+ object({
12516
+ /** The oldest `@camstack/server` this package runs on. */
12517
+ server: MinVersionSchema.optional(),
12518
+ /** The oldest installed version of each named addon package. */
12519
+ addons: record(string(), MinVersionSchema).optional(),
12520
+ /** The oldest shell around the server, per shell kind. */
12521
+ wrapper: object({
12522
+ desktop: MinVersionSchema.optional(),
12523
+ image: MinVersionSchema.optional()
12524
+ }).optional()
12525
+ });
12526
+ /** One unmet requirement; each names the versions on both sides. */
12527
+ var UnmetRequirementSchema = discriminatedUnion("kind", [
12528
+ object({
12529
+ kind: literal("server-too-old"),
12530
+ required: string(),
12531
+ /** `null` — the running server version could not be read. */
12532
+ running: string().nullable()
12533
+ }),
12534
+ object({
12535
+ kind: literal("addon-too-old"),
12536
+ packageName: string(),
12537
+ required: string(),
12538
+ /** `null` — the addon is not installed (or its version is unreadable). */
12539
+ running: string().nullable()
12540
+ }),
12541
+ object({
12542
+ kind: literal("wrapper-too-old"),
12543
+ wrapper: _enum(["desktop", "image"]),
12544
+ required: string(),
12545
+ /** `null` — the wrapper reported no version. */
12546
+ running: string().nullable()
12547
+ })
12548
+ ]);
12549
+ var RequiredAddonUpdateSchema = object({
12550
+ packageName: string(),
12551
+ /** The minimum the target server declares. */
12552
+ required: string(),
12553
+ /** What this node has; `null` when the addon is not installed. */
12554
+ installed: string().nullable()
12555
+ });
12556
+ /**
12492
12557
  * Which integration instance a settings form is for. An addon that serves several instances
12493
12558
  * (`instanceMode: multiple`) can show a different form per instance — an NVR integration
12494
12559
  * needs its connection, the default integration needs the addon's own sections.
@@ -42904,7 +42969,7 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
42904
42969
  * rebuilds without manual reload.
42905
42970
  *
42906
42971
  * The hub-local builtin `addon-pages-aggregator` (see
42907
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
42972
+ * `@camstack/server/system/builtins/addon-pages-aggregator`) registers the
42908
42973
  * provider. Splitting the public aggregator from the raw collection
42909
42974
  * keeps both ends in codegen — there's no hand-written
42910
42975
  * `addon-pages.router.ts` wrapper anymore.
@@ -43204,7 +43269,7 @@ method(_void(), array(WidgetMetadataSchema).readonly(), { auth: "admin" });
43204
43269
  * manual reload — same scheme used by `addon-pages`.
43205
43270
  *
43206
43271
  * The hub-local builtin `addon-widgets-aggregator` (see
43207
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
43272
+ * `@camstack/server/system/builtins/addon-widgets-aggregator`) registers the
43208
43273
  * provider. Splitting the public aggregator from the raw collection
43209
43274
  * keeps both ends in codegen — there's no hand-written wrapper.
43210
43275
  */
@@ -61404,7 +61469,18 @@ var ServerUpdateActionResultSchema = object({
61404
61469
  * store (D877), present when the pre-flight refused the update
61405
61470
  * (`reason: 'addon-would-refuse'`). Optional for version skew.
61406
61471
  */
61407
- blockers: array(UpdateBlockerSchema).optional()
61472
+ blockers: array(UpdateBlockerSchema).optional(),
61473
+ /**
61474
+ * The minimums the target server declares and this node does not meet
61475
+ * (D989), present when the update was refused with
61476
+ * `reason: 'requirements-unmet'`. Each names the versions on both sides.
61477
+ */
61478
+ unmet: array(UnmetRequirementSchema).optional(),
61479
+ /**
61480
+ * The addons this node has below the minimum the staged server declares —
61481
+ * the ones to update WITH the server. Present on an accepted update.
61482
+ */
61483
+ requiredAddonUpdates: array(RequiredAddonUpdateSchema).optional()
61408
61484
  });
61409
61485
  var LaunchOverridesSchema = object({
61410
61486
  /** The persisted heap ceiling in MB, or `null` when none is set. */
@@ -63948,7 +64024,9 @@ var PackageUpdateSchema = object({
63948
64024
  latestVersion: string(),
63949
64025
  category: _enum(["addon", "core"]),
63950
64026
  requiresRestart: boolean(),
63951
- isSystem: boolean()
64027
+ isSystem: boolean(),
64028
+ /** Held: what the newer version needs and this node does not meet (D989). */
64029
+ blockedBy: array(UnmetRequirementSchema).readonly().optional()
63952
64030
  });
63953
64031
  var PackageVersionInfoSchema = object({
63954
64032
  version: string(),
@@ -69248,7 +69326,7 @@ var lawnMowerControlCapability = {
69248
69326
  * clients — they reverse-connect to the hub. Exposing their interfaces
69249
69327
  * via the same surface would leak internal topology with no upside.
69250
69328
  *
69251
- * Implementation in `@camstack/system/builtins/local-network/`.
69329
+ * Implementation in `@camstack/server/system/builtins/local-network/`.
69252
69330
  */
69253
69331
  /** Coarse classification derived from the interface name + IP range. */
69254
69332
  var InterfaceKindEnum = _enum([
@@ -71136,7 +71214,7 @@ onStatusChanged: { data: object({
71136
71214
  * jitter, and observed/peak bandwidth per device + per client.
71137
71215
  *
71138
71216
  * Implementation lives in the server's `NetworkQualityService` (thin
71139
- * wrapper over the shared `NetworkQualityTracker` from `@camstack/system`).
71217
+ * wrapper over the shared `NetworkQualityTracker` from `@camstack/server/system`).
71140
71218
  * The provider is registered from `trpc.router.ts` against the existing
71141
71219
  * service instance — no addon owns this state.
71142
71220
  *
@@ -73287,7 +73365,7 @@ var ptzAutotrackCapability = {
73287
73365
  * - **Native**: the camera's own firmware autotrack, registered per device by
73288
73366
  * the driver when the probe says it exists (Reolink: yes; Hikvision:
73289
73367
  * model-dependent).
73290
- * - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
73368
+ * - **Wrapper**: the emulated implementation (`@camstack/server/system`'s `autotrack` builtin),
73291
73369
  * which follows a subject with `ptz.move` pulses driven by the detection
73292
73370
  * pipeline. `defaultActive: false` — the operator binds it per camera in
73293
73371
  * the bindings UI.
@@ -94419,6 +94497,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94419
94497
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94420
94498
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94421
94499
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94500
+ /**
94501
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94502
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94503
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94504
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94505
+ */
94506
+ var OrphanIntegrationSweep = class {
94507
+ #logger;
94508
+ #minIntervalMs;
94509
+ #now;
94510
+ #suspects = /* @__PURE__ */ new Map();
94511
+ constructor(options) {
94512
+ this.#logger = options.logger;
94513
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94514
+ this.#now = options.now ?? Date.now;
94515
+ }
94516
+ get minIntervalMs() {
94517
+ return this.#minIntervalMs;
94518
+ }
94519
+ async sweep(input) {
94520
+ if (input.listedIds === null) return {
94521
+ newSuspects: 0,
94522
+ removed: []
94523
+ };
94524
+ const listed = new Set(input.listedIds);
94525
+ const owners = new Set(input.ownerIds);
94526
+ const now = this.#now();
94527
+ let newSuspects = 0;
94528
+ const removed = [];
94529
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94530
+ this.#suspects.delete(id);
94531
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94532
+ integrationId: id,
94533
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94534
+ } });
94535
+ }
94536
+ for (const id of owners) {
94537
+ if (listed.has(id)) continue;
94538
+ const since = this.#suspects.get(id);
94539
+ if (since === void 0) {
94540
+ this.#suspects.set(id, now);
94541
+ newSuspects += 1;
94542
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94543
+ integrationId: id,
94544
+ minIntervalMs: this.#minIntervalMs
94545
+ } });
94546
+ continue;
94547
+ }
94548
+ if (now - since < this.#minIntervalMs) continue;
94549
+ try {
94550
+ const count = await input.removeDevices(id);
94551
+ this.#suspects.delete(id);
94552
+ removed.push(id);
94553
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94554
+ integrationId: id,
94555
+ removed: count,
94556
+ firstMissingAt: since
94557
+ } });
94558
+ } catch (err) {
94559
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94560
+ integrationId: id,
94561
+ error: errMsg(err)
94562
+ } });
94563
+ }
94564
+ }
94565
+ return {
94566
+ newSuspects,
94567
+ removed
94568
+ };
94569
+ }
94570
+ };
94571
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94572
+ async function collectOwnerIntegrationIds(input) {
94573
+ const ids = /* @__PURE__ */ new Set();
94574
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94575
+ if (d.parentDeviceId !== null) continue;
94576
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94577
+ const owner = input.ownerOf(cfg);
94578
+ if (owner !== null) ids.add(owner);
94579
+ }
94580
+ return [...ids];
94581
+ }
94422
94582
  String.raw`(?<=^|["'(\s>,\[{])`;
94423
94583
  var MB = 1024 * 1024;
94424
94584
  1024 * MB, 3072 * MB;
@@ -95999,6 +96159,97 @@ function buildUnraidDeviceAdopter(input) {
95999
96159
  };
96000
96160
  }
96001
96161
  //#endregion
96162
+ //#region src/unraid-reconciler.ts
96163
+ /**
96164
+ * Keeps one live client per enabled integration. Devices are removed ONLY on an explicit
96165
+ * lifecycle event naming the integration (D49): a list that failed, an empty list or an
96166
+ * unreadable connection changes nothing about devices and is logged, never read as "gone".
96167
+ */
96168
+ var UnraidReconciler = class {
96169
+ #deps;
96170
+ #sweep;
96171
+ #rerun = null;
96172
+ constructor(deps) {
96173
+ this.#deps = deps;
96174
+ this.#sweep = new OrphanIntegrationSweep({
96175
+ logger: deps.logger,
96176
+ ...deps.sweepOptions
96177
+ });
96178
+ }
96179
+ /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96180
+ async reconcile() {
96181
+ const { logger, clients } = this.#deps;
96182
+ let all;
96183
+ try {
96184
+ all = await this.#deps.listIntegrations();
96185
+ } catch (err) {
96186
+ logger.warn("Unraid integration reconcile skipped — the integration list did not answer", { meta: { error: errMsg(err) } });
96187
+ return;
96188
+ }
96189
+ const enabled = all.filter((i) => i.addonId === this.#deps.addonId && i.enabled);
96190
+ for (const integration of enabled) try {
96191
+ if (!await this.#deps.applySettings(integration)) logger.warn("Unraid integration has no usable connection — left as it is", { meta: { integrationId: integration.id } });
96192
+ } catch (err) {
96193
+ logger.warn("Unraid reconcile failed for integration — left as it is", { meta: {
96194
+ integrationId: integration.id,
96195
+ error: errMsg(err)
96196
+ } });
96197
+ }
96198
+ const enabledIds = new Set(enabled.map((i) => i.id));
96199
+ for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96200
+ await this.#sweepOrphans(all.map((i) => i.id));
96201
+ }
96202
+ /** Stop the scheduled second read (addon stop). */
96203
+ dispose() {
96204
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96205
+ this.#rerun = null;
96206
+ }
96207
+ /**
96208
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96209
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96210
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96211
+ */
96212
+ async #sweepOrphans(listedIds) {
96213
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96214
+ if (ownerIntegrationIds === void 0) return;
96215
+ try {
96216
+ const ownerIds = await ownerIntegrationIds();
96217
+ if ((await this.#sweep.sweep({
96218
+ ownerIds,
96219
+ listedIds,
96220
+ removeDevices
96221
+ })).newSuspects > 0 && this.#rerun === null) {
96222
+ this.#rerun = setTimeout(() => {
96223
+ this.#rerun = null;
96224
+ this.reconcile();
96225
+ }, this.#sweep.minIntervalMs + 1e3);
96226
+ this.#rerun.unref();
96227
+ }
96228
+ } catch (err) {
96229
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96230
+ }
96231
+ }
96232
+ /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96233
+ async onLifecycle(kind, integrationId) {
96234
+ await this.reconcile();
96235
+ if (kind === "enabled" || integrationId === null) return;
96236
+ try {
96237
+ const removed = await this.#deps.removeDevices(integrationId);
96238
+ this.#deps.logger.info("Unraid integration devices removed", { meta: {
96239
+ integrationId,
96240
+ kind,
96241
+ removed
96242
+ } });
96243
+ } catch (err) {
96244
+ this.#deps.logger.warn("Unraid integration device removal failed", { meta: {
96245
+ integrationId,
96246
+ kind,
96247
+ error: errMsg(err)
96248
+ } });
96249
+ }
96250
+ }
96251
+ };
96252
+ //#endregion
96002
96253
  //#region src/unraid-integration-device-cascade.ts
96003
96254
  /**
96004
96255
  * Remove every top-level device (Gateway and instance Container) of an integration that
@@ -96075,6 +96326,7 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96075
96326
  [DeviceType.Button]: UnraidEntityDevice,
96076
96327
  [DeviceType.EventEmitter]: UnraidEntityDevice
96077
96328
  };
96329
+ integrationReconciler = null;
96078
96330
  clients = null;
96079
96331
  constructor() {
96080
96332
  super({});
@@ -96100,6 +96352,8 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96100
96352
  this.ctx.logger.warn("Unraid shutdown error", { meta: { error: errMsg(err) } });
96101
96353
  }
96102
96354
  this.clients = null;
96355
+ this.integrationReconciler?.dispose();
96356
+ this.integrationReconciler = null;
96103
96357
  unraidInstances.clear();
96104
96358
  await super.onShutdown();
96105
96359
  }
@@ -96116,51 +96370,63 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96116
96370
  return unraidStableId(integrationId, nativeId);
96117
96371
  }
96118
96372
  /**
96119
- * Rebuild the clients from the live integration list: upsert one per enabled integration
96120
- * with a host, drop the ones that vanished and cascade-remove their devices. Idempotent;
96121
- * runs at boot and on every integration lifecycle event.
96373
+ * Upsert one client per enabled integration and drop the client of one the list ANSWERED is
96374
+ * gone. Never removes a device (D49): that follows only an explicit lifecycle event.
96375
+ * Idempotent; runs at boot and on every integration lifecycle event.
96122
96376
  */
96123
- async reconcileIntegrations() {
96124
- const reg = this.requireClients();
96125
- try {
96126
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
96127
- const surviving = /* @__PURE__ */ new Set();
96128
- for (const integration of mine) try {
96377
+ reconciler() {
96378
+ this.integrationReconciler ??= new UnraidReconciler({
96379
+ addonId: this.ctx.id,
96380
+ logger: this.ctx.logger,
96381
+ clients: this.requireClients(),
96382
+ listIntegrations: () => this.ctx.api.integrations.list.query(),
96383
+ applySettings: async (integration) => {
96384
+ const reg = this.requireClients();
96129
96385
  const read = connectionFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
96130
- if (read === null) {
96131
- this.ctx.logger.warn("Unraid integration has no server host — skipping", { meta: { integrationId: integration.id } });
96132
- continue;
96133
- }
96386
+ if (read === null) return false;
96134
96387
  await reg.upsert(integration.id, integration.name, read.connection, read.apiKeyLocked);
96135
- surviving.add(integration.id);
96136
- } catch (err) {
96137
- this.ctx.logger.warn("Unraid reconcile failed for integration", { meta: {
96138
- integrationId: integration.id,
96139
- error: errMsg(err)
96140
- } });
96141
- }
96142
- const vanished = reg.list().filter((id) => !surviving.has(id));
96143
- await reg.retain(surviving);
96144
- for (const integrationId of vanished) await this.cascadeRemoveDevicesForIntegration(integrationId);
96145
- } catch (err) {
96146
- this.ctx.logger.warn("Unraid integration reconcile failed", { meta: { error: errMsg(err) } });
96147
- }
96388
+ return true;
96389
+ },
96390
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96391
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96392
+ });
96393
+ return this.integrationReconciler;
96148
96394
  }
96395
+ async reconcileIntegrations() {
96396
+ await this.reconciler().reconcile();
96397
+ }
96398
+ /** Lifecycle events: only an explicit Deleted/Disabled naming an integration removes devices. */
96149
96399
  subscribeIntegrationLifecycle() {
96150
- const handler = (event) => {
96400
+ const on = (kind) => (event) => {
96151
96401
  const addonId = event.data["addonId"];
96152
96402
  if (typeof addonId === "string" && addonId !== this.ctx.id) return;
96153
- this.reconcileIntegrations();
96403
+ const integrationId = event.data["integrationId"];
96404
+ this.reconciler().onLifecycle(kind, typeof integrationId === "string" ? integrationId : null);
96154
96405
  };
96155
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
96156
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
96157
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
96406
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, on("enabled"));
96407
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96408
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96409
+ }
96410
+ /** The integrations that own devices here, for the orphan sweep. */
96411
+ async ownerIntegrationIds() {
96412
+ const reg = this.ctx.kernel.deviceRegistry;
96413
+ const devices = this.ctx.kernel.devices;
96414
+ if (!reg || !devices) return [];
96415
+ return await collectOwnerIntegrationIds({
96416
+ reg,
96417
+ devices,
96418
+ addonId: this.addonId,
96419
+ ownerOf: (cfg) => {
96420
+ const owner = cfg["integrationId"];
96421
+ return cfg["system"] === "unraid" && typeof owner === "string" ? owner : null;
96422
+ }
96423
+ });
96158
96424
  }
96159
96425
  async cascadeRemoveDevicesForIntegration(integrationId) {
96160
96426
  const reg = this.ctx.kernel.deviceRegistry;
96161
96427
  const devices = this.ctx.kernel.devices;
96162
- if (!reg || !devices) return;
96163
- await cascadeRemoveDevicesForIntegration({
96428
+ if (!reg || !devices) return 0;
96429
+ return await cascadeRemoveDevicesForIntegration({
96164
96430
  reg,
96165
96431
  devices,
96166
96432
  addonId: this.addonId,
package/dist/addon.mjs CHANGED
@@ -5830,7 +5830,7 @@ var ZodIssueCode = {
5830
5830
  var ZodFirstPartyTypeKind;
5831
5831
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5832
5832
  //#endregion
5833
- //#region ../types/dist/sleep-BHS4ImRX.mjs
5833
+ //#region ../types/dist/sleep-CSYuxssh.mjs
5834
5834
  /**
5835
5835
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5836
5836
  * window to float samples (D455).
@@ -9113,7 +9113,7 @@ var ProfileRtspEntrySchema = object({
9113
9113
  /**
9114
9114
  * The model weight formats a catalog entry can ship. Schema-free on purpose:
9115
9115
  * `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
9116
- * (the model downloader in `@camstack/system`) needs only the list and must
9116
+ * (the model downloader in `@camstack/server/system`) needs only the list and must
9117
9117
  * reach it through `@camstack/types/addon` without building the cap schema
9118
9118
  * graph (D28).
9119
9119
  */
@@ -12488,6 +12488,71 @@ var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
12488
12488
  var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
12489
12489
  new Set(EVENT_OWNER_TYPES);
12490
12490
  /**
12491
+ * What a published package needs from the rest of the install (D989).
12492
+ *
12493
+ * One published framework package (`@camstack/server`) carries the kernel, the
12494
+ * builtins and the capability contracts, so "which addon runs on which
12495
+ * framework" is a question of version, and the answer is DECLARED by the
12496
+ * package that knows it:
12497
+ *
12498
+ * - an addon says the oldest server it runs on (`requires.server: ">=X"`);
12499
+ * - the server says the oldest addon it works with
12500
+ * (`requires.addons: { "@camstack/addon-x": ">=Y" }`) and the oldest shell
12501
+ * around it (`requires.wrapper: { desktop, image }`);
12502
+ * - a wrapper (the desktop app, the container starter) says which server
12503
+ * range it can host, and reports its own kind and version.
12504
+ *
12505
+ * The publish workflow STAMPS these after the version bump
12506
+ * (`scripts/ci/stamp-requires.mjs`); nobody types a number by hand.
12507
+ *
12508
+ * A requirement that cannot be checked is NOT met: a shell that reports no
12509
+ * version counts as below the minimum, and an unreadable running version is
12510
+ * named in the result (a failed read is never a pass).
12511
+ */
12512
+ /** `>=MAJOR.MINOR.PATCH[-prerelease]` — the only range shape a requirement uses. */
12513
+ var MinVersionSchema = string().regex(/^>=\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, "a minimum is written \">=X.Y.Z\"");
12514
+ object({
12515
+ /** The oldest `@camstack/server` this package runs on. */
12516
+ server: MinVersionSchema.optional(),
12517
+ /** The oldest installed version of each named addon package. */
12518
+ addons: record(string(), MinVersionSchema).optional(),
12519
+ /** The oldest shell around the server, per shell kind. */
12520
+ wrapper: object({
12521
+ desktop: MinVersionSchema.optional(),
12522
+ image: MinVersionSchema.optional()
12523
+ }).optional()
12524
+ });
12525
+ /** One unmet requirement; each names the versions on both sides. */
12526
+ var UnmetRequirementSchema = discriminatedUnion("kind", [
12527
+ object({
12528
+ kind: literal("server-too-old"),
12529
+ required: string(),
12530
+ /** `null` — the running server version could not be read. */
12531
+ running: string().nullable()
12532
+ }),
12533
+ object({
12534
+ kind: literal("addon-too-old"),
12535
+ packageName: string(),
12536
+ required: string(),
12537
+ /** `null` — the addon is not installed (or its version is unreadable). */
12538
+ running: string().nullable()
12539
+ }),
12540
+ object({
12541
+ kind: literal("wrapper-too-old"),
12542
+ wrapper: _enum(["desktop", "image"]),
12543
+ required: string(),
12544
+ /** `null` — the wrapper reported no version. */
12545
+ running: string().nullable()
12546
+ })
12547
+ ]);
12548
+ var RequiredAddonUpdateSchema = object({
12549
+ packageName: string(),
12550
+ /** The minimum the target server declares. */
12551
+ required: string(),
12552
+ /** What this node has; `null` when the addon is not installed. */
12553
+ installed: string().nullable()
12554
+ });
12555
+ /**
12491
12556
  * Which integration instance a settings form is for. An addon that serves several instances
12492
12557
  * (`instanceMode: multiple`) can show a different form per instance — an NVR integration
12493
12558
  * needs its connection, the default integration needs the addon's own sections.
@@ -42903,7 +42968,7 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
42903
42968
  * rebuilds without manual reload.
42904
42969
  *
42905
42970
  * The hub-local builtin `addon-pages-aggregator` (see
42906
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
42971
+ * `@camstack/server/system/builtins/addon-pages-aggregator`) registers the
42907
42972
  * provider. Splitting the public aggregator from the raw collection
42908
42973
  * keeps both ends in codegen — there's no hand-written
42909
42974
  * `addon-pages.router.ts` wrapper anymore.
@@ -43203,7 +43268,7 @@ method(_void(), array(WidgetMetadataSchema).readonly(), { auth: "admin" });
43203
43268
  * manual reload — same scheme used by `addon-pages`.
43204
43269
  *
43205
43270
  * The hub-local builtin `addon-widgets-aggregator` (see
43206
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
43271
+ * `@camstack/server/system/builtins/addon-widgets-aggregator`) registers the
43207
43272
  * provider. Splitting the public aggregator from the raw collection
43208
43273
  * keeps both ends in codegen — there's no hand-written wrapper.
43209
43274
  */
@@ -61403,7 +61468,18 @@ var ServerUpdateActionResultSchema = object({
61403
61468
  * store (D877), present when the pre-flight refused the update
61404
61469
  * (`reason: 'addon-would-refuse'`). Optional for version skew.
61405
61470
  */
61406
- blockers: array(UpdateBlockerSchema).optional()
61471
+ blockers: array(UpdateBlockerSchema).optional(),
61472
+ /**
61473
+ * The minimums the target server declares and this node does not meet
61474
+ * (D989), present when the update was refused with
61475
+ * `reason: 'requirements-unmet'`. Each names the versions on both sides.
61476
+ */
61477
+ unmet: array(UnmetRequirementSchema).optional(),
61478
+ /**
61479
+ * The addons this node has below the minimum the staged server declares —
61480
+ * the ones to update WITH the server. Present on an accepted update.
61481
+ */
61482
+ requiredAddonUpdates: array(RequiredAddonUpdateSchema).optional()
61407
61483
  });
61408
61484
  var LaunchOverridesSchema = object({
61409
61485
  /** The persisted heap ceiling in MB, or `null` when none is set. */
@@ -63947,7 +64023,9 @@ var PackageUpdateSchema = object({
63947
64023
  latestVersion: string(),
63948
64024
  category: _enum(["addon", "core"]),
63949
64025
  requiresRestart: boolean(),
63950
- isSystem: boolean()
64026
+ isSystem: boolean(),
64027
+ /** Held: what the newer version needs and this node does not meet (D989). */
64028
+ blockedBy: array(UnmetRequirementSchema).readonly().optional()
63951
64029
  });
63952
64030
  var PackageVersionInfoSchema = object({
63953
64031
  version: string(),
@@ -69247,7 +69325,7 @@ var lawnMowerControlCapability = {
69247
69325
  * clients — they reverse-connect to the hub. Exposing their interfaces
69248
69326
  * via the same surface would leak internal topology with no upside.
69249
69327
  *
69250
- * Implementation in `@camstack/system/builtins/local-network/`.
69328
+ * Implementation in `@camstack/server/system/builtins/local-network/`.
69251
69329
  */
69252
69330
  /** Coarse classification derived from the interface name + IP range. */
69253
69331
  var InterfaceKindEnum = _enum([
@@ -71135,7 +71213,7 @@ onStatusChanged: { data: object({
71135
71213
  * jitter, and observed/peak bandwidth per device + per client.
71136
71214
  *
71137
71215
  * Implementation lives in the server's `NetworkQualityService` (thin
71138
- * wrapper over the shared `NetworkQualityTracker` from `@camstack/system`).
71216
+ * wrapper over the shared `NetworkQualityTracker` from `@camstack/server/system`).
71139
71217
  * The provider is registered from `trpc.router.ts` against the existing
71140
71218
  * service instance — no addon owns this state.
71141
71219
  *
@@ -73286,7 +73364,7 @@ var ptzAutotrackCapability = {
73286
73364
  * - **Native**: the camera's own firmware autotrack, registered per device by
73287
73365
  * the driver when the probe says it exists (Reolink: yes; Hikvision:
73288
73366
  * model-dependent).
73289
- * - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
73367
+ * - **Wrapper**: the emulated implementation (`@camstack/server/system`'s `autotrack` builtin),
73290
73368
  * which follows a subject with `ptz.move` pulses driven by the detection
73291
73369
  * pipeline. `defaultActive: false` — the operator binds it per camera in
73292
73370
  * the bindings UI.
@@ -94418,6 +94496,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94418
94496
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94419
94497
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94420
94498
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94499
+ /**
94500
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94501
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94502
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94503
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94504
+ */
94505
+ var OrphanIntegrationSweep = class {
94506
+ #logger;
94507
+ #minIntervalMs;
94508
+ #now;
94509
+ #suspects = /* @__PURE__ */ new Map();
94510
+ constructor(options) {
94511
+ this.#logger = options.logger;
94512
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94513
+ this.#now = options.now ?? Date.now;
94514
+ }
94515
+ get minIntervalMs() {
94516
+ return this.#minIntervalMs;
94517
+ }
94518
+ async sweep(input) {
94519
+ if (input.listedIds === null) return {
94520
+ newSuspects: 0,
94521
+ removed: []
94522
+ };
94523
+ const listed = new Set(input.listedIds);
94524
+ const owners = new Set(input.ownerIds);
94525
+ const now = this.#now();
94526
+ let newSuspects = 0;
94527
+ const removed = [];
94528
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94529
+ this.#suspects.delete(id);
94530
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94531
+ integrationId: id,
94532
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94533
+ } });
94534
+ }
94535
+ for (const id of owners) {
94536
+ if (listed.has(id)) continue;
94537
+ const since = this.#suspects.get(id);
94538
+ if (since === void 0) {
94539
+ this.#suspects.set(id, now);
94540
+ newSuspects += 1;
94541
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94542
+ integrationId: id,
94543
+ minIntervalMs: this.#minIntervalMs
94544
+ } });
94545
+ continue;
94546
+ }
94547
+ if (now - since < this.#minIntervalMs) continue;
94548
+ try {
94549
+ const count = await input.removeDevices(id);
94550
+ this.#suspects.delete(id);
94551
+ removed.push(id);
94552
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94553
+ integrationId: id,
94554
+ removed: count,
94555
+ firstMissingAt: since
94556
+ } });
94557
+ } catch (err) {
94558
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94559
+ integrationId: id,
94560
+ error: errMsg(err)
94561
+ } });
94562
+ }
94563
+ }
94564
+ return {
94565
+ newSuspects,
94566
+ removed
94567
+ };
94568
+ }
94569
+ };
94570
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94571
+ async function collectOwnerIntegrationIds(input) {
94572
+ const ids = /* @__PURE__ */ new Set();
94573
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94574
+ if (d.parentDeviceId !== null) continue;
94575
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94576
+ const owner = input.ownerOf(cfg);
94577
+ if (owner !== null) ids.add(owner);
94578
+ }
94579
+ return [...ids];
94580
+ }
94421
94581
  String.raw`(?<=^|["'(\s>,\[{])`;
94422
94582
  var MB = 1024 * 1024;
94423
94583
  1024 * MB, 3072 * MB;
@@ -95998,6 +96158,97 @@ function buildUnraidDeviceAdopter(input) {
95998
96158
  };
95999
96159
  }
96000
96160
  //#endregion
96161
+ //#region src/unraid-reconciler.ts
96162
+ /**
96163
+ * Keeps one live client per enabled integration. Devices are removed ONLY on an explicit
96164
+ * lifecycle event naming the integration (D49): a list that failed, an empty list or an
96165
+ * unreadable connection changes nothing about devices and is logged, never read as "gone".
96166
+ */
96167
+ var UnraidReconciler = class {
96168
+ #deps;
96169
+ #sweep;
96170
+ #rerun = null;
96171
+ constructor(deps) {
96172
+ this.#deps = deps;
96173
+ this.#sweep = new OrphanIntegrationSweep({
96174
+ logger: deps.logger,
96175
+ ...deps.sweepOptions
96176
+ });
96177
+ }
96178
+ /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96179
+ async reconcile() {
96180
+ const { logger, clients } = this.#deps;
96181
+ let all;
96182
+ try {
96183
+ all = await this.#deps.listIntegrations();
96184
+ } catch (err) {
96185
+ logger.warn("Unraid integration reconcile skipped — the integration list did not answer", { meta: { error: errMsg(err) } });
96186
+ return;
96187
+ }
96188
+ const enabled = all.filter((i) => i.addonId === this.#deps.addonId && i.enabled);
96189
+ for (const integration of enabled) try {
96190
+ if (!await this.#deps.applySettings(integration)) logger.warn("Unraid integration has no usable connection — left as it is", { meta: { integrationId: integration.id } });
96191
+ } catch (err) {
96192
+ logger.warn("Unraid reconcile failed for integration — left as it is", { meta: {
96193
+ integrationId: integration.id,
96194
+ error: errMsg(err)
96195
+ } });
96196
+ }
96197
+ const enabledIds = new Set(enabled.map((i) => i.id));
96198
+ for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96199
+ await this.#sweepOrphans(all.map((i) => i.id));
96200
+ }
96201
+ /** Stop the scheduled second read (addon stop). */
96202
+ dispose() {
96203
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96204
+ this.#rerun = null;
96205
+ }
96206
+ /**
96207
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96208
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96209
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96210
+ */
96211
+ async #sweepOrphans(listedIds) {
96212
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96213
+ if (ownerIntegrationIds === void 0) return;
96214
+ try {
96215
+ const ownerIds = await ownerIntegrationIds();
96216
+ if ((await this.#sweep.sweep({
96217
+ ownerIds,
96218
+ listedIds,
96219
+ removeDevices
96220
+ })).newSuspects > 0 && this.#rerun === null) {
96221
+ this.#rerun = setTimeout(() => {
96222
+ this.#rerun = null;
96223
+ this.reconcile();
96224
+ }, this.#sweep.minIntervalMs + 1e3);
96225
+ this.#rerun.unref();
96226
+ }
96227
+ } catch (err) {
96228
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96229
+ }
96230
+ }
96231
+ /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96232
+ async onLifecycle(kind, integrationId) {
96233
+ await this.reconcile();
96234
+ if (kind === "enabled" || integrationId === null) return;
96235
+ try {
96236
+ const removed = await this.#deps.removeDevices(integrationId);
96237
+ this.#deps.logger.info("Unraid integration devices removed", { meta: {
96238
+ integrationId,
96239
+ kind,
96240
+ removed
96241
+ } });
96242
+ } catch (err) {
96243
+ this.#deps.logger.warn("Unraid integration device removal failed", { meta: {
96244
+ integrationId,
96245
+ kind,
96246
+ error: errMsg(err)
96247
+ } });
96248
+ }
96249
+ }
96250
+ };
96251
+ //#endregion
96001
96252
  //#region src/unraid-integration-device-cascade.ts
96002
96253
  /**
96003
96254
  * Remove every top-level device (Gateway and instance Container) of an integration that
@@ -96074,6 +96325,7 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96074
96325
  [DeviceType.Button]: UnraidEntityDevice,
96075
96326
  [DeviceType.EventEmitter]: UnraidEntityDevice
96076
96327
  };
96328
+ integrationReconciler = null;
96077
96329
  clients = null;
96078
96330
  constructor() {
96079
96331
  super({});
@@ -96099,6 +96351,8 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96099
96351
  this.ctx.logger.warn("Unraid shutdown error", { meta: { error: errMsg(err) } });
96100
96352
  }
96101
96353
  this.clients = null;
96354
+ this.integrationReconciler?.dispose();
96355
+ this.integrationReconciler = null;
96102
96356
  unraidInstances.clear();
96103
96357
  await super.onShutdown();
96104
96358
  }
@@ -96115,51 +96369,63 @@ var UnraidProviderAddon = class extends BaseDeviceProvider {
96115
96369
  return unraidStableId(integrationId, nativeId);
96116
96370
  }
96117
96371
  /**
96118
- * Rebuild the clients from the live integration list: upsert one per enabled integration
96119
- * with a host, drop the ones that vanished and cascade-remove their devices. Idempotent;
96120
- * runs at boot and on every integration lifecycle event.
96372
+ * Upsert one client per enabled integration and drop the client of one the list ANSWERED is
96373
+ * gone. Never removes a device (D49): that follows only an explicit lifecycle event.
96374
+ * Idempotent; runs at boot and on every integration lifecycle event.
96121
96375
  */
96122
- async reconcileIntegrations() {
96123
- const reg = this.requireClients();
96124
- try {
96125
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
96126
- const surviving = /* @__PURE__ */ new Set();
96127
- for (const integration of mine) try {
96376
+ reconciler() {
96377
+ this.integrationReconciler ??= new UnraidReconciler({
96378
+ addonId: this.ctx.id,
96379
+ logger: this.ctx.logger,
96380
+ clients: this.requireClients(),
96381
+ listIntegrations: () => this.ctx.api.integrations.list.query(),
96382
+ applySettings: async (integration) => {
96383
+ const reg = this.requireClients();
96128
96384
  const read = connectionFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
96129
- if (read === null) {
96130
- this.ctx.logger.warn("Unraid integration has no server host — skipping", { meta: { integrationId: integration.id } });
96131
- continue;
96132
- }
96385
+ if (read === null) return false;
96133
96386
  await reg.upsert(integration.id, integration.name, read.connection, read.apiKeyLocked);
96134
- surviving.add(integration.id);
96135
- } catch (err) {
96136
- this.ctx.logger.warn("Unraid reconcile failed for integration", { meta: {
96137
- integrationId: integration.id,
96138
- error: errMsg(err)
96139
- } });
96140
- }
96141
- const vanished = reg.list().filter((id) => !surviving.has(id));
96142
- await reg.retain(surviving);
96143
- for (const integrationId of vanished) await this.cascadeRemoveDevicesForIntegration(integrationId);
96144
- } catch (err) {
96145
- this.ctx.logger.warn("Unraid integration reconcile failed", { meta: { error: errMsg(err) } });
96146
- }
96387
+ return true;
96388
+ },
96389
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96390
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96391
+ });
96392
+ return this.integrationReconciler;
96147
96393
  }
96394
+ async reconcileIntegrations() {
96395
+ await this.reconciler().reconcile();
96396
+ }
96397
+ /** Lifecycle events: only an explicit Deleted/Disabled naming an integration removes devices. */
96148
96398
  subscribeIntegrationLifecycle() {
96149
- const handler = (event) => {
96399
+ const on = (kind) => (event) => {
96150
96400
  const addonId = event.data["addonId"];
96151
96401
  if (typeof addonId === "string" && addonId !== this.ctx.id) return;
96152
- this.reconcileIntegrations();
96402
+ const integrationId = event.data["integrationId"];
96403
+ this.reconciler().onLifecycle(kind, typeof integrationId === "string" ? integrationId : null);
96153
96404
  };
96154
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
96155
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
96156
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
96405
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, on("enabled"));
96406
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96407
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96408
+ }
96409
+ /** The integrations that own devices here, for the orphan sweep. */
96410
+ async ownerIntegrationIds() {
96411
+ const reg = this.ctx.kernel.deviceRegistry;
96412
+ const devices = this.ctx.kernel.devices;
96413
+ if (!reg || !devices) return [];
96414
+ return await collectOwnerIntegrationIds({
96415
+ reg,
96416
+ devices,
96417
+ addonId: this.addonId,
96418
+ ownerOf: (cfg) => {
96419
+ const owner = cfg["integrationId"];
96420
+ return cfg["system"] === "unraid" && typeof owner === "string" ? owner : null;
96421
+ }
96422
+ });
96157
96423
  }
96158
96424
  async cascadeRemoveDevicesForIntegration(integrationId) {
96159
96425
  const reg = this.ctx.kernel.deviceRegistry;
96160
96426
  const devices = this.ctx.kernel.devices;
96161
- if (!reg || !devices) return;
96162
- await cascadeRemoveDevicesForIntegration({
96427
+ if (!reg || !devices) return 0;
96428
+ return await cascadeRemoveDevicesForIntegration({
96163
96429
  reg,
96164
96430
  devices,
96165
96431
  addonId: this.addonId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-unraid",
3
- "version": "0.2.152",
3
+ "version": "0.2.154",
4
4
  "description": "Unraid device-provider addon for CamStack — one Container per Unraid instance fanning out array, disk, docker and notification entities",
5
5
  "keywords": [
6
6
  "camstack",
@@ -57,7 +57,10 @@
57
57
  }
58
58
  ]
59
59
  }
60
- ]
60
+ ],
61
+ "requires": {
62
+ "server": ">=1.2.722"
63
+ }
61
64
  },
62
65
  "files": [
63
66
  "dist",