airtouch5 0.2.0

A library for communicating with AirTouch 5 air conditioning system control consoles
Documentation
//! Zone control message.
//!
//! See §4.a.i.

use std::collections::BTreeMap;

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

const SUBTYPE_ZONE_CONTROL: ControlStatusMessageSubtype = 0x20;

ac_status_nibble!(
    /// Requested power setting.
    #[allow(clippy::unusual_byte_groupings)]
    pub enum ZonePower: u8 = 0x07 {
        Toggle         = 0b00000_001,
        Off            = 0b00000_010,
        On             = 0b00000_011,
        Turbo          = 0b00000_101,
    }
);

ac_status_nibble!(
    /// Requested zone control mode setting.
    #[allow(clippy::unusual_byte_groupings)]
    pub enum ZoneControlType: u8 = 0x18 {
        Toggle         = 0b000_01_000,
        Airflow        = 0b000_10_000,
        Temperature    = 0b000_11_000,
    }
);

ac_status_nibble!(
    /// Requested zone value operation.
    enum ZoneControlValueBits: u8 = 0xe0 {
        Decrement      = 0b010_00000,
        Increment      = 0b011_00000,
        Airflow        = 0b100_00000,
        Temperature    = 0b101_00000,
    }
);

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ZoneControlValue {
    /// Decrease setpoint by 1℃, or airflow by 5%
    Decrement,
    /// Increase setpoint by 1℃, or airflow by 5%
    Increment,
    /// Set to the given airflow percentage
    Airflow(u8),
    /// Set to the given temperature setpoint
    Temperature(Temperature),
}

impl ZoneControlValue {
    fn as_bits(&self) -> Result<(ZoneControlValueBits, u8), MessageError> {
        Ok(match self {
            Self::Decrement => (ZoneControlValueBits::Decrement, 0xff),
            Self::Increment => (ZoneControlValueBits::Increment, 0xff),
            Self::Airflow(pct) if *pct <= 100 => (ZoneControlValueBits::Airflow, *pct),
            Self::Temperature(t) if t.is_setpoint_valid() => {
                (ZoneControlValueBits::Temperature, t.as_setpoint_bits())
            }
            _ => return Err(MessageError::InvalidData),
        })
    }
}

impl std::ops::BitOr<u8> for ZoneControlValue {
    type Output = Result<u16, MessageError>;

    fn bitor(self, rhs: u8) -> Self::Output {
        let (a, b) = self.as_bits()?;
        Ok((a as u16 | rhs as u16) << 8 | b as u16)
    }
}
impl std::ops::BitOr<ZoneControlValue> for u8 {
    type Output = Result<u16, MessageError>;

    fn bitor(self, rhs: ZoneControlValue) -> Self::Output {
        rhs | self
    }
}

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

    /// Requested zone control type, or `None` to keep current setting.
    /// Must be `None` if the zone does not have a sensor.
    pub control: Option<ZoneControlType>,

    /// Requested zone control value operation, or `None` to keep current setting.
    pub value: Option<ZoneControlValue>,
}

