/** * ZigBee Lock * * Copyright 2015 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 physicalgraph.zigbee.zcl.DataType metadata { definition (name: "AJB SmartLock Zigbee", namespace: "abuttino", author: "abuttino", genericHandler: "Zigbee") { capability "Actuator" capability "Lock" capability "Polling" capability "Refresh" capability "Sensor" capability "Lock Codes" capability "Battery" capability "Configuration" capability "Health Check" fingerprint profileId: "0104", inClusters: "0000,0001,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRD220/240 TSDB", deviceJoinName: "Yale Touch Screen Deadbolt Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRL220 TS LL", deviceJoinName: "Yale Touch Screen Lever Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRD210 PB DB", deviceJoinName: "Yale Push Button Deadbolt Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRD220/240 TSDB", deviceJoinName: "Yale Touch Screen Deadbolt Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRL210 PB LL", deviceJoinName: "Yale Push Button Lever Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRD226/246 TSDB", deviceJoinName: "Yale Assure Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020,0B05", outClusters: "000A,0019", manufacturer: "Yale", model: "YRD446 BLE TSDB", deviceJoinName: "Yale Assure Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRD216 PBDB", deviceJoinName: "Yale Push Button Deadbolt Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020", outClusters: "000A,0019", manufacturer: "Yale", model: "YRL226 TSLL", deviceJoinName: "Yale Assure Touch Screen Lever Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0009,000A,0101,0020,0B05", outClusters: "000A,0019", manufacturer: "Yale", model: "YRL216 PB", deviceJoinName: "Yale Assure Keypad Lever Lock" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0004,0005,0009,0020,0101,0402,0B05,FDBD", outClusters: "000A,0019", manufacturer: "Kwikset", model: "SMARTCODE_DEADBOLT_5", deviceJoinName: "Kwikset 5-Button Deadbolt" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0004,0005,0009,0020,0101,0402,0B05,FDBD", outClusters: "000A,0019", manufacturer: "Kwikset", model: "SMARTCODE_LEVER_5", deviceJoinName: "Kwikset 5-Button Lever" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0004,0005,0009,0020,0101,0402,0B05,FDBD", outClusters: "000A,0019", manufacturer: "Kwikset", model: "SMARTCODE_DEADBOLT_10", deviceJoinName: "Kwikset 10-Button Deadbolt" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0004,0005,0009,0020,0101,0402,0B05,FDBD", outClusters: "000A,0019", manufacturer: "Kwikset", model: "SMARTCODE_DEADBOLT_10T", deviceJoinName: "Kwikset 10-Button Touch Deadbolt" fingerprint profileId: "0104", inClusters: "0000, 0003, 0101", manufacturer:"Kwikset", model:"Smartcode", deviceJoinName: "Kwikset Smartcode Lock" fingerprint profileId: "0104", inClusters: "0000, 0001, 0003, 0009, 0020, 0101, 0B05, FC00", outClusters: "000A, 0019", manufacturer: "Schlage", model: "BE468", deviceJoinName: "Schlage Connect Smart Deadbolt" } tiles(scale: 2) { multiAttributeTile(name:"toggle", type:"generic", width:6, height:4) { tileAttribute ("device.lock", key:"PRIMARY_CONTROL") { attributeState "locked", label:'locked', action:"lock.unlock", icon:"st.locks.lock.locked", backgroundColor:"#00A0DC", nextState:"unlocking" attributeState "unlocked", label:'unlocked', action:"lock.lock", icon:"st.locks.lock.unlocked", backgroundColor:"#ffffff", nextState:"locking" attributeState "unknown", label:"unknown", action:"lock.lock", icon:"st.locks.lock.unknown", backgroundColor:"#ffffff", nextState:"locking" attributeState "locking", label:'locking', icon:"st.locks.lock.locked", backgroundColor:"#00A0DC" attributeState "unlocking", label:'unlocking', icon:"st.locks.lock.unlocked", backgroundColor:"#ffffff" } } standardTile("lock", "device.lock", inactiveLabel:false, decoration:"flat", width:2, height:2) { state "default", label:'lock', action:"lock.lock", icon:"st.locks.lock.locked", nextState:"locking" } standardTile("unlock", "device.lock", inactiveLabel:false, decoration:"flat", width:2, height:2) { state "default", label:'unlock', action:"lock.unlock", icon:"st.locks.lock.unlocked", nextState:"unlocking" } valueTile("battery", "device.battery", inactiveLabel:false, decoration:"flat", width:2, height:2) { state "battery", label:'${currentValue}% battery', unit:"" } standardTile("refresh", "device.refresh", inactiveLabel:false, decoration:"flat", width:2, height:2) { state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" } main "toggle" details(["toggle", "lock", "unlock", "battery", "refresh"]) } } // Globals - Cluster IDs private getCLUSTER_POWER() { 0x0001 } private getCLUSTER_DOORLOCK() { 0x0101 } private getCLUSTER_ALARM() { 0x0009 } // Globals - Command IDs private getDOORLOCK_CMD_LOCK_DOOR() { 0x00 } private getDOORLOCK_CMD_UNLOCK_DOOR() { 0x01 } private getDOORLOCK_CMD_USER_CODE_SET() { 0x05 } private getDOORLOCK_CMD_USER_CODE_GET() { 0x06 } private getDOORLOCK_CMD_CLEAR_USER_CODE() { 0x07 } private getDOORLOCK_RESPONSE_OPERATION_EVENT() { 0x20 } private getDOORLOCK_RESPONSE_PROGRAMMING_EVENT() { 0x21 } private getPOWER_ATTR_BATTERY_PERCENTAGE_REMAINING() { 0x0021 } private getDOORLOCK_ATTR_LOCKSTATE() { 0x0000 } private getDOORLOCK_ATTR_NUM_PIN_USERS() { 0x0012 } private getDOORLOCK_ATTR_MAX_PIN_LENGTH() { 0x0017 } private getDOORLOCK_ATTR_MIN_PIN_LENGTH() { 0x0018 } private getDOORLOCK_ATTR_SEND_PIN_OTA() { 0x0032 } private getALARM_ATTR_ALARM_COUNT() { 0x0000 } private getALARM_CMD_ALARM() { 0x00 } /** * Called on app installed */ def installed() { log.trace "ZigBee DTH - Executing installed() for device ${device.displayName}" } /** * Called on app uninstalled */ def uninstalled() { def deviceName = device.displayName log.trace "ZigBee DTH - Executing uninstalled() for device $deviceName" sendEvent(name: "lockRemoved", value: device.id, isStateChange: true, displayed: false) } /** * Executed when the user taps on the 'Done' button on the device settings screen. Sends the values to lock. * * @return The list of commands to be executed */ def updated() { try { if (!state.init || !state.configured) { // Executed when the lock is being paired state.init = true log.trace "ZigBee DTH - Returning commands for lock operation get and battery get" def cmds = [] if (!state.configured) { cmds << doConfigure() } cmds << zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_LOCKSTATE) cmds << zigbee.readAttribute(CLUSTER_POWER, POWER_ATTR_BATTERY_PERCENTAGE_REMAINING) cmds = cmds.flatten() log.info "ZigBee DTH - updated() returning with cmds:- $cmds" return response(cmds) } } catch (e) { log.warn "ZigBee DTH - updated() threw exception:- $e" } return null } /** * Ping is used by Device-Watch in attempt to reach the device */ def ping() { log.trace "ZigBee DTH - Executing ping() for device ${device.displayName}" refresh() } /** * Called by the Smart Things platform in case Polling capability is added to the device type */ def poll() { log.trace "ZigBee DTH - Executing poll() for device ${device.displayName}" def cmds = [] def latest = device.currentState("lock")?.date?.time if (!latest || !secondsPast(latest, 6 * 60) || secondsPast(state.lastPoll, 55 * 60)) { cmds << zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_LOCKSTATE) state.lastPoll = now() } else if (!state.lastbatt || now() - state.lastbatt > 53*60*60*1000) { cmds << zigbee.readAttribute(CLUSTER_POWER, POWER_ATTR_BATTERY_PERCENTAGE_REMAINING) state.lastbatt = now() } if (cmds) { log.info "ZigBee DTH - poll() returning with cmds:- $cmds" return cmds } else { // workaround to keep polling from stopping due to lack of activity sendEvent(descriptionText: "skipping poll", isStateChange: true, displayed: false) return null } } /** * Called when the user taps on the refresh button */ def refresh() { log.trace "ZigBee DTH - Executing refresh() for device ${device.displayName}" def cmds = zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_LOCKSTATE) + zigbee.readAttribute(CLUSTER_POWER, POWER_ATTR_BATTERY_PERCENTAGE_REMAINING) log.info "ZigBee DTH - refresh() returning with cmds:- $cmds" return cmds } /** * Configures the device to settings needed by SmarthThings at device discovery time * */ def configure() { log.trace "ZigBee DTH - Executing configure() for device ${device.displayName}" def cmds = doConfigure() log.info "ZigBee DTH - configure() returning with cmds:- $cmds" cmds } /** * Returns the list of commands to be executed when the device is being configured/paired * */ def doConfigure() { log.trace "ZigBee DTH - Executing doConfigure() for device ${device.displayName}" state.configured = true // 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, offlinePingable: "1"]) def cmds = zigbee.configureReporting(CLUSTER_DOORLOCK, DOORLOCK_ATTR_LOCKSTATE, DataType.ENUM8, 0, 3600, null) + zigbee.configureReporting(CLUSTER_POWER, POWER_ATTR_BATTERY_PERCENTAGE_REMAINING, DataType.UINT8, 600, 21600, 0x01) + zigbee.configureReporting(CLUSTER_ALARM, ALARM_ATTR_ALARM_COUNT, DataType.UINT16, 0, 21600, null) def allCmds = refresh() + cmds + reloadAllCodes() log.info "ZigBee DTH - doConfigure() returning with cmds:- $allCmds" allCmds // send refresh and reloadAllCodes cmds as part of configureDevice } /** * Executes lock command on a Zigbee lock */ def lock() { log.trace "ZigBee DTH - Executing lock() for device ${device.displayName}" def cmds = zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_LOCK_DOOR) log.info "ZigBee DTH - lock() returning with cmds:- $cmds" return cmds } /** * Executes unlock command on a Zigbee lock */ def unlock() { log.trace "ZigBee DTH - Executing unlock() for device ${device.displayName}" def cmds = zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_UNLOCK_DOOR) log.info "ZigBee DTH - unlock() returning with cmds:- $cmds" return cmds } /** * API endpoint for server smart app to scan the lock and populate the attributes. Called only when the attributes are not populated. * * @return cmds: The command(s) fired for reading attributes */ def reloadAllCodes() { log.trace "ZigBee DTH - Executing reloadAllCodes() for device ${device.displayName}" sendEvent(name: "scanCodes", value: "Scanning", descriptionText: "Code scan in progress", displayed: false) def lockCodes = loadLockCodes() sendEvent(lockCodesEvent(lockCodes)) def cmds = validateAttributes() if (isYaleLock()) { state.checkCode = state.checkCode ?: 1 } else { state.checkCode = state.checkCode ?: 0 } cmds += requestCode(state.checkCode) log.info "ZigBee DTH - reloadAllCodes() returning with cmds:- $cmds" return cmds } /** * API endpoint for setting a user code on a Zigbee lock * * @param codeID: The code slot number * * @param code: The code PIN * * @param codeName: The name of the code * * @returns cmds: The commands fired for creation and checking of a lock code */ def setCode(codeID, code, codeName = null) { if (!code) { log.trace "ZigBee DTH - Executing nameSlot() for device ${device.displayName}" nameSlot(codeID, codeName) return } log.trace "ZigBee DTH - Executing setCode() for device ${device.displayName}" if (isValidCodeID(codeID) && isValidCode(code)) { log.debug "Zigbee DTH - setting code in slot number $codeID" def cmds = [] def attrCmds = validateAttributes() def setPayload = getPayloadToSetCode(codeID, code) if (isYaleLock()) { // Executing both user code set and get commands as Yale lock do not generate programming event while creating // a user code from the SmartApp cmds << zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_USER_CODE_SET, setPayload).first() cmds << requestCode(codeID).first() state["setcode$codeID"] = encrypt(code.toString()) cmds = delayBetween(cmds, 4200) } else { cmds << zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_USER_CODE_SET, setPayload).first() } def strname = (codeName ?: "Code $codeID") state["setname$codeID"] = strname if(attrCmds) { cmds = attrCmds + cmds } return cmds } else { log.warn "Zigbee DTH - Invalid input: Unable to set code in slot number $codeID" return null } } /** * Validates attributes and if attributes are not populated, adds the command maps to list of commands * @return List of command maps or empty list */ def validateAttributes() { def cmds = [] if (!state.attrAlarmCountSet) { state.attrAlarmCountSet = true cmds += zigbee.configureReporting(CLUSTER_ALARM, ALARM_ATTR_ALARM_COUNT, DataType.UINT16, 0, 21600, null) } // DOORLOCK_ATTR_SEND_PIN_OTA is sometimes getting reset to 0. Hence, writing it explicitly to 1. cmds += zigbee.writeAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_SEND_PIN_OTA, DataType.BOOLEAN, 1) if(!device.currentValue("maxCodes")) { cmds += zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_NUM_PIN_USERS) } if(!device.currentValue("minCodeLength")) { cmds += zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_MIN_PIN_LENGTH) } if(!device.currentValue("maxCodeLength")) { cmds += zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_MAX_PIN_LENGTH) } cmds = cmds.flatten() log.trace "validateAttributes returning commands list: " + cmds cmds } /** * API endpoint for deleting a user code on a Zigbee lock * * @param codeID: The code slot number * * @returns cmds: The command fired for deletion of a lock code */ def deleteCode(codeID) { log.trace "ZigBee DTH - Executing deleteCode() for device ${device.displayName}" def cmds = [] if (isValidCodeID(codeID)) { log.debug "Zigbee DTH - deleting code slot number $codeID" // Calling user code get when deleting a code because some Kwikset locks do not generate // programming event when a code is deleted manually on the lock. // This will also help in resolving the failure cases during deletion of a lock code. cmds = zigbee.writeAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_SEND_PIN_OTA, DataType.BOOLEAN, 1) cmds += zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_CLEAR_USER_CODE, getLittleEndianHexString(codeID)) cmds += requestCode(codeID) } else { log.warn "Zigbee DTH - Invalid input: Unable to delete slot number $codeID" } log.info "ZigBee DTH - deleteCode() returning with cmds:- $cmds" return cmds } /** * API endpoint for setting/deleting multiple user codes on a lock * * @param codeSettings: The map with code slot numbers and code pins (in case of update) * * @returns The commands fired for creation and deletion of lock codes */ def updateCodes(codeSettings) { log.trace "ZigBee DTH - Executing updateCodes() for device ${device.displayName}" if(codeSettings instanceof String) codeSettings = util.parseJson(codeSettings) def set_cmds = [] def get_cmds = [] codeSettings.each { name, updated -> if (name.startsWith("code")) { def n = name[4..-1].toInteger() if (updated && updated.size() >= 4 && updated.size() <= 8) { log.debug "Setting code number $n" def setPayload = getPayloadToSetCode(n, updated) set_cmds << zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_USER_CODE_SET, setPayload).first() if (isYaleLock()) { get_cmds << requestCode(n).first() } } else if (updated == null || updated == "" || updated == "0") { log.debug "Deleting code number $n" set_cmds << zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_CLEAR_USER_CODE, getLittleEndianHexString(n)).first() get_cmds << requestCode(n).first() } } else log.warn("unexpected entry $name: $updated") } if (set_cmds && get_cmds) { def allCmds = [] allCmds = delayBetween(set_cmds, 2200) + ["delay 2200"] + delayBetween(get_cmds, 4200) return response(allCmds) } else if (set_cmds) { return response(delayBetween(set_cmds, 4200)) } return null } /** * Renames an existing lock code slot * * @param codeSlot: The code slot number * * @param codeName The new name of the code */ def nameSlot(codeSlot, codeName) { def lockCodes = loadLockCodes() codeSlot = codeSlot.toString() if (lockCodes[codeSlot]) { def deviceName = device.displayName log.trace "ZigBee DTH - Executing nameSlot() for device $deviceName" def oldCodeName = getCodeName(lockCodes, codeSlot) def newCodeName = codeName ?: "Code $codeSlot" lockCodes[codeSlot] = newCodeName sendEvent(lockCodesEvent(lockCodes)) sendEvent(name: "codeChanged", value: "$codeSlot renamed", data: [ lockName: deviceName, notify: false, notificationText: "Renamed \"$oldCodeName\" to \"$newCodeName\" in $deviceName at ${location.name}" ], descriptionText: "Renamed \"$oldCodeName\" to \"$newCodeName\"", displayed: true, isStateChange: true) } } /** * Constructs the ZigBee command for user code get * * @param codeID: The code slot number * * @return The command for user code get */ def requestCode(codeID) { return zigbee.command(CLUSTER_DOORLOCK, DOORLOCK_CMD_USER_CODE_GET, getLittleEndianHexString(codeID)) } /** * Responsible for parsing incoming device messages to generate events * * @param description The incoming description from the device * * @return result: The list of events to be sent out * */ def parse(String description) { log.trace "ZigBee DTH - Executing parse() for device ${device.displayName}" def result = null if (description) { if (description.startsWith('read attr -')) { result = parseAttributeResponse(description) } else { result = parseCommandResponse(description) } } return result } /** * Responsible for handling attribute responses * * @param description The description to be parsed * * @return result: The list of events to be sent out */ private def parseAttributeResponse(String description) { Map descMap = zigbee.parseDescriptionAsMap(description) log.trace "ZigBee DTH - Executing parseAttributeResponse() for device ${device.displayName} with description map:- $descMap" def result = [] Map responseMap = [:] def clusterInt = descMap.clusterInt def attrInt = descMap.attrInt def deviceName = device.displayName if (clusterInt == CLUSTER_POWER && attrInt == POWER_ATTR_BATTERY_PERCENTAGE_REMAINING) { responseMap.name = "battery" responseMap.value = Math.round(Integer.parseInt(descMap.value, 16) / 2) // Handling Yale locks incorrect battery reporting issue if (reportsBatteryIncorrectly()) { responseMap.value = Integer.parseInt(descMap.value, 16) } responseMap.descriptionText = "Battery is at ${responseMap.value}%" } else if (clusterInt == CLUSTER_DOORLOCK && attrInt == DOORLOCK_ATTR_LOCKSTATE) { def value = Integer.parseInt(descMap.value, 16) responseMap.name = "lock" if (value == 0) { responseMap.value = "unknown" responseMap.descriptionText = "Unknown state" } else if (value == 1) { responseMap.value = "locked" responseMap.descriptionText = "Locked" } else if (value == 2) { responseMap.value = "unlocked" responseMap.descriptionText = "Unlocked" } else { responseMap.value = "unknown" responseMap.descriptionText = "Unknown state" } } else if (clusterInt == CLUSTER_DOORLOCK && attrInt == DOORLOCK_ATTR_MIN_PIN_LENGTH && descMap.value) { def minCodeLength = Integer.parseInt(descMap.value, 16) responseMap = [name: "minCodeLength", value: minCodeLength, descriptionText: "Minimum PIN length is ${minCodeLength}", displayed: false] } else if (clusterInt == CLUSTER_DOORLOCK && attrInt == DOORLOCK_ATTR_MAX_PIN_LENGTH && descMap.value) { def maxCodeLength = Integer.parseInt(descMap.value, 16) responseMap = [name: "maxCodeLength", value: maxCodeLength, descriptionText: "Maximum PIN length is ${maxCodeLength}", displayed: false] } else if (clusterInt == CLUSTER_DOORLOCK && attrInt == DOORLOCK_ATTR_NUM_PIN_USERS && descMap.value) { def maxCodes = Integer.parseInt(descMap.value, 16) responseMap = [name: "maxCodes", value: maxCodes, descriptionText: "Maximum Number of user codes supported is ${maxCodes}", displayed: false] } else { log.trace "ZigBee DTH - parseAttributeResponse() - ignoring attribute response" return null } if (responseMap.data) { responseMap.data.lockName = deviceName } else { responseMap.data = [ lockName: deviceName ] } result << createEvent(responseMap) log.info "ZigBee DTH - parseAttributeResponse() returning with result:- $result" return result } /** * Responsible for handling command responses * * @param description The description to be parsed * * @return result: The list of events to be sent out */ private def parseCommandResponse(String description) { Map descMap = zigbee.parseDescriptionAsMap(description) def deviceName = device.displayName log.trace "ZigBee DTH - Executing parseCommandResponse() for device ${deviceName}" def result = [] Map responseMap = [:] def data = descMap.data def lockCodes = loadLockCodes() def cmd = descMap.commandInt def clusterInt = descMap.clusterInt if (clusterInt == CLUSTER_DOORLOCK && (cmd == DOORLOCK_CMD_LOCK_DOOR || cmd == DOORLOCK_CMD_UNLOCK_DOOR)) { log.trace "ZigBee DTH - Executing DOOR LOCK/UNLOCK SUCCESS for device ${deviceName} with description map:- $descMap" // Reading lock state with a delay of 4200 as some locks do not report their state change def cmdList = [] cmdList << "delay 4200" cmdList << zigbee.readAttribute(CLUSTER_DOORLOCK, DOORLOCK_ATTR_LOCKSTATE).first() result << response(cmdList) } else if (clusterInt == CLUSTER_DOORLOCK && cmd == DOORLOCK_RESPONSE_OPERATION_EVENT) { log.trace "ZigBee DTH - Executing DOORLOCK_RESPONSE_OPERATION_EVENT for device ${deviceName} with description map:- $descMap" def eventSource = Integer.parseInt(data[0], 16) def eventCode = Integer.parseInt(data[1], 16) responseMap.name = "lock" responseMap.displayed = true responseMap.isStateChange = true def desc = "" def codeName = "" if (eventSource == 0) { def codeID = Integer.parseInt(data[3] + data[2], 16) if (!isValidCodeID(codeID, true)) { // invalid code slot number reported by lock log.debug "Invalid slot number := $codeID" return null } codeName = getCodeName(lockCodes, codeID) responseMap.data = [ codeId: codeID as String, usedCode: codeID, codeName: codeName, method: "keypad" ] } else if (eventSource == 1) { responseMap.data = [ method: "command" ] } else if (eventSource == 2) { desc = "manually" responseMap.data = [ method: "manual" ] } switch (eventCode) { case 1: responseMap.value = "locked" if(codeName) { responseMap.descriptionText = "Locked by \"$codeName\"" } else { responseMap.descriptionText = "Locked ${desc}" } break case 2: responseMap.value = "unlocked" if(codeName) { responseMap.descriptionText = "Unlocked by \"$codeName\"" } else { responseMap.descriptionText = "Unlocked ${desc}" } break case 3: //Lock Failure Invalid Pin break case 4: //Lock Failure Invalid Schedule break case 5: //Unlock Invalid PIN break case 6: //Unlock Invalid Schedule break case 7: // locked by touching the keypad responseMap.value = "keypadLocked" responseMap.descriptionText = "locked by keypad" break case 8: // locked using the key case 13: // locked using the Thumbturn responseMap.value = "locked" responseMap.descriptionText = "Locked ${desc}" break case 9: // unlocked using the key case 14: // unlocked using the Thumbturn responseMap.value = "unlocked" responseMap.descriptionText = "Unlocked ${desc}" break case 10: //Auto lock responseMap.value = "locked" responseMap.descriptionText = "Auto locked" responseMap.data = [ method: "auto" ] break default: break } } else if (clusterInt == CLUSTER_DOORLOCK && cmd == DOORLOCK_CMD_USER_CODE_SET) { log.trace "ZigBee DTH - Executing DOORLOCK_CMD_USER_CODE_SET for device ${deviceName} with description map:- $descMap" def status = Integer.parseInt(data[0], 16) switch (status) { case 0: log.debug "Lock code creation successful" // Lock code creation is successful but we do not have the codeID/code number here. // Hence, code creation success event will be sent from DOORLOCK_RESPONSE_PROGRAMMING_EVENT response. break case 1: log.debug "Lock code creation failed - General failure" break case 2: log.debug "Lock code creation failed - Memory full" break case 3: log.debug "Lock code creation failed - Duplicate Code error" break default: break } } else if (clusterInt == CLUSTER_DOORLOCK && cmd == DOORLOCK_RESPONSE_PROGRAMMING_EVENT) { log.trace "ZigBee DTH - Executing DOORLOCK_RESPONSE_PROGRAMMING_EVENT for device ${deviceName} with description map:- $descMap" // Programming event is generated when the user creates/updates/deletes a code manually on the lock. // Ideally it should be generated even when the user tries to create/update a code through the // SmartApp as well, but that is not the case with Yale locks. responseMap.name = "codeChanged" responseMap.isStateChange = true responseMap.displayed = true def codeID = Integer.parseInt(data[3] + data[2], 16) def codeName def eventCode = Integer.parseInt(data[1], 16) switch (eventCode) { case 1: // MasterCodeChanged codeName = "Master Code" responseMap.value = "0 set" responseMap.descriptionText = "${getStatusForDescription('set')} \"Master Code\"" responseMap.data = [ codeName: codeName, notify: true, notificationText: "${getStatusForDescription('set')} \"$codeName\" in $deviceName at ${location.name}" ] break case 3: // PINCodeDeleted if (codeID == 255) { result = allCodesDeletedEvent() responseMap.value = "all deleted" responseMap.descriptionText = "Deleted all user codes" responseMap.data = [notify: true, notificationText: "Deleted all user codes in $deviceName at ${location.name}"] result << createEvent(name: "lockCodes", value: util.toJson([:]), displayed: false, descriptionText: "'lockCodes' attribute updated") } else { if (lockCodes[codeID.toString()]) { codeName = getCodeName(lockCodes, codeID) responseMap.value = "$codeID deleted" responseMap.descriptionText = "Deleted \"$codeName\"" responseMap.data = [ codeName: codeName, notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ] result << codeDeletedEvent(lockCodes, codeID) } } break case 2: // PINCodeAdded case 4: // PINCodeChanged if (isValidCodeID(codeID)) { codeName = getCodeNameFromState(lockCodes, codeID) def changeType = getChangeType(lockCodes, codeID) responseMap.value = "$codeID $changeType" responseMap.descriptionText = "${getStatusForDescription(changeType)} \"$codeName\"" responseMap.data = [ codeName: codeName, notify: true, notificationText: "${getStatusForDescription(changeType)} \"$codeName\" in $deviceName at ${location.name}" ] result << codeSetEvent(lockCodes, codeID, codeName) } else { // invalid code slot number reported by lock log.debug "Invalid slot number := $codeID" } break default: break } } else if (clusterInt == CLUSTER_DOORLOCK && cmd == DOORLOCK_CMD_USER_CODE_GET) { log.trace "ZigBee DTH - Executing DOORLOCK_CMD_USER_CODE_GET for device ${deviceName}" // This is called only when the user creates/updates a code using the SmartApp (in case of Yale locks) // or when the user tries to scan the lock by calling reloadAllCodes() def userStatus = Integer.parseInt(data[2], 16) def codeID = Integer.parseInt(data[1] + data[0], 16) def codeName = getCodeNameFromState(lockCodes, codeID) // PIN code saved in the state - it will be non null only in case of Yale locks def localCode = decrypt(state["setcode$codeID"]) responseMap.name = "codeChanged" responseMap.isStateChange = true responseMap.displayed = true // userStatus = 1 indicates that the code slot is occupied if (userStatus == 1) { if (localCode && isYaleLock()) { // This will be applicable for Yale locks - both create and update through the SmartApp // PIN code fetched from the lock def serverCode = getCodeFromOctet(data) if (localCode == serverCode) { // Code set successfully log.debug "Code matches - lock code creation successful" def changeType = getChangeType(lockCodes, codeID) responseMap.value = "$codeID $changeType" responseMap.descriptionText = "${getStatusForDescription(changeType)} \"$codeName\"" responseMap.data = [ codeName: codeName, notify: true, notificationText: "${getStatusForDescription(changeType)} \"$codeName\" in $deviceName at ${location.name}" ] result << codeSetEvent(lockCodes, codeID, codeName) } else { // Code update failed log.debug "Code update failed" responseMap.value = "$codeID failed" responseMap.descriptionText = "Failed to update code '$codeName'" //It should be OK to mark this as duplicate pin code error because in case lock batteries are down, // or lock is out of range, or there is wireless interference, the Lock will not be able to respond // back with user code get response. responseMap.data = [isCodeDuplicate: true] } } else { // This will be applicable when a slot is found occupied during scanning of lock // Populating the 'lockCodes' attribute after scanning a code slot log.debug "Scanning lock - code $codeID is occupied" def changeType = getChangeType(lockCodes, codeID) responseMap.value = "$codeID $changeType" responseMap.descriptionText = "${getStatusForDescription(changeType)} \"$codeName\"" responseMap.data = [ codeName: codeName ] if ("set" == changeType) { result << codeSetEvent(lockCodes, codeID, codeName) } else { responseMap.displayed = false } } } else { // Code slot is empty - can happen when code creation fails or a slot is empty while scanning the lock if (localCode != null && isYaleLock()) { // Code slot found empty during creation of a user code log.debug "Code creation failed" responseMap.value = "$codeID failed" responseMap.descriptionText = "Failed to set code '$codeName'" //It should be OK to mark this as duplicate pin code error because in case lock batteries are down, // or lock is out of range, or there is wireless interference, the Lock will not be able to respond // back with user code get response. responseMap.data = [isCodeDuplicate: true] def codeReportMap = [ name: "codeReport", value: codeID, data: [ code: "" ], isStateChange: true, displayed: false ] codeReportMap.descriptionText = "Code $codeID is not set" result << createEvent(codeReportMap) } else if (lockCodes[codeID.toString()]) { codeName = getCodeName(lockCodes, codeID) responseMap.value = "$codeID deleted" responseMap.descriptionText = "Deleted \"$codeName\"" responseMap.data = [ codeName: codeName, notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ] result << codeDeletedEvent(lockCodes, codeID) } else { // Code slot is empty - can happen when a slot is found empty while scanning the lock responseMap.value = "$codeID unset" responseMap.descriptionText = "Code slot $codeID found empty during scanning" responseMap.displayed = false } } clearStateForSlot(codeID) if (codeID == state.checkCode) { log.debug "Code scanning in progress..." def defaultMaxCodes = isYaleLock() ? 8 : 7 def maxCodes = device.currentValue("maxCodes") ?: defaultMaxCodes // Hard coding it to defaultMaxCodes as we do not want to scan all the codes. maxCodes = defaultMaxCodes if (state.checkCode >= maxCodes) { log.debug "Code scanning complete" state["checkCode"] = null sendEvent(name: "scanCodes", value: "Complete", descriptionText: "Code scan completed", displayed: false) } else { log.debug "More codes to scan..." state.checkCode = state.checkCode + 1 result << response(requestCode(state.checkCode)) } } } else if (clusterInt == CLUSTER_ALARM && cmd == ALARM_CMD_ALARM) { log.trace "ZigBee DTH - Executing ALARM_CMD_ALARM for device ${deviceName} with description map:- $descMap" def alarmCode = Integer.parseInt(data[0], 16) switch (alarmCode) { case 0: // Deadbolt Jammed responseMap = [ name: "lock", value: "unknown", descriptionText: "Was in unknown state" ] break case 1: // Lock Reset to Factory Defaults responseMap = [ name: "lock", value: "unknown", descriptionText: "Has been reset to factory defaults" ] break case 2: // Reserved break case 3: // RF Module Power Cycled responseMap = [ descriptionText: "Batteries replaced", isStateChange: true ] break case 4: // Tamper Alarm - wrong code entry limit responseMap = [ name: "tamper", value: "detected", descriptionText: "Keypad attempts exceed code entry limit", isStateChange: true ] break case 5: // Tamper Alarm - front escutcheon removed from main responseMap = [ name: "tamper", value: "detected", descriptionText: "Front escutcheon removed", isStateChange: true ] break case 6: // Forced Door Open under Door Locked Condition responseMap = [ name: "tamper", value: "detected", descriptionText: "Door forced open under door locked condition", isStateChange: true ] break case 16: // Battery too low to operate responseMap = [ name: "battery", value: device.currentValue("battery"), descriptionText: "Battery too low to operate lock", isStateChange: true ] break case 17: // Battery level critical responseMap = [ name: "battery", value: device.currentValue("battery"), descriptionText: "Battery level critical", isStateChange: true ] break case 18: // Battery very low responseMap = [ name: "battery", value: device.currentValue("battery"), descriptionText: "Battery very low", isStateChange: true ] break case 19: // Battery low responseMap = [ name: "battery", value: device.currentValue("battery"), descriptionText: "Battery low", isStateChange: true ] break default: break } } else { log.trace "ZigBee DTH - parseCommandResponse() - ignoring command response" } if(responseMap["value"]) { if (responseMap.data) { responseMap.data.lockName = deviceName } else { responseMap.data = [ lockName: deviceName ] } result << createEvent(responseMap) } if (result) { result = result.flatten() } else { result = null } log.debug "ZigBee DTH - parseCommandResponse() returning with result:- $result" return result } /** * Creates the event map for user code creation * * @param lockCodes: The user codes in a lock * * @param codeID: The code slot number * * @param codeName: The name of the user code * * @return The list of events to be sent out */ private def codeSetEvent(lockCodes, codeID, codeName) { clearStateForSlot(codeID) lockCodes[codeID.toString()] = (codeName ?: "Code $codeID") def result = [] result << lockCodesEvent(lockCodes) def codeReportMap = [ name: "codeReport", value: codeID, data: [ code: "" ], isStateChange: true, displayed: false ] codeReportMap.descriptionText = "${device.displayName} code $codeID is set" result << createEvent(codeReportMap) result } /** * Creates the event map for user code deletion * * @param lockCodes: The user codes in a lock * * @param codeID: The code slot number * * @return The list of events to be sent out */ private def codeDeletedEvent(lockCodes, codeID) { lockCodes.remove("$codeID".toString()) clearStateForSlot(codeID) def result = [] result << lockCodesEvent(lockCodes) def codeReportMap = [ name: "codeReport", value: codeID, data: [ code: "" ], isStateChange: true, displayed: false ] codeReportMap.descriptionText = "${device.displayName} code $codeID was deleted" result << createEvent(codeReportMap) result } /** * Creates the event map for all user code deletion * * @return The List of events to be sent out */ private def allCodesDeletedEvent() { def result = [] def lockCodes = loadLockCodes() def deviceName = device.displayName lockCodes.each { id, code -> result << createEvent(name: "codeReport", value: id, data: [ code: "" ], descriptionText: "code $id was deleted", displayed: false, isStateChange: true) def codeName = code result << createEvent(name: "codeChanged", value: "$id deleted", data: [ codeName: codeName, lockName: deviceName, notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ], descriptionText: "Deleted \"$codeName\"", displayed: true, isStateChange: true) clearStateForSlot(id) } result } /** * Populates the 'lockCodes' attribute by calling send event * * @param lockCodes The codes in a lock */ private Map lockCodesEvent(lockCodes) { createEvent(name: "lockCodes", value: util.toJson(lockCodes), displayed: false, descriptionText: "'lockCodes' attribute updated") } /** * Reads the 'lockCodes' attribute and parses the same * * @returns Map: The lockCodes map */ private Map loadLockCodes() { parseJson(device.currentValue("lockCodes") ?: "{}") ?: [:] } /** * Converts the code octet to code PIN * * @param data The data map returned in case of user code get * * @return code: The code string */ private def getCodeFromOctet(data) { def code = "" def codeLength = Integer.parseInt(data[4], 16) if (codeLength >= device.currentValue("minCodeLength") && codeLength <= device.currentValue("maxCodeLength")) { for (def i = 5; i < (5 + codeLength); i++) { code += (char) (zigbee.convertHexToInt(data[i])) } } return code } /** * Checks if the slot number is within the allowed limits * * @param codeID The code slot number * * @param allowMasterCode Flag to indicate if master code slot should be allowed as a valid slot * * @return true if valid, false if not */ private boolean isValidCodeID(codeID, allowMasterCode = false) { def defaultMaxCodes = isYaleLock() ? 250 : 30 def minCodeId = isYaleLock() ? 1 : 0 if (allowMasterCode) { minCodeId = 0 } def maxCodes = device.currentValue("maxCodes") ?: defaultMaxCodes if (codeID.toInteger() >= minCodeId && codeID.toInteger() <= maxCodes) { return true } return false } /** * Checks if the code PIN is valid * * @param code The code PIN * * @return true if valid, false if not */ private boolean isValidCode(code) { def minCodeLength = device.currentValue("minCodeLength") ?: 4 def maxCodeLength = device.currentValue("maxCodeLength") ?: 8 if (code.toString().size() <= maxCodeLength && code.toString().size() >= minCodeLength && code.isNumber()) { return true } return false } /** * Checks if a change type is set or update * * @param lockCodes: The user codes in a lock * * @param codeID The code slot number * * @return "set" or "update" basis the presence of the code id in the lockCodes map */ private def getChangeType(lockCodes, codeID) { def changeType = "set" if (lockCodes[codeID.toString()]) { changeType = "changed" } changeType } /** * Method to obtain status for descriptuion based on change type * @param changeType: Either "set" or "changed" * @return "Added" for "set", "Updated" for "changed", "" otherwise */ private def getStatusForDescription(changeType) { if("set" == changeType) { return "Added" } else if("changed" == changeType) { return "Updated" } //Don't return null as it cause trouble return "" } /** * Checks if the time elapsed from the provided timestamp is greater than the number of senconds provided * * @param timestamp: The timestamp * * @param seconds: The number of seconds * * @returns true if elapsed time is greater than number of seconds provided, else false */ private Boolean secondsPast(timestamp, seconds) { if (!(timestamp instanceof Number)) { if (timestamp instanceof Date) { timestamp = timestamp.time } else if ((timestamp instanceof String) && timestamp.isNumber()) { timestamp = timestamp.toLong() } else { return true } } return (now() - timestamp) > (seconds * 1000) } /** * Clears the code name and pin from the state basis the code slot number * * @param codeID: The code slot number */ def clearStateForSlot(codeID) { state.remove("setname$codeID") state["setname$codeID"] = null if (isYaleLock()) { state.remove("setcode$codeID") state["setcode$codeID"] = null } } /** * Constructs the payload for setting a code * * @param codeID: The code slot number * * @param code: The code PIN * * @returns payload: The payload for setting a code */ def getPayloadToSetCode(codeID, code) { def payload = "" + getLittleEndianHexString(codeID) payload += " " + getUserStatusForOccupied() + " " + getDefaultUserType() payload += " " + getOctetStringForCode(code) payload } /** * Returns the value 1 (Occupied/Enabled) for user status */ def getUserStatusForOccupied() { return "01" } /** * Returns the value 0 (Unrestricted User - default) for user type */ def getDefaultUserType() { return "00" } /** * Converts the code PIN to octet string * * @param code The code PIN * * @return octetCode: The code equivalent in octet string */ def getOctetStringForCode(code) { def octetStr = "" + zigbee.convertToHexString(code.length(), 2) for(int i = 0; i < code.length(); i++) { octetStr += " " + zigbee.convertToHexString((int) code.charAt(i), 2) } octetStr } /** * Returns hex string in little endian format */ def getLittleEndianHexString(numStr) { return zigbee.swapEndianHex(zigbee.convertToHexString(numStr.toInteger(), 4)) } /** * Utility function to check if the lock manufacturer is Yale * * @return true if the lock manufacturer is Yale, else false */ def isYaleLock() { return "Yale" == device.getDataValue("manufacturer") } /** * Utility function to check for specific models of Yale Lock that don't report battery correctly * * @return true if the lock has the bug */ def reportsBatteryIncorrectly() { def badModels = [ "YRD220/240 TSDB", "YRL220 TS LL", "YRD210 PB DB", "YRD220/240 TSDB", "YRL210 PB LL", ] return (isYaleLock() && device.getDataValue("model") in badModels) } /** * Reads the code name from the device state * * @param lockCodes: map with lock code names * * @param codeID: The code slot number * * @returns The code name */ private String getCodeNameFromState(lockCodes, codeID) { if (isMasterCode(codeID) && isYaleLock()) { return "Master Code" } def nameFromLockCodes = lockCodes[codeID.toString()] def nameFromState = state["setname$codeID"] if(nameFromLockCodes) { if(nameFromState) { //Updated from smart app return nameFromState } else { //Updated from lock return nameFromLockCodes } } else if(nameFromState) { //Set from smart app return nameFromState } //Set from lock return "Code $codeID" } /** * Reads the code name from the 'lockCodes' map * * @param lockCodes: map with lock code names * * @param codeID: The code slot number * * @returns The code name */ private String getCodeName(lockCodes, codeID) { if (isMasterCode(codeID) && isYaleLock()) { return "Master Code" } lockCodes[codeID.toString()] ?: "Code $codeID" } /** * Utility function to figure out if code id pertains to master code or not * * @param codeID - The slot number in which code is set * @return - true if slot is for master code, false otherwise */ private boolean isMasterCode(codeID) { if(codeID instanceof String) { codeID = codeID.toInteger() } (codeID == 0) ? true : false }