airtouch5 0.2.0

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

use std::collections::BTreeMap;

use bitflags::bitflags;

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

const SUBTYPE_ZONE_STATUS: ControlStatusMessageSubtype = 0x21;

/// Current power status for a zone.
#[derive(Clone, Copy, Debug, PartialEq)]
#[rustfmt::skip]
pub enum ZonePower {
    Off   = 0b00_000000,
    On    = 0b01_000000,
    Turbo = 0b11_000000,
}

impl TryFrom<u8> for ZonePower {
    type Error = MessageError;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        let value = value & 0xc0;
        let (off, on, turbo) = (Self::Off as u8, Self::On as u8, Self::Turbo as u8);
        match value {
            x if x == off => Ok(Self::Off),
            x if x == on => Ok(Self::On),
            x if x == turbo => Ok(Self::Turbo),
            _ => Err(MessageError::InvalidData),
        }
    }
}

impl std::fmt::Display for ZonePower {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.pad(&format!("{:?}", self))
    }
}

/// Current control setting for a zone.
///
/// A zone may be set to a specific amount of airflow, as a percentage of the
/// possible airflow, or to a target setpoint temperature. In the latter case,
/// the actual airflow percentage in effect is also reported.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ZoneControl {
    /// Control by airflow only, in percent open.
    Airflow(u8),
    /// Control by temperature, in degrees Celsius.
    Temperature(u8, Temperature),
}

/// The current temperature reading for a zone, if available.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ZoneSensorReading {
    /// The zone has no sensor.
    NoSensor,
    /// The zone has a sensor but no reading is available.
    NotAvailable,
    /// The temperature reading.
    Temperature(Temperature),
}

bitflags! {
    /// Status flags for the zone.
    #[derive(Clone, Copy, Debug, PartialEq)]
    #[rustfmt::skip]
    pub struct ZoneFlags: u8 {
        /// Low battery warning for this zone's sensor.
        const LowBattery       = 1 << 0;
        /// This zone is currently being used as spill for excess airflow.
        const Spill            = 1 << 1;
    }
}

/// Current status of a single zone.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ZoneStatus {
    /// Current power status of the zone.
    pub power: ZonePower,

    /// Current control setting for the zone.
    pub control: ZoneControl,

    /// Current sensor reading for the zone.
    pub sensor_reading: ZoneSensorReading,

    /// Current status flags for the zone.
    pub flags: ZoneFlags,
}

impl std::fmt::Display for ZoneStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{:>3} {:3}%",
            self.power,
            match self.control {
                ZoneControl::Airflow(pct) => pct,
                ZoneControl::Temperature(pct, _) => pct,
            }
        )?;
        match self.sensor_reading {
            ZoneSensorReading::Temperature(t) => write!(f, " {:#}", t)?,
            ZoneSensorReading::NotAvailable => write!(f, " n/a")?,
            _ => {}
        }
        if let ZoneControl::Temperature(_, t) = self.control {
            write!(f, " -> {:#}", t)?;
        }
        if !self.flags.is_empty() {
            write!(f, " ")?;
            bitflags::parser::to_writer(&self.flags, f)?;
        }
        Ok(())
    }
}

