waveshare-watch-rs Featured On Hackaday: Rust-y Firmware For Waveshare Smartwatch 100% Rust nostd 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…
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.
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
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:
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
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.
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.
评论