Bluetooth Low Energy with Rust

Daniel Gallagher



I am a senior software engineer at 219 Design. My professional experience has primarily been in writing GUIs and infrastructure for medical devices, almost entirely in C++. For a change of pace, I have been learning embedded development using Rust in my spare time, which has been an exciting challenge. I was new to both when I started this endeavor at the beginning of the year.

Bluetooth on Embedded Rust

A core component of my project is to have two devices communicating wirelessly, and (at the time I started working on this) there were no “no-std” Rust Bluetooth crates available. So I started to write my own. I had already chosen to work on the Nucleo, and to use the IDB05A1 shield, which contains a BlueNRG-MS module.

This development effort resulted in two crates:

A no-std Rust SPI driver for the BlueNRG-MS (and older BlueNRG) module.

A device-independent pure-Rust implementation of the Bluetooth Host-Controller Interface.

Bluetooth HCI

The bluetooth-hci crate implements the host-controller interface portion of the Bluetooth specification. To date, this is only a partial implementation (only supporting the commands that BlueNRG-MS supports), but the framework is in place for extending it to implement the rest of the specification.

By default, the crate implements version 4.1 of the Bluetooth specification, but has features for versions 4.2 and 5.0 as well:

[dependencies.bluetooth-hci] features = ["version-5-0"]

Implementing Controller

The Bluetooth-HCI is written on top of the Controller trait, which determines how the host reads bytes from and writes bytes to the controller. It has three methods and three associated types:

type Error. This is the type of errors from the underlying communication bus.

type Header. This is the type of header to send before the payloads. This should be bluetooth_hci::uart::CommandHeader. This associated type may be removed in the future.

type Vendor. This is a trait that defines vendor-specific extensions that the HCI may use: type Status. This is an enumeration of vendor-specific status codes. type Event. This is the type of vendor-specific events.

fn write. Writes a header and payload to the controller in a single transaction.

fn read_into. Reads available bytes from the controller into a caller-supplied buffer.

fn peek. Returns a byte that will soon be returned by a call to read_into. Implementations should support peeking up to 5 bytes ahead.

Controller operates at a byte level. To read Bluetooth events from the device, you need to use bluetooth_hci::uart::Hci, which is an extension trait to Controller. So you literally just need to

use bluetooth_hci::uart::Hci;

to be able to read Bluetooth events. You will need to specify the particular type of vendor-specific events, errors, and status codes that may occur. But, via the magic of type inference, you don’t need to specify any of these in your Controller implementation.

BlueNRG-MS

The BlueNRG crate provides an embedded-hal driver for the STMicro BlueNRG Bluetooth processor. It communicates on a SPI bus, and needs a few other GPIOs for various purposes. It also needs a dedicated buffer in memory where it can store bytes received from the controller before the application reads them (so it can implement peek, and efficiently read entire messages).

To initialize a BlueNRG instance, you need something that implements embedded_hal::blocking::spi::Transfer and Write, an input GPIO so the controller can notify the host when data is ready, and two output GPIOs: one to select the chip (so multiple chips can talk on one SPI bus), and one to reset the controller. Using the STM32F30x crate, this looks like:

Note that BlueNRG does not take ownership of a SPI bus. However, the BlueNRG type is dependent on a concrete SPI type. Rust’s robust type inference lets us elide that type information, assuming it is determined later in the code. To communicate with the chip (to do anything other than reset it), you must use its with_spi method:

bnrg.with_spi(&mut spi, |c| { block!(c.read_local_version_information()) });

with_spi takes a handle to a SPI bus and a block to execute. The block is passed an ActiveBlueNRG struct, which implements bluetooth_hci::Controller , and has all of the BlueNRG vendor-specific commands available.

Note: The BlueNRG has its own firmware that can be updated. This crate is not for writing firmware that runs on the BlueNRG itself. It is for writing firmware on a host processor that communicates with the BlueNRG over SPI.

Sending Commands to the Controller

Once the BlueNRG is initialized, you must use its with_spi method to get the corresponding ActiveBlueNRG struct. You then use the ActiveBlueNRG to send commands to the chip. All of the methods are defined in traits that are implemented for all bluetooth_hci::Controller implementors (and therefore for ActiveBlueNRG). Vendor-specific commands are implemented in extension traits as well.

