[Release] Yooksmart / Graywind / Yoolax Shade and Curtain Driver (Custom)

New Community Driver

Yooksmart / Yoolax / Graywind Curtain Motor (Zigbee)
Enhanced calibration + stable pause handling

This is a modified version of the original driver by Matt Werline (2021, Apache-2.0 licensed).

The original version worked well, but I needed additional reliability and correctness for real-world curtain behavior, so this version:

Key Improvements

Feature Status
True 0–100% mapping (even if motor reports 1/99) :heavy_check_mark:
Correct open/close sense with invert toggle :heavy_check_mark:
Pause now correctly returns and stays "partially open" :heavy_check_mark:
Stops opening↔partial flicker during travel reporting :heavy_check_mark:
Adds partialState for advanced automations :heavy_check_mark:

Quick Setup (Important)

  1. Assign this driver to your curtain device
  2. Set preferences under Device Page → Preferences
  • Raw % at Fully Open (ex: 1)
  • Raw % at Fully Closed (ex: 99)
  • Enable Invert Direction if open/close feels backward
  1. Press CONFIGURE
  2. Manually run one full movement learning cycle:
Open → Close → Open

After calibration:

curtain state windowShade partialState
fully open open open
fully closed closed closed
moving up opening partiallyOpen
moving down closing partiallyClosed
paused mid way partially open retains last direction

Driver Download / Paste-In Version

/**
 *  Yooksmart / Yoolax / Graywind Curtain Motor (Zigbee)
 *  Hubitat Community Enhanced Driver – Calibration + Stable Pause State
 *
 *  ──────────────────────────────────────────────────────────────────────────────
 *   LICENSE + ORIGINAL AUTHORSHIP
 *  ──────────────────────────────────────────────────────────────────────────────
 *  Original Work (C) 2021 Matt Werline
 *  Modified Version (C) 2025 – enhancements by Eric (Community Release)
 *
 *  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 at:  http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Software is provided on an "AS IS" BASIS, without warranties of any kind.
 *  Redistributions must retain this header + license notice.
 *
 *  ──────────────────────────────────────────────────────────────────────────────
 *   QUICK SETUP – READ THIS FIRST
 *  ──────────────────────────────────────────────────────────────────────────────
 *  1) Install the driver and assign it to your curtain device in Hubitat
 *  2) Open the device page and set the following preferences:
 *       • Raw % at FULLY OPEN  → (example: 1)
 *       • Raw % at FULLY CLOSED → (example: 99)
 *       • Enable "Invert Direction" if Open/Close seem reversed
 *  3) Press CONFIGURE on device page
 *  4) Run one full Open → Close → Open cycle to calibrate
 *
 *  After that:
 *     • level & position = clean 0–100% range
 *     • windowShade correctly reports opening/closing/stopped
 *     • pause() stops movement AND correctly reports partially open
 *
 *  ──────────────────────────────────────────────────────────────────────────────
 *   MAJOR ENHANCEMENTS IN THIS VERSION
 *  ──────────────────────────────────────────────────────────────────────────────
 *  ✔ TRUE 0–100 position mapping even when motor reports 1/99 endpoints
 *  ✔ Invert-direction toggle (no need to physically rotate motor)
 *  ✔ pause() and stopPositionChange() correctly return "partially open"
 *  ✔ No more opening↔partially-open flicker during movement reports
 *  ✔ Adds partialState for advanced automations:
 *      • partiallyOpen  (moving upward and stopped)
 *      • partiallyClosed (moving downward and stopped)
 *  ✔ Still fully compatible with Hubitat "Window Shade" capability
 *
 *  You may use, modify, and redistribute this driver under Apache-2.0.
 *  Contributions, feedback & improvements welcome.
 */

import hubitat.zigbee.zcl.DataType

