nicthegeek

Hardware and firmware projects, written up in detail.

Jump to the projects

Building ESP32 projects with Claude Code

Every project on this site was built the same way: an ESP32 board on the desk, PlatformIO doing the builds, and Claude Code working alongside in the terminal. This page explains that setup from the ground up, and finishes with a starter project you can flash onto a $15 board in a few minutes.

The ESP32

The ESP32 is a microcontroller family from Espressif. The original ESP32 has two Xtensa LX6 cores running at up to 240 MHz, 520 KB of on-chip SRAM, and Wi-Fi and Bluetooth built in. Newer members of the family (S3, C3, C6 and others) change the cores and peripherals, but the idea is the same: a wireless-capable, reasonably fast chip that costs a few dollars.

It is powerful enough for real work, like driving a colour display, doing floating-point image processing or serving a web page, while still being a microcontroller. There is no operating system to boot, it starts in milliseconds, and it talks directly to sensors over I2C, SPI and GPIO.

The board used throughout this site is the ESP32-2432S028R, better known as the Cheap Yellow Display (CYD). It combines an ESP32 module with a 2.8" 320x240 touchscreen, a microSD slot, an RGB LED and a light sensor, all on one board with a USB port. For about $15 it is a complete handheld device waiting for firmware.

Arduino on the ESP32

Espressif's own SDK, ESP-IDF, is a capable but large C framework built on FreeRTOS. The Arduino core for ESP32 sits on top of it and provides the familiar Arduino programming model: a setup() function that runs once and a loop() function that runs forever, plus the standard Serial, Wire (I2C), SPI and digitalWrite() APIs.

That model makes the ESP32 approachable, and it gives access to thousands of Arduino libraries for displays, sensors and protocols. The underlying ESP-IDF and FreeRTOS are still there, so when a project needs DMA, a second task or a hardware peripheral that Arduino doesn't cover, you can call them directly from the same code.

PlatformIO instead of the Arduino IDE

The Arduino IDE is fine for a first blink sketch, but it keeps settings in menus and libraries in a shared global folder. Two projects that need different versions of the same library will fight over it.

PlatformIO puts everything a project needs into one text file, platformio.ini, in the project folder. It lists the board, the framework, the exact library versions and the compiler flags. PlatformIO downloads the toolchain and libraries itself, per project, so a checkout builds the same way on any machine. One project can also hold several build targets, such as a diagnostic sketch and the real firmware, sharing the same board configuration.

It is driven entirely from the command line:

pio run                      # build
pio run -t upload            # build and flash over USB
pio device monitor           # watch the serial output

That last point is what makes it work so well with Claude Code.

Why Claude Code fits embedded work

Claude Code is Anthropic's AI coding assistant, and it runs in the terminal. It reads and edits the files in your project, runs shell commands, sees their output and iterates on it. Because PlatformIO is all text files and command-line tools, Claude Code can take part in the whole loop:

The thermal camera project shows the diagnostic approach in practice. The sensor's I2C wiring turned out to be the reverse of what the board's pinout suggested, and a bus probe that tries both orientations settled it. The display's colours came out inverted, and screenshots lost their red channel. A dedicated panel test firmware traced those to two separate causes: display inversion needed switching on, and screen reads needed a slower SPI clock.

What Claude Code cannot do is see the board. You are its eyes: tell it what the screen shows, paste the serial output, and describe what happened when you pressed the button. The more precisely you describe the physical result, the better its next step will be.

Tips for working this way

Starter project: CYD bring-up

This is the first firmware to flash onto a new CYD. It checks each part of the board in turn and gives you the numbers needed to calibrate the touchscreen. It:

Download the starter project (cyd-bringup.zip)

1. Install the tools

pipx install platformio                               # or: pip install platformio
curl -fsSL https://claude.ai/install.sh | bash        # Claude Code

On Linux, add yourself to the dialout group (sudo usermod -aG dialout $USER, then log out and back in) so you can access the board's USB serial port. Most CYDs use a CH340 USB-to-serial chip, which Linux and macOS support out of the box. Windows may need the CH340 driver.

