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.
- package/LICENSE +0 -0
- package/README.md +31 -9
- package/package.json +2 -2
- package/plugin/index.js +113 -18
- package/plugin/lib/connection.js +0 -0
- package/plugin/lib/history.js +163 -0
- package/plugin/lib/schema.js +6 -1
- 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 +2 -2
- package/plugin/lib/serial/modules/array.js +64 -50
- package/plugin/lib/serial/modules/bat.js +3 -1
- package/plugin/lib/serial/modules/cur.js +0 -0
- package/plugin/lib/serial/modules/derived.js +30 -21
- 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 +5 -5
- 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 +168 -111
- package/public/js/cells.js +212 -82
- package/public/js/chart.js +190 -0
- package/public/js/main.js +170 -132
- package/public/js/settings.js +384 -95
- 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,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
|
|
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
|
-
|
|
13
|
-
|
|
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
|
+
};
|
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
|
@@ -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}.
|
|
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
|
+
};
|