Electronics

Arduino BLE Cycling Power Service

A while back I read about a project on Hackaday that a hacked an expensive indoor smart bicycle, where the virtual riding ability was axed when Peleton bought the company, to work with the popular service Zwift. They did this by capturing the proprietary Bluetooth output, reformatting it, and sending it on as a generic Cycle Power Bluetooth service that Zwift and other virtual riding apps can use.

UNBRICKING A $2,000 EXERCISE BIKE WITH A RASPBERRY PI ZERO AND BLUETOOTH HACKS

But I didn’t think much about this until the COVID-19 pandemic was in full swing and I needed to find a way to exercise. I decided to pick up a cheap folding indoor bicycle that had a way to sit back and had some different resistance values. I didn’t want to spend a lot of money so the bike only has a very basic interface.

Amazon: Similar Exercise Bike

This was ok for a while, but then I started thinking back to the Hackaday article and wondered if I could add smarts to my dumb bike!

Cycling Sensor

I started on the premise that I could tap into the bike’s internal sensor and use that to measure the cycling cadence. This sensor was easily accessible via a simple headphone jack, so I hooked that up to my oscilloscope to see what the data looks like.

This provides the cadence data needed by an app to determine the RPM that the user is pedaling at. Unfortunately the onboard sensor acts as a simple on-off switch as the flywheel rotates, so there is no way to determine what resistance level you are on. This means that there is no way to calculate the cycler’s power output. I needed another method.

I started researching and investigating the bicycles sensor method and it turned out to use a simple magnetic sensor with a spot on the flywheel that was de-magnetized. I used my iPhone and a free sensor app called PhysicsToolbox to log the magnetic field as I spun the pedals as well as when I changed the resistance dial. What I found was that as I rotated the pedals the magnetometer was able to pick up the sudden dip as the de-magnetized portion passed by it, and that as I increased the resistance the maximum amplitude decreased in noticeable increments.

Example App Output

Since I could not use my phone as the final sensor I needed a dedicated sensor and a platform to perform the conversion algorithm as well as host the Bluetooth Low Energy Service that would communicate with the virtual cycling app. I initially was going to use the Raspberry Pi Zero Wifi because Gymnasticon (the software that the Hackaday article mentions) but I did not have a magnetometer sensor that I could easily attach. So I went rummaging through my dev-board bin and came up with this little Arduino Nano 33 BLE Sense board that I received as a freebie from the Hackaday Supercon 2019. It has all the things I needed and more!

Arduino Nano 33 BLE Sense

Arduino Nano 33 BLE Sense

The Arduino Nano 33 BLE Sense is an evolution of the traditional Arduino Nano, but featuring a lot more powerful processor, the nRF52840 from Nordic Semiconductors, a 32-bit ARM® Cortex™-M4 CPU running at 64 MHz. This will allow you to make larger programs than with the Arduino Uno (it has 1MB of program memory, 32 times bigger), and with a lot more variables (the RAM is 128 times bigger). The main processor includes other amazing features like Bluetooth® pairing via NFC and ultra low power consumption modes.

https://store.arduino.cc/usa/nano-33-ble-sense

You can get one below on Amazon if you are looking to set this up or experiment with the other cool sensors.

The first thing I needed to do was set up the Arduino programming environment. I followed this Getting Started with Arduino Nano 33 ble guide. It has all the pieces you need to run the Bluetooth portion, but we also want to play with some of the sensors. Thankfully there is a whole library dedicated to setting up all the sensors on the board called Nano33BLESensor (linked GitHub). A guide to this library can also be found at the Arduino ProjectHub called Getting Started with the Nano 33 bee Sense.

I was able to load up the Magnetometer Example with relative ease, there were a few additional libraries that were needed, but a quick search for them in the Libraries Manager solved the issues. This example helped me determine how to use the magnetometer and allowed me to export samples via serial. I then used this to log the magnetic field changes to inform my cycling cadence and power algorithm but I needed to set up the Bluetooth Cycle Power Service first.

Extra Nano 33 BLE Setup Guide

