@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.
package/lib/derived.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ /**
3
+ * Derived values: live power flows, daily energy, alarms.
4
+ * Sign conventions: grid + = import / - = export, battery + = discharge / - = charge.
5
+ */
6
+ const { round } = require('./registers');
7
+
8
+ const pct = (v) => round(Math.max(0, Math.min(100, v)), 1);
9
+
10
+ /**
11
+ * Live values. Keys consumption/grid/modules/battery/soc are identical to the
12
+ * cloud node "Alpha ESS Monitoring" (node-red-contrib-alphaess).
13
+ */
14
+ function computeLive(d, opts) {
15
+ opts = opts || {};
16
+ const s = {};
17
+ const withAc = opts.includePvMeter && d.pvMeter;
18
+ if (d.inverter || withAc) {
19
+ s.modules = (d.inverter ? d.inverter.pvPowerTotal : 0) + (withAc ? Math.abs(d.pvMeter.totalActivePower) : 0);
20
+ }
21
+ if (d.grid) s.grid = d.grid.totalActivePower;
22
+ if (d.battery) {
23
+ s.battery = d.battery.power;
24
+ s.soc = d.battery.soc;
25
+ }
26
+ if (s.modules !== undefined && s.grid !== undefined && s.battery !== undefined) {
27
+ s.consumption = Math.max(0, s.modules + s.grid + s.battery);
28
+ s.gridImport = Math.max(0, s.grid);
29
+ s.gridExport = Math.max(0, -s.grid);
30
+ s.batteryCharge = Math.max(0, -s.battery);
31
+ s.batteryDischarge = Math.max(0, s.battery);
32
+ s.selfConsumptionRate = s.modules > 0 ? pct((s.modules - s.gridExport) / s.modules * 100) : null;
33
+ s.autarky = s.consumption > 0 ? pct((s.consumption - s.gridImport) / s.consumption * 100) : null;
34
+ }
35
+ return s;
36
+ }
37
+
38
+ // ------------------------------------------------------------------ daily ----
39
+ const COUNTERS = ['pv', 'gridFeed', 'gridImport', 'batteryCharge', 'batteryDischarge', 'batteryChargeFromGrid'];
40
+
41
+ /** a day counts as complete if counting started at most this long after local midnight */
42
+ const COMPLETE_TOLERANCE_MS = 15 * 60 * 1000;
43
+
44
+ function localDay(ts) {
45
+ const d = new Date(ts);
46
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
47
+ }
48
+
49
+ function localMidnight(ts) {
50
+ const d = new Date(ts);
51
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
52
+ }
53
+
54
+ function readCounters(d, opts) {
55
+ if (!d.grid || !d.battery || !d.inverter) return null;
56
+ if (opts.includePvMeter && !d.systemRun) return null;
57
+ return {
58
+ pv: d.inverter.totalPvEnergy + (opts.includePvMeter ? d.systemRun.pvInverterEnergy : 0),
59
+ gridFeed: d.grid.totalEnergyFeedToGrid,
60
+ gridImport: d.grid.totalEnergyConsumeFromGrid,
61
+ batteryCharge: d.battery.chargeEnergy,
62
+ batteryDischarge: d.battery.dischargeEnergy,
63
+ batteryChargeFromGrid: d.battery.energyChargeFromGrid
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Daily energy from the lifetime counters (difference since local midnight).
69
+ * store: { get(key), set(key, value) } – e.g. Node-RED node context.
70
+ *
71
+ * daily.complete is true if counting started within COMPLETE_TOLERANCE_MS
72
+ * after local midnight, i.e. the values cover the whole day. It is false after
73
+ * a restart during the day without a persistent context store.
74
+ */
75
+ class DailyTracker {
76
+ constructor(store, opts) {
77
+ this.store = store;
78
+ this.tolerance = (opts && opts.completeTolerance) || COMPLETE_TOLERANCE_MS;
79
+ }
80
+
81
+ update(d, now, opts) {
82
+ opts = opts || {};
83
+ const today = localDay(now);
84
+ let base = this.store.get('dayBase');
85
+ const last = this.store.get('dailyLast') || null;
86
+ let yesterday = this.store.get('yesterday') || null;
87
+ const c = readCounters(d, opts);
88
+ if (!c) return { daily: last && last.day === today ? last : null, yesterday };
89
+
90
+ let changed = false;
91
+ if (!base || base.day !== today) {
92
+ if (base && last && last.day === base.day) {
93
+ yesterday = last;
94
+ this.store.set('yesterday', yesterday);
95
+ }
96
+ base = { day: today, since: now, counters: Object.assign({}, c) };
97
+ changed = true;
98
+ }
99
+ if (typeof base.complete !== 'boolean') {
100
+ // also for bases stored by versions before 0.2.1
101
+ base.complete = base.since - localMidnight(base.since) <= this.tolerance;
102
+ changed = true;
103
+ }
104
+ const prevToday = (k) => (last && last.day === today && typeof last[k] === 'number' ? last[k] : 0);
105
+ const daily = { day: today, since: new Date(base.since).toISOString(), complete: base.complete };
106
+ COUNTERS.forEach((k) => {
107
+ if (typeof base.counters[k] !== 'number') {
108
+ base.counters[k] = c[k] - prevToday(k);
109
+ changed = true;
110
+ }
111
+ let diff = c[k] - base.counters[k];
112
+ if (diff < -0.05) {
113
+ // counter was reset or replaced – keep today's value and continue from the new counter
114
+ base.counters[k] = c[k] - prevToday(k);
115
+ diff = prevToday(k);
116
+ changed = true;
117
+ }
118
+ daily[k] = round(Math.max(0, diff), 2);
119
+ });
120
+ daily.consumption = round(Math.max(0,
121
+ daily.pv + daily.gridImport - daily.gridFeed + daily.batteryDischarge - daily.batteryCharge), 2);
122
+ const houseImport = Math.max(0, daily.gridImport - daily.batteryChargeFromGrid);
123
+ daily.selfConsumptionRate = daily.pv > 0 ? pct((daily.pv - daily.gridFeed) / daily.pv * 100) : null;
124
+ daily.autarky = daily.consumption > 0 ? pct((daily.consumption - houseImport) / daily.consumption * 100) : null;
125
+
126
+ if (changed) this.store.set('dayBase', base);
127
+ this.store.set('dailyLast', daily);
128
+ return { daily, yesterday };
129
+ }
130
+ }
131
+
132
+ // ----------------------------------------------------------------- alarms ----
133
+ function collectAlarms(d, blocks) {
134
+ const alarms = [];
135
+ const warnings = [];
136
+ const addBits = (arr, prefix, b) => { if (b && b.active) b.active.forEach((a) => arr.push(`${prefix}: ${a.text}`)); };
137
+ const hex = (v) => '0x' + v.toString(16).toUpperCase().padStart(8, '0');
138
+
139
+ if (d.systemRun) addBits(alarms, 'System', d.systemRun.systemFault);
140
+ if (d.battery) {
141
+ addBits(alarms, 'Battery', d.battery.fault);
142
+ (d.battery.faults || []).forEach((v, i) => { if (v) alarms.push(`Battery fault${i + 1}: ${hex(v)}`); });
143
+ addBits(warnings, 'Battery', d.battery.warning);
144
+ (d.battery.warnings || []).forEach((v, i) => { if (v) warnings.push(`Battery warning${i + 1}: ${hex(v)}`); });
145
+ }
146
+ if (d.inverter) {
147
+ ['fault1', 'fault2', 'faultExtend1', 'faultExtend2'].forEach((k) => addBits(alarms, 'Inverter', d.inverter[k]));
148
+ ['faultExtend3', 'faultExtend4'].forEach((k) => { if (d.inverter[k]) alarms.push(`Inverter ${k}: ${hex(d.inverter[k])}`); });
149
+ if (d.inverter.workMode && d.inverter.workMode.raw === 4) alarms.push('Inverter: work mode Fault');
150
+ if (d.inverter.warningBits) {
151
+ addBits(warnings, 'Inverter', d.inverter.warningBits.warning1);
152
+ addBits(warnings, 'Inverter', d.inverter.warningBits.warning2);
153
+ } else {
154
+ ['warning1', 'warning2'].forEach((k) => { if (d.inverter[k]) warnings.push(`Inverter ${k}: ${hex(d.inverter[k])}`); });
155
+ }
156
+ }
157
+ Object.keys(blocks || {}).forEach((k) => {
158
+ if (blocks[k].stale) alarms.push(`Stale data: ${k}` + (blocks[k].error ? ` (${blocks[k].error})` : ''));
159
+ });
160
+ return { alarms, warnings };
161
+ }
162
+
163
+ module.exports = { computeLive, DailyTracker, collectAlarms, localDay, localMidnight, COUNTERS, COMPLETE_TOLERANCE_MS };
package/lib/i18n.js ADDED
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+ /**
3
+ * Runtime translations.
4
+ *
5
+ * In Node-RED, RED._() looks up the message catalogs in locales/<language>/alphaess-modbus.json
6
+ * (language of the Node-RED server). If RED._ is not available (tests, older runtimes) or the key
7
+ * is missing, the English catalog is used directly. The English catalog is therefore the single
8
+ * source of truth for all runtime texts.
9
+ *
10
+ * Placeholders use the Node-RED syntax __name__.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const FALLBACK_FILE = path.join(__dirname, '..', 'locales', 'en-US', 'alphaess-modbus.json');
16
+ let fallbackCatalog = null;
17
+
18
+ function loadFallback() {
19
+ if (!fallbackCatalog) {
20
+ try {
21
+ fallbackCatalog = JSON.parse(fs.readFileSync(FALLBACK_FILE, 'utf8'));
22
+ } catch (err) {
23
+ fallbackCatalog = {};
24
+ }
25
+ }
26
+ return fallbackCatalog;
27
+ }
28
+
29
+ /** "a.b.c" -> catalog.a.b.c */
30
+ function lookup(catalog, key) {
31
+ return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), catalog);
32
+ }
33
+
34
+ function interpolate(text, vars) {
35
+ return String(text).replace(/__(\w+)__/g, (m, k) => (vars && vars[k] !== undefined ? String(vars[k]) : m));
36
+ }
37
+
38
+ /**
39
+ * @param {object} RED Node-RED runtime API (RED._ is pre-scoped to this module)
40
+ * @returns {function(string, object=): string}
41
+ */
42
+ function createTranslator(RED) {
43
+ return function t(key, vars) {
44
+ if (RED && typeof RED._ === 'function') {
45
+ try {
46
+ const s = RED._(key, vars);
47
+ if (typeof s === 'string' && s && s !== key && !s.endsWith(':' + key)) return s;
48
+ } catch (err) {
49
+ // fall through to the English catalog
50
+ }
51
+ }
52
+ const text = lookup(loadFallback(), key);
53
+ return typeof text === 'string' ? interpolate(text, vars) : key;
54
+ };
55
+ }
56
+
57
+ module.exports = { createTranslator, interpolate, lookup, FALLBACK_FILE };
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ /**
3
+ * Minimal Modbus TCP client (FC03 read holding registers, FC16 write multiple registers).
4
+ * No external dependencies. Requests are serialised (one in flight), because
5
+ * the AlphaESS EMS does not handle parallel requests reliably.
6
+ */
7
+ const net = require('net');
8
+ const { EventEmitter } = require('events');
9
+
10
+ const EXCEPTIONS = {
11
+ 1: 'Illegal function',
12
+ 2: 'Illegal data address',
13
+ 3: 'Illegal data value',
14
+ 4: 'Slave device failure',
15
+ 5: 'Acknowledge',
16
+ 6: 'Slave device busy',
17
+ 10: 'Gateway path unavailable',
18
+ 11: 'Gateway target failed to respond'
19
+ };
20
+
21
+ class ModbusError extends Error {
22
+ constructor(message, code) {
23
+ super(message);
24
+ this.name = 'ModbusError';
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ class ModbusTcpClient extends EventEmitter {
30
+ /**
31
+ * @param {object} opts
32
+ * @param {string} opts.host
33
+ * @param {number} [opts.port=502]
34
+ * @param {number} [opts.unitId=85]
35
+ * @param {number} [opts.timeout=2000] ms per request
36
+ * @param {number} [opts.delay=20] ms pause between requests
37
+ */
38
+ constructor(opts) {
39
+ super();
40
+ this.host = opts.host;
41
+ this.port = opts.port || 502;
42
+ this.unitId = opts.unitId === undefined ? 85 : opts.unitId;
43
+ this.timeout = opts.timeout || 2000;
44
+ this.delay = opts.delay === undefined ? 20 : opts.delay;
45
+ this.socket = null;
46
+ this.connecting = null;
47
+ this.buffer = Buffer.alloc(0);
48
+ this.queue = [];
49
+ this.current = null;
50
+ this.tid = 0;
51
+ this.closed = false;
52
+ }
53
+
54
+ get connected() {
55
+ return !!(this.socket && !this.socket.destroyed && !this.connecting);
56
+ }
57
+
58
+ _connect() {
59
+ if (this.connected) return Promise.resolve();
60
+ if (this.connecting) return this.connecting;
61
+ this.connecting = new Promise((resolve, reject) => {
62
+ const sock = net.createConnection({ host: this.host, port: this.port });
63
+ const timer = setTimeout(() => {
64
+ sock.destroy();
65
+ reject(new ModbusError(`Connect timeout ${this.host}:${this.port}`, 'ETIMEDOUT'));
66
+ }, this.timeout);
67
+ sock.setNoDelay(true);
68
+ sock.once('connect', () => {
69
+ clearTimeout(timer);
70
+ this.socket = sock;
71
+ this.buffer = Buffer.alloc(0);
72
+ this.emit('connect');
73
+ resolve();
74
+ });
75
+ sock.on('data', (d) => this._onData(d));
76
+ sock.on('error', (err) => {
77
+ clearTimeout(timer);
78
+ this.emit('socketError', err);
79
+ reject(err);
80
+ });
81
+ sock.on('close', () => {
82
+ if (this.socket === sock) this.socket = null;
83
+ this.emit('close');
84
+ if (this.current && this.current.sent) this._finish(new ModbusError('Connection closed', 'ECONNRESET'));
85
+ });
86
+ }).finally(() => { this.connecting = null; });
87
+ return this.connecting;
88
+ }
89
+
90
+ _onData(data) {
91
+ this.buffer = Buffer.concat([this.buffer, data]);
92
+ while (this.buffer.length >= 7) {
93
+ const frameLen = 6 + this.buffer.readUInt16BE(4);
94
+ if (this.buffer.length < frameLen) return;
95
+ const frame = this.buffer.subarray(0, frameLen);
96
+ this.buffer = this.buffer.subarray(frameLen);
97
+ this._onFrame(frame);
98
+ }
99
+ }
100
+
101
+ _onFrame(frame) {
102
+ const cur = this.current;
103
+ if (!cur || frame.length < 9) return;
104
+ if (frame.readUInt16BE(0) !== cur.tid) return; // stale answer of a timed-out request
105
+ const fc = frame[7];
106
+ if (fc & 0x80) {
107
+ const code = frame[8];
108
+ return this._finish(new ModbusError(
109
+ `Modbus exception ${code} (${EXCEPTIONS[code] || 'unknown'}) at 0x${cur.address.toString(16)}`, code));
110
+ }
111
+ if (fc !== cur.fc) return this._finish(new ModbusError(`Unexpected function code ${fc}`, 'EPROTO'));
112
+ if (fc === 3) {
113
+ const byteCount = frame[8];
114
+ const values = [];
115
+ for (let i = 0; i < byteCount / 2; i++) values.push(frame.readUInt16BE(9 + i * 2));
116
+ if (values.length !== cur.count) {
117
+ return this._finish(new ModbusError(`Expected ${cur.count} registers, got ${values.length}`, 'EPROTO'));
118
+ }
119
+ return this._finish(null, values);
120
+ }
121
+ return this._finish(null, { address: frame.readUInt16BE(8), quantity: frame.readUInt16BE(10) });
122
+ }
123
+
124
+ _finish(err, result) {
125
+ const cur = this.current;
126
+ if (!cur) return;
127
+ clearTimeout(cur.timer);
128
+ this.current = null;
129
+ if (err) cur.reject(err); else cur.resolve(result);
130
+ setTimeout(() => this._next(), this.delay);
131
+ }
132
+
133
+ _enqueue(job) {
134
+ if (this.closed) return Promise.reject(new ModbusError('Client closed', 'ECLOSED'));
135
+ return new Promise((resolve, reject) => {
136
+ this.queue.push(Object.assign(job, { resolve, reject }));
137
+ if (!this.current) this._next();
138
+ });
139
+ }
140
+
141
+ async _next() {
142
+ if (this.current || this.queue.length === 0) return;
143
+ const job = this.queue.shift();
144
+ this.current = job;
145
+ try {
146
+ await this._connect();
147
+ } catch (err) {
148
+ if (this.current === job) this.current = null;
149
+ job.reject(err);
150
+ return setTimeout(() => this._next(), this.delay);
151
+ }
152
+ if (this.current !== job) return;
153
+ this.tid = (this.tid + 1) & 0xFFFF;
154
+ job.tid = this.tid;
155
+ const adu = Buffer.alloc(7 + job.pdu.length);
156
+ adu.writeUInt16BE(job.tid, 0);
157
+ adu.writeUInt16BE(0, 2);
158
+ adu.writeUInt16BE(job.pdu.length + 1, 4);
159
+ adu[6] = this.unitId;
160
+ job.pdu.copy(adu, 7);
161
+ job.timer = setTimeout(() => {
162
+ // drop the connection: late answers would otherwise desync the stream
163
+ if (this.socket) this.socket.destroy();
164
+ this._finish(new ModbusError(`Timeout at 0x${job.address.toString(16)}`, 'ETIMEDOUT'));
165
+ }, this.timeout);
166
+ job.sent = true;
167
+ this.socket.write(adu);
168
+ }
169
+
170
+ /** FC03 – resolves with an array of uint16 */
171
+ readHoldingRegisters(address, count) {
172
+ if (count < 1 || count > 125) return Promise.reject(new RangeError('count must be 1..125'));
173
+ const pdu = Buffer.alloc(5);
174
+ pdu[0] = 3;
175
+ pdu.writeUInt16BE(address, 1);
176
+ pdu.writeUInt16BE(count, 3);
177
+ return this._enqueue({ fc: 3, address, count, pdu });
178
+ }
179
+
180
+ /** FC16 – values: array of uint16 */
181
+ writeRegisters(address, values) {
182
+ if (!Array.isArray(values) || values.length < 1 || values.length > 123) {
183
+ return Promise.reject(new RangeError('values must contain 1..123 registers'));
184
+ }
185
+ const pdu = Buffer.alloc(6 + values.length * 2);
186
+ pdu[0] = 16;
187
+ pdu.writeUInt16BE(address, 1);
188
+ pdu.writeUInt16BE(values.length, 3);
189
+ pdu[5] = values.length * 2;
190
+ values.forEach((v, i) => pdu.writeUInt16BE(v & 0xFFFF, 6 + i * 2));
191
+ return this._enqueue({ fc: 16, address, count: values.length, pdu });
192
+ }
193
+
194
+ close() {
195
+ this.closed = true;
196
+ const err = new ModbusError('Client closed', 'ECLOSED');
197
+ this.queue.splice(0).forEach((j) => j.reject(err));
198
+ if (this.current) this._finish(err);
199
+ if (this.socket) this.socket.destroy();
200
+ this.socket = null;
201
+ }
202
+ }
203
+
204
+ module.exports = { ModbusTcpClient, ModbusError };
package/lib/mqtt.js ADDED
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ /**
3
+ * Publishes to MQTT through an existing Node-RED "mqtt-broker" config node
4
+ * (core node), so no own MQTT client and no additional dependency is needed.
5
+ *
6
+ * Note: register()/publish()/deregister() are used by the core MQTT nodes but
7
+ * are not a documented public API.
8
+ */
9
+
10
+ const LIVE_KEYS = ['consumption', 'grid', 'modules', 'battery', 'soc', 'gridImport', 'gridExport',
11
+ 'batteryCharge', 'batteryDischarge', 'autarky', 'selfConsumptionRate'];
12
+
13
+ const bool = (v) => v === true || v === 'true';
14
+
15
+ class MqttPublisher {
16
+ constructor(RED, node, config) {
17
+ this.broker = null;
18
+ this.node = node;
19
+ const id = config.mqttBroker;
20
+ if (!id || id === '_ADD_') return;
21
+ const broker = RED.nodes.getNode(id);
22
+ if (!broker || typeof broker.publish !== 'function' || typeof broker.register !== 'function') {
23
+ node.warn('MQTT broker not found or not compatible – MQTT publishing disabled');
24
+ return;
25
+ }
26
+ this.broker = broker;
27
+ this.prefix = String(config.mqttPrefix || 'alphaess').replace(/\/+$/, '');
28
+ this.retain = bool(config.mqttRetain);
29
+ this.qos = [0, 1, 2].includes(Number(config.mqttQos)) ? Number(config.mqttQos) : 0;
30
+ this.details = bool(config.mqttDetails);
31
+ // The broker updates the status of all registered users on (dis)connect.
32
+ // A proxy keeps the status of our node untouched.
33
+ this.user = { id: node.id + ':mqtt', status: () => {} };
34
+ broker.register(this.user);
35
+ }
36
+
37
+ get enabled() {
38
+ return !!this.broker;
39
+ }
40
+
41
+ publish(topic, value, retain) {
42
+ if (!this.broker || value === undefined || value === null) return;
43
+ const payload = typeof value === 'object' ? JSON.stringify(value) : String(value);
44
+ try {
45
+ this.broker.publish({
46
+ topic: `${this.prefix}/${topic}`,
47
+ payload,
48
+ qos: this.qos,
49
+ retain: retain === undefined ? this.retain : retain
50
+ });
51
+ } catch (err) {
52
+ this.node.debug(`MQTT publish failed: ${err.message}`);
53
+ }
54
+ }
55
+
56
+ /** publish one poll cycle; alarm is only given when it changed */
57
+ publishCycle(payload, alarm) {
58
+ if (!this.broker) return;
59
+ LIVE_KEYS.forEach((k) => this.publish(k, payload[k]));
60
+ if (payload.daily) {
61
+ Object.keys(payload.daily).forEach((k) => {
62
+ if (k !== 'day' && k !== 'since') this.publish(`daily/${k}`, payload.daily[k]);
63
+ });
64
+ }
65
+ this.publish('status', payload.alarms.length ? 'alarm' : 'ok');
66
+ if (alarm) this.publish('alarm', alarm);
67
+ if (this.details) Object.keys(payload.details).forEach((b) => this.publish(`details/${b}`, payload.details[b]));
68
+ }
69
+
70
+ close(done) {
71
+ if (!this.broker) return done();
72
+ try {
73
+ this.broker.deregister(this.user, done, true);
74
+ } catch (err) {
75
+ done();
76
+ }
77
+ }
78
+ }
79
+
80
+ module.exports = { MqttPublisher, LIVE_KEYS };