airtouch5 0.2.0

A library for communicating with AirTouch 5 air conditioning system control consoles
Documentation
//! AC status message.
//!
//! See ยง4.a.iv.

use std::collections::BTreeMap;

use bitflags::bitflags;

use super::control_status::{
    control_status_message, ControlStatusMessage, ControlStatusMessageSubtype,
};
use crate::types::Temperature;

const SUBTYPE_AC_STATUS: ControlStatusMessageSubtype = 0x23;

macro_rules! ac_status_nibble {
    ($(#[$($attrss:tt)*])*
     $vis:vis enum $name:ident : $ut:ty = $mask:literal {
        $($val:ident = $bits:literal,)*
     } ) => {
        $(#[$($attrss)*])*
        #[derive(Clone, Copy, Debug, PartialEq)]
        $vis enum $name {
            $($val = $bits,)*
        }
        impl TryFrom<$ut> for $name {
            type Error = MessageError;
            fn try_from(value: $ut) -> Result<Self, Self::Error> {
                let value = value & $mask;
                match value {
                    $(x if x == Self::$val as $ut => Ok(Self::$val),)*
                    _ => Err(MessageError::InvalidData),
                }
            }
        }
        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                core::fmt::Debug::fmt(self, f)
            }
        }
    };
}
#[cfg(feature = "control")] // also used by the control messages
pub(super) use ac_status_nibble;

ac_status_nibble!(
    /// Current power status for an AC unit.
    pub enum AcPower: u8 = 0xf0 {
        Off      = 0b0000_0000,
        On       = 0b0001_0000,
        AwayOff  = 0b0010_0000,
        AwayOn   = 0b0011_0000,
        Sleep    = 0b0101_0000,
    }
);

ac_status_nibble!(
    /// The mode the AC is currently operating in.
    #[rustfmt::skip]
    pub enum AcMode: u8 = 0xf0 {
        Auto     = 0b0000_0000,
        Heat     = 0b0001_0000,
        Dry      = 0b0010_0000,
        Fan      = 0b0011_0000,
        Cool     = 0b0100_0000,
        AutoHeat = 0b1000_0000,
        AutoCool = 0b1001_0000,
    }
);

ac_status_nibble!(
    /// Current fan speed setting.
    #[rustfmt::skip]
    pub enum FanSpeed: u8 = 0x07 {
        Auto     = 0b0000,
        Quiet    = 0b0001,
        Low      = 0b0010,
        Medium   = 0b0011,
        High     = 0b0100,
        Powerful = 0b0101,
        Turbo    = 0b0111,
        // IntelligentAuto (0b1000) is a modifier to these speeds
    }
);

bitflags! {
    /// Status flags for the AC unit.
    #[derive(Clone, Copy, Debug, PartialEq)]
    #[rustfmt::skip]
    pub struct AcFlags: u8 {
        /// Timer has been set.
        const Timer            = 1 << 0;
        /// A zone is currently being used as spill for excess airflow.
        const Spill            = 1 << 1;
        /// Bypass is currently being used for excess airflow.
        const ByPass           = 1 << 2;
        /// Turbo is currenly active.
        const Turbo            = 1 << 3;
    }
}

/// Current status of a single AC unit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AcStatus {
    /// Current power status, or `None` if not available.
    pub power: Option<AcPower>,

    /// Current operating mode, or `None` if not available.
    pub mode: Option<AcMode>,

    /// Current fan speed, or `None` if not available.
    ///
    /// The second field is `true` if the IntelligentAuto mode is active.
    pub fan_speed: Option<(FanSpeed, bool)>,

    /// Target temperature, or `None` if not available.
    pub setpoint: Option<Temperature>,

    /// Current temperature, or `None` if not available.
    pub temperature: Option<Temperature>,

    /// Current status flags for the AC unit.
    pub flags: AcFlags,

    /// `None`, or a non-zero error code from the AC unit.
    pub error: Option<u16>,

    /// The protocol spec says these bytes may or may not be present, depending on
    /// the console version, and if they are present they are not used. However,
    /// the example does have non-zero bytes for these, so retain them if they are
    /// present.
    pub unused: Option<u16>,
}

impl std::fmt::Display for AcStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        use std::rc::Rc;
        let na: Rc<String> = Rc::new("n/a".to_string());
        write!(
            f,
            "{} {} {}",
            self.power.map_or(na.clone(), |x| Rc::new(format!("{}", x))),
            self.mode.map_or(na.clone(), |x| Rc::new(format!("{}", x))),
            self.fan_speed.map_or(na.clone(), |x| Rc::new(format!(
                "{}{}",
                x.0,
                if x.1 { "(IntelligentAuto)" } else { "" }
            ))),
        )?;
        if let Some(t) = self.temperature {
            write!(f, " {:#}", t)?;
        }
        if let Some(t) = self.setpoint {
            write!(f, " -> {:#}", t)?;
        }
        if !self.flags.is_empty() {
            write!(f, " ")?;
            bitflags::parser::to_writer_strict(&self.flags, f)?;
        }
        Ok(())
    }
}

control_status_message!(
    SUBTYPE_AC_STATUS,
    pub struct AcStatusMessage {
        pub acs: BTreeMap<u8, AcStatus>,
    },
    {
        // No normal data
        fn impl_frame_normal_len(&self) -> usize {
            0
        }
        fn impl_frame_normal_data<W: std::io::Write>(
            &self,
            _dst: &mut W,
        ) -> Result<(), MessageError> {
            Ok(())
        }

        // Always 10 bytes repeat data, unless this is an empty request.
        fn impl_frame_repeat_len(&self) -> usize {
            if self.is_request && self.acs.is_empty() {
                0
            } else {
                10
            }
        }
        fn impl_frame_repeat_count(&self) -> u16 {
            self.acs.len().try_into().unwrap_or(u16::MAX)
        }
        fn impl_frame_repeat_data<W: std::io::Write>(
            &self,
            index: u16,
            dst: &mut W,
        ) -> Result<(), MessageError> {
            let (ac_idx, ac) = self.acs.iter().nth(index as usize).unwrap();
            dst.write_all(
                &(match ac.power {
                    Some(p) => p as u8,
                    None => 0xf0,
                } | (ac_idx & 0x0f))
                    .to_be_bytes(),
            )?;
            dst.write_all(
                &(match ac.mode {
                    Some(m) => m as u8,
                    None => 0xf0,
                } | match ac.fan_speed {
                    Some((s, false)) => s as u8,
                    Some((s, true)) => s as u8 | 0b1000,
                    None => 0x0f,
                })
                .to_be_bytes(),
            )?;
            dst.write_all(
                &(match ac.setpoint {
                    Some(t) => t.as_setpoint_bits(),
                    None => 0xff,
                })
                .to_be_bytes(),
            )?;
            dst.write_all(&ac.flags.bits().to_be_bytes())?;
            dst.write_all(
                &(match ac.temperature {
                    Some(t) => t.as_sensor_bits(),
                    None => 0x03ff,
                })
                .to_be_bytes(),
            )?;
            dst.write_all(&ac.error.unwrap_or_default().to_be_bytes())?;
            dst.write_all(&ac.unused.unwrap_or_default().to_be_bytes())?;
            Ok(())
        }

        fn from_frame_data(
            message_id: u8,
            is_request: bool,
            _normal_data: Vec<u8>,
            repeat_data: Vec<Vec<u8>>,
        ) -> Result<Self, MessageError> {
            let mut acs = BTreeMap::new();
            for data in repeat_data {
                if data.len() < 8 {
                    return Err(MessageError::InvalidData);
                }
                let ac_idx = data[0] & 0x0f;
                let power = AcPower::try_from(data[0]).ok();
                let mode = AcMode::try_from(data[1]).ok();
                let fan_speed = FanSpeed::try_from(data[1])
                    .ok()
                    .map(|s| (s, data[1] & 0b1000 != 0));
                let setpoint = Temperature::from_setpoint(data[2]).ok();
                let flags = AcFlags::from_bits_retain(data[3]);
                let temperature =
                    Temperature::from_sensor(u16::from_be_bytes(data[4..6].try_into().unwrap()))
                        .ok();
                let error = match u16::from_be_bytes(data[6..8].try_into().unwrap()) {
                    0 => None,
                    e => Some(e),
                };
                let unused = if data.len() >= 10 {
                    Some(u16::from_be_bytes(data[8..10].try_into().unwrap()))
                } else {
                    None
                };
                let ac = AcStatus {
                    power,
                    mode,
                    fan_speed,
                    setpoint,
                    flags,
                    temperature,
                    error,
                    unused,
                };
                acs.insert(ac_idx, ac);
            }

            Ok(Self {
                message_id,
                is_request,
                acs,
            })
        }
    }
);

