Building a Complete Sensor Hub
It is time to put everything together. In this chapter, we build a complete sensor hub — the kind of system you would find at the heart of a drone flight controller, a weather station, or an industrial monitoring unit. Every concept from the previous chapters appears here: SPI, I2C, ADC, DMA, timers, UART, watchdogs, channels, and async tasks.
What We Are Building
Our sensor hub reads from multiple sensors at different rates, fuses the data, and streams telemetry over UART:
| Component | Interface | Rate | Purpose |
|---|---|---|---|
| ICM-42688 IMU | SPI1 | 1000 Hz | Accelerometer + Gyroscope |
| BMP280 Barometer | I2C1 | 50 Hz | Altitude estimation |
| Battery voltage | ADC1 CH0 | 10 Hz | Battery monitoring |
| Status LED | GPIO | Variable | System health indicator |
| Telemetry output | UART1 | 50 Hz | Data stream to ground station |
| Watchdog | IWDG | 1 Hz | Reset if firmware hangs |
This is a real-world architecture. The IMU runs at 1kHz because attitude estimation needs high-frequency inertial data. The barometer runs at 50Hz because pressure changes slowly. The battery monitor runs at 10Hz because voltage changes even more slowly. Each component runs at the rate it actually needs — no faster, no slower.
Architecture: Embassy Tasks
We structure the system as independent Embassy tasks communicating through channels:
┌─────────────┐ ┌──────────┐
│ imu_task │────>│ │ ┌──────────────┐
│ (1000 Hz) │ │ │ │ │
├─────────────┤ │ Channel │────>│ telemetry │──── UART TX
│ baro_task │────>│ (64 deep)│ │ _task (50Hz) │
│ (50 Hz) │ │ │ │ │
├─────────────┤ │ │ └──────────────┘
│ battery_task│────>│ │
│ (10 Hz) │ └──────────┘
├─────────────┤
│ led_task │ (reads system state independently)
│ (variable) │
├─────────────┤
│ main │ (pets watchdog every 500ms)
└─────────────┘
Each task owns its peripheral. No shared mutable state, no mutexes for the common case. Data flows in one direction: sensors produce readings, the telemetry task consumes them.
Data Types
First, we define the data structures that flow through the system:
#![allow(unused)] fn main() { use heapless::String; #[derive(Clone, Copy, Debug, defmt::Format)] pub struct ImuReading { pub accel: [f32; 3], // m/s^2, XYZ pub gyro: [f32; 3], // rad/s, XYZ pub timestamp_ms: u64, } #[derive(Clone, Copy, Debug, defmt::Format)] pub struct BaroReading { pub pressure_pa: f32, pub temperature_c: f32, pub timestamp_ms: u64, } #[derive(Clone, Copy, Debug, defmt::Format)] pub struct BatteryReading { pub voltage: f32, pub percentage: u8, pub timestamp_ms: u64, } #[derive(Clone, Copy, Debug, defmt::Format)] pub enum SensorData { Imu(ImuReading), Baro(BaroReading), Battery(BatteryReading), } #[derive(Clone, Copy, Debug, PartialEq, defmt::Format)] pub enum SystemState { Initializing, Running, LowBattery, SensorFault, } }
Think About It: The
SensorDataenum lets us send different reading types through a single channel. The telemetry task matches on the variant to format each type appropriately. This is much cleaner than having three separate channels.
The Main Entry Point
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_stm32::{self, Config}; use embassy_stm32::gpio::{Level, Output, Speed}; use embassy_stm32::wdg::IndependentWatchdog; use embassy_sync::channel::Channel; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_time::Timer; use defmt_rtt as _; use panic_probe as _; use crate::{SensorData, SystemState}; // Channel: up to 64 readings buffered static SENSOR_CHANNEL: Channel<CriticalSectionRawMutex, SensorData, 64> = Channel::new(); // System state shared via atomic-like signal static SYSTEM_STATE: embassy_sync::signal::Signal< CriticalSectionRawMutex, SystemState > = embassy_sync::signal::Signal::new(); #[embassy_executor::main] async fn main(spawner: Spawner) { // Configure clocks for H743: 480 MHz system clock let mut config = Config::default(); { use embassy_stm32::rcc::*; config.rcc.hse = Some(Hse { freq: embassy_stm32::time::Hertz(25_000_000), mode: HseMode::Oscillator, }); config.rcc.pll1 = Some(Pll { source: PllSource::HSE, prediv: PllPreDiv::DIV5, mul: PllMul::MUL192, divp: Some(PllDiv::DIV2), // 480 MHz divq: Some(PllDiv::DIV4), // 120 MHz for SPI divr: None, }); config.rcc.sys = Sysclk::PLL1_P; } let p = embassy_stm32::init(config); defmt::info!("Sensor hub starting"); // Initialize watchdog — 1 second timeout let mut wdg = IndependentWatchdog::new(p.IWDG1, 1_000_000); wdg.unleash(); // Spawn all tasks spawner.spawn(imu_task(p.SPI1, p.PA5, p.PA7, p.PA6, p.PA4)).unwrap(); spawner.spawn(baro_task(p.I2C1, p.PB6, p.PB7)).unwrap(); spawner.spawn(battery_task(p.ADC1, p.PA0)).unwrap(); spawner.spawn(telemetry_task(p.USART1, p.PA9, p.PA10)).unwrap(); spawner.spawn(led_task(p.PE1)).unwrap(); SYSTEM_STATE.signal(SystemState::Running); defmt::info!("All tasks spawned, entering watchdog loop"); // Main loop: pet the watchdog loop { wdg.pet(); Timer::after_millis(500).await; } }
The IMU Task (1kHz SPI)
#![allow(unused)] fn main() { #[embassy_executor::task] async fn imu_task( spi_peri: embassy_stm32::peripherals::SPI1, sck: embassy_stm32::peripherals::PA5, mosi: embassy_stm32::peripherals::PA7, miso: embassy_stm32::peripherals::PA6, cs_pin: embassy_stm32::peripherals::PA4, ) { use embassy_stm32::spi::{self, Spi}; use embassy_stm32::gpio::{Level, Output, Speed}; let mut spi_config = spi::Config::default(); spi_config.frequency = embassy_stm32::time::Hertz(8_000_000); let mut spi = Spi::new_blocking( spi_peri, sck, mosi, miso, spi_config, ); let mut cs = Output::new(cs_pin, Level::High, Speed::VeryHigh); // Initialize ICM-42688 icm42688_init(&mut spi, &mut cs); defmt::info!("IMU initialized"); let mut ticker = embassy_time::Ticker::every( embassy_time::Duration::from_hz(1000) ); loop { ticker.next().await; match icm42688_read(&mut spi, &mut cs) { Ok(reading) => { SENSOR_CHANNEL.send(SensorData::Imu(reading)).await; } Err(e) => { defmt::warn!("IMU read error: {:?}", e); SYSTEM_STATE.signal(SystemState::SensorFault); } } } } fn icm42688_init( spi: &mut impl embedded_hal::spi::SpiBus, cs: &mut Output<'_>, ) { // Write to PWR_MGMT register: enable accel + gyro cs.set_low(); spi.write(&[0x4E, 0x0F]).ok(); cs.set_high(); } fn icm42688_read( spi: &mut impl embedded_hal::spi::SpiBus, cs: &mut Output<'_>, ) -> Result<ImuReading, ()> { let mut buf = [0u8; 13]; // 1 addr + 6 accel + 6 gyro buf[0] = 0x1F | 0x80; // ACCEL_DATA_X1, read flag cs.set_low(); spi.transfer_in_place(&mut buf).map_err(|_| ())?; cs.set_high(); let raw_accel = [ i16::from_be_bytes([buf[1], buf[2]]) as f32 / 2048.0, i16::from_be_bytes([buf[3], buf[4]]) as f32 / 2048.0, i16::from_be_bytes([buf[5], buf[6]]) as f32 / 2048.0, ]; let raw_gyro = [ i16::from_be_bytes([buf[7], buf[8]]) as f32 / 16.4, i16::from_be_bytes([buf[9], buf[10]]) as f32 / 16.4, i16::from_be_bytes([buf[11], buf[12]]) as f32 / 16.4, ]; Ok(ImuReading { accel: raw_accel, gyro: raw_gyro, timestamp_ms: embassy_time::Instant::now().as_millis(), }) } }
The Barometer Task (50Hz I2C)
#![allow(unused)] fn main() { #[embassy_executor::task] async fn baro_task( i2c_peri: embassy_stm32::peripherals::I2C1, scl: embassy_stm32::peripherals::PB6, sda: embassy_stm32::peripherals::PB7, ) { use embassy_stm32::i2c::I2c; let i2c = I2c::new_blocking( i2c_peri, scl, sda, embassy_stm32::time::Hertz(400_000), Default::default(), ); let mut bmp = Bmp280::new(i2c, 0x76); defmt::info!("Barometer initialized"); let mut ticker = embassy_time::Ticker::every( embassy_time::Duration::from_hz(50) ); loop { ticker.next().await; match bmp.read() { Ok(reading) => { SENSOR_CHANNEL.send(SensorData::Baro(reading)).await; } Err(_) => { defmt::warn!("Baro read failed"); } } } } }
The Battery Task (10Hz ADC)
#![allow(unused)] fn main() { #[embassy_executor::task] async fn battery_task( adc_peri: embassy_stm32::peripherals::ADC1, pin: embassy_stm32::peripherals::PA0, ) { use embassy_stm32::adc::Adc; let mut adc = Adc::new(adc_peri); let mut bat_pin = pin; let mut ticker = embassy_time::Ticker::every( embassy_time::Duration::from_hz(10) ); loop { ticker.next().await; let raw: u16 = adc.blocking_read(&mut bat_pin); // Voltage divider: 10k/3.3k divides battery voltage // ADC reference = 3.3V, 12-bit = 4096 counts let adc_voltage = raw as f32 * 3.3 / 4096.0; let battery_voltage = adc_voltage * (10.0 + 3.3) / 3.3; let percentage = voltage_to_percentage(battery_voltage); let reading = BatteryReading { voltage: battery_voltage, percentage, timestamp_ms: embassy_time::Instant::now().as_millis(), }; if percentage < 20 { SYSTEM_STATE.signal(SystemState::LowBattery); } SENSOR_CHANNEL.send(SensorData::Battery(reading)).await; } } fn voltage_to_percentage(v: f32) -> u8 { // Simple linear mapping for a 3S LiPo (9.0V–12.6V) let pct = ((v - 9.0) / (12.6 - 9.0) * 100.0) as i32; pct.clamp(0, 100) as u8 } }
The Telemetry Task (50Hz UART)
#![allow(unused)] fn main() { #[embassy_executor::task] async fn telemetry_task( uart_peri: embassy_stm32::peripherals::USART1, tx_pin: embassy_stm32::peripherals::PA9, rx_pin: embassy_stm32::peripherals::PA10, ) { use embassy_stm32::usart::{Config as UartConfig, UartTx}; let mut config = UartConfig::default(); config.baudrate = 115200; let mut tx = UartTx::new_blocking(uart_peri, tx_pin, config).unwrap(); let mut last_imu = ImuReading { accel: [0.0; 3], gyro: [0.0; 3], timestamp_ms: 0 }; let mut last_baro = BaroReading { pressure_pa: 0.0, temperature_c: 0.0, timestamp_ms: 0 }; let mut last_bat = BatteryReading { voltage: 0.0, percentage: 0, timestamp_ms: 0 }; let mut msg_buf: heapless::String<256> = heapless::String::new(); loop { // Receive the next sensor reading (blocks until one arrives) let data = SENSOR_CHANNEL.receive().await; match data { SensorData::Imu(r) => last_imu = r, SensorData::Baro(r) => last_baro = r, SensorData::Battery(r) => last_bat = r, } // Format and send telemetry every time we get a baro reading (~50Hz) if matches!(data, SensorData::Baro(_)) { msg_buf.clear(); core::fmt::write( &mut msg_buf, format_args!( "T:{} A:{:.1},{:.1},{:.1} G:{:.1},{:.1},{:.1} P:{:.0} B:{:.1}V {}%\r\n", last_imu.timestamp_ms, last_imu.accel[0], last_imu.accel[1], last_imu.accel[2], last_imu.gyro[0], last_imu.gyro[1], last_imu.gyro[2], last_baro.pressure_pa, last_bat.voltage, last_bat.percentage, ), ).ok(); tx.blocking_write(msg_buf.as_bytes()).ok(); } } } }
The Status LED Task
#![allow(unused)] fn main() { #[embassy_executor::task] async fn led_task(pin: embassy_stm32::peripherals::PE1) { let mut led = Output::new(pin, Level::Low, Speed::Low); loop { let state = SYSTEM_STATE.wait().await; // Blink pattern indicates system state let (on_ms, off_ms) = match state { SystemState::Initializing => (100, 900), // Slow pulse SystemState::Running => (50, 1950), // Brief heartbeat SystemState::LowBattery => (200, 200), // Fast blink SystemState::SensorFault => (500, 500), // Medium blink }; led.set_high(); Timer::after_millis(on_ms).await; led.set_low(); Timer::after_millis(off_ms).await; } } }
Fun Fact: Professional flight controllers use LED blink patterns as their primary status indicator. Three short blinks means "GPS lock acquired." Rapid flashing means "low battery." A solid light means "armed and ready." You can encode a surprising amount of information in a single LED.
Porting to STM32F4
The beauty of this architecture is how little changes when you move to a different chip. Here is what you modify to run this on an STM32F411:
| What Changes | H743 | F411 |
|---|---|---|
| Cargo feature | stm32h743vi | stm32f411ce |
| Clock config | HSE 25MHz, PLL to 480MHz | HSE 25MHz, PLL to 100MHz |
| LED pin | PE1 | PC13 |
| SPI frequency | 8 MHz (same) | 8 MHz (same) |
| Memory sections | .sram1_bss for DMA | Not needed |
The task architecture, the channel communication, the sensor reading code, the telemetry formatting — all identical. You change the chip feature, adjust the clock tree, update pin assignments, and remove the H7-specific memory section attributes. The business logic does not change at all.
# H743 version
[dependencies.embassy-stm32]
features = ["stm32h743vi", "time-driver-any", "memory-x"]
# F411 version — just change the feature
[dependencies.embassy-stm32]
features = ["stm32f411ce", "time-driver-any", "memory-x"]
Summary
This sensor hub demonstrates the full Embassy pattern:
- One task per concern — each sensor gets its own async task running at its own rate
- Channels for communication — data flows from producers to consumers without shared mutable state
- Graceful error handling — sensor failures log warnings and signal state changes, they do not crash the system
- Watchdog protection — the main loop pets the watchdog; if any task blocks or panics, the system resets
- Portable architecture — the same task structure works across STM32 families with minimal changes
This is not a toy example. With real sensor drivers and tuned timing, this code structure runs actual drones, robots, and industrial sensors in production.