/** * * Copyright 2019 SmartThings * * 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. */ import groovy.json.JsonOutput import hubitat.zigbee.zcl.DataType metadata { definition(name: "ZigBee Window Shade", namespace: "smartthings", author: "SmartThings", ocfDeviceType: "oic.d.blind", mnmn: "SmartThings", vid: "generic-shade") { capability "Actuator" capability "Configuration" capability "Refresh" capability "WindowShade" // capability "Window Shade Level" // capability "Window Shade Preset" capability "HealthCheck" capability "SwitchLevel" capability "Switch" command "pause" attribute "level", "number" attribute "shadeLevel", "number" fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0102", outClusters: "0019", model: "E2B0-KR000Z0-HA", deviceJoinName: "eZEX Window Treatment" // SY-IoT201-BD //SOMFY Blind Controller/eZEX fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0005, 0006, 0008, 0102", outClusters: "000A", manufacturer: "Feibit Co.Ltd", model: "FTB56-ZT218AK1.6", deviceJoinName: "Wistar Window Treatment" //Wistar Curtain Motor(CMJ) fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0005, 0006, 0008, 0102", outClusters: "000A", manufacturer: "Feibit Co.Ltd", model: "FTB56-ZT218AK1.8", deviceJoinName: "Wistar Window Treatment" //Wistar Curtain Motor(CMJ) fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0005, 0102", outClusters: "0003", manufacturer: "REXENSE", model: "KG0001", deviceJoinName: "Window Treatment" //Smart Curtain Motor(BCM300D) fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0005, 0102", outClusters: "0003", manufacturer: "REXENSE", model: "DY0010", deviceJoinName: "Window Treatment" //Smart Curtain Motor(DT82TV) fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0005, 0102", outClusters: "0003", manufacturer: "SOMFY", model: "Glydea Ultra Curtain", deviceJoinName: "Somfy Window Treatment" //Somfy Glydea Ultra fingerprint profileId: "0104", inClusters: "0000, 0003, 0004, 0005, 0020, 0102", outClusters: "0003", manufacturer: "SOMFY", model: "Sonesse 30 WF Roller", deviceJoinName: "Somfy Window Treatment" // Somfy Sonesse 30 Zigbee LI-ION Pack } preferences { input "reverseDirection", "bool", title:"Reverse shade direction", defaultValue:false input "preset", "number", title: "Preset position", description: "Set the window shade preset position", defaultValue: 50, range: "1..100", required: false, displayDuringSetup: false } } private getCLUSTER_WINDOW_COVERING() { 0x0102 } private getCOMMAND_OPEN() { 0x00 } private getCOMMAND_CLOSE() { 0x01 } private getCOMMAND_PAUSE() { 0x02 } private getCOMMAND_GOTO_LIFT_PERCENTAGE() { 0x05 } private getATTRIBUTE_POSITION_LIFT() { 0x0008 } private getATTRIBUTE_CURRENT_LEVEL() { 0x0000 } private getCOMMAND_MOVE_LEVEL_ONOFF() { 0x04 } private List collectAttributes(Map descMap) { List descMaps = new ArrayList() descMaps.add(descMap) if (descMap.additionalAttrs) { descMaps.addAll(descMap.additionalAttrs) } return descMaps } // Parse incoming device messages to generate events def parse(String description) { //log.debug "description:- ${description}" if (device.currentValue("shadeLevel") == null && device.currentValue("level") != null) { sendEvent(name: "shadeLevel", value: device.currentValue("level"), unit: "%") } if (description?.startsWith("read attr -")) { Map descMap = zigbee.parseDescriptionAsMap(description) if (isBindingTableMessage(description)) { parseBindingTableMessage(description) } else if (supportsLiftPercentage() && descMap?.clusterInt == CLUSTER_WINDOW_COVERING && descMap.value) { log.debug "attr: ${descMap?.attrInt}, value: ${descMap?.value}, descValue: ${Integer.parseInt(descMap.value, 16)}, ${device.getDataValue("model")}" List descMaps = collectAttributes(descMap) def liftmap = descMaps.find { it.attrInt == ATTRIBUTE_POSITION_LIFT } if (liftmap && liftmap.value) { def newLevel = zigbee.convertHexToInt(liftmap.value) if (shouldInvertLiftPercentage()) { // some devices report % level of being closed (instead of % level of being opened) // inverting that logic is needed here to avoid a code duplication newLevel = 100 - newLevel } levelEventHandler(newLevel) } } else if (!supportsLiftPercentage() && descMap?.clusterInt == zigbee.LEVEL_CONTROL_CLUSTER && descMap.value) { def valueInt = Math.round((zigbee.convertHexToInt(descMap.value)) / 255 * 100) levelEventHandler(valueInt) } } } def getLastLevel() { device.currentState("shadeLevel") ? device.currentValue("shadeLevel") : device.currentValue("level") // Try shadeLevel, if not use level and pass to logic below } def levelEventHandler(currentLevel) { //log.debug "levelEventHandle - currentLevel: ${currentLevel} lastLevel: ${lastLevel}" if ((lastLevel == "undefined" || currentLevel == lastLevel) && state.invalidSameLevelEvent) { //Ignore invalid reports log.debug "Ignore invalid reports" } else { state.invalidSameLevelEvent = true sendEvent(name: "shadeLevel", value: currentLevel, unit: "%") sendEvent(name: "level", value: currentLevel, unit: "%", displayed: false) if (currentLevel == 0 || currentLevel == 100) { sendEvent(name: "windowShade", value: currentLevel == 0 ? "closed" : "open") } else { if (lastLevel < currentLevel) { sendEvent([name:"windowShade", value: "opening"]) } else if (lastLevel > currentLevel) { sendEvent([name:"windowShade", value: "closing"]) } runIn(1, "updateFinalState", [overwrite:true]) } } } def updateFinalState() { def level = device.currentValue("shadeLevel") //log.debug "updateFinalState: ${level}" if (level > 0 && level < 100) { sendEvent(name: "windowShade", value: "partially open") } } def supportsLiftPercentage() { device.getDataValue("manufacturer") != "Feibit Co.Ltd" } def off() { close() } def close() { //log.info "close()" zigbee.command(CLUSTER_WINDOW_COVERING, COMMAND_CLOSE) } def on(){ open() } def open() { //log.info "open()" zigbee.command(CLUSTER_WINDOW_COVERING, COMMAND_OPEN) } def setLevel(value, rate = null) { //log.info "setLevel($value)" setShadeLevel(value) } def setPosition(value) { //log.info "setPosition($value)" setShadeLevel(value) } def setShadeLevel(value) { //log.info "setShadeLevel($value)" Integer level = Math.max(Math.min(value as Integer, 100), 0) sendEvent(name:"level", value: level, unit:"%") sendEvent(name:"shadeLevel", value: level, unit:"%") def cmd if (isSomfy() && Math.abs(level - lastLevel) <= GLYDEA_MOVE_THRESHOLD) { state.invalidSameLevelEvent = false } if (supportsLiftPercentage()) { if (shouldInvertLiftPercentage()) { // some devices keeps % level of being closed (instead of % level of being opened) // inverting that logic is needed here level = 100 - level } cmd = zigbee.command(CLUSTER_WINDOW_COVERING, COMMAND_GOTO_LIFT_PERCENTAGE, zigbee.convertToHexString(level, 2)) } else { cmd = zigbee.command(zigbee.LEVEL_CONTROL_CLUSTER, COMMAND_MOVE_LEVEL_ONOFF, zigbee.convertToHexString(Math.round(level * 255 / 100), 2)) } return cmd } def pause() { //log.info "pause()" def currentShadeStatus = device.currentValue("windowShade") if (currentShadeStatus == "open" || currentShadeStatus == "closed") { sendEvent(name: "windowShade", value: currentShadeStatus) } else { zigbee.command(CLUSTER_WINDOW_COVERING, COMMAND_PAUSE) } } def presetPosition() { setShadeLevel(preset ?: 50) } /** * PING is used by Device-Watch in attempt to reach the Device * */ def ping() { return refresh() } def refresh() { //log.info "refresh()" def cmds if (supportsLiftPercentage()) { cmds = zigbee.readAttribute(CLUSTER_WINDOW_COVERING, ATTRIBUTE_POSITION_LIFT) } else { cmds = zigbee.readAttribute(zigbee.LEVEL_CONTROL_CLUSTER, ATTRIBUTE_CURRENT_LEVEL) } return cmds } def installed() { //log.debug "installed" state.invalidSameLevelEvent = true sendEvent(name: "supportedWindowShadeCommands", value: JsonOutput.toJson(["open", "close", "pause"]), displayed: false) } def configure() { def cmds //log.info "configure()" // Device-Watch allows 2 check-in misses from device + ping (plus 2 min lag time) sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID]) //log.debug "Configuring Reporting and Bindings." if (supportsLiftPercentage()) { cmds = zigbee.configureReporting(CLUSTER_WINDOW_COVERING, ATTRIBUTE_POSITION_LIFT, DataType.UINT8, 0, 600, null) } else { cmds = zigbee.levelConfig() } return refresh() + cmds } private def parseBindingTableMessage(description) { Integer groupAddr = getGroupAddrFromBindingTable(description) if (groupAddr) { List cmds = addHubToGroup(groupAddr) cmds?.collect { new hubitat.device.HubAction(it) } } } private Integer getGroupAddrFromBindingTable(description) { //log.info "Parsing binding table - '$description'" def btr = zigbee.parseBindingTableResponse(description) def groupEntry = btr?.table_entries?.find { it.dstAddrMode == 1 } //log.info "Found ${groupEntry}" !groupEntry?.dstAddr ?: Integer.parseInt(groupEntry.dstAddr, 16) } private List addHubToGroup(Integer groupAddr) { ["st cmd 0x0000 0x01 ${CLUSTER_GROUPS} 0x00 {${zigbee.swapEndianHex(zigbee.convertToHexString(groupAddr,4))} 00}", "delay 200"] } private List readDeviceBindingTable() { ["zdo mgmt-bind 0x${device.deviceNetworkId} 0", "delay 200"] } def shouldInvertLiftPercentage() { if(reverseDirection == null) device.updateSetting("reverseDirection",[value:"false",type:"bool"]) if(device.getDataValue("manufacturer") == "SOMFY" || reverseDirection) return true else return false } def isSomfy() { device.getDataValue("manufacturer") == "SOMFY" } private getGLYDEA_MOVE_THRESHOLD() { 3 }