@camstack/addon-provider-unifi 0.2.152 → 0.2.153

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,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.153] - 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
+
10
16
  ## [0.2.152] - 2026-11-01
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -94497,6 +94497,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94497
94497
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94498
94498
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94499
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
+ }
94500
94582
  String.raw`(?<=^|["'(\s>,\[{])`;
94501
94583
  var MB = 1024 * 1024;
94502
94584
  1024 * MB, 3072 * MB;
@@ -96001,8 +96083,14 @@ async function reconcileInfraFeatures(input) {
96001
96083
  */
96002
96084
  var UnifiReconciler = class {
96003
96085
  #deps;
96086
+ #sweep;
96087
+ #rerun = null;
96004
96088
  constructor(deps) {
96005
96089
  this.#deps = deps;
96090
+ this.#sweep = new OrphanIntegrationSweep({
96091
+ logger: deps.logger,
96092
+ ...deps.sweepOptions
96093
+ });
96006
96094
  }
96007
96095
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96008
96096
  async reconcile() {
@@ -96025,6 +96113,37 @@ var UnifiReconciler = class {
96025
96113
  }
96026
96114
  const enabledIds = new Set(enabled.map((i) => i.id));
96027
96115
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96116
+ await this.#sweepOrphans(all.map((i) => i.id));
96117
+ }
96118
+ /** Stop the scheduled second read (addon stop). */
96119
+ dispose() {
96120
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96121
+ this.#rerun = null;
96122
+ }
96123
+ /**
96124
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96125
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96126
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96127
+ */
96128
+ async #sweepOrphans(listedIds) {
96129
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96130
+ if (ownerIntegrationIds === void 0) return;
96131
+ try {
96132
+ const ownerIds = await ownerIntegrationIds();
96133
+ if ((await this.#sweep.sweep({
96134
+ ownerIds,
96135
+ listedIds,
96136
+ removeDevices
96137
+ })).newSuspects > 0 && this.#rerun === null) {
96138
+ this.#rerun = setTimeout(() => {
96139
+ this.#rerun = null;
96140
+ this.reconcile();
96141
+ }, this.#sweep.minIntervalMs + 1e3);
96142
+ this.#rerun.unref();
96143
+ }
96144
+ } catch (err) {
96145
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96146
+ }
96028
96147
  }
96029
96148
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96030
96149
  async onLifecycle(kind, integrationId) {
@@ -96150,6 +96269,7 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
96150
96269
  this.ctx.logger.warn("UniFi shutdown error", { meta: { error: errMsg(err) } });
96151
96270
  }
96152
96271
  this.clients = null;
96272
+ this.integrationReconciler?.dispose();
96153
96273
  this.integrationReconciler = null;
96154
96274
  unifiControllers.clear();
96155
96275
  await super.onShutdown();
@@ -96184,7 +96304,8 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
96184
96304
  await reg.upsert(integration.id, integration.name, read.connection, read.credentialsLocked);
96185
96305
  return true;
96186
96306
  },
96187
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96307
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96308
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96188
96309
  });
96189
96310
  return this.integrationReconciler;
96190
96311
  }
@@ -96203,6 +96324,21 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
96203
96324
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96204
96325
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96205
96326
  }
96327
+ /** The integrations that own devices here, for the orphan sweep. */
96328
+ async ownerIntegrationIds() {
96329
+ const reg = this.ctx.kernel.deviceRegistry;
96330
+ const devices = this.ctx.kernel.devices;
96331
+ if (!reg || !devices) return [];
96332
+ return await collectOwnerIntegrationIds({
96333
+ reg,
96334
+ devices,
96335
+ addonId: this.addonId,
96336
+ ownerOf: (cfg) => {
96337
+ const owner = cfg["integrationId"];
96338
+ return cfg["system"] === "unifi" && typeof owner === "string" ? owner : null;
96339
+ }
96340
+ });
96341
+ }
96206
96342
  async cascadeRemoveDevicesForIntegration(integrationId) {
96207
96343
  const reg = this.ctx.kernel.deviceRegistry;
96208
96344
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -94496,6 +94496,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94496
94496
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94497
94497
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94498
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
+ }
94499
94581
  String.raw`(?<=^|["'(\s>,\[{])`;
