Skip to main content

pamoja_sensors/
scd4x.rs

1//! Sensirion SCD40 and SCD41 CO2, temperature, and humidity sensor.
2//!
3//! The SCD4x is a photoacoustic NDIR CO2 sensor with a built-in humidity and
4//! temperature sensor, driven over I2C by 16-bit command words. Every data word it
5//! sends or receives is followed by a CRC-8, and its measurement is three such words:
6//! CO2 in parts per million, then a temperature and a humidity ratio scaled over the
7//! 16-bit range. This module carries the command table, the checksum, the frame
8//! builders and parsers, and the datasheet's conversion formulas, each anchored to
9//! the worked examples the datasheet prints beside its command descriptions.
10//!
11//! A caller sends [`command::START_PERIODIC_MEASUREMENT`] once, polls
12//! [`data_ready`] on the [`command::GET_DATA_READY_STATUS`] word, and reads the nine
13//! bytes of [`command::READ_MEASUREMENT`] into [`Measurement::parse`]. Temperatures
14//! and humidities are returned in integer milli-units, with `f32` conveniences
15//! beside them.
16
17use crate::SensorError;
18
19/// The I2C address (Table 8); the part has no address pins.
20pub const I2C_ADDRESS: u8 = 0x62;
21
22/// The largest CO2 concentration the sensor reports, in ppm (Table 1: output range
23/// 0 to 40'000 ppm).
24pub const CO2_MAX_PPM: u16 = 40_000;
25
26/// The factory temperature offset, in milli-degrees Celsius (section 3.6.1: "per
27/// default, the temperature offset is set to 4 °C").
28pub const DEFAULT_TEMPERATURE_OFFSET_MILLI_CELSIUS: u32 = 4_000;
29
30/// The signal update interval of periodic measurement, in milliseconds (section 3.5).
31pub const PERIODIC_MEASUREMENT_INTERVAL_MS: u32 = 5_000;
32
33/// The approximate signal update interval of low power periodic measurement, in
34/// milliseconds (section 3.8).
35pub const LOW_POWER_PERIODIC_MEASUREMENT_INTERVAL_MS: u32 = 30_000;
36
37/// The time the sensor needs after power-up or `reinit` before it accepts a command,
38/// in milliseconds (Table 7).
39pub const POWER_UP_TIME_MS: u32 = 1_000;
40
41/// The `perform_forced_recalibration` response that reports a failed recalibration
42/// (Table 18).
43pub const FORCED_RECALIBRATION_FAILED: u16 = 0xffff;
44
45#[cfg(feature = "embedded-hal")]
46mod driver;
47#[cfg(feature = "alloc")]
48pub mod sim;
49
50#[cfg(feature = "embedded-hal")]
51pub use driver::{Scd4x, DATA_READY_POLLS, DATA_READY_POLL_MILLIS};
52
53/// The command words (Table 9), 16 bits each, most significant byte first, with no
54/// CRC after the command itself.
55pub mod command {
56    /// Start periodic measurement, one reading every 5 seconds.
57    pub const START_PERIODIC_MEASUREMENT: u16 = 0x21b1;
58    /// Read the CO2, temperature, and humidity words of the latest measurement.
59    pub const READ_MEASUREMENT: u16 = 0xec05;
60    /// Stop periodic measurement and return to idle.
61    pub const STOP_PERIODIC_MEASUREMENT: u16 = 0x3f86;
62    /// Write the temperature offset word; see [`super::temperature_offset_word`].
63    pub const SET_TEMPERATURE_OFFSET: u16 = 0x241d;
64    /// Read the temperature offset word; see [`super::temperature_offset_milli_celsius`].
65    pub const GET_TEMPERATURE_OFFSET: u16 = 0x2318;
66    /// Write the sensor altitude, in meters above sea level.
67    pub const SET_SENSOR_ALTITUDE: u16 = 0x2427;
68    /// Read the sensor altitude, in meters above sea level.
69    pub const GET_SENSOR_ALTITUDE: u16 = 0x2322;
70    /// Write the ambient pressure word; see [`super::ambient_pressure_word`].
71    pub const SET_AMBIENT_PRESSURE: u16 = 0xe000;
72    /// Recalibrate against a reference CO2 concentration and fetch the correction.
73    pub const PERFORM_FORCED_RECALIBRATION: u16 = 0x362f;
74    /// Enable (1) or disable (0) automatic self-calibration.
75    pub const SET_AUTOMATIC_SELF_CALIBRATION_ENABLED: u16 = 0x2416;
76    /// Read whether automatic self-calibration is enabled.
77    pub const GET_AUTOMATIC_SELF_CALIBRATION_ENABLED: u16 = 0x2313;
78    /// Start low power periodic measurement, one reading about every 30 seconds.
79    pub const START_LOW_POWER_PERIODIC_MEASUREMENT: u16 = 0x21ac;
80    /// Read the data ready word; see [`super::data_ready`].
81    pub const GET_DATA_READY_STATUS: u16 = 0xe4b8;
82    /// Store the current configuration in EEPROM.
83    pub const PERSIST_SETTINGS: u16 = 0x3615;
84    /// Read the 48-bit serial number; see [`super::serial_number`].
85    pub const GET_SERIAL_NUMBER: u16 = 0x3682;
86    /// Run the end-of-line self-test; see [`super::self_test_passed`].
87    pub const PERFORM_SELF_TEST: u16 = 0x3639;
88    /// Reset the EEPROM configuration and erase the calibration history.
89    pub const PERFORM_FACTORY_RESET: u16 = 0x3632;
90    /// Reload user settings from EEPROM.
91    pub const REINIT: u16 = 0x3646;
92    /// Take one CO2, humidity, and temperature measurement (SCD41 only).
93    pub const MEASURE_SINGLE_SHOT: u16 = 0x219d;
94    /// Take one humidity and temperature measurement, CO2 reads 0 (SCD41 only).
95    pub const MEASURE_SINGLE_SHOT_RHT_ONLY: u16 = 0x2196;
96    /// Put the sensor from idle into sleep (SCD41 only).
97    pub const POWER_DOWN: u16 = 0x36e0;
98    /// Wake the sensor from sleep into idle; not acknowledged (SCD41 only).
99    pub const WAKE_UP: u16 = 0x36f6;
100}
101
102/// Returns the maximum command duration of a command word, in milliseconds.
103///
104/// This is the execution time column of Table 9, the time a caller waits after
105/// sending the command before issuing the read header or the next command.
106///
107/// # Arguments
108///
109/// * `command` - one of the [`command`] words.
110///
111/// # Returns
112///
113/// The maximum duration in milliseconds, or `None` for the two start commands,
114/// whose duration the datasheet lists as not applicable, and for any word that is
115/// not a command.
116pub fn max_duration_ms(command: u16) -> Option<u16> {
117    let ms = match command {
118        command::READ_MEASUREMENT => 1,
119        command::STOP_PERIODIC_MEASUREMENT => 500,
120        command::SET_TEMPERATURE_OFFSET => 1,
121        command::GET_TEMPERATURE_OFFSET => 1,
122        command::SET_SENSOR_ALTITUDE => 1,
123        command::GET_SENSOR_ALTITUDE => 1,
124        command::SET_AMBIENT_PRESSURE => 1,
125        command::PERFORM_FORCED_RECALIBRATION => 400,
126        command::SET_AUTOMATIC_SELF_CALIBRATION_ENABLED => 1,
127        command::GET_AUTOMATIC_SELF_CALIBRATION_ENABLED => 1,
128        command::GET_DATA_READY_STATUS => 1,
129        command::PERSIST_SETTINGS => 800,
130        command::GET_SERIAL_NUMBER => 1,
131        command::PERFORM_SELF_TEST => 10_000,
132        command::PERFORM_FACTORY_RESET => 1_200,
133        command::REINIT => 20,
134        command::MEASURE_SINGLE_SHOT => 5_000,
135        command::MEASURE_SINGLE_SHOT_RHT_ONLY => 50,
136        command::POWER_DOWN => 1,
137        command::WAKE_UP => 20,
138        _ => return None,
139    };
140    Some(ms)
141}
142
143/// Returns whether a command may be sent while a periodic measurement is running.
144///
145/// This is the "during measurement" column of Table 9: only `read_measurement`,
146/// `stop_periodic_measurement`, `set_ambient_pressure`, and `get_data_ready_status`
147/// are allowed; every other command needs the sensor in idle mode.
148///
149/// # Arguments
150///
151/// * `command` - one of the [`command`] words.
152///
153/// # Returns
154///
155/// `true` if the command is accepted during a periodic measurement.
156pub fn allowed_during_measurement(command: u16) -> bool {
157    matches!(
158        command,
159        command::READ_MEASUREMENT
160            | command::STOP_PERIODIC_MEASUREMENT
161            | command::SET_AMBIENT_PRESSURE
162            | command::GET_DATA_READY_STATUS
163    )
164}
165
166/// Computes the CRC-8 that follows every data word.
167///
168/// The parameters are those of Table 32: polynomial 0x31, initial value 0xFF, no
169/// input or output reflection, no final XOR. The datasheet's own check value is
170/// `crc(&[0xBE, 0xEF]) == 0x92`.
171///
172/// # Arguments
173///
174/// * `bytes` - the bytes the checksum covers, normally one 16-bit word.
175///
176/// # Returns
177///
178/// The 8-bit checksum.
179pub fn crc(bytes: &[u8]) -> u8 {
180    let mut crc = 0xFF;
181    for &byte in bytes {
182        crc ^= byte;
183        for _ in 0..8 {
184            crc = if crc & 0x80 != 0 {
185                (crc << 1) ^ 0x31
186            } else {
187                crc << 1
188            };
189        }
190    }
191    crc
192}
193
194/// Decodes one data word from its three-byte frame, checking the CRC.
195///
196/// # Arguments
197///
198/// * `frame` - the word's two bytes, most significant first, and its CRC.
199///
200/// # Returns
201///
202/// The 16-bit word.
203///
204/// # Errors
205///
206/// Returns [`SensorError::Crc`] if the CRC byte does not match the two data bytes.
207pub fn word(frame: &[u8; 3]) -> Result<u16, SensorError> {
208    if crc(&frame[..2]) != frame[2] {
209        return Err(SensorError::Crc);
210    }
211    Ok(u16::from_be_bytes([frame[0], frame[1]]))
212}
213
214/// Builds the three-byte frame of one data word: the word, most significant byte
215/// first, followed by its CRC.
216///
217/// The inverse of [`word`], and the payload every write command carries.
218///
219/// # Arguments
220///
221/// * `value` - the 16-bit word.
222///
223/// # Returns
224///
225/// The word's two bytes and its CRC.
226pub fn word_frame(value: u16) -> [u8; 3] {
227    let [high, low] = value.to_be_bytes();
228    [high, low, crc(&[high, low])]
229}
230
231/// Builds the two bytes of a command that carries no data word.
232///
233/// # Arguments
234///
235/// * `command` - one of the [`command`] words.
236///
237/// # Returns
238///
239/// The command word, most significant byte first; command words carry no CRC.
240pub fn command_frame(command: u16) -> [u8; 2] {
241    command.to_be_bytes()
242}
243
244/// Builds the five bytes of a write command: the command word followed by one
245/// data word and its CRC.
246///
247/// # Arguments
248///
249/// * `command` - one of the [`command`] write words.
250/// * `value` - the data word the command carries.
251///
252/// # Returns
253///
254/// The command's two bytes, the word's two bytes, and the word's CRC.
255pub fn write_frame(command: u16, value: u16) -> [u8; 5] {
256    let [ch, cl] = command.to_be_bytes();
257    let [vh, vl, vc] = word_frame(value);
258    [ch, cl, vh, vl, vc]
259}
260
261/// Decodes the raw temperature word to milli-degrees Celsius.
262///
263/// This is Table 11's `T = -45 + 175 * word / (2^16 - 1)`, in thousandths, rounded
264/// to the nearest.
265///
266/// # Arguments
267///
268/// * `raw` - the 16-bit temperature word of a measurement.
269///
270/// # Returns
271///
272/// The temperature in milli-degrees Celsius, from -45'000 to 130'000.
273pub fn milli_celsius(raw: u16) -> i32 {
274    let scaled = (raw as i64 * 175_000 + 65_535 / 2) / 65_535;
275    scaled as i32 - 45_000
276}
277
278/// Decodes the raw temperature word to degrees Celsius.
279///
280/// # Arguments
281///
282/// * `raw` - the 16-bit temperature word of a measurement.
283///
284/// # Returns
285///
286/// The temperature in degrees Celsius.
287pub fn celsius(raw: u16) -> f32 {
288    -45.0 + 175.0 * raw as f32 / 65_535.0
289}
290
291/// Builds the raw temperature word the sensor reports for a temperature.
292///
293/// The inverse of [`milli_celsius`], so a node can be tested against what a sensor
294/// sends without one attached.
295///
296/// # Arguments
297///
298/// * `milli_celsius` - the temperature in milli-degrees Celsius.
299///
300/// # Returns
301///
302/// The 16-bit temperature word, clamped to the sensor's -45 to 130 °C word range.
303pub fn temperature_raw(milli_celsius: i32) -> u16 {
304    let offset = (milli_celsius as i64 + 45_000).clamp(0, 175_000);
305    ((offset * 65_535 + 175_000 / 2) / 175_000) as u16
306}
307
308/// Decodes the raw humidity word to thousandths of a percent relative humidity.
309///
310/// This is Table 11's `RH = 100 * word / (2^16 - 1)`, in thousandths, rounded to
311/// the nearest.
312///
313/// # Arguments
314///
315/// * `raw` - the 16-bit humidity word of a measurement.
316///
317/// # Returns
318///
319/// The relative humidity in milli-percent, from 0 to 100'000.
320pub fn humidity_milli_percent(raw: u16) -> u32 {
321    ((raw as u64 * 100_000 + 65_535 / 2) / 65_535) as u32
322}
323
324/// Decodes the raw humidity word to percent relative humidity.
325///
326/// # Arguments
327///
328/// * `raw` - the 16-bit humidity word of a measurement.
329///
330/// # Returns
331///
332/// The relative humidity in percent.
333pub fn relative_humidity_percent(raw: u16) -> f32 {
334    100.0 * raw as f32 / 65_535.0
335}
336
337/// Builds the raw humidity word the sensor reports for a relative humidity.
338///
339/// The inverse of [`humidity_milli_percent`].
340///
341/// # Arguments
342///
343/// * `milli_percent` - the relative humidity in milli-percent.
344///
345/// # Returns
346///
347/// The 16-bit humidity word, clamped to 100 %.
348pub fn humidity_raw(milli_percent: u32) -> u16 {
349    let bounded = milli_percent.min(100_000) as u64;
350    ((bounded * 65_535 + 100_000 / 2) / 100_000) as u16
351}
352
353/// Returns whether the data ready word reports a measurement waiting to be read.
354///
355/// Table 22: if the least significant 11 bits of the word are zero, data is not
356/// ready; otherwise a measurement is ready for read-out.
357///
358/// # Arguments
359///
360/// * `word` - the word read after [`command::GET_DATA_READY_STATUS`].
361///
362/// # Returns
363///
364/// `true` if a measurement can be read.
365pub fn data_ready(word: u16) -> bool {
366    word & 0x07ff != 0
367}
368
369/// Encodes a temperature offset as the `set_temperature_offset` word.
370///
371/// Table 13: `word = T_offset [°C] * 2^16 / 175`, rounded to the nearest. The word
372/// scale is 2^16, unlike the measurement's 2^16 - 1.
373///
374/// # Arguments
375///
376/// * `milli_celsius` - the offset in milli-degrees Celsius; the offset is never
377///   negative.
378///
379/// # Returns
380///
381/// The 16-bit offset word.
382pub fn temperature_offset_word(milli_celsius: u32) -> u16 {
383    ((milli_celsius as u64 * 65_536 + 175_000 / 2) / 175_000).min(0xffff) as u16
384}
385
386/// Decodes the `get_temperature_offset` word to milli-degrees Celsius.
387///
388/// Table 14: `T_offset [°C] = 175 * word / 2^16`, rounded to the nearest
389/// thousandth.
390///
391/// # Arguments
392///
393/// * `word` - the word read after [`command::GET_TEMPERATURE_OFFSET`].
394///
395/// # Returns
396///
397/// The offset in milli-degrees Celsius.
398pub fn temperature_offset_milli_celsius(word: u16) -> u32 {
399    ((word as u64 * 175_000 + 65_536 / 2) / 65_536) as u32
400}
401
402/// Encodes an ambient pressure as the `set_ambient_pressure` word.
403///
404/// Table 17: `word = ambient P [Pa] / 100`, so the word is the pressure in
405/// hectopascals.
406///
407/// # Arguments
408///
409/// * `pascals` - the ambient pressure in pascals.
410///
411/// # Returns
412///
413/// The 16-bit pressure word, truncated to whole hectopascals.
414pub fn ambient_pressure_word(pascals: u32) -> u16 {
415    (pascals / 100).min(0xffff) as u16
416}
417
418/// Decodes an ambient pressure word back to pascals.
419///
420/// The inverse of [`ambient_pressure_word`].
421///
422/// # Arguments
423///
424/// * `word` - the pressure word, in hectopascals.
425///
426/// # Returns
427///
428/// The pressure in pascals.
429pub fn ambient_pressure_pascals(word: u16) -> u32 {
430    word as u32 * 100
431}
432
433/// Decodes the `perform_forced_recalibration` response to the correction applied.
434///
435/// Table 18: `FRC correction [ppm] = word - 0x8000`, and a word of `0xffff` means
436/// the recalibration failed.
437///
438/// # Arguments
439///
440/// * `word` - the word fetched after [`command::PERFORM_FORCED_RECALIBRATION`].
441///
442/// # Returns
443///
444/// The signed correction in ppm, or `None` if the recalibration failed.
445pub fn forced_recalibration_correction_ppm(word: u16) -> Option<i32> {
446    if word == FORCED_RECALIBRATION_FAILED {
447        return None;
448    }
449    Some(word as i32 - 0x8000)
450}
451
452/// Builds the `perform_forced_recalibration` response word for a correction.
453///
454/// The inverse of [`forced_recalibration_correction_ppm`].
455///
456/// # Arguments
457///
458/// * `correction_ppm` - the signed correction in ppm, or `None` for a failed
459///   recalibration.
460///
461/// # Returns
462///
463/// The response word, `correction + 0x8000`, or [`FORCED_RECALIBRATION_FAILED`].
464pub fn forced_recalibration_word(correction_ppm: Option<i32>) -> u16 {
465    match correction_ppm {
466        Some(ppm) => (ppm + 0x8000).clamp(0, 0xfffe) as u16,
467        None => FORCED_RECALIBRATION_FAILED,
468    }
469}
470
471/// Returns whether an automatic self-calibration word reports ASC enabled.
472///
473/// Tables 19 and 20: `1` means enabled, `0` means disabled.
474///
475/// # Arguments
476///
477/// * `word` - the word read after
478///   [`command::GET_AUTOMATIC_SELF_CALIBRATION_ENABLED`].
479///
480/// # Returns
481///
482/// `true` if automatic self-calibration is enabled.
483pub fn automatic_self_calibration_enabled(word: u16) -> bool {
484    word == 1
485}
486
487/// Builds the automatic self-calibration word for an enabled state.
488///
489/// The inverse of [`automatic_self_calibration_enabled`], and the word
490/// [`command::SET_AUTOMATIC_SELF_CALIBRATION_ENABLED`] carries.
491///
492/// # Arguments
493///
494/// * `enabled` - whether automatic self-calibration should be on.
495///
496/// # Returns
497///
498/// `1` for enabled, `0` for disabled.
499pub fn automatic_self_calibration_word(enabled: bool) -> u16 {
500    enabled as u16
501}
502
503/// Returns whether the self-test word reports no malfunction.
504///
505/// Table 25: `0` means no malfunction detected, any other value means a
506/// malfunction.
507///
508/// # Arguments
509///
510/// * `word` - the word read after [`command::PERFORM_SELF_TEST`].
511///
512/// # Returns
513///
514/// `true` if the sensor detected no malfunction.
515pub fn self_test_passed(word: u16) -> bool {
516    word == 0
517}
518
519/// Decodes the nine bytes of `get_serial_number` to the 48-bit serial number.
520///
521/// Table 24: `serial = word[0] << 32 | word[1] << 16 | word[2]`, each word followed
522/// by its CRC.
523///
524/// # Arguments
525///
526/// * `frame` - the three word frames read after [`command::GET_SERIAL_NUMBER`].
527///
528/// # Returns
529///
530/// The serial number.
531///
532/// # Errors
533///
534/// Returns [`SensorError::Crc`] if any of the three CRC bytes does not match its
535/// word.
536pub fn serial_number(frame: &[u8; 9]) -> Result<u64, SensorError> {
537    let w0 = word(&[frame[0], frame[1], frame[2]])? as u64;
538    let w1 = word(&[frame[3], frame[4], frame[5]])? as u64;
539    let w2 = word(&[frame[6], frame[7], frame[8]])? as u64;
540    Ok(w0 << 32 | w1 << 16 | w2)
541}
542
543/// Builds the nine bytes a sensor sends for a serial number.
544///
545/// The inverse of [`serial_number`].
546///
547/// # Arguments
548///
549/// * `serial` - the 48-bit serial number; higher bits are discarded.
550///
551/// # Returns
552///
553/// The three word frames, each word followed by its CRC.
554pub fn serial_number_frame(serial: u64) -> [u8; 9] {
555    let w0 = word_frame((serial >> 32) as u16);
556    let w1 = word_frame((serial >> 16) as u16);
557    let w2 = word_frame(serial as u16);
558    [
559        w0[0], w0[1], w0[2], w1[0], w1[1], w1[2], w2[0], w2[1], w2[2],
560    ]
561}
562
563/// One SCD4x measurement, as the three words of `read_measurement`.
564///
565/// The temperature and humidity words are kept raw so the decode stays exact; the
566/// methods convert them.
567#[derive(Clone, Copy, Debug, PartialEq, Eq)]
568pub struct Measurement {
569    /// The CO2 concentration in parts per million (`word[0]`).
570    pub co2_ppm: u16,
571    /// The 16-bit temperature word (`word[1]`).
572    pub temperature_raw: u16,
573    /// The 16-bit humidity word (`word[2]`).
574    pub humidity_raw: u16,
575}
576
577impl Measurement {
578    /// Parses the nine bytes read after [`command::READ_MEASUREMENT`].
579    ///
580    /// Table 11: CO2, temperature, and humidity words in that order, each most
581    /// significant byte first and followed by its CRC.
582    ///
583    /// # Arguments
584    ///
585    /// * `frame` - the nine response bytes.
586    ///
587    /// # Returns
588    ///
589    /// The measurement.
590    ///
591    /// # Errors
592    ///
593    /// Returns [`SensorError::Crc`] if any of the three CRC bytes does not match
594    /// its word.
595    pub fn parse(frame: &[u8; 9]) -> Result<Measurement, SensorError> {
596        Ok(Measurement {
597            co2_ppm: word(&[frame[0], frame[1], frame[2]])?,
598            temperature_raw: word(&[frame[3], frame[4], frame[5]])?,
599            humidity_raw: word(&[frame[6], frame[7], frame[8]])?,
600        })
601    }
602
603    /// Builds a measurement from physical values.
604    ///
605    /// The inverse of the decode methods, so a node can be fed the frame a sensor
606    /// would send for a chosen reading.
607    ///
608    /// # Arguments
609    ///
610    /// * `co2_ppm` - the CO2 concentration in ppm.
611    /// * `milli_celsius` - the temperature in milli-degrees Celsius.
612    /// * `humidity_milli_percent` - the relative humidity in milli-percent.
613    ///
614    /// # Returns
615    ///
616    /// The measurement with its raw words built by [`temperature_raw`] and
617    /// [`humidity_raw`].
618    pub fn from_physical(
619        co2_ppm: u16,
620        milli_celsius: i32,
621        humidity_milli_percent: u32,
622    ) -> Measurement {
623        Measurement {
624            co2_ppm,
625            temperature_raw: temperature_raw(milli_celsius),
626            humidity_raw: humidity_raw(humidity_milli_percent),
627        }
628    }
629
630    /// Builds the nine bytes a sensor sends for this measurement.
631    ///
632    /// The inverse of [`Measurement::parse`].
633    ///
634    /// # Returns
635    ///
636    /// The three word frames, each word followed by its CRC.
637    pub fn to_bytes(&self) -> [u8; 9] {
638        let c = word_frame(self.co2_ppm);
639        let t = word_frame(self.temperature_raw);
640        let h = word_frame(self.humidity_raw);
641        [c[0], c[1], c[2], t[0], t[1], t[2], h[0], h[1], h[2]]
642    }
643
644    /// Returns the temperature in milli-degrees Celsius.
645    pub fn milli_celsius(&self) -> i32 {
646        milli_celsius(self.temperature_raw)
647    }
648
649    /// Returns the temperature in degrees Celsius.
650    pub fn celsius(&self) -> f32 {
651        celsius(self.temperature_raw)
652    }
653
654    /// Returns the relative humidity in milli-percent.
655    pub fn humidity_milli_percent(&self) -> u32 {
656        humidity_milli_percent(self.humidity_raw)
657    }
658
659    /// Returns the relative humidity in percent.
660    pub fn relative_humidity_percent(&self) -> f32 {
661        relative_humidity_percent(self.humidity_raw)
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    // Table 11's example response: 500 ppm, 25 °C, 37 % RH. The datasheet prints the
670    // CO2 word's CRC as 0x7b, which is the CRC of Table 18's 0x7fce; its own algorithm
671    // (Table 32) and every other printed example give 0x33 for 0x01f4, so the frame
672    // here carries 0x33.
673    const EXAMPLE_MEASUREMENT: [u8; 9] = [0x01, 0xf4, 0x33, 0x66, 0x67, 0xa2, 0x5e, 0xb9, 0x3c];
674
675    #[test]
676    fn crc_matches_the_datasheet_check_value() {
677        // Table 32: CRC(0xBEEF) = 0x92.
678        assert_eq!(crc(&[0xBE, 0xEF]), 0x92);
679    }
680
681    #[test]
682    fn crc_matches_every_worked_example_in_the_datasheet() {
683        // Each command example prints the CRC of the word it carries.
684        for (word, expected) in [
685            (0x6667, 0xa2), // Table 11, 25 °C
686            (0x5eb9, 0x3c), // Table 11, 37 % RH
687            (0x07e6, 0x48), // Table 13, offset 5.4 °C
688            (0x0912, 0x63), // Table 14, offset 6.2 °C
689            (0x079e, 0x09), // Table 15, altitude 1950 m
690            (0x044c, 0x42), // Table 16, altitude 1100 m
691            (0x03db, 0x42), // Table 17, 98700 Pa
692            (0x01e0, 0xb4), // Table 18, target 480 ppm
693            (0x7fce, 0x7b), // Table 18, correction -50 ppm
694            (0x0001, 0xb0), // Table 19, ASC enabled
695            (0x0000, 0x81), // Table 20, ASC disabled
696            (0x8000, 0xa2), // Table 22, data not ready
697            (0xf896, 0x31), // Table 24, serial word[0]
698            (0x9f07, 0xc2), // Table 24, serial word[1]
699            (0x3bbe, 0x89), // Table 24, serial word[2]
700        ] {
701            assert_eq!(crc(&u16::to_be_bytes(word)), expected, "crc of {word:#06x}");
702            assert_eq!(word_frame(word)[2], expected);
703        }
704    }
705
706    #[test]
707    fn a_word_frame_with_a_corrupted_crc_is_rejected() {
708        assert_eq!(word(&[0x01, 0xf4, 0x33]), Ok(0x01f4));
709        assert_eq!(word(&[0x01, 0xf4, 0x7b]), Err(SensorError::Crc));
710        assert_eq!(word(&[0x01, 0xf5, 0x7b]), Err(SensorError::Crc));
711        let mut corrupted = EXAMPLE_MEASUREMENT;
712        corrupted[4] ^= 0x01;
713        assert_eq!(Measurement::parse(&corrupted), Err(SensorError::Crc));
714        corrupted = EXAMPLE_MEASUREMENT;
715        corrupted[8] = 0x00;
716        assert_eq!(Measurement::parse(&corrupted), Err(SensorError::Crc));
717    }
718
719    #[test]
720    fn the_datasheet_example_measurement_decodes_to_its_stated_values() {
721        // Table 11: 0x01f4 0x6667 0xa2 0x5eb9 0x3c is 500 ppm, 25 °C, 37 % RH; the
722        // words decode to 25.003 °C and 37.002 %, which the datasheet rounds.
723        let m = Measurement::parse(&EXAMPLE_MEASUREMENT).unwrap();
724        assert_eq!(m.co2_ppm, 500);
725        assert_eq!(m.temperature_raw, 0x6667);
726        assert_eq!(m.humidity_raw, 0x5eb9);
727        assert_eq!(m.milli_celsius(), 25_003);
728        assert_eq!(m.humidity_milli_percent(), 37_002);
729        assert!((m.celsius() - 25.0).abs() < 0.005);
730        assert!((m.relative_humidity_percent() - 37.0).abs() < 0.005);
731        assert_eq!(m.to_bytes(), EXAMPLE_MEASUREMENT);
732    }
733
734    #[test]
735    fn a_measurement_built_from_physical_values_reads_back_as_the_example() {
736        let m = Measurement::from_physical(500, 25_000, 37_000);
737        assert_eq!(m.co2_ppm, 500);
738        assert_eq!(m.milli_celsius(), 25_000);
739        assert_eq!(m.humidity_milli_percent(), 37_000);
740        let parsed = Measurement::parse(&m.to_bytes()).unwrap();
741        assert_eq!(parsed, m);
742        assert_eq!(parsed.temperature_raw, 0x6666);
743    }
744
745    #[test]
746    fn temperature_word_endpoints_match_the_formula() {
747        // T = -45 + 175 * word / 65535: 0 is -45 °C, 0xffff is 130 °C.
748        assert_eq!(milli_celsius(0), -45_000);
749        assert_eq!(milli_celsius(0xffff), 130_000);
750        assert_eq!(temperature_raw(-45_000), 0);
751        assert_eq!(temperature_raw(130_000), 0xffff);
752        assert_eq!(temperature_raw(-60_000), 0);
753        assert_eq!(temperature_raw(200_000), 0xffff);
754    }
755
756    #[test]
757    fn humidity_word_endpoints_match_the_formula() {
758        // RH = 100 * word / 65535: 0 is 0 %, 0xffff is 100 %.
759        assert_eq!(humidity_milli_percent(0), 0);
760        assert_eq!(humidity_milli_percent(0xffff), 100_000);
761        assert_eq!(humidity_raw(0), 0);
762        assert_eq!(humidity_raw(100_000), 0xffff);
763        assert_eq!(humidity_raw(150_000), 0xffff);
764    }
765
766    #[test]
767    fn integer_conversion_tracks_the_floating_point_reference() {
768        // Sweep the whole word range against a direct transcription of Table 11's
769        // formulas; the milli-unit path must agree to within half a thousandth, and
770        // every word must survive its round trip through the raw builder.
771        for raw in (0..=0xffffu32).map(|w| w as u16) {
772            let t_ref = -45.0 + 175.0 * raw as f64 / 65_535.0;
773            let t_int = milli_celsius(raw);
774            assert!(
775                (t_int as f64 / 1000.0 - t_ref).abs() < 0.00051,
776                "temperature {t_int} vs {t_ref} at {raw:#06x}"
777            );
778            assert!((celsius(raw) as f64 - t_ref).abs() < 0.001);
779            assert_eq!(temperature_raw(t_int), raw, "temperature word {raw:#06x}");
780
781            let h_ref = 100.0 * raw as f64 / 65_535.0;
782            let h_int = humidity_milli_percent(raw);
783            assert!(
784                (h_int as f64 / 1000.0 - h_ref).abs() < 0.00051,
785                "humidity {h_int} vs {h_ref} at {raw:#06x}"
786            );
787            assert!((relative_humidity_percent(raw) as f64 - h_ref).abs() < 0.001);
788            assert_eq!(humidity_raw(h_int), raw, "humidity word {raw:#06x}");
789        }
790    }
791
792    #[test]
793    fn data_ready_reads_the_low_eleven_bits() {
794        // Table 22: 0x8000 has its least significant 11 bits clear, data not ready.
795        assert!(!data_ready(0x8000));
796        assert!(!data_ready(0xf800));
797        assert!(data_ready(0x0001));
798        assert!(data_ready(0x8006));
799        assert!(data_ready(0x0400));
800    }
801
802    #[test]
803    fn temperature_offset_matches_the_datasheet_examples() {
804        // Table 13: 5.4 °C is written as 0x07e6; Table 14: 0x0912 reads as 6.2 °C.
805        assert_eq!(temperature_offset_word(5_400), 0x07e6);
806        assert_eq!(temperature_offset_milli_celsius(0x0912), 6_200);
807        assert_eq!(
808            write_frame(command::SET_TEMPERATURE_OFFSET, 0x07e6),
809            [0x24, 0x1d, 0x07, 0xe6, 0x48]
810        );
811        assert_eq!(
812            temperature_offset_word(DEFAULT_TEMPERATURE_OFFSET_MILLI_CELSIUS),
813            1_498
814        );
815        assert_eq!(temperature_offset_milli_celsius(1_498), 4_000);
816        // The word is coarser than a thousandth, so raw words round-trip exactly.
817        for word in (0..=0xffffu32).map(|w| w as u16) {
818            let reference = 175.0 * word as f64 / 65_536.0;
819            let milli = temperature_offset_milli_celsius(word);
820            assert!((milli as f64 / 1000.0 - reference).abs() < 0.00051);
821            assert_eq!(
822                temperature_offset_word(milli),
823                word,
824                "offset word {word:#06x}"
825            );
826        }
827    }
828
829    #[test]
830    fn altitude_and_pressure_frames_match_the_datasheet_examples() {
831        // Table 15: 1950 m is written as 0x079e 0x09; Table 16: 0x044c reads as 1100 m.
832        assert_eq!(
833            write_frame(command::SET_SENSOR_ALTITUDE, 1_950),
834            [0x24, 0x27, 0x07, 0x9e, 0x09]
835        );
836        assert_eq!(word(&[0x04, 0x4c, 0x42]), Ok(1_100));
837        // Table 17: 98'700 Pa is written as 0x03db 0x42.
838        assert_eq!(ambient_pressure_word(98_700), 0x03db);
839        assert_eq!(
840            write_frame(command::SET_AMBIENT_PRESSURE, ambient_pressure_word(98_700)),
841            [0xe0, 0x00, 0x03, 0xdb, 0x42]
842        );
843        assert_eq!(ambient_pressure_pascals(0x03db), 98_700);
844        assert_eq!(ambient_pressure_word(u32::MAX), 0xffff);
845    }
846
847    #[test]
848    fn forced_recalibration_matches_the_datasheet_example() {
849        // Table 18: target 480 ppm is written as 0x01e0 0xb4, and the response
850        // 0x7fce is a correction of -50 ppm; 0xffff reports a failure.
851        assert_eq!(
852            write_frame(command::PERFORM_FORCED_RECALIBRATION, 480),
853            [0x36, 0x2f, 0x01, 0xe0, 0xb4]
854        );
855        assert_eq!(forced_recalibration_correction_ppm(0x7fce), Some(-50));
856        assert_eq!(forced_recalibration_correction_ppm(0x8000), Some(0));
857        assert_eq!(forced_recalibration_correction_ppm(0xffff), None);
858        assert_eq!(forced_recalibration_word(Some(-50)), 0x7fce);
859        assert_eq!(forced_recalibration_word(None), FORCED_RECALIBRATION_FAILED);
860        assert_eq!(forced_recalibration_word(Some(40_000)), 0xfffe);
861    }
862
863    #[test]
864    fn automatic_self_calibration_words_match_the_datasheet_examples() {
865        // Table 19: enabled is written as 0x0001 0xB0; Table 20: 0x0000 0x81 reads as
866        // disabled.
867        assert_eq!(
868            write_frame(
869                command::SET_AUTOMATIC_SELF_CALIBRATION_ENABLED,
870                automatic_self_calibration_word(true)
871            ),
872            [0x24, 0x16, 0x00, 0x01, 0xb0]
873        );
874        assert_eq!(automatic_self_calibration_word(false), 0);
875        assert!(automatic_self_calibration_enabled(
876            word(&[0x00, 0x01, 0xb0]).unwrap()
877        ));
878        assert!(!automatic_self_calibration_enabled(
879            word(&[0x00, 0x00, 0x81]).unwrap()
880        ));
881    }
882
883    #[test]
884    fn self_test_passes_only_on_zero() {
885        // Table 25: 0x0000 0x81 is no malfunction detected.
886        assert!(self_test_passed(word(&[0x00, 0x00, 0x81]).unwrap()));
887        assert!(!self_test_passed(0x0001));
888    }
889
890    #[test]
891    fn serial_number_matches_the_datasheet_example() {
892        // Table 24: 0xf896 0x31 0x9f07 0xc2 0x3bbe 0x89 is serial 273'325'796'834'238.
893        let frame = [0xf8, 0x96, 0x31, 0x9f, 0x07, 0xc2, 0x3b, 0xbe, 0x89];
894        assert_eq!(serial_number(&frame), Ok(273_325_796_834_238));
895        assert_eq!(serial_number_frame(273_325_796_834_238), frame);
896        let mut corrupted = frame;
897        corrupted[5] ^= 0x80;
898        assert_eq!(serial_number(&corrupted), Err(SensorError::Crc));
899    }
900
901    #[test]
902    fn command_words_and_durations_match_table_9() {
903        assert_eq!(
904            command_frame(command::START_PERIODIC_MEASUREMENT),
905            [0x21, 0xb1]
906        );
907        assert_eq!(
908            command_frame(command::STOP_PERIODIC_MEASUREMENT),
909            [0x3f, 0x86]
910        );
911        assert_eq!(max_duration_ms(command::START_PERIODIC_MEASUREMENT), None);
912        assert_eq!(
913            max_duration_ms(command::START_LOW_POWER_PERIODIC_MEASUREMENT),
914            None
915        );
916        assert_eq!(max_duration_ms(command::READ_MEASUREMENT), Some(1));
917        assert_eq!(
918            max_duration_ms(command::STOP_PERIODIC_MEASUREMENT),
919            Some(500)
920        );
921        assert_eq!(
922            max_duration_ms(command::PERFORM_FORCED_RECALIBRATION),
923            Some(400)
924        );
925        assert_eq!(max_duration_ms(command::PERSIST_SETTINGS), Some(800));
926        assert_eq!(max_duration_ms(command::PERFORM_SELF_TEST), Some(10_000));
927        assert_eq!(max_duration_ms(command::PERFORM_FACTORY_RESET), Some(1_200));
928        assert_eq!(max_duration_ms(command::REINIT), Some(20));
929        assert_eq!(max_duration_ms(command::MEASURE_SINGLE_SHOT), Some(5_000));
930        assert_eq!(
931            max_duration_ms(command::MEASURE_SINGLE_SHOT_RHT_ONLY),
932            Some(50)
933        );
934        assert_eq!(max_duration_ms(command::POWER_DOWN), Some(1));
935        assert_eq!(max_duration_ms(command::WAKE_UP), Some(20));
936        assert_eq!(max_duration_ms(0x0000), None);
937    }
938
939    #[test]
940    fn only_four_commands_are_allowed_during_a_measurement() {
941        assert!(allowed_during_measurement(command::READ_MEASUREMENT));
942        assert!(allowed_during_measurement(
943            command::STOP_PERIODIC_MEASUREMENT
944        ));
945        assert!(allowed_during_measurement(command::SET_AMBIENT_PRESSURE));
946        assert!(allowed_during_measurement(command::GET_DATA_READY_STATUS));
947        assert!(!allowed_during_measurement(command::SET_TEMPERATURE_OFFSET));
948        assert!(!allowed_during_measurement(
949            command::PERFORM_FORCED_RECALIBRATION
950        ));
951        assert!(!allowed_during_measurement(
952            command::START_PERIODIC_MEASUREMENT
953        ));
954    }
955}