metadata {
    definition(name: "Yooksmart Calibrated Curtain", namespace: "custom", author: "ChatGPT") {
        capability "Actuator"
        capability "Configuration"
        capability "Refresh"
        capability "Window Shade"
        capability "Health Check"
        capability "Switch Level"
        capability "Battery"

        command "pause"
        command "stopPositionChange"

        attribute "lastCheckin", "String"
        attribute "deviceLevel", "number"
        attribute "position", "number"
        attribute "partialState", "string"

        fingerprint deviceJoinName: "Yooksmart / Graywind Curtain", profileId: "0104", inClusters: "0000,0001,0003,0102", outClusters: "0003,0019", manufacturer: "yooksmart"
    }

    preferences {
        input name: "openRaw", type: "number", title: "Raw % the motor reports at FULLY OPEN", range: "0..100", defaultValue: 100
        input name: "closeRaw", type: "number", title: "Raw % the motor reports at FULLY CLOSED", range: "0..100", defaultValue: 0
        input name: "invertDirection", type: "bool", title: "Invert direction (if Open/Close are backwards)", defaultValue: false
        input name: "maxReportingLift", type: "number", title: "Advanced: Max report time for lift (sec)", defaultValue: 600
        input name: "maxReportingBattery", type: "number", title: "Advanced: Max report time for battery (sec)", defaultValue: 21600
        input name: "debugOutput", type: "bool", title: "Enable debug logging?", defaultValue: true
        input name: "descTextOutput", type: "bool", title: "Enable descriptionText logging?", defaultValue: true
    }
}

private getCLUSTER_BASIC() { 0x0000 }
private getCLUSTER_POWER_CONFIGURATION() { 0x0001 }
private getCLUSTER_WINDOW_COVERING() { 0x0102 }

private getATTRIBUTE_POSITION_LIFT() { 0x0008 }
private getATTRIBUTE_BATTERY_PERCENTAGE_REMAINING() { 0x0021 }

private getCOMMAND_OPEN() { 0x00 }
private getCOMMAND_CLOSE() { 0x01 }
private getCOMMAND_PAUSE() { 0x02 }
private getCOMMAND_GOTO_LIFT_PERCENTAGE() { 0x05 }

List<Map> collectAttributes(Map descMap) {
    List<Map> descMaps = new ArrayList<Map>()
    descMaps.add(descMap)
    if (descMap.additionalAttrs) {
        descMaps.addAll(descMap.additionalAttrs)
    }
    return descMaps
}

// ---- PARSE ----
def parse(String description) {
    if (debugOutput) log.debug "parse(): ${description}"
    def now = new Date().format("yyyy MMM dd EEE h:mm:ss a", location.timeZone)
    sendEvent(name: "lastCheckin", value: now)

    if (!description) return

    if (description?.startsWith("read attr -")) {
        Map descMap = zigbee.parseDescriptionAsMap(description)

        if (descMap?.clusterInt == CLUSTER_WINDOW_COVERING && descMap.value) {
            List<Map> descMaps = collectAttributes(descMap)
            def liftMap = descMaps.find { it.attrInt == ATTRIBUTE_POSITION_LIFT }
            if (liftMap && liftMap.value) {
                Integer raw = zigbee.convertHexToInt(liftMap.value)
                levelEventHandler(raw)
            }
        } else if (descMap?.clusterInt == CLUSTER_POWER_CONFIGURATION && descMap.value) {
            def val = Integer.parseInt(descMap.value, 16)
            def batt = convertBatteryLevel(val)
            sendEvent(name: "battery", value: batt, unit: "%")
        }
    }
}

// ---- BATTERY ----
private int convertBatteryLevel(raw) {
    int result = Math.round(raw / 2)
    if (result < 0) result = 0
    if (result > 100) result = 100
    return result
}

