Optimized driver for LY-1668 Zigbee curtain rod robot motor TS0601

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

2 Likes