From f1a633cf3040ff32c0dc9b9dafb86c8543a48f31 Mon Sep 17 00:00:00 2001 From: Jose Nunes Date: Fri, 7 Oct 2016 11:32:28 -0500 Subject: [PATCH] MSA-1515: Schalge dour lock --- .../zwave-schlage-touchscreen-lock.groovy | 1389 +++++++++++++++ .../user-lock-manager.groovy | 1532 +++++++++++++++++ 2 files changed, 2921 insertions(+) create mode 100644 devicetypes/garyd9/zwave-schlage-touchscreen-lock.src/zwave-schlage-touchscreen-lock.groovy create mode 100644 smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy diff --git a/devicetypes/garyd9/zwave-schlage-touchscreen-lock.src/zwave-schlage-touchscreen-lock.groovy b/devicetypes/garyd9/zwave-schlage-touchscreen-lock.src/zwave-schlage-touchscreen-lock.groovy new file mode 100644 index 0000000..cf6d919 --- /dev/null +++ b/devicetypes/garyd9/zwave-schlage-touchscreen-lock.src/zwave-schlage-touchscreen-lock.groovy @@ -0,0 +1,1389 @@ +/** + * + * INSTRUCTIONS: If you scroll down a couple pages, you should find a line that looks like: + * main "toggle" + * and that followed by a line that STARTS with: + * details(["toggle", + * If you want to change the items that are available on the details page of the device (from 'things'), + * you should edit the "details" line to include whatever items you want to see (along with the order + * you want to see them in.) There's a sample "details" line commented out (starts with //) below the + * first one. That sample enables all (or mostly all) the possible items. + * + * After the first time you have the device type installed (or after you've changed it), you might have + * to forcibly terminate the mobile app before the new/changed stuff will show up. (Most mobile apps + * don't actually terminate when you exit them. How to terminate an app depends on your mobile OS.) + * + * If a toggle is showing up as "loading..." on the UI (and you haven't recently changed it), tap the + * tile and it should reload the status within 10 seconds. + * 2015-08-21 : refactor everything to bring in most of the updates from ST's base z-wave lock type. Ensure + * its compatible with Erik Thayer's "lock code manager" smart app. (https://community.smartthings.com/t/lock-code-manager/12280) + * 2015-03-07 : When the lock is locked/unlocked automatically, from the keypad, or manually, include that + * information in the map.data, usedCode. (0 for keypad, "manual" for manually, and "auto" for automatic) + * 2015-02-02 : changed state values to prevent UI confusion. (Previously, when setting one item to 'unknown', + * the UI might show ALL the items as 'unknown'.) Also added beeper toggle. + * + * This is a modification of work originally copyrighted by "SmartThings." All modifications to their work + * is released under the following terms: + * + * The original licensing applies, with the following exceptions: + * 1. These modifications may NOT be used without freely distributing all these modifications freely + * and without limitation, in source form. The distribution may be met with a link to source code + * with these modifications. + * 2. These modifications may NOT be used, directly or indirectly, for the purpose of any type of + * monetary gain. These modifications may not be used in a larger entity which is being sold, + * leased, or anything other than freely given. + * 3. To clarify 1 and 2 above, if you use these modifications, it must be a free project, and + * available to anyone with "no strings attached." (You may require a free registration on + * a free website or portal in order to distribute the modifications.) + * 4. The above listed exceptions to the original licensing do not apply to the holder of the + * copyright of the original work. The original copyright holder can use the modifications + * to hopefully improve their original work. In that event, this author transfers all claim + * and ownership of the modifications to "SmartThings." + * + * Original Copyright information: + * + * Copyright 2014 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. + * + */ + +metadata +{ + // Automatically generated. Make future change here. + definition (name: "Z-Wave Schlage Touchscreen Lock", namespace: "garyd9", author: "Gary D") + { + capability "Actuator" + capability "Lock" + capability "Polling" + capability "Refresh" + capability "Sensor" + capability "Lock Codes" + capability "Battery" + + attribute "beeperMode", "string" + attribute "vacationMode", "string" // "on", "off", "unknown" + attribute "lockLeave", "string" // "on", "off", "unknown" + attribute "alarmMode", "string" // "unknown", "Off", "Alert", "Tamper", "Kick" + attribute "alarmSensitivity", "number" // 0 is unknown, otherwise 1-5 scaled to 1-99 + attribute "localControl", "string" // "on", "off", "unknown" + attribute "autoLock", "string" // "on", "off", "unknown" + attribute "pinLength", "number" + + command "unlockwtimeout" + + command "setBeeperMode" + command "setVacationMode" + command "setLockLeave" + command "setAlarmMode" + command "setAlarmSensitivity" + command "setLocalControl" + command "setAutoLock" + command "setPinLength" + + fingerprint deviceId: "0x4003", inClusters: "0x98" + fingerprint deviceId: "0x4004", inClusters: "0x98" + } + + simulator { + status "locked": "command: 9881, payload: 00 62 03 FF 00 00 FE FE" + status "unlocked": "command: 9881, payload: 00 62 03 00 00 00 FE FE" + + reply "9881006201FF,delay 4200,9881006202": "command: 9881, payload: 00 62 03 FF 00 00 FE FE" + reply "988100620100,delay 4200,9881006202": "command: 9881, payload: 00 62 03 00 00 00 FE FE" + } + + tiles { + standardTile("toggle", "device.lock", width: 2, height: 2) + { + state "locked", label:'locked', action:"lock.unlock", icon:"st.locks.lock.locked", backgroundColor:"#79b821", nextState:"unlocking" + state "unlocked", label:'unlocked', action:"lock.lock", icon:"st.locks.lock.unlocked", backgroundColor:"#ffffff", nextState:"locking" + state "unknown", label:"unknown", action:"lock.lock", icon:"st.locks.lock.unknown", backgroundColor:"#ffffff", nextState:"locking" + state "locking", label:'locking', icon:"st.locks.lock.locked", backgroundColor:"#79b821" + state "unlocking", label:'unlocking', icon:"st.locks.lock.unlocked", backgroundColor:"#ffffff" + } + standardTile("lock", "device.lock", inactiveLabel: false, decoration: "flat") + { + state "default", label:'lock', action:"lock.lock", icon:"st.locks.lock.locked", nextState:"locking" + } + standardTile("unlock", "device.lock", inactiveLabel: false, decoration: "flat") + { + state "default", label:'unlock', action:"lock.unlock", icon:"st.locks.lock.unlocked", nextState:"unlocking" + } + valueTile("battery", "device.battery", inactiveLabel: false, decoration: "flat") + { + state "battery", label:'${currentValue}% battery', unit:"" + } + standardTile("refresh", "device.lock", inactiveLabel: false, decoration: "flat") + { + state "default", label:' ', action:"refresh.refresh", icon:"st.secondary.refresh" + } + standardTile("alarmMode", "device.alarmMode", inactiveLabel: true, canChangeIcon: false) + { + state "unknown_alarmMode", label: 'Alarm Mode\nLoading...', icon:"st.unknown.unknown.unknown", action:"setAlarmMode", nextState:"unknown_alarmMode" + state "Off_alarmMode", label: 'Alarm: Off', icon:"st.alarm.beep.beep", action:"setAlarmMode", nextState:"unknown_alarmMode" + state "Alert_alarmMode", label: 'Alert Alarm', icon:"st.alarm.beep.beep", action:"setAlarmMode", backgroundColor:"#79b821", nextState:"unknown_alarmMode" + state "Tamper_alarmMode", label: 'Tamper Alarm', icon:"st.alarm.beep.beep", action:"setAlarmMode", backgroundColor:"#79b821", nextState:"unknown_alarmMode" + state "Kick_alarmMode", label: 'Kick Alarm', icon:"st.alarm.beep.beep", action:"setAlarmMode", backgroundColor:"#79b821", nextState:"unknown_alarmMode" + } + controlTile("alarmSensitivity", "device.alarmSensitivity", "slider", height: 1, width: 2, inactiveLabel: false) + { + state "alarmSensitivity", label:'Sensitivity', action:"setAlarmSensitivity", backgroundColor:"#ff0000" + } + standardTile("autoLock", "device.autoLock", inactiveLabel: true, canChangeIcon: false) + { + state "unknown_autoLock", label: 'Auto Lock\nLoading...', icon:"st.unknown.unknown.unknown", action:"setAutoLock", nextState:"unknown_autoLock" + state "off_autoLock", label: 'Auto Lock', icon:"st.presence.house.unlocked", action:"setAutoLock", nextState:"unknown_autoLock" + state "on_autoLock", label: 'Auto Lock', icon:"st.presence.house.secured", action:"setAutoLock", backgroundColor:"#79b821", nextState:"unknown_autoLock" + } + + // not included in details + + standardTile("vacationMode", "device.vacationMode", inactiveLabel: true, canChangeIcon: false) + { + state "unknown_vacationMode", label: 'Vacation\nLoading...', icon:"st.unknown.unknown.unknown", action:"setVacationMode", nextState:"unknown_vacationMode" + state "off_vacationMode", label: 'Vacation', icon:"st.Health & Wellness.health2", action:"setVacationMode", nextState:"unknown_vacationMode" + state "on_vacationMode", label: 'Vacation', icon:"st.Health & Wellness.health2", action:"setVacationMode", backgroundColor:"#79b821", nextState:"unknown_vacationMode" + } + + // not included in details + + standardTile("lockLeave", "device.lockLeave", inactiveLabel: true, canChangeIcon: false) + { + state "unknown_lockLeave", label: 'Lock & Leave\nLoading...', icon:"st.unknown.unknown.unknown", action:"setLockLeave", nextState:"unknown_lockLeave" + state "off_lockLeave", label: 'Lock & Leave', icon:"st.Health & Wellness.health12", action:"setLockLeave", nextState:"unknown_lockLeave" + state "on_lockLeave", label: 'Lock & Leave', icon:"st.Health & Wellness.health12", action:"setLockLeave", backgroundColor:"#79b821", nextState:"unknown_lockLeave" + } + + // not included in details + + standardTile("localControl", "device.localControl", inactiveLabel: true, canChangeIcon: false) + { + state "unknown_localControl", label: 'Local Ctrl\nLoading...', icon:"st.unknown.unknown.unknown", action:"setLocalControl", nextState:"unknown_localControl" + state "off_localControl", label: 'Local Ctrl', icon:"st.Home.home3", action:"setLocalControl", nextState:"unknown_localControl" + state "on_localControl", label: 'Local Ctrl', icon:"st.Home.home3", action:"setLocalControl", backgroundColor:"#79b821", nextState:"unknown_localControl" + } + + + // not included in details + + standardTile("beeperMode", "device.beeperMode", inactiveLabel: true, canChangeIcon: false) + { + state "unknown_beeperMode", label: 'Beeper\nLoading...', icon:"st.unknown.unknown.unknown", action:"setBeeperMode", nextState:"unknown_beeperMode" + state "off_beeperMode", label: 'Beeper', icon:"st.unknown.unknown.unknown", action:"setBeeperMode", nextState:"unknown_beeperMode" + state "on_beeperMode", label: 'Beeper', icon:"st.unknown.unknown.unknown", action:"setBeeperMode", backgroundColor:"#79b821", nextState:"unknown_beeperMode" + } + + + main "toggle" + details(["toggle", "lock", "unlock", "alarmMode", "alarmSensitivity", "battery", "autoLock", "lockLeave", "refresh"]) +// details(["toggle", "lock", "unlock", "alarmMode", "alarmSensitivity", "battery", "autoLock", "lockLeave", "vacationMode", "beeperMode", "refresh"]) + } +} + +import physicalgraph.zwave.commands.doorlockv1.* +import physicalgraph.zwave.commands.usercodev1.* + +def parse(String description) +{ + def result = null + if (description.startsWith("Err")) + { + if (state.sec) + { + result = createEvent(descriptionText:description, displayed:false) + } + else + { + result = createEvent( + descriptionText: "This lock failed to complete the network security key exchange. If you are unable to control it via SmartThings, you must remove it from your network and add it again.", + eventType: "ALERT", + name: "secureInclusion", + value: "failed", + displayed: true, + ) + } + } + else + { + def cmd = zwave.parse(description, [ 0x98: 1, 0x72: 2, 0x85: 2, 0x86: 1 ]) + if (cmd) + { + result = zwaveEvent(cmd) + } + } + log.debug "\"$description\" parsed to ${result.inspect()}" + result +} + +def zwaveEvent(physicalgraph.zwave.commands.securityv1.SecurityMessageEncapsulation cmd) +{ + def encapsulatedCommand = cmd.encapsulatedCommand([0x62: 1, 0x71: 2, 0x80: 1, 0x85: 2, 0x63: 1, 0x98: 1, 0x86: 1]) + // log.debug "encapsulated: $encapsulatedCommand" + if (encapsulatedCommand) + { + zwaveEvent(encapsulatedCommand) + } +} + +def zwaveEvent(physicalgraph.zwave.commands.securityv1.NetworkKeyVerify cmd) { + createEvent(name:"secureInclusion", value:"success", descriptionText:"Secure inclusion was successful") +} + +def zwaveEvent(physicalgraph.zwave.commands.securityv1.SecurityCommandsSupportedReport cmd) +{ + state.sec = cmd.commandClassSupport.collect { String.format("%02X ", it) }.join() + if (cmd.commandClassControl) + { + state.secCon = cmd.commandClassControl.collect { String.format("%02X ", it) }.join() + } + log.debug "Security command classes: $state.sec" + createEvent(name:"secureInclusion", value:"success", descriptionText:"Lock is securely included") +} + +def zwaveEvent(DoorLockOperationReport cmd) +{ + def result = [] + def map = [ name: "lock" ] + if (cmd.doorLockMode == 0xFF) + { + map.value = "locked" + } + else if (cmd.doorLockMode >= 0x40) + { + map.value = "unknown" + } + else if (cmd.doorLockMode & 1) + { + map.value = "unlocked with timeout" + } + else + { + map.value = "unlocked" + if (state.assoc != zwaveHubNodeId) + { + log.debug "setting association" + result << response(secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId))) + result << response(zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId)) + result << response(secure(zwave.associationV1.associationGet(groupingIdentifier:1))) + } + } + result ? [createEvent(map), *result] : createEvent(map) +} + +def zwaveEvent(physicalgraph.zwave.commands.alarmv2.AlarmReport cmd) +{ + def result = [] + def map = null + if (cmd.zwaveAlarmType == 6) // ZWAVE_ALARM_TYPE_ACCESS_CONTROL + { + if (1 <= cmd.zwaveAlarmEvent && cmd.zwaveAlarmEvent < 10) + { + map = [ name: "lock", value: (cmd.zwaveAlarmEvent & 1) ? "locked" : "unlocked" ] + } + switch(cmd.zwaveAlarmEvent) + { + case 1: + map.descriptionText = "$device.displayName was manually locked" + map.data = [ usedCode: "manual" ] + break + case 2: + map.descriptionText = "$device.displayName was manually unlocked" + map.data = [ usedCode: "manual" ] + break + case 5: + if (cmd.eventParameter) + { + map.descriptionText = "$device.displayName was locked with code ${cmd.eventParameter.first()}" + map.data = [ usedCode: cmd.eventParameter[0] ] + } + else + { + map.descriptionText = "$device.displayName was locked with keypad" + map.data = [ usedCode: 0 ] + } + break + case 6: + if (cmd.eventParameter) + { + map.descriptionText = "$device.displayName was unlocked with code ${cmd.eventParameter.first()}" + map.data = [ usedCode: cmd.eventParameter[0] ] + } + else + { + map.descriptionText = "$device.displayName was unlocked with keypad" + map.data = [ usedCode: 0 ] + } + break + case 9: + map.descriptionText = "$device.displayName was autolocked" + map.data = [ usedCode: "auto" ] + break + case 7: + case 8: + case 0xA: + map = [ name: "lock", value: "unknown", descriptionText: "$device.displayName was not locked fully" ] + break + case 0xB: + map = [ name: "lock", value: "unknown", descriptionText: "$device.displayName is jammed", eventType: "ALERT", displayed: true ] + break + case 0xC: + map = [ name: "codeChanged", value: "all", descriptionText: "$device.displayName: all user codes deleted", displayed: true, isStateChange: true ] + allCodesDeleted() + break + case 0xD: + if (cmd.eventParameter) + { + map = [ name: "codeReport", value: cmd.eventParameter[0], data: [ code: "" ], isStateChange: true ] + map.descriptionText = "$device.displayName code ${map.value} was deleted" + map.isStateChange = (state["code$map.value"] != "") + state["code$map.value"] = "" + } + else + { + map = [ name: "codeChanged", descriptionText: "$device.displayName: user code deleted", isStateChange: true ] + } + break + case 0xE: + map = [ name: "codeChanged", value: cmd.alarmLevel, descriptionText: "$device.displayName: user code added", isStateChange: true ] + if (cmd.eventParameter) + { + map.value = cmd.eventParameter[0] + result << response(requestCode(cmd.eventParameter[0])) + } + break + case 0xF: + map = [ name: "tamper", value: "detected", descriptionText: "$device.displayName: Too many user code failures.", eventType: "ALERT", displayed: true, isStateChange: true ] + break + // map = [ name: "codeChanged", descriptionText: "$device.displayName: user code not added, duplicate", isStateChange: true ] + // break + case 0x10: + map = [ name: "tamper", value: "detected", descriptionText: "$device.displayName: keypad temporarily disabled", displayed: true ] + break + case 0x11: + map = [ descriptionText: "$device.displayName: keypad is busy" ] + break + case 0x12: + map = [ name: "codeChanged", descriptionText: "$device.displayName: program code changed", isStateChange: true ] + break + case 0x13: + map = [ name: "tamper", value: "detected", descriptionText: "$device.displayName: code entry attempt limit exceeded", displayed: true ] + break + default: + map = map ?: [ descriptionText: "$device.displayName: alarm event $cmd.zwaveAlarmEvent", displayed: false ] + break + } + } + else if (cmd.zwaveAlarmType == 7) // ZWAVE_ALARM_TYPE_BURGLAR + { + map = [ name: "tamper", value: "detected", displayed: true, isStateChange: true ] + switch (cmd.zwaveAlarmEvent) + { + case 0: + map.value = "clear" + map.descriptionText = "$device.displayName: tamper alert cleared" + break + case 1: + case 2: + map.descriptionText = "$device.displayName: intrusion attempt detected" + break + case 3: + map.descriptionText = "$device.displayName: covering removed" + break + case 4: + map.descriptionText = "$device.displayName: invalid code" + break + default: + map.descriptionText = "$device.displayName: tamper alarm $cmd.zwaveAlarmEvent" + break + } + } + else switch(cmd.alarmType) + { + // Schlage locks should be using the alarmv2 variables above or lock/unlock events +/* + case 21: // Manually locked + case 18: // Locked with keypad + case 24: // Locked by command (Kwikset 914) + case 27: // Autolocked + map = [ name: "lock", value: "locked" ] + break + case 16: // Note: for levers this means it's unlocked, for non-motorized deadbolt, it's just unsecured and might not get unlocked + case 19: + map = [ name: "lock", value: "unlocked" ] + if (cmd.alarmLevel) { + map.descriptionText = "$device.displayName was unlocked with code $cmd.alarmLevel" + map.data = [ usedCode: cmd.alarmLevel ] + } + break + case 22: + case 25: // Kwikset 914 unlocked by command + map = [ name: "lock", value: "unlocked" ] + break +*/ + case 9: + case 17: + case 23: + case 26: + map = [ name: "lock", value: "unknown", descriptionText: "$device.displayName bolt is jammed" ] + break + case 13: + map = [ name: "codeChanged", value: cmd.alarmLevel, descriptionText: "$device.displayName code $cmd.alarmLevel was added", isStateChange: true ] + result << response(requestCode(cmd.alarmLevel)) + break + case 32: + map = [ name: "codeChanged", value: "all", descriptionText: "$device.displayName: all user codes deleted", isStateChange: true ] + allCodesDeleted() + case 33: + map = [ name: "codeReport", value: cmd.alarmLevel, data: [ code: "" ], isStateChange: true ] + map.descriptionText = "$device.displayName code $cmd.alarmLevel was deleted" + map.isStateChange = (state["code$cmd.alarmLevel"] != "") + state["code$cmd.alarmLevel"] = "" + break + case 112: + map = [ name: "codeChanged", value: cmd.alarmLevel, descriptionText: "$device.displayName code $cmd.alarmLevel changed", isStateChange: true ] + result << response(requestCode(cmd.alarmLevel)) + break + case 130: // Yale YRD batteries replaced + map = [ descriptionText: "$device.displayName batteries replaced", isStateChange: true ] + break + case 131: + map = [ /*name: "codeChanged", value: cmd.alarmLevel,*/ descriptionText: "$device.displayName code $cmd.alarmLevel is duplicate", isStateChange: false ] + break + case 161: + if (cmd.alarmLevel == 2) + { + map = [ descriptionText: "$device.displayName front escutcheon removed", isStateChange: true ] + } + else + { + map = [ descriptionText: "$device.displayName detected failed user code attempt", isStateChange: true ] + } + break + case 167: + if (!state.lastbatt || (new Date().time) - state.lastbatt > 12*60*60*1000) + { + map = [ descriptionText: "$device.displayName: battery low", isStateChange: true ] + result << response(secure(zwave.batteryV1.batteryGet())) + } + else + { + map = [ name: "battery", value: device.currentValue("battery"), descriptionText: "$device.displayName: battery low", displayed: true ] + } + break + case 168: + map = [ name: "battery", value: 1, descriptionText: "$device.displayName: battery level critical", displayed: true ] + break + case 169: + map = [ name: "battery", value: 0, descriptionText: "$device.displayName: battery too low to operate lock", isStateChange: true ] + break + default: + map = [ displayed: false, descriptionText: "$device.displayName: alarm event $cmd.alarmType level $cmd.alarmLevel" ] + break + } + result ? [createEvent(map), *result] : createEvent(map) +} + +def zwaveEvent(UserCodeReport cmd) +{ + def result = [] + def name = "code$cmd.userIdentifier" + def code = cmd.code + def map = [:] + if (cmd.userIdStatus == UserCodeReport.USER_ID_STATUS_OCCUPIED || + (cmd.userIdStatus == UserCodeReport.USER_ID_STATUS_STATUS_NOT_AVAILABLE && cmd.user && code != "**********")) + { + if (code == "**********") + { // Schlage locks send us this instead of the real code + state.blankcodes = true + code = state["set$name"] ?: decrypt(state[name]) ?: code + state.remove("set$name".toString()) + } + if (!code && cmd.userIdStatus == 1) + { // Schlage touchscreen sends blank code to notify of a changed code + map = [ name: "codeChanged", value: cmd.userIdentifier, displayed: true, isStateChange: true ] + map.descriptionText = "$device.displayName code $cmd.userIdentifier " + (state[name] ? "changed" : "was added") + code = state["set$name"] ?: decrypt(state[name]) ?: "****" + state.remove("set$name".toString()) + } + else + { + map = [ name: "codeReport", value: cmd.userIdentifier, data: [ code: code ] ] + map.descriptionText = "$device.displayName code $cmd.userIdentifier is set" + map.displayed = (cmd.userIdentifier != state.requestCode && cmd.userIdentifier != state.pollCode) + map.isStateChange = (code != decrypt(state[name])) + } + result << createEvent(map) + } + else + { + map = [ name: "codeReport", value: cmd.userIdentifier, data: [ code: "" ] ] + if (state.blankcodes && state["reset$name"]) + { // we deleted this code so we can tell that our new code gets set + map.descriptionText = "$device.displayName code $cmd.userIdentifier was reset" + map.displayed = map.isStateChange = false + result << createEvent(map) + state["set$name"] = state["reset$name"] + result << response(setCode(cmd.userIdentifier, state["reset$name"])) + state.remove("reset$name".toString()) + } + else + { + if (state[name]) + { + map.descriptionText = "$device.displayName code $cmd.userIdentifier was deleted" + } + else + { + map.descriptionText = "$device.displayName code $cmd.userIdentifier is not set" + } + map.displayed = (cmd.userIdentifier != state.requestCode && cmd.userIdentifier != state.pollCode) + map.isStateChange = state[name] as Boolean + result << createEvent(map) + } + code = "" + } + state[name] = code ? encrypt(code) : code + + if (cmd.userIdentifier == state.requestCode) + { // reloadCodes() was called, keep requesting the codes in order + if (state.requestCode + 1 > state.codes || state.requestCode >= 30) + { + state.remove("requestCode") // done + } + else + { + state.requestCode = state.requestCode + 1 // get next + result << response(requestCode(state.requestCode)) + } + } + if (cmd.userIdentifier == state.pollCode) + { + if (state.pollCode + 1 > state.codes || state.pollCode >= 30) + { + state.remove("pollCode") // done + } + else + { + state.pollCode = state.pollCode + 1 + } + } + log.debug "code report parsed to ${result.inspect()}" + result +} + +def zwaveEvent(UsersNumberReport cmd) +{ + def result = [] + state.codes = cmd.supportedUsers + if (state.requestCode && state.requestCode <= cmd.supportedUsers) + { + result << response(requestCode(state.requestCode)) + } + result +} + +def zwaveEvent(physicalgraph.zwave.commands.associationv2.AssociationReport cmd) { + def result = [] + if (cmd.nodeId.any { it == zwaveHubNodeId }) + { + state.remove("associationQuery") + log.debug "$device.displayName is associated to $zwaveHubNodeId" + result << createEvent(descriptionText: "$device.displayName is associated") + state.assoc = zwaveHubNodeId + if (cmd.groupingIdentifier == 2) + { + result << response(zwave.associationV1.associationRemove(groupingIdentifier:1, nodeId:zwaveHubNodeId)) + } + } + else if (cmd.groupingIdentifier == 1) + { + result << response(secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId))) + } + else if (cmd.groupingIdentifier == 2) + { + result << response(zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId)) + } + result +} + +def zwaveEvent(physicalgraph.zwave.commands.timev1.TimeGet cmd) +{ + def result = [] + def now = new Date().toCalendar() + if(location.timeZone) now.timeZone = location.timeZone + result << createEvent(descriptionText: "$device.displayName requested time update", displayed: false) + result << response(secure(zwave.timeV1.timeReport( + hourLocalTime: now.get(Calendar.HOUR_OF_DAY), + minuteLocalTime: now.get(Calendar.MINUTE), + secondLocalTime: now.get(Calendar.SECOND))) + ) + result +} + +def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet cmd) +{ + // The old Schlage locks use group 1 for basic control - we don't want that, so unsubscribe from group 1 + def result = [ createEvent(name: "lock", value: cmd.value ? "unlocked" : "locked") ] + result << response(zwave.associationV1.associationRemove(groupingIdentifier:1, nodeId:zwaveHubNodeId)) + if (state.assoc != zwaveHubNodeId) + { + result << response(zwave.associationV1.associationGet(groupingIdentifier:2)) + } + result +} + +def zwaveEvent(physicalgraph.zwave.commands.batteryv1.BatteryReport cmd) +{ + def map = [ name: "battery", unit: "%" ] + if (cmd.batteryLevel == 0xFF) + { + map.value = 1 + map.descriptionText = "$device.displayName has a low battery" + } + else + { + map.value = cmd.batteryLevel + } + state.lastbatt = new Date().time + createEvent(map) +} + +def zwaveEvent(physicalgraph.zwave.commands.manufacturerspecificv2.ManufacturerSpecificReport cmd) +{ + def result = [] + + def msr = String.format("%04X-%04X-%04X", cmd.manufacturerId, cmd.productTypeId, cmd.productId) + log.debug "msr: $msr" + updateDataValue("MSR", msr) + + result << createEvent(descriptionText: "$device.displayName MSR: $msr", isStateChange: false) + result +} + +def zwaveEvent(physicalgraph.zwave.commands.versionv1.VersionReport cmd) +{ + def fw = "${cmd.applicationVersion}.${cmd.applicationSubVersion}" + updateDataValue("fw", fw) + if (state.MSR == "003B-6341-5044") { + updateDataValue("ver", "${cmd.applicationVersion >> 4}.${cmd.applicationVersion & 0xF}") + } + def text = "$device.displayName: firmware version: $fw, Z-Wave version: ${cmd.zWaveProtocolVersion}.${cmd.zWaveProtocolSubVersion}" + createEvent(descriptionText: text, isStateChange: false) +} + +def zwaveEvent(physicalgraph.zwave.commands.applicationstatusv1.ApplicationBusy cmd) +{ + def msg = cmd.status == 0 ? "try again later" : + cmd.status == 1 ? "try again in $cmd.waitTime seconds" : + cmd.status == 2 ? "request queued" : "sorry" + createEvent(displayed: true, descriptionText: "$device.displayName is busy, $msg") +} + +def zwaveEvent(physicalgraph.zwave.commands.applicationstatusv1.ApplicationRejectedRequest cmd) +{ + createEvent(displayed: true, descriptionText: "$device.displayName rejected the last request") +} + +def zwaveEvent(physicalgraph.zwave.commands.configurationv2.ConfigurationReport cmd) +{ + def result = [] + def map = null // use this for config reports that are handled + + // use desc/val for generic handling of config reports (it will just send a descriptionText for the acitivty stream) + def desc = null + def val = "" + + switch (cmd.parameterNumber) + { + case 0x3: + map = parseBinaryConfigRpt('beeperMode', cmd.configurationValue[0], 'Beeper Mode') + break + + // done: vacation mode toggle + case 0x4: + map = parseBinaryConfigRpt('vacationMode', cmd.configurationValue[0], 'Vacation Mode') + break + + // done: lock and leave mode + case 0x5: + map = parseBinaryConfigRpt('lockLeave', cmd.configurationValue[0], 'Lock & Leave') + break + + // these don't seem to be useful. It's just a bitmap of the code slots used. + case 0x6: + desc = "User Slot Bit Fields" + val = "${cmd.configurationValue[3]} ${cmd.configurationValue[2]} ${cmd.configurationValue[1]} ${cmd.configurationValue[0]}" + break + + // done: the alarm mode of the lock. + case 0x7: + map = [ name:"alarmMode", displayed: true ] + // when getting the alarm mode, also query the sensitivity for that current alarm mode + switch (cmd.configurationValue[0]) + { + case 0x00: + map.value = "Off_alarmMode" + break + case 0x01: + map.value = "Alert_alarmMode" + result << response(secure(zwave.configurationV2.configurationGet(parameterNumber: 0x08))) + break + case 0x02: + map.value = "Tamper_alarmMode" + result << response(secure(zwave.configurationV2.configurationGet(parameterNumber: 0x09))) + break + case 0x03: + map.value = "Kick_alarmMode" + result << response(secure(zwave.configurationV2.configurationGet(parameterNumber: 0x0A))) + break + default: + map.value = "unknown_alarmMode" + } + map.descriptionText = "$device.displayName Alarm Mode set to \"$map.value\"" + break + + // done: alarm sensitivities - one for each mode + case 0x8: + case 0x9: + case 0xA: + def whichMode = null + switch (cmd.parameterNumber) + { + case 0x8: + whichMode = "Alert" + break; + case 0x9: + whichMode = "Tamper" + break; + case 0xA: + whichMode = "Kick" + break; + } + def curAlarmMode = device.currentValue("alarmMode") + val = "${cmd.configurationValue[0]}" + + // the lock has sensitivity values between 1 and 5. ST sliders want a value between 0 and 99. Use a formula + // to make the internal attribute something visually appealing on the UI slider + def modifiedValue = (cmd.configurationValue[0] * 24) - 23 + + map = [ descriptionText: "$device.displayName Alarm $whichMode Sensitivity set to $val", displayed: true ] + + if (curAlarmMode == "${whichMode}_alarmMode") + { + map.name = "alarmSensitivity" + map.value = modifiedValue + } + else + { + log.debug "got sensitivity for $whichMode while in $curAlarmMode" + map.isStateChange = true + } + + break + + case 0xB: + map = parseBinaryConfigRpt('localControl', cmd.configurationValue[0], 'Local Alarm Control') + break + + // how many times has the electric motor locked or unlock the device? + case 0xC: + desc = "Electronic Transition Count" + def ttl = cmd.configurationValue[3] + (cmd.configurationValue[2] * 0x100) + (cmd.configurationValue[1] * 0x10000) + (cmd.configurationValue[0] * 0x1000000) + val = "$ttl" + break + + // how many times has the device been locked or unlocked manually? + case 0xD: + desc = "Mechanical Transition Count" + def ttl = cmd.configurationValue[3] + (cmd.configurationValue[2] * 0x100) + (cmd.configurationValue[1] * 0x10000) + (cmd.configurationValue[0] * 0x1000000) + val = "$ttl" + break + + // how many times has there been a failure by the electric motor? (due to jamming??) + case 0xE: + desc = "Electronic Failed Count" + def ttl = cmd.configurationValue[3] + (cmd.configurationValue[2] * 0x100) + (cmd.configurationValue[1] * 0x10000) + (cmd.configurationValue[0] * 0x1000000) + val = "$ttl" + break + + // done: auto lock mode + case 0xF: + map = parseBinaryConfigRpt('autoLock', cmd.configurationValue[0], 'Auto Lock') + break + + // this will be useful as an attribute/command usable by a smartapp + case 0x10: + map = [ name: 'pinLength', value: cmd.configurationValue[0], displayed: true, descriptionText: "$device.displayName PIN length configured to ${cmd.configurationValue[0]} digits"] + break + + // not sure what this one stores + case 0x11: + desc = "Electronic High Preload Transition Count" + def ttl = cmd.configurationValue[3] + (cmd.configurationValue[2] * 0x100) + (cmd.configurationValue[1] * 0x10000) + (cmd.configurationValue[0] * 0x1000000) + val = "$ttl" + break + + // ??? + case 0x12: + desc = "Bootloader Version" + val = "${cmd.configurationValue[0]}" + break + default: + desc = "Unknown parameter ${cmd.parameterNumber}" + val = "${cmd.configurationValue[0]}" + break + } + if (map) + { + result << createEvent(map) + } + else if (desc != null) + { + // generic description text + result << createEvent([ descriptionText: "$device.displayName reports \"$desc\" configured as \"$val\"", displayed: true, isStateChange: true ]) + } + result +} + +def parseBinaryConfigRpt(paramName, paramValue, paramDesc) +{ + def map = [ name: paramName, displayed: true ] + + def newVal = "on" + if (paramValue == 0) + { + newVal = "off" + } + map.value = "${newVal}_${paramName}" + map.descriptionText = "$device.displayName $paramDesc has been turned $newVal" + return map +} + + + +def zwaveEvent(physicalgraph.zwave.Command cmd) +{ + createEvent(displayed: false, descriptionText: "$device.displayName: $cmd") +} + +def lockAndCheck(doorLockMode) +{ + secureSequence([ + zwave.doorLockV1.doorLockOperationSet(doorLockMode: doorLockMode), + zwave.doorLockV1.doorLockOperationGet() + ], 4200) +} + +def lock() +{ + lockAndCheck(DoorLockOperationSet.DOOR_LOCK_MODE_DOOR_SECURED) +} + +def unlock() +{ + lockAndCheck(DoorLockOperationSet.DOOR_LOCK_MODE_DOOR_UNSECURED) +} + +def unlockwtimeout() +{ + lockAndCheck(DoorLockOperationSet.DOOR_LOCK_MODE_DOOR_UNSECURED_WITH_TIMEOUT) +} + +def refresh() { +// def cmds = [secure(zwave.doorLockV1.doorLockOperationGet())] + def cmds = secureSequence([ + zwave.doorLockV1.doorLockOperationGet(), +// zwave.configurationV2.configurationBulkGet(numberOfParameters: 3, parameterOffset: 0x8), +// zwave.configurationV2.configurationBulkGet(numberOfParameters: 4, parameterOffset: 0x3), +// zwave.configurationV2.configurationGet(parameterNumber: 0x3), // beeper (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0x4), // vacation mode (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0x5), // lock and leave (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0x6), // user slot bit field (not needed) +// zwave.configurationV2.configurationGet(parameterNumber: 0x7), // alarm mode (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0x8), // alert alarm sensitivity (done: retrieved after alarm mode) +// zwave.configurationV2.configurationGet(parameterNumber: 0x9), // tamper alarm sensitivity (done: retrieved after alarm mode) +// zwave.configurationV2.configurationGet(parameterNumber: 0xA), // kick alarm sensititivy (done: retrieved after alarm mode) +// zwave.configurationV2.configurationGet(parameterNumber: 0xB), // local alarm control disable (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0xC), // electronic transition count +// zwave.configurationV2.configurationGet(parameterNumber: 0xD), // mechanical transition count +// zwave.configurationV2.configurationGet(parameterNumber: 0xE), // electronic failure count +// zwave.configurationV2.configurationGet(parameterNumber: 0xF), // autolock (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0x10), // user code pin length (done) +// zwave.configurationV2.configurationGet(parameterNumber: 0x11,) // electronic high preload transition count +// zwave.configurationV2.configurationGet(parameterNumber: 0x12,) // bootloader version + + ], 6000) + + // go ahead and fill in any missing values + if (null == device.latestValue("pinLength")) + { + log.debug "getting pin length" + cmds << "delay 6000" + cmds << secure(zwave.configurationV2.configurationGet(parameterNumber: 0x10)) + } + + + if (state.assoc == zwaveHubNodeId) { + log.debug "$device.displayName is associated to ${state.assoc}" + } else if (!state.associationQuery) { + log.debug "checking association" + cmds << "delay 4200" + cmds << zwave.associationV1.associationGet(groupingIdentifier:2).format() // old Schlage locks use group 2 and don't secure the Association CC + cmds << secure(zwave.associationV1.associationGet(groupingIdentifier:1)) + state.associationQuery = new Date().time + } else if (new Date().time - state.associationQuery.toLong() > 9000) { + log.debug "setting association" + cmds << "delay 6000" + cmds << zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId).format() + cmds << secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId)) + cmds << zwave.associationV1.associationGet(groupingIdentifier:2).format() + cmds << secure(zwave.associationV1.associationGet(groupingIdentifier:1)) + state.associationQuery = new Date().time + } + log.debug "refresh is sending ${cmds.inspect()}, state: ${state.inspect()}" + cmds +} + +def poll() { + def cmds = [] + state.pinLength = null; + if (state.assoc != zwaveHubNodeId && secondsPast(state.associationQuery, 19 * 60)) + { + log.debug "setting association" + cmds << zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId).format() + cmds << secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId)) + cmds << zwave.associationV1.associationGet(groupingIdentifier:2).format() + cmds << "delay 6000" + cmds << secure(zwave.associationV1.associationGet(groupingIdentifier:1)) + cmds << "delay 6000" + state.associationQuery = new Date().time + } + else + { + // go ahead and fill in any missing values + if (null == device.latestValue("pinLength")) + { + cmds << secure(zwave.configurationV2.configurationGet(parameterNumber: 0x10)) + cmds << "delay 6000" + } + + // Only check lock state if it changed recently or we haven't had an update in an hour + def latest = device.currentState("lock")?.date?.time + if (!latest || !secondsPast(latest, 6 * 60) || secondsPast(state.lastPoll, 55 * 60)) + { + cmds << secure(zwave.doorLockV1.doorLockOperationGet()) + state.lastPoll = (new Date()).time + } + else if (!state.MSR) + { + cmds << zwave.manufacturerSpecificV1.manufacturerSpecificGet().format() + } + else if (!state.fw) + { + cmds << zwave.versionV1.versionGet().format() + } + else if (!state.codes) + { + state.pollCode = 1 + cmds << secure(zwave.userCodeV1.usersNumberGet()) + } + else if (state.pollCode && state.pollCode <= state.codes) + { + cmds << requestCode(state.pollCode) + } + else if (!state.lastbatt || (new Date().time) - state.lastbatt > 53*60*60*1000) + { + cmds << secure(zwave.batteryV1.batteryGet()) + } + else if (!state.enc) + { + encryptCodes() + state.enc = 1 + } + } + reportAllCodes(state) + + log.debug "poll is sending ${cmds.inspect()}" + device.activity() + cmds ?: null +} + +private def encryptCodes() +{ + def keys = new ArrayList(state.keySet().findAll { it.startsWith("code") }) + keys.each { key -> + def match = (key =~ /^code(\d+)$/) + if (match) try + { + def keynum = match[0][1].toInteger() + if (keynum > 30 && !state[key]) + { + state.remove(key) + } + else if (state[key] && !state[key].startsWith("~")) + { + log.debug "encrypting $key: ${state[key].inspect()}" + state[key] = encrypt(state[key]) + } + } + catch (java.lang.NumberFormatException e) { } + } +} + +def requestCode(codeNumber) +{ + log.debug "getting user code $codeNumber" + secure(zwave.userCodeV1.userCodeGet(userIdentifier: codeNumber)) +} + +def reloadAllCodes() +{ + def cmds = [] + if (!state.codes) + { + state.requestCode = 1 + cmds << secure(zwave.userCodeV1.usersNumberGet()) + } + else + { + if(!state.requestCode) state.requestCode = 1 + cmds << requestCode(codeNumber) + } + cmds +} + +def setCode(codeNumber, code) +{ + def strcode = code + log.debug "setting code $codeNumber to $code" + if (code instanceof String) + { + code = code.toList().findResults { if(it > ' ' && it != ',' && it != '-') it.toCharacter() as Short } + } + else + { + strcode = code.collect{ it as Character }.join() + } + if (state.blankcodes) + { + // Can't just set, we won't be able to tell if it was successful + if (state["code$codeNumber"] != "") + { + if (state["setcode$codeNumber"] != strcode) + { + state["resetcode$codeNumber"] = strcode + return deleteCode(codeNumber) + } + } + else + { + state["setcode$codeNumber"] = strcode + } + } + secureSequence([ + zwave.userCodeV1.userCodeSet(userIdentifier:codeNumber, userIdStatus:1, user:code), + zwave.userCodeV1.userCodeGet(userIdentifier:codeNumber) + ], 7000) +} + +def deleteCode(codeNumber) +{ + log.debug "deleting code $codeNumber" + secureSequence([ + zwave.userCodeV1.userCodeSet(userIdentifier:codeNumber, userIdStatus:0), + zwave.userCodeV1.userCodeGet(userIdentifier:codeNumber) + ], 7000) +} + +def updateCodes(codeSettings) +{ +log.debug "updateCodes called with: ${codeSettings.inspect()}" + if(codeSettings instanceof String) codeSettings = util.parseJson(codeSettings) + def set_cmds = [] + def get_cmds = [] + codeSettings.each { name, updated -> + def current = decrypt(state[name]) + if (name.startsWith("code")) + { + def n = name[4..-1].toInteger() + if (updated?.size() >= 4 && updated != current) + { + log.debug "$name was $current, set to $updated" + def cmds = setCode(n, updated) + set_cmds << cmds.first() + get_cmds << cmds.last() + } + else if ((current && updated == "") || updated == "0") + { + log.debug "$name was $current, set to deleted" + def cmds = deleteCode(n) + set_cmds << cmds.first() + get_cmds << cmds.last() + } + else if (updated && updated.size() < 4) + { + log.debug "Attempt to set $name to a value that's too short" + // Entered code was too short + codeSettings["code$n"] = current + } + else + { + log.debug "$name remains unchanged." + } + } + else + log.warn("unexpected entry $name: $updated") + } + if (set_cmds) + { + return response(delayBetween(set_cmds, 2200) + ["delay 2200"] + delayBetween(get_cmds, 4200)) + } +} + +def getCode(codeNumber) +{ + decrypt(state["code$codeNumber"]) +} + +def getAllCodes() +{ + state.findAll { it.key.startsWith 'code' }.collectEntries { + [it.key, (it.value instanceof String && it.value.startsWith("~")) ? decrypt(it.value) : it.value] + } +} + +private secure(physicalgraph.zwave.Command cmd) +{ + zwave.securityV1.securityMessageEncapsulation().encapsulate(cmd).format() +} + +private secureSequence(commands, delay=4200) +{ + delayBetween(commands.collect{ secure(it) }, delay) +} + +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 (new Date().time - timestamp) > (seconds * 1000) +} + +private allCodesDeleted() { + if (state.codes instanceof Integer) + { + (1..state.codes).each { n -> + if (state["code$n"]) + { + result << createEvent(name: "codeReport", value: n, data: [ code: "" ], descriptionText: "code $n was deleted", + displayed: false, isStateChange: true) + } + state["code$n"] = "" + } + } +} + +// all the on/off parameters work the same way, so make a common method +// to deal with them +// +def setOnOffParameter(paramName, paramNumber) +{ + def cmds = null + def cs = device.currentValue(paramName) + + // change parameter to the 'unknown' value - it will get refreshed after it is done changing + sendEvent(name: paramName, value: "unknown_${paramName}", displayed: false ) + + if (cs == "on_${paramName}") + { + // turn it off + cmds = secureSequence([zwave.configurationV2.configurationSet(parameterNumber: paramNumber, size: 1, configurationValue: [0])],5000) + } + else if (cs == "off_${paramName}") + { + // turn it on + cmds = secureSequence([zwave.configurationV2.configurationSet(parameterNumber: paramNumber, size: 1, configurationValue: [0xFF])],5000) + } + else + { + // it's in an unknown state, so just query it + cmds = secureSequence([zwave.configurationV2.configurationGet(parameterNumber: paramNumber)], 5000) + } + + log.debug "set $paramName sending ${cmds.inspect()}" + + cmds +} + +def setBeeperMode() +{ + setOnOffParameter("beeperMode", 0x3) +} + +def setVacationMode() +{ + setOnOffParameter("vacationMode", 0x4) +} + +def setLockLeave() +{ + setOnOffParameter("lockLeave", 0x5) +} + +def setLocalControl() +{ + setOnOffParameter("localControl", 0xB) +} + +def setAutoLock() +{ + setOnOffParameter("autoLock", 0xF) +} + +def setAlarmMode() +{ + + def cs = device.currentValue("alarmMode") + def newMode = 0x0 + + def cmds = null + + switch (cs) + { + case "Off_alarmMode": + newMode = 0x1 + break + + case "Alert_alarmMode": + newMode = 0x2 + break + + case "Tamper_alarmMode": + newMode = 0x3 + break; + + case "Kick_alarmMode": + newMode = 0x0 + break; + + case "unknown_alarmMode": + default: + // don't send a mode - instead request the current state + cmds = secureSequence([zwave.configurationV2.configurationGet(parameterNumber: 0x7)], 5000) + + } + if (cmds == null) + { + // change the alarmSensitivity to the 'unknown' value - it will get refreshed after the alarm mode is done changing + sendEvent(name: 'alarmSensitivity', value: 0, displayed: false ) + cmds = secureSequence([zwave.configurationV2.configurationSet(parameterNumber: 7, size: 1, configurationValue: [newMode])],5000) + } + + log.debug "setAlarmMode sending ${cmds.inspect()}" + cmds +} + +def setPinLength(newValue) +{ + def cmds = null + if ((newValue == null) || (newValue == 0)) + { + // just send a request to refresh the value + cmds = secureSequence([zwave.configurationV2.configurationGet(parameterNumber: 0x10)],5000) + } + else if (newValue <= 8) + { + sendEvent(descriptionText: "$device.displayName attempting to change PIN length to $newValue", displayed: true, isStateChange: true) + cmds = secureSequence([zwave.configurationV2.configurationSet(parameterNumber: 10, size: 1, configurationValue: [newValue])],5000) + } + else + { + sendEvent(descriptionText: "$device.displayName UNABLE to set PIN length of $newValue", displayed: true, isStateChange: true) + } + log.debug "setPinLength sending ${cmds.inspect()}" + cmds +} + +def setAlarmSensitivity(newValue) +{ + def cmds = null + if (newValue != null) + { + // newvalue will be between 0 and 99, but we need a value between 1 and 5 inclusive... + newValue = (newValue / 20) + 1 + newValue = newValue.toInteger(); + + // there are three possible values to set. which one depends on the current alarmMode + def cs = device.currentValue("alarmMode") + + def paramToSet = 0 + + switch(cs) + { + case "Off": + // do nothing. the slider should be disabled anyway + break + case "Alert": + // set param 8 + paramToSet = 0x8 + break; + case "Tamper": + paramToSet = 0x9 + break + case "Kick": + paramToSet = 0xA + break + default: + sendEvent(descriptionText: "$device.displayName unable to set alarm sensitivity while alarm mode in unknown state", displayed: true, isStateChange: true) + break + } + if (paramToSet != 0) + { + // first set the attribute to 0 for UI purposes + sendEvent(name: 'alarmSensitivity', value: 0, displayed: false ) + // then add the actual attribute set call + cmds = secureSequence([zwave.configurationV2.configurationSet(parameterNumber: paramToSet, size: 1, configurationValue: [newValue])],5000) + log.debug "setAlarmSensitivity sending ${cmds.inspect()}" + } + } + cmds +} + +// provides compatibility with Erik Thayer's "Lock Code Manager" +def reportAllCodes(state) +{ + def map = [ name: "reportAllCodes", data: [:], displayed: false, isStateChange: false, type: "physical" ] + state.each { entry -> + //iterate through all the state entries and add them to the event data to be handled by application event handlers + if ( entry.key ==~ /^code\d{1,}/ && entry.value.startsWith("~") ) + { + map.data.put(entry.key, decrypt(entry.value)) + } + else + { + map.data.put(entry.key, entry.value) + } + } + sendEvent(map) +} \ No newline at end of file diff --git a/smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy b/smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy new file mode 100644 index 0000000..38b893d --- /dev/null +++ b/smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy @@ -0,0 +1,1532 @@ +/** + * User Lock Manager v4.1.5 + * + * Copyright 2015 Erik Thayer + * Keypad support added by BLebson + * Door manual unlock notifications and option by DimitriRodis 2016-06-24 + * + * + */ +definition( + name: "User Lock Manager", + namespace: "ethayer", + author: "Erik Thayer", + description: "This app allows you to change, delete, and schedule user access.", + category: "Safety & Security", + iconUrl: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanager.png", + iconX2Url: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanagerx2.png", + iconX3Url: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanagerx3.png") + + import groovy.json.JsonSlurper + import groovy.json.JsonBuilder + +preferences { + page(name: "rootPage") + page(name: "setupPage") + page(name: "userPage") + page(name: "notificationPage") + page(name: "onUnlockPage") + page(name: "schedulingPage") + page(name: "calendarPage") + page(name: "resetAllCodeUsagePage") + page(name: "resetCodeUsagePage") + page(name: "reEnableUserPage") + page(name: "infoPage") + page(name: "keypadPage") + page(name: "infoRefreshPage") + page(name: "lockInfoPage") +} + +def rootPage() { + //reset errors on each load + dynamicPage(name: "rootPage", title: "", install: true, uninstall: true) { + + section("Which Locks?") { + input "theLocks","capability.lockCodes", title: "Select Locks", required: true, multiple: true, submitOnChange: true + } + + if (theLocks) { + initalizeLockData() + + section { + input name: "maxUsers", title: "Number of users", type: "number", multiple: false, refreshAfterSelection: true, submitOnChange: true + href(name: "toSetupPage", title: "User Settings", page: "setupPage", description: setupPageDescription(), state: setupPageDescription() ? "complete" : "") + href(name: "toInfoPage", page: "infoPage", title: "Lock Info") + href(name: "toKeypadPage", page: "keypadPage", title: "Keypad Info (optional)") + href(name: "toNotificationPage", page: "notificationPage", title: "Notification Settings", description: notificationPageDescription(), state: notificationPageDescription() ? "complete" : "") + href(name: "toSchedulingPage", page: "schedulingPage", title: "Schedule (optional)", description: schedulingHrefDescription(), state: schedulingHrefDescription() ? "complete" : "") + href(name: "toOnUnlockPage", page: "onUnlockPage", title: "Global Hello Home") + } + section { + label(title: "Label this SmartApp", required: false, defaultValue: "") + } + } + } +} + +def setupPage() { + dynamicPage(name:"setupPage", title:"User Settings") { + if (maxUsers > 0) { + section('Users') { + (1..maxUsers).each { user-> + if (!state."userState${user}") { + //there's no values, so reset + resetCodeUsage(user) + } + if (settings."userCode${user}" && settings."userSlot${user}") { + getConflicts(settings."userSlot${user}") + } + href(name: "toUserPage${user}", page: "userPage", params: [number: user], required: false, description: userHrefDescription(user), title: userHrefTitle(user), state: userPageState(user) ) + } + } + section { + href(name: "toResetAllCodeUsage", title: "Reset Code Usage", page: "resetAllCodeUsagePage", description: "Tap to reset") + } + } else { + section("Users") { + paragraph "Users are set to zero. Please go back to the main page and change the number of users to at least 1." + } + } + } +} + +def userPage(params) { + dynamicPage(name:"userPage", title:"User Settings") { + def i = getUser(params); + + if (!state."userState${i}".enabled) { + section { + paragraph "WARNING:\n\nThis user has been disabled.\nReason: ${state."userState${i}".disabledReason}" + href(name: "toreEnableUserPage", title: "Reset User", page: "reEnableUserPage", params: [number: i], description: "Tap to reset") + } + } + if (settings."userCode${i}" && settings."userSlot${i}") { + def conflict = getConflicts(settings."userSlot${i}") + if (conflict.has_conflict) { + section("Conflicts:") { + theLocks.each { lock-> + if (conflict."lock${lock.id}" && conflict."lock${lock.id}".conflicts != []) { + paragraph "${lock.displayName} slot ${fancyString(conflict."lock${lock.id}".conflicts)}" + } + } + } + } + } + section("Code #${i}") { + input(name: "userName${i}", type: "text", title: "Name for User", defaultValue: settings."userName${i}") + def title = "Code (4 to 8 digits)" + theLocks.each { lock-> + if (lock.hasAttribute('pinLength')) { + title = "Code (Must be ${lock.latestValue('pinLength')} digits)" + } + } + input(name: "userCode${i}", type: "text", title: title, required: false, defaultValue: settings."userCode${i}", refreshAfterSelection: true) + input(name: "userSlot${i}", type: "number", title: "Slot (1 through 30)", defaultValue: preSlectedCode(i)) + } + section { + input(name: "dontNotify${i}", title: "Mute entry notification?", type: "bool", required: false, defaultValue: settings."dontNotify${i}") + input(name: "burnCode${i}", title: "Burn after use?", type: "bool", required: false, defaultValue: settings."burnCode${i}") + input(name: "userEnabled${i}", title: "Enabled?", type: "bool", required: false, defaultValue: settings."userEnabled${i}") + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + if (hhPhrases) { + hhPhrases.sort() + input name: "userHomePhrases${i}", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, defaultValue: settings."userHomePhrases${i}", refreshAfterSelection: true + input "userNoRunPresence${i}", "capability.presenceSensor", title: "Don't run Actions if any of these are present:", multiple: true, required: false, defaultValue: settings."userNoRunPresence${i}" || false + input "userDoRunPresence${i}", "capability.presenceSensor", title: "Run Actions only if any of these are present:", multiple: true, required: false, defaultValue: settings."userDoRunPresence${i}" || false + } + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + href(name: "toResetCodeUsagePage", title: "Reset Code Usage", page: "resetCodeUsagePage", params: [number: i], description: "Tap to reset") + } + } +} + +def preSlectedCode(i) { + if (settings."userSlot${i}" != null) { + return settings."userSlot${i}" + } else { + return i + } +} + +def notificationPage() { + dynamicPage(name: "notificationPage", title: "Notification Settings") { + + section { + input(name: "phone", type: "text", title: "Text This Number", description: "Phone number", required: false, submitOnChange: true) + paragraph "For multiple SMS recipients, separate phone numbers with a semicolon(;)" + input(name: "notification", type: "bool", title: "Send A Push Notification", description: "Notification", required: false, submitOnChange: true) + if (phone != null || notification || sendevent) { + input(name: "notifyAccess", title: "on User Entry", type: "bool", required: false) + input(name: "notifyLock", title: "on Lock", type: "bool", required: false) + input(name: "notifyUnlock", title: "on Unlock", type: "bool", required: false) + input(name: "notifyAccessStart", title: "when granting access", type: "bool", required: false) + input(name: "notifyAccessEnd", title: "when revoking access", type: "bool", required: false) + } + } + + section("Only During These Times (optional)") { + input(name: "notificationStartTime", type: "time", title: "Notify Starting At This Time", description: null, required: false) + input(name: "notificationEndTime", type: "time", title: "Notify Ending At This Time", description: null, required: false) + } + } +} + +def schedulingPage() { + dynamicPage(name: "schedulingPage", title: "Rules For Access Scheduling") { + if (!days) { + section { + href(name: "toCalendarPage", title: "Calendar", page: "calendarPage", description: calendarHrefDescription(), state: calendarHrefDescription() ? "complete" : "") + } + } + if (!startDay && !startMonth && !startYear && !endDay && !endMonth && !endYear) { + section { + input(name: "days", type: "enum", title: "Allow User Access On These Days", description: "Every day", required: false, multiple: true, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], submitOnChange: true) + } + section { + input(name: "modeStart", title: "Allow Access when in this mode", type: "mode", required: false, mutliple: false, submitOnChange: true) + } + section { + if (modeStart) { + input "andOrTime", "enum", title: "[And/Or] at a set time?", metadata:[values:["and", "or"]], required: false, submitOnChange: true + } + if ((modeStart == null) || andOrTime) { + input(name: "startTime", type: "time", title: "Start Time", description: null, required: false) + input(name: "endTime", type: "time", title: "End Time", description: null, required: false) + } + } + } + } +} + +def calendarPage() { + dynamicPage(name: "calendarPage", title: "Calendar Access") { + section() { + paragraph "This page is for advanced users only. You must enter each field carefully." + paragraph "Calendar use does not support daily grant/deny OR Modes. You cannot both have a date here, and allow access only on certain days/modes." + } + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + section("Start Date") { + input name: "startDay", type: "number", title: "Day", required: false + input name: "startMonth", type: "number", title: "Month", required: false + input name: "startYear", type: "number", description: "Format(yyyy)", title: "Year", required: false + input name: "startTime", type: "time", title: "Start Time", description: null, required: false + if (hhPhrases) { + hhPhrases.sort() + input name: "calStartPhrase", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, refreshAfterSelection: true + } + } + section("End Date") { + input name: "endDay", type: "number", title: "Day", required: false + input name: "endMonth", type: "number", title: "Month", required: false + input name: "endYear", type: "number", description: "Format(yyyy)", title: "Year", required: false + input name: "endTime", type: "time", title: "End Time", description: null, required: false + if (hhPhrases) { + hhPhrases.sort() + input name: "calEndPhrase", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, refreshAfterSelection: true + } + } + } +} + +def onUnlockPage() { + dynamicPage(name:"onUnlockPage", title:"Global Actions (Any Code)") { + section("Actions") { + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + if (hhPhrases) { + hhPhrases.sort() + input name: "homePhrases", type: "enum", title: "Home Mode Phrase", multiple: true, required: false, options: hhPhrases, refreshAfterSelection: true, submitOnChange: true + if (homePhrases) { + input "noRunPresence", "capability.presenceSensor", title: "Don't run Actions if any of these are present:", multiple: true, required: false + input "doRunPresence", "capability.presenceSensor", title: "Run Actions only if any of these are present:", multiple: true, required: false + input name: "manualUnlock", title: "Initiate phrase on manual unlock also?", type: "bool", defaultValue: false, refreshAfterSelection: true + input(name: "modeIgnore", title: "Do not run Routine when in this mode", type: "mode", required: false, mutliple: false) + } + } + } + } +} + +def resetCodeUsagePage(params) { + def i = getUser(params) + // do reset + resetCodeUsage(i) + + dynamicPage(name:"resetCodeUsagePage", title:"User Usage Reset") { + section { + paragraph "User code usage has been reset." + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + } + } +} + +def resetAllCodeUsagePage() { + // do resetAll + resetAllCodeUsage() + dynamicPage(name:"resetAllCodeUsagePage", title:"User Settings") { + section { + paragraph "All user code usages have been reset." + } + section("Users") { + href(name: "toSetupPage", title: "Back to Users", page: "setupPage") + href(name: "toRootPage", title: "Main Page", page: "rootPage") + } + } +} + +def reEnableUserPage(params) { + // do reset + def i = getUser(params) + enableUser(i) + lockErrorLoopReset() + dynamicPage(name:"reEnableUserPage", title:"User re-enabled") { + section { + paragraph "User has been enabled." + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + } + } +} + +def getUser(params) { + def i = 1 + // Assign params to i. Sometimes parameters are double nested. + if (params.number) { + i = params.number + } else if (params.params){ + i = params.params.number + } else if (state.lastUser) { + i = state.lastUser + } + + //Make sure i is a round number, not a float. + if ( ! i.isNumber() ) { + i = i.toInteger(); + } else if ( i.isNumber() ) { + i = Math.round(i * 100) / 100 + } + state.lastUser = i + return i +} + +def getLock(params) { + def id = '' + // Assign params to id. Sometimes parameters are double nested. + if (params.id) { + id = params.id + } else if (params.params){ + id = params.params.id + } else if (state.lastLock) { + id = state.lastLock + } + + state.lastLock = id + return theLocks.find{it.id == id} +} + +def infoPage() { + dynamicPage(name:"infoPage", title:"Lock Info") { + section() { + href(name: "toInfoRefreshPage", page: "infoRefreshPage", title: "Refresh Lock Data", description: 'Tap to refresh') + } + section("Locks") { + if (theLocks) { + def i = 0 + theLocks.each { lock-> + i++ + href(name: "toLockInfoPage${i}", page: "lockInfoPage", params: [id: lock.id], required: false, title: lock.displayName ) + } + } + } + } +} + +def infoRefreshPage() { + dynamicPage(name:"infoRefreshPage", title:"Lock Info") { + section() { + manualPoll() + paragraph "Lock info refreshing soon." + href(name: "toInfoPage", page: "infoPage", title: "Back to Lock Info") + } + } +} + +def lockInfoPage(params) { + dynamicPage(name:"lockInfoPage", title:"Lock Info") { + + def lock = getLock(params) + if (lock) { + section("${lock.displayName}") { + if (state."lock${lock.id}".codes != null) { + def i = 0 + def pass = '' + state."lock${lock.id}".codes.each { code-> + i++ + pass = state."lock${lock.id}".codes."slot${i}" + paragraph "Slot ${i}\nCode: ${pass}" + } + } else { + paragraph "No Lock data received yet. Requires custom device driver. Will be populated on next poll event." + } + } + } + } +} + + +def keypadPage() { + dynamicPage(name: "keypadPage",title: "Keypad Settings (optional)") { + section("Settings") { + // TODO: put inputs here + input(name: "keypad", title: "Keypad", type: "capability.lockCodes", multiple: true, required: false) + } + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + hhPhrases?.sort() + section("Routines", hideable: true, hidden: true) { + input(name: "armRoutine", title: "Arm/Away routine", type: "enum", options: hhPhrases, required: false) + input(name: "disarmRoutine", title: "Disarm routine", type: "enum", options: hhPhrases, required: false) + input(name: "stayRoutine", title: "Arm/Stay routine", type: "enum", options: hhPhrases, required: false) + input(name: "nightRoutine", title: "Arm/Night routine", type: "enum", options: hhPhrases, required: false) + input(name: "armDelay", title: "Arm Delay (in seconds)", type: "number", required: false) + input(name: "notifyIncorrectPin", title: "Notify you when incorrect code is used?", type: "bool", required: false) + } + } +} + +public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } + +public humanReadableStartDate() { + new Date().parse(smartThingsDateFormat(), startTime).format("h:mm a", timeZone(startTime)) +} +public humanReadableEndDate() { + new Date().parse(smartThingsDateFormat(), endTime).format("h:mm a", timeZone(endTime)) +} + +def manualPoll() { + theLocks.poll() +} + +def getConflicts(i) { + def currentCode = settings."userCode${i}" + def currentSlot = settings."userSlot${i}" + def conflict = [:] + conflict.has_conflict = false + + + theLocks.each { lock-> + if (state."lock${lock.id}".codes) { + conflict."lock${lock.id}" = [:] + conflict."lock${lock.id}".conflicts = [] + def ind = 0 + state."lock${lock.id}".codes.each { code -> + ind++ + if (currentSlot?.toInteger() != ind.toInteger() && !isUnique(currentCode, state."lock${lock.id}".codes."slot${ind}")) { + conflict.has_conflict = true + state."userState${i}".enabled = false + state."userState${i}".disabledReason = "Code Conflict Detected" + conflict."lock${lock.id}".conflicts << ind + } + } + } + } + + return conflict +} + +def isUnique(newInt, oldInt) { + + if (newInt == null || oldInt == null) { + // if either number is null, break here. + return true + } + + if (!newInt.isInteger() || !oldInt.isInteger()) { + // number is not an integer, can't check. + return true + } + + def newArray = [] + def oldArray = [] + def result = true + + def i = 0 + // Get a normalized sequence, at the same length + newInt.toString().toList().collect { + i++ + if (i <= oldInt.length()) { + newArray << normalizeNumber(it.toInteger()) + } + } + + i = 0 + oldInt.toString().toList().collect { + i++ + if (i <= oldInt.length()) { + oldArray << normalizeNumber(it.toInteger()) + } + } + + i = 0 + newArray.each { num-> + i++ + if (newArray.join() == oldArray.join()) { + // The normalized numbers are the same! + result = false + } + } + return result +} + +def normalizeNumber(number) { + def result = null + // RULE: Since some locks share buttons, make sure unique. + // Even locks with 10-keys follow this rule! (annoyingly) + switch (number) { + case [1,2]: + result = 1 + break + case [3,4]: + result = 2 + break + case [5,6]: + result = 3 + break + case [7,8]: + result = 4 + break + case [9,0]: + result = 5 + break + } + return result +} + +def setupPageDescription(){ + def parts = [] + for (int i = 1; i <= settings.maxUsers; i++) { + parts << settings."userName${i}" + } + return fancyString(parts) +} + +def notificationPageDescription() { + def parts = [] + def msg = "" + if (settings.phone) { + parts << "SMS to ${phone}" + } + if (settings.sendevent) { + parts << "Event Notification" + } + if (settings.notification) { + parts << "Push Notification" + } + msg += fancyString(parts) + parts = [] + + if (settings.notifyAccess) { + parts << "on entry" + } + if (settings.notifyLock) { + parts << "on lock" + } + if (settings.notifyUnlock) { + parts << "on unlock" + } + if (settings.notifyAccessStart) { + parts << "when granting access" + } + if (settings.notifyAccessEnd) { + parts << "when revoking access" + } + if (settings.notificationStartTime) { + parts << "starting at ${settings.notificationStartTime}" + } + if (settings.notificationEndTime) { + parts << "ending at ${settings.notificationEndTime}" + } + if (parts.size()) { + msg += ": " + msg += fancyString(parts) + } + return msg +} + +def calendarHrefDescription() { + def dateStart = startDateTime() + def dateEnd = endDateTime() + if (dateEnd && dateStart) { + def startReadableTime = readableDateTime(dateStart) + def endReadableTime = readableDateTime(dateEnd) + return "Accessible from ${startReadableTime} until ${endReadableTime}" + } else if (!dateEnd && dateStart) { + def startReadableTime = readableDateTime(dateStart) + return "Accessible on ${startReadableTime}" + } else if (dateEnd && !dateStart){ + def endReadableTime = readableDateTime(dateEnd) + return "Accessible until ${endReadableTime}" + } +} + +def readableDateTime(date) { + new Date().parse(smartThingsDateFormat(), date.format(smartThingsDateFormat(), location.timeZone)).format("EEE, MMM d yyyy 'at' h:mma", location.timeZone) +} + +def userHrefTitle(i) { + def title = "User ${i}" + if (settings."userName${i}") { + title = settings."userName${i}" + } + return title +} + +def userHrefDescription(i) { + def uc = settings."userCode${i}" + def us = settings."userSlot${i}" + def usage = state."userState${i}".usage + def description = "" + if (us != null) { + description += "Slot: ${us}" + } + if (uc != null) { + description += " / ${uc}" + if(settings."burnCode${i}") { + description += ' [Single Use]' + } + } + if (usage != null) { + description += " [Usage: ${usage}]" + } + return description +} + +def userPageState(i) { + if (settings."userCode${i}" && userIsEnabled(i)) { + if (settings."burnCode${i}") { + if (state."userState${i}".usage > 0) { + return 'incomplete' + } else { + return 'complete' + } + } else { + return 'complete' + } + + } else if (settings."userCode${i}" && !settings."userEnabled${i}") { + return 'incomplete' + } else { + return 'incomplete' + } +} + +def userIsEnabled(i) { + if (settings."userEnabled${i}" && (settings."userCode${i}" != null) && (state."userState${i}".enabled != false)) { + return true + } else { + return false + } +} + +def fancyDeviceString(devices = []) { + fancyString(devices.collect { deviceLabel(it) }) +} + +def deviceLabel(device) { + return device.label ?: device.name +} + +def fancyString(listOfStrings) { + listOfStrings.removeAll([null]) + def fancify = { list -> + return list.collect { + def label = it + if (list.size() > 1 && it == list[-1]) { + label = "and ${label}" + } + label + }.join(", ") + } + + return fancify(listOfStrings) +} + +def schedulingHrefDescription() { + if (startDateTime() || endDateTime()) { + calendarHrefDescription() + } else { + def descriptionParts = [] + if (days) { + descriptionParts << "On ${fancyString(days)}," + } + + descriptionParts << "${fancyDeviceString(theLocks)} will be accessible" + if ((andOrTime != null) || (modeStart == null)) { + if (startTime) { + descriptionParts << "at ${humanReadableStartDate()}" + } + if (endTime) { + descriptionParts << "until ${humanReadableEndDate()}" + } + } + + if (modeStart) { + if (startTime && andOrTime) { + descriptionParts << andOrTime + } + descriptionParts << "when ${location.name} enters '${modeStart}' mode" + } + + if (descriptionParts.size() <= 1) { + // locks will be in the list no matter what. No rules are set if only locks are in the list + return null + } + return descriptionParts.join(" ") + } +} + +def installed() { + log.debug "Installing 'Locks' with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updating 'Locks' with settings: ${settings}" + initialize() +} + +private initialize() { + unsubscribe() + unschedule() + if (startTime && !startDateTime()) { + log.debug "scheduling access routine to run at ${startTime}" + schedule(startTime, "reconcileCodesStart") + } else if (startDateTime()) { + // There's a start date, so let's run then + log.debug "scheduling RUNONCE start" + runOnce(startDateTime().format(smartThingsDateFormat(), location.timeZone), "reconcileCodesStart") + } + + if (endTime && !endDateTime()) { + log.debug "scheduling access denial routine to run at ${endTime}" + schedule(endTime, "reconcileCodesEnd") + } else if (endDateTime()) { + // There's a end date, so let's run then + log.debug "scheduling RUNONCE end" + runOnce(endDateTime().format(smartThingsDateFormat(), location.timeZone), "reconcileCodesEnd") + } + + subscribe(location, locationHandler) + + subscribe(theLocks, "codeReport", codereturn) + subscribe(theLocks, "lock", codeUsed) + subscribe(theLocks, "reportAllCodes", pollCodeReport, [filterEvents:false]) + if (keypad) { + subscribe(location,"alarmSystemStatus",alarmStatusHandler) + subscribe(keypad,"codeEntered",codeEntryHandler) + } + + revokeDisabledUsers() + reconcileCodes() + lockErrorLoopReset() + initalizeLockData() + + log.debug "state: ${state}" +} + +def resetAllCodeUsage() { + for (int i = 1; i <= settings.maxUsers; i++) { + lockErrorLoopReset() + resetCodeUsage(i) + } + log.debug "reseting all code usage" +} + +def resetCodeUsage(i) { + if(state."userState${i}" == null) { + state."userState${i}" = [:] + state."userState${i}".enabled = true + } + state."userState${i}".usage = 0 +} + +def enableUser(i) { + state."userState${i}".enabled = true +} + +def initalizeLockData() { + theLocks.each { lock-> + if (state."lock${lock.id}" == null) { + state."lock${lock.id}" = [:] + } + } +} + +def lockErrorLoopReset() { + state.error_loop_count = 0 + theLocks.each { lock-> + if (state."lock${lock.id}" == null) { + state."lock${lock.id}" = [:] + } + state."lock${lock.id}".error_loop = false + } +} + + +def locationHandler(evt) { + log.debug "locationHandler evt: ${evt.value}" + if (modeStart) { + reconcileCodes() + } +} + +def reconcileCodes() { + if (isAbleToStart()) { + grantAccess() + } else { + revokeAccess() + } +} + +def reconcileCodesStart() { + // schedule start of reconcileCodes + reconcileCodes() + if (calStartPhrase) { + location.helloHome.execute(calStartPhrase) + } +} + +def reconcileCodesEnd() { + // schedule end of reconcileCodes + reconcileCodes() + if (calEndPhrase) { + location.helloHome.execute(calEndPhrase) + } +} + +def isAbleToStart() { + def dateStart = startDateTime() + def dateEnd = endDateTime() + + if (dateStart || dateEnd) { + // calendar schedule above all + return checkCalendarSchedule(dateStart, dateEnd) + } else if (modeStart || startTime || endTime || days) { + // No calendar set, check daily schedule + if (isCorrectDay()) { + // it's the right day + checkDailySchedule() + } else { + // it's the wrong day + return false + } + } else { + // no schedule + return true + } +} + +def checkDailySchedule() { + if (andOrTime && modeStart && (isCorrectMode() || isInScheduledTime())) { + // in correct mode or time with and/or switch + if (andOrTime == 'and') { + // must be both + if (isCorrectMode() && isInScheduledTime()) { + // is both + return true + } else { + // is not both + return false + } + } else { + // could be either + if (isCorrectMode() || isInScheduledTime()) { + // it is either mode or time + return true + } else { + // is not either mode or time + return false + } + } + } else { + // Allow either mode or time, no andOrTime is set + if (isCorrectMode() || isInScheduledTime()) { + // it is either mode or time + return true + } else { + // is not either mode or time + return false + } + } +} + +def checkCalendarSchedule(dateStart, dateEnd) { + def now = rightNow().getTime() + if (dateStart && !dateEnd) { + // There's a start time, but no end time. Allow access after start + if (dateStart.getTime() > now) { + // It's after the start time + return true + } else { + // It's before the start time + return false + } + + } else if (dateEnd && !dateStart) { + // There's a end time, but no start time. Allow access until end + if (dateStart.getTime() > now) { + // It's after the start time + return true + } else { + // It's before the start time + return false + } + + } else { + // There's both an end time, and a start time. Allow access between them. + if (dateStart.getTime() < now && dateEnd.getTime() > now) { + // It's in calendar times + return true + } else { + // It's not in calendar times + return false + } + } +} + +def isCorrectMode() { + if (modeStart) { + // mode check is on + if (location.mode == modeStart) { + // we're in the right one mode + return true + } else { + // we're in the wrong mode + return false + } + } else { + // mode check is off + return false + } +} + +def isInScheduledTime() { + def now = new Date() + if (startTime && endTime) { + def start = timeToday(startTime) + def stop = timeToday(endTime) + + // there's both start time and end time + if (start.before(now) && stop.after(now)){ + // It's between the times + return true + } else { + // It's not between the times + return false + } + } else if (startTime && !endTime){ + // there's a start time, but no end time + def start = timeToday(startTime) + if (start.before(now)) { + // it's after start time + return true + } else { + //it's before start time + return false + } + } else if (!startTime && endTime) { + // there's an end time but no start time + def stop = timeToday(endTime) + if (stop.after(now)) { + // it's still before end time + return true + } else { + // it's after end time + return false + } + } else { + // there are no times + return false + } +} + +def startDateTime() { + if (startDay && startMonth && startYear && startTime) { + def time = new Date().parse(smartThingsDateFormat(), startTime).format("'T'HH:mm:ss.SSSZ", timeZone(startTime)) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${startYear}-${startMonth}-${startDay}${time}") + } else { + // Start Date Time not set + return false + } +} + +def endDateTime() { + if (endDay && endMonth && endYear && endTime) { + def time = new Date().parse(smartThingsDateFormat(), endTime).format("'T'HH:mm:ss.SSSZ", timeZone(endTime)) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${endYear}-${endMonth}-${endDay}${time}") + } else { + // End Date Time not set + return false + } +} + +def rightNow() { + def now = new Date().format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", now) +} + +def isCorrectDay() { + def today = new Date().format("EEEE", location.timeZone) + log.debug "today: ${today}, days: ${days}" + if (!days || days.contains(today)) { + // if no days, assume every day + return true + } + log.trace "should not allow access - Not correct Day" + return false +} + +def userSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (settings."userSlot${i}") { + array << settings."userSlot${i}".toInteger() + } + } + return array +} + +def enabledUsersArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (userIsEnabled(i)) { + array << i + } + } + return array +} +def enabledUsersSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (userIsEnabled(i)) { + def userSlot = settings."userSlot${i}" + array << userSlot.toInteger() + } + } + return array +} + +def disabledUsersSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (!userIsEnabled(i)) { + if (settings."userSlot${i}") { + array << settings."userSlot${i}".toInteger() + } + } + } + return array +} + +def codereturn(evt) { + def codeNumber = evt.data.replaceAll("\\D+","") + def codeSlot = evt.value + if (notifyAccessEnd || notifyAccessStart) { + if (userSlotArray().contains(evt.integerValue.toInteger())) { + def userName = settings."userName${usedUserIndex(evt.integerValue)}" + if (codeNumber == "") { + if (notifyAccessEnd) { + def message = "${userName} no longer has access to ${evt.displayName}" + if (codeNumber.isNumber()) { + state."lock${evt.deviceId}".codes."slot${codeSlot}" = codeNumber + } + send(message) + } + } else { + if (notifyAccessStart) { + def message = "${userName} now has access to ${evt.displayName}" + if (codeNumber.isNumber()) { + state."lock${evt.deviceId}".codes."slot${codeSlot}" = codeNumber + } + send(message) + } + } + } + } +} + +def usedUserIndex(usedSlot) { + for (int i = 1; i <= settings.maxUsers; i++) { + if (settings."userSlot${i}" && settings."userSlot${i}".toInteger() == usedSlot.toInteger()) { + return i + } + } + return false +} + +def codeUsed(evt) { + // check the status of the lock, helpful for some schlage locks. + runIn(10, doPoll) + log.debug("codeUsed evt.value: " + evt.value + ". evt.data: " + evt.data) + def message = null + + if(evt.value == "unlocked" && evt.data) { + def codeData = new JsonSlurper().parseText(evt.data) + if(codeData.usedCode && codeData.usedCode.isNumber() && userSlotArray().contains(codeData.usedCode.toInteger())) { + def usedIndex = usedUserIndex(codeData.usedCode).toInteger() + def unlockUserName = settings."userName${usedIndex}" + message = "${evt.displayName} was unlocked by ${unlockUserName}" + // increment usage + state."userState${usedIndex}".usage = state."userState${usedIndex}".usage + 1 + if(settings."userHomePhrases${usedIndex}") { + // Specific User Hello Home + if (settings."userNoRunPresence${usedIndex}" && settings."userDoRunPresence${usedIndex}" == null) { + if (!anyoneHome(settings."userNoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else if (settings."userDoRunPresence${usedIndex}" && settings."userNoRunPresence${usedIndex}" == null) { + if (anyoneHome(settings."userDoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else if (settings."userDoRunPresence${usedIndex}" && settings."userNoRunPresence${usedIndex}") { + if (anyoneHome(settings."userDoRunPresence${usedIndex}") && !anyoneHome(settings."userNoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } + if(settings."burnCode${usedIndex}") { + theLocks.deleteCode(codeData.usedCode) + runIn(60*2, doPoll) + message += ". Now burning code." + } + //Don't send notification if muted + if(settings."dontNotify${usedIndex}" == true) { + message = null + } + } + } else if(evt.value == "unlocked" && settings.notifyUnlock) { + message = "${evt.displayName} has been manually unlocked" + } else if(evt.value == "locked" && settings.notifyLock) { + message = "${evt.displayName} has been locked" + } + + if (message) { + log.debug("Sending message: " + message) + send(message) + } + + if (homePhrases) { + performActions(evt) + } +} + +def performActions(evt) { + if(evt.value == "unlocked" && evt.data) { + def codeData = new JsonSlurper().parseText(evt.data) + if(enabledUsersArray().contains(codeData.usedCode) || isManualUnlock(codeData)) { + // Global Hello Home + if(location.currentMode != modeIgnore) { + if (noRunPresence && doRunPresence == null) { + if (!anyoneHome(noRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else if (doRunPresence && noRunPresence == null) { + if (anyoneHome(doRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else if (doRunPresence && noRunPresence) { + if (anyoneHome(doRunPresence) && !anyoneHome(noRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else { + location.helloHome.execute(homePhrases) + } + } else { + def routineMessage = "Already in ${modeIgnore} mode, skipping execution of ${homePhrases} routine." + log.debug routineMessage + send(routineMessage) + } + } + } +} + +def revokeDisabledUsers() { + def array = [] + disabledUsersSlotArray().each { slot -> + array << ["code${slot}", ""] + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def doPoll() { + // this gets codes if custom device is installed + if (!allCodesDone()) { + state.error_loop_count = state.error_loop_count + 1 + } + theLocks.poll() +} + +def grantAccess() { + def array = [] + enabledUsersArray().each { user-> + def userSlot = settings."userSlot${user}" + if (settings."userCode${user}" != null) { + def newCode = settings."userCode${user}" + array << ["code${userSlot}", "${newCode}"] + } else { + array << ["code${userSlot}", ""] + } + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def revokeAccess() { + def array = [] + enabledUsersArray().each { user-> + def userSlot = settings."userSlot${user}" + array << ["code${userSlot}", ""] + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def isManualUnlock(codeData) { + // check to see if the user wants this + if (manualUnlock) { + // garyd9's device type returns 'manual' + if ((codeData.usedCode == "") || (codeData.usedCode == null) || (codeData.usedCode == 'manual')) { + // no code used on unlock! + return true + } else { + // probably a code we're not dealing with here + return false + } + } else { + return false + } +} + +def isActiveBurnCode(slot) { + if (settings."burnCode${slot}" && state."userState${slot}".usage > 0) { + return false + } else { + // not a burn code / not yet used + return true + } +} + +def pollCodeReport(evt) { + def active = isAbleToStart() + def codeData = new JsonSlurper().parseText(evt.data) + def numberOfCodes = codeData.codes + def userSlots = userSlotArray() + + def array = [] + + (1..maxUsers).each { user-> + def slot = settings."userSlot${user}" + def code = codeData."code${slot}" + def correctCode = settings."userCode${user}" + if (active) { + if (userIsEnabled(user) && isActiveBurnCode(user)) { + if (code == settings."userCode${user}") { + // Code is Active, We should be active. Nothing to do + } else { + // Code is incorrect, We should be active. + array << ["code${slot}", settings."userCode${user}"] + } + } else { + if (code != '') { + // Code is set, user is disabled, We should be disabled. + array << ["code${slot}", ""] + } else { + // Code is not set, user is disabled. Nothing to do + } + } + } else { + if (code != '') { + // Code is set, We should be disabled. + array << ["code${slot}", ""] + } else { + // Code is not active, We should be disabled. Nothing to do + } + } + } + + def currentLock = theLocks.find{it.id == evt.deviceId} + populateDiscovery(codeData, currentLock) + + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + runIn(60*2, doPoll) + + //Lock is in an error state + state."lock${currentLock.id}".error_loop = true + def error_number = state.error_loop_count + 1 + if (error_number <= 10) { + log.debug "sendCodes fix is: ${json} Error: ${error_number}/10" + currentLock.updateCodes(json) + } else { + log.debug "kill fix is: ${json}" + currentLock.updateCodes(json) + json = new JsonSlurper().parseText(json) + def n = 0 + json.each { code -> + n = code[0][4..-1].toInteger() + def usedIndex = usedUserIndex(n) + def name = settings."userName${usedIndex}" + if (state."userState${usedIndex}".enabled) { + state."userState${usedIndex}".enabled = false + state."userState${usedIndex}".disabledReason = "Controller failed to set code" + send("Controller failed to set code for ${name}") + } + } + } + } else { + state."lock${currentLock.id}".error_loop = false + if (allCodesDone) { + lockErrorLoopReset() + } else { + runIn(60, doPoll) + } + } +} + +def allCodesDone() { + def i = 0 + def codeComplete = true + theLocks.each { lock-> + i++ + if (state."lock${lock.id}".error_loop == true) { + codeComplete = false + } + } + return codeComplete +} + +private anyoneHome(sensors) { + def result = false + if(sensors.findAll { it?.currentPresence == "present" }) { + result = true + } + result +} + +private send(msg) { + if (notificationStartTime != null && notificationEndTime != null) { + def start = timeToday(notificationStartTime) + def stop = timeToday(notificationEndTime) + def now = new Date() + if (start.before(now) && stop.after(now)){ + sendMessage(msg) + } + } else { + sendMessage(msg) + } +} + +private sendMessage(msg) { + if (notification) { + sendPush(msg) + } else { + sendNotificationEvent(msg) + } + if (phone) { + if ( phone.indexOf(";") > 1){ + def phones = phone.split(";") + for ( def i = 0; i < phones.size(); i++) { + sendSms(phones[i], msg) + } + } + else { + sendSms(phone, msg) + } + } +} + +def populateDiscovery(codeData, lock) { + def codes = [:] + def codeSlots = 30 + if (codeData.codes) { + codeSlots = codeData.codes + } + (1..codeSlots).each { slot-> + codes."slot${slot}" = codeData."code${slot}" + } + state."lock${lock.id}".codes = codes +} + +private String getPIN() { + return settings.pin.value.toString().padLeft(4,'0') +} + +def alarmStatusHandler(event) { + log.debug "Keypad manager caught alarm status change: "+event.value + if (event.value == "off"){ + keypad?.setDisarmed() + } + else if (event.value == "away"){ + keypad?.setArmedAway() + } + else if (event.value == "stay") { + keypad?.setArmedStay() + } +} + +private sendSHMEvent(String shmState) { + def event = [ + name:"alarmSystemStatus", + value: shmState, + displayed: true, + description: "System Status is ${shmState}" + ] + log.debug "test ${event}" + sendLocationEvent(event) +} + +private execRoutine(armMode) { + if (armMode == 'away') { + location.helloHome?.execute(settings.armRoutine) + } else if (armMode == 'stay') { + location.helloHome?.execute(settings.stayRoutine) + } else if (armMode == 'off') { + location.helloHome?.execute(settings.disarmRoutine) + } +} + +def codeEntryHandler(evt) { + //do stuff + log.debug "Caught code entry event! ${evt.value.value}" + + def codeEntered = evt.value as String + + def data = evt.data as String + def armMode = '' + def currentarmMode = keypad.currentValue("armMode") + def changedMode = 0 + + if (data == '0') { + armMode = 'off' + } + else if (data == '3') { + armMode = 'away' + } + else if (data == '1') { + armMode = 'stay' + } + else if (data == '2') { + armMode = 'stay' //Currently no separate night mode for SHM, set to 'stay' + } else { + log.error "${app.label}: Unexpected arm mode sent by keypad!: "+data + return [] + } + + def i = settings.maxUsers + def message = " " + while (i > 0) { + log.debug "i =" + i + def correctCode = settings."userCode${i}" as String + + if (codeEntered == correctCode) { + + log.debug "User Enabled: " + state."userState${i}".enabled + + if (state."userState${i}".enabled == true) { + log.debug "Correct PIN entered. Change SHM state to ${armMode}" + //log.debug "Delay: ${armDelay}" + //log.debug "Data: ${data}" + //log.debug "armMode: ${armMode}" + + def unlockUserName = settings."userName${i}" + + if (data == "0") { + //log.debug "sendDisarmCommand" + runIn(0, "sendDisarmCommand") + message = "${evt.displayName} was disarmed by ${unlockUserName}" + } + else if (data == "1") { + //log.debug "sendStayCommand" + runIn(armDelay, "sendStayCommand") + message = "${evt.displayName} was armed to 'Stay' by ${unlockUserName}" + } + else if (data == "2") { + //log.debug "sendNightCommand" + runIn(armDelay, "sendNightCommand") + message = "${evt.displayName} was armed to 'Night' by ${unlockUserName}" + } + else if (data == "3") { + //log.debug "sendArmCommand" + runIn(armDelay, "sendArmCommand") + message = "${evt.displayName} was armed to 'Away' by ${unlockUserName}" + } + + if(settings."burnCode${i}") { + state."userState${i}".enabled = false + message += ". Now burning code." + } + + log.debug "${message}" + //log.debug "Initial Usage Count:" + state."userState${i}".usage + state."userState${i}".usage = state."userState${i}".usage + 1 + //log.debug "Final Usage Count:" + state."userState${i}".usage + send(message) + i = 0 + } else if (state."userState${i}".enabled == false){ + log.debug "PIN Disabled" + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + //keypad.sendInvalidKeycodeResponse() + } + } + changedMode = 1 + i-- + } + if (changedMode == 1 && i == 0) { + def errorMsg = "Incorrect Code Entered: ${codeEntered}" + if (notifyIncorrectPin) { + log.debug "Incorrect PIN" + send(errorMsg) + } + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + keypad.sendInvalidKeycodeResponse() + } +} +def sendArmCommand() { + log.debug "Sending Arm Command." + keypad.acknowledgeArmRequest(3) + sendSHMEvent("away") + execRoutine("away") +} +def sendDisarmCommand() { + log.debug "Sending Disarm Command." + keypad.acknowledgeArmRequest(0) + sendSHMEvent("off") + execRoutine("off") +} +def sendStayCommand() { + log.debug "Sending Stay Command." + keypad.acknowledgeArmRequest(1) + sendSHMEvent("stay") + execRoutine("stay") +} +def sendNightCommand() { + log.debug "Sending Night Command." + keypad.acknowledgeArmRequest(2) + sendSHMEvent("stay") + execRoutine("stay") +} \ No newline at end of file