@camstack/addon-provider-rademacher 0.2.155 → 0.2.156

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.156] - 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.155] - 2026-11-01
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -94487,6 +94487,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94487
94487
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94488
94488
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94489
94489
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94490
+ /**
94491
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94492
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94493
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94494
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94495
+ */
94496
+ var OrphanIntegrationSweep = class {
94497
+ #logger;
94498
+ #minIntervalMs;
94499
+ #now;
94500
+ #suspects = /* @__PURE__ */ new Map();
94501
+ constructor(options) {
94502
+ this.#logger = options.logger;
94503
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94504
+ this.#now = options.now ?? Date.now;
94505
+ }
94506
+ get minIntervalMs() {
94507
+ return this.#minIntervalMs;
94508
+ }
94509
+ async sweep(input) {
94510
+ if (input.listedIds === null) return {
94511
+ newSuspects: 0,
94512
+ removed: []
94513
+ };
94514
+ const listed = new Set(input.listedIds);
94515
+ const owners = new Set(input.ownerIds);
94516
+ const now = this.#now();
94517
+ let newSuspects = 0;
94518
+ const removed = [];
94519
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94520
+ this.#suspects.delete(id);
94521
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94522
+ integrationId: id,
94523
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94524
+ } });
94525
+ }
94526
+ for (const id of owners) {
94527
+ if (listed.has(id)) continue;
94528
+ const since = this.#suspects.get(id);
94529
+ if (since === void 0) {
94530
+ this.#suspects.set(id, now);
94531
+ newSuspects += 1;
94532
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94533
+ integrationId: id,
94534
+ minIntervalMs: this.#minIntervalMs
94535
+ } });
94536
+ continue;
94537
+ }
94538
+ if (now - since < this.#minIntervalMs) continue;
94539
+ try {
94540
+ const count = await input.removeDevices(id);
94541
+ this.#suspects.delete(id);
94542
+ removed.push(id);
94543
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94544
+ integrationId: id,
94545
+ removed: count,
94546
+ firstMissingAt: since
94547
+ } });
94548
+ } catch (err) {
94549
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94550
+ integrationId: id,
94551
+ error: errMsg(err)
94552
+ } });
94553
+ }
94554
+ }
94555
+ return {
94556
+ newSuspects,
94557
+ removed
94558
+ };
94559
+ }
94560
+ };
94561
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94562
+ async function collectOwnerIntegrationIds(input) {
94563
+ const ids = /* @__PURE__ */ new Set();
94564
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94565
+ if (d.parentDeviceId !== null) continue;
94566
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94567
+ const owner = input.ownerOf(cfg);
94568
+ if (owner !== null) ids.add(owner);
94569
+ }
94570
+ return [...ids];
94571
+ }
94490
94572
  String.raw`(?<=^|["'(\s>,\[{])`;
94491
94573
  var MB = 1024 * 1024;
94492
94574
  1024 * MB, 3072 * MB;
@@ -94507,8 +94589,14 @@ object({
94507
94589
  */
94508
94590
  var RademacherReconciler = class {
94509
94591
  #deps;
94592
+ #sweep;
94593
+ #rerun = null;
94510
94594
  constructor(deps) {
94511
94595
  this.#deps = deps;
94596
+ this.#sweep = new OrphanIntegrationSweep({
94597
+ logger: deps.logger,
94598
+ ...deps.sweepOptions
94599
+ });
94512
94600
  }
94513
94601
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
94514
94602
  async reconcile() {
@@ -94531,6 +94619,37 @@ var RademacherReconciler = class {
94531
94619
  }
94532
94620
  const enabledIds = new Set(enabled.map((i) => i.id));
94533
94621
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
94622
+ await this.#sweepOrphans(all.map((i) => i.id));
94623
+ }
94624
+ /** Stop the scheduled second read (addon stop). */
94625
+ dispose() {
94626
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
94627
+ this.#rerun = null;
94628
+ }
94629
+ /**
94630
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
94631
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
94632
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
94633
+ */
94634
+ async #sweepOrphans(listedIds) {
94635
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
94636
+ if (ownerIntegrationIds === void 0) return;
94637
+ try {
94638
+ const ownerIds = await ownerIntegrationIds();
94639
+ if ((await this.#sweep.sweep({
94640
+ ownerIds,
94641
+ listedIds,
94642
+ removeDevices
94643
+ })).newSuspects > 0 && this.#rerun === null) {
94644
+ this.#rerun = setTimeout(() => {
94645
+ this.#rerun = null;
94646
+ this.reconcile();
94647
+ }, this.#sweep.minIntervalMs + 1e3);
94648
+ this.#rerun.unref();
94649
+ }
94650
+ } catch (err) {
94651
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
94652
+ }
94534
94653
  }