Bluetooth Low Energy Cycling Power Service

The Bluetooth Low Energy (BLE) specification is quite a beast, I had previously tried to get into BLE but the esoteric nature killed my motivation along with whatever project I was hacking on at the time. Things have gotten a bit better, at least for this service, mostly thanks to the help of a few other Cycling Power Service implementations for the Raspberry Pi. But I still needed to look at the Bluetooth specification to make some critical connections for my Arduino implementation. A blog post on the Bluetooth website gives a little insight into the Cycling Power Service but is more of a high level description.

Cycling Power Service Bluetooth Blog Post

The first thing I needed to look at was the GATT specification for the Cycling Power Service, the PDF can be downloaded off of the Bluetooth website by clicking the version number.

CPSCycling Power Service1.1Active03 May 2016N/A
https://www.bluetooth.com/specifications/gatt/

I will highlight the important pieces but it’s a good idea to read over the whole thing a bit more diligently if you are planning on implementing other services. This is because important implementation information can get buried in blocks of text. For my simple Cycle Power Service, I only need a couple of the many configuration options set.

Here is a table of all the Characteristics the Service supports, I only need to implement the ones with the “M” Requirement.

Characteristic NameRequirementMandatory PropertiesOptional PropertiesSecurity Permissions
Cycling Power FeatureMReadNone
Cycling Power MeasurementMNotifyLE: Broadcast BR/EDR: NoneNone
Sensor LocationMReadNone
Cycling Power Control OWrite, IndicateNone
PointONotifyNone

I also need to let any connecting devices know that I will also be providing the Crank Revolution data. This will allow for RPM to be calculated. This is done by setting the Flags and the Cycling Power Feature bits correctly.

Flags Bit NameWhen Set to 0When Set to 1Corresponding Cycling Power Feature support bit (see Section 3.1)
Crank Revolution Data Present (bit 5), see 3.2.1.6Corresponding field pair not presentCorresponding field pair presentCrank Revolution Data Supported (bit 3)

Now that we have our configuration there are two data fields that the algorithm that we build will have to provide to the BLE service. Here are the data fields along with quotes from the document on implementation.

Instantaneous Power Field

The Instantaneous Power field shall be included in the Cycling Power Measurement characteristic. The Instantaneous Power field represents the value of the power measured by the Server. It represents either the total power the user is producing or a part of the total power depending on the type of sensor 

Cycling Power Service v1.1

Crank Revolutions Data Field Pair

The Crank Revolution Data field pair (Cumulative Crank Revolutions and Last Crank Event Time fields) may be included in the Cycling Power Measurement characteristic if the device supports the Crank Revolution Data feature (see Table 3.2). When present, these fields shall always be present as a pair. The Cumulative Crank Revolutions value, which represents the number of times a crank rotates, is used in combination with the Last Crank Event Time to enable the Client to Determine if the cyclist is coasting, Calculate the instantaneous and average cadence, and Calculate the power if combined with the Wheel Revolution Data. Average cadence is not accurate unless 0 cadence events (i.e., coasting) are subtracted. In addition, if there is link loss, the Cumulative Crank Revolutions value can be used to calculate the average cadence during the link loss. This value is intended to roll over and is not configurable. The ‘crank event time’ is a free-running-count of 1/1024 second units and it represents the time when the crank revolution was detected by the crank rotation sensor. Since several crank events can occur between transmissions, only the Last Crank Event Time value is transmitted. The Last Crank Event Time value rolls over every 64 seconds. To enhance the user experience, the Server should ignore the extra crank revolutions that may be detected when the user is not pedaling (e.g., coasting down the hill) but the sensor is facing the crank revolution detection system (e.g., a magnet installed on the crankset) and may cause unwanted crank revolution detections.

Cycling Power Service v1.1

Since we are implementing a service specified in the Bluetooth Specification we need to use the correct Assigned values. These will make sure the Bluetooth device is recognized as a Cycling Power Service and that the data is interpreted correctly by the apps that want to use that data. Therefore we need both the service value and the characteristic values shown in Hex.

