Last active
December 17, 2023 00:55
-
-
Save lyonzy/9afb24d72bf2ea4bd47b8b391bde06cc to your computer and use it in GitHub Desktop.
ESPHome custom Climate for "alternate" Tuya MCUs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
ESPHome custom component for Arlec Grid Connect Smart Panel Heater. | |
This heater contains an MCU that's not the standard "tuya" integration in ESPHome. | |
Probably works for similar panel heaters e.g. Devola, Kogan. | |
Not implemented: | |
- the Wifi icon on the panel (but this would be a simple improvement given the info in the sources below) | |
- the timer, for the same reasoning as Neon Ninja (Home Assistant is more powerful anyway) | |
Doesn't seem to have any issues being booted from the USB-Serial adapter (outside the heater) as mentioned on the Tasmota page, but YMMV. | |
References/special thanks to: | |
https://templates.blakadder.com/devola_designer.html | |
https://neon.ninja/2021/05/winter-is-coming-local-control-for-my-smart-panel-heater/ | |
======================= Usage ======================= | |
esphome: | |
name: ... | |
friendly_name: ... | |
includes: | |
- tuya_mcu_alt/tuya_mcu_alt.h | |
esp8266: | |
board: esp01_1m | |
... | |
uart: | |
rx_pin: GPIO13 | |
tx_pin: GPIO15 | |
baud_rate: 9600 | |
id: mcu_uart | |
select: | |
- platform: template | |
name: "Heat Level" | |
id: heat_level_select | |
options: | |
- "Low" | |
- "High" | |
- "Anti-frost" | |
set_action: | |
then: | |
- lambda: !lambda |- | |
auto a = id(alt_climate); | |
TuyaMCUAltClimate *c = static_cast<TuyaMCUAltClimate *>(a.get_climate(0)); | |
c->set_heat_level(x.c_str()); | |
- platform: template | |
name: "Child lock" | |
id: child_lock_select | |
options: | |
- "On" | |
- "Off" | |
set_action: | |
then: | |
- lambda: !lambda |- | |
auto a = id(alt_climate); | |
TuyaMCUAltClimate *c = static_cast<TuyaMCUAltClimate *>(a.get_climate(0)); | |
c->set_child_lock(x.c_str()); | |
climate: | |
- platform: custom | |
id: alt_climate | |
lambda: |- | |
auto tuya_mcu_alt_climate = new TuyaMCUAltClimate(id(mcu_uart), id(heat_level_select), id(child_lock_select)); | |
App.register_component(tuya_mcu_alt_climate); | |
return {tuya_mcu_alt_climate}; | |
climates: | |
- name: "Heater" | |
visual: | |
min_temperature: 5 | |
max_temperature: 50 | |
temperature_step: 1 | |
sensor: | |
- platform: template | |
id: power | |
name: "Power" | |
lambda: |- | |
auto a = id(alt_climate); | |
TuyaMCUAltClimate *c = static_cast<TuyaMCUAltClimate *>(a.get_climate(0)); | |
if (c->action == climate::CLIMATE_ACTION_HEATING) { | |
if (id(heat_level_select).state == "Low") { | |
return 1100; | |
} else if (id(heat_level_select).state == "High") { | |
return 2200; | |
} else { | |
// Anti-frost not implemented | |
return 0; | |
} | |
} else { | |
return 0; | |
} | |
update_interval: 10s | |
unit_of_measurement: W | |
- platform: total_daily_energy | |
name: "Daily Energy" | |
power_id: power | |
filters: | |
- multiply: 0.001 | |
unit_of_measurement: kWh | |
time: | |
- platform: homeassistant | |
id: homeassistant_time | |
... | |
===================================================== | |
*/ | |
#include "esphome.h" | |
#include "esphome/components/climate/climate.h" | |
#include "esphome/components/uart/uart.h" | |
#include "esphome/components/select/select.h" | |
class TuyaMCUAltClimate : public PollingComponent, public Climate, public UARTDevice { | |
Select *heat_level_select; | |
Select *child_lock_select; | |
public: | |
TuyaMCUAltClimate(UARTComponent *parent, Select *heat_level_select, Select *child_lock_select) : PollingComponent(10000), UARTDevice(parent) { | |
this->heat_level_select = heat_level_select; | |
this->child_lock_select = child_lock_select; | |
} | |
void setup() override { | |
// Get an update from the MCU to set the initial state in Home Assistant. | |
update(); | |
} | |
void update() override { | |
// This will be called every "update_interval" milliseconds (10000 = 10s, see above). | |
// Ask the MCU what the current state is. Response will be handled in loop(). | |
// We need this to run on an interval because for some reason the MCU doesn't tell us when the ambient temperature changes. | |
// Other changes done with the remote or buttons on the heater are relayed from the MCU immediately. | |
ESP_LOGD("tuya_mcu_alt_climate", "Requesting update from MCU."); | |
uint8_t data[] = {0xf1, 0xf1, 0x01, 0x00, 0x01, 0x7e}; | |
this->write_array(data, sizeof(data)); | |
} | |
void control(const ClimateCall &call) override { | |
// Create a base command that essentially should do nothing. | |
uint8_t command[] = {0xF1, 0xF1, 0x02, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x7E}; | |
if (call.get_mode().has_value()) { | |
// User requested mode change | |
ClimateMode mode = *call.get_mode(); | |
// Set the mode in the command. | |
if (mode == climate::CLIMATE_MODE_OFF) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Turning heater off."); | |
command[4] = 0x02; | |
} else if (mode == climate::CLIMATE_MODE_HEAT) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Turning heater on."); | |
command[4] = 0x01; | |
} | |
} | |
if (call.get_target_temperature().has_value()) { | |
// User requested target temperature change | |
float temp = *call.get_target_temperature(); | |
// Set the temperature in the command. | |
command[11] = static_cast<uint8_t>(temp); | |
} | |
uint8_t checksum = command[2] + command[3] + command[4] + command[5] + command[6] + command[7] + command[8] + command[9] + command[10] + command[11] + command[12] + command[13] + command[14] + command[15] + command[16] + command[17] + command[18]; | |
command[19] = checksum; | |
// Send the command to the MCU. | |
this->write_array(command, sizeof(command)); | |
} | |
public: | |
void set_heat_level(const char* value) { | |
// This method is similar to the above one but only does heat level. | |
ESP_LOGD("tuya_mcu_alt_climate", "Received heat level value: %s", value); | |
uint8_t command[] = {0xF1, 0xF1, 0x02, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x7E}; | |
if (strcmp(value, "Low") == 0) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Setting heat level low."); | |
command[10] = 0x02; | |
} else if (strcmp(value, "High") == 0) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Setting heat level high."); | |
command[10] = 0x03; | |
} else if (strcmp(value, "Anti-frost") == 0) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Setting heat level anti-frost."); | |
command[10] = 0x04; | |
} | |
uint8_t checksum = command[2] + command[3] + command[4] + command[5] + command[6] + command[7] + command[8] + command[9] + command[10] + command[11] + command[12] + command[13] + command[14] + command[15] + command[16] + command[17] + command[18]; | |
command[19] = checksum; | |
this->write_array(command, sizeof(command)); | |
// optimistic=true would do this before this method but doing it here is more accurate and fault tolerant | |
this->heat_level_select->publish_state(value); | |
} | |
public: | |
void set_child_lock(const char* value) { | |
// This method is similar to the above one but does child lock. | |
ESP_LOGD("tuya_mcu_alt_climate", "Received child lock value: %s", value); | |
uint8_t command[] = {0xF1, 0xF1, 0x02, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x7E}; | |
if (strcmp(value, "On") == 0) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Setting child lock on."); | |
command[5] = 0x01; | |
} else if (strcmp(value, "Off") == 0) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Setting child lock off."); | |
command[5] = 0x02; | |
} | |
uint8_t checksum = command[2] + command[3] + command[4] + command[5] + command[6] + command[7] + command[8] + command[9] + command[10] + command[11] + command[12] + command[13] + command[14] + command[15] + command[16] + command[17] + command[18]; | |
command[19] = checksum; | |
this->write_array(command, sizeof(command)); | |
// optimistic=true would do this before this method but doing it here is more accurate and fault tolerant | |
this->child_lock_select->publish_state(value); | |
} | |
void loop() override { | |
if (!this->available()) { | |
return; | |
} | |
// Read data from UART bus | |
optional<std::array<uint8_t, 21>> data = this->read_array<21>(); | |
if (!data.has_value()) { | |
return; | |
} | |
ESP_LOGD("tuya_mcu_alt_climate", "Received data: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", | |
(*data)[0], (*data)[1], (*data)[2], (*data)[3], (*data)[4], (*data)[5], (*data)[6], (*data)[7], (*data)[8], (*data)[9], (*data)[10], (*data)[11], (*data)[12], (*data)[13], (*data)[14], (*data)[15], (*data)[16], (*data)[17], (*data)[18], (*data)[19], (*data)[20]); | |
bool assertions_passed = true; | |
if (!(((*data)[0] == 0xf1 && (*data)[1] == 0xf1) || ((*data)[0] == 0xf2 && (*data)[1] == 0xf2))) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Assertion failed - initial 2 bytes."); | |
assertions_passed = false; | |
} | |
if (!((*data)[2] == 0x02 && (*data)[3] == 0x10)) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Assertion failed - first padding."); | |
assertions_passed = false; | |
} | |
if (!((*data)[15] == 0x01 && (*data)[16] == 0x00 && (*data)[17] == 0x00 && (*data)[18] == 0x01)) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Assertion failed - second padding."); | |
assertions_passed = false; | |
} | |
if ((*data)[20] != 0x7e) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Assertion failed - terminator."); | |
assertions_passed = false; | |
} | |
if (!assertions_passed) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Data didn't pass assertions. This data will be ignored."); | |
return; | |
} | |
ESP_LOGD("tuya_mcu_alt_climate", "Data passed assertions."); | |
uint8_t power_state = (*data)[4]; | |
if (power_state == 0x02) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Heater turned off."); | |
this->mode = climate::CLIMATE_MODE_OFF; | |
} else if (power_state == 0x01) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Heater turned on."); | |
this->mode = climate::CLIMATE_MODE_HEAT; | |
} else { | |
ESP_LOGD("tuya_mcu_alt_climate", "Unknown power state: %02X", power_state); | |
} | |
uint8_t target_temperature = (*data)[11]; | |
if (target_temperature != 0x00) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Target temp set to %02d.", target_temperature); | |
this->target_temperature = target_temperature; | |
} | |
uint8_t current_temperature = (*data)[14]; | |
if (current_temperature != 0x00) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Current temp is %02d.", current_temperature); | |
this->current_temperature = current_temperature; | |
} | |
if (target_temperature != 0x00 && current_temperature != 0x00 && power_state == 0x01) { | |
// this seems to be the algorithm based on observations | |
if (this->action == climate::CLIMATE_ACTION_HEATING) { | |
if (current_temperature <= target_temperature + 1) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Inferred climate action HEATING (was HEATING)."); | |
this->action = climate::CLIMATE_ACTION_HEATING; | |
} else { | |
ESP_LOGD("tuya_mcu_alt_climate", "Inferred climate action IDLE (was HEATING)."); | |
this->action = climate::CLIMATE_ACTION_IDLE; | |
} | |
} else { | |
// action is either IDLE or off/null | |
if (current_temperature <= target_temperature) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Inferred climate action HEATING (was IDLE)."); | |
this->action = climate::CLIMATE_ACTION_HEATING; | |
} else { | |
ESP_LOGD("tuya_mcu_alt_climate", "Inferred climate action IDLE (was IDLE)."); | |
this->action = climate::CLIMATE_ACTION_IDLE; | |
} | |
} | |
} else { | |
ESP_LOGD("tuya_mcu_alt_climate", "Inferred climate action OFF."); | |
this->action = climate::CLIMATE_ACTION_OFF; | |
} | |
uint8_t heat_level = (*data)[10]; | |
if (heat_level == 0x02) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Received heat level low."); | |
this->heat_level_select->publish_state("Low"); | |
} else if (heat_level == 0x03) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Received heat level high."); | |
this->heat_level_select->publish_state("High"); | |
} else if (heat_level == 0x04) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Received heat level anti-frost."); | |
this->heat_level_select->publish_state("Anti-frost"); | |
} | |
uint8_t child_lock = (*data)[5]; | |
if (child_lock == 0x01) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Received child lock on."); | |
this->child_lock_select->publish_state("On"); | |
} else if (child_lock == 0x02) { | |
ESP_LOGD("tuya_mcu_alt_climate", "Received child lock off."); | |
this->child_lock_select->publish_state("Off"); | |
} | |
this->publish_state(); | |
} | |
ClimateTraits traits() override { | |
auto traits = climate::ClimateTraits(); | |
traits.set_supports_current_temperature(true); | |
traits.set_supports_action(true); | |
traits.set_supported_modes({climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_OFF}); | |
return traits; | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment