@camstack/addon-provider-dreame 0.2.162 → 0.2.163

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.163] - 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.162] - 2026-11-01
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -147951,6 +147951,88 @@ function ingestReading(input) {
147951
147951
  value: converted
147952
147952
  };
147953
147953
  }
147954
+ /**
147955
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
147956
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
147957
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
147958
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
147959
+ */
147960
+ var OrphanIntegrationSweep = class {
147961
+ #logger;
147962
+ #minIntervalMs;
147963
+ #now;
147964
+ #suspects = /* @__PURE__ */ new Map();
147965
+ constructor(options) {
147966
+ this.#logger = options.logger;
147967
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
147968
+ this.#now = options.now ?? Date.now;
147969
+ }
147970
+ get minIntervalMs() {
147971
+ return this.#minIntervalMs;
147972
+ }
147973
+ async sweep(input) {
147974
+ if (input.listedIds === null) return {
147975
+ newSuspects: 0,
147976
+ removed: []
147977
+ };
147978
+ const listed = new Set(input.listedIds);
147979
+ const owners = new Set(input.ownerIds);
147980
+ const now = this.#now();
147981
+ let newSuspects = 0;
147982
+ const removed = [];
147983
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
147984
+ this.#suspects.delete(id);
147985
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
147986
+ integrationId: id,
147987
+ reason: listed.has(id) ? "listed again" : "owns no devices"
147988
+ } });
147989
+ }
147990
+ for (const id of owners) {
147991
+ if (listed.has(id)) continue;
147992
+ const since = this.#suspects.get(id);
147993
+ if (since === void 0) {
147994
+ this.#suspects.set(id, now);
147995
+ newSuspects += 1;
147996
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
147997
+ integrationId: id,
147998
+ minIntervalMs: this.#minIntervalMs
147999
+ } });
148000
+ continue;
148001
+ }
148002
+ if (now - since < this.#minIntervalMs) continue;
148003
+ try {
148004
+ const count = await input.removeDevices(id);
148005
+ this.#suspects.delete(id);
148006
+ removed.push(id);
148007
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
148008
+ integrationId: id,
148009
+ removed: count,
148010
+ firstMissingAt: since
148011
+ } });
148012
+ } catch (err) {
148013
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
148014
+ integrationId: id,
148015
+ error: errMsg(err)
148016
+ } });
148017
+ }
148018
+ }
148019
+ return {
148020
+ newSuspects,
148021
+ removed
148022
+ };
148023
+ }
148024
+ };
148025
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
148026
+ async function collectOwnerIntegrationIds(input) {
148027
+ const ids = /* @__PURE__ */ new Set();
148028
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
148029
+ if (d.parentDeviceId !== null) continue;
148030
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
148031
+ const owner = input.ownerOf(cfg);
148032
+ if (owner !== null) ids.add(owner);
148033
+ }
148034
+ return [...ids];
148035
+ }
147954
148036
  String.raw`(?<=^|["'(\s>,\[{])`;
147955
148037
  var MB = 1024 * 1024;
147956
148038
  1024 * MB, 3072 * MB;
@@ -147971,8 +148053,14 @@ object({
147971
148053
  */
147972
148054
  var DreameReconciler = class {
147973
148055
  #deps;
148056
+ #sweep;
148057
+ #rerun = null;
147974
148058
  constructor(deps) {
147975
148059
  this.#deps = deps;
148060
+ this.#sweep = new OrphanIntegrationSweep({
148061
+ logger: deps.logger,
148062
+ ...deps.sweepOptions
148063
+ });
147976
148064
  }
147977
148065
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
147978
148066
  async reconcile() {
@@ -147995,6 +148083,37 @@ var DreameReconciler = class {
147995
148083
  }
147996
148084
  const enabledIds = new Set(enabled.map((i) => i.id));
147997
148085
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
148086
+ await this.#sweepOrphans(all.map((i) => i.id));
148087
+ }
148088
+ /** Stop the scheduled second read (addon stop). */
148089
+ dispose() {
148090
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
148091
+ this.#rerun = null;
148092
+ }
148093
+ /**
148094
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
148095
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
148096
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
148097
+ */
148098
+ async #sweepOrphans(listedIds) {
148099
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
148100
+ if (ownerIntegrationIds === void 0) return;
148101
+ try {
148102
+ const ownerIds = await ownerIntegrationIds();
148103
+ if ((await this.#sweep.sweep({
148104
+ ownerIds,
148105
+ listedIds,
148106
+ removeDevices
148107
+ })).newSuspects > 0 && this.#rerun === null) {
148108
+ this.#rerun = setTimeout(() => {
148109
+ this.#rerun = null;
148110
+ this.reconcile();
148111
+ }, this.#sweep.minIntervalMs + 1e3);
148112
+ this.#rerun.unref();
148113
+ }
148114
+ } catch (err) {
148115
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
148116
+ }
147998
148117
  }
