UNPKG

webbluetooth

Version:

Node.js implementation of the Web Bluetooth Specification

467 lines (386 loc) 17.8 kB
/* * Node Web Bluetooth * Copyright (c) 2025 Rob Moran * * The MIT License (MIT) * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { EventEmitter } from 'events'; import { Adapter as BluetoothAdapter } from './adapter'; import { BluetoothUUID } from '../uuid'; import { BluetoothDeviceImpl } from '../device'; import { BluetoothRemoteGATTCharacteristicImpl } from '../characteristic'; import { BluetoothRemoteGATTServiceImpl } from '../service'; import { BluetoothRemoteGATTDescriptorImpl } from '../descriptor'; import { isEnabled, getAdapters as simpleBleAdapters, Adapter, Peripheral, Service, Characteristic, Descriptor } from './simpleble'; /** * @hidden */ class PeripheralHandles { private handleCounter = 0; private peripheralChildren = new Map<Peripheral, string[]>(); private children = new Map<string, string[]>(); private parents = new Map<string, string>(); private services = new Map<string, Service>(); private characteristics = new Map<string, Characteristic>(); private descriptors = new Map<string, Descriptor>(); public constructor(private peripherals: Map<string, Peripheral>) { } public characteristicEvents = new Map<string, (value: DataView) => void>(); public createHandles(peripheral: Peripheral): void { const services: string[] = []; for (const service of peripheral.services) { const serviceHandle = `${this.handleCounter++}`; this.parents.set(serviceHandle, peripheral.address); this.services.set(serviceHandle, service); services.push(serviceHandle); const characteristics: string[] = []; for (const characteristic of service.characteristics) { const characteristicHandle = `${this.handleCounter++}`; this.parents.set(characteristicHandle, serviceHandle); this.characteristics.set(characteristicHandle, characteristic); characteristics.push(characteristicHandle); const descriptors: string[] = []; for (const descriptor of characteristic.descriptors) { const descHandle = `${this.handleCounter++}`; this.parents.set(descHandle, characteristicHandle); this.descriptors.set(descHandle, descriptor); descriptors.push(descHandle); } this.children.set(characteristicHandle, descriptors); } this.children.set(serviceHandle, characteristics); } this.children.set(peripheral.address, services); } public deleteHandles(peripheral: Peripheral): void { const children = this.peripheralChildren.get(peripheral); for (const child of children) { this.children.delete(child); this.parents.delete(child); this.services.delete(child); this.characteristics.delete(child); this.descriptors.delete(child); this.characteristicEvents.delete(child); } this.peripheralChildren.delete(peripheral); } public getServices(deviceHandle: string): { [key: string]: Service } { const children = this.children.get(deviceHandle); const services: { [key: string]: Service } = {}; for (const child of children) { services[child] = this.services.get(child); } return services; } public getCharacteristics(serviceHandle: string): { peripheral: Peripheral, service: Service, characteristics: { [key: string]: Characteristic } } { const children = this.children.get(serviceHandle); const characteristics: { [key: string]: Characteristic } = {}; for (const child of children) { characteristics[child] = this.characteristics.get(child); } const peripheralHandle = this.parents.get(serviceHandle); return { peripheral: this.peripherals.get(peripheralHandle), service: this.services.get(serviceHandle), characteristics }; } public getDescriptors(characteristicHandle: string): { [key: string]: Descriptor } { const children = this.children.get(characteristicHandle); const descriptors: { [key: string]: Descriptor } = {}; for (const child of children) { descriptors[child] = this.descriptors.get(child); } return descriptors; } public getCharacteristicGraph(characteristicHandle: string): { peripheral: Peripheral, service: Service, characteristic: Characteristic } { const serviceHandle = this.parents.get(characteristicHandle); const peripheralHandle = this.parents.get(serviceHandle); return { peripheral: this.peripherals.get(peripheralHandle), service: this.services.get(serviceHandle), characteristic: this.characteristics.get(characteristicHandle) }; } public getDescriptorGraph(descriptorHandle: string): { peripheral: Peripheral, service: Service, characteristic: Characteristic, descriptor: Descriptor } { const characteristicHandle = this.parents.get(descriptorHandle); const serviceHandle = this.parents.get(characteristicHandle); const peripheralHandle = this.parents.get(serviceHandle); return { peripheral: this.peripherals.get(peripheralHandle), service: this.services.get(serviceHandle), characteristic: this.characteristics.get(characteristicHandle), descriptor: this.descriptors.get(descriptorHandle) }; } } /** * @hidden */ export class SimplebleAdapter extends EventEmitter implements BluetoothAdapter { private adapter: Adapter; private peripherals = new Map<string, Peripheral>(); private handles = new PeripheralHandles(this.peripherals); private validDevice(device: Partial<BluetoothDeviceImpl>, serviceUUIDs: Array<string>): boolean { if (serviceUUIDs.length === 0) { // Match any device return true; } if (!device._serviceUUIDs) { // No advertised services, no match return false; } const advertisedUUIDs = device._serviceUUIDs.map((serviceUUID: string) => BluetoothUUID.canonicalUUID(serviceUUID)); // An advertised UUID matches our search UUIDs return serviceUUIDs.some(serviceUUID => advertisedUUIDs.indexOf(serviceUUID) >= 0); } private buildBluetoothDevice(device: Peripheral): Partial<BluetoothDeviceImpl> { const name = device.identifier; const address = device.address; const rssi = device.rssi; const txPower = device.txPower; const mtu = device.mtu; const id = address || `${name}`; const serviceUUIDs: string[] = []; const serviceData = new Map(); for (const service of device.services) { serviceUUIDs.push(service.uuid); if (service.data) { serviceData.set(service.uuid, service.data); } } const manufacturerData = new Map(); for (const id in device.manufacturerData) { manufacturerData.set(id, new DataView(device.manufacturerData[id].buffer)); } return { id, name, _serviceUUIDs: serviceUUIDs, _adData: { rssi, txPower, mtu, serviceData, manufacturerData } }; } private get state(): boolean { const adapterEnabled = isEnabled(); return !!adapterEnabled; } public async getEnabled(): Promise<boolean> { return this.state; } public getAdapters(): Array<{ index: number; address: string; active: boolean; }> { const adapters = simpleBleAdapters(); return adapters.map(({ address, active }, index) => ({ index, address, active })); } public useAdapter(index: number): void { const adapters = simpleBleAdapters(); const selected = adapters[index]; if (!selected) { throw new Error(`Adapter ${index} not found.`); } this.adapter = selected; } public async startScan(serviceUUIDs: Array<string>, foundFn: (device: Partial<BluetoothDevice>) => void): Promise<void> { if (this.state === false) { throw new Error('adapter not enabled'); } if (!this.adapter) { this.adapter = simpleBleAdapters()[0]; } const foundPeripherals: string[] = []; this.adapter.setCallbackOnScanFound(peripheral => { const device = this.buildBluetoothDevice(peripheral); if (this.validDevice(device, serviceUUIDs)) { if (!foundPeripherals.includes(device.id)) { foundPeripherals.push(device.id); this.peripherals.set(device.id, peripheral); // Only call the found function the first time we find a valid device foundFn(device); } } }); const success = this.adapter.scanStart(); if (!success) { throw new Error('scan start failed'); } } public stopScan(_errorFn?: (errorMsg: string) => void): void { if (this.adapter) { const success = this.adapter.scanStop(); if (!success) { throw new Error('scan stop failed'); } } } public async connect(handle: string, disconnectFn?: () => void): Promise<void> { const peripheral = this.peripherals.get(handle); if (!peripheral) { throw new Error('Peripheral not found'); } if (!peripheral.connectable) { throw new Error('Connection not possible'); } const success = peripheral.connect(); if (!success) { throw new Error('Connect failed'); } if (disconnectFn) { peripheral.setCallbackOnDisconnected(() => disconnectFn()); } this.handles.createHandles(peripheral); } public async disconnect(handle: string): Promise<void> { const peripheral = this.peripherals.get(handle); if (!peripheral) { throw new Error('Peripheral not found'); } const success = peripheral.disconnect(); if (!success) { throw new Error('Disconnect failed'); } this.handles.deleteHandles(peripheral); } public async discoverServices(handle: string, serviceUUIDs?: Array<string>): Promise<Array<Partial<BluetoothRemoteGATTServiceImpl>>> { const services = this.handles.getServices(handle); const discovered: Partial<BluetoothRemoteGATTServiceImpl>[] = []; for (const [handle, service] of Object.entries(services)) { if (!serviceUUIDs || serviceUUIDs.length === 0 || serviceUUIDs.indexOf(service.uuid) >= 0) { discovered.push({ _handle: handle, uuid: service.uuid, isPrimary: true }); } } return discovered; } public async discoverIncludedServices(_handle: string, _serviceUUIDs?: Array<string>): Promise<Array<Partial<BluetoothRemoteGATTServiceImpl>>> { // Currently not implemented return []; } public async discoverCharacteristics(handle: string, characteristicUUIDs?: Array<string>): Promise<Array<Partial<BluetoothRemoteGATTCharacteristicImpl>>> { const { peripheral, service, characteristics } = this.handles.getCharacteristics(handle); const discovered: Partial<BluetoothRemoteGATTCharacteristicImpl>[] = []; for (const [handle, characteristic] of Object.entries(characteristics)) { const charUUID = BluetoothUUID.canonicalUUID(characteristic.uuid); if (!characteristicUUIDs || characteristicUUIDs.length === 0 || characteristicUUIDs.indexOf(charUUID) >= 0) { discovered.push({ _handle: handle, uuid: charUUID, properties: { // Not all of these are supported in SimpleBle broadcast: false, // characteristic.capabilities.includes('???'), read: characteristic.canRead, writeWithoutResponse: characteristic.canWriteRequest, write: characteristic.canWriteCommand, notify: characteristic.canNotify, indicate: characteristic.canIndicate, authenticatedSignedWrites: false, // characteristic.capabilities.includes('???'), reliableWrite: false, // characteristic.capabilities.includes('???'), writableAuxiliaries: false // characteristic.capabilities.includes('???'), } }); if (characteristic.canIndicate) { peripheral.indicate(service.uuid, charUUID, data => { if (this.handles.characteristicEvents.has(handle)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.handles.characteristicEvents.get(handle)!(new DataView(data.buffer)); } }); } if (characteristic.canNotify) { peripheral.notify(service.uuid, charUUID, data => { if (this.handles.characteristicEvents.has(handle)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.handles.characteristicEvents.get(handle)!(new DataView(data.buffer)); } }); } } } return discovered; } public async discoverDescriptors(handle: string, descriptorUUIDs?: Array<string>): Promise<Array<Partial<BluetoothRemoteGATTDescriptorImpl>>> { const descriptors = this.handles.getDescriptors(handle); const discovered = []; for (const [handle, descriptor] of Object.entries(descriptors)) { const descUUID = BluetoothUUID.canonicalUUID(descriptor); if (!descriptorUUIDs || descriptorUUIDs.length === 0 || descriptorUUIDs.indexOf(descUUID) >= 0) { discovered.push({ _handle: handle, uuid: descUUID }); } } return discovered; } public async readCharacteristic(handle: string): Promise<DataView> { const { peripheral, service, characteristic } = this.handles.getCharacteristicGraph(handle); const data = peripheral.read(service.uuid, characteristic.uuid); return new DataView(data.buffer); } public async writeCharacteristic(handle: string, value: DataView, withoutResponse = false): Promise<void> { const { peripheral, service, characteristic } = this.handles.getCharacteristicGraph(handle); let success = false; if (withoutResponse) { success = peripheral.writeCommand(service.uuid, characteristic.uuid, new Uint8Array(value.buffer)); } else { success = peripheral.writeRequest(service.uuid, characteristic.uuid, new Uint8Array(value.buffer)); } if (!success) { throw new Error('Write failed'); } } public async enableNotify(handle: string, notifyFn: (value: DataView) => void): Promise<void> { this.handles.characteristicEvents.set(handle, notifyFn); } public async disableNotify(handle: string): Promise<void> { this.handles.characteristicEvents.delete(handle); } public async readDescriptor(handle: string): Promise<DataView> { const { peripheral, service, characteristic, descriptor } = this.handles.getDescriptorGraph(handle); const data = peripheral.readDescriptor(service.uuid, characteristic.uuid, descriptor); if (!data) { throw new Error('Read failed'); } return new DataView(data.buffer); } public async writeDescriptor(handle: string, value: DataView): Promise<void> { const { peripheral, service, characteristic, descriptor } = this.handles.getDescriptorGraph(handle); const success = peripheral.writeDescriptor(service.uuid, characteristic.uuid, descriptor, new Uint8Array(value.buffer)); if (!success) { throw new Error('Write failed'); } } }