airtouch5 0.2.0

A library for communicating with AirTouch 5 air conditioning system control consoles
Documentation
/// A temperature value, in degrees Celsius with one decimal place resolution.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Temperature(i16);

impl Temperature {
    /// A temperature from a value in tenths of a degree Celsius.
    ///
    /// # Examples
    ///
    /// ```
    /// # use airtouch5::types::Temperature;
    /// let t = Temperature::from_deci(257);
    /// assert_eq!(format!("{t}"), "25.7");
    /// assert_eq!(format!("{t:#}"), "25.7℃");
    /// ```
    pub const fn from_deci(decidegrees: i16) -> Self {
        Self(decidegrees)
    }

    /// A temperature from a floating point value in degrees Celsius.
    /// The valus is rounded to one decimal place (i.e. to the nearest tenth
    /// of a degree).
    ///
    /// # Examples
    ///
    /// ```
    /// # use airtouch5::types::Temperature;
    /// assert_eq!(Temperature::from_float(23.471), Temperature::from_deci(235));
    /// ```
    pub fn from_float(degrees: f32) -> Self {
        Self((degrees * 10.0).round_ties_even() as i16)
    }

    /// Returns `true` if this temperature is within the valid range to be used
    /// as a setpoint temperature.
    ///
    /// A valid setpoint temperature must be between 10℃ and 25℃, inclusive.
    pub fn is_setpoint_valid(&self) -> bool {
        self.0 >= 100 && self.0 <= 350
    }

    /// Construct a temperature from an on-wire setpoint value.
    pub(crate) fn from_setpoint(bits: u8) -> Result<Self, InvalidTemperature> {
        if bits < 0xff {
            Ok(Self(bits as i16 + 100))
        } else {
            Err(InvalidTemperature::FromSetpoint(bits))
        }
    }

    /// Return an on-wire setpoint value for this temperature.
    pub(crate) fn as_setpoint_bits(&self) -> u8 {
        if self.is_setpoint_valid() {
            (self.0 - 100) as u8
        } else {
            Self::invalid().as_setpoint_bits()
        }
    }

    /// Construct a temperature from an on-wire sensor value. The on-wire bits are
    /// actually an unsigned 11-bit quantity, so the top 5 bits on the parameter
    /// will be ignored.
    pub(crate) fn from_sensor(bits: u16) -> Result<Self, InvalidTemperature> {
        let bits = bits & 0x07ff;
        if bits <= 2000 {
            Ok(Self(bits as i16 - 500))
        } else {
            Err(InvalidTemperature::FromSensor(bits))
        }
    }

    /// Return an on-wire sensor value for this temperature. The on-wire bits are
    /// actually an unsigned 11-bit quantity, so the return value from this method
    /// may need to be bitwise-combined with other data.
    pub(crate) fn as_sensor_bits(&self) -> u16 {
        let value = self.0 + 500;
        if (0..=2000).contains(&value) {
            value as u16
        } else {
            Self::invalid().as_sensor_bits()
        }
    }

    /// An invalid temperature value. Mostly useful for converting to explicitly
    /// invalid bits:
    /// ```ignore
    /// # use airtouch5::types::Temperature;
    /// let invalid_setpoint = Temperature::invalid().as_setpoint_bits();
    /// let invalid_sensor = Temperature::invalid().as_sensor_bits();
    /// assert_eq!(invalid_setpoint, 0xff);
    /// assert_eq!(invalid_sensor, 0x07ff);
    /// ```
    pub(crate) fn invalid() -> InvalidTemperature {
        InvalidTemperature::_Constructed
    }
}

impl std::fmt::Display for Temperature {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let p = f.precision().unwrap_or(1);
        let mut tmp = format!("{:.p$}", self.0.abs() as f32 / 10.0);
        if f.alternate() {
            tmp.push('\u{2103}');
        }
        f.pad_integral(self.0 >= 0, "", &tmp)
    }
}

