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/lib/semsApi.js ADDED
@@ -0,0 +1,366 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const {
5
+ SemsAuthError,
6
+ SemsRateLimitError,
7
+ SemsNetworkError,
8
+ SemsProtocolError,
9
+ } = require("./errors");
10
+
11
+ /*
12
+ * ---------------------------------------------------------------------------
13
+ * GoodWe / SEMS Portal API client
14
+ * ---------------------------------------------------------------------------
15
+ * IMPORTANT: There is no public, documented API for a normal SEMS Portal
16
+ * account. GoodWe's official "OpenAPI" / "Real-time Data Monitoring API"
17
+ * require a SEMS *organization* account resp. a signed reseller agreement
18
+ * and are not reachable with a regular homeowner login
19
+ * (see https://community.goodwe.com/solution/API).
20
+ *
21
+ * This client instead speaks the same undocumented HTTPS API that the
22
+ * official SEMS mobile app and web portal use. It has been assembled from:
23
+ * - manual traffic inspection (CrossLogin / GetMonitorDetailByPowerstationId)
24
+ * - the community projects pygoodwe (github.com/yaleman/pygoodwe),
25
+ * goodwe-sems-home-assistant (github.com/TimSoethout/...), and the
26
+ * openHAB SEMSPortal binding, all MIT/EPL licensed reference
27
+ * implementations of the very same endpoints.
28
+ *
29
+ * Because none of this is contractually guaranteed by GoodWe, treat every
30
+ * field as "best effort" and expect the portal to change without notice.
31
+ * The client is deliberately defensive: unknown/missing fields never throw,
32
+ * they just end up as `undefined` and are skipped by the adapter.
33
+ * ---------------------------------------------------------------------------
34
+ */
35
+
36
+ // "New" SEMS+ login, in use since ~2024. Needs an MD5(password) hash,
37
+ // base64-encoded, plus a few boilerplate flags.
38
+ const NEW_LOGIN_URL =
39
+ "https://semsplus.goodwe.com/web/sems/sems-user/api/v1/auth/cross-login";
40
+ // Legacy login, kept as a fallback for accounts / regions where the new
41
+ // endpoint is unavailable or rejects the credentials.
42
+ const LEGACY_LOGIN_URL = "https://www.semsportal.com/api/v3/Common/CrossLogin";
43
+
44
+ // Both logins return an "api" field with the region-specific base URL to use
45
+ // for all further calls. If that field is missing we fall back to these.
46
+ const NEW_LOGIN_FALLBACK_API = "https://eu-gateway.semsportal.com/web/sems";
47
+ const LEGACY_LOGIN_FALLBACK_API = "https://eu.semsportal.com/api";
48
+
49
+ const STATION_LIST_PATH = "/PowerStation/GetPowerStationIdByOwner";
50
+ const MONITOR_DETAIL_PATH = "/v3/PowerStation/GetMonitorDetailByPowerstationId";
51
+
52
+ // Observed rate-limit response code. GoodWe does not document a retry-after
53
+ // value, community projects settled on a 5 minute cool-down.
54
+ const RATE_LIMIT_CODE = "GY0429";
55
+ const DEFAULT_RATE_LIMIT_RETRY_SECONDS = 300;
56
+
57
+ // Response "code"/"msg" values that indicate success across the endpoints we use.
58
+ const SUCCESS_CODES = new Set([0, "0", "00000"]);
59
+ const SUCCESS_MESSAGES = new Set(["success", "successful", "操作成功"]);
60
+
61
+ // Mimics a current SEMS iOS app. Some endpoints behave differently (or reject
62
+ // requests outright) without a "plausible" mobile user agent / token header.
63
+ const DEFAULT_USER_AGENT = "PVMaster/2.9.5 (iPhone; iOS 17.5; Scale/3.00)";
64
+ const DEFAULT_CLIENT_TOKEN = JSON.stringify({
65
+ version: "v3.1",
66
+ client: "ios",
67
+ language: "en",
68
+ });
69
+
70
+ /**
71
+ * Thin wrapper around fetch() with a hard timeout and JSON handling.
72
+ *
73
+ * @param {string} url
74
+ * @param {object} init fetch init, `timeoutMs` is consumed here and not passed on
75
+ * @returns {Promise<{status:number, json: any, text: string}>}
76
+ */
77
+ async function httpPostJson(url, init) {
78
+ const { timeoutMs, ...fetchInit } = init;
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
81
+ try {
82
+ const response = await fetch(url, {
83
+ ...fetchInit,
84
+ signal: controller.signal,
85
+ });
86
+ const text = await response.text();
87
+ let json;
88
+ try {
89
+ json = text ? JSON.parse(text) : {};
90
+ } catch (parseError) {
91
+ throw new SemsProtocolError(
92
+ `Antwort von ${url} war kein gültiges JSON: ${parseError.message}`,
93
+ );
94
+ }
95
+ return { status: response.status, json, text };
96
+ } catch (error) {
97
+ if (error.name === "AbortError") {
98
+ throw new SemsNetworkError(
99
+ `Zeitüberschreitung nach ${timeoutMs} ms bei ${url}`,
100
+ );
101
+ }
102
+ if (error instanceof SemsProtocolError) {
103
+ throw error;
104
+ }
105
+ throw new SemsNetworkError(
106
+ `Netzwerkfehler bei ${url}: ${error.message}`,
107
+ );
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+
113
+ class SemsApi {
114
+ /**
115
+ * @param {object} opts
116
+ * @param {string} opts.account SEMS portal login (e-mail)
117
+ * @param {string} opts.password SEMS portal password (plain text, hashed internally as required)
118
+ * @param {number} [opts.requestTimeoutMs] per-request timeout
119
+ * @param {(level:string, message:string)=>void} opts.log adapter logger bridge, called as log(level, message)
120
+ */
121
+ constructor({ account, password, requestTimeoutMs = 15000, log }) {
122
+ if (!account || !password) {
123
+ throw new SemsAuthError(
124
+ "SEMS-Zugangsdaten (Benutzer/Passwort) fehlen in der Adapter-Konfiguration.",
125
+ );
126
+ }
127
+ this.account = account;
128
+ this.password = password;
129
+ this.requestTimeoutMs = requestTimeoutMs;
130
+ this.log = log || (() => {});
131
+
132
+ this.session = null;
133
+ }
134
+
135
+ /** MD5(password), base64 encoded - required by the "new" SEMS+ login endpoint. */
136
+ _hashPasswordForNewLogin() {
137
+ const md5Hex = crypto
138
+ .createHash("md5")
139
+ .update(this.password, "utf8")
140
+ .digest("hex");
141
+ return Buffer.from(md5Hex, "utf8").toString("base64");
142
+ }
143
+
144
+ _defaultHeaders(extraTokenPayload) {
145
+ return {
146
+ "Content-Type": "application/json",
147
+ Accept: "application/json, */*;q=0.5",
148
+ "User-Agent": DEFAULT_USER_AGENT,
149
+ Token: extraTokenPayload
150
+ ? JSON.stringify(extraTokenPayload)
151
+ : DEFAULT_CLIENT_TOKEN,
152
+ };
153
+ }
154
+
155
+ _authHeaders() {
156
+ if (!this.session) {
157
+ throw new SemsAuthError(
158
+ "Keine aktive SEMS-Session - login() wurde nicht (erfolgreich) aufgerufen.",
159
+ );
160
+ }
161
+ return this._defaultHeaders({
162
+ version: "v3.1",
163
+ client: "ios",
164
+ language: "en",
165
+ timestamp: this.session.timestamp,
166
+ uid: this.session.uid,
167
+ token: this.session.token,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Try the current SEMS+ login first, fall back to the legacy CrossLogin.
173
+ * Both variants are kept because GoodWe has migrated accounts between the
174
+ * two backends without prior notice in the past.
175
+ */
176
+ async login() {
177
+ try {
178
+ const session = await this._loginNew();
179
+ this.log("debug", "SEMS-Login über SEMS+ (neue API) erfolgreich.");
180
+ this.session = session;
181
+ return session;
182
+ } catch (newLoginError) {
183
+ this.log(
184
+ "debug",
185
+ `SEMS+-Login fehlgeschlagen (${newLoginError.message}), versuche Legacy-Login als Fallback.`,
186
+ );
187
+ }
188
+
189
+ const session = await this._loginLegacy();
190
+ this.log("debug", "SEMS-Login über Legacy-API erfolgreich.");
191
+ this.session = session;
192
+ return session;
193
+ }
194
+
195
+ async _loginNew() {
196
+ const body = JSON.stringify({
197
+ account: this.account,
198
+ pwd: this._hashPasswordForNewLogin(),
199
+ agreement: 1,
200
+ isChinese: false,
201
+ isLocal: false,
202
+ });
203
+
204
+ const { json } = await httpPostJson(NEW_LOGIN_URL, {
205
+ method: "POST",
206
+ headers: this._defaultHeaders(),
207
+ body,
208
+ timeoutMs: this.requestTimeoutMs,
209
+ });
210
+
211
+ return this._extractSession(json, NEW_LOGIN_FALLBACK_API, "SEMS+");
212
+ }
213
+
214
+ async _loginLegacy() {
215
+ const body = JSON.stringify({
216
+ account: this.account,
217
+ pwd: this.password,
218
+ });
219
+
220
+ const { json } = await httpPostJson(LEGACY_LOGIN_URL, {
221
+ method: "POST",
222
+ headers: this._defaultHeaders(),
223
+ body,
224
+ timeoutMs: this.requestTimeoutMs,
225
+ });
226
+
227
+ return this._extractSession(json, LEGACY_LOGIN_FALLBACK_API, "Legacy");
228
+ }
229
+
230
+ _extractSession(json, fallbackApi, variantName) {
231
+ const code = json && json.code;
232
+ if (
233
+ !SUCCESS_CODES.has(code) &&
234
+ !(json && SUCCESS_MESSAGES.has(String(json.msg).toLowerCase()))
235
+ ) {
236
+ const msg =
237
+ (json && (json.msg || json.message)) || "unbekannter Fehler";
238
+ throw new SemsAuthError(
239
+ `SEMS-${variantName}-Login abgelehnt: ${msg} (code=${code})`,
240
+ );
241
+ }
242
+
243
+ const data = json && json.data;
244
+ if (!data || !data.token || !data.uid) {
245
+ throw new SemsProtocolError(
246
+ `SEMS-${variantName}-Login lieferte keine verwertbaren Session-Daten (Antwortschlüssel: ${Object.keys(json || {}).join(", ")}).`,
247
+ );
248
+ }
249
+
250
+ return {
251
+ uid: data.uid,
252
+ token: data.token,
253
+ timestamp: data.timestamp || Date.now(),
254
+ api: (json && json.api) || data.api || fallbackApi,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Generic authenticated POST against the current session's API base,
260
+ * with a single transparent re-login retry on auth failure.
261
+ *
262
+ * @param {string} path
263
+ * @param {object} payload
264
+ * @param {boolean} [isRetry]
265
+ */
266
+ async _authenticatedPost(path, payload, isRetry = false) {
267
+ if (!this.session) {
268
+ await this.login();
269
+ }
270
+
271
+ const url = this.session.api.replace(/\/$/, "") + path;
272
+ const { json } = await httpPostJson(url, {
273
+ method: "POST",
274
+ headers: this._authHeaders(),
275
+ body: JSON.stringify(payload),
276
+ timeoutMs: this.requestTimeoutMs,
277
+ });
278
+
279
+ const code = json && json.code;
280
+
281
+ if (String(code) === RATE_LIMIT_CODE) {
282
+ throw new SemsRateLimitError(
283
+ `SEMS Portal hat die Anfrage mit Rate-Limit-Code ${RATE_LIMIT_CODE} abgelehnt.`,
284
+ DEFAULT_RATE_LIMIT_RETRY_SECONDS,
285
+ );
286
+ }
287
+
288
+ const looksExpired =
289
+ !SUCCESS_CODES.has(code) &&
290
+ typeof (json && (json.msg || json.message)) === "string" &&
291
+ /expired|re-?login|authoriz/i.test(json.msg || json.message);
292
+
293
+ if (looksExpired && !isRetry) {
294
+ this.log(
295
+ "debug",
296
+ "SEMS-Session abgelaufen, erneuere Token und wiederhole Anfrage einmalig.",
297
+ );
298
+ this.session = null;
299
+ await this.login();
300
+ return this._authenticatedPost(path, payload, true);
301
+ }
302
+
303
+ if (
304
+ !SUCCESS_CODES.has(code) &&
305
+ !(json && SUCCESS_MESSAGES.has(String(json.msg).toLowerCase()))
306
+ ) {
307
+ const msg =
308
+ (json && (json.msg || json.message)) || "unbekannter Fehler";
309
+ throw new SemsProtocolError(
310
+ `SEMS-API-Aufruf ${path} fehlgeschlagen: ${msg} (code=${code})`,
311
+ );
312
+ }
313
+
314
+ return json.data;
315
+ }
316
+
317
+ /**
318
+ * Auto-discovers the power station(s) owned by the logged-in account.
319
+ * Used when no powerStationId is configured, so the user only ever has
320
+ * to supply the normal SEMS login (requirement: "muss mit dem normalen
321
+ * SEMS Login machbar sein").
322
+ *
323
+ * @returns {Promise<Array<{id:string, name:string}>>}
324
+ */
325
+ async getOwnedPowerStations() {
326
+ const data = await this._authenticatedPost(STATION_LIST_PATH, {});
327
+ 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
+ }));
336
+ }
337
+ if (data && typeof data === "object") {
338
+ // Some regions return a single object instead of an array for accounts with one plant.
339
+ const id = data.powerStationId || data.id || data.PowerStationId;
340
+ if (id) {
341
+ return [{ id, name: data.stationName || data.name || "" }];
342
+ }
343
+ }
344
+ return [];
345
+ }
346
+
347
+ /**
348
+ * Fetches the full monitor detail payload (info / kpi / powerflow / soc /
349
+ * inverter[] / evChargeInfo / ...) for one power station.
350
+ *
351
+ * @param {string} powerStationId
352
+ */
353
+ async getMonitorDetail(powerStationId) {
354
+ if (!powerStationId) {
355
+ throw new SemsProtocolError(
356
+ "getMonitorDetail() ohne powerStationId aufgerufen.",
357
+ );
358
+ }
359
+ return this._authenticatedPost(MONITOR_DETAIL_PATH, { powerStationId });
360
+ }
361
+ }
362
+
363
+ module.exports = {
364
+ SemsApi,
365
+ RATE_LIMIT_CODE,
366
+ };