mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Add Haier climate component (#4001)
* Basic functionality works * Cleanup * Add tests * Separate header * Fix send_data_ * Formatting fix * Add __init__.py * Fix type * Add codeowners * Rename supported_swing_modes * Use multiple swing modes, same as midea platform * Add CLIMATE_FAN_QUIET handler * PR fixes
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							350d4e5071
						
					
				
				
					commit
					fe4fb5f1ac
				
			| @@ -95,6 +95,7 @@ esphome/components/gpio/* @esphome/core | ||||
| esphome/components/gps/* @coogle | ||||
| esphome/components/graph/* @synco | ||||
| esphome/components/growatt_solar/* @leeuwte | ||||
| esphome/components/haier/* @Yarikx | ||||
| esphome/components/havells_solar/* @sourabhjaiswal | ||||
| esphome/components/hbridge/fan/* @WeekendWarrior | ||||
| esphome/components/hbridge/light/* @DotNetDann | ||||
|   | ||||
							
								
								
									
										1
									
								
								esphome/components/haier/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								esphome/components/haier/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| CODEOWNERS = ["@Yarikx"] | ||||
							
								
								
									
										43
									
								
								esphome/components/haier/climate.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								esphome/components/haier/climate.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| from esphome.components import climate | ||||
| import esphome.codegen as cg | ||||
| import esphome.config_validation as cv | ||||
| from esphome.components import uart | ||||
| from esphome.components.climate import ClimateSwingMode | ||||
| from esphome.const import CONF_ID, CONF_SUPPORTED_SWING_MODES | ||||
|  | ||||
| DEPENDENCIES = ["uart"] | ||||
|  | ||||
| haier_ns = cg.esphome_ns.namespace("haier") | ||||
| HaierClimate = haier_ns.class_( | ||||
|     "HaierClimate", climate.Climate, cg.PollingComponent, uart.UARTDevice | ||||
| ) | ||||
|  | ||||
| ALLOWED_CLIMATE_SWING_MODES = { | ||||
|     "BOTH": ClimateSwingMode.CLIMATE_SWING_BOTH, | ||||
|     "VERTICAL": ClimateSwingMode.CLIMATE_SWING_VERTICAL, | ||||
|     "HORIZONTAL": ClimateSwingMode.CLIMATE_SWING_HORIZONTAL, | ||||
| } | ||||
|  | ||||
| validate_swing_modes = cv.enum(ALLOWED_CLIMATE_SWING_MODES, upper=True) | ||||
|  | ||||
| CONFIG_SCHEMA = cv.All( | ||||
|     climate.CLIMATE_SCHEMA.extend( | ||||
|         { | ||||
|             cv.GenerateID(): cv.declare_id(HaierClimate), | ||||
|             cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( | ||||
|                 validate_swing_modes | ||||
|             ), | ||||
|         } | ||||
|     ) | ||||
|     .extend(cv.polling_component_schema("5s")) | ||||
|     .extend(uart.UART_DEVICE_SCHEMA), | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def to_code(config): | ||||
|     var = cg.new_Pvariable(config[CONF_ID]) | ||||
|     await cg.register_component(var, config) | ||||
|     await climate.register_climate(var, config) | ||||
|     await uart.register_uart_device(var, config) | ||||
|     if CONF_SUPPORTED_SWING_MODES in config: | ||||
|         cg.add(var.set_supported_swing_modes(config[CONF_SUPPORTED_SWING_MODES])) | ||||
							
								
								
									
										302
									
								
								esphome/components/haier/haier.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								esphome/components/haier/haier.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,302 @@ | ||||
| #include <cmath> | ||||
| #include "haier.h" | ||||
| #include "esphome/core/macros.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace haier { | ||||
|  | ||||
| static const char *const TAG = "haier"; | ||||
|  | ||||
| static const uint8_t TEMPERATURE = 13; | ||||
| static const uint8_t HUMIDITY = 15; | ||||
|  | ||||
| static const uint8_t MODE = 23; | ||||
|  | ||||
| static const uint8_t FAN_SPEED = 25; | ||||
|  | ||||
| static const uint8_t SWING = 27; | ||||
|  | ||||
| static const uint8_t POWER = 29; | ||||
| static const uint8_t POWER_MASK = 1; | ||||
|  | ||||
| static const uint8_t SET_TEMPERATURE = 35; | ||||
| static const uint8_t DECIMAL_MASK = (1 << 5); | ||||
|  | ||||
| static const uint8_t CRC = 36; | ||||
|  | ||||
| static const uint8_t COMFORT_PRESET_MASK = (1 << 3); | ||||
|  | ||||
| static const uint8_t MIN_VALID_TEMPERATURE = 16; | ||||
| static const uint8_t MAX_VALID_TEMPERATURE = 50; | ||||
| static const float TEMPERATURE_STEP = 0.5f; | ||||
|  | ||||
| static const uint8_t POLL_REQ[13] = {255, 255, 10, 0, 0, 0, 0, 0, 1, 1, 77, 1, 90}; | ||||
| static const uint8_t OFF_REQ[13] = {255, 255, 10, 0, 0, 0, 0, 0, 1, 1, 77, 3, 92}; | ||||
|  | ||||
| void HaierClimate::dump_config() { | ||||
|   ESP_LOGCONFIG(TAG, "Haier:"); | ||||
|   ESP_LOGCONFIG(TAG, "  Update interval: %u", this->get_update_interval()); | ||||
|   this->dump_traits_(TAG); | ||||
|   this->check_uart_settings(9600); | ||||
| } | ||||
|  | ||||
| void HaierClimate::loop() { | ||||
|   if (this->available() >= sizeof(this->data_)) { | ||||
|     this->read_array(this->data_, sizeof(this->data_)); | ||||
|     if (this->data_[0] != 255 || this->data_[1] != 255) | ||||
|       return; | ||||
|  | ||||
|     read_state_(this->data_, sizeof(this->data_)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void HaierClimate::update() { | ||||
|   this->write_array(POLL_REQ, sizeof(POLL_REQ)); | ||||
|   dump_message_("Poll sent", POLL_REQ, sizeof(POLL_REQ)); | ||||
| } | ||||
|  | ||||
| climate::ClimateTraits HaierClimate::traits() { | ||||
|   auto traits = climate::ClimateTraits(); | ||||
|  | ||||
|   traits.set_visual_min_temperature(MIN_VALID_TEMPERATURE); | ||||
|   traits.set_visual_max_temperature(MAX_VALID_TEMPERATURE); | ||||
|   traits.set_visual_temperature_step(TEMPERATURE_STEP); | ||||
|  | ||||
|   traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL, climate::CLIMATE_MODE_COOL, | ||||
|                               climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_FAN_ONLY, climate::CLIMATE_MODE_DRY}); | ||||
|  | ||||
|   traits.set_supported_fan_modes({ | ||||
|       climate::CLIMATE_FAN_AUTO, | ||||
|       climate::CLIMATE_FAN_LOW, | ||||
|       climate::CLIMATE_FAN_MEDIUM, | ||||
|       climate::CLIMATE_FAN_HIGH, | ||||
|   }); | ||||
|  | ||||
|   traits.set_supported_swing_modes(this->supported_swing_modes_); | ||||
|   traits.set_supports_current_temperature(true); | ||||
|   traits.set_supports_two_point_target_temperature(false); | ||||
|  | ||||
|   traits.add_supported_preset(climate::CLIMATE_PRESET_NONE); | ||||
|   traits.add_supported_preset(climate::CLIMATE_PRESET_COMFORT); | ||||
|  | ||||
|   return traits; | ||||
| } | ||||
|  | ||||
| void HaierClimate::read_state_(const uint8_t *data, uint8_t size) { | ||||
|   dump_message_("Received state", data, size); | ||||
|  | ||||
|   uint8_t check = data[CRC]; | ||||
|  | ||||
|   uint8_t crc = get_checksum_(data, size); | ||||
|  | ||||
|   if (check != crc) { | ||||
|     ESP_LOGW(TAG, "Invalid checksum"); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   this->current_temperature = data[TEMPERATURE]; | ||||
|  | ||||
|   this->target_temperature = data[SET_TEMPERATURE] + MIN_VALID_TEMPERATURE; | ||||
|  | ||||
|   if (data[POWER] & DECIMAL_MASK) { | ||||
|     this->target_temperature += 0.5f; | ||||
|   } | ||||
|  | ||||
|   switch (data[MODE]) { | ||||
|     case MODE_SMART: | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT_COOL; | ||||
|       break; | ||||
|     case MODE_COOL: | ||||
|       this->mode = climate::CLIMATE_MODE_COOL; | ||||
|       break; | ||||
|     case MODE_HEAT: | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT; | ||||
|       break; | ||||
|     case MODE_ONLY_FAN: | ||||
|       this->mode = climate::CLIMATE_MODE_FAN_ONLY; | ||||
|       break; | ||||
|     case MODE_DRY: | ||||
|       this->mode = climate::CLIMATE_MODE_DRY; | ||||
|       break; | ||||
|     default:  // other modes are unsupported | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT_COOL; | ||||
|   } | ||||
|  | ||||
|   switch (data[FAN_SPEED]) { | ||||
|     case FAN_AUTO: | ||||
|       this->fan_mode = climate::CLIMATE_FAN_AUTO; | ||||
|       break; | ||||
|  | ||||
|     case FAN_MIN: | ||||
|       this->fan_mode = climate::CLIMATE_FAN_LOW; | ||||
|       break; | ||||
|  | ||||
|     case FAN_MIDDLE: | ||||
|       this->fan_mode = climate::CLIMATE_FAN_MEDIUM; | ||||
|       break; | ||||
|  | ||||
|     case FAN_MAX: | ||||
|       this->fan_mode = climate::CLIMATE_FAN_HIGH; | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   switch (data[SWING]) { | ||||
|     case SWING_OFF: | ||||
|       this->swing_mode = climate::CLIMATE_SWING_OFF; | ||||
|       break; | ||||
|  | ||||
|     case SWING_VERTICAL: | ||||
|       this->swing_mode = climate::CLIMATE_SWING_VERTICAL; | ||||
|       break; | ||||
|  | ||||
|     case SWING_HORIZONTAL: | ||||
|       this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; | ||||
|       break; | ||||
|  | ||||
|     case SWING_BOTH: | ||||
|       this->swing_mode = climate::CLIMATE_SWING_BOTH; | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   if (data[POWER] & COMFORT_PRESET_MASK) { | ||||
|     this->preset = climate::CLIMATE_PRESET_COMFORT; | ||||
|   } else { | ||||
|     this->preset = climate::CLIMATE_PRESET_NONE; | ||||
|   } | ||||
|  | ||||
|   if ((data[POWER] & POWER_MASK) == 0) { | ||||
|     this->mode = climate::CLIMATE_MODE_OFF; | ||||
|   } | ||||
|  | ||||
|   this->publish_state(); | ||||
| } | ||||
|  | ||||
| void HaierClimate::control(const climate::ClimateCall &call) { | ||||
|   if (call.get_mode().has_value()) { | ||||
|     switch (call.get_mode().value()) { | ||||
|       case climate::CLIMATE_MODE_OFF: | ||||
|         send_data_(OFF_REQ, sizeof(OFF_REQ)); | ||||
|         break; | ||||
|  | ||||
|       case climate::CLIMATE_MODE_HEAT_COOL: | ||||
|       case climate::CLIMATE_MODE_AUTO: | ||||
|         data_[POWER] |= POWER_MASK; | ||||
|         data_[MODE] = MODE_SMART; | ||||
|         break; | ||||
|       case climate::CLIMATE_MODE_HEAT: | ||||
|         data_[POWER] |= POWER_MASK; | ||||
|         data_[MODE] = MODE_HEAT; | ||||
|         break; | ||||
|       case climate::CLIMATE_MODE_COOL: | ||||
|         data_[POWER] |= POWER_MASK; | ||||
|         data_[MODE] = MODE_COOL; | ||||
|         break; | ||||
|  | ||||
|       case climate::CLIMATE_MODE_FAN_ONLY: | ||||
|         data_[POWER] |= POWER_MASK; | ||||
|         data_[MODE] = MODE_ONLY_FAN; | ||||
|         break; | ||||
|  | ||||
|       case climate::CLIMATE_MODE_DRY: | ||||
|         data_[POWER] |= POWER_MASK; | ||||
|         data_[MODE] = MODE_DRY; | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_preset().has_value()) { | ||||
|     if (call.get_preset().value() == climate::CLIMATE_PRESET_COMFORT) { | ||||
|       data_[POWER] |= COMFORT_PRESET_MASK; | ||||
|     } else { | ||||
|       data_[POWER] &= ~COMFORT_PRESET_MASK; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_target_temperature().has_value()) { | ||||
|     float target = call.get_target_temperature().value() - MIN_VALID_TEMPERATURE; | ||||
|  | ||||
|     data_[SET_TEMPERATURE] = (uint8_t) target; | ||||
|  | ||||
|     if ((int) target == std::lroundf(target)) { | ||||
|       data_[POWER] &= ~DECIMAL_MASK; | ||||
|     } else { | ||||
|       data_[POWER] |= DECIMAL_MASK; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_fan_mode().has_value()) { | ||||
|     switch (call.get_fan_mode().value()) { | ||||
|       case climate::CLIMATE_FAN_AUTO: | ||||
|         data_[FAN_SPEED] = FAN_AUTO; | ||||
|         break; | ||||
|       case climate::CLIMATE_FAN_LOW: | ||||
|         data_[FAN_SPEED] = FAN_MIN; | ||||
|         break; | ||||
|       case climate::CLIMATE_FAN_MEDIUM: | ||||
|         data_[FAN_SPEED] = FAN_MIDDLE; | ||||
|         break; | ||||
|       case climate::CLIMATE_FAN_HIGH: | ||||
|         data_[FAN_SPEED] = FAN_MAX; | ||||
|         break; | ||||
|  | ||||
|       default:  // other modes are unsupported | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_swing_mode().has_value()) { | ||||
|     switch (call.get_swing_mode().value()) { | ||||
|       case climate::CLIMATE_SWING_OFF: | ||||
|         data_[SWING] = SWING_OFF; | ||||
|         break; | ||||
|       case climate::CLIMATE_SWING_VERTICAL: | ||||
|         data_[SWING] = SWING_VERTICAL; | ||||
|         break; | ||||
|       case climate::CLIMATE_SWING_HORIZONTAL: | ||||
|         data_[SWING] = SWING_HORIZONTAL; | ||||
|         break; | ||||
|       case climate::CLIMATE_SWING_BOTH: | ||||
|         data_[SWING] = SWING_BOTH; | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Parts of the message that must have specific values for "send" command. | ||||
|   // The meaning of those values is unknown at the moment. | ||||
|   data_[9] = 1; | ||||
|   data_[10] = 77; | ||||
|   data_[11] = 95; | ||||
|   data_[17] = 0; | ||||
|  | ||||
|   // Compute checksum | ||||
|   uint8_t crc = get_checksum_(data_, sizeof(data_)); | ||||
|   data_[CRC] = crc; | ||||
|  | ||||
|   send_data_(data_, sizeof(data_)); | ||||
| } | ||||
|  | ||||
| void HaierClimate::send_data_(const uint8_t *message, uint8_t size) { | ||||
|   this->write_array(message, size); | ||||
|  | ||||
|   dump_message_("Sent message", message, size); | ||||
| } | ||||
|  | ||||
| void HaierClimate::dump_message_(const char *title, const uint8_t *message, uint8_t size) { | ||||
|   ESP_LOGV(TAG, "%s:", title); | ||||
|   for (int i = 0; i < size; i++) { | ||||
|     ESP_LOGV(TAG, "  byte %02d - %d", i, message[i]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| uint8_t HaierClimate::get_checksum_(const uint8_t *message, size_t size) { | ||||
|   uint8_t position = size - 1; | ||||
|   uint8_t crc = 0; | ||||
|  | ||||
|   for (int i = 2; i < position; i++) | ||||
|     crc += message[i]; | ||||
|  | ||||
|   return crc; | ||||
| } | ||||
|  | ||||
| }  // namespace haier | ||||
| }  // namespace esphome | ||||
							
								
								
									
										37
									
								
								esphome/components/haier/haier.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								esphome/components/haier/haier.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "esphome/core/component.h" | ||||
| #include "esphome/components/climate/climate.h" | ||||
| #include "esphome/components/uart/uart.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace haier { | ||||
|  | ||||
| enum Mode : uint8_t { MODE_SMART = 0, MODE_COOL = 1, MODE_HEAT = 2, MODE_ONLY_FAN = 3, MODE_DRY = 4 }; | ||||
| enum FanSpeed : uint8_t { FAN_MAX = 0, FAN_MIDDLE = 1, FAN_MIN = 2, FAN_AUTO = 3 }; | ||||
| enum SwingMode : uint8_t { SWING_OFF = 0, SWING_VERTICAL = 1, SWING_HORIZONTAL = 2, SWING_BOTH = 3 }; | ||||
|  | ||||
| class HaierClimate : public climate::Climate, public uart::UARTDevice, public PollingComponent { | ||||
|  public: | ||||
|   void loop() override; | ||||
|   void update() override; | ||||
|   void dump_config() override; | ||||
|   void control(const climate::ClimateCall &call) override; | ||||
|   void set_supported_swing_modes(const std::set<climate::ClimateSwingMode> &modes) { | ||||
|     this->supported_swing_modes_ = modes; | ||||
|   } | ||||
|  | ||||
|  protected: | ||||
|   climate::ClimateTraits traits() override; | ||||
|   void read_state_(const uint8_t *data, uint8_t size); | ||||
|   void send_data_(const uint8_t *message, uint8_t size); | ||||
|   void dump_message_(const char *title, const uint8_t *message, uint8_t size); | ||||
|   uint8_t get_checksum_(const uint8_t *message, size_t size); | ||||
|  | ||||
|  private: | ||||
|   uint8_t data_[37]; | ||||
|   std::set<climate::ClimateSwingMode> supported_swing_modes_{}; | ||||
| }; | ||||
|  | ||||
| }  // namespace haier | ||||
| }  // namespace esphome | ||||
| @@ -283,6 +283,10 @@ uart: | ||||
|     tx_pin: GPIO4 | ||||
|     rx_pin: GPIO5 | ||||
|     baud_rate: 9600 | ||||
|   - id: uart12 | ||||
|     tx_pin: GPIO4 | ||||
|     rx_pin: GPIO5 | ||||
|     baud_rate: 9600 | ||||
|  | ||||
| modbus: | ||||
|   uart_id: uart1 | ||||
| @@ -1194,8 +1198,14 @@ climate: | ||||
|       ki_multiplier: 0.0 | ||||
|       kd_multiplier: 0.0 | ||||
|       deadband_output_averaging_samples: 1 | ||||
|  | ||||
|  | ||||
|   - platform: haier | ||||
|     name: Haier AC | ||||
|     supported_swing_modes: | ||||
|       - vertical | ||||
|       - horizontal | ||||
|       - both | ||||
|     update_interval: 10s | ||||
|     uart_id: uart12 | ||||
|  | ||||
| sprinkler: | ||||
|   - id: yard_sprinkler_ctrlr | ||||
|   | ||||
		Reference in New Issue
	
	Block a user