Home
Softono
waveshare-watch-rs

waveshare-watch-rs

Open source Rust
330
Stars
39
Forks
3
Issues
3
Watchers
1 week
Last Commit

About waveshare-watch-rs

waveshare-watch-rs is a 100% Rust nostd smartwatch firmware for the Waveshare ESP32-S3-Touch-AMOLED-2.06. It is a complete conversion of the original C/C++ project into a single-binary Rust codebase using esp-hal 1.0, esp-rtos, Embassy, and custom peripheral drivers. The firmware supports the 410x502 QSPI AMOLED display at 80 MHz via DMA, I2S audio codec, 2.4 GHz WiFi with NTP sync, SD card storage, capacitive touch, gyroscope, hardware RTC, AXP2101 power management, and smart home control over HTTP. In addition, it includes a launcher with five mini-games, a T9 keyboard, an MP3 player interface, and a sleep/wake mode with an Apple Watch-style always-on display. An event-driven main loop based on GPIO interrupts keeps the CPU asleep over 99% of the time on the watchface. Graphics rendering uses embedded-graphics with FAT32 file access via embedded-sdmmc and includes a prepared nanomp3 decoder. Memory management combines 64 KB SRAM heap with 8 MB PSRAM heap through esp-alloc, with esp-backtrace providing panic

Platforms

Web Self-hosted

Languages

Rust

# waveshare-watch-rs

oosmetrics

Featured On

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

Component Reference Bus
SoC ESP32-S3R8 (Xtensa LX7 dual-core, 8 MB PSRAM)
Display CO5300 AMOLED 410×502, rounded edge QSPI 80 MHz
PMIC AXP2101 (charging, power rails) I2C 400 kHz
Touch FT3168 I2C + INT GPIO
IMU QMI8658 (accelerometer + gyroscope + temp) I2C
RTC PCF85063A I2C
Audio codec ES8311 + amp I2C + I2S
Memory 32 MB flash, 8 MB octal PSRAM
SD Card SDHC SPI SPI3
WiFi + BLE Integrated 2.4 GHz
Tearing Effect GPIO13 IRQ

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

Full pinout in src/board.rs.


Software stack

Layer Crate Role
HAL esp-hal 1.0 Peripherals (GPIO, I2C, SPI, I2S, DMA, timers, PSRAM)
Runtime esp-rtos 0.2 Boot, executor, radio integration
Async embassy-executor 0.9 Cooperative scheduler, tasks, timers
embassy-time, embassy-futures select, Timer::after, Duration
embassy-net 0.9 smoltcp TCP/IP stack (DHCPv4, TCP, UDP, DNS)
Radio esp-radio 0.17 WiFi driver + Embassy interface
Graphics embedded-graphics 0.8 2D primitives, fonts, text layout
Storage embedded-sdmmc 0.8 FAT32 on SD card
Codec nanomp3 0.1 no_std MP3 decoder (prepared, not wired)
Allocator esp-alloc 64 KB SRAM heap + 8 MB PSRAM heap
Panic esp-backtrace Symbolic 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)

State Brightness Idle trigger Behavior
3 0xD0 Normal interactive, full bright
2 0x40 20 s Dimming (transition), still interactive
1 0x18 40 s AOD: minimal HH:MM, pure black background (AMOLED pixels OFF), 1 update/min
0 DISPOFF 10 min in AOD SLPIN 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

Context Tick Effective frequency
Screen OFF (state 0) 30 s 0.033 Hz
AOD (state 1) 10 s 0.1 Hz
Watchface clock, gyro off 1 s 1 Hz
Watchface clock, gyro on 33 ms ~30 Hz
Sensors page 100 ms 10 Hz
System page 2 s 0.5 Hz
Launcher / Settings / MP3 / SmartHome 100 ms 10 Hz
Snake / 2048 / Tetris / Maze 16 ms ~60 Hz
Flappy 8 ms 125 Hz
Finger held on the screen 16 ms 60 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

Metric Value
Lines of Rust 5 545
Source files 23
Release binary 579 KB
Dependency crates ~35
Lines of C/C++ 0
SRAM Heap 64 KB
Allocated PSRAM ~1.2 MB
Framebuffers 2 x 402 KB
Handwritten drivers 8 (QSPI, CO5300, AXP2101, FT3168, QMI8658, PCF85063A, ES8311, HTTP)

C++ vs Rust comparison

Aspect C++ (ESP-IDF + Arduino) Rust (esp-hal + Embassy)
Runtime FreeRTOS (preemptive, ~20 KB RAM) Embassy async (cooperative, ~0 KB overhead)
UI Stack LVGL (C, ~100 KB RAM) embedded-graphics (Rust, zero alloc)
Display driver Arduino GFX (Arduino_CO5300.cpp) Custom driver qspi_bus.rs + co5300.rs
SPI Bus ESP-IDF spi_device, polling esp-hal half_duplex_write, DMA 8 KB
Power management XPowersLib (C++) Custom driver power.rs on embedded-hal I2C
Audio ES8311 Arduino driver Custom driver audio.rs (registers faithful to C)
WiFi ESP-IDF wifi_init + lwIP esp-radio + embassy-net (smoltcp)
Sleep Not implemented 4 levels + AOD, event-driven select3
Build system PlatformIO / Arduino IDE Cargo, Xtensa cross-compile via espup
Safety Raw pointers, buffer overflows Ownership, 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