node-red-contrib-ble-scanner 0.0.1 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # node-red-contrib-ble-scanner
2
2
 
3
- Strict TypeScript Node.js library, CLI, and Node-RED nodes for collecting raw Bluetooth LE notification messages.
3
+ Strict TypeScript Node.js library, CLI, and Node-RED nodes for collecting raw Bluetooth LE advertisement manufacturer data.
4
4
 
5
5
  The package supports two BLE backend implementations:
6
6
 
@@ -8,47 +8,40 @@ The package supports two BLE backend implementations:
8
8
  - `bluez`: uses Linux BlueZ over D-Bus
9
9
  - `noble`: uses `@abandonware/noble`
10
10
 
11
+ The scanner does not connect to BLE devices and does not parse or decrypt device-specific payloads. It listens for BLE advertisements and returns the raw manufacturer data bytes.
12
+
11
13
  ## Library
12
14
 
13
15
  The package exports a typed ESM API from `dist/index.js`:
14
16
 
15
17
  - `discoverDevices(options)` returns matching BLE devices
16
- - `readDevices(options)` discovers, connects, subscribes, and collects raw notifications
17
- - `readDevice(device, options)` reads one discovered device
18
- - `connectReaders(options)` discovers and connects once, then returns persistent readers with `read()` and `disconnect()`
19
- - `shutdownBluetooth()` stops active BLE sessions
18
+ - `readDevices(options)` listens for matching advertisements and collects raw manufacturer data
19
+ - `readDevice(device, options)` reads advertisements for one discovered device
20
+ - `shutdownBluetooth()` stops active BLE scans
20
21
 
21
- Each reading contains device metadata, a timestamp, the subscribed notification characteristic UUID, and a `messages` array. Every raw message includes the original Buffer as `data` plus `hex`, `base64`, `bytes`, `length`, and `timestamp`.
22
+ Each reading contains device metadata, a timestamp, and a `messages` array. Every raw message includes the company id, original Buffer as `data`, plus `hex`, `base64`, `bytes`, `length`, and `timestamp`.
22
23
 
23
24
  ```js
24
- import { connectReaders } from 'node-red-contrib-ble-scanner';
25
+ import { readDevices } from 'node-red-contrib-ble-scanner';
25
26
 
26
- const readers = await connectReaders({
27
+ const readings = await readDevices({
27
28
  deviceName: 'My BLE Device',
28
- scanServiceUuid: 'fff0',
29
- matchServiceUuid: 'fff0',
30
- notifyUuid: 'fff1',
31
29
  listenMs: 5000
32
30
  });
33
31
 
34
- try {
35
- const reading = await readers[0].read();
36
- console.log(reading.messages.map((message) => message.hex));
37
- } finally {
38
- await Promise.all(readers.map((reader) => reader.disconnect()));
39
- }
32
+ console.log(readings.flatMap((reading) => reading.messages.map((message) => message.hex)));
40
33
  ```
41
34
 
42
35
  ## Nodes
43
36
 
44
- - `ble-reader-device`: config node for BLE discovery, device selection, and notification UUID
45
- - `ble-reader-read`: input node that collects raw notifications
37
+ - `ble-reader-device`: config node for BLE advertisement scanning, device selection, and filters
38
+ - `ble-reader-read`: input node that collects raw manufacturer data
46
39
 
47
40
  Use the config node's search button to discover devices from the Node-RED editor. Select one exact advertised device name, or keep "All discovered devices".
48
41
 
49
42
  The output is placed in `msg.payload` as an array with zero, one, or multiple readings.
50
43
 
51
- Incoming `msg.payload` may contain `{ deviceName, listenMs, serviceUuid, notifyUuid }` to override the configured values for one read.
44
+ Incoming `msg.payload` may contain `{ deviceName, listenMs, serviceUuid }` to override the configured values for one read.
52
45
 
53
46
  ## CLI
54
47
 
@@ -65,12 +58,12 @@ Useful options:
65
58
  node ./bin/ble-reader.js discover
66
59
  node ./bin/ble-reader.js discover --name-prefix SK
67
60
  node ./bin/ble-reader.js discover --service-uuid fff0
