@camstack/addon-provider-velux 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
@@ -94566,6 +94566,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94566
94566
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94567
94567
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94568
94568
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94569
+ /**
94570
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94571
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94572
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94573
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94574
+ */
94575
+ var OrphanIntegrationSweep = class {
94576
+ #logger;
94577
+ #minIntervalMs;
94578
+ #now;
94579
+ #suspects = /* @__PURE__ */ new Map();
94580
+ constructor(options) {
94581
+ this.#logger = options.logger;
94582
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94583
+ this.#now = options.now ?? Date.now;
94584
+ }
94585
+ get minIntervalMs() {
94586
+ return this.#minIntervalMs;
94587
+ }
94588
+ async sweep(input) {
94589
+ if (input.listedIds === null) return {
94590
+ newSuspects: 0,
94591
+ removed: []
94592
+ };
94593
+ const listed = new Set(input.listedIds);
94594
+ const owners = new Set(input.ownerIds);
94595
+ const now = this.#now();
94596
+ let newSuspects = 0;
94597
+ const removed = [];
94598
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94599
+ this.#suspects.delete(id);
94600
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94601
+ integrationId: id,
94602
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94603
+ } });
94604
+ }
94605
+ for (const id of owners) {
94606
+ if (listed.has(id)) continue;
94607
+ const since = this.#suspects.get(id);
94608
+ if (since === void 0) {
94609
+ this.#suspects.set(id, now);
94610
+ newSuspects += 1;
94611
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94612
+ integrationId: id,
94613
+ minIntervalMs: this.#minIntervalMs
94614
+ } });
94615
+ continue;
94616
+ }
94617
+ if (now - since < this.#minIntervalMs) continue;
94618
+ try {
94619
+ const count = await input.removeDevices(id);
94620
+ this.#suspects.delete(id);
94621
+ removed.push(id);
94622
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94623
+ integrationId: id,
94624
+ removed: count,
94625
+ firstMissingAt: since
94626
+ } });
94627
+ } catch (err) {
94628
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94629
+ integrationId: id,
94630
+ error: errMsg(err)
94631
+ } });
94632
+ }
94633
+ }
94634
+ return {
94635
+ newSuspects,
94636
+ removed
94637
+ };
94638
+ }
94639
+ };
94640
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94641
+ async function collectOwnerIntegrationIds(input) {
94642
+ const ids = /* @__PURE__ */ new Set();
94643
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94644
+ if (d.parentDeviceId !== null) continue;
94645
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94646
+ const owner = input.ownerOf(cfg);
94647
+ if (owner !== null) ids.add(owner);
94648
+ }
94649
+ return [...ids];
94650
+ }
94569
94651
  String.raw`(?<=^|["'(\s>,\[{])`;
94570
94652
  var MB = 1024 * 1024;
94571
94653
  1024 * MB, 3072 * MB;
@@ -94586,8 +94668,14 @@ object({
94586
94668
  */
94587
94669
  var VeluxReconciler = class {
94588
94670
  #deps;
94671
+ #sweep;
94672
+ #rerun = null;
94589
94673
  constructor(deps) {
94590
94674
  this.#deps = deps;
94675
+ this.#sweep = new OrphanIntegrationSweep({
94676
+ logger: deps.logger,
94677
+ ...deps.sweepOptions
94678
+ });
94591
94679
  }
94592
94680
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
94593
94681
  async reconcile() {
@@ -94610,6 +94698,37 @@ var VeluxReconciler = class {
94610
94698
  }
94611
94699
  const enabledIds = new Set(enabled.map((i) => i.id));
94612
94700
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
94701
+ await this.#sweepOrphans(all.map((i) => i.id));
94702
+ }
94703
+ /** Stop the scheduled second read (addon stop). */
94704
+ dispose() {
94705
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
94706
+ this.#rerun = null;
94707
+ }
94708
+ /**
94709
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
94710
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
94711
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
94712
+ */
94713
+ async #sweepOrphans(listedIds) {
94714
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
94715
+ if (ownerIntegrationIds === void 0) return;
94716
+ try {
94717
+ const ownerIds = await ownerIntegrationIds();
94718
+ if ((await this.#sweep.sweep({
94719
+ ownerIds,
94720
+ listedIds,
94721
+ removeDevices
94722
+ })).newSuspects > 0 && this.#rerun === null) {
94723
+ this.#rerun = setTimeout(() => {
94724
+ this.#rerun = null;
94725
+ this.reconcile();
94726
+ }, this.#sweep.minIntervalMs + 1e3);
94727
+ this.#rerun.unref();
94728
+ }
94729
+ } catch (err) {
94730
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
94731
+ }
94613
94732
  }
