From 964ab654974ebb03ebd08949259fe4844537cd0c Mon Sep 17 00:00:00 2001 From: bazuchan Date: Mon, 28 Jun 2021 22:26:30 +0300 Subject: [PATCH] Climate component for Ballu air conditioners with remote model YKR-K/002E (#1939) --- CODEOWNERS | 1 + esphome/components/ballu/__init__.py | 0 esphome/components/ballu/ballu.cpp | 239 +++++++++++++++++++++++++++ esphome/components/ballu/ballu.h | 31 ++++ esphome/components/ballu/climate.py | 21 +++ 5 files changed, 292 insertions(+) create mode 100644 esphome/components/ballu/__init__.py create mode 100644 esphome/components/ballu/ballu.cpp create mode 100644 esphome/components/ballu/ballu.h create mode 100644 esphome/components/ballu/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 0594a60ef6..f242cc6d9d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,6 +19,7 @@ esphome/components/api/* @OttoWinter esphome/components/async_tcp/* @OttoWinter esphome/components/atc_mithermometer/* @ahpohl esphome/components/b_parasite/* @rbaron +esphome/components/ballu/* @bazuchan esphome/components/bang_bang/* @OttoWinter esphome/components/binary_sensor/* @esphome/core esphome/components/ble_client/* @buxtronix diff --git a/esphome/components/ballu/__init__.py b/esphome/components/ballu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp new file mode 100644 index 0000000000..e2703a79fb --- /dev/null +++ b/esphome/components/ballu/ballu.cpp @@ -0,0 +1,239 @@ +#include "ballu.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace ballu { + +static const char *const TAG = "ballu.climate"; + +const uint16_t BALLU_HEADER_MARK = 9000; +const uint16_t BALLU_HEADER_SPACE = 4500; +const uint16_t BALLU_BIT_MARK = 575; +const uint16_t BALLU_ONE_SPACE = 1675; +const uint16_t BALLU_ZERO_SPACE = 550; + +const uint32_t BALLU_CARRIER_FREQUENCY = 38000; + +const uint8_t BALLU_STATE_LENGTH = 13; + +const uint8_t BALLU_AUTO = 0; +const uint8_t BALLU_COOL = 0x20; +const uint8_t BALLU_DRY = 0x40; +const uint8_t BALLU_HEAT = 0x80; +const uint8_t BALLU_FAN = 0xc0; + +const uint8_t BALLU_FAN_AUTO = 0xa0; +const uint8_t BALLU_FAN_HIGH = 0x20; +const uint8_t BALLU_FAN_MED = 0x40; +const uint8_t BALLU_FAN_LOW = 0x60; + +const uint8_t BALLU_SWING_VER = 0x07; +const uint8_t BALLU_SWING_HOR = 0xe0; +const uint8_t BALLU_POWER = 0x20; + +void BalluClimate::transmit_state() { + uint8_t remote_state[BALLU_STATE_LENGTH] = {0}; + + auto temp = (uint8_t) roundf(clamp(this->target_temperature, YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX)); + auto swing_ver = + ((this->swing_mode == climate::CLIMATE_SWING_VERTICAL) || (this->swing_mode == climate::CLIMATE_SWING_BOTH)); + auto swing_hor = + ((this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL) || (this->swing_mode == climate::CLIMATE_SWING_BOTH)); + + remote_state[0] = 0xc3; + remote_state[1] = ((temp - 8) << 3) | (swing_ver ? 0 : BALLU_SWING_VER); + remote_state[2] = swing_hor ? 0 : BALLU_SWING_HOR; + remote_state[9] = (this->mode == climate::CLIMATE_MODE_OFF) ? 0 : BALLU_POWER; + remote_state[11] = 0x1e; + + // Fan speed + switch (this->fan_mode.value()) { + case climate::CLIMATE_FAN_HIGH: + remote_state[4] |= BALLU_FAN_HIGH; + break; + case climate::CLIMATE_FAN_MEDIUM: + remote_state[4] |= BALLU_FAN_MED; + break; + case climate::CLIMATE_FAN_LOW: + remote_state[4] |= BALLU_FAN_LOW; + break; + case climate::CLIMATE_FAN_AUTO: + remote_state[4] |= BALLU_FAN_AUTO; + break; + default: + break; + } + + // Mode + switch (this->mode) { + case climate::CLIMATE_MODE_AUTO: + remote_state[6] |= BALLU_AUTO; + break; + case climate::CLIMATE_MODE_HEAT: + remote_state[6] |= BALLU_HEAT; + break; + case climate::CLIMATE_MODE_COOL: + remote_state[6] |= BALLU_COOL; + break; + case climate::CLIMATE_MODE_DRY: + remote_state[6] |= BALLU_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + remote_state[6] |= BALLU_FAN; + break; + case climate::CLIMATE_MODE_OFF: + remote_state[6] |= BALLU_AUTO; + default: + break; + } + + // Checksum + for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) + remote_state[12] += remote_state[i]; + + ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", remote_state[0], + remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], + remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], remote_state[12]); + + // Send code + auto transmit = this->transmitter_->transmit(); + auto data = transmit.get_data(); + + data->set_carrier_frequency(38000); + + // Header + data->mark(BALLU_HEADER_MARK); + data->space(BALLU_HEADER_SPACE); + // Data + for (uint8_t i : remote_state) { + for (uint8_t j = 0; j < 8; j++) { + data->mark(BALLU_BIT_MARK); + bool bit = i & (1 << j); + data->space(bit ? BALLU_ONE_SPACE : BALLU_ZERO_SPACE); + } + } + // Footer + data->mark(BALLU_BIT_MARK); + + transmit.perform(); +} + +bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { + // Validate header + if (!data.expect_item(BALLU_HEADER_MARK, BALLU_HEADER_SPACE)) { + ESP_LOGV(TAG, "Header fail"); + return false; + } + + uint8_t remote_state[BALLU_STATE_LENGTH] = {0}; + // Read all bytes. + for (int i = 0; i < BALLU_STATE_LENGTH; i++) { + // Read bit + for (int j = 0; j < 8; j++) { + if (data.expect_item(BALLU_BIT_MARK, BALLU_ONE_SPACE)) + remote_state[i] |= 1 << j; + + else if (!data.expect_item(BALLU_BIT_MARK, BALLU_ZERO_SPACE)) { + ESP_LOGV(TAG, "Byte %d bit %d fail", i, j); + return false; + } + } + + ESP_LOGVV(TAG, "Byte %d %02X", i, remote_state[i]); + } + // Validate footer + if (!data.expect_mark(BALLU_BIT_MARK)) { + ESP_LOGV(TAG, "Footer fail"); + return false; + } + + uint8_t checksum = 0; + // Calculate checksum and compare with signal value. + for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) + checksum += remote_state[i]; + + if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) { + ESP_LOGVV(TAG, "Checksum fail"); + return false; + } + + ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", remote_state[0], + remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], + remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], remote_state[12]); + + // verify header remote code + if (remote_state[0] != 0xc3) + return false; + + // powr on/off button + ESP_LOGV(TAG, "Power: %02X", (remote_state[9] & BALLU_POWER)); + + if ((remote_state[9] & BALLU_POWER) != BALLU_POWER) { + this->mode = climate::CLIMATE_MODE_OFF; + } else { + auto mode = remote_state[6] & 0xe0; + ESP_LOGV(TAG, "Mode: %02X", mode); + switch (mode) { + case BALLU_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case BALLU_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case BALLU_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case BALLU_FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case BALLU_AUTO: + this->mode = climate::CLIMATE_MODE_AUTO; + break; + } + } + + // Set received temp + int temp = remote_state[1] & 0xf8; + ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); + temp = ((uint8_t) temp >> 3) + 8; + ESP_LOGVV(TAG, "Temperature Climate: %u", temp); + this->target_temperature = temp; + + // Set received fan speed + auto fan = remote_state[4] & 0xe0; + ESP_LOGVV(TAG, "Fan: %02X", fan); + switch (fan) { + case BALLU_FAN_HIGH: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case BALLU_FAN_MED: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case BALLU_FAN_LOW: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case BALLU_FAN_AUTO: + default: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + // Set received swing status + ESP_LOGVV(TAG, "Swing status: %02X %02X", remote_state[1] & BALLU_SWING_VER, remote_state[2] & BALLU_SWING_HOR); + if (((remote_state[1] & BALLU_SWING_VER) != BALLU_SWING_VER) && + ((remote_state[2] & BALLU_SWING_HOR) != BALLU_SWING_HOR)) { + this->swing_mode = climate::CLIMATE_SWING_BOTH; + } else if ((remote_state[1] & BALLU_SWING_VER) != BALLU_SWING_VER) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else if ((remote_state[2] & BALLU_SWING_HOR) != BALLU_SWING_HOR) { + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + + this->publish_state(); + return true; +} + +} // namespace ballu +} // namespace esphome diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h new file mode 100644 index 0000000000..80a4699cfb --- /dev/null +++ b/esphome/components/ballu/ballu.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome { +namespace ballu { + +// Support for Ballu air conditioners with YKR-K/002E remote + +// Temperature +const float YKR_K_002E_TEMP_MIN = 16.0; +const float YKR_K_002E_TEMP_MAX = 32.0; + +class BalluClimate : public climate_ir::ClimateIR { + public: + BalluClimate() + : climate_ir::ClimateIR(YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} + + protected: + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Handle received IR Buffer + bool on_receive(remote_base::RemoteReceiveData data) override; +}; + +} // namespace ballu +} // namespace esphome diff --git a/esphome/components/ballu/climate.py b/esphome/components/ballu/climate.py new file mode 100644 index 0000000000..82e9fead1e --- /dev/null +++ b/esphome/components/ballu/climate.py @@ -0,0 +1,21 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import climate_ir +from esphome.const import CONF_ID + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@bazuchan"] + +ballu_ns = cg.esphome_ns.namespace("ballu") +BalluClimate = ballu_ns.class_("BalluClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.CLIMATE_IR_WITH_RECEIVER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BalluClimate), + } +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await climate_ir.register_climate_ir(var, config)