Community project

Bike Speed And Temperature Monitor

ESP32

JONAS BIGODE

Published July 14, 2026

This project transforms an ESP32 into a real-time bike computer that displays current speed and ambient temperature on a bright 2-inch TFT display. A Hall effect sensor mounted on the wheel fork detects each rotation, while a DS18B20 temperature sensor provides live readings. A momentary button allows riders to reset trip data on the fly.

The guide includes a complete wiring diagram showing how to connect the ST7789 display, Hall sensor, temperature probe, and push button to the ESP32-C3 SuperMini, along with a parts list and step-by-step assembly instructions. The included firmware handles speed calculation from wheel circumference and magnet count, renders an analog-style gauge with needle animation, and manages the power supply via a 24V-to-5V buck converter for reliable operation in the field.

Wiring diagram

Wiring diagram for Bike Speed And Temperature Monitor

Gather all the parts

QtyComponent
1

ST7789 TFT Display 2.0 inch

2.0-inch IPS TFT color display breakout driven by the ST7789 controller over 4-wire SPI. Native resolution is 320x240. Adafruit's breakout includes a 3.3V regulator, auto-reset circuit, 3V/5V level shifting, and a microSD holder sharing the SPI bus. Display drawing uses SCK, MOSI, CS, DC, and optional RST; MISO and SDCS are only needed for the onboard microSD card.

1

DS18B20

Digital temperature sensor using OneWire protocol

1

Push Button (Momentary)

Momentary tactile push button. One side connects to GPIO with internal pull-up, other side to GND. Active-low: LOW when pressed, HIGH when released.

1

Módulo sensor Hall digital 3,3 V

3,3 V

Módulo Hall digital alimentado em 3,3 V, conforme confirmado pelo usuário. Saída ativa em nível baixo e com pull-up no próprio módulo.

1

24v Buck Converter

5.0 V output

LM2596-based adjustable step-down buck converter module. Commonly used to regulate a higher battery rail, such as a 2S 18650 pack, down to 5V for Arduino logic. It is a regulator, not a charger or battery protection board.

Assemble it in 7 steps

1. Prepare o ESP32-C3 SuperMini

Coloque o ESP32-C3 SuperMini em uma protoboard e mantenha a fonte externa desligada durante toda a montagem.

  • Reserve GPIO 18 e GPIO 19 para USB nativa.
  • Não use GPIO 9, normalmente ligado ao BOOT.
  • Nunca aplique 5 V ao pino 3V3 ou aos GPIOs.

2. Conecte o display ST7789

Ligue VCC ao 3V3, GND ao GND, SCK ao GPIO 6, MOSI ao GPIO 7, CS ao GPIO 10, DC ao GPIO 4 e RST ao GPIO 5.

  • Use fios SPI curtos.
  • O display deve receber 3,3 V, não 5 V.

3. Conecte o DS18B20 sem resistor externo

Ligue VCC do DS18B20 ao 3V3, GND ao GND e DATA ao GPIO 1. Não instale resistor entre DATA e 3V3.

  • Para o sensor avulso com a face plana voltada para você, os pinos são GND, DATA e VCC.
  • Esta ligação só funciona de modo confiável se seu módulo DS18B20 já tiver pull-up integrado na linha DATA.

4. Conecte o módulo Hall de 3,3 V

Ligue VCC ao 3V3, GND ao GND e OUT ao GPIO 0. O firmware não usa pull-up interno nesse pino.

  • Mantenha o ímã a cerca de 5 mm ou menos do sensor.
  • O módulo Hall precisa ter pull-up próprio na saída OUT.

5. Conecte o botão Trip

Ligue um terminal do botão ao GPIO 3 e o outro ao GND.

  • Em botão tátil de quatro pernas, escolha terminais de lados opostos.

6. Ligue o LM2596 à fonte de entrada

Com tudo desligado, conecte a fonte DC que alimentará o conversor aos terminais VIN+ e VIN- do LM2596, respeitando a polaridade.

  • O LM2596 é redutor: a tensão de entrada deve ser maior que 5 V.
  • Use uma fonte cuja corrente disponível seja de pelo menos 1 A.
  • Não inverta VIN+ e VIN-. Não conecte a entrada do LM2596 diretamente à rede elétrica.