// ---- LEVEL HANDLING ----
def levelEventHandler(Integer rawLevel) {
    if (debugOutput) log.debug "levelEventHandler(): raw=${rawLevel}"

    // Previous *normalized* (0–100) position we exposed to Hubitat
    Integer lastNorm = device.currentValue("position") as Integer
    if (lastNorm == null) lastNorm = 0

    // Store raw from device for debugging/inspection
    sendEvent(name: "deviceLevel", value: rawLevel)

    // Normalised 0–100 value (after invert + open/close calibration)
    Integer norm = normaliseLevel(rawLevel)
    if (debugOutput) log.debug "levelEventHandler(): norm=${norm}, lastNorm=${lastNorm}"

    // Push user-facing level/position
    setReportedLevel(norm)

    String shade       = device.currentValue("windowShade") ?: "closed"
    String partialFlag = device.currentValue("partialState") ?: "unknown"

    // If user manually paused/stopped in the middle, keep reporting "partially open"
    // and don't flip back to opening/closing on extra position reports.
    if (state.manualStopped && norm > 0 && norm < 100) {
        if (debugOutput) {
            log.debug "manualStopped=true; ignoring movement state update at norm=${norm}. " +
                      "Keeping windowShade='${shade}', partialState='${partialFlag}'"
        }
        // We *do* still update position via setReportedLevel() above,
        // we just don't change shade/partialState here.
        return
    }

    if (norm <= 0) {
        // Fully closed
        shade       = "closed"
        partialFlag = "closed"
        state.lastDir = null

    } else if (norm >= 100) {
        // Fully open
        shade       = "open"
        partialFlag = "open"
        state.lastDir = null

    } else {
        // Somewhere between 0 and 100
        if (norm > lastNorm) {
            // Movement toward OPEN
            shade       = "opening"
            partialFlag = "partiallyOpen"
            state.lastDir = "open"

        } else if (norm < lastNorm) {
            // Movement toward CLOSED
            shade       = "closing"
            partialFlag = "partiallyClosed"
            state.lastDir = "close"

        } else {
            // Same normalized position as last report:
            // do NOT change shade / partialFlag here.
            // This avoids flicker between opening/closing and partially open.
            if (debugOutput) log.debug "levelEventHandler(): norm unchanged; keeping shade='${shade}', partialState='${partialFlag}'"
        }
    }

    sendEvent(name: "windowShade", value: shade)
    sendEvent(name: "partialState", value: partialFlag)
}

// convert device's raw 0–100 (or 100–0) into 0–100 for Hubitat
private Integer normaliseLevel(Integer raw) {
    Integer open = (openRaw != null) ? openRaw.toInteger() : 100
    Integer close = (closeRaw != null) ? closeRaw.toInteger() : 0

    if (invertDirection) {
        raw = 100 - raw
    }

    if (open == close) return raw

    // clamp
    if (raw <= close) return 0
    if (raw >= open) return 100

    BigDecimal ratio = (raw - close) * 100.0 / (open - close)
    int result = Math.round(ratio as float)
    if (result < 0) result = 0
    if (result > 100) result = 100
    return result
}

// sends the "user facing" level/position (0–100) to Hubitat
def setReportedLevel(Integer level) {
    if (level == null) return

    // snap tiny errors to ends
    if (level <= 1)  level = 0
    if (level >= 99) level = 100

    sendEvent(name: "level", value: level)
    sendEvent(name: "position", value: level)
}

// ---- COMMANDS FROM HUB ----
def open() {
    if (descTextOutput) log.info "Command: open()"
    setHardLevel(100)
}

def close() {
    if (descTextOutput) log.info "Command: close()"
    setHardLevel(0)
}

def setLevel(value, rate = null) {
    if (descTextOutput) log.info "Command: setLevel(${value})"
    setHardLevel(value as Integer)
}

def setPosition(value) {
    if (descTextOutput) log.info "Command: setPosition(${value})"
    setHardLevel(value as Integer)
}

def pause() {
    if (descTextOutput) log.info "Command: pause()"
    def cmds = zigbee.command(CLUSTER_WINDOW_COVERING, COMMAND_PAUSE)
    handleStopped()
    return cmds
}

def stopPositionChange() {
    if (descTextOutput) log.info "Command: stopPositionChange()"
    // Reuse pause logic so both Stop and Pause behave identically
    return pause()
}

