@stoprocent/noble 2.4.1 → 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.
@@ -0,0 +1,62 @@
1
+ const BLUETOOTH_BASE_SUFFIX = '-0000-1000-8000-00805f9b34fb';
2
+
3
+ function normalizeUuid (uuid) {
4
+ if (!uuid) return uuid;
5
+ const lower = String(uuid).toLowerCase();
6
+ if (lower.length === 36 && lower.endsWith(BLUETOOTH_BASE_SUFFIX)) {
7
+ const head = lower.slice(0, 8);
8
+ if (head.startsWith('0000')) {
9
+ return head.slice(4);
10
+ }
11
+ return head;
12
+ }
13
+ return lower.replace(/-/g, '');
14
+ }
15
+
16
+ function expandUuid (uuid) {
17
+ if (!uuid) return uuid;
18
+ const lower = String(uuid).toLowerCase().replace(/-/g, '');
19
+ if (lower.length === 4) {
20
+ return `0000${lower}-0000-1000-8000-00805f9b34fb`;
21
+ }
22
+ if (lower.length === 8) {
23
+ return `${lower}-0000-1000-8000-00805f9b34fb`;
24
+ }
25
+ if (lower.length === 32) {
26
+ return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
27
+ }
28
+ return lower;
29
+ }
30
+
31
+ function addressToId (address) {
32
+ return address.replace(/:/g, '').toLowerCase();
33
+ }
34
+
35
+ function idToAddress (id) {
36
+ return id.match(/.{1,2}/g).join(':').toUpperCase();
37
+ }
38
+
39
+ function devicePathToAddress (path) {
40
+ const m = path.match(/dev_([0-9A-Fa-f_]+)$/);
41
+ if (!m) return null;
42
+ return m[1].replace(/_/g, ':').toUpperCase();
43
+ }
44
+
45
+ function deviceIdFromPath (path) {
46
+ const address = devicePathToAddress(path);
47
+ return address ? addressToId(address) : null;
48
+ }
49
+
50
+ function devicePathFromAddress (adapterPath, address) {
51
+ return `${adapterPath}/dev_${address.toUpperCase().replace(/:/g, '_')}`;
52
+ }
53
+
54
+ module.exports = {
55
+ normalizeUuid,
56
+ expandUuid,
57
+ addressToId,
58
+ idToAddress,
59
+ devicePathToAddress,
60
+ deviceIdFromPath,
61
+ devicePathFromAddress
62
+ };
@@ -6,6 +6,8 @@ function loadBindings (bindingType = null, options = {}) {
6
6
  switch (bindingType) {
7
7
  case 'hci':
8
8
  return new (require('./hci-socket/bindings'))(options);
9
+ case 'dbus':
10
+ return new (require('./dbus/bindings'))(options);
9
11
  case 'mac':
10
12
  return new (require('./mac/bindings'))(options);
11
13
  case 'win':
@@ -17,7 +19,7 @@ function loadBindings (bindingType = null, options = {}) {
17
19
 
18
20
  function getWindowsBindings () {
19
21
  const ver = os.release().split('.').map((str) => parseInt(str, 10));
20
- const isWin10WithBLE =
22
+ const isWin10WithBLE =
21
23
  ver[0] > 10 ||
22
24
  (ver[0] === 10 && ver[1] > 0) ||
23
25
  (ver[0] === 10 && ver[1] === 0 && ver[2] >= 15063);
@@ -26,6 +28,10 @@ function getWindowsBindings () {
26
28
 
27
29
  function getDefaultBindings (options = {}) {
28
30
  const platform = os.platform();
31
+ const requested = (process.env.NOBLE_BINDINGS || '').toLowerCase();
32
+ if (requested === 'dbus' || requested === 'hci' || requested === 'mac' || requested === 'win') {
33
+ return loadBindings(requested, options);
34
+ }
29
35
  if (
30
36
  platform === 'linux' ||
31
37
  platform === 'freebsd' ||
@@ -44,7 +50,7 @@ function getDefaultBindings (options = {}) {
44
50
  }
45
51
 
46
52
  module.exports = function (bindingType = 'default', options = {}) {
47
- const bindings = bindingType === 'default'
53
+ const bindings = bindingType === 'default'
48
54
  ? getDefaultBindings(options)
49
55
  : loadBindings(bindingType, options);
50
56
  return new Noble(bindings);
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "license": "MIT",
7
7
  "name": "@stoprocent/noble",
8
8
  "description": "A Node.js BLE (Bluetooth Low Energy) central library.",
9
- "version": "2.4.1",
9
+ "version": "2.5.0",
10
10
  "repository": {
11
11
  "type": "git",
12
12
  "url": "https://github.com/stoprocent/noble.git"
@@ -0,0 +1,412 @@
1
+ const { EventEmitter } = require('events');
2
+
3
+ // ---- dbus-next mock ----
4
+ // Provides an in-memory BlueZ object tree we can mutate per-test.
5
+
6
+ class Variant {
7
+ constructor (signature, value) {
8
+ this.signature = signature;
9
+ this.value = value;
10
+ }
11
+ }
12
+
13
+ const mockState = {
14
+ managedObjects: {},
15
+ ifaceCalls: [],
16
+ proxies: new Map(), // path -> proxy
17
+ rootProxy: null,
18
+ systemBusListeners: []
19
+ };
20
+ const state = mockState;
21
+
22
+ function v (signature, value) {
23
+ return new Variant(signature, value);
24
+ }
25
+
26
+ // Wrap a plain props dict to look like dbus-next's variant-wrapped output
27
+ function wrapDict (obj) {
28
+ const out = {};
29
+ for (const [k, val] of Object.entries(obj)) {
30
+ out[k] = val instanceof Variant ? val : v('v', val);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function makeIfaceEmitter (extras = {}) {
36
+ const e = new EventEmitter();
37
+ Object.assign(e, extras);
38
+ return e;
39
+ }
40
+
41
+ function makeProxy (path) {
42
+ if (state.proxies.has(path)) return state.proxies.get(path);
43
+
44
+ const propsIface = makeIfaceEmitter();
45
+ const interfaces = { 'org.freedesktop.DBus.Properties': propsIface };
46
+
47
+ const ifacesAt = state.managedObjects[path] || {};
48
+ for (const ifaceName of Object.keys(ifacesAt)) {
49
+ if (interfaces[ifaceName]) continue;
50
+ interfaces[ifaceName] = makeIfaceEmitter();
51
+ }
52
+
53
+ const recordedCall = (ifaceName, method) => (...args) => {
54
+ state.ifaceCalls.push({ path, iface: ifaceName, method, args });
55
+ return Promise.resolve();
56
+ };
57
+
58
+ if (interfaces['org.bluez.Adapter1']) {
59
+ Object.assign(interfaces['org.bluez.Adapter1'], {
60
+ SetDiscoveryFilter: recordedCall('org.bluez.Adapter1', 'SetDiscoveryFilter'),
61
+ StartDiscovery: recordedCall('org.bluez.Adapter1', 'StartDiscovery'),
62
+ StopDiscovery: recordedCall('org.bluez.Adapter1', 'StopDiscovery')
63
+ });
64
+ }
65
+ if (interfaces['org.bluez.Device1']) {
66
+ Object.assign(interfaces['org.bluez.Device1'], {
67
+ Connect: recordedCall('org.bluez.Device1', 'Connect'),
68
+ Disconnect: recordedCall('org.bluez.Device1', 'Disconnect')
69
+ });
70
+ }
71
+ if (interfaces['org.bluez.GattCharacteristic1']) {
72
+ Object.assign(interfaces['org.bluez.GattCharacteristic1'], {
73
+ ReadValue: jest.fn().mockResolvedValue(Buffer.from([0x01, 0x02])),
74
+ WriteValue: jest.fn().mockResolvedValue(undefined),
75
+ StartNotify: jest.fn().mockResolvedValue(undefined),
76
+ StopNotify: jest.fn().mockResolvedValue(undefined)
77
+ });
78
+ }
79
+ if (interfaces['org.bluez.GattDescriptor1']) {
80
+ Object.assign(interfaces['org.bluez.GattDescriptor1'], {
81
+ ReadValue: jest.fn().mockResolvedValue(Buffer.from([0x09])),
82
+ WriteValue: jest.fn().mockResolvedValue(undefined)
83
+ });
84
+ }
85
+
86
+ if (path === '/') {
87
+ interfaces['org.freedesktop.DBus.ObjectManager'] = makeIfaceEmitter({
88
+ GetManagedObjects: jest.fn().mockImplementation(async () => {
89
+ const out = {};
90
+ for (const [p, ifs] of Object.entries(state.managedObjects)) {
91
+ out[p] = {};
92
+ for (const [iname, props] of Object.entries(ifs)) {
93
+ out[p][iname] = wrapDict(props);
94
+ }
95
+ }
96
+ return out;
97
+ })
98
+ });
99
+ }
100
+
101
+ const proxy = {
102
+ path,
103
+ interfaces,
104
+ getInterface: name => {
105
+ if (!interfaces[name]) interfaces[name] = makeIfaceEmitter();
106
+ return interfaces[name];
107
+ }
108
+ };
109
+ state.proxies.set(path, proxy);
110
+ if (path === '/') state.rootProxy = proxy;
111
+ return proxy;
112
+ }
113
+
114
+ const mockBus = {
115
+ getProxyObject: jest.fn().mockImplementation(async (_service, path) => makeProxy(path)),
116
+ disconnect: jest.fn()
117
+ };
118
+
119
+ const mockDbus = {
120
+ systemBus: jest.fn().mockReturnValue(mockBus),
121
+ Variant
122
+ };
123
+
124
+ jest.mock('dbus-next', () => mockDbus, { virtual: true });
125
+
126
+ // ---- Tests ----
127
+
128
+ const DbusBindings = require('../../../lib/dbus/bindings');
129
+
130
+ function resetState (objects = {}) {
131
+ state.managedObjects = objects;
132
+ state.ifaceCalls = [];
133
+ state.proxies.clear();
134
+ state.rootProxy = null;
135
+ }
136
+
137
+ function adapterTree (extra = {}) {
138
+ return {
139
+ '/org/bluez': { 'org.freedesktop.DBus.ObjectManager': {} },
140
+ '/org/bluez/hci0': {
141
+ 'org.bluez.Adapter1': {
142
+ Address: '00:11:22:33:44:55',
143
+ Powered: true,
144
+ Discovering: false
145
+ }
146
+ },
147
+ ...extra
148
+ };
149
+ }
150
+
151
+ async function flush () {
152
+ // Allow microtasks (init promise chain) to settle.
153
+ await new Promise(resolve => setImmediate(resolve));
154
+ await new Promise(resolve => setImmediate(resolve));
155
+ }
156
+
157
+ describe('dbus/bindings', () => {
158
+ beforeEach(() => {
159
+ resetState(adapterTree());
160
+ });
161
+
162
+ test('start() emits stateChange("poweredOn") and addressChange', async () => {
163
+ const bindings = new DbusBindings();
164
+ const states = [];
165
+ const addresses = [];
166
+ bindings.on('stateChange', s => states.push(s));
167
+ bindings.on('addressChange', a => addresses.push(a));
168
+
169
+ bindings.start();
170
+ await flush();
171
+
172
+ expect(states).toContain('poweredOn');
173
+ expect(addresses).toContain('00:11:22:33:44:55');
174
+ });
175
+
176
+ test('emits stateChange("poweredOff") when adapter is unpowered', async () => {
177
+ resetState(adapterTree({
178
+ '/org/bluez/hci0': {
179
+ 'org.bluez.Adapter1': { Address: 'AA:BB:CC:DD:EE:FF', Powered: false }
180
+ }
181
+ }));
182
+ const bindings = new DbusBindings();
183
+ const states = [];
184
+ bindings.on('stateChange', s => states.push(s));
185
+
186
+ bindings.start();
187
+ await flush();
188
+
189
+ expect(states).toContain('poweredOff');
190
+ });
191
+
192
+ test('emits unsupported when no adapter is found', async () => {
193
+ resetState({ '/org/bluez': { 'org.freedesktop.DBus.ObjectManager': {} } });
194
+ const bindings = new DbusBindings();
195
+ const states = [];
196
+ const warnings = [];
197
+ bindings.on('stateChange', s => states.push(s));
198
+ bindings.on('warning', w => warnings.push(w));
199
+
200
+ bindings.start();
201
+ await flush();
202
+
203
+ expect(states).toContain('unsupported');
204
+ expect(warnings.some(w => /No BlueZ adapter/.test(w))).toBe(true);
205
+ });
206
+
207
+ test('startScanning calls SetDiscoveryFilter + StartDiscovery and emits scanStart', async () => {
208
+ const bindings = new DbusBindings();
209
+ const scanStarts = [];
210
+ bindings.on('scanStart', () => scanStarts.push(true));
211
+
212
+ bindings.start();
213
+ await flush();
214
+
215
+ bindings.startScanning(['180d'], false);
216
+ await flush();
217
+
218
+ const calls = state.ifaceCalls.filter(c => c.path === '/org/bluez/hci0');
219
+ expect(calls.map(c => c.method)).toEqual(
220
+ expect.arrayContaining(['SetDiscoveryFilter', 'StartDiscovery'])
221
+ );
222
+ expect(scanStarts.length).toBe(1);
223
+ });
224
+
225
+ test('stopScanning calls StopDiscovery and emits scanStop', async () => {
226
+ const bindings = new DbusBindings();
227
+ const scanStops = [];
228
+ bindings.on('scanStop', () => scanStops.push(true));
229
+
230
+ bindings.start();
231
+ await flush();
232
+ bindings.startScanning([], false);
233
+ await flush();
234
+ bindings.stopScanning();
235
+ await flush();
236
+
237
+ const stopCall = state.ifaceCalls.find(c => c.method === 'StopDiscovery');
238
+ expect(stopCall).toBeDefined();
239
+ expect(scanStops.length).toBe(1);
240
+ });
241
+
242
+ test('InterfacesAdded for a Device1 emits discover with parsed advertisement', async () => {
243
+ const bindings = new DbusBindings();
244
+ const discoveries = [];
245
+ bindings.on('discover', (...args) => discoveries.push(args));
246
+
247
+ bindings.start();
248
+ await flush();
249
+
250
+ const om = state.rootProxy.getInterface('org.freedesktop.DBus.ObjectManager');
251
+ om.emit('InterfacesAdded', '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF', {
252
+ 'org.bluez.Device1': wrapDict({
253
+ Address: 'AA:BB:CC:DD:EE:FF',
254
+ AddressType: 'public',
255
+ Name: 'Test Device',
256
+ RSSI: -42,
257
+ UUIDs: ['0000180d-0000-1000-8000-00805f9b34fb']
258
+ })
259
+ });
260
+ await flush();
261
+
262
+ expect(discoveries.length).toBe(1);
263
+ const [id, address, addressType, connectable, advertisement, rssi] = discoveries[0];
264
+ expect(id).toBe('aabbccddeeff');
265
+ expect(address).toBe('AA:BB:CC:DD:EE:FF');
266
+ expect(addressType).toBe('public');
267
+ expect(connectable).toBe(true);
268
+ expect(rssi).toBe(-42);
269
+ expect(advertisement.localName).toBe('Test Device');
270
+ expect(advertisement.serviceUuids).toEqual(['180d']);
271
+ });
272
+
273
+ test('discoverServices/Characteristics/Descriptors walk the cached object tree', async () => {
274
+ const tree = adapterTree({
275
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': {
276
+ 'org.bluez.Device1': { Address: 'AA:BB:CC:DD:EE:FF', AddressType: 'public', Connected: false }
277
+ },
278
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001': {
279
+ 'org.bluez.GattService1': { UUID: '0000180d-0000-1000-8000-00805f9b34fb', Primary: true }
280
+ },
281
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002': {
282
+ 'org.bluez.GattCharacteristic1': {
283
+ UUID: '00002a37-0000-1000-8000-00805f9b34fb',
284
+ Flags: ['read', 'notify']
285
+ }
286
+ },
287
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002/desc0003': {
288
+ 'org.bluez.GattDescriptor1': { UUID: '00002902-0000-1000-8000-00805f9b34fb' }
289
+ }
290
+ });
291
+ resetState(tree);
292
+
293
+ const bindings = new DbusBindings();
294
+ bindings.start();
295
+ await flush();
296
+
297
+ const services = [];
298
+ const chars = [];
299
+ const descs = [];
300
+ bindings.on('servicesDiscover', (...a) => services.push(a));
301
+ bindings.on('characteristicsDiscover', (...a) => chars.push(a));
302
+ bindings.on('descriptorsDiscover', (...a) => descs.push(a));
303
+
304
+ bindings.discoverServices('aabbccddeeff', []);
305
+ bindings.discoverCharacteristics('aabbccddeeff', '180d', []);
306
+ bindings.discoverDescriptors('aabbccddeeff', '180d', '2a37');
307
+
308
+ expect(services[0]).toEqual(['aabbccddeeff', ['180d']]);
309
+ expect(chars[0][0]).toBe('aabbccddeeff');
310
+ expect(chars[0][1]).toBe('180d');
311
+ expect(chars[0][2]).toEqual([{ uuid: '2a37', properties: ['read', 'notify'] }]);
312
+ expect(descs[0][0]).toBe('aabbccddeeff');
313
+ expect(descs[0][3]).toEqual(['2902']);
314
+ });
315
+
316
+ test('read emits "read" with the characteristic value', async () => {
317
+ const tree = adapterTree({
318
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': {
319
+ 'org.bluez.Device1': { Address: 'AA:BB:CC:DD:EE:FF', AddressType: 'public' }
320
+ },
321
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001': {
322
+ 'org.bluez.GattService1': { UUID: '0000180d-0000-1000-8000-00805f9b34fb' }
323
+ },
324
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002': {
325
+ 'org.bluez.GattCharacteristic1': {
326
+ UUID: '00002a37-0000-1000-8000-00805f9b34fb',
327
+ Flags: ['read']
328
+ }
329
+ }
330
+ });
331
+ resetState(tree);
332
+
333
+ const bindings = new DbusBindings();
334
+ bindings.start();
335
+ await flush();
336
+
337
+ const reads = [];
338
+ bindings.on('read', (...a) => reads.push(a));
339
+
340
+ bindings.read('aabbccddeeff', '180d', '2a37');
341
+ await flush();
342
+
343
+ expect(reads.length).toBe(1);
344
+ expect(reads[0][0]).toBe('aabbccddeeff');
345
+ expect(reads[0][1]).toBe('180d');
346
+ expect(reads[0][2]).toBe('2a37');
347
+ expect(Buffer.isBuffer(reads[0][3])).toBe(true);
348
+ expect(reads[0][4]).toBe(false); // not a notification
349
+ });
350
+
351
+ test('notify(true) calls StartNotify and forwards value updates as notifications', async () => {
352
+ const charPath = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002';
353
+ const tree = adapterTree({
354
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': {
355
+ 'org.bluez.Device1': { Address: 'AA:BB:CC:DD:EE:FF', AddressType: 'public' }
356
+ },
357
+ '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001': {
358
+ 'org.bluez.GattService1': { UUID: '0000180d-0000-1000-8000-00805f9b34fb' }
359
+ },
360
+ [charPath]: {
361
+ 'org.bluez.GattCharacteristic1': {
362
+ UUID: '00002a37-0000-1000-8000-00805f9b34fb',
363
+ Flags: ['notify']
364
+ }
365
+ }
366
+ });
367
+ resetState(tree);
368
+
369
+ const bindings = new DbusBindings();
370
+ bindings.start();
371
+ await flush();
372
+
373
+ const notifies = [];
374
+ const reads = [];
375
+ bindings.on('notify', (...a) => notifies.push(a));
376
+ bindings.on('read', (...a) => reads.push(a));
377
+
378
+ bindings.notify('aabbccddeeff', '180d', '2a37', true);
379
+ await flush();
380
+
381
+ const proxy = state.proxies.get(charPath);
382
+ expect(proxy.interfaces['org.bluez.GattCharacteristic1'].StartNotify).toHaveBeenCalled();
383
+ expect(notifies[0]).toEqual(['aabbccddeeff', '180d', '2a37', true]);
384
+
385
+ // Simulate BlueZ pushing a notification
386
+ proxy.interfaces['org.freedesktop.DBus.Properties'].emit(
387
+ 'PropertiesChanged',
388
+ 'org.bluez.GattCharacteristic1',
389
+ wrapDict({ Value: Buffer.from([0xaa]) }),
390
+ []
391
+ );
392
+
393
+ expect(reads.length).toBe(1);
394
+ expect(reads[0][4]).toBe(true); // isNotification
395
+ expect(reads[0][3].equals(Buffer.from([0xaa]))).toBe(true);
396
+ });
397
+
398
+ test('readHandle is unsupported and emits an error', () => {
399
+ const bindings = new DbusBindings();
400
+ const events = [];
401
+ bindings.on('handleRead', (...a) => events.push(a));
402
+ bindings.readHandle('aabbccddeeff', 0x42);
403
+ expect(events.length).toBe(1);
404
+ expect(events[0][3]).toBeInstanceOf(Error);
405
+ expect(events[0][3].message).toMatch(/not supported/);
406
+ });
407
+
408
+ test('addressToId normalizes a MAC into a noble peripheral id', () => {
409
+ const bindings = new DbusBindings();
410
+ expect(bindings.addressToId('AA:BB:CC:DD:EE:FF')).toBe('aabbccddeeff');
411
+ });
412
+ });
@@ -0,0 +1,78 @@
1
+ const {
2
+ normalizeUuid,
3
+ expandUuid,
4
+ addressToId,
5
+ idToAddress,
6
+ devicePathToAddress,
7
+ deviceIdFromPath,
8
+ devicePathFromAddress
9
+ } = require('../../../lib/dbus/uuid');
10
+
11
+ describe('dbus/uuid', () => {
12
+ describe('normalizeUuid', () => {
13
+ test('shortens 16-bit Bluetooth base UUIDs', () => {
14
+ expect(normalizeUuid('00002a37-0000-1000-8000-00805f9b34fb')).toBe('2a37');
15
+ });
16
+
17
+ test('shortens 32-bit Bluetooth base UUIDs', () => {
18
+ expect(normalizeUuid('1234abcd-0000-1000-8000-00805f9b34fb')).toBe('1234abcd');
19
+ });
20
+
21
+ test('strips dashes for non-base 128-bit UUIDs', () => {
22
+ expect(normalizeUuid('6E400001-B5A3-F393-E0A9-E50E24DCCA9E'))
23
+ .toBe('6e400001b5a3f393e0a9e50e24dcca9e');
24
+ });
25
+
26
+ test('returns falsy values unchanged', () => {
27
+ expect(normalizeUuid(undefined)).toBeUndefined();
28
+ expect(normalizeUuid(null)).toBeNull();
29
+ expect(normalizeUuid('')).toBe('');
30
+ });
31
+ });
32
+
33
+ describe('expandUuid', () => {
34
+ test('expands 16-bit short form', () => {
35
+ expect(expandUuid('2a37')).toBe('00002a37-0000-1000-8000-00805f9b34fb');
36
+ });
37
+
38
+ test('expands 32-bit short form', () => {
39
+ expect(expandUuid('1234abcd')).toBe('1234abcd-0000-1000-8000-00805f9b34fb');
40
+ });
41
+
42
+ test('expands no-dash 128-bit form', () => {
43
+ expect(expandUuid('6e400001b5a3f393e0a9e50e24dcca9e'))
44
+ .toBe('6e400001-b5a3-f393-e0a9-e50e24dcca9e');
45
+ });
46
+ });
47
+
48
+ describe('addressToId / idToAddress', () => {
49
+ test('addressToId strips colons and lowercases', () => {
50
+ expect(addressToId('AA:BB:CC:DD:EE:FF')).toBe('aabbccddeeff');
51
+ });
52
+
53
+ test('idToAddress reverses the transformation', () => {
54
+ expect(idToAddress('aabbccddeeff')).toBe('AA:BB:CC:DD:EE:FF');
55
+ });
56
+ });
57
+
58
+ describe('devicePathToAddress / deviceIdFromPath', () => {
59
+ test('parses BlueZ device path', () => {
60
+ expect(devicePathToAddress('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF'))
61
+ .toBe('AA:BB:CC:DD:EE:FF');
62
+ expect(deviceIdFromPath('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF'))
63
+ .toBe('aabbccddeeff');
64
+ });
65
+
66
+ test('returns null for non-device paths', () => {
67
+ expect(devicePathToAddress('/org/bluez/hci0')).toBeNull();
68
+ expect(deviceIdFromPath('/org/bluez/hci0')).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe('devicePathFromAddress', () => {
73
+ test('builds the BlueZ device path', () => {
74
+ expect(devicePathFromAddress('/org/bluez/hci0', 'aa:bb:cc:dd:ee:ff'))
75
+ .toBe('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF');
76
+ });
77
+ });
78
+ });