control_status_message!(
    SUBTYPE_ZONE_CONTROL,
    pub struct ZoneControlMessage {
        zones: BTreeMap<u8, ZoneControl>,
    },
    {
        // 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.zones.is_empty() {
                0
            } else {
                4
            }
        }
        fn impl_frame_repeat_count(&self) -> u16 {
            self.zones.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 (zone_idx, zone) = self.zones.iter().nth(index as usize).unwrap();
            dst.write_all(&(zone_idx & 0x3f).to_be_bytes())?;
            let a = match zone.power {
                Some(p) => p as u8,
                None => 0x00,
            } | match zone.control {
                Some(c) => c as u8,
                None => 0x00,
            };
            dst.write_all(
                &(match zone.value {
                    Some(v) => (a | v)?,
                    None => (a as u16) << 8 | 0x00ff,
                })
                .to_be_bytes(),
            )?;
            dst.write_all(&0u8.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 zones = BTreeMap::new();
            for data in repeat_data {
                if data.len() < 4 {
                    return Err(MessageError::InvalidData);
                }
                let zone_idx = data[0] & 0x3f;
                let power = ZonePower::try_from(data[1] & 0x07).ok();
                let control = ZoneControlType::try_from(data[1] & 0x18).ok();
                let value = match ZoneControlValueBits::try_from(data[1] & 0xe0) {
                    Ok(ZoneControlValueBits::Decrement) => Some(ZoneControlValue::Decrement),
                    Ok(ZoneControlValueBits::Increment) => Some(ZoneControlValue::Increment),
                    Ok(ZoneControlValueBits::Airflow) if data[2] <= 100 => {
                        Some(ZoneControlValue::Airflow(data[2]))
                    }
                    Ok(ZoneControlValueBits::Temperature) => {
                        match Temperature::from_setpoint(data[2]) {
                            Ok(t) => Some(ZoneControlValue::Temperature(t)),
                            _ => None,
                        }
                    }
                    _ => None,
                };
                let zone = ZoneControl {
                    power,
                    control,
                    value,
                };
                zones.insert(zone_idx, zone);
            }
            Ok(Self {
                message_id,
                is_request,
                zones,
            })
        }
    }
);

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

    pub fn with_message_id<K: Into<u8>, V: Into<ZoneControl>, T: IntoIterator<Item = (K, V)>>(
        message_id: u8,
        zones: T,
    ) -> Self {
        Self {
            message_id,
            is_request: true,
            zones: zones
                .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 zone. Taken from §4.a.i.
        #[rustfmt::skip]
        pub(crate) const MSG_REQ_ZONE_OFF: &[u8] = &[
            0x55, 0x55, 0x55, 0xAA,                             // message header
            0x80, 0xB0, 0x0F, 0xC0, 0x00, 0x0C,                 // address, msg_id, type, length
            0x20, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01,     // subtype, norm_len, rpt_{cnt,len}
            0x01, 0x02, 0xFF, 0x00,                             // AC 1 control
            0xF0, 0xA1,                                         // CRC
        ];
    }

    #[rstest]
    #[case(ZonePower::On)]
    #[case(ZonePower::Off)]
    #[case(ZonePower::Turbo)]
    #[case(ZonePower::Toggle)]
    fn test_zone_power(#[case] power: ZonePower) {
        let zone_idx = 5u8;
        let orig = ZoneControlMessage::new([(
            zone_idx,
            ZoneControl {
                power: Some(power),
                control: None,
                value: None,
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], zone_idx);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 1], power as u8);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 2], 0xff);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], 0x00);
        let req: ZoneControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(ZoneControlType::Airflow)]
    #[case(ZoneControlType::Temperature)]
    #[case(ZoneControlType::Toggle)]
    fn test_zone_control(#[case] control: ZoneControlType) {
        let zone_idx = 5u8;
        let orig = ZoneControlMessage::new([(
            zone_idx,
            ZoneControl {
                power: None,
                control: Some(control),
                value: None,
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], zone_idx);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 1], control as u8);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 2], 0xff);
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], 0x00);
        let req: ZoneControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(ZoneControlValue::Increment)]
    #[case(ZoneControlValue::Decrement)]
    #[case(ZoneControlValue::Airflow(0))]
    #[case(ZoneControlValue::Airflow(50))]
    #[case(ZoneControlValue::Airflow(100))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(100)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(180)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(235)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(350)))]
    fn test_zone_value(#[case] value: ZoneControlValue) {
        let zone_idx = 5u8;
        let orig = ZoneControlMessage::new([(
            zone_idx,
            ZoneControl {
                power: None,
                control: None,
                value: Some(value),
            },
        )]);
        let frame = orig.clone().into_frame().expect("into frame failed");
        assert_eq!(frame.data[MSG_HEADER_SIZE], zone_idx);
        assert_eq!(
            frame.data[MSG_HEADER_SIZE + 1],
            match value {
                ZoneControlValue::Decrement => ZoneControlValueBits::Decrement,
                ZoneControlValue::Increment => ZoneControlValueBits::Increment,
                ZoneControlValue::Airflow(_) => ZoneControlValueBits::Airflow,
                ZoneControlValue::Temperature(_) => ZoneControlValueBits::Temperature,
            } as u8
        );
        assert_eq!(
            frame.data[MSG_HEADER_SIZE + 2],
            match value {
                ZoneControlValue::Airflow(pct) => pct,
                ZoneControlValue::Temperature(t) => t.as_setpoint_bits(),
                _ => 0xff,
            }
        );
        assert_eq!(frame.data[MSG_HEADER_SIZE + 3], 0x00);
        let req: ZoneControlMessage = frame.try_into().expect("from frame failed");
        assert_eq!(req, orig);
    }

    #[rstest]
    #[case(ZoneControlValue::Airflow(101))]
    #[case(ZoneControlValue::Airflow(255))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(0)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(99)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(351)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(i16::MAX)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(i16::MIN)))]
    #[case(ZoneControlValue::Temperature(Temperature::from_deci(-1)))]
    fn test_zone_value_invalid(#[case] value: ZoneControlValue) {
        let zone_idx = 5u8;
        let orig = ZoneControlMessage::new([(
            zone_idx,
            ZoneControl {
                power: None,
                control: None,
                value: Some(value),
            },
        )]);
        assert_matches!(orig.clone().into_frame(), Err(MessageError::InvalidData));
    }

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

    #[test]
    fn test_zone_contol_from_data_off() {
        let req: ZoneControlMessage = frame(data::MSG_REQ_ZONE_OFF)
            .try_into()
            .expect("from frame failed");
        assert!(req.is_request);
        assert_eq!(req.zones.len(), 1);
        let zone = &req.zones[&1];
        assert_eq!(zone.power, Some(ZonePower::Off));
        assert_eq!(zone.control, None);
        assert_eq!(zone.value, None);
        let f: Frame = req.try_into().expect("into frame failed");
        assert_eq!(f, frame(data::MSG_REQ_ZONE_OFF));
    }
}