private def handleStopped() {
    Integer pos = device.currentValue("position") as Integer

    if (pos != null && pos > 0 && pos < 100) {
        // Only when we are physically between fully open and fully closed
        sendEvent(name: "windowShade", value: "partially open")

        if (state.lastDir == "open") {
            sendEvent(name: "partialState", value: "partiallyOpen")
        } else if (state.lastDir == "close") {
            sendEvent(name: "partialState", value: "partiallyClosed")
        }
    } else if (pos != null && pos <= 0) {
        sendEvent(name: "windowShade", value: "closed")
        sendEvent(name: "partialState", value: "closed")
    } else if (pos != null && pos >= 100) {
        sendEvent(name: "windowShade", value: "open")
        sendEvent(name: "partialState", value: "open")
    }

    state.lastDir = null
    state.manualStopped = true
}

// core command that maps a desired 0–100 level to the motor's raw space
private def setHardLevel(Integer hubLevel) {
    if (hubLevel == null) hubLevel = 0
    if (hubLevel < 0) hubLevel = 0
    if (hubLevel > 100) hubLevel = 100
    
    // New commanded move: no longer in a paused/manual stop state
    state.manualStopped = false

    Integer open = (openRaw != null) ? openRaw.toInteger() : 100
    Integer close = (closeRaw != null) ? closeRaw.toInteger() : 0

    // reverse transform of normaliseLevel()
    int raw
    if (hubLevel <= 0) {
        raw = close
    } else if (hubLevel >= 100) {
        raw = open
    } else {
        BigDecimal scaled = (hubLevel * (open - close) / 100.0) + close
        raw = Math.round(scaled as float)
    }

    if (invertDirection) {
        raw = 100 - raw
    }

    if (debugOutput) log.debug "setHardLevel(): hubLevel=${hubLevel}, raw=${raw} (openRaw=${open}, closeRaw=${close}, invert=${invertDirection})"

    String rawHex = zigbee.convertToHexString(raw, 2)
    // ask device to move to the given lift percentage
    return zigbee.command(CLUSTER_WINDOW_COVERING, COMMAND_GOTO_LIFT_PERCENTAGE, rawHex)
}

// ---- REFRESH / CONFIGURE ----
def refresh() {
    if (descTextOutput) log.info "Refresh requested"

    def cmds = []
    cmds += zigbee.readAttribute(CLUSTER_WINDOW_COVERING, ATTRIBUTE_POSITION_LIFT)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CONFIGURATION, ATTRIBUTE_BATTERY_PERCENTAGE_REMAINING)
    return delayBetween(cmds, 300)
}

def configure() {
    if (descTextOutput) log.info "configure() called"

    Integer liftMax = (maxReportingLift != null) ? maxReportingLift.toInteger() : 600
    Integer battMax = (maxReportingBattery != null) ? maxReportingBattery.toInteger() : 21600

    def cmds = []

    // reporting for lift position
    cmds += zigbee.configureReporting(CLUSTER_WINDOW_COVERING, ATTRIBUTE_POSITION_LIFT,
            DataType.UINT8, 1, liftMax, 0x01)

    // reporting for battery%
    cmds += zigbee.configureReporting(CLUSTER_POWER_CONFIGURATION, ATTRIBUTE_BATTERY_PERCENTAGE_REMAINING,
            DataType.UINT8, 600, battMax, 0x01)

    cmds += refresh()

    if (debugOutput) {
        log.debug "configure(): reporting liftMax=${liftMax}, battMax=${battMax}"
        runIn(1800, "logsOff")
    }

    return cmds
}

def installed() {
    if (debugOutput) log.debug "installed()"
    if (device.currentValue("windowShade") == null) {
        sendEvent(name: "windowShade", value: "closed")
    }
}

def updated() {
    if (debugOutput) log.debug "updated()"
    unschedule()
    if (debugOutput) {
        runIn(1800, "logsOff")
        log.debug "Debug logging will be disabled automatically in 30 minutes."
    }
}

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

License + Notes

  • Original © 2021 Matt Werline
  • Modifications © 2025
  • Published under Apache License 2.0
  • Free to use, modify, and redistribute

I’m not planning ongoing support or feature development, but posting this so others can benefit. Feel free to fork, improve, or maintain your own variant.

3 Likes