Icarus Bluetooth Client

The Rev C design of Icarus uses an ESP32-C3 MCU which supports Bluetooth. I chose Bluetooth as I thought it would make a decent option for a primarily indoor drone application. It also helps with sending sensor and log data back wirelessly for debugging.

In this post I’ll be covering setting up the BLE server on the micro controller and sending sensor data over Bluetooth.

Changes to Firmware

While the basic support for Rust on the ESP32-C3 is available, I could not find a well supported Bluetooth LE stack for no_std embedded Rust or a Bluetooth library binding for the esp-idf-hal flavour of embedded Rust.

There are some in-progress libraries, attempts and unmaintained projects but nothing concrete. I’d very much like to contribute to some of these projects in the future but for the sake of getting things rolling, I’ve decided to just rewrite the firmware in C++ using PlatformIO.

Unfortunate but I can always go back and re-write it.

General Firmware Setup

Each module is a separate PlatformIO library. There is also a common include for the board pinout.

#pragma once

#include <Arduino.h>

// Generic IO Pins
#define ICARUS_IO1 GPIO_NUM_3
#define ICARUS_IO2 GPIO_NUM_10
#define ICARUS_IO3 GPIO_NUM_4
#define ICARUS_IO4 GPIO_NUM_5
#define ICARUS_IO5 GPIO_NUM_6
#define ICARUS_IO6 GPIO_NUM_7

// STAT LED
#define ICARUS_STAT_LED GPIO_NUM_0

// Button
#define ICARUS_USER_BUTTON GPIO_NUM_9

// I2C
#define ICARUS_I2C_SDA GPIO_NUM_1
#define ICARUS_I2C_SCL GPIO_NUM_2

The IO Mux feature of the ESP32-C3 is quite nice as pretty much any pin can be attached to any peripheral.

Collecting Sensor Data

First, lets get some meaningful data to send over bluetooth by collecting the attitude data from the IMU.

The Sensors class will be responsible for collecting sensor data and doing sensor fusion.

The IMU on this board is an MPU6050.

class Sensors
{
public:
    Sensors() = default;
    ~Sensors() = default;

    bool begin(uint8_t scl, uint8_t sda);
    void update();

    attitude_t getAttitude();
private:
    MPU6050 mpu;
};

When the sensor module is initialized, it must set the TwoWire interface pins, initialize the IMU driver and calibrate the IMU.

// IMU Address
#define MPU_ADDRESS 0x68

bool Sensors::begin(uint8_t scl, uint8_t sda)
{
    const auto wire_ok = Wire.setPins(sda, scl) && Wire.begin();

    if (!wire_ok)
    {
        Serial.println("Failed to initialize Wire protocol");
    }

    mpu.setDeviceAddress(MPU_ADDRESS);

    const auto mpu_ok = mpu.begin();

    if (!mpu_ok)
    {
        Serial.println("Failed to initialize MPU");
    }
    else
    {
        mpu.calibrate();
    }

    return wire_ok && mpu_ok;
}

void Sensors::update()
{
    mpu.update();
}

attitude_t Sensors::getAttitude()
{
    return mpu.getAttitude();
}

The MPU6050 library actually provides a function that returns the orientation estimation. This is really convenient and a good option for now. However Icarus Rev D actually includes a magnetometer so at some point in the future I’d like to add that to the fusion algorithm.

#include <Arduino.h>
#include <pins.hpp>
#include <Sensors.hpp>

Sensors sensors;

void setup() {
  Serial.begin(115200);

  if (!sensors.begin(ICARUS_I2C_SCL, ICARUS_I2C_SDA))
  {
    Serial.println("Failed to initialize sensors!");
    while(1){}
  }
  else
  {
    Serial.println("Sensor setup complete!");
  }

  server.begin();
  Serial.println("Server setup complete");
}

void loop() {
  sensors.update();

  const auto attitude = sensors.getAttitude();
  Serial.printf("(%0.2f, %0.2f, %0.2f)\n", attitude.pitch, attitude.roll, attitude.yaw);

  delay(100);
}

