webbluetooth
Version:
Node.js implementation of the Web Bluetooth Specification
422 lines (362 loc) • 15.2 kB
text/typescript
/*
* Node Web Bluetooth
* Copyright (c) 2017 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 { adapter, EVENT_ENABLED } from './adapters';
import { BluetoothDeviceImpl, BluetoothDeviceEvents } from './device';
import { BluetoothUUID } from './uuid';
import { EventDispatcher, DOMEvent } from './events';
/**
* Bluetooth Options interface
*/
export interface BluetoothOptions {
/**
* A `device found` callback function to allow the user to select a device
*/
deviceFound?: (device: BluetoothDevice, selectFn: () => void) => boolean;
/**
* The amount of seconds to scan for the device (default is 10)
*/
scanTime?: number;
/**
* Optional flag to automatically allow all devices
*/
allowAllDevices?: boolean;
/**
* An optional referring device
*/
referringDevice?: BluetoothDevice;
/**
* An optional index of bluetooth adapter to use
*/
adapterIndex?: number;
}
/**
* @hidden
*/
export interface BluetoothEvents extends BluetoothDeviceEvents {
/**
* Bluetooth Availability Changed event
*/
availabilitychanged: Event;
}
/**
* Bluetooth class
*/
export class BluetoothImpl extends EventDispatcher<BluetoothEvents> implements Bluetooth {
/**
* Referring device for the bluetooth instance
*/
public readonly referringDevice?: BluetoothDevice;
private deviceFound: (device: BluetoothDevice, selectFn: () => void) => boolean = undefined;
private scanTime: number = 10.24 * 1000;
private scanner = undefined;
private allowedDevices = new Set<string>();
/**
* Bluetooth constructor
* @param options Bluetooth initialisation options
*/
constructor(private options: BluetoothOptions = {}) {
super();
this.referringDevice = options.referringDevice;
this.deviceFound = options.deviceFound;
if (options.scanTime) {
this.scanTime = options.scanTime * 1000;
}
if (typeof options.adapterIndex === 'number') {
adapter.useAdapter(options.adapterIndex);
}
adapter.on(EVENT_ENABLED, _value => {
this.dispatchEvent(new DOMEvent(this, 'availabilitychanged'));
});
}
private _oncharacteristicvaluechanged: (ev: Event) => void;
public set oncharacteristicvaluechanged(fn: (ev: Event) => void) {
if (this._oncharacteristicvaluechanged) {
this.removeEventListener('characteristicvaluechanged', this._oncharacteristicvaluechanged);
this._oncharacteristicvaluechanged = undefined;
}
if (fn) {
this._oncharacteristicvaluechanged = fn;
this.addEventListener('characteristicvaluechanged', this._oncharacteristicvaluechanged);
}
}
private _onserviceadded: (ev: Event) => void;
public set onserviceadded(fn: (ev: Event) => void) {
if (this._onserviceadded) {
this.removeEventListener('serviceadded', this._onserviceadded);
this._onserviceadded = undefined;
}
if (fn) {
this._onserviceadded = fn;
this.addEventListener('serviceadded', this._onserviceadded);
}
}
private _onservicechanged: (ev: Event) => void;
public set onservicechanged(fn: (ev: Event) => void) {
if (this._onservicechanged) {
this.removeEventListener('servicechanged', this._onservicechanged);
this._onservicechanged = undefined;
}
if (fn) {
this._onservicechanged = fn;
this.addEventListener('servicechanged', this._onservicechanged);
}
}
private _onserviceremoved: (ev: Event) => void;
public set onserviceremoved(fn: (ev: Event) => void) {
if (this._onserviceremoved) {
this.removeEventListener('serviceremoved', this._onserviceremoved);
this._onserviceremoved = undefined;
}
if (fn) {
this._onserviceremoved = fn;
this.addEventListener('serviceremoved', this._onserviceremoved);
}
}
private _ongattserverdisconnected: (ev: Event) => void;
public set ongattserverdisconnected(fn: (ev: Event) => void) {
if (this._ongattserverdisconnected) {
this.removeEventListener('gattserverdisconnected', this._ongattserverdisconnected);
this._ongattserverdisconnected = undefined;
}
if (fn) {
this._ongattserverdisconnected = fn;
this.addEventListener('gattserverdisconnected', this._ongattserverdisconnected);
}
}
private _onadvertisementreceived: (ev: Event) => void;
public set onadvertisementreceived(fn: (ev: Event) => void) {
if (this._onadvertisementreceived) {
this.removeEventListener('advertisementreceived', this._onadvertisementreceived);
this._onadvertisementreceived = undefined;
}
if (fn) {
this._onadvertisementreceived = fn;
this.addEventListener('advertisementreceived', this._onadvertisementreceived);
}
}
private _onavailabilitychanged: (ev: Event) => void;
public set onavailabilitychanged(fn: (ev: Event) => void) {
if (this._onavailabilitychanged) {
this.removeEventListener('availabilitychanged', this._onavailabilitychanged);
this._onavailabilitychanged = undefined;
}
if (fn) {
this._onavailabilitychanged = fn;
this.addEventListener('availabilitychanged', this._onavailabilitychanged);
}
}
private filterDevice(filters: Array<BluetoothLEScanFilter>, deviceInfo: Partial<BluetoothDeviceImpl>, validServices): Partial<BluetoothDevice> | undefined {
let valid = false;
filters.forEach(filter => {
// Name
if (filter.name && filter.name !== deviceInfo.name) return;
// NamePrefix
if (filter.namePrefix) {
if (!deviceInfo.name || filter.namePrefix.length > deviceInfo.name.length) return;
if (filter.namePrefix !== deviceInfo.name.substr(0, filter.namePrefix.length)) return;
}
// Services
if (filter.services) {
const serviceUUIDs = filter.services.map(BluetoothUUID.getService);
const servicesValid = serviceUUIDs.every(serviceUUID => {
return (deviceInfo._serviceUUIDs.indexOf(serviceUUID) > -1);
});
if (!servicesValid) return;
validServices = validServices.concat(serviceUUIDs);
}
// Service Data
if (filter.serviceData) {
if (!deviceInfo._adData.serviceData) return;
const services = [...deviceInfo._adData.serviceData.keys()];
for (const entry of filter.serviceData) {
if (!services.includes(entry.service)) return;
}
}
// Manufacturer Data
if (filter.manufacturerData) {
if (!deviceInfo._adData.manufacturerData) return;
const manufacturers = [...deviceInfo._adData.manufacturerData.keys()];
for (const entry of filter.manufacturerData) {
if (!manufacturers.includes(entry.companyIdentifier)) return;
}
}
valid = true;
});
if (!valid) return undefined;
return deviceInfo;
}
private forgetDevice(uuid: string): void {
this.allowedDevices.delete(uuid);
}
/**
* Gets the availability of a bluetooth adapter
* @returns Promise containing a flag indicating bluetooth availability
*/
public getAvailability(): Promise<boolean> {
return adapter.getEnabled();
}
/**
* Scans for a device matching optional filters
* @param options The options to use when scanning
* @returns Promise containing a device which matches the options
*/
public requestDevice(options: RequestDeviceOptions = { filters: [] }): Promise<BluetoothDevice> {
if (this.scanner !== undefined) {
throw new Error('requestDevice error: request in progress');
}
interface Filtered {
filters: Array<BluetoothLEScanFilter>;
optionalServices?: Array<BluetoothServiceUUID>;
}
interface AcceptAll {
acceptAllDevices: boolean;
optionalServices?: Array<BluetoothServiceUUID>;
}
const isFiltered = (maybeFiltered: RequestDeviceOptions): maybeFiltered is Filtered =>
(maybeFiltered as Filtered).filters !== undefined;
const isAcceptAll = (maybeAcceptAll: RequestDeviceOptions): maybeAcceptAll is AcceptAll =>
(maybeAcceptAll as AcceptAll).acceptAllDevices === true;
let searchUUIDs = [];
if (isFiltered(options)) {
// Must have a filter
if (options.filters.length === 0) {
throw new TypeError('requestDevice error: no filters specified');
}
// Don't allow empty filters
const emptyFilter = options.filters.some(filter => {
return (Object.keys(filter).length === 0);
});
if (emptyFilter) {
throw new TypeError('requestDevice error: empty filter specified');
}
// Don't allow empty namePrefix
const emptyPrefix = options.filters.some(filter => {
return (typeof filter.namePrefix !== 'undefined' && filter.namePrefix === '');
});
if (emptyPrefix) {
throw new TypeError('requestDevice error: empty namePrefix specified');
}
options.filters.forEach(filter => {
if (filter.services) searchUUIDs = searchUUIDs.concat(filter.services.map(BluetoothUUID.getService));
// Unique-ify
searchUUIDs = searchUUIDs.filter((item, index, array) => {
return array.indexOf(item) === index;
});
});
} else if (!isAcceptAll(options)) {
throw new TypeError('requestDevice error: specify filters or acceptAllDevices');
}
return new Promise((resolve, reject) => {
let found = false;
this.scanner = setTimeout(() => {
this.cancelRequest();
if (!found) {
reject('requestDevice error: no devices found');
}
}, this.scanTime);
adapter.startScan(searchUUIDs, deviceInfo => {
let validServices = [];
const complete = (bluetoothDevice: BluetoothDevice) => {
this.allowedDevices.add(bluetoothDevice.id);
this.cancelRequest();
resolve(bluetoothDevice);
};
// filter devices if filters specified
if (isFiltered(options)) {
deviceInfo = this.filterDevice(options.filters, deviceInfo, validServices);
}
if (deviceInfo) {
found = true;
// Add additional services
if (options.optionalServices) {
validServices = validServices.concat(options.optionalServices.map(BluetoothUUID.getService));
}
// Set unique list of allowed services
const allowedServices = validServices.filter((item, index, array) => {
return array.indexOf(item) === index;
});
Object.assign(deviceInfo, {
_bluetooth: this,
_allowedServices: allowedServices
});
const bluetoothDevice = new BluetoothDeviceImpl(deviceInfo, () => this.forgetDevice(deviceInfo.id));
const selectFn = () => {
complete.call(this, bluetoothDevice);
};
if (!this.deviceFound || this.deviceFound(bluetoothDevice, selectFn.bind(this)) === true) {
// If no deviceFound function, or deviceFound returns true, resolve with this device immediately
complete.call(this, bluetoothDevice);
}
}
});
});
}
/**
* Get all bluetooth devices
*/
public getDevices(): Promise<BluetoothDevice[]> {
if (this.scanner !== undefined) {
throw new Error('getDevices error: request in progress');
}
return new Promise(resolve => {
const devices: BluetoothDevice[] = [];
this.scanner = setTimeout(() => {
this.cancelRequest();
resolve(devices);
}, this.scanTime);
adapter.startScan([], deviceInfo => {
if (this.options?.allowAllDevices || this.allowedDevices.has(deviceInfo.id)) {
Object.assign(deviceInfo, {
_bluetooth: this,
_allowedServices: []
});
const bluetoothDevice = new BluetoothDeviceImpl(deviceInfo, () => this.forgetDevice(deviceInfo.id));
devices.push(bluetoothDevice);
}
});
});
}
/**
* Cancels the scan for devices
*/
public cancelRequest(): void {
if (this.scanner) {
clearTimeout(this.scanner);
this.scanner = undefined;
adapter.stopScan();
}
}
/**
* @hidden
* Request LE scan (not implemented)
*/
public requestLEScan(_options?: BluetoothLEScanOptions): Promise<BluetoothLEScan> {
throw new Error('requestLEScan error: method not implemented.');
}
}
/**
* List available bluetooth adapters
*/
export const getAdapters = adapter.getAdapters;