FLIPSKY FT85BD Customizable ESC

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

}

1 Like