signalk-rec-bms 1.1.6 → 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.
Files changed (43) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +31 -9
  3. package/package.json +2 -2
  4. package/plugin/index.js +113 -18
  5. package/plugin/lib/connection.js +0 -0
  6. package/plugin/lib/history.js +163 -0
  7. package/plugin/lib/schema.js +6 -1
  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 +2 -2
  13. package/plugin/lib/serial/modules/array.js +64 -50
  14. package/plugin/lib/serial/modules/bat.js +3 -1
  15. package/plugin/lib/serial/modules/cur.js +0 -0
  16. package/plugin/lib/serial/modules/derived.js +30 -21
  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 +5 -5
  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 +168 -111
  34. package/public/js/cells.js +212 -82
  35. package/public/js/chart.js +190 -0
  36. package/public/js/main.js +170 -132
  37. package/public/js/settings.js +384 -95
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-rec-bms",
3
- "version": "1.1.6",
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/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,37 +2,115 @@
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;
9
- let commsHandler;
10
+ let historyBuffer;
11
+ let historyRouteRegistered = false;
12
+
13
+ function publishMeta(options) {
14
+ const p = options.deltaPrefix || "electrical.batteries.bms";
15
+
16
+ const meta = [
17
+ // Live data
18
+ { path: `${p}.minCellVoltage`, value: { units: "V", description: "Minimum cell voltage across all cells" } },
19
+ { path: `${p}.maxCellVoltage`, value: { units: "V", description: "Maximum cell voltage across all cells" } },
20
+ { path: `${p}.cellVoltageDifference`, value: { units: "V", description: "Difference between maximum and minimum cell voltage" } },
21
+ { path: `${p}.numBMSUnits`, value: { description: "Number of ABMS units connected in series" } },
22
+ { path: `${p}.bmsTemperature`, value: { units: "K", description: "BMS board internal temperature" } },
23
+ { path: `${p}.slaveAddress`, value: { description: "RS-485 address of the BMS unit with the lowest cell voltage" } },
24
+ { path: `${p}.minCellNumber`, value: { description: "Cell number with the lowest voltage" } },
25
+ { path: `${p}.maxCellNumber`, value: { description: "Cell number with the highest voltage" } },
26
+ { path: `${p}.maxTempSensBmsAddress`, value: { description: "RS-485 address of BMS unit with the highest temperature sensor reading" } },
27
+ { path: `${p}.maxTempSensNumber`, value: { description: "Sensor number with the highest temperature reading" } },
28
+ { path: `${p}.id`, value: { description: "Device identification string" } },
29
+ { path: `${p}.errorPresent`, value: { description: "1 if an error is active, 0 if none" } },
30
+ { path: `${p}.errorAddress`, value: { description: "RS-485 address of BMS unit reporting the error" } },
31
+ { path: `${p}.errorCode`, value: { description: "Error code (1-16)" } },
32
+ { path: `${p}.errorSource`, value: { description: "Cell or sensor number associated with the error" } },
33
+ // Derived
34
+ { path: `${p}.power`, value: { units: "W", description: "Instantaneous power (voltage × current)" } },
35
+ { path: `${p}.capacity.remaining`, value: { units: "Ah", description: "Estimated capacity remaining" } },
36
+ { path: `${p}.capacity.nominal`, value: { units: "Ah", description: "Nominal battery pack capacity as configured in BMS" } },
37
+ { path: `${p}.capacity.timeRemaining`, value: { units: "s", description: "Estimated time to full discharge at current load" } },
38
+ { path: `${p}.timeToFull`, value: { units: "s", description: "Estimated time to full charge at current charge rate" } },
39
+ // Battery
40
+ { path: `${p}.batteryCycleCount`, value: { description: "Number of full charge/discharge cycles" } },
41
+ // Voltage thresholds
42
+ { path: `${p}.balEndVoltage`, value: { units: "V", description: "Cell voltage at which balancing ends" } },
43
+ { path: `${p}.balStartVoltage`, value: { units: "V", description: "Cell voltage at which balancing starts" } },
44
+ { path: `${p}.maxAllowedCellVoltage`, value: { units: "V", description: "Cell over-voltage switch-off threshold" } },
45
+ { path: `${p}.maxAllowedVoltageHysteresis`, value: { units: "V", description: "Over-voltage switch-off hysteresis per cell" } },
46
+ { path: `${p}.minAllowedCellVoltage`, value: { units: "V", description: "Cell under-voltage switch-off threshold" } },
47
+ { path: `${p}.minAllowedVoltageHysteresis`, value: { units: "V", description: "Under-voltage switch-off hysteresis per cell" } },
48
+ { path: `${p}.endChargeVoltage`, value: { units: "V", description: "Cell end-of-charge voltage" } },
49
+ { path: `${p}.endChargeHysteresis`, value: { units: "V", description: "End-of-charge voltage hysteresis per cell" } },
50
+ { path: `${p}.endOfChargeCellDifference`, value: { units: "V", description: "Maximum allowed cell voltage difference at end of charge" } },
51
+ { path: `${p}.floatVoltageCoefficient`, value: { description: "Float voltage coefficient (multiplier on charge voltage)" } },
52
+ { path: `${p}.maxAllowedCellVoltDiff`, value: { units: "V", description: "Maximum allowed cell voltage difference during operation" } },
53
+ // Temperature thresholds
54
+ { path: `${p}.cellOverTempSwitchOff`, value: { units: "K", description: "Cell over-temperature switch-off threshold" } },
55
+ { path: `${p}.underTempChargeDisable`, value: { units: "K", description: "Under-temperature charge disable threshold" } },
56
+ { path: `${p}.bmsOverTempSwitchOff`, value: { units: "K", description: "BMS board over-temperature switch-off threshold" } },
57
+ { path: `${p}.bmsOverTempSwitchOffHysteresis`, value: { units: "K", description: "BMS board over-temperature switch-off hysteresis" } },
58
+ // Current sensor
59
+ { path: `${p}.currentSensorOffset`, value: { units: "A", description: "Current measurement zero offset" } },
60
+ { path: `${p}.currentSensorCoefficient`, value: { units: "A/V", description: "Voltage-to-current conversion coefficient" } },
61
+ // SOC
62
+ { path: `${p}.socHysteresis`, value: { units: "ratio", description: "SOC end-of-charge hysteresis (0-1)" } },
63
+ { path: `${p}.socReset`, value: { units: "ratio", description: "SOC value applied on manual reset (0-1)" } },
64
+ // Outputs
65
+ { path: `${p}.relay1Voltage`, value: { units: "V", description: "Relay 1 activation voltage threshold" } },
66
+ { path: `${p}.relay1Hysteresis`, value: { units: "V", description: "Relay 1 voltage hysteresis" } },
67
+ { path: `${p}.opto2Voltage`, value: { units: "V", description: "Optocoupler 2 activation voltage threshold" } },
68
+ { path: `${p}.opto2Hysteresis`, value: { units: "V", description: "Optocoupler 2 voltage hysteresis" } },
69
+ // Victron/Wakespeed
70
+ { path: `${p}.chargeCoefficient`, value: { description: "Charge current coefficient (0-5C)" } },
71
+ { path: `${p}.dischargeCoefficient`, value: { description: "Discharge current coefficient (0-5C)" } },
72
+ { path: `${p}.numberOfInverterDevices`, value: { description: "Number of inverter/charger devices on the CAN bus" } },
73
+ { path: `${p}.maxChargeCurrent`, value: { units: "A", description: "Maximum charge current per inverter device" } },
74
+ { path: `${p}.maxDischargeCurrent`, value: { units: "A", description: "Maximum discharge current per inverter device" } },
75
+ { path: `${p}.minDischargeCellVoltage`, value: { units: "V", description: "Cell under-voltage discharge protection threshold" } },
76
+ // ABMS
77
+ { path: `${p}.deviceName`, value: { description: "BMS device identifier (serial number formatted as 1A-XXXX)" } },
78
+ { path: `${p}.abmsSoftwareVersion`, value: { description: "ABMS firmware software version" } },
79
+ { path: `${p}.abmsHardwareVersion`, value: { description: "ABMS hardware version" } },
80
+ { path: `${p}.time`, value: { description: "BMS real-time clock time (hh:mm:ss)" } },
81
+ { path: `${p}.date`, value: { description: "BMS real-time clock date (dd.mm.yyyy)" } },
82
+ { path: `${p}.balancingCellNumber`, value: { description: "Cell number currently being balanced" } },
83
+ { path: `${p}.calibrationOffsetCell1`, value: { units: "V", description: "Voltage calibration offset for cell 1" } },
84
+ { path: `${p}.calibrationOffsetCell2`, value: { units: "V", description: "Voltage calibration offset for cell 2" } },
85
+ { path: `${p}.calibrationOffsetCell3`, value: { units: "V", description: "Voltage calibration offset for cell 3" } },
86
+ { path: `${p}.calibrationOffsetCell4`, value: { units: "V", description: "Voltage calibration offset for cell 4" } },
87
+ // Error log
88
+ { path: `${p}.errorLogData`, value: { description: "Last error log entry (format: code,cell;hh:mm:ss;dd.mm.yyyy)" } },
89
+ { path: `${p}.errorLogDelete`, value: { description: "Error log delete flag (set to 1 to clear, then restart BMS)" } },
90
+ { path: `${p}.vmaxExceededCount`, value: { description: "Number of times cell over-voltage threshold was exceeded" } },
91
+ { path: `${p}.vminExceededCount`, value: { description: "Number of times cell under-voltage threshold was exceeded" } },
92
+ ];
93
+
94
+ app.handleMessage("signalk-rec-bms", {
95
+ updates: [{ meta }]
96
+ });
97
+ }
10
98
 
