From b4765fb5fbf1fe659e826db57613a803d2beb595 Mon Sep 17 00:00:00 2001 From: Chris Feenstra <73584137+cfeenstra1024@users.noreply.github.com> Date: Tue, 24 Oct 2023 00:33:47 +0200 Subject: [PATCH] Add ZH/LT-01 climate component with IR receiver option (#4333) Co-authored-by: Chris Feenstra Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/zhlt01/__init__.py | 0 esphome/components/zhlt01/climate.py | 19 ++ esphome/components/zhlt01/zhlt01.cpp | 238 ++++++++++++++++++++++++++ esphome/components/zhlt01/zhlt01.h | 167 ++++++++++++++++++ tests/test1.yaml | 2 + 6 files changed, 427 insertions(+) create mode 100644 esphome/components/zhlt01/__init__.py create mode 100644 esphome/components/zhlt01/climate.py create mode 100644 esphome/components/zhlt01/zhlt01.cpp create mode 100644 esphome/components/zhlt01/zhlt01.h diff --git a/CODEOWNERS b/CODEOWNERS index 0c371d6ee6f6..859bcf248c7a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -345,4 +345,5 @@ esphome/components/xiaomi_mhoc401/* @vevsvevs esphome/components/xiaomi_rtcgq02lm/* @jesserockz esphome/components/xl9535/* @mreditor97 esphome/components/xpt2046/* @nielsnl68 @numo68 +esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zio_ultrasonic/* @kahrendt diff --git a/esphome/components/zhlt01/__init__.py b/esphome/components/zhlt01/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/esphome/components/zhlt01/climate.py b/esphome/components/zhlt01/climate.py new file mode 100644 index 000000000000..1451f8ec6975 --- /dev/null +++ b/esphome/components/zhlt01/climate.py @@ -0,0 +1,19 @@ +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 = ["@cfeenstra1024"] + +zhlt01_ns = cg.esphome_ns.namespace("zhlt01") +ZHLT01Climate = zhlt01_ns.class_("ZHLT01Climate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.CLIMATE_IR_WITH_RECEIVER_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(ZHLT01Climate)} +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await climate_ir.register_climate_ir(var, config) diff --git a/esphome/components/zhlt01/zhlt01.cpp b/esphome/components/zhlt01/zhlt01.cpp new file mode 100644 index 000000000000..36d1737c14cc --- /dev/null +++ b/esphome/components/zhlt01/zhlt01.cpp @@ -0,0 +1,238 @@ +#include "zhlt01.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace zhlt01 { + +static const char *const TAG = "zhlt01.climate"; + +void ZHLT01Climate::transmit_state() { + uint8_t ir_message[12] = {0}; + + // Byte 1 : Timer + ir_message[1] = 0x00; // Timer off + + // Byte 3 : Turbo mode + if (this->preset.value() == climate::CLIMATE_PRESET_BOOST) { + ir_message[3] = AC1_FAN_TURBO; + } + + // Byte 5 : Last pressed button + ir_message[5] = 0x00; // fixed as power button + + // Byte 7 : Power | Swing | Fan + // -- Power + if (this->mode == climate::CLIMATE_MODE_OFF) { + ir_message[7] = AC1_POWER_OFF; + } else { + ir_message[7] = AC1_POWER_ON; + } + + // -- Swing + switch (this->swing_mode) { + case climate::CLIMATE_SWING_OFF: + ir_message[7] |= AC1_HDIR_FIXED | AC1_VDIR_FIXED; + break; + case climate::CLIMATE_SWING_HORIZONTAL: + ir_message[7] |= AC1_HDIR_SWING | AC1_VDIR_FIXED; + break; + case climate::CLIMATE_SWING_VERTICAL: + ir_message[7] |= AC1_HDIR_FIXED | AC1_VDIR_SWING; + break; + case climate::CLIMATE_SWING_BOTH: + ir_message[7] |= AC1_HDIR_SWING | AC1_VDIR_SWING; + break; + default: + break; + } + + // -- Fan + switch (this->preset.value()) { + case climate::CLIMATE_PRESET_BOOST: + ir_message[7] |= AC1_FAN3; + break; + case climate::CLIMATE_PRESET_SLEEP: + ir_message[7] |= AC1_FAN_SILENT; + break; + default: + switch (this->fan_mode.value()) { + case climate::CLIMATE_FAN_LOW: + ir_message[7] |= AC1_FAN1; + break; + case climate::CLIMATE_FAN_MEDIUM: + ir_message[7] |= AC1_FAN2; + break; + case climate::CLIMATE_FAN_HIGH: + ir_message[7] |= AC1_FAN3; + break; + case climate::CLIMATE_FAN_AUTO: + ir_message[7] |= AC1_FAN_AUTO; + break; + default: + break; + } + } + + // Byte 9 : AC Mode | Temperature + // -- AC Mode + switch (this->mode) { + case climate::CLIMATE_MODE_AUTO: + case climate::CLIMATE_MODE_HEAT_COOL: + ir_message[9] = AC1_MODE_AUTO; + break; + case climate::CLIMATE_MODE_COOL: + ir_message[9] = AC1_MODE_COOL; + break; + case climate::CLIMATE_MODE_HEAT: + ir_message[9] = AC1_MODE_HEAT; + break; + case climate::CLIMATE_MODE_DRY: + ir_message[9] = AC1_MODE_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + ir_message[9] = AC1_MODE_FAN; + break; + default: + break; + } + + // -- Temperature + ir_message[9] |= (uint8_t) (this->target_temperature - 16.0f); + + // Byte 11 : Remote control ID + ir_message[11] = 0xD5; + + // Set checksum bytes + for (int i = 0; i < 12; i += 2) { + ir_message[i] = ~ir_message[i + 1]; + } + + // Send the code + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + data->set_carrier_frequency(38000); // 38 kHz PWM + + // Header + data->mark(AC1_HDR_MARK); + data->space(AC1_HDR_SPACE); + + // Data + for (uint8_t i : ir_message) { + for (uint8_t j = 0; j < 8; j++) { + data->mark(AC1_BIT_MARK); + bool bit = i & (1 << j); + data->space(bit ? AC1_ONE_SPACE : AC1_ZERO_SPACE); + } + } + + // Footer + data->mark(AC1_BIT_MARK); + data->space(0); + + transmit.perform(); +} + +bool ZHLT01Climate::on_receive(remote_base::RemoteReceiveData data) { + // Validate header + if (!data.expect_item(AC1_HDR_MARK, AC1_HDR_SPACE)) { + ESP_LOGV(TAG, "Header fail"); + return false; + } + + // Decode IR message + uint8_t ir_message[12] = {0}; + // Read all bytes + for (int i = 0; i < 12; i++) { + // Read bit + for (int j = 0; j < 8; j++) { + if (data.expect_item(AC1_BIT_MARK, AC1_ONE_SPACE)) { + ir_message[i] |= 1 << j; + } else if (!data.expect_item(AC1_BIT_MARK, AC1_ZERO_SPACE)) { + ESP_LOGV(TAG, "Byte %d bit %d fail", i, j); + return false; + } + } + ESP_LOGVV(TAG, "Byte %d %02X", i, ir_message[i]); + } + + // Validate footer + if (!data.expect_mark(AC1_BIT_MARK)) { + ESP_LOGV(TAG, "Footer fail"); + return false; + } + + // Validate checksum + for (int i = 0; i < 12; i += 2) { + if (ir_message[i] != (uint8_t) (~ir_message[i + 1])) { + ESP_LOGV(TAG, "Byte %d checksum incorrect (%02X != %02X)", i, ir_message[i], (uint8_t) (~ir_message[i + 1])); + return false; + } + } + + // Validate remote control ID + if (ir_message[11] != 0xD5) { + ESP_LOGV(TAG, "Invalid remote control ID"); + return false; + } + + // All is good to go + + if ((ir_message[7] & AC1_POWER_ON) == 0) { + this->mode = climate::CLIMATE_MODE_OFF; + } else { + // Vertical swing + if ((ir_message[7] & 0x0C) == AC1_VDIR_FIXED) { + if ((ir_message[7] & 0x10) == AC1_HDIR_FIXED) { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } else { + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } + } else { + if ((ir_message[7] & 0x10) == AC1_HDIR_FIXED) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_BOTH; + } + } + + // Preset + Fan speed + if ((ir_message[3] & AC1_FAN_TURBO) == AC1_FAN_TURBO) { + this->preset = climate::CLIMATE_PRESET_BOOST; + this->fan_mode = climate::CLIMATE_FAN_HIGH; + } else if ((ir_message[7] & 0xE1) == AC1_FAN_SILENT) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + this->fan_mode = climate::CLIMATE_FAN_LOW; + } else if ((ir_message[7] & 0xE1) == AC1_FAN_AUTO) { + this->fan_mode = climate::CLIMATE_FAN_AUTO; + } else if ((ir_message[7] & 0xE1) == AC1_FAN1) { + this->fan_mode = climate::CLIMATE_FAN_LOW; + } else if ((ir_message[7] & 0xE1) == AC1_FAN2) { + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + } else if ((ir_message[7] & 0xE1) == AC1_FAN3) { + this->fan_mode = climate::CLIMATE_FAN_HIGH; + } + + // AC Mode + if ((ir_message[9] & 0xE0) == AC1_MODE_COOL) { + this->mode = climate::CLIMATE_MODE_COOL; + } else if ((ir_message[9] & 0xE0) == AC1_MODE_HEAT) { + this->mode = climate::CLIMATE_MODE_HEAT; + } else if ((ir_message[9] & 0xE0) == AC1_MODE_DRY) { + this->mode = climate::CLIMATE_MODE_DRY; + } else if ((ir_message[9] & 0xE0) == AC1_MODE_FAN) { + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + } else { + this->mode = climate::CLIMATE_MODE_AUTO; + } + + // Taregt Temperature + this->target_temperature = (ir_message[9] & 0x1F) + 16.0f; + } + + this->publish_state(); + return true; +} + +} // namespace zhlt01 +} // namespace esphome diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h new file mode 100644 index 000000000000..4413be2835c3 --- /dev/null +++ b/esphome/components/zhlt01/zhlt01.h @@ -0,0 +1,167 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +/*********************************************************************************** + * SOURCE + *********************************************************************************** + * The IR codes and the functional description below were taken from + * 'arduino-heatpumpir/ZHLT01HeatpumpIR.h' as can be found on GitHub + * https://github.com/ToniA/arduino-heatpumpir/blob/master/ZHLT01HeatpumpIR.h + * + ************************************************************************************ + * Airconditional remote control encoder for: + * + * ZH/LT-01 Remote control https://www.google.com/search?q=zh/lt-01 + * + * The ZH/LT-01 remote control is used for many locally branded Split + * airconditioners, so it is better to name this protocol by the name of the + * REMOTE rather then the name of the Airconditioner. For this project I used + * a 2014 model Eurom-airconditioner, which is Dutch-branded and sold in + * the Netherlands at Hornbach. + * + * For airco-brands: + * Eurom + * Chigo + * Tristar + * Tecnomaster + * Elgin + * Geant + * Tekno + * Topair + * Proma + * Sumikura + * JBS + * Turbo Air + * Nakatomy + * Celestial Air + * Ager + * Blueway + * Airlux + * Etc. + * + *********************************************************************************** + * SUMMARY FUNCTIONAL DESCRIPTION + *********************************************************************************** + * The remote sends a 12 Byte message which contains all possible settings every + * time. + * + * Byte 11 (and 10) contain the remote control identifier and are always 0xD5 and + * 0x2A respectively for the ZH/LT-01 remote control. + * Every UNeven Byte (01,03,05,07 and 09) holds command data + * Every EVEN Byte (00,02,04,06,08 and 10) holds a checksum of the corresponding + * command-, or identifier-byte by _inverting_ the bits, for example: + * + * The identifier byte[11] = 0xD5 = B1101 0101 + * The checksum byte[10] = 0x2A = B0010 1010 + * + * So, you can check the message by: + * - inverting the bits of the checksum byte with the corresponding command-, or + * identifier byte, they should be the same, or + * - Summing up the checksum byte and the corresponding command-, or identifier byte, + * they should always add up to 0xFF = B11111111 = 255 + * + * Control bytes: + * [01] - Timer (1-24 hours, Off) + * Time is hardcoded to OFF + * + * [03] - LAMP ON/OFF, TURBO ON/OFF, HOLD ON/OFF + * Lamp and Hold are hardcoded to OFF + * Turbo is used for the BOOST preset + * + * [05] - Indicates which button the user _pressed_ on the remote control + * Hardcoded to POWER-button + * + * [07] - POWER ON/OFF, FAN AUTO/3/2/1, SLEEP ON/OFF, AIRFLOW ON/OFF, + * VERTICAL SWING/WIND/FIXED + * SLEEP is used for preset SLEEP + * Vertical Swing supports Fixed, Swing and "Wind". The Wind option + * is ignored in this implementation + * + * [09] - MODE AUTO/COOL/VENT/DRY/HEAT, TEMPERATURE (16 - 32°C) + * + ***********************************************************************************/ + +namespace esphome { +namespace zhlt01 { + +/******************************************************************************** + * TIMINGS + * Space: Not used + * Header Mark: 6100 us + * Header Space: 7400 us + * Bit Mark: 500 us + * Zero Space: 600 us + * One Space: 1800 us + * + * Note : These timings are slightly different than those of ZHLT01HeatpumpIR + * The values below were measured by taking the average of 2 different + * remote controls each sending 10 commands + *******************************************************************************/ +static const uint32_t AC1_HDR_MARK = 6100; +static const uint32_t AC1_HDR_SPACE = 7400; +static const uint32_t AC1_BIT_MARK = 500; +static const uint32_t AC1_ZERO_SPACE = 600; +static const uint32_t AC1_ONE_SPACE = 1800; + +/******************************************************************************** + * + * ZHLT01 codes + * + *******************************************************************************/ + +// Power +static const uint8_t AC1_POWER_OFF = 0x00; +static const uint8_t AC1_POWER_ON = 0x02; + +// Operating Modes +static const uint8_t AC1_MODE_AUTO = 0x00; +static const uint8_t AC1_MODE_COOL = 0x20; +static const uint8_t AC1_MODE_DRY = 0x40; +static const uint8_t AC1_MODE_FAN = 0x60; +static const uint8_t AC1_MODE_HEAT = 0x80; + +// Fan control +static const uint8_t AC1_FAN_AUTO = 0x00; +static const uint8_t AC1_FAN_SILENT = 0x01; +static const uint8_t AC1_FAN1 = 0x60; +static const uint8_t AC1_FAN2 = 0x40; +static const uint8_t AC1_FAN3 = 0x20; +static const uint8_t AC1_FAN_TURBO = 0x08; + +// Vertical Swing +static const uint8_t AC1_VDIR_WIND = 0x00; // "Natural Wind", ignore +static const uint8_t AC1_VDIR_SWING = 0x04; // Swing +static const uint8_t AC1_VDIR_FIXED = 0x08; // Fixed + +// Horizontal Swing +static const uint8_t AC1_HDIR_SWING = 0x00; // Swing +static const uint8_t AC1_HDIR_FIXED = 0x10; // Fixed + +// Temperature range +static const float AC1_TEMP_MIN = 16.0f; +static const float AC1_TEMP_MAX = 32.0f; +static const float AC1_TEMP_INC = 1.0f; + +class ZHLT01Climate : public climate_ir::ClimateIR { + public: + ZHLT01Climate() + : climate_ir::ClimateIR( + AC1_TEMP_MIN, AC1_TEMP_MAX, AC1_TEMP_INC, 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}, + {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_BOOST}) {} + + void setup() override { climate_ir::ClimateIR::setup(); } + + 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 zhlt01 +} // namespace esphome diff --git a/tests/test1.yaml b/tests/test1.yaml index f40dc3593462..bfed0d5daa45 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -2297,6 +2297,8 @@ climate: heat_mode: extended - platform: whynter name: Whynter + - platform: zhlt01 + name: ZH/LT-01 Climate script: - id: climate_custom