IVARHO Tuya Hubitat Driver fixes

IVARHO Tuya Hubitat Driver

I used this driver to control 4 Smart Life WiFi outlet plugs. The instructions were clear and worked as described. I was able to control
the plugs locally from Hubitat and also Google home linked to the Hubitat.
The biggest issue is that when the device changes state, either manual action (push putton or unplug) or controlled from the Smart Life app, it is not reflected in Hubitat (thus Google home). The work around that I can see is to poll it. The driver defines a command, "Status", which can be used to get the current state and that is reflected in the Hubitat device state displayed in a tile. Status can be actioned in the device view. Status is not a valid action in any of the Hubitat rule apps, that I can see. Hubitat defines "Refresh".

Driver modifications:
Added a new capability at line 19:
capability "Actuator"
capability "Refresh" /* new */
capability "Switch"
capability "Sensor"

Added a new function at line 155 with the same action as the Status function:
/* New */
def refresh() {
send(generate_payload("status"))
}

I can now define a rule to action Refresh from a virtual button or time interval to update the state of the plug.

To better match most drivers I add an "Enable descriptionText logging" preference and some log.info to see the state changes more simply.

The second biggest issue is that there is handling of failures or a removed/unplugged device. The tile for the device will hang with a "busy" indicator for a long time until Hubitat clears the state, leaving the tile in the previous state. I added "sendEvent" of "off" in these failure conditions to release the busy and clear the tile state. I don't know if there is a better way to handle this.

I also added retry (once) logic for communication failures. A sign of a disabled or removed device.

While I started programming in 1971, I only know how to work with my own code repositories, in particular, I don't know how to submit code for consideration is someone else's repository. So here is my updated code: **removed, updated, resubmitted **

2 Likes

After a few days running, some of the failure conditions were not what I thought. Here is the updated code:

/**
 * Copyright 2020-2022 Ivar Holand
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
metadata {
	definition(name: "tuya Generic Device", namespace: "iholand", author: "iholand") {
		capability "Actuator"
		capability "Refresh" /* new */
		capability "Switch"
		capability "Sensor"
		command "status"

		attribute "availableEndpoints", "String"
	}
}

preferences {
	section("URIs") {
		input "ipaddress", "text", title: "Device IP:", required: false
		input "devId", "text", title: "Device ID:", required: false
		input "localKey", "text", title: "Device local key:", required: false
		input "endpoint", "text", title: "End point to control: ", required: true
		input "tuyaProtVersion", "enum", title: "Select tuya protocol version: ", required: true, options: [31: "3.1", 33 : "3.3"]
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
		input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
	}
}

def logsOff() {
	log.warn "debug logging disabled..."
	device.updateSetting("logEnable", [value: "false", type: "bool"])
}

def updated() {
	// log.info "updated..."
	log.warn "debug logging is: ${logEnable == true}"
	if (logEnable) runIn(1800, logsOff)

	sendEvent(name: "switch", value: "off")
}

