/** * Tuya Zigbee Garage Door Opener driver for Hubitat * * https://community.hubitat.com/t/tuya-zigbee-garage-door-opener/95579 * * 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. * * ver. 1.0.0 2022-06-18 kkossev - Inital test version * ver. 1.0.1 2022-06-19 kkossev - fixed Contact status open/close; added doorTimeout preference, default 15s; improved debug loging; PowerSource capability'; contact open/close status determines door state! * ver. 1.0.2 2022-06-20 kkossev - ignore Open command if the sensor is open; ignore Close command if the sensor is closed. * ver. 1.0.3 2022-06-26 kkossev - fixed new device exceptions bug; warnings in Debug logs only; Debug logs are off by default. * ver. 1.0.4 2022-07-06 kkossev - on() command opens the door if it was closed, off() command closes the door if it was open; 'contact is open/closed' info and warning logs are shown only on contact state change; * ver. 1.0.5 2023-10-09 kkossev - added _TZE204_nklqjk62 fingerprint * */ def version() { "1.0.5" } def timeStamp() {"2022/07/06 8:34 AM"} import hubitat.device.HubAction import hubitat.device.Protocol import groovy.transform.Field import hubitat.zigbee.zcl.DataType @Field static final Boolean debug = false @Field static final Integer pulseTimer = 1000 metadata { definition (name: "Tuya Zigbee Garage Door Opener", namespace: "kkossev", author: "Krassimir Kossev", importUrl: "https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20Zigbee%20Garage%20Door%20Opener/Tuya%20Zigbee%20Garage%20Door%20Opener.groovy", singleThreaded: true ) { capability "Actuator" capability "GarageDoorControl" capability "ContactSensor" capability "Configuration" capability "Switch" capability "PowerSource" if (debug) { command "initialize", [[name: "Manually initialize the device after switching drivers. \n\r ***** Will load device default values! *****" ]] command "setContact", [[name:"Set Contact", type: "ENUM", description: "Select Contact State", constraints: ["--- Select ---", "open", "closed" ]]] } fingerprint profileId:"0104", model:"TS0601", manufacturer:"_TZE200_wfxuhoea", endpointId:"01", inClusters:"0004,0005,EF00,0000", outClusters:"0019,000A", application:"42", deviceJoinName: "LoraTap Garage Door Opener" // LoraTap GDC311ZBQ1 fingerprint profileId:"0104", model:"TS0601", manufacturer:"_TZE200_nklqjk62", endpointId:"01", inClusters:"0004,0005,EF00,0000", outClusters:"0019,000A", application:"42", deviceJoinName: "MatSee Garage Door Opener" // MatSee PJ-ZGD01 fingerprint profileId:"0104", model:"TS0601", manufacturer:"_TZE204_nklqjk62", endpointId:"01", inClusters:"0004,0005,EF00,0000", outClusters:"0019,000A", application:"4A", deviceJoinName: "MatSee Garage Door Opener" // MatSee PJ-ZGD01 } preferences { input (name: "logEnable", type: "bool", title: "Debug logging", description: "Debug information, useful for troubleshooting. Recommended value is false", defaultValue: false) input (name: "txtEnable", type: "bool", title: "Description text logging", description: "Display measured values in HE log page. Recommended value is true", defaultValue: true) input (name: "doorTimeout", type: "number", title: "Door timeout", description: "The time needed for the door to open, seconds", range: "1..100", defaultValue: 15) } } private getCLUSTER_TUYA() { 0xEF00 } // Parse incoming device messages to generate events def parse(String description) { if (logEnable == true) log.debug "${device.displayName} parse: description is $description" checkDriverVersion() setPresent() if (description?.startsWith('catchall:') || description?.startsWith('read attr -')) { def descMap = [:] try { descMap = zigbee.parseDescriptionAsMap(description) } catch (e) { log.warn "${device.displayName} parse: exception caught while parsing descMap: ${descMap}" return null } if (descMap?.clusterInt == CLUSTER_TUYA) { if (logEnable) log.debug "${device.displayName} parse Tuya Cluster: descMap = $descMap" if ( descMap?.command in ["00", "01", "02"] ) { def transid = zigbee.convertHexToInt(descMap?.data[1]) def dp = zigbee.convertHexToInt(descMap?.data[2]) def dp_id = zigbee.convertHexToInt(descMap?.data[3]) def fncmd = getTuyaAttributeValue(descMap?.data) if (logEnable) log.trace "${device.displayName} Tuya cluster dp_id=${dp_id} dp=${dp} fncmd=${fncmd}" switch (dp) { case 0x01 : // Relay / trigger switch def value = fncmd == 1 ? "on" : "off" if (logEnable) log.debug "${device.displayName} received Relay / trigger switch report dp_id=${dp_id} dp=${dp} fncmd=${fncmd}" // sendSwitchEvent(value) // version 1.0.4 break case 0x02 : // unknown, received as a confirmation of the relay on/off commands? Payload is always 0 if (logEnable) log.debug "${device.displayName} received confirmation report dp_id=${dp_id} dp=${dp} fncmd=${fncmd}" break case 0x03 : // Contact case 0x07 : // debug/testing only! def contactState = fncmd == 0 ? "closed" : "open" // reversed in ver 1.0.1 def doorState = device.currentState('door').value def previousContactState = device.currentState('contact').value sendContactEvent(contactState) switch (doorState) { case 'open' : // contact state was changed while the door was open if (contactState == "open") { // do nothing - contact state confirms the door open state } else { // if the contact is now closed, the door state should be considered 'closed' as well ! runInMillis( 100, confirmClosed, [overwrite: true]) } break case 'opening' : // contact state was changed while the door was in opening motion state if (contactState == "open") { // do nothing - open contact state confirms the door opening state } else { // contact is reported as closed if (previousContactState != "closed") { // it is unusual if the contact changes to 'closed' during 'opening' door motion... just issue a warning! if (logEnable) log.warn "${device.displayName} Contact changed to 'closed' during door 'open' command?" } } break case 'closing' : // contact state was changed while the door was in closing motion state if (contactState == "closed") { // contact sensor closed confirmation -> force door status 'closed' as well runInMillis( 100, confirmClosed, [overwrite: true]) } else { // contact is reported as open if (previousContactState != "open") { // it is unusual if the contact changes to 'open' during 'closing' door motion... just issue a warning! if (logEnable) log.warn "${device.displayName} Contact changed to 'open' during door 'close' command?" } } break case 'closed' : // contact state was changed while the door was closed if (contactState == "closed") { // do nothing - contact state confirms the door closed state } else { // if the contact is now open, the door state should be considered 'open' as well ! runInMillis( 100, confirmOpen, [overwrite: true]) } break; default : // unknown if (logEnable) log.warn "${device.displayName} unknown door state ${doorState} while the contact was ${contactState}" break } break case 0x0C : // Door Status ? if (logEnable) log.info "${device.displayName} Tuya report: Door status is ${fncmd==2?'CLOSED':fncmd.toString()}" break default : if (logEnable) log.warn "${device.displayName} NOT PROCESSED Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" break } } // if command in ["00", "01", "02"] else if (descMap?.clusterInt==CLUSTER_TUYA && descMap?.command == "0B") { // ZCL Command Default Response if (logEnable) log.debug "${device.displayName} device received Tuya cluster ZCL command 0x${descMap?.command} response: 0x${descMap?.data[1]} status: ${descMap?.data[1]=='00'?'success':'FAILURE'} data: ${descMap?.data}" } else { if (logEnable) log.warn "${device.displayName} NOT PROCESSED COMMAND Tuya cmd ${descMap?.command} : dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } } // if Tuya cluster else { if (descMap?.cluster == "0000" && descMap?.attrId == "0001") { if (logEnable) log.debug "${device.displayName} Tuya check-in: ${descMap}" } else { if (logEnable) log.debug "${device.displayName} parsed non-Tuya cluster: descMap = $descMap" } } } // if catchall or read attr } private int getTuyaAttributeValue(ArrayList _data) { int retValue = 0 if (_data.size() >= 6) { int dataLength = _data[5] as Integer int power = 1; for (i in dataLength..1) { retValue = retValue + power * zigbee.convertHexToInt(_data[i+5]) power = power * 256 } } return retValue } def on() { if (device.currentState('switch').value != "on") { if (logEnable) log.debug "${device.displayName} Turning ON (open)" sendSwitchEvent("on", isDigital=true) open() } else { if (logEnable) log.warn "${device.displayName} ignoring ON (open) command (door was ${device.currentState('door').value} , contact was ${device.currentState('contact').value})" } } def off() { if (device.currentState('switch').value != "off") { if (logEnable) log.debug "${device.displayName} Turning OFF (close)" sendSwitchEvent("off", isDigital=true) close() } else { if (logEnable) log.warn "${device.displayName} ignoring OFF (close) command (door was ${device.currentState('door').value} , contact was ${device.currentState('contact').value})" } } def relayOn() { if (logEnable) log.debug "${device.displayName} Turning the relay ON" sendZigbeeCommands(zigbee.command(0xEF00, 0x0, "00010101000101")) } def relayOff() { if (logEnable) log.debug "${device.displayName} Turning the relay OFF" sendZigbeeCommands(zigbee.command(0xEF00, 0x0, "00010101000100")) } def pulseOn() { if (logEnable) log.debug "${device.displayName} pulseOn()" runInMillis( pulseTimer, pulseOff, [overwrite: true]) relayOn() } def pulseOff() { if (logEnable) log.debug "${device.displayName} pulseOff()" relayOff() } def open() { if (device.currentState('contact').value != "open") { if (logEnable) log.debug "${device.displayName} opening (door was ${device.currentState('door').value} , contact was ${device.currentState('contact').value})" sendDoorEvent("opening") unschedule(confirmClosed) Integer timeout = settings?.doorTimeout * 1000 runInMillis( timeout, confirmOpen, [overwrite: true]) pulseOn() } else { if (logEnable) log.warn "${device.displayName} ignoring Open command (door was ${device.currentState('door').value} , contact was ${device.currentState('contact').value})" } } def close() { if (device.currentState('contact').value != "closed") { if (logEnable) log.debug "${device.displayName} closing (door was ${device.currentState('door').value} , contact was ${device.currentState('contact').value})" sendDoorEvent("closing") unschedule(confirmOpen) Integer timeout = settings?.doorTimeout * 1200 // add 20% tolerance when closing runInMillis( timeout , confirmClosed, [overwrite: true]) pulseOn() } else { if (logEnable) log.warn "${device.displayName} ignoring Close command (door was ${device.currentState('door').value} , contact was ${device.currentState('contact').value})" } } def sendDoorEvent(state) { def map = [:] map.name = "door" map.value = state // ["unknown", "open", "closing", "closed", "opening"] map.descriptionText = "${device.displayName} door is ${map.value}" if (txtEnable) {log.info "${device.displayName} ${map.descriptionText}"} sendEvent(map) } def sendContactEvent(state, isDigital=false) { def map = [:] map.name = "contact" map.value = state // open or closed map.type = isDigital == true ? "digital" : "physical" map.descriptionText = "${device.displayName} contact is ${map.value}" if (device.currentState('contact').value != state) { if (txtEnable) {log.info "${device.displayName} ${map.descriptionText} (${map.type})"} } else { if (logEnable) {log.info "${device.displayName} heartbeat: contact is ${state}"} } sendEvent(map) } def sendSwitchEvent(state, isDigital=false) { def map = [:] map.name = "switch" map.value = state // on or off map.type = isDigital == true ? "digital" : "physical" map.descriptionText = "${device.displayName} switch is ${map.value}" if (logEnable) {log.info "${device.displayName} ${map.descriptionText} (${map.type})"} sendEvent(map) } def confirmClosed() { if (device.currentState('contact').value == 'closed') { sendDoorEvent("closed") sendSwitchEvent("off", isDigital=true) } else { sendDoorEvent("open") sendSwitchEvent("on", isDigital=true) if (logEnable) {log.warn "${device.displayName} closing failed, contact sensor is still open!"} } } def confirmOpen() { if (device.currentState('contact').value == 'open') { sendDoorEvent("open") sendSwitchEvent("on", isDigital=true) } else { sendDoorEvent("closed") sendSwitchEvent("off", isDigital=true) if (logEnable) {log.warn "${device.displayName} open failed, contact sensor is still closed!"} } } void initializeVars( boolean fullInit = true ) { if (logEnable==true) log.info "${device.displayName} InitializeVars()... fullInit = ${fullInit}" if (fullInit == true ) { state.clear() state.driverVersion = driverVersionAndTimeStamp() } if (fullInit == true || settings?.logEnable == null) device.updateSetting("logEnable", false) if (fullInit == true || settings?.txtEnable == null) device.updateSetting("txtEnable", true) if (fullInit == true || settings?.doorTimeout == null) device.updateSetting("doorTimeout", 15) if (device.currentState('contact')?.value == null ) { sendEvent(name : "contact", value : "?", isStateChange : true) } if (device.currentState('door')?.value == null ) { sendEvent(name : "door", value : "?", isStateChange : true) } } def initialize() { if (txtEnable==true) log.info "${device.displayName} Initialize()..." unschedule() initializeVars() sendEvent(name: "door", value: "closed") sendEvent(name: "contact", value: "closed") sendEvent(name : "powerSource", value : "mains") updated() // calls also configure() } void logsOff(){ log.warn "${device.displayName} Debug logging disabled..." device.updateSetting("logEnable",[value:"false",type:"bool"]) } def tuyaBlackMagic() { return zigbee.readAttribute(0x0000, [0x0004, 0x000, 0x0001, 0x0005, 0x0007, 0xfffe], [:], delay=200) } def configure() { if (txtEnable==true) log.info "${device.displayName} configure().." checkDriverVersion() List cmds = [] cmds += tuyaBlackMagic() sendZigbeeCommands(cmds) } def updated() { log.info "${device.displayName} debug logging is: ${logEnable == true}" log.info "${device.displayName} description logging is: ${txtEnable == true}" if (txtEnable) log.info "${device.displayName} Updated..." if (logEnable) runIn(86400, logsOff, [overwrite: true]) } def installed() { log.info "Installing..." log.info "Debug logging will be automatically disabled after 24 hours" device.updateSetting("logEnable",[type:"bool",value:"false"]) device.updateSetting("txtEnable",[type:"bool",value:"true"]) sendEvent(name : "powerSource", value : "?", isStateChange : true) sendEvent(name : "door", value : "?", isStateChange : true) sendEvent(name : "contact", value : "?", isStateChange : true) if (logEnable) runIn(86400, logsOff, [overwrite: true]) } def driverVersionAndTimeStamp() {version()+' '+timeStamp()} def checkDriverVersion() { if (state.driverVersion != null && driverVersionAndTimeStamp() == state.driverVersion) { //log.trace "${device.displayName} driverVersion is the same ${driverVersionAndTimeStamp()}" } else { if (txtEnable==true) log.info "${device.displayName} updating the settings from driver version ${state.driverVersion} to ${driverVersionAndTimeStamp()}" initializeVars( fullInit = false ) state.driverVersion = driverVersionAndTimeStamp() } } // called when any event was received from the Zigbee device in parse() method.. def setPresent() { sendEvent(name : "powerSource", value : "mains", isStateChange : false) } void sendZigbeeCommands(List cmds) { if (logEnable) {log.trace "${device.displayName} sendZigbeeCommands received : ${cmds}"} sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE)) } def setContact( mode ) { if (mode in ['open', 'closed']) { sendContactEvent(mode, isDigital=true) } else { if (logEnable) log.warn "${device.displayName} please select the Contact state" } }