All of the commands return nb::Result structs, so they can be used in an embedded asynchronous environment. Internally, they use the blocking SPI commands from embedded-hal, so they are not completely asynchronous. However, if the BlueNRG is not ready or if its internal buffers are full, your code does not have to block while those conditions are cleared.

Standard Bluetooth Commands

The Bluetooth specification defines a number of HCI commands that the host may use to communicate with the controller.

Work in progress! The bluetooth-hci crate is intended to support the full Bluetooth specification (versions 4.1, 4.2, and 5.0). However, currently only commands and events supported by the BlueNRG are supported.

All of the commands are defined in the bluetooth_hci::host::Hci extension trait, so they must be imported before they can be used:

use bluetooth_hci::host::Hci;

For example, to read the chip’s local version information:

bnrg.with_spi(&mut spi, |c|{ c.read_local_version_information() });

There are various places that you could put one of the nb macros:

Inside the block

Outside the with_spi call

In the calling function

For more methods with more complex arguments, the crate checks that the values are within spec before making the call. In as many places as possible, the arguments are constructed using the builder pattern so that they cannot be invalid at the time the method is called.

Consider the le_set_advertising_parameters call:

bnrg.with_spi(&mut spi, |c| { c.le_set_advertising_parameters( &bluetooth_hci::host::AdvertisingParameters { advertising_interval: AdvertisingInterval::for_type( AdvertisingType::ConnectableUndirected, ).with_range( Duration::from_millis(21), Duration::from_millis(1000), )?, own_address_type: OwnAddressType::Public, peer_address: hci::BdAddrType::Random(hci::BdAddr([ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, ])), advertising_channel_map: Channels::CH_37 | Channels::CH_39, advertising_filter_policy: AdvertisingFilterPolicy::AllowConnectionAndScan, })? );

Here we see both potential types of errors. The advertising interval has different requirements based on the advertising type. If these are not met, the AdvertisingInterval will not be constructed. The call itself may fail if the advertising channel map is empty. These failures are unrelated to failures from the chip itself. Those are communicated back to the host via either CommandStatus or CommandComplete events. However, the crate is designed so that any malformed commands are not even sent to the controller, but fail at the call site. This cannot catch everything, though. For example, most commands take a connection handle, and only the controller itself can know which handles are valid.

Vendor-Specific Commands

The BlueNRG defines many commands in addition to the subset of the standard that it supports. These are split into several extension crates, which must be imported individually:

use bluenrg::gap::Commands as GapCommands; use bluenrg::gatt::Commands as GattCommands; use bluenrg::hal::Commands as HalCommands; use bluenrg::l2cap::Commands as L2CapCommands;

The BlueNRG crate’s API is similar to Bluetooth-HCI. Commands that are known to have invalid parameters are not even sent to the controller, but are rejected at the call site. Other failures are determined by the chip, and returned as CommandStatus or CommandComplete events.

One potential wrinkle you will find when using both the GAP and GATT commands is that both extensions define an init method. Assuming both traits are imported, they must be distinguished manually, and the controller must be cast appropriately:

bnrg.with_spi(&mut spi, |c| { GattCommands::init(c as &mut GattCommands<Error = _>)?; GapCommands::init( c as &mut GapCommands<Error = _>, bluenrg::gap::Role::PERIPHERAL, false, 7, )?; });

Reading from the Controller

The Controller trait defines low-level methods to communicate with the controller. These methods operate on the byte level, not the Bluetooth event level, which is not at all convenient. The bluetooth-hci crate defines an extension trait that adds a read method that returns a Bluetooth event:

use bluetooth_hci::host::uart::Hci;

Note: there is another extension trait in host::event_link that provides a read method, but it would only be useful for a module that does not use a packet type byte as defined in the Bluetooth Spec (version 4.1, Vol 4, Part A, Section 2).

The uart::Hci::read method returns a uart::Packet. Currently, the only packet type is an event packet, but other packet types (ACL Data and Sync Data) may be supported in the future.

read returns a bluetooth_hci::event::Event, which must be specialized with the particular vendor-specific event type. The Event enum has one value for each supported standard event, and one catch-all value representing a vendor-specific event. The vendor-specific type itself has several associated types that define various extension points:

Error: errors that may occur when deserializing vendor-specific events (such as out-of-spec values).

Status: vendor-specific status codes that may be used in CommandStatus or CommandComplete events

ReturnParameters: payloads for CommandComplete events for vendor-specific commands.

Example

Let’s break this down in smaller chunks:

match block!(bnrg.with_spi(&mut spi, |c| c.read())) { Ok(p) => {...} Err(e) => {...} }

This is how we read a Bluetooth event from the controller. Note that we use the nb::block! macro to ensure we get a complete event (or error out). This could equivalently be written as:

bnrg.with_spi(&mut spi, |c| block!(c.read()))

which would probably provide better performance, since the loop hidden in the block macro would be inside the closure passed to with_spi, rather than the read loop including the with_spi call.

In order for the call to read to successfully compile, we must specify the vendor-specific event type. Rust’s powerful type inference comes to our rescue, since we do specify the type on line 9 when we unpack the Vendor event to get a BlueNRGEvent.

let hci::host::uart::Packet::Event(e) = p;

The call to read doesn’t directly return an event. Instead, it returns a Packet, and we need to extract the event from it. In the future, other packet types may be implemented, so this would need to become another match or an if-let.

bluetooth_hci::event::Event::ConnectionComplete(params) => {...}

Here we extract the payload of the connection complete event. Now params contains the structure returned by the controller.

bluetooth_hci::event::Event::Vendor( bluenrg::event::BlueNRGEvent::HalInitialized(reason), ) => {...}

This is how we extract a particular vendor-specific event. In this case, the BlueNRG sends a HalInitialized event every time it starts up. The reason indicates why the controller restarted.

nb::Error::Other( bluetooth_hci::event::Error::BadLinkType(t), ) => (),

In the error branch of the outer match, we need to handle all of the errors that may occur when reading an event. Recall that read returns an nb::Result, and all “real” errors will be wrapped in nb::Error::Other.

This line extracts a standard error that can occur when trying to deserialize a connection complete event, which indicates that the controller sent an unknown value as the link type.

nb::Error::Other(bluetooth_hci::event::Error::Vendor( bluenrg::event::BlueNRGError::UnknownResetReason(code), )) => (),

Here we extract a particular vendor-specific error. This particular error occurs when reading the HalInitialized event, and the reason is an undocumented value.

nb::Error::WouldBlock => (),

For completeness, the compiler forces us to handle the WouldBlock error, even though we know it can never occur (since we wrap the read in a block! call).

Next Steps

So what’s next for Bluetooth-HCI and BlueNRG? First, I’m going to actually try using these crates to develop some embedded applications. So, in the immediate future, I’ll be figuring out how the ergonomics and performance of these crates can be improved. Aside from bug fixes though, I don’t foresee any major changes for a while. Once I have some experience with them, I’ll be stabilizing the APIs (at least enough to provide a 0.1.0 series).

There is still a ton of work to be done to implement the full Bluetooth specification for Bluetooth-HCI. If there’s something in particular that you want to see supported, feel free to raise an issue on Github. (I’ll just be happy someone is using the crate enough to want something fixed!) PRs are always welcome.

Ideas for Improvement

As I was implementing these crates, I came up with a few ideas that may make them easier to use or more performant. As I mentioned, I’ll be using the current API for a while to see if it would be worth it to implement these changes.

Postponing Event Deserialization

In the current API, read will attempt to read an event, and will do all error-checking on that event before it is returned to the caller. However, if the caller chooses to ignore certain events, that work is completely wasted. This change would postpone the error checking to the point where particular values are used. So instead of read returning an UnknownResetReason, it would return a HalInitialized event. Then trying to get the reason would return an UnknownResetReason error.

Add a Blocking API

After we send a command to the controller, we will need to call read to get the CommandStatus or CommandComplete event that the controller sends in response to that command. While this asynchronous API is going to integrate better with a complex application, a blocking API is vastly easier to use.

If a command currently causes the controller to send a CommandComplete event, the blocking API would wait until the CommandComplete was sent, then return those return parameters to the caller. If a command generates a CommandStatus, the command would wait for that event, then return the status to the caller.