@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 +6 -0
- package/dist/addon.js +137 -1
- package/dist/addon.mjs +137 -1
- package/package.json +2 -2
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.
|
|
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.
|
|
62
|
+
"server": ">=1.2.722"
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"files": [
|