Community project

Steering Wheel Controller

ESP32

finnemary

Published July 14, 2026

Build a racing sim controller with steering wheel, pedals, paddle shifters, and a button box—all controlled by an ESP32-S3 and displayed on a 3.4-inch touchscreen. This guide walks through wiring the potentiometers for analog inputs, connecting the six-button control panel, and setting up the ignition key switch and mode selector. The controller emulates a USB gamepad, making it compatible with racing simulators on PC.

This project delivers a complete parts list, wiring diagram, and ready-to-flash firmware that handles all analog and digital inputs while rendering a live dashboard overlay on the TFT display. Assembly takes a few hours and requires basic soldering; no prior experience with game controllers or ESP32 development is needed.

Wiring diagram

Wiring diagram for Steering Wheel Controller

Gather all the parts

QtyComponent
1

10kΩ Potentiometer

10kΩ

A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.

1

10kΩ Potentiometer

10kΩ

A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.

1

10kΩ Potentiometer

10kΩ

A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.

1

10kΩ Potentiometer

10kΩ

A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.

1

Push Button

Shift Up (Right Paddle)

Momentary push button switch

1

Push Button

Shift Down (Left Paddle)

Momentary push button switch

1

Push Button

Manual/Auto Toggle

Momentary push button switch

1

ILI9341 TFT Touchscreen

240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus

1

10kΩ Potentiometer

10kΩ

A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.

1

2-Position Key Switch

A panel-mount key switch with two positions: OFF and ON/ACC. Outputs a digital HIGH when turned to ON, LOW when off. Used to trigger engine start/stop in the sim.

1

Button Box (6 Buttons)

Six panel-mount momentary buttons for sim functions: Lights, Wipers, Traction Control, ABS, Pit Limiter, DRS. Each button connects between signal pin and GND, using internal pull-ups.

Assemble it in 10 steps

1. Mount the ESP32-S3 DevKitC-1

Mount the ESP32-S3-DevKitC-1 board centrally inside the cabinet on a non-conductive base. Secure it with standoffs or double-sided tape. Leave the USB-C port accessible — this is your PC connection for HID and power.

  • Route a USB-C cable from the board port to the front or rear of the cabinet for easy PC connection.

2. Wire the steering wheel potentiometer

Mount a 10kΩ linear pot on the steering column shaft. Connect End1 to the 3.3V pin, End2 to GND, and Wiper to GPIO1.

  • Use 3.3V only — NEVER 5V — for all potentiometers. The ESP32-S3 ADC is 3.3V max.

3. Wire the pedal potentiometers

Mount three 10kΩ pots on the gas, brake, and clutch pedal mechanisms. For each: End1 → 3.3V, End2 → GND. Gas Wiper → GPIO2, Brake Wiper → GPIO3, Clutch Wiper → GPIO4.

  • A return spring or rubber stop on each pedal makes the sim feel much more realistic.

4. Wire the handbrake lever potentiometer

Mount a 10kΩ pot at the pivot of your handbrake lever so pulling the lever rotates the pot shaft. Connect End1 → 3.3V, End2 → GND, Wiper → GPIO5.

  • A short lever (10–15 cm) with the pot at the base pivot gives the best feel.

5. Wire the paddle shifters

Mount two momentary switches behind the steering wheel — right paddle to GPIO14, left paddle to GPIO21. Both share GND. The firmware uses internal pull-ups, so no external resistors needed.

  • Micro-switches (like those in a mouse) give a satisfying click feel for paddle shifters.

6. Wire the Manual/Auto mode button

Mount a momentary button on the dashboard or steering column. Connect SIGNAL to GPIO38 and GND to GND.

  • A flush-mount panel button works well here — label it M/A.

7. Wire the ignition key switch

Mount a 2-position panel key switch on the dashboard. Connect SIGNAL to GPIO6 and GND to GND. The firmware uses an internal pull-up — turning the key LOW triggers engine ON/OFF toggle.

  • Key switches are available on AliExpress/Amazon for ~$2 in panel-mount format.
  • A short barrel-key switch (12mm panel mount) looks the most realistic.
  • The key switch toggles engine state on each turn — make sure it's in OFF position before powering up.

