Modified Driver
/**
* Copyright 2021
*
* 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 of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* This DTH is coded based on iquix's tuya-window-shade DTH and these other files:
* https://github.com/iquix/Smartthings/blob/master/devicetypes/iquix/tuya-window-shade.src/tuya-window-shade.groovy
* https://raw.githubusercontent.com/shin4299/XiaomiSJ/master/devicetypes/shinjjang/zemismart-zigbee-blind.src/zemismart-zigbee-blind.groovy
* https://templates.blakadder.com/zemismart_YH002.html
* https://github.com/zigpy/zha-device-handlers/blob/f3302257fbb57f9f9f99ecbdffdd2e7862cc1fd7/zhaquirks/tuya/__init__.py#L846
*
* VERSION HISTORY
* 3.0.0 (2021-06-18) [Amos Yuen] - Support new window shade command startPositionChange()
* - Rename stop() to stopPositionChange()
* - Handle ack and set time zigbee messages
* 2.3.0 (2021-06-09) [Amos Yuen] - Add presence attribute to indicate whether device is responsive
* 2.2.0 (2021-06-06) [Amos Yuen] - Add commands for stepping
* - Fix push command not sending zigbee commands
* 2.1.0 (2021-05-01) [Amos Yuen] - Add pushable button capability
* - Add configurable close and open position thresholds
* 2.0.0 (2021-03-09) [Amos Yuen] - Change tilt mode open()/close() commands to use set position
* to open/close all the way.
* - Rename pause() to stop()
* - Remove superfluous setDirection() setMode() functions
* 1.0.0 (2021-03-09) [Amos Yuen] - Initial Commit
*/
import groovy.json.JsonOutput
import groovy.transform.Field
import hubitat.zigbee.zcl.DataType
import hubitat.helper.HexUtils
private def textVersion() {
return "3.0.0 - 2021-06-18"
}
private def textCopyright() {
return "Copyright ©2021\nAmos Yuen, iquix, ShinJjang"
}
metadata {
definition(name: "ZemiSmart Zigbee Blind", namespace: "amosyuen", author: "Amos Yuen",
ocfDeviceType: "oic.d.blind", vid: "generic-shade") {
capability "Actuator"
capability "Configuration"
capability "Presence Sensor"
capability "PushableButton"
capability "Window Shade"
capability "Refresh"
capability "Switch"
capability "SwitchLevel"
capability "Battery"
attribute "speed", "integer"
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. Defaults to defaultStepAmount if not set."]]
command "stepOpen", [[
name: "step",
type: "NUMBER",
description: "Amount to change position towards open. Defaults to defaultStepAmount if not set."]]
command "setSpeed", [[
name: "speed*",
type: "NUMBER",
description: "Motor speed (0 to 100). Values below 5 may not work."]]
fingerprint(endpointId: "01", profileId: "0104",
inClusters: "0000, 0003, 0004, 0005, 0006", outClusters: "0019",
manufacturer: "_TYST11_wmcdj3aq", model: "mcdj3aq",
deviceJoinName: "Zemismart Zigbee Blind")
fingerprint(endpointId: "01", profileId: "0104",
inClusters: "0000, 0003, 0004, 0005, 0006", outClusters: "0019",
manufacturer: "_TYST11_cowvfr", model: "owvfni3",
deviceJoinName: "Zemismart Zigbee Blind")
fingerprint(endpointId: "01", profileId: "0104",
inClusters: "0000, 0003, 0004, 0005, 0006", outClusters: "0019",
deviceJoinName: "Zemismart Zigbee Blind")
// AM43-0.45/40-ES-EZ
fingerprint(endpointId: "01", profileId: "0104",
inClusters: "0000, 0004, 0005, EF00", outClusters: "0019, 000A",
manufacturer: "_TZE200_zah67ekd", model: "TS0601",
deviceJoinName: "Zemismart Zigbee Blind Motor")
}
preferences {
input("mode", "enum", title: "Mode",
description: "<li><b>lift</b> - motor moves until button pressed again</li>"
+ "<li><b>tilt</b> - pressing button < 1.5s, movement stops on release"
+ "; pressing button > 1.5s, motor moves until button pressed again</li>",
options: MODE_MAP, required: true, defaultValue: "1")
input("direction", "enum", title: "Direction",
options: DIRECTION_MAP, required: true, defaultValue: 0)
input("maxClosedPosition", "number", title: "Max Closed Position",
description: "The max position value that window shade state should be set to closed",
required: true, defaultValue: 1)
input("minOpenPosition", "number", title: "Min Open Position",
description: "The min position value that window shade state should be set to open",
required: true, defaultValue: 99)
input("defaultStepAmount", "number", title: "Default Step Amount",
description: "The default step amount",
required: true, defaultValue: 5)
input("enableDebugLog", "bool", title: "Enable debug logging", required: true, defaultValue: false)
input("enableTraceLog", "bool", title: "Enable trace logging", required: true, defaultValue: false)
input("enableUnexpectedMessageLog", "bool", title: "Log unexpected messages", required: true, defaultValue: false)
}
}
@Field final String MODE_TILT = "0"
@Field final Map MODE_MAP = [1: "lift", 0: "tilt"]
@Field final Map MODE_MAP_REVERSE = MODE_MAP.collectEntries { [(it.value): it.key] }
@Field final List MODES = MODE_MAP.collect { it.value }
@Field final Map DIRECTION_MAP = [0: "forward", 1: "reverse"]
@Field final Map DIRECTION_MAP_REVERSE = DIRECTION_MAP.collectEntries { [(it.value): it.key] }
@Field final List DIRECTIONS = DIRECTION_MAP.collect { it.value }
@Field final int CHECK_FOR_RESPONSE_INTERVAL_SECONDS = 60
@Field final int HEARTBEAT_INTERVAL_SECONDS = 4000 // a little more than 1 hour
//
// Life Cycle
//
def installed() {
configure()
}
def updated() {
configure()
}
def configure() {
logDebug("configure")
state.version = textVersion()
state.remove("copyright")
sendEvent(name: "battery", value: 0)
state.copyright = textCopyright()
if (atomicState.lastHeardMillis == null) {
atomicState.lastHeardMillis = 0
}
sendEvent(name: "numberOfButtons", value: 5)
if (device.currentPosition != null
&& (device.currentWindowShade == "closed"
|| device.currentWindowShade == "open"
|| device.currentWindowShade == "partially open")) {
updateWindowShadeArrived(device.currentPosition)
}
// Must run async otherwise, one will block the other
runIn(1, setMode)
runIn(2, setDirection)
if (maxClosedPosition < 0 || maxClosedPosition > 100) {
throw new Exception("Invalid maxClosedPosition \"${maxClosedPosition}\" should be between"
+ " 0 and 100 inclusive.")
}
if (minOpenPosition < 0 || minOpenPosition > 100) {
throw new Exception("Invalid minOpenPosition \"${minOpenPosition}\" should be between 0"
+ " and 100 inclusive.")
}
if (maxClosedPosition >= minOpenPosition) {
throw new Exception("maxClosedPosition \"${minOpenPosition}\" must be less than"
+ " minOpenPosition \"${minOpenPosition}\".")
}
}
def setDirection() {
def directionValue = direction as int
logDebug("setDirection: directionText=${DIRECTION_MAP[directionValue]}, directionValue=${directionValue}")
sendTuyaCommand(DP_ID_DIRECTION, DP_TYPE_ENUM, directionValue, 2)
}
def setMode() {
def modeValue = mode as int
logDebug("setMode: modeText=${MODE_MAP[mode]}, modeValue=${modeValue}")
sendTuyaCommand(DP_ID_MODE, DP_TYPE_ENUM, modeValue, 2)
}
//
// Messages
//
@Field final int CLUSTER_TUYA = 0xEF00
@Field final int ZIGBEE_COMMAND_SET_DATA = 0x00
@Field final int ZIGBEE_COMMAND_SET_DATA_RESPONSE = 0x02
@Field final int ZIGBEE_COMMAND_ACK = 0x0B
@Field final int ZIGBEE_COMMAND_SET_TIME = 0x24
@Field final int DP_ID_COMMAND = 0x01
@Field final int DP_ID_TARGET_POSITION = 0x02
@Field final int DP_ID_CURRENT_POSITION = 0x03
@Field final int DP_ID_DIRECTION = 0x05
@Field final int DP_ID_COMMAND_REMOTE = 0x07
@Field final int DP_ID_MODE = 0x65
@Field final int DP_ID_SPEED = 0x69
@Field final int DP_ID_BATTERY = 0x0D
@Field final int DP_TYPE_BOOL = 0x01
@Field final int DP_TYPE_VALUE = 0x02
@Field final int DP_TYPE_ENUM = 0x04
@Field final int DP_COMMAND_OPEN = 0x00
@Field final int DP_COMMAND_STOP = 0x01
@Field final int DP_COMMAND_CLOSE = 0x02
@Field final int DP_COMMAND_CONTINUE = 0x03
def parse(String description) {
if (description == null || (!description.startsWith('catchall:') && !description.startsWith('read attr -'))) {
logUnexpectedMessage("parse: Unhandled description=${description}")
return
}
updatePresence(true)
Map descMap = zigbee.parseDescriptionAsMap(description)
if (descMap.clusterInt != CLUSTER_TUYA) {
logUnexpectedMessage("parse: Not a Tuya Message descMap=${descMap}")
return
}
def command = zigbee.convertHexToInt(descMap.command)
switch (command) {
case ZIGBEE_COMMAND_SET_DATA_RESPONSE: // 0x02
if (!descMap?.data || descMap.data.size() < 7) {
logUnexpectedMessage("parse: Invalid data size for SET_DATA_RESPONSE descMap=${descMap}")
return
}
parseSetDataResponse(descMap)
return
case ZIGBEE_COMMAND_ACK: // 0x0B
if (!descMap?.data || descMap.data.size() < 2) {
logUnexpectedMessage("parse: Invalid data size for ACK descMap=${descMap}")
return
}
def ackCommand = zigbee.convertHexToInt(descMap.data.join())
logTrace("parse: ACK command=${ackCommand}")
return
case ZIGBEE_COMMAND_SET_TIME: // 0x24
// Data payload seems to increment every hour but doesn't seem to be an absolute value
logTrace("parse: SET_TIME data=${descMap.data}")
return
default:
logUnexpectedMessage("parse: Unhandled command=${command} descMap=${descMap}")
return
}
}
/*
* Data (sending and receiving) generally have this format:
* [2 bytes] (packet id)
* [1 byte] (dp ID)
* [1 byte] (dp type)
* [2 bytes] (fnCmd length in bytes)
* [variable bytes] (fnCmd)
*/
def parseSetDataResponse(descMap) {
logTrace("parseSetDataResponse: descMap=${descMap}")
def data = descMap.data
def dp = zigbee.convertHexToInt(data[2])
def dataValue = zigbee.convertHexToInt(data[6..-1].join())
switch (dp) {
case DP_ID_COMMAND: // 0x01 Command
switch (dataValue) {
case DP_COMMAND_OPEN: // 0x00
logDebug("parse: opening")
updateWindowShadeOpening()
return
case DP_COMMAND_STOP: // 0x01
logDebug("parse: stopping")
return
case DP_COMMAND_CLOSE: // 0x02
logDebug("parse: closing")
updateWindowShadeClosing()
return
case DP_COMMAND_CONTINUE: // 0x03
logDebug("parse: continuing")
return
default:
logUnexpectedMessage("parse: Unexpected DP_ID_COMMAND dataValue=${dataValue}")
return
}
case DP_ID_TARGET_POSITION: // 0x02 Target position
if (dataValue >= 0 && dataValue <= 100) {
logDebug("parse: moving to position ${dataValue}")
updateWindowShadeMoving(dataValue)
updatePosition(dataValue)
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_TARGET_POSITION dataValue=${dataValue}")
}
return
case DP_ID_CURRENT_POSITION: // 0x03 Current Position
if (dataValue >= 0 && dataValue <= 100) {
logDebug("parse: arrived at position ${dataValue}")
updateWindowShadeArrived(dataValue)
updatePosition(dataValue)
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_CURRENT_POSITION dataValue=${dataValue}")
}
return
case DP_ID_DIRECTION: // 0x05 Direction
def directionText = DIRECTION_MAP[dataValue]
if (directionText != null) {
logDebug("parse: direction=${directionText}")
updateDirection(dataValue)
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_DIRECTION dataValue=${dataValue}")
}
return
case DP_ID_COMMAND_REMOTE: // 0x07 Remote Command
if (dataValue == 0) {
logDebug("parse: opening from remote")
updateWindowShadeOpening()
} else if (dataValue == 1) {
logDebug("parse: closing from remote")
updateWindowShadeClosing()
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_COMMAND_REMOTE dataValue=${dataValue}")
}
return
case DP_ID_MODE: // 0x65 Mode
def modeText = MODE_MAP[dataValue]
if (modeText != null) {
logDebug("parse: mode=${modeText}")
updateMode(dataValue)
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_MODE dataValue=${dataValue}")
}
return
case DP_ID_SPEED: // 0x69 Motor speed
if (dataValue >= 0 && dataValue <= 100) {
logDebug("parse: speed=${dataValue}")
updateSpeed(dataValue)
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_SPEED dataValue=${dataValue}")
}
return
case DP_ID_BATTERY: // 0xOD Battery
if (dataValue >= 0 && dataValue <= 100) {
logDebug("parse: battery=${dataValue}")
updateBattery(dataValue)
} else {
logUnexpectedMessage("parse: Unexpected DP_ID_BATTERY dataValue=${dataValue}")
}
return
default:
logUnexpectedMessage("parse: Unknown DP_ID dp=0x${data[2]}, dataType=0x${data[3]} dataValue=${dataValue}")
return
}
}
private ignorePositionReport(position) {
def lastPosition = device.currentPosition
logDebug("ignorePositionReport: position=${position}, lastPosition=${lastPosition}")
if (lastPosition == "undefined" || isWithinOne(position)) {
logTrace("Ignore invalid reports")
return true
}
return false
}
private isWithinOne(position) {
def lastPosition = device.currentPosition
logTrace("isWithinOne: position=${position}, lastPosition=${lastPosition}")
if (lastPosition != "undefined" && Math.abs(position - lastPosition) <= 1) {
return true
}
return false
}
private updateDirection(directionValue) {
def directionText = DIRECTION_MAP[directionValue]
logDebug("updateDirection: directionText=${directionText}, directionValue=${directionValue}")
if (directionValue != (direction as int)) {
setDirection()
}
}
private updateMode(modeValue) {
def modeText = MODE_MAP[modeValue]
logDebug("updateMode: modeText=${modeText}, modeValue=${modeValue}")
if (modeValue != (mode as int)) {
setMode()
}
}
private updatePosition(position) {
logDebug("updatePosition: position=${position}")
if (isWithinOne(position)) {
return
}
sendEvent(name: "position", value: position)
}
private updatePresence(present) {
logDebug("updatePresence: present=${present}")
if (present) {
atomicState.lastHeardMillis = now()
checkHeartbeat()
}
atomicState.waitingForResponseSinceMillis = null
sendEvent(name: "presence", value: present ? "present" : "not present")
}
private updateSpeed(speed) {
logDebug("updateSpeed: speed=${speed}")
sendEvent(name: "speed", value: speed)
}
private updateBattery(battery) {
logDebug("updateBattery: battery=${battery}")
sendEvent(name: "battery", value: battery)
}
private updateWindowShadeMoving(position) {
def lastPosition = device.currentPosition
logDebug("updateWindowShadeMoving: position=${position}, lastPosition=${lastPosition}")
if (lastPosition < position) {
updateWindowShadeOpening()
} else if (lastPosition > position) {
updateWindowShadeClosing()
}
}
private updateWindowShadeOpening() {
logTrace("updateWindowShadeOpening")
sendEvent(name:"windowShade", value: "opening")
}
private updateWindowShadeClosing() {
logTrace("updateWindowShadeClosing")
sendEvent(name:"windowShade", value: "closing")
}
private updateWindowShadeArrived(position) {
logDebug("updateWindowShadeArrived: position=${position}")
if (position < 0 || position > 100) {
log.warn("updateWindowShadeArrived: Need to setup limits on device")
sendEvent(name: "windowShade", value: "unknown")
} else if (position <= maxClosedPosition) {
sendEvent(name: "windowShade", value: "closed")
} else if (position >= minOpenPosition) {
sendEvent(name: "windowShade", value: "open")
} else {
sendEvent(name: "windowShade", value: "partially open")
}
}
//
// Actions
//
def refresh()
{
logTrace "Refresh called..."
zigbee.onOffRefresh()
}
def close() {
logDebug("close")
if (mode == MODE_TILT) {
setPosition(0)
} else {
sendEvent(name: "position", value: 0)
sendTuyaCommand(DP_ID_COMMAND, DP_TYPE_ENUM, DP_COMMAND_CLOSE, 2)
}
}
def off()
{
close()
}
def open() {
logDebug("open")
if (mode == MODE_TILT) {
setPosition(100)
} else {
sendEvent(name: "position", value: 100)
sendTuyaCommand(DP_ID_COMMAND, DP_TYPE_ENUM, DP_COMMAND_OPEN, 2)
}
}
def on()
{
open()
}
def startPositionChange(state) {
logDebug("startPositionChange")
switch (state) {
case "close":
close()
return
case "open":
open()
return
default:
throw new Exception("Unsupported startPositionChange state \"${state}\"")
}
}
def stopPositionChange() {
logDebug("stopPositionChange")
sendTuyaCommand(DP_ID_COMMAND, DP_TYPE_ENUM, DP_COMMAND_STOP, 2)
}
def setPosition(position) {
logDebug("setPosition: position=${position}")
if (position < 0 || position > 100) {
throw new Exception("Invalid position ${position}. Position must be between 0 and 100 inclusive.")
}
if (isWithinOne(position)) {
// Motor is off by one sometimes, so set it to desired value if within one
sendEvent(name: "position", value: position)
}
sendTuyaCommand(DP_ID_TARGET_POSITION, DP_TYPE_VALUE, position.intValue(), 8)
}
def setLevel(level, duration = null)
{
setPosition(level)
}
def stepClose(step) {
if (!step) {
step = defaultStepAmount
}
stepOpen(-step)
}
def stepOpen(step) {
logDebug("stepOpen: step=${step}")
if (!step) {
step = defaultStepAmount
}
setPosition(Math.max( 0, Math.min(100, (device.currentPosition + step) as int)))
}
def setSpeed(speed) {
logDebug("setSpeed: speed=${speed}")
if (speed < 0 || speed > 100) {
throw new Exception("Invalid speed ${speed}. Speed must be between 0 and 100 inclusive.")
}
sendTuyaCommand(DP_ID_SPEED, DP_TYPE_ENUM, speed.intValue(), 8)
}
def push(buttonNumber) {
logTrace("push: buttonNumber=${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
default:
throw new Exception("Unsupported buttonNumber \"${buttonNumber}\"")
}
}
//
// Helpers
//
private sendTuyaCommand(int dp, int dpType, int fnCmd, int fnCmdLength) {
atomicState.waitingForResponseSinceMillis = now()
checkForResponse()
def dpHex = zigbee.convertToHexString(dp, 2)
def dpTypeHex = zigbee.convertToHexString(dpType, 2)
def fnCmdHex = zigbee.convertToHexString(fnCmd, fnCmdLength)
logTrace("sendTuyaCommand: dp=0x${dpHex}, dpType=0x${dpTypeHex}, fnCmd=0x${fnCmdHex}, fnCmdLength=${fnCmdLength}")
def message = (randomPacketId().toString()
+ dpHex
+ dpTypeHex
+ zigbee.convertToHexString((fnCmdLength / 2) as int, 4)
+ fnCmdHex)
logTrace("sendTuyaCommand: message=${message}")
zigbee.command(CLUSTER_TUYA, ZIGBEE_COMMAND_SET_DATA, message)
}
private randomPacketId() {
return zigbee.convertToHexString(new Random().nextInt(65536), 4)
}
// Must be non-private to use runInMillis
def checkForResponse() {
logTrace("checkForResponse: waitingForResponseSinceMillis=${atomicState.waitingForResponseSinceMillis}")
if (atomicState.waitingForResponseSinceMillis == null) {
return
}
def waitMillis = (CHECK_FOR_RESPONSE_INTERVAL_SECONDS * 1000
- (now() - atomicState.waitingForResponseSinceMillis))
logTrace("checkForResponse: waitMillis=${waitMillis}")
if (waitMillis <= 0) {
updatePresence(false)
} else {
runInMillis(waitMillis, checkForResponse)
}
}
// Must be non-private to use runInMillis
def checkHeartbeat() {
def waitMillis = (HEARTBEAT_INTERVAL_SECONDS * 1000
- (now() - atomicState.lastHeardMillis))
logTrace("checkHeartbeat: waitMillis=${waitMillis}")
if (waitMillis <= 0) {
updatePresence(false)
} else {
runInMillis(waitMillis, checkHeartbeat)
}
}
private logDebug(text) {
if (!enableDebugLog) {
return
}
log.debug(text)
}
private logTrace(text) {
if (!enableTraceLog) {
return
}
log.trace(text)
}
private logUnexpectedMessage(text) {
if (!enableUnexpectedMessageLog) {
return
}
log.warn(text)
}