Climate_ir_lg for LG S09EQR AC (Remote: AKB74955603)

Just wanted to share my excitement — I finally got the climate_ir_lg platform working with my LG S09EQR air conditioner!

Even better — it now receives signals from the original LG remote via an ESP32 module.
However, this required manually adjusting the timing parameters, since the duration of the first pulse and pause are very different from what the plugin originally expects.

Here’s my working .yaml config:

esphome:
  name: esp32-lg-ir
  friendly_name: ESP32 LG IR

esp32:
  board: esp32dev
  framework:
    type: arduino

wifi:
  ssid: "ssid_name"
  password: "your_password"
  manual_ip:
    static_ip: x.x.x.x 
    gateway: x.x.x.x
    subnet: 255.255.255.0
  ap:
    ssid: "ESP32_Fallback"
    password: "87654321"

captive_portal:

ota:
  platform: esphome
  password: ""

api:
  encryption:
    key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

logger:
  level: DEBUG

external_components:
  - source: custom_components

remote_transmitter:
  id: ir_sender
  pin: GPIO14
  carrier_duty_percent: 50%

remote_receiver:
  id: ir_receiver
  pin:
    number: GPIO33
    mode: INPUT
    inverted: true
  dump: all
  buffer_size: 2kb
  idle: 25ms
  tolerance: 50%

climate:
  - platform: climate_ir_lg
    header_high: 3100us   # LG remote pulse (in microseconds)
    header_low: 9900us    # LG remote pause
    bit_high: 470us       # Bit high duration
    bit_one_low: 1620us   # Bit 1 low duration
    bit_zero_low: 570us 
    id: green_room_ac
    name: "LG Air Conditioner"
    transmitter_id: ir_sender
    receiver_id: ir_receiver
    visual:
      min_temperature: 18
      max_temperature: 30
      temperature_step: 1

Hope this helps someone who’s struggling with a similar issue.
The key was adjusting the header_high and header_low values to match the real-world remote behavior.

1 Like

Today I ran into a problem while working with my ESPHome IR setup.

If the IR receiver is not optically isolated from the IR transmitter, and I send a command (for example, a swing flap command) from ESP to my LG AC, the command is executed by the air conditioner correctly, but Home Assistant interface behaves oddly:

  • The UI reacts to the command,
  • But then quickly reverts back to the previous state.

If I physically block the IR receiver from seeing the transmitter (e.g. by shielding or isolating them), everything works perfectly: Home Assistant receives the correct state update and displays it correctly.

So the issue seems to be caused by the receiver picking up its own transmitted IR signal. The received signal confuses the state machine, and the UI rolls back the state.

Is there a way to programmatically disable IR receiving while ESPHome is transmitting the IR signal?
For example: disable remote_receiver before transmission and enable it right after.

For this air conditioner model, I had to modify climate_ir_lg.cpp.
I added a block in the code to ignore any actions for LG remote control commands from the button that controls the air conditioner’s shutter.
Otherwise, these commands are processed incorrectly and cause the Home Assistant interface to set the temperature to 15-16 degrees °C.

bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) {
  uint8_t nbits = 0;
  uint32_t remote_state = 0;

  if (!data.expect_item(this->header_high_, this->header_low_))
    return false;

  for (nbits = 0; nbits < 32; nbits++) {
    if (data.expect_item(this->bit_high_, this->bit_one_low_)) {
      remote_state = (remote_state << 1) | 1;
    } else if (data.expect_item(this->bit_high_, this->bit_zero_low_)) {
      remote_state = (remote_state << 1) | 0;
    } else if (nbits == BITS) {
      break;
    } else {
      return false;
    }
  }

  ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state);
  if ((remote_state & 0xFF00000) != 0x8800000)
    return false;

  // Adding a check for commands 0x13000 and 0x13100 — these are incorrectly handled shutter control commands
  uint32_t command = remote_state & COMMAND_MASK;
  if (command == 0x13000 || command == 0x13100) {
    ESP_LOGD(TAG, "Received ignored command 0x%02" PRIX32, command);
    return false;  // Just stop processing, do nothing
  }

Damper Control Commands:

1000100000010011000101011010  
0x881315A

1000100000010011000001001000  
0x8813048

1000100000010011000001011001  
0x8813059

1000100000010011000001101010  
0x881306A

1000100000010011000001111011  
0x881307B

