signalk-rec-bms 1.1.6 → 1.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-rec-bms",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "SignalK plugin for REC-BMS",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/index.js CHANGED
@@ -6,25 +6,97 @@ const connection = require('./lib/connection');
6
6
  module.exports = function(app) {
7
7
  let conn;
8
8
  let pluginOptions;
9
- let commsHandler;
9
+
10
+ function publishMeta(options) {
11
+ const p = options.deltaPrefix || "electrical.batteries.bms";
12
+
13
+ const meta = [
14
+ // Live data
15
+ { path: `${p}.minCellVoltage`, value: { units: "V", description: "Minimum cell voltage across all cells" } },
16
+ { path: `${p}.maxCellVoltage`, value: { units: "V", description: "Maximum cell voltage across all cells" } },
17
+ { path: `${p}.cellVoltageDifference`, value: { units: "V", description: "Difference between maximum and minimum cell voltage" } },
18
+ { path: `${p}.numBMSUnits`, value: { description: "Number of ABMS units connected in series" } },
19
+ { path: `${p}.bmsTemperature`, value: { units: "K", description: "BMS board internal temperature" } },
20
+ { path: `${p}.slaveAddress`, value: { description: "RS-485 address of the BMS unit with the lowest cell voltage" } },
21
+ { path: `${p}.minCellNumber`, value: { description: "Cell number with the lowest voltage" } },
22
+ { path: `${p}.maxCellNumber`, value: { description: "Cell number with the highest voltage" } },
23
+ { path: `${p}.maxTempSensBmsAddress`, value: { description: "RS-485 address of BMS unit with the highest temperature sensor reading" } },
24
+ { path: `${p}.maxTempSensNumber`, value: { description: "Sensor number with the highest temperature reading" } },
25
+ { path: `${p}.id`, value: { description: "Device identification string" } },
26
+ { path: `${p}.errorPresent`, value: { description: "1 if an error is active, 0 if none" } },
27
+ { path: `${p}.errorAddress`, value: { description: "RS-485 address of BMS unit reporting the error" } },
28
+ { path: `${p}.errorCode`, value: { description: "Error code (1-16)" } },
29
+ { path: `${p}.errorSource`, value: { description: "Cell or sensor number associated with the error" } },
30
+ // Derived
31
+ { path: `${p}.power`, value: { units: "W", description: "Instantaneous power (voltage × current)" } },
32
+ { path: `${p}.capacity.remaining`, value: { units: "Ah", description: "Estimated capacity remaining" } },
33
+ { path: `${p}.capacity.nominal`, value: { units: "Ah", description: "Nominal battery pack capacity as configured in BMS" } },
34
+ { path: `${p}.capacity.timeRemaining`, value: { units: "s", description: "Estimated time to full discharge at current load" } },
35
+ { path: `${p}.timeToFull`, value: { units: "s", description: "Estimated time to full charge at current charge rate" } },
36
+ // Battery
37
+ { path: `${p}.batteryCycleCount`, value: { description: "Number of full charge/discharge cycles" } },
38
+ // Voltage thresholds
39
+ { path: `${p}.balEndVoltage`, value: { units: "V", description: "Cell voltage at which balancing ends" } },
40
+ { path: `${p}.balStartVoltage`, value: { units: "V", description: "Cell voltage at which balancing starts" } },
41
+ { path: `${p}.maxAllowedCellVoltage`, value: { units: "V", description: "Cell over-voltage switch-off threshold" } },
42
+ { path: `${p}.maxAllowedVoltageHysteresis`, value: { units: "V", description: "Over-voltage switch-off hysteresis per cell" } },
43
+ { path: `${p}.minAllowedCellVoltage`, value: { units: "V", description: "Cell under-voltage switch-off threshold" } },
44
+ { path: `${p}.minAllowedVoltageHysteresis`, value: { units: "V", description: "Under-voltage switch-off hysteresis per cell" } },
45
+ { path: `${p}.endChargeVoltage`, value: { units: "V", description: "Cell end-of-charge voltage" } },
46
+ { path: `${p}.endChargeHysteresis`, value: { units: "V", description: "End-of-charge voltage hysteresis per cell" } },
47
+ { path: `${p}.endOfChargeCellDifference`, value: { units: "V", description: "Maximum allowed cell voltage difference at end of charge" } },
48
+ { path: `${p}.floatVoltageCoefficient`, value: { description: "Float voltage coefficient (multiplier on charge voltage)" } },
49
+ { path: `${p}.maxAllowedCellVoltDiff`, value: { units: "V", description: "Maximum allowed cell voltage difference during operation" } },
50
+ // Temperature thresholds
51
+ { path: `${p}.cellOverTempSwitchOff`, value: { units: "K", description: "Cell over-temperature switch-off threshold" } },
52
+ { path: `${p}.underTempChargeDisable`, value: { units: "K", description: "Under-temperature charge disable threshold" } },
53
+ { path: `${p}.bmsOverTempSwitchOff`, value: { units: "K", description: "BMS board over-temperature switch-off threshold" } },
54
+ { path: `${p}.bmsOverTempSwitchOffHysteresis`, value: { units: "K", description: "BMS board over-temperature switch-off hysteresis" } },
55
+ // Current sensor
56
+ { path: `${p}.currentSensorOffset`, value: { units: "A", description: "Current measurement zero offset" } },
57
+ { path: `${p}.currentSensorCoefficient`, value: { units: "A/V", description: "Voltage-to-current conversion coefficient" } },
58
+ // SOC
59
+ { path: `${p}.socHysteresis`, value: { units: "ratio", description: "SOC end-of-charge hysteresis (0-1)" } },
60
+ { path: `${p}.socReset`, value: { units: "ratio", description: "SOC value applied on manual reset (0-1)" } },
61
+ // Outputs
62
+ { path: `${p}.relay1Voltage`, value: { units: "V", description: "Relay 1 activation voltage threshold" } },
63
+ { path: `${p}.relay1Hysteresis`, value: { units: "V", description: "Relay 1 voltage hysteresis" } },
64
+ { path: `${p}.opto2Voltage`, value: { units: "V", description: "Optocoupler 2 activation voltage threshold" } },
65
+ { path: `${p}.opto2Hysteresis`, value: { units: "V", description: "Optocoupler 2 voltage hysteresis" } },
66
+ // Victron/Wakespeed
67
+ { path: `${p}.chargeCoefficient`, value: { description: "Charge current coefficient (0-5C)" } },
68
+ { path: `${p}.dischargeCoefficient`, value: { description: "Discharge current coefficient (0-5C)" } },
69
+ { path: `${p}.numberOfInverterDevices`, value: { description: "Number of inverter/charger devices on the CAN bus" } },
70
+ { path: `${p}.maxChargeCurrent`, value: { units: "A", description: "Maximum charge current per inverter device" } },
71
+ { path: `${p}.maxDischargeCurrent`, value: { units: "A", description: "Maximum discharge current per inverter device" } },
72
+ { path: `${p}.minDischargeCellVoltage`, value: { units: "V", description: "Cell under-voltage discharge protection threshold" } },
73
+ // ABMS
74
+ { path: `${p}.deviceName`, value: { description: "BMS device identifier (serial number formatted as 1A-XXXX)" } },
75
+ { path: `${p}.abmsSoftwareVersion`, value: { description: "ABMS firmware software version" } },
76
+ { path: `${p}.abmsHardwareVersion`, value: { description: "ABMS hardware version" } },
77
+ { path: `${p}.time`, value: { description: "BMS real-time clock time (hh:mm:ss)" } },
78
+ { path: `${p}.date`, value: { description: "BMS real-time clock date (dd.mm.yyyy)" } },
79
+ { path: `${p}.balancingCellNumber`, value: { description: "Cell number currently being balanced" } },
80
+ { path: `${p}.calibrationOffsetCell1`, value: { units: "V", description: "Voltage calibration offset for cell 1" } },
81
+ { path: `${p}.calibrationOffsetCell2`, value: { units: "V", description: "Voltage calibration offset for cell 2" } },
82
+ { path: `${p}.calibrationOffsetCell3`, value: { units: "V", description: "Voltage calibration offset for cell 3" } },
83
+ { path: `${p}.calibrationOffsetCell4`, value: { units: "V", description: "Voltage calibration offset for cell 4" } },
84
+ // Error log
85
+ { path: `${p}.errorLogData`, value: { description: "Last error log entry (format: code,cell;hh:mm:ss;dd.mm.yyyy)" } },
86
+ { path: `${p}.errorLogDelete`, value: { description: "Error log delete flag (set to 1 to clear, then restart BMS)" } },
87
+ { path: `${p}.vmaxExceededCount`, value: { description: "Number of times cell over-voltage threshold was exceeded" } },
88
+ { path: `${p}.vminExceededCount`, value: { description: "Number of times cell under-voltage threshold was exceeded" } },
89
+ ];
90
+
91
+ app.handleMessage("signalk-rec-bms", {
92
+ updates: [{ meta }]
93
+ });
94
+ }
10
95
 
11
96
  function publishDelta(delta) {
12
- let mergedConfig = {};
13
- if (pluginOptions && pluginOptions.deltas) {
14
- if (pluginOptions.deltas.common) {
15
- Object.assign(mergedConfig, pluginOptions.deltas.common);
16
- }
17
- if (pluginOptions.deltas.ws) {
18
- Object.assign(mergedConfig, pluginOptions.deltas.ws);
19
- }
20
- if (pluginOptions.deltas.serial) {
21
- Object.assign(mergedConfig, pluginOptions.deltas.serial);
22
- }
23
- }
24
-
25
97
  const precision = 5;
26
98
  const factor = Math.pow(10, precision);
27
-
99
+
28
100
  delta.updates.forEach(update => {
29
101
  update.values.forEach(item => {
30
102
  if (typeof item.value === "number") {
@@ -32,7 +104,7 @@ module.exports = function(app) {
32
104
  }
33
105
  });
34
106
  });
35
-
107
+
36
108
  if (delta && delta.updates && delta.updates.some(u => u.values.length > 0)) {
37
109
  app.debug(`[INDEX] Publishing delta: ${JSON.stringify(delta)}`);
38
110
  app.handleMessage("signalk-rec-bms", delta, "v1");
@@ -40,7 +112,7 @@ module.exports = function(app) {
40
112
  app.debug("[INDEX] Delta empty after processing, not publishing.");
41
113
  }
42
114
  }
43
-
115
+
44
116
  var plugin = {
45
117
  id: "signalk-rec-bms",
46
118
  name: "SignalK-REC-BMS",
@@ -50,6 +122,7 @@ module.exports = function(app) {
50
122
  pluginOptions = options;
51
123
  app.debug(`[INDEX] START invoked with options: ${JSON.stringify(options)}`);
52
124
  app.setPluginStatus("Connecting to BMS…");
125
+ publishMeta(options);
53
126
  conn = connection(options, app, publishDelta);
54
127
  conn.start(options);
55
128
  },
@@ -64,4 +137,4 @@ module.exports = function(app) {
64
137
  };
65
138
 
66
139
  return plugin;
67
- };
140
+ };
@@ -54,6 +54,11 @@ module.exports = {
54
54
  title: "Delta Path Prefix",
55
55
  type: "string",
56
56
  default: "electrical.batteries.bms"
57
+ },
58
+ numTempSensors: {
59
+ title: "Number of Temperature Sensors",
60
+ type: "number",
61
+ default: 1
57
62
  }
58
63
  }
59
- };
64
+ };
@@ -208,7 +208,7 @@ function getDelta(parsed, options, app) {
208
208
  updates: [{
209
209
  timestamp: new Date().toISOString(),
210
210
  values: [
211
- { path: `${prefix}.name`, value: "1A-" + d.abmsSerialNumber }
211
+ { path: `${prefix}.deviceName`, value: "1A-" + d.abmsSerialNumber }
212
212
  ]
213
213
  }]
214
214
  };