Start the sensor module and collect the estimated orientation from the IMU.

Bluetooth Server Setup

Bluetooth LE devices expose Services and each service has a number of Characteristics. Each service and characteristic is specified with a unique ID.

Characteristics are the data that a client application will receive.

In the firmware I’m creating a “sensors” service with a characteristic for each sensor value.

#include <NimBLEDevice.h>

struct AttitudeServiceData
{
    AttitudeServiceData() : pitch{0}, roll{0}, yaw{0} {}

    float pitch;
    float roll;
    float yaw;
};

/**
 * @brief Icarus BLE Server
 *
 */
class IcarusServer
{
public:
    IcarusServer();
    ~IcarusServer();

    void begin();

    void updateAttitude(float pitch, float roll, float yaw);

private:
    void serializeAttitude();

    NimBLEServer* server_{nullptr};

    // Sensor Service
    NimBLEService* sensor_service_{nullptr};
    NimBLECharacteristic* attitude_characteristic_{nullptr};
    AttitudeServiceData attitude_data_;
    uint8_t attitude_service_buffer_[sizeof(AttitudeServiceData)];
};

The sensor data is stored in a byte buffer.

void IcarusServer::begin()
{
    // Set the device name
    NimBLEDevice::init("icarus");

    // Setup the server
    server_ = NimBLEDevice::createServer();

    // Setup services
    sensor_service_ = server_->createService(ICARUS_SENSOR_SERVICE_UUID);
    attitude_characteristic_ = sensor_service_->createCharacteristic(
                                            ICARUS_SENSOR_SERVICE_CHARACTERISTIC_ATTITUDE_UUID,
                                            NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
                                        );

    updateAttitude(0, 0, 0);

    // Starting Services
    sensor_service_->start();

    // Start BLE Advertising
    auto advertising = NimBLEDevice::getAdvertising();

    advertising->addServiceUUID(sensor_service_->getUUID());

    advertising->setScanResponse(true);
    advertising->start();
}

Setting the NOTIFY flag will notify clients of new data.

It’s important that byte order is consistent for client applications to receive the data. So updateAttitude serializes the data in network byte order.

void IcarusServer::updateAttitude(float pitch, float roll, float yaw)
{
    attitude_data_.pitch = pitch;
    attitude_data_.roll = roll;
    attitude_data_.yaw = yaw;

    serializeAttitude();
}

void IcarusServer::serializeAttitude()
{
    uint8_t* buf = attitude_service_buffer_;

    buf += serde::serialize(buf, attitude_data_.pitch);
    buf += serde::serialize(buf, attitude_data_.roll);
    buf += serde::serialize(buf, attitude_data_.yaw);

    attitude_characteristic_->setValue(attitude_service_buffer_, sizeof(attitude_service_buffer_));
    attitude_characteristic_->notify();
}

The serialization is pretty straight forward:

namespace serde
{
    union FloatToInt
    {
        float f;
        uint32_t i;
    };
    

    uint32_t serialize(uint8_t* buf, uint8_t value)
    {
        buf[0] = value;
        return 1;
    }

    uint32_t serialize(uint8_t* buf, uint16_t value)
    {
        buf[0] = value >> 8;
        buf[1] = value & 0x00FF;
        return 2;
    }

    uint32_t serialize(uint8_t* buf, uint32_t value)
    {
        buf[0] = value >> 24;
        buf[1] = value >> 16;
        buf[2] = value >> 8;
        buf[3] = value & 0x000000FF;
        return 4;
    }


    uint32_t serialize(uint8_t* buf, float value)
    {
        FloatToInt f2i;
        f2i.f = value;

        return serialize(buf, f2i.i);
    }

} // namespace serde

Updating the sensor values from the IMU

void loop() {
  sensors.update();

  const auto attitude = sensors.getAttitude();
  Serial.printf("(%0.2f, %0.2f, %0.2f)\n", attitude.pitch, attitude.roll, attitude.yaw);

  server.updateAttitude(attitude.pitch, attitude.roll, attitude.yaw);

  delay(100);
}

