waveshare-watch-rs

June 8, 2026 · View on GitHub

oosmetrics

image

100% Rust no_std smartwatch firmware for the Waveshare ESP32-S3-Touch-AMOLED-2.06.

Complete conversion of the original C/C++ project (ESP-IDF + Arduino GFX + LVGL) to a single-binary Rust codebase relying on esp-hal 1.0, esp-rtos, Embassy, and custom drivers for each of the board's peripherals.

The firmware handles the 410×502 QSPI display in 80 MHz DMA, the I2S audio codec, 2.4 GHz WiFi with NTP sync, SD card, capacitive touch, gyroscope, hardware RTC, AXP2101 power management, a launcher with 5 mini-games, a T9 keyboard, an MP3 player (UI), a Smart Home app (HTTP), a sleep/wake mode with an Apple Watch style Always-On Display, and an event-driven main loop based on GPIO interrupts to leave the CPU asleep >99% of the time on the watchface.


Hardware target

ComponentReferenceBus
SoCESP32-S3R8 (Xtensa LX7 dual-core, 8 MB PSRAM)
DisplayCO5300 AMOLED 410×502, rounded edgeQSPI 80 MHz
PMICAXP2101 (charging, power rails)I2C 400 kHz
TouchFT3168I2C + INT GPIO
IMUQMI8658 (accelerometer + gyroscope + temp)I2C
RTCPCF85063AI2C
Audio codecES8311 + ampI2C + I2S
Memory32 MB flash, 8 MB octal PSRAM
SD CardSDHC SPISPI3
WiFi + BLEIntegrated 2.4 GHz
Tearing EffectGPIO13IRQ

Waveshare Wiki reference: https://www.waveshare.com/wiki/ESP32-S3-Touch-AMOLED-2.06

Full pinout in src/board.rs.


Software stack

LayerCrateRole
HALesp-hal 1.0Peripherals (GPIO, I2C, SPI, I2S, DMA, timers, PSRAM)
Runtimeesp-rtos 0.2Boot, executor, radio integration
Asyncembassy-executor 0.9Cooperative scheduler, tasks, timers
embassy-time, embassy-futuresselect, Timer::after, Duration
embassy-net 0.9smoltcp TCP/IP stack (DHCPv4, TCP, UDP, DNS)
Radioesp-radio 0.17WiFi driver + Embassy interface
Graphicsembedded-graphics 0.82D primitives, fonts, text layout
Storageembedded-sdmmc 0.8FAT32 on SD card
Codecnanomp3 0.1no_std MP3 decoder (prepared, not wired)
Allocatoresp-alloc64 KB SRAM heap + 8 MB PSRAM heap
Panicesp-backtraceSymbolic backtrace via esp-println

Zero C code, zero ESP-IDF components compiled into the final target. The bootloader is esp-bootloader-esp-idf on the loader side only.


Architecture

main.rs

├── esp_hal::init(CpuClock::_160MHz)
├── PSRAM allocator init
├── esp_rtos::start(timer)         ← Embassy executor

├── [Peripherals init] ──── drivers/ + peripherals/
│   ├── Shared I2C bus (RefCell + RefCellDevice)
│   ├── AXP2101 power rails + battery monitor
│   ├── QSPI SPI2 80 MHz DMA 8 KB → Co5300Display
│   ├── PSRAM Framebuffer double buffer (2 × 402 KB)
│   ├── FT3168 touch + GPIO38 INT
│   ├── PCF85063A RTC
│   ├── QMI8658 IMU (power_down by default)
│   ├── SD SPI3 4 MHz
│   ├── ES8311 codec + I2S0 DMA
│   └── WiFi esp-radio + embassy-net DHCPv4 + NTP sync

├── [Event-driven loop]
│   │
│   ├── select3(
│   │       Timer::after(adaptive_tick),
│   │       touch_int.wait_for_falling_edge(),
│   │       boot_button.wait_for_falling_edge(),
│   │     ).await
│   │
│   ├── Sensors I/O (gated by screen_state + business need)
│   ├── Touch poll I2C (only if finger is pressed or just lifted)
│   ├── State machine sleep/wake (4 levels + AOD)
│   ├── WiFi auto-disconnect idle >5min
│   └── App state machine
│       ├── Watchface (3 pages: Clock / Sensors / System)
│       ├── Launcher
│       ├── Snake, 2048, Tetris, Flappy, Maze
│       ├── MP3 Player (UI)
│       ├── Settings + T9 keyboard
│       └── SmartHome (buttons → HTTP)

