signalk-rec-bms 2.0.0 → 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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "signalk-rec-bms",
3
- "version": "2.0.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": "echo \"Error: no test specified\" && exit 1"
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": ">=10"
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 || "electrical.batteries.bms";
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 || "electrical.batteries.bms";
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
- // 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}`));
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
- let config = atlasMapping[tag];
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
- const packet = hexer.buildPacket(targetAddress, Buffer.from(commandStr));
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.expectResponse === false) {
43
- this.port.write(packet, (err) => {
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
- this.activeCommand = {
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
- const currentCommand = this.activeCommand;
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
- this.port.write(packet, (err) => {
63
- if (err) {
64
- this.activeCommand = null;
65
- return reject(err);
66
- }
67
- currentCommand.timeout = setTimeout(() => {
68
- const errMsg = `${tag} response timed out after ${timeoutMs} ms`;
69
- if (currentCommand && typeof currentCommand.reject === "function") {
70
- this.activeCommand = null;
71
- currentCommand.reject(new Error(errMsg));
72
- }
73
- }, timeoutMs);
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
- if (!this.activeCommand) {
121
+ const job = this.activeCommand;
122
+ if (!job || !job.expectResponse) {
80
123
  this.emit('unhandledPacket', packet);
81
124
  return;
82
125
  }
83
- this.activeCommand.receivedPackets.push(packet);
84
- if (this.activeCommand.receivedPackets.length === this.activeCommand.expectedPackets) {
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
- const packets = this.activeCommand.receivedPackets;
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
- module.exports = function(app, publishDelta) {
32
- let port, parser, engine;
33
- let derivedModule;
34
- let pollingInterval;
35
- let routeRegistered = false;
36
-
37
- function start(options) {
38
- app.debug("[SERIAL] start() called with options: " + JSON.stringify(options));
39
- port = new SerialPort({
40
- path: options.serial.device,
41
- baudRate: options.serial.baudRate,
42
- dataBits: 8,
43
- stopBits: 1,
44
- parity: 'none'
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
- engine = new CommandEngine(port);
48
- parser = new Layer2();
63
+ function startPolling() {
64
+ polling = true;
65
+ consecutiveFailures = 0;
66
+ let currentIndex = 0;
49
67
 
50
- port.on('data', (data) => parser.push(data));
51
- parser.on('data', (packet) => engine.processPacket(packet));
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
- port.on('open', () => {
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
- const parsed = abmsModule.parseSERIResponse(packets);
58
- if (parsed && parsed.data && parsed.data.abmsSerialNumber != null) {
59
- const sn = parsed.data.abmsSerialNumber;
60
- app.setPluginStatus(
61
- `Connected to BMS ${sn} - Connection Type: Serial`
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
- app.debug("[SERIAL] Failed to read serial number: " + err.message);
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
- derivedModule = derived(app, publishDelta, { deltaPrefix: options.deltaPrefix });
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
- 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
- }
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
- port.on('error', (err) => app.debug("[SERIAL] Error: " + err.message));
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
- clearInterval(pollingInterval);
152
- if (port) port.close();
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
- schema: require('../schema'),
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
  };
@@ -141,7 +141,7 @@ class BMSChart {
141
141
 
142
142
  async fetch() {
143
143
  try {
144
- const res = await fetch(`/signalk/v1/bms/history?hours=${this.activeHours}`);
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.prefix = 'electrical.batteries.bms.';
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
- const settings = new BMSSettings();
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: 'electrical.batteries.bms.*',
110
+ path: `${this.deltaPrefix}.*`,
89
111
  period: 1000
90
112
  }]
91
113
  }));
@@ -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 = 'electrical/batteries/bms';
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
- const response = await this._sendCommand(`${cmd} ${value}`);
450
- const ok = response && typeof response === 'object' && Object.keys(response).length > 0;
451
- input.value = ok ? 'OK' : 'FAIL';
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('/signalk/v1/bms/command', {
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
  }