Why I Built This
Commercial bike computers from brands Performs a great job — but they start at ₹4,000 and go all the way up to ₹10,000+ for anything with GPS.
As someone who already had an ESP32-C3 Mini on the bike, I extended it to track speed, distance, and GPS coordinates, and push everything to Home Assistant.
This post documents the full build: hardware, wiring, ESPHome YAML, and the Home Assistant dashboard.
Build Goal
Replace a ₹8,000 commercial GPS bike computer with a ₹1,200 DIY build that delivers the same core metrics, sends live data to Home Assistant, and shows real-time stats on a handlebar-mounted OLED display.
What This Build Does
- Tracks real-time speed, trip distance, max speed, average speed, and moving time
- Maintains a lifetime odometer that survives reboots
- Trip reset functionality
- Publishes GPS to Home Assistant via MQTT
- OLED display with multiple pages
- Status LED for GPS lock
- Remote dashboard access
Hardware Used
| Component | Purpose | Approx. Cost |
| ESP32-C3 Mini | Microcontroller + WiFi | ₹250–350 |
| NEO-6M GPS module | GPS positioning (UART) | ₹300–450 |
| SSD1306 OLED 0.96″ (128×64) | 4-page handlebar display | ₹150–180 |
| ESP32-C3 expansion board | Clean power + pin access | ₹150–200 |
| Momentary push button 6×6mm | Manual page cycle (future) | ₹5–10 |
| Mi Power Bank (USB out) | Powers everything | Already owned |
| Jumper wires + enclosure | Wiring and handlebar mount | ₹50–100 |
Wiring Summary
GPS: VCC→3.3V, GND→GND, TX→GPIO20, RX→GPIO21
OLED: VCC→3.3V, GND→GND, SCL→GPIO6, SDA→GPIO7
Status LED
Uses the onboard WS2812B RGB LED at GPIO8 — no external components needed. The DevKit board already has this wired.
OLED Display — 4-Page UI
Rather than cramming everything onto one tiny 0.96″ screen, the display cycles through 4 pages automatically — similar to how a Other Tracker’s cycles through screens. While riding, pages rotate every 8 seconds. While stopped, the display snaps back to Page 0 (the speed screen).
| Page | Name | Content |
| 0 | Riding (default) | Large speed readout · trip distance · max speed |
| 1 | Trip Stats | Trip distance · average speed · moving time |
| 2 | Totals | Odometer · top speed · altitude |
| 3 | GPS Debug | Satellite count · HDOP quality · lat/lon coordinates |
Key Firmware Logic
Smart Distance Calculation
Uses the Haversine formula to calculate distance between GPS points every 5 seconds. Multiple guards prevent phantom distance accumulation (the classic 49 km sitting at home problem):
- HDOP > 3.0 → GPS quality too poor, skip
- Satellites < 4 → not enough lock, skip
- Speed < 2 km/h → stationary, update anchor but add no distance
- Jump > 300 m in 5 s → GPS teleport glitch, ignore
- NaN coordinate check → skip if no valid state
Home Assistant Dashboard
ESPHome MQTT discovery automatically creates all entities. No manual configuration needed. The dashboard shows:
- Live speed gauge
- Trip distance and lifetime odometer
- Max speed and average speed this trip
- Moving time (only counts time above 2 km/h)
- GPS Fix status (binary sensor — green/red indicator)
- GPS Status text (“Excellent · 9 sats”, “No Fix”, etc.)
- Satellite count and HDOP value
- Live map card showing current location
- Reset Trip button
- Next Display Page button
- WiFi signal strength and IP address
ESPHome Code
esphome:
name: esp-blr-bicycle01-c3-mini
friendly_name: esp-BLR-bicycle01-C3-mini
platformio_options:
build_flags:
- -DESP_TASK_WDT_TIMEOUT_S=10
on_boot:
priority: -100
then:
- lambda: |-
id(trip_distance) = 0.0f;
id(trip_max_speed) = 0.0f;
id(trip_moving_seconds) = 0;
id(last_lat) = 0.0f;
id(last_lon) = 0.0f;
id(gps_fix_valid) = 0;
id(display_page) = 0;
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
# ------------------------------------------------------------------ LOGGING
logger:
level: INFO
# ---------------------------------------------------------- HOME ASSISTANT API
api:
encryption:
key: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# ----------------------------------------------------------------------- OTA
ota:
- platform: esphome
password: "XXXXXXXXXXXXXXXXXX"
# ---------------------------------------------------------------------- MQTT
mqtt:
client_id: "esp-blr-bicycle01-c3-mini"
username: "XXXXXXXXXXXXXXXXXX"
password: "XXXXXXXXXXXXXXXXXX"
broker: XXXXXXXXXXXXXXXXXX
port: XXXXXXXXXXXXXXXXXX
discovery: true
discovery_prefix: "homeassistant"
keepalive: 30s
on_connect:
- mqtt.publish:
topic: "homeassistant/device_tracker/esp-blr-bicycle01/config"
payload: '{"name":"Firefox MTB","state_topic":"homeassistant/device_tracker/esp-blr-bicycle01/state","source_type":"gps","json_attributes_topic":"homeassistant/device_tracker/esp-blr-bicycle01/state"}'
retain: true
# ----------------------------------------------------------------------- WIFI
wifi:
networks:
- ssid: !secret wifi_ssid_M06-006
password: !secret wifi_password_M06-006
priority: 20
fast_connect: false
power_save_mode: none
use_address: esp-blr-bicycle01-c3-mini.local
ap:
ssid: "Esp-Blr-Bicycle01-C3-Mini"
password: "JsSuoDbHcIbS"
captive_portal:
# ----------------------------------------------------------------------- I2C
i2c:
sda: GPIO6
scl: GPIO7
scan: true
# ----------------------------------------------------------------- UART (GPS)
uart:
rx_pin: GPIO20
tx_pin: GPIO21
baud_rate: 9600
# Uncomment debug block only when troubleshooting raw NMEA output
# debug:
# direction: RX
# dummy_receiver: true
# ------------------------------------------------------------------- GLOBALS
globals:
# ── Trip (resets on boot + manual button) ──────────────────────────────────
- id: trip_distance
type: float
initial_value: '0.0'
restore_value: no
- id: trip_max_speed
type: float
initial_value: '0.0'
restore_value: no
- id: trip_moving_seconds
type: int
initial_value: '0'
restore_value: no
# ── Odometer (persists across reboots) ────────────────────────────────────
- id: odometer
type: float
initial_value: '0.0'
restore_value: yes
# ── Internal GPS tracking ──────────────────────────────────────────────────
- id: last_lat
type: float
initial_value: '0.0'
restore_value: no
- id: last_lon
type: float
initial_value: '0.0'
restore_value: no
- id: gps_fix_valid
type: int
initial_value: '0'
restore_value: no
# ── Display page 0-3 ──────────────────────────────────────────────────────
- id: display_page
type: int
initial_value: '0'
restore_value: no
# ----------------------------------------------------------- ONBOARD RGB LED
# WS2812B on GPIO8 — no external components needed
light:
- platform: esp32_rmt_led_strip
rgb_order: GRB
pin: GPIO8
num_leds: 1
chipset: WS2812
name: "Status LED"
id: status_led
internal: true # hides from HA, controlled only by intervals below
# --------------------------------------------------------------------- FONTS
font:
- file: "gfonts://Roboto Mono"
id: font_big
size: 28
glyphs: "0123456789.-: "
- file: "gfonts://Roboto Mono"
id: font_med
size: 14
glyphs: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ .:-+/"
- file: "gfonts://Roboto Mono"
id: font_sml
size: 11
glyphs: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ .:-+/"
# --------------------------------------------------------------------- GPS
gps:
latitude:
name: "Bike Latitude"
id: gps_lat
accuracy_decimals: 6
longitude:
name: "Bike Longitude"
id: gps_lon
accuracy_decimals: 6
speed:
name: "Bike Speed"
id: gps_speed
unit_of_measurement: "km/h"
accuracy_decimals: 1
filters:
- lambda: |-
if (x < 0.5) return 0.0;
return x;
altitude:
name: "Bike Altitude"
id: gps_altitude
unit_of_measurement: "m"
accuracy_decimals: 1
course:
name: "Bike Heading"
id: gps_course
unit_of_measurement: "°"
accuracy_decimals: 1
satellites:
name: "GPS Satellites"
id: gps_satellites
hdop:
name: "GPS HDOP"
id: gps_hdop
accuracy_decimals: 2
# ------------------------------------------------------------------ INTERVALS
interval:
# ── 1. GPS distance calculation every 5s ──────────────────────────────────
- interval: 5s
then:
- lambda: |-
if (!id(gps_lat).has_state() || !id(gps_lon).has_state()) {
id(gps_fix_valid) = 0;
return;
}
float lat = id(gps_lat).state;
float lon = id(gps_lon).state;
if (isnan(lat) || isnan(lon)) {
id(gps_fix_valid) = 0;
return;
}
if (!id(gps_hdop).has_state() || id(gps_hdop).state > 3.0f) {
id(gps_fix_valid) = 0;
return;
}
if (id(gps_satellites).has_state() && id(gps_satellites).state < 4) {
id(gps_fix_valid) = 0;
return;
}
id(gps_fix_valid) = 1;
if (id(last_lat) == 0.0f && id(last_lon) == 0.0f) {
id(last_lat) = lat;
id(last_lon) = lon;
return;
}
const float R = 6371000.0f;
const float DEG2RAD = 0.017453292519943295f;
float x = (lon - id(last_lon)) * DEG2RAD *
cosf((lat + id(last_lat)) * 0.5f * DEG2RAD);
float y = (lat - id(last_lat)) * DEG2RAD;
float d = sqrtf(x * x + y * y) * R;
bool moving = id(gps_speed).has_state() && id(gps_speed).state >= 2.0f;
if (moving && d > 2.0f && d < 300.0f) {
float d_km = d / 1000.0f;
id(trip_distance) += d_km;
id(odometer) += d_km;
id(last_lat) = lat;
id(last_lon) = lon;
float spd = id(gps_speed).state;
if (spd > id(trip_max_speed)) {
id(trip_max_speed) = spd;
}
id(trip_moving_seconds) += 5;
} else if (!moving) {
id(last_lat) = lat;
id(last_lon) = lon;
}
# Publish device_tracker for HA map card
- if:
condition:
lambda: 'return id(gps_fix_valid) == 1;'
then:
- mqtt.publish:
topic: "homeassistant/device_tracker/esp-blr-bicycle01/state"
payload: !lambda |-
char buf[200];
snprintf(buf, sizeof(buf),
"{\"latitude\":%.6f,\"longitude\":%.6f,\"gps_accuracy\":%.1f,\"speed\":%.1f}",
id(gps_lat).state,
id(gps_lon).state,
id(gps_hdop).has_state() ? id(gps_hdop).state * 5.0f : 20.0f,
id(gps_speed).has_state() ? id(gps_speed).state : 0.0f
);
return std::string(buf);
retain: false
# ── 2. Auto-cycle display pages every 8s ──────────────────────────────────
# Moving → cycles 0 → 1 → 2 → 3 → 0 automatically
# Stopped → always snaps back to page 0 (speed screen)
# Physical button (GPIO4) or HA virtual button overrides instantly
- interval: 8s
then:
- lambda: |-
bool moving = id(gps_speed).has_state() && id(gps_speed).state >= 2.0f;
if (moving) {
id(display_page) = (id(display_page) + 1) % 4;
} else {
id(display_page) = 0;
}
# ── 3. RGB LED status every 1s ────────────────────────────────────────────
# No fix → slow red blink
# Fix OK → solid dim green
- interval: 1s
then:
- lambda: |-
static bool blink_state = false;
if (id(gps_fix_valid) == 1) {
auto call = id(status_led).turn_on();
call.set_rgb(0.0f, 1.0f, 0.0f);
call.set_brightness(0.4f);
call.perform();
} else {
blink_state = !blink_state;
auto call = id(status_led).turn_on();
call.set_rgb(1.0f, 0.0f, 0.0f);
call.set_brightness(blink_state ? 0.5f : 0.0f);
call.perform();
}
# ------------------------------------------------------------------- SENSORS
sensor:
- platform: template
name: "Trip Distance"
icon: mdi:map-marker-distance
unit_of_measurement: "km"
accuracy_decimals: 2
update_interval: 5s
lambda: return id(trip_distance);
- platform: template
name: "Trip Max Speed"
icon: mdi:speedometer
unit_of_measurement: "km/h"
accuracy_decimals: 1
update_interval: 5s
lambda: return id(trip_max_speed);
- platform: template
name: "Trip Moving Time"
icon: mdi:timer-outline
unit_of_measurement: "min"
accuracy_decimals: 0
update_interval: 5s
lambda: return (float)(id(trip_moving_seconds)) / 60.0f;
- platform: template
name: "Trip Average Speed"
icon: mdi:speedometer-medium
unit_of_measurement: "km/h"
accuracy_decimals: 1
update_interval: 5s
lambda: |-
if (id(trip_moving_seconds) < 5) return 0.0f;
float hours = (float)id(trip_moving_seconds) / 3600.0f;
return id(trip_distance) / hours;
- platform: template
name: "Odometer"
icon: mdi:counter
unit_of_measurement: "km"
accuracy_decimals: 2
update_interval: 5s
lambda: return id(odometer);
- platform: template
name: "GPS Fix Quality"
icon: mdi:satellite-variant
unit_of_measurement: ""
accuracy_decimals: 0
update_interval: 5s
lambda: |-
if (!id(gps_hdop).has_state()) return 0.0f;
float h = id(gps_hdop).state;
if (h < 1.0f) return 3.0f;
if (h < 2.0f) return 2.0f;
if (h < 5.0f) return 1.0f;
return 0.0f;
- platform: wifi_signal
name: "WiFi Signal"
icon: mdi:wifi
update_interval: 30s
- platform: uptime
name: "Uptime"
icon: mdi:clock-outline
update_interval: 60s
# ----------------------------------------------------------------- BINARY SENSORS
binary_sensor:
- platform: template
name: "GPS Fix"
icon: mdi:crosshairs-gps
lambda: return id(gps_fix_valid) == 1;
device_class: connectivity
# ── Physical page button — wire between GPIO4 and GND when you have one ───
# Safe to leave in now — INPUT_PULLUP means floating pin = unpressed
# Pressing instantly overrides the 8s auto-cycle
- platform: gpio
pin:
number: GPIO4
mode: INPUT_PULLUP
inverted: true
name: "Display Page Button"
internal: true
filters:
- delayed_on: 20ms
on_press:
then:
- lambda: |-
id(display_page) = (id(display_page) + 1) % 4;
# ---------------------------------------------------------------- TEXT SENSORS
text_sensor:
- platform: template
name: "GPS Status"
icon: mdi:information-outline
update_interval: 5s
lambda: |-
if (!id(gps_hdop).has_state()) return std::string("Initialising");
float h = id(gps_hdop).state;
int s = id(gps_satellites).has_state() ? (int)id(gps_satellites).state : 0;
if (h > 10.0f || s == 0) return std::string("No Fix");
if (h > 3.0f) return std::string("Poor (" + to_string(s) + " sats)");
if (h > 2.0f) return std::string("Fair (" + to_string(s) + " sats)");
if (h > 1.0f) return std::string("Good (" + to_string(s) + " sats)");
return std::string("Excellent (" + to_string(s) + " sats)");
- platform: wifi_info
ip_address:
name: "IP Address"
icon: mdi:ip-network
ssid:
name: "Connected SSID"
icon: mdi:wifi
# -------------------------------------------------------------------- BUTTONS
button:
- platform: template
name: "Reset Trip"
icon: mdi:restart
on_press:
then:
- lambda: |-
id(trip_distance) = 0.0f;
id(trip_max_speed) = 0.0f;
id(trip_moving_seconds) = 0;
id(last_lat) = 0.0f;
id(last_lon) = 0.0f;
ESP_LOGI("bike", "Trip reset by user");
- platform: template
name: "Reset Odometer"
icon: mdi:counter
on_press:
then:
- lambda: |-
id(odometer) = 0.0f;
ESP_LOGI("bike", "Odometer reset by user");
# Tap this in HA app to manually cycle pages — useful until physical button arrives
- platform: template
name: "Next Display Page"
icon: mdi:page-next-outline
on_press:
then:
- lambda: |-
id(display_page) = (id(display_page) + 1) % 4;
# -------------------------------------------------------------------- DISPLAY
display:
- platform: ssd1306_i2c
model: "SSD1306 128x64"
address: 0x3C
update_interval: 1s
rotation: 180°
lambda: |-
int page = id(display_page);
bool fix = id(gps_fix_valid) == 1;
// ── PAGE 0: Speed + Trip + Max ─────────────────────────────────────
if (page == 0) {
it.printf(0, 0, id(font_sml), "SPD");
if (fix && id(gps_speed).has_state()) {
it.printf(0, 10, id(font_big), "%.1f", id(gps_speed).state);
} else {
it.printf(0, 10, id(font_big), "--.-");
}
it.printf(90, 30, id(font_sml), "km/h");
it.horizontal_line(0, 42, 128, Color(1,1,1));
it.printf(0, 46, id(font_sml), "TRIP");
it.printf(0, 55, id(font_sml), "%.2fkm", id(trip_distance));
it.printf(68, 46, id(font_sml), "MAX");
it.printf(68, 55, id(font_sml), "%.1f", id(trip_max_speed));
}
// ── PAGE 1: Trip stats — Distance + Avg + Time ─────────────────────
else if (page == 1) {
it.printf(0, 0, id(font_sml), "TRIP DIST");
it.printf(0, 10, id(font_big), "%.2f", id(trip_distance));
it.printf(90, 30, id(font_sml), "km");
it.horizontal_line(0, 42, 128, Color(1,1,1));
float avg = id(trip_moving_seconds) > 5
? id(trip_distance) / ((float)id(trip_moving_seconds) / 3600.0f)
: 0.0f;
it.printf(0, 46, id(font_sml), "AVG");
it.printf(0, 55, id(font_sml), "%.1fkm/h", avg);
it.printf(68, 46, id(font_sml), "TIME");
it.printf(68, 55, id(font_sml), "%dm", id(trip_moving_seconds) / 60);
}
// ── PAGE 2: Totals — Odometer + Top speed + Altitude ───────────────
else if (page == 2) {
it.printf(0, 0, id(font_sml), "ODOMETER");
it.printf(0, 10, id(font_big), "%.1f", id(odometer));
it.printf(90, 30, id(font_sml), "km");
it.horizontal_line(0, 42, 128, Color(1,1,1));
it.printf(0, 46, id(font_sml), "TOP SPD");
it.printf(0, 55, id(font_sml), "%.1fkm/h", id(trip_max_speed));
it.printf(68, 46, id(font_sml), "ALT");
if (fix && id(gps_altitude).has_state())
it.printf(68, 55, id(font_sml), "%.0fm", id(gps_altitude).state);
else
it.printf(68, 55, id(font_sml), "---m");
}
// ── PAGE 3: GPS debug — Status + Sats + HDOP + Coords ──────────────
else if (page == 3) {
if (fix) {
it.printf(0, 0, id(font_sml), "GPS LOCKED");
it.printf(0, 14, id(font_sml), "SAT:%d HDOP:%.1f",
id(gps_satellites).has_state() ? (int)id(gps_satellites).state : 0,
id(gps_hdop).has_state() ? id(gps_hdop).state : 99.9f);
it.printf(0, 28, id(font_sml), "LAT %.5f",
id(gps_lat).has_state() ? id(gps_lat).state : 0.0f);
it.printf(0, 42, id(font_sml), "LON %.5f",
id(gps_lon).has_state() ? id(gps_lon).state : 0.0f);
} else {
it.printf(0, 0, id(font_sml), "GPS NO FIX");
it.printf(0, 16, id(font_sml), "SAT:%d",
id(gps_satellites).has_state() ? (int)id(gps_satellites).state : 0);
it.printf(0, 30, id(font_sml), "HDOP:%.1f",
id(gps_hdop).has_state() ? id(gps_hdop).state : 99.9f);
it.printf(0, 46, id(font_sml), "Searching...");
}
}
// ── Page indicator dots — bottom right corner ──────────────────────
for (int i = 0; i < 4; i++) {
if (i == page)
it.filled_circle(108 + i * 6, 61, 2);
else
it.circle(108 + i * 6, 61, 2);
}
Home Assistant Dashboard

