Blocking vs async parity
One of the nicest things about building on embedded-hal is that you do
not have to choose one execution model forever.
pico-de-gallo-hal::I2c implements both the blocking
embedded_hal::i2c::I2c trait and the async
embedded_hal_async::i2c::I2c trait. So the exact same TMP102 driver
can be used from synchronous code and from async code.
Share the hard parts
The register definitions, address handling, temperature conversions, and configuration builder do not care whether the bus is blocking or async. Keep those pieces shared:
fn decode_temperature(raw: [u8; 2], extended_mode: bool) -> Celsius {
let mut value = i16::from_be_bytes(raw);
value /= if extended_mode { 8 } else { 16 };
Celsius(value as f32 * 0.0625)
}
fn encode_limit(limit: Celsius) -> [u8; 2] {
((limit.0 / 0.0625) as i16 * 16).to_be_bytes()
}
Then make the blocking and async fronts as thin as possible.
A simple parity pattern
Because some I2C types implement both traits, duplicating the same inherent method names can get awkward. The least surprising pattern for a small driver is:
- keep the ergonomic async API as the primary surface
- add thin blocking siblings with
_blockingsuffixes - keep all encoding and decoding logic in shared helpers
impl<I2C: embedded_hal::i2c::I2c> Tmp102<I2C, Running> {
pub fn temperature_blocking(&mut self) -> Result<Celsius, ErrorKind> {
let raw: [u8; 2] = self.inner.temperature().read()?.into();
Ok(decode_temperature(raw, self.extended_mode))
}
pub fn configure_blocking(&mut self, config: Config) -> Result<(), ErrorKind> {
self.extended_mode = config.extended_mode == ExtendedMode::Enable;
self.inner.configuration().modify(|reg| {
reg.set_tm(config.thermostat_mode);
reg.set_pol(config.polarity);
reg.set_em(config.extended_mode);
reg.set_cr(config.conversion_rate);
})
}
}
impl<I2C: embedded_hal_async::i2c::I2c> Tmp102<I2C, Running> {
pub async fn temperature(&mut self) -> Result<Celsius, ErrorKind> {
let raw: [u8; 2] = self.inner.temperature().read_async().await?.into();
Ok(decode_temperature(raw, self.extended_mode))
}
pub async fn configure(&mut self, config: Config) -> Result<(), ErrorKind> {
self.extended_mode = config.extended_mode == ExtendedMode::Enable;
self.inner
.configuration()
.modify_async(|reg| {
reg.set_tm(config.thermostat_mode);
reg.set_pol(config.polarity);
reg.set_em(config.extended_mode);
reg.set_cr(config.conversion_rate);
})
.await
}
}
If you want identical method names on both sides, a macro-based approach
such as maybe-async-cfg is a good next step. For a first driver,
though, the explicit version is easier to read and maintain.
Same driver, blocking usage
#![allow(unused)]
fn main() {
use pico_de_gallo_hal::Hal;
let hal = Hal::new();
let i2c = hal.i2c();
let mut sensor = Tmp102::continuous(i2c, A0::Gnd);
let Celsius(temp) = sensor.temperature_blocking()?;
println!("{temp:.2} °C");
Ok::<(), embedded_hal::i2c::ErrorKind>(())
}
Same driver, async usage
use pico_de_gallo_hal::Hal;
#[tokio::main]
async fn main() -> Result<(), embedded_hal::i2c::ErrorKind> {
let hal = Hal::new();
let i2c = hal.i2c();
let mut sensor = Tmp102::continuous(i2c, A0::Gnd);
let Celsius(temp) = sensor.temperature().await?;
println!("{temp:.2} °C");
Ok(())
}
That is the payoff: same driver type, same register model, same public concepts, two execution models.
When async is worth it
For one occasional temperature read, blocking code is often perfectly fine.
Async starts paying for itself when your program wants to interleave sensor access with other work, for example:
- polling several devices on one executor
- serving a network API while reading sensors
- overlapping I/O-bound work with unrelated futures
- driving a GUI or TUI without stalling the event loop
The nice part is that you do not have to guess up front. A well-shaped TMP102 driver can serve both audiences.