From 6bdf2e7f85301440c476395a7e7e8b99422c7467 Mon Sep 17 00:00:00 2001 From: Eliza Weisman Date: Sat, 8 Jun 2024 12:18:35 -0700 Subject: [PATCH] Add `embedded-hal-async` support This commit adds support for the `embedded-hal-async` crate in addition to `embedded-hal`. I've done this by adding a separate `AsyncSgp30` type, based on the assumption that most projects won't need to use both the blocking `embedded-hal` traits and the `embedded-hal-async` traits at the same time, and providing `async fn` methods on a separate type with the same names as the blocking ones seemed a bit nicer than having one type that has both `fn measure` and `async fn measure_async` and so on. I've also factored out some of the no-IO code for packing and unpacking Rust to/from bytes, so that it can be shared by both the async and blocking driver types. Support for `embedded-hal-async` is gated behind the `embedded-hal-async` feature flag, so the dependency is not enabled by default. Note that this branch depends on my PR #18, which updates this crate to use `embedded-hal` v1.0. It also depends on my upstream PR adding `embedded-hal-async` support to `sensirion-i2c-rs`, sensirion/sensirion-i2c-rs#30, which has been [merged], but hasn't been published to crates.io yet. Currently, this branch adds a Cargo `[patch]` to use a Git dep on `sensirion-i2c-rs`. So, this change cannot be released to crates.io until upstream publishes a new release of `sensirion-i2c-rs`. Hopefully they do that soon! :) [merged]: https://github.com/Sensirion/sensirion-i2c-rs/commit/f7b9f3a81b777bc6e6b2f0acb4c1ef9c57dfa06d --- Cargo.toml | 11 ++ README.md | 6 +- src/async_impl.rs | 326 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 94 +++++++------ src/types.rs | 29 +++++ 5 files changed, 427 insertions(+), 39 deletions(-) create mode 100644 src/async_impl.rs diff --git a/Cargo.toml b/Cargo.toml index 09a87b9..89ba10c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,10 +21,12 @@ edition = "2018" [features] default = [] +embedded-hal-async = ["dep:embedded-hal-async", "sensirion-i2c/embedded-hal-async"] [dependencies] byteorder = { version = "1", default-features = false } embedded-hal = "1" +embedded-hal-async = { version = "1", optional = true } num-traits = { version = "0.2", default-features = false } sensirion-i2c = "0.3" @@ -34,3 +36,12 @@ embedded-hal-mock = { version = "0.11.1", features = ["eh1"] } [profile.release] lto = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +# Necessary for `embedded-hal-async` support until a new release of +# `sensirion-i2c-rs` is published. +[patch.crates-io] +sensirion-i2c = { git = "https://github.com/sensirion/sensirion-i2c-rs", rev = "f7b9f3a81b777bc6e6b2f0acb4c1ef9c57dfa06d" } diff --git a/README.md b/README.md index a5723b2..cd4d01d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ ![No Std][no-std-badge] This is a platform agnostic Rust driver for the Sensirion SGP30 gas sensor, -based on the [`embedded-hal`](https://github.com/japaric/embedded-hal) traits. +based on the [`embedded-hal`](https://github.com/japaric/embedded-hal) or +[`embedded-hal-async`] traits. Docs: https://docs.rs/sgp30 @@ -29,6 +30,7 @@ Datasheet: https://www.sensirion.com/file/datasheet_sgp30 - [x] Support on-chip self-test - [x] CRC checks - [x] Docs +- [x] [`embedded-hal-async] support ## License @@ -45,7 +47,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. - +[`embedded-hal-async`]: https://crates.io/crates/embedded-hal-async [workflow]: https://github.com/dbrgn/sgp30-rs/actions?query=workflow%3ACI [workflow-badge]: https://github.com/dbrgn/sgp30-rs/actions/workflows/ci.yml/badge.svg diff --git a/src/async_impl.rs b/src/async_impl.rs new file mode 100644 index 0000000..e7d33d1 --- /dev/null +++ b/src/async_impl.rs @@ -0,0 +1,326 @@ +use super::{types::*, Command, Error, SELFTEST_SUCCESS}; +use byteorder::{BigEndian, ByteOrder}; +use embedded_hal_async::{delay::DelayNs, i2c::I2c}; +use sensirion_i2c::i2c_async; + +/// Async driver for the SGP30. +/// +/// This type is identical to the [`Sgp30`](crate::Sgp30) type, but using the +/// [`embedded_hal_async`] versions of the [`I2c`] and [`DelayNs`] traits. +#[derive(Debug, Default)] +pub struct AsyncSgp30 { + /// The concrete I²C device implementation. + i2c: I2C, + /// The I²C device address. + address: u8, + /// The concrete Delay implementation. + delay: D, + /// Whether the air quality measurement was initialized. + initialized: bool, +} + +impl AsyncSgp30 +where + I2C: I2c, + D: DelayNs, +{ + /// Create a new instance of the SGP30 driver. + pub fn new(i2c: I2C, address: u8, delay: D) -> Self { + Self { + i2c, + address, + delay, + initialized: false, + } + } + + /// Destroy driver instance, return I²C bus instance. + pub fn destroy(self) -> I2C { + self.i2c + } + + /// Write an I²C command to the sensor. + async fn send_command(&mut self, command: Command) -> Result<(), Error> { + self.i2c + .write(self.address, &command.as_bytes()) + .await + .map_err(Error::I2cWrite) + } + + /// Write an I²C command and data to the sensor. + /// + /// The data slice must have a length of 2 or 4. + /// + /// CRC checksums will automatically be added to the data. + async fn send_command_and_data( + &mut self, + command: Command, + data: &[u8], + ) -> Result<(), Error> { + let mut buf = [0; 2 /* command */ + 6 /* max length of data + crc */]; + let payload = command.as_bytes_with_data(&mut buf, data); + self.i2c + .write(self.address, payload) + .await + .map_err(Error::I2cWrite) + } + + /// Return the 48 bit serial number of the SGP30. + pub async fn serial(&mut self) -> Result<[u8; 6], Error> { + // Request serial number + self.send_command(Command::GetSerial).await?; + + // Recommended wait time according to datasheet (6.5) + self.delay.delay_us(500).await; + + // Read serial number + let mut buf = [0; 9]; + i2c_async::read_words_with_crc(&mut self.i2c, self.address, &mut buf).await?; + + Ok([buf[0], buf[1], buf[3], buf[4], buf[6], buf[7]]) + } + + /// Run an on-chip self-test. Return a boolean indicating whether the test succeeded. + pub async fn selftest(&mut self) -> Result> { + // Start self test + self.send_command(Command::SelfTest).await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(220).await; + + // Read result + let mut buf = [0; 3]; + i2c_async::read_words_with_crc(&mut self.i2c, self.address, &mut buf).await?; + + // Compare with self-test success pattern + Ok(&buf[0..2] == SELFTEST_SUCCESS) + } + + /// Initialize the air quality measurement. + /// + /// The SGP30 uses a dynamic baseline compensation algorithm and on-chip + /// calibration parameters to provide two complementary air quality + /// signals. + /// + /// Calling this method starts the air quality measurement. After + /// initializing the measurement, the `measure()` method must be called in + /// regular intervals of 1 s to ensure proper operation of the dynamic + /// baseline compensation algorithm. It is the responsibility of the user + /// of this driver to ensure that these periodic measurements are being + /// done. + /// + /// For the first 15 s after initializing the air quality measurement, the + /// sensor is in an initialization phase during which it returns fixed + /// values of 400 ppm CO₂eq and 0 ppb TVOC. After 15 s (15 measurements) + /// the values should start to change. + /// + /// A new init command has to be sent after every power-up or soft reset. + pub async fn init(&mut self) -> Result<(), Error> { + if self.initialized { + // Already initialized + return Ok(()); + } + self.force_init().await + } + + /// Like [`init()`](Self::init), but without checking + /// whether the sensor is already initialized. + /// + /// This might be necessary after a sensor soft or hard reset. + pub async fn force_init(&mut self) -> Result<(), Error> { + // Send command to sensor + self.send_command(Command::InitAirQuality).await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(10).await; + + self.initialized = true; + Ok(()) + } + + /// Get an air quality measurement. + /// + /// Before calling this method, the air quality measurements must have been + /// initialized using the [`init()`](Self::init) method. + /// Otherwise an [`Error::NotInitialized`] will be returned. + /// + /// Once the measurements have been initialized, the + /// [`measure()`](Self::measure) method must be called + /// in regular intervals of 1 s to ensure proper operation of the dynamic + /// baseline compensation algorithm. It is the responsibility of the user + /// of this driver to ensure that these periodic measurements are being + /// done. + /// + /// For the first 15 s after initializing the air quality measurement, the + /// sensor is in an initialization phase during which it returns fixed + /// values of 400 ppm CO₂eq and 0 ppb TVOC. After 15 s (15 measurements) + /// the values should start to change. + pub async fn measure(&mut self) -> Result> { + if !self.initialized { + // Measurements weren't initialized + return Err(Error::NotInitialized); + } + + // Send command to sensor + self.send_command(Command::MeasureAirQuality).await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(12).await; + + // Read result + let mut buf = [0; 6]; + i2c_async::read_words_with_crc(&mut self.i2c, self.address, &mut buf).await?; + Ok(Measurement::from_bytes(&buf)) + } + + /// Return sensor raw signals. + /// + /// This command is intended for part verification and testing purposes. It + /// returns the raw signals which are used as inputs for the on-chip + /// calibration and baseline compensation algorithm. The command performs a + /// measurement to which the sensor responds with the two signals for H2 + /// and Ethanol. + pub async fn measure_raw_signals(&mut self) -> Result> { + if !self.initialized { + // Measurements weren't initialized + return Err(Error::NotInitialized); + } + + // Send command to sensor + self.send_command(Command::MeasureRawSignals).await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(25).await; + + // Read result + let mut buf = [0; 6]; + i2c_async::read_words_with_crc(&mut self.i2c, self.address, &mut buf).await?; + Ok(RawSignals::from_bytes(&buf)) + } + + /// Return the baseline values of the baseline correction algorithm. + /// + /// The SGP30 provides the possibility to read and write the baseline + /// values of the baseline correction algorithm. This feature is used to + /// save the baseline in regular intervals on an external non-volatile + /// memory and restore it after a new power-up or soft reset of the sensor. + /// + /// This function returns the baseline values for the two air quality + /// signals. These two values should be stored on an external memory. After + /// a power-up or soft reset, the baseline of the baseline correction + /// algorithm can be restored by calling [`init()`](Self::init) followed by + /// [`set_baseline()`](Self::set_baseline). + pub async fn get_baseline(&mut self) -> Result> { + // Send command to sensor + self.send_command(Command::GetBaseline).await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(10).await; + + // Read result + let mut buf = [0; 6]; + i2c_async::read_words_with_crc(&mut self.i2c, self.address, &mut buf).await?; + Ok(Baseline::from_bytes(&buf)) + } + + /// Set the baseline values for the baseline correction algorithm. + /// + /// Before calling this method, the air quality measurements must have been + /// initialized using the [`init()`](Self::init) method. + /// Otherwise an [`Error::NotInitialized`] will be returned. + /// + /// The SGP30 provides the possibility to read and write the baseline + /// values of the baseline correction algorithm. This feature is used to + /// save the baseline in regular intervals on an external non-volatile + /// memory and restore it after a new power-up or soft reset of the sensor. + /// + /// This function sets the baseline values for the two air quality + /// signals. + pub async fn set_baseline(&mut self, baseline: &Baseline) -> Result<(), Error> { + if !self.initialized { + // Measurements weren't initialized + return Err(Error::NotInitialized); + } + + // Send command and data to sensor + // Note that the order of the two parameters is inverted when writing + // compared to when reading. + let mut buf = [0; 4]; + BigEndian::write_u16(&mut buf[0..2], baseline.tvoc); + BigEndian::write_u16(&mut buf[2..4], baseline.co2eq); + self.send_command_and_data(Command::SetBaseline, &buf) + .await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(10).await; + + Ok(()) + } + + /// Set the humidity value for the baseline correction algorithm. + /// + /// The SGP30 features an on-chip humidity compensation for the air quality + /// signals (CO₂eq and TVOC) and sensor raw signals (H2 and Ethanol). To + /// use the on-chip humidity compensation, an absolute humidity value from + /// an external humidity sensor is required. + /// + /// After setting a new humidity value, this value will be used by the + /// on-chip humidity compensation algorithm until a new humidity value is + /// set. Restarting the sensor (power-on or soft reset) or calling the + /// function with a `None` value sets the humidity value used for + /// compensation to its default value (11.57 g/m³) until a new humidity + /// value is sent. + /// + /// Before calling this method, the air quality measurements must have been + /// initialized using the [`init()`](Self::init) method. + /// Otherwise an [`Error::NotInitialized`] will be returned. + pub async fn set_humidity( + &mut self, + humidity: Option<&Humidity>, + ) -> Result<(), Error> { + if !self.initialized { + // Measurements weren't initialized + return Err(Error::NotInitialized); + } + + // Send command and data to sensor + let buf = match humidity { + Some(humi) => humi.as_bytes(), + None => [0, 0], + }; + self.send_command_and_data(Command::SetHumidity, &buf) + .await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(10).await; + + Ok(()) + } + + /// Get the feature set. + /// + /// The SGP30 features a versioning system for the available set of + /// measurement commands and on-chip algorithms. This so called feature set + /// version number can be read out with this method. + pub async fn get_feature_set(&mut self) -> Result> { + // Send command to sensor + self.send_command(Command::GetFeatureSet).await?; + + // Max duration according to datasheet (Table 10) + self.delay.delay_ms(2).await; + + // Read result + let mut buf = [0; 3]; + i2c_async::read_words_with_crc(&mut self.i2c, self.address, &mut buf).await?; + + Ok(FeatureSet::parse(buf[0], buf[1])) + } +} + +#[cfg(test)] +mod tests { + // TODO: `embedded-hal-mock`'s support for `embedded-hal-async` does not + // currently have a mock I2C implementation. When that's available, we + // should add tests for the async I2C functions here that are analogous to + // the ones in the `i2c` module. +} diff --git a/src/lib.rs b/src/lib.rs index 8504158..a778e1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,16 +164,48 @@ //! function with a `None` value sets the humidity value used for //! compensation to its default value (11.57 g/m³) until a new humidity //! value is sent. +//! +//! ## `embedded-hal-async` support +//! +//! This crate has optional support for the [`embedded-hal-async`] crate, which +//! provides `async` versions of the `I2c` and `DelayNs` traits. Async support +//! is an off-by-default optional feature, so that projects which aren't using +//! [`embedded-hal-async`] can avoid the additional dependency. +//! +//! To use this crate with `embedded-hal-async`, enable the `embedded-hal-async` +//! feature flag in your `Cargo.toml`: +//! +//! ```toml +//! sgp30 = { version = "0.4", features = ["embedded-hal-async"] } +//! ``` +//! +//! Once the `embedded-hal-async` feature is enabled, construct an instance of +//! the [`AsyncSgp30`] struct, providing types implementing the +//! [`embedded_hal_async::i2c::I2c`] and [`embedded_hal_async::delay::DelayNs`] +//! traits. The [`AsyncSgp30`] struct is identical to the [`Sgp30`] struct, +//! except that its methods are `async fn`s. +//! +//! [`embedded-hal-async`]: https://crates.io/crates/embedded-hal-async +//! [`embedded_hal_async::i2c::I2c`]: https://docs.rs/embedded-hal-async/embedded-hal-async #![deny(unsafe_code)] #![deny(missing_docs)] #![cfg_attr(not(test), no_std)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] use byteorder::{BigEndian, ByteOrder}; use embedded_hal as hal; use sensirion_i2c::{crc8, i2c}; -use crate::hal::{delay::DelayNs, i2c::I2c}; +use crate::hal::{ + delay::DelayNs, + i2c::{ErrorType, I2c}, +}; + +#[cfg(feature = "embedded-hal-async")] +mod async_impl; +#[cfg(feature = "embedded-hal-async")] +pub use async_impl::AsyncSgp30; mod types; @@ -195,7 +227,7 @@ pub enum Error { impl From> for Error where - I: I2c, + I: ErrorType, { fn from(err: i2c::Error) -> Self { match err { @@ -243,6 +275,22 @@ impl Command { Command::GetFeatureSet => [0x20, 0x2F], } } + + fn as_bytes_with_data<'buf>(self, buf: &'buf mut [u8; 8], data: &[u8]) -> &'buf [u8] { + assert!(data.len() == 2 || data.len() == 4); + buf[0..2].copy_from_slice(&self.as_bytes()); + buf[2..4].copy_from_slice(&data[0..2]); + buf[4] = crc8::calculate(&data[0..2]); + if data.len() > 2 { + buf[5..7].copy_from_slice(&data[2..4]); + buf[7] = crc8::calculate(&data[2..4]); + } + if data.len() > 2 { + &buf[0..8] + } else { + &buf[0..5] + } + } } /// Driver for the SGP30 @@ -258,6 +306,8 @@ pub struct Sgp30 { initialized: bool, } +const SELFTEST_SUCCESS: &[u8] = &[0xd4, 0x00]; + impl Sgp30 where I2C: I2c, @@ -295,20 +345,8 @@ where command: Command, data: &[u8], ) -> Result<(), Error> { - assert!(data.len() == 2 || data.len() == 4); let mut buf = [0; 2 /* command */ + 6 /* max length of data + crc */]; - buf[0..2].copy_from_slice(&command.as_bytes()); - buf[2..4].copy_from_slice(&data[0..2]); - buf[4] = crc8::calculate(&data[0..2]); - if data.len() > 2 { - buf[5..7].copy_from_slice(&data[2..4]); - buf[7] = crc8::calculate(&data[2..4]); - } - let payload = if data.len() > 2 { - &buf[0..8] - } else { - &buf[0..5] - }; + let payload = command.as_bytes_with_data(&mut buf, data); self.i2c .write(self.address, payload) .map_err(Error::I2cWrite) @@ -342,7 +380,7 @@ where i2c::read_words_with_crc(&mut self.i2c, self.address, &mut buf)?; // Compare with self-test success pattern - Ok(buf[0..2] == [0xd4, 0x00]) + Ok(&buf[0..2] == SELFTEST_SUCCESS) } /// Initialize the air quality measurement. @@ -420,13 +458,7 @@ where // Read result let mut buf = [0; 6]; i2c::read_words_with_crc(&mut self.i2c, self.address, &mut buf)?; - let co2eq_ppm = (u16::from(buf[0]) << 8) | u16::from(buf[1]); - let tvoc_ppb = (u16::from(buf[3]) << 8) | u16::from(buf[4]); - - Ok(Measurement { - co2eq_ppm, - tvoc_ppb, - }) + Ok(Measurement::from_bytes(&buf)) } /// Return sensor raw signals. @@ -451,13 +483,7 @@ where // Read result let mut buf = [0; 6]; i2c::read_words_with_crc(&mut self.i2c, self.address, &mut buf)?; - let h2_signal = (u16::from(buf[0]) << 8) | u16::from(buf[1]); - let ethanol_signal = (u16::from(buf[3]) << 8) | u16::from(buf[4]); - - Ok(RawSignals { - h2: h2_signal, - ethanol: ethanol_signal, - }) + Ok(RawSignals::from_bytes(&buf)) } /// Return the baseline values of the baseline correction algorithm. @@ -483,13 +509,7 @@ where // Read result let mut buf = [0; 6]; i2c::read_words_with_crc(&mut self.i2c, self.address, &mut buf)?; - let co2eq_baseline = (u16::from(buf[0]) << 8) | u16::from(buf[1]); - let tvoc_baseline = (u16::from(buf[3]) << 8) | u16::from(buf[4]); - - Ok(Baseline { - co2eq: co2eq_baseline, - tvoc: tvoc_baseline, - }) + Ok(Baseline::from_bytes(&buf)) } /// Set the baseline values for the baseline correction algorithm. diff --git a/src/types.rs b/src/types.rs index 6d240dc..1acf60d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -10,6 +10,17 @@ pub struct Measurement { pub tvoc_ppb: u16, } +impl Measurement { + pub(crate) fn from_bytes(buf: &[u8; 6]) -> Self { + let co2eq_ppm = (u16::from(buf[0]) << 8) | u16::from(buf[1]); + let tvoc_ppb = (u16::from(buf[3]) << 8) | u16::from(buf[4]); + Self { + co2eq_ppm, + tvoc_ppb, + } + } +} + /// A raw signals result from the sensor. #[derive(Debug, PartialEq, Eq, Clone)] pub struct RawSignals { @@ -19,6 +30,14 @@ pub struct RawSignals { pub ethanol: u16, } +impl RawSignals { + pub(crate) fn from_bytes(buf: &[u8; 6]) -> Self { + let h2 = (u16::from(buf[0]) << 8) | u16::from(buf[1]); + let ethanol = (u16::from(buf[3]) << 8) | u16::from(buf[4]); + Self { h2, ethanol } + } +} + /// The baseline values. #[derive(Debug, PartialEq, Eq, Clone)] pub struct Baseline { @@ -28,6 +47,16 @@ pub struct Baseline { pub tvoc: u16, } +impl Baseline { + pub(crate) fn from_bytes(buf: &[u8; 6]) -> Self { + let measurement = Measurement::from_bytes(buf); + Baseline { + co2eq: measurement.co2eq_ppm, + tvoc: measurement.tvoc_ppb, + } + } +} + /// Absolute humidity in g/m³. /// /// Internally this is represented as a 8.8bit fixed-point number.