Cycling Powerorg.bluetooth.service.cycling_power0x1818GSS
https://www.bluetooth.com/specifications/gatt/services/
Cycling Power Featureorg.bluetooth.characteristic.cycling_power_feature0x2A65GSS
Cycling Power Measurementorg.bluetooth.characteristic.cycling_power_measurement0x2A63GSS
Sensor Locationorg.bluetooth.characteristic.sensor_location0x2A5DGSS
https://www.bluetooth.com/specifications/gatt/characteristics/

Now that we have found those values we can use them along with the ArduinoBLE Library to implement the bluetooth service on the Arduino Nano 33 BLE Sense board.

ArduinoBLE Reference

Now I can set up the ArduinoBLE Service and Characteristics

BLEService CyclePowerService("1818");
BLECharacteristic CyclePowerFeature("2A65", BLERead, 4);
BLECharacteristic CyclePowerMeasurement("2A63", BLERead | BLENotify, 8);
BLECharacteristic CyclePowerSensorLocation("2A5D", BLERead, 1);

We also need buffers of the same length to hold the data for each characteristics, one for the measurement, sensor location, and feature.

unsigned char bleBuffer[8];
unsigned char slBuffer[1];
unsigned char fBuffer[4];

Then we initiate the variables that will be written into the buffers. The flags and sensor location will not change.

short power;
unsigned short revolutions = 0;
unsigned short timestamp = 0;
unsigned short flags = 0x20;
byte sensorlocation = 0x0D;

Now that the variables have been initiated we can set up the BLE service

BLE.setDeviceName(BLE_DEVICE_NAME);
BLE.setLocalName(BLE_LOCAL_NAME);
BLE.setAdvertisedService(CyclePowerService);
CyclePowerService.addCharacteristic(CyclePowerFeature);
CyclePowerService.addCharacteristic(CyclePowerMeasurement);
CyclePowerService.addCharacteristic(CyclePowerSensorLocation);

BLE.addService(CyclePowerService);
BLE.advertise();

In the loop portion of the arduino code, we write the variables into the buffers and use those buffers to update the service

bleBuffer[0] = flags & 0xff;
bleBuffer[1] = (flags >> 8) & 0xff;
bleBuffer[2] = power & 0xff;
bleBuffer[3] = (power >> 8) & 0xff;
bleBuffer[4] = revolutions & 0xff;
bleBuffer[5] = (revolutions >> 8) & 0xff;
bleBuffer[6] = timestamp & 0xff;
bleBuffer[7] = (timestamp >> 8) & 0xff;

slBuffer[0] = sensorlocation & 0xff;

fBuffer[0] = 0x00;
fBuffer[1] = 0x00;
fBuffer[2] = 0x00;
fBuffer[3] = 0x08;
         
CyclePowerFeature.writeValue(fBuffer, 4);
CyclePowerMeasurement.writeValue(bleBuffer, 8);
CyclePowerSensorLocation.writeValue(slBuffer, 1);

We can check to see if the blue service is coming up correctly by using the nRF Connect App to inspect the Bluetooth output.

Here are a few code examples that also implement the Cycling Power Service that I used to figure out how to correctly set the data fields for ArduinoBLE.

https://github.com/ptx2/gymnasticon/

https://github.com/paixaop/zwack

https://os.mbed.com/users/p3miya/code/BLE_CyclingPower/file/d9d7edb1ddfc/CyclingPowerService.h/

Cycling Cadence and Power Algorithm Design

Now that the BLE service is up and running I needed to process the magnetic field information coming from the magnetometer to determine the cycling cadence, in terms of crank revolutions and the timestamp of the last revolution, and the cycling power in terms of watts.

Now I needed data, so I loaded up the Magnetometer Example and wrote a small python script to collect the data over serial and save it as a CSV file. Then I mounted the Arduino Nano 33 BLE Sense onto the bike with some double stick tape and collected a dataset for each of the 8 tension settings as I rode the bike at various speed levels. I also collected a dataset for measuring the static tension levels.

