airtouch5 0.2.0

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

use std::collections::BTreeMap;

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

const SUBTYPE_AC_CONTROL: ControlStatusMessageSubtype = 0x22;

ac_status_nibble!(
    /// Requested power setting.
    pub enum AcPower: u8 = 0xf0 {
        Toggle   = 0b0001_0000,
        Off      = 0b0010_0000,
        On       = 0b0011_0000,
        Away     = 0b0100_0000,
        Sleep    = 0b0101_0000,
    }
);

ac_status_nibble!(
    /// Requested operating mode setting.
    pub enum AcMode: u8 = 0xf0 {
        Auto     = 0b0000_0000,
        Heat     = 0b0001_0000,
        Dry      = 0b0010_0000,
        Fan      = 0b0011_0000,
        Cool     = 0b0100_0000,
    }
);

ac_status_nibble!(
    /// Requested fan speed setting.
    pub enum FanSpeed: u8 = 0x0f {
        Auto     = 0b0000,
        Quiet    = 0b0001,
        Low      = 0b0010,
        Medium   = 0b0011,
        High     = 0b0100,
        Powerful = 0b0101,
        Turbo    = 0b0111,
        IntelligentAuto = 0b1000,
    }
);

/// Control a single AC unit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AcControl {
    /// Requested power setting, or `None` to keep current setting.
    pub power: Option<AcPower>,

    /// Requested operating mode, or `None` to keep current setting.
    pub mode: Option<AcMode>,

    /// Requested fan speed, or `None` to keep current setting.
    pub fan_speed: Option<FanSpeed>,

    /// Requested target temperature, or `None` to keep current setting.
    pub setpoint: Option<Temperature>,
}