1000100000010011000010001100  
0x881308C

1000100000010011000010011101  
0x881309D

1000100000010011000101001001  
0x8813149

On this specific LG remote, the swing function shares the same button with the louver position selection. After cycling through all fixed louver positions, the button switches the unit to swing mode. This behavior sends the same IR command every time the swing function is toggled.

In my case, it was necessary to add command == 0x10000 to the list of ignored commands in the following block:

if (command == 0x13000 || command == 0x13100 || command == 0x10000) {
    ESP_LOGD(TAG, "Received ignored command 0x%02" PRIX32, command);
    return false;  // Skip processing
}

Without ignoring this command, the IR receiver would capture it and immediately revert the Home Assistant UI state back to the previous one, creating inconsistency between the physical unit and the HA interface.

Since the swing mode on this remote is tied to the same button as the louver position, there is no real need to track this command separately. If anyone needs to implement manual louver position control in the Home Assistant UI, it can easily be done via .yaml configuration.

Here is the modified climate_ir_lg.cpp file for the LG S09EQR air conditioner:

#include "climate_ir_lg.h"
#include "esphome/core/log.h"

namespace esphome {
namespace climate_ir_lg {

static const char *const TAG = "climate.climate_ir_lg";

// Commands
const uint32_t COMMAND_MASK = 0xFF000;
const uint32_t COMMAND_OFF = 0xC0000;
const uint32_t COMMAND_SWING = 0x13000;

const uint32_t COMMAND_ON_COOL = 0x00000;
const uint32_t COMMAND_ON_DRY = 0x01000;
const uint32_t COMMAND_ON_FAN_ONLY = 0x02000;
const uint32_t COMMAND_ON_AI = 0x03000;
const uint32_t COMMAND_ON_HEAT = 0x04000;

const uint32_t COMMAND_COOL = 0x08000;
const uint32_t COMMAND_DRY = 0x09000;
const uint32_t COMMAND_FAN_ONLY = 0x0A000;
const uint32_t COMMAND_AI = 0x0B000;
const uint32_t COMMAND_HEAT = 0x0C000;

// Fan speed
const uint32_t FAN_MASK = 0xF0;
const uint32_t FAN_AUTO = 0x50;
const uint32_t FAN_MIN = 0x00;
const uint32_t FAN_MED = 0x20;
const uint32_t FAN_MAX = 0x40;

// Temperature
const uint8_t TEMP_RANGE = TEMP_MAX - TEMP_MIN + 1;
const uint32_t TEMP_MASK = 0xF00;
const uint32_t TEMP_SHIFT = 8;

const uint16_t BITS = 28;

void LgIrClimate::transmit_state() {
  uint32_t remote_state = 0x8800000;

  // ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", modeBefore_);

  // Set command
  if (this->send_swing_cmd_) {
    this->send_swing_cmd_ = false;
    remote_state |= COMMAND_SWING;
  } else {
    bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
    switch (this->mode) {
      case climate::CLIMATE_MODE_COOL:
        remote_state |= climate_is_off ? COMMAND_ON_COOL : COMMAND_COOL;
        break;
      case climate::CLIMATE_MODE_DRY:
        remote_state |= climate_is_off ? COMMAND_ON_DRY : COMMAND_DRY;
        break;
      case climate::CLIMATE_MODE_FAN_ONLY:
        remote_state |= climate_is_off ? COMMAND_ON_FAN_ONLY : COMMAND_FAN_ONLY;
        break;
      case climate::CLIMATE_MODE_HEAT_COOL:
        remote_state |= climate_is_off ? COMMAND_ON_AI : COMMAND_AI;
        break;
      case climate::CLIMATE_MODE_HEAT:
        remote_state |= climate_is_off ? COMMAND_ON_HEAT : COMMAND_HEAT;
        break;
      case climate::CLIMATE_MODE_OFF:
      default:
        remote_state |= COMMAND_OFF;
        break;
    }
  }

  this->mode_before_ = this->mode;

  ESP_LOGD(TAG, "climate_lg_ir mode code: 0x%02X", this->mode);

  // Set fan speed
  if (this->mode == climate::CLIMATE_MODE_OFF) {
    remote_state |= FAN_AUTO;
  } else {
    switch (this->fan_mode.value()) {
      case climate::CLIMATE_FAN_HIGH:
        remote_state |= FAN_MAX;
        break;
      case climate::CLIMATE_FAN_MEDIUM:
        remote_state |= FAN_MED;
        break;
      case climate::CLIMATE_FAN_LOW:
        remote_state |= FAN_MIN;
        break;
      case climate::CLIMATE_FAN_AUTO:
      default:
        remote_state |= FAN_AUTO;
        break;
    }
  }

  // Set temperature
  if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT || this->mode == climate::CLIMATE_MODE_HEAT_COOL) {
    auto temp = (uint8_t) roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX));
    remote_state |= ((temp - 15) << TEMP_SHIFT);
  }

  this->transmit_(remote_state);
  this->publish_state();
}

bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) {
  uint8_t nbits = 0;
  uint32_t remote_state = 0;

  if (!data.expect_item(this->header_high_, this->header_low_))
    return false;

  for (nbits = 0; nbits < 32; nbits++) {
    if (data.expect_item(this->bit_high_, this->bit_one_low_)) {
      remote_state = (remote_state << 1) | 1;
    } else if (data.expect_item(this->bit_high_, this->bit_zero_low_)) {
      remote_state = (remote_state << 1) | 0;
    } else if (nbits == BITS) {
      break;
    } else {
      return false;
    }
  }

  ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state);
  if ((remote_state & 0xFF00000) != 0x8800000)
    return false;


  // Get command
  if ((remote_state & COMMAND_MASK) == COMMAND_OFF) {
    this->mode = climate::CLIMATE_MODE_OFF;
  } else if ((remote_state & COMMAND_MASK) == COMMAND_SWING) {
    this->swing_mode =
        this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
  } else {
    switch (remote_state & COMMAND_MASK) {
      case COMMAND_DRY:
      case COMMAND_ON_DRY:
        this->mode = climate::CLIMATE_MODE_DRY;
        break;
      case COMMAND_FAN_ONLY:
      case COMMAND_ON_FAN_ONLY:
        this->mode = climate::CLIMATE_MODE_FAN_ONLY;
        break;
      case COMMAND_AI:
      case COMMAND_ON_AI:
        this->mode = climate::CLIMATE_MODE_HEAT_COOL;
        break;
      case COMMAND_HEAT:
      case COMMAND_ON_HEAT:
        this->mode = climate::CLIMATE_MODE_HEAT;
        break;
      case COMMAND_COOL:
      case COMMAND_ON_COOL:
      default:
        this->mode = climate::CLIMATE_MODE_COOL;
        break;
    }

    // Get fan speed
      if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_DRY ||
               this->mode == climate::CLIMATE_MODE_FAN_ONLY || this->mode == climate::CLIMATE_MODE_HEAT ||
			   this->mode == climate::CLIMATE_MODE_HEAT_COOL) {
      if ((remote_state & FAN_MASK) == FAN_AUTO) {
        this->fan_mode = climate::CLIMATE_FAN_AUTO;
      } else if ((remote_state & FAN_MASK) == FAN_MIN) {
        this->fan_mode = climate::CLIMATE_FAN_LOW;
      } else if ((remote_state & FAN_MASK) == FAN_MED) {
        this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
      } else if ((remote_state & FAN_MASK) == FAN_MAX) {
        this->fan_mode = climate::CLIMATE_FAN_HIGH;
      }
    }

    // Get temperature
    if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT || this->mode == climate::CLIMATE_MODE_HEAT_COOL) {
      this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
    }
  }
  this->publish_state();

  return true;
}

void LgIrClimate::transmit_(uint32_t value) {
  this->calc_checksum_(value);
  ESP_LOGD(TAG, "Sending climate_lg_ir code: 0x%02" PRIX32, value);

  auto transmit = this->transmitter_->transmit();
  auto *data = transmit.get_data();

  data->set_carrier_frequency(38000);
  data->reserve(2 + BITS * 2u);

  data->item(this->header_high_, this->header_low_);

  for (uint32_t mask = 1UL << (BITS - 1); mask != 0; mask >>= 1) {
    if (value & mask) {
      data->item(this->bit_high_, this->bit_one_low_);
    } else {
      data->item(this->bit_high_, this->bit_zero_low_);
    }
  }
  data->mark(this->bit_high_);
  transmit.perform();
}
void LgIrClimate::calc_checksum_(uint32_t &value) {
  uint32_t mask = 0xF;
  uint32_t sum = 0;
  for (uint8_t i = 1; i < 8; i++) {
    sum += (value & (mask << (i * 4))) >> (i * 4);
  }

  value |= (sum & mask);
}

}  // namespace climate_ir_lg
}  // namespace esphome