8. Wire the button box

Mount 6 momentary buttons on a panel (the button box). Wire them as follows — all share a common GND rail: • BTN1 (Lights) → GPIO7 • BTN2 (Wipers) → GPIO15 • BTN3 (Traction Control) → GPIO16 • BTN4 (ABS) → GPIO17 • BTN5 (Pit Limiter) → GPIO18 • BTN6 (DRS) → GPIO47

  • Label each button clearly with a label maker or printed panel insert.
  • 16mm or 19mm illuminated panel buttons look great on a button box — the LED ring just needs 3.3V and a resistor.

9. Wire the TFT touchscreen display

Connect the ILI9341 2.8" TFT to the SPI bus: • VCC → 3.3V • GND → GND • MOSI → GPIO11 • MISO → GPIO13 • SCK → GPIO12 • TFT_CS → GPIO10 • TFT_DC → GPIO39 • TFT_RST → GPIO40 • TOUCH_CS → GPIO41 • TOUCH_IRQ → GPIO42 Mount the screen on the cabinet dashboard where the driver can see it as an instrument cluster.

  • Hot-glue or a 3D-printed bezel looks great for mounting the TFT in the dashboard.
  • The TFT shows Speed (MPH) and Gear only — touch MENU CAR/BG to change the theme.

10. Connect to PC and open the overlay

Plug the USB-C cable from the ESP32-S3 into your PC. The board appears as a USB HID gamepad immediately. Open data/pc_overlay.html in Chrome or Edge, click anywhere on the page, and select the ESP32 COM port from the browser's serial picker (115200 baud). The overlay shows mode badge, gas/brake/handbrake bars, steering indicator, engine status, and button box lights.

  • If the serial picker doesn't appear, make sure you're opening the file via localhost or a local web server — Web Serial only works on HTTPS or localhost in Chrome/Edge.
  • Use joy.cpl on Windows to verify all axes and buttons before launching a game.

Review all connections

1. Connections between "wheel_pot" and "ESP32"

Functionwheel_potESP32
powerEnd1 (VCC side)3V3
groundEnd2 (GND side)GND
analogWiper (middle)GPIO 1

2. Connections between "gas_pot" and "ESP32"

Functiongas_potESP32
powerEnd1 (VCC side)3V3
groundEnd2 (GND side)GND
analogWiper (middle)GPIO 2

3. Connections between "brake_pot" and "ESP32"

Functionbrake_potESP32
powerEnd1 (VCC side)3V3
groundEnd2 (GND side)GND
analogWiper (middle)GPIO 3

4. Connections between "clutch_pot" and "ESP32"

Functionclutch_potESP32
powerEnd1 (VCC side)3V3
groundEnd2 (GND side)GND
analogWiper (middle)GPIO 4

5. Connections between "paddle_up" and "ESP32"

Functionpaddle_upESP32
groundGNDGND
digitalSIGNALGPIO 14

6. Connections between "paddle_dn" and "ESP32"

Functionpaddle_dnESP32
groundGNDGND
digitalSIGNALGPIO 21

7. Connections between "mode_btn" and "ESP32"

Functionmode_btnESP32
groundGNDGND
digitalSIGNALGPIO 38

8. Connections between "tft" and "ESP32"

FunctiontftESP32
powerVCC3V3
groundGNDGND
spiMOSIGPIO 11
spiMISOGPIO 13
spiSCKGPIO 12
digitalTFT_CSGPIO 10
digitalTFT_DCGPIO 39
digitalTFT_RSTGPIO 40
digitalTOUCH_CSGPIO 41
digitalTOUCH_IRQGPIO 42

9. Connections between "handbrake_pot" and "ESP32"

Functionhandbrake_potESP32
powerEnd1 (VCC side)3V3
groundEnd2 (GND side)GND
analogWiper (middle)GPIO 5

