iobroker.goodwe-sems 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![NPM version](https://img.shields.io/npm/v/iobroker.goodwe-sems.svg)](https://www.npmjs.com/package/iobroker.goodwe-sems)
6
6
  [![Downloads](https://img.shields.io/npm/dm/iobroker.goodwe-sems.svg)](https://www.npmjs.com/package/iobroker.goodwe-sems)
7
7
  ![Test and Release](https://github.com/bueste/ioBroker.goodwe-sems/actions/workflows/test-and-release.yml/badge.svg)
8
- [![Donate](https://img.shields.io/badge/Spenden-PayPal-00457C?style=flat&logo=paypal&logoColor=white)](https://www.paypal.com/donate/?hosted_button_id=8QZKPB9JEVWKY)
8
+ [![Donate](https://img.shields.io/badge/Spenden-PayPal-00457C?style=flat&logo=paypal&logoColor=white)](https://www.paypal.com/ncp/payment/TT6MTBLXX9L9U)
9
9
 
10
10
  Liest Wechselrichter-, Batterie- und Energiefluss-Daten aus dem **GoodWe SEMS Portal (Cloud)** – für Anlagen, die (z. B. weil kein LAN-Zugriff auf den Wechselrichter besteht) **nicht** mit dem lokalen [ioBroker.goodwe](https://github.com/FossyTom/ioBroker.goodwe)-Adapter (Modbus/UDP, Port 8899) abgefragt werden können.
11
11
 
@@ -144,6 +144,23 @@ Pull Requests willkommen, insbesondere um zusätzliche, vom Portal gelieferte Fe
144
144
 
145
145
  ### **WORK IN PROGRESS**
146
146
 
147
+ ### 0.1.6 (2026-07-18)
148
+
149
+ Sicherheits-/Qualitätsaudit (Security-Tester, Maintainer-Review, Fuzzing der Mapping-Schicht):
150
+
151
+ - (Stefan Bühler) **Security:** Wechselrichter-Seriennummern aus der (nicht vertrauenswürdigen) Portal-Antwort werden bereinigt, bevor sie Teil von ioBroker-Objekt-IDs werden (verhindert kaputte/unerwartet verschachtelte Objektbäume durch Sonderzeichen wie `.` `*` `]`)
152
+ - (Stefan Bühler) **Security:** die vom Login-Server gelieferte API-Basis-URL wird validiert - nur HTTPS auf GoodWe-eigenen Domains (`*.semsportal.com`, `*.goodwe.com`), sonst Fallback auf die bekannte Regional-URL. Eine manipulierte Login-Antwort kann das Session-Token damit nicht mehr an fremde Hosts umleiten
153
+ - (Stefan Bühler) **Fix:** `null`/defekte Einträge im `inverter[]`-Array des Portals ließen den kompletten Poll-Zyklus abstürzen - werden jetzt übersprungen, gesunde Wechselrichter derselben Antwort werden weiter verarbeitet
154
+ - (Stefan Bühler) **Fix:** Zahlen in Exponentialschreibweise (`"1e5"`) wurden falsch geparst (ergab 15 statt 100000)
155
+ - (Stefan Bühler) **Fix:** offensichtlich ungültige Portal-Zeitstempel (`99/99/9999 …`) erzeugten durch JS-Date-Rollover absurde Epochen-Werte - werden jetzt verworfen
156
+ - (Stefan Bühler) **Fix:** automatische Anlagen-Erkennung filtert Einträge ohne verwertbare ID (verhinderte sonst dauerhafte Fehlzyklen)
157
+ - (Stefan Bühler) **Robustheit:** keine State-Writes mehr nach Adapter-Unload; `adapterError`-Dedupe wird nach Erholung ebenfalls zurückgesetzt
158
+ - (Stefan Bühler) 14 neue Regressionstests (42 Unit-Tests gesamt); `npm audit`: 0 Schwachstellen in Produktions-Dependencies (verbleibende betreffen ausschließlich Dev-Toolchain)
159
+
160
+ ### 0.1.5 (2026-07-18)
161
+
162
+ - (Stefan Bühler) fix: PayPal-Spendenlink im README korrigiert (Button-Link statt Donate-Link)
163
+
147
164
  ### 0.1.4 (2026-07-18)
148
165
 
149
166
  - (Stefan Bühler) docs: PayPal-Spendenlink im README ergänzt
package/io-package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "goodwe-sems",
4
- "version": "0.1.4",
4
+ "version": "0.1.6",
5
5
  "news": {
6
6
  "0.1.2": {
7
7
  "en": "switch npm publishing from a long-lived token to OIDC trusted publishing (no stored secret)",
@@ -67,6 +67,32 @@
67
67
  "pl": "docs: dodano link do darowizny PayPal w README",
68
68
  "uk": "документація: додано посилання для пожертв PayPal у README",
69
69
  "zh-cn": "文档:在 README 中添加 PayPal 捐赠链接"
70
+ },
71
+ "0.1.5": {
72
+ "en": "fix: correct PayPal donation link in README",
73
+ "de": "Fix: PayPal-Spendenlink im README korrigiert",
74
+ "ru": "исправление: исправлена ссылка для пожертвований PayPal в README",
75
+ "pt": "correção: link de doação PayPal corrigido no README",
76
+ "nl": "fix: PayPal-donatielink in README gecorrigeerd",
77
+ "fr": "correction : lien de don PayPal corrigé dans le README",
78
+ "it": "correzione: link di donazione PayPal corretto nel README",
79
+ "es": "corrección: se corrigió el enlace de donación de PayPal en el README",
80
+ "pl": "poprawka: poprawiono link do darowizny PayPal w README",
81
+ "uk": "виправлення: виправлено посилання для пожертв PayPal у README",
82
+ "zh-cn": "修复:更正 README 中的 PayPal 捐赠链接"
83
+ },
84
+ "0.1.6": {
85
+ "en": "security/quality audit: sanitize inverter serials used in state IDs, survive broken inverter array entries, validate server-provided API base URL (HTTPS + GoodWe domains only), fix scientific-notation number parsing, reject invalid portal timestamps, filter stations without id",
86
+ "de": "Sicherheits-/Qualitätsaudit: Wechselrichter-Seriennummern für State-IDs bereinigt, defekte Einträge im Inverter-Array abgefangen, vom Server gelieferte API-Basis-URL validiert (nur HTTPS + GoodWe-Domains), Zahlen-Parsing für Exponentialschreibweise korrigiert, ungültige Portal-Zeitstempel verworfen, Anlagen ohne ID gefiltert",
87
+ "ru": "аудит безопасности/качества: очистка серийных номеров инверторов в ID состояний, устойчивость к повреждённым записям, проверка базового URL API, исправлен разбор чисел, отклонение неверных меток времени",
88
+ "pt": "auditoria de segurança/qualidade: sanitização de números de série, tolerância a entradas corrompidas, validação da URL base da API, correção do parsing numérico, rejeição de timestamps inválidos",
89
+ "nl": "beveiligings-/kwaliteitsaudit: serienummers gesaneerd voor state-IDs, bestand tegen kapotte inverter-items, API-basis-URL gevalideerd, wetenschappelijke notatie gecorrigeerd, ongeldige tijdstempels afgewezen",
90
+ "fr": "audit sécurité/qualité : assainissement des numéros de série, tolérance aux entrées corrompues, validation de l URL de base de l API, correction de l analyse numérique, rejet des horodatages invalides",
91
+ "it": "audit di sicurezza/qualità: sanificazione dei numeri di serie, tolleranza a voci corrotte, validazione dell URL base API, correzione del parsing numerico, rifiuto di timestamp non validi",
92
+ "es": "auditoría de seguridad/calidad: saneamiento de números de serie, tolerancia a entradas corruptas, validación de la URL base de la API, corrección del análisis numérico, rechazo de marcas de tiempo inválidas",
93
+ "pl": "audyt bezpieczeństwa/jakości: sanityzacja numerów seryjnych, odporność na uszkodzone wpisy, walidacja bazowego URL API, poprawka parsowania liczb, odrzucanie nieprawidłowych znaczników czasu",
94
+ "uk": "аудит безпеки/якості: очищення серійних номерів, стійкість до пошкоджених записів, перевірка базової URL API, виправлення розбору чисел, відхилення недійсних міток часу",
95
+ "zh-cn": "安全/质量审计:清理用于状态ID的逆变器序列号、容忍损坏的数组条目、验证服务器提供的API基础URL、修复科学计数法解析、拒绝无效时间戳、过滤无ID电站"
70
96
  }
71
97
  },
72
98
  "title": "GoodWe SEMS Portal",
package/lib/mapping.js CHANGED
@@ -21,6 +21,13 @@ function toNumber(value) {
21
21
  return Number.isFinite(value) ? value : undefined;
22
22
  }
23
23
  if (typeof value === "string") {
24
+ // Try the untouched string first so scientific notation ("1e5",
25
+ // "2.5E3") is parsed correctly instead of being mangled by the
26
+ // unit-stripping fallback below.
27
+ const direct = Number(value.trim());
28
+ if (value.trim() !== "" && Number.isFinite(direct)) {
29
+ return direct;
30
+ }
24
31
  const cleaned = value.replace(/[^0-9.+-]/g, "");
25
32
  if (cleaned === "" || cleaned === "-" || cleaned === "+") {
26
33
  return undefined;
@@ -66,9 +73,35 @@ function parsePortalTimestamp(value) {
66
73
  return Number.isNaN(fallback) ? undefined : fallback;
67
74
  }
68
75
  const [month, day, year, hour, minute, second] = match.slice(1).map(Number);
76
+ if (
77
+ month < 1 ||
78
+ month > 12 ||
79
+ day < 1 ||
80
+ day > 31 ||
81
+ hour > 23 ||
82
+ minute > 59 ||
83
+ second > 59
84
+ ) {
85
+ return undefined;
86
+ }
69
87
  return new Date(year, month - 1, day, hour, minute, second).getTime();
70
88
  }
71
89
 
90
+ /**
91
+ * ioBroker object IDs must not contain the characters []*,;'"`<>\? or dots
92
+ * (dots create hierarchy levels) or whitespace. Inverter serials come from
93
+ * the (untrusted) portal response and are used as part of state IDs, so any
94
+ * forbidden character is replaced to prevent broken or unexpectedly nested
95
+ * object trees ("state-ID injection").
96
+ *
97
+ * @param {string} raw
98
+ * @returns {string}
99
+ */
100
+ function sanitizeObjectId(raw) {
101
+ const cleaned = String(raw).replace(/[^0-9A-Za-z_-]/g, "_");
102
+ return cleaned || "_";
103
+ }
104
+
72
105
  const NUM = (unit, role = "value") => ({
73
106
  type: "number",
74
107
  role,
@@ -276,9 +309,13 @@ function mapMonitorDetail(detail) {
276
309
  : [];
277
310
  const inverterSerials = [];
278
311
 
279
- inverters.forEach((inv, index) => {
280
- const sn =
312
+ inverters.forEach((rawInv, index) => {
313
+ // The portal has been observed to include null/garbage entries in the
314
+ // inverter array; one broken entry must never kill the whole cycle.
315
+ const inv = rawInv && typeof rawInv === "object" ? rawInv : {};
316
+ const rawSn =
281
317
  pick(inv, ["sn", "SN", "invertersn"]) || `UNKNOWN_${index + 1}`;
318
+ const sn = sanitizeObjectId(rawSn);
282
319
  inverterSerials.push(sn);
283
320
  const full = inv.invert_full || inv.d || {};
284
321
  const base = `Inverters.${sn}`;
@@ -405,4 +442,5 @@ module.exports = {
405
442
  pickNumber,
406
443
  parsePortalTimestamp,
407
444
  toNumber,
445
+ sanitizeObjectId,
408
446
  };
package/lib/semsApi.js CHANGED
@@ -251,10 +251,49 @@ class SemsApi {
251
251
  uid: data.uid,
252
252
  token: data.token,
253
253
  timestamp: data.timestamp || Date.now(),
254
- api: (json && json.api) || data.api || fallbackApi,
254
+ api: this._validateApiBase(
255
+ (json && json.api) || data.api,
256
+ fallbackApi,
257
+ ),
255
258
  };
256
259
  }
257
260
 
261
+ /**
262
+ * The login response dictates the base URL for all further (token-carrying)
263
+ * requests. Only accept HTTPS URLs on GoodWe-owned domains; anything else
264
+ * falls back to the known-good regional default. Prevents a manipulated
265
+ * login payload from redirecting the session token to a foreign host.
266
+ *
267
+ * @param {string|undefined} candidate
268
+ * @param {string} fallbackApi
269
+ * @returns {string}
270
+ */
271
+ _validateApiBase(candidate, fallbackApi) {
272
+ if (!candidate || typeof candidate !== "string") {
273
+ return fallbackApi;
274
+ }
275
+ try {
276
+ const url = new URL(candidate);
277
+ const host = url.hostname.toLowerCase();
278
+ const allowed =
279
+ url.protocol === "https:" &&
280
+ (host === "semsportal.com" ||
281
+ host.endsWith(".semsportal.com") ||
282
+ host === "goodwe.com" ||
283
+ host.endsWith(".goodwe.com"));
284
+ if (allowed) {
285
+ return candidate;
286
+ }
287
+ } catch {
288
+ // fall through to fallback
289
+ }
290
+ this.log(
291
+ "warn",
292
+ `SEMS-Login lieferte eine unerwartete API-Basis-URL ("${candidate}") - verwende stattdessen ${fallbackApi}.`,
293
+ );
294
+ return fallbackApi;
295
+ }
296
+
258
297
  /**
259
298
  * Generic authenticated POST against the current session's API base,
260
299
  * with a single transparent re-login retry on auth failure.
@@ -325,14 +364,20 @@ class SemsApi {
325
364
  async getOwnedPowerStations() {
326
365
  const data = await this._authenticatedPost(STATION_LIST_PATH, {});
327
366
  if (Array.isArray(data)) {
328
- return data.map((entry) => ({
329
- id: entry.powerStationId || entry.id || entry.PowerStationId,
330
- name:
331
- entry.stationName ||
332
- entry.name ||
333
- entry.powerStationName ||
334
- "",
335
- }));
367
+ return data
368
+ .filter((entry) => entry && typeof entry === "object")
369
+ .map((entry) => ({
370
+ id:
371
+ entry.powerStationId ||
372
+ entry.id ||
373
+ entry.PowerStationId,
374
+ name:
375
+ entry.stationName ||
376
+ entry.name ||
377
+ entry.powerStationName ||
378
+ "",
379
+ }))
380
+ .filter((entry) => entry.id);
336
381
  }
337
382
  if (data && typeof data === "object") {
338
383
  // Some regions return a single object instead of an array for accounts with one plant.
package/main.js CHANGED
@@ -145,6 +145,9 @@ class GoodweSems extends utils.Adapter {
145
145
  try {
146
146
  await this._resolveStationId();
147
147
  const detail = await this.api.getMonitorDetail(this.stationId);
148
+ if (this.destroyed) {
149
+ return;
150
+ }
148
151
  await this._applyMonitorDetail(detail);
149
152
 
150
153
  this.consecutiveErrors = 0;
@@ -153,6 +156,7 @@ class GoodweSems extends utils.Adapter {
153
156
  this.notifier.resetDedupe("stationOffline");
154
157
  this.notifier.resetDedupe("loginFailure");
155
158
  this.notifier.resetDedupe("rateLimit");
159
+ this.notifier.resetDedupe("adapterError");
156
160
 
157
161
  await this.setStateAsync("info.connection", true, true);
158
162
  await this.setStateAsync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.goodwe-sems",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
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
5
  "author": {
6
6
  "name": "bueste",