signalk-rec-bms 2.0.2 → 2.1.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/package.json +3 -3
- package/plugin/index.js +51 -12
- package/plugin/lib/serial/engine.js +88 -34
- package/plugin/lib/serial/serial.js +194 -120
- package/public/js/chart.js +1 -1
- package/public/js/main.js +26 -4
- package/public/js/settings.js +18 -7
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-rec-bms",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "SignalK plugin for REC-BMS",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test test/parsers.test.js"
|
|
7
|
+
"test": "node --test test/parsers.test.js test/engine.test.js test/serial.test.js test/routes.test.js"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
10
10
|
"signalk-webapp",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"url": "https://github.com/ofernander/signalk-rec-bms"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
29
|
+
"node": ">=20"
|
|
30
30
|
},
|
|
31
31
|
"signalk": {
|
|
32
32
|
"appIcon": "./assets/icons/signalk-rec-bms-icon_72x72.png",
|
package/plugin/index.js
CHANGED
|
@@ -4,14 +4,15 @@ const schema = require('./lib/schema');
|
|
|
4
4
|
const connection = require('./lib/connection');
|
|
5
5
|
const HistoryBuffer = require('./lib/history');
|
|
6
6
|
|
|
7
|
+
const DEFAULT_PREFIX = "electrical.batteries.bms";
|
|
8
|
+
|
|
7
9
|
module.exports = function(app) {
|
|
8
10
|
let conn;
|
|
9
11
|
let pluginOptions;
|
|
10
12
|
let historyBuffer;
|
|
11
|
-
let historyRouteRegistered = false;
|
|
12
13
|
|
|
13
14
|
function publishMeta(options) {
|
|
14
|
-
const p = options.deltaPrefix ||
|
|
15
|
+
const p = options.deltaPrefix || DEFAULT_PREFIX;
|
|
15
16
|
|
|
16
17
|
const meta = [
|
|
17
18
|
// Live data
|
|
@@ -119,29 +120,67 @@ module.exports = function(app) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
// When server security is enabled every request reaching a plugin route is
|
|
124
|
+
// authenticated, but read-only users (including anonymous ones when
|
|
125
|
+
// allow_readonly is set) get through too. Changing BMS settings requires admin.
|
|
126
|
+
function isAdmin(req) {
|
|
127
|
+
const principal = req.skPrincipal;
|
|
128
|
+
return !principal || principal.permissions === 'admin';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Mounted by the server at /plugins/signalk-rec-bms/
|
|
132
|
+
function registerWithRouter(router) {
|
|
133
|
+
// Servers >= 2.33 require admin for plugin routes unless declared via
|
|
134
|
+
// router.access(); the read-only routes back the dashboard, so any logged
|
|
135
|
+
// in user may read them. /command is left undeclared (admin only).
|
|
136
|
+
const readonly = typeof router.access === 'function' ? router.access('readonly') : router;
|
|
137
|
+
|
|
138
|
+
readonly.get('/info', (req, res) => {
|
|
139
|
+
res.json({
|
|
140
|
+
running: !!conn,
|
|
141
|
+
deltaPrefix: (pluginOptions && pluginOptions.deltaPrefix) || DEFAULT_PREFIX,
|
|
142
|
+
connectionType: pluginOptions && pluginOptions.connection && pluginOptions.connection.connectionType
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
readonly.get('/history', (req, res) => {
|
|
147
|
+
if (!historyBuffer) return res.status(503).json({ error: "Buffer not ready" });
|
|
148
|
+
const hours = req.query.hours || 1;
|
|
149
|
+
res.json(historyBuffer.getHistory(hours));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
router.post('/command', (req, res) => {
|
|
153
|
+
const command = req.body && req.body.command;
|
|
154
|
+
if (!isAdmin(req)) {
|
|
155
|
+
return res.status(403).json({ error: "Admin permission required to send BMS commands", command });
|
|
156
|
+
}
|
|
157
|
+
if (!conn) {
|
|
158
|
+
return res.status(503).json({ error: "Plugin not running", command });
|
|
159
|
+
}
|
|
160
|
+
if (typeof conn.handleCommand !== 'function') {
|
|
161
|
+
return res.status(400).json({ error: "Commands are only supported on a serial connection", command });
|
|
162
|
+
}
|
|
163
|
+
conn.handleCommand(command)
|
|
164
|
+
.then(result => res.json(result))
|
|
165
|
+
.catch(err => res.status(err.statusCode || 500).json({ error: err.message, command }));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
122
169
|
var plugin = {
|
|
123
170
|
id: "signalk-rec-bms",
|
|
124
171
|
name: "SignalK-REC-BMS",
|
|
125
172
|
description: "SignalK plugin for REC-BMS",
|
|
126
173
|
schema: schema,
|
|
174
|
+
registerWithRouter,
|
|
127
175
|
start: function(options) {
|
|
128
176
|
pluginOptions = options;
|
|
129
177
|
app.debug(`[INDEX] START invoked with options: ${JSON.stringify(options)}`);
|
|
130
178
|
app.setPluginStatus("Connecting to BMS…");
|
|
131
179
|
|
|
132
|
-
const prefix = options.deltaPrefix ||
|
|
180
|
+
const prefix = options.deltaPrefix || DEFAULT_PREFIX;
|
|
133
181
|
const dataDir = typeof app.getDataDirPath === 'function' ? app.getDataDirPath() : null;
|
|
134
182
|
historyBuffer = new HistoryBuffer(prefix, dataDir);
|
|
135
183
|
|
|
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
184
|
publishMeta(options);
|
|
146
185
|
conn = connection(options, app, publishDelta);
|
|
147
186
|
conn.start(options);
|
|
@@ -7,24 +7,36 @@ atlas.forEach(entry => {
|
|
|
7
7
|
atlasMapping[entry.tag] = entry;
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
+
const DEFAULT_MAX_QUEUE = 20;
|
|
11
|
+
// After a write-only (set) command, hold the bus briefly so any reply the BMS
|
|
12
|
+
// sends is discarded rather than mistaken for the next command's response.
|
|
13
|
+
const DEFAULT_SETTLE_MS = 250;
|
|
14
|
+
|
|
15
|
+
// Serialises commands onto the RS-485 bus. Only one command is ever in flight;
|
|
16
|
+
// everything else waits in a FIFO queue. Priority commands (manual commands
|
|
17
|
+
// from the UI) jump to the front of the queue but never interrupt the command
|
|
18
|
+
// currently in flight.
|
|
10
19
|
class CommandEngine extends EventEmitter {
|
|
11
|
-
constructor(port) {
|
|
20
|
+
constructor(port, options = {}) {
|
|
12
21
|
super();
|
|
13
22
|
this.port = port;
|
|
23
|
+
this.maxQueue = options.maxQueue || DEFAULT_MAX_QUEUE;
|
|
24
|
+
this.settleMs = options.settleMs != null ? options.settleMs : DEFAULT_SETTLE_MS;
|
|
25
|
+
this.queue = [];
|
|
14
26
|
this.activeCommand = null;
|
|
27
|
+
this.closed = false;
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
sendCommand(tag, targetAddress, rawCommandStr = null, options = {}) {
|
|
18
31
|
return new Promise((resolve, reject) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return reject(new Error(`[ENGINE] Busy — command ${this.activeCommand.tag} in flight, dropping ${tag}`));
|
|
32
|
+
if (this.closed) {
|
|
33
|
+
return reject(new Error(`[ENGINE] Closed, dropping ${tag}`));
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
let commandStr;
|
|
25
37
|
let expectedPackets = 1;
|
|
26
38
|
let timeoutMs = 3000;
|
|
27
|
-
|
|
39
|
+
const config = atlasMapping[tag];
|
|
28
40
|
|
|
29
41
|
if (rawCommandStr) {
|
|
30
42
|
commandStr = rawCommandStr;
|
|
@@ -37,56 +49,98 @@ class CommandEngine extends EventEmitter {
|
|
|
37
49
|
timeoutMs = config.timeout;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
|
|
52
|
+
let packet;
|
|
53
|
+
try {
|
|
54
|
+
packet = hexer.buildPacket(targetAddress, Buffer.from(commandStr));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return reject(err);
|
|
57
|
+
}
|
|
41
58
|
|
|
42
|
-
if (options.
|
|
43
|
-
|
|
44
|
-
if (err) return reject(err);
|
|
45
|
-
resolve({ raw: true });
|
|
46
|
-
});
|
|
47
|
-
return;
|
|
59
|
+
if (!options.priority && this.queue.length >= this.maxQueue) {
|
|
60
|
+
return reject(new Error(`[ENGINE] Queue full, dropping ${tag}`));
|
|
48
61
|
}
|
|
49
62
|
|
|
50
|
-
|
|
63
|
+
const job = {
|
|
51
64
|
tag,
|
|
65
|
+
packet,
|
|
52
66
|
expectedPackets,
|
|
53
67
|
timeoutMs,
|
|
68
|
+
expectResponse: options.expectResponse !== false,
|
|
54
69
|
receivedPackets: [],
|
|
70
|
+
timeout: null,
|
|
55
71
|
resolve,
|
|
56
|
-
reject
|
|
57
|
-
config
|
|
72
|
+
reject
|
|
58
73
|
};
|
|
59
74
|
|
|
60
|
-
|
|
75
|
+
if (options.priority) {
|
|
76
|
+
this.queue.unshift(job);
|
|
77
|
+
} else {
|
|
78
|
+
this.queue.push(job);
|
|
79
|
+
}
|
|
80
|
+
this._next();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
61
83
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
_next() {
|
|
85
|
+
if (this.closed || this.activeCommand || this.queue.length === 0) return;
|
|
86
|
+
|
|
87
|
+
const job = this.queue.shift();
|
|
88
|
+
this.activeCommand = job;
|
|
89
|
+
|
|
90
|
+
this.port.write(job.packet, (err) => {
|
|
91
|
+
// The command may have been cancelled by close() while the write was pending
|
|
92
|
+
if (this.activeCommand !== job) return;
|
|
93
|
+
|
|
94
|
+
if (err) {
|
|
95
|
+
this._finish(job);
|
|
96
|
+
return job.reject(err);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!job.expectResponse) {
|
|
100
|
+
job.resolve({ raw: true });
|
|
101
|
+
job.timeout = setTimeout(() => this._finish(job), this.settleMs);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
job.timeout = setTimeout(() => {
|
|
106
|
+
if (this.activeCommand !== job) return;
|
|
107
|
+
this._finish(job);
|
|
108
|
+
job.reject(new Error(`${job.tag} response timed out after ${job.timeoutMs} ms`));
|
|
109
|
+
}, job.timeoutMs);
|
|
75
110
|
});
|
|
76
111
|
}
|
|
77
112
|
|
|
113
|
+
// Clear the active slot and move on to the next queued command
|
|
114
|
+
_finish(job) {
|
|
115
|
+
clearTimeout(job.timeout);
|
|
116
|
+
if (this.activeCommand === job) this.activeCommand = null;
|
|
117
|
+
setImmediate(() => this._next());
|
|
118
|
+
}
|
|
119
|
+
|
|
78
120
|
processPacket(packet) {
|
|
79
|
-
|
|
121
|
+
const job = this.activeCommand;
|
|
122
|
+
if (!job || !job.expectResponse) {
|
|
80
123
|
this.emit('unhandledPacket', packet);
|
|
81
124
|
return;
|
|
82
125
|
}
|
|
83
|
-
|
|
84
|
-
if (
|
|
126
|
+
job.receivedPackets.push(packet);
|
|
127
|
+
if (job.receivedPackets.length === job.expectedPackets) {
|
|
128
|
+
this._finish(job);
|
|
129
|
+
job.resolve(job.receivedPackets);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Reject everything in flight and queued; the engine cannot be reused afterwards
|
|
134
|
+
close() {
|
|
135
|
+
this.closed = true;
|
|
136
|
+
const err = new Error('[ENGINE] Closed');
|
|
137
|
+
const pending = this.queue.splice(0);
|
|
138
|
+
if (this.activeCommand) {
|
|
85
139
|
clearTimeout(this.activeCommand.timeout);
|
|
86
|
-
|
|
87
|
-
this.activeCommand.resolve(packets);
|
|
140
|
+
pending.unshift(this.activeCommand);
|
|
88
141
|
this.activeCommand = null;
|
|
89
142
|
}
|
|
143
|
+
pending.forEach(job => job.reject(err));
|
|
90
144
|
}
|
|
91
145
|
}
|
|
92
146
|
|
|
@@ -28,146 +28,220 @@ const moduleMapping = {
|
|
|
28
28
|
abms: abmsModule
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
// Minimum spacing between poll commands (start to start). Polls run one at a
|
|
32
|
+
// time, so a slow or timed-out command delays the next one instead of
|
|
33
|
+
// causing it to be skipped.
|
|
34
|
+
const POLL_SPACING_MS = 100;
|
|
35
|
+
const INITIAL_RECONNECT_MS = 5000;
|
|
36
|
+
const MAX_RECONNECT_MS = 60000;
|
|
37
|
+
// Consecutive failed polls before the port is considered open but the BMS silent
|
|
38
|
+
const NO_RESPONSE_THRESHOLD = 20;
|
|
39
|
+
|
|
40
|
+
const pollList = atlas.filter(entry => entry.module && moduleMapping[entry.module]);
|
|
41
|
+
|
|
42
|
+
// deps.SerialPort lets tests substitute a fake port
|
|
43
|
+
module.exports = function(app, publishDelta, deps = {}) {
|
|
44
|
+
const SerialPortClass = deps.SerialPort || SerialPort;
|
|
45
|
+
|
|
46
|
+
let options;
|
|
47
|
+
let port = null;
|
|
48
|
+
let engine = null;
|
|
49
|
+
let derivedModule = null;
|
|
50
|
+
let stopped = true;
|
|
51
|
+
let reconnectTimer = null;
|
|
52
|
+
let reconnectDelay = INITIAL_RECONNECT_MS;
|
|
53
|
+
let pollTimer = null;
|
|
54
|
+
let polling = false;
|
|
55
|
+
let consecutiveFailures = 0;
|
|
56
|
+
let connectedStatus = "Connected to BMS - Connection Type: Serial";
|
|
57
|
+
|
|
58
|
+
function setError(msg) {
|
|
59
|
+
if (typeof app.setPluginError === 'function') app.setPluginError(msg);
|
|
60
|
+
else app.setPluginStatus(msg + " - ERROR");
|
|
61
|
+
}
|
|
46
62
|
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
function startPolling() {
|
|
64
|
+
polling = true;
|
|
65
|
+
consecutiveFailures = 0;
|
|
66
|
+
let currentIndex = 0;
|
|
49
67
|
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
const pollNext = () => {
|
|
69
|
+
if (!polling) return;
|
|
70
|
+
const config = pollList[currentIndex];
|
|
71
|
+
currentIndex = (currentIndex + 1) % pollList.length;
|
|
72
|
+
const commandModule = moduleMapping[config.module];
|
|
73
|
+
const startedAt = Date.now();
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
app.setPluginStatus("Connected to BMS - Connection Type: Serial");
|
|
55
|
-
engine.sendCommand('SERI', options.serial.targetAddress)
|
|
75
|
+
engine.sendCommand(config.tag, options.serial.targetAddress)
|
|
56
76
|
.then(packets => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
77
|
+
if (consecutiveFailures >= NO_RESPONSE_THRESHOLD) app.setPluginStatus(connectedStatus);
|
|
78
|
+
consecutiveFailures = 0;
|
|
79
|
+
const parsed = commandModule[config.parser]?.(packets);
|
|
80
|
+
if (parsed) {
|
|
81
|
+
const moduleDelta = commandModule.getDelta(parsed, options, app);
|
|
82
|
+
if (moduleDelta) publishDelta(moduleDelta);
|
|
63
83
|
}
|
|
64
84
|
})
|
|
65
85
|
.catch(err => {
|
|
66
|
-
|
|
86
|
+
if (!polling) return;
|
|
87
|
+
app.debug("[SERIAL] " + config.tag + " error: " + err.message);
|
|
88
|
+
consecutiveFailures++;
|
|
89
|
+
if (consecutiveFailures === NO_RESPONSE_THRESHOLD) {
|
|
90
|
+
setError("Serial port open but no response from BMS - check wiring and 3V supply");
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
if (!polling) return;
|
|
95
|
+
const wait = Math.max(0, POLL_SPACING_MS - (Date.now() - startedAt));
|
|
96
|
+
pollTimer = setTimeout(pollNext, wait);
|
|
67
97
|
});
|
|
98
|
+
};
|
|
68
99
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const commandList = atlas.map(entry => entry.tag);
|
|
72
|
-
let currentIndex = 0;
|
|
73
|
-
|
|
74
|
-
pollingInterval = setInterval(() => {
|
|
75
|
-
const tag = commandList[currentIndex];
|
|
76
|
-
currentIndex = (currentIndex + 1) % commandList.length;
|
|
77
|
-
|
|
78
|
-
const config = atlas.find(entry => entry.tag === tag);
|
|
79
|
-
if (!config || !config.module || !moduleMapping[config.module]) return;
|
|
80
|
-
|
|
81
|
-
const commandModule = moduleMapping[config.module];
|
|
82
|
-
|
|
83
|
-
engine.sendCommand(tag, options.serial.targetAddress)
|
|
84
|
-
.then(packets => {
|
|
85
|
-
const parsed = commandModule[config.parser]?.(packets);
|
|
86
|
-
if (parsed) {
|
|
87
|
-
const moduleDelta = commandModule.getDelta(parsed, options, app);
|
|
88
|
-
if (moduleDelta) publishDelta(moduleDelta);
|
|
89
|
-
}
|
|
90
|
-
})
|
|
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
|
-
});
|
|
98
|
-
}, 100);
|
|
99
|
-
|
|
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" });
|
|
105
|
-
|
|
106
|
-
const parts = command.trim().split(/\s+/);
|
|
107
|
-
let tag = parts[0];
|
|
108
|
-
const raw = command.trim();
|
|
109
|
-
const isQuery = tag.endsWith("?");
|
|
110
|
-
|
|
111
|
-
if (isQuery) {
|
|
112
|
-
tag = tag.slice(0, -1);
|
|
113
|
-
}
|
|
100
|
+
pollNext();
|
|
101
|
+
}
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
103
|
+
function stopPolling() {
|
|
104
|
+
polling = false;
|
|
105
|
+
clearTimeout(pollTimer);
|
|
106
|
+
pollTimer = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readSerialNumber() {
|
|
110
|
+
engine.sendCommand('SERI', options.serial.targetAddress)
|
|
111
|
+
.then(packets => {
|
|
112
|
+
const parsed = abmsModule.parseSERIResponse(packets);
|
|
113
|
+
if (parsed && parsed.data && parsed.data.abmsSerialNumber != null) {
|
|
114
|
+
connectedStatus = `Connected to BMS ${parsed.data.abmsSerialNumber} - Connection Type: Serial`;
|
|
115
|
+
app.setPluginStatus(connectedStatus);
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
.catch(err => app.debug("[SERIAL] Failed to read serial number: " + err.message));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function openPort() {
|
|
122
|
+
app.debug(`[SERIAL] Opening ${options.serial.device}`);
|
|
123
|
+
const p = new SerialPortClass({
|
|
124
|
+
path: options.serial.device,
|
|
125
|
+
baudRate: options.serial.baudRate,
|
|
126
|
+
dataBits: 8,
|
|
127
|
+
stopBits: 1,
|
|
128
|
+
parity: 'none',
|
|
129
|
+
autoOpen: false
|
|
130
|
+
});
|
|
131
|
+
const eng = new CommandEngine(p);
|
|
132
|
+
const parser = new Layer2();
|
|
133
|
+
port = p;
|
|
134
|
+
engine = eng;
|
|
135
|
+
|
|
136
|
+
p.on('data', (data) => parser.push(data));
|
|
137
|
+
parser.on('data', (packet) => eng.processPacket(packet));
|
|
138
|
+
|
|
139
|
+
p.on('open', () => {
|
|
140
|
+
if (port !== p) return;
|
|
141
|
+
app.debug("[SERIAL] Port open");
|
|
142
|
+
reconnectDelay = INITIAL_RECONNECT_MS;
|
|
143
|
+
app.setPluginStatus(connectedStatus);
|
|
144
|
+
readSerialNumber();
|
|
145
|
+
startPolling();
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
// Fires on USB unplug (err.disconnected) as well as on a normal close
|
|
149
|
+
p.on('close', (err) => {
|
|
150
|
+
if (port !== p) return;
|
|
151
|
+
handleDisconnect(err ? err.message : "port closed");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
p.on('error', (err) => app.debug("[SERIAL] Error: " + err.message));
|
|
155
|
+
|
|
156
|
+
p.open((err) => {
|
|
157
|
+
if (err && port === p) handleDisconnect(err.message);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function teardownPort() {
|
|
162
|
+
stopPolling();
|
|
163
|
+
if (engine) engine.close();
|
|
164
|
+
const p = port;
|
|
165
|
+
port = null;
|
|
166
|
+
engine = null;
|
|
167
|
+
if (p) {
|
|
168
|
+
p.removeAllListeners();
|
|
169
|
+
p.on('error', () => {}); // a late error with no listener would throw
|
|
170
|
+
if (p.isOpen) p.close(() => {});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleDisconnect(reason) {
|
|
175
|
+
teardownPort();
|
|
176
|
+
if (stopped || reconnectTimer) return;
|
|
177
|
+
const delay = reconnectDelay;
|
|
178
|
+
setError(`BMS serial disconnected (${reason}) - retrying in ${Math.round(delay / 1000)}s`);
|
|
179
|
+
app.debug(`[SERIAL] Disconnected: ${reason}. Reconnecting in ${delay} ms`);
|
|
180
|
+
reconnectTimer = setTimeout(() => {
|
|
181
|
+
reconnectTimer = null;
|
|
182
|
+
if (!stopped) openPort();
|
|
183
|
+
}, delay);
|
|
184
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_MS);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function start(opts) {
|
|
188
|
+
app.debug("[SERIAL] start() called with options: " + JSON.stringify(opts));
|
|
189
|
+
options = opts;
|
|
190
|
+
stopped = false;
|
|
191
|
+
reconnectDelay = INITIAL_RECONNECT_MS;
|
|
192
|
+
derivedModule = derived(app, publishDelta, { deltaPrefix: options.deltaPrefix });
|
|
193
|
+
openPort();
|
|
148
194
|
}
|
|
149
195
|
|
|
150
196
|
function stop() {
|
|
151
|
-
|
|
152
|
-
|
|
197
|
+
stopped = true;
|
|
198
|
+
clearTimeout(reconnectTimer);
|
|
199
|
+
reconnectTimer = null;
|
|
200
|
+
teardownPort();
|
|
153
201
|
if (derivedModule && typeof derivedModule.stop === 'function') derivedModule.stop();
|
|
202
|
+
derivedModule = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Run a manual command from the UI, e.g. "CHAR?" (query) or "CHAR 3.55" (set).
|
|
206
|
+
// Resolves to a JSON-serialisable result; rejects with err.statusCode set for
|
|
207
|
+
// client errors.
|
|
208
|
+
function handleCommand(command) {
|
|
209
|
+
const clientError = (msg, statusCode = 400) => Object.assign(new Error(msg), { statusCode });
|
|
210
|
+
|
|
211
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
212
|
+
return Promise.reject(clientError("Missing command"));
|
|
213
|
+
}
|
|
214
|
+
if (!engine) {
|
|
215
|
+
return Promise.reject(clientError("BMS serial port not connected", 503));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const raw = command.trim();
|
|
219
|
+
let tag = raw.split(/\s+/)[0];
|
|
220
|
+
const isQuery = tag.endsWith("?");
|
|
221
|
+
if (isQuery) tag = tag.slice(0, -1);
|
|
222
|
+
|
|
223
|
+
const config = atlas.find(entry => entry.tag === tag);
|
|
224
|
+
if (!config || !config.module || !moduleMapping[config.module]) {
|
|
225
|
+
return Promise.reject(clientError("Unknown or unsupported command"));
|
|
226
|
+
}
|
|
227
|
+
const commandModule = moduleMapping[config.module];
|
|
228
|
+
|
|
229
|
+
return engine.sendCommand(tag, options.serial.targetAddress, raw, { expectResponse: isQuery, priority: true })
|
|
230
|
+
.then(packets => {
|
|
231
|
+
if (!isQuery) {
|
|
232
|
+
return { command, response: { status: "sent (no response expected)" } };
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
command,
|
|
236
|
+
response: commandModule[config.parser]?.(packets),
|
|
237
|
+
rawPackets: packets.map(p => p.toString('hex'))
|
|
238
|
+
};
|
|
239
|
+
});
|
|
154
240
|
}
|
|
155
241
|
|
|
156
242
|
return {
|
|
157
243
|
start,
|
|
158
244
|
stop,
|
|
159
|
-
|
|
160
|
-
sendManualCommand: (tag, targetAddress) => {
|
|
161
|
-
const config = atlas.find(entry => entry.tag === tag);
|
|
162
|
-
if (!config || !config.module || !moduleMapping[config.module]) {
|
|
163
|
-
throw new Error("Unknown or unsupported command");
|
|
164
|
-
}
|
|
165
|
-
const commandModule = moduleMapping[config.module];
|
|
166
|
-
return engine.sendCommand(tag, targetAddress, null, { force: true })
|
|
167
|
-
.then(packets => ({
|
|
168
|
-
packets,
|
|
169
|
-
parsed: commandModule[config.parser]?.(packets)
|
|
170
|
-
}));
|
|
171
|
-
}
|
|
245
|
+
handleCommand
|
|
172
246
|
};
|
|
173
247
|
};
|
package/public/js/chart.js
CHANGED
|
@@ -141,7 +141,7 @@ class BMSChart {
|
|
|
141
141
|
|
|
142
142
|
async fetch() {
|
|
143
143
|
try {
|
|
144
|
-
const res = await fetch(
|
|
144
|
+
const res = await fetch(`${PLUGIN_API}/history?hours=${this.activeHours}`);
|
|
145
145
|
if (!res.ok) return;
|
|
146
146
|
const json = await res.json();
|
|
147
147
|
this._update(json);
|
package/public/js/main.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const PLUGIN_API = '/plugins/signalk-rec-bms';
|
|
4
|
+
const DEFAULT_PREFIX = 'electrical.batteries.bms';
|
|
5
|
+
|
|
3
6
|
class BMSMonitor {
|
|
4
7
|
constructor() {
|
|
5
|
-
this.
|
|
8
|
+
this.deltaPrefix = DEFAULT_PREFIX;
|
|
9
|
+
this.prefix = DEFAULT_PREFIX + '.';
|
|
6
10
|
this.ws = null;
|
|
7
11
|
this.reconnectDelay = 5000;
|
|
8
12
|
this.reconnectTimer = null;
|
|
@@ -31,16 +35,34 @@ class BMSMonitor {
|
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
init() {
|
|
38
|
+
async init() {
|
|
35
39
|
this._initNightMode();
|
|
36
40
|
this._initTabs();
|
|
37
41
|
CellVisualizer.init('cell-towers');
|
|
38
42
|
BMSChartManager.init();
|
|
39
|
-
|
|
43
|
+
|
|
44
|
+
this.deltaPrefix = await this._fetchDeltaPrefix();
|
|
45
|
+
this.prefix = this.deltaPrefix + '.';
|
|
46
|
+
|
|
47
|
+
const settings = new BMSSettings(this.deltaPrefix);
|
|
40
48
|
settings.init();
|
|
41
49
|
this._connect();
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
// The plugin's deltaPrefix setting decides which paths we read
|
|
53
|
+
async _fetchDeltaPrefix() {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${PLUGIN_API}/info`);
|
|
56
|
+
if (res.ok) {
|
|
57
|
+
const info = await res.json();
|
|
58
|
+
if (info.deltaPrefix) return info.deltaPrefix;
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.warn('[BMS] Could not read plugin info, using default path prefix', e);
|
|
62
|
+
}
|
|
63
|
+
return DEFAULT_PREFIX;
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
// ---- Night mode ----
|
|
45
67
|
_initNightMode() {
|
|
46
68
|
const toggle = document.getElementById('nightModeToggle');
|
|
@@ -85,7 +107,7 @@ class BMSMonitor {
|
|
|
85
107
|
this.ws.send(JSON.stringify({
|
|
86
108
|
context: 'vessels.self',
|
|
87
109
|
subscribe: [{
|
|
88
|
-
path:
|
|
110
|
+
path: `${this.deltaPrefix}.*`,
|
|
89
111
|
period: 1000
|
|
90
112
|
}]
|
|
91
113
|
}));
|
package/public/js/settings.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
class BMSSettings {
|
|
4
|
-
constructor() {
|
|
4
|
+
constructor(deltaPrefix = 'electrical.batteries.bms') {
|
|
5
5
|
this.tempCommands = new Set(['TMAX', 'TMIN', 'TBAL', 'BMTH']);
|
|
6
|
-
this.prefix = '
|
|
6
|
+
this.prefix = deltaPrefix.replace(/\./g, '/');
|
|
7
7
|
|
|
8
8
|
this.commandGroups = [
|
|
9
9
|
{ title: "Voltage Settings", commands: ["BVOL","BMIN","CMAX","MAXH","CMIN","MINH","CHAR","CHIS","UBDI","CFVC","RAZL"] },
|
|
@@ -446,9 +446,19 @@ class BMSSettings {
|
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
btn.disabled = true;
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
449
|
+
try {
|
|
450
|
+
const response = await this._sendCommand(`${cmd} ${value}`);
|
|
451
|
+
const ok = response && typeof response === 'object' && Object.keys(response).length > 0;
|
|
452
|
+
input.value = ok ? 'OK' : 'FAIL';
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error(`[SETTINGS] ${cmd} failed:`, err);
|
|
455
|
+
input.value = 'FAIL';
|
|
456
|
+
input.classList.add('input-error');
|
|
457
|
+
const msg = document.createElement('div');
|
|
458
|
+
msg.className = 'input-error-msg';
|
|
459
|
+
msg.textContent = err.message;
|
|
460
|
+
input.parentElement.appendChild(msg);
|
|
461
|
+
}
|
|
452
462
|
btn.disabled = false;
|
|
453
463
|
|
|
454
464
|
setTimeout(() => this.prefillValues(), 5000);
|
|
@@ -509,12 +519,13 @@ class BMSSettings {
|
|
|
509
519
|
}
|
|
510
520
|
|
|
511
521
|
async _sendCommand(command) {
|
|
512
|
-
const res = await fetch(
|
|
522
|
+
const res = await fetch(`${PLUGIN_API}/command`, {
|
|
513
523
|
method: 'POST',
|
|
514
524
|
headers: { 'Content-Type': 'application/json' },
|
|
515
525
|
body: JSON.stringify({ command })
|
|
516
526
|
});
|
|
517
|
-
const result = await res.json();
|
|
527
|
+
const result = await res.json().catch(() => ({}));
|
|
528
|
+
if (!res.ok) throw new Error(result.error || `HTTP ${res.status}`);
|
|
518
529
|
return result.response;
|
|
519
530
|
}
|
|
520
531
|
}
|