def parse(String description) {
	if (logEnable) log.debug "Receiving message from device"
	if (logEnable) log.debug(description)

	byte[] msg_byte = hubitat.helper.HexUtils.hexStringToByteArray(description)

	String status = new String(msg_byte, "UTF-8")
	
	String protocol_version = ""

	status = status[20..-1]

	if (logEnable) log.debug "Raw incoming data: " + status

	if (!status.startsWith("{")) {
		// Encrypted message incoming, decrypt first

		if (logEnable) log.debug "Encrypted message detected"
		if (logEnable) log.debug "Bytes incoming: " + msg_byte.size()

		def message_start = 0

		// Find message type to determine start of message
		def message_type = msg_byte[11].toInteger()

		if (message_type == 7) {
			// Incoming control message
			// Find protocol version
			byte[] ver_bytes = [msg_byte[48], msg_byte[49], msg_byte[50]]
			protocol_version = new String(ver_bytes)

			if (protocol_version == "3.1") {
				message_start = 67
			} else if (protocol_version == "3.3") {
				message_start = 63
			}

		} else if (message_type == 10) {
			// Incoming status message
			message_start = 20

			// Status messages do not contain version information, however v 3.3
			// protocol encrypts status messages, v 3.1 does not
			protocol_version = "3.3"
		}

		// Find end of message by looking for 0xAA55
		def end_of_message = 0
		for (u = message_start; u < msg_byte.size()-1; u++) {
			if (msg_byte[u] == (byte)0xAA && msg_byte[u+1] == (byte)0x55) {
				//msg end found
				if (logEnable) log.debug "End of message: ${u-message_start-6}"
				end_of_message = u-message_start-6
				break
			}
		}

		// Re-assemble the bytes for decoding
		ByteArrayOutputStream output = new ByteArrayOutputStream()
		for (i = message_start; i < end_of_message+message_start; i++) {
			output.write(msg_byte[i])
		}

		byte[] payload = output.toByteArray()

		if (logEnable) log.debug "Assembled payload for decrypt: "+ hubitat.helper.HexUtils.byteArrayToHexString(payload)

		def dec_status = ""

		if (protocol_version == "3.1") {
			dec_status = decrypt_bytes(payload, settings.localKey, true)
		} else if (protocol_version == "3.3") {
			dec_status = decrypt_bytes(payload, settings.localKey, false)
		}

		if (logEnable) log.debug "Decryted message: ${dec_status}"

		status = dec_status
	}
	
	try {
		def jsonSlurper = new groovy.json.JsonSlurper()
		def status_object = jsonSlurper.parseText(status)

		sendEvent(name: "availableEndpoints", value: status_object.dps)

		if (status_object.dps[endpoint] == true) {
			if (txtEnable) log.info "${device.displayName} is on" 
			sendEvent(name: "switch", value : "on", isStateChange : true)
		} else {
			if (txtEnable) log.info "${device.displayName} is off" 
			sendEvent(name: "switch", value : "off", isStateChange : true)
		}

		try {
			interfaces.rawSocket.close()
		} catch (e) {
			log.error "Could not close socket: $e"
		}
	} catch (e1) {  /* random noise message */
			log.error "parse: $e1"
            log.error String(msg_byte, "UTF-8")
	}
}

def status() {
	send(generate_payload("status"))
}

/* New */
def refresh() {
	send(generate_payload("status"))
}

def on() {
	send(generate_payload("set", ["${settings.endpoint}":true]))
}

def off() {
	send(generate_payload("set", ["${settings.endpoint}":false]))
}

import hubitat.device.HubAction
import hubitat.device.Protocol

def send(byte[] message) {
	try {
		String msg = hubitat.helper.HexUtils.byteArrayToHexString(message)
		if (logEnable) log.debug "Sending message to " + settings.ipaddress + ":" + 6668 + " msg: " + msg

			//port 6668
		interfaces.rawSocket.connect(settings.ipaddress, 6668, byteInterface: true, readDelay: 500)
		interfaces.rawSocket.sendMessage(msg)
	} catch (e) {
		log.error "Error retry $e"
        if (txtEnable) log.info "${device.displayName} failure, turned off"
		sendEvent(name: "switch", value : "off", isStateChange : true)
		try { /* new retry */ 
			String msg1 = hubitat.helper.HexUtils.byteArrayToHexString(message)
			if (logEnable) log.debug "Sending message to " + settings.ipaddress + ":" + 6668 + " msg: " + msg1
		
			//port 6668
			interfaces.rawSocket.connect(settings.ipaddress, 6668, byteInterface: true, readDelay: 500)
			interfaces.rawSocket.sendMessage(msg1)
		} catch (e1) {
			log.error "Error stop $e1"
		}
	}
}

