Mycelium v2: firmware for the edge-peripheral device
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.
Why Rust for embedded?
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++.
1. Safety and reliability
- Rust’s ownership model and borrow checker eliminate common embedded bugs:
- Buffer overflows
- Use-after-free
- Dangling pointers
- Data races in multithreaded contexts
- In C++ (even modern C++17/20), these issues are still possible unless you’re very disciplined.
- In constrained embedded systems, a single memory corruption can be catastrophic — Rust helps prevent that at compile time.
- Shorter feedback loop: Safer code, fewer debugging headaches, especially on constrained devices.
2. Portability
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:
- GPIO (digital input/output)
- SPI
- I2C
- PWM
- Timers
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.
Setting up 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!
Reading from the i2c devices
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
3. Async/await through embassy
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
- Safe Hardware Access HALs provide idiomatic Rust APIs for STM32, Nordic nRF, RP2040, ESP32, and more—no raw register fiddling needed. Use Embassy HALs or your own.
- Reliable Timing
embassy::timegivesInstant,Duration, andTimerthat never overflow—timing “just works.” - Real-Time Ready Multiple executors with priorities allow high-priority tasks to preempt lower-priority ones.
- Networking & Connectivity Async-friendly stacks for Ethernet, TCP/UDP, ICMP, DHCP, BLE (nRF SoftDevice), and USB (CDC, HID, custom classes).
Bluetooth Low Energy communication
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:
- Peripheral role - advertise as a peripheral and accept connections.
- Central role - scan for devices and establish connections.
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
Power management
To save power we use a few different techniques and we know three states

AwaitingTimeSync
- Purpose: The device waits until it has a valid time reference from the central which is BLE.
- Entry Condition: Device just powered on or reset.
- Exit Condition: Time is synchronized.
- Actions: None beyond waiting for time sync.
Buffering
- Purpose: Collect measurements periodically and store them in a limited-size buffer.
- Entry Condition: Time is synchronized.
- Internal Cycle: Wake up every 10 minutes from deep sleep. Read sensors via I2C (light, temperature, humidity, soil moisture). Compress the measurements using a deviation-based algorithm and store in RTC memory.
- Transitions: Stay in Buffering if buffer has less than 6 entries. Move to Flush when buffer reaches 6 entries.
Flush
- Purpose: Transmit buffered data to a central device via BLE.
- Entry Condition: Buffer is full (6 entries).
- Actions: Advertise presence via BLE. Wait for central to connect and request data. Transmit buffered data. Clear buffer after successful transmission.
- Exit Condition: Buffer cleared, device returns to Buffering for next cycle.
Booting
On each boot of the microcontroller we also read the state which is stored in the RTC memory to initialize the devices.
AwaitingTimeSync
- RTC initialized for timekeeping
- MAC address read from efuse
- BLE controller initialized via BleConnector and ExternalController
- CPU clock set to maximum
Notes: Device is waiting for time synchronization; no sensors or gauges are initialized.
Buffering
- RTC for timekeeping
- Gauge initialized for measurements, includes:
- CPU clock limited to 80 MHz for energy efficiency
Notes: Device wakes every 10 minutes, reads sensors, compresses, and buffers data; BLE is not initialized.
Flush
- RTC for timekeeping
- MAC address read from efuse
- Gauge initialized for measurements (same as Buffering)
- BLE controller via BleConnector and ExternalController
- CPU clock set to maximum for fast BLE operations
Notes: Device advertises via BLE, transmits buffered data, clears the buffer, and returns to deep sleep.
Timeseries crate (unpublished)
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.
- Memory safe & fixed-capacity: Uses
heapless::Vecwith#![deny(unsafe_code)]. - Monotonic timestamps: Points must increase strictly.
- Deviation-based compression: Stores only significantly changed values, merging ranges when possible.
- Generic: Works with any ordered index and numeric value type.
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.
Conclusion
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:
- Safe sensor reading from multiple I2C devices
- Efficient power management through state transitions and deliberate bootstrapping
- Reliable BLE communication using the trouble crate
- Intelligent data compression and buffering with our custom timeseries algorithm
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:
- Scanning and connecting to multiple BLE peripherals
- Implementing Auth0 device code flow for secure authentication
- Aggregating sensor data from distributed edge devices
- Data transmission to our Scala backend services
The central acts as the hub in our sensor network and orchestrating data collection.
📚 Posts in this series: Mycelium v2
- 1 Introducing Mycelium v2: A smarter way to water and monitor plants
- 2 CI/CD Pipelines: Choosing the Right Tool
- 3 Mycelium v2: building the edge-peripheral device
- 4 Mycelium v2: firmware for the edge-peripheral device (current)
- 5 Mycelium v2: measuring efficiency of edge-peripheral