Bluetooth Low Energy in JavaScript

Reading time ~12 minutes

This article is part of a series.

For a long time now I’ve put off learning JavaScript. It really never interested me. I’d like to say it was the thought, “Oh, JavaScript is for web developers and I’m embedded all the way!” But that wasn’t really it. I think it hasn’t appealed to me because I couldn’t connect it to hardware. Well, at least, that was my assumption.

However, I’ve recently discovered Google’s Web APIs. Specifically, their Bluetooth Low Energy API.

It’s pretty amazing. It allows a developer to write asynchronous JavaScript using Promises to get into the hardware of the client’s PC, all from the browser!

Now, this might sound like it open for security issues–and perhaps it will be. But there are two requirements Google has put in place which hopefully gets around any issues. First, the API can only be called by action. Secondly, the API can only be called from a secured connection (HTTP over SSL).

Ok, there are few other downers to talk about. First this only works in Chrome–but given this is a Google API, well, duh. The other is not all OSes are currently supported. The following I’ve tested and work right out of the box:

  • Mac OS
  • Android

The others which are supposed to be supported but I’ve not tested:

  • Linux
  • Windows (with some work)
  • Chromium

Having worked with Bluetooth LE on all of these OSes I can say there is hope for Windows. In fact, I think with the Creator’s Update the Microsoft folk opened up the last needed ingredient. The real hold out will be iOS. Apple is not a fan of browser apps. They would much rather browsing be done inside their native apps. If I’m being positive, I’d say this is so Apple can make sure the mobile UX is excellent, and by forcing native apps, they have control by app approval. If I’m being negative, well, Apple takes 30% on app purchases and web apps land them nada. Just my opinion.

If you’d like to stay up to date on compatibility of BLE in the browser there is a an implementation status page on the Web Bluetooth Community Group:

Sadly, right now iOS is the loser.

Moving into the fun part. Below is how to kick things off.

To begin, it will pay to keep the Mozilla Developer Netowork’s Web Bluetooth API open for reference.

The documentation is actually pretty robust–and with this guide, the process of subscribing to a device characteristic should be pretty straight forward.

The first piece we need are service IDs to search for.

let optionalServices = document.getElementById('optionalServices').value
	.split(/, ?/).map(s => s.startsWith('0x') ? parseInt(s) : s)
	.filter(s => s && BluetoothUUID.getService);

This takes the text element of the DOM element ‘optionalServices’, which should be in the in 16 bit hex format, 0x0000. This becomes one of the service IDs searched in the Bluetooth LE search cycle. For the Bluetooth module HM-10, HM-11, HM-16, HM-17 the service ID is 0xFFE0.

Moving on to the search, when the code below is executed the Chrome browser should show a search and pair menu (see image) for pairing a device. When a device has been paired the promise will resolve returning the device which has been paired.

				
navigator.bluetooth.requestDevice({
		acceptAllDevices: true,
		optionalServices: optionalServices
	})
		

It is important to note this block must be called by a user action. For example, if set to execute on page load it will refuse to fire. But if called onClick then it will show. This is meant to provide more security to the API.

As stated, the requestDevice will return a device. Using the promise .then we can begin working with the BluetoothDevice

Which is returned after it has been paired by the user. The BluetoothDevice object has three items of interest.

  • name – which provides the string name of the device
  • id – the ID string
  • gatt – a gatt which contains a reference to the BluetoothRemoteGATTServer object

The BluetoothRemoteGATTServer interface contains many of the methods needed to interact with the Bluetooth device. For example,

device.gatt.connect()

Attempts to asynchronously create a connection with the device through a Promise. If .then is attached then the method will return a service object if succesful. If you are just looking to get something done with Bluetooth, feel free to keep hacking through this article (that’s what I’d do–TL;DR). However, if you want to know more about Bluetooth 4 protocol here a few useful links:

Back to the code.

.then(device => {
	pairedDevices[device.name] = device;
	return device.gatt.connect();
}).then
			

Once the connection attempt has been made and returned succesful, the BluetoothRemoteGATTServer object returned can be petitioned for a list of services.

....
	return device.gatt.connect();
})
.then(server => {
	return server.getPrimaryServices();
})
			

