@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,256 @@
1
+ <!--
2
+ AlphaESS Modbus – editor definition.
3
+ All texts come from locales/<language>/alphaess-modbus.json (data-i18n / this._),
4
+ the help texts from locales/<language>/alphaess-modbus.html.
5
+ -->
6
+
7
+ <!-- ======================================================= config node === -->
8
+ <script type="text/javascript">
9
+ RED.nodes.registerType('alphaess-modbus-config', {
10
+ category: 'config',
11
+ defaults: {
12
+ name: { value: '' },
13
+ host: { value: '', required: true },
14
+ port: { value: 502, required: true, validate: RED.validators.number() },
15
+ unitId: { value: 85, required: true, validate: RED.validators.number() },
16
+ timeout: { value: 2000, validate: RED.validators.number() },
17
+ delay: { value: 20, validate: RED.validators.number() }
18
+ },
19
+ label: function () {
20
+ return this.name || (this.host ? this.host + ':' + this.port : 'AlphaESS Modbus');
21
+ }
22
+ });
23
+ </script>
24
+
25
+ <script type="text/html" data-template-name="alphaess-modbus-config">
26
+ <div class="form-row">
27
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> <span data-i18n="node-red:common.label.name"></span></label>
28
+ <input type="text" id="node-config-input-name">
29
+ </div>
30
+ <div class="form-row">
31
+ <label for="node-config-input-host"><i class="fa fa-server"></i> <span data-i18n="alphaess-modbus.label.host"></span></label>
32
+ <input type="text" id="node-config-input-host" data-i18n="[placeholder]alphaess-modbus.placeholder.host">
33
+ </div>
34
+ <div class="form-row">
35
+ <label for="node-config-input-port"><i class="fa fa-random"></i> <span data-i18n="alphaess-modbus.label.port"></span></label>
36
+ <input type="text" id="node-config-input-port" style="width:80px">
37
+ </div>
38
+ <div class="form-row">
39
+ <label for="node-config-input-unitId"><i class="fa fa-hashtag"></i> <span data-i18n="alphaess-modbus.label.unitId"></span></label>
40
+ <input type="text" id="node-config-input-unitId" style="width:80px"> <span data-i18n="alphaess-modbus.unit.unitId"></span>
41
+ </div>
42
+ <div class="form-row">
43
+ <label for="node-config-input-timeout"><i class="fa fa-clock-o"></i> <span data-i18n="alphaess-modbus.label.timeout"></span></label>
44
+ <input type="text" id="node-config-input-timeout" style="width:80px"> <span data-i18n="alphaess-modbus.unit.ms"></span>
45
+ </div>
46
+ <div class="form-row">
47
+ <label for="node-config-input-delay"><i class="fa fa-hourglass-half"></i> <span data-i18n="alphaess-modbus.label.delay"></span></label>
48
+ <input type="text" id="node-config-input-delay" style="width:80px"> <span data-i18n="alphaess-modbus.unit.delay"></span>
49
+ </div>
50
+ <div class="form-tips"><span data-i18n="alphaess-modbus.tip.disclaimer"></span></div>
51
+ </script>
52
+
53
+ <!-- ========================================================= main node === -->
54
+ <script type="text/javascript">
55
+ (function () {
56
+ /**
57
+ * Context stores configured in settings.js, as provided to the editor.
58
+ * RED.settings.context = { default: "memory", stores: ["memory", "file"] }
59
+ * Without contextStorage in settings.js only the default store exists.
60
+ */
61
+ function contextStores() {
62
+ var ctx = (RED.settings && RED.settings.context) || {};
63
+ var stores = Array.isArray(ctx.stores) ? ctx.stores.slice() : [];
64
+ return { def: ctx.default || 'memory', stores: stores };
65
+ }
66
+
67
+ function fillStoreSelect(_, select, current) {
68
+ var info = contextStores();
69
+ select.empty();
70
+ $('<option>').val('').text(_('alphaess-modbus.store.default', { store: info.def })).appendTo(select);
71
+ info.stores.forEach(function (s) {
72
+ if (s === info.def) return; // already covered by "default"
73
+ $('<option>').val(s).text(s).appendTo(select);
74
+ });
75
+ var known = current === '' || current === undefined || current === 'default' ||
76
+ current === info.def || info.stores.indexOf(current) !== -1;
77
+ if (!known) {
78
+ // keep a value from an older version or another installation visible
79
+ $('<option>').val(current).text(_('alphaess-modbus.store.notConfigured', { store: current })).appendTo(select);
80
+ }
81
+ select.val(current === 'default' || current === info.def ? '' : (current || ''));
82
+
83
+ var hint = $('#alphaess-store-hint');
84
+ var update = function () {
85
+ var v = select.val();
86
+ if (v && info.stores.indexOf(v) === -1) {
87
+ hint.text(_('alphaess-modbus.store.missing')).css('color', '#c00').show();
88
+ } else if (info.stores.length <= 1) {
89
+ hint.text(_('alphaess-modbus.store.onlyMemory')).css('color', '').show();
90
+ } else {
91
+ hint.hide();
92
+ }
93
+ };
94
+ select.off('change.alphaess').on('change.alphaess', update);
95
+ update();
96
+ }
97
+
98
+ RED.nodes.registerType('alphaess-modbus', {
99
+ category: 'Alpha ESS',
100
+ color: '#79A9E7',
101
+ defaults: {
102
+ name: { value: '' },
103
+ server: { value: '', type: 'alphaess-modbus-config', required: true },
104
+ interval: { value: 15, validate: RED.validators.number() },
105
+ slowInterval: { value: 300, validate: RED.validators.number() },
106
+ staleAfter: { value: 180, validate: RED.validators.number() },
107
+ topic: { value: 'alphaess/modbus' },
108
+ platform: { value: 'EMS3.x' },
109
+ block_grid: { value: true },
110
+ block_pvMeter: { value: false },
111
+ block_battery: { value: true },
112
+ block_inverter: { value: true },
113
+ block_systemRun: { value: true },
114
+ block_systemConfig: { value: true },
115
+ block_timePeriod: { value: true },
116
+ block_dispatch: { value: true },
117
+ block_info: { value: true },
118
+ includePvMeter: { value: false },
119
+ contextStore: { value: '' },
120
+ mqttBroker: { value: '', type: 'mqtt-broker', required: false },
121
+ mqttPrefix: { value: 'alphaess' },
122
+ mqttQos: { value: '0' },
123
+ mqttRetain: { value: false },
124
+ mqttDetails: { value: false },
125
+ allowWrite: { value: false },
126
+ maxPower: { value: '' },
127
+ writeInterval: { value: 10, validate: RED.validators.number() },
128
+ socScale: { value: 0.1, validate: RED.validators.number() }
129
+ },
130
+ inputs: 1,
131
+ outputs: 3,
132
+ outputLabels: function (i) {
133
+ var keys = ['data', 'alarm', 'response'];
134
+ return this._('alphaess-modbus.outputs.' + keys[i]);
135
+ },
136
+ icon: 'font-awesome/fa-bolt',
137
+ label: function () { return this.name || 'AlphaESS Modbus'; },
138
+ paletteLabel: 'AlphaESS Modbus',
139
+ oneditprepare: function () {
140
+ var node = this;
141
+ var _ = function (key, vars) { return node._(key, vars); };
142
+ var toggle = function () {
143
+ var on = $('#node-input-allowWrite').is(':checked');
144
+ $('.alphaess-write-row').toggle(on);
145
+ };
146
+ $('#node-input-allowWrite').on('change', toggle);
147
+ toggle();
148
+ fillStoreSelect(_, $('#node-input-contextStore'), this.contextStore);
149
+ }
150
+ });
151
+ })();
152
+ </script>
153
+
154
+ <script type="text/html" data-template-name="alphaess-modbus">
155
+ <div class="form-row">
156
+ <label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="node-red:common.label.name"></span></label>
157
+ <input type="text" id="node-input-name">
158
+ </div>
159
+ <div class="form-row">
160
+ <label for="node-input-server"><i class="fa fa-server"></i> <span data-i18n="alphaess-modbus.label.connection"></span></label>
161
+ <input type="text" id="node-input-server">
162
+ </div>
163
+ <div class="form-row">
164
+ <label for="node-input-interval"><i class="fa fa-repeat"></i> <span data-i18n="alphaess-modbus.label.interval"></span></label>
165
+ <input type="text" id="node-input-interval" style="width:80px"> <span data-i18n="alphaess-modbus.unit.interval"></span>
166
+ </div>
167
+ <div class="form-row">
168
+ <label for="node-input-slowInterval"><i class="fa fa-clock-o"></i> <span data-i18n="alphaess-modbus.label.slowInterval"></span></label>
169
+ <input type="text" id="node-input-slowInterval" style="width:80px"> <span data-i18n="alphaess-modbus.unit.slowInterval"></span>
170
+ </div>
171
+ <div class="form-row">
172
+ <label for="node-input-staleAfter"><i class="fa fa-hourglass-end"></i> <span data-i18n="alphaess-modbus.label.staleAfter"></span></label>
173
+ <input type="text" id="node-input-staleAfter" style="width:80px"> <span data-i18n="alphaess-modbus.unit.staleAfter"></span>
174
+ </div>
175
+ <div class="form-row">
176
+ <label for="node-input-topic"><i class="fa fa-tasks"></i> <span data-i18n="alphaess-modbus.label.topic"></span></label>
177
+ <input type="text" id="node-input-topic">
178
+ </div>
179
+ <div class="form-row">
180
+ <label for="node-input-platform"><i class="fa fa-microchip"></i> <span data-i18n="alphaess-modbus.label.platform"></span></label>
181
+ <select id="node-input-platform">
182
+ <option value="EMS3.x">EMS 3.5 / 3.6</option>
183
+ <option value="EMS2.5">EMS 2.5</option>
184
+ </select>
185
+ </div>
186
+ <div class="form-row">
187
+ <label><i class="fa fa-list"></i> <span data-i18n="alphaess-modbus.label.blocks"></span></label>
188
+ <div style="display:inline-block; width:70%">
189
+ <label style="width:auto"><input type="checkbox" id="node-input-block_grid" style="width:auto"> <span data-i18n="alphaess-modbus.block.grid"></span></label><br>
190
+ <label style="width:auto"><input type="checkbox" id="node-input-block_pvMeter" style="width:auto"> <span data-i18n="alphaess-modbus.block.pvMeter"></span></label><br>
191
+ <label style="width:auto"><input type="checkbox" id="node-input-block_battery" style="width:auto"> <span data-i18n="alphaess-modbus.block.battery"></span></label><br>
192
+ <label style="width:auto"><input type="checkbox" id="node-input-block_inverter" style="width:auto"> <span data-i18n="alphaess-modbus.block.inverter"></span></label><br>
193
+ <label style="width:auto"><input type="checkbox" id="node-input-block_systemRun" style="width:auto"> <span data-i18n="alphaess-modbus.block.systemRun"></span></label><br>
194
+ <label style="width:auto"><input type="checkbox" id="node-input-block_systemConfig" style="width:auto"> <span data-i18n="alphaess-modbus.block.systemConfig"></span></label><br>
195
+ <label style="width:auto"><input type="checkbox" id="node-input-block_timePeriod" style="width:auto"> <span data-i18n="alphaess-modbus.block.timePeriod"></span></label><br>
196
+ <label style="width:auto"><input type="checkbox" id="node-input-block_dispatch" style="width:auto"> <span data-i18n="alphaess-modbus.block.dispatch"></span></label><br>
197
+ <label style="width:auto"><input type="checkbox" id="node-input-block_info" style="width:auto"> <span data-i18n="alphaess-modbus.block.info"></span></label>
198
+ </div>
199
+ </div>
200
+ <div class="form-row">
201
+ <label style="width:auto"><input type="checkbox" id="node-input-includePvMeter" style="width:auto"> <span data-i18n="alphaess-modbus.label.includePvMeter"></span></label>
202
+ </div>
203
+ <div class="form-row">
204
+ <label for="node-input-contextStore"><i class="fa fa-database"></i> <span data-i18n="alphaess-modbus.label.contextStore"></span></label>
205
+ <select id="node-input-contextStore" style="width:250px"></select>
206
+ <div id="alphaess-store-hint" class="form-tips" style="display:none; margin-top:6px"></div>
207
+ </div>
208
+
209
+ <h4 data-i18n="alphaess-modbus.section.mqtt"></h4>
210
+ <div class="form-row">
211
+ <label for="node-input-mqttBroker"><i class="fa fa-rss"></i> <span data-i18n="alphaess-modbus.label.mqttBroker"></span></label>
212
+ <input type="text" id="node-input-mqttBroker">
213
+ </div>
214
+ <div class="form-row">
215
+ <label for="node-input-mqttPrefix"><i class="fa fa-sitemap"></i> <span data-i18n="alphaess-modbus.label.mqttPrefix"></span></label>
216
+ <input type="text" id="node-input-mqttPrefix" placeholder="alphaess">
217
+ </div>
218
+ <div class="form-row">
219
+ <label for="node-input-mqttQos"><i class="fa fa-empire"></i> <span data-i18n="alphaess-modbus.label.mqttQos"></span></label>
220
+ <select id="node-input-mqttQos" style="width:80px">
221
+ <option value="0">0</option>
222
+ <option value="1">1</option>
223
+ <option value="2">2</option>
224
+ </select>
225
+ </div>
226
+ <div class="form-row">
227
+ <label style="width:auto"><input type="checkbox" id="node-input-mqttRetain" style="width:auto"> <span data-i18n="alphaess-modbus.label.mqttRetain"></span></label>
228
+ &nbsp;&nbsp;
229
+ <label style="width:auto"><input type="checkbox" id="node-input-mqttDetails" style="width:auto"> <span data-i18n="alphaess-modbus.label.mqttDetails"></span></label>
230
+ </div>
231
+
232
+ <h4 data-i18n="alphaess-modbus.section.control"></h4>
233
+ <div class="form-row">
234
+ <label style="width:auto"><input type="checkbox" id="node-input-allowWrite" style="width:auto">
235
+ <b data-i18n="alphaess-modbus.label.allowWrite"></b> <span data-i18n="alphaess-modbus.unit.allowWrite"></span></label>
236
+ </div>
237
+ <div class="form-row alphaess-write-row">
238
+ <div class="form-tips" style="border-color:#c00">
239
+ <i class="fa fa-exclamation-triangle" style="color:#c00"></i> <span data-i18n="alphaess-modbus.tip.writeWarning"></span>
240
+ </div>
241
+ </div>
242
+ <div class="form-row alphaess-write-row">
243
+ <label for="node-input-maxPower"><i class="fa fa-shield"></i> <span data-i18n="alphaess-modbus.label.maxPower"></span></label>
244
+ <input type="text" id="node-input-maxPower" style="width:100px" data-i18n="[placeholder]alphaess-modbus.placeholder.maxPower">
245
+ <span data-i18n="alphaess-modbus.unit.maxPower"></span>
246
+ </div>
247
+ <div class="form-row alphaess-write-row">
248
+ <label for="node-input-writeInterval"><i class="fa fa-tachometer"></i> <span data-i18n="alphaess-modbus.label.writeInterval"></span></label>
249
+ <input type="text" id="node-input-writeInterval" style="width:80px"> <span data-i18n="alphaess-modbus.unit.writeInterval"></span>
250
+ </div>
251
+ <div class="form-row">
252
+ <label for="node-input-socScale"><i class="fa fa-percent"></i> <span data-i18n="alphaess-modbus.label.socScale"></span></label>
253
+ <input type="text" id="node-input-socScale" style="width:80px"> <span data-i18n="alphaess-modbus.unit.socScale"></span>
254
+ </div>
255
+ <div class="form-tips"><span data-i18n="alphaess-modbus.tip.disclaimer"></span></div>
256
+ </script>
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+
3
+ const { ModbusTcpClient } = require('./lib/modbus-tcp');
4
+ const { BLOCKS, decodeBlock } = require('./lib/registers');
5
+ const { computeLive, DailyTracker, collectAlarms } = require('./lib/derived');
6
+ const { MqttPublisher } = require('./lib/mqtt');
7
+ const { isStoreConfigured, getContextStorage } = require('./lib/context-store');
8
+ const { createTranslator } = require('./lib/i18n');
9
+ const cmd = require('./lib/commands');
10
+
11
+ const FAST_BLOCKS = ['grid', 'pvMeter', 'battery', 'inverter', 'systemRun'];
12
+ const SLOW_BLOCKS = ['systemConfig', 'timePeriod', 'dispatch'];
13
+ const INFO_BLOCKS = ['inverterInfo', 'systemInfo', 'batteryInfo'];
14
+ const OPTIONAL_INFO_BLOCKS = ['batteryInfo']; // not available on every firmware
15
+ const INFO_REFRESH_MS = 24 * 3600 * 1000;
16
+
17
+ const bool = (v) => v === true || v === 'true';
18
+ const numOr = (v, d) => {
19
+ const n = Number(v);
20
+ return v === '' || v === undefined || v === null || !Number.isFinite(n) ? d : n;
21
+ };
22
+ const hex = (a) => '0x' + a.toString(16).padStart(4, '0');
23
+ /** Modbus exceptions that mean "register range not supported by this firmware" */
24
+ const isUnsupported = (err) => err && (err.code === 2 || err.code === 3);
25
+
26
+ module.exports = function (RED) {
27
+ /**
28
+ * Translation of status texts, log messages and errors (language of the Node-RED server).
29
+ * Data in msg.payload (alarm/warning texts, field names) is never translated, so flows do not
30
+ * depend on the language setting.
31
+ */
32
+ const t = createTranslator(RED);
33
+
34
+ // ------------------------------------------------------------ config ----
35
+ function AlphaEssModbusConfigNode(config) {
36
+ RED.nodes.createNode(this, config);
37
+ this.host = config.host;
38
+ this.port = numOr(config.port, 502);
39
+ this.unitId = numOr(config.unitId, 85);
40
+ this.client = new ModbusTcpClient({
41
+ host: this.host,
42
+ port: this.port,
43
+ unitId: this.unitId,
44
+ timeout: numOr(config.timeout, 2000),
45
+ delay: numOr(config.delay, 20)
46
+ });
47
+ this.client.on('socketError', (err) => this.debug(`socket error: ${err.message}`));
48
+ this.on('close', (done) => { this.client.close(); done(); });
49
+ }
50
+ RED.nodes.registerType('alphaess-modbus-config', AlphaEssModbusConfigNode);
51
+
52
+ // -------------------------------------------------------------- main ----
53
+ function AlphaEssModbusNode(config) {
54
+ RED.nodes.createNode(this, config);
55
+ const node = this;
56
+ const server = RED.nodes.getNode(config.server);
57
+ if (!server) {
58
+ node.status({ fill: 'red', shape: 'ring', text: t('alphaess-modbus.status.noConnection') });
59
+ return;
60
+ }
61
+ const client = server.client;
62
+
63
+ const interval = Math.max(0, numOr(config.interval, 10));
64
+ const slowInterval = Math.max(interval, numOr(config.slowInterval, 300));
65
+ const staleAfter = Math.max(1, numOr(config.staleAfter, 180));
66
+ const topic = config.topic || 'alphaess/modbus';
67
+ const fast = FAST_BLOCKS.filter((b) => bool(config['block_' + b]));
68
+ const slow = SLOW_BLOCKS.filter((b) => bool(config['block_' + b]));
69
+ const all = fast.concat(slow);
70
+ const readInfo = bool(config.block_info);
71
+ const opts = {
72
+ platform: config.platform || 'EMS3.x',
73
+ includePvMeter: bool(config.includePvMeter),
74
+ socScale: numOr(config.socScale, 0.1)
75
+ };
76
+ const allowWrite = bool(config.allowWrite);
77
+ const maxPower = config.maxPower !== undefined && config.maxPower !== '' ? Number(config.maxPower) : undefined;
78
+ const writeInterval = Math.max(0, numOr(config.writeInterval, 10));
79
+ if (allowWrite) node.log(t('alphaess-modbus.warn.writeEnabled'));
80
+
81
+ // ------------------------------------------------ daily store ----
82
+ const store = String(config.contextStore || '').trim() || undefined;
83
+ let storeWarning = null;
84
+ if (!isStoreConfigured(store, getContextStorage(RED))) {
85
+ storeWarning = t('alphaess-modbus.warn.storeMissing', { store });
86
+ node.warn(storeWarning);
87
+ }
88
+ const ctx = node.context();
89
+ const daily = new DailyTracker({ get: (k) => ctx.get(k, store), set: (k, v) => ctx.set(k, v, store) });
90
+ const mqtt = new MqttPublisher(RED, node, config);
91
+
92
+ const cache = {}; // block -> { data, ts, error, errorTs }
93
+ const countFor = {}; // block -> register count after fallback to the older documentation
94
+ const unsupported = new Set(); // optional blocks the system does not provide
95
+ const lastWrite = {}; // command -> timestamp of the last actual write
96
+ let info = null;
97
+ let infoTs = 0;
98
+ let slowTs = 0;
99
+ let busy = false;
100
+ let timer = null;
101
+ let closed = false;
102
+ let cycle = 0;
103
+ let alarmKey = null;
104
+
105
+ function store2cache(name, values) {
106
+ const data = decodeBlock(name, values, opts);
107
+ cache[name] = { data, ts: Date.now() };
108
+ return data;
109
+ }
110
+
111
+ /**
112
+ * Reads a block. Blocks that were extended in the newer register list
113
+ * (minCount) are read with the new length first; if the system rejects
114
+ * the request, the node falls back to the old length permanently.
115
+ */
116
+ async function readRaw(name) {
117
+ const b = BLOCKS[name];
118
+ const count = countFor[name] || b.count;
119
+ try {
120
+ return await client.readHoldingRegisters(b.start, count);
121
+ } catch (err) {
122
+ if (!b.minCount || count <= b.minCount || !isUnsupported(err)) throw err;
123
+ countFor[name] = b.minCount;
124
+ node.log(t('alphaess-modbus.log.fallback', { block: name, min: b.minCount, count: b.count }));
125
+ return client.readHoldingRegisters(b.start, b.minCount);
126
+ }
127
+ }
128
+
129
+ async function readBlock(name) {
130
+ return store2cache(name, await readRaw(name));
131
+ }
132
+
133
+ async function readList(names, errors) {
134
+ for (const name of names) {
135
+ try {
136
+ await readBlock(name);
137
+ } catch (err) {
138
+ errors[name] = err.message;
139
+ cache[name] = Object.assign(cache[name] || {}, { error: err.message, errorTs: Date.now() });
140
+ }
141
+ }
142
+ }
143
+
144
+ async function refreshInfo() {
145
+ const errors = {};
146
+ for (const name of INFO_BLOCKS) {
147
+ if (unsupported.has(name)) continue;
148
+ try {
149
+ await readBlock(name);
150
+ } catch (err) {
151
+ if (OPTIONAL_INFO_BLOCKS.includes(name) && isUnsupported(err)) {
152
+ unsupported.add(name);
153
+ node.log(t('alphaess-modbus.log.unsupported', { block: name }));
154
+ continue;
155
+ }
156
+ errors[name] = err.message;
157
+ node.warn(`${name}: ${err.message}`);
158
+ }
159
+ }
160
+ const res = {};
161
+ INFO_BLOCKS.forEach((n) => { if (cache[n] && cache[n].data) res[n] = cache[n].data; });
162
+ if (Object.keys(res).length) {
163
+ info = res;
164
+ infoTs = Date.now();
165
+ mqtt.publish('info', info, true);
166
+ }
167
+ return errors;
168
+ }
169
+
170
+ function blockStatus(now) {
171
+ const res = {};
172
+ all.forEach((n) => {
173
+ const c = cache[n];
174
+ if (!c) return;
175
+ const limit = SLOW_BLOCKS.includes(n) ? Math.max(staleAfter, 2 * slowInterval + interval) : staleAfter;
176
+ // compare the exact age in ms; the rounded seconds are only for display
177
+ const ageMs = c.ts ? now - c.ts : null;
178
+ const s = { age: ageMs === null ? null : Math.round(ageMs / 1000), stale: ageMs === null ? true : ageMs > limit * 1000 };
179
+ if (c.error && (!c.ts || c.errorTs >= c.ts)) s.error = c.error;
180
+ if (countFor[n]) s.registers = countFor[n];
181
+ res[n] = s;
182
+ });
183
+ return res;
184
+ }
185
+
186
+ function setStatus(p, errors) {
187
+ const errs = Object.keys(errors);
188
+ if (errs.length && errs.length === all.length) {
189
+ node.status({ fill: 'red', shape: 'ring', text: errors[errs[0]] });
190
+ return;
191
+ }
192
+ const parts = [];
193
+ if (p.modules !== undefined) parts.push(t('alphaess-modbus.status.pv', { value: p.modules }));
194
+ if (p.grid !== undefined) parts.push(t('alphaess-modbus.status.grid', { value: p.grid }));
195
+ if (p.soc !== undefined) parts.push(t('alphaess-modbus.status.soc', { value: p.soc }));
196
+ if (p.consumption !== undefined) parts.push(t('alphaess-modbus.status.load', { value: p.consumption }));
197
+ if (storeWarning) parts.push(t('alphaess-modbus.status.storeMissingShort', { store }));
198
+ node.status({
199
+ fill: p.alarms.length ? 'red' : ((errs.length || storeWarning) ? 'yellow' : 'green'),
200
+ shape: 'dot',
201
+ text: parts.join(' | ') || t('alphaess-modbus.status.ok')
202
+ });
203
+ }
204
+
205
+ async function poll(send, origMsg, force) {
206
+ if (busy) return false;
207
+ busy = true;
208
+ try {
209
+ const start = Date.now();
210
+ if (readInfo && (!info || start - infoTs > INFO_REFRESH_MS)) await refreshInfo();
211
+ const errors = {};
212
+ const names = fast.slice();
213
+ if (slow.length && (force || !slowTs || start - slowTs >= slowInterval * 1000)) {
214
+ names.push(...slow);
215
+ slowTs = start;
216
+ }
217
+ await readList(names, errors);
218
+
219
+ const now = Date.now();
220
+ const blocks = blockStatus(now);
221
+ const details = {};
222
+ all.forEach((n) => { if (cache[n] && cache[n].data && !blocks[n].stale) details[n] = cache[n].data; });
223
+
224
+ const live = computeLive(details, opts);
225
+ const day = daily.update(details, now, opts);
226
+ const { alarms, warnings } = collectAlarms(details, blocks);
227
+ if (storeWarning) warnings.push(storeWarning);
228
+ const payload = Object.assign(live, {
229
+ daily: day.daily, yesterday: day.yesterday, alarms, warnings, blocks, details
230
+ });
231
+ if (info) payload.info = info;
232
+
233
+ const msg = origMsg ? RED.util.cloneMessage(origMsg) : {};
234
+ msg.topic = topic;
235
+ msg.payload = payload;
236
+ msg.timestamp = now;
237
+ msg.cycle = ++cycle;
238
+ msg.duration = now - start;
239
+ if (Object.keys(errors).length) msg.errors = errors; else delete msg.errors;
240
+
241
+ let alarmMsg = null;
242
+ const key = alarms.concat(['|'], warnings).join('\n');
243
+ if (key !== alarmKey) {
244
+ alarmKey = key;
245
+ alarmMsg = {
246
+ topic: topic + '/alarm',
247
+ payload: { active: alarms.length > 0, alarms, warnings, timestamp: new Date(now).toISOString() }
248
+ };
249
+ }
250
+ mqtt.publishCycle(payload, alarmMsg && alarmMsg.payload);
251
+ setStatus(payload, errors);
252
+ send([msg, alarmMsg, null]);
253
+ return true;
254
+ } finally {
255
+ busy = false;
256
+ }
257
+ }
258
+
259
+ function schedule(delay) {
260
+ if (!interval || closed) return;
261
+ timer = setTimeout(async () => {
262
+ try {
263
+ await poll((m) => node.send(m));
264
+ } catch (err) {
265
+ node.error(err.message);
266
+ }
267
+ schedule(interval * 1000);
268
+ }, delay);
269
+ }
270
+
271
+ // ------------------------------------------------------ commands ----
272
+ function requireWrite() {
273
+ if (!allowWrite) throw new Error(t('alphaess-modbus.errors.writeDisabled'));
274
+ }
275
+
276
+ /** minimum interval between two actual writes of the same command */
277
+ function checkRate(command) {
278
+ if (!writeInterval || !lastWrite[command]) return;
279
+ const wait = writeInterval * 1000 - (Date.now() - lastWrite[command]);
280
+ if (wait > 0) {
281
+ throw new Error(t('alphaess-modbus.errors.rateLimited',
282
+ { command, wait: Math.ceil(wait / 1000), interval: writeInterval }));
283
+ }
284
+ }
285
+
286
+ async function writeFrames(command, frames) {
287
+ for (const f of frames) await client.writeRegisters(f.address, f.values);
288
+ lastWrite[command] = Date.now();
289
+ return frames.map((f) => ({ address: hex(f.address), values: f.values }));
290
+ }
291
+
292
+ function reply(send, msg, payload, extra) {
293
+ const out = RED.util.cloneMessage(msg);
294
+ out.payload = payload;
295
+ Object.assign(out, extra);
296
+ send([null, null, out]);
297
+ }
298
+
299
+ /**
300
+ * read-modify-write of a setting block: writes only changed registers,
301
+ * nothing at all if the values are unchanged.
302
+ */
303
+ async function writeSetting(command, block, build, send, msg) {
304
+ const b = BLOCKS[block];
305
+ const current = await client.readHoldingRegisters(b.start, b.count);
306
+ const next = build(current);
307
+ const frames = cmd.diffFrames(b.start, current, next);
308
+ if (!frames.length) {
309
+ const data = store2cache(block, current);
310
+ reply(send, msg, data, { unchanged: true, written: [] });
311
+ node.status({ fill: 'blue', shape: 'ring', text: t('alphaess-modbus.status.unchanged', { command }) });
312
+ return;
313
+ }
314
+ checkRate(command);
315
+ const written = await writeFrames(command, frames);
316
+ const data = await readBlock(block);
317
+ reply(send, msg, data, { unchanged: false, written });
318
+ const count = frames.reduce((s, f) => s + f.values.length, 0);
319
+ node.status({ fill: 'blue', shape: 'dot', text: t('alphaess-modbus.status.written', { command, count }) });
320
+ }
321
+
322
+ node.on('input', async (msg, send, done) => {
323
+ send = send || function () { node.send.apply(node, arguments); };
324
+ done = done || function (err) { if (err) node.error(err, msg); };
325
+ const tp = String(msg.topic || 'read');
326
+ try {
327
+ switch (tp) {
328
+ case 'read':
329
+ case topic:
330
+ if (!(await poll(send, msg, true))) node.warn(t('alphaess-modbus.warn.pollRunning'));
331
+ break;
332
+ case 'readInfo': {
333
+ const errors = await refreshInfo();
334
+ reply(send, msg, info, Object.keys(errors).length ? { errors } : {});
335
+ break;
336
+ }
337
+ case 'readRaw': {
338
+ const a = Number(msg.payload && msg.payload.address);
339
+ const c = Number(msg.payload && msg.payload.count) || 1;
340
+ if (!Number.isInteger(a) || a < 0 || a > 0xFFFF) throw new RangeError(t('alphaess-modbus.errors.addressRange'));
341
+ reply(send, msg, await client.readHoldingRegisters(a, c));
342
+ break;
343
+ }
344
+ case 'dispatch': {
345
+ requireWrite();
346
+ const frame = cmd.encodeDispatch(Object.assign({ maxPower }, msg.payload));
347
+ checkRate('dispatch');
348
+ const written = await writeFrames('dispatch', [frame]);
349
+ reply(send, msg, await readBlock('dispatch'), { written });
350
+ node.status({ fill: 'blue', shape: 'dot', text: t('alphaess-modbus.status.dispatch', { power: msg.payload.power }) });
351
+ break;
352
+ }
353
+ case 'dispatchStop': {
354
+ // never rate limited: stopping must always be possible
355
+ requireWrite();
356
+ const written = await writeFrames('dispatchStop', [cmd.encodeDispatchStop()]);
357
+ reply(send, msg, await readBlock('dispatch'), { written });
358
+ node.status({ fill: 'blue', shape: 'ring', text: t('alphaess-modbus.status.dispatchStopped') });
359
+ break;
360
+ }
361
+ case 'feedIn': {
362
+ requireWrite();
363
+ const p = msg.payload !== null && typeof msg.payload === 'object' ? msg.payload.percent : msg.payload;
364
+ const frame = cmd.encodeFeedIn(p);
365
+ await writeSetting('feedIn', 'systemConfig', (current) => {
366
+ const next = current.slice();
367
+ next[frame.address - BLOCKS.systemConfig.start] = frame.values[0];
368
+ return next;
369
+ }, send, msg);
370
+ break;
371
+ }
372
+ case 'timePeriod':
373
+ requireWrite();
374
+ await writeSetting('timePeriod', 'timePeriod',
375
+ (current) => cmd.encodeTimePeriod(current, msg.payload, opts.socScale).values, send, msg);
376
+ break;
377
+ default:
378
+ throw new Error(t('alphaess-modbus.errors.unknownTopic', { topic: tp }));
379
+ }
380
+ done();
381
+ } catch (err) {
382
+ node.status({ fill: 'red', shape: 'ring', text: err.message });
383
+ done(err);
384
+ }
385
+ });
386
+
387
+ node.on('close', (done) => {
388
+ closed = true;
389
+ clearTimeout(timer);
390
+ mqtt.close(done);
391
+ });
392
+
393
+ node.status(storeWarning
394
+ ? { fill: 'yellow', shape: 'ring', text: t('alphaess-modbus.status.storeMissing', { store }) }
395
+ : { fill: 'grey', shape: 'ring', text: interval ? t('alphaess-modbus.status.polling', { interval }) : t('alphaess-modbus.status.manual') });
396
+ schedule(1000);
397
+ }
398
+ RED.nodes.registerType('alphaess-modbus', AlphaEssModbusNode);
399
+ };