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:

ComponentInterfaceRatePurpose
ICM-42688 IMUSPI11000 HzAccelerometer + Gyroscope
BMP280 BarometerI2C150 HzAltitude estimation
Battery voltageADC1 CH010 HzBattery monitoring
Status LEDGPIOVariableSystem health indicator
Telemetry outputUART150 HzData stream to ground station
WatchdogIWDG1 HzReset 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 SensorData enum 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 ChangesH743F411
Cargo featurestm32h743vistm32f411ce
Clock configHSE 25MHz, PLL to 480MHzHSE 25MHz, PLL to 100MHz
LED pinPE1PC13
SPI frequency8 MHz (same)8 MHz (same)
Memory sections.sram1_bss for DMANot 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:

  1. One task per concern — each sensor gets its own async task running at its own rate
  2. Channels for communication — data flows from producers to consumers without shared mutable state
  3. Graceful error handling — sensor failures log warnings and signal state changes, they do not crash the system
  4. Watchdog protection — the main loop pets the watchdog; if any task blocks or panics, the system resets
  5. 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.