7. Ajuste e conecte a saída de 5 V

Antes de ligar ao ESP32, energize o LM2596 e ajuste o trimpot medindo VOUT+ e VOUT- com multímetro até obter exatamente 5,0 V. Desligue a fonte; então ligue VOUT+ ao pad 5V/VBUS do ESP32-C3 SuperMini e VOUT- ao GND. Depois religue a fonte.

  • Todos os GNDs permanecem comuns pelo VOUT- do LM2596.
  • O ESP32 fornece 3V3 regulados ao TFT, Hall e DS18B20.
  • Não conecte uma saída acima de 5,0 V ao pad 5V/VBUS.
  • Evite conectar simultaneamente USB-C e o 5 V externo, salvo se houver proteção contra retorno de corrente.

Review all connections

1. Connections between "tft" and "ESP32"

FunctiontftESP32
powerVCC3V3
groundGNDGND
spiSCKGPIO 6
spiMOSIGPIO 7
spiCSGPIO 10
digitalDCGPIO 4
digitalRSTGPIO 5

2. Connections between "hall" and "ESP32"

FunctionhallESP32
powerVCC3V3
groundGNDGND
digitalOUTGPIO 0

3. Connections between "ds18b20" and "ESP32"

Functionds18b20ESP32
powerVCC3V3
groundGNDGND
dataDATAGPIO 1

4. Connections between "btn_trip" and "ESP32"

Functionbtn_tripESP32
digitalPINGPIO 3
groundGNDGND

5. Connections between "lm2596_5v" and "ESP32"

Functionlm2596_5vESP32
powerVIN+Fonte DC de entrada (+)EXT
groundVIN-Fonte DC de entrada (-)EXT
powerVOUT+5V
groundVOUT-GND

Deploy the firmware

firmware.cppOpen in Schematik
#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <math.h>

// ── Pin definitions ───────────────────────────────────────────────────────────
#define HALL_PIN    0
#define BTN_TRIP    3
#define TFT_CS     10
#define TFT_DC      4
#define TFT_RST     5
#define TFT_MOSI    7
#define TFT_SCK     6
#define TEMP_PIN    1   // DS18B20 alimentado pelo trilho 3V3

// ── Speedometer config ────────────────────────────────────────────────────────
#define WHEEL_CIRCUMFERENCE_M  2.05f
#define MAGNETS_PER_REV        1
#define SPEED_TIMEOUT_MS       3000
#define MAX_SPEED_GAUGE        60.0f   // km/h at full arc

// ── Colour palette (RGB565) ───────────────────────────────────────────────────
#define C_BG        0x0841   // near-black blue-grey
#define C_PANEL     0x1082   // slightly lighter panel
#define C_CYAN      0x07FF
#define C_WHITE     0xFFFF
#define C_GREY      0x8410
#define C_DKGREY    0x2104
#define C_GREEN     0x07E0
#define C_ORANGE    0xFD20
#define C_RED       0xF800
#define C_BLUE      0x001F
#define C_YELLOW    0xFFE0
#define C_ACCENT    0x055F   // teal accent
#define C_NEEDLE    0xF81F   // magenta needle

// ── Gauge geometry ────────────────────────────────────────────────────────────
// Display is 320×240 (landscape).  Gauge centre sits at (160, 138).
#define GCX   160
#define GCY   138
#define GR    100   // outer arc radius
#define GR_IN  82   // inner arc radius (thick arc)
#define GR_TK  (GR - GR_IN)  // arc thickness ≈ 18 px

// Arc spans from 210° to 330° (clockwise, 0° = 3 o'clock)
#define ARC_START_DEG  210.0f
#define ARC_END_DEG    330.0f   // total 240°

// ── Display ───────────────────────────────────────────────────────────────────

// Forward declarations
float speedToAngle(float spd);
void drawArc(int cx, int cy, int r_out, int r_in, float a_start_deg, float a_end_deg, uint16_t colour, float step_deg);
uint16_t blendColor(uint16_t c1, uint16_t c2, float t);
uint16_t speedColor(float spd);

Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCK, TFT_RST);