def generate_payload(command, data=null) {

	def json = new groovy.json.JsonBuilder()

	json_data = payload()["device"][command]["command"]

	if (json_data.containsKey("gwId")) {
		json_data["gwId"] = settings.devId
	}
	if (json_data.containsKey("devId")) {
		json_data["devId"] = settings.devId
	}
	if (json_data.containsKey("uid")) {
		json_data["uid"] = settings.devId
	}
	if (json_data.containsKey("t")) {
		Date now = new Date()
		json_data["t"] = (now.getTime()/1000).toInteger().toString()
		//json_data["t"] = "1602184793" // for testing
	}

	if (data != null) {
		json_data["dps"] = data
	}

	json json_data

	if (logEnable) log.debug tuyaProtVersion

	json_payload = groovy.json.JsonOutput.toJson(json.toString())
	json_payload = json_payload.replaceAll("\\\\", "")
	json_payload = json_payload.replaceFirst("\"", "")
	json_payload = json_payload[0..-2]

	if (logEnable) log.debug "payload before=" + json_payload

	ByteArrayOutputStream output = new ByteArrayOutputStream()

	if (command == "set" && tuyaProtVersion == "31") {
		encrypted_payload = encrypt(json_payload, settings.localKey)

		if (logEnable) log.debug "Encrypted payload: " + hubitat.helper.HexUtils.byteArrayToHexString(encrypted_payload.getBytes())

		preMd5String = "data=" + encrypted_payload + "||lpv=" + "3.1" + "||" + settings.localKey

		if (logEnable) log.debug "preMd5String" + preMd5String

		hexdigest = generateMD5(preMd5String)

		hexdig = new String(hexdigest[8..-9].getBytes("UTF-8"), "ISO-8859-1")

		json_payload = "3.1" + hexdig + encrypted_payload

	} else if (tuyaProtVersion == "33") {
		encrypted_payload = encrypt(json_payload, settings.localKey, false)

		if (logEnable) log.debug encrypted_payload

		if (command != "status" && command != "12") {
			output.write("3.3".getBytes())
			output.write("\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000".getBytes())
			output.write(hubitat.helper.HexUtils.hexStringToByteArray(encrypted_payload))
		} else {
			output.write(hubitat.helper.HexUtils.hexStringToByteArray(encrypted_payload))
		}
	}

	if (tuyaProtVersion == "31") {
		output.write(json_payload.getBytes())
	}

	if (logEnable) log.debug "payload after=" + json_payload

	output.write(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"]["suffix"]))

	byte[] bff = output.toByteArray()

	if (logEnable) log.debug hubitat.helper.HexUtils.byteArrayToHexString(bff)

	postfix_payload = bff

	postfix_payload_hex_len = postfix_payload.size()

	if (logEnable) log.debug postfix_payload_hex_len

	if (logEnable) log.debug "Prefix: " + hubitat.helper.HexUtils.byteArrayToHexString(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"]["prefix"]))

	output = new ByteArrayOutputStream();

	output.write(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"]["prefix"]))
	output.write(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"][command]["hexByte"]))
	output.write(hubitat.helper.HexUtils.hexStringToByteArray("000000"))
	output.write(postfix_payload_hex_len)
	output.write(postfix_payload)

	byte[] buf = output.toByteArray()

	crc32 = CRC32b(buf, buf.size()-8) & 0xffffffff
	if (logEnable) log.debug buf.size()

	hex_crc = Long.toHexString(crc32)

	if (logEnable) log.debug "HEX crc: $hex_crc : " + hex_crc.size()/2

	// Pad the CRC in case highest byte is 0
	if (hex_crc.size() < 7) {
		hex_crc = "00" + hex_crc
	}

	crc_bytes = hubitat.helper.HexUtils.hexStringToByteArray(hex_crc)

	buf[buf.size()-8] = crc_bytes[0]
	buf[buf.size()-7] = crc_bytes[1]
	buf[buf.size()-6] = crc_bytes[2]
	buf[buf.size()-5] = crc_bytes[3]

	return buf
}

// Helper functions
def payload()
{
	def payload_dict = [
		"device": [
			"status": [
				"hexByte": "0a",
				"command": ["devId": "", "gwId": "", "uid":"", "t": ""]
			],
			"set": [
				"hexByte": "07",
				"command": ["devId":"", "uid": "", "t": ""]
			],
			"prefix": "000055aa00000000000000",
			"suffix": "000000000000aa55"
		]
	]

	return payload_dict
}

// Huge thank you to MrYutz for posting Groovy AES ecryption drivers for groovy
//https://community.hubitat.com/t/groovy-aes-encryption-driver/31556

import javax.crypto.spec.SecretKeySpec;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.Cipher

// Encrypt plain text v. 3.1 uses base64 encoding, while 3.3 does not
def encrypt (def plainText, def secret, encodeB64=true) {

	// Encryption is AES in ECB mode, pad using PKCS5Padding as needed
	def cipher = Cipher.getInstance("AES/ECB/PKCS5Padding ")
	SecretKeySpec key = new SecretKeySpec(secret.getBytes("UTF-8"), "AES")

	// Give the encryption engine the encryption key
	cipher.init(Cipher.ENCRYPT_MODE, key)

	def result = ""

	if (encodeB64) {
		result = cipher.doFinal(plainText.getBytes("UTF-8")).encodeBase64().toString()
	} else {
		result = cipher.doFinal(plainText.getBytes("UTF-8")).encodeHex().toString()
	}

	return result
}

// Decrypt ByteArray
def decrypt_bytes (byte[] cypherBytes, def secret, decodeB64=false) {
	if (logEnable) log.debug "*********** Decrypting **************"

	def cipher = Cipher.getInstance("AES/ECB/PKCS5Padding ")
	SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES")

	cipher.init(Cipher.DECRYPT_MODE, key)

	if (decodeB64) {
		cypherBytes = cypherBytes.decodeBase64()
	}

	def result = cipher.doFinal(cypherBytes)

	return new String(result, "UTF-8")
}

import java.security.MessageDigest

def generateMD5(String s){
	MessageDigest.getInstance("MD5").digest(s.bytes).encodeHex().toString()
}

def CRC32b(bytes, length) {
	crc = 0xFFFFFFFF

	for (i = 0; i < length; i++) {
		b = Byte.toUnsignedInt(bytes[i])

		crc = crc ^ b
		for (j = 7; j >= 0; j--) {
			mask = -(crc & 1)
			crc = (crc >> 1) ^(0xEDB88320 & mask)
		}
	}

	return ~crc
}
2 Likes

Unsure if this is related to similar issues you were seeing, but I, too, noticed today that my hub wasn't controlling a tuya/Smart Life device. Upon looking at the logs, I saw a strange error -

error org.codehaus.groovy.runtime.metaclass.MissingMethodExceptionNoStack: No signature of method: user_driver_iholand_tuya_Generic_Device_779.socketStatus() is applicable for argument types: (java.lang.String) values: [send error: Connection reset] (method socketStatus)

On a lark, I added

def socketStatus(String s) {
    if (logEnable) log.debug "in socketStatus, received ${s}"
}

at the bottom of the driver and ... it started working again. Maybe you can shed some light on what's going on here, as you seem to have done some additional work to fix this driver?

Hello Sir, just an inquiry, if i have a Tuya wifi wall outlet with 2 receptacles/sockets what is the ENDPOINT TO CONTROL VALUE?

Thanks for this. One more hole sealed. You helped me to reframe some of my thinking to solve a button hanging on failure issue. I don't know anything more about the Hubitat call interface or the Tuya device protocol than what I found in this code and trying different things to analyze/fix the errors. I am not exactly sure which language it is written in, I suspect python. I have worked in dozens of languages in my career so it is not that important as I can kinda fake this syntax. This looks like a call back missed in the original driver. Not all Tuya devices work exactly the same, it is a big ecosystem, so other devices may not have triggered it.

Not an expert, but my single wifi outlet uses a value of "1". Your device should have 2 endpoints and I would guess a value of "1" and "2" I have looked at a scene controller that has 5 or 6 endpoints they range from 0 or 1 to 5 or 6.
If you run this driver with your device you should see in the device view under Current States "availableEndpoints" this should give you hints as to the values. IIRC the documentation for setting this driver up talks about it a little bit.
However, this driver only manages one end point. You will need to try to set up 2 Hubitat devices, one for each endpoint, but the same IP address. I don't know for sure, it it seems like that would work. Let us know what you find.

Hello Sir, from Philippines :philippines:. Yes I tried values 1 and 2. And it worked, 1 for Right Socket and 2 for the Left Socket. I followed your advice to setup 2 devices. Thank You.

BTW, is there a way to link it to Homekit? HE message is incompatible device.:grinning::grinning::grinning:

Thank You :grinning:
TOTO

Sent from my iPhone

I don't use Homekit, you will need to search on that community.

Here is my latest version. I added issue above and resolved the issue with a button controlling the device hanging for a long time on commination failure.

/**
 * Copyright 2020-2022 Ivar Holand
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
metadata {
	definition(name: "tuya Generic Device", namespace: "iholand", author: "iholand") {
		capability "Actuator"
		capability "Refresh" /* new */
		capability "Switch"
		capability "Sensor"
		command "status"

		attribute "availableEndpoints", "String"
	}
}

preferences {
	section("URIs") {
		input "ipaddress", "text", title: "Device IP:", required: false
		input "devId", "text", title: "Device ID:", required: false
		input "localKey", "text", title: "Device local key:", required: false
		input "endpoint", "text", title: "End point to control: ", required: true
		input "tuyaProtVersion", "enum", title: "Select tuya protocol version: ", required: true, options: [31: "3.1", 33 : "3.3"]
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
		input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
	}
}

def logsOff() {
	log.warn "debug logging disabled..."
	device.updateSetting("logEnable", [value: "false", type: "bool"])
}

def updated() {
	// log.info "updated..."
	log.warn "debug logging is: ${logEnable == true}"
	if (logEnable) runIn(1800, logsOff)

	sendEvent(name: "switch", value: "off")
}

def parse(String description) {
	if (logEnable) log.debug "Receiving message from device"
	if (logEnable) log.debug(description)

	byte[] msg_byte = hubitat.helper.HexUtils.hexStringToByteArray(description)

	String status = new String(msg_byte, "UTF-8")
	
	String protocol_version = ""

	status = status[20..-1]

	if (logEnable) log.debug "Raw incoming data: " + status

	if (!status.startsWith("{")) {
		// Encrypted message incoming, decrypt first

		if (logEnable) log.debug "Encrypted message detected"
		if (logEnable) log.debug "Bytes incoming: " + msg_byte.size()

		def message_start = 0

		// Find message type to determine start of message
		def message_type = msg_byte[11].toInteger()

		if (message_type == 7) {
			// Incoming control message
			// Find protocol version
			byte[] ver_bytes = [msg_byte[48], msg_byte[49], msg_byte[50]]
			protocol_version = new String(ver_bytes)

			if (protocol_version == "3.1") {
				message_start = 67
			} else if (protocol_version == "3.3") {
				message_start = 63
			}

		} else if (message_type == 10) {
			// Incoming status message
			message_start = 20

			// Status messages do not contain version information, however v 3.3
			// protocol encrypts status messages, v 3.1 does not
			protocol_version = "3.3"
		}

		// Find end of message by looking for 0xAA55
		def end_of_message = 0
		for (u = message_start; u < msg_byte.size()-1; u++) {
			if (msg_byte[u] == (byte)0xAA && msg_byte[u+1] == (byte)0x55) {
				//msg end found
				if (logEnable) log.debug "End of message: ${u-message_start-6}"
				end_of_message = u-message_start-6
				break
			}
		}

		// Re-assemble the bytes for decoding
		ByteArrayOutputStream output = new ByteArrayOutputStream()
		for (i = message_start; i < end_of_message+message_start; i++) {
			output.write(msg_byte[i])
		}

		byte[] payload = output.toByteArray()

		if (logEnable) log.debug "Assembled payload for decrypt: "+ hubitat.helper.HexUtils.byteArrayToHexString(payload)

		def dec_status = ""

		if (protocol_version == "3.1") {
			dec_status = decrypt_bytes(payload, settings.localKey, true)
		} else if (protocol_version == "3.3") {
			dec_status = decrypt_bytes(payload, settings.localKey, false)
		}

		if (logEnable) log.debug "Decryted message: ${dec_status}"

		status = dec_status
	}

	try {
		def jsonSlurper = new groovy.json.JsonSlurper()
		def status_object = jsonSlurper.parseText(status)

		sendEvent(name: "availableEndpoints", value: status_object.dps)

		if (status_object.dps[endpoint] == true) {
			if (txtEnable) log.info "${device.displayName} is on" 
			sendEvent(name: "switch", value : "on", isStateChange : true)
		} else {
			if (txtEnable) log.info "${device.displayName} is off" 
			sendEvent(name: "switch", value : "off", isStateChange : true)
		}

		try {
			interfaces.rawSocket.close()
		} catch (e) {
			log.error "Could not close socket: $e"
		}
	} catch (e1) {  //bad response, try to clear hung button
			log.error "IP: $settings.ipaddress Endpoint: $settings.endpoint Parse response error: $e1" 
			String tstr = "null"
			if (msg_byte != null) tstr = new String(msg_byte, "UTF-8")
			log.error "raw data: $tstr"
			if (status != null) log.error "status: $status" else log.error "status: null"
			sendEvent(name: "switch", value : "failed", isStateChange : true)
	}

}

def status() {
	send(generate_payload("status"))
}

/* New */
def refresh() {
	send(generate_payload("status"))
}

def on() {
	send(generate_payload("set", ["${settings.endpoint}":true]))
}

def off() {
	send(generate_payload("set", ["${settings.endpoint}":false]))
}

import hubitat.device.HubAction
import hubitat.device.Protocol

def send(byte[] message) {
	try {
		String msg = hubitat.helper.HexUtils.byteArrayToHexString(message)
		if (logEnable) log.debug "Sending message to " + settings.ipaddress + ":" + 6668 + " msg: " + msg

			//port 6668
		interfaces.rawSocket.connect(settings.ipaddress, 6668, byteInterface: true, readDelay: 500)
		interfaces.rawSocket.sendMessage(msg)
	} catch (e) {
		log.error "Error retry: $e"
        if (txtEnable) log.info "${device.displayName} failure, turned off"
		sendEvent(name: "switch", value : "off", isStateChange : true)
		try { /* new retry */ 
			String msg1 = hubitat.helper.HexUtils.byteArrayToHexString(message)
			if (logEnable) log.debug "Sending message to " + settings.ipaddress + ":" + 6668 + " msg: " + msg1
		
			//port 6668
			interfaces.rawSocket.connect(settings.ipaddress, 6668, byteInterface: true, readDelay: 500)
			interfaces.rawSocket.sendMessage(msg1)
		} catch (e1) {
			log.error "Error stop: $e1"
		}
	}
}

def generate_payload(command, data=null) {

	def json = new groovy.json.JsonBuilder()

	json_data = payload()["device"][command]["command"]

	if (json_data.containsKey("gwId")) {
		json_data["gwId"] = settings.devId
	}
	if (json_data.containsKey("devId")) {
		json_data["devId"] = settings.devId
	}
	if (json_data.containsKey("uid")) {
		json_data["uid"] = settings.devId
	}
	if (json_data.containsKey("t")) {
		Date now = new Date()
		json_data["t"] = (now.getTime()/1000).toInteger().toString()
		//json_data["t"] = "1602184793" // for testing
	}

	if (data != null) {
		json_data["dps"] = data
	}

	json json_data

	if (logEnable) log.debug tuyaProtVersion

	json_payload = groovy.json.JsonOutput.toJson(json.toString())
	json_payload = json_payload.replaceAll("\\\\", "")
	json_payload = json_payload.replaceFirst("\"", "")
	json_payload = json_payload[0..-2]

	if (logEnable) log.debug "payload before=" + json_payload

	ByteArrayOutputStream output = new ByteArrayOutputStream()

	if (command == "set" && tuyaProtVersion == "31") {
		encrypted_payload = encrypt(json_payload, settings.localKey)

		if (logEnable) log.debug "Encrypted payload: " + hubitat.helper.HexUtils.byteArrayToHexString(encrypted_payload.getBytes())

		preMd5String = "data=" + encrypted_payload + "||lpv=" + "3.1" + "||" + settings.localKey

		if (logEnable) log.debug "preMd5String" + preMd5String

		hexdigest = generateMD5(preMd5String)

		hexdig = new String(hexdigest[8..-9].getBytes("UTF-8"), "ISO-8859-1")

		json_payload = "3.1" + hexdig + encrypted_payload

	} else if (tuyaProtVersion == "33") {
		encrypted_payload = encrypt(json_payload, settings.localKey, false)

		if (logEnable) log.debug encrypted_payload

		if (command != "status" && command != "12") {
			output.write("3.3".getBytes())
			output.write("\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000".getBytes())
			output.write(hubitat.helper.HexUtils.hexStringToByteArray(encrypted_payload))
		} else {
			output.write(hubitat.helper.HexUtils.hexStringToByteArray(encrypted_payload))
		}
	}

	if (tuyaProtVersion == "31") {
		output.write(json_payload.getBytes())
	}

	if (logEnable) log.debug "payload after=" + json_payload

	output.write(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"]["suffix"]))

	byte[] bff = output.toByteArray()

	if (logEnable) log.debug hubitat.helper.HexUtils.byteArrayToHexString(bff)

	postfix_payload = bff

	postfix_payload_hex_len = postfix_payload.size()

	if (logEnable) log.debug postfix_payload_hex_len

	if (logEnable) log.debug "Prefix: " + hubitat.helper.HexUtils.byteArrayToHexString(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"]["prefix"]))

	output = new ByteArrayOutputStream();

	output.write(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"]["prefix"]))
	output.write(hubitat.helper.HexUtils.hexStringToByteArray(payload()["device"][command]["hexByte"]))
	output.write(hubitat.helper.HexUtils.hexStringToByteArray("000000"))
	output.write(postfix_payload_hex_len)
	output.write(postfix_payload)

	byte[] buf = output.toByteArray()

	crc32 = CRC32b(buf, buf.size()-8) & 0xffffffff
	if (logEnable) log.debug buf.size()

	hex_crc = Long.toHexString(crc32)

	if (logEnable) log.debug "HEX crc: $hex_crc : " + hex_crc.size()/2

	// Pad the CRC in case highest byte is 0
	if (hex_crc.size() < 7) {
		hex_crc = "00" + hex_crc
	}

	crc_bytes = hubitat.helper.HexUtils.hexStringToByteArray(hex_crc)

	buf[buf.size()-8] = crc_bytes[0]
	buf[buf.size()-7] = crc_bytes[1]
	buf[buf.size()-6] = crc_bytes[2]
	buf[buf.size()-5] = crc_bytes[3]

	return buf
}

// Helper functions
def payload()
{
	def payload_dict = [
		"device": [
			"status": [
				"hexByte": "0a",
				"command": ["devId": "", "gwId": "", "uid":"", "t": ""]
			],
			"set": [
				"hexByte": "07",
				"command": ["devId":"", "uid": "", "t": ""]
			],
			"prefix": "000055aa00000000000000",
			"suffix": "000000000000aa55"
		]
	]

	return payload_dict
}

// Huge thank you to MrYutz for posting Groovy AES ecryption drivers for groovy
//https://community.hubitat.com/t/groovy-aes-encryption-driver/31556

import javax.crypto.spec.SecretKeySpec;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.Cipher

// Encrypt plain text v. 3.1 uses base64 encoding, while 3.3 does not
def encrypt (def plainText, def secret, encodeB64=true) {

	// Encryption is AES in ECB mode, pad using PKCS5Padding as needed
	def cipher = Cipher.getInstance("AES/ECB/PKCS5Padding ")
	SecretKeySpec key = new SecretKeySpec(secret.getBytes("UTF-8"), "AES")

	// Give the encryption engine the encryption key
	cipher.init(Cipher.ENCRYPT_MODE, key)

	def result = ""

	if (encodeB64) {
		result = cipher.doFinal(plainText.getBytes("UTF-8")).encodeBase64().toString()
	} else {
		result = cipher.doFinal(plainText.getBytes("UTF-8")).encodeHex().toString()
	}

	return result
}

// Decrypt ByteArray
def decrypt_bytes (byte[] cypherBytes, def secret, decodeB64=false) {
	if (logEnable) log.debug "*********** Decrypting **************"

	def cipher = Cipher.getInstance("AES/ECB/PKCS5Padding ")
	SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES")

	cipher.init(Cipher.DECRYPT_MODE, key)

	if (decodeB64) {
		cypherBytes = cypherBytes.decodeBase64()
	}

	def result = cipher.doFinal(cypherBytes)

	return new String(result, "UTF-8")
}

import java.security.MessageDigest

def generateMD5(String s){
	MessageDigest.getInstance("MD5").digest(s.bytes).encodeHex().toString()
}

def CRC32b(bytes, length) {
	crc = 0xFFFFFFFF

	for (i = 0; i < length; i++) {
		b = Byte.toUnsignedInt(bytes[i])

		crc = crc ^ b
		for (j = 7; j >= 0; j--) {
			mask = -(crc & 1)
			crc = (crc >> 1) ^(0xEDB88320 & mask)
		}
	}

	return ~crc
}

def socketStatus(String s) {
    log.error "IP: $settings.ipaddress Endpoint: $settings.endpoint socketStatus: ${s}" 
	sendEvent(name: "switch", value : "failed", isStateChange : true)
}
2 Likes

Thank You so much Sir :smile: :smile:

From: TOTO

Groovy :wink: Basically Java w/o all the ceremony and a hefty dose of syntactic sugar. Probably inspired more by Ruby than Python, but there's similarities even between those two.

Applied your newest code - working a charm for me, thx!

1 Like

Thanks, that clarifies the language. I have a number of Java apps in the play store and dabbled a bit in Ruby and Python. It seemed like Java, but the "def" and line break statement separators felt more like the latter 2.

Thanks for this driver. I can Turn On/Off a Tuya based Wi-Fi Fan(Brand name : Atomberg) using this and can control the in-built night lamp in this.

Is it possible to integrate the speed controller in this driver ?

When I ran the “Tiny Tuya Scan” I found the following end points.

Status: {'1': True, '2': 'Normal', '3': '3', '9': True, '17': False, '101': 0, '102': 0, '103': 0, '104': 0, '105': 0, '106': 0, '107': 0, '108': 0, '109': '', '110': '', '111': 0}

Upon checking, end points are as follows.

1: = Turn On/Off <- working with your driver

9: = Turn On/Off In-built night lamp <- working with your driver

3: = This is the speed control, When the fan is at speed 1 , “Tiny Tuya Scan” shows that '3': '1' and when the fan is at speed 3, it shows '3': '3' and so on.

It would be great if you could please help me on this.

I only updated a few small thing to make it work better with devices that I have. I don’t have a device like your to analyze or test. It would be nice to see more Tuya devices supported.

That said, I did lean a few things and can make some suggestions.

First, you would need to analyze the inputs for your device fan. I seems like positive integer correlates with the fan speed. Does a 0 turn it off. If you manually set a speed does off (false) turn it off and then does on (true) turn it back on to the previous speed?

You can test this by setting up a device controlling the fan (endpoint 3) and turning it on and off to observe the effect on the fan. This might get most of what you need.

Second, you would need to find the right Hubitat capability to control the Tuya fan. Here is a likely suspect, there may other type that coiuld be made to work:

FanControl

Device Selector

capability.fanControl

Driver Definition

capability "FanControl"

Attributes

  • speed - ENUM ["low","medium-low","medium","medium-high","high","on","off","auto"]
  • supportedFanSpeeds - JSON_OBJECT

Commands

  • setSpeed(fanspeed)
    • fanspeed required (ENUM) - Fan speed to set
  • cycleSpeed()

Using the current on/off driver as a base, create a new driver for fans as we don’t want to clutter the driver for a particular type of device with commands and options that don’t apply.

Remove capabilities that don’t apply and add the FanControl capability to the new driver as well as the code for the 2 commands: setSpeed and cycleSpeed

Then a lot of testing, adjusting and verifying to make it work.

There is a Hubitat community thread that mentions Tuya fan control but it seems to be cloud based, so it might not be useful.

Thanks for your inputs. Unfortunately, I don’t have experience in writing device handler.

I have added the driver and gotten the local key and device id and local is address. Here is the key I get back when I invoke the tuya api in their debug tool:
"local_key": "HYnk8+EDR{iOG<r!"
It looks like it is communicating in the logs but when I try and issue a command I get anerrror and this message:
Any ideas?


dev:3682023-06-17 06:34:06.141 AMerrorstatus:
dev:3682023-06-17 06:34:06.140 AMerrorraw data: U�a,�
ͨ���rߋ�4|�����#�1K�К�0��…�U
dev:3682023-06-17 06:34:06.139 AMerrorIP: 172.31.11.188 Endpoint: 1 Parse response error: java.lang.IllegalArgumentException: Text must not be null or empty
dev:3682023-06-17 06:34:06.137 AMdebugDecryted message:
dev:3682023-06-17 06:34:06.136 AMdebugAssembled payload for decrypt: 000055AA00000000000000070000002C00000001E2B10ACDA8A3CBFA1572DF8BFB347C94F4008689BB239D314B90D09A15F13012
dev:3682023-06-17 06:34:06.133 AMdebugEnd of message: 52
dev:3682023-06-17 06:34:06.129 AMdebugBytes incoming: 60
dev:3682023-06-17 06:34:06.128 AMdebugEncrypted message detected
dev:3682023-06-17 06:34:06.126 AMdebugRaw incoming data: �
ͨ���rߋ�4|�����#�1K�К�0��…�U
dev:3682023-06-17 06:34:06.123 AMdebug000055AA00000000000000070000002C00000001E2B10ACDA8A3CBFA1572DF8BFB347C94F4008689BB239D314B90D09A15F13012F0DAC2850000AA55
dev:3682023-06-17 06:34:06.122 AMdebugReceiving message from device
dev:3682023-06-17 06:34:05.034 AMdebugSending message to 172.31.11.188:6668 msg: 000055AA000000000000000700000087332E330000000000000000000000008DD2EC359B68F0CBAA80D3105312E7C5934C431A1396E4B5494BDEA2C293733050CD674681985CAFD64B0C625B0CA30354EF156C865B867B9DCABF7500C2A30219603E1145A64CFB28B774FF7431121D6B1DEE848B30BE63C8D19AD018E205DE27673F4370F417F6A5C68770A1E02588FF6B98B00000AA55
dev:3682023-06-17 06:34:05.032 AMdebugHEX crc: ff6b98b0 : 4
dev:3682023-06-17 06:34:05.031 AMdebug151
dev:3682023-06-17 06:34:04.943 AMdebugPrefix: 000055AA00000000000000
dev:3682023-06-17 06:34:04.941 AMdebug135
dev:3682023-06-17 06:34:04.939 AMdebug

You need to show more, go back to a couple of lines below: payload before=...

Also, copy the "Current States" in the upper right hand of the device view from before sending the command and after.

What I see here is that an encrypted message was not able to be decrypted, so the result was a null message.

I only cleaned up some Hubitat interface issues. You will need to go back to the original author to address protocol changes.

1 Like

I just setup and tried to use this driver, pulled the keys, but the driver won't accept it. IE I paste in FO4:l6$gE{+JHA, and it changes it to F$gE{+JHA in the device local key field, and then when I try to hit an endpoint, I get this:

[dev:4113](https://10.54.25.35/logs?tab=past&deviceId=4113#)2024-03-21 11:23:55.551 AM[error](https://10.54.25.35/logs?tab=past&deviceId=4113#)java.security.InvalidKeyException: Invalid AES key length: 9 bytes on line 1064 (method off)

And will I need a seperate driver for each type of device I have? I'm very confused at this point...vs the cloud driver that sucks, but works, when Tuya's api isn't breaking...

Thank you!