From fe4fb5f1ac796180c25711da2af624f5b2e8c663 Mon Sep 17 00:00:00 2001 From: Yaroslav Heriatovych <184247+Yarikx@users.noreply.github.com> Date: Thu, 23 Feb 2023 02:05:33 +0000 Subject: [PATCH] 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 --- CODEOWNERS | 1 + esphome/components/haier/__init__.py | 1 + esphome/components/haier/climate.py | 43 ++++ esphome/components/haier/haier.cpp | 302 +++++++++++++++++++++++++++ esphome/components/haier/haier.h | 37 ++++ tests/test3.yaml | 14 +- 6 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 esphome/components/haier/__init__.py create mode 100644 esphome/components/haier/climate.py create mode 100644 esphome/components/haier/haier.cpp create mode 100644 esphome/components/haier/haier.h diff --git a/CODEOWNERS b/CODEOWNERS index 00f207862a..4e3c6c3510 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/haier/__init__.py b/esphome/components/haier/__init__.py new file mode 100644 index 0000000000..b9ea055a41 --- /dev/null +++ b/esphome/components/haier/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Yarikx"] diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py new file mode 100644 index 0000000000..cee83232a1 --- /dev/null +++ b/esphome/components/haier/climate.py @@ -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])) diff --git a/esphome/components/haier/haier.cpp b/esphome/components/haier/haier.cpp new file mode 100644 index 0000000000..cf69d483b5 --- /dev/null +++ b/esphome/components/haier/haier.cpp @@ -0,0 +1,302 @@ +#include +#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 diff --git a/esphome/components/haier/haier.h b/esphome/components/haier/haier.h new file mode 100644 index 0000000000..5399fd187b --- /dev/null +++ b/esphome/components/haier/haier.h @@ -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 &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 supported_swing_modes_{}; +}; + +} // namespace haier +} // namespace esphome diff --git a/tests/test3.yaml b/tests/test3.yaml index 6755be9f14..16489335af 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -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