94535
94654
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
94536
94655
  async onLifecycle(kind, integrationId) {
@@ -96739,6 +96858,7 @@ var RademacherProviderAddon = class extends BaseDeviceProvider {
96739
96858
  this.ctx.logger.warn("Rademacher: shutdown error", { meta: { error: errMsg(err) } });
96740
96859
  }
96741
96860
  this.clients = null;
96861
+ this.integrationReconciler?.dispose();
96742
96862
  this.integrationReconciler = null;
96743
96863
  await super.onShutdown();
96744
96864
  }
@@ -96781,7 +96901,8 @@ var RademacherProviderAddon = class extends BaseDeviceProvider {
96781
96901
  await this.requireClients().upsert(integration.id, integration.name, connection);
96782
96902
  return true;
96783
96903
  },
96784
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96904
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96905
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96785
96906
  });
96786
96907
  return this.integrationReconciler;
96787
96908
  }
@@ -96801,6 +96922,21 @@ var RademacherProviderAddon = class extends BaseDeviceProvider {
96801
96922
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96802
96923
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96803
96924
  }
96925
+ /** The integrations that own devices here, for the orphan sweep. */
96926
+ async ownerIntegrationIds() {
96927
+ const reg = this.ctx.kernel.deviceRegistry;
96928
+ const devices = this.ctx.kernel.devices;
96929
+ if (!reg || !devices) return [];
96930
+ return await collectOwnerIntegrationIds({
96931
+ reg,
96932
+ devices,
96933
+ addonId: this.addonId,
96934
+ ownerOf: (cfg) => {
96935
+ const owner = cfg["integrationId"];
96936
+ return cfg["system"] === "rademacher" && typeof owner === "string" ? owner : null;
96937
+ }
96938
+ });
96939
+ }
96804
96940
  async cascadeRemoveDevicesForIntegration(integrationId) {
96805
96941
  const reg = this.ctx.kernel.deviceRegistry;
96806
96942
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -94486,6 +94486,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94486
94486
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94487
94487
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94488
94488
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94489
+ /**
94490
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94491
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94492
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94493
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94494
+ */
94495
+ var OrphanIntegrationSweep = class {
94496
+ #logger;
94497
+ #minIntervalMs;
94498
+ #now;
94499
+ #suspects = /* @__PURE__ */ new Map();
94500
+ constructor(options) {
94501
+ this.#logger = options.logger;
94502
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94503
+ this.#now = options.now ?? Date.now;
94504
+ }
94505
+ get minIntervalMs() {
94506
+ return this.#minIntervalMs;
94507
+ }
94508
+ async sweep(input) {
94509
+ if (input.listedIds === null) return {
94510
+ newSuspects: 0,
94511
+ removed: []
94512
+ };
94513
+ const listed = new Set(input.listedIds);
94514
+ const owners = new Set(input.ownerIds);
94515
+ const now = this.#now();
94516
+ let newSuspects = 0;
94517
+ const removed = [];
94518
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94519
+ this.#suspects.delete(id);
94520
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94521
+ integrationId: id,
94522
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94523
+ } });
94524
+ }
94525
+ for (const id of owners) {
94526
+ if (listed.has(id)) continue;
94527
+ const since = this.#suspects.get(id);
94528
+ if (since === void 0) {
94529
+ this.#suspects.set(id, now);
94530
+ newSuspects += 1;
94531
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94532
+ integrationId: id,
94533
+ minIntervalMs: this.#minIntervalMs
94534
+ } });
94535
+ continue;
94536
+ }
94537
+ if (now - since < this.#minIntervalMs) continue;
94538
+ try {
94539
+ const count = await input.removeDevices(id);
94540
+ this.#suspects.delete(id);
94541
+ removed.push(id);
94542
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94543
+ integrationId: id,
94544
+ removed: count,
94545
+ firstMissingAt: since
94546
+ } });
94547
+ } catch (err) {
94548
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94549
+ integrationId: id,
94550
+ error: errMsg(err)
94551
+ } });
94552
+ }
94553
+ }
94554
+ return {
94555
+ newSuspects,
94556
+ removed
94557
+ };
94558
+ }
94559
+ };
94560
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94561
+ async function collectOwnerIntegrationIds(input) {
94562
+ const ids = /* @__PURE__ */ new Set();
94563
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94564
+ if (d.parentDeviceId !== null) continue;
94565
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94566
+ const owner = input.ownerOf(cfg);
94567
+ if (owner !== null) ids.add(owner);
94568
+ }
94569
+ return [...ids];
94570
+ }
94489
94571
  String.raw`(?<=^|["'(\s>,\[{])`;