impl From<i16> for Temperature {
    fn from(value: i16) -> Self {
        Self::from_deci(value * 10)
    }
}

impl From<f32> for Temperature {
    fn from(value: f32) -> Self {
        Self::from_float(value)
    }
}

// TODO: conversion methods to integer/float values?
// TODO: implement arithmetic operations for temperatures?

/// An invalid temperature
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub(crate) enum InvalidTemperature {
    FromSetpoint(u8),
    FromSensor(u16),
    #[doc(hidden)]
    _Constructed,
}

impl InvalidTemperature {
    /// The on-wire bits to use for an invalid setpoint.
    pub(crate) fn as_setpoint_bits(&self) -> u8 {
        0xff
    }

    /// The on-wire bits to use for an invalid or unavailable sensor reading.
    pub(crate) fn as_sensor_bits(&self) -> u16 {
        0x07ff
    }
}

impl std::fmt::Display for InvalidTemperature {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::FromSetpoint(v) => write!(f, "Invalid setpoint {v:#04x}"),
            Self::FromSensor(v) => write!(f, "Invalid sensor value {v:#06x}"),
            _ => write!(f, "Invalid temperature"),
        }
    }
}
impl std::error::Error for InvalidTemperature {}

#[cfg(test)]
mod tests {
    use super::*;

    use rstest::rstest;

    #[rstest]
    #[case(0x96, 250)] // see exmaple in §4.a.ii.
    #[case(0xA0, 260)] // see example in §4.a.iii.
    #[case(0x78, 220)] // see example in §4.a.iv.
    #[case(0x64, 200)] // see example in §4.a.iv.
    #[case(0x00, 100)]
    #[case(0xFA, 350)]
    fn test_from_setpoint_ok(#[case] bits: u8, #[case] expected: i16) {
        assert_matches!(Temperature::from_setpoint(bits), Ok(t) => {
            assert_eq!(t.0, expected);
        })
    }

    #[test]
    fn test_from_setpoint_invalid() {
        let bits = 0xff;
        assert_matches!(Temperature::from_setpoint(bits), Err(InvalidTemperature::FromSetpoint(v)) => {
            assert_eq!(v, bits);
        });
    }

    #[rstest]
    #[case(0x02E7, 243)] // see exmaple in §4.a.ii.
    #[case(0x02DA, 230)] // see example in §4.a.iv.
    #[case(0x02E4, 240)] // see example in §4.a.iv.
    #[case(0x0000, -500)]
    #[case(0x07D0, 1500)]
    #[case(0x32E9, 245)] // high bits are ignored
    fn test_from_sensor_ok(#[case] bits: u16, #[case] expected: i16) {
        assert_matches!(Temperature::from_sensor(bits), Ok(t) => {
            assert_eq!(t.0, expected);
        })
    }

    #[rstest]
    #[case(0xffff)]
    #[case(0x07ff)]
    #[case(0x07d1)]
    fn test_from_sensor_invalid(#[case] bits: u16) {
        assert_matches!(Temperature::from_sensor(bits), Err(InvalidTemperature::FromSensor(v)) => {
            assert_eq!(v, bits & 0x07ff);
        });
    }

    #[rstest]
    #[case(0)]
    #[case(25)]
    #[case(500)]
    #[case(-1)]
    #[case(-100)]
    fn test_from_degrees_int(#[case] degrees: i16) {
        let t: Temperature = degrees.into();
        assert_eq!(t.0, degrees * 10);
    }

    #[rstest]
    #[case(0.0, 0)]
    #[case(12.34, 123)]
    #[case(12.35, 124)]
    #[case(12.45, 124)]
    #[case(-1.0, -10)]
    fn test_from_degrees_float(#[case] degrees: f32, #[case] expected: i16) {
        let t: Temperature = degrees.into();
        assert_eq!(t.0, expected);
    }
}