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/mapping.js ADDED
@@ -0,0 +1,408 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * Pure data-mapping helpers: turn one GetMonitorDetailByPowerstationId
5
+ * response into a flat list of ioBroker state points. Deliberately kept
6
+ * free of any ioBroker/adapter-core dependency so it can be unit-tested
7
+ * with plain fixtures (see test/unit/mapping.test.js).
8
+ *
9
+ * The exact field names used by the SEMS portal are not documented and
10
+ * have been observed to differ slightly between portal versions/regions
11
+ * (see README "API-Herkunft"). Every lookup therefore tries several
12
+ * candidate keys and silently skips anything it cannot find instead of
13
+ * throwing - a missing field must never break the whole poll cycle.
14
+ */
15
+
16
+ function toNumber(value) {
17
+ if (value === null || value === undefined) {
18
+ return undefined;
19
+ }
20
+ if (typeof value === "number") {
21
+ return Number.isFinite(value) ? value : undefined;
22
+ }
23
+ if (typeof value === "string") {
24
+ const cleaned = value.replace(/[^0-9.+-]/g, "");
25
+ if (cleaned === "" || cleaned === "-" || cleaned === "+") {
26
+ return undefined;
27
+ }
28
+ const n = Number(cleaned);
29
+ return Number.isFinite(n) ? n : undefined;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ function pick(obj, keys) {
35
+ if (!obj) {
36
+ return undefined;
37
+ }
38
+ for (const key of keys) {
39
+ if (obj[key] !== undefined && obj[key] !== null && obj[key] !== "") {
40
+ return obj[key];
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function pickNumber(obj, keys) {
47
+ return toNumber(pick(obj, keys));
48
+ }
49
+
50
+ /**
51
+ * Parses GoodWe's "MM/DD/YYYY HH:mm:ss" timestamp format into epoch ms.
52
+ * Falls back to Date.parse() for other formats, returns undefined instead
53
+ * of throwing if nothing works.
54
+ *
55
+ * @param {string} value
56
+ */
57
+ function parsePortalTimestamp(value) {
58
+ if (!value || typeof value !== "string") {
59
+ return undefined;
60
+ }
61
+ const match = value.match(
62
+ /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})$/,
63
+ );
64
+ if (!match) {
65
+ const fallback = Date.parse(value);
66
+ return Number.isNaN(fallback) ? undefined : fallback;
67
+ }
68
+ const [month, day, year, hour, minute, second] = match.slice(1).map(Number);
69
+ return new Date(year, month - 1, day, hour, minute, second).getTime();
70
+ }
71
+
72
+ const NUM = (unit, role = "value") => ({
73
+ type: "number",
74
+ role,
75
+ unit,
76
+ read: true,
77
+ write: false,
78
+ });
79
+ const STR = (role = "text") => ({
80
+ type: "string",
81
+ role,
82
+ read: true,
83
+ write: false,
84
+ });
85
+ const BOOL = (role = "indicator") => ({
86
+ type: "boolean",
87
+ role,
88
+ read: true,
89
+ write: false,
90
+ });
91
+
92
+ /**
93
+ * @param {any} detail raw "data" object from GetMonitorDetailByPowerstationId
94
+ * @returns {{points: Array<{id:string, name:string, common:object, value:any}>, inverterSerials: string[]}}
95
+ */
96
+ function mapMonitorDetail(detail) {
97
+ const points = [];
98
+ const push = (id, name, common, value) => {
99
+ if (value === undefined || value === null) {
100
+ return;
101
+ }
102
+ points.push({ id, name, common, value });
103
+ };
104
+
105
+ const info = detail && detail.info;
106
+ push(
107
+ "Station.Name",
108
+ "Station name",
109
+ STR(),
110
+ pick(info, ["stationname", "name"]),
111
+ );
112
+ push(
113
+ "Station.Capacity",
114
+ "Installed capacity",
115
+ NUM("kWp"),
116
+ pickNumber(info, ["capacity"]),
117
+ );
118
+ push("Station.Address", "Address", STR(), pick(info, ["address"]));
119
+ push(
120
+ "Station.Latitude",
121
+ "Latitude",
122
+ NUM("°", "value.gps"),
123
+ pickNumber(info, ["latitude"]),
124
+ );
125
+ push(
126
+ "Station.Longitude",
127
+ "Longitude",
128
+ NUM("°", "value.gps"),
129
+ pickNumber(info, ["longitude"]),
130
+ );
131
+ push(
132
+ "Station.PortalTimestamp",
133
+ "Last update time reported by portal",
134
+ NUM("", "value.time"),
135
+ parsePortalTimestamp(pick(info, ["time"])),
136
+ );
137
+ push(
138
+ "Station.Status",
139
+ "Station status code reported by portal",
140
+ NUM(),
141
+ pickNumber(info, ["status"]),
142
+ );
143
+
144
+ const kpi = detail && detail.kpi;
145
+ push(
146
+ "KPI.CurrentPower",
147
+ "Current output power",
148
+ NUM("W", "value.power"),
149
+ pickNumber(kpi, ["pac"]),
150
+ );
151
+ push(
152
+ "KPI.TodayGeneration",
153
+ "Today's generation",
154
+ NUM("kWh", "value.energy"),
155
+ pickNumber(kpi, ["power"]),
156
+ );
157
+ push(
158
+ "KPI.MonthGeneration",
159
+ "This month's generation",
160
+ NUM("kWh", "value.energy"),
161
+ pickNumber(kpi, ["month_generation", "monthGeneration"]),
162
+ );
163
+ push(
164
+ "KPI.TotalGeneration",
165
+ "Total generation since installation",
166
+ NUM("kWh", "value.energy"),
167
+ pickNumber(kpi, ["total_power", "totalPower"]),
168
+ );
169
+ push(
170
+ "KPI.TodayIncome",
171
+ "Today's income",
172
+ NUM("", "value"),
173
+ pickNumber(kpi, ["day_income", "dayIncome"]),
174
+ );
175
+ push(
176
+ "KPI.TotalIncome",
177
+ "Total income",
178
+ NUM("", "value"),
179
+ pickNumber(kpi, ["total_income", "totalIncome"]),
180
+ );
181
+ push(
182
+ "KPI.Currency",
183
+ "Currency of the income values",
184
+ STR(),
185
+ pick(kpi, ["currency"]),
186
+ );
187
+
188
+ const powerflow = detail && detail.powerflow;
189
+ push(
190
+ "PowerFlow.PV",
191
+ "PV generation power",
192
+ NUM("W", "value.power"),
193
+ pickNumber(powerflow, ["pv"]),
194
+ );
195
+ push(
196
+ "PowerFlow.Load",
197
+ "House load power",
198
+ NUM("W", "value.power"),
199
+ pickNumber(powerflow, ["load"]),
200
+ );
201
+ push(
202
+ "PowerFlow.Grid",
203
+ "Grid import/export power",
204
+ NUM("W", "value.power"),
205
+ pickNumber(powerflow, ["grid"]),
206
+ );
207
+ push(
208
+ "PowerFlow.Battery",
209
+ "Battery charge/discharge power",
210
+ NUM("W", "value.power"),
211
+ pickNumber(powerflow, ["bettery", "battery"]),
212
+ );
213
+ push(
214
+ "PowerFlow.LoadStatus",
215
+ "Load flow direction code",
216
+ NUM(),
217
+ pickNumber(powerflow, ["loadStatus"]),
218
+ );
219
+ push(
220
+ "PowerFlow.GridStatus",
221
+ "Grid flow direction code",
222
+ NUM(),
223
+ pickNumber(powerflow, ["gridStatus"]),
224
+ );
225
+ push(
226
+ "PowerFlow.PvStatus",
227
+ "PV flow status code",
228
+ NUM(),
229
+ pickNumber(powerflow, ["pvStatus"]),
230
+ );
231
+ push(
232
+ "PowerFlow.BatteryStatus",
233
+ "Battery flow direction code",
234
+ NUM(),
235
+ pickNumber(powerflow, ["betteryStatus", "batteryStatus"]),
236
+ );
237
+
238
+ const soc = detail && detail.soc;
239
+ push(
240
+ "Battery.SOC",
241
+ "Overall battery state of charge",
242
+ NUM("%", "value.battery"),
243
+ pickNumber(soc, ["power"]),
244
+ );
245
+ push(
246
+ "Battery.Status",
247
+ "Battery status code",
248
+ NUM(),
249
+ pickNumber(soc, ["status"]),
250
+ );
251
+
252
+ const evCharge = detail && (detail.evChargeInfo || detail.evCharge);
253
+ if (evCharge || (detail && detail.isEvCharge)) {
254
+ push(
255
+ "EVCharger.Present",
256
+ "EV charger present according to portal",
257
+ BOOL(),
258
+ Boolean(detail.isEvCharge || evCharge),
259
+ );
260
+ push(
261
+ "EVCharger.Power",
262
+ "EV charger power",
263
+ NUM("W", "value.power"),
264
+ pickNumber(evCharge, ["power", "pac"]),
265
+ );
266
+ push(
267
+ "EVCharger.Status",
268
+ "EV charger status code",
269
+ NUM(),
270
+ pickNumber(evCharge, ["status"]),
271
+ );
272
+ }
273
+
274
+ const inverters = Array.isArray(detail && detail.inverter)
275
+ ? detail.inverter
276
+ : [];
277
+ const inverterSerials = [];
278
+
279
+ inverters.forEach((inv, index) => {
280
+ const sn =
281
+ pick(inv, ["sn", "SN", "invertersn"]) || `UNKNOWN_${index + 1}`;
282
+ inverterSerials.push(sn);
283
+ const full = inv.invert_full || inv.d || {};
284
+ const base = `Inverters.${sn}`;
285
+
286
+ push(`${base}.Name`, "Inverter name", STR(), pick(inv, ["name"]));
287
+ push(
288
+ `${base}.Model`,
289
+ "Inverter model",
290
+ STR(),
291
+ pick(inv, ["model_type", "modelType"]),
292
+ );
293
+ push(
294
+ `${base}.Status`,
295
+ "Inverter status code reported by portal",
296
+ NUM(),
297
+ pickNumber(inv, ["status"]),
298
+ );
299
+ push(
300
+ `${base}.WarningCode`,
301
+ "Warning code",
302
+ NUM(),
303
+ pickNumber(inv, ["warning", "warningCode"]),
304
+ );
305
+
306
+ const pac = pickNumber(inv, ["pac"]) ?? pickNumber(full, ["pac"]);
307
+ push(
308
+ `${base}.CurrentPower`,
309
+ "Current AC output power",
310
+ NUM("W", "value.power"),
311
+ pac,
312
+ );
313
+ const eday = pickNumber(inv, ["eday"]) ?? pickNumber(full, ["eday"]);
314
+ push(
315
+ `${base}.TodayGeneration`,
316
+ "Today's generation of this inverter",
317
+ NUM("kWh", "value.energy"),
318
+ eday,
319
+ );
320
+ const etotal =
321
+ pickNumber(inv, ["etotal"]) ?? pickNumber(full, ["etotal"]);
322
+ push(
323
+ `${base}.TotalGeneration`,
324
+ "Total generation of this inverter",
325
+ NUM("kWh", "value.energy"),
326
+ etotal,
327
+ );
328
+ const temp =
329
+ pickNumber(inv, ["tempperature", "temperature"]) ??
330
+ pickNumber(full, ["tempperature"]);
331
+ push(
332
+ `${base}.Temperature`,
333
+ "Inverter temperature",
334
+ NUM("°C", "value.temperature"),
335
+ temp,
336
+ );
337
+
338
+ for (let mppt = 1; mppt <= 4; mppt++) {
339
+ const v = pickNumber(full, [`vpv${mppt}`]);
340
+ const i = pickNumber(full, [`ipv${mppt}`]);
341
+ push(
342
+ `${base}.PV${mppt}.Voltage`,
343
+ `PV string ${mppt} voltage`,
344
+ NUM("V", "value.voltage"),
345
+ v,
346
+ );
347
+ push(
348
+ `${base}.PV${mppt}.Current`,
349
+ `PV string ${mppt} current`,
350
+ NUM("A", "value.current"),
351
+ i,
352
+ );
353
+ }
354
+ for (let phase = 1; phase <= 3; phase++) {
355
+ const v = pickNumber(full, [`vac${phase}`]);
356
+ const i = pickNumber(full, [`iac${phase}`]);
357
+ const f = pickNumber(full, [`fac${phase}`]);
358
+ push(
359
+ `${base}.AC_L${phase}.Voltage`,
360
+ `AC phase ${phase} voltage`,
361
+ NUM("V", "value.voltage"),
362
+ v,
363
+ );
364
+ push(
365
+ `${base}.AC_L${phase}.Current`,
366
+ `AC phase ${phase} current`,
367
+ NUM("A", "value.current"),
368
+ i,
369
+ );
370
+ push(
371
+ `${base}.AC_L${phase}.Frequency`,
372
+ `AC phase ${phase} frequency`,
373
+ NUM("Hz", "value.frequency"),
374
+ f,
375
+ );
376
+ }
377
+
378
+ const batSoc = pickNumber(inv, ["soc"]) ?? pickNumber(full, ["soc"]);
379
+ push(
380
+ `${base}.Battery.SOC`,
381
+ "Battery state of charge (this inverter)",
382
+ NUM("%", "value.battery"),
383
+ batSoc,
384
+ );
385
+ push(
386
+ `${base}.Battery.Voltage`,
387
+ "Battery voltage",
388
+ NUM("V", "value.voltage"),
389
+ pickNumber(full, ["vbattery1"]),
390
+ );
391
+ push(
392
+ `${base}.Battery.Current`,
393
+ "Battery current",
394
+ NUM("A", "value.current"),
395
+ pickNumber(full, ["ibattery1"]),
396
+ );
397
+ });
398
+
399
+ return { points, inverterSerials };
400
+ }
401
+
402
+ module.exports = {
403
+ mapMonitorDetail,
404
+ pick,
405
+ pickNumber,
406
+ parsePortalTimestamp,
407
+ toNumber,
408
+ };
package/lib/notify.js ADDED
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+
3
+ const PUSHOVER_API_URL = "https://api.pushover.net/1/messages.json";
4
+
5
+ // Do not re-send the *same* critical condition more often than this, even if
6
+ // every poll cycle keeps failing. Prevents Pushover-spam during a longer
7
+ // outage while still re-alerting periodically in case the first push got lost.
8
+ const DEFAULT_DEDUPE_MS = 60 * 60 * 1000; // 1 hour
9
+
10
+ /**
11
+ * Sends critical adapter events to Pushover, either via an existing
12
+ * ioBroker.pushover adapter instance (sendTo), directly against the Pushover
13
+ * HTTPS API, or both - configurable per installation. Never throws: a
14
+ * notification failure must not take down the polling logic.
15
+ */
16
+ class Notifier {
17
+ /**
18
+ * @param {ioBroker.Adapter} adapter
19
+ * @param {object} config effective (decrypted) adapter config (this.config)
20
+ */
21
+ constructor(adapter, config) {
22
+ this.adapter = adapter;
23
+ this.config = config;
24
+ /** last-sent timestamp per dedupe key */
25
+ this._lastSent = new Map();
26
+ }
27
+
28
+ /**
29
+ * @param {"loginFailure"|"rateLimit"|"stationOffline"|"adapterError"} category
30
+ * @param {string} title
31
+ * @param {string} message
32
+ * @param {object} [opts]
33
+ * @param {string} [opts.dedupeKey] override key used for spam-protection (defaults to category)
34
+ * @param {number} [opts.dedupeMs] override cool-down window in ms
35
+ * @param {number} [opts.priority] Pushover priority (-2..2), defaults to configured value
36
+ */
37
+ async notify(category, title, message, opts = {}) {
38
+ // Always log locally regardless of Pushover configuration/availability.
39
+ this.adapter.log.error(`[${category}] ${title}: ${message}`);
40
+
41
+ if (!this._categoryEnabled(category)) {
42
+ return;
43
+ }
44
+
45
+ const dedupeKey = opts.dedupeKey || category;
46
+ const dedupeMs = opts.dedupeMs || DEFAULT_DEDUPE_MS;
47
+ const last = this._lastSent.get(dedupeKey) || 0;
48
+ if (Date.now() - last < dedupeMs) {
49
+ this.adapter.log.debug(
50
+ `Pushover-Meldung "${dedupeKey}" unterdrückt (letzte Meldung vor ${Math.round((Date.now() - last) / 1000)}s, Sperrfrist ${dedupeMs / 1000}s).`,
51
+ );
52
+ return;
53
+ }
54
+
55
+ const mode = this.config.pushoverMode || "none";
56
+ if (mode === "none") {
57
+ return;
58
+ }
59
+
60
+ const priority =
61
+ typeof opts.priority === "number"
62
+ ? opts.priority
63
+ : Number(this.config.pushoverPriority) || 0;
64
+ let sentAny = false;
65
+
66
+ if (mode === "instance" || mode === "both") {
67
+ sentAny =
68
+ (await this._sendViaInstance(title, message, priority)) ||
69
+ sentAny;
70
+ }
71
+ if (mode === "direct" || mode === "both") {
72
+ sentAny =
73
+ (await this._sendDirect(title, message, priority)) || sentAny;
74
+ }
75
+
76
+ if (sentAny) {
77
+ this._lastSent.set(dedupeKey, Date.now());
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Clears the dedupe-timer for a category, e.g. once the adapter recovers.
83
+ *
84
+ * @param category
85
+ */
86
+ resetDedupe(category) {
87
+ this._lastSent.delete(category);
88
+ }
89
+
90
+ _categoryEnabled(category) {
91
+ switch (category) {
92
+ case "loginFailure":
93
+ return this.config.notifyOnLoginFailure !== false;
94
+ case "rateLimit":
95
+ return this.config.notifyOnRateLimit !== false;
96
+ case "stationOffline":
97
+ return this.config.notifyOnStationOffline !== false;
98
+ case "adapterError":
99
+ return this.config.notifyOnAdapterError !== false;
100
+ default:
101
+ return true;
102
+ }
103
+ }
104
+
105
+ async _sendViaInstance(title, message, priority) {
106
+ const instance = this.config.pushoverInstance;
107
+ if (!instance) {
108
+ this.adapter.log.warn(
109
+ "Pushover-Modus 'instance'/'both' aktiv, aber keine Pushover-Instanz konfiguriert.",
110
+ );
111
+ return false;
112
+ }
113
+ try {
114
+ await this.adapter.sendToAsync(instance, "send", {
115
+ message,
116
+ title: `GoodWe SEMS: ${title}`,
117
+ priority,
118
+ });
119
+ return true;
120
+ } catch (error) {
121
+ this.adapter.log.warn(
122
+ `Konnte Pushover-Meldung nicht über Instanz "${instance}" versenden (ist der Adapter installiert und läuft er?): ${error.message}`,
123
+ );
124
+ return false;
125
+ }
126
+ }
127
+
128
+ async _sendDirect(title, message, priority) {
129
+ const userKey = this.config.pushoverUserKey;
130
+ const apiToken = this.config.pushoverApiToken;
131
+ if (!userKey || !apiToken) {
132
+ this.adapter.log.warn(
133
+ "Pushover-Modus 'direct'/'both' aktiv, aber User-Key/API-Token fehlen in der Konfiguration.",
134
+ );
135
+ return false;
136
+ }
137
+ try {
138
+ const params = new URLSearchParams({
139
+ token: apiToken,
140
+ user: userKey,
141
+ title: `GoodWe SEMS: ${title}`,
142
+ message,
143
+ priority: String(priority),
144
+ });
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), 10000);
147
+ try {
148
+ const response = await fetch(PUSHOVER_API_URL, {
149
+ method: "POST",
150
+ headers: {
151
+ "Content-Type": "application/x-www-form-urlencoded",
152
+ },
153
+ body: params.toString(),
154
+ signal: controller.signal,
155
+ });
156
+ if (!response.ok) {
157
+ const text = await response.text();
158
+ throw new Error(`HTTP ${response.status}: ${text}`);
159
+ }
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+ return true;
164
+ } catch (error) {
165
+ this.adapter.log.warn(
166
+ `Direkter Pushover-API-Aufruf fehlgeschlagen: ${error.message}`,
167
+ );
168
+ return false;
169
+ }
170
+ }
171
+ }
172
+
173
+ module.exports = { Notifier };