mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00:00 
			
		
		
		
	Add ZH/LT-01 climate component with IR receiver option (#4333)
Co-authored-by: Chris Feenstra <chris@cfeenstra.nl> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										0
									
								
								esphome/components/zhlt01/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								esphome/components/zhlt01/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										19
									
								
								esphome/components/zhlt01/climate.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								esphome/components/zhlt01/climate.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										238
									
								
								esphome/components/zhlt01/zhlt01.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								esphome/components/zhlt01/zhlt01.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										167
									
								
								esphome/components/zhlt01/zhlt01.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								esphome/components/zhlt01/zhlt01.h
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -2297,6 +2297,8 @@ climate: | ||||
|     heat_mode: extended | ||||
|   - platform: whynter | ||||
|     name: Whynter | ||||
|   - platform: zhlt01 | ||||
|     name: ZH/LT-01 Climate | ||||
|  | ||||
| script: | ||||
|   - id: climate_custom | ||||
|   | ||||
		Reference in New Issue
	
	Block a user