94490
94572
  var MB = 1024 * 1024;
94491
94573
  1024 * MB, 3072 * MB;
@@ -94506,8 +94588,14 @@ object({
94506
94588
  */
94507
94589
  var RademacherReconciler = class {
94508
94590
  #deps;
94591
+ #sweep;
94592
+ #rerun = null;
94509
94593
  constructor(deps) {
94510
94594
  this.#deps = deps;
94595
+ this.#sweep = new OrphanIntegrationSweep({
94596
+ logger: deps.logger,
94597
+ ...deps.sweepOptions
94598
+ });
94511
94599
  }
94512
94600
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
94513
94601
  async reconcile() {
@@ -94530,6 +94618,37 @@ var RademacherReconciler = class {
94530
94618
  }
94531
94619
  const enabledIds = new Set(enabled.map((i) => i.id));
94532
94620
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
94621
+ await this.#sweepOrphans(all.map((i) => i.id));
94622
+ }
94623
+ /** Stop the scheduled second read (addon stop). */
94624
+ dispose() {
94625
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
94626
+ this.#rerun = null;
94627
+ }
94628
+ /**
94629
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
94630
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
94631
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
94632
+ */
94633
+ async #sweepOrphans(listedIds) {
94634
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
94635
+ if (ownerIntegrationIds === void 0) return;
94636
+ try {
94637
+ const ownerIds = await ownerIntegrationIds();
94638
+ if ((await this.#sweep.sweep({
94639
+ ownerIds,
94640
+ listedIds,
94641
+ removeDevices
94642
+ })).newSuspects > 0 && this.#rerun === null) {
94643
+ this.#rerun = setTimeout(() => {
94644
+ this.#rerun = null;
94645
+ this.reconcile();
94646
+ }, this.#sweep.minIntervalMs + 1e3);
94647
+ this.#rerun.unref();
94648
+ }
94649
+ } catch (err) {
94650
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
94651
+ }
94533
94652
  }
94534
94653
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
94535
94654
  async onLifecycle(kind, integrationId) {
@@ -96738,6 +96857,7 @@ var RademacherProviderAddon = class extends BaseDeviceProvider {
96738
96857
  this.ctx.logger.warn("Rademacher: shutdown error", { meta: { error: errMsg(err) } });
96739
96858
  }
96740
96859
  this.clients = null;
96860
+ this.integrationReconciler?.dispose();
96741
96861
  this.integrationReconciler = null;
96742
96862
  await super.onShutdown();
96743
96863
  }
@@ -96780,7 +96900,8 @@ var RademacherProviderAddon = class extends BaseDeviceProvider {
96780
96900
  await this.requireClients().upsert(integration.id, integration.name, connection);
96781
96901
  return true;
96782
96902
  },
96783
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
96903
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
96904
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
96784
96905
  });
96785
96906
  return this.integrationReconciler;
96786
96907
  }
@@ -96800,6 +96921,21 @@ var RademacherProviderAddon = class extends BaseDeviceProvider {
96800
96921
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
96801
96922
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
96802
96923
  }
96924
+ /** The integrations that own devices here, for the orphan sweep. */
96925
+ async ownerIntegrationIds() {
96926
+ const reg = this.ctx.kernel.deviceRegistry;
96927
+ const devices = this.ctx.kernel.devices;
96928
+ if (!reg || !devices) return [];
96929
+ return await collectOwnerIntegrationIds({
96930
+ reg,
96931
+ devices,
96932
+ addonId: this.addonId,
96933
+ ownerOf: (cfg) => {
96934
+ const owner = cfg["integrationId"];
96935
+ return cfg["system"] === "rademacher" && typeof owner === "string" ? owner : null;
96936
+ }
96937
+ });
96938
+ }
96803
96939
  async cascadeRemoveDevicesForIntegration(integrationId) {
96804
96940
  const reg = this.ctx.kernel.deviceRegistry;
96805
96941
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-rademacher",
3
- "version": "0.2.155",
3
+ "version": "0.2.156",
4
4
  "description": "Rademacher HomePilot device-provider addon for CamStack — wraps the @apocaliss92/noderademacher local-hub client (roller shutters over the cover cap)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -63,7 +63,7 @@
63
63
  }
64
64
  ],
65
65
  "requires": {
66
- "server": ">=1.2.719"
66
+ "server": ">=1.2.722"
67
67
  }
68
68
  },
69
69
  "files": [