// ── DS18B20 ───────────────────────────────────────────────────────────────────
OneWire           oneWire(TEMP_PIN);
DallasTemperature tempSensor(&oneWire);
float currentTempC = -127.0f;

// ── Speed state ───────────────────────────────────────────────────────────────
volatile unsigned long lastPulseTime  = 0;
volatile unsigned long pulsePeriodMs  = 0;
volatile bool          newPulse       = false;
volatile unsigned long tripPulses     = 0;

float  speedKmh      = 0.0f;
float  smoothSpeedKmh = 0.0f;   // interpolated for animation
float  maxSpeedKmh   = 0.0f;
float  totalDistKm   = 0.0f;

// ── Button ────────────────────────────────────────────────────────────────────
unsigned long btnPressTime = 0;
bool          btnWasHigh   = true;

// ── Forward declarations ──────────────────────────────────────────────────────
void IRAM_ATTR hallISR();
void drawStaticBackground();
void drawGaugeArc(float spd, float maxSpd);
void drawSpeedDigits(float spd);
void drawStats(float maxSpd, float distKm);
void drawTempBar(float tempC);
void resetTrip();
void showResetFlash();

// ═════════════════════════════════════════════════════════════════════════════
//  Helpers
// ═════════════════════════════════════════════════════════════════════════════

// Map a speed value to an angle in radians (0° = 3 o'clock, CW)
float speedToAngle(float spd) {
  float frac = constrain(spd / MAX_SPEED_GAUGE, 0.0f, 1.0f);
  float deg  = ARC_START_DEG + frac * (ARC_END_DEG - ARC_START_DEG);
  // Normalise to [0, 360), then convert to radian with display's CW convention
  // Adafruit angles: 0=right, 90=down (y-down screen)
  return deg * (float)M_PI / 180.0f;
}

// Draw a thick arc (approximated with filled triangles between successive steps)
// colour: RGB565.  step_deg: angular resolution in degrees.
void drawArc(int cx, int cy, int r_out, int r_in,
             float a_start_deg, float a_end_deg,
             uint16_t colour, float step_deg = 2.0f) {
  float a  = a_start_deg * M_PI / 180.0f;
  float a2 = a_end_deg   * M_PI / 180.0f;
  float step = step_deg  * M_PI / 180.0f;

  float cos_a = cosf(a), sin_a = sinf(a);
  int x0o = cx + (int)(r_out * cos_a);
  int y0o = cy + (int)(r_out * sin_a);
  int x0i = cx + (int)(r_in  * cos_a);
  int y0i = cy + (int)(r_in  * sin_a);

  for (float ang = a + step; ang <= a2 + 0.001f; ang += step) {
    float ca = cosf(ang), sa = sinf(ang);
    int x1o = cx + (int)(r_out * ca);
    int y1o = cy + (int)(r_out * sa);
    int x1i = cx + (int)(r_in  * ca);
    int y1i = cy + (int)(r_in  * sa);

    tft.fillTriangle(x0o, y0o, x0i, y0i, x1i, y1i, colour);
    tft.fillTriangle(x0o, y0o, x1o, y1o, x1i, y1i, colour);

    x0o = x1o; y0o = y1o;
    x0i = x1i; y0i = y1i;
  }
}

// Blend two RGB565 colours — t in [0,1]
uint16_t blendColor(uint16_t c1, uint16_t c2, float t) {
  uint8_t r1 = (c1 >> 11) & 0x1F, g1 = (c1 >> 5) & 0x3F, b1 = c1 & 0x1F;
  uint8_t r2 = (c2 >> 11) & 0x1F, g2 = (c2 >> 5) & 0x3F, b2 = c2 & 0x1F;
  uint8_t r  = r1 + (uint8_t)((r2 - r1) * t);
  uint8_t g  = g1 + (uint8_t)((g2 - g1) * t);
  uint8_t b  = b1 + (uint8_t)((b2 - b1) * t);
  return (r << 11) | (g << 5) | b;
}