The .yaml file (replace values where necessary with your own):

esphome:
  name: esp32-lg-ir
  friendly_name: ESP32 LG IR

esp32:
  board: esp32dev
  framework:
    type: arduino

wifi:
  ssid: "xxxxxxxxxxxxx"
  password: "xxxxxxxxxxxx"
  manual_ip:
    static_ip: 192.168.x.x
    gateway: 192.168.x.1
    subnet: 255.255.255.0
  ap:
    ssid: "ESP32_Fallback"
    password: "12345678"

captive_portal:

ota:
  platform: esphome
  password: ""

api:
  encryption:
    key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

logger:
  level: DEBUG

external_components:
  - source: custom_components

remote_transmitter:
  id: ir_sender
  pin: GPIO2
  carrier_duty_percent: 50%

remote_receiver:
  id: ir_receiver
  pin:
    number: GPIO13
    mode: INPUT
    inverted: true
  dump:
    - raw
    - lg
  buffer_size: 2kb
  idle: 25ms
  tolerance: 50%

climate:
  - platform: climate_ir_lg
    header_high: 3350us   
    header_low: 9850us    
    bit_high: 527us       
    bit_one_low: 1546us   
    bit_zero_low: 530us 
    id: green_room_ac
    name: "LG Bed Room"
    transmitter_id: ir_sender
    receiver_id: ir_receiver
    visual:
      min_temperature: 15
      max_temperature: 30
      temperature_step: 1      
      
button:
  - platform: template
    name: "Send Power kwht"
    id: send_power_kwht
    on_press:
      - remote_transmitter.transmit_raw:
          code: [3238, -9954, 407, -1650, 446, -619, 409, -626, 408, -626, 408, -1668, 407, -627, 597, -605, 406, -626, 409, -1659, 409, -1675, 409, -640, 409, -626, 408, -626, 407, -627, 407, -629, 406, -641, 409, -626, 407, -1660, 408, -626, 409, -626, 407, -626, 409, -1659, 650, -1634, 407, -627, 407, -642, 408, -1668, 408, -1660, 408, -633, 408]
          carrier_frequency: 38000Hz
          
  - platform: template
    name: "Send Self Diagnosis"
    id: send_selfdiagnosis
    on_press:
      - remote_transmitter.transmit_raw:
          code: [3292, -9890, 401, -1687, 402, -640, 401, -633, 402, -632, 402, -1666, 403, -647, 401, -633, 402, -640, 400, -1676, 401, -1674, 402, -640, 402, -632, 402, -633, 400, -633, 401, -641, 401, -641, 400, -1668, 402, -1674, 400, -633, 402, -633, 401, -1674, 401, -1668, 401, -1682, 401, -632, 403, -632, 401, -1675, 401, -1675, 402, -621, 442]
          carrier_frequency: 38000Hz
          
  - platform: template
    name: "Send Swing On"
    id: send_swing_on
    on_press:
      - remote_transmitter.transmit_raw:
          code: [3194, -9848, 547, -1547, 527, -507, 527, -507, 527, -506, 528, -1548, 526, -516, 527, -516, 526, -516, 526, -507, 527, -507, 527, -507, 526, -1550, 525, -517, 526, -508, 524, -1558, 527, -1566, 524, -526, 525, -525, 525, -508, 526, -1550, 527, -507, 526, -1542, 525, -509, 526, -508, 526, -1543, 525, -516, 526, -515, 526, -1543, 526]
          carrier_frequency: 38000Hz
          
  - platform: template
    name: "Send Swing Off"
    id: send_swing_off
    on_press:
      - remote_transmitter.transmit_raw:
          code: [3186, -9837, 554, -1543, 526, -516, 556, -495, 557, -491, 526, -1550, 526, -509, 526, -508, 526, -508, 526, -516, 531, -518, 526, -516, 532, -1550, 526, -517, 525, -509, 526, -1558, 525, -1551, 526, -516, 526, -507, 526, -508, 526, -508, 526, -1551, 526, -516, 530, -520, 526, -1547, 528, -1541, 526, -1542, 526, -508, 527, -1549, 527]
          carrier_frequency: 38000Hz