10. Connections between "ignition" and "ESP32"

FunctionignitionESP32
groundGNDGND
digitalSIGNALGPIO 6

11. Connections between "btn_box" and "ESP32"

Functionbtn_boxESP32
digitalBTN1GPIO 7
digitalBTN2GPIO 15
digitalBTN3GPIO 16
digitalBTN4GPIO 17
digitalBTN5GPIO 18
digitalBTN6GPIO 47
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#include "USB.h"
#include "USBHIDGamepad.h"

// ── Pin definitions ──────────────────────────────────────────
#define WHEEL_PIN      1
#define GAS_PIN        2
#define BRAKE_PIN      3
#define CLUTCH_PIN     4
#define PADDLE_UP_PIN  14
#define PADDLE_DN_PIN  21
#define MODE_BTN_PIN   38
#define HANDBRAKE_PIN  5
#define IGNITION_PIN   6
#define BTN1_PIN       7
#define BTN2_PIN       15
#define BTN3_PIN       16
#define BTN4_PIN       17
#define BTN5_PIN       18
#define BTN6_PIN       47
#define TFT_CS         10
#define TFT_DC         39
#define TFT_RST        40
#define TOUCH_CS       41
#define TOUCH_IRQ      42

// ── Display & Touch ──────────────────────────────────────────

// Only two states now: dashboard or overlay open
bool menuOpen = false;

Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);

// ── USB HID Gamepad ──────────────────────────────────────────
USBHIDGamepad Gamepad;

// ── Colours ──────────────────────────────────────────────────
#define C_BG       0x0841   // dark charcoal
#define C_RED      0xF800
#define C_GREEN    0x07E0
#define C_BLUE     0x001F
#define C_YELLOW   0xFFE0
#define C_WHITE    0xFFFF
#define C_GREY     0x8410
#define C_ORANGE   0xFD20
#define C_DARKGREY 0x4208

// ── Car & background presets ────────────────────────────────
const char* CAR_NAMES[]  = {"F1 Car", "GT500",  "Rally",  "Muscle"};
const char* BG_NAMES[]   = {"Night",  "Sunset", "Desert", "Track" };
const uint16_t CAR_COLOURS[] = {C_RED, C_BLUE, C_YELLOW, C_ORANGE};
const uint16_t BG_COLOURS[]  = {0x0841, 0x8C00, 0xC600, 0x0460};
#define NUM_CARS 4
#define NUM_BGS  4

// ── State ────────────────────────────────────────────────────
bool     autoMode       = false;
int      gear           = 1;
uint32_t lastShiftMs    = 0;
const uint32_t DEBOUNCE_MS    = 50;
const uint32_t AUTO_SHIFT_MS  = 800;
#define AUTO_UP_THRESH  200   // out of 255 (~78%)
#define AUTO_DN_THRESH   50   // out of 255 (~20%)

int      selectedCar    = 0;
int      selectedBg     = 0;

// Previous values for selective TFT redraw
int      prevGear       = -1;
int      prevSpeedDisp  = -1;

// ── Touch calibration (adjust if needed) ────────────────────
#define TOUCH_X_MIN  350
#define TOUCH_X_MAX  3700
#define TOUCH_Y_MIN  350
#define TOUCH_Y_MAX  3800

// ── Forward declarations ─────────────────────────────────────
void drawDashboard(bool full);
void drawMenuOverlay();
void closeMenu();
void handleTouch();
int  mapTouch(int raw, int rMin, int rMax, int oMin, int oMax);

