@rhizomatics/signalk-bluetti-plugin 1.0.8-alpha → 1.1.0-alpha

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/index.js CHANGED
@@ -40,8 +40,8 @@ function builtinModelNames() {
40
40
  module.exports = function (app) {
41
41
  const log = (msg) => app.debug(msg);
42
42
 
43
- let scanner = null;
44
- let activeDevices = [];
43
+ let scanner = null;
44
+ let activeDevices = [];
45
45
  let scanResultCache = [];
46
46
 
47
47
  const builtins = builtinModelNames();
@@ -185,17 +185,16 @@ module.exports = function (app) {
185
185
  const pending = new Map(devices.map(cfg => [normalise(cfg.address), cfg]));
186
186
  const deps = { BluettiDevice, loadCsv, buildDelta, readEncryptionKey };
187
187
 
188
- scanner.on('discovered', ({ address, name, peripheral }) => {
188
+ scanner.on('discovered', ({ address, name, device: bleDevice }) => {
189
189
  scanResultCache.push({ address, name });
190
190
  const cfg = pending.get(normalise(address));
191
191
  if (cfg) {
192
192
  pending.delete(normalise(address));
193
193
  app.setPluginStatus(`Found ${name} [${address}] — connecting …`);
194
- // Noble cannot scan and connect simultaneously stop scan first.
195
- // A short delay lets the BlueZ adapter finish scanning before we connect.
196
- // The scanComplete handler will restart scanning for any remaining pending devices.
194
+ // Stop our discovery session before connecting (keeps the poll loop quiet).
195
+ // BlueZ handles scan+connect coexistence at the adapter level.
197
196
  scanner.stopScan();
198
- setTimeout(() => startDevice(cfg, peripheral, name, deps), 500);
197
+ startDevice(cfg, bleDevice, name, deps);
199
198
  } else {
200
199
  log(`Discovered unconfigured Bluetti device: ${name} [${address}]`);
201
200
  }
@@ -257,7 +256,7 @@ module.exports = function (app) {
257
256
  return path.join(REGISTERS_DIR, `${builtinModel}.csv`);
258
257
  }
259
258
 
260
- function startDevice(cfg, peripheral, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
259
+ function startDevice(cfg, bleDevice, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
261
260
  const { address, name, encryptionCsvPath = '', pollIntervalSeconds = 10 } = cfg;
262
261
 
263
262
  let registerPath;
@@ -300,7 +299,7 @@ module.exports = function (app) {
300
299
  const device = new BluettiDevice({
301
300
  address,
302
301
  name,
303
- peripheral,
302
+ device: bleDevice,
304
303
  fields,
305
304
  pollIntervalMs: pollIntervalSeconds * 1000,
306
305
  xorKey,
package/lib/device.js CHANGED
@@ -3,40 +3,40 @@
3
3
  const EventEmitter = require('events');
4
4
  const { buildReadRequest, completeFrameLength, parseReadResponse, applyXor, groupRegisters } = require('./protocol');
5
5
 
6
- // UUIDs used by most Bluetti devices (short form — noble strips dashes and lowercases)
7
- const SERVICE_UUID = 'ff00';
8
- const NOTIFY_UUID = 'ff01';
9
- const WRITE_UUID = 'ff02';
6
+ // UUIDs used by most Bluetti devices
7
+ const SERVICE_UUID = 'ff00';
8
+ const NOTIFY_UUID = 'ff01';
9
+ const WRITE_UUID = 'ff02';
10
10
 
11
11
  // Fallback UUIDs seen on some older/alternate firmwares
12
12
  const ALT_SERVICE_UUID = 'ffe0';
13
13
  const ALT_CHAR_UUID = 'ffe1';
14
14
 
15
- const RECONNECT_DELAYS = [5000, 10000, 20000, 30000, 60000]; // ms, capped at last value
16
- const CONNECT_TIMEOUT_MS = 15000;
15
+ const RECONNECT_DELAYS = [5000, 10000, 20000, 30000, 60000]; // ms, capped at last value
16
+ const CONNECT_TIMEOUT_MS = 30000;
17
17
 
18
18
  class BluettiDevice extends EventEmitter {
19
- constructor({ address, name, peripheral, fields, pollIntervalMs = 10000, xorKey = null, log }) {
19
+ constructor({ address, name, device, fields, pollIntervalMs = 10000, xorKey = null, log }) {
20
20
  super();
21
- this._address = address;
22
- this._name = name;
23
- this._peripheral = peripheral;
24
- this._fields = fields;
21
+ this._address = address;
22
+ this._name = name;
23
+ this._device = device; // node-ble Device (D-Bus backed, persists across disconnects)
24
+ this._fields = fields;
25
25
  this._pollIntervalMs = pollIntervalMs;
26
- this._xorKey = xorKey ? Buffer.from(xorKey, 'hex') : null;
27
- this._log = log;
28
-
29
- this._connected = false;
30
- this._notifyChar = null;
31
- this._writeChar = null;
32
- this._rxBuf = Buffer.alloc(0);
33
- this._currentBatch = null; // { start, count } being awaited
34
- this._batchQueue = [];
35
- this._pollTimer = null;
36
- this._reconnectAttempt = 0;
37
- this._stopped = false;
26
+ this._xorKey = xorKey ? Buffer.from(xorKey, 'hex') : null;
27
+ this._log = log;
28
+
29
+ this._connected = false;
30
+ this._notifyChar = null;
31
+ this._writeChar = null;
32
+ this._rxBuf = Buffer.alloc(0);
33
+ this._currentBatch = null; // { start, count } being awaited
34
+ this._batchQueue = [];
35
+ this._pollTimer = null;
36
+ this._reconnectAttempt = 0;
37
+ this._stopped = false;
38
+ this._disconnectHandler = null; // stored so we can remove it on reconnect
38
39
 
39
- // Pre-compute register poll batches from field list
40
40
  const addrs = [...new Set(fields.flatMap(f => Array.from({ length: f.count }, (_, i) => f.register + i)))];
41
41
  this._batches = groupRegisters(addrs);
42
42
  this._log(`[${name}] Poll plan: ${this._batches.length} batch(es) covering ${addrs.length} registers`);
@@ -50,17 +50,27 @@ class BluettiDevice extends EventEmitter {
50
50
  stop() {
51
51
  this._stopped = true;
52
52
  this._stopPolling();
53
- if (this._peripheral && this._connected) {
54
- try { this._peripheral.disconnect(); } catch (_) {}
53
+ if (this._disconnectHandler) {
54
+ this._device.removeListener('disconnect', this._disconnectHandler);
55
+ this._disconnectHandler = null;
56
+ }
57
+ if (this._connected) {
58
+ this._device.disconnect().catch(() => {});
55
59
  }
56
60
  }
57
61
 
58
62
  // ── Connection lifecycle ─────────────────────────────────────────────────
59
63
 
60
- _connect() {
64
+ async _connect() {
61
65
  if (this._stopped) return;
62
66
  this._log(`[${this._name}] Connecting to ${this._address} …`);
63
67
 
68
+ // Clear any stale disconnect handler from a previous attempt.
69
+ if (this._disconnectHandler) {
70
+ this._device.removeListener('disconnect', this._disconnectHandler);
71
+ this._disconnectHandler = null;
72
+ }
73
+
64
74
  let settled = false;
65
75
  const connectTimer = setTimeout(() => {
66
76
  if (settled) return;
@@ -69,66 +79,94 @@ class BluettiDevice extends EventEmitter {
69
79
  this._scheduleReconnect();
70
80
  }, CONNECT_TIMEOUT_MS);
71
81
 
72
- this._peripheral.connect((err) => {
82
+ try {
83
+ await this._device.connect();
84
+ } catch (err) {
73
85
  if (settled) return;
74
86
  settled = true;
75
87
  clearTimeout(connectTimer);
76
- if (err) {
77
- this._log(`[${this._name}] Connect error: ${err.message}`);
78
- this._scheduleReconnect();
79
- return;
80
- }
81
- this._log(`[${this._name}] Connected`);
82
- this._reconnectAttempt = 0;
83
- this._discoverServices();
84
- });
88
+ this._log(`[${this._name}] Connect error: ${err.message}`);
89
+ this._scheduleReconnect();
90
+ return;
91
+ }
92
+
93
+ if (settled) return; // timed out while awaiting
94
+ settled = true;
95
+ clearTimeout(connectTimer);
85
96
 
86
- this._peripheral.once('disconnect', () => {
97
+ this._log(`[${this._name}] Connected`);
98
+ this._reconnectAttempt = 0;
99
+
100
+ // Register disconnect handler — stored so it can be removed on reconnect.
101
+ this._disconnectHandler = () => {
87
102
  if (this._stopped) return;
88
- this._connected = false;
89
- this._notifyChar = null;
90
- this._writeChar = null;
103
+ this._connected = false;
104
+ this._notifyChar = null;
105
+ this._writeChar = null;
106
+ this._disconnectHandler = null;
91
107
  this._log(`[${this._name}] Disconnected`);
92
108
  this._stopPolling();
93
109
  this._scheduleReconnect();
94
- });
110
+ };
111
+ this._device.once('disconnect', this._disconnectHandler);
112
+
113
+ await this._discoverServices();
95
114
  }
96
115
 
97
- _discoverServices() {
98
- this._peripheral.discoverAllServicesAndCharacteristics((err, _services, chars) => {
99
- if (err) {
100
- this._log(`[${this._name}] Service discovery error: ${err.message}`);
101
- this._scheduleReconnect();
102
- return;
103
- }
104
-
105
- const notifyChar = chars.find(c => c.uuid === NOTIFY_UUID) ||
106
- chars.find(c => c.uuid === ALT_CHAR_UUID);
107
- const writeChar = chars.find(c => c.uuid === WRITE_UUID) ||
108
- chars.find(c => c.uuid === ALT_CHAR_UUID);
109
-
110
- if (!notifyChar || !writeChar) {
111
- const uuids = chars.map(c => c.uuid).join(', ');
112
- this._log(`[${this._name}] Could not find required characteristics. Found: ${uuids}`);
113
- this._scheduleReconnect();
114
- return;
115
- }
116
-
117
- this._notifyChar = notifyChar;
118
- this._writeChar = writeChar;
119
-
120
- notifyChar.subscribe((err) => {
121
- if (err) {
122
- this._log(`[${this._name}] Subscribe error: ${err.message}`);
123
- this._scheduleReconnect();
124
- return;
125
- }
126
- notifyChar.on('data', (data) => this._onData(data));
127
- this._connected = true;
128
- this.emit('connected');
129
- this._startPolling();
130
- });
131
- });
116
+ async _discoverServices() {
117
+ let gatt;
118
+ try {
119
+ gatt = await this._device.gatt();
120
+ } catch (err) {
121
+ this._log(`[${this._name}] GATT error: ${err.message}`);
122
+ this._scheduleReconnect();
123
+ return;
124
+ }
125
+
126
+ let service;
127
+ for (const uuid of [SERVICE_UUID, ALT_SERVICE_UUID]) {
128
+ try {
129
+ service = await gatt.getPrimaryService(uuid);
130
+ break;
131
+ } catch (_) {}
132
+ }
133
+ if (!service) {
134
+ this._log(`[${this._name}] Could not find BLE service (tried ${SERVICE_UUID}, ${ALT_SERVICE_UUID})`);
135
+ this._scheduleReconnect();
136
+ return;
137
+ }
138
+
139
+ let notifyChar;
140
+ for (const uuid of [NOTIFY_UUID, ALT_CHAR_UUID]) {
141
+ try { notifyChar = await service.getCharacteristic(uuid); break; } catch (_) {}
142
+ }
143
+
144
+ let writeChar;
145
+ for (const uuid of [WRITE_UUID, ALT_CHAR_UUID]) {
146
+ try { writeChar = await service.getCharacteristic(uuid); break; } catch (_) {}
147
+ }
148
+
149
+ if (!notifyChar || !writeChar) {
150
+ this._log(`[${this._name}] Could not find required GATT characteristics`);
151
+ this._scheduleReconnect();
152
+ return;
153
+ }
154
+
155
+ try {
156
+ await notifyChar.startNotifications();
157
+ } catch (err) {
158
+ this._log(`[${this._name}] Subscribe error: ${err.message}`);
159
+ this._scheduleReconnect();
160
+ return;
161
+ }
162
+
163
+ notifyChar.on('valuechanged', (data) => this._onData(data));
164
+
165
+ this._notifyChar = notifyChar;
166
+ this._writeChar = writeChar;
167
+ this._connected = true;
168
+ this.emit('connected');
169
+ this._startPolling();
132
170
  }
133
171
 
134
172
  _scheduleReconnect() {
@@ -136,6 +174,8 @@ class BluettiDevice extends EventEmitter {
136
174
  const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
137
175
  this._reconnectAttempt++;
138
176
  this._log(`[${this._name}] Reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`);
177
+ // The node-ble Device object is D-Bus backed and persists across disconnects —
178
+ // no need to rescan for a fresh peripheral; just call connect() again.
139
179
  setTimeout(() => {
140
180
  if (!this._stopped) this._connect();
141
181
  }, delay);
@@ -154,13 +194,12 @@ class BluettiDevice extends EventEmitter {
154
194
  clearInterval(this._pollTimer);
155
195
  this._pollTimer = null;
156
196
  }
157
- this._batchQueue = [];
197
+ this._batchQueue = [];
158
198
  this._currentBatch = null;
159
199
  }
160
200
 
161
201
  _poll() {
162
202
  if (!this._connected || this._batches.length === 0) return;
163
- // Queue all batches; they'll be sent one at a time as responses arrive
164
203
  this._batchQueue = [...this._batches];
165
204
  this._rxBuf = Buffer.alloc(0);
166
205
  this._sendNextBatch();
@@ -173,20 +212,19 @@ class BluettiDevice extends EventEmitter {
173
212
  let frame = buildReadRequest(start, count);
174
213
  if (this._xorKey) frame = applyXor(frame, this._xorKey);
175
214
  this._rxBuf = Buffer.alloc(0);
176
- this._writeChar.write(frame, false, (err) => {
177
- if (err) this._log(`[${this._name}] Write error: ${err.message}`);
215
+ this._writeChar.writeValueWithoutResponse(frame).catch((err) => {
216
+ this._log(`[${this._name}] Write error: ${err.message}`);
178
217
  });
179
218
  }
180
219
 
181
220
  // ── Receive ──────────────────────────────────────────────────────────────
182
221
 
183
222
  _onData(data) {
184
- // Undo XOR on incoming data if encryption is in use
185
223
  const chunk = this._xorKey ? applyXor(data, this._xorKey) : data;
186
224
  this._rxBuf = Buffer.concat([this._rxBuf, chunk]);
187
225
 
188
226
  const frameLen = completeFrameLength(this._rxBuf);
189
- if (frameLen === 0) return; // waiting for more BLE packets
227
+ if (frameLen === 0) return; // waiting for more BLE packets
190
228
 
191
229
  const frame = this._rxBuf.slice(0, frameLen);
192
230
  this._rxBuf = this._rxBuf.slice(frameLen);
@@ -199,7 +237,6 @@ class BluettiDevice extends EventEmitter {
199
237
  this._log(`[${this._name}] CRC error or unexpected frame, skipping batch`);
200
238
  }
201
239
 
202
- // Move to next batch
203
240
  this._sendNextBatch();
204
241
  }
205
242
  }
package/lib/scanner.js CHANGED
@@ -2,105 +2,107 @@
2
2
 
3
3
  const EventEmitter = require('events');
4
4
 
5
- // Bluetti devices advertise with names matching these prefixes.
6
- // BT-TH-XXXXXXXXXX is the most common; some models use BLUETTI or model names directly.
7
5
  const BLUETTI_NAME_PREFIXES = ['BT-TH-', 'BLUETTI', 'AC', 'EP', 'EB', 'EL'];
8
6
 
9
- function isBluettiDevice(peripheral) {
10
- const name = (peripheral.advertisement && peripheral.advertisement.localName) || '';
7
+ function isBluettiDevice(name) {
8
+ if (!name) return false;
11
9
  return BLUETTI_NAME_PREFIXES.some(p => name.toUpperCase().startsWith(p.toUpperCase()));
12
10
  }
13
11
 
14
12
  class Scanner extends EventEmitter {
15
13
  constructor(log) {
16
14
  super();
17
- this._log = log;
18
- this._noble = null;
19
- this._scanning = false;
20
- this._found = new Map(); // address → peripheral
15
+ this._log = log;
16
+ this._bluetooth = null;
17
+ this._destroy = null;
18
+ this._adapter = null;
19
+ this._scanning = false;
20
+ this._scanTimer = null;
21
+ this._pollTimer = null;
22
+ this._found = new Map(); // normalised address → { address, name, device }
21
23
  }
22
24
 
23
- // Returns a promise that resolves once noble is powered on.
24
- _initNoble() {
25
- if (this._noble) return Promise.resolve();
26
-
27
- return new Promise((resolve, reject) => {
28
- let noble;
29
- try {
30
- noble = require('@stoprocent/noble');
31
- } catch (e) {
32
- return reject(new Error('@stoprocent/noble not installed — run: npm install'));
33
- }
34
-
35
- this._noble = noble;
36
-
37
- noble.on('discover', (peripheral) => {
38
- if (!isBluettiDevice(peripheral)) return;
39
- const addr = peripheral.address || peripheral.id;
40
- const name = (peripheral.advertisement.localName || 'unknown').trim();
41
- if (!this._found.has(addr)) {
42
- this._found.set(addr, peripheral);
43
- this._log(`Discovered Bluetti device: ${name} [${addr}]`);
44
- this.emit('discovered', { address: addr, name, peripheral });
45
- }
46
- });
47
-
48
- noble.on('stateChange', (state) => {
49
- this._log(`BLE adapter state: ${state}`);
50
- if (state === 'poweredOn') {
51
- resolve();
52
- } else if (state === 'poweredOff' || state === 'unsupported') {
53
- reject(new Error(`BLE adapter state: ${state}`));
54
- }
55
- });
56
-
57
- // Already powered on (noble initialised before our listener)
58
- if (noble.state === 'poweredOn') resolve();
59
- });
25
+ async _init() {
26
+ if (this._adapter) return;
27
+ const { createBluetooth } = require('@naugehyde/node-ble');
28
+ const { bluetooth, destroy } = createBluetooth();
29
+ this._bluetooth = bluetooth;
30
+ this._destroy = destroy;
31
+ this._adapter = await bluetooth.defaultAdapter();
60
32
  }
61
33
 
62
34
  async startScan(durationMs = 15000) {
63
35
  if (this._scanning) return;
64
- await this._initNoble();
36
+
37
+ try {
38
+ await this._init();
39
+ } catch (err) {
40
+ this._log(`BLE adapter init failed: ${err.message}`);
41
+ this.emit('error', err);
42
+ return;
43
+ }
44
+
65
45
  this._found.clear();
66
46
  this._scanning = true;
67
47
  this._log(`Scanning for Bluetti devices for ${durationMs / 1000}s …`);
68
- this._noble.startScanning([], false);
48
+
49
+ try {
50
+ if (!await this._adapter.isDiscovering()) {
51
+ await this._adapter.startDiscovery();
52
+ }
53
+ } catch (err) {
54
+ this._log(`Failed to start BLE discovery: ${err.message}`);
55
+ this._scanning = false;
56
+ return;
57
+ }
58
+
69
59
  this._scanTimer = setTimeout(() => this.stopScan(), durationMs);
60
+ this._pollTimer = setInterval(() => this._poll(), 1000);
61
+ this._poll(); // check immediately for already-known devices
62
+ }
63
+
64
+ async _poll() {
65
+ if (!this._adapter || !this._scanning) return;
66
+ let addresses;
67
+ try {
68
+ addresses = await this._adapter.devices();
69
+ } catch (_) {
70
+ return;
71
+ }
72
+ for (const addr of addresses) {
73
+ const normAddr = addr.toLowerCase().replace(/:/g, '');
74
+ if (this._found.has(normAddr)) continue;
75
+ try {
76
+ const device = await this._adapter.getDevice(addr);
77
+ const name = await Promise.resolve(device.getName()).catch(() => '') || '';
78
+ if (!isBluettiDevice(name)) continue;
79
+ this._found.set(normAddr, { address: addr, name, device });
80
+ this._log(`Discovered Bluetti device: ${name} [${addr}]`);
81
+ this.emit('discovered', { address: addr, name, device });
82
+ } catch (_) {
83
+ // Device may have disappeared before we could read it; skip
84
+ }
85
+ }
70
86
  }
71
87
 
72
88
  stopScan() {
73
89
  if (!this._scanning) return;
74
90
  clearTimeout(this._scanTimer);
91
+ clearInterval(this._pollTimer);
75
92
  this._scanning = false;
76
- if (this._noble) this._noble.stopScanning();
93
+ // Decrement BlueZ discovery reference count — other plugins' sessions keep scanning.
94
+ if (this._adapter) this._adapter.stopDiscovery().catch(() => {});
77
95
  this._log(`Scan complete. Found ${this._found.size} Bluetti device(s).`);
78
- this.emit('scanComplete', [...this._found.values()].map(p => ({
79
- address: p.address || p.id,
80
- name: (p.advertisement.localName || 'unknown').trim(),
81
- })));
82
- }
83
-
84
- // Retrieve a previously-discovered peripheral by address (for reconnect).
85
- getPeripheral(address) {
86
- return this._found.get(address) || null;
87
- }
88
-
89
- // Start a persistent background scan (noble keeps discovering; don't auto-stop).
90
- startPassiveScan() {
91
- this._initNoble().then(() => {
92
- this._scanning = true;
93
- this._noble.startScanning([], true); // allowDuplicates=true so we see RSSI updates
94
- }).catch(err => {
95
- this._log(`BLE init failed: ${err.message}`);
96
- this.emit('error', err);
97
- });
96
+ this.emit('scanComplete', [...this._found.values()].map(({ address, name }) => ({ address, name })));
98
97
  }
99
98
 
100
99
  stopAll() {
101
100
  this.stopScan();
102
- if (this._noble) {
103
- try { this._noble.stopScanning(); } catch (_) {}
101
+ if (this._destroy) {
102
+ try { this._destroy(); } catch (_) {}
103
+ this._destroy = null;
104
+ this._adapter = null;
105
+ this._bluetooth = null;
104
106
  }
105
107
  }
106
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-bluetti-plugin",
3
- "version": "1.0.8-alpha",
3
+ "version": "1.1.0-alpha",
4
4
  "signalk": {
5
5
  "displayName": "Bluetti Monitoring"
6
6
  },
@@ -30,7 +30,7 @@
30
30
  "start": "node index.js"
31
31
  },
32
32
  "dependencies": {
33
- "@stoprocent/noble": "^2.5.3",
33
+ "@naugehyde/node-ble": "^1.13.5",
34
34
  "csv-parse": "^5.5.6"
35
35
  }
36
36
  }