Skip to content

Commit

Permalink
Add HVAC/Air Conditioner support
Browse files Browse the repository at this point in the history
This commits add a new type of supported devices, which is air
conditioners. I was testing this only on my specific device but I think
that the code should also work with other compatible air conditioners.

The device was controlled using `AC Freedom` app:
https://play.google.com/store/apps/details?id=com.broadlink.acfreedom

I was searching for opensource projects for controlling the device but
it is not as popular as the IR blasters or other components, so finally
I put the pieces together mainly from two projects:

https://github.com/liaan/broadlink_ac_mqtt
and this not-yet-merged PR:
mjg59/python-broadlink#520

so the credits goes to:
@liaan and @mjg59
  • Loading branch information
manio committed Sep 6, 2023
1 parent b491881 commit 5a92877
Show file tree
Hide file tree
Showing 2 changed files with 364 additions and 0 deletions.
112 changes: 112 additions & 0 deletions src/hvac.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use std::net::Ipv4Addr;

use packed_struct::PackedStructSlice;
use phf::phf_map;

use crate::{
constants,
network::{
util::reverse_mac, AirCondInfo, AirCondState, DiscoveryResponse, HvacDataCommand,
HvacDataMessage,
},
Device, DeviceInfo,
};

/// A mapping of hvac device codes to their friendly model equivalent.
pub const HVAC_CODES: phf::Map<u16, &'static str> = phf_map! {
0x4E2Au16 => "Licensed manufacturer",
};

/// A broadlink HVAC/Air Conditioner device.
#[derive(Debug, Clone)]
pub struct HvacDevice {
/// Base information about the device.
pub info: DeviceInfo,
}

impl HvacDevice {
/// Create a new HvacDevice.
///
/// Note: This should not be called directly. Please use [Device::from_ip] or
/// [Device::list] instead.
pub fn new(name: &str, addr: Ipv4Addr, response: DiscoveryResponse) -> HvacDevice {
// Get the name of air conditioner
let friendly_model: String = HVAC_CODES
.get(&response.model_code)
.unwrap_or(&"Unknown")
.to_string();

return Self {
info: DeviceInfo {
address: addr,
mac: reverse_mac(response.mac),
model_code: response.model_code,
friendly_type: "HVAC".into(),
friendly_model: friendly_model,
name: name.into(),
auth_id: 0, // This will be populated when authenticated.
key: constants::INITIAL_KEY,
is_locked: response.is_locked,
},
};
}

/// Get basic information from the air conditioner.
pub fn get_info(&self) -> Result<AirCondInfo, String> {
let data = self
.send_command(&[], HvacDataCommand::GetAcInfo)
.expect("Could not obtain AC info from device!");
let info =
AirCondInfo::unpack_from_slice(&data).expect("Could not unpack command from bytes!");

return Ok(info);
}

/// Get current air conditioner state into AirCondState structure.
pub fn get_state(&self) -> Result<AirCondState, String> {
let data = self
.send_command(&[], HvacDataCommand::GetState)
.expect("Could not obtain AC state from device!");
let state =
AirCondState::unpack_from_slice(&data).expect("Could not unpack command from bytes!");

return Ok(state);
}

/// Set new air conditioner state based on passed structure.
pub fn set_state(&self, state: &mut AirCondState) -> Result<Vec<u8>, String> {
let payload = state.prepare_and_pack().expect("Could not pack message");
let response = self
.send_command(&payload, HvacDataCommand::SetState)
.unwrap();

return Ok(response);
}

/// Sends a raw command to the device.
/// Note: Try to avoid using this method in favor of [HvacDevice::get_info], [HvacDevice::set_state], etc.
pub fn send_command(
&self,
payload: &[u8],
command: HvacDataCommand,
) -> Result<Vec<u8>, String> {
// We cast this object to a generic device in order to make use of the shared
// helper utilities.
let generic_device = Device::Hvac { hvac: self.clone() };

// Construct the data message
let msg = HvacDataMessage::new(command);
let packed = msg
.pack_with_payload(&payload)
.expect("Could not pack HVAC data message!");

let response = generic_device
.send_command::<HvacDataMessage>(&packed)
.expect("Could not send command!");

// TODO: check if there is some relation between
// msg.command and the same return field from the response

return HvacDataMessage::unpack_with_payload(&response);
}
}
252 changes: 252 additions & 0 deletions src/network/hvac_data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
use packed_struct::prelude::{
packed_bits, Integer, PackedStruct, PackedStructSlice, PrimitiveEnum_u8,
};

