Emulating a Bike Sensor

As a reverse engineer and someone who enjoys dissecting how systems communicate, my interest was recently piqued by the Bluetooth Low Energy (BLE) cycling sensors that integrate with fitness tracking apps like Samsung Health. What began as an investigative dive into what I initially presumed were proprietary connections quickly evolved into a hands-on DIY project with an ESP32, demonstrating the power of open standards.

Proprietary Sensors Only?

My adventure began when I noticed the “Accessories” option in Samsung Health while preparing for an exercise session. Upon clicking, I was presented with a list of sensor devices for biking. My immediate thought was that Samsung Health might only support a specific, limited set of official or licensed sensors. This led me to purchase a second-hand Polar cadence sensor, believing it was the only way to get my cycling data into the app. Here’s a glimpse of what the accessory scanning looked like:

How Bike Sensors Really Work

While my Polar sensor was still in transit, I decided to dive deeper into how these cycling sensors actually function. I quickly learned that most bike sensors, whether for speed or cadence, fall into two main categories:

  • Magnet-based sensors: These are simple and reliable. A small magnet is attached to a rotating part (like a wheel spoke for speed, or a crank arm for cadence), and a stationary sensor (often a reed switch) is mounted nearby. Every time the magnet passes the sensor, it registers a “rotation.” My Polar sensor, it turned out, used this exact mechanism for cadence: a magnet on the crank, and the sensor on the bike’s chainstay, counting each crank rotation. In fact, when I looked up the FCC ID (INWY6) for Polar bike sensors, the internal photos readily available online confirmed it was indeed using a magnetic reed sensor for its operation.

  • Accelerometer-based sensors: These are more advanced and magnet-less. They use an accelerometer to detect the orientation and rotation of the component they’re attached to (e.g., a wheel hub or crank arm) to infer revolutions.

What truly surprised me was discovering that Bluetooth has an official Cycling Speed and Cadence (CSC) Service. This isn’t a proprietary Samsung thing; it’s a standardized BLE profile that any compatible sensor or app can use. This meant my initial assumption was wrong. Samsung Health, and many other fitness apps, are designed to connect to any sensor that adheres to this standard.

The CSC service is quite clever. It doesn’t send “speed” or “RPM” directly. Instead, it transmits:

  • Cumulative Rotation Count: A running total of how many times the wheel or crank has rotated.
  • Last Event Time: A timestamp indicating when the last rotation was detected.

The connected app (like Samsung Health) then takes these raw numbers and performs the calculations to give you real-time speed and RPM. For speed, the app typically asks for your wheel circumference, which it then multiplies by the wheel revolutions to get distance, and divides by the time elapsed to get speed.

The DIY Solution: ESP32 + Reed Sensor Emulation

Since I already had an ESP32, a microcontroller with built-in BLE capabilities, I realized I didn’t need to wait for my Polar sensor to arrive to start experimenting. I could build my own “fake” sensor! I grabbed a simple magnetic reed sensor (the same type used in many commercial sensors) and connected it to my ESP32.

My goal was to create an ESP32 BLE server that would emulate a full Cycling Speed and Cadence sensor using just this one reed switch. This meant every time the single reed sensor triggered, I would update both the emulated wheel revolutions and crank revolutions. While not physically accurate (a real bike’s wheel turns many more times than its cranks), it would demonstrate the BLE service functionality perfectly.

Here’s a simplified look at the core ESP32 Arduino code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h> // For Client Characteristic Configuration Descriptor (CCCD)

// --- BLE Service and Characteristic UUIDs ---
#define CSC_SERVICE_UUID "00001816-0000-1000-8000-00805f9b34fb"
#define CSC_MEASUREMENT_CHAR_UUID "00002a5b-0000-1000-8000-00805f9b34fb"
#define CSC_FEATURE_CHAR_UUID "00002a5c-0000-1000-8000-00805f9b34fb"

// --- Hardware Configuration ---
const int SINGLE_REED_SENSOR_PIN = 13; // Connect reed sensor here

// --- Sensor Data Variables (volatile for ISR) ---
volatile uint32_t cumulativeWheelRevolutions = 0;
volatile uint16_t lastWheelEventTime = 0;
volatile uint16_t cumulativeCrankRevolutions = 0;
volatile uint16_t lastCrankEventTime = 0;
volatile unsigned long lastSingleSensorTriggerMillis = 0;
const unsigned long DEBOUNCE_DELAY_MS = 50;