2. Build and flash

Unzip the project, plug in the board and run:

cd cyd-bringup
pio run -t upload -t monitor

The first build takes a few minutes while PlatformIO downloads the ESP32 toolchain and libraries. Later builds take seconds. If the upload can't find the board, hold the BOOT button while it starts connecting.

3. Calibrate the touchscreen

Resistive touch panels report raw readings between about 0 and 4095, and the exact range varies from panel to panel. Press each red corner dot and note the raw values shown on screen and in the serial output. Put the smallest and largest X and Y values into these constants at the top of src/main.cpp, then flash again:

static const uint16_t RAW_X_MIN =  230, RAW_X_MAX = 3789;
static const uint16_t RAW_Y_MIN =  171, RAW_Y_MAX = 3768;

After that, the crosshair should land under your finger everywhere on the screen.

4. If something looks wrong

These are also good first prompts for Claude Code. Start it in the project folder with claude, and try something like:

How the code works

platformio.ini configures the TFT_eSPI display library entirely through compiler flags (USER_SETUP_LOADED=1 tells it to ignore its own settings file). The display driver, the pins and the SPI speeds are all in the project, so it builds the same anywhere.

The display and the touch controller are on separate SPI buses. TFT_eSPI drives the display on HSPI, while the touch controller gets its own SPIClass on VSPI:

SPIClass touchSPI = SPIClass(VSPI);
XPT2046_Touchscreen touchscreen(XPT2046_CS, XPT2046_IRQ);
...
touchSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
touchscreen.begin(touchSPI);

In loop(), each touch is mapped from raw coordinates to screen pixels with the calibration constants, and redraws are limited to about 30 per second so they can't outpace the display:

int16_t x = map(p.x, RAW_X_MIN, RAW_X_MAX, 0, tft.width() - 1);
int16_t y = map(p.y, RAW_Y_MIN, RAW_Y_MAX, 0, tft.height() - 1);
x = constrain(x, 0, tft.width() - 1);
y = constrain(y, 0, tft.height() - 1);

Text is drawn with a padded, opaque background, so each value overwrites the last without clearing the area first. A fillRect() before every update would flicker at touch refresh rates.

Full source: src/main.cpp
// ESP32-2432S028R (CYD) bring-up and touch calibration.
//
// Proves out serial, backlight, display and resistive touch, and gives you the
// raw XPT2046 numbers needed to calibrate the panel.
//
// Press each of the four red corner dots in turn. The raw reading is shown live
// in the header and echoed to serial. Put the extremes into the RAW_* constants
// below and reflash.

#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>

// Touch controller sits on the other SPI bus from the display.
#define XPT2046_IRQ  36
#define XPT2046_MOSI 32
#define XPT2046_MISO 39
#define XPT2046_CLK  25
#define XPT2046_CS   33

// On-board extras.
#define LED_RED   4
#define LED_GREEN 16
#define LED_BLUE  17
#define LDR_PIN   34

TFT_eSPI tft = TFT_eSPI();
SPIClass touchSPI = SPIClass(VSPI);
XPT2046_Touchscreen touchscreen(XPT2046_CS, XPT2046_IRQ);

// Per-panel raw ranges. These came from one CYD and are a reasonable start,
// but every resistive panel differs: press the four corner dots, note the
// extremes printed on screen and over serial, put them here and reflash.
static const uint16_t RAW_X_MIN =  230, RAW_X_MAX = 3789;
static const uint16_t RAW_Y_MIN =  171, RAW_Y_MAX = 3768;

static const int16_t ARM       = 14;   // crosshair arm length
static const int16_t HEADER_H  = 58;
static const int16_t FOOTER_H  = 20;

static const char IDLE_MSG[] = "press the four red dots";

static int16_t lastX = -1, lastY = -1;   // -1 means no crosshair drawn
static uint32_t lastDraw = 0;
static char statusLine[40] = {0};
static int  ldrValue = 0;

static void setRgb(bool r, bool g, bool b) {
  // Common anode: LOW turns a channel on.
  digitalWrite(LED_RED,   r ? LOW : HIGH);
  digitalWrite(LED_GREEN, g ? LOW : HIGH);
  digitalWrite(LED_BLUE,  b ? LOW : HIGH);
}

static void drawBorder() {
  tft.drawRect(0, 0, tft.width(), tft.height(), TFT_DARKGREY);
}

// Opaque text write with padding, so it overwrites the previous value without a
// fillRect first. A fillRect here would flicker at the touch refresh rate.
static void writeStatus() {
  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(TFT_CYAN, TFT_BLACK);
  tft.setTextPadding(tft.width() - 16);
  tft.drawString(statusLine, 8, 36, 2);
  tft.setTextPadding(0);
}

static void writeLdr() {
  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
  tft.setTextPadding(100);
  char buf[20];
  snprintf(buf, sizeof(buf), "ldr %d", ldrValue);
  tft.drawString(buf, 8, tft.height() - FOOTER_H + 2, 2);
  tft.setTextPadding(0);
}

static void repaintHeader() {
  tft.fillRect(1, 1, tft.width() - 2, HEADER_H - 1, TFT_BLACK);
  tft.setTextDatum(TL_DATUM);
  tft.setTextColor(TFT_GREEN, TFT_BLACK);
  tft.drawString("CYD bring-up", 8, 6, 4);
  tft.fillCircle(6, 6, 3, TFT_RED);
  tft.fillCircle(tft.width() - 7, 6, 3, TFT_RED);
  writeStatus();
  drawBorder();
}

static void repaintFooter() {
  tft.fillRect(1, tft.height() - FOOTER_H, tft.width() - 2, FOOTER_H - 1, TFT_BLACK);
  tft.fillCircle(6, tft.height() - 7, 3, TFT_RED);
  tft.fillCircle(tft.width() - 7, tft.height() - 7, 3, TFT_RED);
  writeLdr();
  drawBorder();
}

static bool hitsHeader(int16_t cy) { return (cy - ARM - 2) < HEADER_H; }
static bool hitsFooter(int16_t cy) { return (cy + ARM + 2) > (tft.height() - FOOTER_H); }

static void eraseCrosshair() {
  if (lastX < 0) return;

  const int16_t s = ARM * 2 + 3;
  tft.fillRect(lastX - ARM - 1, lastY - ARM - 1, s, s, TFT_BLACK);

  // Repair anything the erase square chewed into.
  if (hitsHeader(lastY)) repaintHeader();
  if (hitsFooter(lastY)) repaintFooter();
  drawBorder();

  lastX = lastY = -1;
}

static void drawCrosshair(int16_t x, int16_t y) {
  tft.drawFastHLine(x - ARM, y, ARM * 2 + 1, TFT_CYAN);
  tft.drawFastVLine(x, y - ARM, ARM * 2 + 1, TFT_CYAN);
  tft.fillCircle(x, y, 3, TFT_YELLOW);
  lastX = x;
  lastY = y;
}

void setup() {
  Serial.begin(115200);
  delay(300);

  Serial.println();
  Serial.println("ESP32-2432S028R bring-up");
  Serial.printf("chip     : %s rev%d, %d core(s)\n",
                ESP.getChipModel(), ESP.getChipRevision(), ESP.getChipCores());
  Serial.printf("flash    : %u bytes\n", ESP.getFlashChipSize());
  Serial.printf("psram    : %u bytes\n", ESP.getPsramSize());
  Serial.printf("free heap: %u bytes\n", ESP.getFreeHeap());

  pinMode(LED_RED, OUTPUT);
  pinMode(LED_GREEN, OUTPUT);
  pinMode(LED_BLUE, OUTPUT);
  setRgb(false, false, false);

  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);

  tft.init();
  tft.setRotation(0);           // 0 = portrait, USB at the bottom
  tft.fillScreen(TFT_BLACK);

  ldrValue = analogRead(LDR_PIN);
  strncpy(statusLine, IDLE_MSG, sizeof(statusLine) - 1);
  repaintHeader();
  repaintFooter();

  touchSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
  touchscreen.begin(touchSPI);
  touchscreen.setRotation(0);

  setRgb(false, true, false);
  Serial.println("ready");
}

void loop() {
  static uint32_t lastLdrRead = 0;
  static bool wasTouched = false;

  if (millis() - lastLdrRead > 1000) {
    lastLdrRead = millis();
    ldrValue = analogRead(LDR_PIN);
    if (!wasTouched) writeLdr();
  }

  bool touching = touchscreen.tirqTouched() && touchscreen.touched();

  if (touching) {
    // Throttle to ~30 Hz so redraws cannot outrun the panel.
    if (millis() - lastDraw >= 33) {
      lastDraw = millis();

      TS_Point p = touchscreen.getPoint();

      int16_t x = map(p.x, RAW_X_MIN, RAW_X_MAX, 0, tft.width() - 1);
      int16_t y = map(p.y, RAW_Y_MIN, RAW_Y_MAX, 0, tft.height() - 1);
      x = constrain(x, 0, tft.width() - 1);
      y = constrain(y, 0, tft.height() - 1);

      Serial.printf("touch raw(%4d,%4d) z=%4d -> screen(%3d,%3d)\n",
                    p.x, p.y, p.z, x, y);

      eraseCrosshair();

      snprintf(statusLine, sizeof(statusLine), "raw %d,%d  z%d", p.x, p.y, p.z);
      writeStatus();

      drawCrosshair(x, y);
      setRgb(false, false, true);
    }
    wasTouched = true;
  } else if (wasTouched) {
    // Released: clear the marker, back to the resting screen.
    eraseCrosshair();
    strncpy(statusLine, IDLE_MSG, sizeof(statusLine) - 1);
    writeStatus();
    setRgb(false, true, false);
    wasTouched = false;
  }
}
Full source: platformio.ini
; CYD (ESP32-2432S028R) starter: display, touch, RGB LED and light sensor.
;
;   pio run -t upload -t monitor
;
; Uses the pioarduino fork of platform-espressif32, which tracks Arduino
; core 3.x. The official platformio/espressif32 package is stuck on 2.x.
;
; TFT_eSPI is configured entirely here. USER_SETUP_LOADED=1 makes the library
; ignore its own User_Setup.h, so the project is self-contained.

[env:cyd]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
board = esp32dev
framework = arduino

monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
upload_speed = 460800

board_build.flash_mode = dio
board_build.f_flash = 80000000L

lib_deps =
    bodmer/TFT_eSPI@^2.5.43
    https://github.com/PaulStoffregen/XPT2046_Touchscreen.git

build_flags =
    -DUSER_SETUP_LOADED=1

    ; Panel. ILI9341_2_DRIVER suits most CYDs. Some later boards use an
    ; ST7789 instead: swap in -DST7789_DRIVER=1 if the screen stays blank.
    -DILI9341_2_DRIVER=1
    -DTFT_WIDTH=240
    -DTFT_HEIGHT=320

    ; Display on the HSPI pins
    -DUSE_HSPI_PORT=1
    -DTFT_MISO=12
    -DTFT_MOSI=13
    -DTFT_SCLK=14
    -DTFT_CS=15
    -DTFT_DC=2
    -DTFT_RST=-1
    -DTFT_BL=21
    -DTFT_BACKLIGHT_ON=HIGH

    ; Panels vary. If colours come out as their complements (red shows cyan,
    ; black shows white), uncomment this.
    ; -DTFT_INVERSION_ON=1

    ; Fonts used by the sketch
    -DLOAD_GLCD=1
    -DLOAD_FONT2=1
    -DLOAD_FONT4=1

    ; Bus speeds. Drop SPI_FREQUENCY to 27000000 if you see noise.
    -DSPI_FREQUENCY=40000000
    -DSPI_READ_FREQUENCY=6000000
    -DSPI_TOUCH_FREQUENCY=2500000

Projects