@@ -297,4 +297,4 @@ module.exports = {
297
297
  parseTIMEResponse,
298
298
  parseDATEResponse,
299
299
  parseWCBIResponse
300
- };
300
+ };
@@ -123,20 +123,22 @@ function parsePTEMResponse(packets) {
123
123
  }
124
124
 
125
125
  const sizePacket = packets.find(p => p[3] === 1);
126
- const dataPacket = packets.find(p => p[3] >= 4 && (p.length - 7) % 4 === 0); // Payload divisible by 4 bytes
126
+ const dataPackets = packets.filter(p => p[3] !== 1);
127
127
 
128
- if (!sizePacket || !dataPacket) {
128
+ if (!sizePacket || dataPackets.length === 0) {
129
129
  return null;
130
130
  }
131
131
 
132
132
  const numBMSUnits = parseInt(sizePacket.slice(4, 5).toString('utf8'), 10);
133
- const floatCount = (dataPacket.length - 7) / 4;
134
133
 
135
134
  let temperature = [];
136
- for (let i = 0; i < floatCount; i++) {
137
- const offset = 4 + i * 4;
138
- temperature.push(dataPacket.readFloatLE(offset));
139
- }
135
+ dataPackets.forEach(packet => {
136
+ const floatCount = (packet.length - 7) / 4;
137
+ for (let i = 0; i < floatCount; i++) {
138
+ const offset = 4 + i * 4;
139
+ temperature.push(packet.readFloatLE(offset));
140
+ }
141
+ });
140
142
 
141
143
  const result = {
142
144
  type: "PTEM",
@@ -235,7 +237,7 @@ function getDelta(parsed, options, app) {
235
237
  let vesselId = (typeof app.getSelfId === 'function') ? app.getSelfId() : (app.selfId || "self");
236
238
  const context = `vessels.${vesselId}`;
237
239
  const prefix = options.deltaPrefix || "electrical.batteries.bms";
238
-
240
+
239
241
  let delta = null;
240
242
  switch (parsed.type) {
241
243
  case "LCD1": {
@@ -245,13 +247,13 @@ function getDelta(parsed, options, app) {
245
247
  updates: [{
246
248
  timestamp: new Date().toISOString(),
247
249
  values: [
248
- { path: `${prefix}.minCellVoltage`, value: d.minVoltage },
249
- { path: `${prefix}.maxCellVoltage`, value: d.maxVoltage },
250
- { path: `${prefix}.current`, value: d.current },
251
- //{ path: `${prefix}.cellTemperature`, value: d.temperature },
252
- { path: `${prefix}.voltage`, value: d.packVoltage },
253
- { path: `${prefix}.stateOfCharge`, value: d.soc },
254
- { path: `${prefix}.stateOfHealth`, value: d.soh }
250
+ { path: `${prefix}.minCellVoltage`, value: d.minVoltage },
251
+ { path: `${prefix}.maxCellVoltage`, value: d.maxVoltage },
252
+ { path: `${prefix}.current`, value: d.current },
253
+ //{ path: `${prefix}.temperature`, value: d.temperature },
254
+ { path: `${prefix}.voltage`, value: d.packVoltage },
255
+ { path: `${prefix}.capacity.stateOfCharge`, value: d.soc },
256
+ { path: `${prefix}.capacity.stateOfHealth`, value: d.soh }
255
257
  ]
256
258
  }]
257
259
  };
@@ -264,11 +266,12 @@ function getDelta(parsed, options, app) {
264
266
  updates: [{
265
267
  timestamp: new Date().toISOString(),
266
268
  values: [
267
- { path: `${prefix}.slaveAddress`, value: d.minCellBmsAddress },
268
- { path: `${prefix}.slaveCellCount`, value: d.maxCellNumber - d.minCellNumber + 1 },
269
- { path: `${prefix}.maxTempSensBmsAddress`, value: d.maxTempSensBmsAddress },
270
- { path: `${prefix}.maxTempSensNumber`, value: d.maxTempSensNumber },
271
- { path: `${prefix}.ampHourUsed`, value: d.ah }
269
+ { path: `${prefix}.slaveAddress`, value: d.minCellBmsAddress },
270
+ { path: `${prefix}.minCellNumber`, value: d.minCellNumber },
271
+ { path: `${prefix}.maxCellNumber`, value: d.maxCellNumber },
272
+ { path: `${prefix}.maxTempSensBmsAddress`, value: d.maxTempSensBmsAddress },
273
+ { path: `${prefix}.maxTempSensNumber`, value: d.maxTempSensNumber },
274
+ { path: `${prefix}.capacity.dischargeSinceFull`, value: d.ah * 3600 }
272
275
  ]
273
276
  }]
274
277
  };
@@ -291,50 +294,62 @@ function getDelta(parsed, options, app) {
291
294
  let values = [
292
295
  { path: `${prefix}.numBMSUnits`, value: d.numBMSUnits }
293
296
  ];
297
+ let metaEntries = [];
294
298
  d.cellVoltages.forEach((voltage, index) => {
295
- values.push({ path: `${prefix}.cellVoltage${index + 1}`, value: voltage });
299
+ const path = `${prefix}.cellVoltage${index + 1}`;
300
+ values.push({ path, value: voltage });
301
+ metaEntries.push({ path, value: { units: "V", description: `Voltage of cell ${index + 1}` } });
296
302
  });
297
303
  delta = {
298
304
  context,
299
305
  updates: [{
300
306
  timestamp: new Date().toISOString(),
301
- values: values
307
+ values,
308
+ meta: metaEntries
302
309
  }]
303
310
  };
304
311
  break;
305
312
  }
306
313
  case "PTEM": {
307
314
  const d = parsed.data;
315
+ const sensorCount = (options && options.numTempSensors) ? options.numTempSensors : 1;
316
+ const temperatures = d.temperature.slice(0, sensorCount);
308
317
  let values = [
309
318
  { path: `${prefix}.numBMSUnits`, value: d.numBMSUnits }
310
319
  ];
311
-
312
- d.temperature.forEach((temp, index) => {
313
- values.push({ path: `${prefix}.cellTemperature${index + 1}`, value: temp });
320
+ let metaEntries = [];
321
+ temperatures.forEach((temp, index) => {
322
+ const path = `${prefix}.cellTemperature${index + 1}`;
323
+ values.push({ path, value: temp + 273.15 });
324
+ metaEntries.push({ path, value: { units: "K", description: `Temperature of sensor ${index + 1}` } });
314
325
  });
315
-
316
326
  delta = {
317
327
  context,
318
328
  updates: [{
319
329
  timestamp: new Date().toISOString(),
320
- values: values
330
+ values,
331
+ meta: metaEntries
321
332
  }]
322
333
  };
323
334
  break;
324
- }
335
+ }
325
336
  case "RINT": {
326
337
  const d = parsed.data;
327
338
  let values = [
328
339
  { path: `${prefix}.numBMSUnits`, value: d.numBMSUnits }
329
340
  ];
341
+ let metaEntries = [];
330
342
  d.resistances.forEach((res, index) => {
331
- values.push({ path: `${prefix}.cellResistance${index + 1}`, value: res });
343
+ const path = `${prefix}.cellResistance${index + 1}`;
344
+ values.push({ path, value: res });
345
+ metaEntries.push({ path, value: { units: "Ohm", description: `Internal DC resistance of cell ${index + 1}` } });
332
346
  });
333
347
  delta = {
334
348
  context,
335
349
  updates: [{
336
350
  timestamp: new Date().toISOString(),
337
- values: values
351
+ values,
352
+ meta: metaEntries
338
353
  }]
339
354
  };
340
355
  break;
@@ -346,7 +361,7 @@ function getDelta(parsed, options, app) {
346
361
  updates: [{
347
362
  timestamp: new Date().toISOString(),
348
363
  values: [
349
- { path: `${prefix}.bmsTemperature`, value: d.bmsTemperature }
364
+ { path: `${prefix}.bmsTemperature`, value: d.bmsTemperature + 273.15 }
350
365
  ]
351
366
  }]
352
367
  };
@@ -361,13 +376,13 @@ function getDelta(parsed, options, app) {
361
376
  values: [
362
377
  { path: `${prefix}.errorPresent`, value: d.hasError },
363
378
  { path: `${prefix}.errorAddress`, value: d.bmsUnit },
364
- { path: `${prefix}.errorCode`, value: d.errorCode },
365
- { path: `${prefix}.errorSource`, value: d.cellOrSensor }
379
+ { path: `${prefix}.errorCode`, value: d.errorCode },
380
+ { path: `${prefix}.errorSource`, value: d.cellOrSensor }
366
381
  ]
367
382
  }]
368
383
  };
369
384
  break;
370
- }
385
+ }
371
386
 
372
387
  default:
373
388
  break;
@@ -379,15 +394,15 @@ function getDelta(parsed, options, app) {
379
394
  4. EXPORTS
380
395
  Export functions for building commands, parsing responses, and building deltas.
381
396
  ============================================================================ */
382
- module.exports = {
383
- buildCommand,
384
- getDelta,
385
- parseLCD1Response,
386
- parseLCD3Response,
387
- parseIDNResponse,
388
- parseCELLResponse,
389
- parsePTEMResponse,
390
- parseRINTResponse,
391
- parseBTEMResponse,
392
- parseERROResponse
393
- };
397
+ module.exports = {
398
+ buildCommand,
399
+ getDelta,
400
+ parseLCD1Response,
401
+ parseLCD3Response,
402
+ parseIDNResponse,
403
+ parseCELLResponse,
404
+ parsePTEMResponse,
405
+ parseRINTResponse,
406
+ parseBTEMResponse,
407
+ parseERROResponse
408
+ };
@@ -90,7 +90,9 @@ function getDelta(parsed, options, app) {
90
90
  updates: [{
91
91
  timestamp: new Date().toISOString(),
92
92
  values: [
93
- { path: `${prefix}.capacity`, value: d.capacity }
93
+ // Published in Ah. SK spec wants Joules for capacity.nominal but Ah is
94
+ // what the BMS provides and what is meaningful for battery management.
95
+ { path: `${prefix}.capacity.nominal`, value: d.capacity }
94
96
  ]
95
97
  }]
96
98
  };
@@ -9,29 +9,32 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
9
9
  ? (base.endsWith('.') ? base : base + '.')
10
10
  : "";
11
11
 
12
+ // 'capacity' is stored internally in Ah (as received from capacity.nominal).
13
+ // 'ampHourUsed' is stored internally in Ah (converted from Coulombs off the stream).
14
+
12
15
  function computeAndSendDeltas() {
13
16
  if ([voltage, current, ampHourUsed, capacity].every(v => typeof v === 'number')) {
14
17
  const power = voltage * current;
15
18
  const ahRemaining = capacity - ampHourUsed;
16
19
 
17
20
  const values = [
18
- { path: prefix + 'power', value: power },
19
- { path: prefix + 'ampHourRemaining', value: ahRemaining }
21
+ { path: prefix + 'power', value: power },
22
+ { path: prefix + 'capacity.remaining', value: ahRemaining }
20
23
  ];
21
24
 
22
25
  if (current > 0 && ahRemaining < capacity) {
23
26
  const missingAh = capacity - ahRemaining;
24
27
  const timeToFull = (missingAh / current) * 3600;
25
- values.push({ path: prefix + 'timeToFull', value: timeToFull });
26
- values.push({ path: prefix + 'timeToEmpty', value: 0 });
28
+ values.push({ path: prefix + 'timeToFull', value: timeToFull });
29
+ values.push({ path: prefix + 'capacity.timeRemaining', value: 0 });
27
30
  } else if (current < 0 && ahRemaining > 0) {
28
31
  const timeToEmpty = (ahRemaining / Math.abs(current)) * 3600;
29
- values.push({ path: prefix + 'timeToFull', value: 0 });
30
- values.push({ path: prefix + 'timeToEmpty', value: timeToEmpty });
32
+ values.push({ path: prefix + 'timeToFull', value: 0 });
33
+ values.push({ path: prefix + 'capacity.timeRemaining', value: timeToEmpty });
31
34
  } else {
32
35
  // fallback when current is 0 or data is abnormal
33
- values.push({ path: prefix + 'timeToFull', value: 0 });
34
- values.push({ path: prefix + 'timeToEmpty', value: 0 });
36
+ values.push({ path: prefix + 'timeToFull', value: 0 });
37
+ values.push({ path: prefix + 'capacity.timeRemaining', value: 0 });
35
38
  }
36
39
 
37
40
  sendDeltaCallback({
@@ -46,10 +49,12 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
46
49
  app.streambundle.getSelfStream(prefix + 'current').forEach(v => {
47
50
  if (typeof v === 'number') { current = v; computeAndSendDeltas(); }
48
51
  });
49
- app.streambundle.getSelfStream(prefix + 'ampHourUsed').forEach(v => {
50
- if (typeof v === 'number') { ampHourUsed = v; computeAndSendDeltas(); }
52
+ app.streambundle.getSelfStream(prefix + 'capacity.dischargeSinceFull').forEach(v => {
53
+ // value on stream is Coulombs; convert to Ah for internal arithmetic
54
+ if (typeof v === 'number') { ampHourUsed = v / 3600; computeAndSendDeltas(); }
51
55
  });
52
- app.streambundle.getSelfStream(prefix + 'capacity').forEach(v => {
56
+ app.streambundle.getSelfStream(prefix + 'capacity.nominal').forEach(v => {
57
+ // value on stream is Ah
53
58
  if (typeof v === 'number') { capacity = v; computeAndSendDeltas(); }
54
59
  });
55
60
 
@@ -81,7 +81,7 @@ function getDelta(parsed, options, app) {
81
81
  let vesselId = (typeof app.getSelfId === 'function') ? app.getSelfId() : (app.selfId || "self");
82
82
  const context = `vessels.${vesselId}`;
83
83
  const prefix = options.deltaPrefix || "electrical.batteries.bms";
84
-
84
+
85
85
  let delta = null;
86
86
  switch (parsed.type) {
87
87
  case "TMAX": {
@@ -91,7 +91,7 @@ function getDelta(parsed, options, app) {
91
91
  updates: [{
92
92
  timestamp: new Date().toISOString(),
93
93
  values: [
94
- { path: `${prefix}.cellOverTempSwitchOff`, value: d.cellOverTempSwitchOff }
94
+ { path: `${prefix}.cellOverTempSwitchOff`, value: d.cellOverTempSwitchOff + 273.15 }
95
95
  ]
96
96
  }]
97
97
  };
@@ -104,7 +104,7 @@ function getDelta(parsed, options, app) {
104
104
  updates: [{
105
105
  timestamp: new Date().toISOString(),
106
106
  values: [
107
- { path: `${prefix}.underTempChargeDisable`, value: d.underTempChargeDisable }
107
+ { path: `${prefix}.underTempChargeDisable`, value: d.underTempChargeDisable + 273.15 }
108
108
  ]
109
109
  }]
110
110
  };
@@ -117,7 +117,7 @@ function getDelta(parsed, options, app) {
117
117
  updates: [{
118
118
  timestamp: new Date().toISOString(),
119
119
  values: [
120
- { path: `${prefix}.bmsOverTempSwitchOff`, value: d.bmsOverTempSwitchOff }
120
+ { path: `${prefix}.bmsOverTempSwitchOff`, value: d.bmsOverTempSwitchOff + 273.15 }
121
121
  ]
122
122
  }]
123
123
  };
@@ -130,7 +130,7 @@ function getDelta(parsed, options, app) {
130
130
  updates: [{
131
131
  timestamp: new Date().toISOString(),
132
132
  values: [
133
- { path: `${prefix}.bmsOverTempSwitchOffHysteresis`, value: d.bmsOverTempSwitchOffHysteresis }
133
+ { path: `${prefix}.bmsOverTempSwitchOffHysteresis`, value: d.bmsOverTempSwitchOffHysteresis + 273.15 }
134
134
  ]
135
135
  }]
136
136
  };
package/public/index.html CHANGED
@@ -24,20 +24,28 @@
24
24
  <div class="stats-panel">
25
25
  <div class="metric-box">
26
26
  <div class="metric-label">Capacity</div>
27
- <div class="metric-value" id="capacity">--.-- Ah</div>
27
+ <div class="metric-value" id="capacity-nominal">--.-- Ah</div>
28
28
  </div>
29
29
  <div class="metric-box">
30
30
  <div class="metric-label">Amp Hours Remaining</div>
31
- <div class="metric-value" id="ampHourRemaining">--.-- Ah</div>
31
+ <div class="metric-value" id="capacity-remaining">--.-- Ah</div>
32
+ </div>
33
+ <div class="metric-box">
34
+ <div class="metric-label">Time Remaining</div>
35
+ <div class="metric-value" id="capacity-timeRemaining">--.-- h</div>
32
36
  </div>
33
37
  <div class="metric-box">
34
38
  <div class="metric-label">Amp Hours Used</div>
35
- <div class="metric-value" id="ampHourUsed">--.-- Ah</div>
39
+ <div class="metric-value" id="capacity-dischargeSinceFull">--.-- Ah</div>
36
40
  </div>
37
41
  <div class="metric-box">
38
42
  <div class="metric-label">State of Charge</div>
39
43
  <div class="metric-value" id="stateOfCharge">-- %</div>
40
44
  </div>
45
+ <div class="metric-box">
46
+ <div class="metric-label">State of Health</div>
47
+ <div class="metric-value" id="stateOfHealth">-- %</div>
48
+ </div>
41
49
  <div class="metric-box">
42
50
  <div class="metric-label">Pack Voltage</div>
43
51
  <div class="metric-value" id="voltage">--.-- V</div>
@@ -77,39 +85,38 @@
77
85
  <div id="cell-towers" class="towers-container"></div>
78
86
  </div>
79
87
 
80
- <div id="settings-view" class="content-view">
81
- <div class="settings-container">
82
- <h2 class="settings-title">BMS Settings</h2>
83
- <div class="settings-section" id="settings-command-grid">
84
- </div>
85
- <div class="settings-section" style="margin-top: 25px;">
86
- <h3>Manual BMS Command</h3>
87
- <form id="command-form" class="settings-form-group">
88
- <label for="command-input" class="settings-label">
89
- Enter Command (e.g., <code>BVOL 3.25</code>)
90
- </label>
91
- <input
92
- type="text"
93
- id="command-input"
94
- class="settings-input"
95
- placeholder="Enter command like 'BVOL 3.25'"
96
- required
97
- />
98
- <div class="settings-buttons">
99
- <button type="submit" class="settings-button primary">Send Command</button>
88
+ <div id="settings-view" class="content-view">
89
+ <div class="settings-container">
90
+ <h2 class="settings-title">BMS Settings</h2>
91
+ <div class="settings-section" id="settings-command-grid">
92
+ </div>
93
+ <div class="settings-section" style="margin-top: 25px;">
94
+ <h3>Manual BMS Command</h3>
95
+ <form id="command-form" class="settings-form-group">
96
+ <label for="command-input" class="settings-label">
97
+ Enter Command (e.g., <code>BVOL 3.25</code>)
98
+ </label>
99
+ <input
100
+ type="text"
101
+ id="command-input"
102
+ class="settings-input"
103
+ placeholder="Enter command like 'BVOL 3.25'"
104
+ required
105
+ />
106
+ <div class="settings-buttons">
107
+ <button type="submit" class="settings-button primary">Send Command</button>
108
+ </div>
109
+ </form>
110
+ <div
111
+ id="command-response"
112
+ class="settings-form-group settings-label"
113
+ style="margin-top: 10px;"
114
+ ></div>
115
+ </div>
100
116
  </div>
101
- </form>
102
- <div
103
- id="command-response"
104
- class="settings-form-group settings-label"
105
- style="margin-top: 10px;"
106
- ></div>
117
+ </div>
107
118
  </div>
108
-
109
119
  </div>
110
- </div>
111
-
112
-
113
120
 
114
121
  <div class="info-box">
115
122
  <a href="https://github.com/ofernander/signalk-rec-bms" target="_blank">signalk-rec-bms on GitHub</a>
@@ -123,7 +130,7 @@
123
130
  document.getElementById('nav-settings').classList.remove('active');
124
131
  });
125
132
 
126
- document.getElementById('nav-settings').addEventListener('click', function() {
133
+ document.getElementById('nav-settings').addEventListener('click', function() {
127
134
  document.getElementById('status-view').classList.remove('active');
128
135
  document.getElementById('settings-view').classList.add('active');
129
136
  document.getElementById('nav-settings').classList.add('active');
package/public/js/main.js CHANGED
@@ -14,18 +14,19 @@ class BMSSystem {
14
14
  voltage: null,
15
15
  current: null,
16
16
  power: null,
17
- stateOfCharge: null,
18
- stateOfHealth: null,
17
+ 'capacity.stateOfCharge': null,
18
+ 'capacity.stateOfHealth': null,
19
+ 'capacity.remaining': null,
20
+ 'capacity.dischargeSinceFull': null,
21
+ 'capacity.nominal': null,
22
+ 'capacity.timeRemaining': null,
19
23
  cellVoltages: {},
20
24
  cellResistances: {},
21
25
  cellVoltageDifference: null,
22
26
  bmsTemperature: null,
23
27
  cellTemperature1: null,
24
28
  maxCellVoltage: null,
25
- minCellVoltage: null,
26
- ampHourRemaining: null,
27
- ampHourUsed: null,
28
- capacity: null
29
+ minCellVoltage: null
29
30
  };
30
31
  }
31
32
 
@@ -38,16 +39,27 @@ class BMSSystem {
38
39
  }
39
40
 
40
41
  cacheDomElements() {
41
- const metrics = [
42
- 'voltage', 'current', 'power', 'stateOfCharge', 'cellVoltageDifference', 'bmsTemperature', 'cellTemperature1',
43
- 'maxCellVoltage', 'minCellVoltage', 'ampHourRemaining',
44
- 'ampHourUsed', 'capacity'
45
- ];
46
-
47
- metrics.forEach(metric => {
48
- this.dom.metricElements[metric] = document.getElementById(metric);
49
- if (!this.dom.metricElements[metric]) {
50
- console.warn(`Missing metric element: ${metric}`);
42
+ const metricMap = {
43
+ 'voltage': 'voltage',
44
+ 'current': 'current',
45
+ 'power': 'power',
46
+ 'capacity.stateOfCharge': 'stateOfCharge',
47
+ 'capacity.stateOfHealth': 'stateOfHealth',
48
+ 'cellVoltageDifference': 'cellVoltageDifference',
49
+ 'bmsTemperature': 'bmsTemperature',
50
+ 'cellTemperature1': 'cellTemperature1',
51
+ 'maxCellVoltage': 'maxCellVoltage',
52
+ 'minCellVoltage': 'minCellVoltage',
53
+ 'capacity.remaining': 'capacity-remaining',
54
+ 'capacity.dischargeSinceFull': 'capacity-dischargeSinceFull',
55
+ 'capacity.nominal': 'capacity-nominal',
56
+ 'capacity.timeRemaining': 'capacity-timeRemaining'
57
+ };
58
+
59
+ Object.entries(metricMap).forEach(([key, id]) => {
60
+ this.dom.metricElements[key] = document.getElementById(id);
61
+ if (!this.dom.metricElements[key]) {
62
+ console.warn(`Missing metric element for key: ${key} (id: ${id})`);
51
63
  }
52
64
  });
53
65
  }
@@ -88,33 +100,58 @@ class BMSSystem {
88
100
  if (el) el.textContent = value !== null ? `${value.toFixed(2)}${unit}` : "--";
89
101
  };
90
102
 
91
- setValue(this.dom.metricElements.voltage, this.liveData.voltage, "V");
92
- setValue(this.dom.metricElements.current, this.liveData.current, "A");
93
- setValue(this.dom.metricElements.power, this.liveData.power, "W");
103
+ setValue(this.dom.metricElements['voltage'], this.liveData.voltage, "V");
104
+ setValue(this.dom.metricElements['current'], this.liveData.current, "A");
105
+ setValue(this.dom.metricElements['power'], this.liveData.power, "W");
94
106
 
95
107
  setValue(
96
- this.dom.metricElements.stateOfCharge,
97
- this.liveData.stateOfCharge !== null ? this.liveData.stateOfCharge * 100 : null,
108
+ this.dom.metricElements['capacity.stateOfCharge'],
109
+ this.liveData['capacity.stateOfCharge'] !== null ? this.liveData['capacity.stateOfCharge'] * 100 : null,
98
110
  "%"
99
111
  );
100
112
  setValue(
101
- this.dom.metricElements.stateOfHealth,
102
- this.liveData.stateOfHealth !== null ? this.liveData.stateOfHealth * 100 : null,
113
+ this.dom.metricElements['capacity.stateOfHealth'],
114
+ this.liveData['capacity.stateOfHealth'] !== null ? this.liveData['capacity.stateOfHealth'] * 100 : null,
103
115
  "%"
104
116
  );
105
117
 
106
- if (this.dom.metricElements.cellVoltageDifference) {
118
+ if (this.dom.metricElements['cellVoltageDifference']) {
107
119
  const v = this.liveData.cellVoltageDifference;
108
- this.dom.metricElements.cellVoltageDifference.textContent = v !== null ? `${v.toFixed(4)}V` : "--";
120
+ this.dom.metricElements['cellVoltageDifference'].textContent = v !== null ? `${v.toFixed(4)}V` : "--";
109
121
  }
110
122
 
111
- setValue(this.dom.metricElements.bmsTemperature, this.liveData.bmsTemperature, "°C");
112
- setValue(this.dom.metricElements.cellTemperature1, this.liveData.cellTemperature1, "°C");
113
- setValue(this.dom.metricElements.maxCellVoltage, this.liveData.maxCellVoltage, "V");
114
- setValue(this.dom.metricElements.minCellVoltage, this.liveData.minCellVoltage, "V");
115
- setValue(this.dom.metricElements.ampHourRemaining, this.liveData.ampHourRemaining, "Ah");
116
- setValue(this.dom.metricElements.ampHourUsed, this.liveData.ampHourUsed, "Ah");
117
- setValue(this.dom.metricElements.capacity, this.liveData.capacity, "Ah");
123
+ // Values arrive in Kelvin; subtract 273.15 for °C display
124
+ // Note: stopgap until frontend rewrite — SK will handle unit display natively
125
+ setValue(
126
+ this.dom.metricElements['bmsTemperature'],
127
+ this.liveData.bmsTemperature !== null ? this.liveData.bmsTemperature - 273.15 : null,
128
+ "°C"
129
+ );
130
+ setValue(
131
+ this.dom.metricElements['cellTemperature1'],
132
+ this.liveData.cellTemperature1 !== null ? this.liveData.cellTemperature1 - 273.15 : null,
133
+ "°C"
134
+ );
135
+
136
+ setValue(this.dom.metricElements['maxCellVoltage'], this.liveData.maxCellVoltage, "V");
137
+ setValue(this.dom.metricElements['minCellVoltage'], this.liveData.minCellVoltage, "V");
138
+
139
+ setValue(this.dom.metricElements['capacity.nominal'], this.liveData['capacity.nominal'], "Ah");
140
+ setValue(this.dom.metricElements['capacity.remaining'], this.liveData['capacity.remaining'], "Ah");
141
+
142
+ // dischargeSinceFull arrives in Coulombs; display as Ah
143
+ setValue(
144
+ this.dom.metricElements['capacity.dischargeSinceFull'],
145
+ this.liveData['capacity.dischargeSinceFull'] !== null ? this.liveData['capacity.dischargeSinceFull'] / 3600 : null,
146
+ "Ah"
147
+ );
148
+
149
+ // timeRemaining arrives in seconds; display as hours
150
+ setValue(
151
+ this.dom.metricElements['capacity.timeRemaining'],
152
+ this.liveData['capacity.timeRemaining'] !== null ? this.liveData['capacity.timeRemaining'] / 3600 : null,
153
+ "h"
154
+ );
118
155
  }
119
156
 
120
157
  connectWebSocket() {
@@ -123,19 +160,19 @@ class BMSSystem {
123
160
  const ws = new WebSocket(
124
161
  `${wsScheme}://${window.location.host}/signalk/v1/stream?subscribe=self&path=electrical.batteries.bms.*`
125
162
  );
126
-
163
+
127
164
  ws.onopen = () => console.log("WebSocket connected");
128
165
  ws.onerror = (error) => console.error("WebSocket error:", error);
129
166
  ws.onclose = () => console.log("WebSocket connection closed");
130
-
167
+
131
168
  ws.onmessage = (event) => {
132
169
  const delta = JSON.parse(event.data);
133
170
  if (!delta.updates) return;
134
-
171
+
135
172
  delta.updates.forEach(update => {
136
173
  update.values?.forEach(({ path, value }) => {
137
174
  if (!path.startsWith(this.prefix)) return;
138
-
175
+
139
176
  const key = path.slice(this.prefix.length);
140
177
  if (key in this.liveData) {
141
178
  this.liveData[key] = value;
@@ -146,7 +183,7 @@ class BMSSystem {
146
183
  }
147
184
  });
148
185
  });
149
-
186
+
150
187
  this.updateMetrics();
151
188
  CellVisualizer.updateAllCells({
152
189
  cellVoltages: this.liveData.cellVoltages,
@@ -156,7 +193,7 @@ class BMSSystem {
156
193
  } catch (error) {
157
194
  console.error("WebSocket initialization failed:", error);
158
195
  }
159
- }
196
+ }
160
197
  }
161
198
 
162
199
  document.addEventListener('DOMContentLoaded', () => {
@@ -1,5 +1,7 @@
1
1
  export class BMSSettings {
2
2
  constructor() {
3
+ this.tempCommands = new Set(['TMAX', 'TMIN', 'TBAL', 'BMTH']);
4
+
3
5
  this.commandGroups = [
4
6
  {
5
7
  title: "Voltage Settings",
@@ -54,7 +56,7 @@ export class BMSSettings {
54
56
  IOFF: "currentSensorOffset",
55
57
  IOJA: "currentSensorCoefficient",
56
58
  CYCL: "batteryCycleCount",
57
- CAPA: "capacity",
59
+ CAPA: "capacity.nominal",
58
60
  CHEM: "chemistry",
59
61
  SOCH: "socHysteresis",
60
62
  SOCS: "socReset",
@@ -72,7 +74,7 @@ export class BMSSettings {
72
74
  CAL2: "calibrationOffsetCell2",
73
75
  CAL3: "calibrationOffsetCell3",
74
76
  CAL4: "calibrationOffsetCell4",
75
- SERI: "name",
77
+ SERI: "deviceName",
76
78
  TIME: "time",
77
79
  DATE: "date",
78
80
  RSBR: "balancingCellNumber"
@@ -155,24 +157,23 @@ export class BMSSettings {
155
157
  });
156
158
  });
157
159
 
158
- container.querySelectorAll("button[data-command]").forEach(button => {
159
- button.addEventListener("click", async () => {
160
- const cmd = button.getAttribute("data-command");
161
- const inputField = document.getElementById(cmd);
162
- const value = inputField.value.trim();
163
- if (!value) return;
164
-
165
- const response = await this.sendCommand(`${cmd} ${value}`);
166
- const path = this.commandToDeltaPath[cmd];
167
- const success = response && typeof response === 'object' &&
168
- Object.keys(response).length > 0;
169
-
170
- inputField.value = success ? "OK" : "FAIL";
171
-
172
- setTimeout(() => this.prefillValues(), 5000);
160
+ container.querySelectorAll("button[data-command]").forEach(button => {
161
+ button.addEventListener("click", async () => {
162
+ const cmd = button.getAttribute("data-command");
163
+ const inputField = document.getElementById(cmd);
164
+ const value = inputField.value.trim();
165
+ if (!value) return;
166
+
167
+ const response = await this.sendCommand(`${cmd} ${value}`);
168
+ const success = response && typeof response === 'object' &&
169
+ Object.keys(response).length > 0;
170
+
171
+ inputField.value = success ? "OK" : "FAIL";
172
+
173
+ setTimeout(() => this.prefillValues(), 5000);
174
+ });
173
175
  });
174
- });
175
- }
176
+ }
176
177
 
177
178
  async prefillValues() {
178
179
  try {
@@ -185,10 +186,16 @@ export class BMSSettings {
185
186
  if (!field) continue;
186
187
 
187
188
  const path = this.commandToDeltaPath[cmd];
188
- const val = bmsData[path]?.value;
189
+ if (!path) continue;
190
+
191
+ // Support dot-notation paths (e.g. "capacity.nominal")
192
+ const val = path.split('.').reduce((obj, key) => obj?.[key], bmsData)?.value;
189
193
 
190
194
  if (val !== undefined) {
191
- field.value = val;
195
+ // Temperature values are stored in Kelvin; display in °C for user
196
+ field.value = this.tempCommands.has(cmd)
197
+ ? (val - 273.15).toFixed(3)
198
+ : val;
192
199
  }
193
200
  }
194
201
  }