A bike computer counts the turns of the wheel. Every turn moves the bike forward by exactly one wheel circumference, about 2 metres, so the time between two turns tells the speed. Here a magnet on a spoke passes a Hall sensor on the fork once per turn. The ESP32 doesn't need your home Wi-Fi: it makes its own network, and your phone connects to it and shows the speed, the distance, your top speed and the ride time. Only look at the phone when you have stopped, and test it first with the bike upside down.
What you'll learn
- How a Hall sensor notices a magnet
- How an interrupt catches a pulse that only lasts a moment
- How to turn the time between pulses into a speed
- How the ESP32 can make its own Wi-Fi network (access point)
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- Linear Hall sensor module (45-in-1 Sensor Kit)
- 1 small strong magnet, about 10 mm across (not in the kits)
- A USB power bank and a short USB cable
- 2 cable ties and some tape
- 3 long female-to-female jumper wires
- A bike, and a phone to connect to the ESP32
- The printed parts from the 3D printing box below (fork mount and spoke clip), or the cardboard version described there
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| Hall sensor module DO | ESP32 GPIO 27 |
| Hall sensor module + | 3V3 |
| Hall sensor module G | GND |
| Hall sensor module AO | Not used |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
Move the magnet past the sensor: the small LED on the module should flash. If it doesn't, turn the screw on the module a little until it flashes every time the magnet passes, but stays off without it.
Measure your wheel
- Make a chalk mark on the tyre where it touches the ground, and a mark on the ground next to it.
- Roll the bike forward in a straight line until the chalk mark is at the bottom again, and mark the ground.
- Measure between the two ground marks in millimetres and put the number into
WHEEL_MM. A typical bike wheel is between 1,900 and 2,200 mm.
The sketch
#include <WiFi.h>
#include <WebServer.h>
const char* NETWORK = "Bike-Speedo";
const char* PASSWORD = "pedal1234"; // at least 8 characters
const int HALL_PIN = 27;
const float WHEEL_MM = 2100; // your wheel's circumference
WebServer server(80);
volatile unsigned long lastPulse = 0; // changed inside the interrupt
volatile unsigned long interval = 0; // ms between the last two pulses
volatile unsigned long turns = 0;
float topSpeed = 0;
unsigned long rideStart = 0, moving = 0, lastCheck = 0;
// Runs the moment the magnet passes, whatever loop() is doing
void IRAM_ATTR onMagnet() {
unsigned long now = millis();
if (now - lastPulse > 60) { // ignore bounces (60 ms = over 120 km/h)
interval = now - lastPulse;
lastPulse = now;
turns++;
}
}
float speedKmh() {
if (millis() - lastPulse > 3000 || interval == 0) return 0; // no turn for 3 s: stopped
return WHEEL_MM / interval * 3.6; // mm per ms = m per s, times 3.6 = km/h
}
void handleData() {
float km = turns * WHEEL_MM / 1000000.0;
String json = "{\"speed\":" + String(speedKmh(), 1) + ",\"km\":" + String(km, 2) +
",\"top\":" + String(topSpeed, 1) + ",\"minutes\":" + String(moving / 60000) + "}";
server.send(200, "application/json", json);
}
void handleReset() {
turns = 0;
topSpeed = 0;
moving = 0;
server.send(200, "text/plain", "ok");
}
const char PAGE[] = R"html(<!doctype html><meta name=viewport content='width=device-width'><title>Speedo</title>
<style>body{font-family:sans-serif;margin:0;padding:24px;text-align:center;background:#12285A;color:#fff}
#sp{font-size:96px;font-weight:bold;line-height:1}small{opacity:.7}div.g{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin:28px 0}
b{display:block;font-size:26px}button{padding:12px 20px;border:0;border-radius:12px;font-size:16px}</style>
<div id=sp>0.0</div><small>km/h</small>
<div class=g><span><b id=km>0</b>km</span><span><b id=tp>0</b>top km/h</span><span><b id=mn>0</b>minutes</span></div>
<button onclick="fetch('/reset')">New ride</button>
<script>
async function tick() {
try {
const d = await (await fetch('/data')).json();
const $ = (id) => document.getElementById(id);
$('sp').textContent = d.speed.toFixed(1); $('km').textContent = d.km.toFixed(2);
$('tp').textContent = d.top.toFixed(1); $('mn').textContent = d.minutes;
} catch (e) {}
setTimeout(tick, 1000);
}
tick();
</script>)html";
void setup() {
Serial.begin(115200);
pinMode(HALL_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(HALL_PIN), onMagnet, RISING);
WiFi.softAP(NETWORK, PASSWORD); // the ESP32 makes its own network
Serial.print("Join the Wi-Fi network ");
Serial.print(NETWORK);
Serial.print(" and open http://");
Serial.println(WiFi.softAPIP()); // always 192.168.4.1
server.on("/", []() { server.send(200, "text/html", PAGE); });
server.on("/data", handleData);
server.on("/reset", handleReset);
server.begin();
}
void loop() {
server.handleClient();
if (millis() - lastCheck >= 1000) { // once a second
lastCheck = millis();
float v = speedKmh();
if (v > 0) moving += 1000; // count only the time you are moving
if (v > topSpeed && v < 80) topSpeed = v; // ignore impossible values
if (v > 0) Serial.printf("%.1f km/h, %lu turns\n", v, turns);
}
}
Ride
- Turn the bike upside down and spin the front wheel by hand. The Serial Monitor shows the speed.
- Power the ESP32 from the power bank. On your phone, join the Wi-Fi network Bike-Speedo with the password
pedal1234, then openhttp://192.168.4.1. - The phone may warn that this network has no internet. Choose to stay connected. Fix the phone to the handlebar, tap New ride and go.
- Ride in a safe place, wear a helmet, and look at the phone only when you have stopped.
How it works
A Hall sensor reacts to magnetic fields. On the module, a comparator turns its reading into a clean on-off signal on DO: it switches when the magnet comes close. With one magnet on the wheel, that happens once per turn.
At 25 km/h the wheel turns more than three times a second, and the magnet is near the sensor for only a few milliseconds. attachInterrupt() makes the ESP32 drop everything and run onMagnet() the moment the signal rises, so no turn is missed while it is busy with the web page. Variables changed inside an interrupt are marked volatile, so the rest of the sketch always reads their latest value.
Speed is distance divided by time: one wheel circumference in millimetres divided by the milliseconds between two turns gives metres per second, and times 3.6 that is km/h. If no turn comes for three seconds, you have stopped.
WiFi.softAP() turns the ESP32 into an access point, like a small router. Your phone joins it directly, so the speedometer works anywhere, far from home Wi-Fi.
Try this next
- Show the average speed: distance divided by the moving time.
- Save the total distance in Preferences, so it adds up over all your rides.
- Add a second magnet on the opposite spoke and halve the wheel size in the sketch. What changes?
Troubleshooting
- The speed stays at 0: check that the module's LED flashes when the magnet passes. If not, bring the magnet closer or turn the screw on the module.
-
The speed is double what it should be: the sensor switches twice per pass. Change
RISINGtoFALLING, or move the magnet a little further away. - The phone can't see the network: check the Serial Monitor. The password must have at least 8 characters.
- The phone keeps leaving the network: turn off mobile data or "switch to mobile data automatically" while you ride.
- A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.