use crate::{network::util::compute_generic_checksum, traits::CommandTrait};

/// The type of command to send to the unit.
#[derive(PrimitiveEnum_u8, Debug, Copy, Clone)]
pub enum HvacDataCommand {
/// Set New State
SetState = 0x00,
/// Get Current State
GetState = 0x01,
/// Obtain air conditioner basic information
GetAcInfo = 0x02,
}

/// Enumerates modes.
#[derive(PrimitiveEnum_u8, Clone, Copy, Debug)]
pub enum HvacMode {
Auto = 0,
Cool = 1,
Dry = 2,
Heat = 3,
Fan = 4,
}

/// Enumerates fan speed.
#[derive(PrimitiveEnum_u8, Clone, Copy, Debug)]
pub enum HvacSpeed {
None = 0,
High = 1,
Mid = 2,
Low = 3,
Auto = 5,
}

/// Enumerates presets.
#[derive(PrimitiveEnum_u8, Clone, Copy, Debug)]
pub enum HvacPreset {
Normal = 0,
Turbo = 1,
Mute = 2,
}

/// Enumerates horizontal swing.
#[derive(PrimitiveEnum_u8, Clone, Copy, Debug)]
pub enum HvacSwHoriz {
LeftFix = 2,
LeftRightFix = 7,
RightFix = 6,
RightFlap = 5,
On = 0,
Off = 1,
}

/// Enumerates vertical swing.
#[derive(PrimitiveEnum_u8, Clone, Copy, Debug)]
pub enum HvacSwVert {
On = 0,
Pos1 = 1,
Pos2 = 2,
Pos3 = 3,
Pos4 = 4,
Pos5 = 5,
Off = 7,
}

/// A struct with air conditioner state.
#[derive(PackedStruct, Debug)]
#[packed_struct(bit_numbering = "msb0", size_bytes = "13")]
pub struct AirCondState {
#[packed_field(bits = "66")]
pub power: bool,
#[packed_field(bits = "0..=4")]
target_temp_int: Integer<u8, packed_bits::Bits<5>>,
// TODO the following fract field was not tested, commented out now
// #[packed_field( bits="16")]
// target_temp_fract: bool,
#[packed_field(bits = "5..=7", ty = "enum")]
pub swing_v: HvacSwVert,
#[packed_field(bits = "8..=10", ty = "enum")]
pub swing_h: HvacSwHoriz,
#[packed_field(bits = "40..=42", ty = "enum")]
pub mode: HvacMode,
#[packed_field(bits = "20..=23")]
magic1: Integer<u8, packed_bits::Bits<4>>,
#[packed_field(bits = "24..=26", ty = "enum")]
pub fanspeed: HvacSpeed,
#[packed_field(bits = "38..=39", ty = "enum")]
pub preset: HvacPreset,
#[packed_field(bits = "45")]
pub sleep: bool,
#[packed_field(bits = "44")]
pub ifeel: bool,
#[packed_field(bits = "70")]
pub health: bool,
#[packed_field(bits = "69")]
pub clean: bool,
#[packed_field(bits = "83")]
pub display: bool,
#[packed_field(bits = "84")]
pub mildew: bool,
}

impl AirCondState {
pub fn prepare_and_pack(&mut self) -> Result<Vec<u8>, String> {
// set magic values before sending
self.magic1 = 0x0f.into();

Ok(self.pack().expect("Could not pack message!").to_vec())
}

/// Calculate final temperature value from internal partial fields.
pub fn get_target_temp(&self) -> f32 {
u8::from(self.target_temp_int) as f32 + 8.0
}

/// Set target temperature from input.
pub fn set_target_temp(&mut self, input: f32) -> Result<(), String> {
if input < 16.0 || input > 32.0 {
return Err("Target temperature is out of range (16-32)".into());
}
// TODO: some units also have a 0.5 degree resolution, so in this
// case the formula would be:
// 8 + target_temp_int + target_temp_fract * 0.5
// not tested, so currently only the integer:
self.target_temp_int = (input as u8 - 8).into();

Ok(())
}
}

