@matter/nodejs-ble 0.11.0-alpha.0-20241005-e3e4e4a7a

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.
Files changed (84) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/dist/cjs/BleBroadcaster.d.ts +20 -0
  4. package/dist/cjs/BleBroadcaster.d.ts.map +1 -0
  5. package/dist/cjs/BleBroadcaster.js +121 -0
  6. package/dist/cjs/BleBroadcaster.js.map +6 -0
  7. package/dist/cjs/BlePeripheralInterface.d.ts +15 -0
  8. package/dist/cjs/BlePeripheralInterface.d.ts.map +1 -0
  9. package/dist/cjs/BlePeripheralInterface.js +54 -0
  10. package/dist/cjs/BlePeripheralInterface.js.map +6 -0
  11. package/dist/cjs/BleScanner.d.ts +52 -0
  12. package/dist/cjs/BleScanner.d.ts.map +1 -0
  13. package/dist/cjs/BleScanner.js +240 -0
  14. package/dist/cjs/BleScanner.js.map +6 -0
  15. package/dist/cjs/BlenoBleServer.d.ts +70 -0
  16. package/dist/cjs/BlenoBleServer.d.ts.map +1 -0
  17. package/dist/cjs/BlenoBleServer.js +337 -0
  18. package/dist/cjs/BlenoBleServer.js.map +6 -0
  19. package/dist/cjs/NobleBleChannel.d.ts +32 -0
  20. package/dist/cjs/NobleBleChannel.d.ts.map +1 -0
  21. package/dist/cjs/NobleBleChannel.js +266 -0
  22. package/dist/cjs/NobleBleChannel.js.map +6 -0
  23. package/dist/cjs/NobleBleClient.d.ts +20 -0
  24. package/dist/cjs/NobleBleClient.d.ts.map +1 -0
  25. package/dist/cjs/NobleBleClient.js +108 -0
  26. package/dist/cjs/NobleBleClient.js.map +6 -0
  27. package/dist/cjs/NodeJsBle.d.ts +22 -0
  28. package/dist/cjs/NodeJsBle.d.ts.map +1 -0
  29. package/dist/cjs/NodeJsBle.js +68 -0
  30. package/dist/cjs/NodeJsBle.js.map +6 -0
  31. package/dist/cjs/index.d.ts +9 -0
  32. package/dist/cjs/index.d.ts.map +1 -0
  33. package/dist/cjs/index.js +26 -0
  34. package/dist/cjs/index.js.map +6 -0
  35. package/dist/cjs/package.json +3 -0
  36. package/dist/cjs/tsconfig.tsbuildinfo +1 -0
  37. package/dist/esm/BleBroadcaster.d.ts +20 -0
  38. package/dist/esm/BleBroadcaster.d.ts.map +1 -0
  39. package/dist/esm/BleBroadcaster.js +101 -0
  40. package/dist/esm/BleBroadcaster.js.map +6 -0
  41. package/dist/esm/BlePeripheralInterface.d.ts +15 -0
  42. package/dist/esm/BlePeripheralInterface.d.ts.map +1 -0
  43. package/dist/esm/BlePeripheralInterface.js +34 -0
  44. package/dist/esm/BlePeripheralInterface.js.map +6 -0
  45. package/dist/esm/BleScanner.d.ts +52 -0
  46. package/dist/esm/BleScanner.d.ts.map +1 -0
  47. package/dist/esm/BleScanner.js +220 -0
  48. package/dist/esm/BleScanner.js.map +6 -0
  49. package/dist/esm/BlenoBleServer.d.ts +70 -0
  50. package/dist/esm/BlenoBleServer.d.ts.map +1 -0
  51. package/dist/esm/BlenoBleServer.js +327 -0
  52. package/dist/esm/BlenoBleServer.js.map +6 -0
  53. package/dist/esm/NobleBleChannel.d.ts +32 -0
  54. package/dist/esm/NobleBleChannel.d.ts.map +1 -0
  55. package/dist/esm/NobleBleChannel.js +266 -0
  56. package/dist/esm/NobleBleChannel.js.map +6 -0
  57. package/dist/esm/NobleBleClient.d.ts +20 -0
  58. package/dist/esm/NobleBleClient.d.ts.map +1 -0
  59. package/dist/esm/NobleBleClient.js +88 -0
  60. package/dist/esm/NobleBleClient.js.map +6 -0
  61. package/dist/esm/NodeJsBle.d.ts +22 -0
  62. package/dist/esm/NodeJsBle.d.ts.map +1 -0
  63. package/dist/esm/NodeJsBle.js +48 -0
  64. package/dist/esm/NodeJsBle.js.map +6 -0
  65. package/dist/esm/index.d.ts +9 -0
  66. package/dist/esm/index.d.ts.map +1 -0
  67. package/dist/esm/index.js +9 -0
  68. package/dist/esm/index.js.map +6 -0
  69. package/dist/esm/package.json +3 -0
  70. package/dist/esm/tsconfig.tsbuildinfo +1 -0
  71. package/package.json +83 -0
  72. package/require/package.json +4 -0
  73. package/require/require.cjs +1 -0
  74. package/require/require.d.ts +1 -0
  75. package/require/require.mjs +3 -0
  76. package/src/BleBroadcaster.ts +126 -0
  77. package/src/BlePeripheralInterface.ts +36 -0
  78. package/src/BleScanner.ts +279 -0
  79. package/src/BlenoBleServer.ts +403 -0
  80. package/src/NobleBleChannel.ts +337 -0
  81. package/src/NobleBleClient.ts +117 -0
  82. package/src/NodeJsBle.ts +56 -0
  83. package/src/index.ts +9 -0
  84. package/src/tsconfig.json +16 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2024 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { ImplementationError, Logger } from "@matter/general";
