@camstack/addon-provider-enpal 0.1.6 → 0.1.7

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.1.7] - 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.1.6] - 2026-11-01
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -94602,6 +94602,88 @@ function ingestReading(input) {
94602
94602
  value: converted
94603
94603
  };
94604
94604
  }
94605
+ /**
94606
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94607
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94608
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94609
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94610
+ */
94611
+ var OrphanIntegrationSweep = class {
94612
+ #logger;
94613
+ #minIntervalMs;
94614
+ #now;
94615
+ #suspects = /* @__PURE__ */ new Map();
94616
+ constructor(options) {
94617
+ this.#logger = options.logger;
94618
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94619
+ this.#now = options.now ?? Date.now;
94620
+ }
94621
+ get minIntervalMs() {
94622
+ return this.#minIntervalMs;
94623
+ }
94624
+ async sweep(input) {
94625
+ if (input.listedIds === null) return {
94626
+ newSuspects: 0,
94627
+ removed: []
94628
+ };
94629
+ const listed = new Set(input.listedIds);
94630
+ const owners = new Set(input.ownerIds);
94631
+ const now = this.#now();
94632
+ let newSuspects = 0;
94633
+ const removed = [];
94634
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94635
+ this.#suspects.delete(id);
94636
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94637
+ integrationId: id,
94638
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94639
+ } });
94640
+ }
94641
+ for (const id of owners) {
94642
+ if (listed.has(id)) continue;
94643
+ const since = this.#suspects.get(id);
94644
+ if (since === void 0) {
94645
+ this.#suspects.set(id, now);
94646
+ newSuspects += 1;
94647
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94648
+ integrationId: id,
94649
+ minIntervalMs: this.#minIntervalMs
94650
+ } });
94651
+ continue;
94652
+ }
94653
+ if (now - since < this.#minIntervalMs) continue;
94654
+ try {
94655
+ const count = await input.removeDevices(id);
94656
+ this.#suspects.delete(id);
94657
+ removed.push(id);
94658
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94659
+ integrationId: id,
94660
+ removed: count,
94661
+ firstMissingAt: since
94662
+ } });
94663
+ } catch (err) {
94664
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94665
+ integrationId: id,
94666
+ error: errMsg(err)
94667
+ } });
94668
+ }
94669
+ }
94670
+ return {
94671
+ newSuspects,
94672
+ removed
94673
+ };
94674
+ }
94675
+ };
94676
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94677
+ async function collectOwnerIntegrationIds(input) {
94678
+ const ids = /* @__PURE__ */ new Set();
94679
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94680
+ if (d.parentDeviceId !== null) continue;
94681
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94682
+ const owner = input.ownerOf(cfg);
94683
+ if (owner !== null) ids.add(owner);
94684
+ }
94685
+ return [...ids];
94686
+ }
94605
94687
  String.raw`(?<=^|["'(\s>,\[{])`;
94606
94688
  var MB = 1024 * 1024;
94607
94689
  1024 * MB, 3072 * MB;
@@ -96116,8 +96198,14 @@ var EnpalClientRegistry = class {
96116
96198
  */
96117
96199
  var EnpalReconciler = class {
96118
96200
  #deps;
96201
+ #sweep;
96202
+ #rerun = null;
96119
96203
  constructor(deps) {
96120
96204
  this.#deps = deps;
96205
+ this.#sweep = new OrphanIntegrationSweep({
96206
+ logger: deps.logger,
96207
+ ...deps.sweepOptions
96208
+ });
96121
96209
  }
96122
96210
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96123
96211
  async reconcile() {
@@ -96140,6 +96228,37 @@ var EnpalReconciler = class {
96140
96228
  }
96141
96229
  const enabledIds = new Set(enabled.map((i) => i.id));
96142
96230
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96231
+ await this.#sweepOrphans(all.map((i) => i.id));
96232
+ }
96233
+ /** Stop the scheduled second read (addon stop). */
96234
+ dispose() {
96235
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96236
+ this.#rerun = null;
96237
+ }
96238
+ /**
96239
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96240
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96241
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96242
+ */
96243
+ async #sweepOrphans(listedIds) {
96244
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96245
+ if (ownerIntegrationIds === void 0) return;
96246
+ try {
96247
+ const ownerIds = await ownerIntegrationIds();
96248
+ if ((await this.#sweep.sweep({
96249
+ ownerIds,
96250
+ listedIds,
96251
+ removeDevices
96252
+ })).newSuspects > 0 && this.#rerun === null) {
96253
+ this.#rerun = setTimeout(() => {
96254
+ this.#rerun = null;
96255
+ this.reconcile();
96256
+ }, this.#sweep.minIntervalMs + 1e3);
96257
+ this.#rerun.unref();
96258
+ }
96259
+ } catch (err) {
96260
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96261
+ }
96143
96262
  }