147999
148118
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
148000
148119
  async onLifecycle(kind, integrationId) {
@@ -201120,6 +201239,7 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
201120
201239
  this.ctx.logger.warn("Dreame: provider shutdown error", { meta: { error: errMsg(err) } });
201121
201240
  }
201122
201241
  this.clients = null;
201242
+ this.integrationReconciler?.dispose();
201123
201243
  this.integrationReconciler = null;
201124
201244
  dreameSession.unbind();
201125
201245
  await super.onShutdown();
@@ -201192,7 +201312,8 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
201192
201312
  await this.requireClients().upsert(integration.id, integration.name, connection);
201193
201313
  return true;
201194
201314
  },
201195
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
201315
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
201316
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
201196
201317
  });
201197
201318
  return this.integrationReconciler;
201198
201319
  }
@@ -201212,6 +201333,21 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
201212
201333
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
201213
201334
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
201214
201335
  }
201336
+ /** The integrations that own devices here, for the orphan sweep. */
201337
+ async ownerIntegrationIds() {
201338
+ const reg = this.ctx.kernel.deviceRegistry;
201339
+ const devices = this.ctx.kernel.devices;
201340
+ if (!reg || !devices) return [];
201341
+ return await collectOwnerIntegrationIds({
201342
+ reg,
201343
+ devices,
201344
+ addonId: this.addonId,
201345
+ ownerOf: (cfg) => {
201346
+ const owner = cfg["brokerId"];
201347
+ return cfg["system"] === "dreame" && typeof owner === "string" ? owner : null;
201348
+ }
201349
+ });
201350
+ }
201215
201351
  async cascadeRemoveDevicesForIntegration(integrationId) {
201216
201352
  const reg = this.ctx.kernel.deviceRegistry;
201217
201353
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -147948,6 +147948,88 @@ function ingestReading(input) {
147948
147948
  value: converted
147949
147949
  };
147950
147950
  }
147951
+ /**
147952
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
147953
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
147954
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
147955
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
147956
+ */
147957
+ var OrphanIntegrationSweep = class {
147958
+ #logger;
147959
+ #minIntervalMs;
147960
+ #now;
147961
+ #suspects = /* @__PURE__ */ new Map();
147962
+ constructor(options) {
147963
+ this.#logger = options.logger;
147964
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
147965
+ this.#now = options.now ?? Date.now;
147966
+ }
147967
+ get minIntervalMs() {
147968
+ return this.#minIntervalMs;
147969
+ }
147970
+ async sweep(input) {
147971
+ if (input.listedIds === null) return {
147972
+ newSuspects: 0,
147973
+ removed: []
147974
+ };
147975
+ const listed = new Set(input.listedIds);
147976
+ const owners = new Set(input.ownerIds);
147977
+ const now = this.#now();
147978
+ let newSuspects = 0;
147979
+ const removed = [];
147980
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
147981
+ this.#suspects.delete(id);
147982
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
147983
+ integrationId: id,
147984
+ reason: listed.has(id) ? "listed again" : "owns no devices"
147985
+ } });
147986
+ }
147987
+ for (const id of owners) {
147988
+ if (listed.has(id)) continue;
147989
+ const since = this.#suspects.get(id);
147990
+ if (since === void 0) {
147991
+ this.#suspects.set(id, now);
147992
+ newSuspects += 1;
147993
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
147994
+ integrationId: id,
147995
+ minIntervalMs: this.#minIntervalMs
147996
+ } });
147997
+ continue;
147998
+ }
147999
+ if (now - since < this.#minIntervalMs) continue;
148000
+ try {
148001
+ const count = await input.removeDevices(id);
148002
+ this.#suspects.delete(id);
148003
+ removed.push(id);
148004
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
148005
+ integrationId: id,
148006
+ removed: count,
148007
+ firstMissingAt: since
148008
+ } });
148009
+ } catch (err) {
148010
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
148011
+ integrationId: id,
148012
+ error: errMsg(err)
148013
+ } });
148014
+ }
148015
+ }
148016
+ return {
148017
+ newSuspects,
148018
+ removed
148019
+ };
148020
+ }
148021
+ };
148022
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
148023
+ async function collectOwnerIntegrationIds(input) {
148024
+ const ids = /* @__PURE__ */ new Set();
148025
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
148026
+ if (d.parentDeviceId !== null) continue;
148027
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
148028
+ const owner = input.ownerOf(cfg);
148029
+ if (owner !== null) ids.add(owner);
148030
+ }
148031
+ return [...ids];
148032
+ }
147951
148033
  String.raw`(?<=^|["'(\s>,\[{])`;
