Tuya Zigbee 12 Channel Relay Board Driver

I got my 12 Channel Zigbee relay board today from Ali Express.

There is no working driver for it available, so I had Deepseek AI write me a driver. It took less than an hour, and probably about 10 iterations of feeding logs back to it.

So, should anyone be interested in this board, here are the AI drivers. I had it make the switch driver first, and then I had it change that into using component valve children instead of switches (for controlling irrigation valves).

The switch driver just uses the built-in Hubitat Generic Component Switch for the children.
There is no built-in Generic Component Valve, so the valve parent driver also needs the Generic Component Valve child driver added to Hubitat.

Tuya TS0601 12-Channel Relay Board Driver
/**
 *  Tuya TS0601 12‑Channel Relay Board – Final
 *  Relays 1‑6: DP = 1‑6
 *  Relays 7‑12: DP = 101‑106
 */

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

metadata {
    definition(name: "Tuya TS0601 12-Channel Relay", namespace: "yourNamespace", author: "Your Name") {
        capability "Initialize"
        capability "Refresh"
        capability "Configuration"

        command "configure"
        command "refresh"
        command "recreateChildDevices"

        fingerprint profileId:"0104", inClusters:"0000,0004,0005,EF00", outClusters:"0019,000A", model:"TS0601", deviceJoinName:"Tuya 12-Channel Relay"
    }

    preferences {
        input name: "txtEnable", type: "bool", title: "Enable debug logging", defaultValue: true
    }
}

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

def installed() {
    log.info "Installed"
    atomicState.transactionSeq = 0
    configure()
}

def updated() {
    log.info "Updated"
    unschedule()
    if (txtEnable) runIn(1800, logsOff)
    configure()
}

def configure() {
    log.info "Configuring"
    for (i in 1..12) {
        def childId = "${device.deviceNetworkId}-relay${i}"
        if (!getChildDevice(childId)) {
            try {
                addChildDevice("hubitat", "Generic Component Switch", childId, [
                    name: "${device.displayName} Relay ${i}",
                    label: "${device.displayName} Relay ${i}",
                    isComponent: false
                ])
                if (txtEnable) log.debug "Created child for relay ${i}"
            } catch (e) {
                log.error "Failed to create child for relay ${i}: ${e.message}"
            }
        }
    }
}

def refresh() {}

def recreateChildDevices() {
    log.info "Recreating child devices"
    childDevices.each { child ->
        try { deleteChildDevice(child.deviceNetworkId) } catch(e) {}
    }
    configure()
}

def parse(String description) {
    if (txtEnable) log.debug "Parsing: ${description}"
    def map = zigbee.parseDescriptionAsMap(description)
    if (!map || map.clusterInt != 0xEF00) return

    def payload = map.data
    if (!payload || payload.size() < 2) return
    if (txtEnable) log.debug "Tuya payload: ${payload}"

    if (payload[0] == 0x01) {  // status report
        if (payload.size() < 4) {
            if (txtEnable) log.trace "Heartbeat, ignoring"
            return
        }
        def idx = 2
        while (idx + 3 < payload.size()) {
            def dp = payload[idx] & 0xFF
            def dataType = payload[idx+1] & 0xFF
            def len = payload[idx+2] & 0xFF
            def dataStart = idx + 3
            if (dataStart + len <= payload.size()) {
                def value = payload[dataStart]
                if (dataType == 0x01) {
                    // Map DP to relay number
                    def relayNum = null
                    if (dp >= 1 && dp <= 6) {
                        relayNum = dp
                    } else if (dp >= 101 && dp <= 106) {
                        relayNum = dp - 94  // 101->7, 102->8, ..., 106->12
                    }
                    if (relayNum) {
                        def childId = "${device.deviceNetworkId}-relay${relayNum}"
                        def child = getChildDevice(childId)
                        if (child) {
                            def newState = (value == 0x01) ? "on" : "off"
                            child.sendEvent(name: "switch", value: newState, displayed: true)
                            if (txtEnable) log.info "Relay ${relayNum} (DP ${dp}) updated to ${newState}"
                        }
                    } else {
                        if (txtEnable) log.debug "Unhandled DP: ${dp}"
                    }
                }
            }
            idx = dataStart + len
        }
    }
}

