Tuya Curtain Motor custom component with set position and realtime feedback

So the Tuya integration support for the curtain motor is lack of position support, so a decided to make a custom component with set position and actual feedback from tuya mcu protocol.

Here is what i have so far: https://youtu.be/OGtZhjLm-no

Here is the source code: https://github.com/iphong/esphome-tuya-curtain

3 Likes

Thanks for sharing!!
It seems that you’re running it on a motorized curtain, right?
I’ve ordered a Tuya switch for my motorized (dumb) blind. Do you think it will work?
I’ll keep track of your project!!

The motor is wifi or zigbee?

Its wifi + rf. I flashed the esp8266 and and have it talk to the Tuya MCU to control and receive feedback.

Is this compatible with the Zemismart Tuya Curtain motor like this one, the one with the Tuya “USB” UART interface?

The internal MCU should expose RX and TX pins for serial communication. Just use an external serial adapter and watch the data in and out the esp8266 serial pins. If you see something sequence starts with “0x55 0xaa”, its tuya protocol and it definitely works!!

Let me know how it goes…

cheers!

Hi, I would then have to solder straight to the ESP, and that’s not for me :wink:

I have set it up like this:

substitutions:
  devicename: zs_curtain_01
  upper_devicename: Zemismart Curtain controller - 01

esphome:
  name: $devicename
  platform: ESP8266
  board: esp01_1m
  includes:
    - zs_curtain_01/curtain.h
  
wifi:
  ssid: !secret esphome_wifi_ssid
  password: !secret esphome_wifi_password
  # enable the fallback hotspot
  ap:
    ssid: $devicename
    password: "qwe12345"

captive_portal:
  
api:
  password: !secret esphome_api_password

ota:
  password: !secret esphome_ota_password
  
web_server:
  port: 80
  
logger:
  baud_rate: 0

uart:
  - id: com_1
    tx_pin: GPIO15
    rx_pin: GPIO13
    baud_rate: 9600

globals:
  - id: cover_open
    type: bool
    restore_value: yes
    initial_value: "false"
  - id: cover_reversed
    type: bool
    restore_value: yes
    initial_value: "false"
  - id: cover_position
    type: float
    restore_value: yes
    initial_value: "0"

cover:
  - platform: custom
    lambda: |-
      auto curtain = new CustomCurtain();
      App.register_component(curtain);
      return {curtain};
    covers:
      - name: Curtain
        device_class: blind

custom_component:
  - lambda: |-
      return { new CustomAPI() };

And when I press th eleft or middle button, the motor starts turning, but I’t won’t stop en the right button remains gray and does not work.

image

Hey, I’m experiencing some odd behavior too after the previous home assistant update. It didn’t report position of the cover anymore.

As for your problem, looks like your curtain size is not set. Use your remote control to fully open and fully close several times, and let it stop by itself. Then it can work out its relative position.

try to play around with the esphome.curtain_send_command service to send these command directly:

#define TUYA_CLOSE   "55aa000600056504000100"
#define TUYA_STOP    "55aa000600056504000101"
#define TUYA_OPEN    "55aa000600056504000102"

if you want to dig deep into the Tuya protocol, take a look at this. This is what i mostly used as reference.

By the way how to i upload file attachments to a post??

Hi, thanks for the component! I a bit changed your code and changed commands to get it working with Zemismart curtain, works like a charm! But I did not find a command get stop and real time position (curtain reports only position after operation) working for this curtain, also removed opening/closing interim states and left only closed/opened.

// basic commands
#define TUYA_CLOSE   "55aa000600056604000100"
#define TUYA_STOP    "55aa000600056604000101"  // does not work
#define TUYA_OPEN    "55aa000600056604000102"

// set position percentage
#define TUYA_SET_POSITION  "55aa0006000865020004000000"

So it doesn’t report any position at all?

I’ve just uploaded 2 PDFs on the repo which i used as reference. I’m not sure if they’re even legit, but hey, at lease I’ve got mine working.