Module organization

src/
├── main.rs                 Hardware init + async main loop
├── board.rs                Pinout + display dimensions

├── drivers/                Low-level drivers (direct hardware)
│   ├── qspi_bus.rs         QSPI bus quad-mode half-duplex, begin/stream/end
│   ├── co5300.rs           CO5300 init sequence, addr window, set_brightness,
│   │                       display_on/off (MIPI DCS), TEARON
│   └── framebuffer.rs      410×502 RGB565 PSRAM FB, double buffer, flush_vsync

├── peripherals/            High-level I2C / SPI / I2S drivers
│   ├── power.rs            AXP2101: battery %, voltage, is_charging, power rails
│   ├── touch.rs            FT3168: read, tracking swipe/tap, SwipeDirection
│   ├── rtc.rs              PCF85063A: get_time, set_time, DateTime
│   ├── imu.rs              QMI8658: read_accel/gyro/temp, power_up/down
│   ├── audio.rs            ES8311: Waveshare registers init, mute/unmute, beep
│   ├── sdcard.rs           Stub wrapper around embedded-sdmmc
│   ├── wifi.rs             WiFi types (scan stub)
│   └── http.rs             HTTP GET/POST client via embassy-net TCP

├── ui/                     UI components rendered on DrawTarget<Rgb565>
│   ├── watchface.rs        Clock + gyro ball + battery + FR date + AOD
│   ├── segments.rs         7-segment digits for time
│   ├── pages.rs            Clock / Sensors / System pages
│   ├── launcher.rs         App list, interpolated scroll
│   └── t9_keyboard.rs      Alphanumeric T9 keyboard

└── apps/                   Applications (implement the App trait)
    ├── snake.rs            Snake with I2S beep on consume
    ├── game2048.rs         2048 swipe merge
    ├── tetris.rs           Tetris gyro + touch
    ├── flappy.rs           Flappy Bird (direct rendering + framebuffer)
    ├── maze.rs             Maze with IMU ball
    ├── settings.rs         WiFi SSID/password + T9
    ├── mp3player.rs        MP3 player UI (decoding to be wired)
    └── smarthome.rs        Button grid → HTTP GET/POST

Power management

The firmware is designed to leave the CPU parked most of the time. The Embassy executor only wakes the core on:

  • the GPIO38 touch interrupt (FT3168 in monitor mode)
  • the GPIO0 button interrupt
  • a periodic timer whose period depends on the current state

Screen states (4 levels + AOD)

StateBrightnessIdle triggerBehavior
30xD0Normal interactive, full bright
20x4020 sDimming (transition), still interactive
10x1840 sAOD: minimal HH:MM, pure black background (AMOLED pixels OFF), 1 update/min
0DISPOFF10 min in AODSLPIN panel, QSPI idle, only GPIO IRQ for wake

On wake via touch/button: immediate return to state 3, framebuffer forced into full redraw.

Adaptive main loop ticks

ContextTickEffective frequency
Screen OFF (state 0)30 s0.033 Hz
AOD (state 1)10 s0.1 Hz
Watchface clock, gyro off1 s1 Hz
Watchface clock, gyro on33 ms~30 Hz
Sensors page100 ms10 Hz
System page2 s0.5 Hz
Launcher / Settings / MP3 / SmartHome100 ms10 Hz
Snake / 2048 / Tetris / Maze16 ms~60 Hz
Flappy8 ms125 Hz
Finger held on the screen16 ms60 Hz (override)

Extra optimizations

  • 160 MHz CPU by default (instead of 240 MHz), ~30% CPU power saving.
  • IMU power-down: CTRL7 = 0x00 at boot, power-up only when requested by a consumer.
  • Touch I2C polled only when the finger is placed (GPIO38 LOW) or just lifted.
  • RTC polled at 1 Hz (instead of 5 Hz before optimization).
  • Battery polled at 1/60 Hz (1/300 Hz when the screen is off).
  • Conditional Watchface flush: the PSRAM FB is flushed only if needs_render() signals an actual change.
  • WiFi auto-disconnect after 5 mins of inactivity: wifi_controller.disconnect_async(); the 2.4 GHz radio is the biggest constant consumer. Automatic reconnect on next wake.
  • TE VSync spin limited to 400 iterations (instead of 2000) to avoid wasting cycles when TE doesn't pulse.
  • Blocking delays replaced by Timer::after(...).await so the CPU stays parked during button debounces.
  • Audio PA amp (GPIO46) held LOW at boot, codec muted (DAC power-down + HP drive off) immediately after init. The amp is pulled HIGH only while writing the beep via DMA, then pulled back down.
  • AOD Anti burn-in: the position of the HH:MM block in AOD is shifted by (minutes % 9) - 4 pixels in X and Y, like Apple Watch.