def componentOn(childDevice) {
    def channel = getChannelFromChild(childDevice)
    if (channel == null) return
    def dp = (channel <= 6) ? channel : channel + 94  // 7->101, 8->102, ..., 12->106
    sendTuyaCommand(channel, dp, 1)
}

def componentOff(childDevice) {
    def channel = getChannelFromChild(childDevice)
    if (channel == null) return
    def dp = (channel <= 6) ? channel : channel + 94
    sendTuyaCommand(channel, dp, 0)
}

private Integer getChannelFromChild(childDevice) {
    def match = childDevice.deviceNetworkId =~ /-relay(\d+)$/
    if (match) return match[0][1] as Integer
    log.warn "Could not extract relay number from ${childDevice.deviceNetworkId}"
    return null
}

private void sendTuyaCommand(Integer channel, Integer dp, Integer value) {
    if (atomicState.transactionSeq == null) atomicState.transactionSeq = 0
    def transId = (atomicState.transactionSeq++ & 0xFF)

    def endpoint = "01"
    def payload = [0x00, transId, dp, 0x01, 0x00, 0x01, value]
    def hexPayload = payload.collect { String.format("%02X", it) }.join()
    def cmd = "he cmd 0x${device.deviceNetworkId} 0x${endpoint} 0xEF00 0x00 {${hexPayload}}"
    if (txtEnable) log.debug "Sending: ${cmd}"
    sendHubCommand(new HubAction(cmd, Protocol.ZIGBEE))

    // Optimistically update child state (so UI responds immediately)
    def childId = "${device.deviceNetworkId}-relay${channel}"
    def child = getChildDevice(childId)
    if (child) {
        child.sendEvent(name: "switch", value: value == 1 ? "on" : "off", displayed: false)
    }
}
Tuya TS0601 12-Channel Valve Controller
/**
 *  Tuya TS0601 12‑Channel Relay Board as Valve Controller
 *  Relays 1‑6 → DP 1‑6
 *  Relays 7‑12 → DP 101‑106
 *  Uses raw Zigbee commands and a custom “Generic Valve” child driver.
 */

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

metadata {
    definition(name: "Tuya TS0601 12-Channel Valve Controller", namespace: "yourNamespace", author: "Your Name") {
        capability "Initialize"
        capability "Refresh"
        capability "Configuration"

        command "configure"
        command "refresh"
        command "recreateChildDevices"

        fingerprint profileId:"0104", inClusters:"0000,0004,0005,EF00", outClusters:"0019,000A", model:"TS0601", deviceJoinName:"Tuya 12-Channel Relay"
    }

    preferences {
        input name: "txtEnable", type: "bool", title: "Enable debug logging", defaultValue: true
    }
}

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

def installed() {
    log.info "Installed"
    atomicState.transactionSeq = 0
    configure()
}

def updated() {
    log.info "Updated"
    unschedule()
    if (txtEnable) runIn(1800, logsOff)
    configure()
}

def configure() {
    log.info "Configuring"
    for (i in 1..12) {
        def childId = "${device.deviceNetworkId}-valve${i}"
        if (!getChildDevice(childId)) {
            try {
                addChildDevice("yourNamespace", "Generic Valve", childId, [
                    name: "${device.displayName} Valve ${i}",
                    label: "${device.displayName} Valve ${i}",
                    isComponent: false
                ])
                if (txtEnable) log.debug "Created child valve for relay ${i}"
            } catch (e) {
                log.error "Failed to create child for relay ${i}: ${e.message}"
            }
        }
    }
}

def refresh() {}