You may want to take a look at it. Maybe there was something I didn’t get right.

It reports position after operation correctly even if I control it by a 433 MHz remote. So I actually get it completely working without stop button and on-line position reporting while opening or closing

If someone has a working method for this Zemismart Tuya controller for just cover opening and closing can you then please post the curtain.h file and the esphome yaml?

Thanks!

Michel

Hi, sure it works with this config:

ESPHome yaml:

# Переменные 
substitutions:
  curtain_name: zemismart_curtain

esphome:
  name: zemismart_curtain
  platform: ESP8266
  board: esp01_1m
  includes:
    - curtain.h

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

logger:
  baud_rate: 0

api:
  password: !secret api_pass

ota:
  password: !secret ota_pass

uart:
  - id: com_1
    rx_pin: GPIO13
    tx_pin: GPIO15
    baud_rate: 9600

globals:
  - id: cover_open
    type: bool
    restore_value: yes
    initial_value: "false"
  - id: cover_reversed
    type: bool
    restore_value: yes
    initial_value: "false"
  - id: cover_position
    type: float
    restore_value: yes
    initial_value: "0"

cover:
  - platform: custom
    lambda: |-
      auto curtain = new CustomCurtain();
      App.register_component(curtain);
      return {curtain};
    covers:
      - name: Curtain
        device_class: blind

custom_component:
  - lambda: |-
      return { new CustomAPI() };

sensor: 
# Время в онлайне   
  - platform: uptime
    name: ${curtain_name}_Uptime

# Уровень сигнала
  - platform: wifi_signal
    name: ${curtain_name}_Wi-Fi_Signal
    update_interval: 180s

# Версия прошивки
text_sensor:
  - platform: version
    name: ${curtain_name}_firmware_version
    
binary_sensor:
  - platform: status
    name: "Online"

And curtain.h fixed for zemismart:

#include "esphome.h"

// basic commands
#define TUYA_CLOSE   "55aa000600056604000100"
#define TUYA_STOP    "55aa000600056604000101" // command does not work
#define TUYA_OPEN    "55aa000600056604000102"

// set position percentage
#define TUYA_SET_POSITION  "55aa0006000865020004000000" // and 1 byte (0x00-0x64)

class CustomCurtain : public Component, public Cover {
public:
	CoverTraits get_traits() override {
		auto traits = CoverTraits();
		traits.set_is_assumed_state(false);
		traits.set_supports_position(true);
		traits.set_supports_tilt(false);
		return traits;
	}

	void setup() override {
		position = id(cover_position);
		publish_state();
	}

	void loop() override {
		while (Serial.available()) {
			readByte(Serial.read());
		}
	}

	void control(const CoverCall &call) override {
		if (call.get_position().has_value()) {
			crc = 0;
			uint8_t pos = *call.get_position() * 100.0f;
				writeHex(TUYA_SET_POSITION);
				writeByte(pos);
			writeByte(crc);
		}
	}

	void readByte(uint8_t data) {
		if ((offset == 0 && data == 0x55) || (offset == 1 && data == 0xAA) || offset) {
			buffer[offset++] = data;
			if (offset > 6) {
				int len = buffer[4] << 8 | buffer[5];
				if (7 + len == offset) {
					uint8_t crc = buffer[offset - 1];
					uint8_t sum = 0;
					for (int i = 0; i < offset - 1; i++) sum += buffer[i];
					if (sum == crc) {
						uint8_t cmd_type = buffer[3];
						uint8_t data_id = buffer[6];
						uint8_t data_size = buffer[9]; // only 1byte or 4bytes values
						switch (cmd_type) {
							case 0x07: {
								switch (data_id) {
									// Operation mode state
									// Position report after operation
									case 0x65: {
										position = buffer[13] / 100.0f;
										if (position > 0.95) position = 1;
										if (position < 0.05) position = 0;
										id(cover_open) = !!position;
										break;
									}
								}
								publish_state();
								break;
							}
						}
					}
					offset = 0;
				}
			}
		}
	}

	uint8_t crc;

