Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ Features currently available in Home Assistant:
* Switch and binary sensor for Auto Dry (also known as Auto Clean) feature. Used to dry indoor unit when it's turned off after cooling/dehumidifying.
* Sensors for reporting outdoor unit on/off, defrost, preheat, error code.
* Sensors for reporting in/mid/out pipe temperatures (if supported by unit).
* Optional passive sensors for total energy and current power, if the unit emits `0xCC`
or `0xCF` frames on the wired-controller bus.
* Input field for sleep timer from 0 to 420 minutes (0 turns off the sleep timer).
* Input fields for fan speed installer setting (to fine-tune fan speeds, 0-255 with 0 being factory default). This is installer setting 3 (ESP Setting) on LG controllers.
* Select option for over heating installer setting from 0-4 (to change over heating behavior in heating mode). This is installer setting 15 (Over Heating) on LG controllers.
Expand All @@ -118,6 +120,10 @@ The LG ThinQ app and wireless remote can still be used to change these settings

Unfortunately not all settings are exposed to the wired controller, but if you're interested in a feature and it's supported by the PREMTB100 or PREMTA200 controller, please open an issue and we can consider adding it.

The optional energy/power sensors only publish values for units that send the relevant wired-bus
frames. Some LG units expose energy data through ThinQ/WiFi but do not send `0xCC` or `0xCF`
frames to the wired controller; for those units these sensors will not update.

# Tips
* [Issue #43](https://github.com/JanM321/esphome-lg-controller/issues/43) has some information on temperature sensors that work well for this.
* It's possible to use a Home Assistant template sensor as room temperature sensor. I'm [using this](https://gist.github.com/JanM321/b550285713f20231386509b2c227f0b8) to work around some issues with my LG Multi F unit in heating mode.
Expand Down
35 changes: 34 additions & 1 deletion esphome/components/lg_controller/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
import esphome.config_validation as cv
from esphome import pins
from esphome.components import binary_sensor, climate, number, select, sensor, switch, uart
from esphome.const import CONF_ID, CONF_RX_PIN, CONF_INTERNAL
from esphome.const import (
CONF_ID,
CONF_RX_PIN,
CONF_INTERNAL,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
)

CODEOWNERS = ["JanM321"]
DEPENDENCIES = ["uart"]
Expand Down Expand Up @@ -37,6 +47,8 @@
CONF_PIPE_TEMP_IN = "pipe_temp_in"
CONF_PIPE_TEMP_MID = "pipe_temp_mid"
CONF_PIPE_TEMP_OUT = "pipe_temp_out"
CONF_POWER_CONSUMPTION = "power_consumption"
CONF_CURRENT_POWER = "current_power"

CONF_DEFROST = "defrost"
CONF_PREHEAT = "preheat"
Expand Down Expand Up @@ -75,6 +87,18 @@
cv.Required(CONF_PIPE_TEMP_IN): sensor.sensor_schema(),
cv.Required(CONF_PIPE_TEMP_MID): sensor.sensor_schema(),
cv.Required(CONF_PIPE_TEMP_OUT): sensor.sensor_schema(),
cv.Optional(CONF_POWER_CONSUMPTION): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_CURRENT_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The protocol reports this in 3 decimal accuracy, so better to set it to 3 decimals.

device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),

cv.Required(CONF_DEFROST): binary_sensor.binary_sensor_schema(),
cv.Required(CONF_PREHEAT): binary_sensor.binary_sensor_schema(),
Expand Down Expand Up @@ -120,6 +144,14 @@ async def to_code(config):
pipe_temp_in = await sensor.new_sensor(config[CONF_PIPE_TEMP_IN])
pipe_temp_mid = await sensor.new_sensor(config[CONF_PIPE_TEMP_MID])
pipe_temp_out = await sensor.new_sensor(config[CONF_PIPE_TEMP_OUT])
if CONF_POWER_CONSUMPTION in config:
power_consumption = await sensor.new_sensor(config[CONF_POWER_CONSUMPTION])
else:
power_consumption = cg.nullptr
if CONF_CURRENT_POWER in config:
current_power = await sensor.new_sensor(config[CONF_CURRENT_POWER])
else:
current_power = cg.nullptr

defrost = await binary_sensor.new_binary_sensor(config[CONF_DEFROST])
preheat = await binary_sensor.new_binary_sensor(config[CONF_PREHEAT])
Expand All @@ -135,6 +167,7 @@ async def to_code(config):
fan_speed_slow, fan_speed_low, fan_speed_medium, fan_speed_high,
sleep_timer,
error_code, pipe_temp_in, pipe_temp_mid, pipe_temp_out,
power_consumption, current_power,
defrost, preheat, outdoor, auto_dry_active,
purifier, internal_thermistor, auto_dry,
config[CONF_FAHRENHEIT], config[CONF_IS_SLAVE_CONTROLLER])
Expand Down
40 changes: 40 additions & 0 deletions esphome/components/lg_controller/lg-controller.h
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ class LgController final : public climate::Climate, public uart::UARTDevice, pub
esphome::sensor::Sensor& pipe_temp_in_;
esphome::sensor::Sensor& pipe_temp_mid_;
esphome::sensor::Sensor& pipe_temp_out_;
esphome::sensor::Sensor* power_consumption_;
esphome::sensor::Sensor* current_power_;
esphome::binary_sensor::BinarySensor& defrost_;
esphome::binary_sensor::BinarySensor& preheat_;
esphome::binary_sensor::BinarySensor& outdoor_;
Expand Down Expand Up @@ -347,6 +349,8 @@ class LgController final : public climate::Climate, public uart::UARTDevice, pub
sensor::Sensor* pipe_temp_in,
sensor::Sensor* pipe_temp_mid,
sensor::Sensor* pipe_temp_out,
sensor::Sensor* power_consumption,
sensor::Sensor* current_power,
binary_sensor::BinarySensor* defrost,
binary_sensor::BinarySensor* preheat,
binary_sensor::BinarySensor* outdoor,
Expand All @@ -371,6 +375,8 @@ class LgController final : public climate::Climate, public uart::UARTDevice, pub
pipe_temp_in_(*pipe_temp_in),
pipe_temp_mid_(*pipe_temp_mid),
pipe_temp_out_(*pipe_temp_out),
power_consumption_(power_consumption),
current_power_(current_power),
defrost_(*defrost),
preheat_(*preheat),
outdoor_(*outdoor),
Expand Down Expand Up @@ -935,6 +941,12 @@ class LgController final : public climate::Climate, public uart::UARTDevice, pub
case 3: // 0xCB/AB/2B
process_type_b_settings_message(*sender, buffer);
break;
case 4: // 0xCC/AC/2C
process_type_c_status_message(*sender, buffer);
break;
case 7: // 0xCF/AF/2F
process_type_f_status_message(*sender, buffer);
break;
default:
return;
}
Expand Down Expand Up @@ -1273,6 +1285,34 @@ class LgController final : public climate::Climate, public uart::UARTDevice, pub
}
}