This will fire asynchronously using promises, and if succesful, return a BluetoothRemoteGATTService object. This represents all the services the device has public. Then, the returned service object may be iterated over to identify get characteristics of the device. (Almost to the data, I swear).

....
return server.getPrimaryServices();
	})
	.then(services => {
	services.forEach(service => {
			

Essentially, the BluetoothRemoteGATTService object is merely an array containing on the services. Using a services.forEach we get each individual service to explore its characteristics.

Now, I’m going to add the whole block which is responsible for iterating over each service and its characteristics, essentially turning on notifications for each device. This will ultimately allow a callback to be fired every every time the device sends data and a reference to a method by which data can be written to the device.

	....
		let queue = Promise.resolve();
		queue = queue.then(_ => service.getCharacteristics()
			.then(characteristics => {
				characteristics.forEach(characteristic => {
					writeCharacteristic = characteristic;
					writeCharacteristic.startNotifications();
					resolve();
			}); // End enumerating characteristics
		})); // End queue
	}) // End enumerating services
}) // End Service exploration  
				

The queue is a promise which allows us to loop through services and characteristics without breaking asynchronousity. Since this is my first JavaScript program, I will not try to explain it, but here’s another guy’s article which attempts to explain it:

Essentially, each service and characteristic contained in the service enumerated. At each characteristic there are two calls. One is to get a reference to the characteristic for writing. This is the global variable writeCharacteristic. Then, notifications for the writeCharacteristic are started. This will assure any time data is made available on the remote device our program is notified.

Now, it should be noted, this above code is hackish. For example, what if there are multiple characteristics and the last one isn’t the one we want to know about. Well, we’d have a write reference to the wrong characteristic. So, filtering to the desired characteritic is on my TODO list.

But, let’s finish before refactoring.

Let’s take a look at how to write data to the device after getting a reference to the desired characteristic.

var write = function (data, string = true) {
	p = new Promise(function (resolve, reject) {
		// See if the device is paired.
		if (pairedDevices) {
			// Has a write reference been discovered.
			if (writeCharacteristic != null) {
				// Don't double encode.
				if (string) {
					let encoder = new TextEncoder('utf-8');
					writeCharacteristic.writeValue(encoder.encode(data));
				} else {
					dataInUint8 = Uint8Array.from(data);
					writeCharacteristic.writeValue(dataInUint8);
				}
				resolve();

			} else {
				reject("No write characteristic")
			}
		} else {
			reject("No devices paired.")
		}
	}).catch(error => {
	});
	return p;
}

The above method creates a promise and writes to the device asynchoronously. On the way, it checks to make sure the device is paired (not connected, that’s on the TODO list). Also, it makes sure we still have a reference to the writeCharacteristic. Then, it will either encode it in utf-8 and write the data, or if the string argument is false it’ll just write the data. After it has written the data, the resolve is executed. This would allow the writeMethod to be called like so:

	
write("Buggers", true).then(_ => {
	// Do something after write has completed.
})

Ok, last bit. Let’s setup capturing incoming data. To begin, I created a method which holds a list of all the callback methods to call when data has been received.

	
var onReceivedDataCallbacks = [];
...
// Adds a function called when a BLE characteristic changes value.
// Mutiple callbacks may be added.
this.addReceivedDataCallback = function (callback) {
	if (writeCharacteristic) {
		writeCharacteristic.addEventListener('characteristicvaluechanged', callback);
		onReceivedDataCallbacks.push({
			key: callback.name,
			value: callback
		})
	}
}
	

This method allows a method’s name to be passed in. It then adds an event listener to this method, which will be called whenever characteristicvaluechanged. Also, it saves this method’s name in an array in case I want to stop notifications later (again, not completed, but on the TODO).

The purpose of allowing multiple callbacks is for when I’m working with many modules which all would like to know what’s going on with the Bluetooth LE device.

For example, this module is meant to be a piece of a larger project, which is an uploader app using BLE to upload HEX files to AVRs running TinySafeBoot.

Ok, one last piece. Let us see what the onRecievedData callback could looks like:

    
this.onReceivedData = function (event) {
	// TODO: Handle received data better.  
	// NOTE: the TX buffer for the HM-1X is only 20 bytes.  
	// But other devices differ.
	var receivedData = new Uint8Array(event.target.value.byteLength);
	for (var i = 0; i < event.target.value.byteLength; i++) {
		receivedData[i] = event.target.value.getUint8(i);
	}
}