def recreateChildDevices() {
    log.info "Recreating child devices"
    childDevices.each { child ->
        try { deleteChildDevice(child.deviceNetworkId) } catch(e) {}
    }
    configure()
}

def parse(String description) {
    if (txtEnable) log.debug "Parsing: ${description}"
    def map = zigbee.parseDescriptionAsMap(description)
    if (!map || map.clusterInt != 0xEF00) return

    def payload = map.data
    if (!payload || payload.size() < 2) return
    if (txtEnable) log.debug "Tuya payload: ${payload}"

    if (payload[0] == 0x01) {
        if (payload.size() < 4) {
            if (txtEnable) log.trace "Heartbeat, ignoring"
            return
        }
        def idx = 2
        while (idx + 3 < payload.size()) {
            def dp = payload[idx] & 0xFF
            def dataType = payload[idx+1] & 0xFF
            def len = payload[idx+2] & 0xFF
            def dataStart = idx + 3
            if (dataStart + len <= payload.size()) {
                def value = payload[dataStart]
                if (dataType == 0x01) {
                    def relayNum = null
                    if (dp >= 1 && dp <= 6) relayNum = dp
                    else if (dp >= 101 && dp <= 106) relayNum = dp - 94
                    if (relayNum) {
                        def child = getChildDevice("${device.deviceNetworkId}-valve${relayNum}")
                        if (child) {
                            def newState = (value == 0x01) ? "open" : "closed"
                            child.sendEvent(name: "valve", value: newState, displayed: true)
                            if (txtEnable) log.info "Valve ${relayNum} (DP ${dp}) updated to ${newState}"
                        }
                    } else if (txtEnable) log.debug "Unhandled DP: ${dp}"
                }
            }
            idx = dataStart + len
        }
    }
}

def componentOpen(childDevice) {
    def channel = getChannelFromChild(childDevice)
    if (channel == null) return
    def dp = (channel <= 6) ? channel : channel + 94
    sendTuyaCommand(channel, dp, 1)   // 1 = open
}

def componentClose(childDevice) {
    def channel = getChannelFromChild(childDevice)
    if (channel == null) return
    def dp = (channel <= 6) ? channel : channel + 94
    sendTuyaCommand(channel, dp, 0)   // 0 = closed
}

private Integer getChannelFromChild(childDevice) {
    def match = childDevice.deviceNetworkId =~ /-valve(\d+)$/
    if (match) return match[0][1] as Integer
    return null
}

private void sendTuyaCommand(Integer channel, Integer dp, Integer value) {
    if (atomicState.transactionSeq == null) atomicState.transactionSeq = 0
    def transId = (atomicState.transactionSeq++ & 0xFF)

    def payload = [0x00, transId, dp, 0x01, 0x00, 0x01, value]
    def hexPayload = payload.collect { String.format("%02X", it) }.join()
    def cmd = "he cmd 0x${device.deviceNetworkId} 0x01 0xEF00 0x00 {${hexPayload}}"
    if (txtEnable) log.debug "Sending: ${cmd}"
    sendHubCommand(new HubAction(cmd, Protocol.ZIGBEE))

    // Optimistic local update
    def child = getChildDevice("${device.deviceNetworkId}-valve${channel}")
    if (child) child.sendEvent(name: "valve", value: value == 1 ? "open" : "closed", displayed: false)
}
Generic Component Valve
/**
 *  Generic Valve Child Driver
 *  Implements the Valve capability for a child device.
 *  Based on the Hubitat Generic Component Switch pattern.
 */

metadata {
    definition(name: "Generic Valve", namespace: "yourNamespace", author: "Your Name", component: true) {
        capability "Valve"
        capability "Refresh"
        capability "Actuator"
    }
    preferences {
        input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
    }
}

def updated() {
    log.info "Updated..."
    log.warn "description logging is: ${txtEnable == true}"
}

def installed() {
    log.info "Installed..."
    device.updateSetting("txtEnable", [type:"bool", value:true])
    refresh()
}

