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

ComponentPurposeApprox. Cost
ESP32-C3 MiniMicrocontroller + WiFi₹250–350
NEO-6M GPS moduleGPS positioning (UART)₹300–450
SSD1306 OLED 0.96″ (128×64)4-page handlebar display₹150–180
ESP32-C3 expansion boardClean power + pin access₹150–200
Momentary push button 6×6mmManual page cycle (future)₹5–10
Mi Power Bank (USB out)Powers everythingAlready owned
Jumper wires + enclosureWiring 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).

PageNameContent
0Riding (default)Large speed readout · trip distance · max speed
1Trip StatsTrip distance · average speed · moving time
2TotalsOdometer · top speed · altitude
3GPS DebugSatellite 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.

By Abhi

Leave a Reply

Your email address will not be published. Required fields are marked *