static float decode_power_nibbles_(const uint8_t* buffer, size_t offset, size_t length, float divisor) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: the trailing underscore does not align with other naming conventions in this file.

// Power payloads use LG's nibble format rather than binary integers. Some documented
// current-power examples contain A-F nibbles, so this intentionally is not strict BCD.
uint32_t result = 0;
for (size_t i = 0; i < length; i++) {
result = result * 100 + ((buffer[offset + i] >> 4) & 0xF) * 10 + (buffer[offset + i] & 0xF);
}
return result / divisor;
}
Comment on lines +1288 to +1296

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I'm not quite sure if this is actually how these numbers should be interpreted. The current implementation leads to a power consumption sensor that rolls back every now and then on my units, whenever the trailing nibble goes from F to 0. It might also happen for the other nibbles but I haven't seen those roll over from F to 0 yet.

It surely seems like the numbers in the protocol act like normal hexidecimal numbers, i.e. 00.02.DF becomes 00.02.E0 when 1 is added. However, interpreting it as a normal hexadecimal number gives very different results from what is reported in protocol.md.

Here's the moment it rolled over from 33.5 to 33.0, which probably corresponded with 00.02.CF to 00.02.D0. Didn't catch it live in the logs yet, but I'm trying to see it happen there as well.
Image

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option is that the numbers should still be interpreted this way, but the implementation of it by LG in my units is flawed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just discovered that interpreting the reported current power as hexadecimal gives me the exact same numbers as ThinQ reports (in watts), so at least for current power on my units that seems to be the way. ThinQ shows at what moment it retrieved the power use, so I can align it exactly with what was logged. Example:

At the moment that ThinQ retrieved a power use of 0.55kW, my logs show this message: CF.02.00.02.29.00.04.52.00.00.00.00.07. Hex 229 translates to 553 decimal.