// Speed → arc colour: green→yellow→orange→red
uint16_t speedColor(float spd) {
  float frac = constrain(spd / MAX_SPEED_GAUGE, 0.0f, 1.0f);
  if (frac < 0.5f) return blendColor(C_GREEN,  C_YELLOW, frac * 2.0f);
  else             return blendColor(C_YELLOW,  C_RED,   (frac - 0.5f) * 2.0f);
}

// ═════════════════════════════════════════════════════════════════════════════
//  Static background — drawn once (and after reset)
// ═════════════════════════════════════════════════════════════════════════════
void drawStaticBackground() {
  tft.fillScreen(C_BG);

  // ── Top bar ───────────────────────────────────────────────────────────────
  tft.fillRect(0, 0, 320, 26, C_PANEL);
  // Title
  tft.setTextColor(C_CYAN);
  tft.setTextSize(2);
  tft.setCursor(8, 5);
  tft.print("VELOCI");
  // Small "METRO" superscript-style
  tft.setTextSize(1);
  tft.setTextColor(C_GREY);
  tft.setCursor(74, 8);
  tft.print("METRO");

  // Thin accent line under header
  tft.drawFastHLine(0, 26, 320, C_ACCENT);

  // ── Bottom panel ──────────────────────────────────────────────────────────
  // Panel background
  tft.fillRect(0, 196, 320, 44, C_PANEL);
  tft.drawFastHLine(0, 196, 320, C_ACCENT);

  // Dividers
  tft.drawFastVLine(106, 196, 44, C_ACCENT);
  tft.drawFastVLine(213, 196, 44, C_ACCENT);

  // Labels
  tft.setTextSize(1);
  tft.setTextColor(C_GREY);
  tft.setCursor(18, 200);
  tft.print("MAX  km/h");
  tft.setCursor(118, 200);
  tft.print("DIST  km");
  tft.setCursor(222, 200);
  tft.print("TEMP  " "\xF7" "C");  // ÷ symbol as degree approximation

  // ── Gauge track (background arc) ─────────────────────────────────────────
  drawArc(GCX, GCY, GR, GR_IN, ARC_START_DEG, ARC_END_DEG, C_DKGREY, 2.0f);

  // ── Tick marks ───────────────────────────────────────────────────────────
  // 7 major ticks: 0,10,20,30,40,50,60 km/h
  for (int i = 0; i <= 6; i++) {
    float spd   = i * 10.0f;
    float frac  = spd / MAX_SPEED_GAUGE;
    float deg   = ARC_START_DEG + frac * (ARC_END_DEG - ARC_START_DEG);
    float rad   = deg * M_PI / 180.0f;
    float ca    = cosf(rad), sa = sinf(rad);
    int x0 = GCX + (int)((GR + 4)  * ca);
    int y0 = GCY + (int)((GR + 4)  * sa);
    int x1 = GCX + (int)((GR + 12) * ca);
    int y1 = GCY + (int)((GR + 12) * sa);
    tft.drawLine(x0, y0, x1, y1, C_GREY);

    // Tick label (skip 0 to avoid clutter near 210°)
    if (i > 0) {
      int lx = GCX + (int)((GR + 20) * ca) - 6;
      int ly = GCY + (int)((GR + 20) * sa) - 4;
      tft.setTextSize(1);
      tft.setTextColor(C_GREY);
      tft.setCursor(lx, ly);
      tft.print(i * 10);
    }
  }

  // Minor ticks (every 5 km/h)
  for (int i = 0; i <= 12; i++) {
    if (i % 2 == 0) continue; // skip major tick positions
    float spd  = i * 5.0f;
    float frac = spd / MAX_SPEED_GAUGE;
    float deg  = ARC_START_DEG + frac * (ARC_END_DEG - ARC_START_DEG);
    float rad  = deg * M_PI / 180.0f;
    float ca = cosf(rad), sa = sinf(rad);
    int x0 = GCX + (int)((GR + 4) * ca);
    int y0 = GCY + (int)((GR + 4) * sa);
    int x1 = GCX + (int)((GR + 8) * ca);
    int y1 = GCY + (int)((GR + 8) * sa);
    tft.drawLine(x0, y0, x1, y1, C_DKGREY);
  }

  // ── "km/h" label inside gauge ─────────────────────────────────────────────
  tft.setTextSize(1);
  tft.setTextColor(C_GREY);
  tft.setCursor(GCX - 12, GCY + 54);
  tft.print("km/h");
}