Transition order

touch/button       interaction +0s    state 3 (full)
   └─ +20s idle    ────────────────→  state 2 (dim)
   └─ +40s idle    ────────────────→  state 1 (AOD, if Clock page) or state 0 (otherwise)
   └─ +300s idle   ────────────────→  WiFi disconnect
   └─ +600s idle   ────────────────→  state 0 (full OFF)

touch or button GPIO IRQ             → immediate state 3, WiFi reconnect follows

Display pipeline

Embedded-graphics draw calls


410×502 u16 RGB565 PSRAM Framebuffer  (402 KB back buffer)

         │  fb.flush() OR fb.flush_vsync(te_pin)

Co5300Display::set_addr_window(...)


QspiBus::write_pixels()


esp-hal SPI2 half_duplex_write(
    DataMode::Quad,             ← 4-bit QSPI mode
    Command::_8Bit(0x12),       ← write memory
    Address::_24Bit(0x003C00),
    dummy = 0,
    buffer,                      ← pixel data in quad mode
)


DMA_CH0 → GPIO SIO0..SIO3 @ 80 MHz
  • swap_and_flush(): double buffer, for games (Flappy); zero tearing.
  • flush_vsync(): single buffer, waits for a TE pulse (GPIO13) before sending pixels.
  • flush_region(x, y, w, h): partial update, used by watchface partial updates.

Build setup

Prerequisites

  • Xtensa ESP Rust toolchain (installed via espup):

    cargo install espup
    espup install
    

    rust-toolchain.toml pins channel = "esp".

  • MSVC linker (Windows): needed for host build scripts. Install "Desktop development with C++" via Visual Studio Installer. The link.exe must be in the PATH when running cargo. On this project we typically have:

    export PATH="/c/Program Files/Microsoft Visual Studio/18/Community/VC/Tools/MSVC/14.50.35717/bin/Hostx64/x64:$PATH"
    
  • espflash:

    cargo install espflash
    

WiFi credentials

SSID and password are read at compile-time via env!(). They must be defined before building:

# Linux / macOS / Git Bash (Windows)
export WIFI_SSID="MyNetwork"
export WIFI_PASS="MyPassword"
# PowerShell
$env:WIFI_SSID = "MyNetwork"
$env:WIFI_PASS = "MyPassword"

Build

WIFI_SSID="MyNetwork" WIFI_PASS="MyPassword" cargo build --release

The final binary is around 579 KB (full firmware with WiFi stack + games + UI).

Flash + serial monitor

espflash flash --port COM7 --monitor target/xtensa-esp32s3-none-elf/release/waveshare-watch-rs

On Linux: /dev/ttyACM0 or /dev/ttyUSB0 depending on the USB bridge.

Build config

  • opt-level = "s" in dev AND release (size optimized).
  • lto = true in release (global inlining, reduces size by ~20%).
  • 64 KB SRAM heap (for the WiFi stack), 8 MB PSRAM heap (framebuffers + Vecs).

Features

Integrated and working

  • 410×502 QSPI 80 MHz DMA double-buffer display
  • PSRAM framebuffer + DMA flush
  • Tearing Effect VSync anti-tearing
  • FT3168 touch + iOS-like swipe detection + tap + diagonal rejection
  • QMI8658 IMU accel + gyro + temperature with power management
  • PCF85063A RTC + NTP sync via embassy-net UDP
  • WiFi STA: DHCPv4, NTP, HTTP GET/POST, auto-disconnect idle
  • ES8311 audio + I2S DMA (Snake beep)
  • SD Card 4 GB detection (FAT32 /mp3 scan in place, stable mount if MBR is valid)
  • Watchface 3 pages: Clock (7-segment time + battery + FR date + gyro ball), Sensors, System
  • Launcher app list with smooth scroll
  • Games: Snake, 2048, Tetris, Flappy Bird, Maze (gyro)
  • Settings: WiFi SSID/password fields with T9 keyboard
  • SmartHome: configurable 6-button HTTP grid
  • MP3 Player: UI (play/pause, prev/next, progress bar)
  • Screen sleep/wake 4 levels with minute-by-minute Always-On Display
  • Boot button = launcher, swipe up = launcher