def parse(String description) {
    log.warn "parse(String description) not implemented"
}

def parse(List description) {
    description.each {
        if (it.name in ["valve"]) {
            if (txtEnable) log.info it.descriptionText
            sendEvent(it)
        }
    }
}

def open() {
    parent?.componentOpen(this.device)
}

def close() {
    parent?.componentClose(this.device)
}

def refresh() {
    parent?.componentRefresh(this.device)
}
3 Likes

Nice!

ZigBee I/O for under $2/switch!

Time to break out the mad scientist hat. :wink:

I really only needed the 8-channel board, but the 8-channel 9-27v board was not available at the time.

Then it was available, so I ordered an 8-channel board as well which comes next week. If this driver doesn't work with the 8-channel, I will have AI write one for that board as well.

Mode is the sticking point. There is no physical switch for mode, so it is normally done in the Tuya App. I had Deepseek take a couple swipes at it, but it can't find any reference for what those would be. It gave me a command method to add to the driver to figure it out for myself.

I don't really need to change mode, so I didn't play around with it.


Extended Parent Driver with testInterlockCombination
Add this new command to your existing working parent driver. It will try a specific DP, group size, and number of groups.

Merge the following code into your parent driver (keep all your relay control code unchanged).

groovy
// Add this command inside the metadata definition, alongside the others:
command "testInterlockCombination", [[name: "dp*", type: "NUMBER", description: "DP (7,15,102,104,105)"],
                                     [name: "groupSize", type: "NUMBER", description: "Channels per group (2,3,5,10)"],
                                     [name: "numGroups", type: "NUMBER", description: "Number of groups (1,2,3,4,5,6)"]]

// Then add the corresponding method:
def testInterlockCombination(Integer dp, Integer groupSize, Integer numGroups) {
    log.warn "Testing DP=${dp}, groups=${numGroups} of size ${groupSize}"
    // Encode value as (groupSize << 8) | numGroups  (example: 2,2 → 0x0202 = 514)
    def value = (groupSize << 8) | numGroups
    // Use dataType 0x04 (enum, 2 bytes) for this kind of setting
    sendRawTuyaCommand(dp, value, 0x04)
}
You also need sendRawTuyaCommand (I’ll include the full minimal version below).

🧪 Recommended Test Matrix
For each DP below, try the following (groupSize, numGroups) combinations and then test interlock behaviour (turn on two relays that should be in the same group – e.g., relays 1 & 2 – and see if the second turns off the first).

DP	groupSize	numGroups	Expected interlock if correct
7	2	2	1‑2 interlock, 3‑4 interlock
7	10	1	all 10 channels interlocked (if board supports 10)
15	2	2	same as above
15	10	1	all interlocked
102	2	2	(already tried 2,2 but used wrong DP?)
104	2	2	common for interlock groups
104	10	1	
105	2	2	
Also try groupSize = 0, numGroups = 0 – that should disable all interlock (normal independent mode).
private void sendRawTuyaCommand(Integer dp, Integer value, Integer dataType) {
    if (atomicState.transactionSeq == null) atomicState.transactionSeq = 0
    def transId = (atomicState.transactionSeq++ & 0xFF)

    def payload
    if (dataType == 0x01) { // bool, 1 byte
        payload = [0x00, transId, dp, 0x01, 0x00, 0x01, value]
    } else { // enum, 2 bytes (little‑endian)
        def lowByte = value & 0xFF
        def highByte = (value >> 8) & 0xFF
        payload = [0x00, transId, dp, 0x04, 0x00, 0x02, lowByte, highByte]
    }
    def hexPayload = payload.collect { String.format("%02X", it) }.join()
    def cmd = "he cmd 0x${device.deviceNetworkId} 0x01 0xEF00 0x00 {${hexPayload}}"
    if (txtEnable) log.debug "Sending: ${cmd}"
    sendHubCommand(new HubAction(cmd, Protocol.ZIGBEE))
}
1 Like