Building an efficient ESP32 edge device for Mycelium v2
In our previous post, we explored the hardware design and architecture of Mycelium v2’s edge-peripheral device - a battery-powered ESP32 sensor node capable of measuring environmental conditions like temperature, humidity, and light levels. Now it’s time to dive into the software side: building robust, efficient firmware that can run for weeks on a single battery charge while reliably collecting and transmitting sensor data.
Are you also interested in the development of an internet of things application?! See my venture development service, which also spawns the development of software as a service (SaaS) of course!
We’ve written our firmware for the edge-peripheral mentioned in the previous blog post in Rust with no_std
instead of C or C++ with esp-idf.
INFO
no_std Rust is a subset of Rust that excludes the standard library, making it suitable for embedded systems with limited resources. It removes heap allocation, threading primitives, and OS-dependent features, while keeping the core language features and basic data types. ESP-IDF (Espressif IoT Development Framework) is the official development framework for ESP32 chips, traditionally used with C/C++. It provides drivers, networking stacks, and system services, but comes with the overhead of a full operating system and the memory safety challenges of C/C++.
The embedded-hal
crate is a foundational abstraction layer in Rust’s embedded ecosystem that defines a set of traits for common hardware interfaces. Think of it as a standardized API that allows device drivers to work across different microcontrollers without being tied to specific hardware implementations.
embedded-hal is a set of traits that describe common hardware abstractions for embedded systems in Rust.
It defines traits for peripherals such as:
For I2C, it defines traits like embedded_hal::i2c::I2c
This allows sensor/actuator crates to be written in a hardware-independent way. Any HAL (Hardware Abstraction Layer) crate just needs to implement these traits for its specific MCU/peripherals.
In our case we use esp-hal
which implements embedded-hal
, you can find this crate here
Like mentioned before, we use BH1730FVC and SHTC3X. In Rust there are crates available which are based on embedded-hal
which implement these I2C peripherals.
Gauge
use bh1730fvc::blocking::BH1730FVC;
use embassy_time::{Delay, Timer};
use embedded_hal_bus::i2c::RefCellDevice;
use esp_hal::{i2c::master::I2c, Blocking};
pub struct Gauge<'a> {
i2c: RefCell<I2c<'a, Blocking>>
}
impl <'a> Gauge<'a> {
pub fn new(i2c: RefCell<I2c<'a, Blocking>>) -> Self {
Self {
i2c
}
}
}
We use esp_hal::i2c::master::I2c
in Blocking
mode, both the crates for bh1730fvc and shtcx support non-blocking. This is something to be done later, which makes the code use more async/await goodness!
pub async fn sample(&mut self) -> anyhow::Result<Measurement> {
// Here we initialize a `RefCellDevice` with i2c for SHTC3X
let mut i2c_pcb_sht = RefCellDevice::new(&self.i2c);
// Here we initialize a `RefCellDevice` with i2c for BH1730FVC
let mut i2c_pcb_bh1730fvc = RefCellDevice::new(&self.i2c);
let mut delay = Delay;
// Setup SHTC3X driver
let mut sht = shtcx::blocking::shtc3(RefCellDevice::new(&self.i2c));
// Setup BH1730FVC driver, which returns a `Result` .. so we map it to anyhow
let mut bh1730fvc = BH1730FVC::new(&mut delay, &mut i2c_pcb_bh1730fvc)
.with_anyhow("BH1730FVC init failed")?;
// Kick off measuring with SHTC3X
sht.start_measurement(shtcx::blocking::PowerMode::NormalMode)
.with_anyhow("SHT start measurement failed")?;
// Setup the mode to SingleShot for BH1730FVC
bh1730fvc.set_mode(bh1730fvc::Mode::SingleShot, &mut i2c_pcb_bh1730fvc)
.with_anyhow("BH1730FVC set mode failed")?;
// Use embassy delay to sleep for 300ms
Timer::after_millis(300).await;
// Read lux from the BH1730FVC sensor
let lux = bh1730f vc.read_ambient_light_intensity(&mut i2c_pcb_sht).with_anyhow("BH1730FVC read failed")?;
// Use embassy delay to sleep for 300ms
Timer::after_millis(300).await;
// Read temperature and humidity from the SHTC3X sensor
let measurement = sht.get_measurement_result().with_anyhow("SHT read failed")?;
// Construct a measurement struct
let measurement = Measurement {
lux,
temperature: measurement.temperature.as_degrees_celsius(),
humidity: measurement.humidity.as_percent()
};
// Return the result
Ok(measurement)
}
Since both BH1730FVC and SHTC3X use I2c
from esp_hal the ownership rule of Rust kicks in. This means only one device can read from the i2c bus. To work around this we use embedded_hal_bus::i2c::RefCellDevice
which makes use of a RefCell
.
INFO
We also use the anyhow crate which allows multiple Error
types to be converted to one Result
type. We use the elvis operator ?
to unwrap the Result
which is idiomatic to Rust. If an error turns up, the function will short circuit and return with the error, otherwise it will continue with the happy path.
We are also able to use the async/await constructs in the embedded firmware setting which is exciting! More on that at the embassy section which is right below
Rust’s async/await allows for unprecedentedly easy and efficient multitasking in embedded systems. Multiple tasks can be run concurrently and are executed by a custom executor. Tasks get transformed at compile time into state machines that get run cooperatively. It obsoletes the need for a traditional RTOS with kernel context switching, and is faster and smaller than one!
In Rust you might be familiar with tokio which is also an executor for async/await. However, embedded environments are different and need a special executor. This is what embassy provides, see a diagram above
EXCITEMENT
Poll::Pending
. Once a task yields, the executor enqueues the task at the end of the run queue, and proceeds to (3) poll the next task in the queue. When a task is finished or canceled, it will not be enqueued again.
Also next to the regular executor there is an interrupt executor. The Embassy Interrupt Executor coordinates async tasks with hardware interrupts. A task requests a peripheral operation and awaits completion. When the peripheral finishes, it raises an interrupt. The HAL handles the interrupt, updates the peripheral state, and notifies the executor, which then polls the task to resume execution.
Next to this it also provides a set of interesting features
embassy::time
gives Instant
, Duration
, and Timer
that never overflow—timing “just works.”For creating a Bluetooth Low Energy (BLE) peripheral in Rust and embedded we used trouble. TrouBLE is a Bluetooth Low Energy (BLE) Host implementation for embedded devices written in Rust and embassy, with a future goal of qualification. This means it’s also relying on the async/await constructs which are supported in Rust, which makes it a more efficient and clean implementation than its competitors.
The implementation has the following functionality working we are looking for:
Trouble uses the bt-hci crate for the HCI interface, which means that any controller implementing the traits in bt-hci can work with Trouble. At present, the following controllers are available: Linux HCI sockets, nRF Softdevice Controller, UART HCI, Raspberry Pi Pico W, Apache NimBLE Controller and ESP32
To save power we use a few different techniques and we know three states
On each boot of the microcontroller we also read the state which is stored in the RTC memory to initialize the devices.
Notes: Device is waiting for time synchronization; no sensors or gauges are initialized.
Notes: Device wakes every 10 minutes, reads sensors, compresses, and buffers data; BLE is not initialized.
Notes: Device advertises via BLE, transmits buffered data, clears the buffer, and returns to deep sleep.
As mentioned before I’m using a custom timeseries algorithm which I haven’t published yet to crates.io, but is available here. Let me introduce it briefly here.
A lightweight, no-std compatible Rust crate for embedded systems that stores time-series data efficiently.
heapless::Vec
with #![deny(unsafe_code)]
.use timeseries::Series;
// Create a series with capacity for 10 entries, max deviation of 0.3
let mut timeseries: Series<10, u8, f32> = Series::new(0.3);
// Add monotonic data points
assert!(timeseries.append_monotonic(1, 32.6));
assert!(timeseries.append_monotonic(2, 32.7)); // Within deviation, extends range
assert!(timeseries.append_monotonic(3, 32.5)); // Within deviation, extends range
assert!(timeseries.append_monotonic(4, 33.8)); // Exceeds deviation, new entry
assert!(timeseries.append_monotonic(6, 34.0)); // Within deviation, extends range
// Check series bounds
println!("Starts at: {:?}", timeseries.starts_at()); // Some(1)
println!("Ends at: {:?}", timeseries.ends_at()); // Some(6)
println!("Is full: {}", timeseries.is_full()); // false
Using this crate with a trait implementation for Measurement
we are able to only save measurements which significantly differ from the previous ones. In a practical setting this means that during a stable environment there are not many synchronization operations which saves a ton of energy. This is because it requires more energy to bootstrap and send data via BLE.
Building embedded firmware in Rust with no_std
and embassy has proven to be an excellent choice for our Mycelium v2 edge-peripheral device. The combination of memory safety, hardware abstraction through embedded-hal
, and efficient async/await execution provides a robust foundation for IoT applications.
Our ESP32-based peripheral successfully demonstrates:
The device operates autonomously, collecting environmental data every 10 minutes while maintaining weeks of battery life through deep sleep cycles. When the buffer fills, it transitions to BLE advertising mode to transmit data to nearby central devices.
In the next post, we’ll explore the energy efficiency of the peripheral and after that we’ll go over the central implementation - the bridge between our edge peripherals and the cloud infrastructure. We’ll dive into:
The central acts as the hub in our sensor network and orchestrating data collection.