signalk-rec-bms 1.0.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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/index.js +315 -0
  3. package/package.json +24 -0
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # signalk-rec-bms
2
+ SignalK plugin for REC-BMS
package/index.js ADDED
@@ -0,0 +1,315 @@
1
+ const WebSocket = require('ws');
2
+
3
+ module.exports = function(app) {
4
+ let ws;
5
+ const initialReconnectInterval = 5000; // 5 seconds
6
+ let currentReconnectInterval = initialReconnectInterval;
7
+ const heartbeatInterval = 5000; // 5 seconds
8
+ let pingInterval;
9
+ let shouldReconnect = true;
10
+
11
+ var plugin = {
12
+ id: "rec-bms-plugin",
13
+ name: "REC BMS Plugin",
14
+ description: "SignalK plugin for REC-BMS",
15
+ schema: {
16
+ type: "object",
17
+ properties: {
18
+ bmsUrl: {
19
+ title: "BMS WebSocket URL",
20
+ type: "string",
21
+ default: "ws://bms-ip/ws"
22
+ },
23
+ deltaPrefix: {
24
+ title: "Delta",
25
+ type: "string",
26
+ default: "electrical.batteries.bms"
27
+ }
28
+ }
29
+ },
30
+ start: function(options) {
31
+ app.debug("REC BMS Plugin: START function invoked with options " + JSON.stringify(options));
32
+ app.setPluginStatus("Connecting to BMS WebSocket…");
33
+ shouldReconnect = true;
34
+ currentReconnectInterval = initialReconnectInterval;
35
+
36
+ // Determine the vessel ID (use app.getSelfId if available, otherwise app.selfId or fallback to "self")
37
+ let vesselId;
38
+ if (typeof app.getSelfId === "function") {
39
+ vesselId = app.getSelfId();
40
+ } else if (app.selfId) {
41
+ vesselId = app.selfId;
42
+ } else {
43
+ vesselId = "self";
44
+ }
45
+ const context = "vessels." + vesselId;
46
+ app.debug("REC BMS Plugin: Using vessel context: " + context);
47
+
48
+ // Get the user-defined delta prefix (or default if not provided)
49
+ const prefix = options.deltaPrefix || "electrical.batteries.bms";
50
+ app.debug("REC BMS Plugin: Using delta prefix: " + prefix);
51
+
52
+ // Function to (re)connect.
53
+ function connect() {
54
+ app.debug("REC BMS Plugin: Attempting to connect to " + options.bmsUrl);
55
+ ws = new WebSocket(options.bmsUrl);
56
+
57
+ ws.on('open', () => {
58
+ app.debug("REC BMS Plugin: WebSocket connection opened");
59
+ app.setPluginStatus("Connected to BMS");
60
+ // Reset reconnection interval on successful connection.
61
+ currentReconnectInterval = initialReconnectInterval;
62
+ // Start the ping interval for heartbeat.
63
+ pingInterval = setInterval(() => {
64
+ if (ws && ws.readyState === WebSocket.OPEN) {
65
+ ws.ping();
66
+ app.debug("REC BMS Plugin: Sent ping");
67
+ }
68
+ }, heartbeatInterval);
69
+ });
70
+
71
+ ws.on('pong', () => {
72
+ app.debug("REC BMS Plugin: Received pong");
73
+ });
74
+
75
+ ws.on('message', (data) => {
76
+ let message = data;
77
+ if (typeof message !== 'string') {
78
+ if (Buffer.isBuffer(message)) {
79
+ message = message.toString();
80
+ } else {
81
+ app.debug("REC BMS Plugin: Received payload is not a string or Buffer");
82
+ return;
83
+ }
84
+ }
85
+ app.debug("REC BMS Plugin: Received message: " + message);
86
+
87
+ try {
88
+ const parsedData = JSON.parse(message);
89
+ app.debug("REC BMS Plugin: JSON parsed successfully");
90
+
91
+ // Build the delta update with proper context and timestamp.
92
+ let delta = {
93
+ context: context,
94
+ updates: [{
95
+ timestamp: new Date().toISOString(),
96
+ values: []
97
+ }]
98
+ };
99
+
100
+ // Helper: Convert time strings like "1 h 15 min" into minutes.
101
+ function extractMinutes(timeString) {
102
+ if (!timeString) return null;
103
+ let match = timeString.match(/(\d+)\s*h\s*(\d+)?\s*min/);
104
+ if (match) {
105
+ let hours = parseInt(match[1], 10);
106
+ let minutes = match[2] ? parseInt(match[2], 10) : 0;
107
+ return hours * 60 + minutes;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ // Process "status" messages.
113
+ if (parsedData.type === "status" && parsedData.bms_array) {
114
+ let master = parsedData.bms_array.master;
115
+ let power = (master.vbat !== undefined && master.ibat !== undefined)
116
+ ? master.vbat * master.ibat : null;
117
+ let cellVoltageDifference = master.maxcell - master.mincell;
118
+
119
+ delta.updates[0].values.push(
120
+ { path: `${prefix}.MinCellVoltage`, value: master.mincell },
121
+ { path: `${prefix}.MaxCellVoltage`, value: master.maxcell },
122
+ { path: `${prefix}.CellVoltageDifference`, value: cellVoltageDifference },
123
+ { path: `${prefix}.Current`, value: master.ibat },
124
+ { path: `${prefix}.Temperature`, value: master.tmax },
125
+ { path: `${prefix}.Voltage`, value: master.vbat },
126
+ { path: `${prefix}.StateOfCharge`, value: master.soc },
127
+ { path: `${prefix}.StateOfHealth`, value: master.soh },
128
+ { path: `${prefix}.TimeRemaining`, value: extractMinutes(master.time_remaining) },
129
+ { path: `${prefix}.ChargeDirection`, value: master.st_naprav },
130
+ { path: `${prefix}.ErrorPresent`, value: master.erro.present },
131
+ { path: `${prefix}.ErrorAddress`, value: master.erro.addr },
132
+ { path: `${prefix}.ErrorStatus`, value: master.erro.st },
133
+ { path: `${prefix}.ErrorConnection`, value: master.erro.con_st },
134
+ { path: `${prefix}.ErrorMessage`, value: master.error },
135
+ { path: `${prefix}.Power`, value: power },
136
+ { path: `${prefix}.MasterTime`, value: master.time },
137
+ { path: `${prefix}.MasterDate`, value: master.date }
138
+ );
139
+
140
+ if (parsedData.bms_array.slave) {
141
+ let slave = parsedData.bms_array.slave["0"];
142
+ if (slave) {
143
+ delta.updates[0].values.push(
144
+ { path: `${prefix}.SlaveAddress`, value: slave.address },
145
+ { path: `${prefix}.SlaveTempSensorStatus`, value: slave.st_temp },
146
+ { path: `${prefix}.BMSTemperature`, value: slave.temp_bms },
147
+ { path: `${prefix}.SlaveCellCount`, value: slave.st_celic }
148
+ );
149
+
150
+ Object.entries(slave.temp || {}).forEach(([key, value], index) => {
151
+ delta.updates[0].values.push({
152
+ path: `${prefix}.CellTemperature${index + 1}`,
153
+ value
154
+ });
155
+ });
156
+
157
+ Object.entries(slave.res || {}).forEach(([key, value], index) => {
158
+ delta.updates[0].values.push({
159
+ path: `${prefix}.CellResistance${index + 1}`,
160
+ value
161
+ });
162
+ });
163
+
164
+ Object.entries(slave.nap || {}).forEach(([key, value], index) => {
165
+ delta.updates[0].values.push({
166
+ path: `${prefix}.CellVoltage${index + 1}`,
167
+ value
168
+ });
169
+ });
170
+ }
171
+ }
172
+ }
173
+
174
+ // Process "settings" messages.
175
+ if (parsedData.type === "settings") {
176
+ let AhRemaining = (parsedData.capa !== undefined && parsedData.Ah !== undefined)
177
+ ? parsedData.capa - parsedData.Ah : null;
178
+ let maxChargeVoltage = (parsedData.cmax !== undefined && parsedData.cans !== undefined)
179
+ ? parsedData.cmax * parsedData.cans : null;
180
+
181
+ delta.updates[0].values.push(
182
+ { path: `${prefix}.MinVoltageThreshold`, value: parsedData.cmin },
183
+ { path: `${prefix}.MaxVoltageThreshold`, value: parsedData.cmax },
184
+ { path: `${prefix}.Tmax`, value: parsedData.tmax },
185
+ { path: `${prefix}.BVoltage`, value: parsedData.bvol },
186
+ { path: `${prefix}.BMinVoltage`, value: parsedData.bmin },
187
+ { path: `${prefix}.TBalance`, value: parsedData.tbal },
188
+ { path: `${prefix}.Tmin`, value: parsedData.tmin },
189
+ { path: `${prefix}.Capacity`, value: parsedData.capa },
190
+ { path: `${prefix}.ChargeVoltage`, value: parsedData.char },
191
+ { path: `${prefix}.Ioff`, value: parsedData.ioff },
192
+ { path: `${prefix}.ChargeHysteresis`, value: parsedData.chis },
193
+ { path: `${prefix}.VoltageDiffThreshold`, value: parsedData.razl },
194
+ { path: `${prefix}.MaxH`, value: parsedData.maxh },
195
+ { path: `${prefix}.MinH`, value: parsedData.minh },
196
+ { path: `${prefix}.BMSThreshold`, value: parsedData.bmth },
197
+ { path: `${prefix}.Ioja`, value: parsedData.ioja },
198
+ { path: `${prefix}.Soch`, value: parsedData.soch },
199
+ { path: `${prefix}.Op2L`, value: parsedData.op2l },
200
+ { path: `${prefix}.Op2H`, value: parsedData.op2h },
201
+ { path: `${prefix}.Re1L`, value: parsedData.re1l },
202
+ { path: `${prefix}.Re1H`, value: parsedData.re1h },
203
+ { path: `${prefix}.ChargeMode`, value: parsedData.chac },
204
+ { path: `${prefix}.DischargeMode`, value: parsedData.dchc },
205
+ { path: `${prefix}.MaxChargeCurrent`, value: parsedData.maxc },
206
+ { path: `${prefix}.MaxDischargeCurrent`, value: parsedData.maxd },
207
+ { path: `${prefix}.LowVoltageCutoff`, value: parsedData.clow },
208
+ //{ path: `${prefix}.StateOfChargeS`, value: parsedData.socs },
209
+ { path: `${prefix}.CycleCount`, value: parsedData.cycl },
210
+ { path: `${prefix}.Cans`, value: parsedData.cans },
211
+ { path: `${prefix}.Chemistry`, value: parsedData.chem },
212
+ { path: `${prefix}.Strn`, value: parsedData.strn },
213
+ { path: `${prefix}.Re1T`, value: parsedData.re1t },
214
+ { path: `${prefix}.Op2T`, value: parsedData.op2t },
215
+ { path: `${prefix}.Re1V`, value: parsedData.re1v },
216
+ { path: `${prefix}.Op2V`, value: parsedData.op2v },
217
+ { path: `${prefix}.Cfvc`, value: parsedData.cfvc },
218
+ { path: `${prefix}.Rsbr`, value: parsedData.rsbr },
219
+ { path: `${prefix}.BMSClockTime`, value: parsedData.mcu_time },
220
+ { path: `${prefix}.BMSClockDate`, value: parsedData.mcu_date },
221
+ { path: `${prefix}.BMSName`, value: parsedData.bms_name },
222
+ { path: `${prefix}.Address`, value: parsedData.addr },
223
+ { path: `${prefix}.TUnit`, value: parsedData.tunit },
224
+ { path: `${prefix}.AhUsed`, value: parsedData.Ah },
225
+ { path: `${prefix}.Current`, value: parsedData.cur },
226
+ { path: `${prefix}.NewLogEveryMidnight`, value: parsedData.new_log_every_midnight },
227
+ { path: `${prefix}.Out`, value: parsedData.out },
228
+ { path: `${prefix}.AhRemaining`, value: AhRemaining },
229
+ { path: `${prefix}.MaxChargeVoltage`, value: maxChargeVoltage }
230
+ );
231
+
232
+ if (parsedData.baud) {
233
+ delta.updates[0].values.push(
234
+ { path: `${prefix}.LcdBaudRate`, value: parsedData.baud.lcd },
235
+ { path: `${prefix}.ComBaudRate`, value: parsedData.baud.com }
236
+ );
237
+ }
238
+
239
+ if (parsedData.err) {
240
+ delta.updates[0].values.push(
241
+ { path: `${prefix}.ErrorPresenceFlag`, value: parsedData.err.p },
242
+ { path: `${prefix}.ErrorNumber`, value: parsedData.err.num }
243
+ );
244
+ }
245
+
246
+ if (parsedData.nnc) {
247
+ delta.updates[0].values.push(
248
+ { path: `${prefix}.NearestNeighborBMS`, value: parsedData.nnc.bms },
249
+ { path: `${prefix}.NearestNeighborCell`, value: parsedData.nnc.cell }
250
+ );
251
+ }
252
+
253
+ if (parsedData.vnc) {
254
+ delta.updates[0].values.push(
255
+ { path: `${prefix}.VNCBMS`, value: parsedData.vnc.bms },
256
+ { path: `${prefix}.VNCCell`, value: parsedData.vnc.cell }
257
+ );
258
+ }
259
+
260
+ if (parsedData.toor) {
261
+ delta.updates[0].values.push(
262
+ { path: `${prefix}.ToorBMS`, value: parsedData.toor.bms },
263
+ { path: `${prefix}.ToorCell`, value: parsedData.toor.cell }
264
+ );
265
+ }
266
+ }
267
+
268
+ delta.updates[0].values = delta.updates[0].values.filter(item => item.value !== null && item.value !== undefined);
269
+
270
+ app.debug("REC BMS Plugin: Sending delta: " + JSON.stringify(delta));
271
+ app.handleMessage(plugin.id, delta, 'v1');
272
+ } catch (err) {
273
+ app.error("REC BMS Plugin: Failed to parse JSON: " + err.message);
274
+ }
275
+ });
276
+
277
+ ws.on('close', () => {
278
+ app.debug("REC BMS Plugin: WebSocket connection closed");
279
+ app.setPluginStatus("BMS disconnected - ERROR");
280
+ clearInterval(pingInterval);
281
+ if (shouldReconnect) {
282
+ app.debug("REC BMS Plugin: Reconnecting in " + currentReconnectInterval + "ms");
283
+ setTimeout(connect, currentReconnectInterval);
284
+ // Exponential backoff, up to a maximum of 60 seconds.
285
+ currentReconnectInterval = Math.min(currentReconnectInterval * 2, 60000);
286
+ }
287
+ });
288
+
289
+ ws.on('error', (error) => {
290
+ app.error("REC BMS Plugin: error: " + error.message);
291
+ app.setPluginStatus("Error: " + error.message + " - ERROR");
292
+ clearInterval(pingInterval);
293
+ if (ws) {
294
+ ws.terminate();
295
+ }
296
+ });
297
+ }
298
+
299
+ // Start by connecting.
300
+ connect();
301
+ },
302
+ stop: function() {
303
+ shouldReconnect = false;
304
+ clearInterval(pingInterval);
305
+ if (ws) {
306
+ ws.close();
307
+ ws = null;
308
+ }
309
+ app.debug("REC BMS Plugin: stopped");
310
+ app.setPluginStatus("Stopped");
311
+ }
312
+ };
313
+
314
+ return plugin;
315
+ };
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "signalk-rec-bms",
3
+ "version": "1.0.0",
4
+ "description": "SignalK plugin for REC-BMS",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [
10
+ "signalk-node-server-plugin",
11
+ "signalk-category-hardware"
12
+ ],
13
+ "author": {
14
+ "name": "Oliver Fernander",
15
+ "email": "oliver@fernander.net"
16
+ },
17
+ "license": "ISC",
18
+ "dependencies": {
19
+ "ws": "^8.18.1"
20
+ },
21
+ "engines": {
22
+ "node": ">=10"
23
+ }
24
+ }