I used Gemini to edit this driver [RELEASE] Zemismart Zigbee Blind Driver Hopefully I have attributed everyone properly in the original driver, if not I will be happy to include them.
These are typically sold in a red box as LY-1668 Zigbee curtain rod robot motors on Ebay and Aliexpress. They identify as TS0601 or TZE200_cpbo62rn (mine has both) when looking at Device Data in the settings page. My version (all versions?) are dual-motor with a master-slave configuration. I am not sure what happens if you don't have the exact same version as I do, I have no way to test. I am not sure this driver will work with other similar models.
- One goal was to add some missing items like lux and temp. The light sensor needs to be polled, there is an option to enable that with some various poll times.
- I also was having issues with that existing driver calibrating the motor limits. No matter which calibration option I used, I couldn't get it to enable calibration for some reason. That now works as expected.
- Also, response time seemed slower than it should be using the multi-driver, so minimizing and optimizing was one of my goals with this stripped down driver. It is a lot more responsive in both reporting, position and to initially move open/closed.
This is as-is, but I can attempt to make changes via Gemini if someone finds an issue that I haven't discovered. I am not going to put it into HPM, this is just a vibe coded as-is driver.
/* groovylint-disable CompileStatic, CouldBeElvis, CouldBeSwitchStatement, DuplicateMapLiteral, DuplicateNumberLiteral, DuplicateStringLiteral, ImplicitClosureParameter, InsecureRandom, LineLength, MethodCount, MethodSize, PublicMethodsBeforeNonPublicMethods, SpaceAroundOperator, ThrowException, UnnecessaryGetter, UnnecessarySetter */
/**
* Tuya Zigbee Curtain Driver for _TZE200_cpbo62rn
*
* Originally based on the comprehensive Tuya window shade DTH by:
* Amos Yuen, kkossev, iquix, and ShinJjang (Copyright © 2021-2025)
*
* Refactored, leaned out, and optimized by Gemini (2026) for Hubitat performance.
*
* Optimizations: DP 102/107 Calibration Commands, Universal DP Sniffer,
* Illuminance, Temperature, Extended Reporting, Auto-Refresh, Forced State Graphing,
* and DP1 Phantom Bounce Fix.
*/
import groovy.transform.Field
private String textVersion() {
return '5.0.0 - 2026-07-31 (Final Optimized Release)'
}
private String textCopyright() {
return 'Copyright ©2021-2025 Amos Yuen, kkossev, iquix, ShinJjang\nOptimized & Refactored by Gemini (2026)'
}
@Field static final Boolean _DEBUG = false
metadata {
definition(name: 'Tuya Curtain Driver', namespace: 'custom', author: 'Amos Yuen, kkossev, Gemini', importUrl: '', singleThreaded: true) {
capability 'Actuator'
capability 'Configuration'
capability 'HealthCheck'
capability 'PushableButton'
capability 'WindowShade'
capability 'Switch'
capability 'SwitchLevel'
capability 'Battery'
capability 'Refresh'
capability 'IlluminanceMeasurement'
capability 'TemperatureMeasurement'
attribute 'speed', 'number'
attribute 'targetPosition', 'number'
attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
attribute 'rtt', 'number'
// Extended Attributes
attribute 'faultStatus', 'string'
attribute 'motorWorkState', 'string'
attribute 'limitCalibration', 'string'
attribute 'lightThreshold', 'number'
attribute 'rawScheduleData', 'string'
command 'configure', [[name: '*** will load defaults! ***']]
command 'push', [[name: 'button number*', type: 'NUMBER', description: '1: Open, 2: Close, 3: Stop, 4: Step Open, 5: Step Close']]
command 'stepClose', [[name: 'step', type: 'NUMBER', description: 'Amount to change position towards close.']]
command 'stepOpen', [[name: 'step', type: 'NUMBER', description: 'Amount to change position towards open.']]
command 'setSpeed', [[name: 'speed*', type: 'NUMBER', description: 'Motor speed (0 to 100).']]
command 'ping'
// Custom Calibration & Setup Commands
command 'resetLimits'
command 'startLearning'
command 'stopLearning'
command 'setLightThreshold', [[name: 'threshold*', type: 'NUMBER', description: 'Local light sensitivity threshold (e.g. 0-1000)']]
fingerprint profileId:'0104', endpointId:'01', inClusters:'0000,0004,0005,EF00', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_cpbo62rn', deviceJoinName: 'Tuya LY-108 Cover'
}
preferences {
input('enableInfoLog', 'bool', title: 'Enable descriptionText logging', required: true, defaultValue: true)
input('enableDebugLog', 'bool', title: 'Enable debug logging', required: true, defaultValue: false)
input('direction', 'enum', title: 'Direction', options: ['0': 'forward', '1': 'reverse'], required: true, defaultValue: '0')
input('maxClosedPosition', 'number', title: 'Max Closed Position', description: 'Position value considered fully closed', required: true, defaultValue: 1)
input('minOpenPosition', 'number', title: 'Min Open Position', description: 'Position value considered fully open', required: true, defaultValue: 99)
input('defaultStepAmount', 'number', title: 'Default Step Amount', description: 'Default step percentage', required: true, defaultValue: 10)
input('invertPosition', 'bool', title: 'Invert position reporting', description: 'Invert position 0..100 if reporting is reversed', required: true, defaultValue: false)
input('positionReportTimeout', 'number', title: 'Position report timeout, ms', description: 'Max time between position reports', required: true, defaultValue: 15000)
input name: 'pollingInterval', type: 'enum', title: '<b>Auto-Refresh Interval</b>', description: 'Forces an update of light/temp sensors if they do not auto-report.', options: [0: 'Disabled', 5: 'Every 5 Mins', 10: 'Every 10 Mins', 15: 'Every 15 Mins', 30: 'Every 30 Mins', 60: 'Every 1 Hour'], defaultValue: 0, required: true
input name: 'healthCheckMethod', type: 'enum', title: '<b>Healthcheck Method</b>', options: [0: 'Disabled', 1: 'Activity check', 2: 'Periodic polling'], defaultValue: 1, required: true
input name: 'healthCheckInterval', type: 'enum', title: '<b>Healthcheck Interval</b>', options: [2: 'Every 2 Mins', 10: 'Every 10 Mins', 30: 'Every 30 Mins', 60: 'Every 1 Hour', 240: 'Every 4 Hours'], defaultValue: 240, required: true
}
}
@Field final Map MOVING_MAP = [0: 'up/open', 1: 'stop', 2: 'down/close' ]
@Field final int POSITION_UPDATE_TIMEOUT = 15000
@Field final int INVALID_POSITION = -1
@Field static final Integer COMMAND_TIMEOUT = 10
@Field static final Integer MAX_PING_MILISECONDS = 10000
@Field static final int PING_ATTR_ID = 0x01
@Field static final Integer PRESENCE_COUNT_THRESHOLD = 3
// Life Cycle
void installed() {
configure()
}
void updated() {
configure(false)
if (settings?.enableDebugLog == true || settings?.enableDebugLog == 'true') {
runIn(86400, 'logsOff')
}
checkHealthStatusConfiguration()
}
void logsOff() {
log.info "Debug logging automatically disabled after 24 hours"
device.updateSetting('enableDebugLog', [value: false, type: 'bool'])
}
void configure(boolean fullInit = true) {
state.version = textVersion()
state.copyright = textCopyright()
if (state.target == null || state.target < 0 || state.target > 100) { state.target = 0 }
state.isTargetRcvd = false
sendEvent(name: 'numberOfButtons', value: 5, type: 'digital')
sendEvent(name: 'targetPosition', value: 50, type: 'digital')
unschedule('endOfMovement')
unschedule('deviceCommandTimeout')
runIn(2, 'setDirection')
if (fullInit) {
device.updateSetting('enableInfoLog', [value: true, type: 'bool'])
device.updateSetting('enableDebugLog', [value: false, type: 'bool'])
device.updateSetting('direction', [value: '0', type: 'enum'])
device.updateSetting('invertPosition', [value: false, type: 'bool'])
device.updateSetting('positionReportTimeout', [value: POSITION_UPDATE_TIMEOUT, type: 'number'])
device.updateSetting('maxClosedPosition', [value: 1, type: 'number'])
device.updateSetting('minOpenPosition', [value: 99, type: 'number'])
device.updateSetting('pollingInterval', [value: 0, type: 'enum'])
}
setupPolling()
logInfo("${device.displayName} configured cleanly.")
}
void setupPolling() {
unschedule('refresh')
int interval = (settings?.pollingInterval as Integer) ?: 0
if (interval == 5) {
runEvery5Minutes('refresh')
} else if (interval == 10) {
runEvery10Minutes('refresh')
} else if (interval == 15) {
runEvery15Minutes('refresh')
} else if (interval == 30) {
runEvery30Minutes('refresh')
} else if (interval == 60) {
runEvery1Hour('refresh')
}
if (interval > 0) {
logInfo "Auto-refresh scheduled every ${interval} minutes."
} else {
logInfo "Auto-refresh is disabled."
}
}
void setDirection() {
int dirVal = (settings?.direction ?: '0') as int
sendTuyaCommand(0x05, 0x04, dirVal, 2)
}
// Calibration & Setup Actions
void resetLimits() {
logInfo "Clearing physical limits..."
sendTuyaCommand(107, 4, 0, 2) // DP 107 (0x6B), Enum, SET(0)
}
void startLearning() {
logInfo "Entering limit learning mode..."
sendTuyaCommand(102, 4, 0, 2) // DP 102 (0x66), Enum, START(0)
}
void stopLearning() {
logInfo "Saving limit..."
sendTuyaCommand(102, 4, 1, 2) // DP 102 (0x66), Enum, STOP(1)
}
void setLightThreshold(BigDecimal threshold) {
if (threshold == null || threshold < 0) {
log.warn "Invalid threshold value: ${threshold}"
return
}
logInfo "Setting local light threshold to ${threshold.intValue()}..."
sendTuyaCommand(106, 2, threshold.intValue(), 8) // DP 106 (0x6A), Value, length 8
}
// Messages
@Field final int CLUSTER_TUYA = 0xEF00
@Field final int ZIGBEE_COMMAND_SET_DATA = 0x00
@Field final int ZIGBEE_COMMAND_REPORTING = 0x01
@Field final int ZIGBEE_COMMAND_SET_DATA_RESPONSE = 0x02
@Field final int ZIGBEE_COMMAND_ACK = 0x0B
@Field final int ZIGBEE_COMMAND_SET_TIME = 0x24
void parse(String description) {
unscheduleCommandTimeoutCheck()
setHealthStatusOnline()
if (description == null || (!description.startsWith('catchall:') && !description.startsWith('read attr -'))) {
return
}
final Map descMap = zigbee.parseDescriptionAsMap(description)
if (descMap.clusterInt != CLUSTER_TUYA) {
parseNonTuyaMessage(descMap)
return
}
final int command = zigbee.convertHexToInt(descMap.command)
switch (command) {
case ZIGBEE_COMMAND_SET_DATA_RESPONSE:
case ZIGBEE_COMMAND_REPORTING:
if (!descMap?.data || descMap.data.size() < 7) { return }
parseSetDataResponse(descMap)
break
case ZIGBEE_COMMAND_ACK:
break
case ZIGBEE_COMMAND_SET_TIME:
processTuyaSetTime()
break
default:
break
}
}
void parseNonTuyaMessage(final Map descMap) {
if (descMap?.cluster == '0000' && descMap?.attrId == '0001') {
if (state.states?.isPing ?: false) {
handlePingResponse()
}
}
}
void parseSetDataResponse(final Map descMap) {
def data = descMap.data
int dp = zigbee.convertHexToInt(data[2])
int dataValue = zigbee.convertHexToInt(data[6..-1].join())
// Universal DP Sniffer
logDebug "Tuya DP Sniffer: DP_ID=${dp} (0x${zigbee.convertToHexString(dp, 2)}), type=${data[3]}, value=${dataValue}"
switch (dp) {
case 0x01: // Command response (Open/Close/Stop status)
// Bypassed for state setting to prevent phantom bounces during refresh
break
case 0x02: // Target Position
if (dataValue >= 0 && dataValue <= 100) {
if (settings?.invertPosition == true) { dataValue = 100 - dataValue }
state.isTargetRcvd = true
sendEvent(name: 'targetPosition', value: dataValue, type: 'physical')
}
break
case 0x03: // Current Position
if (dataValue >= 0 && dataValue <= 100) {
if (settings?.invertPosition == true) { dataValue = 100 - dataValue }
restartPositionReportTimeout()
updatePosition(dataValue)
}
break
case 0x05: // Direction response
break
case 0x07: // Motor Work State
def stateMap = [0: 'opening', 1: 'closing', 2: 'stopped']
def stateStr = stateMap[dataValue] ?: "unknown (${dataValue})"
sendEvent(name: 'motorWorkState', value: stateStr)
// Map physical state directly to windowShade capabilities
if (dataValue == 0) {
updateWindowShadeOpening()
} else if (dataValue == 1) {
updateWindowShadeClosing()
} else if (dataValue == 2) {
// Motor stopped. Instantly evaluate final position to update dashboard
updateWindowShadeArrived()
}
if (device.currentValue('motorWorkState') != stateStr) {
logInfo "Motor state is ${stateStr}"
}
break
case 0x0C: // Fault Status
def faultStr = (dataValue == 0) ? "clear" : "fault (${dataValue})"
sendEvent(name: 'faultStatus', value: faultStr)
if (dataValue != 0) {
logInfo "Fault status is ${faultStr}"
}
break
case 0x0D: // Battery
if (dataValue >= 0 && dataValue <= 100) {
updateBattery(dataValue)
}
break
case 0x66: // Limit Calibration Status
def calStr = (dataValue == 0) ? 'limits set/normal' : "learning/other (${dataValue})"
if (device.currentValue('limitCalibration') != calStr) {
sendEvent(name: 'limitCalibration', value: calStr)
logInfo "Limit calibration status is ${calStr}"
}
break
case 0x67: // Temperature Sensor
def tempValue = dataValue
if (location.temperatureScale == "F") {
tempValue = Math.round((dataValue * 1.8) + 32)
}
if (device.currentValue('temperature') != tempValue) {
logInfo "Temperature is ${tempValue}°${location.temperatureScale}"
} else {
logDebug "Temperature remains ${tempValue}°${location.temperatureScale}"
}
sendEvent(name: 'temperature', value: tempValue, unit: location.temperatureScale, isStateChange: true)
break
case 0x68: // Light Sensor
if (device.currentValue('illuminance') != dataValue) {
logInfo "Illuminance is ${dataValue} lux"
} else {
logDebug "Illuminance remains ${dataValue} lux"
}
sendEvent(name: 'illuminance', value: dataValue, unit: 'lux', isStateChange: true)
break
case 0x69: // Speed response
if (dataValue >= 0 && dataValue <= 100) {
updateSpeed(dataValue)
}
break
case 0x6A: // Light Threshold
if (device.currentValue('lightThreshold') != dataValue) {
sendEvent(name: 'lightThreshold', value: dataValue)
logInfo "Light threshold is ${dataValue}"
}
break
case 0x6C: // Raw Schedule Data
def scheduleData = data[6..-1].join()
if (device.currentValue('rawScheduleData') != scheduleData) {
sendEvent(name: 'rawScheduleData', value: scheduleData)
logInfo "Raw schedule data updated: ${scheduleData}"
}
break
default:
break
}
}
void processTuyaSetTime() {
int offset = 0
try {
offset = location.getTimeZone().getOffset(new Date().getTime())
} catch (e) {
// Fallback offset
}
List<String> cmds = zigbee.command(CLUSTER_TUYA, ZIGBEE_COMMAND_SET_TIME, '0008' + zigbee.convertToHexString((int)(now() / 1000), 8) + zigbee.convertToHexString((int)((now() + offset) / 1000), 8))
cmds.each { sendHubCommand(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) }
}
private boolean isWithinOne(int position) {
int lastPosition = (device.currentValue('position') as Integer) ?: INVALID_POSITION
return (lastPosition != INVALID_POSITION && Math.abs(position - lastPosition) <= 1)
}
private void updatePosition(final int position) {
sendEvent(name: 'position', value: position, unit: '%')
sendEvent(name: 'level', value: position, unit: '%')
if (position <= (settings?.maxClosedPosition as Integer ?: 1)) {
sendEvent(name: 'switch', value: 'off')
} else {
sendEvent(name: 'switch', value: 'on')
}
if (isWithinOne(position)) {
updateWindowShadeArrived(position)
stopPositionReportTimeout()
}
}
private void updateSpeed(final int speed) {
if (device.currentValue('speed') != speed) {
sendEvent(name: 'speed', value: speed)
logInfo "Speed is ${speed}%"
}
}
private void updateBattery(final int battery) {
if (device.currentValue('battery') != battery) {
logInfo "Battery is ${battery}%"
} else {
logDebug "Battery remains ${battery}%"
}
sendEvent(name: 'battery', value: battery, isStateChange: true)
}
private void updateWindowShadeOpening() {
if ((device.currentValue('windowShade') ?: 'undefined') != 'opening') {
sendEvent(name: 'windowShade', value: 'opening')
logInfo 'is opening'
}
}
private void updateWindowShadeClosing() {
if ((device.currentValue('windowShade') ?: 'undefined') != 'closing') {
sendEvent(name: 'windowShade', value: 'closing')
logInfo 'is closing'
}
}
private void updateWindowShadeArrived(int positionParam = -1) {
int position = (positionParam == -1) ? ((device.currentValue('position') as Integer) ?: INVALID_POSITION) : positionParam
if (position == INVALID_POSITION || position < 0 || position > 100) {
sendEvent(name: 'windowShade', value: 'unknown')
stopPositionReportTimeout()
} else if (position <= (settings?.maxClosedPosition as Integer ?: 1)) {
if ((device.currentValue('windowShade') ?: 'undefined') != 'closed') {
sendEvent(name: 'windowShade', value: 'closed')
logInfo 'is closed'
stopPositionReportTimeout()
}
} else if (position >= (settings?.minOpenPosition as Integer ?: 99)) {
if ((device.currentValue('windowShade') ?: 'undefined') != 'open') {
sendEvent(name: 'windowShade', value: 'open')
logInfo 'is open'
stopPositionReportTimeout()
}
} else {
if ((device.currentValue('windowShade') ?: 'undefined') != 'partially open') {
sendEvent(name: 'windowShade', value: 'partially open')
logInfo "is partially open ${position}%"
}
}
}
// Actions
void refresh() {
logDebug "Sending manual refresh query to Tuya MCU"
sendZigbeeCommands(zigbee.command(0xEF00, 0x03))
}
void close() {
logDebug "Close command intercepted: mapping to setPosition(0) to bypass DP1 bug."
setPosition(0)
}
void open() {
logDebug "Open command intercepted: mapping to setPosition(100) to bypass DP1 bug."
setPosition(100)
}
void on() { open() }
void off() { close() }
void startPositionChange(final String stateStr) {
if (stateStr == 'close') { close() }
else if (stateStr == 'open') { open() }
}
void stopPositionChange() {
restartPositionReportTimeout()
sendEvent(name: 'targetPosition', value: '?', type: 'digital')
sendTuyaCommand(0x01, 0x04, 0x01, 2)
}
void setLevel(BigDecimal level, BigDecimal duration = null) {
setPosition(level)
}
void setPosition(BigDecimal positionParam) {
int position = positionParam as int
if (position < 0 || position > 100) { return }
state.target = position
sendEvent(name: 'targetPosition', value: position, type: 'digital')
if (isWithinOne(position)) {
updateWindowShadeArrived(position)
state.isTargetRcvd = false
return
}
if (settings?.invertPosition == true) { position = 100 - position }
restartPositionReportTimeout()
state.isTargetRcvd = false
sendTuyaCommand(0x02, 0x02, position, 8)
}
void restartPositionReportTimeout() {
int timeout = settings?.positionReportTimeout as Integer ?: POSITION_UPDATE_TIMEOUT
if (timeout > 100) {
runInMillis(timeout, endOfMovement, [overwrite: true])
}
}
void stopPositionReportTimeout() {
unschedule('endOfMovement')
}
void stepClose(final BigDecimal stepParam = settings?.defaultStepAmount) {
BigDecimal step = Math.max(10, stepParam?.doubleValue() ?: 10)
BigDecimal position = Math.max(0, Math.min(100, ((device.currentValue('position') ?: 100).doubleValue() - step.doubleValue())))
setPosition(position)
}
void stepOpen(final BigDecimal stepParam = settings?.defaultStepAmount) {
BigDecimal step = Math.max(10, stepParam?.doubleValue() ?: 10)
BigDecimal position = Math.max(0, Math.min(100, ((device.currentValue('position') ?: 0).doubleValue() + step.doubleValue())))
setPosition(position)
}
void setSpeed(final BigDecimal speed) {
if (speed < 0 || speed > 100) { return }
sendTuyaCommand(0x69, 0x04, speed.intValue(), 8)
}
void push(final BigDecimal buttonNumber) {
sendEvent(name: 'pushed', value: buttonNumber, isStateChange: true)
switch (buttonNumber) {
case 1: open(); break
case 2: close(); break
case 3: stopPositionChange(); break
case 4: stepOpen(); break
case 5: stepClose(); break
}
}
void endOfMovement() {
updateWindowShadeArrived((device.currentValue('position') ?: 0) as int)
}
// Helpers
private void sendTuyaCommand(int dp, int dpType, int fnCmd, int fnCmdLength) {
String dpHex = zigbee.convertToHexString(dp, 2)
String dpTypeHex = zigbee.convertToHexString(dpType, 2)
String fnCmdHex = zigbee.convertToHexString(fnCmd, fnCmdLength)
String message = zigbee.convertToHexString(new Random().nextInt(65536), 4) + dpHex + dpTypeHex + zigbee.convertToHexString((fnCmdLength / 2) as int, 4) + fnCmdHex
sendZigbeeCommands(zigbee.command(CLUSTER_TUYA, ZIGBEE_COMMAND_SET_DATA, message))
}
private void logInfo(final String text) {
if (settings?.enableInfoLog == true || settings?.enableInfoLog == 'true') {
log.info "${device.displayName} " + text
}
}
private void logDebug(final String text) {
if (settings?.enableDebugLog == true || settings?.enableDebugLog == 'true') {
log.debug "${device.displayName} " + text
}
}
void sendZigbeeCommands(List<String> cmd) {
hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction()
cmd.each {
allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE))
}
sendHubCommand(allActions)
}
private void checkHealthStatusConfiguration() {
final int healthMethod = (settings.healthCheckMethod as Integer) ?: 0
if (healthMethod == 1 || healthMethod == 2) {
final int interval = (settings.healthCheckInterval as Integer) ?: 0
if (interval > 0) {
scheduleDeviceHealthCheck(interval, healthMethod)
}
} else {
unschedule('deviceHealthCheck')
}
}
private void setHealthStatusOnline() {
if (!((device.currentValue('healthStatus') ?: 'unknown') in ['online'])) {
sendHealthStatusEvent('online')
}
}
private void sendHealthStatusEvent(final String value) {
String descriptionText = "healthStatus changed to ${value}"
sendEvent(name: 'healthStatus', value: value, descriptionText: descriptionText, isStateChange: true, type: 'digital')
if (value == 'online') { logInfo "${descriptionText}" }
}
void unscheduleCommandTimeoutCheck() {
unschedule('deviceCommandTimeout')
}
void ping() {
if (state.states == null) { state.states = [:] }
state.states['isPing'] = true
sendZigbeeCommands(zigbee.readAttribute(zigbee.BASIC_CLUSTER, PING_ATTR_ID, [:], 0))
}
void handlePingResponse() {
state.states['isPing'] = false
}
String getCron(int timeInSeconds) {
int minutes = (timeInSeconds / 60) as int
int hours = (minutes / 60) as int
if (hours > 23) { hours = 23 }
return minutes < 60 ? "0 */$minutes * * * ?" : "0 0 */$hours * * ?"
}
private void scheduleDeviceHealthCheck(final int intervalMins, final int healthMethod) {
String cron = getCron(intervalMins * 60)
schedule(cron, 'deviceHealthCheck')
}
private void deviceHealthCheck() {
if (settings?.healthCheckMethod as int == 2) {
ping()
}
}