control_status_message!(
    SUBTYPE_ZONE_STATUS,
    pub struct ZoneStatusMessage {
        pub zones: BTreeMap<u8, ZoneStatus>,
    },
    {
        // 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 8 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 {
                8
            }
        }
        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.power as u8 | (zone_idx & 0x3f)).to_be_bytes())?;
            match zone.control {
                ZoneControl::Airflow(pct) => {
                    dst.write_all(&(pct & 0x7f).to_be_bytes())?;
                    dst.write_all(&Temperature::invalid().as_setpoint_bits().to_be_bytes())?;
                }
                ZoneControl::Temperature(pct, temp) => {
                    dst.write_all(&(0x80 | (pct & 0x7f)).to_be_bytes())?;
                    dst.write_all(&temp.as_setpoint_bits().to_be_bytes())?;
                }
            };
            match zone.sensor_reading {
                ZoneSensorReading::NoSensor => {
                    dst.write_all(&[0x00])?;
                    dst.write_all(&Temperature::invalid().as_sensor_bits().to_be_bytes())?;
                }
                ZoneSensorReading::NotAvailable => {
                    dst.write_all(&[0x80])?;
                    dst.write_all(&Temperature::invalid().as_sensor_bits().to_be_bytes())?;
                }
                ZoneSensorReading::Temperature(temp) => {
                    dst.write_all(&[0x80])?;
                    dst.write_all(&temp.as_sensor_bits().to_be_bytes())?;
                }
            }
            dst.write_all(&zone.flags.bits().to_be_bytes())?;
            dst.write_all(&[0x00])?;
            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() < 7 {
                    return Err(MessageError::InvalidData);
                }
                let zone_idx = data[0] & 0x3f;
                let power = ZonePower::try_from(data[0])?;
                let control = if data[1] & 0x80 != 0 {
                    ZoneControl::Temperature(
                        data[1] & 0x7f,
                        Temperature::from_setpoint(data[2])
                            .map_err(|_| MessageError::InvalidData)?,
                    )
                } else {
                    if data[2] != Temperature::invalid().as_setpoint_bits() {
                        log::warn!("Got setpoint when in Airflow control: {:#04x}", data[2]);
                    }
                    ZoneControl::Airflow(data[1] & 0x7f)
                };
                let sensor_reading = if data[3] & 0x80 == 0 {
                    ZoneSensorReading::NoSensor
                } else {
                    Temperature::from_sensor(u16::from_be_bytes(data[4..6].try_into().unwrap()))
                        .map(ZoneSensorReading::Temperature)
                        .unwrap_or(ZoneSensorReading::NotAvailable)
                };
                let flags = ZoneFlags::from_bits_retain(data[6]);
                let zone = ZoneStatus {
                    power,
                    control,
                    sensor_reading,
                    flags,
                };
                zones.insert(zone_idx, zone);
            }

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

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

    pub fn new<K: Into<u8>, V: Into<ZoneStatus>, 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<ZoneStatus>, T: IntoIterator<Item = (K, V)>>(
        message_id: u8,
        zones: T,
    ) -> Self {
        Self {
            message_id,
            is_request: false,
            zones: zones
                .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 ZONES: std::sync::LazyLock<[(u8, ZoneStatus); 2]> =
        std::sync::LazyLock::new(|| {
            [
                (
                    0,
                    ZoneStatus {
                        power: ZonePower::On,
                        control: ZoneControl::Temperature(0, 25.into()),
                        sensor_reading: ZoneSensorReading::Temperature(24.3.into()),
                        flags: ZoneFlags::empty(),
                    },
                ),
                (
                    1,
                    ZoneStatus {
                        power: ZonePower::Off,
                        control: ZoneControl::Airflow(100),
                        sensor_reading: ZoneSensorReading::NoSensor,
                        flags: ZoneFlags::empty(),
                    },
                ),
            ]
        });

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

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

    #[test]
    fn test_zone_status_request_from_data() {
        let req: ZoneStatusMessage = frame(MSG_REQ_STATUS_ZONES)
            .try_into()
            .expect("from frame failed");
        assert!(req.is_request);
        assert_eq!(req.zones.len(), 0);
        let f: Frame = req.try_into().expect("into frame failed");
        assert_eq!(f, frame(MSG_REQ_STATUS_ZONES));
    }

    #[test]
    fn test_zone_status_response_from_data() {
        let resp: ZoneStatusMessage = frame(MSG_RESP_STATUS_ZONES)
            .try_into()
            .expect("from frame failed");
        assert!(!resp.is_request);
        assert_eq!(resp.zones.len(), 2);
        let f: Frame = resp.try_into().expect("into frame failed");
        assert_eq!(f, frame(MSG_RESP_STATUS_ZONES));
    }
}