68
- node ./bin/ble-reader.js read "My BLE Device" --notify-uuid fff1 --listen-ms 5000
61
+ node ./bin/ble-reader.js read "My BLE Device" --listen-ms 5000
69
62
  node ./bin/ble-reader.js read --bluetooth bluez --debug
70
63
  node ./bin/ble-reader.js read "My BLE Device" --interval 5
71
64
  ```
72
65
 
73
- `read --interval 5` keeps the BLE session open and collects notifications every five seconds until stopped.
66
+ `read --interval 5` repeats an advertisement scan every five seconds until stopped.
74
67
 
75
68
  ## Development
76
69
 
@@ -1,27 +1,36 @@
1
1
  export type BluetoothBackendName = 'auto' | 'bluez' | 'noble';
2
2
  export type ResolvedBluetoothBackendName = Exclude<BluetoothBackendName, 'auto'>;
3
3
  export type Logger = (message: string) => void;
4
+ export interface BleManufacturerData {
5
+ companyId: string;
6
+ companyIdHex: string;
7
+ length: number;
8
+ data: Buffer;
9
+ hex: string;
10
+ base64: string;
11
+ bytes: number[];
12
+ }
4
13
  export interface BleDiscoveredDevice {
5
14
  id: string;
6
15
  name: string | null;
7
16
  address: string | null;
8
17
  addressType?: string | null;
9
18
  rssi: number | null;
19
+ serviceUuids?: string[];
20
+ manufacturerData?: BleManufacturerData[];
10
21
  backend: ResolvedBluetoothBackendName;
11
22
  }
12
- export interface RawBleMessage {
23
+ export interface RawBleMessage extends BleManufacturerData {
13
24
  timestamp: string;
14
- uuid: string;
15
- length: number;
16
- data: Buffer;
17
- hex: string;
18
- base64: string;
19
- bytes: number[];
25
+ source: 'manufacturerData';
26
+ }
27
+ export interface BleAdvertisement {
28
+ device: BleDiscoveredDevice;
29
+ message: RawBleMessage;
20
30
  }
21
31
  export interface BleReading {
22
32
  device: BleDiscoveredDevice;
23
33
  timestamp: string;
24
- notifyUuid: string;
25
34
  messages: RawBleMessage[];
26
35
  }
27
36
  export interface ReadOptions {
@@ -30,8 +39,6 @@ export interface ReadOptions {
30
39
  deviceName?: string;
31
40
  timeoutMs?: number;
32
41
  listenMs?: number;
33
- connectTimeoutMs?: number;
34
- notifyUuid?: string;
35
42
  scanServiceUuid?: string | null;
36
43
  matchServiceUuid?: string | null;
37
44
  logger?: Logger;
@@ -53,30 +60,14 @@ export interface ResolvedDiscoverOptions {
53
60
  matchServiceUuid: string | null;
54
61
  logger: Logger;
55
62
  }
56
- export interface ResolvedConnectOptions {
57
- device: BleDiscoveredDevice;
58
- timeoutMs: number;
59
- connectTimeoutMs: number;
60
- notifyUuid: string;
61
- logger: Logger;
62
- }
63
- export interface BleCharacteristic {
64
- uuid: string;
65
- properties: string[];
66
- write(data: Buffer, withoutResponse?: boolean): Promise<void>;
67
- subscribe(): Promise<void>;
68
- onData(listener: (data: Buffer) => void): void;
69
- removeDataListener(listener: (data: Buffer) => void): void;
70
- }
71
- export interface BleSession {
72
- device: BleDiscoveredDevice;
73
- notify: BleCharacteristic;
74
- disconnect(): Promise<void>;
63
+ export interface ResolvedScanOptions extends ResolvedDiscoverOptions {
64
+ listenMs: number;
65
+ onAdvertisement(advertisement: BleAdvertisement): void;
75
66
  }
76
67
  export interface BluetoothBackend {
77
68
  name: ResolvedBluetoothBackendName;
78
69
  isAvailable(logger?: Logger): Promise<boolean>;
79
70
  discover(options: ResolvedDiscoverOptions): Promise<BleDiscoveredDevice[]>;
80
- connect(options: ResolvedConnectOptions): Promise<BleSession>;
71
+ scanAdvertisements(options: ResolvedScanOptions): Promise<void>;
81
72
  shutdown(): Promise<void>;
82
73
  }
@@ -6,5 +6,5 @@ interface BluezAdapter {
6
6
  SetDiscoveryFilter(filter: Record<string, unknown>): Promise<void>;
7
7
  }
8
8
  export declare const bluezBackend: BluetoothBackend;
9
- export declare function setBluezDiscoveryFilter(adapter: BluezAdapter, Variant: BluezVariantConstructor, serviceUuid: string | null, logger: Logger): Promise<void>;
9
+ export declare function setBluezDiscoveryFilter(adapter: BluezAdapter, Variant: BluezVariantConstructor, serviceUuid: string | null, duplicateData: boolean, logger: Logger): Promise<void>;
10
10
  export {};
package/dist/ble/bluez.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { setTimeout as delay } from 'node:timers/promises';
3
- import { errorMessage, formatCanonicalUuid, matchesBleDevice, normalizeUuid, nullableNumber, nullableString, unboxBluezValue, uniqueDevices } from './utils.js';
3
+ import { createManufacturerData, createRawManufacturerMessage, errorMessage, formatCanonicalUuid, matchesBleDevice, nullableNumber, nullableString, normalizeUuid, unboxBluezValue, uniqueDevices } from './utils.js';
4
4
  const requireOptional = createRequire(import.meta.url);
5
+ const BLUEZ_SERVICE = 'org.bluez';
6
+ const DBUS_OBJECT_MANAGER = 'org.freedesktop.DBus.ObjectManager';
7
+ const DBUS_PROPERTIES = 'org.freedesktop.DBus.Properties';
8
+ const BLUEZ_ADAPTER = 'org.bluez.Adapter1';
9
+ const BLUEZ_DEVICE = 'org.bluez.Device1';
5
10
  const DISCOVERY_POLL_MS = 500;
6
- let activeSessions = new Set();
7
11
  export const bluezBackend = {
8
12
  name: 'bluez',
9
13
  isAvailable: isBluezAvailable,
10
14
  discover: discoverBluezDevices,
11
- connect: connectBluezDevice,
12
- shutdown: shutdownBluez
15
+ scanAdvertisements: scanBluezAdvertisements,
16
+ shutdown: async () => { }
13
17
  };
14
18
  async function isBluezAvailable(logger = () => { }) {
15
19
  if (process.platform !== 'linux')
@@ -18,8 +22,8 @@ async function isBluezAvailable(logger = () => { }) {
18
22
  const { systemBus } = loadDbusNext();
19
23
  const bus = systemBus();
20
24
  try {
21
- const object = await bus.getProxyObject('org.bluez', '/');
22
- const objectManager = object.getInterface('org.freedesktop.DBus.ObjectManager');
25
+ const object = await bus.getProxyObject(BLUEZ_SERVICE, '/');
26
+ const objectManager = object.getInterface(DBUS_OBJECT_MANAGER);
23
27
  const objects = await objectManager.GetManagedObjects();
24
28
  return findBluezAdapterPath(objects) !== null;
25
29
  }
@@ -33,207 +37,152 @@ async function isBluezAvailable(logger = () => { }) {
33
37
  }
34
38
  }
35
39
  async function discoverBluezDevices(options) {
40
+ const devices = new Map();
41
+ await runBluezScan({
42
+ timeoutMs: options.timeoutMs,
43
+ scanServiceUuid: options.scanServiceUuid,
44
+ logger: options.logger,
45
+ onDevice: (device) => {
46
+ if (matchesDiscoveredDevice(device, options))
47
+ devices.set(deviceKey(device), device);
48
+ }
49
+ });
50
+ return uniqueDevices([...devices.values()]);
51
+ }
52
+ async function scanBluezAdvertisements(options) {
53
+ await runBluezScan({
54
+ timeoutMs: options.listenMs,
55
+ scanServiceUuid: options.scanServiceUuid,
56
+ logger: options.logger,
57
+ onAdvertisement: (device, payload) => {
58
+ if (!matchesDiscoveredDevice(device, options))
59
+ return;
60
+ options.onAdvertisement({
61
+ device,
62
+ message: createRawManufacturerMessage(payload.companyId, payload.data)
63
+ });
64
+ }
65
+ });
66
+ }
67
+ async function runBluezScan({ timeoutMs, scanServiceUuid, logger, onDevice, onAdvertisement }) {
36
68
  if (process.platform !== 'linux')
37
69
  throw new Error('BlueZ backend is only available on Linux.');
38
70
  const { systemBus, Variant } = loadDbusNext();
39
71
  const bus = systemBus();
40
- try {
41
- const { objectManager, adapter } = await openBluezAdapter(bus);
42
- const canonicalScanServiceUuid = options.scanServiceUuid ? formatCanonicalUuid(options.scanServiceUuid) : null;
43
- if (options.deviceName) {
44
- const cachedMatches = uniqueDevices(findBluezDevices(await objectManager.GetManagedObjects(), options));
45
- if (cachedMatches.length > 0) {
46
- options.logger(`BlueZ found ${cachedMatches[0].name || cachedMatches[0].address || cachedMatches[0].id} in managed objects.`);
47
- return cachedMatches;
48
- }
72
+ const deviceProperties = new Map();
73
+ const deviceWatchers = new Map();
74
+ const lastManufacturerData = new Map();
75
+ let pollTimer = null;
76
+ let polling = false;
77
+ const processDeviceProperties = (path, properties) => {
78
+ const merged = { ...(deviceProperties.get(path) || {}), ...properties };
79
+ deviceProperties.set(path, merged);
80
+ const device = toDiscoveredDevice(path, merged, 'bluez');
81
+ onDevice?.(device);
82
+ for (const payload of readManufacturerData(merged.ManufacturerData)) {
83
+ const key = `${path}:${payload.companyId}`;
84
+ const hex = payload.data.toString('hex');
85
+ if (lastManufacturerData.get(key) === hex)
86
+ continue;
87
+ lastManufacturerData.set(key, hex);
88
+ const advertisedDevice = toDiscoveredDevice(path, { ...merged, ManufacturerData: new Map([[payload.companyId, payload.data]]) }, 'bluez');
89
+ onAdvertisement?.(advertisedDevice, payload);
49
90
  }
50
- await setBluezDiscoveryFilter(adapter, Variant, canonicalScanServiceUuid, options.logger);
51
- await adapter.StartDiscovery();
52
- options.logger(`BlueZ discovery started. Waiting ${options.timeoutMs}ms.`);
53
- const devices = [];
54
- const startedAt = Date.now();
91
+ };
92
+ const watchDeviceProperties = async (path) => {
93
+ if (deviceWatchers.has(path))
94
+ return;
55
95
  try {
56
- while (Date.now() - startedAt < options.timeoutMs) {
96
+ const object = await bus.getProxyObject(BLUEZ_SERVICE, path);
97
+ const properties = object.getInterface(DBUS_PROPERTIES);
98
+ const listener = (interfaceName, changed) => {
99
+ if (interfaceName === BLUEZ_DEVICE)
100
+ processDeviceProperties(path, changed);
101
+ };
102
+ properties.on('PropertiesChanged', listener);
103
+ deviceWatchers.set(path, { properties, listener });
104
+ }
105
+ catch (error) {
106
+ logger(`Warning: could not watch BlueZ properties for ${path}: ${errorMessage(error)}.`);
107
+ }
108
+ };
109
+ const processInterfaces = (path, interfaces) => {
110
+ const device = interfaces[BLUEZ_DEVICE];
111
+ if (!device)
112
+ return;
113
+ void watchDeviceProperties(path);
114
+ processDeviceProperties(path, device);
115
+ };
116
+ try {
117
+ const { objectManager, adapter } = await openBluezAdapter(bus);
118
+ const onInterfacesAdded = (path, interfaces) => processInterfaces(path, interfaces);
119
+ objectManager.on?.('InterfacesAdded', onInterfacesAdded);
120
+ const pollManagedObjects = async () => {
121
+ if (polling)
122
+ return;
123
+ polling = true;
124
+ try {
57
125
  const objects = await objectManager.GetManagedObjects();
58
- devices.push(...findBluezDevices(objects, options));
59
- if (options.deviceName && devices.length > 0)
60
- break;
61
- await delay(DISCOVERY_POLL_MS);
126
+ for (const [path, interfaces] of Object.entries(objects))
127
+ processInterfaces(path, interfaces);
62
128
  }
129
+ finally {
130
+ polling = false;
131
+ }
132
+ };
133
+ try {
134
+ await setBluezDiscoveryFilter(adapter, Variant, scanServiceUuid, true, logger);
135
+ await adapter.StartDiscovery();
136
+ logger(`BlueZ advertisement scan started. Listening ${timeoutMs}ms.`);
137
+ await pollManagedObjects();
138
+ pollTimer = setInterval(() => {
139
+ void pollManagedObjects().catch((error) => logger(`Warning: could not poll BlueZ devices: ${errorMessage(error)}.`));
140
+ }, DISCOVERY_POLL_MS);
141
+ await delay(timeoutMs);
63
142
  }
64
143
  finally {
65
- await adapter.StopDiscovery().catch((error) => options.logger(`Warning: could not stop BlueZ discovery: ${errorMessage(error)}.`));
144
+ if (pollTimer)
145
+ clearInterval(pollTimer);
146
+ objectManager.removeListener?.('InterfacesAdded', onInterfacesAdded);
147
+ for (const { properties, listener } of deviceWatchers.values())
148
+ properties.removeListener('PropertiesChanged', listener);
149
+ await adapter.StopDiscovery().catch((error) => logger(`Warning: could not stop BlueZ discovery: ${errorMessage(error)}.`));
66
150
  }
67
- return uniqueDevices(devices);
68
151
  }
69
152
  finally {
70
153
  bus.disconnect();
71
154
  }
72
155
  }
73
- async function connectBluezDevice({ device, timeoutMs, connectTimeoutMs, notifyUuid, logger }) {
74
- if (process.platform !== 'linux')
75
- throw new Error('BlueZ backend is only available on Linux.');
76
- const { systemBus } = loadDbusNext();
77
- const bus = systemBus();
78
- try {
79
- const objectManagerObject = await bus.getProxyObject('org.bluez', '/');
80
- const objectManager = objectManagerObject.getInterface('org.freedesktop.DBus.ObjectManager');
81
- let objects = await objectManager.GetManagedObjects();
82
- let target = findBluezDeviceByDiscovered(objects, device);
83
- if (!target) {
84
- const matches = await discoverBluezDevices({
85
- namePrefix: '',
86
- deviceName: device.name,
87
- timeoutMs,
88
- scanServiceUuid: null,
89
- matchServiceUuid: null,
90
- logger
91
- });
92
- const refreshed = matches.find((match) => match.name === device.name || match.address === device.address || match.id === device.id);
93
- if (!refreshed)
94
- throw new Error(`Could not rediscover ${device.name || device.address || device.id}.`);
95
- objects = await objectManager.GetManagedObjects();
96
- target = findBluezDeviceByDiscovered(objects, refreshed);
97
- }
98
- if (!target)
99
- throw new Error(`Could not find ${device.name || device.address || device.id} in BlueZ managed objects.`);
100
- logger(`BlueZ connecting to ${target.address || '<unknown address>'} at ${target.path}.`);
101
- const deviceObject = await bus.getProxyObject('org.bluez', target.path);
102
- const bluezDevice = deviceObject.getInterface('org.bluez.Device1');
103
- const deviceProperties = deviceObject.getInterface('org.freedesktop.DBus.Properties');
104
- if (!(await getBluezBoolean(deviceProperties, 'org.bluez.Device1', 'Connected'))) {
105
- await withTimeout(bluezDevice.Connect(), connectTimeoutMs, 'BlueZ Device1.Connect()');
106
- }
107
- await waitForBluezBooleanProperty(deviceProperties, 'org.bluez.Device1', 'ServicesResolved', true, timeoutMs);
108
- logger('BlueZ connected and services resolved.');
109
- const refreshedObjects = await objectManager.GetManagedObjects();
110
- const characteristics = await buildBluezCharacteristics({ bus, objects: refreshedObjects, devicePath: target.path, notifyUuid, logger });
111
- await characteristics.notify.subscribe();
112
- logger(`Subscribed to BLE notification characteristic ${characteristics.notify.uuid} via BlueZ.`);
113
- const session = {
114
- device: toDiscoveredDevice(target.path, refreshedObjects[target.path]?.['org.bluez.Device1'] || {}, 'bluez'),
115
- notify: characteristics.notify,
116
- disconnect: async () => {
117
- activeSessions.delete(session);
118
- await bluezDevice.Disconnect().catch(() => { });
119
- bus.disconnect();
120
- }
121
- };
122
- activeSessions.add(session);
123
- return session;
124
- }
125
- catch (error) {
126
- bus.disconnect();
127
- throw error;
128
- }
129
- }
130
156
  async function openBluezAdapter(bus) {
131
- const objectManagerObject = await bus.getProxyObject('org.bluez', '/');
132
- const objectManager = objectManagerObject.getInterface('org.freedesktop.DBus.ObjectManager');
157
+ const objectManagerObject = await bus.getProxyObject(BLUEZ_SERVICE, '/');
158
+ const objectManager = objectManagerObject.getInterface(DBUS_OBJECT_MANAGER);
133
159
  const objects = await objectManager.GetManagedObjects();
134
160
  const adapterPath = findBluezAdapterPath(objects);
135
161
  if (!adapterPath)
136
162
  throw new Error('Could not find a BlueZ adapter via org.bluez ObjectManager.');
137
- const adapterObject = await bus.getProxyObject('org.bluez', adapterPath);
138
- const adapter = adapterObject.getInterface('org.bluez.Adapter1');
163
+ const adapterObject = await bus.getProxyObject(BLUEZ_SERVICE, adapterPath);
164
+ const adapter = adapterObject.getInterface(BLUEZ_ADAPTER);
139
165
  return { objectManager, adapter, adapterPath };
140
166
  }
141
- async function buildBluezCharacteristics({ bus, objects, devicePath, notifyUuid, logger }) {
142
- const byUuid = new Map();
143
- for (const [path, interfaces] of Object.entries(objects)) {
144
- if (!path.startsWith(`${devicePath}/`))
145
- continue;
146
- const characteristic = interfaces['org.bluez.GattCharacteristic1'];
147
- if (!characteristic)
148
- continue;
149
- const uuid = nullableString(unboxBluezValue(characteristic.UUID));
150
- if (!uuid)
151
- continue;
152
- byUuid.set(normalizeUuid(uuid), { path, properties: characteristic });
153
- }
154
- logger(`BlueZ discovered ${byUuid.size} GATT characteristic(s): ${[...byUuid.keys()].sort().join(', ')}.`);
155
- const notify = byUuid.get(normalizeUuid(notifyUuid));
156
- if (!notify) {
157
- throw new Error(`Missing BLE notify characteristic ${notifyUuid} via BlueZ. Found: ${[...byUuid.keys()].sort().join(', ')}`);
158
- }
159
- return {
160
- notify: await wrapBluezCharacteristic(bus, notify.path, notify.properties)
161
- };
162
- }
163
- async function wrapBluezCharacteristic(bus, path, initialProperties) {
164
- const object = await bus.getProxyObject('org.bluez', path);
165
- const characteristic = object.getInterface('org.bluez.GattCharacteristic1');
166
- const properties = object.getInterface('org.freedesktop.DBus.Properties');
167
- const listeners = new Set();
168
- const flags = Array.isArray(unboxBluezValue(initialProperties.Flags)) ? unboxBluezValue(initialProperties.Flags).map(String) : [];
169
- let writeQueue = Promise.resolve();
170
- const onPropertiesChanged = (interfaceName, changed) => {
171
- if (interfaceName !== 'org.bluez.GattCharacteristic1' || !('Value' in changed))
172
- return;
173
- const value = Buffer.from(unboxBluezValue(changed.Value) || []);
174
- for (const listener of listeners)
175
- listener(value);
176
- };
177
- properties.on('PropertiesChanged', onPropertiesChanged);
178
- return {
179
- uuid: normalizeUuid(String(unboxBluezValue(initialProperties.UUID) || '')),
180
- properties: flags,
181
- async write(data, withoutResponse = false) {
182
- const options = withoutResponse ? { type: new (loadDbusNext().Variant)('s', 'command') } : {};
183
- writeQueue = writeQueue.then(() => writeBluezValueWithRetry(characteristic, [...data], options));
184
- await writeQueue;
185
- },
186
- async subscribe() {
187
- await characteristic.StartNotify();
188
- },
189
- onData(listener) {
190
- listeners.add(listener);
191
- },
192
- removeDataListener(listener) {
193
- listeners.delete(listener);
194
- }
195
- };
196
- }
197
167
  function findBluezAdapterPath(objects) {
198
168
  for (const [path, interfaces] of Object.entries(objects)) {
199
- if (interfaces['org.bluez.Adapter1'])
169
+ if (interfaces[BLUEZ_ADAPTER])
200
170
  return path;
201
171
  }
202
172
  return null;
203
173
  }
204
- function findBluezDevices(objects, options) {
205
- const devices = [];
206
- for (const [path, interfaces] of Object.entries(objects)) {
207
- const device = interfaces['org.bluez.Device1'];
208
- if (!device)
209
- continue;
210
- const uuids = readBluezUuids(device);
211
- const discovered = toDiscoveredDevice(path, device, 'bluez');
212
- if (matchesBleDevice({ name: discovered.name, address: discovered.address, serviceUuids: uuids }, { namePrefix: options.namePrefix, deviceName: options.deviceName, serviceUuid: options.matchServiceUuid })) {
213
- devices.push(discovered);
214
- }
215
- }
216
- return devices;
217
- }
218
- function findBluezDeviceByDiscovered(objects, selected) {
219
- for (const [path, interfaces] of Object.entries(objects)) {
220
- const device = interfaces['org.bluez.Device1'];
221
- if (!device)
222
- continue;
223
- const discovered = toDiscoveredDevice(path, device, 'bluez');
224
- if (selected.id === path || (!!selected.name && selected.name === discovered.name) || (!!selected.address && selected.address.toUpperCase() === discovered.address?.toUpperCase())) {
225
- return { path, address: discovered.address };
226
- }
227
- }
228
- return null;
174
+ function matchesDiscoveredDevice(device, options) {
175
+ return matchesBleDevice({ name: device.name, address: device.address, serviceUuids: device.serviceUuids }, { namePrefix: options.namePrefix, deviceName: options.deviceName, serviceUuid: options.matchServiceUuid });
229
176
  }
230
177
  function toDiscoveredDevice(path, device, backend) {
231
178
  return {
232
179
  id: path,
233
180
  address: nullableString(unboxBluezValue(device.Address)),
234
- addressType: null,
181
+ addressType: nullableString(unboxBluezValue(device.AddressType)),
235
182
  name: nullableString(unboxBluezValue(device.Name)) || nullableString(unboxBluezValue(device.Alias)),
236
183
  rssi: nullableNumber(unboxBluezValue(device.RSSI)),
184
+ serviceUuids: readBluezUuids(device),
185
+ manufacturerData: readManufacturerData(device.ManufacturerData).map((payload) => createManufacturerData(payload.companyId, payload.data)),
237
186
  backend
238
187
  };
239
188
  }
@@ -241,70 +190,45 @@ function readBluezUuids(device) {
241
190
  const value = unboxBluezValue(device.UUIDs);
242
191
  return Array.isArray(value) ? value.map((uuid) => String(uuid)) : [];
243
192
  }
244
- export async function setBluezDiscoveryFilter(adapter, Variant, serviceUuid, logger) {
193
+ function readManufacturerData(value) {
194
+ const data = unboxBluezValue(value);
195
+ if (!data || typeof data !== 'object')
196
+ return [];
197
+ const entries = data instanceof Map ? Array.from(data.entries()) : Object.entries(data);
198
+ const payloads = [];
199
+ for (const [companyId, bytes] of entries) {
200
+ const buffer = bytesToBuffer(bytes);
201
+ if (buffer && buffer.length > 0)
202
+ payloads.push({ companyId: String(companyId), data: buffer });
203
+ }
204
+ return payloads;
205
+ }
206
+ function bytesToBuffer(value) {
207
+ const unboxed = unboxBluezValue(value);
208
+ if (Buffer.isBuffer(unboxed))
209
+ return unboxed;
210
+ if (unboxed instanceof Uint8Array)
211
+ return Buffer.from(unboxed);
212
+ if (Array.isArray(unboxed) && unboxed.every((byte) => typeof byte === 'number'))
213
+ return Buffer.from(unboxed);
214
+ return null;
215
+ }
216
+ export async function setBluezDiscoveryFilter(adapter, Variant, serviceUuid, duplicateData, logger) {
245
217
  const filter = {
246
218
  Transport: new Variant('s', 'le'),
247
- DuplicateData: new Variant('b', false)
219
+ DuplicateData: new Variant('b', duplicateData)
248
220
  };
249
221
  if (serviceUuid)
250
- filter.UUIDs = new Variant('as', [serviceUuid]);
251
- const names = ['transport', 'duplicates'];
222
+ filter.UUIDs = new Variant('as', [formatCanonicalUuid(serviceUuid)]);
223
+ const names = ['transport', duplicateData ? 'duplicate advertisement data' : 'deduplicated advertisement data'];
252
224
  if (serviceUuid)
253
- names.push('service UUID');
225
+ names.push(`service UUID ${normalizeUuid(serviceUuid)}`);
254
226
  await adapter.SetDiscoveryFilter(filter)
255
227
  .then(() => logger(`BlueZ discovery filter applied: ${names.join(', ')}.`))
256
228
  .catch((error) => logger(`Warning: could not set BlueZ discovery filter (${names.join(', ')}): ${errorMessage(error)}.`));
257
229
  }
258
- async function getBluezBoolean(properties, interfaceName, propertyName) {
259
- const value = await properties.Get(interfaceName, propertyName).catch(() => null);
260
- const unboxed = unboxBluezValue(value);
261
- return typeof unboxed === 'boolean' ? unboxed : null;
262
- }
263
- async function waitForBluezBooleanProperty(properties, interfaceName, propertyName, expected, timeoutMs) {
264
- const startedAt = Date.now();
265
- while (Date.now() - startedAt < timeoutMs) {
266
- if ((await getBluezBoolean(properties, interfaceName, propertyName)) === expected)
267
- return;
268
- await delay(250);
269
- }
270
- throw new Error(`Timed out after ${timeoutMs}ms waiting for BlueZ ${propertyName}=${expected}.`);
271
- }
272
- async function writeBluezValueWithRetry(characteristic, value, options) {
273
- let lastError;
274
- for (let attempt = 0; attempt < 12; attempt += 1) {
275
- try {
276
- await characteristic.WriteValue(value, options);
277
- return;
278
- }
279
- catch (error) {
280
- lastError = error;
281
- if (!isBluezInProgressError(error))
282
- throw error;
283
- await delay(80);
284
- }
285
- }
286
- throw lastError instanceof Error ? lastError : new Error(String(lastError));
287
- }
288
- function isBluezInProgressError(error) {
289
- const message = errorMessage(error).toLowerCase();
290
- return message.includes('in progress') || message.includes('inprogress');
291
- }
292
- function withTimeout(promise, timeoutMs, label) {
293
- return new Promise((resolve, reject) => {
294
- const timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms during ${label}.`)), timeoutMs);
295
- promise.then((value) => {
296
- clearTimeout(timeout);
297
- resolve(value);
298
- }, (error) => {
299
- clearTimeout(timeout);
300
- reject(error);
301
- });
302
- });
303
- }
304
- async function shutdownBluez() {
305
- const sessions = [...activeSessions];
306
- activeSessions = new Set();
307
- await Promise.all(sessions.map((session) => session.disconnect().catch(() => { })));
230
+ function deviceKey(device) {
231
+ return device.address?.toLowerCase() || device.name || `${device.backend}:${device.id}`;
308
232
  }
309
233
  function loadDbusNext() {
310
234
  try {