iobroker.goodwe-sems 0.1.5 → 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/README.md CHANGED
@@ -144,6 +144,25 @@ Pull Requests willkommen, insbesondere um zusätzliche, vom Portal gelieferte Fe
144
144
 
145
145
  ### **WORK IN PROGRESS**
146
146
 
147
+ ### 0.1.7 (2026-07-19)
148
+
149
+ - (Stefan Bühler) Branding: Platzhalter-Icon durch das offizielle GoodWe-Logo ersetzt (mit Genehmigung von GoodWe verwendet)
150
+
151
+ ### 0.1.6 (2026-07-18)
152
+
153
+ - (Stefan Bühler) Dev-Toolchain aktualisiert: mocha 11, sinon 22, @alcalzone/release-script 5, @iobroker/eslint-config 2; verbleibende transitive CVEs (adm-zip, diff, esbuild, serialize-javascript) per npm-`overrides` erzwungen behoben - `npm audit`: 0 Schwachstellen (auch inkl. Dev-Dependencies)
154
+
155
+ Sicherheits-/Qualitätsaudit (Security-Tester, Maintainer-Review, Fuzzing der Mapping-Schicht):
156
+
157
+ - (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 `.` `*` `]`)
158
+ - (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
159
+ - (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
160
+ - (Stefan Bühler) **Fix:** Zahlen in Exponentialschreibweise (`"1e5"`) wurden falsch geparst (ergab 15 statt 100000)
161
+ - (Stefan Bühler) **Fix:** offensichtlich ungültige Portal-Zeitstempel (`99/99/9999 …`) erzeugten durch JS-Date-Rollover absurde Epochen-Werte - werden jetzt verworfen
162
+ - (Stefan Bühler) **Fix:** automatische Anlagen-Erkennung filtert Einträge ohne verwertbare ID (verhinderte sonst dauerhafte Fehlzyklen)
163
+ - (Stefan Bühler) **Robustheit:** keine State-Writes mehr nach Adapter-Unload; `adapterError`-Dedupe wird nach Erholung ebenfalls zurückgesetzt
164
+ - (Stefan Bühler) 14 neue Regressionstests (42 Unit-Tests gesamt); `npm audit`: 0 Schwachstellen in Produktions-Dependencies (verbleibende betreffen ausschließlich Dev-Toolchain)
165
+
147
166
  ### 0.1.5 (2026-07-18)
148
167
 
149
168
  - (Stefan Bühler) fix: PayPal-Spendenlink im README korrigiert (Button-Link statt Donate-Link)
Binary file
package/io-package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "goodwe-sems",
4
- "version": "0.1.5",
4
+ "version": "0.1.7",
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)",
@@ -80,6 +80,32 @@
80
80
  "pl": "poprawka: poprawiono link do darowizny PayPal w README",
81
81
  "uk": "виправлення: виправлено посилання для пожертв PayPal у README",
82
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电站"
96
+ },
97
+ "0.1.7": {
98
+ "en": "branding: replace placeholder icon with the official GoodWe logo (used with permission from GoodWe)",
99
+ "de": "Branding: Platzhalter-Icon durch das offizielle GoodWe-Logo ersetzt (mit Genehmigung von GoodWe verwendet)",
100
+ "ru": "брендинг: заменена иконка-заполнитель на официальный логотип GoodWe (используется с разрешения GoodWe)",
101
+ "pt": "marca: ícone substituído pelo logótipo oficial da GoodWe (usado com autorização da GoodWe)",
102
+ "nl": "branding: placeholder-icoon vervangen door het officiële GoodWe-logo (gebruikt met toestemming van GoodWe)",
103
+ "fr": "image de marque : icône remplacée par le logo officiel GoodWe (utilisé avec l autorisation de GoodWe)",
104
+ "it": "branding: icona sostituita con il logo ufficiale GoodWe (utilizzato con l autorizzazione di GoodWe)",
105
+ "es": "marca: icono sustituido por el logotipo oficial de GoodWe (utilizado con permiso de GoodWe)",
106
+ "pl": "branding: zastąpiono ikonę zastępczą oficjalnym logo GoodWe (używane za zgodą GoodWe)",
107
+ "uk": "брендинг: замінено іконку-заповнювач на офіційний логотип GoodWe (використовується з дозволу GoodWe)",
108
+ "zh-cn": "品牌:将占位图标替换为 GoodWe 官方标志(经 GoodWe 许可使用)"
83
109
  }
84
110
  },
85
111
  "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,
@@ -90,8 +123,8 @@ const BOOL = (role = "indicator") => ({
90
123
  });
91
124
 
92
125
  /**
93
- * @param {any} detail raw "data" object from GetMonitorDetailByPowerstationId
94
- * @returns {{points: Array<{id:string, name:string, common:object, value:any}>, inverterSerials: string[]}}
126
+ * @param {object} detail raw "data" object from GetMonitorDetailByPowerstationId
127
+ * @returns {{points: Array<{id:string, name:string, common:object, value:unknown}>, inverterSerials: string[]}}
95
128
  */
96
129
  function mapMonitorDetail(detail) {
97
130
  const points = [];
@@ -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
@@ -72,7 +72,7 @@ const DEFAULT_CLIENT_TOKEN = JSON.stringify({
72
72
  *
73
73
  * @param {string} url
74
74
  * @param {object} init fetch init, `timeoutMs` is consumed here and not passed on
75
- * @returns {Promise<{status:number, json: any, text: string}>}
75
+ * @returns {Promise<{status:number, json: object, text: string}>}
76
76
  */
77
77
  async function httpPostJson(url, init) {
78
78
  const { timeoutMs, ...fetchInit } = init;
@@ -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.5",
3
+ "version": "0.1.7",
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",
@@ -54,16 +54,22 @@
54
54
  "@iobroker/adapter-core": "^3.1.6"
55
55
  },
56
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",
57
+ "@alcalzone/release-script": "^5.2.1",
58
+ "@alcalzone/release-script-plugin-iobroker": "^5.2.0",
59
+ "@alcalzone/release-script-plugin-license": "^5.2.0",
60
60
  "@iobroker/adapter-dev": "^1.3.0",
61
- "@iobroker/eslint-config": "^1.0.0",
61
+ "@iobroker/eslint-config": "^2.3.4",
62
62
  "@iobroker/testing": "^5.0.0",
63
63
  "chai": "^4.4.1",
64
64
  "chai-as-promised": "^7.1.2",
65
65
  "eslint": "^9.9.0",
66
- "mocha": "^10.7.0",
67
- "sinon": "^18.0.0"
66
+ "mocha": "^11.7.6",
67
+ "sinon": "^22.0.0"
68
+ },
69
+ "overrides": {
70
+ "adm-zip": "^0.6.0",
71
+ "diff": "^8.0.3",
72
+ "esbuild": "^0.25.0",
73
+ "serialize-javascript": "^7.0.5"
68
74
  }
69
75
  }