signalk-rec-bms 1.1.0 → 1.1.2
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 +4 -4
- package/package.json +1 -1
- package/plugin/lib/schema.js +1 -1
- package/plugin/lib/serial/modules/array.js +2 -2
- package/plugin/lib/serial/modules/derived.js +27 -7
- package/plugin/lib/websocket/derived.js +78 -0
- package/plugin/lib/websocket/settings.js +0 -5
- package/plugin/lib/websocket/status.js +2 -7
- package/plugin/lib/websocket/ws.js +7 -2
- package/public/assets/images/bms_serial.png +0 -0
- package/public/assets/images/rs485.jpg +0 -0
- package/public/index.html +1 -1
- package/public/js/main.js +26 -9
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# signalk-rec-bms
|
|
2
2
|
|
|
3
|
-

|
|
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
|
|
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
|
-

|
|
30
30
|
|
|
31
|
-

|
|
32
32
|
|
|
33
33
|
## Dependencies
|
|
34
34
|
|
package/package.json
CHANGED
package/plugin/lib/schema.js
CHANGED
|
@@ -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)
|
|
39
|
-
soh: packet.readFloatLE(28)
|
|
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
|
}
|
|
@@ -56,7 +74,7 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
56
74
|
});
|
|
57
75
|
|
|
58
76
|
function trySendCellDiff() {
|
|
59
|
-
const keys = Object.keys(cellVoltages).map(k => parseInt(k,10));
|
|
77
|
+
const keys = Object.keys(cellVoltages).map(k => parseInt(k, 10));
|
|
60
78
|
if (keys.length === totalCells) {
|
|
61
79
|
const vals = keys.map(i => cellVoltages[i]);
|
|
62
80
|
const minV = Math.min(...vals);
|
|
@@ -68,6 +86,8 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
68
86
|
]
|
|
69
87
|
}]
|
|
70
88
|
});
|
|
89
|
+
} else {
|
|
90
|
+
setTimeout(trySendCellDiff, 10);
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
|
|
@@ -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(
|
|
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/index.html
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
</div>
|
|
37
37
|
<div class="metric-box">
|
|
38
38
|
<div class="metric-label">State of Charge</div>
|
|
39
|
-
<div class="metric-value" id="
|
|
39
|
+
<div class="metric-value" id="stateOfCharge">-- %</div>
|
|
40
40
|
</div>
|
|
41
41
|
<div class="metric-box">
|
|
42
42
|
<div class="metric-label">Pack Voltage</div>
|
package/public/js/main.js
CHANGED
|
@@ -14,7 +14,8 @@ class BMSSystem {
|
|
|
14
14
|
voltage: null,
|
|
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,11 +39,12 @@ class BMSSystem {
|
|
|
38
39
|
|
|
39
40
|
cacheDomElements() {
|
|
40
41
|
const metrics = [
|
|
41
|
-
'voltage', 'current', 'power', '
|
|
42
|
-
'
|
|
43
|
-
'
|
|
42
|
+
'voltage', 'current', 'power', 'stateOfCharge', 'stateOfHealth',
|
|
43
|
+
'cellVoltageDifference', 'bmsTemperature', 'cellTemperature1',
|
|
44
|
+
'maxCellVoltage', 'minCellVoltage', 'ampHourRemaining',
|
|
45
|
+
'ampHourUsed', 'capacity'
|
|
44
46
|
];
|
|
45
|
-
|
|
47
|
+
|
|
46
48
|
metrics.forEach(metric => {
|
|
47
49
|
this.dom.metricElements[metric] = document.getElementById(metric);
|
|
48
50
|
if (!this.dom.metricElements[metric]) {
|
|
@@ -54,7 +56,7 @@ class BMSSystem {
|
|
|
54
56
|
setupTabSwitching() {
|
|
55
57
|
const statusTab = document.getElementById('nav-status');
|
|
56
58
|
const settingsTab = document.getElementById('nav-settings');
|
|
57
|
-
|
|
59
|
+
|
|
58
60
|
if (!statusTab || !settingsTab) {
|
|
59
61
|
console.warn("Tab elements not found");
|
|
60
62
|
return;
|
|
@@ -68,7 +70,7 @@ class BMSSystem {
|
|
|
68
70
|
switchView(view) {
|
|
69
71
|
const statusView = document.getElementById('status-view');
|
|
70
72
|
const settingsView = document.getElementById('settings-view');
|
|
71
|
-
|
|
73
|
+
|
|
72
74
|
if (view === 'status') {
|
|
73
75
|
statusView?.classList.add('active');
|
|
74
76
|
settingsView?.classList.remove('active');
|
|
@@ -90,8 +92,23 @@ class BMSSystem {
|
|
|
90
92
|
setValue(this.dom.metricElements.voltage, this.liveData.voltage, "V");
|
|
91
93
|
setValue(this.dom.metricElements.current, this.liveData.current, "A");
|
|
92
94
|
setValue(this.dom.metricElements.power, this.liveData.power, "W");
|
|
93
|
-
|
|
94
|
-
setValue(
|
|
95
|
+
|
|
96
|
+
setValue(
|
|
97
|
+
this.dom.metricElements.stateOfCharge,
|
|
98
|
+
this.liveData.stateOfCharge !== null ? this.liveData.stateOfCharge * 100 : null,
|
|
99
|
+
"%"
|
|
100
|
+
);
|
|
101
|
+
setValue(
|
|
102
|
+
this.dom.metricElements.stateOfHealth,
|
|
103
|
+
this.liveData.stateOfHealth !== null ? this.liveData.stateOfHealth * 100 : null,
|
|
104
|
+
"%"
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (this.dom.metricElements.cellVoltageDifference) {
|
|
108
|
+
const v = this.liveData.cellVoltageDifference;
|
|
109
|
+
this.dom.metricElements.cellVoltageDifference.textContent = v !== null ? `${v.toFixed(4)}V` : "--";
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
setValue(this.dom.metricElements.bmsTemperature, this.liveData.bmsTemperature, "°C");
|
|
96
113
|
setValue(this.dom.metricElements.cellTemperature1, this.liveData.cellTemperature1, "°C");
|
|
97
114
|
setValue(this.dom.metricElements.maxCellVoltage, this.liveData.maxCellVoltage, "V");
|