94500
94582
  var MB = 1024 * 1024;
94501
94583
  1024 * MB, 3072 * MB;
@@ -96000,8 +96082,14 @@ async function reconcileInfraFeatures(input) {
96000
96082
  */
96001
96083
  var UnifiReconciler = class {
96002
96084
  #deps;
96085
+ #sweep;
96086
+ #rerun = null;
96003
96087
  constructor(deps) {
96004
96088
  this.#deps = deps;
96089
+ this.#sweep = new OrphanIntegrationSweep({
96090
+ logger: deps.logger,
96091
+ ...deps.sweepOptions
96092
+ });
96005
96093
  }
96006
96094
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96007
96095
  async reconcile() {
@@ -96024,6 +96112,37 @@ var UnifiReconciler = class {
96024
96112
  }
96025
96113
  const enabledIds = new Set(enabled.map((i) => i.id));
96026
96114
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96115
+ await this.#sweepOrphans(all.map((i) => i.id));
96116
+ }
96117
+ /** Stop the scheduled second read (addon stop). */
96118
+ dispose() {
96119
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96120
+ this.#rerun = null;
96121
+ }
96122
+ /**
96123
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96124
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96125
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96126
+ */
96127
+ async #sweepOrphans(listedIds) {
96128
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96129
+ if (ownerIntegrationIds === void 0) return;
96130
+ try {
96131
+ const ownerIds = await ownerIntegrationIds();
96132
+ if ((await this.#sweep.sweep({
96133
+ ownerIds,
96134
+ listedIds,
96135
+ removeDevices
96136
+ })).newSuspects > 0 && this.#rerun === null) {
96137
+ this.#rerun = setTimeout(() => {
96138
+ this.#rerun = null;
96139
+ this.reconcile();
96140
+ }, this.#sweep.minIntervalMs + 1e3);
96141
+ this.#rerun.unref();
96142
+ }
96143
+ } catch (err) {
96144
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96145
+ }
96027
96146
  }
96028
96147
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96029
96148
  async onLifecycle(kind, integrationId) {
@@ -96149,6 +96268,7 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
96149
96268
  this.ctx.logger.warn("UniFi shutdown error", { meta: { error: errMsg(err) } });
96150
96269
  }
96151
96270
  this.clients = null;
96271
+ this.integrationReconciler?.dispose();
96152
96272
  this.integrationReconciler = null;
96153
96273
  unifiControllers.clear();
96154
96274
  await super.onShutdown();
@@ -96183,7 +96303,8 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
96183
96303
  await reg.upsert(integration.id, integration.name, read.connection, read.credentialsLocked);
96184
96304
  return true;
96185
96305
  },
96186
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96306
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96307
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96187
96308
  });
96188
96309
  return this.integrationReconciler;
96189
96310
  }
@@ -96202,6 +96323,21 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
96202
96323
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96203
96324
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96204
96325
  }
96326
+ /** The integrations that own devices here, for the orphan sweep. */
96327
+ async ownerIntegrationIds() {
96328
+ const reg = this.ctx.kernel.deviceRegistry;
96329
+ const devices = this.ctx.kernel.devices;
96330
+ if (!reg || !devices) return [];
96331
+ return await collectOwnerIntegrationIds({
96332
+ reg,
96333
+ devices,
96334
+ addonId: this.addonId,
96335
+ ownerOf: (cfg) => {
96336
+ const owner = cfg["integrationId"];
96337
+ return cfg["system"] === "unifi" && typeof owner === "string" ? owner : null;
96338
+ }
96339
+ });
96340
+ }
96205
96341
  async cascadeRemoveDevicesForIntegration(integrationId) {
96206
96342
  const reg = this.ctx.kernel.deviceRegistry;
96207
96343
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-unifi",
3
- "version": "0.2.152",
3
+ "version": "0.2.153",
4
4
  "description": "UniFi Network controller device-provider addon for CamStack — local-controller infra switches/APs (as containers) + network-client presence. No cameras.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -59,7 +59,7 @@
59
59
  }
60
60
  ],
61
61
  "requires": {
62
- "server": ">=1.2.719"
62
+ "server": ">=1.2.722"
63
63
  }
64
64
  },
65
65
  "files": [