control_status_message!(
    SUBTYPE_AC_CONTROL,
    pub struct AcControlMessage {
        acs: BTreeMap<u8, AcControl>,
    },
    {
        // 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 4 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 {
                4
            }
        }
        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 => 0x00,
                } | (ac_idx & 0x0f))
                    .to_be_bytes(),
            )?;
            dst.write_all(
                &(match ac.mode {
                    Some(m) => m as u8,
                    None => 0xf0,
                } | match ac.fan_speed {
                    Some(f) => f as u8,
                    None => 0x0f,
                })
                .to_be_bytes(),
            )?;
            dst.write_all(
                &(match ac.setpoint {
                    Some(s) if s.is_setpoint_valid() => 0x4000 | s.as_setpoint_bits() as u16,
                    Some(_) => return Err(MessageError::InvalidData),
                    None => Temperature::invalid().as_setpoint_bits() as u16, // | 0x0000
                })
                .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() < 4 {
                    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();
                let setpoint = match data[2] {
                    0x40 => Temperature::from_setpoint(data[3]).ok(),
                    _ => None,
                };
                let ac = AcControl {
                    power,
                    mode,
                    fan_speed,
                    setpoint,
                };
                acs.insert(ac_idx, ac);
            }

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

impl AcControlMessage {
    pub fn new<K: Into<u8>, V: Into<AcControl>, 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<AcControl>, T: IntoIterator<Item = (K, V)>>(
        message_id: u8,
        acs: T,
    ) -> Self {
        Self {
            message_id,
            is_request: true,
            acs: acs.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

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

    use rstest::rstest;

    use crate::conn::tests::data::*;
    mod data {
        /// Turn off the second AC. Taken from ยง4.a.iii.
        #[rustfmt::skip]
        pub(crate) const MSG_REQ_AC_OFF: &[u8] = &[
            0x55, 0x55, 0x55, 0xAA,                             // message header
            0x80, 0xb0, 0x01, 0xC0, 0x00, 0x0C,                 // address, msg_id, type, length
            0x22, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01,     // subtype, norm_len, rpt_{cnt,len}
            0x21, 0xFF, 0x00, 0xFF,                             // AC 1 control
            0xD3, 0x47,                                         // CRC
        ];

        /// Set the first AC to cool mode and second AC 26 degree. Taken from ยง4.a.iii.
        #[rustfmt::skip]
        pub(crate) const MSG_REQ_AC_ON: &[u8] = &[
            0x55, 0x55, 0x55, 0xAA,                             // message header
            0x80, 0xb0, 0x01, 0xC0, 0x00, 0x10,                 // address, msg_id, type, length
            0x22, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x02,     // subtype, norm_len, rpt_{cnt,len}
            0x00, 0x4F, 0x00, 0xFF,                             // AC 0 control
            0x01, 0xFF, 0x40, 0xA0,                             // AC 1 control
            0x10, 0x4B,                                         // CRC
        ];
    }

    #[test]
    fn test_empty_ac_control() {
        let orig = AcControlMessage::new(BTreeMap::<u8, AcControl>::new());
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data.len(), MSG_HEADER_SIZE);
        let req: AcControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(AcPower::On)]
    #[case(AcPower::Off)]
    #[case(AcPower::Toggle)]
    #[case(AcPower::Sleep)]
    #[case(AcPower::Away)]
    fn test_ac_power(#[case] power: AcPower) {
        let ac_idx = 5u8;
        let orig = AcControlMessage::new([(
            ac_idx,
            AcControl {
                power: Some(power),
                mode: None,
                fan_speed: None,
                setpoint: None,
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], power as u8 | ac_idx);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 1], 0xff);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 2], 0x00);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], 0xff);
        let req: AcControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(AcMode::Cool)]
    #[case(AcMode::Heat)]
    #[case(AcMode::Fan)]
    #[case(AcMode::Dry)]
    #[case(AcMode::Auto)]
    fn test_ac_mode(#[case] mode: AcMode) {
        let ac_idx = 5u8;
        let orig = AcControlMessage::new([(
            ac_idx,
            AcControl {
                power: None,
                mode: Some(mode),
                fan_speed: None,
                setpoint: None,
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], ac_idx);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 1], mode as u8 | 0x0f);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 2], 0x00);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], 0xff);
        let req: AcControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(FanSpeed::Low)]
    #[case(FanSpeed::Medium)]
    #[case(FanSpeed::High)]
    #[case(FanSpeed::Powerful)]
    #[case(FanSpeed::Quiet)]
    #[case(FanSpeed::Turbo)]
    #[case(FanSpeed::Auto)]
    #[case(FanSpeed::IntelligentAuto)]
    fn test_ac_fan_speed(#[case] speed: FanSpeed) {
        let ac_idx = 5u8;
        let orig = AcControlMessage::new([(
            ac_idx,
            AcControl {
                power: None,
                mode: None,
                fan_speed: Some(speed),
                setpoint: None,
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], ac_idx);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 1], speed as u8 | 0xf0);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 2], 0x00);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], 0xff);
        let req: AcControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(Temperature::from_deci(180))]
    #[case(Temperature::from_deci(195))]
    #[case(Temperature::from_deci(257))]
    fn test_ac_setpoint(#[case] setpoint: Temperature) {
        let ac_idx = 5u8;
        let orig = AcControlMessage::new([(
            ac_idx,
            AcControl {
                power: None,
                mode: None,
                fan_speed: None,
                setpoint: Some(setpoint),
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], ac_idx);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 1], 0xff);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 2], 0x40);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], setpoint.as_setpoint_bits());
        let req: AcControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(Temperature::from_deci(0))]
    #[case(Temperature::from_deci(99))]
    #[case(Temperature::from_deci(351))]
    #[case(Temperature::from_deci(i16::MAX))]
    #[case(Temperature::from_deci(i16::MIN))]
    #[case(Temperature::from_deci(-1))]
    fn test_ac_setpoint_invalid(#[case] setpoint: Temperature) {
        let ac_idx = 5u8;
        let orig = AcControlMessage::new([(
            ac_idx,
            AcControl {
                power: None,
                mode: None,
                fan_speed: None,
                setpoint: Some(setpoint),
            },
        )]);
        assert_matches!(orig.clone().into_frame(), Err(MessageError::InvalidData));
    }

    #[test]
    fn test_ac_contol_from_data_off() {
        let req: AcControlMessage = frame(data::MSG_REQ_AC_OFF)
            .try_into()
            .expect("from frame failed");
        assert!(req.is_request);
        assert_eq!(req.acs.len(), 1);
        let ac = &req.acs[&1];
        assert_eq!(ac.power, Some(AcPower::Off));
        assert_eq!(ac.mode, None);
        assert_eq!(ac.fan_speed, None);
        assert_eq!(ac.setpoint, None);
        let f: Frame = req.try_into().expect("into frame failed");
        assert_eq!(f, frame(data::MSG_REQ_AC_OFF));
    }

    #[test]
    fn test_ac_contol_from_data_on() {
        let req: AcControlMessage = frame(data::MSG_REQ_AC_ON)
            .try_into()
            .expect("from frame failed");
        assert!(req.is_request);
        assert_eq!(req.acs.len(), 2);
        let ac = &req.acs[&0];
        assert_eq!(ac.power, None);
        assert_eq!(ac.mode, Some(AcMode::Cool));
        assert_eq!(ac.fan_speed, None);
        assert_eq!(ac.setpoint, None);
        let ac = &req.acs[&1];
        assert_eq!(ac.power, None);
        assert_eq!(ac.mode, None);
        assert_eq!(ac.fan_speed, None);
        assert_eq!(ac.setpoint, Some(Temperature::from_deci(260)));
        let f: Frame = req.try_into().expect("into frame failed");
        assert_eq!(f, frame(data::MSG_REQ_AC_ON));
    }
}