Community project

Sunlight Cycling Navigation Computer

ESP32
Photo of Sunlight Cycling Navigation Computer
Generated with AI

Swarup R. Paul

Published November 1, 2026

The Sunlight Cycling Navigation Computer is a GPS-enabled bike computer that displays real-time ride metrics including speed, distance, altitude, and heading on a bright 2.8-inch display. Built around an ESP32-S3 with integrated GPS and compass, barometric altitude sensor, and microSD logging, it provides navigation and performance tracking for outdoor cycling adventures.

This guide covers the complete assembly of the navigation computer, from battery and sensor integration to button wiring and enclosure fitting. Builders will receive a detailed wiring diagram, component checklist, step-by-step assembly instructions, and the full Arduino firmware with GPS parsing, sensor fusion, and data logging capabilities.

Wiring diagram

Wiring diagram for Sunlight Cycling Navigation Computer
Loading diagram…

Gather all the parts

QtyComponent
1

Momentary button UP

Normally open, closes input to ground; internal expander pull-up enabled.

1

Momentary button DOWN

Normally open, closes input to ground; internal expander pull-up enabled.

1

Protected flat Li-ion pouch 3.7V 1200mAh

4.2V maximum charge; MX1.25 2-pin plug, verify polarity and charge-current rating before purchase. Capacity and thickness provisional.

1

GEPRC GEP-M1025Q GPS with QMC5883L

GEPRC M10050 GNSS plus QMC5883L compass at I2C 0x0D; no barometer. Supply 3.3-5V; use 3.3V here. Default UART 115200 baud, 10Hz UBX. Size 29x29x8.5mm. Follow labelled pins, not cable colours. Source https://geprc.com/product/gep-m1025-series-gps-module/

1

Adafruit BMP390 STEMMA QT breakout

3.3V I2C breakout, default address 0x77. CS and SDO left disconnected.

1

Adafruit MCP23017 I2C GPIO expander

Provides four button inputs without consuming USB or UART pins. Address 0x20; breakout has address defaults.

1

Momentary button LEFT

Normally open, closes input to ground; internal expander pull-up enabled.

1

Momentary button RIGHT

Normally open, closes input to ground; internal expander pull-up enabled.

1

FAT32 microSD card 16GB

Inserts in onboard socket; no external reader. Stores route, road overlay and ride logs.

Assemble it in 5 steps

1. Check the V2 board and battery

PURCHASED / HAVE IT: Waveshare ESP32-S3-Touch-LCD-2.8 V2 display + ESP32-S3 board (the main board, not a separate parts-list item). Start phase-one testing on USB power with the purchased GPS module; battery, barometer, buttons and storage are later additions. Use Waveshare ESP32-S3-Touch-LCD-2.8 V2, not 2.8B or 2.8C. The screen, microSD socket, motion sensor and charger are already fitted. Select a protected flat 3.7V cell rated for 4.2V charging and the onboard charger's current. Verify MX1.25 plug polarity with a meter before connecting to the main battery socket, never the RTC socket.

  • Do not connect a 4.35V/4.4V phone cell, compress the pouch, or place screw tips against it. Charge dry and supervised.

2. Mount and connect the combined GPS and compass

PURCHASED / HAVE IT: GEPRC GEP-M1025Q GPS + QMC5883L module. Mount the antenna facing the sky beneath plastic at the top. Use GEPRC GEP-M1025Q (QMC5883L, no built-in barometer), powered at 3.3V within its specified 3.3-5V supply range. Follow pin labels, not wire colours. TX goes to GPIO44 (GPS data into computer), RX to GPIO43 (commands), SDA to GPIO11 (compass data), SCL to GPIO10 (clock), and ground to GND. Firmware uses factory 115200 baud, decodes checksum-validated UBX NAV-PVT and accepts NMEA fallback. It polls NAV-PVT if absent; a module reconfigured for a different baud must be restored to 115200.

  • Omit the optional speaker; avoid magnets, steel fasteners and power wiring near the compass. Mount with antenna upward and module arrow toward the bicycle front. Calibrate in final enclosure using POST /compass JSON action start, rotate the whole device level through several full circles, then action finish. Verify north/east/south/west against a trusted compass. POST action alignment with yawOffset and declination in degrees adjusts mounting and magnetic declination. Calibration persists; tilt compensation is not implemented.
  • Do not feed 5V into ESP32 signals. Check labels and connector order before power-on. Magnetic heading is provisional until calibrated and verified; leaning the bike can cause heading errors.

3. Add the barometer and button expander

Connect each breakout VIN to 3V3 and GND to GND. Both SDA lines go to GPIO11 and SCL/SCK lines to GPIO10. Leave BMP390 CS and SDO unconnected for its default 0x77 address. MCP23017 uses default 0x20. Keep wires short; these share the onboard I2C bus.

  • Give the barometer a protected pressure vent, not a sealed cavity. Default sea-level pressure is 1013.25hPa; absolute altitude needs local calibration.

4. Connect four physical buttons

Connect UP to expander A0, DOWN to A1, LEFT to A2 and RIGHT to A3. Each button's other contact goes to GND. The expander enables pull-ups. LEFT opens dashboard; RIGHT opens map; UP/DOWN change zoom. Mount these below the screen as drawn.

5. Fit storage and enclosure

Insert a FAT32 microSD card into the onboard socket. Keep the USB-C port accessible for charging and programming. Use a reinforced Garmin quarter-turn mount insert and a safety tether. Leave room around the antenna and battery; measure the full stack before printing the case.

  • The TFT is not a sunlight-optimised reflective display. Final thickness, waterproofing and runtime are not yet verified.

