Skip to main content

pamoja_sensors/
ds18b20.rs

1//! Maxim DS18B20 1-Wire digital thermometer.
2//!
3//! The DS18B20 reports temperature as a 16-bit two's-complement number in a nine-byte
4//! scratchpad, with a CRC byte that covers the rest. This module decodes that
5//! temperature exactly as the datasheet's temperature/data table specifies, reads the
6//! resolution out of the configuration byte, and verifies the scratchpad's CRC with
7//! the Maxim 1-Wire polynomial so a read corrupted on the bus is caught rather than
8//! trusted.
9//!
10//! It is pure logic: a caller drives the 1-Wire transactions (convert, then read
11//! scratchpad) and hands the nine bytes to [`Scratchpad::parse`].
12
13use crate::SensorError;
14
15#[cfg(feature = "embedded-hal")]
16mod driver;
17
18#[cfg(feature = "embedded-hal")]
19pub use driver::Ds18b20;
20
21/// The function commands a DS18B20 answers once it has been addressed.
22pub mod command {
23    /// Start a temperature conversion; the result lands in the scratchpad.
24    pub const CONVERT_T: u8 = 0x44;
25    /// Send the nine scratchpad bytes, CRC last.
26    pub const READ_SCRATCHPAD: u8 = 0xBE;
27    /// Take the alarm thresholds and the configuration byte, three bytes in that order.
28    pub const WRITE_SCRATCHPAD: u8 = 0x4E;
29    /// Copy those three bytes from the scratchpad to the EEPROM.
30    pub const COPY_SCRATCHPAD: u8 = 0x48;
31    /// Reload the three EEPROM bytes into the scratchpad.
32    pub const RECALL_EEPROM: u8 = 0xB8;
33    /// Answer one bit: 1 for external power, 0 for parasite power.
34    pub const READ_POWER_SUPPLY: u8 = 0xB4;
35}
36
37/// How long a copy to the EEPROM takes, in microseconds.
38pub const EEPROM_WRITE_MICROS: u32 = 10_000;
39
40/// The 1-Wire family code that identifies a DS18B20 in the first ROM byte.
41pub const FAMILY_CODE: u8 = 0x28;
42
43/// The conversion resolution, selected by the R1/R0 bits of the configuration byte.
44///
45/// Higher resolution resolves smaller steps but takes longer to convert, a tradeoff
46/// the datasheet spells out. The temperature register always carries 1/16 °C per
47/// count; at lower resolutions the unused low bits simply read as zero.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Resolution {
50    /// 9-bit, 0.5 °C steps, up to 93.75 ms per conversion.
51    Bits9,
52    /// 10-bit, 0.25 °C steps, up to 187.5 ms per conversion.
53    Bits10,
54    /// 11-bit, 0.125 °C steps, up to 375 ms per conversion.
55    Bits11,
56    /// 12-bit, 0.0625 °C steps, up to 750 ms per conversion. The power-on default.
57    Bits12,
58}
59
60impl Resolution {
61    /// Returns the number of significant bits this resolution produces.
62    ///
63    /// # Returns
64    ///
65    /// `9`, `10`, `11`, or `12`.
66    pub fn bits(self) -> u8 {
67        match self {
68            Resolution::Bits9 => 9,
69            Resolution::Bits10 => 10,
70            Resolution::Bits11 => 11,
71            Resolution::Bits12 => 12,
72        }
73    }
74
75    /// Returns the configuration-register byte that selects this resolution.
76    ///
77    /// The byte places R1/R0 in bits 6:5 over the datasheet's fixed surrounding
78    /// pattern (bit 7 clear, bit 4 set, bits 3:0 set), so 12-bit is `0x7F`, 11-bit
79    /// `0x5F`, 10-bit `0x3F`, and 9-bit `0x1F`.
80    ///
81    /// # Returns
82    ///
83    /// The configuration byte written to scratchpad byte 4.
84    pub fn config_byte(self) -> u8 {
85        let r1r0 = match self {
86            Resolution::Bits9 => 0b00,
87            Resolution::Bits10 => 0b01,
88            Resolution::Bits11 => 0b10,
89            Resolution::Bits12 => 0b11,
90        };
91        0b0001_1111 | (r1r0 << 5)
92    }
93
94    /// Reads the resolution out of a configuration byte's R1/R0 bits.
95    ///
96    /// # Arguments
97    ///
98    /// * `byte` - the configuration register (scratchpad byte 4).
99    ///
100    /// # Returns
101    ///
102    /// The resolution selected by bits 6:5.
103    pub fn from_config_byte(byte: u8) -> Resolution {
104        match (byte >> 5) & 0b11 {
105            0b00 => Resolution::Bits9,
106            0b01 => Resolution::Bits10,
107            0b10 => Resolution::Bits11,
108            _ => Resolution::Bits12,
109        }
110    }
111
112    /// Returns the temperature step this resolution resolves, in micro-degrees Celsius.
113    ///
114    /// # Returns
115    ///
116    /// `500000` (0.5 °C) for 9-bit down to `62500` (0.0625 °C) for 12-bit.
117    pub fn step_micro_celsius(self) -> u32 {
118        match self {
119            Resolution::Bits9 => 500_000,
120            Resolution::Bits10 => 250_000,
121            Resolution::Bits11 => 125_000,
122            Resolution::Bits12 => 62_500,
123        }
124    }
125
126    /// Returns the datasheet's maximum conversion time, in microseconds.
127    ///
128    /// # Returns
129    ///
130    /// `93750` for 9-bit, doubling up to `750000` for 12-bit.
131    pub fn max_conversion_micros(self) -> u32 {
132        match self {
133            Resolution::Bits9 => 93_750,
134            Resolution::Bits10 => 187_500,
135            Resolution::Bits11 => 375_000,
136            Resolution::Bits12 => 750_000,
137        }
138    }
139}
140
141/// Converts a raw temperature register value to micro-degrees Celsius, exactly.
142///
143/// Each count is 1/16 °C, which is 62500 micro-degrees, so the conversion is exact in
144/// integer arithmetic and needs no floating point.
145///
146/// # Arguments
147///
148/// * `raw` - the 16-bit two's-complement temperature register, as a signed value.
149///
150/// # Returns
151///
152/// The temperature in micro-degrees Celsius (millionths of a degree).
153pub fn temperature_to_micro_celsius(raw: i16) -> i32 {
154    raw as i32 * 62_500
155}
156
157/// Converts a raw temperature register value to degrees Celsius.
158///
159/// # Arguments
160///
161/// * `raw` - the 16-bit two's-complement temperature register, as a signed value.
162///
163/// # Returns
164///
165/// The temperature in degrees Celsius.
166pub fn temperature_to_celsius(raw: i16) -> f32 {
167    raw as f32 / 16.0
168}
169
170/// Converts micro-degrees Celsius to a raw temperature register value.
171///
172/// The result is truncated to the step `resolution` resolves, since the datasheet
173/// specifies that the bits below a selected resolution read as zero.
174///
175/// # Arguments
176///
177/// * `micro_celsius` - the temperature in millionths of a degree Celsius.
178/// * `resolution` - the resolution the part is configured for.
179///
180/// # Returns
181///
182/// The 16-bit two's-complement temperature register, as a signed value.
183pub fn temperature_from_micro_celsius(micro_celsius: i32, resolution: Resolution) -> i16 {
184    let counts = micro_celsius.div_euclid(62_500) as i16;
185    let unused = 12 - resolution.bits();
186    (counts >> unused) << unused
187}
188
189/// Converts degrees Celsius to a raw temperature register value.
190///
191/// # Arguments
192///
193/// * `celsius` - the temperature in degrees Celsius.
194/// * `resolution` - the resolution the part is configured for.
195///
196/// # Returns
197///
198/// The 16-bit two's-complement temperature register, as a signed value.
199pub fn temperature_from_celsius(celsius: f32, resolution: Resolution) -> i16 {
200    temperature_from_micro_celsius((celsius * 1_000_000.0) as i32, resolution)
201}
202
203/// Computes the Maxim 1-Wire CRC-8 over `data`.
204///
205/// This is the CRC the DS18B20 (and every Maxim 1-Wire device) appends to its ROM
206/// code and scratchpad. The polynomial is X^8 + X^5 + X^4 + 1, processed
207/// least-significant-bit first from a zero shift register, which is the reflected
208/// form `0x8C`.
209///
210/// # Arguments
211///
212/// * `data` - the bytes the CRC covers, in transmission order.
213///
214/// # Returns
215///
216/// The 8-bit CRC; for a correctly received message followed by its CRC byte, running
217/// this over all of them yields zero.
218pub fn crc8(data: &[u8]) -> u8 {
219    let mut crc = 0u8;
220    for &byte in data {
221        let mut bits = byte;
222        for _ in 0..8 {
223            let mix = (crc ^ bits) & 0x01;
224            crc >>= 1;
225            if mix != 0 {
226                crc ^= 0x8C;
227            }
228            bits >>= 1;
229        }
230    }
231    crc
232}
233
234/// A decoded, CRC-verified DS18B20 scratchpad.
235///
236/// The scratchpad is nine bytes: temperature LSB and MSB, the high and low alarm
237/// thresholds, the configuration byte, three reserved bytes, and a CRC. [`parse`]
238/// checks the CRC before exposing any of it.
239///
240/// [`parse`]: Scratchpad::parse
241///
242/// # Examples
243///
244/// ```
245/// use pamoja_sensors::ds18b20::{temperature_from_celsius, Resolution, Scratchpad};
246///
247/// // What a part sitting at +25.0625 °C, set to 12-bit steps with its thresholds at
248/// // +75/-10 °C, puts on the bus. A driver reads these nine bytes off the wire.
249/// let bytes = Scratchpad::new(
250///     temperature_from_celsius(25.0625, Resolution::Bits12),
251///     Resolution::Bits12,
252///     75,
253///     -10,
254/// )
255/// .to_bytes();
256///
257/// let scratchpad = Scratchpad::parse(&bytes)?;
258/// assert_eq!(scratchpad.temperature_micro_celsius(), 25_062_500);
259/// assert_eq!(scratchpad.resolution(), Resolution::Bits12);
260/// # Ok::<(), pamoja_sensors::SensorError>(())
261/// ```
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub struct Scratchpad {
264    raw_temperature: i16,
265    alarm_high: i8,
266    alarm_low: i8,
267    resolution: Resolution,
268}
269
270impl Scratchpad {
271    /// Parses and CRC-checks a nine-byte scratchpad.
272    ///
273    /// # Arguments
274    ///
275    /// * `bytes` - the nine scratchpad bytes in the order the device sends them, the
276    ///   ninth being the CRC.
277    ///
278    /// # Returns
279    ///
280    /// The decoded scratchpad.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`SensorError::Crc`] if the CRC byte does not match the first eight,
285    /// which means the read was corrupted and should be repeated.
286    pub fn parse(bytes: &[u8; 9]) -> Result<Scratchpad, SensorError> {
287        if crc8(&bytes[..8]) != bytes[8] {
288            return Err(SensorError::Crc);
289        }
290        let raw_temperature = i16::from_le_bytes([bytes[0], bytes[1]]);
291        Ok(Scratchpad {
292            raw_temperature,
293            alarm_high: bytes[2] as i8,
294            alarm_low: bytes[3] as i8,
295            resolution: Resolution::from_config_byte(bytes[4]),
296        })
297    }
298
299    /// Builds the scratchpad a part in the given state reports.
300    ///
301    /// This is the inverse of [`parse`](Self::parse), so a node can be developed and
302    /// tested against the bytes a thermometer would send without one attached.
303    ///
304    /// # Arguments
305    ///
306    /// * `raw_temperature` - the 16-bit two's-complement temperature register, as a
307    ///   signed value; [`temperature_from_celsius`] builds one from a temperature.
308    /// * `resolution` - the resolution the part is configured for.
309    /// * `alarm_high` - the high alarm threshold in whole degrees Celsius (TH).
310    /// * `alarm_low` - the low alarm threshold in whole degrees Celsius (TL).
311    ///
312    /// # Returns
313    ///
314    /// The scratchpad holding those values.
315    pub fn new(
316        raw_temperature: i16,
317        resolution: Resolution,
318        alarm_high: i8,
319        alarm_low: i8,
320    ) -> Scratchpad {
321        Scratchpad {
322            raw_temperature,
323            alarm_high,
324            alarm_low,
325            resolution,
326        }
327    }
328
329    /// Returns the nine bytes a part holding this scratchpad puts on the bus.
330    ///
331    /// Bytes 5 to 7 are the reserved bytes, which a part reports but which carry no
332    /// reading; they are filled with the values its scratchpad figure shows so the
333    /// frame is the one a device would actually send. The ninth byte is the CRC over
334    /// the other eight, so the result parses.
335    ///
336    /// # Returns
337    ///
338    /// The nine scratchpad bytes in transmission order, CRC last.
339    pub fn to_bytes(&self) -> [u8; 9] {
340        let temperature = self.raw_temperature.to_le_bytes();
341        let mut bytes = [
342            temperature[0],
343            temperature[1],
344            self.alarm_high as u8,
345            self.alarm_low as u8,
346            self.resolution.config_byte(),
347            0xFF,
348            0x0C,
349            0x10,
350            0x00,
351        ];
352        bytes[8] = crc8(&bytes[..8]);
353        bytes
354    }
355
356    /// Returns the raw temperature register value.
357    ///
358    /// # Returns
359    ///
360    /// The 16-bit two's-complement register, as a signed value.
361    pub fn raw_temperature(&self) -> i16 {
362        self.raw_temperature
363    }
364
365    /// Returns the temperature in micro-degrees Celsius.
366    ///
367    /// # Returns
368    ///
369    /// The temperature, exact, in millionths of a degree Celsius.
370    pub fn temperature_micro_celsius(&self) -> i32 {
371        temperature_to_micro_celsius(self.raw_temperature)
372    }
373
374    /// Returns the temperature in degrees Celsius.
375    ///
376    /// # Returns
377    ///
378    /// The temperature in degrees Celsius.
379    pub fn temperature_celsius(&self) -> f32 {
380        temperature_to_celsius(self.raw_temperature)
381    }
382
383    /// Returns the configured conversion resolution.
384    pub fn resolution(&self) -> Resolution {
385        self.resolution
386    }
387
388    /// Returns the high temperature alarm threshold (TH), in whole degrees Celsius.
389    pub fn alarm_high(&self) -> i8 {
390        self.alarm_high
391    }
392
393    /// Returns the low temperature alarm threshold (TL), in whole degrees Celsius.
394    pub fn alarm_low(&self) -> i8 {
395        self.alarm_low
396    }
397}
398
399/// Decodes the text the Linux kernel's `w1_therm` driver serves for a thermometer.
400///
401/// The `w1_slave` file under `/sys/bus/w1/devices/28-*/` holds two lines: the nine
402/// scratchpad bytes in hex followed by `: crc=xx YES` or `NO`, then the same bytes
403/// followed by `t=` and the temperature in millidegrees. This takes the first line's
404/// bytes and parses them as a [`Scratchpad`], so the CRC is checked here as well; a
405/// `NO` from the kernel is reported without a second look.
406///
407/// # Arguments
408///
409/// * `text` - the file's contents.
410///
411/// # Returns
412///
413/// The scratchpad.
414///
415/// # Errors
416///
417/// Returns [`SensorError::Crc`] if the kernel or this decoder rejects the CRC, and
418/// [`SensorError::Invalid`] if the text is not in the driver's format.
419///
420/// # Examples
421///
422/// ```
423/// use pamoja_sensors::ds18b20::{parse_w1_slave, temperature_from_celsius, Resolution, Scratchpad};
424///
425/// // What the kernel prints for a part at 20.8125 C, built from the same library
426/// // that decodes it.
427/// let raw = temperature_from_celsius(20.8125, Resolution::Bits12);
428/// let bytes = Scratchpad::new(raw, Resolution::Bits12, 75, -10).to_bytes();
429/// let hex: Vec<String> = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
430/// let text = format!("{} : crc={:02x} YES\n", hex.join(" "), bytes[8]);
431///
432/// let scratchpad = parse_w1_slave(&text)?;
433/// assert_eq!(scratchpad.temperature_micro_celsius(), 20_812_500);
434/// # Ok::<(), pamoja_sensors::SensorError>(())
435/// ```
436pub fn parse_w1_slave(text: &str) -> Result<Scratchpad, SensorError> {
437    let line = text.lines().next().ok_or(SensorError::Invalid)?;
438    let (hex, verdict) = line.split_once(": crc=").ok_or(SensorError::Invalid)?;
439    let verdict = verdict.trim();
440    if verdict.ends_with("NO") {
441        return Err(SensorError::Crc);
442    }
443    if !verdict.ends_with("YES") {
444        return Err(SensorError::Invalid);
445    }
446    let mut bytes = [0u8; 9];
447    let mut count = 0;
448    for token in hex.split_ascii_whitespace() {
449        if count == bytes.len() {
450            return Err(SensorError::Invalid);
451        }
452        bytes[count] = u8::from_str_radix(token, 16).map_err(|_| SensorError::Invalid)?;
453        count += 1;
454    }
455    if count != bytes.len() {
456        return Err(SensorError::Invalid);
457    }
458    Scratchpad::parse(&bytes)
459}
460
461/// Renders the text the Linux kernel's `w1_therm` driver serves for a scratchpad it read
462/// cleanly: the contents of a thermometer's `w1_slave` file.
463///
464/// This is the inverse of [`parse_w1_slave`], so a program that reads the kernel's files is
465/// written and tested with no thermometer and no kernel. The format is the one
466/// `w1_slave_show` prints in `drivers/w1/slaves/w1_therm.c`: the nine bytes as two
467/// lowercase hex digits and a space each, then `: crc=`, the CRC the kernel computed over
468/// the first eight, and `YES`; then the nine bytes again and `t=` with the temperature in
469/// millidegrees, which `w1_DS18B20_convert_temp` takes as the raw register times 1000
470/// divided by 16, truncated toward zero as C divides.
471///
472/// # Arguments
473///
474/// * `scratchpad` - the scratchpad the kernel read.
475///
476/// # Returns
477///
478/// The file's two lines.
479///
480/// # Examples
481///
482/// ```
483/// use pamoja_sensors::ds18b20::{
484///     parse_w1_slave, temperature_from_celsius, w1_slave_text, Resolution, Scratchpad,
485/// };
486///
487/// let raw = temperature_from_celsius(21.5, Resolution::Bits12);
488/// let scratchpad = Scratchpad::new(raw, Resolution::Bits12, 75, -10);
489/// let text = w1_slave_text(&scratchpad);
490/// assert!(text.ends_with(" t=21500\n"));
491/// assert_eq!(parse_w1_slave(&text)?, scratchpad);
492/// # Ok::<(), pamoja_sensors::SensorError>(())
493/// ```
494#[cfg(feature = "alloc")]
495pub fn w1_slave_text(scratchpad: &Scratchpad) -> alloc::string::String {
496    use core::fmt::Write;
497
498    let bytes = scratchpad.to_bytes();
499    let mut hex = alloc::string::String::with_capacity(27);
500    for byte in bytes {
501        let _ = write!(hex, "{byte:02x} ");
502    }
503    alloc::format!(
504        "{hex}: crc={:02x} YES\n{hex}t={}\n",
505        crc8(&bytes[..8]),
506        kernel_millidegrees(&bytes)
507    )
508}
509
510// The temperature the kernel prints, as `w1_DS18B20_convert_temp` works it out, including
511// the path it takes for a clone that sets bit 7 of the configuration register.
512#[cfg(feature = "alloc")]
513fn kernel_millidegrees(bytes: &[u8; 9]) -> i32 {
514    let register = u16::from_le_bytes([bytes[0], bytes[1]]);
515    if bytes[4] & 0x80 != 0 {
516        let extended = (register << 2) | u16::from(bytes[4] & 0x03);
517        i32::from(extended as i16) * 1000 / 64
518    } else {
519        i32::from(register as i16) * 1000 / 16
520    }
521}
522
523/// The kernel's own 1-Wire driver: thermometers as files under `/sys/bus/w1/devices`.
524///
525/// On a Raspberry Pi the `w1-gpio` overlay (`dtoverlay=w1-gpio` in `config.txt`)
526/// puts the bus on a GPIO, and the kernel's `w1_therm` driver lists every DS18B20 it
527/// finds as a directory named by its family code and serial, `28-000005e2fdc3`, with
528/// a `w1_slave` file that runs a conversion and prints the scratchpad on every read.
529/// A [`Thermometer`](linux::Thermometer) reads that file as a
530/// [`Sensor`](pamoja_core::Sensor), which is the right way to reach a DS18B20 from a
531/// Linux process, where the bit timing a [`Ds18b20`] needs cannot be held.
532#[cfg(feature = "linux")]
533pub mod linux {
534    use std::path::{Path, PathBuf};
535    use std::{fmt, fs, io};
536
537    use pamoja_core::Sensor;
538
539    use super::{parse_w1_slave, Scratchpad, FAMILY_CODE};
540    use crate::SensorError;
541
542    /// Where the kernel lists the devices on its 1-Wire buses.
543    pub const DEVICES: &str = "/sys/bus/w1/devices";
544
545    /// The name of the file that runs a conversion and prints the scratchpad.
546    pub const W1_SLAVE: &str = "w1_slave";
547
548    /// What can go wrong reading a thermometer through the kernel.
549    #[derive(Debug)]
550    pub enum ThermometerError {
551        /// The file could not be read: the overlay is off, the device is gone, or the
552        /// process lacks permission.
553        Io(io::Error),
554        /// The file was read but its scratchpad did not decode.
555        Sensor(SensorError),
556    }
557
558    impl fmt::Display for ThermometerError {
559        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560            match self {
561                ThermometerError::Io(error) => write!(f, "reading the w1_slave file: {error}"),
562                ThermometerError::Sensor(error) => write!(f, "{error}"),
563            }
564        }
565    }
566
567    impl std::error::Error for ThermometerError {
568        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
569            match self {
570                ThermometerError::Io(error) => Some(error),
571                ThermometerError::Sensor(error) => Some(error),
572            }
573        }
574    }
575
576    impl From<ThermometerError> for pamoja_core::Error {
577        fn from(error: ThermometerError) -> Self {
578            match error {
579                ThermometerError::Io(io) => pamoja_core::Error::Io(io.to_string()),
580                ThermometerError::Sensor(sensor) => pamoja_core::Error::Codec(sensor.to_string()),
581            }
582        }
583    }
584
585    /// A DS18B20 the kernel exposes as a `w1_slave` file.
586    ///
587    /// # Examples
588    ///
589    /// ```no_run
590    /// use pamoja_core::Sensor;
591    /// use pamoja_sensors::ds18b20::linux::Thermometer;
592    ///
593    /// # async fn run() -> pamoja_core::Result<()> {
594    /// let mut probe = Thermometer::new("000005e2fdc3");
595    /// let scratchpad = probe.read().await?;
596    /// println!("{:.4} C", scratchpad.temperature_celsius());
597    /// # Ok(())
598    /// # }
599    /// ```
600    #[derive(Clone, Debug, PartialEq, Eq)]
601    pub struct Thermometer {
602        path: PathBuf,
603    }
604
605    impl Thermometer {
606        /// Names a thermometer by the serial in its directory name.
607        ///
608        /// # Arguments
609        ///
610        /// * `serial` - the twelve hex digits after `28-` in the device directory.
611        ///
612        /// # Returns
613        ///
614        /// The thermometer, reading `/sys/bus/w1/devices/28-<serial>/w1_slave`.
615        pub fn new(serial: &str) -> Thermometer {
616            let directory = format!("{FAMILY_CODE:02x}-{serial}");
617            Thermometer {
618                path: Path::new(DEVICES).join(directory).join(W1_SLAVE),
619            }
620        }
621
622        /// Names a thermometer by the path of its `w1_slave` file.
623        ///
624        /// # Arguments
625        ///
626        /// * `path` - the file to read.
627        ///
628        /// # Returns
629        ///
630        /// The thermometer.
631        pub fn at(path: impl Into<PathBuf>) -> Thermometer {
632            Thermometer { path: path.into() }
633        }
634
635        /// Returns the path of the file the thermometer reads.
636        pub fn path(&self) -> &Path {
637            &self.path
638        }
639
640        /// Returns the serial the kernel named the thermometer's directory after.
641        ///
642        /// # Returns
643        ///
644        /// The twelve hex digits after `28-`, as in `000005e2fdc3`, which tell one probe
645        /// from another and stay with the part for life. `None` when the file does not sit
646        /// in a DS18B20's directory, as a thermometer named by [`at`](Thermometer::at) may
647        /// not.
648        ///
649        /// # Examples
650        ///
651        /// ```
652        /// use pamoja_sensors::ds18b20::linux::Thermometer;
653        ///
654        /// assert_eq!(Thermometer::new("000005e2fdc3").serial(), Some("000005e2fdc3"));
655        /// assert_eq!(Thermometer::at("/tmp/w1_slave").serial(), None);
656        /// ```
657        pub fn serial(&self) -> Option<&str> {
658            let directory = self.path.parent()?.file_name()?.to_str()?;
659            let (family, serial) = directory.split_once('-')?;
660            let family = u8::from_str_radix(family, 16).ok()?;
661            (family == FAMILY_CODE && !serial.is_empty()).then_some(serial)
662        }
663
664        /// Lists every DS18B20 the kernel has found.
665        ///
666        /// # Returns
667        ///
668        /// One thermometer per `28-*` directory under [`DEVICES`].
669        ///
670        /// # Errors
671        ///
672        /// Returns the I/O error if the directory cannot be listed, which usually
673        /// means the 1-Wire overlay is not enabled.
674        pub fn discover() -> io::Result<Vec<Thermometer>> {
675            Thermometer::discover_in(Path::new(DEVICES))
676        }
677
678        /// Lists every DS18B20 directory under `devices`.
679        ///
680        /// # Arguments
681        ///
682        /// * `devices` - the directory the kernel lists its 1-Wire devices in.
683        ///
684        /// # Returns
685        ///
686        /// One thermometer per directory whose name starts with the DS18B20 family
687        /// code, sorted by name.
688        ///
689        /// # Errors
690        ///
691        /// Returns the I/O error if the directory cannot be listed.
692        pub fn discover_in(devices: &Path) -> io::Result<Vec<Thermometer>> {
693            let prefix = format!("{FAMILY_CODE:02x}-");
694            let mut found: Vec<Thermometer> = fs::read_dir(devices)?
695                .filter_map(|entry| entry.ok())
696                .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix))
697                .map(|entry| Thermometer::at(entry.path().join(W1_SLAVE)))
698                .collect();
699            found.sort_by(|a, b| a.path.cmp(&b.path));
700            Ok(found)
701        }
702
703        /// Reads the file, which makes the kernel run a conversion, and decodes it.
704        ///
705        /// # Returns
706        ///
707        /// The CRC-checked scratchpad.
708        ///
709        /// # Errors
710        ///
711        /// Returns [`ThermometerError::Io`] if the file cannot be read and
712        /// [`ThermometerError::Sensor`] if it does not decode.
713        pub fn read_scratchpad(&self) -> Result<Scratchpad, ThermometerError> {
714            let text = fs::read_to_string(&self.path).map_err(ThermometerError::Io)?;
715            parse_w1_slave(&text).map_err(ThermometerError::Sensor)
716        }
717    }
718
719    impl Sensor for Thermometer {
720        type Reading = Scratchpad;
721
722        async fn read(&mut self) -> pamoja_core::Result<Scratchpad> {
723            self.read_scratchpad().map_err(pamoja_core::Error::from)
724        }
725    }
726
727    #[cfg(test)]
728    mod tests {
729        use super::*;
730        use crate::ds18b20::{temperature_from_celsius, Resolution};
731
732        fn kernel_text(celsius: f32) -> String {
733            let raw = temperature_from_celsius(celsius, Resolution::Bits12);
734            let bytes = Scratchpad::new(raw, Resolution::Bits12, 75, -10).to_bytes();
735            let hex: Vec<String> = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
736            let hex = hex.join(" ");
737            format!(
738                "{hex} : crc={:02x} YES\n{hex} t={}\n",
739                bytes[8],
740                (celsius * 1000.0) as i32
741            )
742        }
743
744        #[test]
745        fn a_thermometer_reads_and_decodes_its_file() {
746            let dir = std::env::temp_dir().join(format!("pamoja-w1-{}", std::process::id()));
747            let device = dir.join("28-000005e2fdc3");
748            fs::create_dir_all(&device).unwrap();
749            fs::write(device.join(W1_SLAVE), kernel_text(21.5)).unwrap();
750            fs::create_dir_all(dir.join("w1_bus_master1")).unwrap();
751
752            let found = Thermometer::discover_in(&dir).unwrap();
753            assert_eq!(found.len(), 1);
754            assert_eq!(found[0].path(), device.join(W1_SLAVE));
755            assert_eq!(found[0].serial(), Some("000005e2fdc3"));
756            let scratchpad = found[0].read_scratchpad().unwrap();
757            assert_eq!(scratchpad.temperature_celsius(), 21.5);
758            assert_eq!(
759                pamoja_hal::script::block_on(found[0].clone().read()).unwrap(),
760                scratchpad
761            );
762
763            let missing = Thermometer::at(dir.join("28-none").join(W1_SLAVE));
764            assert!(matches!(
765                missing.read_scratchpad(),
766                Err(ThermometerError::Io(_))
767            ));
768            fs::remove_dir_all(&dir).unwrap();
769        }
770
771        #[test]
772        fn a_serial_names_the_kernel_path() {
773            let probe = Thermometer::new("000005e2fdc3");
774            assert_eq!(
775                probe.path(),
776                Path::new("/sys/bus/w1/devices/28-000005e2fdc3/w1_slave")
777            );
778            assert_eq!(probe.serial(), Some("000005e2fdc3"));
779        }
780
781        #[test]
782        fn only_a_ds18b20_directory_names_a_serial() {
783            let at = |path: &str| Thermometer::at(path);
784            assert_eq!(
785                at("/w1/28-0316a2795cff/w1_slave").serial(),
786                Some("0316a2795cff")
787            );
788            assert_eq!(at("/w1/10-000802f1e4a3/w1_slave").serial(), None);
789            assert_eq!(at("/w1/28-/w1_slave").serial(), None);
790            assert_eq!(at("/w1/w1_bus_master1/w1_slave").serial(), None);
791            assert_eq!(at("w1_slave").serial(), None);
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    #[test]
801    fn temperature_table_matches_the_datasheet() {
802        // The DS18B20 datasheet's temperature/data relationship table, in 1/16 °C.
803        let table: &[(i16, i32)] = &[
804            (0x07D0, 125_000_000),
805            (0x0550, 85_000_000),
806            (0x0191, 25_062_500),
807            (0x00A2, 10_125_000),
808            (0x0008, 500_000),
809            (0x0000, 0),
810            (i16::from_le_bytes([0xF8, 0xFF]), -500_000),
811            (i16::from_le_bytes([0x5E, 0xFF]), -10_125_000),
812            (i16::from_le_bytes([0x6F, 0xFE]), -25_062_500),
813            (i16::from_le_bytes([0x90, 0xFC]), -55_000_000),
814        ];
815        for &(raw, micro) in table {
816            assert_eq!(temperature_to_micro_celsius(raw), micro, "raw {raw:#06x}");
817        }
818    }
819
820    #[test]
821    fn crc8_matches_the_published_check_value() {
822        // CRC-8/MAXIM-DOW check value for the ASCII string "123456789" is 0xA1.
823        assert_eq!(crc8(b"123456789"), 0xA1);
824        // An empty message leaves the zero-initialized register untouched.
825        assert_eq!(crc8(&[]), 0x00);
826    }
827
828    #[test]
829    fn crc_over_a_message_and_its_crc_is_zero() {
830        let data = [0x28, 0xFF, 0x64, 0x1E, 0x0C, 0x00, 0x00, 0x00];
831        let crc = crc8(&data);
832        let mut with_crc = [0u8; 9];
833        with_crc[..8].copy_from_slice(&data);
834        with_crc[8] = crc;
835        assert_eq!(crc8(&with_crc), 0x00);
836    }
837
838    #[test]
839    fn a_scratchpad_round_trips_through_parse() {
840        let mut bytes = [0x91, 0x01, 75, 0xF6, 0x7F, 0xFF, 0x00, 0x10, 0x00];
841        bytes[8] = crc8(&bytes[..8]);
842        let scratchpad = Scratchpad::parse(&bytes).expect("valid crc");
843        assert_eq!(scratchpad.raw_temperature(), 0x0191);
844        assert_eq!(scratchpad.temperature_micro_celsius(), 25_062_500);
845        assert_eq!(scratchpad.resolution(), Resolution::Bits12);
846        assert_eq!(scratchpad.alarm_high(), 75);
847        assert_eq!(scratchpad.alarm_low(), -10);
848    }
849
850    #[test]
851    fn a_corrupted_scratchpad_fails_the_crc() {
852        let mut bytes = [0x91, 0x01, 75, 0xF6, 0x7F, 0xFF, 0x00, 0x10, 0x00];
853        bytes[8] = crc8(&bytes[..8]);
854        bytes[0] ^= 0x01; // flip a temperature bit after the CRC was computed
855        assert_eq!(Scratchpad::parse(&bytes), Err(SensorError::Crc));
856    }
857
858    #[test]
859    fn resolution_config_bytes_match_the_datasheet() {
860        assert_eq!(Resolution::Bits9.config_byte(), 0x1F);
861        assert_eq!(Resolution::Bits10.config_byte(), 0x3F);
862        assert_eq!(Resolution::Bits11.config_byte(), 0x5F);
863        assert_eq!(Resolution::Bits12.config_byte(), 0x7F);
864        for resolution in [
865            Resolution::Bits9,
866            Resolution::Bits10,
867            Resolution::Bits11,
868            Resolution::Bits12,
869        ] {
870            assert_eq!(
871                Resolution::from_config_byte(resolution.config_byte()),
872                resolution
873            );
874        }
875    }
876    #[test]
877    fn a_built_scratchpad_parses_back_to_what_it_was_built_from() {
878        let built = Scratchpad::new(
879            temperature_from_celsius(25.0625, Resolution::Bits12),
880            Resolution::Bits12,
881            75,
882            -10,
883        );
884        let parsed = Scratchpad::parse(&built.to_bytes()).expect("a built scratchpad is valid");
885        assert_eq!(parsed, built);
886        assert_eq!(parsed.raw_temperature(), 0x0191);
887        assert_eq!(parsed.temperature_micro_celsius(), 25_062_500);
888        assert_eq!(parsed.alarm_high(), 75);
889        assert_eq!(parsed.alarm_low(), -10);
890    }
891
892    #[test]
893    fn a_built_scratchpad_carries_the_datasheet_temperature_bytes() {
894        // The +25.0625 °C row of the datasheet's temperature/data table is 0x0191, sent
895        // least-significant byte first, and byte 4 selects 12-bit resolution.
896        let bytes = Scratchpad::new(0x0191, Resolution::Bits12, 75, -10).to_bytes();
897        assert_eq!(bytes[0], 0x91);
898        assert_eq!(bytes[1], 0x01);
899        assert_eq!(bytes[4], 0x7F);
900        assert_eq!(bytes[8], crc8(&bytes[..8]));
901    }
902
903    #[test]
904    fn a_temperature_truncates_to_the_step_the_resolution_resolves() {
905        // 12-bit resolves a sixteenth of a degree; the coarser settings read the bits
906        // below their step as zero, so the same temperature lands on a lower count.
907        assert_eq!(temperature_from_celsius(25.0625, Resolution::Bits12), 401);
908        assert_eq!(temperature_from_celsius(25.0625, Resolution::Bits11), 400);
909        assert_eq!(temperature_from_celsius(25.0625, Resolution::Bits10), 400);
910        assert_eq!(temperature_from_celsius(25.0625, Resolution::Bits9), 400);
911        assert_eq!(
912            temperature_from_micro_celsius(-10_062_500, Resolution::Bits12),
913            -161
914        );
915    }
916
917    #[test]
918    fn every_temperature_register_survives_a_round_trip() {
919        for raw in [-880i16, -161, -1, 0, 1, 401, 1250] {
920            let micro = temperature_to_micro_celsius(raw);
921            assert_eq!(
922                temperature_from_micro_celsius(micro, Resolution::Bits12),
923                raw
924            );
925        }
926    }
927    #[test]
928    fn the_kernel_text_decodes_and_its_verdict_is_honored() {
929        let raw = temperature_from_celsius(20.8125, Resolution::Bits12);
930        let bytes = Scratchpad::new(raw, Resolution::Bits12, 75, -10).to_bytes();
931        let mut hex = alloc::string::String::new();
932        for byte in bytes {
933            hex.push_str(&alloc::format!("{byte:02x} "));
934        }
935        let text = alloc::format!("{hex}: crc={:02x} YES\n{hex}t=20812\n", bytes[8]);
936        let scratchpad = parse_w1_slave(&text).unwrap();
937        assert_eq!(scratchpad.temperature_micro_celsius(), 20_812_500);
938
939        let rejected = alloc::format!("{hex}: crc=00 NO\n{hex}t=20812\n");
940        assert_eq!(parse_w1_slave(&rejected), Err(SensorError::Crc));
941        assert_eq!(parse_w1_slave(""), Err(SensorError::Invalid));
942        assert_eq!(
943            parse_w1_slave("4d 01 : crc=e8 YES\n"),
944            Err(SensorError::Invalid)
945        );
946        assert_eq!(
947            parse_w1_slave("zz 01 4b 46 7f ff 03 10 e8 : crc=e8 YES\n"),
948            Err(SensorError::Invalid)
949        );
950    }
951
952    #[cfg(feature = "alloc")]
953    #[test]
954    fn the_kernel_text_prints_what_w1_therm_prints() {
955        // Rows of the datasheet's temperature/data table, with the millidegrees the kernel
956        // prints for each: the raw count times 1000 over 16, truncated toward zero, so
957        // -25.0625 C prints as -25062 rather than -25063.
958        let rows: &[(i16, i32)] = &[
959            (0x07D0, 125_000),
960            (0x0191, 25_062),
961            (0x0008, 500),
962            (0x0000, 0),
963            (i16::from_le_bytes([0x5E, 0xFF]), -10_125),
964            (i16::from_le_bytes([0x6F, 0xFE]), -25_062),
965            (i16::from_le_bytes([0x90, 0xFC]), -55_000),
966        ];
967        for &(raw, millidegrees) in rows {
968            let scratchpad = Scratchpad::new(raw, Resolution::Bits12, 75, -10);
969            let bytes = scratchpad.to_bytes();
970            let text = w1_slave_text(&scratchpad);
971            let (first, second) = text.split_once('\n').expect("two lines");
972            let hex: alloc::vec::Vec<alloc::string::String> = bytes
973                .iter()
974                .map(|byte| alloc::format!("{byte:02x}"))
975                .collect();
976            assert_eq!(
977                first,
978                alloc::format!("{} : crc={:02x} YES", hex.join(" "), bytes[8])
979            );
980            assert_eq!(
981                second,
982                alloc::format!("{} t={millidegrees}\n", hex.join(" "))
983            );
984            assert_eq!(parse_w1_slave(&text), Ok(scratchpad));
985        }
986
987        // A clone that sets bit 7 of the configuration register carries two more
988        // temperature bits there, which the kernel folds in before dividing by 64.
989        let mut clone = [0x91, 0x01, 75, 0xF6, 0x83, 0xFF, 0x00, 0x10, 0x00];
990        clone[8] = crc8(&clone[..8]);
991        assert_eq!(kernel_millidegrees(&clone), (0x0191 * 4 + 3) * 1000 / 64);
992    }
993}