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:
- Build and fix. It runs
pio run, reads the compiler errors and fixes them, repeating until the build is clean. - Flash. With the board plugged in, it can run the upload itself.
- Explain. It can read a vendor driver and explain what a constant does and whether the default suits your hardware, then document the answer in the README.
- Diagnose. When the hardware misbehaves, it can write a small diagnostic firmware to isolate the problem, rather than guessing at the main code.
- Keep the paperwork. It updates comments and READMEs when the code changes, and writes the commit messages.
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
- Start with a
CLAUDE.md. Claude Code reads this file from the project root at the start of every session. Put the board, the wiring, the pin numbers and any hard-won quirks in it, so it never has to rediscover them. Running/initdrafts one from the existing code. - Keep the serial monitor in a second terminal.
pio device monitorruns until you stop it, so it is better in its own window. Paste the lines that matter into Claude Code, or ask it for a script that captures a few seconds of serial output and exits. - Bring up hardware one piece at a time. Get the display, then touch, then each sensor working in a small standalone sketch before combining them. It is far easier to debug one unknown than five.
- Commit often. Every working state is a commit. If an experiment goes wrong, you can go straight back.
- Ask why, not just what. Asking Claude Code to explain a constant or a register setting often turns up an assumption that doesn't hold for your hardware.
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:
- prints the chip model, flash size and free memory over serial
- turns on the backlight and draws a header, footer and four red corner dots
- draws a crosshair wherever you touch, and shows the raw touch reading
- lights the RGB LED green when idle and blue while you're touching
- shows the light sensor reading in the footer
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
- Colours are inverted (red shows as cyan, black as white): uncomment
-DTFT_INVERSION_ON=1inplatformio.ini. - The screen stays white or blank: some newer CYDs use an ST7789 display
controller. Replace
-DILI9341_2_DRIVER=1with-DST7789_DRIVER=1. - Touch works, but the crosshair is mirrored or rotated: swap the MIN and MAX values for that axis.
These are also good first prompts for Claude Code. Start it in the project
folder with claude, and try something like:
- "The display shows red as cyan. Find the fix in platformio.ini, rebuild and flash."
- "Explain how the touch calibration in main.cpp maps raw readings to screen pixels."
- "Add a button in the middle of the screen that toggles the backlight."
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