signalk-rec-bms 1.1.7 → 2.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.
- package/LICENSE +0 -0
- package/README.md +31 -9
- package/package.json +2 -2
- package/plugin/index.js +22 -0
- package/plugin/lib/connection.js +0 -0
- package/plugin/lib/history.js +163 -0
- package/plugin/lib/schema.js +0 -0
- package/plugin/lib/serial/Layer2.js +1 -3
- package/plugin/lib/serial/atlas.json +1 -11
- package/plugin/lib/serial/engine.js +7 -3
- package/plugin/lib/serial/hexer.js +0 -0
- package/plugin/lib/serial/modules/abms.js +0 -0
- package/plugin/lib/serial/modules/array.js +3 -4
- package/plugin/lib/serial/modules/bat.js +0 -0
- package/plugin/lib/serial/modules/cur.js +0 -0
- package/plugin/lib/serial/modules/derived.js +16 -12
- package/plugin/lib/serial/modules/erro.js +0 -0
- package/plugin/lib/serial/modules/outputs.js +0 -0
- package/plugin/lib/serial/modules/soc.js +0 -0
- package/plugin/lib/serial/modules/temp.js +0 -0
- package/plugin/lib/serial/modules/victron.js +0 -0
- package/plugin/lib/serial/modules/volt.js +0 -0
- package/plugin/lib/serial/serial.js +54 -43
- package/plugin/lib/websocket/derived.js +29 -25
- package/plugin/lib/websocket/settings.js +64 -84
- package/plugin/lib/websocket/status.js +32 -36
- package/plugin/lib/websocket/ws.js +18 -13
- package/public/assets/icons/signalk-rec-bms-icon_72x72.png +0 -0
- package/public/assets/images/bms_serial.png +0 -0
- package/public/assets/images/rec-sk.png +0 -0
- package/public/assets/images/rs485.jpg +0 -0
- package/public/css/core.css +614 -121
- package/public/index.html +166 -116
- package/public/js/cells.js +212 -82
- package/public/js/chart.js +190 -0
- package/public/js/main.js +161 -160
- package/public/js/settings.js +368 -86
- package/venus-os/signalk-rec-bms-venusOS.sh +0 -0
- package/public/assets/icons/rec_ball-72x72.png +0 -0
- package/public/css/components.css +0 -60
- package/public/css/responsive.css +0 -200
- package/public/css/settings.css +0 -132
- package/public/css/towers.css +0 -100
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- Reads all
|
|
8
|
-
- Supports
|
|
7
|
+
- Reads all available data streams from BMS and publishes to SignalK
|
|
8
|
+
- Supports WebSocket connection from the REC WiFi module with firmware >= SW164_2
|
|
9
9
|
- Supports serial connection directly from BMS
|
|
10
10
|
- Can set BMS settings when using serial connection
|
|
11
|
-
- Live status & settings page within SignalK
|
|
11
|
+
- Live status & settings page within SignalK
|
|
12
12
|
|
|
13
13
|
## Notes
|
|
14
14
|
|
|
15
|
-
Tested with the 4 cell ABMS model only
|
|
15
|
+
Tested with the 4 cell ABMS model only. Multi-unit series configurations are detected automatically but have not been tested beyond a single unit — proceed with caution if running multiple ABMS units in series.
|
|
16
16
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
19
|
-
####
|
|
19
|
+
#### WebSocket connection (WiFi):
|
|
20
20
|
|
|
21
21
|
- A REC WiFi module with firmware >= SW164_2, which enables websockets
|
|
22
22
|
|
|
@@ -32,7 +32,7 @@ Tested with the 4 cell ABMS model only
|
|
|
32
32
|
|
|
33
33
|
## Dependencies
|
|
34
34
|
|
|
35
|
-
- npm package: [
|
|
35
|
+
- npm package: [ws](https://www.npmjs.com/package/ws)
|
|
36
36
|
- npm package: [serialport](https://www.npmjs.com/package/serialport)
|
|
37
37
|
- npm package: [crc](https://www.npmjs.com/package/crc)
|
|
38
38
|
|
|
@@ -45,14 +45,36 @@ Run the following command:
|
|
|
45
45
|
npm install signalk-rec-bms
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## SignalK Paths
|
|
49
|
+
|
|
50
|
+
All data is published under `electrical.batteries.bms.*` by default. The base path is configurable via the `deltaPrefix` plugin setting. Key paths include:
|
|
51
|
+
|
|
52
|
+
| Path | Description |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `electrical.batteries.bms.voltage` | Pack voltage |
|
|
55
|
+
| `electrical.batteries.bms.current` | Pack current |
|
|
56
|
+
| `electrical.batteries.bms.power` | Instantaneous power |
|
|
57
|
+
| `electrical.batteries.bms.capacity.stateOfCharge` | State of charge (0–1) |
|
|
58
|
+
| `electrical.batteries.bms.capacity.stateOfHealth` | State of health (0–1) |
|
|
59
|
+
| `electrical.batteries.bms.capacity.nominal` | Configured capacity (Ah) |
|
|
60
|
+
| `electrical.batteries.bms.capacity.remaining` | Estimated remaining (Ah) |
|
|
61
|
+
| `electrical.batteries.bms.cellVoltage{N}` | Individual cell voltages |
|
|
62
|
+
| `electrical.batteries.bms.cellTemperature{N}` | Cell temperatures (K) |
|
|
63
|
+
| `electrical.batteries.bms.cellResistance{N}` | Cell internal resistance (Ohm) |
|
|
64
|
+
| `electrical.batteries.bms.bmsTemperature` | BMS board temperature (K) |
|
|
65
|
+
| `electrical.batteries.bms.minCellVoltage` | Minimum cell voltage |
|
|
66
|
+
| `electrical.batteries.bms.maxCellVoltage` | Maximum cell voltage |
|
|
67
|
+
|
|
68
|
+
Temperature values are published in Kelvin in compliance with the SignalK specification. Instrument displays that respect SK unit preferences will convert automatically to °C or °F.
|
|
69
|
+
|
|
48
70
|
## Victron Cerbo GX / Venus OS
|
|
49
71
|
|
|
50
|
-
VenusOS claims all serial devices by default
|
|
72
|
+
VenusOS claims all USB serial devices by default via its `serial-starter` service, which cycles through Victron protocols and prevents SignalK from accessing the port. The setup script creates a udev rule that instructs VenusOS to ignore your specific RS485 adapter, leaving it available exclusively for SignalK. The rule uses a `zz-` prefix to ensure it overrides Victron's default rules regardless of load order.
|
|
51
73
|
|
|
52
74
|
```bash
|
|
53
75
|
curl -o /home/root/signalk-rec-bms-venusOS.sh https://raw.githubusercontent.com/ofernander/signalk-rec-bms/main/venus-os/signalk-rec-bms-venusOS.sh && bash /home/root/signalk-rec-bms-venusOS.sh
|
|
54
76
|
```
|
|
55
77
|
|
|
56
|
-
The script will list your connected USB serial devices, you select which one is the usb to serial converter for your REC-BMS. Once complete, unplug and replug your RS485 adapter, then reboot the Cerbo device.
|
|
78
|
+
The script will list your connected USB serial devices, you select which one is the usb to serial converter for your REC-BMS. Once complete, unplug and replug your RS485 adapter, then reboot the Cerbo device.
|
|
57
79
|
|
|
58
|
-
To uninstall: `bash /home/root/signalk-rec-bms-venusOS.sh --uninstall` or select the last option in the script menu.
|
|
80
|
+
To uninstall: `bash /home/root/signalk-rec-bms-venusOS.sh --uninstall` or select the last option in the script menu.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-rec-bms",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "SignalK plugin for REC-BMS",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=10"
|
|
30
30
|
},
|
|
31
31
|
"signalk": {
|
|
32
|
-
"appIcon": "./assets/icons/
|
|
32
|
+
"appIcon": "./assets/icons/signalk-rec-bms-icon_72x72.png",
|
|
33
33
|
"displayName": "SignalK-REC-BMS"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/plugin/index.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const schema = require('./lib/schema');
|
|
4
4
|
const connection = require('./lib/connection');
|
|
5
|
+
const HistoryBuffer = require('./lib/history');
|
|
5
6
|
|
|
6
7
|
module.exports = function(app) {
|
|
7
8
|
let conn;
|
|
8
9
|
let pluginOptions;
|
|
10
|
+
let historyBuffer;
|
|
11
|
+
let historyRouteRegistered = false;
|
|
9
12
|
|
|
10
13
|
function publishMeta(options) {
|
|
11
14
|
const p = options.deltaPrefix || "electrical.batteries.bms";
|
|
@@ -94,10 +97,13 @@ module.exports = function(app) {
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
function publishDelta(delta) {
|
|
100
|
+
if (historyBuffer) historyBuffer.record(delta);
|
|
101
|
+
|
|
97
102
|
const precision = 5;
|
|
98
103
|
const factor = Math.pow(10, precision);
|
|
99
104
|
|
|
100
105
|
delta.updates.forEach(update => {
|
|
106
|
+
update.$source = "signalk-rec-bms";
|
|
101
107
|
update.values.forEach(item => {
|
|
102
108
|
if (typeof item.value === "number") {
|
|
103
109
|
item.value = Math.round(item.value * factor) / factor;
|
|
@@ -122,15 +128,31 @@ module.exports = function(app) {
|
|
|
122
128
|
pluginOptions = options;
|
|
123
129
|
app.debug(`[INDEX] START invoked with options: ${JSON.stringify(options)}`);
|
|
124
130
|
app.setPluginStatus("Connecting to BMS…");
|
|
131
|
+
|
|
132
|
+
const prefix = options.deltaPrefix || "electrical.batteries.bms";
|
|
133
|
+
const dataDir = typeof app.getDataDirPath === 'function' ? app.getDataDirPath() : null;
|
|
134
|
+
historyBuffer = new HistoryBuffer(prefix, dataDir);
|
|
135
|
+
|
|
136
|
+
if (!historyRouteRegistered) {
|
|
137
|
+
historyRouteRegistered = true;
|
|
138
|
+
app.get('/signalk/v1/bms/history', (req, res) => {
|
|
139
|
+
if (!historyBuffer) return res.status(503).json({ error: "Buffer not ready" });
|
|
140
|
+
const hours = req.query.hours || 1;
|
|
141
|
+
res.json(historyBuffer.getHistory(hours));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
125
145
|
publishMeta(options);
|
|
126
146
|
conn = connection(options, app, publishDelta);
|
|
127
147
|
conn.start(options);
|
|
128
148
|
},
|
|
129
149
|
stop: function() {
|
|
150
|
+
if (historyBuffer) historyBuffer.stop();
|
|
130
151
|
if (conn) {
|
|
131
152
|
conn.stop();
|
|
132
153
|
conn = null;
|
|
133
154
|
}
|
|
155
|
+
historyBuffer = null;
|
|
134
156
|
app.debug("[INDEX] Plugin stopped");
|
|
135
157
|
app.setPluginStatus("Stopped");
|
|
136
158
|
},
|
package/plugin/lib/connection.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
7
|
+
|
|
8
|
+
module.exports = function HistoryBuffer(prefix, dataDir) {
|
|
9
|
+
const persistPath = dataDir
|
|
10
|
+
? path.join(dataDir, 'bms-history.json')
|
|
11
|
+
: null;
|
|
12
|
+
|
|
13
|
+
// Three-tier storage per metric
|
|
14
|
+
// Each entry: { t: timestamp_ms, v: value }
|
|
15
|
+
const store = {
|
|
16
|
+
voltage: { fine: [], medium: [], coarse: [] },
|
|
17
|
+
current: { fine: [], medium: [], coarse: [] },
|
|
18
|
+
power: { fine: [], medium: [], coarse: [] },
|
|
19
|
+
soc: { fine: [], medium: [], coarse: [] }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Tier boundaries and max retention
|
|
23
|
+
const FINE_RESOLUTION_MS = 10 * 1000; // 10s — keep for 1 hour
|
|
24
|
+
const MEDIUM_RESOLUTION_MS = 60 * 1000; // 1m — keep for 6 hours
|
|
25
|
+
const COARSE_RESOLUTION_MS = 5 * 60 * 1000; // 5m — keep for 24 hours
|
|
26
|
+
|
|
27
|
+
const FINE_MAX_AGE_MS = 1 * 60 * 60 * 1000; // 1 hour
|
|
28
|
+
const MEDIUM_MAX_AGE_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
29
|
+
const COARSE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
30
|
+
|
|
31
|
+
// Last recorded timestamp per metric per tier, for resolution gating
|
|
32
|
+
const lastRecorded = {
|
|
33
|
+
voltage: { fine: 0, medium: 0, coarse: 0 },
|
|
34
|
+
current: { fine: 0, medium: 0, coarse: 0 },
|
|
35
|
+
power: { fine: 0, medium: 0, coarse: 0 },
|
|
36
|
+
soc: { fine: 0, medium: 0, coarse: 0 }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Load persisted data on startup
|
|
40
|
+
if (persistPath) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(persistPath, 'utf8');
|
|
43
|
+
const saved = JSON.parse(raw);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
|
|
46
|
+
for (const metric of ['voltage', 'current', 'power', 'soc']) {
|
|
47
|
+
if (saved[metric]) {
|
|
48
|
+
store[metric].fine = (saved[metric].fine || []).filter(p => p.t >= now - FINE_MAX_AGE_MS);
|
|
49
|
+
store[metric].medium = (saved[metric].medium || []).filter(p => p.t >= now - MEDIUM_MAX_AGE_MS);
|
|
50
|
+
store[metric].coarse = (saved[metric].coarse || []).filter(p => p.t >= now - COARSE_MAX_AGE_MS);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Restore lastRecorded from last entry in each tier so we don't duplicate points
|
|
55
|
+
for (const metric of ['voltage', 'current', 'power', 'soc']) {
|
|
56
|
+
for (const tier of ['fine', 'medium', 'coarse']) {
|
|
57
|
+
const arr = store[metric][tier];
|
|
58
|
+
if (arr.length > 0) lastRecorded[metric][tier] = arr[arr.length - 1].t;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Missing or corrupt file — start fresh silently
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pathMap = {
|
|
67
|
+
[`${prefix}.voltage`]: 'voltage',
|
|
68
|
+
[`${prefix}.current`]: 'current',
|
|
69
|
+
[`${prefix}.power`]: 'power',
|
|
70
|
+
[`${prefix}.capacity.stateOfCharge`]: 'soc'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function record(delta) {
|
|
74
|
+
if (!delta.updates) return;
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
|
|
77
|
+
delta.updates.forEach(update => {
|
|
78
|
+
if (!update.values) return;
|
|
79
|
+
update.values.forEach(({ path, value }) => {
|
|
80
|
+
const metric = pathMap[path];
|
|
81
|
+
if (!metric || typeof value !== 'number') return;
|
|
82
|
+
push(metric, now, value);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function push(metric, now, value) {
|
|
88
|
+
const tiers = store[metric];
|
|
89
|
+
const last = lastRecorded[metric];
|
|
90
|
+
|
|
91
|
+
if (now - last.fine >= FINE_RESOLUTION_MS) {
|
|
92
|
+
tiers.fine.push({ t: now, v: value });
|
|
93
|
+
last.fine = now;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (now - last.medium >= MEDIUM_RESOLUTION_MS) {
|
|
97
|
+
tiers.medium.push({ t: now, v: value });
|
|
98
|
+
last.medium = now;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (now - last.coarse >= COARSE_RESOLUTION_MS) {
|
|
102
|
+
tiers.coarse.push({ t: now, v: value });
|
|
103
|
+
last.coarse = now;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
prune(tiers.fine, now - FINE_MAX_AGE_MS);
|
|
107
|
+
prune(tiers.medium, now - MEDIUM_MAX_AGE_MS);
|
|
108
|
+
prune(tiers.coarse, now - COARSE_MAX_AGE_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function prune(arr, cutoff) {
|
|
112
|
+
while (arr.length > 0 && arr[0].t < cutoff) arr.shift();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function flush() {
|
|
116
|
+
if (!persistPath) return;
|
|
117
|
+
try {
|
|
118
|
+
const data = {};
|
|
119
|
+
for (const metric of ['voltage', 'current', 'power', 'soc']) {
|
|
120
|
+
data[metric] = {
|
|
121
|
+
fine: store[metric].fine,
|
|
122
|
+
medium: store[metric].medium,
|
|
123
|
+
coarse: store[metric].coarse
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(persistPath, JSON.stringify(data), 'utf8');
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Write failure — non-fatal, will retry on next interval
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Periodic flush to disk
|
|
133
|
+
let flushTimer = null;
|
|
134
|
+
if (persistPath) {
|
|
135
|
+
flushTimer = setInterval(() => flush(), FLUSH_INTERVAL_MS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getHistory(hours) {
|
|
139
|
+
const h = Math.min(Math.max(parseFloat(hours) || 1, 0.25), 24);
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const cutoff = now - h * 60 * 60 * 1000;
|
|
142
|
+
|
|
143
|
+
let tier;
|
|
144
|
+
if (h <= 1) tier = 'fine';
|
|
145
|
+
else if (h <= 6) tier = 'medium';
|
|
146
|
+
else tier = 'coarse';
|
|
147
|
+
|
|
148
|
+
const result = {};
|
|
149
|
+
for (const metric of ['voltage', 'current', 'power', 'soc']) {
|
|
150
|
+
result[metric] = store[metric][tier]
|
|
151
|
+
.filter(p => p.t >= cutoff)
|
|
152
|
+
.map(p => ({ t: p.t, v: p.v }));
|
|
153
|
+
}
|
|
154
|
+
return { hours: h, tier, generated: now, data: result };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stop() {
|
|
158
|
+
if (flushTimer) clearInterval(flushTimer);
|
|
159
|
+
flush(); // flush on stop so shutdown data is preserved
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { record, getHistory, stop };
|
|
163
|
+
};
|
package/plugin/lib/schema.js
CHANGED
|
File without changes
|
|
@@ -490,16 +490,6 @@
|
|
|
490
490
|
"description": "SERI? returns ABMS serial number",
|
|
491
491
|
"module": "abms"
|
|
492
492
|
},
|
|
493
|
-
{
|
|
494
|
-
"tag": "SERI2",
|
|
495
|
-
"command": "SERI?",
|
|
496
|
-
"expectedPackets": 1,
|
|
497
|
-
"payloadSizes": [null],
|
|
498
|
-
"parser": "parseSERIResponse",
|
|
499
|
-
"timeout": 1000,
|
|
500
|
-
"description": "SERI2? returns ABMS serial number (hidden duplicate)",
|
|
501
|
-
"module": "abms"
|
|
502
|
-
},
|
|
503
493
|
{
|
|
504
494
|
"tag": "SWVR",
|
|
505
495
|
"command": "SWVR?",
|
|
@@ -549,5 +539,5 @@
|
|
|
549
539
|
"timeout": 1000,
|
|
550
540
|
"description": "WCBI? returns balancing cell number",
|
|
551
541
|
"module": "abms"
|
|
552
|
-
}
|
|
542
|
+
}
|
|
553
543
|
]
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
2
|
const EventEmitter = require('events');
|
|
5
3
|
const hexer = require('./hexer');
|
|
6
4
|
const atlas = require('./atlas.json');
|
|
@@ -18,9 +16,14 @@ class CommandEngine extends EventEmitter {
|
|
|
18
16
|
|
|
19
17
|
sendCommand(tag, targetAddress, rawCommandStr = null, options = {}) {
|
|
20
18
|
return new Promise((resolve, reject) => {
|
|
19
|
+
// If a command is already in flight, reject unless caller explicitly forces
|
|
20
|
+
if (this.activeCommand && !options.force) {
|
|
21
|
+
return reject(new Error(`[ENGINE] Busy — command ${this.activeCommand.tag} in flight, dropping ${tag}`));
|
|
22
|
+
}
|
|
23
|
+
|
|
21
24
|
let commandStr;
|
|
22
25
|
let expectedPackets = 1;
|
|
23
|
-
let timeoutMs = 3000;
|
|
26
|
+
let timeoutMs = 3000;
|
|
24
27
|
let config = atlasMapping[tag];
|
|
25
28
|
|
|
26
29
|
if (rawCommandStr) {
|
|
@@ -64,6 +67,7 @@ class CommandEngine extends EventEmitter {
|
|
|
64
67
|
currentCommand.timeout = setTimeout(() => {
|
|
65
68
|
const errMsg = `${tag} response timed out after ${timeoutMs} ms`;
|
|
66
69
|
if (currentCommand && typeof currentCommand.reject === "function") {
|
|
70
|
+
this.activeCommand = null;
|
|
67
71
|
currentCommand.reject(new Error(errMsg));
|
|
68
72
|
}
|
|
69
73
|
}, timeoutMs);
|
|
File without changes
|
|
File without changes
|
|
@@ -70,6 +70,9 @@ function parseLCD3Response(input) {
|
|
|
70
70
|
|
|
71
71
|
function parseIDNResponse(input) {
|
|
72
72
|
const packet = Array.isArray(input) ? input[0] : input;
|
|
73
|
+
if (!packet) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
73
76
|
const payload = packet.slice(4, packet.length - 3).toString('utf8');
|
|
74
77
|
const result = {
|
|
75
78
|
type: "IDN",
|
|
@@ -390,10 +393,6 @@ function getDelta(parsed, options, app) {
|
|
|
390
393
|
return delta;
|
|
391
394
|
}
|
|
392
395
|
|
|
393
|
-
/* ============================================================================
|
|
394
|
-
4. EXPORTS
|
|
395
|
-
Export functions for building commands, parsing responses, and building deltas.
|
|
396
|
-
============================================================================ */
|
|
397
396
|
module.exports = {
|
|
398
397
|
buildCommand,
|
|
399
398
|
getDelta,
|
|
File without changes
|
|
File without changes
|
|
@@ -12,6 +12,8 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
12
12
|
// 'capacity' is stored internally in Ah (as received from capacity.nominal).
|
|
13
13
|
// 'ampHourUsed' is stored internally in Ah (converted from Coulombs off the stream).
|
|
14
14
|
|
|
15
|
+
const subscriptions = [];
|
|
16
|
+
|
|
15
17
|
function computeAndSendDeltas() {
|
|
16
18
|
if ([voltage, current, ampHourUsed, capacity].every(v => typeof v === 'number')) {
|
|
17
19
|
const power = voltage * current;
|
|
@@ -43,20 +45,20 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
app.streambundle.getSelfStream(prefix + 'voltage').forEach(v => {
|
|
48
|
+
subscriptions.push(app.streambundle.getSelfStream(prefix + 'voltage').forEach(v => {
|
|
47
49
|
if (typeof v === 'number') { voltage = v; computeAndSendDeltas(); }
|
|
48
|
-
});
|
|
49
|
-
app.streambundle.getSelfStream(prefix + 'current').forEach(v => {
|
|
50
|
+
}));
|
|
51
|
+
subscriptions.push(app.streambundle.getSelfStream(prefix + 'current').forEach(v => {
|
|
50
52
|
if (typeof v === 'number') { current = v; computeAndSendDeltas(); }
|
|
51
|
-
});
|
|
52
|
-
app.streambundle.getSelfStream(prefix + 'capacity.dischargeSinceFull').forEach(v => {
|
|
53
|
+
}));
|
|
54
|
+
subscriptions.push(app.streambundle.getSelfStream(prefix + 'capacity.dischargeSinceFull').forEach(v => {
|
|
53
55
|
// value on stream is Coulombs; convert to Ah for internal arithmetic
|
|
54
56
|
if (typeof v === 'number') { ampHourUsed = v / 3600; computeAndSendDeltas(); }
|
|
55
|
-
});
|
|
56
|
-
app.streambundle.getSelfStream(prefix + 'capacity.nominal').forEach(v => {
|
|
57
|
+
}));
|
|
58
|
+
subscriptions.push(app.streambundle.getSelfStream(prefix + 'capacity.nominal').forEach(v => {
|
|
57
59
|
// value on stream is Ah
|
|
58
60
|
if (typeof v === 'number') { capacity = v; computeAndSendDeltas(); }
|
|
59
|
-
});
|
|
61
|
+
}));
|
|
60
62
|
|
|
61
63
|
// ---- Cell diff logic ----
|
|
62
64
|
let totalCells = 0;
|
|
@@ -73,7 +75,7 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
73
75
|
const CELL_DIFF_RETRY_MS = 200;
|
|
74
76
|
const CELL_DIFF_MAX_WAIT_MS = 8000;
|
|
75
77
|
|
|
76
|
-
app.streambundle.getSelfStream(prefix + 'numBMSUnits').forEach(units => {
|
|
78
|
+
subscriptions.push(app.streambundle.getSelfStream(prefix + 'numBMSUnits').forEach(units => {
|
|
77
79
|
if (typeof units === 'number' && units > 0) {
|
|
78
80
|
// If units changes (or first time), reset expectations and cached values
|
|
79
81
|
if (units !== unitsExpected) {
|
|
@@ -95,13 +97,13 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
95
97
|
|
|
96
98
|
subscribedCells.add(i);
|
|
97
99
|
const cellPath = prefix + 'cellVoltage' + i;
|
|
98
|
-
app.streambundle.getSelfStream(cellPath).forEach(val => {
|
|
100
|
+
subscriptions.push(app.streambundle.getSelfStream(cellPath).forEach(val => {
|
|
99
101
|
if (typeof val === 'number' && Number.isFinite(val)) {
|
|
100
102
|
if (!firstCellSeenAtMs) firstCellSeenAtMs = Date.now();
|
|
101
103
|
cellVoltages[i] = val;
|
|
102
104
|
trySendCellDiff();
|
|
103
105
|
}
|
|
104
|
-
});
|
|
106
|
+
}));
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
subscribedCellStreams = true;
|
|
@@ -109,7 +111,7 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
109
111
|
// Attempt immediately in case we already have cached values after restart
|
|
110
112
|
trySendCellDiff();
|
|
111
113
|
}
|
|
112
|
-
});
|
|
114
|
+
}));
|
|
113
115
|
|
|
114
116
|
function scheduleCellDiffRetry() {
|
|
115
117
|
if (pendingCellDiffTimer) return;
|
|
@@ -173,6 +175,8 @@ module.exports = function Derived(app, sendDeltaCallback, config) {
|
|
|
173
175
|
return {
|
|
174
176
|
stop: () => {
|
|
175
177
|
app.debug("[DERIVED] Stopping derived module");
|
|
178
|
+
if (pendingCellDiffTimer) clearTimeout(pendingCellDiffTimer);
|
|
179
|
+
subscriptions.forEach(unsub => { if (typeof unsub === 'function') unsub(); });
|
|
176
180
|
}
|
|
177
181
|
};
|
|
178
182
|
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -31,6 +31,8 @@ const moduleMapping = {
|
|
|
31
31
|
module.exports = function(app, publishDelta) {
|
|
32
32
|
let port, parser, engine;
|
|
33
33
|
let derivedModule;
|
|
34
|
+
let pollingInterval;
|
|
35
|
+
let routeRegistered = false;
|
|
34
36
|
|
|
35
37
|
function start(options) {
|
|
36
38
|
app.debug("[SERIAL] start() called with options: " + JSON.stringify(options));
|
|
@@ -69,7 +71,7 @@ module.exports = function(app, publishDelta) {
|
|
|
69
71
|
const commandList = atlas.map(entry => entry.tag);
|
|
70
72
|
let currentIndex = 0;
|
|
71
73
|
|
|
72
|
-
setInterval(() => {
|
|
74
|
+
pollingInterval = setInterval(() => {
|
|
73
75
|
const tag = commandList[currentIndex];
|
|
74
76
|
currentIndex = (currentIndex + 1) % commandList.length;
|
|
75
77
|
|
|
@@ -86,58 +88,67 @@ module.exports = function(app, publishDelta) {
|
|
|
86
88
|
if (moduleDelta) publishDelta(moduleDelta);
|
|
87
89
|
}
|
|
88
90
|
})
|
|
89
|
-
.catch(err =>
|
|
91
|
+
.catch(err => {
|
|
92
|
+
if (err.message.startsWith('[ENGINE] Busy')) {
|
|
93
|
+
app.debug("[SERIAL] Skipping " + tag + " — engine busy");
|
|
94
|
+
} else {
|
|
95
|
+
app.debug("[SERIAL] Error: " + err.message);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
90
98
|
}, 100);
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const parts = command.trim().split(/\s+/);
|
|
98
|
-
let tag = parts[0];
|
|
99
|
-
const raw = command.trim();
|
|
100
|
-
const isQuery = tag.endsWith("?");
|
|
100
|
+
if (!routeRegistered) {
|
|
101
|
+
routeRegistered = true;
|
|
102
|
+
app.post('/signalk/v1/bms/command', (req, res) => {
|
|
103
|
+
const { command } = req.body;
|
|
104
|
+
if (!command) return res.status(400).json({ error: "Missing command" });
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
tag =
|
|
104
|
-
|
|
106
|
+
const parts = command.trim().split(/\s+/);
|
|
107
|
+
let tag = parts[0];
|
|
108
|
+
const raw = command.trim();
|
|
109
|
+
const isQuery = tag.endsWith("?");
|
|
105
110
|
|
|
106
|
-
|
|
111
|
+
if (isQuery) {
|
|
112
|
+
tag = tag.slice(0, -1);
|
|
113
|
+
}
|
|
107
114
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
115
|
+
const config = atlas.find(entry => entry.tag === tag);
|
|
116
|
+
|
|
117
|
+
if (config && config.module && moduleMapping[config.module]) {
|
|
118
|
+
const commandModule = moduleMapping[config.module];
|
|
119
|
+
const expectResponse = isQuery;
|
|
120
|
+
|
|
121
|
+
engine.sendCommand(tag, options.serial.targetAddress, raw, { expectResponse, force: true })
|
|
122
|
+
.then(packets => {
|
|
123
|
+
if (expectResponse) {
|
|
124
|
+
const parsed = commandModule[config.parser]?.(packets);
|
|
125
|
+
res.json({
|
|
126
|
+
command,
|
|
127
|
+
response: parsed,
|
|
128
|
+
rawPackets: packets.map(p => p.toString('hex'))
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
res.json({
|
|
132
|
+
command,
|
|
133
|
+
response: { status: "sent (no response expected)" }
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.catch(err => {
|
|
138
|
+
res.status(500).json({ error: err.message, command });
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
res.status(400).json({ error: "Unknown or unsupported command" });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
135
145
|
});
|
|
136
146
|
|
|
137
147
|
port.on('error', (err) => app.debug("[SERIAL] Error: " + err.message));
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
function stop() {
|
|
151
|
+
clearInterval(pollingInterval);
|
|
141
152
|
if (port) port.close();
|
|
142
153
|
if (derivedModule && typeof derivedModule.stop === 'function') derivedModule.stop();
|
|
143
154
|
}
|
|
@@ -152,7 +163,7 @@ module.exports = function(app, publishDelta) {
|
|
|
152
163
|
throw new Error("Unknown or unsupported command");
|
|
153
164
|
}
|
|
154
165
|
const commandModule = moduleMapping[config.module];
|
|
155
|
-
return engine.sendCommand(tag, targetAddress)
|
|
166
|
+
return engine.sendCommand(tag, targetAddress, null, { force: true })
|
|
156
167
|
.then(packets => ({
|
|
157
168
|
packets,
|
|
158
169
|
parsed: commandModule[config.parser]?.(packets)
|