8
+ import { BtpCodec } from "@project-chip/matter.js/codec";
9
+ import { VendorId } from "@project-chip/matter.js/datatype";
10
+ import {
11
+ CommissionerInstanceData,
12
+ CommissioningModeInstanceData,
13
+ InstanceBroadcaster,
14
+ } from "@project-chip/matter.js/fabric";
15
+ import { BlenoBleServer } from "./BlenoBleServer.js";
16
+
17
+ const logger = Logger.get("BleBroadcaster");
18
+
19
+ export class BleBroadcaster implements InstanceBroadcaster {
20
+ #blenoServer: BlenoBleServer;
21
+ #additionalAdvertisementData?: Uint8Array;
22
+ #vendorId: VendorId | undefined;
23
+ #productId: number | undefined;
24
+ #discriminator: number | undefined;
25
+ #advertise = false;
26
+ #isClosed = false;
27
+
28
+ constructor(blenoServer: BlenoBleServer, additionalAdvertisementData?: Uint8Array) {
29
+ this.#blenoServer = blenoServer;
30
+ this.#additionalAdvertisementData = additionalAdvertisementData;
31
+ }
32
+
33
+ async setCommissionMode(
34
+ mode: number,
35
+ { name: deviceName, deviceType, vendorId, productId, discriminator }: CommissioningModeInstanceData,
36
+ ) {
37
+ this.#assertOpen();
38
+ if (mode !== 1) {
39
+ this.#advertise = false;
40
+ logger.info(
41
+ `skip BLE announce because of commissioning mode ${mode} ${deviceName} ${deviceType} ${vendorId} ${productId} ${discriminator}`,
42
+ );
43
+ await this.#blenoServer.stopAdvertising();
44
+ return;
45
+ }
46
+ logger.debug(
47
+ `set data for commissioning mode ${mode} ${deviceName} ${deviceType} ${vendorId} ${productId} ${discriminator}`,
48
+ );
49
+ this.#productId = productId;
50
+ this.#vendorId = vendorId;
51
+ this.#discriminator = discriminator;
52
+ process.env["BLENO_DEVICE_NAME"] = deviceName;
53
+ this.#advertise = true;
54
+ }
55
+
56
+ async setFabrics() {
57
+ this.#assertOpen();
58
+ this.#advertise = false;
59
+ logger.info(`skip BLE announce because announcing an operational device is not supported`);
60
+ await this.#blenoServer.stopAdvertising();
61
+ return; // Not needed because we only advertise un-commissioned devices
62
+ }
63
+
64
+ async setCommissionerInfo(_commissionerData: CommissionerInstanceData) {
65
+ this.#assertOpen();
66
+ this.#advertise = false;
67
+ logger.error(`skip BLE announce because announcing a commissioner is not supported`);
68
+ }
69
+
70
+ async announce() {
71
+ this.#assertOpen();
72
+ if (this.#vendorId === undefined || this.#productId === undefined || this.#discriminator === undefined) {
73
+ logger.debug(
74
+ `skip BLE announce because of missing commissioning data vendorId, productId or discriminator`,
75
+ );
76
+ return;
77
+ }
78
+ if (!this.#advertise) {
79
+ logger.debug(`skip BLE announce because nothing to advertise`);
80
+ return;
81
+ }
82
+
83
+ const advertisementData = BtpCodec.encodeBleAdvertisementData(
84
+ this.#discriminator,
85
+ this.#vendorId,
86
+ this.#productId,
87
+ this.#additionalAdvertisementData !== undefined && this.#additionalAdvertisementData.length > 0,
88
+ );
89
+
90
+ // TODO if needed implement this according to the spec 5.4.2.5.3. (first 30s 20-60ms, 150-1285ms after)
91
+ process.env["BLENO_ADVERTISING_INTERVAL"] = "100"; // use statically 100ms for now
92
+
93
+ await this.#blenoServer.advertise(advertisementData, this.#additionalAdvertisementData);
94
+ }
95
+
96
+ async expireCommissioningAnnouncement() {
97
+ this.#assertOpen();
98
+ this.#advertise = false;
99
+ await this.#blenoServer.stopAdvertising();
100
+ }
101
+
102
+ async expireFabricAnnouncement() {
103
+ // nothing to do
104
+ }
105
+
106
+ async expireAllAnnouncements() {
107
+ this.#assertOpen();
108
+ this.#advertise = false;
109
+ await this.#blenoServer.stopAdvertising();
110
+ }
111
+
112
+ async close() {
113
+ if (this.#isClosed) {
114
+ return;
115
+ }
116
+ this.#isClosed = true;
117
+
118
+ await this.#blenoServer.stopAdvertising();
119
+ }
120
+
121
+ #assertOpen() {
122
+ if (this.#isClosed) {
123
+ throw new ImplementationError("Illegal operation on closed BleBroadcaster");
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2024 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Channel, ChannelType, TransportInterface } from "@matter/general";
8
+ import { BlenoBleServer } from "./BlenoBleServer.js";
9
+
10
+ export class BlePeripheralInterface implements TransportInterface {
11
+ constructor(private readonly blenoServer: BlenoBleServer) {}
12
+
13
+ // TransportInterface
14
+ onData(listener: (socket: Channel<Uint8Array>, data: Uint8Array) => void): TransportInterface.Listener {
15
+ this.blenoServer.setMatterMessageListener(listener);
16
+ return {
17
+ close: async () => await this.close(),
18
+ };
19
+ }
20
+
21
+ async close() {
22
+ await this.blenoServer.close();
23
+ }
24
+
25
+ supports(type: ChannelType, address?: string) {
26
+ if (type === ChannelType.BLE) {
27
+ return true;
28
+ }
29
+
30
+ if (address === undefined) {
31
+ return true;
32
+ }
33
+
34
+ return this.blenoServer.clientAddress === address;
35
+ }
36
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2024 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Bytes, ChannelType, createPromise, Logger, Time, Timer } from "@matter/general";
8
+ import { BleError } from "@project-chip/matter.js/ble";
9
+ import { BtpCodec } from "@project-chip/matter.js/codec";
10
+ import { CommissionableDevice, CommissionableDeviceIdentifiers, Scanner } from "@project-chip/matter.js/common";
11
+ import { VendorId } from "@project-chip/matter.js/datatype";
12
+ import type { Peripheral } from "@stoprocent/noble";
13
+ import { NobleBleClient } from "./NobleBleClient.js";
14
+
15
+ const logger = Logger.get("BleScanner");
16
+
17
+ export type DiscoveredBleDevice = {
18
+ deviceData: CommissionableDeviceData;
19
+ peripheral: Peripheral;
20
+ hasAdditionalAdvertisementData: boolean;
21
+ };
22
+
23
+ type CommissionableDeviceData = CommissionableDevice & {
24
+ SD: number; // Additional Field for Short discriminator
25
+ };
26
+
27
+ export class BleScanner implements Scanner {
28
+ get type() {
29
+ return ChannelType.BLE;
30
+ }
31
+
32
+ private readonly recordWaiters = new Map<
33
+ string,
34
+ {
35
+ resolver: () => void;
36
+ timer: Timer;
37
+ resolveOnUpdatedRecords: boolean;
38
+ }
39
+ >();
40
+ private readonly discoveredMatterDevices = new Map<string, DiscoveredBleDevice>();
41
+
42
+ constructor(private readonly nobleClient: NobleBleClient) {
43
+ this.nobleClient.setDiscoveryCallback((address, manufacturerData) =>
44
+ this.handleDiscoveredDevice(address, manufacturerData),
45
+ );
46
+ }
47
+
48
+ public getDiscoveredDevice(address: string): DiscoveredBleDevice {
49
+ const device = this.discoveredMatterDevices.get(address);
50
+ if (device === undefined) {
51
+ throw new BleError(`No device found for address ${address}`);
52
+ }
53
+ return device;
54
+ }
55
+
56
+ /**
57
+ * Registers a deferred promise for a specific queryId together with a timeout and return the promise.
58
+ * The promise will be resolved when the timer runs out latest.
59
+ */
60
+ private async registerWaiterPromise(queryId: string, timeoutSeconds: number, resolveOnUpdatedRecords = true) {
61
+ const { promise, resolver } = createPromise<void>();
62
+ const timer = Time.getTimer("BLE query timeout", timeoutSeconds * 1000, () =>
63
+ this.finishWaiter(queryId, true),
64
+ ).start();
65
+ this.recordWaiters.set(queryId, { resolver, timer, resolveOnUpdatedRecords });
66
+ logger.debug(
67
+ `Registered waiter for query ${queryId} with timeout ${timeoutSeconds} seconds${
68
+ resolveOnUpdatedRecords ? "" : " (not resolving on updated records)"
69
+ }`,
70
+ );
71
+ await promise;
72
+ }
73
+
74
+ /**
75
+ * Remove a waiter promise for a specific queryId and stop the connected timer. If required also resolve the
76
+ * promise.
77
+ */
78
+ private finishWaiter(queryId: string, resolvePromise: boolean, isUpdatedRecord = false) {
79
+ const waiter = this.recordWaiters.get(queryId);
80
+ if (waiter === undefined) return;
81
+ const { timer, resolver, resolveOnUpdatedRecords } = waiter;
82
+ if (isUpdatedRecord && !resolveOnUpdatedRecords) return;
83
+ logger.debug(`Finishing waiter for query ${queryId}, resolving: ${resolvePromise}`);
84
+ timer.stop();
85
+ if (resolvePromise) {
86
+ resolver();
87
+ }
88
+ this.recordWaiters.delete(queryId);
89
+ }
90
+
91
+ cancelCommissionableDeviceDiscovery(identifier: CommissionableDeviceIdentifiers) {
92
+ const queryKey = this.buildCommissionableQueryIdentifier(identifier);
93
+ this.finishWaiter(queryKey, true);
94
+ }
95
+
96
+ private handleDiscoveredDevice(peripheral: Peripheral, manufacturerServiceData: Uint8Array) {
97
+ logger.debug(
98
+ `Discovered device ${peripheral.address} ${manufacturerServiceData === undefined ? undefined : Bytes.toHex(manufacturerServiceData)}`,
99
+ );
100
+
101
+ try {
102
+ const { discriminator, vendorId, productId, hasAdditionalAdvertisementData } =
103
+ BtpCodec.decodeBleAdvertisementServiceData(manufacturerServiceData);
104
+
105
+ const commissionableDevice: CommissionableDeviceData = {
106
+ deviceIdentifier: peripheral.address,
107
+ D: discriminator,
108
+ SD: (discriminator >> 8) & 0x0f,
109
+ VP: `${vendorId}+${productId}`,
110
+ CM: 1, // Can be no other mode,
111
+ addresses: [{ type: "ble", peripheralAddress: peripheral.address }],
112
+ };
113
+ logger.debug(`Discovered device ${peripheral.address} data: ${Logger.toJSON(commissionableDevice)}`);
114
+
115
+ const deviceExisting = this.discoveredMatterDevices.has(peripheral.address);
116
+
117
+ this.discoveredMatterDevices.set(peripheral.address, {
118
+ deviceData: commissionableDevice,
119
+ peripheral: peripheral,
120
+ hasAdditionalAdvertisementData,
121
+ });
122
+
123
+ const queryKey = this.findCommissionableQueryIdentifier(commissionableDevice);
124
+ if (queryKey !== undefined) {
125
+ this.finishWaiter(queryKey, true, deviceExisting);
126
+ }
127
+ } catch (error) {
128
+ logger.debug(`Seems not to be a valid Matter device: Failed to decode device data: ${error}`);
129
+ }
130
+ }
131
+
132
+ private findCommissionableQueryIdentifier(record: CommissionableDeviceData) {
133
+ const longDiscriminatorQueryId = this.buildCommissionableQueryIdentifier({ longDiscriminator: record.D });
134
+ if (this.recordWaiters.has(longDiscriminatorQueryId)) {
135
+ return longDiscriminatorQueryId;
136
+ }
137
+
138
+ const shortDiscriminatorQueryId = this.buildCommissionableQueryIdentifier({ shortDiscriminator: record.SD });
139
+ if (this.recordWaiters.has(shortDiscriminatorQueryId)) {
140
+ return shortDiscriminatorQueryId;
141
+ }
142
+
143
+ if (record.VP !== undefined) {
144
+ const vendorIdQueryId = this.buildCommissionableQueryIdentifier({
145
+ vendorId: VendorId(parseInt(record.VP.split("+")[0])),
146
+ });
147
+ if (this.recordWaiters.has(vendorIdQueryId)) {
148
+ return vendorIdQueryId;
149
+ }
150
+ if (record.VP.includes("+")) {
151
+ const productIdQueryId = this.buildCommissionableQueryIdentifier({
152
+ vendorId: VendorId(parseInt(record.VP.split("+")[1])),
153
+ });
154
+ if (this.recordWaiters.has(productIdQueryId)) {
155
+ return productIdQueryId;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (this.recordWaiters.has("*")) {
161
+ return "*";
162
+ }
163
+
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * Builds an identifier string for commissionable queries based on the given identifier object.
169
+ * Some identifiers are identical to the official DNS-SD identifiers, others are custom.
170
+ */
171
+ private buildCommissionableQueryIdentifier(identifier: CommissionableDeviceIdentifiers) {
172
+ if ("longDiscriminator" in identifier) {
173
+ return `D:${identifier.longDiscriminator}`;
174
+ } else if ("shortDiscriminator" in identifier) {
175
+ return `SD:${identifier.shortDiscriminator}`;
176
+ } else if ("vendorId" in identifier) {
177
+ return `V:${identifier.vendorId}`;
178
+ } else if ("productId" in identifier) {
179
+ // Custom identifier because normally productId is only included in TXT record
180
+ return `P:${identifier.productId}`;
181
+ } else return "*";
182
+ }
183
+
184
+ private getCommissionableDevices(identifier: CommissionableDeviceIdentifiers) {
185
+ const storedRecords = Array.from(this.discoveredMatterDevices.values());
186
+
187
+ const foundRecords = new Array<DiscoveredBleDevice>();
188
+ if ("longDiscriminator" in identifier) {
189
+ foundRecords.push(...storedRecords.filter(({ deviceData: { D } }) => D === identifier.longDiscriminator));
190
+ } else if ("shortDiscriminator" in identifier) {
191
+ foundRecords.push(
192
+ ...storedRecords.filter(({ deviceData: { SD } }) => SD === identifier.shortDiscriminator),
193
+ );
194
+ } else if ("vendorId" in identifier) {
195
+ foundRecords.push(
196
+ ...storedRecords.filter(
197
+ ({ deviceData: { VP } }) =>
198
+ VP === `${identifier.vendorId}` || VP?.startsWith(`${identifier.vendorId}+`),
199
+ ),
200
+ );
201
+ } else if ("productId" in identifier) {
202
+ foundRecords.push(
203
+ ...storedRecords.filter(({ deviceData: { VP } }) => VP?.endsWith(`+${identifier.productId}`)),
204
+ );
205
+ } else {
206
+ foundRecords.push(...storedRecords.filter(({ deviceData: { CM } }) => CM === 1 || CM === 2));
207
+ }
208
+
209
+ return foundRecords;
210
+ }
211
+
212
+ async findOperationalDevice(): Promise<undefined> {
213
+ logger.info(`skip BLE scan because scanning for operational devices is not supported`);
214
+ return undefined;
215
+ }
216
+
217
+ getDiscoveredOperationalDevice(): undefined {
218
+ logger.info(`skip BLE scan because scanning for operational devices is not supported`);
219
+ return undefined;
220
+ }
221
+
222
+ async findCommissionableDevices(
223
+ identifier: CommissionableDeviceIdentifiers,
224
+ timeoutSeconds = 10,
225
+ ): Promise<CommissionableDevice[]> {
226
+ let storedRecords = this.getCommissionableDevices(identifier);
227
+ if (storedRecords.length === 0) {
228
+ const queryKey = this.buildCommissionableQueryIdentifier(identifier);
229
+
230
+ await this.nobleClient.startScanning();
231
+ await this.registerWaiterPromise(queryKey, timeoutSeconds);
232
+
233
+ storedRecords = this.getCommissionableDevices(identifier);
234
+ await this.nobleClient.stopScanning();
235
+ }
236
+ return storedRecords.map(({ deviceData }) => deviceData);
237
+ }
238
+
239
+ async findCommissionableDevicesContinuously(
240
+ identifier: CommissionableDeviceIdentifiers,
241
+ callback: (device: CommissionableDevice) => void,
242
+ timeoutSeconds = 60,
243
+ ): Promise<CommissionableDevice[]> {
244
+ const discoveredDevices = new Set<string>();
245
+
246
+ const discoveryEndTime = Time.nowMs() + timeoutSeconds * 1000;
247
+ const queryKey = this.buildCommissionableQueryIdentifier(identifier);
248
+ await this.nobleClient.startScanning();
249
+
250
+ while (true) {
251
+ this.getCommissionableDevices(identifier).forEach(({ deviceData }) => {
252
+ const { deviceIdentifier } = deviceData;
253
+ if (!discoveredDevices.has(deviceIdentifier)) {
254
+ discoveredDevices.add(deviceIdentifier);
255
+ callback(deviceData);
256
+ }
257
+ });
258
+
259
+ const remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000);
260
+ if (remainingTime <= 0) {
261
+ break;
262
+ }
263
+ await this.registerWaiterPromise(queryKey, remainingTime, false);
264
+ }
265
+ await this.nobleClient.stopScanning();
266
+ return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData);
267
+ }
268
+
269
+ getDiscoveredCommissionableDevices(identifier: CommissionableDeviceIdentifiers): CommissionableDevice[] {
270
+ return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData);
271
+ }
272
+
273
+ close(): void {
274
+ void this.nobleClient.stopScanning();
275
+ [...this.recordWaiters.keys()].forEach(queryId =>
276
+ this.finishWaiter(queryId, !!this.recordWaiters.get(queryId)?.timer),
277
+ );
278
+ }
279
+ }