// ─────────────────────────────────────────────────────────────
void setup() {
  // Buttons & switches
  pinMode(PADDLE_UP_PIN, INPUT_PULLUP);
  pinMode(PADDLE_DN_PIN, INPUT_PULLUP);
  pinMode(MODE_BTN_PIN,  INPUT_PULLUP);
  pinMode(IGNITION_PIN,  INPUT_PULLUP);
  pinMode(BTN1_PIN,      INPUT_PULLUP);
  pinMode(BTN2_PIN,      INPUT_PULLUP);
  pinMode(BTN3_PIN,      INPUT_PULLUP);
  pinMode(BTN4_PIN,      INPUT_PULLUP);
  pinMode(BTN5_PIN,      INPUT_PULLUP);
  pinMode(BTN6_PIN,      INPUT_PULLUP);

  // Serial (for PC companion overlay)
  Serial.begin(115200);

  // Display
  SPI.begin(12, 13, 11); // SCK, MISO, MOSI
  tft.begin();
  tft.setRotation(1);    // landscape
  touch.begin();
  touch.setRotation(1);

  menuOpen = false;
  tft.fillScreen(BG_COLOURS[selectedBg]);
  drawDashboard(true);

  // USB HID
  Gamepad.begin();
  USB.begin();
}

// ─────────────────────────────────────────────────────────────
void loop() {
  uint32_t now = millis();

  // ── Read ADC axes ────────────────────────────────────────
  int rawWheel     = analogRead(WHEEL_PIN);
  int rawGas       = analogRead(GAS_PIN);
  int rawBrake     = analogRead(BRAKE_PIN);
  int rawClutch    = analogRead(CLUTCH_PIN);
  int rawHandbrake = analogRead(HANDBRAKE_PIN);

  // Map to 0–255 for HID
  int axisWheel     = map(rawWheel,     0, 4095, -127, 127);
  int axisGas       = map(rawGas,       0, 4095,    0, 255);
  int axisBrake     = map(rawBrake,     0, 4095,    0, 255);
  int axisClutch    = map(rawClutch,    0, 4095,    0, 255);
  int axisHandbrake = map(rawHandbrake, 0, 4095,    0, 255);

  // ── Ignition key ─────────────────────────────────────────
  static bool lastIgnition = HIGH;
  static bool engineOn     = false;
  bool ignState = digitalRead(IGNITION_PIN);
  if (lastIgnition == HIGH && ignState == LOW) {
    engineOn = !engineOn;  // toggle on key turn
    if (!engineOn) { gear = 1; }  // reset gear on engine off
  }
  lastIgnition = ignState;

  // ── Button box reads ─────────────────────────────────────
  bool bb1 = !digitalRead(BTN1_PIN); // active LOW
  bool bb2 = !digitalRead(BTN2_PIN);
  bool bb3 = !digitalRead(BTN3_PIN);
  bool bb4 = !digitalRead(BTN4_PIN);
  bool bb5 = !digitalRead(BTN5_PIN);
  bool bb6 = !digitalRead(BTN6_PIN);

  // ── Mode toggle ──────────────────────────────────────────
  static bool lastModeBtn = HIGH;
  bool modeBtn = digitalRead(MODE_BTN_PIN);
  if (lastModeBtn == HIGH && modeBtn == LOW && (now - lastShiftMs) > DEBOUNCE_MS) {
    autoMode = !autoMode;
    gear = 1;
    lastShiftMs = now;
    if (!menuOpen) drawDashboard(true);
  }
  lastModeBtn = modeBtn;

  // ── Shifting ─────────────────────────────────────────────
  if (!autoMode) {
    static bool lastUp = HIGH, lastDn = HIGH;
    bool upBtn = digitalRead(PADDLE_UP_PIN);
    bool dnBtn = digitalRead(PADDLE_DN_PIN);
    if (lastUp == HIGH && upBtn == LOW && (now - lastShiftMs) > DEBOUNCE_MS) {
      if (gear < 6) { gear++; lastShiftMs = now; }
    }
    if (lastDn == HIGH && dnBtn == LOW && (now - lastShiftMs) > DEBOUNCE_MS) {
      if (gear > 1) { gear--; lastShiftMs = now; }
    }
    lastUp = upBtn;
    lastDn = dnBtn;
  } else {
    if ((now - lastShiftMs) > AUTO_SHIFT_MS) {
      if (axisGas > AUTO_UP_THRESH && gear < 6) { gear++; lastShiftMs = now; }
      else if (axisGas < AUTO_DN_THRESH && gear > 1) { gear--; lastShiftMs = now; }
    }
  }

  // ── Simulated speed (MPH) from gas + gear ───────────────
  // Pure display value — real speed comes from the game on the PC
  int simSpeed = (axisGas * gear * 15) / 255;
  if (simSpeed > 199) simSpeed = 199;

  // ── HID report ───────────────────────────────────────────
  Gamepad.leftStick(axisWheel, 0);
  Gamepad.rightStick(axisHandbrake, axisClutch); // handbrake = Ry, clutch = Rx
  uint32_t buttons = 0;
  buttons |= (1u << (gear - 1));           // buttons 1-6: gear
  if (autoMode)   buttons |= (1u << 6);    // button 7: auto mode
  if (engineOn)   buttons |= (1u << 7);    // button 8: ignition/engine
  if (bb1)        buttons |= (1u << 8);    // button 9:  Lights
  if (bb2)        buttons |= (1u << 9);    // button 10: Wipers
  if (bb3)        buttons |= (1u << 10);   // button 11: Traction Control
  if (bb4)        buttons |= (1u << 11);   // button 12: ABS
  if (bb5)        buttons |= (1u << 12);   // button 13: Pit Limiter
  if (bb6)        buttons |= (1u << 13);   // button 14: DRS
  Gamepad.buttons(buttons);
  Gamepad.send();

  // ── Touch / menu ─────────────────────────────────────────
  handleTouch();

  // ── Serial JSON → PC overlay ─────────────────────────────
  // Send at ~20 Hz so the PC companion app can display
  // mode, gas, brake, steering without adding display load to TFT
  static uint32_t lastSerialMs = 0;
  if (now - lastSerialMs >= 50) {
    lastSerialMs = now;
    int gasDisp    = axisGas    * 100 / 255;
    int brakeDisp  = axisBrake  * 100 / 255;
    int steerDisp  = map(rawWheel, 0, 4095, -100, 100);
    Serial.print('{');
    Serial.print("\"mode\":\"");    Serial.print(autoMode ? "AUTO" : "MANUAL"); Serial.print('"');
    Serial.print(",\"gas\":");      Serial.print(gasDisp);
    Serial.print(",\"brake\":");    Serial.print(brakeDisp);
    Serial.print(",\"steer\":");    Serial.print(steerDisp);
    Serial.print(",\"gear\":");     Serial.print(gear);
    Serial.print(",\"speed\":");       Serial.print((axisGas * gear * 15) / 255);
    Serial.print(",\"handbrake\":");   Serial.print(axisHandbrake * 100 / 255);
    Serial.print(",\"engine\":");      Serial.print(engineOn ? 1 : 0);
    Serial.print(",\"lights\":");      Serial.print(bb1 ? 1 : 0);
    Serial.print(",\"wipers\":");      Serial.print(bb2 ? 1 : 0);
    Serial.print(",\"tc\":");          Serial.print(bb3 ? 1 : 0);
    Serial.print(",\"abs\":");         Serial.print(bb4 ? 1 : 0);
    Serial.print(",\"pitlimit\":");    Serial.print(bb5 ? 1 : 0);
    Serial.print(",\"drs\":");         Serial.print(bb6 ? 1 : 0);
    Serial.println('}');
  }

  // ── TFT instrument cluster update (speed + gear only) ─────
  if (!menuOpen) {
    int speedDisp = (axisGas * gear * 15) / 255;
    if (speedDisp > 199) speedDisp = 199;
    if (gear != prevGear || speedDisp != prevSpeedDisp) {
      drawDashboard(false);
      prevGear      = gear;
      prevSpeedDisp = speedDisp;
    }
  }

  delay(10);
}

// ─────────────────────────────────────────────────────────────
int mapTouch(int raw, int rMin, int rMax, int oMin, int oMax) {
  return constrain(map(raw, rMin, rMax, oMin, oMax), oMin, oMax);
}

// ── Draw the TFT instrument cluster: Speed + Gear only ───────
void drawDashboard(bool full) {
  int rawGas   = analogRead(GAS_PIN);
  int axisGas  = map(rawGas, 0, 4095, 0, 255);
  int simSpeed = (axisGas * gear * 15) / 255;
  if (simSpeed > 199) simSpeed = 199;

  uint16_t bg  = BG_COLOURS[selectedBg];
  uint16_t acc = CAR_COLOURS[selectedCar];

  if (full) {
    tft.fillScreen(bg);

    // ── Title bar ─────────────────────────────────────────
    tft.fillRect(0, 0, 320, 26, C_DARKGREY);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(6, 9);
    tft.print("INSTRUMENT CLUSTER");
    tft.setTextColor(acc);
    tft.setCursor(210, 9);
    tft.print(CAR_NAMES[selectedCar]);

    // ── Divider line ──────────────────────────────────────
    tft.drawFastHLine(0, 26, 320, C_GREY);
    tft.drawFastVLine(160, 26, 194, C_GREY); // centre divider

    // ── Labels ────────────────────────────────────────────
    tft.setTextColor(C_GREY); tft.setTextSize(1);
    tft.setCursor(10,  34); tft.print("SPEED (MPH)");
    tft.setCursor(172, 34); tft.print("GEAR");

    // ── MENU button (bottom-right, touchable) ─────────────
    tft.fillRoundRect(222, 208, 88, 24, 6, C_DARKGREY);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(234, 218); tft.print("MENU CAR/BG");
  } else {
    // Erase number areas only
    tft.fillRect(6,  48, 148, 90, bg);
    tft.fillRect(166, 48, 148, 90, bg);
  }

  // ── Speed — large centred in left half ────────────────────
  tft.setTextColor(C_WHITE); tft.setTextSize(7);
  // 3-digit field: each char ~42px wide at size 7
  int sx = (simSpeed < 10) ? 46 : (simSpeed < 100) ? 25 : 6;
  tft.setCursor(sx, 52);
  tft.print(simSpeed);

  // MPH unit label
  tft.setTextColor(C_GREY); tft.setTextSize(1);
  tft.setCursor(10, 142); tft.print("MPH");

  // ── Gear — large centred in right half ────────────────────
  tft.setTextColor(acc); tft.setTextSize(7);
  tft.setCursor(204, 52);   // single digit, centred in right half
  tft.print(gear);
}

// ── Menu overlay (drawn ON TOP of the dashboard) ─────────────
// Layout (centred panel 300x220 at x=10, y=10):
//   Title row
//   CAR section:  4 car buttons in 2 columns
//   BG  section:  4 bg  buttons in 2 columns
//   CLOSE button at bottom
#define OVL_X   10
#define OVL_Y   8
#define OVL_W   300
#define OVL_H   226
#define OVL_BTN_W  130
#define OVL_BTN_H  26

void drawMenuOverlay() {
  // Dim background with a dark semi-frame
  tft.fillRoundRect(OVL_X, OVL_Y, OVL_W, OVL_H, 10, 0x1082); // very dark grey
  tft.drawRoundRect(OVL_X, OVL_Y, OVL_W, OVL_H, 10, C_WHITE);

  // Title
  tft.setTextColor(C_WHITE); tft.setTextSize(1);
  tft.setCursor(OVL_X + 8, OVL_Y + 6);
  tft.print("SETTINGS");
  // Divider
  tft.drawFastHLine(OVL_X + 1, OVL_Y + 18, OVL_W - 2, C_GREY);

  // ── CAR section ────────────────────────────────────────────
  tft.setTextColor(C_GREY); tft.setTextSize(1);
  tft.setCursor(OVL_X + 8, OVL_Y + 22);
  tft.print("CAR");

  // 4 cars in 2 columns, 2 rows  (each button 130x26)
  for (int i = 0; i < NUM_CARS; i++) {
    int col = i % 2;                    // 0 or 1
    int row = i / 2;                    // 0 or 1
    int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
    int by = OVL_Y + 32 + row * (OVL_BTN_H + 6);
    bool sel = (i == selectedCar);
    tft.fillRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5,
                      sel ? CAR_COLOURS[i] : C_DARKGREY);
    if (sel) tft.drawRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5, C_WHITE);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(bx + 6, by + 9);
    tft.print(CAR_NAMES[i]);
  }

  // ── BG section ─────────────────────────────────────────────
  int bgSectionY = OVL_Y + 32 + 2 * (OVL_BTN_H + 6) + 8;
  tft.drawFastHLine(OVL_X + 1, bgSectionY - 4, OVL_W - 2, C_DARKGREY);
  tft.setTextColor(C_GREY); tft.setTextSize(1);
  tft.setCursor(OVL_X + 8, bgSectionY);
  tft.print("BACKGROUND");

  for (int i = 0; i < NUM_BGS; i++) {
    int col = i % 2;
    int row = i / 2;
    int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
    int by = bgSectionY + 12 + row * (OVL_BTN_H + 6);
    bool sel = (i == selectedBg);
    tft.fillRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5, BG_COLOURS[i]);
    if (sel) tft.drawRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5, C_WHITE);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(bx + 6, by + 9);
    tft.print(BG_NAMES[i]);
  }

  // ── CLOSE button ───────────────────────────────────────────
  int closeY = OVL_Y + OVL_H - 30;
  tft.fillRoundRect(OVL_X + 80, closeY, 140, 24, 6, C_GREEN);
  tft.setTextColor(0x0000); tft.setTextSize(1);
  tft.setCursor(OVL_X + 112, closeY + 8);
  tft.print("CLOSE");
}

void closeMenu() {
  menuOpen = false;
  drawDashboard(true); // full redraw restores dashboard
}

// ── Touch handler ────────────────────────────────────────────
void handleTouch() {
  if (!touch.touched()) return;
  TS_Point p = touch.getPoint();

  // Map raw touch to screen coords (landscape)
  int tx = mapTouch(p.x, TOUCH_X_MIN, TOUCH_X_MAX, 0, 320);
  int ty = mapTouch(p.y, TOUCH_Y_MIN, TOUCH_Y_MAX, 0, 240);

  delay(30); // debounce touch
  while (touch.touched()) {};

  if (!menuOpen) {
    // ── Dashboard: only the MENU button is tappable ──────────
    if (tx >= 222 && tx <= 310 && ty >= 196 && ty <= 222) {
      menuOpen = true;
      drawMenuOverlay();
    }
  } else {
    // ── Overlay is open ──────────────────────────────────────

    // CAR buttons: 2 columns x 2 rows starting at (OVL_X+8, OVL_Y+32)
    for (int i = 0; i < NUM_CARS; i++) {
      int col = i % 2;
      int row = i / 2;
      int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
      int by = OVL_Y + 32 + row * (OVL_BTN_H + 6);
      if (tx >= bx && tx <= bx + OVL_BTN_W &&
          ty >= by && ty <= by + OVL_BTN_H) {
        selectedCar = i;
        drawMenuOverlay();
        return;
      }
    }

    // BG buttons: same grid but offset to bgSectionY
    int bgSectionY = OVL_Y + 32 + 2 * (OVL_BTN_H + 6) + 8;
    for (int i = 0; i < NUM_BGS; i++) {
      int col = i % 2;
      int row = i / 2;
      int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
      int by = bgSectionY + 12 + row * (OVL_BTN_H + 6);
      if (tx >= bx && tx <= bx + OVL_BTN_W &&
          ty >= by && ty <= by + OVL_BTN_H) {
        selectedBg = i;
        drawMenuOverlay();
        return;
      }
    }

    // CLOSE button
    int closeY = OVL_Y + OVL_H - 30;
    if (tx >= OVL_X + 80 && tx <= OVL_X + 220 &&
        ty >= closeY && ty <= closeY + 24) {
      closeMenu();
    }
  }
}

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