Bluetooth LE Sensors and React as Part of my Cycling Training
The story of the time when a tinkerer meets Bluetooth Low Energy sensors, gets access to cycling performance data and decides to do something about it ...
I’m a software engineer who has been cycling for a few years. I have a Giant Roam 2 2022 hybrid bike and recently moved to a Trek Domane AL3 for biking adventures.
My (other) stack!
For a while, I have started recording my rides on Strava (a platform for cyclists) to get the most out of my training sessions. Initially, I started with an Apple Watch (Series 7, LTE model). The LTE connectivity allowed maps to work and helped to not take the phone out on every other turn to look at the directions. It worked fine until I started doing long rides (4 - 5h) and discovered that the watch’s battery life was not enough. The Apple Watch only provided GPS and heart-rate data for my rides. However, I wanted more data - such as speed, power & cadence, and heart rate. I bought a Garmin Edge 520 Plus Bundle with Speed/Cadence sensor and a chest-strap heart-rate monitor.
The Garmin Edge 520 Plus is a fabulous bike computer. It has a built-in GPS with navigation and interfaces with BLE and ANT+ sensors, so you don’t need to worry about bringing along your smartphone. It can upload rides directly to Strava, which I use for tracking my progress and sharing my rides with friends. It also interacts with dozens of interesting apps, such as Kumoot and Trailforks, which help plan and share routes.
The heart rate monitor makes it easy for me to keep an eye on how hard I work during each ride, ensuring that I’m not overdoing or getting lazy. I also use the power/cadence sensors to get more accurate information about my training performance!
Since I discovered the sensors run on Bluetooth Low Energy, I looked for documentation of their working and, to much joy, found out that entire specs are standardized and documented. I wanted to get creative to build beautiful charts and have fun with React hooks!
A primer of Bluetooth LE and Web Bluetooth
You have used Bluetooth Headphones and maybe Bluetooth Keyboard and Mice. Bluetooth LE (BLE), similar to Bluetooth, is a low-power wireless technology that allows heart rate monitors and power/cadence sensors to communicate with your phone. BLE devices require very little energy - literally! The cycling sensors run on CR2032 coin cell batteries for years.
Web Bluetooth is a new browser API that allows web pages to communicate directly with Bluetooth LE devices. That means you can use your browser to pair your heart rate monitor or bike computer! However, support for the API is currently limited to Google Chrome and other Chromium derivatives on all desktops and Android.
How BLE devices work
When your device searches for BLE devices, it discovers nearby devices and their advertised services using the Generic Application Profile protocol.
Once you initiate pairing, you can discover what “services” the devices expose. Specifications published by Bluetooth SIG define BLE services. Once you have discovered the services, you can send and receive data via a “characteristic” like an address on the street. The sensor sends data as characteristic values (e.g., heart rate). At the same time, your phone listens for changes in these values over time (e.g., heart rate readings every second). If you are interested to read more, look up the Generic Attribute Profile.
Here’s the technical TLDR; Sensors act as GATT servers, and your devices as GATT central. Servers publish “characteristics” on multiple “services” that your device central continuously listens to. If you want to dig deeper into BLE services and characteristics, the GATT spec in XML is your friend.
Using the Web Bluetooth API
The Web Bluetooth API lets you interact with BLE devices from a web page. It’s pretty bleeding edge and only works in Chrome, but there are plans to support it in other browsers soon.
Check for Web Bluetooth support with:
const bluetoothSupported = 'Bluetooth' in navigator;
Once you have determined you have Bluetooth support available, it’s time to discover some BLE devices and pair them with them. The current shape of Web Bluetooth API allows you to request a single device at once that advertises a list of services, and the browser provides the pairing UI. However, improvements to auto-connect and remembering chosen devices are coming soon. Let’s search for a heart-rate monitor and pair it with it.
async function readDeviceInfo(btDevice: BluetoothDevice): Promise<DeviceInfo> {
const deviceInfo = await btDevice.gatt!.getPrimaryService(
"device_information"
);
if (!deviceInfo) {
return {};
}
const decoder = new TextDecoder("utf-8");
const [manufacturer, model] = await Promise.all([
deviceInfo.getCharacteristic("manufacturer_name_string"),
deviceInfo.getCharacteristic("model_number_string")
]);
return {
manufacturer: manufacturer
? decoder.decode(await manufacturer.readValue())
: undefined,
model: model ? decoder.decode(await model.readValue()) : undefined
};
}
const btDevice = await navigator.bluetooth.requestDevice({
filters: [
{ services: ["heart_rate"] }
],
optionalServices: ["device_information"]
});
const server = await btDevice.gatt!.connect();
const [services, deviceInfo] = await Promise.all([
server.getPrimaryServices(),
readDeviceInfo(btDevice)
]);
The readDeviceInfo
function decodes the device_info
characteristic and returns basic info such as BLE device manufacturer, device name and model number. When requesting scanning devices, you can specify a string such as heart_rate
or its corresponding UUID 0000180d-0000-1000-8000-00805f9b34fb
. The GATT XML specifications contain the values and the corresponding UUIDs in the descriptors.
Once we have discovered the services, we can read the characteristics and display them on the screen.
const service = await server.getPrimaryService("heart_rate");
const characteristic = await service.getCharacteristic("heart_rate_measurement");
await characteristic.startNotifications();
characteristic.addEventListener("characteristicvaluechanged", (event: Event) => {
// @ts-ignore
const value = event.target!.value as DataView;
const flags = value.getUint8(0);
const result: HeartRateMonitorValue = {
heartRate: 0,
};
let index = 1;
const rate16Bits = flags & 0x1;
if (rate16Bits) {
result.heartRate = value.getUint16(index, true);
index += 2;
} else {
result.heartRate = value.getUint8(index);
index += 1;
}
const contactDetected = flags & 0x2;
const contactSensorPresent = flags & 0x4;
if (contactSensorPresent) {
result.contactDetected = !!contactDetected;
}
const energyPresent = flags & 0x8;
if (energyPresent) {
result.energyExpended = value.getUint16(index, true);
index += 2;
}
document.querySelector('#heartRate').innerHTML = result;
});
The heart_rate_meaturement
characteristic emits 2 or 3 octets for each measurement. For the uninformed, one octet is 8 bits or 1 byte in popular convention, but historically, bytes had different lengths. The first octet is a flag, the first bit of which says whether the heart rate value is 1-bit or 16-bit (human values can fit in 8-bit, but what if you want to measure the heart rate of your cat?). The preceding two bits represent whether the sensor is in contact and whether it emits calories consumed as a value. Depending on the flag values, we read the heart rate as an 8-bit or 16-bit value and energy expended as a 16-bit value if present.
Similarly, we can also read cadence (rotation per minute) values from speed and cadence sensors (CSC). However, interpreting these sensor values involves a bit more calculation. Instead of providing the raw values like the heart rate monitor, the CSC sensors provide wheel rotations and crank rotations along with the last rotation time. Using these, we have to derive speed and cadence.
const service = await server.getPrimaryService("cycling_speed_and_cadence");
const characteristic = await service.getCharacteristic("csc_measurement");
await characteristic.startNotifications();
const TYRE_CIRCUMFERENCE_METRES = 2.155; // I ride a 32c tyr
const MAX_UINT16 = 65535;
const CSC_TIME_FACTOR = 1024;
let lastWheelRevolutions = 0;
let lastWheelEventTime = 0;
let lastCrackRevolutions = 0;
let lastCrankEventTime = 0;
characteristic.addEventListener("characteristicvaluechanged", (event: Event) => {
const value = event.target!.value as DataView;
const flags = value.getUint8(0);
let index = 1;
const wheelRevolutionDataPresent = flags & 0x1;
if (wheelRevolutionDataPresent) {
const cumulativeWheelRevolutions = value.getUint32(index, true);
index += 4;
const wheelEventTime = value.getUint16(index, true);
index += 2;
const speed =
(((lastWheelRevolutions - cumulativeWheelRevolutions) *
TYRE_CIRCUMFERENCE_METRES) /
diff(
lastWheelEventTime,
wheelEventTime,
MAX_UINT16
)) *
CSC_TIME_FACTOR;
document.querySelector('#speed').innerHTML = speed;
lastWheelRevolutions = cumulativeWheelRevolutions;
lastWheelEventTime = wheelEventTime;
}
const crankRevolutionDataPresent = flags & 0x2;
if (crankRevolutionDataPresent) {
const cumulativeCrankRevolutions = value.getUint16(index, true);
index += 2;
const crankEventTime = value.getUint16(index, true);
index += 2;
const crankCadence =
(diff(
lastCrackRevolutions,
cumulativeCrankRevolutions,
MAX_UINT16
) /
diff(
lastCrankEventTime,
crankEventTime,
MAX_UINT16
)) *
CSC_TIME_FACTOR;
document.querySelector('#cadence').innerHTML = crankCadence;
lastCrackRevolutions = cumulativeCrankRevolutions;
lastCrankEventTime = crankEventTime;
}
});
Similar to heart_rate_monitor
characteristics, the cycling_speed_and_cadence
also emits a flag value that says whether or not wheel rotation data and crank rotation data are present. Many sensors (especially frame-integrated magnetic ones) have a single unit for wheel and crank revolution data. Still, some units, like Garmin, have separate wheel (speed) and crank (cadence) sensors, which connect individually.
If the wheel revolution data is present, we subtract the cumulative value as a 32-bit integer from the last value and multiply it by the tyre circumference to find the distance travelled. The difference in timestamps gives the time and thus speed (= distance/time). Note that the time is also a 16-bit value representing 1/1024 of a second and will overflow quickly. Similarly, the crank rotation value is also a 16-bit value (why?) along with a 16-bit timestamp which provides the current RPM (= rotations/time).
There is a separate diff function to calculate the difference between 16-bit values with consideration for overflow. Here it goes:
function diff(last: number, current: number, overflow: number) {
const diff = current - last;
if (diff < 0) {
return diff + overflow;
}
return diff;
}
It’s a simple function that checks if the last number is less than the current. If yes, it assumes an overflow and adds the overflow value to the difference.
Putting it all together
Putting all this as multiple React Hooks with a tiny front end gave me an excellent little bike computer that runs as a web app! It doesn’t have all the features that my Garmin has, but hey, it works, and with access to JS and raw data, we can go as creative as we want. Power meter support would be a relatively simple addition. Support for recording and exporting rides and showing multiple graphs at the end of the ride would be fantastic as well. Turn-by-turn GPS Navigation? Difficult, but still doable with OpenStreetMaps!
The online version is available at bike-computer.pages.dev, and the code is at github.com/recrsn/bike-computer.
I started this project as a playground for experimenting with HRM and CSC BLE characteristics, and it turned out well. The next step is to use iOS CoreBluetooth or Dart and build a fully functioning mobile app using the lessons learnt here. But this is a showcase of the modern web platform.