	void writeByte(uint8_t data) {
		Serial.write(data);
		crc += data;
	}

	void writeHex(std::string data) {
		int i = 0;
		while (i < data.length()) {
			const char hex[2] = {data[i++], data[i++]};
			uint8_t d = strtoul(hex, NULL, 16);
			writeByte(d);
		}
	}

protected:
	uint16_t offset = 0;
	uint8_t buffer[1024];
};

class CustomAPI : public Component, public CustomAPIDevice {
public:
	void setup() override {
		register_service(&CustomAPI::sendMessage, "send_command", {"data"});
	}

	void sendMessage(std::string data) {
		int i = 0;
		uint8_t sum = 0;
		while (i < data.length()) {
			const char hex[2] = {data[i++], data[i++]};
			uint8_t d = strtoul(hex, NULL, 16);
			sum += d;
			writeByte(d);
		}
		writeByte(sum);
	}

	void writeByte(uint8_t data) {
		Serial.write(data);
	}
};

What exact problem are you having?

Thanks. I implemented it exactly as above and I am experiencing the following issues:

  1. The open/close Icon is exactly the wrong way around:


    It’s showing this when CLOSED, but the blinds icon is ‘open’ in stead of ‘closed’.
    Also the state is saying ‘OPEN’ when the curtain is actually closed.

    How to reverse this?
    On the RF remote control the curtain is configured correctly. The open buttons opens, the close button closes.

  2. Stop is not working, which is a known issues af far as I understand.

Hi, I went throught the code and documentation again and found a mistake with stop button and states (opening/closing), now stop is working and it shows the current state with position.
To change motor direction you can send a command through esphome.zemismart_curtain_send_command with data: “55aa000600056700000100”
where last 00 - forward or 01 reversed direction. Then you get correct control from Home Assistent component but most probably buttons on your RF remote will be reversed too (you can try to teach it again)
Here is the code, hope that helps

#include "esphome.h"

// basic commands
#define TUYA_OPEN   "55aa000600056604000100"
#define TUYA_CLOSE  "55aa000600056604000101"
#define TUYA_STOP   "55aa000600056604000102"

// set position percentage
#define TUYA_SET_POSITION  "55aa0006000865020004000000" // and 1 byte (0x00-0x64)

// enable/disable reversed motor direction
#define TUYA_DISABLE_REVERSING  "55aa000600056700000100"
#define TUYA_ENABLE_REVERSING  "55aa000600056700000101"

class CustomCurtain : public Component, public Cover {
public:
	CoverTraits get_traits() override {
		auto traits = CoverTraits();
		traits.set_is_assumed_state(false);
		traits.set_supports_position(true);
		traits.set_supports_tilt(false);
		return traits;
	}

	void setup() override {
		position = id(cover_position);
		publish_state();
	}

	void loop() override {
		while (Serial.available()) {
			readByte(Serial.read());
		}
	}

	void control(const CoverCall &call) override {
		if (call.get_stop()) {
			crc = 0;
			writeHex(TUYA_STOP);
			writeByte(crc);
		}
		if (call.get_position().has_value()) {
			crc = 0;
			uint8_t pos = *call.get_position() * 100.0f;
			if (pos == 100) {
				writeHex(TUYA_OPEN);
			} else if (pos == 0) {
				writeHex(TUYA_CLOSE);
			} else {
				writeHex(TUYA_SET_POSITION);
				writeByte(pos);
			}
			writeByte(crc);
		}
	}