impl AcStatusMessage {
    pub fn request() -> Self {
        Self {
            message_id: super::next_msg_id(),
            is_request: true,
            acs: BTreeMap::new(),
        }
    }

    pub fn new<K: Into<u8>, V: Into<AcStatus>, T: IntoIterator<Item = (K, V)>>(acs: T) -> Self {
        Self::with_message_id(super::next_msg_id(), acs)
    }

    pub fn with_message_id<K: Into<u8>, V: Into<AcStatus>, T: IntoIterator<Item = (K, V)>>(
        message_id: u8,
        acs: T,
    ) -> Self {
        Self {
            message_id,
            is_request: false,
            acs: acs.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

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

    use super::super::control_status::MSG_HEADER_SIZE;
    use crate::conn::tests::data::*;

    pub(crate) static ACS: std::sync::LazyLock<[(u8, AcStatus); 2]> =
        std::sync::LazyLock::new(|| {
            [
                (
                    0,
                    AcStatus {
                        power: Some(AcPower::On),
                        mode: Some(AcMode::Heat),
                        fan_speed: Some((FanSpeed::Low, false)),
                        setpoint: Some(22.into()),
                        flags: AcFlags::empty(),
                        temperature: Some(23.into()),
                        error: None,
                        unused: Some(0x8000),
                    },
                ),
                (
                    1,
                    AcStatus {
                        power: Some(AcPower::Off),
                        mode: Some(AcMode::Cool),
                        fan_speed: Some((FanSpeed::Low, false)),
                        setpoint: Some(20.into()),
                        flags: AcFlags::empty(),
                        temperature: Some(24.into()),
                        error: None,
                        unused: Some(0x8000),
                    },
                ),
            ]
        });

    #[test]
    fn test_ac_status_request() {
        let orig = AcStatusMessage::request();
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data.len(), MSG_HEADER_SIZE);
        let req: AcStatusMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[test]
    fn test_zone_status_response() {
        let orig = AcStatusMessage::with_message_id(17, *ACS);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.msg_id, 17);
        assert_eq!(frame.data.len(), MSG_HEADER_SIZE + ACS.len() * 10);
        let resp: AcStatusMessage = frame.try_into().expect("from frame failed");
        assert_eq!(resp, orig);
    }

    #[test]
    fn test_ac_status_request_from_data() {
        let req: AcStatusMessage = frame(MSG_REQ_STATUS_ACS)
            .try_into()
            .expect("from frame failed");
        assert!(req.is_request);
        assert_eq!(req.acs.len(), 0);
        let f: Frame = req.try_into().expect("into frame failed");
        assert_eq!(f, frame(MSG_REQ_STATUS_ACS));
    }

    #[test]
    fn test_ac_status_response_from_data() {
        let resp: AcStatusMessage = frame(MSG_RESP_STATUS_ACS)
            .try_into()
            .expect("from frame failed");
        assert!(!resp.is_request);
        assert_eq!(resp.acs.len(), 2);
        let f: Frame = resp.try_into().expect("into frame failed");
        assert_eq!(f, frame(MSG_RESP_STATUS_ACS));
    }
}