Review all connections

1. Connections between "gps_compass" and "ESP32"

Functiongps_compassESP32
powerVCC3V3
groundGNDGND
uartTXGPIO 44
uartRXGPIO 43
i2cSDAGPIO 11
i2cSCLGPIO 10

2. Connections between "barometer" and "ESP32"

FunctionbarometerESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 11
i2cSCKGPIO 10

3. Connections between "button_io" and "ESP32"

Functionbutton_ioESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 11
i2cSCLGPIO 10

4. Connections between "btn_up" and "ESP32"

Functionbtn_upESP32
digitalSIG → Adafruit MCP23017 I2C GPIO expander A0EXT
groundGNDGND

5. Connections between "btn_dn" and "ESP32"

Functionbtn_dnESP32
digitalSIG → Adafruit MCP23017 I2C GPIO expander A1EXT
groundGNDGND

6. Connections between "btn_lt" and "ESP32"

Functionbtn_ltESP32
digitalSIG → Adafruit MCP23017 I2C GPIO expander A2EXT
groundGNDGND

7. Connections between "btn_rt" and "ESP32"

Functionbtn_rtESP32
digitalSIG → Adafruit MCP23017 I2C GPIO expander A3EXT
groundGNDGND

8. Connections between "lipo_flat" and "ESP32"

Functionlipo_flatESP32
powerBAT+ → Onboard main BAT connector positive (not RTC, 3V3 or VBUS)EXT
groundBAT-GND

9. Connections between "storage" and "ESP32"

FunctionstorageESP32
dataCARD → Onboard microSD socketEXT

Deploy the firmware

firmware.cppOpen in Schematik
// UART RX44/TX43 and shared I2C SDA11/SCL10 verified against Waveshare connector table.
#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_ST7789.h>
#include <Adafruit_MCP23X17.h>
#include <Adafruit_BMP3XX.h>
#include <Adafruit_GFX.h>
#include <TinyGPSPlus.h>

#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <SD.h>
#include <math.h>
#include "geprc.h"

// Waveshare ESP32-S3-Touch-LCD-2.8 V2; touch intentionally not driven yet.
#define GPS_RX_PIN 44
#define GPS_TX_PIN 43
#define COMPASS_SDA 11
#define COMPASS_SCL 10
#define SD_SCK_PIN 14
#define SD_MOSI_PIN 17
#define SD_MISO_PIN 16
#define SD_CS_PIN 21
#define BTN_UP 0
#define BTN_DN 1
#define BTN_SEL 2
#define RMT_UP 0
#define RMT_DN 1
#define RMT_LT 2
#define RMT_RT 3
#define DISP_W 240
#define DISP_H 320
#define BLACK ST77XX_BLACK
#define WHITE ST77XX_WHITE
// ─── Data structures ─────────────────────────────────────────────────────────
struct RideData {
  float    speedKmh     = 0;
  float    distanceKm   = 0;
  float    altM         = 0;
  float    gradePercent = 0;
  float    lat          = 0, lon = 0;
  int      cadenceRpm   = 0;
  int      powerW       = 0;
  float    vo2est       = 0;
  int      heading      = 0;
  uint32_t elapsedSec   = 0;
  float    avgSpeedKmh  = 0;
  float    maxSpeedKmh  = 0;
  int      maxPowerW    = 0;
  bool     gpsFixed     = false;
};

struct Config {
  float gearRatio       = 50.0f / 17.0f;
  float wheelCircumM    = 2.096f;
  float riderMassKg     = 75.0f;
  float bikeMassKg      = 9.0f;
  float maxSpeedKmh     = 60.0f;
  float maxPowerW       = 400.0f;
  float maxCadRpm       = 120.0f;
  float vo2maxManual    = 0.0f;
  float targetDistanceKm= 0.0f;   // 0 = no target set
};

struct SensorFlags {
  bool  cadencePhysical = false;
  bool  speedPhysical   = false;
  int   physCadenceRpm  = 0;
  float physSpeedKmh    = 0;
};

struct NavState {
  float destLat         = 0, destLon = 0;
  bool  hasDestination  = false;
  float remainDistKm    = 0;
  int   bearingDeg      = 0;
  int   routeIndex      = 0;
};

struct Btn {
  uint8_t  pin;
  bool     last    = HIGH;
  uint32_t lastMs  = 0;
  bool     pressed = false;
};

struct RoutePoint { float lat, lon; };
struct TrackPoint  { float lat, lon, alt; uint32_t sec; };

enum Screen { SCR_DASH, SCR_MAP, SCR_COUNT };

// ─── Globals ─────────────────────────────────────────────────────────────────

// Forward declarations
static void drawArcOutline(int cx, int cy, int r, int startDeg, int endDeg);
static const char* cardinalStr(int hdg);

SPIClass lcdSPI(FSPI);
class CycleDisplay : public Adafruit_ST7789 {
public:
 CycleDisplay(): Adafruit_ST7789(&lcdSPI,42,41,39) {}
 void clearDisplay(){fillScreen(WHITE);}
 void refresh(){}
} display;
Adafruit_MCP23X17 buttons;
Adafruit_BMP3XX bmp;
bool buttonsOk=false, bmpOk=false;
uint32_t sensorMs=0;

TinyGPSPlus       gps;
GeprcGps geprcGps;
GeprcCompass compass;
HardwareSerial    gpsSerial(1);


WebServer         server(80);
Preferences       prefs;
SPIClass          sdSPI(HSPI);

RideData    ride;
Config      cfg;
SensorFlags sensors;
uint32_t externalSensorMs=0;
NavState    nav;

