@stoprocent/noble 2.4.0 → 2.5.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/README.md +21 -3
- package/examples/peripheral-explorer-async.js +2 -0
- package/lib/dbus/bindings.js +783 -0
- package/lib/dbus/uuid.js +62 -0
- package/lib/resolve-bindings.js +8 -2
- package/lib/win/src/ble_manager.cc +2 -2
- package/lib/win/src/noble_winrt.cc +1 -1
- package/package.json +4 -3
- package/prebuilds/darwin-x64+arm64/@stoprocent+noble.node +0 -0
- package/prebuilds/win32-ia32/@stoprocent+noble.node +0 -0
- package/prebuilds/win32-x64/@stoprocent+noble.node +0 -0
- package/test/lib/dbus/bindings.test.js +412 -0
- package/test/lib/dbus/uuid.test.js +78 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const debug = require('debug')('noble-dbus');
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
normalizeUuid,
|
|
6
|
+
expandUuid,
|
|
7
|
+
addressToId,
|
|
8
|
+
devicePathToAddress,
|
|
9
|
+
deviceIdFromPath,
|
|
10
|
+
devicePathFromAddress
|
|
11
|
+
} = require('./uuid');
|
|
12
|
+
|
|
13
|
+
const BLUEZ_SERVICE = 'org.bluez';
|
|
14
|
+
const ROOT_PATH = '/';
|
|
15
|
+
const ADAPTER_IFACE = 'org.bluez.Adapter1';
|
|
16
|
+
const DEVICE_IFACE = 'org.bluez.Device1';
|
|
17
|
+
const GATT_SERVICE_IFACE = 'org.bluez.GattService1';
|
|
18
|
+
const GATT_CHAR_IFACE = 'org.bluez.GattCharacteristic1';
|
|
19
|
+
const GATT_DESC_IFACE = 'org.bluez.GattDescriptor1';
|
|
20
|
+
const PROPS_IFACE = 'org.freedesktop.DBus.Properties';
|
|
21
|
+
const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager';
|
|
22
|
+
|
|
23
|
+
const FLAG_TO_PROPERTY = {
|
|
24
|
+
broadcast: 'broadcast',
|
|
25
|
+
read: 'read',
|
|
26
|
+
'write-without-response': 'writeWithoutResponse',
|
|
27
|
+
write: 'write',
|
|
28
|
+
notify: 'notify',
|
|
29
|
+
indicate: 'indicate',
|
|
30
|
+
'authenticated-signed-writes': 'authenticatedSignedWrites',
|
|
31
|
+
'reliable-write': 'extendedProperties',
|
|
32
|
+
'writable-auxiliaries': 'extendedProperties'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function loadDbus () {
|
|
36
|
+
try {
|
|
37
|
+
// dbus-next is an optional peer of this Linux-only backend; the host
|
|
38
|
+
// project installs it explicitly. eslint-plugin-node would otherwise
|
|
39
|
+
// flag the missing module on platforms where it is not present.
|
|
40
|
+
// eslint-disable-next-line node/no-missing-require
|
|
41
|
+
return require('dbus-next');
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const wrapped = new Error(
|
|
44
|
+
'noble dbus backend requires the "dbus-next" package. ' +
|
|
45
|
+
'Install it with: npm install dbus-next'
|
|
46
|
+
);
|
|
47
|
+
wrapped.cause = err;
|
|
48
|
+
throw wrapped;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unwrapVariant (variant) {
|
|
53
|
+
if (variant && typeof variant === 'object' && 'value' in variant && 'signature' in variant) {
|
|
54
|
+
return variant.value;
|
|
55
|
+
}
|
|
56
|
+
return variant;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unwrapDict (dict) {
|
|
60
|
+
const out = {};
|
|
61
|
+
if (!dict) return out;
|
|
62
|
+
for (const key of Object.keys(dict)) {
|
|
63
|
+
out[key] = unwrapVariant(dict[key]);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function flagsToProperties (flags) {
|
|
69
|
+
const set = new Set();
|
|
70
|
+
for (const flag of flags || []) {
|
|
71
|
+
const mapped = FLAG_TO_PROPERTY[flag];
|
|
72
|
+
if (mapped) set.add(mapped);
|
|
73
|
+
}
|
|
74
|
+
return Array.from(set);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildAdvertisement (deviceProps) {
|
|
78
|
+
const advertisement = {
|
|
79
|
+
localName: undefined,
|
|
80
|
+
txPowerLevel: undefined,
|
|
81
|
+
manufacturerData: undefined,
|
|
82
|
+
serviceData: [],
|
|
83
|
+
serviceUuids: [],
|
|
84
|
+
serviceSolicitationUuids: []
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (typeof deviceProps.Name === 'string') {
|
|
88
|
+
advertisement.localName = deviceProps.Name;
|
|
89
|
+
} else if (typeof deviceProps.Alias === 'string') {
|
|
90
|
+
advertisement.localName = deviceProps.Alias;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof deviceProps.TxPower === 'number') {
|
|
94
|
+
advertisement.txPowerLevel = deviceProps.TxPower;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(deviceProps.UUIDs)) {
|
|
98
|
+
advertisement.serviceUuids = deviceProps.UUIDs.map(normalizeUuid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (deviceProps.ManufacturerData && typeof deviceProps.ManufacturerData === 'object') {
|
|
102
|
+
const entries = Object.entries(deviceProps.ManufacturerData);
|
|
103
|
+
if (entries.length > 0) {
|
|
104
|
+
const buffers = [];
|
|
105
|
+
for (const [companyId, payload] of entries) {
|
|
106
|
+
const id = Number(companyId) & 0xffff;
|
|
107
|
+
const header = Buffer.from([id & 0xff, (id >> 8) & 0xff]);
|
|
108
|
+
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
109
|
+
buffers.push(Buffer.concat([header, data]));
|
|
110
|
+
}
|
|
111
|
+
advertisement.manufacturerData = buffers.length === 1 ? buffers[0] : Buffer.concat(buffers);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (deviceProps.ServiceData && typeof deviceProps.ServiceData === 'object') {
|
|
116
|
+
for (const [uuid, payload] of Object.entries(deviceProps.ServiceData)) {
|
|
117
|
+
advertisement.serviceData.push({
|
|
118
|
+
uuid: normalizeUuid(uuid),
|
|
119
|
+
data: Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return advertisement;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class DbusBindings extends EventEmitter {
|
|
128
|
+
constructor (options = {}) {
|
|
129
|
+
super();
|
|
130
|
+
this._options = options || {};
|
|
131
|
+
this._dbus = null;
|
|
132
|
+
this._bus = null;
|
|
133
|
+
this._rootProxy = null;
|
|
134
|
+
this._objectManager = null;
|
|
135
|
+
|
|
136
|
+
this._adapterPath = null;
|
|
137
|
+
this._adapterProxy = null;
|
|
138
|
+
this._adapterIface = null;
|
|
139
|
+
this._adapterProps = null;
|
|
140
|
+
this._adapterAddress = 'unknown';
|
|
141
|
+
this._state = 'unknown';
|
|
142
|
+
this._isScanning = false;
|
|
143
|
+
|
|
144
|
+
// Object tree mirror: path -> { iface: props }
|
|
145
|
+
this._objects = new Map();
|
|
146
|
+
|
|
147
|
+
// Discovery filter for service uuid filtering
|
|
148
|
+
this._scanServiceUuids = [];
|
|
149
|
+
|
|
150
|
+
// Per-device live state
|
|
151
|
+
// id -> { path, address, addressType, connectable, scannable, rssi, advertisement, proxy, propsListener, connectPromise, servicesResolved }
|
|
152
|
+
this._devices = new Map();
|
|
153
|
+
|
|
154
|
+
// Per-characteristic notify listener: path -> { iface, listener }
|
|
155
|
+
this._charPropsListeners = new Map();
|
|
156
|
+
|
|
157
|
+
this._onInterfacesAddedBound = this._onInterfacesAdded.bind(this);
|
|
158
|
+
this._onInterfacesRemovedBound = this._onInterfacesRemoved.bind(this);
|
|
159
|
+
this._onAdapterPropsBound = this._onAdapterProperties.bind(this);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
start () {
|
|
163
|
+
this._dbus = loadDbus();
|
|
164
|
+
this._bus = this._dbus.systemBus();
|
|
165
|
+
this._init().catch(err => {
|
|
166
|
+
debug('init failed: %s', err && err.stack);
|
|
167
|
+
this.emit('warning', `dbus init failed: ${err.message}`);
|
|
168
|
+
this._state = 'unsupported';
|
|
169
|
+
this.emit('stateChange', 'unsupported');
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async _init () {
|
|
174
|
+
this._rootProxy = await this._bus.getProxyObject(BLUEZ_SERVICE, ROOT_PATH);
|
|
175
|
+
this._objectManager = this._rootProxy.getInterface(OBJECT_MANAGER_IFACE);
|
|
176
|
+
this._objectManager.on('InterfacesAdded', this._onInterfacesAddedBound);
|
|
177
|
+
this._objectManager.on('InterfacesRemoved', this._onInterfacesRemovedBound);
|
|
178
|
+
|
|
179
|
+
const managed = await this._objectManager.GetManagedObjects();
|
|
180
|
+
for (const [path, ifaces] of Object.entries(managed)) {
|
|
181
|
+
const unwrapped = {};
|
|
182
|
+
for (const [iface, props] of Object.entries(ifaces)) {
|
|
183
|
+
unwrapped[iface] = unwrapDict(props);
|
|
184
|
+
}
|
|
185
|
+
this._objects.set(path, unwrapped);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const adapterPath = this._pickAdapterPath();
|
|
189
|
+
if (!adapterPath) {
|
|
190
|
+
throw new Error('No BlueZ adapter found (is bluetoothd running?)');
|
|
191
|
+
}
|
|
192
|
+
this._adapterPath = adapterPath;
|
|
193
|
+
|
|
194
|
+
const adapterProxy = await this._bus.getProxyObject(BLUEZ_SERVICE, adapterPath);
|
|
195
|
+
this._adapterProxy = adapterProxy;
|
|
196
|
+
this._adapterIface = adapterProxy.getInterface(ADAPTER_IFACE);
|
|
197
|
+
this._adapterProps = adapterProxy.getInterface(PROPS_IFACE);
|
|
198
|
+
this._adapterProps.on('PropertiesChanged', this._onAdapterPropsBound);
|
|
199
|
+
|
|
200
|
+
const adapterProps = this._objects.get(adapterPath)[ADAPTER_IFACE] || {};
|
|
201
|
+
if (typeof adapterProps.Address === 'string') {
|
|
202
|
+
this._adapterAddress = adapterProps.Address;
|
|
203
|
+
this.emit('addressChange', adapterProps.Address);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const powered = !!adapterProps.Powered;
|
|
207
|
+
this._setState(powered ? 'poweredOn' : 'poweredOff');
|
|
208
|
+
|
|
209
|
+
// Surface devices that already exist in BlueZ's cache as discovery events.
|
|
210
|
+
for (const [path, ifaces] of this._objects) {
|
|
211
|
+
if (ifaces[DEVICE_IFACE] && this._isUnderAdapter(path)) {
|
|
212
|
+
this._handleDeviceProps(path, ifaces[DEVICE_IFACE]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_pickAdapterPath () {
|
|
218
|
+
const requested = this._options.adapterId
|
|
219
|
+
|| (this._options.hciDeviceId != null ? `hci${this._options.hciDeviceId}` : null);
|
|
220
|
+
let firstMatch = null;
|
|
221
|
+
for (const [path, ifaces] of this._objects) {
|
|
222
|
+
if (!ifaces[ADAPTER_IFACE]) continue;
|
|
223
|
+
if (!firstMatch) firstMatch = path;
|
|
224
|
+
if (requested && path.endsWith(`/${requested}`)) return path;
|
|
225
|
+
}
|
|
226
|
+
return firstMatch;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_isUnderAdapter (path) {
|
|
230
|
+
return this._adapterPath && path.startsWith(`${this._adapterPath}/`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_setState (state) {
|
|
234
|
+
if (this._state === state) return;
|
|
235
|
+
this._state = state;
|
|
236
|
+
this.emit('stateChange', state);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
stop () {
|
|
240
|
+
if (this._objectManager) {
|
|
241
|
+
this._objectManager.off('InterfacesAdded', this._onInterfacesAddedBound);
|
|
242
|
+
this._objectManager.off('InterfacesRemoved', this._onInterfacesRemovedBound);
|
|
243
|
+
}
|
|
244
|
+
if (this._adapterProps) {
|
|
245
|
+
this._adapterProps.off('PropertiesChanged', this._onAdapterPropsBound);
|
|
246
|
+
}
|
|
247
|
+
for (const device of this._devices.values()) {
|
|
248
|
+
if (device.proxy && device.propsListener) {
|
|
249
|
+
const props = device.proxy.getInterface(PROPS_IFACE);
|
|
250
|
+
props.off('PropertiesChanged', device.propsListener);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const entry of this._charPropsListeners.values()) {
|
|
254
|
+
entry.props.off('PropertiesChanged', entry.listener);
|
|
255
|
+
}
|
|
256
|
+
this._charPropsListeners.clear();
|
|
257
|
+
if (this._isScanning && this._adapterIface) {
|
|
258
|
+
this._adapterIface.StopDiscovery().catch(() => {});
|
|
259
|
+
}
|
|
260
|
+
if (this._bus && typeof this._bus.disconnect === 'function') {
|
|
261
|
+
try { this._bus.disconnect(); } catch (_) { /* ignore */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setScanParameters (_interval, _window) {
|
|
266
|
+
this.emit('warning', 'setScanParameters is not supported on the dbus backend (BlueZ controls scan parameters)');
|
|
267
|
+
this.emit('scanParametersSet');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setAddress (_address) {
|
|
271
|
+
this.emit('warning', 'setAddress is not supported on the dbus backend');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
startScanning (serviceUuids, allowDuplicates) {
|
|
275
|
+
this._scanServiceUuids = (serviceUuids || []).map(normalizeUuid);
|
|
276
|
+
this._startScanning(allowDuplicates).catch(err => {
|
|
277
|
+
this.emit('warning', `startScanning failed: ${err.message}`);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async _startScanning (allowDuplicates) {
|
|
282
|
+
if (!this._adapterIface) {
|
|
283
|
+
throw new Error('adapter not initialized');
|
|
284
|
+
}
|
|
285
|
+
const { Variant } = this._dbus;
|
|
286
|
+
const filter = {
|
|
287
|
+
Transport: new Variant('s', 'le'),
|
|
288
|
+
DuplicateData: new Variant('b', !!allowDuplicates)
|
|
289
|
+
};
|
|
290
|
+
if (this._scanServiceUuids.length > 0) {
|
|
291
|
+
filter.UUIDs = new Variant('as', this._scanServiceUuids.map(expandUuid));
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await this._adapterIface.SetDiscoveryFilter(filter);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
debug('SetDiscoveryFilter failed: %s', err.message);
|
|
297
|
+
}
|
|
298
|
+
if (!this._isScanning) {
|
|
299
|
+
await this._adapterIface.StartDiscovery();
|
|
300
|
+
this._isScanning = true;
|
|
301
|
+
}
|
|
302
|
+
this.emit('scanStart', !!allowDuplicates);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
stopScanning () {
|
|
306
|
+
this._stopScanning().catch(err => {
|
|
307
|
+
this.emit('warning', `stopScanning failed: ${err.message}`);
|
|
308
|
+
this.emit('scanStop');
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async _stopScanning () {
|
|
313
|
+
if (this._isScanning && this._adapterIface) {
|
|
314
|
+
try {
|
|
315
|
+
await this._adapterIface.StopDiscovery();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
debug('StopDiscovery failed: %s', err.message);
|
|
318
|
+
}
|
|
319
|
+
this._isScanning = false;
|
|
320
|
+
}
|
|
321
|
+
this.emit('scanStop');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- ObjectManager + property change handlers ----
|
|
325
|
+
|
|
326
|
+
_onInterfacesAdded (path, ifaces) {
|
|
327
|
+
const unwrapped = {};
|
|
328
|
+
for (const [iface, props] of Object.entries(ifaces)) {
|
|
329
|
+
unwrapped[iface] = unwrapDict(props);
|
|
330
|
+
}
|
|
331
|
+
const existing = this._objects.get(path) || {};
|
|
332
|
+
this._objects.set(path, Object.assign(existing, unwrapped));
|
|
333
|
+
|
|
334
|
+
if (unwrapped[DEVICE_IFACE] && this._isUnderAdapter(path)) {
|
|
335
|
+
this._handleDeviceProps(path, unwrapped[DEVICE_IFACE]);
|
|
336
|
+
}
|
|
337
|
+
// Trigger services-resolved processing if a device just gained ServicesResolved
|
|
338
|
+
if (unwrapped[GATT_SERVICE_IFACE] || unwrapped[GATT_CHAR_IFACE] || unwrapped[GATT_DESC_IFACE]) {
|
|
339
|
+
// No direct emit; clients call discoverServices/Characteristics/Descriptors.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_onInterfacesRemoved (path, ifaces) {
|
|
344
|
+
const stored = this._objects.get(path);
|
|
345
|
+
if (stored) {
|
|
346
|
+
for (const iface of ifaces) delete stored[iface];
|
|
347
|
+
if (Object.keys(stored).length === 0) {
|
|
348
|
+
this._objects.delete(path);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (ifaces.includes(DEVICE_IFACE)) {
|
|
352
|
+
const id = deviceIdFromPath(path);
|
|
353
|
+
if (id && this._devices.has(id)) {
|
|
354
|
+
this._onDeviceDisconnected(id, 'removed');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_onAdapterProperties (iface, changed) {
|
|
360
|
+
if (iface !== ADAPTER_IFACE) return;
|
|
361
|
+
const props = unwrapDict(changed);
|
|
362
|
+
if ('Powered' in props) {
|
|
363
|
+
this._setState(props.Powered ? 'poweredOn' : 'poweredOff');
|
|
364
|
+
}
|
|
365
|
+
if (typeof props.Address === 'string') {
|
|
366
|
+
this._adapterAddress = props.Address;
|
|
367
|
+
this.emit('addressChange', props.Address);
|
|
368
|
+
}
|
|
369
|
+
const stored = this._objects.get(this._adapterPath) || {};
|
|
370
|
+
stored[ADAPTER_IFACE] = Object.assign(stored[ADAPTER_IFACE] || {}, props);
|
|
371
|
+
this._objects.set(this._adapterPath, stored);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
_handleDeviceProps (path, props) {
|
|
375
|
+
const address = props.Address || devicePathToAddress(path);
|
|
376
|
+
if (!address) return;
|
|
377
|
+
const id = addressToId(address);
|
|
378
|
+
let device = this._devices.get(id);
|
|
379
|
+
if (!device) {
|
|
380
|
+
device = {
|
|
381
|
+
path,
|
|
382
|
+
address,
|
|
383
|
+
addressType: props.AddressType || 'unknown',
|
|
384
|
+
connectable: true,
|
|
385
|
+
scannable: false,
|
|
386
|
+
rssi: typeof props.RSSI === 'number' ? props.RSSI : 0,
|
|
387
|
+
advertisement: buildAdvertisement(props),
|
|
388
|
+
proxy: null,
|
|
389
|
+
propsListener: null,
|
|
390
|
+
servicesResolved: !!props.ServicesResolved,
|
|
391
|
+
connectPromise: null
|
|
392
|
+
};
|
|
393
|
+
this._devices.set(id, device);
|
|
394
|
+
} else {
|
|
395
|
+
device.path = path;
|
|
396
|
+
device.address = address;
|
|
397
|
+
if (props.AddressType) device.addressType = props.AddressType;
|
|
398
|
+
if (typeof props.RSSI === 'number') device.rssi = props.RSSI;
|
|
399
|
+
Object.assign(device.advertisement, buildAdvertisement(props));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
this.emit(
|
|
403
|
+
'discover',
|
|
404
|
+
id,
|
|
405
|
+
device.address,
|
|
406
|
+
device.addressType,
|
|
407
|
+
device.connectable,
|
|
408
|
+
device.advertisement,
|
|
409
|
+
device.rssi,
|
|
410
|
+
device.scannable
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---- Per-device proxy + property listening ----
|
|
415
|
+
|
|
416
|
+
async _ensureDeviceProxy (id) {
|
|
417
|
+
const device = this._devices.get(id);
|
|
418
|
+
if (!device) throw new Error(`unknown peripheral ${id}`);
|
|
419
|
+
if (device.proxy) return device;
|
|
420
|
+
const path = device.path || devicePathFromAddress(this._adapterPath, device.address);
|
|
421
|
+
device.path = path;
|
|
422
|
+
device.proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
|
|
423
|
+
const props = device.proxy.getInterface(PROPS_IFACE);
|
|
424
|
+
device.propsListener = (iface, changed) => {
|
|
425
|
+
if (iface !== DEVICE_IFACE) return;
|
|
426
|
+
const c = unwrapDict(changed);
|
|
427
|
+
if ('RSSI' in c) {
|
|
428
|
+
device.rssi = c.RSSI;
|
|
429
|
+
this.emit('rssiUpdate', id, c.RSSI);
|
|
430
|
+
}
|
|
431
|
+
if ('Connected' in c) {
|
|
432
|
+
if (c.Connected) {
|
|
433
|
+
// Wait for ServicesResolved to fire 'connect' with services available.
|
|
434
|
+
// If ServicesResolved already true (cached device), fire now.
|
|
435
|
+
if (device.servicesResolved && device.connectPromise) {
|
|
436
|
+
device.connectPromise.resolve();
|
|
437
|
+
device.connectPromise = null;
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
this._onDeviceDisconnected(id, 'remote');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if ('ServicesResolved' in c) {
|
|
444
|
+
device.servicesResolved = !!c.ServicesResolved;
|
|
445
|
+
if (c.ServicesResolved && device.connectPromise) {
|
|
446
|
+
device.connectPromise.resolve();
|
|
447
|
+
device.connectPromise = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
props.on('PropertiesChanged', device.propsListener);
|
|
452
|
+
return device;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_onDeviceDisconnected (id, reason) {
|
|
456
|
+
const device = this._devices.get(id);
|
|
457
|
+
if (!device) return;
|
|
458
|
+
if (device.proxy && device.propsListener) {
|
|
459
|
+
const props = device.proxy.getInterface(PROPS_IFACE);
|
|
460
|
+
props.off('PropertiesChanged', device.propsListener);
|
|
461
|
+
}
|
|
462
|
+
device.proxy = null;
|
|
463
|
+
device.propsListener = null;
|
|
464
|
+
device.servicesResolved = false;
|
|
465
|
+
if (device.connectPromise) {
|
|
466
|
+
device.connectPromise.reject(new Error(`disconnected: ${reason}`));
|
|
467
|
+
device.connectPromise = null;
|
|
468
|
+
}
|
|
469
|
+
this._removeDeviceCharListeners(id);
|
|
470
|
+
this.emit('disconnect', id, reason);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_removeDeviceCharListeners (id) {
|
|
474
|
+
const device = this._devices.get(id);
|
|
475
|
+
if (!device || !device.path) return;
|
|
476
|
+
const prefix = `${device.path}/`;
|
|
477
|
+
for (const [path, entry] of this._charPropsListeners) {
|
|
478
|
+
if (path.startsWith(prefix)) {
|
|
479
|
+
entry.props.off('PropertiesChanged', entry.listener);
|
|
480
|
+
this._charPropsListeners.delete(path);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---- Connect / disconnect ----
|
|
486
|
+
|
|
487
|
+
connect (peripheralUuid, _parameters) {
|
|
488
|
+
this._connect(peripheralUuid).catch(err => {
|
|
489
|
+
this.emit('connect', peripheralUuid, err);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async _connect (id) {
|
|
494
|
+
const device = await this._ensureDeviceProxy(id);
|
|
495
|
+
const iface = device.proxy.getInterface(DEVICE_IFACE);
|
|
496
|
+
|
|
497
|
+
const cached = this._objects.get(device.path) || {};
|
|
498
|
+
const deviceProps = cached[DEVICE_IFACE] || {};
|
|
499
|
+
if (deviceProps.Connected && deviceProps.ServicesResolved) {
|
|
500
|
+
device.servicesResolved = true;
|
|
501
|
+
this.emit('connect', id, null);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const waitConnected = new Promise((resolve, reject) => {
|
|
506
|
+
device.connectPromise = { resolve, reject };
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
await iface.Connect();
|
|
511
|
+
} catch (err) {
|
|
512
|
+
device.connectPromise = null;
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
await waitConnected;
|
|
517
|
+
this.emit('connect', id, null);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
cancelConnect (peripheralUuid, _parameters) {
|
|
521
|
+
this.disconnect(peripheralUuid);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
disconnect (peripheralUuid) {
|
|
525
|
+
this._disconnect(peripheralUuid).catch(err => {
|
|
526
|
+
this.emit('warning', `disconnect failed: ${err.message}`);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async _disconnect (id) {
|
|
531
|
+
const device = this._devices.get(id);
|
|
532
|
+
if (!device) return;
|
|
533
|
+
if (!device.proxy) return;
|
|
534
|
+
const iface = device.proxy.getInterface(DEVICE_IFACE);
|
|
535
|
+
try {
|
|
536
|
+
await iface.Disconnect();
|
|
537
|
+
} catch (err) {
|
|
538
|
+
debug('Disconnect call failed: %s', err.message);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
updateRssi (peripheralUuid) {
|
|
543
|
+
const device = this._devices.get(peripheralUuid);
|
|
544
|
+
if (!device || !device.path) {
|
|
545
|
+
this.emit('rssiUpdate', peripheralUuid, 0, new Error('unknown peripheral'));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const cached = this._objects.get(device.path) || {};
|
|
549
|
+
const props = cached[DEVICE_IFACE] || {};
|
|
550
|
+
const rssi = typeof props.RSSI === 'number' ? props.RSSI : (device.rssi || 0);
|
|
551
|
+
this.emit('rssiUpdate', peripheralUuid, rssi);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---- Service / characteristic / descriptor discovery ----
|
|
555
|
+
|
|
556
|
+
_findServicesForDevice (id) {
|
|
557
|
+
const device = this._devices.get(id);
|
|
558
|
+
if (!device || !device.path) return [];
|
|
559
|
+
const prefix = `${device.path}/`;
|
|
560
|
+
const services = [];
|
|
561
|
+
for (const [path, ifaces] of this._objects) {
|
|
562
|
+
if (!path.startsWith(prefix)) continue;
|
|
563
|
+
const svc = ifaces[GATT_SERVICE_IFACE];
|
|
564
|
+
if (!svc) continue;
|
|
565
|
+
services.push({ path, uuid: normalizeUuid(svc.UUID), primary: !!svc.Primary });
|
|
566
|
+
}
|
|
567
|
+
return services;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
_findCharacteristicsForService (servicePath) {
|
|
571
|
+
const prefix = `${servicePath}/`;
|
|
572
|
+
const result = [];
|
|
573
|
+
for (const [path, ifaces] of this._objects) {
|
|
574
|
+
if (!path.startsWith(prefix)) continue;
|
|
575
|
+
const ch = ifaces[GATT_CHAR_IFACE];
|
|
576
|
+
if (!ch) continue;
|
|
577
|
+
result.push({
|
|
578
|
+
path,
|
|
579
|
+
uuid: normalizeUuid(ch.UUID),
|
|
580
|
+
properties: flagsToProperties(ch.Flags)
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_findDescriptorsForCharacteristic (charPath) {
|
|
587
|
+
const prefix = `${charPath}/`;
|
|
588
|
+
const result = [];
|
|
589
|
+
for (const [path, ifaces] of this._objects) {
|
|
590
|
+
if (!path.startsWith(prefix)) continue;
|
|
591
|
+
const d = ifaces[GATT_DESC_IFACE];
|
|
592
|
+
if (!d) continue;
|
|
593
|
+
result.push({ path, uuid: normalizeUuid(d.UUID) });
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
discoverServices (peripheralUuid, uuids) {
|
|
599
|
+
const wanted = (uuids || []).map(normalizeUuid);
|
|
600
|
+
const found = this._findServicesForDevice(peripheralUuid);
|
|
601
|
+
const filtered = wanted.length === 0 ? found : found.filter(s => wanted.includes(s.uuid));
|
|
602
|
+
const serviceUuids = filtered.map(s => s.uuid);
|
|
603
|
+
this.emit('servicesDiscover', peripheralUuid, serviceUuids);
|
|
604
|
+
this.emit('servicesDiscovered', peripheralUuid, serviceUuids);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
discoverIncludedServices (peripheralUuid, serviceUuid, _serviceUuids) {
|
|
608
|
+
// BlueZ does not expose included services directly via D-Bus.
|
|
609
|
+
this.emit('includedServicesDiscover', peripheralUuid, serviceUuid, []);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
discoverCharacteristics (peripheralUuid, serviceUuid, characteristicUuids) {
|
|
613
|
+
const services = this._findServicesForDevice(peripheralUuid);
|
|
614
|
+
const service = services.find(s => s.uuid === normalizeUuid(serviceUuid));
|
|
615
|
+
if (!service) {
|
|
616
|
+
this.emit('characteristicsDiscover', peripheralUuid, serviceUuid, [], new Error('service not found'));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const wanted = (characteristicUuids || []).map(normalizeUuid);
|
|
620
|
+
const all = this._findCharacteristicsForService(service.path);
|
|
621
|
+
const filtered = wanted.length === 0 ? all : all.filter(c => wanted.includes(c.uuid));
|
|
622
|
+
const result = filtered.map(c => ({ uuid: c.uuid, properties: c.properties }));
|
|
623
|
+
this.emit('characteristicsDiscover', peripheralUuid, serviceUuid, result);
|
|
624
|
+
this.emit('characteristicsDiscovered', peripheralUuid, serviceUuid, result);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
_findCharacteristicPath (peripheralUuid, serviceUuid, characteristicUuid) {
|
|
628
|
+
const services = this._findServicesForDevice(peripheralUuid);
|
|
629
|
+
const service = services.find(s => s.uuid === normalizeUuid(serviceUuid));
|
|
630
|
+
if (!service) return null;
|
|
631
|
+
const chars = this._findCharacteristicsForService(service.path);
|
|
632
|
+
const ch = chars.find(c => c.uuid === normalizeUuid(characteristicUuid));
|
|
633
|
+
return ch ? ch.path : null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
_findDescriptorPath (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) {
|
|
637
|
+
const charPath = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
|
|
638
|
+
if (!charPath) return null;
|
|
639
|
+
const descs = this._findDescriptorsForCharacteristic(charPath);
|
|
640
|
+
const d = descs.find(x => x.uuid === normalizeUuid(descriptorUuid));
|
|
641
|
+
return d ? d.path : null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
read (peripheralUuid, serviceUuid, characteristicUuid) {
|
|
645
|
+
this._readChar(peripheralUuid, serviceUuid, characteristicUuid).catch(err => {
|
|
646
|
+
this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, null, false, err);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async _readChar (peripheralUuid, serviceUuid, characteristicUuid) {
|
|
651
|
+
const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
|
|
652
|
+
if (!path) throw new Error('characteristic not found');
|
|
653
|
+
const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
|
|
654
|
+
const iface = proxy.getInterface(GATT_CHAR_IFACE);
|
|
655
|
+
const value = await iface.ReadValue({});
|
|
656
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
657
|
+
this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, buf, false);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
write (peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse) {
|
|
661
|
+
this._writeChar(peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse).catch(err => {
|
|
662
|
+
this.emit('write', peripheralUuid, serviceUuid, characteristicUuid, err);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async _writeChar (peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse) {
|
|
667
|
+
const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
|
|
668
|
+
if (!path) throw new Error('characteristic not found');
|
|
669
|
+
const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
|
|
670
|
+
const iface = proxy.getInterface(GATT_CHAR_IFACE);
|
|
671
|
+
const { Variant } = this._dbus;
|
|
672
|
+
const options = { type: new Variant('s', withoutResponse ? 'command' : 'request') };
|
|
673
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
674
|
+
await iface.WriteValue(buf, options);
|
|
675
|
+
this.emit('write', peripheralUuid, serviceUuid, characteristicUuid);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
broadcast (peripheralUuid, serviceUuid, characteristicUuid, _broadcast) {
|
|
679
|
+
this.emit('warning', 'broadcast is not supported on the dbus backend');
|
|
680
|
+
this.emit('broadcast', peripheralUuid, serviceUuid, characteristicUuid, false);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
notify (peripheralUuid, serviceUuid, characteristicUuid, notify) {
|
|
684
|
+
this._setNotify(peripheralUuid, serviceUuid, characteristicUuid, notify).catch(err => {
|
|
685
|
+
this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, false, err);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async _setNotify (peripheralUuid, serviceUuid, characteristicUuid, notify) {
|
|
690
|
+
const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
|
|
691
|
+
if (!path) throw new Error('characteristic not found');
|
|
692
|
+
const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
|
|
693
|
+
const iface = proxy.getInterface(GATT_CHAR_IFACE);
|
|
694
|
+
const props = proxy.getInterface(PROPS_IFACE);
|
|
695
|
+
|
|
696
|
+
if (notify) {
|
|
697
|
+
if (!this._charPropsListeners.has(path)) {
|
|
698
|
+
const listener = (ifaceName, changed) => {
|
|
699
|
+
if (ifaceName !== GATT_CHAR_IFACE) return;
|
|
700
|
+
const c = unwrapDict(changed);
|
|
701
|
+
if ('Value' in c) {
|
|
702
|
+
const buf = Buffer.isBuffer(c.Value) ? c.Value : Buffer.from(c.Value);
|
|
703
|
+
this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, buf, true);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
props.on('PropertiesChanged', listener);
|
|
707
|
+
this._charPropsListeners.set(path, { props, listener });
|
|
708
|
+
}
|
|
709
|
+
await iface.StartNotify();
|
|
710
|
+
this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, true);
|
|
711
|
+
} else {
|
|
712
|
+
try {
|
|
713
|
+
await iface.StopNotify();
|
|
714
|
+
} catch (err) {
|
|
715
|
+
debug('StopNotify failed: %s', err.message);
|
|
716
|
+
}
|
|
717
|
+
const entry = this._charPropsListeners.get(path);
|
|
718
|
+
if (entry) {
|
|
719
|
+
entry.props.off('PropertiesChanged', entry.listener);
|
|
720
|
+
this._charPropsListeners.delete(path);
|
|
721
|
+
}
|
|
722
|
+
this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, false);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
discoverDescriptors (peripheralUuid, serviceUuid, characteristicUuid) {
|
|
727
|
+
const charPath = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
|
|
728
|
+
if (!charPath) {
|
|
729
|
+
this.emit('descriptorsDiscover', peripheralUuid, serviceUuid, characteristicUuid, [], new Error('characteristic not found'));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const descs = this._findDescriptorsForCharacteristic(charPath).map(d => d.uuid);
|
|
733
|
+
this.emit('descriptorsDiscover', peripheralUuid, serviceUuid, characteristicUuid, descs);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
readValue (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) {
|
|
737
|
+
this._readDesc(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid).catch(err => {
|
|
738
|
+
this.emit('valueRead', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, null, err);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async _readDesc (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) {
|
|
743
|
+
const path = this._findDescriptorPath(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid);
|
|
744
|
+
if (!path) throw new Error('descriptor not found');
|
|
745
|
+
const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
|
|
746
|
+
const iface = proxy.getInterface(GATT_DESC_IFACE);
|
|
747
|
+
const value = await iface.ReadValue({});
|
|
748
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
749
|
+
this.emit('valueRead', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, buf);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
writeValue (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data) {
|
|
753
|
+
this._writeDesc(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data).catch(err => {
|
|
754
|
+
this.emit('valueWrite', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, err);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async _writeDesc (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data) {
|
|
759
|
+
const path = this._findDescriptorPath(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid);
|
|
760
|
+
if (!path) throw new Error('descriptor not found');
|
|
761
|
+
const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
|
|
762
|
+
const iface = proxy.getInterface(GATT_DESC_IFACE);
|
|
763
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
764
|
+
await iface.WriteValue(buf, {});
|
|
765
|
+
this.emit('valueWrite', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
readHandle (peripheralUuid, handle) {
|
|
769
|
+
const err = new Error('readHandle is not supported on the dbus backend (BlueZ exposes UUIDs only)');
|
|
770
|
+
this.emit('handleRead', peripheralUuid, handle, null, err);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
writeHandle (peripheralUuid, handle, _data, _withoutResponse) {
|
|
774
|
+
const err = new Error('writeHandle is not supported on the dbus backend (BlueZ exposes UUIDs only)');
|
|
775
|
+
this.emit('handleWrite', peripheralUuid, handle, err);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
addressToId (address) {
|
|
779
|
+
return addressToId(address);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
module.exports = DbusBindings;
|