/// A struct with air conditioner basic info.
#[derive(PackedStruct, Debug)]
#[packed_struct(bit_numbering = "msb0", size_bytes = "22")]
pub struct AirCondInfo {
#[packed_field(bits = "15")]
pub power: bool,
#[packed_field(bits = "43..=47")]
ambient_temp_int: Integer<u8, packed_bits::Bits<5>>,
#[packed_field(bits = "171..=175")]
ambient_temp_fract: Integer<u8, packed_bits::Bits<5>>,
}

impl AirCondInfo {
/// Calculate final temperature value from internal partial fields.
pub fn get_ambient_temp(&self) -> f32 {
u8::from(self.ambient_temp_int) as f32 + u8::from(self.ambient_temp_fract) as f32 / 10.0
}
}

/// A message used to communicate with the device.
#[derive(PackedStruct, Debug)]
#[packed_struct(bit_numbering = "msb0", endian = "lsb", size_bytes = "12")]
pub struct HvacDataMessage {
/// Length of the payload
#[packed_field(bytes = "0x00:0x01")]
payload_length: u16,

#[packed_field(bytes = "0x02:0x03")]
magic1: u16,
#[packed_field(bytes = "0x04:0x05")]
magic2: u16,
#[packed_field(bytes = "0x06:0x07")]
magic3: u16,

#[packed_field(bytes = "0x08:0x09")]
data_length: u16,

/// Command flag for the message
#[packed_field(bytes = "0x0a:0x0b")]
command: u16,
}

impl HvacDataMessage {
/// Create a new HvacDataMessage.
pub fn new(command_type: HvacDataCommand) -> HvacDataMessage {
return HvacDataMessage {
payload_length: 0,
command: (1 << 8) as u16 + ((command_type as u8) << 4 | 1) as u16,
magic1: 0x00BBu16,
magic2: 0x8006u16,
magic3: 0,
data_length: 2,
};
}

/// Pack the HvacDataMessage with an associated payload.
pub fn pack_with_payload(mut self, payload: &[u8]) -> Result<Vec<u8>, String> {
// Calculate tyhe length of the payload
self.data_length +=
<usize as TryInto<u16>>::try_into(payload.len()).expect("Payload is too long!");

// Add 10 bytes for the header
self.payload_length = self
.data_length
.checked_add(10u16)
.expect("Could not add the start buffer! Payload is too long");

// Append the payload to the header
let mut result = self.pack().expect("Could not pack message!").to_vec();
result.extend(payload);

// Compute and add the final payload checksum
let checksum = compute_generic_checksum(&result[2..]);
result.extend(checksum.to_le_bytes().to_vec());

return Ok(result);
}

/// Unpack a HvacDataMessage and return the associated payload.
pub fn unpack_with_payload(bytes: &[u8]) -> Result<Vec<u8>, String> {
// Unpack the header
let command_header = HvacDataMessage::unpack_from_slice(&bytes[0..12])
.expect("Could not unpack command from bytes!");

// Check total payload length:
// get real size and substract 2 bytes length field for correct comparision
let real_size: u16 = (bytes.len() as u16) - 2;
if real_size != command_header.payload_length {
return Err(format!(
"Command checksum does not match actual checksum! Expected {} got {}",
command_header.payload_length, real_size,
));
}

// Ensure that the checksums match
let crc_offset = usize::from(command_header.payload_length);
let data_crc = u16::from_le_bytes([bytes[crc_offset], bytes[crc_offset + 1]]);
let real_checksum = compute_generic_checksum(&bytes[0x02..crc_offset]);
if data_crc != real_checksum {
return Err(format!(
"Data checksum does not match actual checksum! Expected {} got {}",
data_crc, real_checksum,
));
}

// Extract the data:
// skip the first two bytes which probably contains the command code
// returned by the device
let data = &bytes[0x0C..0x0C + usize::from(command_header.data_length - 2)];

return Ok(data.to_vec());
}
}

impl CommandTrait for HvacDataMessage {
fn packet_type() -> u16 {
return 0x006A;
}
}

0 comments on commit 5a92877

Please sign in to comment.