iobroker.goodwe-sems 0.1.0

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/main.js ADDED
@@ -0,0 +1,400 @@
1
+ "use strict";
2
+
3
+ const utils = require("@iobroker/adapter-core");
4
+ const { SemsApi } = require("./lib/semsApi");
5
+ const { Notifier } = require("./lib/notify");
6
+ const { mapMonitorDetail } = require("./lib/mapping");
7
+ const {
8
+ SemsAuthError,
9
+ SemsRateLimitError,
10
+ SemsNetworkError,
11
+ SemsProtocolError,
12
+ } = require("./lib/errors");
13
+
14
+ // Hard floor for the poll interval, independent of user configuration.
15
+ // Protects the SEMS account from being rate-limited/locked even if someone
16
+ // sets an unreasonably low value in the admin UI.
17
+ const MIN_POLL_INTERVAL_SEC = 60;
18
+ // Ceiling for the exponential backoff on repeated errors.
19
+ const MAX_BACKOFF_SEC = 3600;
20
+
21
+ class GoodweSems extends utils.Adapter {
22
+ /**
23
+ * @param {Partial<utils.AdapterOptions>} [options]
24
+ */
25
+ constructor(options) {
26
+ super({ ...options, name: "goodwe-sems" });
27
+
28
+ this.on("ready", this.onReady.bind(this));
29
+ this.on("unload", this.onUnload.bind(this));
30
+
31
+ this.pollTimer = null;
32
+ this.api = null;
33
+ this.notifier = null;
34
+ this.consecutiveErrors = 0;
35
+ this.stationOfflineNotified = false;
36
+ this.lastSuccessTs = 0;
37
+ this.startTs = 0;
38
+ this.knownObjectIds = new Set();
39
+ this.stationId = null;
40
+ this.destroyed = false;
41
+ this.basePollIntervalSec = 300;
42
+ this.stationOfflineMs = 30 * 60000;
43
+ }
44
+
45
+ async onReady() {
46
+ await this.setStateAsync("info.connection", false, true);
47
+ this.startTs = Date.now();
48
+
49
+ if (!this.config.account || !this.config.password) {
50
+ this.log.error(
51
+ "SEMS-Zugangsdaten fehlen (Benutzer/Passwort). Bitte in der Instanzkonfiguration eintragen und die Instanz danach neu starten.",
52
+ );
53
+ return;
54
+ }
55
+
56
+ this.basePollIntervalSec = Math.max(
57
+ MIN_POLL_INTERVAL_SEC,
58
+ Number(this.config.pollInterval) || 300,
59
+ );
60
+ if (
61
+ Number(this.config.pollInterval) &&
62
+ Number(this.config.pollInterval) < MIN_POLL_INTERVAL_SEC
63
+ ) {
64
+ this.log.warn(
65
+ `Konfiguriertes Poll-Intervall (${this.config.pollInterval}s) liegt unter dem Minimum von ${MIN_POLL_INTERVAL_SEC}s ` +
66
+ `und wurde zum Schutz vor SEMS-Rate-Limits auf ${this.basePollIntervalSec}s angehoben.`,
67
+ );
68
+ }
69
+ this.maxConsecutiveErrors = Math.max(
70
+ 1,
71
+ Number(this.config.maxConsecutiveErrors) || 3,
72
+ );
73
+ this.stationOfflineMs =
74
+ Math.max(1, Number(this.config.stationOfflineMinutes) || 30) *
75
+ 60000;
76
+
77
+ this.notifier = new Notifier(this, this.config);
78
+ this.api = new SemsApi({
79
+ account: this.config.account,
80
+ password: this.config.password,
81
+ requestTimeoutMs:
82
+ Math.max(5, Number(this.config.requestTimeout) || 15) * 1000,
83
+ log: (level, message) => {
84
+ if (typeof this.log[level] === "function") {
85
+ this.log[level](message);
86
+ } else {
87
+ this.log.debug(message);
88
+ }
89
+ },
90
+ });
91
+
92
+ await this.setStateAsync(
93
+ "info.activePollInterval",
94
+ this.basePollIntervalSec,
95
+ true,
96
+ );
97
+ this.log.info(
98
+ `GoodWe SEMS Adapter gestartet. Poll-Intervall: ${this.basePollIntervalSec}s, Konto: ${this._maskAccount(this.config.account)}.`,
99
+ );
100
+
101
+ this._schedulePoll(0);
102
+ }
103
+
104
+ onUnload(callback) {
105
+ try {
106
+ this.destroyed = true;
107
+ if (this.pollTimer) {
108
+ this.clearTimeout(this.pollTimer);
109
+ this.pollTimer = null;
110
+ }
111
+ callback();
112
+ } catch (error) {
113
+ this.log.error(
114
+ `Fehler beim Beenden des Adapters: ${error.message}`,
115
+ );
116
+ callback();
117
+ }
118
+ }
119
+
120
+ _schedulePoll(delayMs) {
121
+ if (this.destroyed) {
122
+ return;
123
+ }
124
+ if (this.pollTimer) {
125
+ this.clearTimeout(this.pollTimer);
126
+ }
127
+ this.pollTimer = this.setTimeout(() => {
128
+ this._pollCycle().catch((error) => {
129
+ // _pollCycle already handles its own errors; this is a last-resort
130
+ // safety net so a programming mistake can never silently kill the
131
+ // polling loop.
132
+ this.log.error(
133
+ `Unbehandelter Fehler im Poll-Zyklus: ${error.stack || error.message}`,
134
+ );
135
+ this._schedulePoll(this.basePollIntervalSec * 1000);
136
+ });
137
+ }, delayMs);
138
+ }
139
+
140
+ async _pollCycle() {
141
+ if (this.destroyed) {
142
+ return;
143
+ }
144
+ const startedAt = Date.now();
145
+ try {
146
+ await this._resolveStationId();
147
+ const detail = await this.api.getMonitorDetail(this.stationId);
148
+ await this._applyMonitorDetail(detail);
149
+
150
+ this.consecutiveErrors = 0;
151
+ this.stationOfflineNotified = false;
152
+ this.lastSuccessTs = Date.now();
153
+ this.notifier.resetDedupe("stationOffline");
154
+ this.notifier.resetDedupe("loginFailure");
155
+ this.notifier.resetDedupe("rateLimit");
156
+
157
+ await this.setStateAsync("info.connection", true, true);
158
+ await this.setStateAsync(
159
+ "info.lastSuccess",
160
+ this.lastSuccessTs,
161
+ true,
162
+ );
163
+ await this.setStateAsync("info.lastError", "", true);
164
+ await this.setStateAsync("info.consecutiveErrors", 0, true);
165
+ await this.setStateAsync("info.rateLimited", false, true);
166
+ await this.setStateAsync(
167
+ "info.activePollInterval",
168
+ this.basePollIntervalSec,
169
+ true,
170
+ );
171
+
172
+ this.log.debug(
173
+ `Poll-Zyklus erfolgreich (${Date.now() - startedAt} ms), nächster Abruf in ${this.basePollIntervalSec}s.`,
174
+ );
175
+ this._schedulePoll(this.basePollIntervalSec * 1000);
176
+ } catch (error) {
177
+ await this._handlePollError(error);
178
+ }
179
+ }
180
+
181
+ async _resolveStationId() {
182
+ if (this.stationId) {
183
+ return;
184
+ }
185
+
186
+ const configuredId = (this.config.powerStationId || "").trim();
187
+ if (configuredId) {
188
+ this.stationId = configuredId;
189
+ this.log.debug(
190
+ `Verwende in der Konfiguration hinterlegte powerStationId: ${configuredId}`,
191
+ );
192
+ return;
193
+ }
194
+
195
+ this.log.info(
196
+ "Keine powerStationId konfiguriert - versuche automatische Erkennung über das SEMS-Konto.",
197
+ );
198
+ const stations = await this.api.getOwnedPowerStations();
199
+ if (!stations.length) {
200
+ throw new SemsProtocolError(
201
+ "Automatische Anlagen-Erkennung lieferte keine Anlage für dieses SEMS-Konto. Bitte powerStationId manuell in der Instanzkonfiguration eintragen (aus der SEMS-Portal-URL nach dem Login).",
202
+ );
203
+ }
204
+ if (stations.length > 1) {
205
+ this.log.warn(
206
+ `Es wurden ${stations.length} Anlagen auf diesem SEMS-Konto gefunden. Verwende die erste (${stations[0].id}). ` +
207
+ "Für eine bestimmte Anlage bitte powerStationId manuell in der Instanzkonfiguration setzen.",
208
+ );
209
+ }
210
+ this.stationId = stations[0].id;
211
+ await this._ensureState(
212
+ "Station.StationId",
213
+ "Power station ID used by this instance",
214
+ { type: "string", role: "text", read: true, write: false },
215
+ this.stationId,
216
+ );
217
+ }
218
+
219
+ async _applyMonitorDetail(detail) {
220
+ const { points } = mapMonitorDetail(detail);
221
+
222
+ await this._ensureChannel("Station", "Station information");
223
+ await this._ensureChannel("KPI", "Key performance indicators");
224
+ await this._ensureChannel("PowerFlow", "Current plant power flow");
225
+ await this._ensureChannel("Battery", "Overall battery state");
226
+
227
+ const hasEvCharger = points.some((p) => p.id.startsWith("EVCharger."));
228
+ if (hasEvCharger) {
229
+ await this._ensureChannel("EVCharger", "EV charger");
230
+ }
231
+
232
+ const inverterSerials = new Set(
233
+ points
234
+ .filter((p) => p.id.startsWith("Inverters."))
235
+ .map((p) => p.id.split(".")[1]),
236
+ );
237
+ if (inverterSerials.size) {
238
+ await this._ensureChannel(
239
+ "Inverters",
240
+ "One channel per inverter reported by the portal",
241
+ );
242
+ for (const sn of inverterSerials) {
243
+ await this._ensureChannel(`Inverters.${sn}`, `Inverter ${sn}`);
244
+ }
245
+ }
246
+
247
+ for (const point of points) {
248
+ await this._ensureState(
249
+ point.id,
250
+ point.name,
251
+ point.common,
252
+ point.value,
253
+ );
254
+ }
255
+
256
+ if (this.config.debugRawResponse) {
257
+ await this.setStateAsync(
258
+ "info.rawResponse",
259
+ JSON.stringify(detail),
260
+ true,
261
+ );
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Creates (once) and updates a state, minimising redundant object writes across poll cycles.
267
+ *
268
+ * @param id
269
+ * @param name
270
+ * @param common
271
+ * @param value
272
+ */
273
+ async _ensureState(id, name, common, value) {
274
+ if (!this.knownObjectIds.has(id)) {
275
+ await this.setObjectNotExistsAsync(id, {
276
+ type: "state",
277
+ common: { name, ...common },
278
+ native: {},
279
+ });
280
+ this.knownObjectIds.add(id);
281
+ }
282
+ await this.setStateAsync(id, value, true);
283
+ }
284
+
285
+ async _ensureChannel(id, name) {
286
+ if (this.knownObjectIds.has(`channel:${id}`)) {
287
+ return;
288
+ }
289
+ await this.setObjectNotExistsAsync(id, {
290
+ type: "channel",
291
+ common: { name },
292
+ native: {},
293
+ });
294
+ this.knownObjectIds.add(`channel:${id}`);
295
+ }
296
+
297
+ async _handlePollError(error) {
298
+ this.consecutiveErrors++;
299
+ await this.setStateAsync("info.connection", false, true);
300
+ await this.setStateAsync("info.lastError", error.message, true);
301
+ await this.setStateAsync(
302
+ "info.consecutiveErrors",
303
+ this.consecutiveErrors,
304
+ true,
305
+ );
306
+
307
+ let nextDelaySec = this.basePollIntervalSec;
308
+
309
+ if (error instanceof SemsRateLimitError) {
310
+ this.log.warn(`SEMS-Portal Rate-Limit erreicht: ${error.message}`);
311
+ await this.setStateAsync("info.rateLimited", true, true);
312
+ nextDelaySec = error.retryAfterSeconds;
313
+ await this.notifier.notify(
314
+ "rateLimit",
315
+ "SEMS Rate-Limit erreicht",
316
+ `Das SEMS-Portal hat Anfragen mit dem Rate-Limit-Code abgelehnt. Polling pausiert für ${nextDelaySec}s. ` +
317
+ "Falls das öfter vorkommt, das Poll-Intervall in der Instanzkonfiguration erhöhen.",
318
+ );
319
+ } else if (error instanceof SemsAuthError) {
320
+ this.log.error(`SEMS-Login fehlgeschlagen: ${error.message}`);
321
+ nextDelaySec = Math.min(
322
+ this.basePollIntervalSec *
323
+ Math.pow(2, Math.min(this.consecutiveErrors, 5)),
324
+ MAX_BACKOFF_SEC,
325
+ );
326
+ await this.notifier.notify(
327
+ "loginFailure",
328
+ "SEMS-Login fehlgeschlagen",
329
+ `Anmeldung am SEMS-Portal für Konto "${this._maskAccount(this.config.account)}" schlägt fehl: ${error.message}. ` +
330
+ "Bitte Benutzername/Passwort in der Instanzkonfiguration prüfen.",
331
+ );
332
+ } else if (
333
+ error instanceof SemsNetworkError ||
334
+ error instanceof SemsProtocolError
335
+ ) {
336
+ this.log.warn(`SEMS-API-Fehler: ${error.message}`);
337
+ await this.setStateAsync("info.rateLimited", false, true);
338
+ nextDelaySec = Math.min(
339
+ this.basePollIntervalSec *
340
+ Math.pow(1.5, Math.min(this.consecutiveErrors, 6)),
341
+ MAX_BACKOFF_SEC / 2,
342
+ );
343
+ } else {
344
+ this.log.error(
345
+ `Unerwarteter Fehler im Poll-Zyklus: ${error.stack || error.message}`,
346
+ );
347
+ await this.setStateAsync("info.rateLimited", false, true);
348
+ nextDelaySec = Math.min(
349
+ this.basePollIntervalSec * 2,
350
+ MAX_BACKOFF_SEC / 2,
351
+ );
352
+ await this.notifier.notify(
353
+ "adapterError",
354
+ "Unerwarteter Adapterfehler",
355
+ error.message,
356
+ );
357
+ }
358
+
359
+ // "Anlage offline" is only alarmiert, wenn BEIDE Kriterien erfüllt sind:
360
+ // genug aufeinanderfolgende Fehlversuche UND lange genug kein Erfolg mehr
361
+ // (konfigurierbar über stationOfflineMinutes). Verhindert Fehlalarme bei
362
+ // kurzen Intervallen und wartet nicht unnötig lange bei langen Intervallen.
363
+ const referenceTs = this.lastSuccessTs || this.startTs;
364
+ const downMs = Date.now() - referenceTs;
365
+ if (
366
+ this.consecutiveErrors >= this.maxConsecutiveErrors &&
367
+ downMs >= this.stationOfflineMs &&
368
+ !this.stationOfflineNotified
369
+ ) {
370
+ const downMinutes = Math.round(downMs / 60000);
371
+ await this.notifier.notify(
372
+ "stationOffline",
373
+ "GoodWe-Anlage nicht erreichbar",
374
+ `${this.consecutiveErrors} aufeinanderfolgende Poll-Versuche sind fehlgeschlagen (seit ca. ${downMinutes} Minuten keine Daten vom SEMS-Portal). ` +
375
+ `Letzter Fehler: ${error.message}`,
376
+ );
377
+ this.stationOfflineNotified = true;
378
+ }
379
+
380
+ await this.setStateAsync("info.activePollInterval", nextDelaySec, true);
381
+ this._schedulePoll(nextDelaySec * 1000);
382
+ }
383
+
384
+ _maskAccount(account) {
385
+ if (!account) {
386
+ return "(nicht gesetzt)";
387
+ }
388
+ const at = account.indexOf("@");
389
+ if (at <= 1) {
390
+ return "***";
391
+ }
392
+ return `${account.slice(0, 2)}***${account.slice(at)}`;
393
+ }
394
+ }
395
+
396
+ if (require.main !== module) {
397
+ module.exports = (options) => new GoodweSems(options);
398
+ } else {
399
+ new GoodweSems();
400
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "iobroker.goodwe-sems",
3
+ "version": "0.1.0",
4
+ "description": "ioBroker adapter to read GoodWe inverter data from the SEMS Portal cloud API (for installations without local/LAN access to the inverter).",
5
+ "author": {
6
+ "name": "bueste",
7
+ "email": "bueste@users.noreply.github.com"
8
+ },
9
+ "homepage": "https://github.com/bueste/ioBroker.goodwe-sems",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "ioBroker",
13
+ "goodwe",
14
+ "sems",
15
+ "semsportal",
16
+ "inverter",
17
+ "solar",
18
+ "photovoltaik",
19
+ "pv"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/bueste/ioBroker.goodwe-sems.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/bueste/ioBroker.goodwe-sems/issues"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "os": [
32
+ "linux",
33
+ "darwin",
34
+ "win32"
35
+ ],
36
+ "main": "main.js",
37
+ "files": [
38
+ "admin{,/!(src)/**}/!(tsconfig|tsconfig.*|.eslintrc).{json,json5}",
39
+ "admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}",
40
+ "lib/",
41
+ "io-package.json",
42
+ "LICENSE",
43
+ "main.js"
44
+ ],
45
+ "scripts": {
46
+ "test:unit": "mocha test/unit --config test/mocharc.custom.json --exit",
47
+ "test:package": "mocha test/package --exit",
48
+ "test": "npm run test:unit && npm run test:package",
49
+ "lint": "eslint .",
50
+ "translate": "translate-adapter",
51
+ "release": "release-script"
52
+ },
53
+ "dependencies": {
54
+ "@iobroker/adapter-core": "^3.1.6"
55
+ },
56
+ "devDependencies": {
57
+ "@alcalzone/release-script": "^3.8.0",
58
+ "@alcalzone/release-script-plugin-iobroker": "^3.7.2",
59
+ "@alcalzone/release-script-plugin-license": "^3.7.0",
60
+ "@iobroker/adapter-dev": "^1.3.0",
61
+ "@iobroker/eslint-config": "^1.0.0",
62
+ "@iobroker/testing": "^5.0.0",
63
+ "chai": "^4.4.1",
64
+ "chai-as-promised": "^7.1.2",
65
+ "eslint": "^9.9.0",
66
+ "mocha": "^10.7.0",
67
+ "sinon": "^18.0.0"
68
+ }
69
+ }