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)
}