94614
94733
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
94615
94734
  async onLifecycle(kind, integrationId) {
@@ -103662,6 +103781,7 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103662
103781
  this.ctx.logger.warn("Velux: shutdown error", { meta: { error: errMsg(err) } });
103663
103782
  }
103664
103783
  this.clients = null;
103784
+ this.integrationReconciler?.dispose();
103665
103785
  this.integrationReconciler = null;
103666
103786
  setKlfLibraryLogSink(null);
103667
103787
  await super.onShutdown();
@@ -103746,7 +103866,8 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103746
103866
  await this.requireClients().upsert(integration.id, integration.name, connection);
103747
103867
  return true;
103748
103868
  },
103749
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
103869
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
103870
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
103750
103871
  });
103751
103872
  return this.integrationReconciler;
103752
103873
  }
@@ -103766,6 +103887,21 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103766
103887
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
103767
103888
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
103768
103889
  }
103890
+ /** The integrations that own devices here, for the orphan sweep. */
103891
+ async ownerIntegrationIds() {
103892
+ const reg = this.ctx.kernel.deviceRegistry;
103893
+ const devices = this.ctx.kernel.devices;
103894
+ if (!reg || !devices) return [];
103895
+ return await collectOwnerIntegrationIds({
103896
+ reg,
103897
+ devices,
103898
+ addonId: this.addonId,
103899
+ ownerOf: (cfg) => {
103900
+ const owner = cfg["integrationId"];
103901
+ return cfg["system"] === "velux" && typeof owner === "string" ? owner : null;
103902
+ }
103903
+ });
103904
+ }
103769
103905
  async cascadeRemoveDevicesForIntegration(integrationId) {
103770
103906
  const reg = this.ctx.kernel.deviceRegistry;
103771
103907
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -94567,6 +94567,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94567
94567
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94568
94568
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94569
94569
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94570
+ /**
94571
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94572
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94573
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94574
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94575
+ */
94576
+ var OrphanIntegrationSweep = class {
94577
+ #logger;
94578
+ #minIntervalMs;
94579
+ #now;
94580
+ #suspects = /* @__PURE__ */ new Map();
94581
+ constructor(options) {
94582
+ this.#logger = options.logger;
94583
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94584
+ this.#now = options.now ?? Date.now;
94585
+ }
94586
+ get minIntervalMs() {
94587
+ return this.#minIntervalMs;
94588
+ }
94589
+ async sweep(input) {
94590
+ if (input.listedIds === null) return {
94591
+ newSuspects: 0,
94592
+ removed: []
94593
+ };
94594
+ const listed = new Set(input.listedIds);
94595
+ const owners = new Set(input.ownerIds);
94596
+ const now = this.#now();
94597
+ let newSuspects = 0;
94598
+ const removed = [];
94599
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94600
+ this.#suspects.delete(id);
94601
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94602
+ integrationId: id,
94603
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94604
+ } });
94605
+ }
94606
+ for (const id of owners) {
94607
+ if (listed.has(id)) continue;
94608
+ const since = this.#suspects.get(id);
94609
+ if (since === void 0) {
94610
+ this.#suspects.set(id, now);
94611
+ newSuspects += 1;
94612
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94613
+ integrationId: id,
94614
+ minIntervalMs: this.#minIntervalMs
94615
+ } });
94616
+ continue;
94617
+ }
94618
+ if (now - since < this.#minIntervalMs) continue;
94619
+ try {
94620
+ const count = await input.removeDevices(id);
94621
+ this.#suspects.delete(id);
94622
+ removed.push(id);
94623
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94624
+ integrationId: id,
94625
+ removed: count,
94626
+ firstMissingAt: since
94627
+ } });
94628
+ } catch (err) {
94629
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94630
+ integrationId: id,
94631
+ error: errMsg(err)
94632
+ } });
94633
+ }
94634
+ }
94635
+ return {
94636
+ newSuspects,
94637
+ removed
94638
+ };
94639
+ }
94640
+ };
94641
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94642
+ async function collectOwnerIntegrationIds(input) {
94643
+ const ids = /* @__PURE__ */ new Set();
94644
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94645
+ if (d.parentDeviceId !== null) continue;
94646
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94647
+ const owner = input.ownerOf(cfg);
94648
+ if (owner !== null) ids.add(owner);
94649
+ }
94650
+ return [...ids];
94651
+ }
94570
94652
  String.raw`(?<=^|["'(\s>,\[{])`;