Arduino Nano 33 BLE Sense mounted near the magnetic flywheel

I imported the data into Matlab to plot the data and start to analyze it for algorithm design. This could also be rewritten in python if you do not have access to Matlab. Here are graphs of the magnetometer data for tension set to 1 and 7 along with the tension calibration data for all tension settings from 1-8.

From these graphs, I see that the rotation causes the Z-axis magnitude of the magnetic field to decrease rapidly, and the faster the pedals are turning the dips occur at more rapid intervals. So I will need some way of detecting this peak, but I cannot use a simple threshold as a static pedal at certain points could erroneously trigger it. This exact problem can be seen at the beginning of tension 1 data and the end of tension 7 data. The threshold would also cause some issues as the tension value changes as shown in the calibration data graph.

So how can we track these markers without inputting dedicated calibration values? Since we are going to be processing on real-time data and on an arduino I decided to start with a simple peak detector where I compare 3 values to see if the middle one is lower than the other 2.

d_{t-1} < d_{t-2}  \: and  \: d_{t-1} < d_{t-0}

if(((tm1-tm2) < 0) && ((tm1-tm0) < 0))
{
  point = true;
}
else
{
  point = false;
}

Now we have a minimum point detector over 3 points, but since the data is quite noisy I need a way to determine if that point is a deep or in the noise. To do this I added maximum and minimum tracking, but with a small modification to allow for different maximums and minimums in the various different tension levels. This looks like a decaying min and max that will eventually decay to a steady state if the cyclist is not rotating the pedals. Here is the arduino code that will set the max or min otherwise will decay by the decay factor (df).

if(tm2 > curr_max)
{
  curr_max = tm2;
}
else
{
  curr_max = curr_max - df;
}
        
if(tm2 < curr_min)
{
  curr_min = tm2;
}
else
{
  curr_min = curr_min + df;
}

Now we can combine this into a moving threshold with the point detector and only set the variables if this condition is met.

if((tm1 < (curr_max - curr_min)) && point)
{ 
  i_curr = counter-1;
  i_diff = i_curr - i_prev;

  //Update measurements

  i_prev = i_curr;

  //Notify BLE Service previous code snippet
}

To update the measurements we need to take into account the size of the variables since some of them are meant to roll over. The first one being the total number of revolutions of the crank, this one is really easy, its just a 16bit counter.

revolutions = revolutions + 1;

The next is the timestamp, it is another 16bit counter but the increment is based on the difference between this cycle and the last cycle along with the sampling frequency.

timestamp = timestamp + (unsigned short)(i_diff*
(1024/mag_samps_per_sec));

Now the power calculation is a bit of an estimation, because we don’t have actual force values, so we will estimate based on the maximum magnetometer amplitude. Here I use a mag_power_calib to dial in the power output and square the difference. Then I multiply by the current RPM based on the difference calculation.

power = short(((mag_power_calib - curr_max)*(mag_power_calib -
  curr_max))*(60/(i_diff*(1024/mag_samps_per_sec))));

My power output ranges from under 100 up to about 350 on the maximum tension setting.

How to calculate cycling power

App Integration

Now what we have all been waiting for, does it work with the virtual riding apps?

Yes, yes it does! Here is a small video of the connection process and then what it looks like during a ride.

We can see the device show up under the power meter devices

This works with Zwift App and I also tested it with RGT App.

Conclusion

The compromise in using the magnetometer data acquisition method is that we don’t know exactly the correct force being applied at each crank, we only know the resistance level. To account for this we might need to get a force sensor on the pedal to get a baseline force for each resistance level in the future.

Calibration might be different from bike to bike so the code includes some values that could be used to adjust the algorithm. Translating this to other bikes we would need to make sure increased resistance equates to decreased magnetic field strength and calibrate the power output using the mag_power_calib variable.

The Code

Arduino BLE Cycle Power Service Github

Update- Compile error in the sensor library:

if you see a “ospriority” error in building this project you need to install an older version of the Arduino Mbed OS boards package. Install version 1.1.6 to fix this issue.