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) | |
| Correct open/close sense with invert toggle | |
| Pause now correctly returns and stays "partially open" | |
| Stops opening↔partial flicker during travel reporting | |
Adds partialState for advanced automations |
Quick Setup (Important)
- Assign this driver to your curtain device
- 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
- Press CONFIGURE
- 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.