// ═════════════════════════════════════════════════════════════════════════════
//  Gauge arc — redrawn on every 200 ms tick
// ═════════════════════════════════════════════════════════════════════════════
void drawGaugeArc(float spd, float maxSpd) {
  // 1. Erase the gauge area (preserve background tracks drawn as static)
  //    Re-draw the grey track first, then overlay the coloured arc.
  drawArc(GCX, GCY, GR, GR_IN, ARC_START_DEG, ARC_END_DEG, C_DKGREY, 2.0f);

  // 2. Coloured speed arc
  if (spd > 0.5f) {
    float fracSpd = constrain(spd / MAX_SPEED_GAUGE, 0.0f, 1.0f);
    float endDeg  = ARC_START_DEG + fracSpd * (ARC_END_DEG - ARC_START_DEG);
    uint16_t col  = speedColor(spd);
    drawArc(GCX, GCY, GR, GR_IN, ARC_START_DEG, endDeg, col, 2.0f);
  }

  // 3. Max-speed marker (small bright dot on the outer rim)
  if (maxSpd > 0.5f) {
    float fracMax = constrain(maxSpd / MAX_SPEED_GAUGE, 0.0f, 1.0f);
    float degMax  = ARC_START_DEG + fracMax * (ARC_END_DEG - ARC_START_DEG);
    float rad     = degMax * M_PI / 180.0f;
    int mx = GCX + (int)(GR * cosf(rad));
    int my = GCY + (int)(GR * sinf(rad));
    tft.fillCircle(mx, my, 4, C_ORANGE);
    tft.drawCircle(mx, my, 5, C_WHITE);
  }

  // The centre remains clear for the speed digits.
}

// ═════════════════════════════════════════════════════════════════════════════
//  Speed digits
// ═════════════════════════════════════════════════════════════════════════════
void drawSpeedDigits(float spd) {
  // Clear the digit area (inside the gauge)
  tft.fillRect(GCX - 72, GCY - 36, 144, 52, C_BG);

  char buf[7];
  if (spd < 10.0f)
    snprintf(buf, sizeof(buf), " %.1f", spd);
  else if (spd < 100.0f)
    snprintf(buf, sizeof(buf), "%.1f", spd);
  else
    snprintf(buf, sizeof(buf), "%.0f", spd);

  tft.setTextSize(5);
  // Colour matches the arc
  uint16_t col = (spd < 1.0f) ? C_GREY : speedColor(spd);
  tft.setTextColor(col);

  int16_t x1, y1;
  uint16_t w, h;
  tft.getTextBounds(buf, 0, 0, &x1, &y1, &w, &h);
  tft.setCursor(GCX - w / 2, GCY - h / 2 - 4);
  tft.print(buf);
}

// ═════════════════════════════════════════════════════════════════════════════
//  Bottom stats panel
// ═════════════════════════════════════════════════════════════════════════════
void drawStats(float maxSpd, float distKm) {
  char buf[10];

  // MAX speed
  tft.fillRect(1, 212, 104, 26, C_PANEL);
  tft.setTextSize(2);
  tft.setTextColor(C_ORANGE);
  snprintf(buf, sizeof(buf), "%5.1f", maxSpd);
  tft.setCursor(8, 214);
  tft.print(buf);

  // Distance
  tft.fillRect(108, 212, 104, 26, C_PANEL);
  tft.setTextColor(C_GREEN);
  snprintf(buf, sizeof(buf), "%6.2f", distKm);
  tft.setCursor(112, 214);
  tft.print(buf);
}