Bluetooth Client on the PC

And this time in Rust…

It is pretty straight forward to get the data over bluetooth. I’m using a library call.. er.. btleplug.

This is an async bluetooth client.

pub async fn initialize() -> anyhow::Result<Client> {
    let manager = Manager::new().await?;
    let adaptor_list = manager.adapters().await?;

    for adaptor in adaptor_list.iter() {
        log::debug!("Starting scan of {}...", adaptor.adapter_info().await?);

        adaptor
            .start_scan(ScanFilter::default())
            .await
            .expect("Can't scan BLE adaptor for connected devices");

        time::sleep(Duration::from_secs(10)).await;

        let peripherals = adaptor.peripherals().await?;

        // Find the icarus device
        for peripheral in peripherals.iter() {
            let properties = peripheral.properties().await?;
            let is_connected = peripheral.is_connected().await?;
            let local_name = properties
                                .map(|p| p.local_name)
                                .flatten()
                                .unwrap_or(String::from("unknown"));

            if local_name == String::from("icarus") {
                if !is_connected {
                    if let Err(e) = peripheral.connect().await {
                        log::error!("Failed to connect: {}", e);
                        continue;
                    }
                }

                peripheral.discover_services().await?;

                // Setup client streams
                let (attitude_tx, attitude_rx) = mpsc::channel::<Attitude>(10);

                let attitude_char = peripheral
                                        .characteristics()
                                        .iter()
                                        .filter(|c| c.uuid == ATTITUDE_CHARACTERISTIC)
                                        .next()
                                        .map(|c| c.clone())
                                        .ok_or(Error::CharacteristicNotFound)?;

                tokio::spawn(attitude_recv_task(peripheral.clone(), attitude_char, attitude_tx));


                let client = Client { attitude_recv: attitude_rx };
                return Ok(client)
            }
        }
    }

    Err(Error::DeviceNotFound)?
}
async fn attitude_recv_task<P: Peripheral>(p: P, c: Characteristic, tx: Sender<Attitude>) -> anyhow::Result<()> {
    p.subscribe(&c).await?;

    let mut stream = p.notifications().await?;

    while !tx.is_closed() {
        while let Some(data) = stream.next().await {
            let mut cursor = Cursor::new(&data.value[..]);

            let pitch = cursor.read_f32::<NetworkEndian>()?;
            let roll = cursor.read_f32::<NetworkEndian>()?;
            let yaw = cursor.read_f32::<NetworkEndian>()?;

            log::debug!("({}, {}, {})", pitch, roll, yaw);

            let attitude = Attitude {pitch, roll, yaw};

            if let Err(e) = tx.send(attitude).await {
                log::error!("Failed to send attitude data: {}", e);
            }
        }
        time::sleep(Duration::from_millis(10)).await;
    }

    Ok(())
}

When receiving data, deserialize using Network byte order.

RUST_LOG=info cargo run -p icarus-cli
   Compiling icarus-cli v0.1.0 (D:\Users\nnarain\Code\workspace\Projects\icarus-desktop\icarus-cli)
    Finished dev [unoptimized + debuginfo] target(s) in 10.49s
     Running `target\debug\icarus-cli.exe`
 INFO  icarus_cli > Initializing icarus client
 INFO  icarus_cli > Pitch: 0.00818, Roll: -0.03394, Yaw: -2.15001
 INFO  icarus_cli > Pitch: 0.00803, Roll: -0.03389, Yaw: -2.14994
 INFO  icarus_cli > Pitch: 0.00803, Roll: -0.03410, Yaw: -2.14994
 INFO  icarus_cli > Pitch: 0.00828, Roll: -0.03406, Yaw: -2.14992
 INFO  icarus_cli > Pitch: 0.00818, Roll: -0.03401, Yaw: -2.14944
 INFO  icarus_cli > Pitch: 0.00845, Roll: -0.03399, Yaw: -2.14931
 INFO  icarus_cli > Pitch: 0.00834, Roll: -0.03386, Yaw: -2.14877

Next up, plotting sensor data.