That still leaves the question if that's also the case for total power usage. Interpretating 00.02.EE as hex gives 750, and I have no idea how to translate that to the 61.6 kWh ThinQ reports. I'll keep tabs on different values over time to see if I can find some logic there.

@joeblack2k joeblack2k Aug 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the encoding can now be determined with fairly high confidence. Both values appear to be normal unsigned 24-bit big-endian binary integers, rather than LG’s nibble-decimal format.

For current power:

00 02 29 = 0x0229 = 553 W, which matches ThinQ.

For accumulated energy, the counter probably has a resolution of 0.1 kWh:

00 02 EE = 0x02EE = 750 → 75.0 kWh

This also explains and fixes the observed rollback:

  • 00 02 CF = 0x02CF = 719 → 71.9 kWh
  • 00 02 D0 = 0x02D0 = 720 → 72.0 kWh

With the current nibble-based decoder, those values incorrectly become 33.5 and 33.0 kWh.

The difference between the resulting 75.0 kWh and ThinQ’s 61.6 kWh is therefore probably not an encoding issue. It looks more like a difference in scope or reset point.

Since these are multisplit indoor units, the CC value may represent the total consumption of the complete system, while ThinQ allocates consumption to each indoor unit separately. It would be useful to check whether the ThinQ consumption of the other indoor unit or units adds up to 13.4 kWh:

61.6 + 13.4 = 75.0 kWh

Alternatively, ThinQ may show consumption since a monthly/app reset, while the wired-bus counter uses a different starting point.

There is another interesting clue in the same CF frame:

CF.02.00.02.29.00.04.52.00.00.00.00.07

  • 00 02 29 = 0x0229 = 553 W
  • 00 04 52 = 0x0452 = 1106 W

1106 W is exactly twice 553 W. This may be a second system-wide power field, while the first field is allocated to the individual indoor unit, although more captures are needed to confirm that.

My suggested decoding would therefore be:

  • CF, bytes 2–4: unsigned 24-bit big-endian value in watts
  • CC, bytes 3–5: unsigned 24-bit big-endian value in 0.1 kWh

It would be useful to monitor the deltas as well. For example, 00 02 EE → 00 02 EF should mean 75.0 → 75.1 kWh, and 00 02 FF → 00 03 00 should mean 76.7 → 76.8 kWh.

@WDeenik WDeenik Aug 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No the other unit has a total power consumption of 28.4kWh in ThinQ, so I don't think it's a total for the whole system. For that unit the message has a value of 00.01.66, which translates to 16.6kWh using the method of this PR, and 358 when interpreting it as a hex integer.

What did cross my mind was that this power consumption counter might be non-zero out of the factory because of unknown reasons, and it's just a different offset. I'm planning to collect some more values over time and see if it's a constant offset or not.

@WDeenik WDeenik Aug 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have checked the values again, but there is no stable offset that makes the data I get from the unit itself match ThinQ's data.

My own conclusion is that the power consumption data is wildly inaccurate, both reported by the unit and by ThinQ. One of my units was on intermittently during the night yesterday. I checked it's power consumption through four ways:

  1. As reported by the unit itself
  2. Through ThinQ
  3. Using an integral sensor that uses the current power reported by the unit as input
  4. Subtracting a normal night's power usage of my whole home from yesterday night's power usage, as the only difference was the AC being on every now and then.

The results:

  1. 0.3kWh (regardless of which method was used to interpret the data, it went from 00 03 30 to 00 03 33)
  2. 0.0kWh
  3. 0.6kWh
  4. 0.63kWh

This shows that setting up an integral helper sensor on the current power sensor of the AC unit is very accurate, as it matches the increase in power consumption that the power meter of my house shows. I don't see any reason to use any data reported by LG, it's so far off that there's no point using it in my opinion.


void process_type_c_status_message(MessageSender sender, const uint8_t* buffer) {
if (sender != MessageSender::Unit) {
return;
}
if (power_consumption_ != nullptr) {
power_consumption_->publish_state(decode_power_nibbles_(buffer, 3, 3, 10.0f));
}
}

void process_type_f_status_message(MessageSender sender, const uint8_t* buffer) {
if (sender != MessageSender::Unit) {
return;
}
if (current_power_ != nullptr) {
current_power_->publish_state(decode_power_nibbles_(buffer, 2, 3, 1000.0f));
}
}

void update() {
ESP_LOGD(TAG, "update");

Expand Down