@rhizomatics/signalk-bluetti-plugin 1.0.7-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,16 +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
- // 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.
196
196
  scanner.stopScan();
197
- startDevice(cfg, peripheral, name, deps);
197
+ startDevice(cfg, bleDevice, name, deps);
198
198
  } else {
199
199
  log(`Discovered unconfigured Bluetti device: ${name} [${address}]`);
200
200
  }
@@ -256,7 +256,7 @@ module.exports = function (app) {
256
256
  return path.join(REGISTERS_DIR, `${builtinModel}.csv`);
257
257
  }
258
258
 
259
- function startDevice(cfg, peripheral, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
259
+ function startDevice(cfg, bleDevice, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
260
260
  const { address, name, encryptionCsvPath = '', pollIntervalSeconds = 10 } = cfg;
261
261
 
262
262
  let registerPath;
@@ -299,7 +299,7 @@ module.exports = function (app) {
299
299
  const device = new BluettiDevice({
300
300
  address,
301
301
  name,
302
- peripheral,
302
+ device: bleDevice,
303
303
  fields,
304
304
  pollIntervalMs: pollIntervalSeconds * 1000,
305
305
  xorKey,
package/lib/device.js CHANGED
@@ -3,39 +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
15
+ const RECONNECT_DELAYS = [5000, 10000, 20000, 30000, 60000]; // ms, capped at last value
16
+ const CONNECT_TIMEOUT_MS = 30000;
16
17
 
17
18
  class BluettiDevice extends EventEmitter {
18
- constructor({ address, name, peripheral, fields, pollIntervalMs = 10000, xorKey = null, log }) {
19
+ constructor({ address, name, device, fields, pollIntervalMs = 10000, xorKey = null, log }) {
19
20
  super();
20
- this._address = address;
21
- this._name = name;
22
- this._peripheral = peripheral;
23
- 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;
24
25
  this._pollIntervalMs = pollIntervalMs;
25
- this._xorKey = xorKey ? Buffer.from(xorKey, 'hex') : null;
26
- this._log = log;
27
-
28
- this._connected = false;
29
- this._notifyChar = null;
30
- this._writeChar = null;
31
- this._rxBuf = Buffer.alloc(0);
32
- this._currentBatch = null; // { start, count } being awaited
33
- this._batchQueue = [];
34
- this._pollTimer = null;
35
- this._reconnectAttempt = 0;
36
- 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
37
39
 
38
- // Pre-compute register poll batches from field list
39
40
  const addrs = [...new Set(fields.flatMap(f => Array.from({ length: f.count }, (_, i) => f.register + i)))];
40
41
  this._batches = groupRegisters(addrs);
41
42
  this._log(`[${name}] Poll plan: ${this._batches.length} batch(es) covering ${addrs.length} registers`);
@@ -49,74 +50,123 @@ class BluettiDevice extends EventEmitter {
49
50
  stop() {
50
51
  this._stopped = true;
51
52
  this._stopPolling();
52
- if (this._peripheral && this._connected) {
53
- 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(() => {});
54
59
  }
55
60
  }
56
61
 
57
62
  // ── Connection lifecycle ─────────────────────────────────────────────────
58
63
 
59
- _connect() {
64
+ async _connect() {
60
65
  if (this._stopped) return;
61
66
  this._log(`[${this._name}] Connecting to ${this._address} …`);
62
67
 
63
- this._peripheral.connect((err) => {
64
- if (err) {
65
- this._log(`[${this._name}] Connect error: ${err.message}`);
66
- this._scheduleReconnect();
67
- return;
68
- }
69
- this._log(`[${this._name}] Connected`);
70
- this._reconnectAttempt = 0;
71
- this._discoverServices();
72
- });
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
+
74
+ let settled = false;
75
+ const connectTimer = setTimeout(() => {
76
+ if (settled) return;
77
+ settled = true;
78
+ this._log(`[${this._name}] Connect timed out after ${CONNECT_TIMEOUT_MS / 1000}s — will retry`);
79
+ this._scheduleReconnect();
80
+ }, CONNECT_TIMEOUT_MS);
81
+
82
+ try {
83
+ await this._device.connect();
84
+ } catch (err) {
85
+ if (settled) return;
86
+ settled = true;
87
+ clearTimeout(connectTimer);
88
+ this._log(`[${this._name}] Connect error: ${err.message}`);
89
+ this._scheduleReconnect();
90
+ return;
91
+ }
73
92
 
74
- this._peripheral.once('disconnect', () => {
93
+ if (settled) return; // timed out while awaiting
94
+ settled = true;
95
+ clearTimeout(connectTimer);
96
+
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 = () => {
75
102
  if (this._stopped) return;
76
- this._connected = false;
77
- this._notifyChar = null;
78
- this._writeChar = null;
103
+ this._connected = false;
104
+ this._notifyChar = null;
105
+ this._writeChar = null;
106
+ this._disconnectHandler = null;
79
107
  this._log(`[${this._name}] Disconnected`);
80
108
  this._stopPolling();
81
109
  this._scheduleReconnect();
82
- });
110
+ };
111
+ this._device.once('disconnect', this._disconnectHandler);
112
+
113
+ await this._discoverServices();
83
114
  }
84
115
 
85
- _discoverServices() {
86
- this._peripheral.discoverAllServicesAndCharacteristics((err, _services, chars) => {
87
- if (err) {
88
- this._log(`[${this._name}] Service discovery error: ${err.message}`);
89
- this._scheduleReconnect();
90
- return;
91
- }
92
-
93
- const notifyChar = chars.find(c => c.uuid === NOTIFY_UUID) ||
94
- chars.find(c => c.uuid === ALT_CHAR_UUID);
95
- const writeChar = chars.find(c => c.uuid === WRITE_UUID) ||
96
- chars.find(c => c.uuid === ALT_CHAR_UUID);
97
-
98
- if (!notifyChar || !writeChar) {
99
- const uuids = chars.map(c => c.uuid).join(', ');
100
- this._log(`[${this._name}] Could not find required characteristics. Found: ${uuids}`);
101
- this._scheduleReconnect();
102
- return;
103
- }
104
-
105
- this._notifyChar = notifyChar;
106
- this._writeChar = writeChar;
107
-
108
- notifyChar.subscribe((err) => {
109
- if (err) {
110
- this._log(`[${this._name}] Subscribe error: ${err.message}`);
111
- this._scheduleReconnect();
112
- return;
113
- }
114
- notifyChar.on('data', (data) => this._onData(data));
115
- this._connected = true;
116
- this.emit('connected');
117
- this._startPolling();
118
- });
119
- });
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();
120
170
  }
121
171
 
122
172
  _scheduleReconnect() {
@@ -124,6 +174,8 @@ class BluettiDevice extends EventEmitter {
124
174
  const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
125
175
  this._reconnectAttempt++;
126
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.
127
179
  setTimeout(() => {
128
180
  if (!this._stopped) this._connect();
129
181
  }, delay);
@@ -142,13 +194,12 @@ class BluettiDevice extends EventEmitter {
142
194
  clearInterval(this._pollTimer);
143
195
  this._pollTimer = null;
144
196
  }
145
- this._batchQueue = [];
197
+ this._batchQueue = [];
146
198
  this._currentBatch = null;
147
199
  }
148
200
 
149
201
  _poll() {
150
202
  if (!this._connected || this._batches.length === 0) return;
151
- // Queue all batches; they'll be sent one at a time as responses arrive
152
203
  this._batchQueue = [...this._batches];
153
204
  this._rxBuf = Buffer.alloc(0);
154
205
  this._sendNextBatch();
@@ -161,20 +212,19 @@ class BluettiDevice extends EventEmitter {
161
212
  let frame = buildReadRequest(start, count);
162
213
  if (this._xorKey) frame = applyXor(frame, this._xorKey);
163
214
  this._rxBuf = Buffer.alloc(0);
164
- this._writeChar.write(frame, false, (err) => {
165
- 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}`);
166
217
  });
167
218
  }
168
219
 
169
220
  // ── Receive ──────────────────────────────────────────────────────────────
170
221
 
171
222
  _onData(data) {
172
- // Undo XOR on incoming data if encryption is in use
173
223
  const chunk = this._xorKey ? applyXor(data, this._xorKey) : data;
174
224
  this._rxBuf = Buffer.concat([this._rxBuf, chunk]);
175
225
 
176
226
  const frameLen = completeFrameLength(this._rxBuf);
177
- if (frameLen === 0) return; // waiting for more BLE packets
227
+ if (frameLen === 0) return; // waiting for more BLE packets
178
228
 
179
229
  const frame = this._rxBuf.slice(0, frameLen);
180
230
  this._rxBuf = this._rxBuf.slice(frameLen);
@@ -187,7 +237,6 @@ class BluettiDevice extends EventEmitter {
187
237
  this._log(`[${this._name}] CRC error or unexpected frame, skipping batch`);
188
238
  }
189
239
 
190
- // Move to next batch
191
240
  this._sendNextBatch();
192
241
  }
193
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.7-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
  }