iobroker.goodwe-sems 0.1.5 → 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 +13 -0
- package/io-package.json +14 -1
- package/lib/mapping.js +40 -2
- package/lib/semsApi.js +54 -9
- package/main.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,6 +144,19 @@ 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
|
+
|
|
147
160
|
### 0.1.5 (2026-07-18)
|
|
148
161
|
|
|
149
162
|
- (Stefan Bühler) fix: PayPal-Spendenlink im README korrigiert (Button-Link statt Donate-Link)
|
package/io-package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "goodwe-sems",
|
|
4
|
-
"version": "0.1.
|
|
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)",
|
|
@@ -80,6 +80,19 @@
|
|
|
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电站"
|
|
83
96
|
}
|
|
84
97
|
},
|
|
85
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((
|
|
280
|
-
|
|
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: (
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
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",
|