Skip to content

Instantly share code, notes, and snippets.

@lyonzy
Last active December 17, 2023 00:55
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lyonzy/9afb24d72bf2ea4bd47b8b391bde06cc to your computer and use it in GitHub Desktop.
Save lyonzy/9afb24d72bf2ea4bd47b8b391bde06cc to your computer and use it in GitHub Desktop.
ESPHome custom Climate for "alternate" Tuya MCUs
/*
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