Partially wired / stubbed

  • MP3 decoding: nanomp3 compiled and as a dependency, UI ready, SD → I2S stream to be wired.
  • BLE: esp-radio compiled with stub feature, init disabled due to a panic btdm_controller_init -4 in coex with WiFi (requires additional coex config).
  • WiFi scan list: ScanResult types ready, Settings UI shows the field but without scan.
  • USB Mass Storage: not wired (copy-from-PC would require usb-device + usbd-storage).
  • ESP deep sleep: no esp_hal::system::Sleep; we stay in light sleep via the Embassy executor, sufficient for watch usage.

Detailed custom drivers

drivers/qspi_bus.rs: QspiBus

Half-duplex quad-SPI bus for the CO5300. API:

fn write_command(&mut self, cmd: u8)
fn write_c8d8(&mut self, cmd: u8, data: u8)
fn write_pixels(&mut self, pixels: &[u16])
fn begin_pixels(&mut self)
fn stream_pixels(&mut self, pixels: &[u16])
fn end_pixels(&mut self)

Uses esp-hal Spi::half_duplex_write() with Command::_8Bit + Address::_24Bit. DataMode::Single is used for commands, DataMode::Quad for pixels.

drivers/co5300.rs: Co5300Display

Init sequence faithful to the C Arduino Waveshare driver Arduino_CO5300.cpp:

  • Hardware reset (10ms low, 120ms high)
  • SLPOUT (0x11) + delay 120 ms
  • 0xFE 0x00 (vendor register access)
  • 0xC4 0x80 (SPI mode control)
  • 0x3A 0x55 (RGB565 pixel format)
  • 0x53 0x20 (write CTRL display)
  • 0x63 0xFF (HBM brightness)
  • DISPON (0x29)
  • 0x51 0xD0 (brightness)
  • 0x35 0x00 (TEARON VBlank only)

Functions: init, set_addr_window, set_brightness, display_on, display_off, bus_mut.

peripherals/audio.rs: Es8311

ES8311 init based on the Waveshare C driver. Critical registers missing in my first attempt:

  • 0x00 = 0x1F (reset) → 0x00 = 0x000x00 = 0x80 (power-on command, initially forgotten)
  • Clock coefficients for 4.096 MHz MCLK @ 16 kHz sample rate
  • 0x0D = 0x01, 0x0E = 0x02 (power up analog)
  • 0x12 = 0x00 (DAC power up), 0x13 = 0x10 (HP drive)
  • 0x32 = 0xD9 (volume 85%)

API: init, mute (DAC power-down + HP off + vol 0), unmute, set_volume.

peripherals/power.rs: Axp2101Power

Wrapper around axp2101-embedded for battery monitoring + power rails.

peripherals/touch.rs: Ft3168Touch

Monitor mode (REG_POWER_MODE = 0x01): the chip asserts GPIO38 only on a touch event. Internal state machine to distinguish tap / swipe up/down/left/right with:

  • minimum 30 px threshold to qualify a swipe
  • 1.5× ratio on the dominant axis to reject diagonal swipes
  • tracking start/end coordinates

peripherals/imu.rs: Qmi8658Imu

Init accel ±2g @ 500 Hz, gyro ±512 dps @ 119 Hz, LPF enabled.

fn read_accel() -> AccelData    // m.x, y, z in g
fn read_gyro() -> GyroData      // °/s
fn read_temperature() -> f32    // °C
fn power_up() / power_down()    // CTRL7 0x03 / 0x00

peripherals/rtc.rs: Pcf85063aRtc

BCD read/write of registers 0x04..0x0A. Auto conversion to DateTime { year, month, day, hours, minutes, seconds }.

peripherals/http.rs: http_get / http_post

Minimal HTTP client without external crate: parse URL, TcpSocket::connect, format request manually, custom write_all (handling partial writes), read until close, parse status code + body truncated to 128 bytes.


Runtime data flow

On boot

  1. esp_hal::init(CpuClock::_160MHz)
  2. esp_alloc::psram_allocator!: 8 MB PSRAM heap
  3. esp_rtos::start(timg0.timer0): Embassy executor
  4. Sequential init of all I2C/SPI/I2S drivers
  5. wifi_controller.connect_async().await
  6. embassy_net::Stack + StackResources<3>, spawn net_task task
  7. Wait for DHCP IP
  8. ntp_sync(): UDP to 216.239.35.0:123, parse timestamp, rtc.set_time()
  9. Initial watchface render
  10. Enter main loop

In the loop

