signalk-rec-bms 1.1.1 → 1.1.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # signalk-rec-bms
2
2
 
3
- ![alt text](https://github.com/ofernander/signalk-rec-bms/blob/main/public/assets/images/rec-sk.png)
3
+ ![alt text](https://raw.githubusercontent.com/ofernander/signalk-rec-bms/refs/heads/main/public/assets/images/rec-sk.png)
4
4
 
5
5
  ## Features
6
6
 
@@ -24,11 +24,11 @@ Tested with the 4 cell ABMS model only
24
24
 
25
25
  #### Serial connection:
26
26
 
27
- - RS485 to USB adapter with 5v output. The REC-BMS serial chip is not powered internally and you must supply 5v to power it.
27
+ - RS485 to USB adapter with 3v output. The REC-BMS serial chip is not powered internally and you must supply 3v to power it.
28
28
 
29
- ![alt text](https://github.com/ofernander/signalk-rec-bms/blob/main/public/assets/images/rs485.jpg)
29
+ ![alt text](https://raw.githubusercontent.com/ofernander/signalk-rec-bms/refs/heads/main/public/assets/images/rs485.jpg)
30
30
 
31
- ![alt text](https://github.com/ofernander/signalk-rec-bms/blob/main/public/assets/images/bms_serial.png)
31
+ ![alt text](https://raw.githubusercontent.com/ofernander/signalk-rec-bms/refs/heads/main/public/assets/images/bms_serial.png)
32
32
 
33
33
  ## Dependencies
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-rec-bms",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "SignalK plugin for REC-BMS",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -20,7 +20,7 @@ module.exports = {
20
20
  websocketURL: {
21
21
  title: "WebSocket URL",
22
22
  type: "string",
23
- default: "ws://WiFi_module_ip/ws"
23
+ default: "ws://WiFi_module_IP/ws"
24
24
  }
25
25
  }
26
26
  },
@@ -132,8 +132,11 @@ function parseWCBIResponse(input) {
132
132
  if (!packet) {
133
133
  return null;
134
134
  }
135
- const payload = packet.slice(4, packet.length - 3).toString('utf8');
135
+ const payload = packet.slice(4, packet.length - 3).toString('utf8').trim();
136
136
  const value = parseInt(payload, 10);
137
+ if (isNaN(value)) {
138
+ return null;
139
+ }
137
140
  const result = {
138
141
  type: "WCBI",
139
142
  data: { balancingCellNumber: value }
@@ -294,4 +297,4 @@ module.exports = {
294
297
  parseTIMEResponse,
295
298
  parseDATEResponse,
296
299
  parseWCBIResponse
297
- };
300
+ };
@@ -35,8 +35,8 @@ function parseLCD1Response(input) {
35
35
  current: packet.readFloatLE(12),
36
36
  temperature: packet.readFloatLE(16),
37
37
  packVoltage: packet.readFloatLE(20),
38
- soc: packet.readFloatLE(24) * 100,
39
- soh: packet.readFloatLE(28) * 100
38
+ soc: packet.readFloatLE(24),
39
+ soh: packet.readFloatLE(28)
40
40
  }
41
41
  };
42
42
  return result;
@@ -1,3 +1,5 @@
1
+ "use strict";
2
+
1
3
  module.exports = function Derived(app, sendDeltaCallback, config) {
2
4
  app.debug("[DERIVED] Module instantiated with config: " + JSON.stringify(config));
3
5
  let voltage, current, ampHourUsed, capacity;
@@ -11,13 +13,29 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
11
13
  if ([voltage, current, ampHourUsed, capacity].every(v => typeof v === 'number')) {
12
14
  const power = voltage * current;
13
15
  const ahRemaining = capacity - ampHourUsed;
16
+
17
+ const values = [
18
+ { path: prefix + 'power', value: power },
19
+ { path: prefix + 'ampHourRemaining', value: ahRemaining }
20
+ ];
21
+
22
+ if (current > 0 && ahRemaining < capacity) {
23
+ const missingAh = capacity - ahRemaining;
24
+ const timeToFull = (missingAh / current) * 3600;
25
+ values.push({ path: prefix + 'timeToFull', value: timeToFull });
26
+ values.push({ path: prefix + 'timeToEmpty', value: 0 });
27
+ } else if (current < 0 && ahRemaining > 0) {
28
+ const timeToEmpty = (ahRemaining / Math.abs(current)) * 3600;
29
+ values.push({ path: prefix + 'timeToFull', value: 0 });
30
+ values.push({ path: prefix + 'timeToEmpty', value: timeToEmpty });
31
+ } else {
32
+ // 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 });
35
+ }
36
+
14
37
  sendDeltaCallback({
15
- updates: [{
16
- values: [
17
- { path: prefix + 'power', value: power },
18
- { path: prefix + 'ampHourRemaining', value: ahRemaining }
19
- ]
20
- }]
38
+ updates: [{ values }]
21
39
  });
22
40
  }
23
41
  }
@@ -35,39 +53,115 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
35
53
  if (typeof v === 'number') { capacity = v; computeAndSendDeltas(); }
36
54
  });
37
55
 
56
+ // ---- Cell diff logic ----
38
57
  let totalCells = 0;
39
- const cellVoltages = {};
58
+ let unitsExpected = 0;
59
+ let cellVoltages = {};
40
60
  let subscribedCellStreams = false;
41
61
 
62
+ // prevent duplicate subscriptions if units changes / reconnects
63
+ const subscribedCells = new Set();
64
+
65
+ // retry controls (avoid timer storms + max wait)
66
+ let pendingCellDiffTimer = null;
67
+ let firstCellSeenAtMs = null;
68
+ const CELL_DIFF_RETRY_MS = 200;
69
+ const CELL_DIFF_MAX_WAIT_MS = 8000;
70
+
42
71
  app.streambundle.getSelfStream(prefix + 'numBMSUnits').forEach(units => {
43
- if (!subscribedCellStreams && typeof units === 'number' && units > 0) {
44
- totalCells = units * 4;
72
+ if (typeof units === 'number' && units > 0) {
73
+ // If units changes (or first time), reset expectations and cached values
74
+ if (units !== unitsExpected) {
75
+ unitsExpected = units;
76
+ totalCells = units * 4;
77
+ cellVoltages = {};
78
+ firstCellSeenAtMs = null;
79
+
80
+ // clear any pending retry from old expectations
81
+ if (pendingCellDiffTimer) {
82
+ clearTimeout(pendingCellDiffTimer);
83
+ pendingCellDiffTimer = null;
84
+ }
85
+ }
86
+
87
+ // Ensure we have subscriptions for 1..totalCells (do not duplicate)
45
88
  for (let i = 1; i <= totalCells; i++) {
89
+ if (subscribedCells.has(i)) continue;
90
+
91
+ subscribedCells.add(i);
46
92
  const cellPath = prefix + 'cellVoltage' + i;
47
93
  app.streambundle.getSelfStream(cellPath).forEach(val => {
48
- if (typeof val === 'number') {
94
+ if (typeof val === 'number' && Number.isFinite(val)) {
95
+ if (!firstCellSeenAtMs) firstCellSeenAtMs = Date.now();
49
96
  cellVoltages[i] = val;
50
97
  trySendCellDiff();
51
98
  }
52
99
  });
53
100
  }
101
+
54
102
  subscribedCellStreams = true;
103
+
104
+ // Attempt immediately in case we already have cached values after restart
105
+ trySendCellDiff();
55
106
  }
56
107
  });
57
108
 
109
+ function scheduleCellDiffRetry() {
110
+ if (pendingCellDiffTimer) return;
111
+ if (!subscribedCellStreams || totalCells <= 0) return;
112
+
113
+ pendingCellDiffTimer = setTimeout(() => {
114
+ pendingCellDiffTimer = null;
115
+ trySendCellDiff();
116
+ }, CELL_DIFF_RETRY_MS);
117
+ }
118
+
58
119
  function trySendCellDiff() {
59
- const keys = Object.keys(cellVoltages).map(k => parseInt(k,10));
60
- if (keys.length === totalCells) {
61
- const vals = keys.map(i => cellVoltages[i]);
62
- const minV = Math.min(...vals);
63
- const maxV = Math.max(...vals);
64
- sendDeltaCallback({
65
- updates: [{
66
- values: [
67
- { path: prefix + 'cellVoltageDifference', value: maxV - minV }
68
- ]
69
- }]
70
- });
120
+ if (!subscribedCellStreams || totalCells <= 0) return;
121
+
122
+ // Only consider cells within expected range
123
+ const keys = Object.keys(cellVoltages)
124
+ .map(k => parseInt(k, 10))
125
+ .filter(i => Number.isInteger(i) && i >= 1 && i <= totalCells);
126
+
127
+ // A: publish as soon as we have enough to compute (>= 2 cells)
128
+ if (keys.length >= 2) {
129
+ const vals = keys.map(i => cellVoltages[i]).filter(v => typeof v === 'number' && Number.isFinite(v));
130
+ if (vals.length >= 2) {
131
+ const minV = Math.min(...vals);
132
+ const maxV = Math.max(...vals);
133
+
134
+ sendDeltaCallback({
135
+ updates: [{
136
+ values: [
137
+ { path: prefix + 'cellVoltageDifference', value: maxV - minV }
138
+ ]
139
+ }]
140
+ });
141
+ }
142
+ }
143
+
144
+ // B: if we haven't seen all cells yet, retry safely with max-wait
145
+ if (keys.length < totalCells) {
146
+ const now = Date.now();
147
+ if (!firstCellSeenAtMs) firstCellSeenAtMs = now;
148
+
149
+ const elapsed = now - firstCellSeenAtMs;
150
+ if (elapsed < CELL_DIFF_MAX_WAIT_MS) {
151
+ scheduleCellDiffRetry();
152
+ } else {
153
+ // stop retrying after max wait; cell updates will still trigger publishes
154
+ if (pendingCellDiffTimer) {
155
+ clearTimeout(pendingCellDiffTimer);
156
+ pendingCellDiffTimer = null;
157
+ }
158
+ }
159
+ } else {
160
+ // all cells present: no need to keep retry timer
161
+ if (pendingCellDiffTimer) {
162
+ clearTimeout(pendingCellDiffTimer);
163
+ pendingCellDiffTimer = null;
164
+ }
71
165
  }
72
166
  }
73
167
 
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+
3
+ // Cache latest values across messages
4
+ let latestMaster = null;
5
+ let latestSettings = null;
6
+
7
+ module.exports = function(parsedData, prefix) {
8
+ const values = [];
9
+
10
+ // Update cache if master data is present
11
+ if (parsedData?.bms_array?.master) {
12
+ latestMaster = parsedData.bms_array.master;
13
+ }
14
+
15
+ // Update cache if settings data is present
16
+ if (parsedData?.type === "settings") {
17
+ latestSettings = parsedData;
18
+ }
19
+
20
+ // If we don't have both yet, don't compute anything
21
+ if (!latestMaster || !latestSettings) return values;
22
+
23
+ const master = latestMaster;
24
+ const settings = latestSettings;
25
+
26
+ // power = voltage * current
27
+ if (typeof master.vbat === "number" && typeof master.ibat === "number") {
28
+ values.push({
29
+ path: `${prefix}.power`,
30
+ value: master.vbat * master.ibat
31
+ });
32
+ }
33
+
34
+ // cellVoltageDifference = max - min
35
+ if (typeof master.maxcell === "number" && typeof master.mincell === "number") {
36
+ values.push({
37
+ path: `${prefix}.cellVoltageDifference`,
38
+ value: master.maxcell - master.mincell
39
+ });
40
+ }
41
+
42
+ // ampHourRemaining = capacity - ampHourUsed
43
+ if (typeof settings.capa === "number" && typeof settings.Ah === "number") {
44
+ values.push({
45
+ path: `${prefix}.ampHourRemaining`,
46
+ value: settings.capa - settings.Ah
47
+ });
48
+ }
49
+
50
+ // timeToFull / timeToEmpty in seconds
51
+ if (
52
+ typeof settings.capa === "number" &&
53
+ typeof settings.Ah === "number" &&
54
+ typeof master.ibat === "number"
55
+ ) {
56
+ const capacity = settings.capa;
57
+ const current = master.ibat;
58
+ const soc = typeof master.soc === "number" ? master.soc : null;
59
+ const remainingAh = (soc !== null) ? (soc / 100) * capacity : (capacity - settings.Ah);
60
+
61
+ if (current > 0 && remainingAh < capacity) {
62
+ const missingAh = capacity - remainingAh;
63
+ const timeToFull = (missingAh / current) * 3600;
64
+ values.push({ path: `${prefix}.timeToFull`, value: timeToFull });
65
+ values.push({ path: `${prefix}.timeToEmpty`, value: 0 });
66
+ } else if (current < 0 && remainingAh > 0) {
67
+ const timeToEmpty = (remainingAh / Math.abs(current)) * 3600;
68
+ values.push({ path: `${prefix}.timeToFull`, value: 0 });
69
+ values.push({ path: `${prefix}.timeToEmpty`, value: timeToEmpty });
70
+ } else {
71
+ // idle or insufficient current
72
+ values.push({ path: `${prefix}.timeToFull`, value: 0 });
73
+ values.push({ path: `${prefix}.timeToEmpty`, value: 0 });
74
+ }
75
+ }
76
+
77
+ return values;
78
+ };
@@ -8,11 +8,6 @@ module.exports = function(parsedData, prefix, config) {
8
8
  const settings = parsedData;
9
9
  const values = [];
10
10
 
11
- // Derived data - NOT FROM BMS
12
- const AhR = (parsedData.capa !== undefined && parsedData.Ah !== undefined)
13
- ? parsedData.capa - parsedData.Ah
14
- : null;
15
- values.push({ path: `${prefix}.ampHourRemaining`, value: AhR });
16
11
  // Data from WiFi module WebSocket under SETTINGS message
17
12
  values.push({ path: `${prefix}.address`, value: parsedData.addr });
18
13
  values.push({ path: `${prefix}.ampHourUsed`, value: parsedData.Ah });
@@ -23,15 +23,10 @@ module.exports = function(parsedData, prefix, config) {
23
23
  return null;
24
24
  }
25
25
 
26
- // Derived data - NOT FROM BMS
27
- const power = (master.vbat !== undefined && master.ibat !== undefined)
28
- ? master.vbat * master.ibat
29
- : null;
30
- values.push({ path: `${prefix}.power`, value: power });
31
26
  // Data from WiFi module WebSocket under SETTINGS message
32
27
  values.push({ path: `${prefix}.date`, value: master.date });
33
28
  values.push({ path: `${prefix}.time`, value: master.time });
34
- values.push({ path: `${prefix}.cellVoltageDifference`, value: master.maxcell - master.mincell });
29
+ //values.push({ path: `${prefix}.cellVoltageDifference`, value: master.maxcell - master.mincell });
35
30
  values.push({ path: `${prefix}.current`, value: master.ibat });
36
31
  values.push({ path: `${prefix}.errorAddress`, value: master.erro.addr });
37
32
  values.push({ path: `${prefix}.errorConnectionState`, value: master.erro.con_st });
@@ -42,7 +37,7 @@ module.exports = function(parsedData, prefix, config) {
42
37
  values.push({ path: `${prefix}.minCellVoltage`, value: master.mincell });
43
38
  values.push({ path: `${prefix}.stateOfCharge`, value: master.soc });
44
39
  values.push({ path: `${prefix}.stateOfHealth`, value: master.soh });
45
- values.push({ path: `${prefix}.timeRemaining`, value: extractMinutes(master.time_remaining) });
40
+ //values.push({ path: `${prefix}.timeRemaining`, value: extractMinutes(master.time_remaining) });
46
41
  values.push({ path: `${prefix}.voltage`, value: master.vbat });
47
42
  values.push({ path: `${prefix}.bmsTemperature`, value: slave.temp_bms });
48
43
  values.push({ path: `${prefix}.slaveAddress`, value: slave.address });
@@ -2,6 +2,7 @@
2
2
  const WebSocket = require('ws');
3
3
  const status = require('./status');
4
4
  const settings = require('./settings');
5
+ const derived = require('./derived');
5
6
  const schema = require('../schema');
6
7
 
7
8
  module.exports = function(app, publishDelta) {
@@ -84,6 +85,11 @@ module.exports = function(app, publishDelta) {
84
85
  }
85
86
  }
86
87
 
88
+ // Add derived values
89
+ const derivedValues = derived(parsedData, prefix) || [];
90
+ delta.updates[0].values.push(...derivedValues);
91
+
92
+ // Filter out null/undefined
87
93
  delta.updates[0].values = delta.updates[0].values.filter(item => item.value !== null && item.value !== undefined);
88
94
 
89
95
  if (delta.updates[0].values.length > 0) {
@@ -111,9 +117,8 @@ module.exports = function(app, publishDelta) {
111
117
  });
112
118
 
113
119
  ws.on('error', (error) => {
114
- app.error(`[WEBSOCKET] WebSocket error: ${error.message}`);
115
120
  app.setPluginStatus(`Error: ${error.message} - ERROR`);
116
- app.debug("[WEBSOCKET] WebSocket error occurred, terminating connection.");
121
+ app.debug(`[WEBSOCKET] WebSocket error: "${error.message}", terminating connection.`);
117
122
  clearInterval(pingInterval);
118
123
  if (ws) ws.terminate();
119
124
  if (shouldReconnect) {
Binary file
Binary file
package/public/js/main.js CHANGED
@@ -15,6 +15,7 @@ class BMSSystem {
15
15
  current: null,
16
16
  power: null,
17
17
  stateOfCharge: null,
18
+ stateOfHealth: null,
18
19
  cellVoltages: {},
19
20
  cellResistances: {},
20
21
  cellVoltageDifference: null,
@@ -38,15 +39,15 @@ class BMSSystem {
38
39
 
39
40
  cacheDomElements() {
40
41
  const metrics = [
41
- 'voltage', 'current', 'power', 'stateOfCharge', 'cellVoltageDifference',
42
- 'bmsTemperature', 'cellTemperature1', 'maxCellVoltage', 'minCellVoltage',
43
- 'ampHourRemaining', 'ampHourUsed', 'capacity'
42
+ 'voltage', 'current', 'power', 'stateOfCharge', 'cellVoltageDifference', 'bmsTemperature', 'cellTemperature1',
43
+ 'maxCellVoltage', 'minCellVoltage', 'ampHourRemaining',
44
+ 'ampHourUsed', 'capacity'
44
45
  ];
45
-
46
+
46
47
  metrics.forEach(metric => {
47
48
  this.dom.metricElements[metric] = document.getElementById(metric);
48
49
  if (!this.dom.metricElements[metric]) {
49
- console.error(`Missing metric element: ${metric}`);
50
+ console.warn(`Missing metric element: ${metric}`);
50
51
  }
51
52
  });
52
53
  }
@@ -54,7 +55,7 @@ class BMSSystem {
54
55
  setupTabSwitching() {
55
56
  const statusTab = document.getElementById('nav-status');
56
57
  const settingsTab = document.getElementById('nav-settings');
57
-
58
+
58
59
  if (!statusTab || !settingsTab) {
59
60
  console.warn("Tab elements not found");
60
61
  return;
@@ -68,7 +69,7 @@ class BMSSystem {
68
69
  switchView(view) {
69
70
  const statusView = document.getElementById('status-view');
70
71
  const settingsView = document.getElementById('settings-view');
71
-
72
+
72
73
  if (view === 'status') {
73
74
  statusView?.classList.add('active');
74
75
  settingsView?.classList.remove('active');
@@ -90,11 +91,23 @@ class BMSSystem {
90
91
  setValue(this.dom.metricElements.voltage, this.liveData.voltage, "V");
91
92
  setValue(this.dom.metricElements.current, this.liveData.current, "A");
92
93
  setValue(this.dom.metricElements.power, this.liveData.power, "W");
93
- setValue(this.dom.metricElements.stateOfCharge, this.liveData.stateOfCharge, "%");
94
+
95
+ setValue(
96
+ this.dom.metricElements.stateOfCharge,
97
+ this.liveData.stateOfCharge !== null ? this.liveData.stateOfCharge * 100 : null,
98
+ "%"
99
+ );
100
+ setValue(
101
+ this.dom.metricElements.stateOfHealth,
102
+ this.liveData.stateOfHealth !== null ? this.liveData.stateOfHealth * 100 : null,
103
+ "%"
104
+ );
105
+
94
106
  if (this.dom.metricElements.cellVoltageDifference) {
95
107
  const v = this.liveData.cellVoltageDifference;
96
108
  this.dom.metricElements.cellVoltageDifference.textContent = v !== null ? `${v.toFixed(4)}V` : "--";
97
- }
109
+ }
110
+
98
111
  setValue(this.dom.metricElements.bmsTemperature, this.liveData.bmsTemperature, "°C");
99
112
  setValue(this.dom.metricElements.cellTemperature1, this.liveData.cellTemperature1, "°C");
100
113
  setValue(this.dom.metricElements.maxCellVoltage, this.liveData.maxCellVoltage, "V");
@@ -106,20 +119,23 @@ class BMSSystem {
106
119
 
107
120
  connectWebSocket() {
108
121
  try {
109
- const ws = new WebSocket(`ws://${window.location.host}/signalk/v1/stream?subscribe=self&path=electrical.batteries.bms.*`);
110
-
122
+ const wsScheme = (window.location.protocol === "https:") ? "wss" : "ws";
123
+ const ws = new WebSocket(
124
+ `${wsScheme}://${window.location.host}/signalk/v1/stream?subscribe=self&path=electrical.batteries.bms.*`
125
+ );
126
+
111
127
  ws.onopen = () => console.log("WebSocket connected");
112
128
  ws.onerror = (error) => console.error("WebSocket error:", error);
113
129
  ws.onclose = () => console.log("WebSocket connection closed");
114
-
130
+
115
131
  ws.onmessage = (event) => {
116
132
  const delta = JSON.parse(event.data);
117
133
  if (!delta.updates) return;
118
-
134
+
119
135
  delta.updates.forEach(update => {
120
136
  update.values?.forEach(({ path, value }) => {
121
137
  if (!path.startsWith(this.prefix)) return;
122
-
138
+
123
139
  const key = path.slice(this.prefix.length);
124
140
  if (key in this.liveData) {
125
141
  this.liveData[key] = value;
@@ -130,7 +146,7 @@ class BMSSystem {
130
146
  }
131
147
  });
132
148
  });
133
-
149
+
134
150
  this.updateMetrics();
135
151
  CellVisualizer.updateAllCells({
136
152
  cellVoltages: this.liveData.cellVoltages,
@@ -140,7 +156,7 @@ class BMSSystem {
140
156
  } catch (error) {
141
157
  console.error("WebSocket initialization failed:", error);
142
158
  }
143
- }
159
+ }
144
160
  }
145
161
 
146
162
  document.addEventListener('DOMContentLoaded', () => {