Community project

Bike Speed And Temperature Monitor

JONAS BIGODE

Published July 14, 2026

ESP325 components7 assembly steps
Remix this project

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

Interactive · read-only
Wiring diagram for Bike Speed And Temperature Monitor

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
ST7789 TFT Display 2.0 inch12.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.
DS18B201Digital temperature sensor using OneWire protocol
Push Button (Momentary)1Momentary tactile push button. One side connects to GPIO with internal pull-up, other side to GND. Active-low: LOW when pressed, HIGH when released.
Módulo sensor Hall digital 3,3 V3,3 V1Mó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.
24v Buck Converter5.0 V output1LM2596-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.

Assembly

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.

    • Tip: Reserve GPIO 18 e GPIO 19 para USB nativa.
    • Tip: 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.

    • Tip: 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.

    • Tip: 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.

    • Tip: 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.

    • Tip: 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.

    • Tip: O LM2596 é redutor: a tensão de entrada deve ser maior que 5 V.
    • Tip: 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.

    • Tip: Todos os GNDs permanecem comuns pelo VOUT- do LM2596.
    • Tip: 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.

Firmware

ESP32
firmware.cppDeploy to device
#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);
  }
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

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