Cycle LCD




Lessons Learned
The NEO-6M patch antenna is weak indoors
HDOP reads 99.99 inside the house — completely normal. Step outside and within 60–90 seconds you get a solid fix. For better sensitivity, an active external SMA antenna (₹200–400) is a worthwhile upgrade.
HDOP filtering is critical
Without a quality gate, GPS jitter with no fix accumulates phantom kilometres. My first test showed 49 km travelled without leaving home — all noise. The HDOP + satellite count guards completely eliminate this.
Page-based UI makes the small screen usable
Showing only 2–3 large values per page rather than cramming everything at once makes the 0.96″ OLED readable at moderate riding speed. The 28px font for the main value is legible at a glance.
Power bank works great
A standard 10,000 mAh Mi Power Bank powers the ESP32, GPS module, and OLED continuously via USB. Estimated runtime is well over 10 hours per charge.
What’s Next
- Upgrade to NEO-M8N or NEO-M9N for better sensitivity and faster cold starts
- Replace 0.96″ OLED with ILI9341 2.8″ colour TFT for a proper bike computer feel
- Wire the physical 6×6mm button for instant manual page cycling
- Add cadence sensor via reed switch on the crank
- Auto trip logging to InfluxDB via HA for ride history
- Heart rate monitor integration via BLE
- 3D print a proper handlebar mount case — display window cutout for OLED, top-facing slot for GPS antenna, USB port opening on the side for power bank cable, and a GoPro-style handlebar clamp on the bottom.
A Note of Thanks
ESPHome
For making ESP32 firmware development approachable for hobbyists and professionals alike. YAML-based configuration, native MQTT discovery, GPS component, display drivers, I²C, RGB LED, and OTA update support made what would have been weeks of C++ development into a weekend project. The ESPHome community forums and documentation are world-class.
Home Assistant
For being the glue that holds every smart home and IoT project together. Auto-discovery via MQTT, the map card, dashboard builder, and mobile app mean your data is always one tap away. Thank you to every contributor who has spent countless hours making HA what it is.
Hardware Vendors & Open Source Community
Espressif for the ESP32 family — the C3 Mini is an incredibly capable chip at a price that makes experimentation accessible. u-blox for the NEO-6M module that has powered thousands of DIY GPS projects. The SSD1306 OLED manufacturers for a display that just works with 4 wires and no fuss. And every Stack Overflow answer, GitHub issue, and forum post that saved hours of debugging.