#define MAX_ROUTE 512
RoutePoint route[MAX_ROUTE];
int        routeLen = 0;

#define MAX_TRACK 1024
TrackPoint track[MAX_TRACK];
int        trackLen = 0;

Btn btnUp{BTN_UP}, btnDn{BTN_DN}, btnSel{BTN_SEL};
Btn rmtUp{RMT_UP}, rmtDn{RMT_DN}, rmtLt{RMT_LT}, rmtRt{RMT_RT};



bool    sdOk          = false;
Screen  currentScreen = SCR_DASH;
uint8_t mapZoom       = 2;    // 1–5

// Ride accumulators
static uint32_t rideStartMs  = 0;
static bool     rideStarted  = false;
static float    lastLat      = 0, lastLon = 0, lastAltM = 0;
static float    distAccum    = 0;

// ─── Forward declarations ────────────────────────────────────────────────────
static float haversineKm(float la1, float lo1, float la2, float lo2);
static int   bearingTo(float la1, float lo1, float la2, float lo2);
static int   calcCadence(float speedKmh);
static int   calcVirtualPower(float speedKmh, float gradePercent);

static void  updateBtn(Btn &b);

static void  drawArcFill(int cx, int cy, int r, int startDeg, int spanDeg, int thicc);
static void  drawProgressBar(int x, int y, int w, int h, float frac);
static void  drawDividerH(int y, int x0 = 0, int x1 = DISP_W - 1);
static void  drawDashScreen();
static void  drawMapScreen();
static void  drawSplash();
static void  handleRoot();
static void  handleStatus();
static void  handleNavSet();
static void  handleUploadRoute();
static void  handleConfig();
static void  handleSensor();
static void  handleExportGPX();

// ═══════════════════════════════════════════════════════════════════════════════
//  Maths
// ═══════════════════════════════════════════════════════════════════════════════
static float haversineKm(float la1, float lo1, float la2, float lo2) {
  const float R = 6371.0f;
  float dLat = radians(la2 - la1);
  float dLon = radians(lo2 - lo1);
  float a = sinf(dLat/2)*sinf(dLat/2)
           + cosf(radians(la1))*cosf(radians(la2))*sinf(dLon/2)*sinf(dLon/2);
  return R * 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a));
}

static int bearingTo(float la1, float lo1, float la2, float lo2) {
  float y = sinf(radians(lo2-lo1)) * cosf(radians(la2));
  float x = cosf(radians(la1))*sinf(radians(la2))
            - sinf(radians(la1))*cosf(radians(la2))*cosf(radians(lo2-lo1));
  return (int)(degrees(atan2f(y, x)) + 360) % 360;
}

static int calcCadence(float speedKmh) {
  if (speedKmh < 0.5f) return 0;
  return (int)((speedKmh / 3.6f / cfg.wheelCircumM) * 60.0f / cfg.gearRatio);
}

static int calcVirtualPower(float speedKmh, float gradePercent) {
  if (speedKmh < 0.5f) return 0;
  float v  = speedKmh / 3.6f;
  float m  = cfg.riderMassKg + cfg.bikeMassKg;
  float g  = 9.81f;
  float gr = gradePercent / 100.0f;
  return max(0, (int)(( m*g*gr + 0.004f*m*g + 0.5f*1.2f*0.35f*v*v ) * v));
}



// ═══════════════════════════════════════════════════════════════════════════════
//  Button debounce
// ═══════════════════════════════════════════════════════════════════════════════
static void updateBtn(Btn &b) {
  bool cur = buttonsOk ? buttons.digitalRead(b.pin) : HIGH;
  if (cur != b.last && (millis() - b.lastMs) > 40) {
    b.lastMs = millis(); b.last = cur;
    if (cur == LOW) b.pressed = true;
  }
}

// ═══════════════════════════════════════════════════════════════════════════════
//  VCOM ISR  — 2 Hz, prevents Sharp Memory LCD burn-in
// ═══════════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════════
//  Drawing primitives
// ═══════════════════════════════════════════════════════════════════════════════

// Arc: startDeg=0 at top, clockwise. spanDeg = how far to fill (0-360).
// thicc = stroke width in pixels.
static void drawArcFill(int cx, int cy, int r, int startDeg, int spanDeg, int thicc) {
  spanDeg = constrain(spanDeg, 0, 360);
  for (int t = startDeg; t <= startDeg + spanDeg; t++) {
    float rad = radians(t - 90);
    for (int d = 0; d < thicc; d++) {
      int x = cx + (int)((r - d) * cosf(rad));
      int y = cy + (int)((r - d) * sinf(rad));
      if (x >= 0 && x < DISP_W && y >= 0 && y < DISP_H)
        display.drawPixel(x, y, BLACK);
    }
  }
}

// Thin outline arc (background ring)
static void drawArcOutline(int cx, int cy, int r, int startDeg, int endDeg) {
  for (int t = startDeg; t <= endDeg; t++) {
    float rad = radians(t - 90);
    int x = cx + (int)(r * cosf(rad));
    int y = cy + (int)(r * sinf(rad));
    if (x >= 0 && x < DISP_W && y >= 0 && y < DISP_H)
      display.drawPixel(x, y, BLACK);
  }
}

// Rounded-corner progress bar
static void drawProgressBar(int x, int y, int w, int h, float frac) {
  frac = constrain(frac, 0.0f, 1.0f);
  display.drawRect(x, y, w, h, BLACK);
  int fill = (int)(frac * (w - 4));
  if (fill > 0) display.fillRect(x + 2, y + 2, fill, h - 4, BLACK);
}

static void drawDividerH(int y, int x0, int x1) {
  display.drawLine(x0, y, x1, y, BLACK);
}

// Cardinal direction from bearing
static const char* cardinalStr(int hdg) {
  static const char* c[] = {"N","NE","E","SE","S","SW","W","NW"};
  return c[((hdg + 22) % 360) / 45];
}

// ═══════════════════════════════════════════════════════════════════════════════
//  DASHBOARD SCREEN
// ═══════════════════════════════════════════════════════════════════════════════
/*
  Exact layout matching the hand-drawn sketch (400×240, portrait landscape):

  ┌─────────────────────────────────────────────────────────────────┐  y=0
  │ [✕]                                            [⊙ map icon]    │  y=4..20
  │─────────────────────────────────────────────────────────────────│  y=24
  │                                                                 │
  │        ╭────────────────╮                                       │
  │       ╱   SPEED HERO     ╲         Big dominant arc dial       │  y=28..145
  │      │     33.2          │         r=80, centred x=130,y=90    │
  │      │     km/h          │                                      │
  │       ╲                 ╱                                       │
  │        ╰────────────────╯                                       │
  │─────────────────────────────────────────────────────────────────│  y=148
  │  Distance        │  Runtime                                     │  y=152..178
  │  24.0 km         │  01:35:22                                    │
  │─────────────────────────────────────────────────────────────────│  y=180
  │  VO2MAX  [████████░░░] xx.x   │  Power  [██████░░░░] xxxW     │  y=184..203
  │─────────────────────────────────────────────────────────────────│  y=205
  │  TARGET DISTANCE  [███████████████░░░░░░░░░░] x.x / xx.x km   │  y=209..228
  └─────────────────────────────────────────────────────────────────┘  y=240
*/

static void drawDashScreen() {
 display.clearDisplay(); display.setTextColor(BLACK);
 display.setTextSize(1); display.setCursor(8,8); display.print("CYCLOCOMP | V2");
 display.setCursor(8,23); display.print(ride.gpsFixed?"GPS LIVE":"WAITING FOR GPS");
 drawArcOutline(120,105,66,225,495);
 drawArcFill(120,105,62,225,270*constrain(ride.speedKmh/cfg.maxSpeedKmh,0.0f,1.0f),5);
 display.setTextSize(3); display.setCursor(64,85); display.print(ride.speedKmh,1);
 display.setTextSize(1); display.setCursor(100,125); display.print("km/h");
 display.setCursor(8,174); display.printf("DIST %.2f km   %lu:%02lu",ride.distanceKm,ride.elapsedSec/3600,(ride.elapsedSec/60)%60);
 display.setCursor(8,194); display.printf("CAD %d %s",ride.cadenceRpm,sensors.cadencePhysical?"external":"est (fixed gear)");
 display.setCursor(8,213); display.printf("POWER %d W est",ride.powerW);
 drawProgressBar(8,226,224,8,ride.powerW/cfg.maxPowerW);
 display.setCursor(8,244); if(cfg.vo2maxManual>0)display.printf("VO2max %.1f imported/manual",cfg.vo2maxManual);else display.print("VO2max -- (not measured)");
 display.setCursor(8,261); display.printf("ALT %.1fm %s",ride.altM,bmpOk?"baro":"GPS");
 display.setCursor(8,277); if(ride.gpsFixed)display.printf("%.5f, %.5f",ride.lat,ride.lon);else display.print("Coordinates --");
 drawProgressBar(8,294,224,8,cfg.targetDistanceKm>0?ride.distanceKm/cfg.targetDistanceKm:0);
 display.setCursor(8,309); display.print("LEFT dash  RIGHT map  UP/DN zoom");
}

static const float zoomRadiusKm[] = {0, 4.0f, 1.5f, 0.5f, 0.2f, 0.08f};

static void drawMapScreen() {
 display.clearDisplay(); display.setTextColor(BLACK); display.setTextSize(1);
 display.setCursor(8,8);display.print("ROUTE / BREADCRUMBS   N ^");
 double lat=ride.gpsFixed?ride.lat:(routeLen?route[0].lat:0);
 double lon=ride.gpsFixed?ride.lon:(routeLen?route[0].lon:0);
 float scale=100/zoomRadiusKm[mapZoom];
 auto px=[&](float x){return constrain((int)(120+(x-lon)*111*cos(radians(lat))*scale),0,239);};
 auto py=[&](float y){return constrain((int)(132-(y-lat)*111*scale),28,235);};
 for(int i=1;i<routeLen;i++)display.drawLine(px(route[i-1].lon),py(route[i-1].lat),px(route[i].lon),py(route[i].lat),BLACK);
 for(int i=0;i<trackLen;i++)display.drawPixel(px(track[i].lon),py(track[i].lat),ST77XX_BLUE);
 if(ride.gpsFixed){float h=radians(ride.heading);display.fillTriangle(120+9*sin(h),132-9*cos(h),120-5*cos(h),132-5*sin(h),120+5*cos(h),132+5*sin(h),ST77XX_RED);}
 if(nav.hasDestination)display.drawCircle(px(nav.destLon),py(nav.destLat),5,ST77XX_RED);
 display.setCursor(8,244);display.printf("%.1f km/h  %d rpm  %dW est",ride.speedKmh,ride.cadenceRpm,ride.powerW);
 display.setCursor(8,263);if(nav.hasDestination)display.printf("Direct %.2f km  bearing %d",nav.remainDistKm,nav.bearingDeg);else display.print("Set destination in web page");
 display.setCursor(8,282);if(nav.hasDestination&&ride.speedKmh>3)display.printf("Direct ETA %.0f min",nav.remainDistKm/ride.speedKmh*60);else display.print("ETA --");
 display.setCursor(8,303);display.print("No road routing yet | Z");display.print(mapZoom);
}

static void drawSplash() {
 display.clearDisplay();display.setTextColor(BLACK);display.setTextSize(2);
 display.setCursor(12,50);display.print("CycloComp V2");
 display.setTextSize(1);display.setCursor(12,90);display.print("CycloComp / cycling1234");
 display.setCursor(12,110);display.print("http://192.168.4.1");
}

static const char page[] PROGMEM=R"HTML(<!doctype html><meta name="viewport" content="width=device-width"><title>CycloComp</title><style>body{font:16px system-ui;background:#101c28;color:white;max-width:600px;margin:30px auto;padding:16px}input,button,textarea{padding:12px;margin:5px;max-width:90%}pre{white-space:pre-wrap}</style><h1>CycloComp V2</h1><p>Prototype: coordinate guidance, not road routing. Estimates are not measured fitness data.</p><h2>Destination</h2><input id="coord" placeholder="latitude, longitude"><button onclick="nav()">Navigate</button><h2>Configuration JSON</h2><textarea id="conf">{"gearRatio":2.94,"wheelCircumM":2.096,"mass":75,"targetDistanceKm":50,"vo2maxManual":0}</textarea><button onclick="post('/config',JSON.parse(conf.value))">Save</button><h2>Route JSON</h2><input type="file" id="route"><button onclick="upload()">Load route</button><p>Array of {"lat":number,"lon":number}, max 512 points.</p><a href="/export">Download current GPX preview (max 1024 points)</a><p><a href="/log">Download full SD ride CSV</a></p><pre id="out"></pre><script>async function post(p,d){let r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});out.textContent=await r.text()}function nav(){let a=coord.value.split(',').map(Number);if(a.length===2&&a.every(Number.isFinite))post('/nav',{lat:a[0],lon:a[1]})}async function upload(){if(route.files[0])post('/upload',JSON.parse(await route.files[0].text()))}setInterval(async()=>{try{out.textContent=JSON.stringify(await(await fetch('/status')).json(),null,2)}catch(e){}},2000)</script>)HTML";
static void handleRoot(){server.send_P(200,"text/html",page);}

static void handleStatus() {
  StaticJsonDocument<512> doc;
  doc["speedKmh"]        = ride.speedKmh;
  doc["distKm"]          = ride.distanceKm;
  doc["cadence"]         = ride.cadenceRpm;
  doc["power"]           = ride.powerW;
  doc["vo2maxManual"] = cfg.vo2maxManual;
  doc["compassReady"]=compass.ready; doc["compassCalibrated"]=compass.calibrated; doc["compassCalibrating"]=compass.calibrating; doc["headingSource"]=compass.usable()?"magnetic-level-only":"GPS course"; doc["compassX"]=compass.raw[0]; doc["compassY"]=compass.raw[1]; doc["compassZ"]=compass.raw[2]; doc["touchReady"]=false; doc["blePairingReady"]=false; doc["barometerReady"]=bmpOk;
  doc["heading"]         = ride.heading;
  doc["lat"]             = ride.lat;
  doc["lon"]             = ride.lon;
  doc["altM"]            = ride.altM;
  doc["gradePercent"]    = ride.gradePercent;
  doc["elapsed"]         = ride.elapsedSec;
  doc["avgSpeedKmh"]     = ride.avgSpeedKmh;
  doc["maxSpeedKmh"]     = ride.maxSpeedKmh;
  doc["maxPowerW"]       = ride.maxPowerW;
  doc["gpsFixed"]        = ride.gpsFixed;
  doc["sdOk"]            = sdOk;
  doc["cadenceHW"]       = sensors.cadencePhysical;
  doc["speedHW"]         = sensors.speedPhysical;
  doc["targetDistKm"]    = cfg.targetDistanceKm;
  String out; serializeJson(doc, out);
  server.send(200, "application/json", out);
}

static void handleNavSet() {
  if (!server.hasArg("plain")) { server.send(400, "text/plain", "No body"); return; }
  StaticJsonDocument<64> doc;
  if (deserializeJson(doc, server.arg("plain"))) { server.send(400, "text/plain", "Bad JSON"); return; }
  if(!doc["lat"].is<double>()||!doc["lon"].is<double>()||fabs(doc["lat"].as<double>())>90||fabs(doc["lon"].as<double>())>180){server.send(400,"text/plain","Latitude -90..90, longitude -180..180 required");return;}
  nav.destLat        = doc["lat"] | 0.0f;
  nav.destLon        = doc["lon"] | 0.0f;
  nav.hasDestination = true;
  nav.remainDistKm   = haversineKm(ride.lat, ride.lon, nav.destLat, nav.destLon);
  nav.bearingDeg     = bearingTo(ride.lat, ride.lon, nav.destLat, nav.destLon);
  server.send(200, "text/plain", "Destination set");
}

static void handleUploadRoute() {
  if (!server.hasArg("plain")) { server.send(400, "text/plain", "No body"); return; }
  DynamicJsonDocument doc(16384);
  if (deserializeJson(doc, server.arg("plain"))) { server.send(400, "text/plain", "Bad JSON"); return; }
  if(!doc.is<JsonArray>()||doc.size()>MAX_ROUTE){server.send(400,"text/plain","Route must be an array with at most 512 points");return;}
  for(JsonObject pt:doc.as<JsonArray>())if(!pt["lat"].is<double>()||!pt["lon"].is<double>()||fabs(pt["lat"].as<double>())>90||fabs(pt["lon"].as<double>())>180){server.send(400,"text/plain","Invalid point");return;}
  routeLen = 0;
  for (JsonObject pt : doc.as<JsonArray>()) {
    if (routeLen >= MAX_ROUTE) break;
    route[routeLen++] = {pt["lat"].as<float>(), pt["lon"].as<float>()};
  }
  if (routeLen > 0) {
    nav.hasDestination = true;
    nav.destLat = route[routeLen-1].lat;
    nav.destLon = route[routeLen-1].lon;
  }
  nav.routeIndex = 0;
  if (sdOk) {
    File f = SD.open("/route.json", "w");
    if (f) { serializeJson(doc, f); f.close(); }
  }
  server.send(200, "text/plain", "Route: " + String(routeLen) + " pts");
}

static void handleConfig() {
  if (!server.hasArg("plain")) { server.send(400, "text/plain", "No body"); return; }
  StaticJsonDocument<256> doc;
  if (deserializeJson(doc, server.arg("plain"))) { server.send(400, "text/plain", "Bad JSON"); return; }
  if (doc.containsKey("gearRatio"))       cfg.gearRatio        = doc["gearRatio"];
  if (doc.containsKey("wheelCircumM"))    cfg.wheelCircumM     = doc["wheelCircumM"];
  if (doc.containsKey("mass"))            cfg.riderMassKg      = doc["mass"];
  if (doc.containsKey("maxSpeedKmh"))     cfg.maxSpeedKmh      = doc["maxSpeedKmh"];
  if (doc.containsKey("maxPowerW"))       cfg.maxPowerW        = doc["maxPowerW"];
  if (doc.containsKey("maxCadenceRpm"))   cfg.maxCadRpm        = doc["maxCadenceRpm"];
  if (doc.containsKey("vo2maxManual"))    cfg.vo2maxManual     = doc["vo2maxManual"];
  if (doc.containsKey("targetDistanceKm")) cfg.targetDistanceKm = doc["targetDistanceKm"];
  cfg.gearRatio=constrain(cfg.gearRatio,0.1f,10.0f);cfg.wheelCircumM=constrain(cfg.wheelCircumM,0.5f,3.0f);
  cfg.riderMassKg=constrain(cfg.riderMassKg,20.0f,250.0f);cfg.maxSpeedKmh=constrain(cfg.maxSpeedKmh,10.0f,150.0f);cfg.maxPowerW=constrain(cfg.maxPowerW,50.0f,2500.0f);
  prefs.begin("cyclo", false);
  prefs.putFloat("gearRatio",  cfg.gearRatio);
  prefs.putFloat("wheelCircum",cfg.wheelCircumM);
  prefs.putFloat("riderMass",  cfg.riderMassKg);
  prefs.putFloat("maxSpeed",   cfg.maxSpeedKmh);
  prefs.putFloat("maxPower",   cfg.maxPowerW);
  prefs.putFloat("maxCad",     cfg.maxCadRpm);
  prefs.putFloat("vo2max",     cfg.vo2maxManual);
  prefs.putFloat("targetDist", cfg.targetDistanceKm);
  prefs.end();
  server.send(200, "text/plain", "Config saved");
}

static void handleSensor() {
  if (!server.hasArg("plain")) { server.send(400, "text/plain", "No body"); return; }
  StaticJsonDocument<128> doc;
  if (deserializeJson(doc, server.arg("plain"))) { server.send(400, "text/plain", "Bad JSON"); return; }
  if (doc.containsKey("cadence"))    sensors.cadencePhysical = doc["cadence"].as<bool>();
  if (doc.containsKey("speed"))      sensors.speedPhysical   = doc["speed"].as<bool>();
  if (doc.containsKey("cadenceRpm")) sensors.physCadenceRpm  = doc["cadenceRpm"];
  if (doc.containsKey("speedKmh"))   sensors.physSpeedKmh    = doc["speedKmh"];
  externalSensorMs=millis();
  server.send(200, "text/plain", "External bridge readings updated (not BLE pairing)");
}

