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