@matter/react-native 0.16.11-alpha.0-20260301-bae8468e3 → 0.16.11

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.
@@ -4,21 +4,317 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
- import { BleScanner as BaseBleScanner, DiscoveredBleDevice } from "#protocol";
8
- import { ReactNativeBleClient, ReactNativeBlePeripheral } from "./ReactNativeBleClient.js";
7
+ import { Bytes, ChannelType, createPromise, Duration, Logger, Millis, Seconds, Time, Timer, Timespan } from "#general";
8
+ import { BleError, BtpCodec, CommissionableDevice, CommissionableDeviceIdentifiers, Scanner } from "#protocol";
9
+ import { VendorId } from "#types";
10
+ import { Device } from "react-native-ble-plx";
11
+ import { ReactNativeBleClient } from "./ReactNativeBleClient.js";
9
12
 
10
- export type { DiscoveredBleDevice } from "#protocol";
13
+ const logger = Logger.get("BleScanner");
11
14
 
12
- export type ReactNativeDiscoveredBleDevice = Omit<DiscoveredBleDevice, "peripheral"> & {
13
- peripheral: ReactNativeBlePeripheral;
15
+ export type DiscoveredBleDevice = {
16
+ deviceData: CommissionableDeviceData;
17
+ peripheral: Device;
18
+ hasAdditionalAdvertisementData: boolean;
14
19
  };
15
20
 
16
- export class BleScanner extends BaseBleScanner {
17
- constructor(bleClient: ReactNativeBleClient) {
18
- super(bleClient);
21
+ type CommissionableDeviceData = CommissionableDevice & {
22
+ SD: number; // Additional Field for Short discriminator
23
+ };
24
+
25
+ export class BleScanner implements Scanner {
26
+ get type() {
27
+ return ChannelType.BLE;
28
+ }
29
+
30
+ private readonly recordWaiters = new Map<
31
+ string,
32
+ {
33
+ resolver: () => void;
34
+ timer?: Timer;
35
+ resolveOnUpdatedRecords: boolean;
36
+ cancelResolver?: (value: void) => void;
37
+ }
38
+ >();
39
+ private readonly discoveredMatterDevices = new Map<string, DiscoveredBleDevice>();
40
+
41
+ constructor(private readonly bleClient: ReactNativeBleClient) {
42
+ this.bleClient.setDiscoveryCallback((address, manufacturerData) =>
43
+ this.handleDiscoveredDevice(address, manufacturerData),
44
+ );
45
+ }
46
+
47
+ public getDiscoveredDevice(address: string): DiscoveredBleDevice {
48
+ const device = this.discoveredMatterDevices.get(address);
49
+ if (device === undefined) {
50
+ throw new BleError(`No device found for address ${address}`);
51
+ }
52
+ return device;
53
+ }
54
+
55
+ /**
56
+ * Registers a deferred promise for a specific queryId together with a timeout and return the promise.
57
+ * The promise will be resolved when the timer runs out latest.
58
+ */
59
+ private async registerWaiterPromise(
60
+ queryId: string,
61
+ timeout?: Duration,
62
+ resolveOnUpdatedRecords = true,
63
+ cancelResolver?: (value: void) => void,
64
+ ) {
65
+ const { promise, resolver } = createPromise<void>();
66
+ let timer;
67
+ if (timeout) {
68
+ timer = Time.getTimer("BLE query timeout", timeout, () => {
69
+ cancelResolver?.();
70
+ this.finishWaiter(queryId, true);
71
+ }).start();
72
+ }
73
+ this.recordWaiters.set(queryId, { resolver, timer, resolveOnUpdatedRecords, cancelResolver });
74
+ logger.debug(
75
+ `Registered waiter for query ${queryId} with timeout ${timeout === undefined ? "(none)" : Duration.format(timeout)}${
76
+ resolveOnUpdatedRecords ? "" : " (not resolving on updated records)"
77
+ }`,
78
+ );
79
+ await promise;
80
+ }
81
+
82
+ /**
83
+ * Remove a waiter promise for a specific queryId and stop the connected timer. If required also resolve the
84
+ * promise.
85
+ */
86
+ private finishWaiter(queryId: string, resolvePromise: boolean, isUpdatedRecord = false) {
87
+ const waiter = this.recordWaiters.get(queryId);
88
+ if (waiter === undefined) return;
89
+ const { timer, resolver, resolveOnUpdatedRecords } = waiter;
90
+ if (isUpdatedRecord && !resolveOnUpdatedRecords) return;
91
+ logger.debug(`Finishing waiter for query ${queryId}, resolving: ${resolvePromise}`);
92
+ timer?.stop();
93
+ if (resolvePromise) {
94
+ resolver();
95
+ }
96
+ this.recordWaiters.delete(queryId);
97
+ }
98
+
99
+ cancelCommissionableDeviceDiscovery(identifier: CommissionableDeviceIdentifiers, resolvePromise = true) {
100
+ const queryKey = this.buildCommissionableQueryIdentifier(identifier);
101
+ const { cancelResolver } = this.recordWaiters.get(queryKey) ?? {};
102
+ cancelResolver?.();
103
+ this.finishWaiter(queryKey, resolvePromise);
104
+ }
105
+
106
+ private handleDiscoveredDevice(peripheral: Device, manufacturerServiceData: Bytes) {
107
+ logger.debug(
108
+ `Discovered device ${peripheral.id} "${peripheral.localName}" ${manufacturerServiceData === undefined ? undefined : Bytes.toHex(manufacturerServiceData)}`,
109
+ );
110
+
111
+ try {
112
+ const { discriminator, vendorId, productId, hasAdditionalAdvertisementData } =
113
+ BtpCodec.decodeBleAdvertisementServiceData(manufacturerServiceData);
114
+
115
+ const commissionableDevice: CommissionableDeviceData = {
116
+ deviceIdentifier: peripheral.id,
117
+ D: discriminator,
118
+ SD: (discriminator >> 8) & 0x0f,
119
+ VP: `${vendorId}+${productId}`,
120
+ CM: 1, // Can be no other mode,
121
+ addresses: [{ type: "ble", peripheralAddress: peripheral.id }],
122
+ };
123
+ logger.debug(`Discovered device ${peripheral.id} data: ${JSON.stringify(commissionableDevice)}`);
124
+
125
+ const deviceExisting = this.discoveredMatterDevices.has(peripheral.id);
126
+
127
+ this.discoveredMatterDevices.set(peripheral.id, {
128
+ deviceData: commissionableDevice,
129
+ peripheral: peripheral,
130
+ hasAdditionalAdvertisementData,
131
+ });
132
+
133
+ const queryKey = this.findCommissionableQueryIdentifier(commissionableDevice);
134
+ if (queryKey !== undefined) {
135
+ this.finishWaiter(queryKey, true, deviceExisting);
136
+ }
137
+ } catch (error) {
138
+ logger.debug(`Seems not to be a valid Matter device: Failed to decode device data: ${error}`);
139
+ }
140
+ }
141
+
142
+ private findCommissionableQueryIdentifier(record: CommissionableDeviceData) {
143
+ const longDiscriminatorQueryId = this.buildCommissionableQueryIdentifier({ longDiscriminator: record.D });
144
+ if (this.recordWaiters.has(longDiscriminatorQueryId)) {
145
+ return longDiscriminatorQueryId;
146
+ }
147
+
148
+ const shortDiscriminatorQueryId = this.buildCommissionableQueryIdentifier({ shortDiscriminator: record.SD });
149
+ if (this.recordWaiters.has(shortDiscriminatorQueryId)) {
150
+ return shortDiscriminatorQueryId;
151
+ }
152
+
153
+ if (record.VP !== undefined) {
154
+ const vendorIdQueryId = this.buildCommissionableQueryIdentifier({
155
+ vendorId: VendorId(parseInt(record.VP.split("+")[0])),
156
+ });
157
+ if (this.recordWaiters.has(vendorIdQueryId)) {
158
+ return vendorIdQueryId;
159
+ }
160
+ if (record.VP.includes("+")) {
161
+ const productIdQueryId = this.buildCommissionableQueryIdentifier({
162
+ vendorId: VendorId(parseInt(record.VP.split("+")[1])),
163
+ });
164
+ if (this.recordWaiters.has(productIdQueryId)) {
165
+ return productIdQueryId;
166
+ }
167
+ }
168
+ }
169
+
170
+ if (this.recordWaiters.has("*")) {
171
+ return "*";
172
+ }
173
+
174
+ return undefined;
175
+ }
176
+
177
+ /**
178
+ * Builds an identifier string for commissionable queries based on the given identifier object.
179
+ * Some identifiers are identical to the official DNS-SD identifiers, others are custom.
180
+ */
181
+ private buildCommissionableQueryIdentifier(identifier: CommissionableDeviceIdentifiers) {
182
+ if ("longDiscriminator" in identifier) {
183
+ return `D:${identifier.longDiscriminator}`;
184
+ } else if ("shortDiscriminator" in identifier) {
185
+ return `SD:${identifier.shortDiscriminator}`;
186
+ } else if ("vendorId" in identifier) {
187
+ return `V:${identifier.vendorId}`;
188
+ } else if ("productId" in identifier) {
189
+ // Custom identifier because normally productId is only included in TXT record
190
+ return `P:${identifier.productId}`;
191
+ } else return "*";
192
+ }
193
+
194
+ private getCommissionableDevices(identifier: CommissionableDeviceIdentifiers) {
195
+ const storedRecords = Array.from(this.discoveredMatterDevices.values());
196
+
197
+ const foundRecords = new Array<DiscoveredBleDevice>();
198
+ if ("longDiscriminator" in identifier) {
199
+ foundRecords.push(...storedRecords.filter(({ deviceData: { D } }) => D === identifier.longDiscriminator));
200
+ } else if ("shortDiscriminator" in identifier) {
201
+ foundRecords.push(
202
+ ...storedRecords.filter(({ deviceData: { SD } }) => SD === identifier.shortDiscriminator),
203
+ );
204
+ } else if ("vendorId" in identifier) {
205
+ foundRecords.push(
206
+ ...storedRecords.filter(
207
+ ({ deviceData: { VP } }) =>
208
+ VP === `${identifier.vendorId}` || VP?.startsWith(`${identifier.vendorId}+`),
209
+ ),
210
+ );
211
+ } else if ("productId" in identifier) {
212
+ foundRecords.push(
213
+ ...storedRecords.filter(({ deviceData: { VP } }) => VP?.endsWith(`+${identifier.productId}`)),
214
+ );
215
+ } else {
216
+ foundRecords.push(...storedRecords.filter(({ deviceData: { CM } }) => CM === 1 || CM === 2));
217
+ }
218
+
219
+ return foundRecords;
220
+ }
221
+
222
+ async findOperationalDevice(): Promise<undefined> {
223
+ logger.info(`skip BLE scan because scanning for operational devices is not supported`);
224
+ return undefined;
225
+ }
226
+
227
+ getDiscoveredOperationalDevice(): undefined {
228
+ logger.info(`skip BLE scan because scanning for operational devices is not supported`);
229
+ return undefined;
230
+ }
231
+
232
+ async findCommissionableDevices(
233
+ identifier: CommissionableDeviceIdentifiers,
234
+ timeout = Seconds(10),
235
+ ignoreExistingRecords = false,
236
+ ): Promise<CommissionableDevice[]> {
237
+ let storedRecords = this.getCommissionableDevices(identifier);
238
+ if (ignoreExistingRecords) {
239
+ // We want to have a fresh discovery result, so clear out the stored records because they might be outdated
240
+ for (const record of storedRecords) {
241
+ this.discoveredMatterDevices.delete(record.peripheral.id);
242
+ }
243
+ storedRecords = [];
244
+ }
245
+ if (storedRecords.length === 0) {
246
+ const queryKey = this.buildCommissionableQueryIdentifier(identifier);
247
+
248
+ await this.bleClient.startScanning();
249
+ await this.registerWaiterPromise(queryKey, timeout);
250
+
251
+ storedRecords = this.getCommissionableDevices(identifier);
252
+ await this.bleClient.stopScanning();
253
+ }
254
+ return storedRecords.map(({ deviceData }) => deviceData);
255
+ }
256
+
257
+ async findCommissionableDevicesContinuously(
258
+ identifier: CommissionableDeviceIdentifiers,
259
+ callback: (device: CommissionableDevice) => void,
260
+ timeout?: Duration,
261
+ cancelSignal?: Promise<void>,
262
+ ): Promise<CommissionableDevice[]> {
263
+ const discoveredDevices = new Set<string>();
264
+
265
+ const discoveryEndTime = timeout ? Time.nowMs + timeout : undefined;
266
+ const queryKey = this.buildCommissionableQueryIdentifier(identifier);
267
+ await this.bleClient.startScanning();
268
+
269
+ let queryResolver: ((value: void) => void) | undefined;
270
+ if (cancelSignal === undefined) {
271
+ const { promise, resolver } = createPromise<void>();
272
+ cancelSignal = promise;
273
+ queryResolver = resolver;
274
+ }
275
+
276
+ let canceled = false;
277
+ cancelSignal?.then(
278
+ () => {
279
+ canceled = true;
280
+ this.finishWaiter(queryKey, true);
281
+ },
282
+ cause => {
283
+ logger.error("Unexpected error canceling commissioning", cause);
284
+ },
285
+ );
286
+
287
+ while (!canceled) {
288
+ this.getCommissionableDevices(identifier).forEach(({ deviceData }) => {
289
+ const { deviceIdentifier } = deviceData;
290
+ if (!discoveredDevices.has(deviceIdentifier)) {
291
+ discoveredDevices.add(deviceIdentifier);
292
+ callback(deviceData);
293
+ }
294
+ });
295
+
296
+ let remainingTime;
297
+ if (discoveryEndTime !== undefined) {
298
+ remainingTime = Millis.ceil(Timespan(Time.nowMs, discoveryEndTime).duration);
299
+ if (remainingTime <= 0) {
300
+ break;
301
+ }
302
+ }
303
+
304
+ await this.registerWaiterPromise(queryKey, remainingTime, false, queryResolver);
305
+ }
306
+ await this.bleClient.stopScanning();
307
+ return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData);
308
+ }
309
+
310
+ getDiscoveredCommissionableDevices(identifier: CommissionableDeviceIdentifiers): CommissionableDevice[] {
311
+ return this.getCommissionableDevices(identifier).map(({ deviceData }) => deviceData);
19
312
  }
20
313
 
21
- override getDiscoveredDevice(address: string): ReactNativeDiscoveredBleDevice {
22
- return super.getDiscoveredDevice(address) as ReactNativeDiscoveredBleDevice;
314
+ async close() {
315
+ await this.bleClient.stopScanning();
316
+ [...this.recordWaiters.keys()].forEach(queryId =>
317
+ this.finishWaiter(queryId, !!this.recordWaiters.get(queryId)?.timer),
318
+ );
23
319
  }
24
320
  }
@@ -61,10 +61,9 @@ export class ReactNativeBleCentralInterface implements ConnectionlessTransport {
61
61
  }
62
62
 
63
63
  // Get the peripheral by address and connect to it.
64
- const { peripheral: blePeripheral, hasAdditionalAdvertisementData } = (
65
- this.#ble.scanner as BleScanner
66
- ).getDiscoveredDevice(address.peripheralAddress);
67
- const peripheral = blePeripheral.device;
64
+ const { peripheral, hasAdditionalAdvertisementData } = (this.#ble.scanner as BleScanner).getDiscoveredDevice(
65
+ address.peripheralAddress,
66
+ );
68
67
  if (this.#openChannels.has(address)) {
69
68
  throw new BleError(
70
69
  `Peripheral ${address.peripheralAddress} is already connected. Only one connection supported right now.`,
@@ -5,13 +5,9 @@
5
5
  */
6
6
 
7
7
  import { Bytes, Diagnostic, Logger, MatterError } from "#general";
8
- import { BLE_MATTER_SERVICE_UUID, BlePeripheral } from "#protocol";
8
+ import { BLE_MATTER_SERVICE_UUID } from "#protocol";
9
9
  import { BleError, BleErrorCode, BleManager, State as BluetoothState, Device } from "react-native-ble-plx";
10
10
 
11
- export interface ReactNativeBlePeripheral extends BlePeripheral {
12
- readonly device: Device;
13
- }
14
-
15
11
  const logger = Logger.get("ReactNativeBleClient");
16
12
 
17
13
  export class BluetoothUnauthorizedError extends MatterError {}
@@ -23,7 +19,7 @@ export class ReactNativeBleClient {
23
19
  private shouldScan = false;
24
20
  private isScanning = false;
25
21
  private bleState = BluetoothState.Unknown;
26
- private deviceDiscoveredCallback: ((peripheral: BlePeripheral, manufacturerData: Bytes) => void) | undefined;
22
+ private deviceDiscoveredCallback: ((peripheral: Device, manufacturerData: Bytes) => void) | undefined;
27
23
 
28
24
  constructor() {
29
25
  // this.bleManager.setLogLevel(LogLevel.Verbose)
@@ -60,10 +56,10 @@ export class ReactNativeBleClient {
60
56
  }, true);
61
57
  }
62
58
 
63
- public setDiscoveryCallback(callback: (peripheral: BlePeripheral, manufacturerData: Bytes) => void) {
59
+ public setDiscoveryCallback(callback: (peripheral: Device, manufacturerData: Bytes) => void) {
64
60
  this.deviceDiscoveredCallback = callback;
65
61
  for (const { peripheral, matterServiceData } of this.discoveredPeripherals.values()) {
66
- this.deviceDiscoveredCallback(this.#asBlePeripheral(peripheral), matterServiceData);
62
+ this.deviceDiscoveredCallback(peripheral, matterServiceData);
67
63
  }
68
64
  }
69
65
 
@@ -139,10 +135,6 @@ export class ReactNativeBleClient {
139
135
  matterServiceData: matterServiceData,
140
136
  });
141
137
 
142
- this.deviceDiscoveredCallback?.(this.#asBlePeripheral(peripheral), matterServiceData);
143
- }
144
-
145
- #asBlePeripheral(device: Device): ReactNativeBlePeripheral {
146
- return { device, address: device.id };
138
+ this.deviceDiscoveredCallback?.(peripheral, matterServiceData);
147
139
  }
148
140
  }