100% Rust `no_std` smartwatch firmware for the Waveshare ESP32-S3-Touch-AMOLED-2.06
See the code
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.
| 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.
| 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.
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)
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
The firmware is designed to leave the CPU parked most of the time. The Embassy executor only wakes the core on:
| 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.
| 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) |
CTRL7 = 0x00 at boot, power-up only when requested by a consumer.needs_render() signals an actual change.wifi_controller.disconnect_async(); the 2.4 GHz radio is the biggest constant consumer. Automatic reconnect on next wake.Timer::after(...).await so the CPU stays parked during button debounces.(minutes % 9) - 4 pixels in X and Y, like Apple Watch.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
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.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
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"
WIFI_SSID="MyNetwork" WIFI_PASS="MyPassword" cargo build --release
The final binary is around 579 KB (full firmware with WiFi stack + games + UI).
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.
opt-level = "s" in dev AND release (size optimized).lto = true in release (global inlining, reduces size by ~20%).nanomp3 compiled and as a dependency, UI ready, SD → I2S stream to be wired.esp-radio compiled with stub feature, init disabled due to a panic btdm_controller_init -4 in coex with WiFi (requires additional coex config).ScanResult types ready, Settings UI shows the field but without scan.usb-device + usbd-storage).esp_hal::system::Sleep; we stay in light sleep via the Embassy executor, sufficient for watch usage.drivers/qspi_bus.rs: QspiBusHalf-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: Co5300DisplayInit sequence faithful to the C Arduino Waveshare driver Arduino_CO5300.cpp:
Functions: init, set_addr_window, set_brightness, display_on, display_off, bus_mut.
peripherals/audio.rs: Es8311ES8311 init based on the Waveshare C driver. Critical registers missing in my first attempt:
0x00 = 0x1F (reset) → 0x00 = 0x00 → 0x00 = 0x80 (power-on command, initially forgotten)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: Axp2101PowerWrapper around axp2101-embedded for battery monitoring + power rails.
peripherals/touch.rs: Ft3168TouchMonitor 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:
peripherals/imu.rs: Qmi8658ImuInit 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: Pcf85063aRtcBCD read/write of registers 0x04..0x0A. Auto conversion to DateTime { year, month, day, hours, minutes, seconds }.
peripherals/http.rs: http_get / http_postMinimal 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.
esp_hal::init(CpuClock::_160MHz)esp_alloc::psram_allocator!: 8 MB PSRAM heapesp_rtos::start(timg0.timer0): Embassy executorwifi_controller.connect_async().awaitembassy_net::Stack + StackResources<3>, spawn net_task taskntp_sync(): UDP to 216.239.35.0:123, parse timestamp, rtc.set_time()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
}
| 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) |
| 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) |
The original C++ project used:
Major steps of the rewrite:
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.
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.
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.
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.
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.
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.
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.
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.
721 followers · starred Apr 2026
739 followers · starred Apr 2026
144 followers · starred Apr 2026
148 followers · starred Apr 2026
100% Rust `no_std` smartwatch firmware for the Waveshare ESP32-S3-Touch-AMOLED-2.06
See the code
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.
| 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.
| 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.
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)
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
The firmware is designed to leave the CPU parked most of the time. The Embassy executor only wakes the core on:
| 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.
| 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) |
CTRL7 = 0x00 at boot, power-up only when requested by a consumer.needs_render() signals an actual change.wifi_controller.disconnect_async(); the 2.4 GHz radio is the biggest constant consumer. Automatic reconnect on next wake.Timer::after(...).await so the CPU stays parked during button debounces.(minutes % 9) - 4 pixels in X and Y, like Apple Watch.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
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.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
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"
WIFI_SSID="MyNetwork" WIFI_PASS="MyPassword" cargo build --release
The final binary is around 579 KB (full firmware with WiFi stack + games + UI).
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.
opt-level = "s" in dev AND release (size optimized).lto = true in release (global inlining, reduces size by ~20%).nanomp3 compiled and as a dependency, UI ready, SD → I2S stream to be wired.esp-radio compiled with stub feature, init disabled due to a panic btdm_controller_init -4 in coex with WiFi (requires additional coex config).ScanResult types ready, Settings UI shows the field but without scan.usb-device + usbd-storage).esp_hal::system::Sleep; we stay in light sleep via the Embassy executor, sufficient for watch usage.drivers/qspi_bus.rs: QspiBusHalf-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: Co5300DisplayInit sequence faithful to the C Arduino Waveshare driver Arduino_CO5300.cpp:
Functions: init, set_addr_window, set_brightness, display_on, display_off, bus_mut.
peripherals/audio.rs: Es8311ES8311 init based on the Waveshare C driver. Critical registers missing in my first attempt:
0x00 = 0x1F (reset) → 0x00 = 0x00 → 0x00 = 0x80 (power-on command, initially forgotten)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: Axp2101PowerWrapper around axp2101-embedded for battery monitoring + power rails.
peripherals/touch.rs: Ft3168TouchMonitor 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:
peripherals/imu.rs: Qmi8658ImuInit 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: Pcf85063aRtcBCD read/write of registers 0x04..0x0A. Auto conversion to DateTime { year, month, day, hours, minutes, seconds }.
peripherals/http.rs: http_get / http_postMinimal 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.
esp_hal::init(CpuClock::_160MHz)esp_alloc::psram_allocator!: 8 MB PSRAM heapesp_rtos::start(timg0.timer0): Embassy executorwifi_controller.connect_async().awaitembassy_net::Stack + StackResources<3>, spawn net_task taskntp_sync(): UDP to 216.239.35.0:123, parse timestamp, rtc.set_time()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
}
| 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) |
| 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) |
The original C++ project used:
Major steps of the rewrite:
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.
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.
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.
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.
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.
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.
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.
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.
721 followers · starred Apr 2026
739 followers · starred Apr 2026
144 followers · starred Apr 2026
148 followers · starred Apr 2026