UNPKG

@matter/nodejs-ble

Version:

Matter BLE support for node.js

327 lines (286 loc) 13.1 kB
/** * @license * Copyright 2022-2025 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Bytes, ChannelType, createPromise, Diagnostic, Logger, Time, Timer } from "#general"; import { BleError, BtpCodec, CommissionableDevice, CommissionableDeviceIdentifiers, Scanner } from "#protocol"; import { VendorId } from "#types"; import type { Peripheral } from "@stoprocent/noble"; import { NobleBleClient } from "./NobleBleClient.js"; const logger = Logger.get("BleScanner"); export type DiscoveredBleDevice = { deviceData: CommissionableDeviceData; peripheral: Peripheral; hasAdditionalAdvertisementData: boolean; }; type CommissionableDeviceData = CommissionableDevice & { SD: number; // Additional Field for Short discriminator }; export class BleScanner implements Scanner { readonly type = ChannelType.BLE; readonly #nobleClient: NobleBleClient; readonly #recordWaiters = new Map< string, { resolver: () => void; timer?: Timer; resolveOnUpdatedRecords: boolean; cancelResolver?: (value: void) => void; } >(); readonly #discoveredMatterDevices = new Map<string, DiscoveredBleDevice>(); constructor(nobleClient: NobleBleClient) { this.#nobleClient = nobleClient; this.#nobleClient.setDiscoveryCallback((address, manufacturerData) => this.#handleDiscoveredDevice(address, manufacturerData), ); } public getDiscoveredDevice(address: string): DiscoveredBleDevice { const device = this.#discoveredMatterDevices.get(address); if (device === undefined) { throw new BleError(`No device found for address ${address}`); } return device; } /** * Registers a deferred promise for a specific queryId together with a timeout and return the promise. * The promise will be resolved when the timer runs out latest. */ async #registerWaiterPromise( queryId: string, timeoutSeconds?: number, resolveOnUpdatedRecords = true, cancelResolver?: (value: void) => void, ) { const { promise, resolver } = createPromise<void>(); let timer; if (timeoutSeconds !== undefined) { timer = Time.getTimer("BLE query timeout", timeoutSeconds * 1000, () => { cancelResolver?.(); this.#finishWaiter(queryId, true); }).start(); } this.#recordWaiters.set(queryId, { resolver, timer, resolveOnUpdatedRecords, cancelResolver }); logger.debug( `Registered waiter for query ${queryId} with timeout ${timeoutSeconds} seconds${ resolveOnUpdatedRecords ? "" : " (not resolving on updated records)" }`, ); await promise; } /** * Remove a waiter promise for a specific queryId and stop the connected timer. If required also resolve the * promise. */ #finishWaiter(queryId: string, resolvePromise: boolean, isUpdatedRecord = false) { const waiter = this.#recordWaiters.get(queryId); if (waiter === undefined) return; const { timer, resolver, resolveOnUpdatedRecords } = waiter; if (isUpdatedRecord && !resolveOnUpdatedRecords) return; logger.debug(`Finishing waiter for query ${queryId}, resolving: ${resolvePromise}`); timer?.stop(); if (resolvePromise) { resolver(); } this.#recordWaiters.delete(queryId); } cancelCommissionableDeviceDiscovery(identifier: CommissionableDeviceIdentifiers, resolvePromise = true) { const queryKey = this.#buildCommissionableQueryIdentifier(identifier); const { cancelResolver } = this.#recordWaiters.get(queryKey) ?? {}; // Mark as canceled to not loop further in discovery, if cancel-resolver is used cancelResolver?.(); this.#finishWaiter(queryKey, resolvePromise); } #handleDiscoveredDevice(peripheral: Peripheral, manufacturerServiceData: Uint8Array) { const address = peripheral.address; try { const { discriminator, vendorId, productId, hasAdditionalAdvertisementData } = BtpCodec.decodeBleAdvertisementServiceData(manufacturerServiceData); const deviceData: CommissionableDeviceData = { deviceIdentifier: address, D: discriminator, SD: (discriminator >> 8) & 0x0f, VP: `${vendorId}+${productId}`, CM: 1, // Can be no other mode, addresses: [{ type: "ble", peripheralAddress: address }], }; const deviceExisting = this.#discoveredMatterDevices.has(address); logger.debug( `${deviceExisting ? "Re-" : ""}Discovered device ${address} data: ${Diagnostic.json(deviceData)}`, ); this.#discoveredMatterDevices.set(address, { deviceData, peripheral, hasAdditionalAdvertisementData, }); const queryKey = this.#findCommissionableQueryIdentifier(deviceData); if (queryKey !== undefined) { this.#finishWaiter(queryKey, true, deviceExisting); } } catch (error) { logger.debug( `Discovered device ${address} ${manufacturerServiceData === undefined ? undefined : Bytes.toHex(manufacturerServiceData)} does not seem to be a valid Matter device: ${error}`, ); } } #findCommissionableQueryIdentifier(record: CommissionableDeviceData) { const longDiscriminatorQueryId = this.#buildCommissionableQueryIdentifier({ longDiscriminator: record.D }); if (this.#recordWaiters.has(longDiscriminatorQueryId)) { return longDiscriminatorQueryId; } const shortDiscriminatorQueryId = this.#buildCommissionableQueryIdentifier({ shortDiscriminator: record.SD }); if (this.#recordWaiters.has(shortDiscriminatorQueryId)) { return shortDiscriminatorQueryId; } if (record.VP !== undefined) { const vendorIdQueryId = this.#buildCommissionableQueryIdentifier({ vendorId: VendorId(parseInt(record.VP.split("+")[0])), }); if (this.#recordWaiters.has(vendorIdQueryId)) { return vendorIdQueryId; } if (record.VP.includes("+")) { const productIdQueryId = this.#buildCommissionableQueryIdentifier({ vendorId: VendorId(parseInt(record.VP.split("+")[1])), }); if (this.#recordWaiters.has(productIdQueryId)) { return productIdQueryId; } } } if (this.#recordWaiters.has("*")) { return "*"; } return undefined; } /** * Builds an identifier string for commissionable queries based on the given identifier object. * Some identifiers are identical to the official DNS-SD identifiers, others are custom. */ #buildCommissionableQueryIdentifier(identifier: CommissionableDeviceIdentifiers) { if ("longDiscriminator" in identifier) { return `D:${identifier.longDiscriminator}`; } else if ("shortDiscriminator" in identifier) { return `SD:${identifier.shortDiscriminator}`; } else if ("vendorId" in identifier) { return `V:${identifier.vendorId}`; } else if ("productId" in identifier) { // Custom identifier because normally productId is only included in TXT record return `P:${identifier.productId}`; } else return "*"; } #getCommissionableDevices(identifier: CommissionableDeviceIdentifiers) { const storedRecords = Array.from(this.#discoveredMatterDevices.values()); const foundRecords = new Array<DiscoveredBleDevice>(); if ("longDiscriminator" in identifier) { foundRecords.push(...storedRecords.filter(({ deviceData: { D } }) => D === identifier.longDiscriminator)); } else if ("shortDiscriminator" in identifier) { foundRecords.push( ...storedRecords.filter(({ deviceData: { SD } }) => SD === identifier.shortDiscriminator), ); } else if ("vendorId" in identifier) { foundRecords.push( ...storedRecords.filter( ({ deviceData: { VP } }) => VP === `${identifier.vendorId}` || VP?.startsWith(`${identifier.vendorId}+`), ), ); } else if ("productId" in identifier) { foundRecords.push( ...storedRecords.filter(({ deviceData: { VP } }) => VP?.endsWith(`+${identifier.productId}`)), ); } else { foundRecords.push(...storedRecords.filter(({ deviceData: { CM } }) => CM === 1 || CM === 2)); } return foundRecords; } async findOperationalDevice(): Promise<undefined> { logger.info(`skip BLE scan because scanning for operational devices is not supported`); return undefined; } getDiscoveredOperationalDevice(): undefined { logger.info(`skip BLE scan because scanning for operational devices is not supported`); return undefined; } async findCommissionableDevices( identifier: CommissionableDeviceIdentifiers, timeoutSeconds = 10, ignoreExistingRecords = false, ): Promise<CommissionableDevice[]> { let storedRecords = this.#getCommissionableDevices(identifier); if (ignoreExistingRecords) { // We want to have a fresh discovery result, so clear out the stored records because they might be outdated for (const record of storedRecords) { this.#discoveredMatterDevices.delete(record.peripheral.address); } storedRecords = []; } if (storedRecords.length === 0) { const queryKey = this.#buildCommissionableQueryIdentifier(identifier); await this.#nobleClient.startScanning(); await this.#registerWaiterPromise(queryKey, timeoutSeconds); storedRecords = this.#getCommissionableDevices(identifier); await this.#nobleClient.stopScanning(); } return storedRecords.map(({ deviceData }) => deviceData); } async findCommissionableDevicesContinuously( identifier: CommissionableDeviceIdentifiers, callback: (device: CommissionableDevice) => void, timeoutSeconds?: number, cancelSignal?: Promise<void>, ): Promise<CommissionableDevice[]> { const discoveredDevices = new Set<string>(); const discoveryEndTime = timeoutSeconds ? Time.nowMs() + timeoutSeconds * 1000 : undefined; const queryKey = this.#buildCommissionableQueryIdentifier(identifier); await this.#nobleClient.startScanning(); let queryResolver: ((value: void) => void) | undefined; if (cancelSignal === undefined) { const { promise, resolver } = createPromise<void>(); cancelSignal = promise; queryResolver = resolver; } let canceled = false; cancelSignal?.then( () => { canceled = true; if (queryResolver === undefined) { // Always finish when cancelSignal parameter was used, else cancelling is done separately this.#finishWaiter(queryKey, true); } }, cause => { logger.error("Unexpected error canceling commissioning", cause); }, ); while (!canceled) { this.#getCommissionableDevices(identifier).forEach(({ deviceData }) => { const { deviceIdentifier } = deviceData; if (!discoveredDevices.has(deviceIdentifier)) { discoveredDevices.add(deviceIdentifier); callback(deviceData); } }); let remainingTime; if (discoveryEndTime !== undefined) { remainingTime = Math.ceil((discoveryEndTime - Time.nowMs()) / 1000); if (remainingTime <= 0) { break; } } await this.#registerWaiterPromise(queryKey, remainingTime, false, queryResolver); } await this.#nobleClient.stopScanning(); return this.#getCommissionableDevices(identifier).map(({ deviceData }) => deviceData); } getDiscoveredCommissionableDevices(identifier: CommissionableDeviceIdentifiers): CommissionableDevice[] { return this.#getCommissionableDevices(identifier).map(({ deviceData }) => deviceData); } async close() { this.#nobleClient.close(); [...this.#recordWaiters.keys()].forEach(queryId => this.#finishWaiter(queryId, !!this.#recordWaiters.get(queryId)?.timer), ); } }