This is how I’ve written the notification of data callback. The event.target.value contains the data, which is in an untyped array. I choice to encode it into Uint8 as I’ll be working with both ASCII and non-ASCII data.

Well, that’s it. This code will allow one to search, connect, write data to, and receive data from Bluetooth Low Energy devices from Chrome browser. Let me know if you have any recommendations.

Here is the full code referenced directly from my project:

    
	var LumiBluetooth = (function () {

	// Privates
	var pairedDevices = {};
	var onReceivedDataCallbacks = [];
	var writeCharacteristic;
	var writeBuffer = [];
	var writing = false;
	var napsSinceWrite = 0;

	// Adds a function called when a BLE characteristic changes value.
	// Mutiple callbacks may be added.
	this.addReceivedDataCallback = function (callback) {
		if (writeCharacteristic) {
			writeCharacteristic.addEventListener('characteristicvaluechanged', callback);
			onReceivedDataCallbacks.push({
				key: callback.name,
				value: callback
			})
		}
	}

	// Clears the RecievedDataCallback dictionary.
	this.removeAllReceivedDataCallbacks = function () {
		onReceivedDataCallbacks = [];
	}

	// Searches for Devices based upon Service IDs.  Then prompts
	// a user to select a target device.  Lastly, it conencts to
	// target d evice.
	this.searchAndConnect = function (primaryServicesUUID, addSystemText = "") {
		return new Promise(function (resolve, reject) {
			let optionalServices = document.getElementById('optionalServices').value
				.split(/, ?/).map(s => s.startsWith('0x') ? parseInt(s) : s)
				.filter(s => s && BluetoothUUID.getService);

			if (addSystemText) {
				addSystemText('Requesting any Bluetooth Device...');
			}
			navigator.bluetooth.requestDevice({
					acceptAllDevices: true,
					optionalServices: optionalServices

				}) // After getting a device
				.then(device => {
					pairedDevices[device.name] = device;
					if (addSystemText) {
						addSystemText('Connecting to GATT Server...');
					}
					return device.gatt.connect();
				}) // After connecting
				.then(server => {
					if (addSystemText) {
						addSystemText('Getting Services...');
					}
					return server.getPrimaryServices();
				}) // After getting services
				.then(services => {
					if (addSystemText) {
						addSystemText("Found services: ");
					}
					services.forEach(service => {
						let queue = Promise.resolve();
						queue = queue.then(_ => service.getCharacteristics().then(characteristics => {
							if (addSystemText) {
								addSystemText('Service: ' + service.uuid);
							}
							characteristics.forEach(characteristic => {
								if (addSystemText) {
									addSystemText('>> Characteristic: ' + characteristic.uuid + ' ' +
										getSupportedProperties(characteristic));
								}
								writeCharacteristic = characteristic;
								if (addSystemText) {
									addSystemText("Write characteristic set");
								}
								writeCharacteristic.startNotifications();
								resolve();
							}); // End enumerating characteristics
						})); // End queue
					}) // End enumerating services
				}). // End Service exploration                   
			catch(error => {
				if (addSystemText) {
					addSystemText(error);
				}
			})
		}); // End Search and Connect Promise
	} // End Search and Connect Function

	this.writeString = async function (data, addSystemText = null) {
		write(data, true, addSystemText);
	}

	this.writeData = async function (data, addSystemText = null) {
		write(data, false, addSystemText);
	}

	var write = function (data, string = true, addSystemText = null) {
		p = new Promise(function (resolve, reject) {
			if (pairedDevices) {
				if (writeCharacteristic != null) {
					// Don't double encode.
					if (string) {
						let encoder = new TextEncoder('utf-8');
						var writeData = encoder.encode(data);
						writeBuffer.push.apply(writeBuffer, writeData); // test
						// writeBuffer = appendUint8Buffer(writeBuffer, writeData);
						writeLoop(writeData);
					} else if (data != null){
						writeData = Array.from(data);
						// dataInUint8 = Uint8Array.from(data);
						writeBuffer.push.apply(writeBuffer, writeData);
						// writeBuffer = appendUint8Buffer(writeBuffer, dataInUint8);
						writeLoop(writeData);
					} else {
						resolve();
					}
					resolve();
				} else {
					reject("No write characteristic")
				}
			} else {
				reject("No devices paired.")
			}
		}).catch(error => {
			if (addSystemText) {
				addSystemText("No device paired");
			}
		});
		return p;
	}

	this.disconnectDevice = function () {

	}

	// Important information on write queue
	// https://github.com/WebBluetoothCG/web-bluetooth/issues/188
	// 


	var writeLoop = async function(data){
		// writing = true;
		// for(var i = 0; i < writeBuffer.length; i){
		// 	var length = 0;
		// 	// if(writeBuffer.length < (i + 20)){ length = writeBuffer.length} else { length = i + 20; }
		// 	if(writeBuffer.length < 20){ length = writeBuffer.length; } else { length = 20; }
		// 	var tmpWriteBfr = Uint8Array.from(writeBuffer.splice(0, length));
		// 	console.log(tmpWriteBfr);
		// 	writeCharacteristic.writeValue(tmp=WriteBfr);
		// 	await sleep(42);
		// 	i+=20;
		// }

		// 1. Create a rollback buffer, in case there is an error writing.
		// 2. Check if the buffer is empty.
		// 3. If writing is currently in progress, wait.  But not forever.
		//	  After three naps, assume write is complete.
		// 4. Limit the write to the HM-10 TX buffer (20 bytes)
			// TODO Make the TX buffer size mutable.  For example, the 
			// HM-16 has a TX buffer of 256
		// 5. Cut a chunk off the writeBuffer for writing.
		// 6. Attempt to write the value to the device
		// 7. Once the write is complete, set the writing flag to false.
			// NOTE The write callback doesn't seem to be working.
		// 8. If there is an error restore the buffer.

		var mementoWriteBuffer = [];
		while(writeBuffer.length > 0){
			if(writing === false){
				writing = true;
				var length = 0;
				if(writeBuffer.length < 20){ length = writeBuffer.length; } else { length = 20; }
				mementoWriteBuffer = writeBuffer;
				var tmpWriteBfr = Uint8Array.from(writeBuffer.splice(0, length));
				console.log("TX: " + tmpWriteBfr);
				writeCharacteristic.writeValue(tmpWriteBfr).
				then(blah => {
					writing = false;
				}).catch(error => {
					writeBuffer = mementoWriteBuffer;
					writing = false;
					console.log("BLE Write Error: ");
					console.log(error);
					delayAndWriteAgain();
				});
			} else {
				await bleWriteThrottling(42);
			}
			
		}
		writeBuffer = [];
	}

	var delayAndWriteAgain = function(){
		setTimeout(writeLoop(), 100);
	}

	var bleWriteThrottling = async function(ms){
		// 1. Sleep a bit
		// 2. Count naps
		// 3. Too many naps, then assume BLE writing is done.
		await sleep(ms);
		napsSinceWrite++;
		if(napsSinceWrite > 2){
			writing = false;
			napsSinceWrite = 0;
		}

	}

	/* Utils */
	function getSupportedProperties(characteristic) {
		let supportedProperties = [];
		for (const p in characteristic.properties) {
			if (characteristic.properties[p] === true) {
				supportedProperties.push(p.toUpperCase());
			}
		}
		return '[' + supportedProperties.join(', ') + ']';
	}

	var appendUint8Buffer = function (bufferOne, bufferTwo) {
		if(!bufferOne){return bufferTwo;}
		var tmp = new Uint8Array(bufferOne.byteLength + bufferTwo.byteLength);
		tmp.set(new Uint8Array(bufferOne), 0);
		tmp.set(new Uint8Array(bufferTwo), bufferOne.byteLength)
		return tmp.buffer;
	}

	return {
		addReceivedDataCallback: addReceivedDataCallback,
		searchAndConnect: searchAndConnect,
		writeString: writeString,
		writeData: writeData,
		disconnectDevice: disconnectDevice
	}
})(); // End Proto

	

What is a Data Warehouse

## Insights over DataData. They are the plastic of the tech world. We're are making way too much of it, you can't seem to get rid of it, ...… Continue reading