94571
94653
  var MB = 1024 * 1024;
94572
94654
  1024 * MB, 3072 * MB;
@@ -94587,8 +94669,14 @@ object({
94587
94669
  */
94588
94670
  var VeluxReconciler = class {
94589
94671
  #deps;
94672
+ #sweep;
94673
+ #rerun = null;
94590
94674
  constructor(deps) {
94591
94675
  this.#deps = deps;
94676
+ this.#sweep = new OrphanIntegrationSweep({
94677
+ logger: deps.logger,
94678
+ ...deps.sweepOptions
94679
+ });
94592
94680
  }
94593
94681
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
94594
94682
  async reconcile() {
@@ -94611,6 +94699,37 @@ var VeluxReconciler = class {
94611
94699
  }
94612
94700
  const enabledIds = new Set(enabled.map((i) => i.id));
94613
94701
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
94702
+ await this.#sweepOrphans(all.map((i) => i.id));
94703
+ }
94704
+ /** Stop the scheduled second read (addon stop). */
94705
+ dispose() {
94706
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
94707
+ this.#rerun = null;
94708
+ }
94709
+ /**
94710
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
94711
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
94712
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
94713
+ */
94714
+ async #sweepOrphans(listedIds) {
94715
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
94716
+ if (ownerIntegrationIds === void 0) return;
94717
+ try {
94718
+ const ownerIds = await ownerIntegrationIds();
94719
+ if ((await this.#sweep.sweep({
94720
+ ownerIds,
94721
+ listedIds,
94722
+ removeDevices
94723
+ })).newSuspects > 0 && this.#rerun === null) {
94724
+ this.#rerun = setTimeout(() => {
94725
+ this.#rerun = null;
94726
+ this.reconcile();
94727
+ }, this.#sweep.minIntervalMs + 1e3);
94728
+ this.#rerun.unref();
94729
+ }
94730
+ } catch (err) {
94731
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
94732
+ }
94614
94733
  }
94615
94734
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
94616
94735
  async onLifecycle(kind, integrationId) {
@@ -103663,6 +103782,7 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103663
103782
  this.ctx.logger.warn("Velux: shutdown error", { meta: { error: errMsg(err) } });
103664
103783
  }
103665
103784
  this.clients = null;
103785
+ this.integrationReconciler?.dispose();
103666
103786
  this.integrationReconciler = null;
103667
103787
  setKlfLibraryLogSink(null);
103668
103788
  await super.onShutdown();
@@ -103747,7 +103867,8 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103747
103867
  await this.requireClients().upsert(integration.id, integration.name, connection);
103748
103868
  return true;
103749
103869
  },
103750
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
103870
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
103871
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
103751
103872
  });
103752
103873
  return this.integrationReconciler;
103753
103874
  }
@@ -103767,6 +103888,21 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103767
103888
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
103768
103889
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
103769
103890
  }
103891
+ /** The integrations that own devices here, for the orphan sweep. */
103892
+ async ownerIntegrationIds() {
103893
+ const reg = this.ctx.kernel.deviceRegistry;
103894
+ const devices = this.ctx.kernel.devices;
103895
+ if (!reg || !devices) return [];
103896
+ return await collectOwnerIntegrationIds({
103897
+ reg,
103898
+ devices,
103899
+ addonId: this.addonId,
103900
+ ownerOf: (cfg) => {
103901
+ const owner = cfg["integrationId"];
103902
+ return cfg["system"] === "velux" && typeof owner === "string" ? owner : null;
103903
+ }
103904
+ });
103905
+ }
103770
103906
  async cascadeRemoveDevicesForIntegration(integrationId) {
103771
103907
  const reg = this.ctx.kernel.deviceRegistry;
103772
103908
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-velux",
3
- "version": "0.2.152",
3
+ "version": "0.2.153",
4
4
  "description": "Velux KLF-200 integration for CamStack — the gateway, local-gateway windows (with rain sensor) and shutter covers (positional, no tilt)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -62,7 +62,7 @@
62
62
  }
63
63
  ],
64
64
  "requires": {
65
- "server": ">=1.2.719"
65
+ "server": ">=1.2.722"
66
66
  }
67
67
  },
68
68
  "files": [