96144
96263
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96145
96264
  async onLifecycle(kind, integrationId) {
@@ -96310,6 +96429,7 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
96310
96429
  this.ctx.logger.warn("Enpal shutdown error", { meta: { error: errMsg(err) } });
96311
96430
  }
96312
96431
  this.clients = null;
96432
+ this.integrationReconciler?.dispose();
96313
96433
  this.integrationReconciler = null;
96314
96434
  enpalConnections.clear();
96315
96435
  await super.onShutdown();
@@ -96344,7 +96464,8 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
96344
96464
  await reg.upsert(integration.id, integration.name, read.connection, read.tokenLocked);
96345
96465
  return true;
96346
96466
  },
96347
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96467
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96468
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96348
96469
  });
96349
96470
  return this.integrationReconciler;
96350
96471
  }
@@ -96363,6 +96484,21 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
96363
96484
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96364
96485
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96365
96486
  }
96487
+ /** The integrations that own devices here, for the orphan sweep. */
96488
+ async ownerIntegrationIds() {
96489
+ const reg = this.ctx.kernel.deviceRegistry;
96490
+ const devices = this.ctx.kernel.devices;
96491
+ if (!reg || !devices) return [];
96492
+ return await collectOwnerIntegrationIds({
96493
+ reg,
96494
+ devices,
96495
+ addonId: this.addonId,
96496
+ ownerOf: (cfg) => {
96497
+ const owner = cfg["integrationId"];
96498
+ return cfg["system"] === "enpal" && typeof owner === "string" ? owner : null;
96499
+ }
96500
+ });
96501
+ }
96366
96502
  async cascadeRemoveDevicesForIntegration(integrationId) {
96367
96503
  const reg = this.ctx.kernel.deviceRegistry;
96368
96504
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -94601,6 +94601,88 @@ function ingestReading(input) {
94601
94601
  value: converted
94602
94602
  };
94603
94603
  }
94604
+ /**
94605
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94606
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94607
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94608
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94609
+ */
94610
+ var OrphanIntegrationSweep = class {
94611
+ #logger;
94612
+ #minIntervalMs;
94613
+ #now;
94614
+ #suspects = /* @__PURE__ */ new Map();
94615
+ constructor(options) {
94616
+ this.#logger = options.logger;
94617
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94618
+ this.#now = options.now ?? Date.now;
94619
+ }
94620
+ get minIntervalMs() {
94621
+ return this.#minIntervalMs;
94622
+ }
94623
+ async sweep(input) {
94624
+ if (input.listedIds === null) return {
94625
+ newSuspects: 0,
94626
+ removed: []
94627
+ };
94628
+ const listed = new Set(input.listedIds);
94629
+ const owners = new Set(input.ownerIds);
94630
+ const now = this.#now();
94631
+ let newSuspects = 0;
94632
+ const removed = [];
94633
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94634
+ this.#suspects.delete(id);
94635
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94636
+ integrationId: id,
94637
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94638
+ } });
94639
+ }
94640
+ for (const id of owners) {
94641
+ if (listed.has(id)) continue;
94642
+ const since = this.#suspects.get(id);
94643
+ if (since === void 0) {
94644
+ this.#suspects.set(id, now);
94645
+ newSuspects += 1;
94646
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94647
+ integrationId: id,
94648
+ minIntervalMs: this.#minIntervalMs
94649
+ } });
94650
+ continue;
94651
+ }
94652
+ if (now - since < this.#minIntervalMs) continue;
94653
+ try {
94654
+ const count = await input.removeDevices(id);
94655
+ this.#suspects.delete(id);
94656
+ removed.push(id);
94657
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94658
+ integrationId: id,
94659
+ removed: count,
94660
+ firstMissingAt: since
94661
+ } });
94662
+ } catch (err) {
94663
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94664
+ integrationId: id,
94665
+ error: errMsg(err)
94666
+ } });
94667
+ }
94668
+ }
94669
+ return {
94670
+ newSuspects,
94671
+ removed
94672
+ };
94673
+ }
94674
+ };
94675
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94676
+ async function collectOwnerIntegrationIds(input) {
94677
+ const ids = /* @__PURE__ */ new Set();
94678
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94679
+ if (d.parentDeviceId !== null) continue;
94680
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94681
+ const owner = input.ownerOf(cfg);
94682
+ if (owner !== null) ids.add(owner);
94683
+ }
94684
+ return [...ids];
94685
+ }
94604
94686
  String.raw`(?<=^|["'(\s>,\[{])`;
