dont use uart, use canbus with your esp32 (s3) i assume. check to see if your chip supports canbus. the s3 can. so you get a Adafruit CAN Pal - CAN Bus Transceiver - TJA1051T/3
and a small round tft screenβ¦
use arduino ide.
// ============================================================================
// ESC DASH β Neon CAN dashboard for FLIPSKY FT-series ESCs
// Arduino Nano ESP32 (ESP32-S3) + GC9A01 240x240 round display
// ----------------------------------------------------------------------------
// Reads speed / voltage / current / temps / duty from a Flipsky FT-firmware
// ESC (FT85BS etc.) over CAN, and shows it on a round display as 5 neon pages:
// Speed | Battery | Power | ESC | Trip
// Cycle pages with a button on D6 (short press). Hold the button ~1s on the
// Trip page to reset the trip.
//
// >>> EVERYTHING YOU NEED TO ADJUST FOR YOUR BUILD IS IN THE βUSER CONFIGβ
// >>> BLOCK BELOW. You normally donβt need to touch anything past it.
//
// ---- WHAT YOU NEED ----------------------------------------------------------
// * Board: Arduino Nano ESP32 (ESP32-S3). Install βArduino ESP32 Boardsβ.
// * Library: βLovyanGFXβ (Library Manager). Nothing else β TWAI/CAN is built in.
// * CAN transceiver: a 3.3V-capable CAN transceiver (e.g. TJA1051T/3 βCAN Palβ).
// * build_opt.h: this sketch SHIPS WITH a build_opt.h next to it containing
// -DBOARD_USES_HW_GPIO_NUMBERS
// Keep that file in the sketch folder β it lets LovyanGFX compile on the
// Nano ESP32 (and makes all pins below raw GPIO numbers). Without it the
// build fails with a pinMode macro error.
//
// ---- WIRING (Nano ESP32 header pin β raw GPIO) ----------------------------
// Display: CS=D10(21) DC=D9(18) RST=D8(17) MOSI=D11(38) SCK=D13(48)
// VCC->3V3 GND->GND BLK->3V3 (MISO not used)
// CAN Pal: TX->D4(7) RX->D5(8) Vcc->3V3 GND->GND S/SLNT->GND
// CANH/CANL β ESC CAN-H / CAN-L (+ shared GND). Terminator ON if
// this node is at the end of the bus.
// Button: D6(9) β GND (uses internal pull-up)
// Power: USB while testing; on the vehicle feed 5V into VBUS (e.g. from
// the ESCβs BEC) with a common ground.
//
// ---- ABOUT THE CAN DECODE ---------------------------------------------------
// This is the reverse-engineered Flipsky FT (non-VESC) CAN format. The ID is
// (esc_id << 8) | cmd, so it works regardless of your ESC ID. Voltage/temps are
// confirmed; SPEED and CURRENT need a quick per-build calibration (see config).
// In ESCTool set CAN Bus Baud Rate to match CAN_BAUD below, and CAN Status
// Frequency to ~20-50 Hz.
// ============================================================================
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// βΌβΌβΌ U S E R C O N F I G β E D I T F O R Y O U R B U I L D βΌβΌβΌ
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// ---- 1. DEMO MODE ----------------------------------------------------------
// 1 = show fake moving data (no ESC/CAN needed) so you can test the display.
// 0 = read real telemetry from the ESC over CAN.
#define DEMO_MODE 1
// ---- 2. UNITS --------------------------------------------------------------
#define USE_MPH 0 // 0 = km/h, 1 = mph
// ---- 3. DRIVETRAIN (makes the speed reading correct) ----------------------
// Speed is derived from the ESCβs electrical RPM:
// wheel_rpm = erpm / MOTOR_POLE_PAIRS / GEAR_RATIO
// - MOTOR_POLE_PAIRS = motor poles / 2 (your ESC tool shows it after motor
// detection; common hub motors are ~15-23, geared inrunners ~2-7).
// - GEAR_RATIO = motor-sprocket : wheel-sprocket reduction.
// Chain/belt e.g. 15T motor β 54T wheel = 54.0/15.0. DIRECT/HUB drive = 1.0
// - WHEEL_DIAM_MM = tyre OUTER diameter in mm.
// Tip: if speed is off by a constant %, just trim WHEEL_DIAM_MM against GPS.
static const int MOTOR_POLE_PAIRS = 3;
static const float GEAR_RATIO = 54.0f / 15.0f;
static const float WHEEL_DIAM_MM = 255.0f;
static const float SPEED_FULL_SCALE = 60.0f; // value (in your units) at full ring
// ---- 4. BATTERY (state-of-charge gauge, estimated from voltage) -----------
// Set the packβs voltage at 100% (full) and 0% (your cutoff). Examples:
// Li-ion full = cells * 4.2 , empty = cells * 3.2..3.4
// LiPo full = cells * 4.2 , empty = cells * 3.2..3.5
static const int BATT_CELLS_S = 18; // series cell count (display only)
static const float VOLT_FULL = 74.3f; // pack volts at 100%
static const float VOLT_EMPTY = 57.6f; // pack volts at 0% (cutoff)
// ---- 5. CAN BUS ------------------------------------------------------------
// Must match your ESCβs βCAN Bus Baud Rateβ. Pick ONE:
#define CAN_BAUD_1M // 1 Mbit (FT default)
//#define CAN_BAUD_500K
//#define CAN_BAUD_250K
#define CAN_TX_PIN 7 // D4 (any free GPIO; not the display pins)
#define CAN_RX_PIN 8 // D5
// ---- 6. CURRENT / POWER SCALING (calibrate on a loaded ride) --------------
// AMPS_PER_COUNT converts the raw current field to amps. 0.27 is a starting
// point; on a loaded pull, compare the dashβs current to your ESC tool and set
// AMPS_PER_COUNT = 0.27 * (real_amps / dash_amps).
static const float AMPS_PER_COUNT = 0.27f;
static const float CURRENT_RING_FS = 150.0f; // motor amps at full current ring
static const float POWER_RING_FS = 5000.0f; // watts at full power ring
// ---- 7. DISPLAY PINS (only change if you wired the GC9A01 differently) ----
// Values are RAW ESP32 GPIO numbers (Nano ESP32 header label in the comment).
#define PIN_TFT_SCLK 48 // D13
#define PIN_TFT_MOSI 38 // D11
#define PIN_TFT_MISO 47 // D12 (unused by GC9A01)
#define PIN_TFT_DC 18 // D9
#define PIN_TFT_CS 21 // D10
#define PIN_TFT_RST 17 // D8
#define PIN_BUTTON 9 // D6 (to GND)
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// β²β²β² END OF USER CONFIG β²β²β²
// (you shouldnβt need to edit below here)
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include βdriver/twai.hβ
#if defined(CAN_BAUD_1M)
#define CAN_TIMING TWAI_TIMING_CONFIG_1MBITS()
#elif defined(CAN_BAUD_500K)
#define CAN_TIMING TWAI_TIMING_CONFIG_500KBITS()
#elif defined(CAN_BAUD_250K)
#define CAN_TIMING TWAI_TIMING_CONFIG_250KBITS()
#endif
#if USE_MPH
#define SPD_CONV 0.621371f
#define SPD_LABEL βMPHβ
#else
#define SPD_CONV 1.0f
#define SPD_LABEL βKM/Hβ
#endif
// ---------------- display driver (GC9A01 over hardware SPI) ------------------
class LGFX : public lgfx::LGFX_Device {
lgfx::Panel_GC9A01 _panel;
lgfx::Bus_SPI _bus;
public:
LGFX() {
{ auto c = _bus.config();
c.spi_host = SPI2_HOST; c.spi_mode = 0;
c.freq_write = 40000000; c.freq_read = 16000000;
c.spi_3wire = false; c.use_lock = true; c.dma_channel = SPI_DMA_CH_AUTO;
c.pin_sclk = PIN_TFT_SCLK; c.pin_mosi = PIN_TFT_MOSI;
c.pin_miso = PIN_TFT_MISO; c.pin_dc = PIN_TFT_DC;
_bus.config(c); _panel.setBus(&_bus); }
{ auto p = _panel.config();
p.pin_cs = PIN_TFT_CS; p.pin_rst = PIN_TFT_RST; p.pin_busy = -1;
p.panel_width = 240; p.panel_height = 240;
p.offset_x = 0; p.offset_y = 0;
p.invert = true; // GC9A01: usually true. Flip if colors look wrong.
p.rgb_order = false; p.readable = false;
_panel.config(p); }
setPanel(&_panel);
}
};
static LGFX lcd;
static LGFX_Sprite fb(&lcd);
static inline uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) { return lcd.color565(r, g, b); }
#define CX 120
#define CY 120
#define R 120
static const float START_ANGLE = 135.0f; // gauge gap at the bottom; nudge if not
static const float SWEEP_ANGLE = 270.0f;
// ---------------- CAN telemetry model (Flipsky FT format) -------------------
// ID = (esc_id<<8) | cmd β cmd = id & 0xFF
// 0x0D: b0-1/100=tempA, b2-3/100=tempB, b4-5/100=VOLTAGE
// 0x0C: i32 b0-3 / 256 = erpm (-> speed), b4-5/10 = duty%
// 0x0B: i16 b0-1 = motor current (x AMPS_PER_COUNT)
#define FT_CMD_CURRENT 0x0B
#define FT_CMD_SPEED 0x0C
#define FT_CMD_SLOW 0x0D
#define CAN_STALE_MS 800UL
struct EscData {
float speedKmh, voltage, current, currentIn, dutyPct;
long erpm; float tempFet, tempMotor; uint8_t fault; bool connected;
};
static EscData esc = {0,0,0,0,0,0,0,0,0,false};
static unsigned long lastFrameMs = 0;
static float tripWh = 0, tripKm = 0;
#define PAGE_BUTTON PIN_BUTTON
static int page = 0;
static const int NUM_PAGES = 5;
// ---------------- neon palette ----------------------------------------------
#define CY_R 0
#define CY_G 229
#define CY_B 255
#define MG_R 255
#define MG_G 43
#define MG_B 214
#define GR_R 0
#define GR_G 255
#define GR_B 127
#define AM_R 255
#define AM_G 176
#define AM_B 0
static void neonArc(int cx, int cy, int rOut, int thick, float frac,
uint8_t r, uint8_t g, uint8_t b) {
if (frac < 0) frac = 0; if (frac > 1) frac = 1;
float a0 = START_ANGLE, a1 = START_ANGLE + SWEEP_ANGLE;
float aF = START_ANGLE + SWEEP_ANGLE * frac;
fb.fillArc(cx, cy, rOut - thick, rOut, a0, a1, rgb(14, 26, 34));
if (frac > 0.002f) {
fb.fillArc(cx, cy, rOut - thick - 3, rOut + 3, a0, aF, rgb(r/7, g/7, b/7));
fb.fillArc(cx, cy, rOut - thick, rOut, a0, aF, rgb(r/2, g/2, b/2));
int mid = rOut - thick / 2;
fb.fillArc(cx, cy, mid - 2, mid + 2, a0, aF, rgb(r, g, b));
}
}
static float estSOC(float v) {
float f = (v - VOLT_EMPTY) / (VOLT_FULL - VOLT_EMPTY);
if (f < 0) f = 0; if (f > 1) f = 1; return f * 100.0f;
}
static void drawBezel() {
fb.drawCircle(CX, CY, 119, rgb(40, 52, 64));
fb.drawCircle(CX, CY, 117, rgb(8, 12, 16));
}
// ---------------- pages ------------------------------------------------------
static void pageSpeed() {
float spd = esc.speedKmh * SPD_CONV;
float absA = fabs(esc.current);
float spdFrac = spd / SPEED_FULL_SCALE;
float ampFrac = absA / CURRENT_RING_FS;
float vFrac = (esc.voltage - VOLT_EMPTY) / (VOLT_FULL - VOLT_EMPTY);
neonArc(CX, CY, 116, 14, spdFrac, CY_R, CY_G, CY_B);
neonArc(CX, CY, 96, 9, vFrac, GR_R, GR_G, GR_B);
neonArc(CX, CY, 80, 6, ampFrac, MG_R, MG_G, MG_B);
char buf[12];
if (spd < 10.0f) snprintf(buf, sizeof(buf), β%.1fβ, spd);
else snprintf(buf, sizeof(buf), β%dβ, (int)(spd + 0.5f));
fb.setTextDatum(middle_center);
fb.setFont(&fonts::Font7); fb.setTextColor(rgb(235, 250, 255));
fb.drawString(buf, CX, CY - 6);
fb.setFont(&fonts::FreeSansBold9pt7b); fb.setTextColor(rgb(95, 217, 236));
fb.drawString(SPD_LABEL, CX, CY + 30);
char v[12], a[12];
snprintf(v, sizeof(v), β%.1fVβ, esc.voltage);
snprintf(a, sizeof(a), β%.0fAβ, esc.current);
fb.setTextColor(rgb(GR_R, GR_G, GR_B)); fb.drawString(v, CX, CY + 56);
fb.setTextColor(rgb(MG_R, MG_G, MG_B)); fb.drawString(a, CX, CY - 52);
}
static void pageBattery() {
float soc = estSOC(esc.voltage);
neonArc(CX, CY, 116, 18, soc / 100.0f, GR_R, GR_G, GR_B);
char buf[8]; snprintf(buf, sizeof(buf), β%dβ, (int)(soc + 0.5f));
fb.setTextDatum(middle_center);
fb.setFont(&fonts::Font7); fb.setTextColor(rgb(235, 255, 240));
fb.drawString(buf, CX - 6, CY - 6);
fb.setFont(&fonts::FreeSansBold9pt7b); fb.setTextColor(rgb(GR_R, GR_G, GR_B));
fb.drawString(β%β, CX + 44, CY - 18);
fb.setTextColor(rgb(150, 160, 170)); fb.drawString(βCHARGE~β, CX, CY + 30);
char vb[12]; snprintf(vb, sizeof(vb), β%.1f Vβ, esc.voltage);
fb.setTextColor(rgb(235, 250, 255)); fb.drawString(vb, CX, CY + 54);
}
static void pagePower() {
float watts = esc.voltage * esc.currentIn;
neonArc(CX, CY, 116, 16, fabs(watts) / POWER_RING_FS, AM_R, AM_G, AM_B);
char buf[12]; snprintf(buf, sizeof(buf), β%dβ, (int)(watts + 0.5f));
fb.setTextDatum(middle_center);
fb.setFont(&fonts::Font7); fb.setTextColor(rgb(255, 245, 220));
fb.drawString(buf, CX, CY - 10);
fb.setFont(&fonts::FreeSansBold9pt7b); fb.setTextColor(rgb(AM_R, AM_G, AM_B));
fb.drawString(βWATTSβ, CX, CY + 26);
char v[12], a[12];
snprintf(v, sizeof(v), β%.1fVβ, esc.voltage);
snprintf(a, sizeof(a), β%.1fAβ, esc.currentIn);
fb.setTextColor(rgb(95, 217, 236)); fb.drawString(v, CX - 34, CY + 54);
fb.setTextColor(rgb(GR_R, GR_G, GR_B)); fb.drawString(a, CX + 34, CY + 54);
}
static void pageEsc() {
neonArc(CX, CY, 116, 14, fabs(esc.dutyPct) / 100.0f, CY_R, CY_G, CY_B);
fb.setTextDatum(middle_center);
fb.setFont(&fonts::FreeSansBold9pt7b); fb.setTextColor(rgb(95, 217, 236));
fb.drawString(βDUTYβ, CX, CY - 44);
char d[8]; snprintf(d, sizeof(d), β%d%%β, (int)(fabs(esc.dutyPct) + 0.5f));
fb.setFont(&fonts::Font7); fb.setTextColor(rgb(235, 250, 255));
fb.drawString(d, CX, CY - 12);
char tf[12], tm[12];
snprintf(tf, sizeof(tf), β%.0f\xB0β, esc.tempFet);
snprintf(tm, sizeofβ’, β%.0f\xB0β, esc.tempMotor);
fb.setFont(&fonts::FreeSans9pt7b); fb.setTextColor(rgb(120, 130, 140));
fb.drawString(βFETβ, CX - 42, CY + 30); fb.drawString(βMOTORβ, CX + 42, CY + 30);
fb.setFont(&fonts::FreeSansBold12pt7b);
fb.setTextColor(esc.tempFet > 80 ? rgb(255, 60, 60) : rgb(235, 250, 255));
fb.drawString(tf, CX - 42, CY + 52);
fb.setTextColor(esc.tempMotor > 90 ? rgb(255, 60, 60) : rgb(235, 250, 255));
fb.drawString(tm, CX + 42, CY + 52);
fb.setFont(&fonts::FreeSansBold9pt7b);
if (esc.fault == 0) { fb.setTextColor(rgb(GR_R, GR_G, GR_B)); fb.drawString(βOKβ, CX, CY + 78); }
else { char f[16]; snprintf(f, sizeof(f), βFAULT %dβ, esc.fault);
fb.setTextColor(rgb(255, 60, 60)); fb.drawString(f, CX, CY + 78); }
}
static void pageTrip() {
fb.setTextDatum(middle_center);
fb.setFont(&fonts::FreeSansBold9pt7b); fb.setTextColor(rgb(95, 217, 236));
fb.drawString(βTRIPβ, CX, CY - 64);
char buf[16]; snprintf(buf, sizeof(buf), β%.2fβ, tripKm * SPD_CONV);
fb.setFont(&fonts::Font7); fb.setTextColor(rgb(235, 250, 255));
fb.drawString(buf, CX, CY - 30);
fb.setFont(&fonts::FreeSans9pt7b); fb.setTextColor(rgb(150, 160, 170));
fb.drawString(USE_MPH ? βmiβ : βkmβ, CX, CY + 2);
snprintf(buf, sizeof(buf), β%d Whβ, (int)(tripWh + 0.5f));
fb.setFont(&fonts::FreeSansBold12pt7b); fb.setTextColor(rgb(GR_R, GR_G, GR_B));
fb.drawString(buf, CX, CY + 32);
float perKm = (tripKm > 0.05f) ? (tripWh / (tripKm * SPD_CONV)) : 0.0f;
snprintf(buf, sizeof(buf), β%.0f Wh/%sβ, perKm, USE_MPH ? βmiβ : βkmβ);
fb.setTextColor(rgb(AM_R, AM_G, AM_B)); fb.drawString(buf, CX, CY + 62);
}
static void drawPageDots() {
int sp = 18, sx = CX - ((NUM_PAGES - 1) * sp) / 2, y = 210;
for (int i = 0; i < NUM_PAGES; i++)
fb.fillSmoothCircle(sx + i * sp, y, (i == page) ? 4 : 2,
(i == page) ? rgb(0, 229, 255) : rgb(50, 60, 70));
}
static void pageDisconnected() {
fb.setTextDatum(middle_center);
fb.setFont(&fonts::FreeSansBold12pt7b); fb.setTextColor(rgb(255, 60, 60));
fb.drawString(βNO CANβ, CX, CY - 10);
fb.setFont(&fonts::FreeSans9pt7b); fb.setTextColor(rgb(150, 160, 170));
fb.drawString(βlisteningβ¦β, CX, CY + 20);
}
static void renderFrame() {
fb.fillScreen(TFT_BLACK);
drawBezel();
if (!esc.connected) pageDisconnected();
else switch (page) {
case 0: pageSpeed(); break; case 1: pageBattery(); break;
case 2: pagePower(); break; case 3: pageEsc(); break; case 4: pageTrip(); break;
}
drawPageDots();
fb.pushSprite(0, 0);
}
// ---------------- button (short = next page, long = reset trip) --------------
static void pollPageButton() {
static bool released = true; static unsigned long pressStart = 0;
bool up = (digitalRead(PAGE_BUTTON) == HIGH); unsigned long now = millis();
if (released && !up) pressStart = now;
else if (!released && up) {
unsigned long held = now - pressStart;
if (held >= 800) { tripWh = 0; tripKm = 0; }
else if (held >= 30) { page = (page + 1) % NUM_PAGES; }
}
released = up;
}
// ---------------- CAN ---------------------------------------------------------
static int32_t be_i32(const uint8_t *b){ return (int32_t)(((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3]); }
static int16_t be_i16(const uint8_t *b){ return (int16_t)(((uint16_t)b[0]<<8)|b[1]); }
static void canBegin() {
// NORMAL mode: we ACK frames (never transmit data). On a 2-node bus a
// listen-only node never ACKs, so the ESC retransmits one frame forever.
twai_general_config_t g = TWAI_GENERAL_CONFIG_DEFAULT(
(gpio_num_t)CAN_TX_PIN, (gpio_num_t)CAN_RX_PIN, TWAI_MODE_NORMAL);
twai_timing_config_t t = CAN_TIMING;
twai_filter_config_t f = TWAI_FILTER_CONFIG_ACCEPT_ALL();
twai_driver_install(&g, &t, &f); twai_start();
}
static void applySpeedFromErpm(long erpm) {
float wheelRpm = fabsf((float)erpm) / (float)MOTOR_POLE_PAIRS / GEAR_RATIO;
float spd = wheelRpm * (3.14159f * WHEEL_DIAM_MM / 1.0e6f) * 60.0f; // km/h
if (spd > 200.0f) spd = 200.0f;
esc.speedKmh += (spd - esc.speedKmh) * 0.35f;
if (esc.speedKmh < 0.2f) esc.speedKmh = 0.0f;
}
static void handleFrame(const twai_message_t &m) {
if (!m.extd) return;
uint8_t cmd = m.identifier & 0xFF; const uint8_t *d = m.data;
switch (cmd) {
case FT_CMD_SLOW:
esc.tempFet = be_i16(&d[0]) / 100.0f; esc.tempMotor = be_i16(&d[2]) / 100.0f;
esc.voltage = be_i16(&d[4]) / 100.0f; lastFrameMs = millis(); break;
case FT_CMD_SPEED:
esc.erpm = be_i32(&d[0]) / 256; esc.dutyPct = be_i16(&d[4]) / 10.0f;
applySpeedFromErpm(esc.erpm); lastFrameMs = millis(); break;
case FT_CMD_CURRENT:
esc.current = be_i16(&d[0]) * AMPS_PER_COUNT;
esc.currentIn = esc.current * (esc.dutyPct * 0.01f);
lastFrameMs = millis(); break;
}
}
static void pollData() {
#if DEMO_MODE
float ph = millis() / 1000.0f;
esc.speedKmh = 22 + 20 * sin(ph * 0.40f);
esc.voltage = (VOLT_FULL + VOLT_EMPTY) / 2 + 4 * sin(ph * 0.30f);
esc.current = 14 * sin(ph * 0.50f); esc.currentIn = esc.current * 0.85f;
esc.dutyPct = 50 + 45 * sin(ph * 0.40f);
esc.tempFet = 42 + 8 * sin(ph * 0.20f); esc.tempMotor = 55 + 10 * sin(ph * 0.15f);
esc.fault = 0; esc.connected = true; return;
#endif
twai_message_t msg;
while (twai_receive(&msg, 0) == ESP_OK) handleFrame(msg);
esc.connected = (lastFrameMs != 0) && (millis() - lastFrameMs < CAN_STALE_MS);
if (!esc.connected) esc.speedKmh = 0;
twai_status_info_t s; twai_get_status_info(&s);
if (s.state == TWAI_STATE_BUS_OFF) twai_initiate_recovery();
}
static void updateTrip() {
static unsigned long last = 0; unsigned long now = millis();
if (last == 0) { last = now; return; }
float dtH = (now - last) / 3600000.0f; last = now;
if (!esc.connected) return;
tripWh += (esc.voltage * esc.currentIn) * dtH;
tripKm += esc.speedKmh * dtH;
if (tripWh < 0) tripWh = 0;
}
// ---------------- setup / loop ----------------------------------------------
void setup() {
Serial.begin(115200); delay(150);
pinMode(PAGE_BUTTON, INPUT_PULLUP);
lcd.init(); lcd.setRotation(0); lcd.fillScreen(TFT_BLACK);
fb.createSprite(240, 240);
fb.fillScreen(TFT_BLACK);
fb.drawCircle(CX, CY, 119, rgb(0, 120, 150));
fb.setTextDatum(middle_center);
fb.setFont(&fonts::FreeSansBold12pt7b); fb.setTextColor(rgb(0, 229, 255));
fb.drawString(βESC DASHβ, CX, CY - 8);
fb.setFont(&fonts::FreeSans9pt7b); fb.setTextColor(rgb(150, 160, 170));
fb.drawString(βneonβ, CX, CY + 18);
fb.pushSprite(0, 0); delay(1400);
#if !DEMO_MODE
canBegin();
#endif
}
void loop() {
static unsigned long lastPoll = 0; pollPageButton();
unsigned long now = millis();
if (now - lastPoll >= 40) { lastPoll = now; pollData(); }
updateTrip();
renderFrame();
#if DEMO_MODE
static unsigned long lastFlip = 0;
if (now - lastFlip > 5000) { lastFlip = now; page = (page + 1) % NUM_PAGES; }
#endif
}