	void readByte(uint8_t data) {
		if ((offset == 0 && data == 0x55) || (offset == 1 && data == 0xAA) || offset) {
			buffer[offset++] = data;
			if (offset > 6) {
				int len = buffer[4] << 8 | buffer[5];
				if (7 + len == offset) {
					uint8_t crc = buffer[offset - 1];
					uint8_t sum = 0;
					for (int i = 0; i < offset - 1; i++) sum += buffer[i];
					if (sum == crc) {
						uint8_t cmd_type = buffer[3];
						uint8_t data_id = buffer[6];
						uint8_t data_size = buffer[9]; // only 1byte or 4bytes values
						switch (cmd_type) {
							case 0x07: {
								switch (data_id) {
									// Motor reversing state
									case 0x67: {
										id(cover_reversed) = !!buffer[10];
										break;
									}
									// Operation mode state
									case 0x66: {
										switch (buffer[10]) {
											case 0x01:
												current_operation = COVER_OPERATION_CLOSING;
												break;
											case 0x02:
												current_operation = COVER_OPERATION_IDLE;
												break;
											case 0x00:
												current_operation = COVER_OPERATION_OPENING;
												break;
										}
										break;
									}
									// Position report during operation
									// Max value is 0x64 so the last byte is good enough
									case 0x68: {
										position = buffer[13] / 100.0f;
										id(cover_open) = !!position;
										break;
									}
									// Position report after operation
									case 0x65: {
										position = buffer[13] / 100.0f;
										if (position > 0.95) position = 1;
										if (position < 0.05) position = 0;
										id(cover_open) = !!position;
										current_operation = COVER_OPERATION_IDLE;
										break;
									}
								}
								publish_state();
								break;
							}
						}
					}
					offset = 0;
				}
			}
		}
	}

	uint8_t crc;

	void writeByte(uint8_t data) {
		Serial.write(data);
		crc += data;
	}

	void writeHex(std::string data) {
		int i = 0;
		while (i < data.length()) {
			const char hex[2] = {data[i++], data[i++]};
			uint8_t d = strtoul(hex, NULL, 16);
			writeByte(d);
		}
	}

protected:
	uint16_t offset = 0;
	uint8_t buffer[1024];
};

class CustomAPI : public Component, public CustomAPIDevice {
public:
	void setup() override {
		register_service(&CustomAPI::setMotorNormal, "set_motor_normal");
		register_service(&CustomAPI::setMotorReversed, "set_motor_reversed");
		register_service(&CustomAPI::sendMessage, "send_command", {"data"});
	}

	void setMotorNormal() {
		sendMessage(TUYA_DISABLE_REVERSING);
	}

	void setMotorReversed() {
		sendMessage(TUYA_ENABLE_REVERSING);
	}

	void sendMessage(std::string data) {
		int i = 0;
		uint8_t sum = 0;
		while (i < data.length()) {
			const char hex[2] = {data[i++], data[i++]};
			uint8_t d = strtoul(hex, NULL, 16);
			sum += d;
			writeByte(d);
		}
		writeByte(sum);
	}

	void writeByte(uint8_t data) {
		Serial.write(data);
	}
};

Hi,

Thanks, stop and position work flawlessly now, but whatever I do, either HA or the remote is the wrong way around.

It seems that pressing the close+open button and pressing stop within 3 seconds on the remote (according to instructions that will reverse motor direction) also changes it for HA and the other way around; changing it in HA by following your instructions, changes it back and then the remote is wrong.

Is it not possible to change it in code? I just need HA to respond differently, without changing the actual hardware motor direction…

Thanks!

Michel

Hi,
I played around and added a workaround so should be reversed in HA and sinchronized with the remote.
The motor should be in the forward direction

#include "esphome.h"

// basic commands
#define TUYA_OPEN   "55aa000600056604000100"
#define TUYA_CLOSE  "55aa000600056604000101"
#define TUYA_STOP   "55aa000600056604000102"

// set position percentage
#define TUYA_SET_POSITION  "55aa0006000865020004000000" // and 1 byte (0x00-0x64)

// enable/disable reversed motor direction
#define TUYA_DISABLE_REVERSING  "55aa000600056700000100"
#define TUYA_ENABLE_REVERSING  "55aa000600056700000101"

class CustomCurtain : public Component, public Cover {
public:
	CoverTraits get_traits() override {
		auto traits = CoverTraits();
		traits.set_is_assumed_state(false);
		traits.set_supports_position(true);
		traits.set_supports_tilt(false);
		return traits;
	}

