Community project
Sunlight Cycling Navigation Computer
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

Gather all the parts
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"
2. Connections between "barometer" and "ESP32"
3. Connections between "button_io" and "ESP32"
4. Connections between "btn_up" and "ESP32"
5. Connections between "btn_dn" and "ESP32"
6. Connections between "btn_lt" and "ESP32"
7. Connections between "btn_rt" and "ESP32"
8. Connections between "lipo_flat" and "ESP32"
9. Connections between "storage" and "ESP32"
Deploy the firmware
// 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.




