Building a wearable or desktop ESP32 watch requires balancing screen refresh rates, battery life, and physical footprint. Off-the-shelf smartwatches lock you into proprietary ecosystems, while raw ESP32 dev boards are too bulky for wrist or lanyard mounting. The solution is a custom build using the Seeed Studio XIAO ESP32S3 paired with a 1.28-inch GC9A01 round SPI TFT display. This combination yields a 240x240 pixel circular watch face in a footprint small enough to 3D-print a custom enclosure around, while retaining dual-core 240 MHz processing, WiFi 6, and Bluetooth 5.0.

This guide targets the Seeed Studio XIAO ESP32S3 (Standard variant, not the Sense variant). We will cover the exact bill of materials, hardware SPI pin mapping, complete compilable firmware using the Arduino_GFX library, and bench-tested debugging steps for the most common power and bus faults.

Bill of Materials and Hardware Specifications

Before wiring, verify you have the exact variants listed below. Substituting the XIAO S3 with an ESP32-C3 or standard ESP32 will change the pin mapping and available RAM, breaking the display buffer allocation. The GC9A01 must be the SPI version (usually 7 or 8 pins), not the I2C variant.

Difficulty Rating: Intermediate (Requires soldering 0.1" headers and managing 3.3V logic limits).
Estimated Build Time: 2 hours (hardware) + 1 hour (firmware tuning).
Estimated Cost: ~$34 USD (excluding 3D printed case).
Table 1: ESP32 Watch BOM and Component Specifications
Component Exact Variant / Model Key Specifications Est. Price
Microcontroller Seeed XIAO ESP32S3 Dual-core 240MHz, 512KB SRAM, 8MB PSRAM, 2.4GHz WiFi/BLE 5.0 $15.00
Display 1.28" GC9A01 Round TFT (SPI) 240x240 resolution, 65K colors, IPS panel, 3.3V logic, ~80mA backlight $9.50
Fuel Gauge MAX17048 I2C Breakout Coulomb counting, 12-bit ADC, alerts at < 20% State of Charge (SoC) $4.00
Battery 302030 LiPo Cell (250mAh) 3.7V nominal, 4.2V max, 1.25mm JST-PH connector, 1C discharge rate $5.50

Pin Mapping and Wiring the ESP32 Watch

The XIAO ESP32S3 exposes 11 usable GPIO pins. We will use the hardware SPI bus for the display to ensure smooth UI rendering, and the hardware I2C bus for the MAX17048 fuel gauge. Do not use software SPI (bit-banging) for the GC9A01; it will cause screen tearing and watchdog resets at high refresh rates.

Table 2: XIAO ESP32S3 to Peripherals Pin Mapping
XIAO Pin (Silkscreen) XIAO GPIO Number GC9A01 TFT Pin MAX17048 Pin
D8 (SCK) GPIO 7 SCL / CLK -
D10 (MOSI) GPIO 5 SDA / DIN -
D1 GPIO 2 CS -
D2 GPIO 3 DC / AO -
D3 GPIO 4 RST -
D0 GPIO 1 BLK / Backlight -
D5 (SCL) GPIO 8 - SCL
D4 (SDA) GPIO 9 - SDA
3V3 - VCC VIN
GND - GND GND

Wiring Steps and Power Decoupling

  1. Solder Headers: Solder the included 0.1" male headers to the XIAO ESP32S3. Keep the USB-C port overhanging the edge of your breadboard or perfboard.
  2. Wire SPI and I2C: Connect the display and fuel gauge according to Table 2. Keep SPI wires (SCK, MOSI, CS, DC) under 3 inches (7.5 cm) to prevent signal degradation at 40MHz clock speeds.
  3. Add Decoupling Capacitance: Solder a 100µF ceramic capacitor directly across the VCC and GND pins on the GC9A01 display breakout. The backlight LED draws up to 80mA in pulses; without local capacitance, the voltage sag will trigger the ESP32's brownout detector.
  4. Connect Battery: Plug the 302030 LiPo into the XIAO's JST connector. Ensure the polarity is correct (Red to +, Black to -). The XIAO has built-in charging via the USB-C port, so no external TP4056 module is needed.

Complete ESP32 Watch Firmware

We use the GFX Library for Arduino (Arduino_GFX) instead of TFT_eSPI. This allows us to define the SPI pins directly in the sketch without manually editing library header files, making the code fully copy-pasteable.

Prerequisites: Install the "Seeed XIAO ESP32S3" board package via the Arduino IDE Boards Manager, and install the GFX Library for Arduino via the Library Manager.

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Wire.h>
#include <Adafruit_MAX1704X.h> // Install via Library Manager

// --- PIN DEFINITIONS (XIAO ESP32S3) ---
#define TFT_CS    2   // D1
#define TFT_DC    3   // D2
#define TFT_RST   4   // D3
#define TFT_BLK   1   // D0
#define TFT_SCK   7   // D8
#define TFT_MOSI  5   // D10
#define TFT_MISO  -1  // Not used for GC9A01

#define I2C_SDA   9   // D4
#define I2C_SCL   8   // D5

// --- HARDWARE INSTANTIATION ---
Arduino_DataBus *bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCK, TFT_MOSI, TFT_MISO);
Arduino_GFX *gfx = new Arduino_GC9A01(bus, TFT_RST, 0, true); // 0=rotation, true=IPS

Adafruit_MAX17048 maxlipo;

void setup() {
  Serial.begin(115200);
  delay(500); // Allow serial monitor to connect

  // 1. Initialize Display
  if (!gfx->begin()) {
    Serial.println(F("[ERROR] GC9A01 Display initialization failed! Check SPI wiring."));
    while (1) { delay(100); } // Halt execution
  }
  
  gfx->fillScreen(BLACK);
  
  // Turn on backlight via PWM to avoid current spike
  ledcSetup(0, 5000, 8);
  ledcAttachPin(TFT_BLK, 0);
  ledcWrite(0, 200); // ~78% brightness

  // 2. Initialize I2C and Fuel Gauge
  Wire.begin(I2C_SDA, I2C_SCL);
  if (!maxlipo.begin()) {
    Serial.println(F("[WARN] MAX17048 not found. Check I2C wiring. Running without fuel gauge."));
  } else {
    Serial.println(F("[OK] MAX17048 Fuel Gauge initialized."));
  }

  // Draw static watch face elements
  gfx->setTextColor(WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(60, 110);
  gfx->print("ESP32");
}

void loop() {
  // Read battery percentage
  float cellPercent = 0.0;
  if (maxlipo.begin()) { // Quick check if still connected
    cellPercent = maxlipo.cellPercent();
  }

  // Render dynamic time (Placeholder: using millis for demo without RTC)
  unsigned long currentMillis = millis();
  int seconds = (currentMillis / 1000) % 60;
  int minutes = (currentMillis / 60000) % 60;
  int hours   = (currentMillis / 3600000) % 24;

  gfx->fillRect(40, 40, 160, 50, BLACK); // Clear time area
  gfx->setTextSize(3);
  gfx->setCursor(45, 50);
  
  char timeStr[9];
  sprintf(timeStr, "%02d:%02d:%02d", hours, minutes, seconds);
  gfx->print(timeStr);

  // Render Battery
  gfx->fillRect(80, 150, 80, 30, BLACK);
  gfx->setTextSize(1);
  gfx->setCursor(90, 160);
  gfx->printf("BAT: %5.1f%%", cellPercent);

  // Throttle refresh to 1Hz to save battery
  delay(1000); 
}

Debugging Common ESP32 Watch Build Failures

When a custom ESP32 watch fails to boot or render, the issue almost always traces back to power delivery or bus misconfiguration. If your serial monitor outputs an error, check these first three things:

  1. Logic Level Mismatch: Ensure you are not feeding 5V into the GC9A01 VCC pin. The XIAO outputs 3.3V logic, which is perfect, but a 5V VCC supply will fry the display's internal shift registers.
  2. Backlight Current Draw: If the screen is white/blank but the serial monitor shows the code is running, the backlight isn't turning on. Verify the BLK pin is driven via PWM, not just digitalWrite(HIGH), to manage the inrush current.
  3. SPI Pin Variant Confusion: The XIAO ESP32S3 has different GPIO mappings than the XIAO ESP32C3. Double-check that your SCK is on GPIO 7, not GPIO 4.

Exact Error Strings and Ranked Causes

Error String: Brownout detector was triggered
Meaning: The ESP32's internal voltage monitor detected VDD dropping below ~2.4V, forcing a reset to prevent flash corruption.
Ranked Causes:
  1. Missing 100µF decoupling capacitor on the display VCC/GND.
  2. LiPo battery is deeply discharged (below 3.2V) and cannot supply the 150mA peak boot current.
  3. USB cable has high resistance (thin gauge wires), causing voltage drop at the XIAO's USB-C port.
Error String: Guru Meditation Error: Core 1 panic'ed (StoreProhibited)
Meaning: The CPU attempted to write to an invalid memory address, usually a null pointer.
Ranked Causes:
  1. The gfx->begin() function failed silently (SPI wiring fault), and subsequent gfx->fillScreen() calls dereferenced a null display object.
  2. Insufficient PSRAM allocation. Ensure "OPI PSRAM" is enabled in the Arduino IDE Tools menu for the XIAO S3.

For deeper hardware-level debugging regarding the ESP32-S3's power domains and brownout thresholds, refer to the Espressif ESP32-S3 Technical Reference Manual.

Extending and Simplifying the Build

Once the base watch face is rendering, you have two paths: add sensors for a true smartwatch experience, or strip the hardware down for maximum battery life.

How to Extend: Add Motion and Notifications

To turn this into a step-counting smartwatch, add a Bosch BMA423 accelerometer breakout to the existing I2C bus (it shares the SDA/SCL lines with the MAX17048 but uses a different address, 0x19). The BMA423 features a built-in pedometer algorithm that offloads step-counting from the ESP32's CPU. For phone notifications, utilize the ESP32's BLE capabilities to act as a peripheral, subscribing to a custom UUID characteristic pushed by a companion Android app via the NimBLE-Arduino library.

How to Simplify: Drop the Fuel Gauge

If the MAX17048 breakout is too expensive or bulky, you can read the LiPo voltage directly using the XIAO ESP32S3's internal routing. On the bottom of the XIAO board, there is a tiny solder pad labeled BAT_READ. By bridging this pad with a blob of solder, you connect the internal battery voltage divider to GPIO 21. You can then use analogReadMilliVolts(21) in your code, multiply by the divider ratio (usually 2x or 3x depending on the board revision, check the Seeed XIAO S3 Wiki for the exact schematic), and estimate battery percentage using a standard LiPo discharge curve lookup table. This saves $4, reduces I2C bus traffic, and frees up physical space inside your 3D-printed enclosure.