// ═════════════════════════════════════════════════════════════════════════════
//  Temperature — coloured value + small bar
// ═════════════════════════════════════════════════════════════════════════════
void drawTempBar(float tempC) {
  tft.fillRect(215, 212, 104, 26, C_PANEL);

  if (tempC <= -126.0f) {
    tft.setTextSize(2);
    tft.setTextColor(C_GREY);
    tft.setCursor(222, 214);
    tft.print(" ---");
    return;
  }

  // Colour ramp: blue < 10 → cyan < 20 → green < 30 → orange < 40 → red
  uint16_t col;
  if      (tempC < 10.0f) col = C_BLUE;
  else if (tempC < 20.0f) col = C_CYAN;
  else if (tempC < 30.0f) col = C_GREEN;
  else if (tempC < 40.0f) col = C_ORANGE;
  else                    col = C_RED;

  // Numeric value
  char tb[8];
  snprintf(tb, sizeof(tb), "%5.1f", tempC);
  tft.setTextSize(2);
  tft.setTextColor(col);
  tft.setCursor(218, 214);
  tft.print(tb);

  // Mini horizontal bar (bottom of cell)
  float frac = constrain((tempC - (-10.0f)) / 60.0f, 0.0f, 1.0f);
  int barW   = (int)(96 * frac);
  tft.drawFastHLine(216, 236, 96,  C_DKGREY);
  if (barW > 0)
    tft.drawFastHLine(216, 236, barW, col);
}

// ═════════════════════════════════════════════════════════════════════════════
//  Trip reset
// ═════════════════════════════════════════════════════════════════════════════
void showResetFlash() {
  // Overlay banner over the gauge area
  tft.fillRoundRect(50, 88, 220, 56, 10, C_GREEN);
  tft.drawRoundRect(50, 88, 220, 56, 10, C_WHITE);
  tft.setTextSize(2);
  tft.setTextColor(C_BG);
  tft.setCursor(74, 102);
  tft.print("TRIP RESET!");
  tft.setTextSize(1);
  tft.setTextColor(C_BG);
  tft.setCursor(88, 124);
  tft.print("segure p/ confirmar");
  delay(700);
}

void resetTrip() {
  noInterrupts();
  tripPulses = 0;
  interrupts();
  maxSpeedKmh  = 0.0f;
  totalDistKm  = 0.0f;
  smoothSpeedKmh = 0.0f;
  Serial.println("[TRIP] Resetado!");
  showResetFlash();
  drawStaticBackground();
  drawGaugeArc(0.0f, 0.0f);
  drawSpeedDigits(0.0f);
  drawStats(0.0f, 0.0f);
  drawTempBar(currentTempC);
}

// ═════════════════════════════════════════════════════════════════════════════
//  ISR
// ═════════════════════════════════════════════════════════════════════════════
void IRAM_ATTR hallISR() {
  unsigned long now    = millis();
  unsigned long period = now - lastPulseTime;
  if (period < 20) return;   // debounce
  pulsePeriodMs = period;
  lastPulseTime = now;
  newPulse      = true;
  tripPulses++;
}

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

  // Display init — 320×240 landscape
  tft.init(240, 320);
  tft.setRotation(1);

  drawStaticBackground();
  drawGaugeArc(0.0f, 0.0f);
  drawSpeedDigits(0.0f);
  drawStats(0.0f, 0.0f);
  drawTempBar(-127.0f);

  // DS18B20 — non-blocking mode
  tempSensor.begin();
  tempSensor.setResolution(11);
  tempSensor.setWaitForConversion(false);
  tempSensor.requestTemperatures();

  // Hall sensor module powered at 3.3 V — active-low, no internal pull-up.
  // Its OUT signal is already at ESP32-safe 3.3 V.
  pinMode(HALL_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(HALL_PIN), hallISR, FALLING);

  // Trip reset button — pull-up
  pinMode(BTN_TRIP, INPUT_PULLUP);

  lastPulseTime = millis();
  Serial.println("[VELOCI] Iniciado!");
}