11
99
  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
-
100
+ if (historyBuffer) historyBuffer.record(delta);
101
+
25
102
  const precision = 5;
26
103
  const factor = Math.pow(10, precision);
27
-
104
+
28
105
  delta.updates.forEach(update => {
106
+ update.$source = "signalk-rec-bms";
29
107
  update.values.forEach(item => {
30
108
  if (typeof item.value === "number") {
31
109
  item.value = Math.round(item.value * factor) / factor;
32
110
  }
33
111
  });
34
112
  });
35
-
113
+
36
114
  if (delta && delta.updates && delta.updates.some(u => u.values.length > 0)) {
37
115
  app.debug(`[INDEX] Publishing delta: ${JSON.stringify(delta)}`);
38
116
  app.handleMessage("signalk-rec-bms", delta, "v1");
@@ -40,7 +118,7 @@ module.exports = function(app) {
40
118
  app.debug("[INDEX] Delta empty after processing, not publishing.");
41
119
  }
42
120
  }
43
-
121
+
44
122
  var plugin = {
45
123
  id: "signalk-rec-bms",
46
124
  name: "SignalK-REC-BMS",
@@ -50,18 +128,35 @@ module.exports = function(app) {
50
128
  pluginOptions = options;
51
129
  app.debug(`[INDEX] START invoked with options: ${JSON.stringify(options)}`);
52
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
+
145
+ publishMeta(options);
53
146
  conn = connection(options, app, publishDelta);
54
147
  conn.start(options);
55
148
  },
56
149
  stop: function() {
150
+ if (historyBuffer) historyBuffer.stop();
57
151
  if (conn) {
58
152
  conn.stop();
59
153
  conn = null;
60
154
  }
155
+ historyBuffer = null;
61
156
  app.debug("[INDEX] Plugin stopped");
62
157
  app.setPluginStatus("Stopped");
63
158
  },
64
159
  };
65
160
 
66
161
  return plugin;
67
- };
162
+ };
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
+ };
@@ -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
+ };
@@ -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
@@ -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
+ };