UNPKG

@matter/nodejs-ble

Version:

Matter BLE support for node.js

435 lines (380 loc) 15.7 kB
/** * @license * Copyright 2022-2025 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Channel, InternalError, Logger, Time, createPromise } from "#general"; import { BLE_MATTER_C1_CHARACTERISTIC_UUID, BLE_MATTER_C2_CHARACTERISTIC_UUID, BLE_MATTER_C3_CHARACTERISTIC_UUID, BLE_MATTER_SERVICE_UUID, BTP_CONN_RSP_TIMEOUT_MS, BleChannel, BleError, BtpFlowError, BtpSessionHandler, ChannelNotConnectedError, } from "#protocol"; import { require } from "@matter/nodejs-ble/require"; import type { Bleno as BlenoType } from "@stoprocent/bleno"; import { BleOptions } from "./NodeJsBle.js"; const logger = Logger.get("BlenoBleServer"); let Bleno: BlenoType; function initializeBleno(server: BlenoBleServer, hciId?: number) { // load Bleno driver with the correct device selected if (hciId !== undefined) { process.env.BLENO_HCI_DEVICE_ID = hciId.toString(); } Bleno = require("@stoprocent/bleno"); class BtpWriteCharacteristicC1 extends Bleno.Characteristic { constructor() { super({ uuid: BLE_MATTER_C1_CHARACTERISTIC_UUID, properties: ["write"], onWriteRequest: (_handle, data, offset, withoutResponse, callback) => this.#onWriteRequest(data, offset, withoutResponse, callback), }); } #onWriteRequest( data: Buffer<ArrayBufferLike>, offset: number, withoutResponse: boolean, callback: (result: number) => void, ) { logger.debug(`C1 write request: ${data.toString("hex")} ${offset} ${withoutResponse}`); try { server.handleC1WriteRequest(data, offset, withoutResponse); callback(this.RESULT_SUCCESS); } catch (e) { logger.error(`C1 write request failed: ${e}`); callback(this.RESULT_UNLIKELY_ERROR); } } } class BtpIndicateCharacteristicC2 extends Bleno.Characteristic { constructor() { super({ uuid: BLE_MATTER_C2_CHARACTERISTIC_UUID, properties: ["indicate"], onSubscribe: (_handle, maxValueSize, updateValueCallback) => this.#onSubscribe(maxValueSize, updateValueCallback), onUnsubscribe: () => this.#onUnsubscribe(), onIndicate: () => this.#onIndicate(), }); } #onSubscribe(maxValueSize: number, updateValueCallback: (data: Buffer) => void) { logger.debug(`C2 subscribe ${maxValueSize}`); server .handleC2SubscribeRequest(maxValueSize, updateValueCallback) .catch(e => logger.warn("Error happened in when handling C2 subscribe", e)); } #onUnsubscribe() { logger.debug("C2 unsubscribe"); server.close().catch(e => logger.warn("Error happened when closing server for C2 unsubscribe", e)); } #onIndicate() { logger.debug("C2 indicate"); server.handleC2Indicate(); } } class BtpReadCharacteristicC3 extends Bleno.Characteristic { constructor() { super({ uuid: BLE_MATTER_C3_CHARACTERISTIC_UUID, properties: ["read"], onReadRequest: (_handle, offset, callback) => this.#onReadRequest(offset, callback), }); } #onReadRequest(offset: number, callback: (result: number, data?: Buffer) => void) { try { const data = server.handleC3ReadRequest(offset); logger.debug(`C3 read request: ${data.toString("hex")} ${offset}`); callback(this.RESULT_SUCCESS, data); } catch (e) { logger.debug(`C3 read request failed : ${e} ${offset}`); callback(this.RESULT_INVALID_OFFSET); } } } class BtpService extends Bleno.PrimaryService { constructor() { super({ uuid: BLE_MATTER_SERVICE_UUID, characteristics: [ new BtpWriteCharacteristicC1(), new BtpIndicateCharacteristicC2(), new BtpReadCharacteristicC3(), ], }); } } return new BtpService(); } /** * Implements the Matter over BLE server using Bleno as Peripheral. * * Note: Bleno is only supporting a single connection at a time right now - mainly because it also only can announce * one BLE device at a time! */ export class BlenoBleServer extends BleChannel<Uint8Array> { private state = "unknown"; isAdvertising = false; private additionalAdvertisingData: Buffer = Buffer.alloc(0); private advertisingData: Buffer | undefined; private latestHandshakePayload: Buffer | undefined; private btpSession: BtpSessionHandler | undefined; private onMatterMessageListener: ((socket: Channel<Uint8Array>, data: Uint8Array) => void) | undefined; private writeConformationResolver: ((value: void) => void) | undefined; public clientAddress: string | undefined; private btpHandshakeTimeout = Time.getTimer("BTP handshake timeout", BTP_CONN_RSP_TIMEOUT_MS, () => this.btpHandshakeTimeoutTriggered(), ); #disconnected = false; #closing = false; private readonly matterBleService; constructor(options?: BleOptions) { super(); const { environment } = options ?? {}; environment?.runtime.add(this); this.matterBleService = initializeBleno(this, options?.hciId); // Write Bleno into this class Bleno.on("stateChange", state => { if (state === this.state) return; this.state = state; logger.debug(`stateChange: ${state}, address = ${Bleno.address}`); if (state !== "poweredOn") { // When we shut down we expect a state change but can not call any command anmore if (this.#disconnected || this.#closing) return; Bleno.stopAdvertising(); } else if (this.advertisingData) { Bleno.startAdvertisingWithEIRData(this.advertisingData); this.isAdvertising = true; } }); // Linux only events ///////////////// Bleno.on("accept", clientAddress => { logger.debug(`accept new connection, client: ${clientAddress}`); this.clientAddress = clientAddress; Bleno.updateRssi(); }); Bleno.on("disconnect", clientAddress => { logger.debug(`disconnect, client: ${clientAddress}`); this.isAdvertising = false; if (this.btpSession !== undefined) { this.btpSession .close() .then(() => { this.btpSession = undefined; }) .catch(() => { this.btpSession = undefined; }); } }); Bleno.on("rssiUpdate", rssi => { logger.debug(`rssiUpdate: ${rssi}`); }); ////////////////////////////////////// Bleno.on("mtuChange", mtu => { logger.debug(`mtuChange: ${mtu}`); }); Bleno.on("advertisingStart", error => { logger.debug(`advertisingStart: ${error ? `error ${error}` : "success"}`); if (!error) { Bleno.setServices([this.matterBleService]); } // TODO handle transport error }); Bleno.on("advertisingStop", () => { logger.debug("advertisingStop"); }); Bleno.on("servicesSet", error => { logger.debug(`servicesSet: ${error ? `error ${error}` : "success"}`); }); } /** * Process a Write request on characteristic C1 from the Matter service. * The data are checked if it might be a handshake request and stored until the subscribe request comes in. * Otherwise, the data are forwarded to the BTP session handler to be decoded and processed. * * @param data * @param offset * @param withoutResponse */ handleC1WriteRequest(data: Buffer<ArrayBufferLike>, offset: number, withoutResponse: boolean) { if (offset !== 0 || withoutResponse) { throw new BleError(`Offset ${offset} or withoutResponse ${withoutResponse} not supported`); } if (data[0] === 0x65 && data[1] === 0x6c && data.length === 9) { // Check if the first two bytes and length match the Matter handshake this.btpHandshakeTimeout.start(); // starts timer logger.info( `Received Matter handshake request: ${data.toString("hex")}, store until subscribe request comes in.`, ); this.latestHandshakePayload = data; // TODO Handle edge case where handshake comes with an already open BTP session (should never happen?) } else { if (this.btpSession !== undefined) { logger.debug(`Received Matter data for BTP Session: ${data.toString("hex")}`); void this.btpSession.handleIncomingBleData(new Uint8Array(data)); } else { throw new BtpFlowError( `Received Matter data but no BTP session was initialized: ${data.toString("hex")}`, ); } } } /** * Process a Subscribe request on characteristic C2 from the Matter service. * This is expected directly after a handshake request and initializes the BTP session handler with the stored * handshake payload. * The BtpSessionHandler instance is wired with the bleno instance for sending data and disconnecting. * * @param maxValueSize * @param updateValueCallback */ async handleC2SubscribeRequest(maxValueSize: number, updateValueCallback: (data: Buffer) => void) { if (this.latestHandshakePayload === undefined) { throw new BtpFlowError(`Subscription request received before handshake Request`); } if (this.btpSession !== undefined) { throw new BtpFlowError( `Subscription request received but BTP session already initialized. Cannot handle two sessions!`, ); } this.btpHandshakeTimeout.stop(); this.btpSession = await BtpSessionHandler.createFromHandshakeRequest( Math.min(Bleno.mtu - 3, maxValueSize), new Uint8Array(this.latestHandshakePayload), // callback to write data to characteristic C2 async (data: Uint8Array) => { updateValueCallback(Buffer.from(data.buffer)); const { promise, resolver } = createPromise<void>(); this.writeConformationResolver = resolver; return promise; }, // callback to disconnect the BLE connection async () => this.close(), // callback to forward decoded and de-assembled Matter messages to ExchangeManager async (data: Uint8Array) => { if (this.onMatterMessageListener === undefined) { throw new InternalError(`No listener registered for Matter messages`); } this.onMatterMessageListener(this, data); }, ); this.latestHandshakePayload = undefined; // BTP Session initialized, handshake payload not needed anymore } handleC2Indicate() { if (this.writeConformationResolver !== undefined) { this.writeConformationResolver(); this.writeConformationResolver = undefined; } else { logger.warn(`Received C2 indication but no former write expected a confirmation`); } } /** * Process a Read request on characteristic C3 from the Matter service. * The relevant data needs optionally to be set before advertising, else empty data are used. * * @param offset */ handleC3ReadRequest(offset: number) { if (offset > this.additionalAdvertisingData.length) { throw new BleError(`Offset ${offset} is larger than data ${this.additionalAdvertisingData.length}`); } else { return this.additionalAdvertisingData.subarray(offset); } } async advertise(advertiseData: Uint8Array, additionalAdvertisementData?: Uint8Array) { this.advertisingData = Buffer.from(advertiseData.buffer); if (additionalAdvertisementData) { this.additionalAdvertisingData = Buffer.from(additionalAdvertisementData.buffer); } else { this.additionalAdvertisingData = Buffer.alloc(0); } if (this.isAdvertising) { await this.stopAdvertising(); this.isAdvertising = false; } if (this.state === "poweredOn") { Bleno.startAdvertisingWithEIRData(this.advertisingData); this.isAdvertising = true; } else { logger.debug(`State is ${this.state}, advertise when powered on`); } return new Promise<void>(resolve => { Bleno.once("advertisingStart", () => resolve()); }); } async stopAdvertising() { if (this.isAdvertising) { return new Promise<void>(resolve => { Bleno.stopAdvertising(); Bleno.once("advertisingStop", () => { this.isAdvertising = false; resolve(); }); }); } } setMatterMessageListener(listener: (socket: Channel<Uint8Array>, data: Uint8Array) => void) { if (this.onMatterMessageListener !== undefined) { throw new InternalError(`onData listener already set`); } this.onMatterMessageListener = listener; } async btpHandshakeTimeoutTriggered() { await this.disconnect(); logger.error("Timeout for handshake subscribe request on C2 reached, disconnecting."); } async close() { if (this.#closing) { return; } this.#closing = true; this.btpHandshakeTimeout.stop(); await this.disconnect(); if (this.btpSession !== undefined) { await this.btpSession.close(); this.btpSession = undefined; } this.onMatterMessageListener = undefined; } async disconnect() { this.isAdvertising = false; if (this.#disconnected) { return; } this.#disconnected = true; logger.debug(`Disconnecting Bleno`); Bleno.disconnect(); logger.debug(`Stopping Bleno`); Bleno.stop(); /* TODO: This is not working as expected, the disconnect event is not triggered, seems issue in Bleno return new Promise<void>(resolve => { Bleno.once("disconnect", () => { console.log("DISCONNECTED"); resolve(); }); });*/ } // Channel<Uint8Array> /** * Send a Matter message to the connected device - need to do BTP assembly first. * * @param data */ async send(data: Uint8Array) { if (this.btpSession === undefined) { throw new ChannelNotConnectedError(`Cannot send data, no BTP session initialized`); } await this.btpSession.sendMatterMessage(data); } // Channel<Uint8Array> get name() { return `${this.type}://${this.clientAddress}`; } }