@rhizomatics/signalk-bluetti-plugin 1.0.8-alpha → 1.1.1-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 +8 -9
- package/lib/device.js +126 -84
- package/lib/scanner.js +77 -70
- package/package.json +2 -2
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
|
|
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,
|
|
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
|
-
//
|
|
195
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
7
|
-
const SERVICE_UUID
|
|
8
|
-
const NOTIFY_UUID
|
|
9
|
-
const WRITE_UUID
|
|
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
|
|
16
|
-
const CONNECT_TIMEOUT_MS =
|
|
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,
|
|
19
|
+
constructor({ address, name, device, fields, pollIntervalMs = 10000, xorKey = null, log }) {
|
|
20
20
|
super();
|
|
21
|
-
this._address
|
|
22
|
-
this._name
|
|
23
|
-
this.
|
|
24
|
-
this._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
|
|
27
|
-
this._log
|
|
28
|
-
|
|
29
|
-
this._connected
|
|
30
|
-
this._notifyChar
|
|
31
|
-
this._writeChar
|
|
32
|
-
this._rxBuf
|
|
33
|
-
this._currentBatch
|
|
34
|
-
this._batchQueue
|
|
35
|
-
this._pollTimer
|
|
36
|
-
this._reconnectAttempt
|
|
37
|
-
this._stopped
|
|
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,32 @@ class BluettiDevice extends EventEmitter {
|
|
|
50
50
|
stop() {
|
|
51
51
|
this._stopped = true;
|
|
52
52
|
this._stopPolling();
|
|
53
|
-
if (this.
|
|
54
|
-
|
|
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;
|
|
66
|
+
// Cancel any lingering BlueZ connection state (e.g. a previous attempt that
|
|
67
|
+
// timed out on our side but is still in progress inside BlueZ).
|
|
68
|
+
if (!this._connected) {
|
|
69
|
+
try { await this._device.disconnect(); } catch (_) {}
|
|
70
|
+
}
|
|
62
71
|
this._log(`[${this._name}] Connecting to ${this._address} …`);
|
|
63
72
|
|
|
73
|
+
// Clear any stale disconnect handler from a previous attempt.
|
|
74
|
+
if (this._disconnectHandler) {
|
|
75
|
+
this._device.removeListener('disconnect', this._disconnectHandler);
|
|
76
|
+
this._disconnectHandler = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
let settled = false;
|
|
65
80
|
const connectTimer = setTimeout(() => {
|
|
66
81
|
if (settled) return;
|
|
@@ -69,66 +84,94 @@ class BluettiDevice extends EventEmitter {
|
|
|
69
84
|
this._scheduleReconnect();
|
|
70
85
|
}, CONNECT_TIMEOUT_MS);
|
|
71
86
|
|
|
72
|
-
|
|
87
|
+
try {
|
|
88
|
+
await this._device.connect();
|
|
89
|
+
} catch (err) {
|
|
73
90
|
if (settled) return;
|
|
74
91
|
settled = true;
|
|
75
92
|
clearTimeout(connectTimer);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
this._log(`[${this._name}] Connected`);
|
|
82
|
-
this._reconnectAttempt = 0;
|
|
83
|
-
this._discoverServices();
|
|
84
|
-
});
|
|
93
|
+
this._log(`[${this._name}] Connect error: ${err.message}`);
|
|
94
|
+
this._scheduleReconnect();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
85
97
|
|
|
86
|
-
|
|
98
|
+
if (settled) return; // timed out while awaiting
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(connectTimer);
|
|
101
|
+
|
|
102
|
+
this._log(`[${this._name}] Connected`);
|
|
103
|
+
this._reconnectAttempt = 0;
|
|
104
|
+
|
|
105
|
+
// Register disconnect handler — stored so it can be removed on reconnect.
|
|
106
|
+
this._disconnectHandler = () => {
|
|
87
107
|
if (this._stopped) return;
|
|
88
|
-
this._connected
|
|
89
|
-
this._notifyChar
|
|
90
|
-
this._writeChar
|
|
108
|
+
this._connected = false;
|
|
109
|
+
this._notifyChar = null;
|
|
110
|
+
this._writeChar = null;
|
|
111
|
+
this._disconnectHandler = null;
|
|
91
112
|
this._log(`[${this._name}] Disconnected`);
|
|
92
113
|
this._stopPolling();
|
|
93
114
|
this._scheduleReconnect();
|
|
94
|
-
}
|
|
115
|
+
};
|
|
116
|
+
this._device.once('disconnect', this._disconnectHandler);
|
|
117
|
+
|
|
118
|
+
await this._discoverServices();
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
_discoverServices() {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
121
|
+
async _discoverServices() {
|
|
122
|
+
let gatt;
|
|
123
|
+
try {
|
|
124
|
+
gatt = await this._device.gatt();
|
|
125
|
+
} catch (err) {
|
|
126
|
+
this._log(`[${this._name}] GATT error: ${err.message}`);
|
|
127
|
+
this._scheduleReconnect();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let service;
|
|
132
|
+
for (const uuid of [SERVICE_UUID, ALT_SERVICE_UUID]) {
|
|
133
|
+
try {
|
|
134
|
+
service = await gatt.getPrimaryService(uuid);
|
|
135
|
+
break;
|
|
136
|
+
} catch (_) {}
|
|
137
|
+
}
|
|
138
|
+
if (!service) {
|
|
139
|
+
this._log(`[${this._name}] Could not find BLE service (tried ${SERVICE_UUID}, ${ALT_SERVICE_UUID})`);
|
|
140
|
+
this._scheduleReconnect();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let notifyChar;
|
|
145
|
+
for (const uuid of [NOTIFY_UUID, ALT_CHAR_UUID]) {
|
|
146
|
+
try { notifyChar = await service.getCharacteristic(uuid); break; } catch (_) {}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let writeChar;
|
|
150
|
+
for (const uuid of [WRITE_UUID, ALT_CHAR_UUID]) {
|
|
151
|
+
try { writeChar = await service.getCharacteristic(uuid); break; } catch (_) {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!notifyChar || !writeChar) {
|
|
155
|
+
this._log(`[${this._name}] Could not find required GATT characteristics`);
|
|
156
|
+
this._scheduleReconnect();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await notifyChar.startNotifications();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
this._log(`[${this._name}] Subscribe error: ${err.message}`);
|
|
164
|
+
this._scheduleReconnect();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
notifyChar.on('valuechanged', (data) => this._onData(data));
|
|
169
|
+
|
|
170
|
+
this._notifyChar = notifyChar;
|
|
171
|
+
this._writeChar = writeChar;
|
|
172
|
+
this._connected = true;
|
|
173
|
+
this.emit('connected');
|
|
174
|
+
this._startPolling();
|
|
132
175
|
}
|
|
133
176
|
|
|
134
177
|
_scheduleReconnect() {
|
|
@@ -136,6 +179,8 @@ class BluettiDevice extends EventEmitter {
|
|
|
136
179
|
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
|
137
180
|
this._reconnectAttempt++;
|
|
138
181
|
this._log(`[${this._name}] Reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`);
|
|
182
|
+
// The node-ble Device object is D-Bus backed and persists across disconnects —
|
|
183
|
+
// no need to rescan for a fresh peripheral; just call connect() again.
|
|
139
184
|
setTimeout(() => {
|
|
140
185
|
if (!this._stopped) this._connect();
|
|
141
186
|
}, delay);
|
|
@@ -154,13 +199,12 @@ class BluettiDevice extends EventEmitter {
|
|
|
154
199
|
clearInterval(this._pollTimer);
|
|
155
200
|
this._pollTimer = null;
|
|
156
201
|
}
|
|
157
|
-
this._batchQueue
|
|
202
|
+
this._batchQueue = [];
|
|
158
203
|
this._currentBatch = null;
|
|
159
204
|
}
|
|
160
205
|
|
|
161
206
|
_poll() {
|
|
162
207
|
if (!this._connected || this._batches.length === 0) return;
|
|
163
|
-
// Queue all batches; they'll be sent one at a time as responses arrive
|
|
164
208
|
this._batchQueue = [...this._batches];
|
|
165
209
|
this._rxBuf = Buffer.alloc(0);
|
|
166
210
|
this._sendNextBatch();
|
|
@@ -173,20 +217,19 @@ class BluettiDevice extends EventEmitter {
|
|
|
173
217
|
let frame = buildReadRequest(start, count);
|
|
174
218
|
if (this._xorKey) frame = applyXor(frame, this._xorKey);
|
|
175
219
|
this._rxBuf = Buffer.alloc(0);
|
|
176
|
-
this._writeChar.
|
|
177
|
-
|
|
220
|
+
this._writeChar.writeValueWithoutResponse(frame).catch((err) => {
|
|
221
|
+
this._log(`[${this._name}] Write error: ${err.message}`);
|
|
178
222
|
});
|
|
179
223
|
}
|
|
180
224
|
|
|
181
225
|
// ── Receive ──────────────────────────────────────────────────────────────
|
|
182
226
|
|
|
183
227
|
_onData(data) {
|
|
184
|
-
// Undo XOR on incoming data if encryption is in use
|
|
185
228
|
const chunk = this._xorKey ? applyXor(data, this._xorKey) : data;
|
|
186
229
|
this._rxBuf = Buffer.concat([this._rxBuf, chunk]);
|
|
187
230
|
|
|
188
231
|
const frameLen = completeFrameLength(this._rxBuf);
|
|
189
|
-
if (frameLen === 0) return;
|
|
232
|
+
if (frameLen === 0) return; // waiting for more BLE packets
|
|
190
233
|
|
|
191
234
|
const frame = this._rxBuf.slice(0, frameLen);
|
|
192
235
|
this._rxBuf = this._rxBuf.slice(frameLen);
|
|
@@ -199,7 +242,6 @@ class BluettiDevice extends EventEmitter {
|
|
|
199
242
|
this._log(`[${this._name}] CRC error or unexpected frame, skipping batch`);
|
|
200
243
|
}
|
|
201
244
|
|
|
202
|
-
// Move to next batch
|
|
203
245
|
this._sendNextBatch();
|
|
204
246
|
}
|
|
205
247
|
}
|
package/lib/scanner.js
CHANGED
|
@@ -2,105 +2,112 @@
|
|
|
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(
|
|
10
|
-
|
|
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
|
|
18
|
-
this.
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
if (!this._scanning) break; // scanner stopped while we were iterating
|
|
74
|
+
const normAddr = addr.toLowerCase().replace(/:/g, '');
|
|
75
|
+
if (this._found.has(normAddr)) continue;
|
|
76
|
+
// Reserve the slot immediately to prevent concurrent _poll() calls from
|
|
77
|
+
// both processing the same address before either stores the device.
|
|
78
|
+
this._found.set(normAddr, null);
|
|
79
|
+
try {
|
|
80
|
+
const device = await this._adapter.getDevice(addr);
|
|
81
|
+
const name = await Promise.resolve(device.getName()).catch(() => '') || '';
|
|
82
|
+
if (!this._scanning) break;
|
|
83
|
+
if (!isBluettiDevice(name)) { this._found.delete(normAddr); continue; }
|
|
84
|
+
this._found.set(normAddr, { address: addr, name, device });
|
|
85
|
+
this._log(`Discovered Bluetti device: ${name} [${addr}]`);
|
|
86
|
+
this.emit('discovered', { address: addr, name, device });
|
|
87
|
+
} catch (_) {
|
|
88
|
+
this._found.delete(normAddr); // allow retry on transient D-Bus error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
stopScan() {
|
|
73
94
|
if (!this._scanning) return;
|
|
74
95
|
clearTimeout(this._scanTimer);
|
|
96
|
+
clearInterval(this._pollTimer);
|
|
75
97
|
this._scanning = false;
|
|
76
|
-
|
|
98
|
+
// Decrement BlueZ discovery reference count — other plugins' sessions keep scanning.
|
|
99
|
+
if (this._adapter) this._adapter.stopDiscovery().catch(() => {});
|
|
77
100
|
this._log(`Scan complete. Found ${this._found.size} Bluetti device(s).`);
|
|
78
|
-
this.emit('scanComplete', [...this._found.values()].map(
|
|
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
|
-
});
|
|
101
|
+
this.emit('scanComplete', [...this._found.values()].map(({ address, name }) => ({ address, name })));
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
stopAll() {
|
|
101
105
|
this.stopScan();
|
|
102
|
-
if (this.
|
|
103
|
-
try { this.
|
|
106
|
+
if (this._destroy) {
|
|
107
|
+
try { this._destroy(); } catch (_) {}
|
|
108
|
+
this._destroy = null;
|
|
109
|
+
this._adapter = null;
|
|
110
|
+
this._bluetooth = null;
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-bluetti-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1-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
|
-
"@
|
|
33
|
+
"@naugehyde/node-ble": "^1.13.5",
|
|
34
34
|
"csv-parse": "^5.5.6"
|
|
35
35
|
}
|
|
36
36
|
}
|