loop {
    // Choose tick based on state
    let tick = match (screen_state, app_state, current_page, gyro_enabled) { ... };

    // Sleep until next event
    select3(
        Timer::after(tick),
        touch_int.wait_for_falling_edge(),
        boot_button.wait_for_falling_edge(),
    ).await;

    // Sensors throttled by need + screen_state
    if need_imu { imu.read_accel(); ... }
    if screen_state >= 2 && now >= next_rtc { rtc.get_time(); ... }
    if now >= next_battery { power.get_battery_percent(); ... }

    // Conditional touch poll (finger placed or just lifted)
    if touch_active { touch.poll(); ... }

    // Sleep/wake state machine → transitions 3→2→1→0
    // WiFi auto-disconnect
    // AOD render path (1x/min) → continue
    // Screen OFF → continue
    // App state machine → render + conditional flush
}

Project metrics

MetricValue
Lines of Rust5 545
Source files23
Release binary579 KB
Dependency crates~35
Lines of C/C++0
SRAM Heap64 KB
Allocated PSRAM~1.2 MB
Framebuffers2 x 402 KB
Handwritten drivers8 (QSPI, CO5300, AXP2101, FT3168, QMI8658, PCF85063A, ES8311, HTTP)

C++ vs Rust comparison

AspectC++ (ESP-IDF + Arduino)Rust (esp-hal + Embassy)
RuntimeFreeRTOS (preemptive, ~20 KB RAM)Embassy async (cooperative, ~0 KB overhead)
UI StackLVGL (C, ~100 KB RAM)embedded-graphics (Rust, zero alloc)
Display driverArduino GFX (Arduino_CO5300.cpp)Custom driver qspi_bus.rs + co5300.rs
SPI BusESP-IDF spi_device, pollingesp-hal half_duplex_write, DMA 8 KB
Power managementXPowersLib (C++)Custom driver power.rs on embedded-hal I2C
AudioES8311 Arduino driverCustom driver audio.rs (registers faithful to C)
WiFiESP-IDF wifi_init + lwIPesp-radio + embassy-net (smoltcp)
SleepNot implemented4 levels + AOD, event-driven select3
Build systemPlatformIO / Arduino IDECargo, Xtensa cross-compile via espup
SafetyRaw pointers, buffer overflowsOwnership, borrow checker, no UB
Firmware size~1.2 MB (ESP-IDF + LVGL + WiFi)579 KB (all included)

Conversion history (C++ to Rust)

The original C++ project used:

  • ESP-IDF + FreeRTOS
  • Arduino GFX for the CO5300
  • LVGL for the UI
  • ES8311 codec via Arduino driver
  • XPowersLib for the AXP2101

Major steps of the rewrite:

  1. QSPI bus + CO5300. The hardest part: discovering that esp-hal half_duplex_write supports DataMode::Quad via the Command + Address machinery. Initial bug: using with_miso (input) instead of with_sio1 (output) caused SIO1 to float → all blacks appeared green.

  2. AXP2101. Activating the DC1 (3.3 V main) and ALDO1 (panel) rails via registers 0x80 and 0x92, otherwise the screen stays black even with the CO5300 correctly initialized.

  3. PSRAM Framebuffer. Alignment issue: the CO5300 is strict on even widths for partial writes. Added even-rounding logic in flush_region. The PSRAM allocator requires features = ["psram"] on esp-hal + esp_alloc::psram_allocator! macro after esp_hal::init.

  4. ES8311 Audio. 4 attempts before getting sound: the correct public method to play via I2S is write_dma() (not write() which is private). The init must exactly match the C sequence, particularly the write_reg(0x00, 0x80) after the reset, otherwise the codec stays in power-down.

  5. Event-driven loop. Converted from loop { Timer::after(5ms).await; ... } to select3(Timer, touch_edge, button_edge). Gain: CPU wake-ups reduced by ~6000× in screen OFF and ~200× in idle watchface.

  6. BLE. Init attempt with esp_radio::ble::BleConnector::new → panic btdm_controller_init returned -4. BLE disabled in Cargo.toml features pending a correct coex configuration.

  7. Sleep/wake. Initial bug: the display_on() sequence did DISPON then SLPOUT (incorrect order), so DISPON happened while the panel was still in SLPIN. Fixed to SLPOUT (120 ms) → DISPON (20 ms), standard MIPI DCS order.


License

Licensed under either of

at your option.

Hardware drivers were written from scratch, informed by Waveshare's C/C++ examples and the esp-hal ecosystem.


Resources

Star History

Star History Chart