UNPKG

@matter/nodejs-ble

Version:

Matter BLE support for node.js

578 lines (518 loc) 25.8 kB
/** * @license * Copyright 2022-2025 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Channel, ChannelType, Diagnostic, InternalError, Logger, NetInterface, NetworkError, ServerAddress, Time, Timer, TransportInterface, asError, createPromise, } from "#general"; import { BLE_MATTER_C1_CHARACTERISTIC_UUID, BLE_MATTER_C2_CHARACTERISTIC_UUID, BLE_MATTER_C3_CHARACTERISTIC_UUID, BLE_MATTER_SERVICE_UUID, BLE_MAXIMUM_BTP_MTU, BTP_CONN_RSP_TIMEOUT_MS, BTP_MAXIMUM_WINDOW_SIZE, BTP_SUPPORTED_VERSIONS, BleChannel, BleError, BtpCodec, BtpFlowError, BtpSessionHandler, } from "#protocol"; import type { Characteristic, Peripheral } from "@stoprocent/noble"; import { BleScanner } from "./BleScanner.js"; const logger = Logger.get("BleChannel"); /** * Convert a UUID in noble's format to a proper UUID. * * @param {string} uuid - UUID to convert * @returns {string} UUID */ function nobleUuidToUuid(uuid: string): string { uuid = uuid.toUpperCase(); if (uuid.length !== 32) { return uuid; } const parts = [ uuid.substring(0, 8), uuid.substring(8, 12), uuid.substring(12, 16), uuid.substring(16, 20), uuid.substring(20, 32), ]; return parts.join("-"); } type BleConnectionGuard = { connectTimeout: Timer; interviewTimeout: Timer; disconnectTimeout: Timer; }; export class NobleBleCentralInterface implements NetInterface { #bleScanner: BleScanner; #connectionsInProgress = new Set<ServerAddress>(); #connectionGuards = new Set<BleConnectionGuard>(); #openChannels = new Map<ServerAddress, Peripheral>(); #onMatterMessageListener: ((socket: Channel<Uint8Array>, data: Uint8Array) => void) | undefined; #closed = false; constructor(bleScanner: BleScanner) { this.#bleScanner = bleScanner; } openChannel(address: ServerAddress, tryCount = 1): Promise<Channel<Uint8Array>> { if (this.#closed) { throw new NetworkError("Network interface is closed"); } return new Promise((resolve, reject) => { let resolvedOrRejected = false; function rejectOnce(error: unknown) { if (!resolvedOrRejected) { resolvedOrRejected = true; reject(asError(error)); } else { logger.debug(`Already resolved or rejected, ignore error:`, error); } } function resolveOnce(value: Channel<Uint8Array>) { if (!resolvedOrRejected) { resolvedOrRejected = true; resolve(value); } else { logger.debug(`Already resolved or rejected, ignore success`); } } if (this.#onMatterMessageListener === undefined) { rejectOnce( new InternalError(`Network Interface was not added to the system yet, so can not connect it.`), ); return; } if (address.type !== "ble") { rejectOnce(new InternalError(`Unsupported address type ${address.type}.`)); return; } const { peripheralAddress } = address; if (tryCount > 3) { rejectOnce(new BleError(`Failed to connect to peripheral ${peripheralAddress}`)); return; } // Get the peripheral by address and connect to it. const { peripheral, hasAdditionalAdvertisementData } = this.#bleScanner.getDiscoveredDevice(peripheralAddress); if (this.#openChannels.has(address)) { rejectOnce( new BleError( `Peripheral ${peripheralAddress} is already connected. Only one connection supported right now.`, ), ); return; } if (this.#connectionsInProgress.has(address)) { logger.debug(`Connection to peripheral ${peripheralAddress} is already in progress.`); return; } if (peripheral.state === "error") { // Weired state, so better cancel here and try a re-discovery rejectOnce( new BleError( `Can not connect to peripheral "${peripheralAddress}" because unexpected state "${peripheral.state}"`, ), ); return; } // Guard object to indicate if the connection was cancelled. This is used as safe guard in some places // if data come in delayed after we already gave up. const connectionGuard: BleConnectionGuard = { // Timeout when trying to connect to the device because sometimes connect fails and noble does // not emit an event. If device does not connect we do not try any longer and reject the promise // because a re-discovery is the best option to get teh device into a good state again connectTimeout: Time.getTimer("BLE connect timeout", 120_000, () => { logger.debug(`Timeout while connecting to peripheral ${peripheralAddress}`); // eslint-disable-next-line @typescript-eslint/no-misused-promises peripheral.removeListener("connect", connectHandler); peripheral.removeListener("disconnect", reTryHandler); clearConnectionGuard(); rejectOnce(new BleError(`Timeout while connecting to peripheral ${peripheralAddress}`)); }), disconnectTimeout: Time.getTimer("BLE disconnect timeout", 60_000, () => { logger.debug(`Timeout while disconnecting to peripheral ${peripheralAddress}`); peripheral.removeListener("disconnect", reTryHandler); clearConnectionGuard(); rejectOnce(new BleError(`Timeout while disconnecting to peripheral ${peripheralAddress}`)); }), // Timeout when trying to interview the device because sometimes when no response from device // comes noble does not resolve promises interviewTimeout: Time.getTimer("BLE interview timeout", 60_000, () => { logger.debug(`Timeout while interviewing peripheral ${peripheralAddress}`); peripheral.removeListener("disconnect", reTryHandler); clearConnectionGuard(); if (peripheral.state === "connected") { // We accept the dangling promise potentially because we got a timeout on reading data, // so chance is high also disconnect does not work reliably for now peripheral .disconnectAsync() .catch(error => logger.error(`Ignored error while disconnecting`, error)); } rejectOnce(new BleError(`Timeout while interviewing peripheral ${peripheralAddress}`)); }), }; this.#connectionGuards.add(connectionGuard); const clearConnectionGuard = () => { const { connectTimeout, interviewTimeout, disconnectTimeout } = connectionGuard; connectTimeout?.stop(); interviewTimeout?.stop(); disconnectTimeout?.stop(); this.#connectionGuards.delete(connectionGuard); }; // Handler to retry the connection. Called on disconnections and errors. const reTryHandler = (error?: any) => { // Cancel tracking states because we are done in this context clearConnectionGuard(); this.#connectionsInProgress.delete(address); // eslint-disable-next-line @typescript-eslint/no-misused-promises peripheral.removeListener("connect", connectHandler); peripheral.removeListener("disconnect", reTryHandler); if (error) { logger.error( `Peripheral ${peripheralAddress} disconnected while trying to connect, try again`, error, ); } else { logger.info(`Peripheral ${peripheralAddress} disconnected while trying to connect, try again`); } // Try again and chain promises this.openChannel(address, tryCount + 1) .then(resolveOnce) .catch(rejectOnce); }; const connectHandler = async (error?: any) => { connectionGuard.connectTimeout.stop(); // Connection done, so clear timeout if (!this.#connectionGuards.has(connectionGuard)) { // Seems that the response was delayed and this process was cancelled in the meantime return; } if (error) { clearConnectionGuard(); rejectOnce(new BleError(`Error while connecting to peripheral ${peripheralAddress}`, error)); return; } if (this.#onMatterMessageListener === undefined) { clearConnectionGuard(); rejectOnce(new InternalError(`Network Interface was not added to the system yet or was cleared.`)); return; } if (this.#connectionsInProgress.has(address)) { return; } this.#connectionsInProgress.add(address); try { connectionGuard.interviewTimeout.start(); const services = await peripheral.discoverServicesAsync([BLE_MATTER_SERVICE_UUID]); if (!this.#connectionGuards.has(connectionGuard)) { // Seems that the response was delayed and this process was cancelled in the meantime return; } logger.debug( `Peripheral ${peripheralAddress}: Found services: ${services.map(s => s.uuid).join(", ")}`, ); for (const service of services) { logger.debug(`Peripheral ${peripheralAddress}: Handle service: ${service.uuid}`); if (service.uuid !== BLE_MATTER_SERVICE_UUID) continue; // It's Matter, discover its characteristics. const characteristics = await service.discoverCharacteristicsAsync(); if (!this.#connectionGuards.has(connectionGuard)) { // Seems that the response was delayed and this process was cancelled in the meantime return; } let characteristicC1ForWrite: Characteristic | undefined; let characteristicC2ForSubscribe: Characteristic | undefined; let additionalCommissioningRelatedData: Uint8Array | undefined; for (const characteristic of characteristics) { // Loop through each characteristic and match them to the UUIDs that we know about. logger.debug( `Peripheral ${peripheralAddress}: Handle characteristic:`, characteristic.uuid, characteristic.properties, ); switch (nobleUuidToUuid(characteristic.uuid)) { case BLE_MATTER_C1_CHARACTERISTIC_UUID: logger.debug(`Peripheral ${peripheralAddress}: Found C1 characteristic`); characteristicC1ForWrite = characteristic; break; case BLE_MATTER_C2_CHARACTERISTIC_UUID: logger.debug(`Peripheral ${peripheralAddress}: Found C2 characteristic`); characteristicC2ForSubscribe = characteristic; break; case BLE_MATTER_C3_CHARACTERISTIC_UUID: logger.debug(`Peripheral ${peripheralAddress}: Found C3 characteristic`); if (hasAdditionalAdvertisementData) { logger.debug( `Peripheral ${peripheralAddress}: Reading additional commissioning related data`, ); const data = await characteristic.readAsync(); if (!this.#connectionGuards.has(connectionGuard)) { // Seems that the response was delayed and this process was cancelled in the meantime return; } additionalCommissioningRelatedData = new Uint8Array(data); logger.debug(`Peripheral ${peripheralAddress}: Additional data:`, data); } } } if (!characteristicC1ForWrite || !characteristicC2ForSubscribe) { logger.debug( `Peripheral ${peripheralAddress}: Missing required Matter characteristics. Ignore.`, ); continue; } connectionGuard.interviewTimeout.stop(); peripheral.removeListener("disconnect", reTryHandler); this.#openChannels.set(address, peripheral); try { resolveOnce( await NobleBleChannel.create( peripheral, characteristicC1ForWrite, characteristicC2ForSubscribe, this.#onMatterMessageListener, additionalCommissioningRelatedData, ), ); clearConnectionGuard(); this.#connectionsInProgress.delete(address); } catch (error) { this.#connectionsInProgress.delete(address); this.#openChannels.delete(address); if (peripheral.state === "connected") { logger.debug(`Disconnect because of initialization error of peripheral ${address}`); await peripheral .disconnectAsync() .catch(error => logger.debug( `Peripheral ${peripheral.address}: Error while disconnecting`, error, ), ); } reTryHandler(error); return; } } } finally { this.#connectionsInProgress.delete(address); clearConnectionGuard(); } peripheral.removeListener("disconnect", reTryHandler); rejectOnce( new BleError(`Peripheral ${peripheralAddress} does not have the required Matter characteristics`), ); }; if (peripheral.state === "connected") { logger.debug(`Peripheral ${peripheralAddress}: Already connected`); connectHandler().catch(error => logger.warn(`Error while connecting`, error)); // Error should never happen } else if (peripheral.state === "disconnecting") { logger.debug(`Peripheral ${peripheralAddress}: Disconnect in progress`); connectionGuard.disconnectTimeout.start(); tryCount--; peripheral.once("disconnect", reTryHandler); } else { if (peripheral.state === "connecting") { peripheral.cancelConnect(); // Send cancel to noble to make sure we can connect } // connecting, disconnected connectionGuard.connectTimeout.start(); // eslint-disable-next-line @typescript-eslint/no-misused-promises peripheral.once("connect", connectHandler); peripheral.once("disconnect", reTryHandler); logger.debug(`Peripheral ${peripheralAddress}: Connect to Peripheral now (try ${tryCount})`); peripheral.connectAsync().catch(error => { if (!this.#connectionGuards.has(connectionGuard)) { // Seems that the response was delayed and this process was cancelled in the meantime return; } logger.info(`Peripheral ${peripheralAddress}: Error while connecting to peripheral`, error); reTryHandler(error); }); } }); } onData(listener: (socket: Channel<Uint8Array>, data: Uint8Array) => void): TransportInterface.Listener { this.#onMatterMessageListener = listener; return { close: async () => await this.close(), }; } async close() { this.#closed = true; for (const peripheral of this.#openChannels.values()) { if (peripheral.state === "connected") { logger.debug(`Peripheral ${peripheral.address}: Disconnect from peripheral while closing central`); peripheral .disconnectAsync() .catch(error => logger.error(`Peripheral ${peripheral.address}: Error while disconnecting`, error)); } } this.#openChannels.clear(); } supports(type: ChannelType, _address?: string) { if (type !== ChannelType.BLE) { return false; } return true; } } export class NobleBleChannel extends BleChannel<Uint8Array> { static async create( peripheral: Peripheral, characteristicC1ForWrite: Characteristic, characteristicC2ForSubscribe: Characteristic, onMatterMessageListener: (socket: Channel<Uint8Array>, data: Uint8Array) => void, _additionalCommissioningRelatedData?: Uint8Array, ): Promise<NobleBleChannel> { const { address: peripheralAddress } = peripheral; let mtu = peripheral.mtu ?? 0; if (mtu > BLE_MAXIMUM_BTP_MTU) { mtu = BLE_MAXIMUM_BTP_MTU; } logger.debug( `Peripheral ${peripheralAddress}: Using MTU=${mtu} bytes (Peripheral supports up to ${peripheral.mtu} bytes)`, ); const { promise: handshakeResponseReceivedPromise, resolver: handshakeResolver, rejecter: handshakeRejecter, } = createPromise<Buffer>(); const handshakeHandler = (data: Buffer, isNotification: boolean) => { if (data[0] === 0x65 && data[1] === 0x6c && data.length === 6) { // Check if the first two bytes and length match the Matter handshake logger.info( `Peripheral ${peripheralAddress}: Received Matter handshake response: ${data.toString("hex")}.`, ); btpHandshakeTimeout.stop(); handshakeResolver(data); } else { logger.debug( `Peripheral ${peripheralAddress}: Received first data on C2: ${data.toString("hex")} (isNotification: ${isNotification}) - No handshake response, ignoring`, ); } }; const btpHandshakeTimeout = Time.getTimer("BLE handshake timeout", BTP_CONN_RSP_TIMEOUT_MS, async () => { characteristicC2ForSubscribe.removeListener("data", handshakeHandler); await characteristicC2ForSubscribe .unsubscribeAsync() .catch(error => logger.error(`Peripheral ${peripheralAddress}: Error while unsubscribing`, error)); logger.debug( `Peripheral ${peripheralAddress}: Handshake Response not received. Disconnect from peripheral`, ); handshakeRejecter(new BleError(`Peripheral ${peripheralAddress}: Handshake Response not received`)); }).start(); const btpHandshakeRequest = BtpCodec.encodeBtpHandshakeRequest({ versions: BTP_SUPPORTED_VERSIONS, attMtu: mtu, clientWindowSize: BTP_MAXIMUM_WINDOW_SIZE, }); logger.debug( `Peripheral ${peripheralAddress}: Sending BTP handshake request: ${Diagnostic.json(btpHandshakeRequest)}`, ); await characteristicC1ForWrite.writeAsync(Buffer.from(btpHandshakeRequest.buffer), false); characteristicC2ForSubscribe.once("data", handshakeHandler); logger.debug(`Peripheral ${peripheralAddress}: Subscribing to C2 characteristic`); await characteristicC2ForSubscribe.subscribeAsync(); const handshakeResponse = await handshakeResponseReceivedPromise; const btpSession = await BtpSessionHandler.createAsCentral( new Uint8Array(handshakeResponse), // callback to write data to characteristic C1 async (data: Uint8Array) => { return await characteristicC1ForWrite.writeAsync(Buffer.from(data.buffer), false); }, // callback to disconnect the BLE connection async () => { if (peripheral.state !== "connected" || !nobleChannel.connected) return; logger.debug(`Peripheral ${peripheralAddress}: Disconnect from peripheral because btp session closed`); characteristicC2ForSubscribe.unsubscribeAsync().then( () => peripheral.disconnectAsync().then( () => logger.debug(`Peripheral ${peripheralAddress}: Disconnected from peripheral`), error => logger.debug(`Peripheral ${peripheralAddress}: Error while unsubscribing`, error), ), error => logger.debug(`Peripheral ${peripheralAddress}: Error while disconnecting`, error), ); }, // callback to forward decoded and de-assembled Matter messages to ExchangeManager async (data: Uint8Array) => { if (onMatterMessageListener === undefined) { throw new InternalError(`No listener registered for Matter messages`); } onMatterMessageListener(nobleChannel, data); }, ); characteristicC2ForSubscribe.on("data", (data, isNotification) => { logger.debug( `Peripheral ${peripheralAddress}: received data on C2: ${data.toString("hex")} (isNotification: ${isNotification})`, ); void btpSession.handleIncomingBleData(new Uint8Array(data)); }); const nobleChannel = new NobleBleChannel(peripheral, btpSession); return nobleChannel; } #connected = true; constructor( private readonly peripheral: Peripheral, private readonly btpSession: BtpSessionHandler, ) { super(); peripheral.once("disconnect", () => { logger.debug(`Disconnected from peripheral ${peripheral.address}. Closing BTP session`); this.#connected = false; void this.btpSession.close(); }); } get connected() { return this.#connected && this.peripheral.state === "connected"; } /** * Send a Matter message to the connected device - need to do BTP assembly first. * * @param data */ async send(data: Uint8Array) { if (!this.connected) { logger.debug( `Peripheral ${this.peripheral.address}: Cannot send data because not connected to peripheral.`, ); return; } if (this.btpSession === undefined) { throw new BtpFlowError( `Peripheral ${this.peripheral.address}: Cannot send data, no BTP session initialized`, ); } await this.btpSession.sendMatterMessage(data); } // Channel<Uint8Array> get name() { return `${this.type}://${this.peripheral.address}`; } async close() { await this.btpSession.close(); if (this.connected) { this.peripheral .disconnectAsync() .catch(error => logger.error(`Peripheral ${this.peripheral.address}: Error while disconnecting`, error), ); } } }