147952
148034
  var MB = 1024 * 1024;
147953
148035
  1024 * MB, 3072 * MB;
@@ -147968,8 +148050,14 @@ object({
147968
148050
  */
147969
148051
  var DreameReconciler = class {
147970
148052
  #deps;
148053
+ #sweep;
148054
+ #rerun = null;
147971
148055
  constructor(deps) {
147972
148056
  this.#deps = deps;
148057
+ this.#sweep = new OrphanIntegrationSweep({
148058
+ logger: deps.logger,
148059
+ ...deps.sweepOptions
148060
+ });
147973
148061
  }
147974
148062
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
147975
148063
  async reconcile() {
@@ -147992,6 +148080,37 @@ var DreameReconciler = class {
147992
148080
  }
147993
148081
  const enabledIds = new Set(enabled.map((i) => i.id));
147994
148082
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
148083
+ await this.#sweepOrphans(all.map((i) => i.id));
148084
+ }
148085
+ /** Stop the scheduled second read (addon stop). */
148086
+ dispose() {
148087
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
148088
+ this.#rerun = null;
148089
+ }
148090
+ /**
148091
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
148092
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
148093
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
148094
+ */
148095
+ async #sweepOrphans(listedIds) {
148096
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
148097
+ if (ownerIntegrationIds === void 0) return;
148098
+ try {
148099
+ const ownerIds = await ownerIntegrationIds();
148100
+ if ((await this.#sweep.sweep({
148101
+ ownerIds,
148102
+ listedIds,
148103
+ removeDevices
148104
+ })).newSuspects > 0 && this.#rerun === null) {
148105
+ this.#rerun = setTimeout(() => {
148106
+ this.#rerun = null;
148107
+ this.reconcile();
148108
+ }, this.#sweep.minIntervalMs + 1e3);
148109
+ this.#rerun.unref();
148110
+ }
148111
+ } catch (err) {
148112
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
148113
+ }
147995
148114
  }
147996
148115
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
147997
148116
  async onLifecycle(kind, integrationId) {
@@ -201117,6 +201236,7 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
201117
201236
  this.ctx.logger.warn("Dreame: provider shutdown error", { meta: { error: errMsg(err) } });
201118
201237
  }
201119
201238
  this.clients = null;
201239
+ this.integrationReconciler?.dispose();
201120
201240
  this.integrationReconciler = null;
201121
201241
  dreameSession.unbind();
201122
201242
  await super.onShutdown();
@@ -201189,7 +201309,8 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
201189
201309
  await this.requireClients().upsert(integration.id, integration.name, connection);
201190
201310
  return true;
201191
201311
  },
201192
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
201312
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
201313
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
201193
201314
  });
201194
201315
  return this.integrationReconciler;
201195
201316
  }
@@ -201209,6 +201330,21 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
201209
201330
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
201210
201331
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
201211
201332
  }
201333
+ /** The integrations that own devices here, for the orphan sweep. */
201334
+ async ownerIntegrationIds() {
201335
+ const reg = this.ctx.kernel.deviceRegistry;
201336
+ const devices = this.ctx.kernel.devices;
201337
+ if (!reg || !devices) return [];
201338
+ return await collectOwnerIntegrationIds({
201339
+ reg,
201340
+ devices,
201341
+ addonId: this.addonId,
201342
+ ownerOf: (cfg) => {
201343
+ const owner = cfg["brokerId"];
201344
+ return cfg["system"] === "dreame" && typeof owner === "string" ? owner : null;
201345
+ }
201346
+ });
201347
+ }
201212
201348
  async cascadeRemoveDevicesForIntegration(integrationId) {
201213
201349
  const reg = this.ctx.kernel.deviceRegistry;
201214
201350
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-dreame",
3
- "version": "0.2.162",
3
+ "version": "0.2.163",
4
4
  "description": "Dreame robot-vacuum / lawn-mower device-provider addon for CamStack — wraps the @apocaliss92/nodedreame Dreamehome cloud client",
5
5
  "keywords": [
6
6
  "camstack",
@@ -74,7 +74,7 @@
74
74
  }
75
75
  ],
76
76
  "requires": {
77
- "server": ">=1.2.719"
77
+ "server": ">=1.2.722"
78
78
  }
79
79
  },
80
80
  "files": [