static void handleExportGPX() {
  String gpx = "<?xml version=\"1.0\"?>\n<gpx version=\"1.1\" creator=\"CycloComp\">\n<trk><trkseg>\n";
  for (int i = 0; i < trackLen; i++) {
    gpx += "<trkpt lat=\""; gpx += String(track[i].lat, 6);
    gpx += "\" lon=\"";     gpx += String(track[i].lon, 6);
    gpx += "\"><ele>";       gpx += String(track[i].alt, 1);
    gpx += "</ele></trkpt>\n";

  }
  gpx += "</trkseg></trk>\n</gpx>";
  server.send(200, "application/gpx+xml", gpx);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  setup()
// ═══════════════════════════════════════════════════════════════════════════════
void setup() {
  Serial.begin(115200);

  // SYS_EN active high maintains battery power, before peripheral startup.
  pinMode(7, OUTPUT); digitalWrite(7,HIGH);
  pinMode(6, INPUT);
  pinMode(5, OUTPUT);digitalWrite(5,LOW);
  lcdSPI.begin(40,-1,45,42);
  display.init(240,320); display.setRotation(0);display.invertDisplay(true);
  digitalWrite(5,HIGH);drawSplash();
  Wire.begin(COMPASS_SDA,COMPASS_SCL);
  Wire.setTimeOut(30);
  buttonsOk=buttons.begin_I2C(0x20,&Wire);
  if(buttonsOk)for(int i=0;i<4;i++)buttons.pinMode(i,INPUT_PULLUP);
  bmpOk=bmp.begin_I2C(0x77,&Wire);
  compass.begin();
  gpsSerial.setRxBufferSize(2048);
  gpsSerial.begin(115200,SERIAL_8N1,GPS_RX_PIN,GPS_TX_PIN);
  server.on("/compass", HTTP_POST, [](){
    JsonDocument d; if(deserializeJson(d,server.arg("plain"))){server.send(400,"text/plain","Invalid JSON");return;}
    String action=d["action"] | "";
    if(action=="start")compass.start();
    else if(action=="finish"){if(!compass.finish()){server.send(400,"text/plain","Insufficient rotation: rotate entire device level through full circles");return;}}
    else if(action=="alignment"){float y=d["yawOffset"] | 0.f, dec=d["declination"] | 0.f;if(!isfinite(y)||!isfinite(dec)||fabs(y)>360||fabs(dec)>90){server.send(400,"text/plain","Invalid angle");return;}compass.yawOffset=y;compass.declination=dec;compass.save();}
    else {server.send(400,"text/plain","Use action start, finish or alignment");return;}
    server.send(200,"text/plain","OK");
  });

  // SD card HSPI
  sdSPI.begin(SD_SCK_PIN, SD_MISO_PIN, SD_MOSI_PIN, SD_CS_PIN);
  sdOk = SD.begin(SD_CS_PIN, sdSPI);
  if (sdOk) {
    SD.mkdir("/maps");
    if (SD.exists("/route.json")) {
      File f = SD.open("/route.json", "r");
      if (f) {
        DynamicJsonDocument doc(16384);
        if (!deserializeJson(doc, f)) {
          routeLen = 0;
          for (JsonObject pt : doc.as<JsonArray>()) {
            if (routeLen >= MAX_ROUTE) break;
            route[routeLen++] = {pt["lat"].as<float>(), pt["lon"].as<float>()};
          }
          if (routeLen > 0) {
            nav.hasDestination = true;
            nav.destLat = route[routeLen-1].lat;
            nav.destLon = route[routeLen-1].lon;
          }
        }
        f.close();
      }
    }
  }

  // Load config from NVS
  prefs.begin("cyclo", true);
  cfg.gearRatio         = prefs.getFloat("gearRatio",  cfg.gearRatio);
  cfg.wheelCircumM      = prefs.getFloat("wheelCircum", cfg.wheelCircumM);
  cfg.riderMassKg       = prefs.getFloat("riderMass",  cfg.riderMassKg);
  cfg.maxSpeedKmh       = prefs.getFloat("maxSpeed",   cfg.maxSpeedKmh);
  cfg.maxPowerW         = prefs.getFloat("maxPower",   cfg.maxPowerW);
  cfg.maxCadRpm         = prefs.getFloat("maxCad",     cfg.maxCadRpm);
  cfg.vo2maxManual      = prefs.getFloat("vo2max",     cfg.vo2maxManual);
  cfg.targetDistanceKm  = prefs.getFloat("targetDist", cfg.targetDistanceKm);
  prefs.end();

  // WiFi AP + web server
  WiFi.softAP("CycloComp", "cycling1234");
  server.on("/",       HTTP_GET,  handleRoot);
  server.on("/status", HTTP_GET,  handleStatus);
  server.on("/nav",    HTTP_POST, handleNavSet);
  server.on("/upload", HTTP_POST, handleUploadRoute);
  server.on("/config", HTTP_POST, handleConfig);
  server.on("/sensor", HTTP_POST, handleSensor);
  server.on("/export", HTTP_GET,  handleExportGPX);
  server.on("/log",HTTP_GET,[](){
    File f=SD.open("/ride.csv","r"); if(!f){server.send(404,"text/plain","No SD log");return;}
    server.sendHeader("Content-Disposition","attachment; filename=ride.csv");server.streamFile(f,"text/csv");f.close();
  });
  server.begin();
}

// ═══════════════════════════════════════════════════════════════════════════════
//  loop()
// ═══════════════════════════════════════════════════════════════════════════════
static uint32_t lastDispMs  = 0;
static uint32_t lastTrackMs = 0;

void loop() {
  static uint32_t held=0;
  if(digitalRead(6)==LOW){if(!held)held=millis();if(millis()-held>3000){digitalWrite(5,LOW);digitalWrite(7,LOW);delay(100);}}else held=0;
  // GPS feed
  while (gpsSerial.available()) geprcGps.feed(gpsSerial.read(),gps);
  geprcGps.poll(gpsSerial);
  compass.update();

  if (gps.location.isValid() && gps.location.isUpdated()) {
    float nLat = gps.location.lat(), nLon = gps.location.lng();

    if (lastLat != 0.0f) {
      float delta = haversineKm(lastLat, lastLon, nLat, nLon);
      if (delta > 0.001f && delta<0.1f && gps.speed.kmph()>2 && ((geprcGps.lastPvt && millis()-geprcGps.lastPvt<3000)?geprcGps.pdop<3:(gps.hdop.isValid() && gps.hdop.hdop()<3))) {   // ignore sub-metre jitter
        distAccum += delta;
        ride.distanceKm = distAccum;
        // Grade: alt change / horizontal distance
        float altDelta = ride.altM - lastAltM;
        ride.gradePercent = constrain((altDelta / (delta * 1000.0f)) * 100.0f, -30.0f, 30.0f);
        lastAltM = ride.altM;
        // Advance route waypoint
        if (routeLen > 0 && nav.routeIndex < routeLen - 1) {
          float wd = haversineKm(nLat, nLon, route[nav.routeIndex+1].lat, route[nav.routeIndex+1].lon);
          if (wd < 0.025f) nav.routeIndex++;
        }
      }
    }
    lastLat = ride.lat = nLat;
    lastLon = ride.lon = nLon;
    ride.gpsFixed = gps.location.age()<3000;

    // Update nav
    if (nav.hasDestination) {
      nav.remainDistKm = haversineKm(nLat, nLon, nav.destLat, nav.destLon);
      nav.bearingDeg   = bearingTo(nLat, nLon, nav.destLat, nav.destLon);
    }

    // Track breadcrumb every 3s while moving
    if (millis() - lastTrackMs > 3000 && ride.speedKmh > 0.5f) {
      lastTrackMs = millis();
      if(sdOk){File f=SD.open("/ride.csv",FILE_APPEND);if(f){
        if(!f.size())f.println("utc,lat,lon,alt_m,speed_kmh,cadence_est_rpm,power_est_w");
        char utc[30]="";
        if(gps.date.isValid()&&gps.time.isValid())snprintf(utc,sizeof(utc),"%04d-%02d-%02dT%02d:%02d:%02dZ",gps.date.year(),gps.date.month(),gps.date.day(),gps.time.hour(),gps.time.minute(),gps.time.second());
        f.printf("%s,%.7f,%.7f,%.1f,%.2f,%d,%d\n",utc,nLat,nLon,ride.altM,ride.speedKmh,ride.cadenceRpm,ride.powerW);f.close();}}

      if (trackLen < MAX_TRACK)
        track[trackLen++] = {nLat, nLon, ride.altM, ride.elapsedSec};
    }
  } else if (!gps.location.isValid()) {
    ride.gpsFixed = false;
  }

  ride.gpsFixed=gps.location.isValid()&&gps.location.age()<3000;
  ride.speedKmh=ride.gpsFixed && gps.speed.isValid()?gps.speed.kmph():0;
  if(bmpOk && millis()-sensorMs>500){sensorMs=millis(); if(bmp.performReading())ride.altM=bmp.readAltitude(1013.25);}
  if(!bmpOk && gps.altitude.isValid())ride.altM=gps.altitude.meters();

  // Speed source
  if (sensors.speedPhysical && millis()-externalSensorMs<5000)
    ride.speedKmh = sensors.physSpeedKmh;

  // Cadence source
  ride.cadenceRpm = (sensors.cadencePhysical && millis()-externalSensorMs<5000)
                    ? sensors.physCadenceRpm
                    : calcCadence(ride.speedKmh);

  // Power + VO2
  ride.powerW = calcVirtualPower(ride.speedKmh, ride.gradePercent);
  ride.vo2est = cfg.vo2maxManual; // Never derive VO2max from instantaneous power.

  // Ride timer
  if (ride.speedKmh > 0.5f && !rideStarted) {
    rideStarted = true; rideStartMs = millis();
  }
  if (rideStarted) {
    ride.elapsedSec = (millis() - rideStartMs) / 1000;
    if (ride.elapsedSec > 0)
      ride.avgSpeedKmh = (distAccum / (float)ride.elapsedSec) * 3600.0f;
  }
  if (ride.speedKmh > ride.maxSpeedKmh) ride.maxSpeedKmh = ride.speedKmh;
  if (ride.powerW   > ride.maxPowerW)   ride.maxPowerW   = ride.powerW;

  // Compass
  // Calibrated compass heading is level-only; IMU tilt compensation remains pending.
  if(ride.gpsFixed && ride.speedKmh>3 && gps.course.isValid())ride.heading=gps.course.deg();
  if(compass.usable())ride.heading=lroundf(compass.heading)%360;

  // ── Buttons ───────────────────────────────────────────────────────────────

  updateBtn(rmtUp);  updateBtn(rmtDn);  updateBtn(rmtLt);  updateBtn(rmtRt);

  // SEL (body) → switch screen

  // Remote LEFT → dash, Remote RIGHT → map
  if (rmtLt.pressed) { rmtLt.pressed = false; currentScreen = SCR_DASH; }
  if (rmtRt.pressed) { rmtRt.pressed = false; currentScreen = SCR_MAP;  }

  // Remote UP/DN → zoom (map) or future use on dash
  if (rmtUp.pressed) { rmtUp.pressed = false; if (currentScreen == SCR_MAP) mapZoom = min((int)mapZoom+1, 5); }
  if (rmtDn.pressed) { rmtDn.pressed = false; if (currentScreen == SCR_MAP) mapZoom = max((int)mapZoom-1, 1); }

  // Body UP/DN → zoom on map too (mirrored for one-handed use)

  // Web server
  server.handleClient();

  // Redraw at 5 fps
  if (millis() - lastDispMs > 200) {
    lastDispMs = millis();
    String state=String(currentScreen)+":"+String(mapZoom)+":"+String(ride.speedKmh,1)+":"+String(ride.distanceKm,2)+":"+String(ride.elapsedSec/60)+":"+String(ride.lat,5)+":"+String(ride.lon,5)+":"+String(ride.altM,1)+":"+String(ride.cadenceRpm)+":"+String(ride.powerW)+":"+String(ride.gpsFixed)+":"+String(routeLen)+":"+String(trackLen)+":"+String(nav.destLat,5)+":"+String(nav.destLon,5)+":"+String(cfg.vo2maxManual)+":"+String(cfg.targetDistanceKm);
    static String previous; if(state==previous)return;previous=state;
    switch (currentScreen) {
      case SCR_DASH: drawDashScreen(); break;
      case SCR_MAP:  drawMapScreen();  break;
      default: break;
    }
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik