bleat
Version:
Abstraction library following Web Bluetooth specification for hiding differences in JavaScript BLE APIs
559 lines (503 loc) • 24.3 kB
JavaScript
/* @license
*
* BLE Abstraction Tool: core functionality - web bluetooth specification
*
* The MIT License (MIT)
*
* Copyright (c) 2017 Rob Moran
*
* 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.
*/
// https://github.com/umdjs/umd
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['es6-promise', 'es6-map', 'bluetooth.helpers'], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS
module.exports = factory(Promise, Map, require('./bluetooth.helpers'));
} else {
// Browser globals with support for web workers (root is window)
// Assume Promise exists or has been poly-filled
root.bleat = factory(root.Promise, root.Map, root.bleatHelpers);
}
}(this, function(Promise, Map, helpers) {
"use strict";
var defaultScanTime = 10.24 * 1000;
var adapter = null;
var adapters = {};
function wrapReject(reject, msg) {
return function(error) {
reject(msg + ": " + error);
};
}
function mergeDictionary(base, extension) {
if (extension) {
Object.keys(extension).forEach(function(key) {
if (extension[key] && base.hasOwnProperty(key)) {
if (Object.prototype.toString.call(base[key]) === "[object Object]") mergeDictionary(base[key], extension[key]);
else if (Object.prototype.toString.call(base[key]) === "[object Map]" && Object.prototype.toString.call(extension[key]) === "[object Object]") {
Object.keys(extension[key]).forEach(function(mapKey) {
base[key].set(mapKey, extension[key][mapKey]);
});
}
else base[key] = extension[key];
}
});
}
}
function createListenerFn(eventTypes) {
return function(type, callback, capture) {
if (eventTypes.indexOf(type) < 0) return; //error
if (!this.__events) this.__events = {};
if (!this.__events[type]) this.__events[type] = [];
this.__events[type].push(callback);
};
}
function removeEventListener(type, callback, capture) {
if (!this.__events || !this.__events[type]) return; //error
var i = this.__events[type].indexOf(callback);
if (i >= 0) this.__events[type].splice(i, 1);
if (this.__events[type].length === 0) delete this.__events[type];
if (Object.keys(this.__events).length === 0) delete this.__events;
}
function dispatchEvent(event) {
if (!this.__events || !this.__events[event.type]) return; //error
event.target = this;
this.__events[event.type].forEach(function(callback) {
if (typeof callback === "function") callback(event);
});
}
function filterDevice(options, deviceInfo, validServices) {
var valid = false;
options.filters.forEach(function(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) {
var serviceUUIDs = filter.services.map(helpers.getServiceUUID);
var servicesValid = serviceUUIDs.every(function(serviceUUID) {
return (deviceInfo.uuids.indexOf(serviceUUID) > -1);
});
if (!servicesValid) return;
validServices = validServices.concat(serviceUUIDs);
}
valid = true;
});
if (!valid) return false;
return deviceInfo;
}
var scanner = null;
function requestDevice(options) {
return new Promise(function(resolve, reject) {
if (scanner !== null) return reject("requestDevice error: request in progress");
if (!options.acceptAllDevices && !options.deviceFound) {
// Must have a filter
if (!options.filters || options.filters.length === 0) {
return reject(new TypeError("requestDevice error: no filters specified"));
}
// Don't allow empty filters
var emptyFilter = options.filters.some(function(filter) {
return (Object.keys(filter).length === 0);
});
if (emptyFilter) {
return reject(new TypeError("requestDevice error: empty filter specified"));
}
// Don't allow empty namePrefix
var emptyPrefix = options.filters.some(function(filter) {
return (typeof filter.namePrefix !== "undefined" && filter.namePrefix === "");
});
if (emptyPrefix) {
return reject(new TypeError("requestDevice error: empty namePrefix specified"));
}
}
var searchUUIDs = [];
if (options.filters) {
options.filters.forEach(function(filter) {
if (filter.services) searchUUIDs = searchUUIDs.concat(filter.services.map(helpers.getServiceUUID));
});
}
// Unique-ify
searchUUIDs = searchUUIDs.filter(function(item, index, array) {
return array.indexOf(item) === index;
});
var found = false;
adapter.startScan(searchUUIDs, function(deviceInfo) {
var validServices = [];
function complete(bluetoothDevice) {
cancelRequest()
.then(function() {
resolve(bluetoothDevice);
});
}
function selectFn() {
complete(bluetoothDevice);
}
// filter devices if filters specified
if (options.filters) {
deviceInfo = filterDevice(options, deviceInfo, validServices);
}
if (deviceInfo) {
found = true;
// Add additional services
if (options.optionalServices) {
validServices = validServices.concat(options.optionalServices.map(helpers.getServiceUUID));
}
// Set unique list of allowed services
deviceInfo._allowedServices = validServices.filter(function(item, index, array) {
return array.indexOf(item) === index;
});
var bluetoothDevice = new BluetoothDevice(deviceInfo);
if (!options.deviceFound || options.deviceFound(bluetoothDevice, selectFn)) {
// If no deviceFound function, or deviceFound returns true, resolve with this device immediately
complete(bluetoothDevice);
}
}
}, function() {
scanner = setTimeout(function() {
cancelRequest()
.then(function() {
if (!found) reject("requestDevice error: no devices found");
});
}, options.scanTime || defaultScanTime);
}, wrapReject(reject, "requestDevice error"));
});
}
function cancelRequest() {
return new Promise(function(resolve, reject) {
if (scanner) {
clearTimeout(scanner);
scanner = null;
adapter.stopScan();
}
resolve();
});
}
// BluetoothDevice Object
var BluetoothDevice = function(properties) {
this._handle = null;
this._allowedServices = [];
this.id = "unknown";
this.name = null;
this.adData = {
appearance: null,
txPower: null,
rssi: null,
manufacturerData: new Map(),
serviceData: new Map()
};
this.gatt = new BluetoothRemoteGATTServer();
this.gatt.device = this;
this.uuids = [];
mergeDictionary(this, properties);
};
BluetoothDevice.prototype.addEventListener = createListenerFn([
"gattserverdisconnected",
]);
BluetoothDevice.prototype.removeEventListener = removeEventListener;
BluetoothDevice.prototype.dispatchEvent = dispatchEvent;
// BluetoothRemoteGATTServer Object
var BluetoothRemoteGATTServer = function() {
this._services = null;
this.device = null;
this.connected = false;
};
BluetoothRemoteGATTServer.prototype.connect = function() {
return new Promise(function(resolve, reject) {
if (this.connected) return reject("connect error: device already connected");
adapter.connect(this.device._handle, function() {
this.connected = true;
resolve(this);
}.bind(this), function() {
this._services = null;
this.connected = false;
this.device.dispatchEvent({ type: "gattserverdisconnected", bubbles: true });
}.bind(this), wrapReject(reject, "connect error"));
}.bind(this));
};
BluetoothRemoteGATTServer.prototype.disconnect = function() {
adapter.disconnect(this.device._handle);
this.connected = false;
};
BluetoothRemoteGATTServer.prototype.getPrimaryService = function(serviceUUID) {
return new Promise(function(resolve, reject) {
if (!this.connected) return reject("getPrimaryService error: device not connected");
if (!serviceUUID) return reject("getPrimaryService error: no service specified");
this.getPrimaryServices(serviceUUID)
.then(function(services) {
if (services.length !== 1) return reject("getPrimaryService error: service not found");
resolve(services[0]);
})
.catch(function(error) {
reject(error);
});
}.bind(this));
};
BluetoothRemoteGATTServer.prototype.getPrimaryServices = function(serviceUUID) {
return new Promise(function(resolve, reject) {
if (!this.connected) return reject("getPrimaryServices error: device not connected");
function complete() {
if (!serviceUUID) return resolve(this._services);
var filtered = this._services.filter(function(service) {
return (service.uuid === helpers.getServiceUUID(serviceUUID));
});
if (filtered.length !== 1) return reject("getPrimaryServices error: service not found");
resolve(filtered);
}
if (this._services) return complete.call(this);
adapter.discoverServices(this.device._handle, this.device._allowedServices, function(services) {
this._services = services.map(function(serviceInfo) {
serviceInfo.device = this.device;
return new BluetoothRemoteGATTService(serviceInfo);
}.bind(this));
complete.call(this);
}.bind(this), wrapReject(reject, "getPrimaryServices error"));
}.bind(this));
};
// BluetoothRemoteGATTService Object
var BluetoothRemoteGATTService = function(properties) {
this._handle = null;
this._services = null;
this._characteristics = null;
this.device = null;
this.uuid = null;
this.isPrimary = false;
mergeDictionary(this, properties);
this.dispatchEvent({ type: "serviceadded", bubbles: true });
};
BluetoothRemoteGATTService.prototype.getCharacteristic = function(characteristicUUID) {
return new Promise(function(resolve, reject) {
if (!this.device.gatt.connected) return reject("getCharacteristic error: device not connected");
if (!characteristicUUID) return reject("getCharacteristic error: no characteristic specified");
this.getCharacteristics(characteristicUUID)
.then(function(characteristics) {
if (characteristics.length !== 1) return reject("getCharacteristic error: characteristic not found");
resolve(characteristics[0]);
})
.catch(function(error) {
reject(error);
});
}.bind(this));
};
BluetoothRemoteGATTService.prototype.getCharacteristics = function(characteristicUUID) {
return new Promise(function(resolve, reject) {
if (!this.device.gatt.connected) return reject("getCharacteristics error: device not connected");
function complete() {
if (!characteristicUUID) return resolve(this._characteristics);
var filtered = this._characteristics.filter(function(characteristic) {
return (characteristic.uuid === helpers.getCharacteristicUUID(characteristicUUID));
});
if (filtered.length !== 1) return reject("getCharacteristics error: characteristic not found");
resolve(filtered);
}
if (this._characteristics) return complete.call(this);
adapter.discoverCharacteristics(this._handle, [], function(characteristics) {
this._characteristics = characteristics.map(function(characteristicInfo) {
characteristicInfo.service = this;
return new BluetoothRemoteGATTCharacteristic(characteristicInfo);
}.bind(this));
complete.call(this);
}.bind(this), wrapReject(reject, "getCharacteristics error"));
}.bind(this));
};
BluetoothRemoteGATTService.prototype.getIncludedService = function(serviceUUID) {
return new Promise(function(resolve, reject) {
if (!this.device.gatt.connected) return reject("getIncludedService error: device not connected");
if (!serviceUUID) return reject("getIncludedService error: no service specified");
this.getIncludedServices(serviceUUID)
.then(function(services) {
if (services.length !== 1) return reject("getIncludedService error: service not found");
resolve(services[0]);
})
.catch(function(error) {
reject(error);
});
}.bind(this));
};
BluetoothRemoteGATTService.prototype.getIncludedServices = function(serviceUUID) {
return new Promise(function(resolve, reject) {
if (!this.device.gatt.connected) return reject("getIncludedServices error: device not connected");
function complete() {
if (!serviceUUID) return resolve(this._services);
var filtered = this._services.filter(function(service) {
return (service.uuid === helpers.getServiceUUID(serviceUUID));
});
if (filtered.length !== 1) return reject("getIncludedServices error: service not found");
resolve(filtered);
}
if (this._services) return complete.call(this);
adapter.discoverIncludedServices(this._handle, this.device._allowedServices, function(services) {
this._services = services.map(function(serviceInfo) {
serviceInfo.device = this.device;
return new BluetoothRemoteGATTService(serviceInfo);
}.bind(this));
complete.call(this);
}.bind(this), wrapReject(reject, "getIncludedServices error"));
}.bind(this));
};
BluetoothRemoteGATTService.prototype.addEventListener = createListenerFn([
"serviceadded",
"servicechanged",
"serviceremoved"
]);
BluetoothRemoteGATTService.prototype.removeEventListener = removeEventListener;
BluetoothRemoteGATTService.prototype.dispatchEvent = dispatchEvent;
// BluetoothRemoteGATTCharacteristic Object
var BluetoothRemoteGATTCharacteristic = function(properties) {
this._handle = null;
this._descriptors = null;
this.service = null;
this.uuid = null;
this.properties = {
broadcast: false,
read: false,
writeWithoutResponse: false,
write: false,
notify: false,
indicate: false,
authenticatedSignedWrites: false,
reliableWrite: false,
writableAuxiliaries: false
};
this.value = null;
mergeDictionary(this, properties);
};
BluetoothRemoteGATTCharacteristic.prototype.getDescriptor = function(descriptorUUID) {
return new Promise(function(resolve, reject) {
if (!this.service.device.gatt.connected) return reject("getDescriptor error: device not connected");
if (!descriptorUUID) return reject("getDescriptor error: no descriptor specified");
this.getDescriptors(descriptorUUID)
.then(function(descriptors) {
if (descriptors.length !== 1) return reject("getDescriptor error: descriptor not found");
resolve(descriptors[0]);
})
.catch(function(error) {
reject(error);
});
}.bind(this));
};
BluetoothRemoteGATTCharacteristic.prototype.getDescriptors = function(descriptorUUID) {
return new Promise(function(resolve, reject) {
if (!this.service.device.gatt.connected) return reject("getDescriptors error: device not connected");
function complete() {
if (!descriptorUUID) return resolve(this._descriptors);
var filtered = this._descriptors.filter(function(descriptor) {
return (descriptor.uuid === helpers.getDescriptorUUID(descriptorUUID));
});
if (filtered.length !== 1) return reject("getDescriptors error: descriptor not found");
resolve(filtered);
}
if (this._descriptors) return complete.call(this);
adapter.discoverDescriptors(this._handle, [], function(descriptors) {
this._descriptors = descriptors.map(function(descriptorInfo) {
descriptorInfo.characteristic = this;
return new BluetoothRemoteGATTDescriptor(descriptorInfo);
}.bind(this));
complete.call(this);
}.bind(this), wrapReject(reject, "getDescriptors error"));
}.bind(this));
};
BluetoothRemoteGATTCharacteristic.prototype.readValue = function() {
return new Promise(function(resolve, reject) {
if (!this.service.device.gatt.connected) return reject("readValue error: device not connected");
adapter.readCharacteristic(this._handle, function(dataView) {
this.value = dataView;
resolve(dataView);
this.dispatchEvent({ type: "characteristicvaluechanged", bubbles: true });
}.bind(this), wrapReject(reject, "readValue error"));
}.bind(this));
};
BluetoothRemoteGATTCharacteristic.prototype.writeValue = function(bufferSource) {
return new Promise(function(resolve, reject) {
if (!this.service.device.gatt.connected) return reject("writeValue error: device not connected");
var arrayBuffer = bufferSource.buffer || bufferSource;
var dataView = new DataView(arrayBuffer);
adapter.writeCharacteristic(this._handle, dataView, function() {
this.value = dataView;
resolve();
}.bind(this), wrapReject(reject, "writeValue error"));
}.bind(this));
};
BluetoothRemoteGATTCharacteristic.prototype.startNotifications = function() {
return new Promise(function(resolve, reject) {
if (!this.service.device.gatt.connected) return reject("startNotifications error: device not connected");
adapter.enableNotify(this._handle, function(dataView) {
this.value = dataView;
this.dispatchEvent({ type: "characteristicvaluechanged", bubbles: true });
}.bind(this), function() {
resolve(this);
}.bind(this), wrapReject(reject, "startNotifications error"));
}.bind(this));
};
BluetoothRemoteGATTCharacteristic.prototype.stopNotifications = function() {
return new Promise(function(resolve, reject) {
if (!this.service.device.gatt.connected) return reject("stopNotifications error: device not connected");
adapter.disableNotify(this._handle, function() {
resolve(this);
}.bind(this), wrapReject(reject, "stopNotifications error"));
}.bind(this));
};
BluetoothRemoteGATTCharacteristic.prototype.addEventListener = createListenerFn([
"characteristicvaluechanged"
]);
BluetoothRemoteGATTCharacteristic.prototype.removeEventListener = removeEventListener;
BluetoothRemoteGATTCharacteristic.prototype.dispatchEvent = dispatchEvent;
// BluetoothRemoteGATTDescriptor Object
var BluetoothRemoteGATTDescriptor = function(properties) {
this._handle = null;
this.characteristic = null;
this.uuid = null;
this.value = null;
mergeDictionary(this, properties);
};
BluetoothRemoteGATTDescriptor.prototype.readValue = function() {
return new Promise(function(resolve, reject) {
if (!this.characteristic.service.device.gatt.connected) return reject("readValue error: device not connected");
adapter.readDescriptor(this._handle, function(dataView) {
this.value = dataView;
resolve(dataView);
}.bind(this), wrapReject(reject, "readValue error"));
}.bind(this));
};
BluetoothRemoteGATTDescriptor.prototype.writeValue = function(bufferSource) {
return new Promise(function(resolve, reject) {
if (!this.characteristic.service.device.gatt.connected) return reject("writeValue error: device not connected");
var arrayBuffer = bufferSource.buffer || bufferSource;
var dataView = new DataView(arrayBuffer);
adapter.writeDescriptor(this._handle, dataView, function() {
this.value = dataView;
resolve();
}.bind(this), wrapReject(reject, "writeValue error"));
}.bind(this));
};
// Bluetooth Object
return {
_addAdapter: function(adapterName, definition) {
adapters[adapterName] = definition;
adapter = definition;
},
requestDevice: requestDevice,
cancelRequest: cancelRequest
};
}));