@impact0815/node-red-contrib-alphaess-modbus 0.0.0-stage → 0.4.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.
@@ -0,0 +1,420 @@
1
+ [
2
+ {
3
+ "id": "tab1",
4
+ "type": "tab",
5
+ "label": "AlphaESS Modbus",
6
+ "disabled": false,
7
+ "info": "Example for @impact0815/node-red-contrib-alphaess-modbus. Set the IP address in the connection config. Write commands only work when \"Allow write access\" is enabled – use at your own risk."
8
+ },
9
+ {
10
+ "id": "cfg1",
11
+ "type": "alphaess-modbus-config",
12
+ "name": "AlphaESS",
13
+ "host": "192.168.1.100",
14
+ "port": 502,
15
+ "unitId": 85,
16
+ "timeout": 2000,
17
+ "delay": 20
18
+ },
19
+ {
20
+ "id": "i1",
21
+ "type": "inject",
22
+ "z": "tab1",
23
+ "name": "read now",
24
+ "props": [
25
+ {
26
+ "p": "topic",
27
+ "vt": "str"
28
+ },
29
+ {
30
+ "p": "payload"
31
+ }
32
+ ],
33
+ "repeat": "",
34
+ "crontab": "",
35
+ "once": false,
36
+ "onceDelay": 0.1,
37
+ "topic": "read",
38
+ "payload": "",
39
+ "payloadType": "date",
40
+ "x": 190,
41
+ "y": 60,
42
+ "wires": [
43
+ [
44
+ "node1"
45
+ ]
46
+ ]
47
+ },
48
+ {
49
+ "id": "i2",
50
+ "type": "inject",
51
+ "z": "tab1",
52
+ "name": "read device info",
53
+ "props": [
54
+ {
55
+ "p": "topic",
56
+ "vt": "str"
57
+ },
58
+ {
59
+ "p": "payload"
60
+ }
61
+ ],
62
+ "repeat": "",
63
+ "crontab": "",
64
+ "once": false,
65
+ "onceDelay": 0.1,
66
+ "topic": "readInfo",
67
+ "payload": "",
68
+ "payloadType": "date",
69
+ "x": 190,
70
+ "y": 100,
71
+ "wires": [
72
+ [
73
+ "node1"
74
+ ]
75
+ ]
76
+ },
77
+ {
78
+ "id": "i3",
79
+ "type": "inject",
80
+ "z": "tab1",
81
+ "name": "read raw 0x0850 (SOC scale check)",
82
+ "props": [
83
+ {
84
+ "p": "topic",
85
+ "vt": "str"
86
+ },
87
+ {
88
+ "p": "payload"
89
+ }
90
+ ],
91
+ "repeat": "",
92
+ "crontab": "",
93
+ "once": false,
94
+ "onceDelay": 0.1,
95
+ "topic": "readRaw",
96
+ "payload": "{\"address\":2128,\"count\":1}",
97
+ "payloadType": "json",
98
+ "x": 190,
99
+ "y": 140,
100
+ "wires": [
101
+ [
102
+ "node1"
103
+ ]
104
+ ]
105
+ },
106
+ {
107
+ "id": "c1",
108
+ "type": "comment",
109
+ "z": "tab1",
110
+ "name": "Control – requires \"Allow write access\", use at your own risk",
111
+ "info": "",
112
+ "x": 260,
113
+ "y": 200,
114
+ "wires": []
115
+ },
116
+ {
117
+ "id": "i4",
118
+ "type": "inject",
119
+ "z": "tab1",
120
+ "name": "charge 3 kW to 90 % for 15 min",
121
+ "props": [
122
+ {
123
+ "p": "topic",
124
+ "vt": "str"
125
+ },
126
+ {
127
+ "p": "payload"
128
+ }
129
+ ],
130
+ "repeat": "",
131
+ "crontab": "",
132
+ "once": false,
133
+ "onceDelay": 0.1,
134
+ "topic": "dispatch",
135
+ "payload": "{\"power\":-3000,\"soc\":90,\"duration\":900,\"mode\":2}",
136
+ "payloadType": "json",
137
+ "x": 190,
138
+ "y": 240,
139
+ "wires": [
140
+ [
141
+ "node1"
142
+ ]
143
+ ]
144
+ },
145
+ {
146
+ "id": "i5",
147
+ "type": "inject",
148
+ "z": "tab1",
149
+ "name": "discharge 2 kW for 10 min",
150
+ "props": [
151
+ {
152
+ "p": "topic",
153
+ "vt": "str"
154
+ },
155
+ {
156
+ "p": "payload"
157
+ }
158
+ ],
159
+ "repeat": "",
160
+ "crontab": "",
161
+ "once": false,
162
+ "onceDelay": 0.1,
163
+ "topic": "dispatch",
164
+ "payload": "{\"power\":2000,\"soc\":20,\"duration\":600,\"mode\":2}",
165
+ "payloadType": "json",
166
+ "x": 190,
167
+ "y": 280,
168
+ "wires": [
169
+ [
170
+ "node1"
171
+ ]
172
+ ]
173
+ },
174
+ {
175
+ "id": "i6",
176
+ "type": "inject",
177
+ "z": "tab1",
178
+ "name": "stop dispatch",
179
+ "props": [
180
+ {
181
+ "p": "topic",
182
+ "vt": "str"
183
+ },
184
+ {
185
+ "p": "payload"
186
+ }
187
+ ],
188
+ "repeat": "",
189
+ "crontab": "",
190
+ "once": false,
191
+ "onceDelay": 0.1,
192
+ "topic": "dispatchStop",
193
+ "payload": "",
194
+ "payloadType": "date",
195
+ "x": 190,
196
+ "y": 320,
197
+ "wires": [
198
+ [
199
+ "node1"
200
+ ]
201
+ ]
202
+ },
203
+ {
204
+ "id": "i7",
205
+ "type": "inject",
206
+ "z": "tab1",
207
+ "name": "feed-in limit 70 %",
208
+ "props": [
209
+ {
210
+ "p": "topic",
211
+ "vt": "str"
212
+ },
213
+ {
214
+ "p": "payload"
215
+ }
216
+ ],
217
+ "repeat": "",
218
+ "crontab": "",
219
+ "once": false,
220
+ "onceDelay": 0.1,
221
+ "topic": "feedIn",
222
+ "payload": "70",
223
+ "payloadType": "num",
224
+ "x": 190,
225
+ "y": 360,
226
+ "wires": [
227
+ [
228
+ "node1"
229
+ ]
230
+ ]
231
+ },
232
+ {
233
+ "id": "i8",
234
+ "type": "inject",
235
+ "z": "tab1",
236
+ "name": "night charge 01:00-05:00",
237
+ "props": [
238
+ {
239
+ "p": "topic",
240
+ "vt": "str"
241
+ },
242
+ {
243
+ "p": "payload"
244
+ }
245
+ ],
246
+ "repeat": "",
247
+ "crontab": "",
248
+ "once": false,
249
+ "onceDelay": 0.1,
250
+ "topic": "timePeriod",
251
+ "payload": "{\"flag\":1,\"chargeCutSoc\":90,\"charge1\":{\"start\":\"01:00\",\"stop\":\"05:00\"}}",
252
+ "payloadType": "json",
253
+ "x": 190,
254
+ "y": 400,
255
+ "wires": [
256
+ [
257
+ "node1"
258
+ ]
259
+ ]
260
+ },
261
+ {
262
+ "id": "i9",
263
+ "type": "inject",
264
+ "z": "tab1",
265
+ "name": "UPS reserve 20 %",
266
+ "props": [
267
+ {
268
+ "p": "topic",
269
+ "vt": "str"
270
+ },
271
+ {
272
+ "p": "payload"
273
+ }
274
+ ],
275
+ "repeat": "",
276
+ "crontab": "",
277
+ "once": false,
278
+ "onceDelay": 0.1,
279
+ "topic": "timePeriod",
280
+ "payload": "{\"upsReserveSoc\":20}",
281
+ "payloadType": "json",
282
+ "x": 190,
283
+ "y": 440,
284
+ "wires": [
285
+ [
286
+ "node1"
287
+ ]
288
+ ]
289
+ },
290
+ {
291
+ "id": "node1",
292
+ "type": "alphaess-modbus",
293
+ "z": "tab1",
294
+ "name": "",
295
+ "server": "cfg1",
296
+ "interval": 15,
297
+ "slowInterval": 300,
298
+ "staleAfter": 180,
299
+ "topic": "alphaess/modbus",
300
+ "platform": "EMS3.x",
301
+ "block_grid": true,
302
+ "block_pvMeter": false,
303
+ "block_battery": true,
304
+ "block_inverter": true,
305
+ "block_systemRun": true,
306
+ "block_systemConfig": true,
307
+ "block_timePeriod": true,
308
+ "block_dispatch": true,
309
+ "block_info": true,
310
+ "includePvMeter": false,
311
+ "contextStore": "",
312
+ "mqttBroker": "",
313
+ "mqttPrefix": "alphaess",
314
+ "mqttQos": "0",
315
+ "mqttRetain": false,
316
+ "mqttDetails": false,
317
+ "allowWrite": false,
318
+ "maxPower": "5000",
319
+ "writeInterval": 10,
320
+ "socScale": 0.1,
321
+ "x": 520,
322
+ "y": 240,
323
+ "wires": [
324
+ [
325
+ "dbg1",
326
+ "ch1"
327
+ ],
328
+ [
329
+ "dbg2"
330
+ ],
331
+ [
332
+ "dbg4"
333
+ ]
334
+ ]
335
+ },
336
+ {
337
+ "id": "dbg1",
338
+ "type": "debug",
339
+ "z": "tab1",
340
+ "name": "data",
341
+ "active": true,
342
+ "tosidebar": true,
343
+ "console": false,
344
+ "tostatus": false,
345
+ "complete": "payload",
346
+ "targetType": "msg",
347
+ "x": 750,
348
+ "y": 180,
349
+ "wires": []
350
+ },
351
+ {
352
+ "id": "ch1",
353
+ "type": "change",
354
+ "z": "tab1",
355
+ "name": "daily consumption",
356
+ "rules": [
357
+ {
358
+ "t": "set",
359
+ "p": "payload",
360
+ "pt": "msg",
361
+ "to": "payload.daily.consumption",
362
+ "tot": "msg"
363
+ }
364
+ ],
365
+ "x": 770,
366
+ "y": 220,
367
+ "wires": [
368
+ [
369
+ "dbg3"
370
+ ]
371
+ ]
372
+ },
373
+ {
374
+ "id": "dbg3",
375
+ "type": "debug",
376
+ "z": "tab1",
377
+ "name": "kWh today",
378
+ "active": false,
379
+ "tosidebar": true,
380
+ "console": false,
381
+ "tostatus": true,
382
+ "complete": "payload",
383
+ "targetType": "msg",
384
+ "statusVal": "payload",
385
+ "statusType": "auto",
386
+ "x": 970,
387
+ "y": 220,
388
+ "wires": []
389
+ },
390
+ {
391
+ "id": "dbg2",
392
+ "type": "debug",
393
+ "z": "tab1",
394
+ "name": "alarm",
395
+ "active": true,
396
+ "tosidebar": true,
397
+ "console": false,
398
+ "tostatus": false,
399
+ "complete": "payload",
400
+ "targetType": "msg",
401
+ "x": 750,
402
+ "y": 260,
403
+ "wires": []
404
+ },
405
+ {
406
+ "id": "dbg4",
407
+ "type": "debug",
408
+ "z": "tab1",
409
+ "name": "command response",
410
+ "active": true,
411
+ "tosidebar": true,
412
+ "console": false,
413
+ "tostatus": false,
414
+ "complete": "true",
415
+ "targetType": "full",
416
+ "x": 770,
417
+ "y": 300,
418
+ "wires": []
419
+ }
420
+ ]
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+ /**
3
+ * Encoders for the whitelisted writable AlphaESS registers.
4
+ * Every encoder returns { address, values } for an FC16 write.
5
+ * Deliberately NOT supported: safety test (0x1000+), reset/ATE (0x1100+),
6
+ * CT calibration (0x11B9+), network/Modbus settings, battery MOS control,
7
+ * dispatch test modes (BurnIn, OSW modes, aging parameters 0x0889/0x088A).
8
+ */
9
+
10
+ const DISPATCH_OFFSET = 32000;
11
+
12
+ /** dispatch modes that may be written (Note 7 / Note 29); test and off-grid modes are excluded */
13
+ const DISPATCH_WRITE_MODES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19];
14
+
15
+ function num(v, name) {
16
+ const n = Number(v);
17
+ if (v === null || v === '' || !Number.isFinite(n)) throw new TypeError(`${name} must be a number`);
18
+ return n;
19
+ }
20
+
21
+ function clamp(v, min, max, name) {
22
+ if (v < min || v > max) throw new RangeError(`${name} must be between ${min} and ${max} (got ${v})`);
23
+ return v;
24
+ }
25
+
26
+ const split32 = (v) => { const u = v >>> 0; return [(u >>> 16) & 0xFFFF, u & 0xFFFF]; };
27
+
28
+ /**
29
+ * Dispatch (0x0880..0x0888)
30
+ * @param {object} p
31
+ * @param {number} p.power W, + = discharge, - = charge (same sign as battery power)
32
+ * @param {number} [p.mode=2] Note 7 (2 = State of Charge control)
33
+ * @param {number} [p.soc] target SOC in %, default 100 when charging, 10 when discharging
34
+ * @param {number} [p.duration=300] seconds
35
+ * @param {number} [p.reactivePower=0]
36
+ * @param {number} [p.maxPower] optional safety limit in W
37
+ */
38
+ function encodeDispatch(p) {
39
+ p = p || {};
40
+ const power = Math.round(num(p.power, 'power'));
41
+ const limit = p.maxPower !== undefined ? num(p.maxPower, 'maxPower') : DISPATCH_OFFSET - 1;
42
+ clamp(Math.abs(power), 0, limit, 'power');
43
+ const reactive = Math.round(num(p.reactivePower === undefined ? 0 : p.reactivePower, 'reactivePower'));
44
+ clamp(Math.abs(reactive), 0, DISPATCH_OFFSET - 1, 'reactivePower');
45
+ const mode = Math.round(num(p.mode === undefined ? 2 : p.mode, 'mode'));
46
+ if (!DISPATCH_WRITE_MODES.includes(mode)) {
47
+ throw new RangeError(`mode must be one of ${DISPATCH_WRITE_MODES.join(', ')} (got ${mode})`);
48
+ }
49
+ const soc = num(p.soc === undefined ? (power < 0 ? 100 : 10) : p.soc, 'soc');
50
+ clamp(soc, 0, 100, 'soc');
51
+ const duration = Math.round(num(p.duration === undefined ? 300 : p.duration, 'duration'));
52
+ clamp(duration, 1, 0xFFFFFFFF, 'duration');
53
+ return {
54
+ address: 0x0880,
55
+ values: [1, ...split32(DISPATCH_OFFSET + power), ...split32(DISPATCH_OFFSET + reactive),
56
+ mode, Math.round(soc / 0.4), ...split32(duration)]
57
+ };
58
+ }
59
+
60
+ function encodeDispatchStop() {
61
+ return { address: 0x0880, values: [0, ...split32(DISPATCH_OFFSET), ...split32(DISPATCH_OFFSET), 0, 0, 0, 0] };
62
+ }
63
+
64
+ /** Max feed into grid in percent (0x0800) */
65
+ function encodeFeedIn(percent) {
66
+ const v = Math.round(num(percent, 'percent'));
67
+ clamp(v, 0, 100, 'percent');
68
+ return { address: 0x0800, values: [v] };
69
+ }
70
+
71
+ function parseHm(s, name) {
72
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(s));
73
+ if (!m) throw new TypeError(`${name} must be "HH:MM"`);
74
+ const h = Number(m[1]), min = Number(m[2]);
75
+ clamp(h, 0, 23, name + ' hour');
76
+ clamp(min, 0, 59, name + ' minute');
77
+ return [h, min];
78
+ }
79
+
80
+ /**
81
+ * Time period control (0x084F..0x0861) – read-modify-write.
82
+ * @param {number[]} current 19 raw registers as read from 0x084F
83
+ * @param {object} p { flag, upsReserveSoc, chargeCutSoc, charge1:{start,stop}, charge2, discharge1, discharge2 }
84
+ * @param {number} [socScale=0.1] %/bit according to the documentation
85
+ */
86
+ function encodeTimePeriod(current, p, socScale) {
87
+ if (!Array.isArray(current) || current.length !== 19) throw new TypeError('current must contain 19 registers');
88
+ const scale = socScale || 0.1;
89
+ const v = current.slice();
90
+ const idx = (a) => a - 0x084F;
91
+ p = p || {};
92
+ if (p.flag !== undefined) v[idx(0x084F)] = clamp(Math.round(num(p.flag, 'flag')), 0, 3, 'flag');
93
+ if (p.upsReserveSoc !== undefined) {
94
+ v[idx(0x0850)] = Math.round(clamp(num(p.upsReserveSoc, 'upsReserveSoc'), 0, 100, 'upsReserveSoc') / scale);
95
+ }
96
+ if (p.chargeCutSoc !== undefined) {
97
+ v[idx(0x0855)] = Math.round(clamp(num(p.chargeCutSoc, 'chargeCutSoc'), 0, 100, 'chargeCutSoc') / scale);
98
+ }
99
+ // [hour start, hour stop, minute start, minute stop]
100
+ const slots = {
101
+ discharge1: [0x0851, 0x0852, 0x085A, 0x085B],
102
+ discharge2: [0x0853, 0x0854, 0x085C, 0x085D],
103
+ charge1: [0x0856, 0x0857, 0x085E, 0x085F],
104
+ charge2: [0x0858, 0x0859, 0x0860, 0x0861]
105
+ };
106
+ Object.keys(slots).forEach((k) => {
107
+ if (!p[k]) return;
108
+ const a = slots[k];
109
+ if (p[k].start !== undefined) { const [h, m] = parseHm(p[k].start, k + '.start'); v[idx(a[0])] = h; v[idx(a[2])] = m; }
110
+ if (p[k].stop !== undefined) { const [h, m] = parseHm(p[k].stop, k + '.stop'); v[idx(a[1])] = h; v[idx(a[3])] = m; }
111
+ });
112
+ return { address: 0x084F, values: v };
113
+ }
114
+
115
+ /**
116
+ * Compares the current register values with the new ones and returns one
117
+ * FC16 frame per contiguous run of changed registers. Unchanged registers are
118
+ * never written. An empty array means: nothing to write.
119
+ * @param {number} address address of current[0] / next[0]
120
+ * @param {number[]} current
121
+ * @param {number[]} next
122
+ */
123
+ function diffFrames(address, current, next) {
124
+ if (!Array.isArray(current) || !Array.isArray(next) || current.length !== next.length) {
125
+ throw new TypeError('current and next must be arrays of the same length');
126
+ }
127
+ const frames = [];
128
+ let run = null;
129
+ for (let i = 0; i < next.length; i++) {
130
+ if ((current[i] & 0xFFFF) === (next[i] & 0xFFFF)) {
131
+ run = null;
132
+ continue;
133
+ }
134
+ if (!run) {
135
+ run = { address: address + i, values: [] };
136
+ frames.push(run);
137
+ }
138
+ run.values.push(next[i] & 0xFFFF);
139
+ }
140
+ return frames;
141
+ }
142
+
143
+ module.exports = {
144
+ encodeDispatch, encodeDispatchStop, encodeFeedIn, encodeTimePeriod, diffFrames, DISPATCH_OFFSET, DISPATCH_WRITE_MODES
145
+ };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ /**
3
+ * Checks whether a context store name is configured in Node-RED's settings.js.
4
+ * Node-RED silently falls back to the default store (usually memory) for an
5
+ * unknown store name, so a typo or a missing contextStorage entry would make
6
+ * the daily values non-persistent without any visible error.
7
+ *
8
+ * @param {string} name store name selected in the node ('' = default)
9
+ * @param {object} contextStorage RED.settings.contextStorage (may be undefined)
10
+ * @returns {boolean}
11
+ */
12
+ function isStoreConfigured(name, contextStorage) {
13
+ if (!name || name === 'default') return true;
14
+ if (!contextStorage || typeof contextStorage !== 'object') {
15
+ // without contextStorage Node-RED only provides the in-memory store
16
+ return name === 'memory';
17
+ }
18
+ return Object.prototype.hasOwnProperty.call(contextStorage, name);
19
+ }
20
+
21
+ /** reads RED.settings.contextStorage without failing on older/stub runtimes */
22
+ function getContextStorage(RED) {
23
+ try {
24
+ return RED.settings ? RED.settings.contextStorage : undefined;
25
+ } catch (err) {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ module.exports = { isStoreConfigured, getContextStorage };