94605
94687
  var MB = 1024 * 1024;
94606
94688
  1024 * MB, 3072 * MB;
@@ -96115,8 +96197,14 @@ var EnpalClientRegistry = class {
96115
96197
  */
96116
96198
  var EnpalReconciler = class {
96117
96199
  #deps;
96200
+ #sweep;
96201
+ #rerun = null;
96118
96202
  constructor(deps) {
96119
96203
  this.#deps = deps;
96204
+ this.#sweep = new OrphanIntegrationSweep({
96205
+ logger: deps.logger,
96206
+ ...deps.sweepOptions
96207
+ });
96120
96208
  }
96121
96209
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
96122
96210
  async reconcile() {
@@ -96139,6 +96227,37 @@ var EnpalReconciler = class {
96139
96227
  }
96140
96228
  const enabledIds = new Set(enabled.map((i) => i.id));
96141
96229
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
96230
+ await this.#sweepOrphans(all.map((i) => i.id));
96231
+ }
96232
+ /** Stop the scheduled second read (addon stop). */
96233
+ dispose() {
96234
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
96235
+ this.#rerun = null;
96236
+ }
96237
+ /**
96238
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
96239
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
96240
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
96241
+ */
96242
+ async #sweepOrphans(listedIds) {
96243
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
96244
+ if (ownerIntegrationIds === void 0) return;
96245
+ try {
96246
+ const ownerIds = await ownerIntegrationIds();
96247
+ if ((await this.#sweep.sweep({
96248
+ ownerIds,
96249
+ listedIds,
96250
+ removeDevices
96251
+ })).newSuspects > 0 && this.#rerun === null) {
96252
+ this.#rerun = setTimeout(() => {
96253
+ this.#rerun = null;
96254
+ this.reconcile();
96255
+ }, this.#sweep.minIntervalMs + 1e3);
96256
+ this.#rerun.unref();
96257
+ }
96258
+ } catch (err) {
96259
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
96260
+ }
96142
96261
  }
96143
96262
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
96144
96263
  async onLifecycle(kind, integrationId) {
@@ -96309,6 +96428,7 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
96309
96428
  this.ctx.logger.warn("Enpal shutdown error", { meta: { error: errMsg(err) } });
96310
96429
  }
96311
96430
  this.clients = null;
96431
+ this.integrationReconciler?.dispose();
96312
96432
  this.integrationReconciler = null;
96313
96433
  enpalConnections.clear();
96314
96434
  await super.onShutdown();
@@ -96343,7 +96463,8 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
96343
96463
  await reg.upsert(integration.id, integration.name, read.connection, read.tokenLocked);
96344
96464
  return true;
96345
96465
  },
96346
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96466
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96467
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96347
96468
  });
96348
96469
  return this.integrationReconciler;
96349
96470
  }
@@ -96362,6 +96483,21 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
96362
96483
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96363
96484
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96364
96485
  }
96486
+ /** The integrations that own devices here, for the orphan sweep. */
96487
+ async ownerIntegrationIds() {
96488
+ const reg = this.ctx.kernel.deviceRegistry;
96489
+ const devices = this.ctx.kernel.devices;
96490
+ if (!reg || !devices) return [];
96491
+ return await collectOwnerIntegrationIds({
96492
+ reg,
96493
+ devices,
96494
+ addonId: this.addonId,
96495
+ ownerOf: (cfg) => {
96496
+ const owner = cfg["integrationId"];
96497
+ return cfg["system"] === "enpal" && typeof owner === "string" ? owner : null;
96498
+ }
96499
+ });
96500
+ }
96365
96501
  async cascadeRemoveDevicesForIntegration(integrationId) {
96366
96502
  const reg = this.ctx.kernel.deviceRegistry;
96367
96503
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-enpal",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Enpal solar-box device-provider addon for CamStack — PV, battery, grid, house and heat-pump readings from the box's local InfluxDB (device page as fallback)",
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": [