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}