	void setup() override {
		position = 1 - id(cover_position);
		publish_state();
	}

	void loop() override {
		while (Serial.available()) {
			readByte(Serial.read());
		}
	}

	void control(const CoverCall &call) override {
		if (call.get_stop()) {
			crc = 0;
			writeHex(TUYA_STOP);
			writeByte(crc);
		}
		if (call.get_position().has_value()) {
			crc = 0;
			uint8_t pos = 100 - (*call.get_position() * 100.0f);
			if (pos == 100) {
				writeHex(TUYA_OPEN);
			} else if (pos == 0) {
				writeHex(TUYA_CLOSE);
			} else {
				writeHex(TUYA_SET_POSITION);
				writeByte(100 - pos);
			}
			writeByte(crc);
		}
	}

	void readByte(uint8_t data) {
		if ((offset == 0 && data == 0x55) || (offset == 1 && data == 0xAA) || offset) {
			buffer[offset++] = data;
			if (offset > 6) {
				int len = buffer[4] << 8 | buffer[5];
				if (7 + len == offset) {
					uint8_t crc = buffer[offset - 1];
					uint8_t sum = 0;
					for (int i = 0; i < offset - 1; i++) sum += buffer[i];
					if (sum == crc) {
						uint8_t cmd_type = buffer[3];
						uint8_t data_id = buffer[6];
						uint8_t data_size = buffer[9]; // only 1byte or 4bytes values
						switch (cmd_type) {
							case 0x07: {
								switch (data_id) {
									// Motor reversing state
									case 0x67: {
										id(cover_reversed) = !!buffer[10];
										break;
									}
									// Operation mode state
									case 0x66: {
										switch (buffer[10]) {
											case 0x00:
												current_operation = COVER_OPERATION_CLOSING;
												break;
											case 0x02:
												current_operation = COVER_OPERATION_IDLE;
												break;
											case 0x01:
												current_operation = COVER_OPERATION_OPENING;
												break;
										}
										break;
									}
									// Position report during operation
									// Max value is 0x64 so the last byte is good enough
									case 0x68: {
										position = buffer[13] / 100.0f;
										id(cover_open) = !!position;
										break;
									}
									// Position report after operation
									case 0x65: {
										position = 1 - (buffer[13] / 100.0f);
										if (position > 0.95) position = 1;
										if (position < 0.05) position = 0;
										id(cover_open) = !!position;
										current_operation = COVER_OPERATION_IDLE;
										break;
									}
								}
								publish_state();
								break;
							}
						}
					}
					offset = 0;
				}
			}
		}
	}

	uint8_t crc;

	void writeByte(uint8_t data) {
		Serial.write(data);
		crc += data;
	}

	void writeHex(std::string data) {
		int i = 0;
		while (i < data.length()) {
			const char hex[2] = {data[i++], data[i++]};
			uint8_t d = strtoul(hex, NULL, 16);
			writeByte(d);
		}
	}

protected:
	uint16_t offset = 0;
	uint8_t buffer[1024];
};

class CustomAPI : public Component, public CustomAPIDevice {
public:
	void setup() override {
		register_service(&CustomAPI::setMotorNormal, "set_motor_normal");
		register_service(&CustomAPI::setMotorReversed, "set_motor_reversed");
		register_service(&CustomAPI::sendMessage, "send_command", {"data"});
	}

	void setMotorNormal() {
		sendMessage(TUYA_DISABLE_REVERSING);
	}

	void setMotorReversed() {
		sendMessage(TUYA_ENABLE_REVERSING);
	}

	void sendMessage(std::string data) {
		int i = 0;
		uint8_t sum = 0;
		while (i < data.length()) {
			const char hex[2] = {data[i++], data[i++]};
			uint8_t d = strtoul(hex, NULL, 16);
			sum += d;
			writeByte(d);
		}
		writeByte(sum);
	}

	void writeByte(uint8_t data) {
		Serial.write(data);
	}
};

What can I say…PERFECT! :+1: :+1: