With lockdowns and winter I converted my bike into a "turbo trainer" to cycle indoors. A cheap eBay cadence sensor can then report on my RPM, however when doing guided exercises on my Phone, I cant use the app to see my cadence. This makes it difficult when the coaches say "now hit upper 90s on your RPM".
I decided to try and build a cheap display to show the cadence using an ESP32 and a SSD1306 OLED screen: total cost ~£10
It turned out to be one of the harder projects I've completed. You know those projects where someone solves something with bold code, smart electronics and a nifty 3D print? This isn't that, this is someone struggling through a bunch of areas and learning as they went. The code is probably wrong.
Some of the challenges covered in this blog:
- Micropython on ESP32
- Bluetooth Low Energy (BLE)
- BLE in micropython
- Understanding the sensor
- Getting it all together
Adventures in Micropython
I knew this project would be a lot of experimentation and as I'm more comfortable with Python, figured it was best to use that. Also, you can have a live "shell" on the ESP32 with Python which would allow experimentation without uploading new code.
There are a number of blogs of coding python on the ESP32. I decided on the Adafruit method, as it seemed to require the least installed on my windows computer, and I can use VS Code with normal linting to write the code.
A good blog was: https://www.digikey.co.uk/en/maker/projects/micropython-basics-load-files-run-code/fb1fcedaf11e4547943abfdd8ad825ce
Then to develop I would just use VS Code, and when deploying: run the ampy module in the directory where I was developing to move main.py onto the esp32.
The guides will recommend that you add in switches etc to switch into REPL (python shell) mode, but instead I just used Putty as my Serial interface, which lets you send Ctrl-C to kill the python script and drop you into the shell.
A quick warning was this GitHub issue, you may need to use an older micropython firmware if you want BT LE to work.
Another note here: If you installed python using the Windows App Store then anything you pull with Pip is in a horrible chain of subdirectories under your user's AppData\Local\Packages\PythonSoftware.. directory.
Bluetooth Low Energy
There are a number of blogs on Bluetooth Low Energy in micropython but none of them did the full job helping me understand so I'll try here. Some of the theory in this Blog is a good start https://learn.adafruit.com/bluetooth-bicycle-speed-cadence-sensor-display-with-clue/understanding-ble
A real struggle with this project is that I couldn't find many examples or docs on how to do this in Python, so will attempt to detail here.
But here are the key bits:
Devices will advertise themselves
In the advertisement:
- all devices will have a MAC address of the form 11:22:33:44:55:66
- they may also have extended information like a common name
Once you connect to a device, you can discover it's services. These will have GUIDs and potentially that GUID maps to a name such as "Generic Access".
A Service can be probed for it's "Characteristics" which will be a "handle" (small integer), details on whether the handle can be read/written etc and maybe a name.
As an example:
A device advertises its address: 11:22:33:44:55:66 and name: "FitBob".
You connect to it and its services are:
- 19b3530b-c999-4184-92b5-2c3b60a7e926
- 44bd726c-3d6d-4073-993f-c487437917ab
You query the characteristics for the first GUID and see that it has two characteristics
- Handle 4 - read only - name: "Steves FitBob"
- Handle 6 - read only - Version: 4
There are many recommendations in blogs of apps you can use to explore BLE, however they all suffer from a similar issue: its hard to get technical details out like the MAC address, and specific handles. I found that the "Bluetooth LE Explorer" program in the Windows Store on Win10 from Microsoft was by far the best for exploring devices and understanding them. Highly Recommended.
Connecting to BLE in Micropython
bt = ubluetooth.BLE() #create an object
bt.active(True) #activate BT, this wierdly takes upto 30 seconds each timebt.gap_scan(3000) # scan for advertising devices for 3000ms, you can give more specific numbers if needed like how long the scan window is, see documentation at https://docs.micropython.org/en/latest/library/ubluetooth.html
def bt_irq(event, data):print(str(event))
bt.irq(bt_irq)
adr = binascii.unhexlify(b'112233445566') #puts the address in a format that the next function can handlebt.gap_connect(1,adr,10000) # connectsbt.gattc_discover_services(0) # the 0 is for the connection handle, starts at 0 if you have only connected to 1 device, it will return the services and their handlesbt.gattc_discover_characteristics(0, 14, 24) # give the handle ranges from the last call to discovered characteristics.
Understanding your target device
Exploring the device in "BT Low Energy Explorer" I found its MAC address and the cycle profile. This is covered a bit by https://learn.adafruit.com/bluetooth-bicycle-speed-cadence-sensor-display-with-clue/cycling-speed-and-cadence-service and the full documentation is available at https://www.bluetooth.com/specifications/specs/cycling-speed-and-cadence-profile-1-0/
Looking at the Cycling Cadence service, you can see it has a "Measurement" characteristic. But how to read it?
I assumed I could poll it, but this service is a notification service. I.e. you write to it telling it you want it to notify you, it starts to notify you with each notification raising an irq event.
Clicking on the Measurement service in BT LE Explorer gives you the handle to write to in order to trigger notifications, 27 in this case.
So the python to activate notifications in python is:
bt.gattc_write(0,27, struct.pack('<h', 1), 1) # from https://github.com/micropython/micropython/issues/6185#issuecomment-650848053
From then, sweet sweet measurements start coming in.
I used excerpts from some of the code from a related AdaFruit project to translate the data
However, the data only gives you:
- Count of how many revolutions of the pedal
- Time in 1/1024 of a second from last crank to this
Which you then need to convert to RPM. I am particularly bad at Python and maths so this wasn't easy, but the below gives me reasonable output. I'm using globals because remember it gets called by the irq event and so you can't pass in variables.
global last_crank_count
global last_crank_time
global rpm
if crank_time >= last_crank_time:
if crank_count-last_crank_count == 0:
rpm = 0
rpm = (60/((crank_time - last_crank_time)/1024))*(crank_rev_count-last_crank_count)
last_crank_count = crank_count
last_crank_time = crank_time
Putting it all together
- taking battery out of sensor and replacing worked about 10% of the time
- I could see it on my phone
- I could often see it on Windows
- I could rarely see it on the ESP32
Future Plans
Now I know what I'm doing (a bit) I will rewrite in C/Arduino. This is for a couple of reasons
- Better OLED support so dont have to squint
- The 30s to boot Bluetooth is a pain
- Practice C
Total cost about £10 and a few evenings of entertainment / frustration.