// ═════════════════════════════════════════════════════════════════════════════
//  Loop
// ═════════════════════════════════════════════════════════════════════════════
void loop() {
  static float         lastGaugeSpd   = -1.0f;
  static float         lastGaugeMax   = -1.0f;
  static float         lastDigitSpd   = -1.0f;
  static float         lastMaxSpd     = -1.0f;
  static float         lastDist       = -1.0f;
  static float         lastTemp       = -999.0f;
  static unsigned long lastUpdate     = 0;
  static unsigned long lastTempReq    = 0;

  unsigned long now = millis();

  // ── Button debounce & trip reset ─────────────────────────────────────────
  bool btnNow = digitalRead(BTN_TRIP);
  if (btnWasHigh && !btnNow)  btnPressTime = now;
  if (!btnWasHigh && btnNow && (now - btnPressTime) >= 50) resetTrip();
  btnWasHigh = btnNow;

  // ── Speed timeout ─────────────────────────────────────────────────────────
  if ((now - lastPulseTime) > SPEED_TIMEOUT_MS) speedKmh = 0.0f;

  // ── Compute speed from new pulse ──────────────────────────────────────────
  if (newPulse) {
    noInterrupts();
    unsigned long period = pulsePeriodMs;
    newPulse = false;
    interrupts();
    if (period > 0) {
      float periodSec = period / 1000.0f;
      float speedMs   = (WHEEL_CIRCUMFERENCE_M / MAGNETS_PER_REV) / periodSec;
      speedKmh        = speedMs * 3.6f;
    }
  }

  // ── Distance ─────────────────────────────────────────────────────────────
  noInterrupts();
  unsigned long pulses = tripPulses;
  interrupts();
  totalDistKm = (pulses * WHEEL_CIRCUMFERENCE_M) /
                (1000.0f * MAGNETS_PER_REV);

  // ── Max speed ─────────────────────────────────────────────────────────────
  if (speedKmh > maxSpeedKmh) maxSpeedKmh = speedKmh;

  // ── Smooth speed animation (exponential filter) ───────────────────────────
  // α = 0.35 → snappy but not jittery
  smoothSpeedKmh = smoothSpeedKmh * 0.65f + speedKmh * 0.35f;
  if (smoothSpeedKmh < 0.3f) smoothSpeedKmh = 0.0f; // snap to zero at low vals

  // ── Display update at 200 ms ─────────────────────────────────────────────
  if (now - lastUpdate >= 200) {
    lastUpdate = now;

    // Gauge arc — redraw if speed or max changed noticeably
    bool gaugeChanged =
      fabsf(smoothSpeedKmh - lastGaugeSpd) >= 0.3f ||
      fabsf(maxSpeedKmh    - lastGaugeMax)  >= 0.2f;

    if (gaugeChanged) {
      drawGaugeArc(smoothSpeedKmh, maxSpeedKmh);
      lastGaugeSpd = smoothSpeedKmh;
      lastGaugeMax = maxSpeedKmh;
    }

    // Speed digits
    if (fabsf(smoothSpeedKmh - lastDigitSpd) >= 0.1f) {
      drawSpeedDigits(smoothSpeedKmh);
      lastDigitSpd = smoothSpeedKmh;
    }

    // Stats bar
    bool statsChanged =
      fabsf(maxSpeedKmh - lastMaxSpd) >= 0.1f ||
      fabsf(totalDistKm - lastDist)   >= 0.01f;
    if (statsChanged) {
      drawStats(maxSpeedKmh, totalDistKm);
      lastMaxSpd = maxSpeedKmh;
      lastDist   = totalDistKm;
    }

    // Temperature every 2 s
    if (now - lastTempReq >= 2000) {
      float t = tempSensor.getTempCByIndex(0);
      if (t > -126.0f) currentTempC = t;
      tempSensor.requestTemperatures();
      lastTempReq = now;
      if (fabsf(currentTempC - lastTemp) >= 0.2f) {
        drawTempBar(currentTempC);
        lastTemp = currentTempC;
      }
    }

    // Serial log
    Serial.printf("[VEL] %.1f km/h (smooth %.1f) | MAX %.1f | DIST %.2f km | TEMP %.1f C\n",
                  speedKmh, smoothSpeedKmh, maxSpeedKmh, totalDistKm, currentTempC);
  }
}

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