RaspRover-RS is a monorepo for the Waveshare RaspRover robot platform:
firmware/— embedded firmware for the Espressif ESP32 DevKit-C (Xtensa) on the ROS Driver Board, using esp-rtos with Embassy. All esp-hal family crates are consumed directly from crates.io — seefirmware/Cargo.tomlfor pinned versions.robot/— ROS 2 (Lyrical Luth) workspace for the Raspberry Pi host, built withcolcon.data/— mechanical CAD and reference documents for the RaspRover platform.
Both halves live under one pixi workspace (pixi.toml), with a separate environment each:
pixi run -e firmware build # cargo build --release
pixi run -e firmware flash # cargo run --release
pixi run -e firmware lint # clippy, see below
pixi run -e ros build # colcon build --symlink-installThe firmware tasks are thin wrappers — plain Cargo from firmware/ works too, provided the esp
toolchain and xtensa-esp32-elf-gcc are on PATH (see firmware/esp-activate.sh).
Do not lint with cargo clippy --all-targets: it ignores the test = false flags on [lib] and
[[bin]] and links them against a libtest that does not exist for xtensa-esp32-none-elf. The lint
task runs cargo clippy --lib --bins --tests -- -D warnings, which covers the same code.
WiFi credentials are configured via environment variables (with fallback defaults in firmware/.cargo/config.toml):
export CONFIG_WIFI_NETWORK="your-ssid"
export CONFIG_WIFI_PASSWORD="your-password"This project requires the ESP Rust toolchain (not stable/nightly), which supports the Xtensa instruction set used by ESP32:
- Toolchain:
esp(RUSTUP_TOOLCHAIN=esp) - Target:
xtensa-esp32-none-elf
Rust Analyzer is configured via .vscode/settings.json with the correct RUSTFLAGS and environment variables for the ESP32 target. If using VS Code, these settings are picked up automatically.
The codebase is no_std + no_main — standard library and Rust runtime are unavailable. All async code runs in the Embassy executor provided by esp-rtos. However, alloc is available — the heap is configured with esp_alloc::heap_allocator!, so Box, Vec, etc. work fine.
Entry point pattern:
#![no_main]
#![no_std]
extern crate alloc;
esp_bootloader_esp_idf::esp_app_desc!();
#[esp_rtos::main]
async fn main(spawner: Spawner) {
rtt_target::rtt_init_defmt!();
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let p = esp_hal::init(config);
esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 98768);
let timg0 = TimerGroup::new(p.TIMG0);
let sw_int = SoftwareInterruptControl::new(p.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_int.software_interrupt0);
// application code
}esp_rtos::start(...) must be called inside the async main function after heap init, and takes a
timer plus a software interrupt.
Key dependency structure:
esp-rtos— async runtime (Embassy executor + timer integration)esp-radio— WiFi viaesp_radio::wifiembassy-net— TCP/IP networkingembassy-sync— synchronisation primitives (Watch,Mutex, etc.)- All esp-hal family crates are consumed from crates.io at the versions pinned in
Cargo.toml
Cargo configuration (firmware/.cargo/config.toml) is self-contained — no external includes. Sets the build target, linker arguments, build-std, and WiFi credential defaults.
Uses defmt structured logging via RTT. Log level is controlled by the DEFMT_LOG environment variable (default: info in firmware/.cargo/config.toml). Import macros directly from defmt:
use defmt::{debug, error, info, warn};
use panic_rtt_target as _;
info!("message {}", value);Initialize RTT at the top of main before any logging:
rtt_target::rtt_init_defmt!();The panic handler is provided by panic-rtt-target. The rtt-target crate (with features = ["defmt"]) provides the transport.
Peripherals struct (firmware/src/board.rs) is a plain Rust struct. Use concrete peripheral types from esp_hal::peripherals for typed pins and AnyPin<'static> (via .into()) for interchangeable pins:
pub struct Peripherals {
pub i2c_sda: GPIO32<'static>,
pub i2c_scl: GPIO33<'static>,
pub motor_ain1: AnyPin<'static>, // p.GPIO21.into()
// ...
}All peripherals have 'static lifetime — GPIO21<'static>, LEDC<'static>, etc. This propagates through esp-hal types (Output<'static>, Channel<'static, LowSpeed>, etc.).
PWM (LEDC): Use esp_hal::ledc directly with the unstable feature. LEDC source is in the esp-hal crate under src/ledc/ (browse via cargo doc --open or in ~/.cargo/registry/src/).
Self-referential HAL structs: Channel<'a, LowSpeed> holds a &'a dyn TimerIFace so the timer must outlive the channel. Use Box::leak(Box::new(timer)) to get a &'static Timer — this is safe given the always-available heap.
I2C: Use esp_hal::i2c::master::I2c directly:
I2c::new(i2c0, config).unwrap().with_sda(sda).with_scl(scl).into_async()Wrap with embassy_embedded_hal::shared_bus::asynch::i2c::I2cDevice for sharing across tasks.
Rng: Rng::new() — no arguments.
WiFi is initialized in network::init():
let (controller, interfaces) = esp_radio::wifi::new(wifi, Default::default()).unwrap();
let wifi_device: Interface<'static> = interfaces.station;controller.is_connected()— check connection statuscontroller.set_config(&Config::Station(station_config))— configure, whereConfigisesp_radio::wifi::ConfigStationConfig::default().with_ssid(&str).with_password(alloc::string::String)— note the two argument types differ
Encapsulate subsystems as spawned tasks — each major subsystem (network, display, etc.) owns its lifecycle via #[embassy_executor::task] functions and a dedicated src/<subsystem>.rs module. main coordinates but does not inline subsystem logic.
Use Watch for state broadcasting — embassy_sync::watch::Watch is the preferred primitive for publishing subsystem state to other tasks:
- Delivers the current value to late subscribers (no missed events on join)
try_get()enables non-blocking polling in tight loops- Declare as a
pub staticin the owning module; consumers call.receiver().unwrap() - Size N to the number of concurrent receivers needed
// In the owning module (e.g. src/network.rs):
pub static NET_STATE: Watch<CriticalSectionRawMutex, NetworkState, 2> = Watch::new();
// In a consumer:
let mut net_rx = network::NET_STATE.receiver().unwrap();
if let Some(state) = net_rx.try_get() { /* react */ }Carry data in enum variants — state enums should embed associated payload directly (e.g. Up(String<18>)) rather than storing it separately.
Track last-seen state to avoid redundant work — consumers that perform side effects should cache the last processed state and only act when it changes:
let mut last_state: Option<NetworkState> = None;
if Some(&state) != last_state.as_ref() {
// redraw / update
last_state = Some(state);
}