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 +15 -22
- package/dist/ble/backend.d.ts +21 -30
- package/dist/ble/bluez.d.ts +1 -1
- package/dist/ble/bluez.js +150 -226
- package/dist/ble/bluez.js.map +1 -1
- package/dist/ble/noble.js +86 -247
- package/dist/ble/noble.js.map +1 -1
- package/dist/ble/utils.d.ts +3 -1
- package/dist/ble/utils.js +23 -0
- package/dist/ble/utils.js.map +1 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +8 -27
- package/dist/cli.js.map +1 -1
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +0 -2
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/reader.d.ts +1 -9
- package/dist/reader.js +59 -88
- package/dist/reader.js.map +1 -1
- package/nodes/ble-reader-device.html +1 -6
- package/nodes/ble-reader-device.js +0 -1
- package/nodes/ble-reader-read.html +4 -4
- package/nodes/ble-reader-read.js +1 -4
- package/package.json +5 -4
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
|
|
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)`
|
|
17
|
-
- `readDevice(device, options)` reads one discovered device
|
|
18
|
-
- `
|
|
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,
|
|
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 {
|
|
25
|
+
import { readDevices } from 'node-red-contrib-ble-scanner';
|
|
25
26
|
|
|
26
|
-
const
|
|
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
|
-
|
|
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
|
|
45
|
-
- `ble-reader-read`: input node that collects raw
|
|
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
|
|
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" --
|
|
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`
|
|
66
|
+
`read --interval 5` repeats an advertisement scan every five seconds until stopped.
|
|
74
67
|
|
|
75
68
|
## Development
|
|
76
69
|
|
package/dist/ble/backend.d.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
71
|
+
scanAdvertisements(options: ResolvedScanOptions): Promise<void>;
|
|
81
72
|
shutdown(): Promise<void>;
|
|
82
73
|
}
|
package/dist/ble/bluez.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
12
|
-
shutdown:
|
|
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(
|
|
22
|
-
const objectManager = object.getInterface(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const startedAt = Date.now();
|
|
91
|
+
};
|
|
92
|
+
const watchDeviceProperties = async (path) => {
|
|
93
|
+
if (deviceWatchers.has(path))
|
|
94
|
+
return;
|
|
55
95
|
try {
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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(
|
|
132
|
-
const objectManager = objectManagerObject.getInterface(
|
|
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(
|
|
138
|
-
const adapter = adapterObject.getInterface(
|
|
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[
|
|
169
|
+
if (interfaces[BLUEZ_ADAPTER])
|
|
200
170
|
return path;
|
|
201
171
|
}
|
|
202
172
|
return null;
|
|
203
173
|
}
|
|
204
|
-
function
|
|
205
|
-
|
|
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:
|
|
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
|
-
|
|
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',
|
|
219
|
+
DuplicateData: new Variant('b', duplicateData)
|
|
248
220
|
};
|
|
249
221
|
if (serviceUuid)
|
|
250
|
-
filter.UUIDs = new Variant('as', [serviceUuid]);
|
|
251
|
-
const names = ['transport', '
|
|
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(
|
|
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
|
-
|
|
259
|
-
|
|
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 {
|