// --- Interrupt Service Routine (ISR) ---
// This function runs every time the reed sensor detects a magnet.
void IRAM_ATTR singleReedISR() {
if (millis() - lastSingleSensorTriggerMillis > DEBOUNCE_DELAY_MS) {
cumulativeWheelRevolutions++; // Emulate wheel revolution
cumulativeCrankRevolutions++; // Emulate crank revolution

uint16_t currentTime1024s = (uint16_t)(esp_timer_get_time() / 1000 * 1024 / 1000);
lastWheelEventTime = currentTime1024s;
lastCrankEventTime = currentTime1024s;

lastSingleSensorTriggerMillis = millis();
Serial.println("Sensor Triggered! Updating both counts.");
}
}

void setup() {
Serial.begin(115200);
BLEDevice::init("ESP32_Emulated_CSC"); // Device name for BLE advertising

// Create BLE Server, Service, and Characteristics
BLEServer* pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks()); // Handle connect/disconnect

BLEService* pCscService = pServer->createService(CSC_SERVICE_UUID);
BLECharacteristic* pCscMeasurementCharacteristic = pCscService->createCharacteristic(
CSC_MEASUREMENT_CHAR_UUID, BLECharacteristic::PROPERTY_NOTIFY);
pCscMeasurementCharacteristic->addDescriptor(new BLE2902()); // For notifications

BLECharacteristic* pCscFeatureCharacteristic = pCscService->createCharacteristic(
CSC_FEATURE_CHAR_UUID, BLECharacteristic::PROPERTY_READ);
uint16_t cscFeatures = 0x0003; // Indicate support for both Wheel and Crank data
pCscFeatureCharacteristic->setValue((uint8_t*)&cscFeatures, 2);

pCscService->start();

// Start BLE Advertising
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(CSC_SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();

// Configure reed sensor pin and attach interrupt
pinMode(SINGLE_REED_SENSOR_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SINGLE_REED_SENSOR_PIN), singleReedISR, FALLING);
}

void loop() {
if (deviceConnected) {
uint8_t flags = 0x03; // Both wheel and crank data present
uint8_t cscData[11];
int offset = 0;

cscData[offset++] = flags;
memcpy(&cscData[offset], (const void*)&cumulativeWheelRevolutions, 4); offset += 4;
memcpy(&cscData[offset], (const void*)&lastWheelEventTime, 2); offset += 2;
memcpy(&cscData[offset], (const void*)&cumulativeCrankRevolutions, 2); offset += 2;
memcpy(&cscData[offset], (const void*)&lastCrankEventTime, 2); offset += 2;

pCscMeasurementCharacteristic->setValue(cscData, 11); // Set the full data packet
pCscMeasurementCharacteristic->notify(true); // Send notification

delay(100); // Send updates every 100ms
} else {
delay(500);
}
}

Key parts of the code:

  • SINGLE_REED_SENSOR_PIN = 13: This is where your single reed sensor is connected.
  • singleReedISR(): This Interrupt Service Routine is the heart of the emulation. Every time the magnet passes the sensor, this function runs. It increments both cumulativeWheelRevolutions and cumulativeCrankRevolutions and updates their lastEventTime with the current timestamp. This makes the single sensor appear as both a speed and cadence sensor.
  • cscFeatures = 0x0003: In the setup() function, this line tells any connecting app that our ESP32 supports both wheel (speed) and crank (cadence) data.
  • pCscMeasurementCharacteristic->setValue(cscData, 11); pCscMeasurementCharacteristic->notify(true);: In the loop() function, after preparing the cscData packet with the latest revolution counts and times, these lines update the BLE characteristic’s value and send a notification to the connected fitness app.

Connecting to Samsung Health

Once the ESP32 code is uploaded and running, it starts advertising itself as “ESP32_Emulated_CSC”. You can then go into Samsung Health and scan for new devices. After selecting “ESP32_Emulated_CSC” and connecting, Samsung Health will typically ask for some configuration details, especially the wheel size. This is crucial for the app to accurately calculate your speed from the wheel revolution data it receives.

Once configured, you can start your exercise! Every time you trigger the reed sensor (e.g., by waving a magnet past it, or by mounting it on a wheel and spinning it), you’ll see the values update in Samsung Health. The app takes the raw revolution counts and timestamps from the ESP32 and displays your speed and RPM.

This project demonstrates the power of open standards like BLE and how a simple microcontroller like the ESP32 can be used to create custom sensors that integrate seamlessly with existing fitness ecosystems. It’s a fantastic way to learn about BLE, microcontrollers, and even a bit about how your fitness data is collected!

Happy cycling (and emulating)!

Funny Finding

While looking at the Samsung Health APK for the blogpost, I stumbled upon some curious strings related to antioxidant. My first thought was that it might be some obscure codename for another type of sensor.

However, upon further investigation (and a quick search that led me to this Android Authority blog post published just 12 hours before my discovery!), it turned out to be a real feature. Samsung watches can actually measure an “Antioxidant Index,” and this functionality was introduced in the very patch of Samsung Health that I was reversing. It was a fun, unexpected detour in my exploration!