signalk-rec-bms 1.1.7 → 2.0.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.
Files changed (43) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +31 -9
  3. package/package.json +3 -3
  4. package/plugin/index.js +22 -0
  5. package/plugin/lib/connection.js +0 -0
  6. package/plugin/lib/history.js +163 -0
  7. package/plugin/lib/schema.js +0 -0
  8. package/plugin/lib/serial/Layer2.js +1 -3
  9. package/plugin/lib/serial/atlas.json +1 -11
  10. package/plugin/lib/serial/engine.js +7 -3
  11. package/plugin/lib/serial/hexer.js +0 -0
  12. package/plugin/lib/serial/modules/abms.js +0 -0
  13. package/plugin/lib/serial/modules/array.js +3 -4
  14. package/plugin/lib/serial/modules/bat.js +0 -0
  15. package/plugin/lib/serial/modules/cur.js +0 -0
  16. package/plugin/lib/serial/modules/derived.js +16 -12
  17. package/plugin/lib/serial/modules/erro.js +0 -0
  18. package/plugin/lib/serial/modules/outputs.js +0 -0
  19. package/plugin/lib/serial/modules/soc.js +0 -0
  20. package/plugin/lib/serial/modules/temp.js +0 -0
  21. package/plugin/lib/serial/modules/victron.js +0 -0
  22. package/plugin/lib/serial/modules/volt.js +0 -0
  23. package/plugin/lib/serial/serial.js +54 -43
  24. package/plugin/lib/websocket/derived.js +29 -25
  25. package/plugin/lib/websocket/settings.js +64 -84
  26. package/plugin/lib/websocket/status.js +32 -36
  27. package/plugin/lib/websocket/ws.js +18 -13
  28. package/public/assets/icons/signalk-rec-bms-icon_72x72.png +0 -0
  29. package/public/assets/images/bms_serial.png +0 -0
  30. package/public/assets/images/rec-sk.png +0 -0
  31. package/public/assets/images/rs485.jpg +0 -0
  32. package/public/css/core.css +614 -121
  33. package/public/index.html +166 -116
  34. package/public/js/cells.js +212 -82
  35. package/public/js/chart.js +190 -0
  36. package/public/js/main.js +161 -160
  37. package/public/js/settings.js +368 -86
  38. package/venus-os/signalk-rec-bms-venusOS.sh +0 -0
  39. package/public/assets/icons/rec_ball-72x72.png +0 -0
  40. package/public/css/components.css +0 -60
  41. package/public/css/responsive.css +0 -200
  42. package/public/css/settings.css +0 -132
  43. 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 avabile data streams from BMS and publishes to SignalK
8
- - Supports Websocket connection from the REC WiFi module with firmare >= SW164_2
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
- #### Websocket connection (WiFi):
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: [websocket](https://www.npmjs.com/package/websocket)
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 and attempts to pair them with a pre-defined service. This script works by telling VenusOS to ignore your usb to serial device & allowing you to use it with SignalK.
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,10 +1,10 @@
1
1
  {
2
2
  "name": "signalk-rec-bms",
3
- "version": "1.1.7",
3
+ "version": "2.0.2",
4
4
  "description": "SignalK plugin for REC-BMS",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "node --test test/parsers.test.js"
8
8
  },
9
9
  "keywords": [
10
10
  "signalk-webapp",
@@ -29,7 +29,7 @@
29
29
  "node": ">=10"
30
30
  },
31
31
  "signalk": {
32
- "appIcon": "./assets/icons/rec_ball-72x72.png",
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
  },
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
+ };
File without changes
@@ -5,8 +5,6 @@ class Layer2 {
5
5
  constructor() {
6
6
  this.buffer = [];
7
7
  this.stats = {
8
- tx: 0,
9
- txBytes: 0,
10
8
  rx: 0,
11
9
  rxBytes: 0,
12
10
  badCRC: 0
@@ -66,4 +64,4 @@ class Layer2 {
66
64
  }
67
65
  }
68
66
 
69
- module.exports = Layer2;
67
+ module.exports = Layer2;
@@ -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 => app.debug("[SERIAL] Error: " + err.message));
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
- // command endpoint
93
- app.post('/signalk/v1/bms/command', (req, res) => {
94
- const { command } = req.body;
95
- if (!command) return res.status(400).json({ error: "Missing command" });
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
- if (isQuery) {
103
- tag = tag.slice(0, -1);
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
- const config = atlas.find(entry => entry.tag === tag);
111
+ if (isQuery) {
112
+ tag = tag.slice(0, -1);
113
+ }
107
114
 
108
- if (config && config.module && moduleMapping[config.module]) {
109
- const commandModule = moduleMapping[config.module];
110
- const expectResponse = isQuery;
111
-
112
- engine.sendCommand(tag, options.serial.targetAddress, raw, { expectResponse })
113
- .then(packets => {
114
- if (expectResponse) {
115
- const parsed = commandModule[config.parser]?.(packets);
116
- res.json({
117
- command,
118
- response: parsed,
119
- rawPackets: packets.map(p => p.toString('hex'))
120
- });
121
- } else {
122
- res.json({
123
- command,
124
- response: { status: "sent (no response expected)" }
125
- });
126
- }
127
- })
128
- .catch(err => {
129
- res.status(500).json({ error: err.message, command });
130
- });
131
- } else {
132
- res.status(400).json({ error: "Unknown or unsupported command" });
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)