├── .gitignore ├── LICENSE ├── README.markdown ├── devicetypes └── ethayer │ ├── zwave-lock-schlage.src │ └── zwave-lock-schlage.groovy │ └── zwave-lock-yale.src │ └── zwave-lock-yale.groovy ├── gulpfile.js ├── package-lock.json ├── package.json ├── smartapps └── ethayer │ └── lock-manager.src │ └── lock-manager.groovy └── source ├── api.groovy ├── keypad.groovy ├── lock.groovy ├── main.groovy └── user.groovy /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | archive 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Erik Thayer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | 2 | # Lock Manager BETA 3 | 4 | This BETA is provided for testing purposes. If you are uncomfortable about figuring things out on your own, you should wait until a proper relase in the MASTER branch of this repository. 5 | 6 | **Questions** like 'How do I install' are NOT proper questions for a beta release. 7 | 8 | **Feedback** like 'this thing doesn't work' is highly appreciated, and will help lead to a release. 9 | 10 | 11 | ## NOTES 12 | UNINSTALL all 1.x apps and device types before attepting an install of 2.0. Only install this software AFTER you've already uninstalled old versions of Lock Manager. I would recommend uninstalling instances of the app in the SmartThings IDE. 13 | 14 | 15 | 16 | ### Questions? 17 | Ask in the [Community Forum](https://community.smartthings.com/t/release-lock-manager/63022). 18 | 19 | ### Suggestions/Bugs? 20 | Create a Ticket in the [Issue Tracker](https://github.com/ethayer/lock-manager/issues). 21 | (yes, even for feature requests) 22 | 23 | Pull Requests are welcome. 24 | 25 | ## 26 | 27 | ### Please donate 28 | 29 | Donations are completely optional, but if this made your life easier, please consider donating. 30 | 31 | * Paypal- [paypal] 32 | 33 | * [Google Wallet-](https://www.google.com/wallet/) Send to: thayer.er@gmail.com 34 | -------------------------------------------------------------------------------- /devicetypes/ethayer/zwave-lock-yale.src/zwave-lock-yale.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Z-Wave Lock 3 | * 4 | * Copyright 2015 SmartThings 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "Z-Wave Lock Yale", namespace: "ethayer", author: "SmartThings") { 18 | capability "Actuator" 19 | capability "Lock" 20 | capability "Polling" 21 | capability "Refresh" 22 | capability "Sensor" 23 | capability "Lock Codes" 24 | capability "Battery" 25 | capability "Health Check" 26 | capability "Configuration" 27 | 28 | // Generic 29 | fingerprint deviceId: "0x4003", inClusters: "0x98" 30 | fingerprint deviceId: "0x4004", inClusters: "0x98" 31 | // KwikSet 32 | fingerprint mfr:"0090", prod:"0001", model:"0236", deviceJoinName: "KwikSet SmartCode 910 Deadbolt Door Lock" 33 | fingerprint mfr:"0090", prod:"0003", model:"0238", deviceJoinName: "KwikSet SmartCode 910 Deadbolt Door Lock" 34 | fingerprint mfr:"0090", prod:"0001", model:"0001", deviceJoinName: "KwikSet SmartCode 910 Contemporary Deadbolt Door Lock" 35 | fingerprint mfr:"0090", prod:"0003", model:"0339", deviceJoinName: "KwikSet SmartCode 912 Lever Door Lock" 36 | fingerprint mfr:"0090", prod:"0003", model:"4006", deviceJoinName: "KwikSet SmartCode 914 Deadbolt Door Lock" //backlit version 37 | fingerprint mfr:"0090", prod:"0003", model:"0440", deviceJoinName: "KwikSet SmartCode 914 Deadbolt Door Lock" 38 | fingerprint mfr:"0090", prod:"0001", model:"0642", deviceJoinName: "KwikSet SmartCode 916 Touchscreen Deadbolt Door Lock" 39 | fingerprint mfr:"0090", prod:"0003", model:"0642", deviceJoinName: "KwikSet SmartCode 916 Touchscreen Deadbolt Door Lock" 40 | // Schlage 41 | fingerprint mfr:"003B", prod:"6341", model:"0544", deviceJoinName: "Schlage Camelot Touchscreen Deadbolt Door Lock" 42 | fingerprint mfr:"003B", prod:"6341", model:"5044", deviceJoinName: "Schlage Century Touchscreen Deadbolt Door Lock" 43 | fingerprint mfr:"003B", prod:"634B", model:"504C", deviceJoinName: "Schlage Connected Keypad Lever Door Lock" 44 | // Yale 45 | fingerprint mfr:"0129", prod:"0002", model:"0800", deviceJoinName: "Yale Touchscreen Deadbolt Door Lock" // YRD120 46 | fingerprint mfr:"0129", prod:"0002", model:"0000", deviceJoinName: "Yale Touchscreen Deadbolt Door Lock" // YRD220, YRD240 47 | fingerprint mfr:"0129", prod:"0002", model:"FFFF", deviceJoinName: "Yale Touchscreen Lever Door Lock" // YRD220 48 | fingerprint mfr:"0129", prod:"0004", model:"0800", deviceJoinName: "Yale Push Button Deadbolt Door Lock" // YRD110 49 | fingerprint mfr:"0129", prod:"0004", model:"0000", deviceJoinName: "Yale Push Button Deadbolt Door Lock" // YRD210 50 | fingerprint mfr:"0129", prod:"0001", model:"0000", deviceJoinName: "Yale Push Button Lever Door Lock" // YRD210 51 | fingerprint mfr:"0129", prod:"8002", model:"0600", deviceJoinName: "Yale Assure Lock" //YRD416, YRD426, YRD446 52 | fingerprint mfr:"0129", prod:"0007", model:"0001", deviceJoinName: "Yale Keyless Connected Smart Door Lock" 53 | fingerprint mfr:"0129", prod:"8004", model:"0600", deviceJoinName: "Yale Assure Lock Push Button Deadbolt" //YRD216 54 | fingerprint mfr:"0129", prod:"6600", model:"0002", deviceJoinName: "Yale Conexis Lock" 55 | // Samsung 56 | fingerprint mfr:"022E", prod:"0001", model:"0001", deviceJoinName: "Samsung Digital Lock" // SHP-DS705, SHP-DHP728, SHP-DHP525 57 | } 58 | 59 | simulator { 60 | status "locked": "command: 9881, payload: 00 62 03 FF 00 00 FE FE" 61 | status "unlocked": "command: 9881, payload: 00 62 03 00 00 00 FE FE" 62 | 63 | reply "9881006201FF,delay 4200,9881006202": "command: 9881, payload: 00 62 03 FF 00 00 FE FE" 64 | reply "988100620100,delay 4200,9881006202": "command: 9881, payload: 00 62 03 00 00 00 FE FE" 65 | } 66 | 67 | tiles(scale: 2) { 68 | multiAttributeTile(name:"toggle", type: "generic", width: 6, height: 4){ 69 | tileAttribute ("device.lock", key: "PRIMARY_CONTROL") { 70 | attributeState "locked", label:'locked', action:"lock.unlock", icon:"st.locks.lock.locked", backgroundColor:"#00A0DC", nextState:"unlocking" 71 | attributeState "unlocked", label:'unlocked', action:"lock.lock", icon:"st.locks.lock.unlocked", backgroundColor:"#e86d13", nextState:"locking" 72 | attributeState "unlocked with timeout", label:'unlocked', action:"lock.lock", icon:"st.locks.lock.unlocked", backgroundColor:"#e86d13", nextState:"locking" 73 | attributeState "unknown", label:"unknown", action:"lock.lock", icon:"st.locks.lock.unknown", backgroundColor:"#ffffff", nextState:"locking" 74 | attributeState "locking", label:'locking', icon:"st.locks.lock.locked", backgroundColor:"#00A0DC" 75 | attributeState "unlocking", label:'unlocking', icon:"st.locks.lock.unlocked", backgroundColor:"#ffffff" 76 | } 77 | } 78 | standardTile("lock", "device.lock", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { 79 | state "default", label:'lock', action:"lock.lock", icon:"st.locks.lock.locked", nextState:"locking" 80 | } 81 | standardTile("unlock", "device.lock", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { 82 | state "default", label:'unlock', action:"lock.unlock", icon:"st.locks.lock.unlocked", nextState:"unlocking" 83 | } 84 | valueTile("battery", "device.battery", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 85 | state "battery", label:'${currentValue}% Battery', unit:"", 86 | backgroundColors:[ 87 | [value: 19, color: "#BC2323"], 88 | [value: 20, color: "#D04E00"], 89 | [value: 30, color: "#D04E00"], 90 | [value: 40, color: "#DAC400"], 91 | [value: 41, color: "#79b821"] 92 | ] 93 | } 94 | 95 | standardTile("refresh", "device.lock", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { 96 | state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" 97 | } 98 | 99 | valueTile("audioMode", "device.audioMode", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 100 | state 'val', label: 'Audio Mode ${currentValue}', backgroundColor: "#ffffff" 101 | } 102 | valueTile("autoLock", "device.autoLock", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 103 | state 'val', label: 'Auto Lock ${currentValue}', backgroundColor: "#ffffff" 104 | } 105 | valueTile("reLockTime", "device.reLockTime", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 106 | state 'val', label: 'Relock Time ${currentValue} Seconds', backgroundColor: "#ffffff" 107 | } 108 | valueTile("wrongCodeEntryLimit", "device.wrongCodeEntryLimit", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 109 | state 'val', label: 'Code Entry Attempts Allowed ${currentValue}', backgroundColor: "#ffffff" 110 | } 111 | valueTile("lockOutTime", "device.lockOutTime", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 112 | state 'val', label: 'Wrong entry lockout time ${currentValue} seconds', backgroundColor: "#ffffff" 113 | } 114 | valueTile("operationMode", "device.operationMode", inactiveLabel: false, canChangeBackground: true, width: 2, height: 2) { 115 | state 'val', label: 'Operation: ${currentValue}', backgroundColor: "#ffffff" 116 | } 117 | 118 | main "toggle" 119 | details(["toggle", "lock", "unlock", "battery", "refresh", "autoLock", "audioMode", "reLockTime", "wrongCodeEntryLimit", "lockOutTime", "operationMode"]) 120 | } 121 | preferences { 122 | input name: "audioMode", type: "enum", title: "Audio Mode", description: "Enter Mode for Audio", required: false, 123 | displayDuringSetup: false, options: ["High", "On", "Off"] 124 | input name: "autoLock", type: "bool", title: "Auto Re-Lock", description: "Enable Auto Re-Lock?", required: false, displayDuringSetup: false 125 | input name: "reLockTime", type: "number", title: "Auto Relock Time", description: "Amount of time for the lock to take before it automatically re-locks in seconds.", range: "5..255", required: false, displayDuringSetup: false 126 | 127 | input name: "entryAttemptLimit", type: "number", title: "Entry Attempt Limit", description: "The number of invalid code enteid lock will accept before TAMPER alarm is triggered and lockout is initiated.", range: "1..7", required: false, displayDuringSetup: false 128 | input name: "lockOutTime", type: "number", title: "Lockout Time", description: "Amount of time for keypad lock-out after number of wrong code entries is exceeded. Lock will be disabled for the specified amount of seconds.", range: "1..255", required: false, displayDuringSetup: false 129 | 130 | input name: "operatingMode", type: "enum", title: "Operation Mode", description: "Mode of Operation", 131 | options: ['Normal', 'Vacation', 'Privacy'], required: false, displayDuringSetup: false 132 | } 133 | } 134 | 135 | import physicalgraph.zwave.commands.doorlockv1.* 136 | import physicalgraph.zwave.commands.usercodev1.* 137 | 138 | /** 139 | * Called on app installed 140 | */ 141 | def installed() { 142 | // Device-Watch pings if no device events received for 1 hour (checkInterval) 143 | sendEvent(name: "checkInterval", value: 1 * 60 * 60, displayed: false, data: [protocol: "zwave", hubHardwareId: device.hub.hardwareID, offlinePingable: "1"]) 144 | } 145 | 146 | /** 147 | * Called on app uninstalled 148 | */ 149 | def uninstalled() { 150 | def deviceName = device.displayName 151 | log.trace "[DTH] Executing 'uninstalled()' for device $deviceName" 152 | sendEvent(name: "lockRemoved", value: device.id, isStateChange: true, displayed: false) 153 | } 154 | 155 | /** 156 | * Executed when the user taps on the 'Done' button on the device settings screen. Sends the values to lock. 157 | * 158 | * @return hubAction: The commands to be executed 159 | */ 160 | def updated() { 161 | // run only once 162 | def timeCheck = 0 163 | if (state.updatedDate) { 164 | timeCheck = state.updatedDate 165 | } 166 | if ( (now() - timeCheck) < 5000 ) return 167 | state.updatedDate = now() 168 | // Device-Watch pings if no device events received for 1 hour (checkInterval) 169 | sendEvent(name: "checkInterval", value: 1 * 60 * 60, displayed: false, data: [protocol: "zwave", hubHardwareId: device.hub.hardwareID, offlinePingable: "1"]) 170 | def hubAction = null 171 | def cmds = [] 172 | try { 173 | if (!state.init || !state.configured) { 174 | state.init = true 175 | log.debug "Returning commands for lock operation get and battery get" 176 | if (!state.configured) { 177 | cmds << doConfigure() 178 | } 179 | cmds << refresh() 180 | cmds << reloadAllCodes() 181 | if (!state.MSR) { 182 | cmds << zwave.manufacturerSpecificV1.manufacturerSpecificGet().format() 183 | } 184 | if (!state.fw) { 185 | cmds << zwave.versionV1.versionGet().format() 186 | } 187 | } 188 | } catch (e) { 189 | log.warn "updated() threw $e" 190 | } 191 | cmds << setDeviceSettings() 192 | if (cmds) { 193 | hubAction = response(delayBetween(cmds, 4200)) 194 | } 195 | hubAction 196 | } 197 | 198 | /** 199 | * Configures the device to settings needed by SmarthThings at device discovery time 200 | * 201 | */ 202 | def configure() { 203 | log.trace "[DTH] Executing 'configure()' for device ${device.displayName}" 204 | def cmds = doConfigure() 205 | log.debug "Configure returning with commands := $cmds" 206 | cmds 207 | } 208 | 209 | /** 210 | * Returns the list of commands to be executed when the device is being configured/paired 211 | * 212 | */ 213 | def doConfigure() { 214 | log.trace "[DTH] Executing 'doConfigure()' for device ${device.displayName}" 215 | state.configured = true 216 | def cmds = [] 217 | cmds << secure(zwave.doorLockV1.doorLockOperationGet()) 218 | cmds << secure(zwave.batteryV1.batteryGet()) 219 | if (isSchlageLock()) { 220 | cmds << secure(zwave.configurationV2.configurationGet(parameterNumber: getSchlageLockParam().codeLength.number)) 221 | } 222 | cmds = delayBetween(cmds, 30*1000) 223 | log.debug "Do configure returning with commands := $cmds" 224 | cmds 225 | } 226 | 227 | /** 228 | * Responsible for parsing incoming device messages to generate events 229 | * 230 | * @param description: The incoming description from the device 231 | * 232 | * @return result: The list of events to be sent out 233 | * 234 | */ 235 | def parse(String description) { 236 | log.trace "[DTH] Executing 'parse(String description)' for device ${device.displayName} with description = $description" 237 | 238 | def result = null 239 | if (description.startsWith("Err")) { 240 | if (state.sec) { 241 | result = createEvent(descriptionText:description, isStateChange:true, displayed:false) 242 | } else { 243 | result = createEvent( 244 | 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.", 245 | eventType: "ALERT", 246 | name: "secureInclusion", 247 | value: "failed", 248 | displayed: true, 249 | ) 250 | } 251 | } else { 252 | def cmd = zwave.parse(description, [ 0x98: 1, 0x72: 2, 0x85: 2, 0x86: 1 ]) 253 | if (cmd) { 254 | result = zwaveEvent(cmd) 255 | } 256 | } 257 | log.info "[DTH] parse() - returning result=$result" 258 | result 259 | } 260 | 261 | /** 262 | * Responsible for parsing ConfigurationReport command 263 | * 264 | * @param cmd: The ConfigurationReport command to be parsed 265 | * 266 | * @return The event(s) to be sent out 267 | * 268 | */ 269 | def zwaveEvent(physicalgraph.zwave.commands.configurationv2.ConfigurationReport cmd) { 270 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.configurationv2.ConfigurationReport cmd)' with cmd = $cmd" 271 | def result = [] 272 | if (isYaleLock()) { 273 | result << processYaleLockConfig(cmd) 274 | } 275 | if (isSchlageLock() && cmd.parameterNumber == getSchlageLockParam().codeLength.number) { 276 | def length = cmd.scaledConfigurationValue 277 | def deviceName = device.displayName 278 | log.trace "[DTH] Executing 'ConfigurationReport' for device $deviceName with code length := $length" 279 | def codeLength = device.currentValue("codeLength") 280 | if (codeLength && codeLength != length) { 281 | log.trace "[DTH] Executing 'ConfigurationReport' for device $deviceName - all codes deleted" 282 | result = allCodesDeletedEvent() 283 | result << createEvent(name: "codeChanged", value: "all deleted", descriptionText: "Deleted all user codes", 284 | isStateChange: true, data: [lockName: deviceName, notify: true, 285 | notificationText: "Deleted all user codes in $deviceName at ${location.name}"]) 286 | result << createEvent(name: "lockCodes", value: util.toJson([:]), displayed: false, descriptionText: "'lockCodes' attribute updated") 287 | } 288 | result << createEvent(name:"codeLength", value: length, descriptionText: "Code length is $length", displayed: false) 289 | return result 290 | } 291 | return result 292 | } 293 | 294 | def processYaleLockConfig(cmd) { 295 | def result = [] 296 | def map = null // use this for config reports that are handled 297 | 298 | // use desc/val for generic handling of config reports (it will just send a descriptionText for the acitivty stream) 299 | def desc = null 300 | def value = "" 301 | def isEnabled = 'Enabled' 302 | 303 | switch (cmd.parameterNumber) { 304 | case 0x01: // Audio Mode 305 | map = [ name: "audioMode" ] 306 | switch(cmd.configurationValue[0]) { 307 | case 0x01: 308 | switch(zwaveInfo.prod) { 309 | case '0001': 310 | case '0002': 311 | value = 'Off' 312 | break 313 | default: 314 | value = 'On' 315 | break 316 | } 317 | break 318 | case 0x02: 319 | value = 'Low' 320 | break 321 | case 0x03: 322 | switch(zwaveInfo.prod) { 323 | case '0001': 324 | case '0002': 325 | value = 'High' 326 | break 327 | default: 328 | value = 'Off' 329 | break 330 | } 331 | } 332 | desc = "Audio Mode switched to ${value}" 333 | map.value = value 334 | map.descriptionText = desc 335 | sendEvent(name: 'audioMode', value: value) 336 | break // End Audo Mode 337 | 338 | case 0x02: //Auto Re-Lock 339 | map = [ name: "autoLock" ] 340 | switch(cmd.configurationValue[0]) { 341 | case 0xFF: 342 | value = 'On' 343 | break 344 | case 0x00: 345 | value = 'Off' 346 | break 347 | } 348 | desc = "Auto Lock switched to ${value}" 349 | map.value = value 350 | map.descriptionText = desc 351 | sendEvent(name: 'autoLock', value: value) 352 | break 353 | case 0x03: //Re-lock Timeout 354 | map = [ name: "reLockTime" ] 355 | value = cmd.configurationValue[0] 356 | desc = "Auto lock Timeout set to ${value} seconds" 357 | map.value = value 358 | map.descriptionText = desc 359 | sendEvent(name: 'reLockTime', value: value) 360 | break 361 | case 0x04: //Wrong Code Entry Limit 362 | map = [ name: "wrongCodeEntryLimit" ] 363 | value = cmd.configurationValue[0] 364 | desc = "Wrong code entry limit set to ${value} attempts" 365 | map.value = value 366 | map.descriptionText = desc 367 | sendEvent(name: 'wrongCodeEntryLimit', value: value) 368 | break 369 | case 0x05: // language change 370 | break 371 | case 0x07: // Shutdown Time 372 | map = [ name: "lockOutTime" ] 373 | value = cmd.configurationValue[0] 374 | desc = "Lock Out time set to ${value} seconds" 375 | map.value = value 376 | map.descriptionText = desc 377 | sendEvent(name: 'lockOutTime', value: value) 378 | break 379 | case 0x08: // Operating Mode 380 | map = [ name: "operationMode" ] 381 | switch (cmd.configurationValue[0]) { 382 | case 0x00: 383 | value = 'Normal Mode' 384 | break 385 | case 0x01: 386 | value = 'Vacation Mode' 387 | break 388 | case 0x02: 389 | value = 'Privacy Mode' 390 | break 391 | } 392 | desc = "Lock Operation mode set to ${value}" 393 | map.value = value 394 | map.descriptionText = desc 395 | sendEvent(name: 'operationMode', value: value) 396 | break 397 | } 398 | if (map) { 399 | result << createEvent(map) 400 | } 401 | return result 402 | } 403 | 404 | /** 405 | * Responsible for parsing SecurityMessageEncapsulation command 406 | * 407 | * @param cmd: The SecurityMessageEncapsulation command to be parsed 408 | * 409 | * @return The event(s) to be sent out 410 | * 411 | */ 412 | def zwaveEvent(physicalgraph.zwave.commands.securityv1.SecurityMessageEncapsulation cmd) { 413 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.securityv1.SecurityMessageEncapsulation)' with cmd = $cmd" 414 | def encapsulatedCommand = cmd.encapsulatedCommand([0x62: 1, 0x71: 2, 0x80: 1, 0x85: 2, 0x63: 1, 0x98: 1, 0x86: 1]) 415 | if (encapsulatedCommand) { 416 | zwaveEvent(encapsulatedCommand) 417 | } 418 | } 419 | 420 | /** 421 | * Responsible for parsing NetworkKeyVerify command 422 | * 423 | * @param cmd: The NetworkKeyVerify command to be parsed 424 | * 425 | * @return The event(s) to be sent out 426 | * 427 | */ 428 | def zwaveEvent(physicalgraph.zwave.commands.securityv1.NetworkKeyVerify cmd) { 429 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.securityv1.NetworkKeyVerify)' with cmd = $cmd" 430 | createEvent(name:"secureInclusion", value:"success", descriptionText:"Secure inclusion was successful", isStateChange: true) 431 | } 432 | 433 | /** 434 | * Responsible for parsing SecurityCommandsSupportedReport command 435 | * 436 | * @param cmd: The SecurityCommandsSupportedReport command to be parsed 437 | * 438 | * @return The event(s) to be sent out 439 | * 440 | */ 441 | def zwaveEvent(physicalgraph.zwave.commands.securityv1.SecurityCommandsSupportedReport cmd) { 442 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.securityv1.SecurityCommandsSupportedReport)' with cmd = $cmd" 443 | state.sec = cmd.commandClassSupport.collect { String.format("%02X ", it) }.join() 444 | if (cmd.commandClassControl) { 445 | state.secCon = cmd.commandClassControl.collect { String.format("%02X ", it) }.join() 446 | } 447 | createEvent(name:"secureInclusion", value:"success", descriptionText:"Lock is securely included", isStateChange: true) 448 | } 449 | 450 | /** 451 | * Responsible for parsing DoorLockOperationReport command 452 | * 453 | * @param cmd: The DoorLockOperationReport command to be parsed 454 | * 455 | * @return The event(s) to be sent out 456 | * 457 | */ 458 | def zwaveEvent(DoorLockOperationReport cmd) { 459 | log.trace "[DTH] Executing 'zwaveEvent(DoorLockOperationReport)' with cmd = $cmd" 460 | def result = [] 461 | 462 | unschedule("followupStateCheck") 463 | unschedule("stateCheck") 464 | 465 | // DoorLockOperationReport is called when trying to read the lock state or when the lock is locked/unlocked from the DTH or the smart app 466 | def map = [ name: "lock" ] 467 | map.data = [ lockName: device.displayName ] 468 | if (cmd.doorLockMode == 0xFF) { 469 | map.value = "locked" 470 | map.descriptionText = "Locked" 471 | } else if (cmd.doorLockMode >= 0x40) { 472 | map.value = "unknown" 473 | map.descriptionText = "Unknown state" 474 | } else if (cmd.doorLockMode == 0x01) { 475 | map.value = "unlocked with timeout" 476 | map.descriptionText = "Unlocked with timeout" 477 | } else { 478 | map.value = "unlocked" 479 | map.descriptionText = "Unlocked" 480 | if (state.assoc != zwaveHubNodeId) { 481 | result << response(secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId))) 482 | result << response(zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId)) 483 | result << response(secure(zwave.associationV1.associationGet(groupingIdentifier:1))) 484 | } 485 | } 486 | if (generatesDoorLockOperationReportBeforeAlarmReport()) { 487 | // we're expecting lock events to come after notification events, but for specific yale locks they come out of order 488 | runIn(3, "delayLockEvent", [data: [map: map]]) 489 | return [:] 490 | } else { 491 | return result ? [createEvent(map), *result] : createEvent(map) 492 | } 493 | } 494 | 495 | def delayLockEvent(data) { 496 | log.debug "Sending cached lock operation: $data.map" 497 | sendEvent(data.map) 498 | } 499 | 500 | /** 501 | * Responsible for parsing AlarmReport command 502 | * 503 | * @param cmd: The AlarmReport command to be parsed 504 | * 505 | * @return The event(s) to be sent out 506 | * 507 | */ 508 | def zwaveEvent(physicalgraph.zwave.commands.alarmv2.AlarmReport cmd) { 509 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.alarmv2.AlarmReport)' with cmd = $cmd" 510 | def result = [] 511 | 512 | if (cmd.zwaveAlarmType == 6) { 513 | result = handleAccessAlarmReport(cmd) 514 | } else if (cmd.zwaveAlarmType == 7) { 515 | result = handleBurglarAlarmReport(cmd) 516 | } else if(cmd.zwaveAlarmType == 8) { 517 | result = handleBatteryAlarmReport(cmd) 518 | } else { 519 | result = handleAlarmReportUsingAlarmType(cmd) 520 | } 521 | 522 | result = result ?: null 523 | log.debug "[DTH] zwaveEvent(physicalgraph.zwave.commands.alarmv2.AlarmReport) returning with result = $result" 524 | result 525 | } 526 | 527 | /** 528 | * Responsible for handling Access AlarmReport command 529 | * 530 | * @param cmd: The AlarmReport command to be parsed 531 | * 532 | * @return The event(s) to be sent out 533 | * 534 | */ 535 | private def handleAccessAlarmReport(cmd) { 536 | log.trace "[DTH] Executing 'handleAccessAlarmReport' with cmd = $cmd" 537 | def result = [] 538 | def map = null 539 | def codeID, changeType, lockCodes, codeName 540 | def deviceName = device.displayName 541 | lockCodes = loadLockCodes() 542 | if (1 <= cmd.zwaveAlarmEvent && cmd.zwaveAlarmEvent < 10) { 543 | map = [ name: "lock", value: (cmd.zwaveAlarmEvent & 1) ? "locked" : "unlocked" ] 544 | } 545 | switch(cmd.zwaveAlarmEvent) { 546 | case 1: // Manually locked 547 | map.descriptionText = "Locked manually" 548 | map.data = [ method: (cmd.alarmLevel == 2) ? "keypad" : "manual" ] 549 | break 550 | case 2: // Manually unlocked 551 | map.descriptionText = "Unlocked manually" 552 | map.data = [ method: "manual" ] 553 | break 554 | case 3: // Locked by command 555 | map.descriptionText = "Locked" 556 | map.data = [ method: "command" ] 557 | break 558 | case 4: // Unlocked by command 559 | map.descriptionText = "Unlocked" 560 | map.data = [ method: "command" ] 561 | break 562 | case 5: // Locked with keypad 563 | if (cmd.eventParameter || cmd.alarmLevel) { 564 | codeID = readCodeSlotId(cmd) 565 | codeName = getCodeName(lockCodes, codeID) 566 | map.descriptionText = "Locked by \"$codeName\"" 567 | map.data = [ usedCode: codeID, codeName: codeName, method: "keypad" ] 568 | } else { 569 | // locked by pressing the Schlage button 570 | map.descriptionText = "Locked manually" 571 | } 572 | break 573 | case 6: // Unlocked with keypad 574 | if (cmd.eventParameter || cmd.alarmLevel) { 575 | codeID = readCodeSlotId(cmd) 576 | codeName = getCodeName(lockCodes, codeID) 577 | map.descriptionText = "Unlocked by \"$codeName\"" 578 | map.data = [ usedCode: codeID, codeName: codeName, method: "keypad" ] 579 | } 580 | break 581 | case 7: 582 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 583 | map.data = [ method: "manual" ] 584 | break 585 | case 8: 586 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 587 | map.data = [ method: "command" ] 588 | break 589 | case 9: // Auto locked 590 | map = [ name: "lock", value: "locked", data: [ method: "auto" ] ] 591 | map.descriptionText = "Auto locked" 592 | break 593 | case 0xA: 594 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 595 | map.data = [ method: "auto" ] 596 | break 597 | case 0xB: 598 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 599 | break 600 | case 0xC: // All user codes deleted 601 | result = allCodesDeletedEvent() 602 | map = [ name: "codeChanged", value: "all deleted", descriptionText: "Deleted all user codes", isStateChange: true ] 603 | map.data = [notify: true, notificationText: "Deleted all user codes in $deviceName at ${location.name}"] 604 | result << createEvent(name: "lockCodes", value: util.toJson([:]), displayed: false, descriptionText: "'lockCodes' attribute updated") 605 | break 606 | case 0xD: // User code deleted 607 | if (cmd.eventParameter || cmd.alarmLevel) { 608 | codeID = readCodeSlotId(cmd) 609 | if (lockCodes[codeID.toString()]) { 610 | codeName = getCodeName(lockCodes, codeID) 611 | map = [ name: "codeChanged", value: "$codeID deleted", isStateChange: true ] 612 | map.descriptionText = "Deleted \"$codeName\"" 613 | map.data = [ codeName: codeName, notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ] 614 | result << codeDeletedEvent(lockCodes, codeID) 615 | } 616 | } 617 | break 618 | case 0xE: // Master or user code changed/set 619 | if (cmd.eventParameter || cmd.alarmLevel) { 620 | codeID = readCodeSlotId(cmd) 621 | if(codeID == 0 && isKwiksetLock()) { 622 | //Ignoring this AlarmReport as Kwikset reports codeID 0 when all slots are full and user tries to set another lock code manually 623 | //Kwikset locks don't send AlarmReport when Master code is set 624 | log.trace "Ignoring this alarm report in case of Kwikset locks" 625 | break 626 | } 627 | codeName = getCodeNameFromState(lockCodes, codeID) 628 | changeType = getChangeType(lockCodes, codeID) 629 | map = [ name: "codeChanged", value: "$codeID $changeType", descriptionText: "${getStatusForDescription(changeType)} \"$codeName\"", isStateChange: true ] 630 | map.data = [ codeName: codeName, notify: true, notificationText: "${getStatusForDescription(changeType)} \"$codeName\" in $deviceName at ${location.name}" ] 631 | if(!isMasterCode(codeID)) { 632 | result << codeSetEvent(lockCodes, codeID, codeName) 633 | } else { 634 | map.descriptionText = "${getStatusForDescription('set')} \"$codeName\"" 635 | map.data.notificationText = "${getStatusForDescription('set')} \"$codeName\" in $deviceName at ${location.name}" 636 | } 637 | } 638 | break 639 | case 0xF: // Duplicate Pin-code error 640 | if (cmd.eventParameter || cmd.alarmLevel) { 641 | codeID = readCodeSlotId(cmd) 642 | 643 | def description 644 | if (codeID == 251) { 645 | description = "User code is duplicate of Master" 646 | } else { 647 | clearStateForSlot(codeID) 648 | description = "User code is duplicate and not added" 649 | } 650 | map = [ name: "codeChanged", value: "$codeID failed", descriptionText: description, 651 | isStateChange: true, data: [isCodeDuplicate: true] ] 652 | } 653 | break 654 | case 0x10: // Tamper Alarm 655 | case 0x13: 656 | map = [ name: "tamper", value: "detected", descriptionText: "Keypad attempts exceed code entry limit", isStateChange: true, displayed: true ] 657 | break 658 | case 0x11: // Keypad busy 659 | map = [ descriptionText: "Keypad is busy" ] 660 | break 661 | case 0x12: // Master code changed 662 | codeName = getCodeNameFromState(lockCodes, 0) 663 | map = [ name: "codeChanged", value: "0 set", descriptionText: "${getStatusForDescription('set')} \"$codeName\"", isStateChange: true ] 664 | map.data = [ codeName: codeName, notify: true, notificationText: "${getStatusForDescription('set')} \"$codeName\" in $deviceName at ${location.name}" ] 665 | break 666 | case 0xFE: 667 | // delegating it to handleAlarmReportUsingAlarmType 668 | return handleAlarmReportUsingAlarmType(cmd) 669 | default: 670 | // delegating it to handleAlarmReportUsingAlarmType 671 | return handleAlarmReportUsingAlarmType(cmd) 672 | } 673 | 674 | if (map) { 675 | if (map.data) { 676 | map.data.lockName = deviceName 677 | } else { 678 | map.data = [ lockName: deviceName ] 679 | } 680 | result << createEvent(map) 681 | } 682 | result = result.flatten() 683 | result 684 | } 685 | 686 | /** 687 | * Responsible for handling Burglar AlarmReport command 688 | * 689 | * @param cmd: The AlarmReport command to be parsed 690 | * 691 | * @return The event(s) to be sent out 692 | * 693 | */ 694 | private def handleBurglarAlarmReport(cmd) { 695 | log.trace "[DTH] Executing 'handleBurglarAlarmReport' with cmd = $cmd" 696 | def result = [] 697 | def deviceName = device.displayName 698 | 699 | def map = [ name: "tamper", value: "detected" ] 700 | map.data = [ lockName: deviceName ] 701 | switch (cmd.zwaveAlarmEvent) { 702 | case 0: 703 | map.value = "clear" 704 | map.descriptionText = "Tamper alert cleared" 705 | break 706 | case 1: 707 | case 2: 708 | map.descriptionText = "Intrusion attempt detected" 709 | break 710 | case 3: 711 | map.descriptionText = "Covering removed" 712 | break 713 | case 4: 714 | map.descriptionText = "Invalid code" 715 | break 716 | default: 717 | // delegating it to handleAlarmReportUsingAlarmType 718 | return handleAlarmReportUsingAlarmType(cmd) 719 | } 720 | 721 | result << createEvent(map) 722 | result 723 | } 724 | 725 | /** 726 | * Responsible for handling Battery AlarmReport command 727 | * 728 | * @param cmd: The AlarmReport command to be parsed 729 | * 730 | * @return The event(s) to be sent out 731 | */ 732 | private def handleBatteryAlarmReport(cmd) { 733 | log.trace "[DTH] Executing 'handleBatteryAlarmReport' with cmd = $cmd" 734 | def result = [] 735 | def deviceName = device.displayName 736 | def map = null 737 | switch(cmd.zwaveAlarmEvent) { 738 | case 0x0A: 739 | map = [ name: "battery", value: 1, descriptionText: "Battery level critical", displayed: true, data: [ lockName: deviceName ] ] 740 | break 741 | case 0x0B: 742 | map = [ name: "battery", value: 0, descriptionText: "Battery too low to operate lock", isStateChange: true, displayed: true, data: [ lockName: deviceName ] ] 743 | break 744 | default: 745 | // delegating it to handleAlarmReportUsingAlarmType 746 | return handleAlarmReportUsingAlarmType(cmd) 747 | } 748 | result << createEvent(map) 749 | result 750 | } 751 | 752 | /** 753 | * Responsible for handling AlarmReport commands which are ignored by Access & Burglar handlers 754 | * 755 | * @param cmd: The AlarmReport command to be parsed 756 | * 757 | * @return The event(s) to be sent out 758 | * 759 | */ 760 | private def handleAlarmReportUsingAlarmType(cmd) { 761 | log.trace "[DTH] Executing 'handleAlarmReportUsingAlarmType' with cmd = $cmd" 762 | def result = [] 763 | def map = null 764 | def codeID, lockCodes, codeName 765 | def deviceName = device.displayName 766 | lockCodes = loadLockCodes() 767 | switch(cmd.alarmType) { 768 | case 9: 769 | case 17: 770 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 771 | break 772 | case 16: // Note: for levers this means it's unlocked, for non-motorized deadbolt, it's just unsecured and might not get unlocked 773 | case 19: // Unlocked with keypad 774 | map = [ name: "lock", value: "unlocked" ] 775 | if (cmd.alarmLevel != null) { 776 | codeID = readCodeSlotId(cmd) 777 | codeName = getCodeName(lockCodes, codeID) 778 | map.descriptionText = "Unlocked by \"$codeName\"" 779 | map.data = [ usedCode: codeID, codeName: codeName, method: "keypad" ] 780 | } 781 | break 782 | case 18: // Locked with keypad 783 | codeID = readCodeSlotId(cmd) 784 | map = [ name: "lock", value: "locked" ] 785 | // Kwikset lock reporting code id as 0 when locked using the lock keypad button 786 | if (isKwiksetLock() && codeID == 0) { 787 | map.descriptionText = "Locked manually" 788 | map.data = [ method: "manual" ] 789 | } else { 790 | codeName = getCodeName(lockCodes, codeID) 791 | map.descriptionText = "Locked by \"$codeName\"" 792 | map.data = [ usedCode: codeID, codeName: codeName, method: "keypad" ] 793 | } 794 | break 795 | case 21: // Manually locked 796 | map = [ name: "lock", value: "locked", data: [ method: (cmd.alarmLevel == 2) ? "keypad" : "manual" ] ] 797 | map.descriptionText = "Locked manually" 798 | break 799 | case 22: // Manually unlocked 800 | map = [ name: "lock", value: "unlocked", data: [ method: "manual" ] ] 801 | map.descriptionText = "Unlocked manually" 802 | break 803 | case 23: 804 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 805 | map.data = [ method: "command" ] 806 | break 807 | case 24: // Locked by command 808 | map = [ name: "lock", value: "locked", data: [ method: "command" ] ] 809 | map.descriptionText = "Locked" 810 | break 811 | case 25: // Unlocked by command 812 | map = [ name: "lock", value: "unlocked", data: [ method: "command" ] ] 813 | map.descriptionText = "Unlocked" 814 | break 815 | case 26: 816 | map = [ name: "lock", value: "unknown", descriptionText: "Unknown state" ] 817 | map.data = [ method: "auto" ] 818 | break 819 | case 27: // Auto locked 820 | map = [ name: "lock", value: "locked", data: [ method: "auto" ] ] 821 | map.descriptionText = "Auto locked" 822 | break 823 | case 32: // All user codes deleted 824 | result = allCodesDeletedEvent() 825 | map = [ name: "codeChanged", value: "all deleted", descriptionText: "Deleted all user codes", isStateChange: true ] 826 | map.data = [notify: true, notificationText: "Deleted all user codes in $deviceName at ${location.name}"] 827 | result << createEvent(name: "lockCodes", value: util.toJson([:]), displayed: false, descriptionText: "'lockCodes' attribute updated") 828 | break 829 | case 33: // User code deleted 830 | codeID = readCodeSlotId(cmd) 831 | if (lockCodes[codeID.toString()]) { 832 | codeName = getCodeName(lockCodes, codeID) 833 | map = [ name: "codeChanged", value: "$codeID deleted", isStateChange: true ] 834 | map.descriptionText = "Deleted \"$codeName\"" 835 | map.data = [ codeName: codeName, notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ] 836 | result << codeDeletedEvent(lockCodes, codeID) 837 | } 838 | break 839 | case 38: // Non Access 840 | map = [ descriptionText: "A Non Access Code was entered at the lock", isStateChange: true ] 841 | break 842 | case 13: 843 | case 112: // Master or user code changed/set 844 | codeID = readCodeSlotId(cmd) 845 | if(codeID == 0 && isKwiksetLock()) { 846 | //Ignoring this AlarmReport as Kwikset reports codeID 0 when all slots are full and user tries to set another lock code manually 847 | //Kwikset locks don't send AlarmReport when Master code is set 848 | log.trace "Ignoring this alarm report in case of Kwikset locks" 849 | break 850 | } 851 | codeName = getCodeNameFromState(lockCodes, codeID) 852 | def changeType = getChangeType(lockCodes, codeID) 853 | map = [ name: "codeChanged", value: "$codeID $changeType", descriptionText: 854 | "${getStatusForDescription(changeType)} \"$codeName\"", isStateChange: true ] 855 | map.data = [ codeName: codeName, notify: true, notificationText: "${getStatusForDescription(changeType)} \"$codeName\" in $deviceName at ${location.name}" ] 856 | if(!isMasterCode(codeID)) { 857 | result << codeSetEvent(lockCodes, codeID, codeName) 858 | } else { 859 | map.descriptionText = "${getStatusForDescription('set')} \"$codeName\"" 860 | map.data.notificationText = "${getStatusForDescription('set')} \"$codeName\" in $deviceName at ${location.name}" 861 | } 862 | break 863 | case 34: 864 | case 113: // Duplicate Pin-code error 865 | codeID = readCodeSlotId(cmd) 866 | clearStateForSlot(codeID) 867 | map = [ name: "codeChanged", value: "$codeID failed", descriptionText: "User code is duplicate and not added", 868 | isStateChange: true, data: [isCodeDuplicate: true] ] 869 | break 870 | case 130: // Batteries replaced 871 | map = [ descriptionText: "Batteries replaced", isStateChange: true ] 872 | break 873 | case 131: // Disabled user entered at keypad 874 | map = [ descriptionText: "Code ${cmd.alarmLevel} is disabled", isStateChange: false ] 875 | break 876 | case 161: // Tamper Alarm 877 | if (cmd.alarmLevel == 2) { 878 | map = [ name: "tamper", value: "detected", descriptionText: "Front escutcheon removed", isStateChange: true ] 879 | } else { 880 | map = [ name: "tamper", value: "detected", descriptionText: "Keypad attempts exceed code entry limit", isStateChange: true, displayed: true ] 881 | } 882 | break 883 | case 167: // Low Battery Alarm 884 | if (!state.lastbatt || now() - state.lastbatt > 12*60*60*1000) { 885 | map = [ descriptionText: "Battery low", isStateChange: true ] 886 | result << response(secure(zwave.batteryV1.batteryGet())) 887 | } else { 888 | map = [ name: "battery", value: device.currentValue("battery"), descriptionText: "Battery low", isStateChange: true ] 889 | } 890 | break 891 | case 168: // Critical Battery Alarms 892 | map = [ name: "battery", value: 1, descriptionText: "Battery level critical", displayed: true ] 893 | break 894 | case 169: // Battery too low to operate 895 | map = [ name: "battery", value: 0, descriptionText: "Battery too low to operate lock", isStateChange: true, displayed: true ] 896 | break 897 | default: 898 | map = [ displayed: false, descriptionText: "Alarm event ${cmd.alarmType} level ${cmd.alarmLevel}" ] 899 | break 900 | } 901 | 902 | if (map) { 903 | if (map.data) { 904 | map.data.lockName = deviceName 905 | } else { 906 | map.data = [ lockName: deviceName ] 907 | } 908 | result << createEvent(map) 909 | } 910 | result = result.flatten() 911 | result 912 | } 913 | 914 | /** 915 | * Responsible for parsing UserCodeReport command 916 | * 917 | * @param cmd: The UserCodeReport command to be parsed 918 | * 919 | * @return The event(s) to be sent out 920 | * 921 | */ 922 | def zwaveEvent(UserCodeReport cmd) { 923 | log.trace "[DTH] Executing 'zwaveEvent(UserCodeReport)' with userIdentifier: ${cmd.userIdentifier} and status: ${cmd.userIdStatus}" 924 | def result = [] 925 | // cmd.userIdentifier seems to be an int primitive type 926 | def codeID = cmd.userIdentifier.toString() 927 | def lockCodes = loadLockCodes() 928 | def map = [ name: "codeChanged", isStateChange: true ] 929 | def deviceName = device.displayName 930 | def userIdStatus = cmd.userIdStatus 931 | 932 | if (userIdStatus == UserCodeReport.USER_ID_STATUS_OCCUPIED || 933 | (userIdStatus == UserCodeReport.USER_ID_STATUS_STATUS_NOT_AVAILABLE && cmd.user)) { 934 | 935 | def codeName 936 | 937 | // Schlage locks sends a blank/empty code during code creation/updation where as it sends "**********" during scanning 938 | // Some Schlage locks send "**********" during code creation also. The state check will work for them 939 | if ((!cmd.code || state["setname$codeID"]) && isSchlageLock()) { 940 | // this will be executed when the user tries to create/update a user code through the 941 | // smart app or manually on the lock. This is specific to Schlage locks. 942 | log.trace "[DTH] User code creation successful for Schlage lock" 943 | codeName = getCodeNameFromState(lockCodes, codeID) 944 | def changeType = getChangeType(lockCodes, codeID) 945 | 946 | map.value = "$codeID $changeType" 947 | map.isStateChange = true 948 | map.descriptionText = "${getStatusForDescription(changeType)} \"$codeName\"" 949 | map.data = [ codeName: codeName, lockName: deviceName, notify: true, notificationText: "${getStatusForDescription(changeType)} \"$codeName\" in $deviceName at ${location.name}" ] 950 | if(!isMasterCode(codeID)) { 951 | result << codeSetEvent(lockCodes, codeID, codeName) 952 | } else { 953 | map.descriptionText = "${getStatusForDescription('set')} \"$codeName\"" 954 | map.data.notificationText = "${getStatusForDescription('set')} \"$codeName\" in $deviceName at ${location.name}" 955 | map.data.lockName = deviceName 956 | } 957 | } else { 958 | // We'll land here during scanning of codes 959 | codeName = getCodeName(lockCodes, codeID) 960 | def changeType = getChangeType(lockCodes, codeID) 961 | if (!lockCodes[codeID]) { 962 | result << codeSetEvent(lockCodes, codeID, codeName) 963 | } else { 964 | map.displayed = false 965 | } 966 | map.value = "$codeID $changeType" 967 | map.descriptionText = "${getStatusForDescription(changeType)} \"$codeName\"" 968 | map.data = [ codeName: codeName, lockName: deviceName ] 969 | } 970 | } else if(userIdStatus == 254 && isSchlageLock()) { 971 | // This is code creation/updation error for Schlage locks. 972 | // It should be OK to mark this as duplicate pin code error since in case the batteries are down, or lock is not in range, 973 | // or wireless interference is there, the UserCodeReport will anyway not be received. 974 | map = [ name: "codeChanged", value: "$codeID failed", descriptionText: "User code is not added", isStateChange: true, 975 | data: [ lockName: deviceName, isCodeDuplicate: true] ] 976 | } else { 977 | // We are using userIdStatus here because codeID = 0 is reported when user tries to set programming code as the user code 978 | if (codeID == "0" && userIdStatus == UserCodeReport.USER_ID_STATUS_AVAILABLE_NOT_SET && isSchlageLock()) { 979 | // all codes deleted for Schlage locks 980 | log.trace "[DTH] All user codes deleted for Schlage lock" 981 | result << allCodesDeletedEvent() 982 | map = [ name: "codeChanged", value: "all deleted", descriptionText: "Deleted all user codes", isStateChange: true, 983 | data: [ lockName: deviceName, notify: true, 984 | notificationText: "Deleted all user codes in $deviceName at ${location.name}"] ] 985 | lockCodes = [:] 986 | result << lockCodesEvent(lockCodes) 987 | } else { 988 | // code is not set 989 | if (lockCodes[codeID]) { 990 | def codeName = getCodeName(lockCodes, codeID) 991 | map.value = "$codeID deleted" 992 | map.descriptionText = "Deleted \"$codeName\"" 993 | map.data = [ codeName: codeName, lockName: deviceName, notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ] 994 | result << codeDeletedEvent(lockCodes, codeID) 995 | } else { 996 | map.value = "$codeID unset" 997 | map.displayed = false 998 | map.data = [ lockName: deviceName ] 999 | } 1000 | } 1001 | } 1002 | 1003 | clearStateForSlot(codeID) 1004 | result << createEvent(map) 1005 | 1006 | if (codeID.toInteger() == state.checkCode) { // reloadAllCodes() was called, keep requesting the codes in order 1007 | if (state.checkCode + 1 > state.codes || state.checkCode >= 8) { 1008 | state.remove("checkCode") // done 1009 | state["checkCode"] = null 1010 | sendEvent(name: "scanCodes", value: "Complete", descriptionText: "Code scan completed", displayed: false) 1011 | } else { 1012 | state.checkCode = state.checkCode + 1 // get next 1013 | result << response(requestCode(state.checkCode)) 1014 | } 1015 | } 1016 | if (codeID == state.pollCode) { 1017 | if (state.pollCode + 1 > state.codes || state.pollCode >= 15) { 1018 | state.remove("pollCode") // done 1019 | state["pollCode"] = null 1020 | } else { 1021 | state.pollCode = state.pollCode + 1 1022 | } 1023 | } 1024 | 1025 | result = result.flatten() 1026 | result 1027 | } 1028 | 1029 | /** 1030 | * Responsible for parsing UsersNumberReport command 1031 | * 1032 | * @param cmd: The UsersNumberReport command to be parsed 1033 | * 1034 | * @return The event(s) to be sent out 1035 | * 1036 | */ 1037 | def zwaveEvent(UsersNumberReport cmd) { 1038 | log.trace "[DTH] Executing 'zwaveEvent(UsersNumberReport)' with cmd = $cmd" 1039 | def result = [createEvent(name: "maxCodes", value: cmd.supportedUsers, displayed: false)] 1040 | state.codes = cmd.supportedUsers 1041 | if (state.checkCode) { 1042 | if (state.checkCode <= cmd.supportedUsers) { 1043 | result << response(requestCode(state.checkCode)) 1044 | } else { 1045 | state.remove("checkCode") 1046 | state["checkCode"] = null 1047 | } 1048 | } 1049 | result 1050 | } 1051 | 1052 | /** 1053 | * Responsible for parsing AssociationReport command 1054 | * 1055 | * @param cmd: The AssociationReport command to be parsed 1056 | * 1057 | * @return The event(s) to be sent out 1058 | * 1059 | */ 1060 | def zwaveEvent(physicalgraph.zwave.commands.associationv2.AssociationReport cmd) { 1061 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.associationv2.AssociationReport)' with cmd = $cmd" 1062 | def result = [] 1063 | if (cmd.nodeId.any { it == zwaveHubNodeId }) { 1064 | state.remove("associationQuery") 1065 | state["associationQuery"] = null 1066 | result << createEvent(descriptionText: "Is associated") 1067 | state.assoc = zwaveHubNodeId 1068 | if (cmd.groupingIdentifier == 2) { 1069 | result << response(zwave.associationV1.associationRemove(groupingIdentifier:1, nodeId:zwaveHubNodeId)) 1070 | } 1071 | } else if (cmd.groupingIdentifier == 1) { 1072 | result << response(secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId))) 1073 | } else if (cmd.groupingIdentifier == 2) { 1074 | result << response(zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId)) 1075 | } 1076 | result 1077 | } 1078 | 1079 | /** 1080 | * Responsible for parsing TimeGet command 1081 | * 1082 | * @param cmd: The TimeGet command to be parsed 1083 | * 1084 | * @return The event(s) to be sent out 1085 | * 1086 | */ 1087 | def zwaveEvent(physicalgraph.zwave.commands.timev1.TimeGet cmd) { 1088 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.timev1.TimeGet)' with cmd = $cmd" 1089 | def result = [] 1090 | def now = new Date().toCalendar() 1091 | if(location.timeZone) now.timeZone = location.timeZone 1092 | result << createEvent(descriptionText: "Requested time update", displayed: false) 1093 | result << response(secure(zwave.timeV1.timeReport( 1094 | hourLocalTime: now.get(Calendar.HOUR_OF_DAY), 1095 | minuteLocalTime: now.get(Calendar.MINUTE), 1096 | secondLocalTime: now.get(Calendar.SECOND))) 1097 | ) 1098 | result 1099 | } 1100 | 1101 | /** 1102 | * Responsible for parsing BasicSet command 1103 | * 1104 | * @param cmd: The BasicSet command to be parsed 1105 | * 1106 | * @return The event(s) to be sent out 1107 | * 1108 | */ 1109 | def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet cmd) { 1110 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet)' with cmd = $cmd" 1111 | // The old Schlage locks use group 1 for basic control - we don't want that, so unsubscribe from group 1 1112 | def result = [ createEvent(name: "lock", value: cmd.value ? "unlocked" : "locked") ] 1113 | def cmds = [ 1114 | zwave.associationV1.associationRemove(groupingIdentifier:1, nodeId:zwaveHubNodeId).format(), 1115 | "delay 1200", 1116 | zwave.associationV1.associationGet(groupingIdentifier:2).format() 1117 | ] 1118 | [result, response(cmds)] 1119 | } 1120 | 1121 | /** 1122 | * Responsible for parsing BatteryReport command 1123 | * 1124 | * @param cmd: The BatteryReport command to be parsed 1125 | * 1126 | * @return The event(s) to be sent out 1127 | * 1128 | */ 1129 | def zwaveEvent(physicalgraph.zwave.commands.batteryv1.BatteryReport cmd) { 1130 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.batteryv1.BatteryReport)' with cmd = $cmd" 1131 | def map = [ name: "battery", unit: "%" ] 1132 | if (cmd.batteryLevel == 0xFF) { 1133 | map.value = 1 1134 | map.descriptionText = "Has a low battery" 1135 | } else { 1136 | map.value = cmd.batteryLevel 1137 | map.descriptionText = "Battery is at ${cmd.batteryLevel}%" 1138 | } 1139 | state.lastbatt = now() 1140 | createEvent(map) 1141 | } 1142 | 1143 | /** 1144 | * Responsible for parsing ManufacturerSpecificReport command 1145 | * 1146 | * @param cmd: The ManufacturerSpecificReport command to be parsed 1147 | * 1148 | * @return The event(s) to be sent out 1149 | * 1150 | */ 1151 | def zwaveEvent(physicalgraph.zwave.commands.manufacturerspecificv2.ManufacturerSpecificReport cmd) { 1152 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.manufacturerspecificv2.ManufacturerSpecificReport)' with cmd = $cmd" 1153 | def result = [] 1154 | def msr = String.format("%04X-%04X-%04X", cmd.manufacturerId, cmd.productTypeId, cmd.productId) 1155 | updateDataValue("MSR", msr) 1156 | result << createEvent(descriptionText: "MSR: $msr", isStateChange: false) 1157 | result 1158 | } 1159 | 1160 | /** 1161 | * Responsible for parsing VersionReport command 1162 | * 1163 | * @param cmd: The VersionReport command to be parsed 1164 | * 1165 | * @return The event(s) to be sent out 1166 | * 1167 | */ 1168 | def zwaveEvent(physicalgraph.zwave.commands.versionv1.VersionReport cmd) { 1169 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.versionv1.VersionReport)' with cmd = $cmd" 1170 | def fw = "${cmd.applicationVersion}.${cmd.applicationSubVersion}" 1171 | updateDataValue("fw", fw) 1172 | if (getDataValue("MSR") == "003B-6341-5044") { 1173 | updateDataValue("ver", "${cmd.applicationVersion >> 4}.${cmd.applicationVersion & 0xF}") 1174 | } 1175 | def text = "${device.displayName}: firmware version: $fw, Z-Wave version: ${cmd.zWaveProtocolVersion}.${cmd.zWaveProtocolSubVersion}" 1176 | createEvent(descriptionText: text, isStateChange: false) 1177 | } 1178 | 1179 | /** 1180 | * Responsible for parsing ApplicationBusy command 1181 | * 1182 | * @param cmd: The ApplicationBusy command to be parsed 1183 | * 1184 | * @return The event(s) to be sent out 1185 | * 1186 | */ 1187 | def zwaveEvent(physicalgraph.zwave.commands.applicationstatusv1.ApplicationBusy cmd) { 1188 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.applicationstatusv1.ApplicationBusy)' with cmd = $cmd" 1189 | def msg = cmd.status == 0 ? "try again later" : 1190 | cmd.status == 1 ? "try again in ${cmd.waitTime} seconds" : 1191 | cmd.status == 2 ? "request queued" : "sorry" 1192 | createEvent(displayed: true, descriptionText: "Is busy, $msg") 1193 | } 1194 | 1195 | /** 1196 | * Responsible for parsing ApplicationRejectedRequest command 1197 | * 1198 | * @param cmd: The ApplicationRejectedRequest command to be parsed 1199 | * 1200 | * @return The event(s) to be sent out 1201 | * 1202 | */ 1203 | def zwaveEvent(physicalgraph.zwave.commands.applicationstatusv1.ApplicationRejectedRequest cmd) { 1204 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.commands.applicationstatusv1.ApplicationRejectedRequest)' with cmd = $cmd" 1205 | createEvent(displayed: true, descriptionText: "Rejected the last request") 1206 | } 1207 | 1208 | /** 1209 | * Responsible for parsing zwave command 1210 | * 1211 | * @param cmd: The zwave command to be parsed 1212 | * 1213 | * @return The event(s) to be sent out 1214 | * 1215 | */ 1216 | def zwaveEvent(physicalgraph.zwave.Command cmd) { 1217 | log.trace "[DTH] Executing 'zwaveEvent(physicalgraph.zwave.Command)' with cmd = $cmd" 1218 | createEvent(displayed: false, descriptionText: "$cmd") 1219 | } 1220 | 1221 | /** 1222 | * Executes lock and then check command with a delay on a lock 1223 | */ 1224 | def lockAndCheck(doorLockMode) { 1225 | secureSequence([ 1226 | zwave.doorLockV1.doorLockOperationSet(doorLockMode: doorLockMode), 1227 | zwave.doorLockV1.doorLockOperationGet() 1228 | ], 4200) 1229 | } 1230 | 1231 | /** 1232 | * Executes lock command on a lock 1233 | */ 1234 | def lock() { 1235 | log.trace "[DTH] Executing lock() for device ${device.displayName}" 1236 | lockAndCheck(DoorLockOperationSet.DOOR_LOCK_MODE_DOOR_SECURED) 1237 | } 1238 | 1239 | /** 1240 | * Executes unlock command on a lock 1241 | */ 1242 | def unlock() { 1243 | log.trace "[DTH] Executing unlock() for device ${device.displayName}" 1244 | lockAndCheck(DoorLockOperationSet.DOOR_LOCK_MODE_DOOR_UNSECURED) 1245 | } 1246 | 1247 | /** 1248 | * Executes unlock with timeout command on a lock 1249 | */ 1250 | def unlockWithTimeout() { 1251 | log.trace "[DTH] Executing unlockWithTimeout() for device ${device.displayName}" 1252 | lockAndCheck(DoorLockOperationSet.DOOR_LOCK_MODE_DOOR_UNSECURED_WITH_TIMEOUT) 1253 | } 1254 | 1255 | /** 1256 | * PING is used by Device-Watch in attempt to reach the Device 1257 | */ 1258 | def ping() { 1259 | log.trace "[DTH] Executing ping() for device ${device.displayName}" 1260 | runIn(30, followupStateCheck) 1261 | secure(zwave.doorLockV1.doorLockOperationGet()) 1262 | } 1263 | 1264 | /** 1265 | * Checks the door lock state. Also, schedules checking of door lock state every one hour. 1266 | */ 1267 | def followupStateCheck() { 1268 | runEvery1Hour(stateCheck) 1269 | stateCheck() 1270 | } 1271 | 1272 | /** 1273 | * Checks the door lock state 1274 | */ 1275 | def stateCheck() { 1276 | sendHubCommand(new physicalgraph.device.HubAction(secure(zwave.doorLockV1.doorLockOperationGet()))) 1277 | } 1278 | 1279 | /** 1280 | * Called when the user taps on the refresh button 1281 | */ 1282 | def refresh() { 1283 | log.trace "[DTH] Executing refresh() for device ${device.displayName}" 1284 | 1285 | def cmds = secureSequence([zwave.doorLockV1.doorLockOperationGet(), zwave.batteryV1.batteryGet()]) 1286 | if (!state.associationQuery) { 1287 | cmds << "delay 4200" 1288 | cmds << zwave.associationV1.associationGet(groupingIdentifier:2).format() // old Schlage locks use group 2 and don't secure the Association CC 1289 | cmds << secure(zwave.associationV1.associationGet(groupingIdentifier:1)) 1290 | state.associationQuery = now() 1291 | } else if (now() - state.associationQuery.toLong() > 9000) { 1292 | cmds << "delay 6000" 1293 | cmds << zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId).format() 1294 | cmds << secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId)) 1295 | cmds << zwave.associationV1.associationGet(groupingIdentifier:2).format() 1296 | cmds << secure(zwave.associationV1.associationGet(groupingIdentifier:1)) 1297 | state.associationQuery = now() 1298 | } 1299 | cmds 1300 | } 1301 | 1302 | /** 1303 | * Called by the Smart Things platform in case Polling capability is added to the device type 1304 | */ 1305 | def poll() { 1306 | log.trace "[DTH] Executing poll() for device ${device.displayName}" 1307 | def cmds = [] 1308 | // Only check lock state if it changed recently or we haven't had an update in an hour 1309 | def latest = device.currentState("lock")?.date?.time 1310 | if (!latest || !secondsPast(latest, 6 * 60) || secondsPast(state.lastPoll, 55 * 60)) { 1311 | cmds << secure(zwave.doorLockV1.doorLockOperationGet()) 1312 | state.lastPoll = now() 1313 | } else if (!state.lastbatt || now() - state.lastbatt > 53*60*60*1000) { 1314 | cmds << secure(zwave.batteryV1.batteryGet()) 1315 | state.lastbatt = now() //inside-214 1316 | } 1317 | if (state.assoc != zwaveHubNodeId && secondsPast(state.associationQuery, 19 * 60)) { 1318 | cmds << zwave.associationV1.associationSet(groupingIdentifier:2, nodeId:zwaveHubNodeId).format() 1319 | cmds << secure(zwave.associationV1.associationSet(groupingIdentifier:1, nodeId:zwaveHubNodeId)) 1320 | cmds << zwave.associationV1.associationGet(groupingIdentifier:2).format() 1321 | cmds << "delay 6000" 1322 | cmds << secure(zwave.associationV1.associationGet(groupingIdentifier:1)) 1323 | cmds << "delay 6000" 1324 | state.associationQuery = now() 1325 | } else { 1326 | // Only check lock state once per hour 1327 | if (secondsPast(state.lastPoll, 55 * 60)) { 1328 | cmds << secure(zwave.doorLockV1.doorLockOperationGet()) 1329 | state.lastPoll = now() 1330 | } else if (!state.MSR) { 1331 | cmds << zwave.manufacturerSpecificV1.manufacturerSpecificGet().format() 1332 | } else if (!state.fw) { 1333 | cmds << zwave.versionV1.versionGet().format() 1334 | } else if (!device.currentValue("maxCodes")) { 1335 | state.pollCode = 1 1336 | cmds << secure(zwave.userCodeV1.usersNumberGet()) 1337 | } else if (state.pollCode && state.pollCode <= state.codes) { 1338 | cmds << requestCode(state.pollCode) 1339 | } else if (!state.lastbatt || now() - state.lastbatt > 53*60*60*1000) { 1340 | cmds << secure(zwave.batteryV1.batteryGet()) 1341 | } 1342 | } 1343 | 1344 | if (cmds) { 1345 | log.debug "poll is sending ${cmds.inspect()}" 1346 | cmds 1347 | } else { 1348 | // workaround to keep polling from stopping due to lack of activity 1349 | sendEvent(descriptionText: "skipping poll", isStateChange: true, displayed: false) 1350 | null 1351 | } 1352 | } 1353 | 1354 | /** 1355 | * Returns the command for user code get 1356 | * 1357 | * @param codeID: The code slot number 1358 | * 1359 | * @return The command for user code get 1360 | */ 1361 | def requestCode(codeID) { 1362 | secure(zwave.userCodeV1.userCodeGet(userIdentifier: codeID)) 1363 | } 1364 | 1365 | /** 1366 | * API endpoint for server smart app to populate the attributes. Called only when the attributes are not populated. 1367 | * 1368 | * @return The command(s) fired for reading attributes 1369 | */ 1370 | def reloadAllCodes() { 1371 | log.trace "[DTH] Executing 'reloadAllCodes()' by ${device.displayName}" 1372 | sendEvent(name: "scanCodes", value: "Scanning", descriptionText: "Code scan in progress", displayed: false) 1373 | def lockCodes = loadLockCodes() 1374 | sendEvent(lockCodesEvent(lockCodes)) 1375 | state.checkCode = state.checkCode ?: 1 1376 | 1377 | def cmds = [] 1378 | // Not calling validateAttributes() here because userNumberGet command will be added twice 1379 | if(!device.currentValue("codeLength") && isSchlageLock()) { 1380 | cmds << secure(zwave.configurationV2.configurationGet(parameterNumber: getSchlageLockParam().codeLength.number)) 1381 | } 1382 | if (!state.codes) { 1383 | // BUG: There might be a bug where Schlage does not return the below number of codes 1384 | cmds << secure(zwave.userCodeV1.usersNumberGet()) 1385 | } else { 1386 | sendEvent(name: "maxCodes", value: state.codes, displayed: false) 1387 | cmds << requestCode(state.checkCode) 1388 | } 1389 | if(cmds.size() > 1) { 1390 | cmds = delayBetween(cmds, 4200) 1391 | } 1392 | cmds 1393 | } 1394 | 1395 | /** 1396 | * API endpoint for setting the user code length on a lock. This is specific to Schlage locks. 1397 | * 1398 | * @param length: The user code length 1399 | * 1400 | * @returns The command fired for writing the code length attribute 1401 | */ 1402 | def setCodeLength(length) { 1403 | if (isSchlageLock()) { 1404 | length = length.toInteger() 1405 | if (length >= 4 && length <= 8) { 1406 | log.trace "[DTH] Executing 'setCodeLength()' by ${device.displayName}" 1407 | def val = [] 1408 | val << length 1409 | def param = getSchlageLockParam() 1410 | return secure(zwave.configurationV2.configurationSet(parameterNumber: param.codeLength.number, size: param.codeLength.size, configurationValue: val)) 1411 | } 1412 | } 1413 | return null 1414 | } 1415 | 1416 | /** 1417 | * API endpoint for setting a user code on a lock 1418 | * 1419 | * @param codeID: The code slot number 1420 | * 1421 | * @param code: The code PIN 1422 | * 1423 | * @param codeName: The name of the code 1424 | * 1425 | * @returns cmds: The commands fired for creation and checking of a lock code 1426 | */ 1427 | def setCode(codeID, code, codeName = null) { 1428 | if (!code) { 1429 | log.trace "[DTH] Executing 'nameSlot()' by ${this.device.displayName}" 1430 | nameSlot(codeID, codeName) 1431 | return 1432 | } 1433 | 1434 | log.trace "[DTH] Executing 'setCode()' by ${this.device.displayName}" 1435 | def strcode = code 1436 | if (code instanceof String) { 1437 | code = code.toList().findResults { if(it > ' ' && it != ',' && it != '-') it.toCharacter() as Short } 1438 | } else { 1439 | strcode = code.collect{ it as Character }.join() 1440 | } 1441 | 1442 | def strname = (codeName ?: "Code $codeID") 1443 | state["setname$codeID"] = strname 1444 | 1445 | def cmds = validateAttributes() 1446 | cmds << secure(zwave.userCodeV1.userCodeSet(userIdentifier:codeID, userIdStatus:1, user:code)) 1447 | if(cmds.size() > 1) { 1448 | cmds = delayBetween(cmds, 4200) 1449 | } 1450 | cmds 1451 | } 1452 | 1453 | /** 1454 | * Validates attributes and if attributes are not populated, adds the command maps to list of commands 1455 | * @return List of commands or empty list 1456 | */ 1457 | def validateAttributes() { 1458 | def cmds = [] 1459 | if(!device.currentValue("maxCodes")) { 1460 | cmds << secure(zwave.userCodeV1.usersNumberGet()) 1461 | } 1462 | if(!device.currentValue("codeLength") && isSchlageLock()) { 1463 | cmds << secure(zwave.configurationV2.configurationGet(parameterNumber: getSchlageLockParam().codeLength.number)) 1464 | } 1465 | log.trace "validateAttributes returning commands list: " + cmds 1466 | cmds 1467 | } 1468 | 1469 | /** 1470 | * API endpoint for setting/deleting multiple user codes on a lock 1471 | * 1472 | * @param codeSettings: The map with code slot numbers and code pins (in case of update) 1473 | * 1474 | * @returns The commands fired for creation and deletion of lock codes 1475 | */ 1476 | def updateCodes(codeSettings) { 1477 | log.trace "[DTH] Executing updateCodes() for device ${device.displayName}" 1478 | if(codeSettings instanceof String) codeSettings = util.parseJson(codeSettings) 1479 | def set_cmds = [] 1480 | codeSettings.each { name, updated -> 1481 | if (name.startsWith("code")) { 1482 | def n = name[4..-1].toInteger() 1483 | if (updated && updated.size() >= 4 && updated.size() <= 8) { 1484 | log.debug "Setting code number $n" 1485 | set_cmds << secure(zwave.userCodeV1.userCodeSet(userIdentifier:n, userIdStatus:1, user:updated)) 1486 | } else if (updated == null || updated == "" || updated == "0") { 1487 | log.debug "Deleting code number $n" 1488 | set_cmds << deleteCode(n) 1489 | } 1490 | } else log.warn("unexpected entry $name: $updated") 1491 | } 1492 | if (set_cmds) { 1493 | return response(delayBetween(set_cmds, 2200)) 1494 | } 1495 | return null 1496 | } 1497 | 1498 | /** 1499 | * Renames an existing lock slot 1500 | * 1501 | * @param codeSlot: The code slot number 1502 | * 1503 | * @param codeName The new name of the code 1504 | */ 1505 | void nameSlot(codeSlot, codeName) { 1506 | codeSlot = codeSlot.toString() 1507 | if (!isCodeSet(codeSlot)) { 1508 | return 1509 | } 1510 | def deviceName = device.displayName 1511 | log.trace "[DTH] - Executing nameSlot() for device $deviceName" 1512 | def lockCodes = loadLockCodes() 1513 | def oldCodeName = getCodeName(lockCodes, codeSlot) 1514 | def newCodeName = codeName ?: "Code $codeSlot" 1515 | lockCodes[codeSlot] = newCodeName 1516 | sendEvent(lockCodesEvent(lockCodes)) 1517 | sendEvent(name: "codeChanged", value: "$codeSlot renamed", data: [ lockName: deviceName, notify: false, notificationText: "Renamed \"$oldCodeName\" to \"$newCodeName\" in $deviceName at ${location.name}" ], 1518 | descriptionText: "Renamed \"$oldCodeName\" to \"$newCodeName\"", displayed: true, isStateChange: true) 1519 | } 1520 | 1521 | /** 1522 | * API endpoint for deleting a user code on a lock 1523 | * 1524 | * @param codeID: The code slot number 1525 | * 1526 | * @returns cmds: The command fired for deletion of a lock code 1527 | */ 1528 | def deleteCode(codeID) { 1529 | log.trace "[DTH] Executing 'deleteCode()' by ${this.device.displayName}" 1530 | // Calling user code get when deleting a code because some Kwikset locks do not generate 1531 | // AlarmReport when a code is deleted manually on the lock 1532 | secureSequence([ 1533 | zwave.userCodeV1.userCodeSet(userIdentifier:codeID, userIdStatus:0), 1534 | zwave.userCodeV1.userCodeGet(userIdentifier:codeID) 1535 | ], 4200) 1536 | } 1537 | 1538 | /** 1539 | * Encapsulates a command 1540 | * 1541 | * @param cmd: The command to be encapsulated 1542 | * 1543 | * @returns ret: The encapsulated command 1544 | */ 1545 | private secure(physicalgraph.zwave.Command cmd) { 1546 | zwave.securityV1.securityMessageEncapsulation().encapsulate(cmd).format() 1547 | } 1548 | 1549 | /** 1550 | * Encapsulates list of command and adds a delay 1551 | * 1552 | * @param commands: The list of command to be encapsulated 1553 | * 1554 | * @param delay: The delay between commands 1555 | * 1556 | * @returns The encapsulated commands 1557 | */ 1558 | private secureSequence(commands, delay=4200) { 1559 | delayBetween(commands.collect{ secure(it) }, delay) 1560 | } 1561 | 1562 | /** 1563 | * Checks if the time elapsed from the provided timestamp is greater than the number of senconds provided 1564 | * 1565 | * @param timestamp: The timestamp 1566 | * 1567 | * @param seconds: The number of seconds 1568 | * 1569 | * @returns true if elapsed time is greater than number of seconds provided, else false 1570 | */ 1571 | private Boolean secondsPast(timestamp, seconds) { 1572 | if (!(timestamp instanceof Number)) { 1573 | if (timestamp instanceof Date) { 1574 | timestamp = timestamp.time 1575 | } else if ((timestamp instanceof String) && timestamp.isNumber()) { 1576 | timestamp = timestamp.toLong() 1577 | } else { 1578 | return true 1579 | } 1580 | } 1581 | return (now() - timestamp) > (seconds * 1000) 1582 | } 1583 | 1584 | /** 1585 | * Reads the code name from the 'lockCodes' map 1586 | * 1587 | * @param lockCodes: map with lock code names 1588 | * 1589 | * @param codeID: The code slot number 1590 | * 1591 | * @returns The code name 1592 | */ 1593 | private String getCodeName(lockCodes, codeID) { 1594 | if (isMasterCode(codeID)) { 1595 | return "Master Code" 1596 | } 1597 | lockCodes[codeID.toString()] ?: "Code $codeID" 1598 | } 1599 | 1600 | /** 1601 | * Reads the code name from the device state 1602 | * 1603 | * @param lockCodes: map with lock code names 1604 | * 1605 | * @param codeID: The code slot number 1606 | * 1607 | * @returns The code name 1608 | */ 1609 | private String getCodeNameFromState(lockCodes, codeID) { 1610 | if (isMasterCode(codeID)) { 1611 | return "Master Code" 1612 | } 1613 | def nameFromLockCodes = lockCodes[codeID.toString()] 1614 | def nameFromState = state["setname$codeID"] 1615 | if(nameFromLockCodes) { 1616 | if(nameFromState) { 1617 | //Updated from smart app 1618 | return nameFromState 1619 | } else { 1620 | //Updated from lock 1621 | return nameFromLockCodes 1622 | } 1623 | } else if(nameFromState) { 1624 | //Set from smart app 1625 | return nameFromState 1626 | } 1627 | //Set from lock 1628 | return "Code $codeID" 1629 | } 1630 | 1631 | /** 1632 | * Check if a user code is present in the 'lockCodes' map 1633 | * 1634 | * @param codeID: The code slot number 1635 | * 1636 | * @returns true if code is present, else false 1637 | */ 1638 | private Boolean isCodeSet(codeID) { 1639 | // BUG: Needed to add loadLockCodes to resolve null pointer when using schlage? 1640 | def lockCodes = loadLockCodes() 1641 | lockCodes[codeID.toString()] ? true : false 1642 | } 1643 | 1644 | /** 1645 | * Reads the 'lockCodes' attribute and parses the same 1646 | * 1647 | * @returns Map: The lockCodes map 1648 | */ 1649 | private Map loadLockCodes() { 1650 | parseJson(device.currentValue("lockCodes") ?: "{}") ?: [:] 1651 | } 1652 | 1653 | /** 1654 | * Populates the 'lockCodes' attribute by calling create event 1655 | * 1656 | * @param lockCodes The user codes in a lock 1657 | */ 1658 | private Map lockCodesEvent(lockCodes) { 1659 | createEvent(name: "lockCodes", value: util.toJson(lockCodes), displayed: false, 1660 | descriptionText: "'lockCodes' attribute updated") 1661 | } 1662 | 1663 | /** 1664 | * Utility function to figure out if code id pertains to master code or not 1665 | * 1666 | * @param codeID - The slot number in which code is set 1667 | * @return - true if slot is for master code, false otherwise 1668 | */ 1669 | private boolean isMasterCode(codeID) { 1670 | if(codeID instanceof String) { 1671 | codeID = codeID.toInteger() 1672 | } 1673 | (codeID == 0) ? true : false 1674 | } 1675 | 1676 | /** 1677 | * Creates the event map for user code creation 1678 | * 1679 | * @param lockCodes: The user codes in a lock 1680 | * 1681 | * @param codeID: The code slot number 1682 | * 1683 | * @param codeName: The name of the user code 1684 | * 1685 | * @return The list of events to be sent out 1686 | */ 1687 | private def codeSetEvent(lockCodes, codeID, codeName) { 1688 | clearStateForSlot(codeID) 1689 | // codeID seems to be an int primitive type 1690 | lockCodes[codeID.toString()] = (codeName ?: "Code $codeID") 1691 | def result = [] 1692 | result << lockCodesEvent(lockCodes) 1693 | def codeReportMap = [ name: "codeReport", value: codeID, data: [ code: "" ], isStateChange: true, displayed: false ] 1694 | codeReportMap.descriptionText = "${device.displayName} code $codeID is set" 1695 | result << createEvent(codeReportMap) 1696 | result 1697 | } 1698 | 1699 | /** 1700 | * Creates the event map for user code deletion 1701 | * 1702 | * @param lockCodes: The user codes in a lock 1703 | * 1704 | * @param codeID: The code slot number 1705 | * 1706 | * @return The list of events to be sent out 1707 | */ 1708 | private def codeDeletedEvent(lockCodes, codeID) { 1709 | lockCodes.remove("$codeID".toString()) 1710 | // not sure if the trigger has done this or not 1711 | clearStateForSlot(codeID) 1712 | def result = [] 1713 | result << lockCodesEvent(lockCodes) 1714 | def codeReportMap = [ name: "codeReport", value: codeID, data: [ code: "" ], isStateChange: true, displayed: false ] 1715 | codeReportMap.descriptionText = "${device.displayName} code $codeID was deleted" 1716 | result << createEvent(codeReportMap) 1717 | result 1718 | } 1719 | 1720 | /** 1721 | * Creates the event map for all user code deletion 1722 | * 1723 | * @return The List of events to be sent out 1724 | */ 1725 | private def allCodesDeletedEvent() { 1726 | def result = [] 1727 | def lockCodes = loadLockCodes() 1728 | def deviceName = device.displayName 1729 | lockCodes.each { id, code -> 1730 | result << createEvent(name: "codeReport", value: id, data: [ code: "" ], descriptionText: "code $id was deleted", 1731 | displayed: false, isStateChange: true) 1732 | 1733 | def codeName = code 1734 | result << createEvent(name: "codeChanged", value: "$id deleted", data: [ codeName: codeName, lockName: deviceName, 1735 | notify: true, notificationText: "Deleted \"$codeName\" in $deviceName at ${location.name}" ], 1736 | descriptionText: "Deleted \"$codeName\"", 1737 | displayed: true, isStateChange: true) 1738 | clearStateForSlot(id) 1739 | } 1740 | result 1741 | } 1742 | 1743 | /** 1744 | * Checks if a change type is set or update 1745 | * 1746 | * @param lockCodes: The user codes in a lock 1747 | * 1748 | * @param codeID The code slot number 1749 | * 1750 | * @return "set" or "update" basis the presence of the code id in the lockCodes map 1751 | */ 1752 | private def getChangeType(lockCodes, codeID) { 1753 | def changeType = "set" 1754 | if (lockCodes[codeID.toString()]) { 1755 | changeType = "changed" 1756 | } 1757 | changeType 1758 | } 1759 | 1760 | /** 1761 | * Method to obtain status for descriptuion based on change type 1762 | * @param changeType: Either "set" or "changed" 1763 | * @return "Added" for "set", "Updated" for "changed", "" otherwise 1764 | */ 1765 | private def getStatusForDescription(changeType) { 1766 | if("set" == changeType) { 1767 | return "Added" 1768 | } else if("changed" == changeType) { 1769 | return "Updated" 1770 | } 1771 | //Don't return null as it cause trouble 1772 | return "" 1773 | } 1774 | 1775 | /** 1776 | * Clears the code name and pin from the state basis the code slot number 1777 | * 1778 | * @param codeID: The code slot number 1779 | */ 1780 | def clearStateForSlot(codeID) { 1781 | state.remove("setname$codeID") 1782 | state["setname$codeID"] = null 1783 | } 1784 | 1785 | /** 1786 | * Constructs a map of the code length parameter in Schlage lock 1787 | * 1788 | * @return map: The map with key and values for parameter number, and size 1789 | */ 1790 | def getSchlageLockParam() { 1791 | def map = [ 1792 | codeLength: [ number: 16, size: 1] 1793 | ] 1794 | map 1795 | } 1796 | 1797 | /** 1798 | * Utility function to check if the lock manufacturer is Schlage 1799 | * 1800 | * @return true if the lock manufacturer is Schlage, else false 1801 | */ 1802 | def isSchlageLock() { 1803 | if ("003B" == zwaveInfo.mfr) { 1804 | if("Schlage" != getDataValue("manufacturer")) { 1805 | updateDataValue("manufacturer", "Schlage") 1806 | } 1807 | return true 1808 | } 1809 | return false 1810 | } 1811 | 1812 | /** 1813 | * Utility function to check if the lock manufacturer is Kwikset 1814 | * 1815 | * @return true if the lock manufacturer is Kwikset, else false 1816 | */ 1817 | def isKwiksetLock() { 1818 | if ("0090" == zwaveInfo.mfr) { 1819 | if("Kwikset" != getDataValue("manufacturer")) { 1820 | updateDataValue("manufacturer", "Kwikset") 1821 | } 1822 | return true 1823 | } 1824 | return false 1825 | } 1826 | 1827 | /** 1828 | * Utility function to check if the lock manufacturer is Yale 1829 | * 1830 | * @return true if the lock manufacturer is Yale, else false 1831 | */ 1832 | def isYaleLock() { 1833 | if ("0129" == zwaveInfo.mfr) { 1834 | if("Yale" != getDataValue("manufacturer")) { 1835 | updateDataValue("manufacturer", "Yale") 1836 | } 1837 | return true 1838 | } 1839 | return false 1840 | } 1841 | 1842 | /** 1843 | * Returns true if this lock generates door lock operation report before alarm report, false otherwise 1844 | * @return true if this lock generates door lock operation report before alarm report, false otherwise 1845 | */ 1846 | def generatesDoorLockOperationReportBeforeAlarmReport() { 1847 | //Fix for ICP-2367, ICP-2366 1848 | if(isYaleLock() && 1849 | (("0007" == zwaveInfo.prod && "0001" == zwaveInfo.model) || 1850 | ("6600" == zwaveInfo.prod && "0002" == zwaveInfo.model) )) { 1851 | //Yale Keyless Connected Smart Door Lock and Conexis 1852 | return true 1853 | } 1854 | return false 1855 | } 1856 | 1857 | /** 1858 | * Generic function for reading code Slot ID from AlarmReport command 1859 | * @param cmd: The AlarmReport command 1860 | * @return user code slot id 1861 | */ 1862 | def readCodeSlotId(physicalgraph.zwave.commands.alarmv2.AlarmReport cmd) { 1863 | if(cmd.numberOfEventParameters == 1) { 1864 | return cmd.eventParameter[0] 1865 | } else if(cmd.numberOfEventParameters >= 3) { 1866 | return cmd.eventParameter[2] 1867 | } 1868 | return cmd.alarmLevel 1869 | } 1870 | 1871 | def setDeviceSettings(){ 1872 | def cmds = [] 1873 | cmds << configureAudio() 1874 | cmds << configureAutoLock() 1875 | cmds << configureReLockTime() 1876 | cmds << configureAttemptLimit() 1877 | cmds << configureLockOutTime() 1878 | cmds << configureOperationMode() 1879 | 1880 | return secureSequence(cmds, 4200) 1881 | } 1882 | 1883 | def configureAudio() { 1884 | if (audioMode) { 1885 | def value 1886 | switch(audioMode) { 1887 | case 'Off': 1888 | switch(zwaveInfo.prod) { 1889 | case '0001': 1890 | case '0002': 1891 | value = 0x01 1892 | break 1893 | default: 1894 | value = 0x03 1895 | } 1896 | break 1897 | case 'On': 1898 | switch(zwaveInfo.prod) { 1899 | case '0001': 1900 | case '0002': 1901 | value = 0x02 1902 | break 1903 | default: 1904 | value = 0x01 1905 | break 1906 | } 1907 | case 'High': 1908 | switch(zwaveInfo.prod) { 1909 | case '0001': 1910 | case '0002': 1911 | value = 0x03 1912 | break 1913 | default: 1914 | value = 0x01 1915 | break 1916 | } 1917 | } 1918 | return zwave.configurationV2.configurationSet(parameterNumber: 1, size: 1, configurationValue: [value]) 1919 | } 1920 | } 1921 | 1922 | def configureAutoLock() { 1923 | if (autoLock != null) { 1924 | def value 1925 | if (autoLock) { 1926 | value = 0xFF 1927 | } else { 1928 | value = 0x00 1929 | } 1930 | return zwave.configurationV2.configurationSet(parameterNumber: 2, size: 1, configurationValue: [value]) 1931 | } 1932 | } 1933 | def configureReLockTime() { 1934 | if (reLockTime) { 1935 | return zwave.configurationV2.configurationSet(parameterNumber: 3, size: 1, configurationValue: [reLockTime]) 1936 | } 1937 | } 1938 | 1939 | def configureAttemptLimit() { 1940 | if (entryAttemptLimit) { 1941 | return zwave.configurationV2.configurationSet(parameterNumber: 4, size: 1, configurationValue: [entryAttemptLimit]) 1942 | } 1943 | } 1944 | 1945 | def configureLockOutTime() { 1946 | if (lockOutTime) { 1947 | return zwave.configurationV2.configurationSet(parameterNumber: 7, size: 1, configurationValue: [lockOutTime]) 1948 | } 1949 | } 1950 | 1951 | def configureOperationMode() { 1952 | if (operatingMode) { 1953 | def value 1954 | switch(operatingMode) { 1955 | case 'Normal': 1956 | value = 0x00 1957 | break 1958 | case 'Vacation': 1959 | value = 0x01 1960 | break 1961 | case 'Privacy': 1962 | value = 0x02 1963 | break 1964 | default: 1965 | value = 0x00 1966 | break 1967 | } 1968 | return zwave.configurationV2.configurationSet(parameterNumber: 8, size: 1, configurationValue: [value]) 1969 | } 1970 | } 1971 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | concat = require('gulp-concat'); 3 | 4 | 5 | const { watch } = require('gulp'); 6 | 7 | gulp.task('concat', function() { 8 | return gulp.src([ 9 | 'source/main.groovy', 10 | 'source/lock.groovy', 11 | 'source/user.groovy', 12 | 'source/keypad.groovy', 13 | // 'source/api.groovy' 14 | ]) 15 | .pipe(concat('lock-manager.groovy')) 16 | .pipe(gulp.dest('./smartapps/ethayer/lock-manager.src/')); 17 | }); 18 | 19 | // Watch Files For Changes 20 | exports.default = function() { 21 | // You can use a single task 22 | watch('source/*.groovy', gulp.parallel(['concat'])) 23 | }; 24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lock-manager", 3 | "version": "1.0.0", 4 | "description": "This BETA is provided for testing purposes. If you are uncomfortable about figuring things out on your own, you should wait until a proper relase in the MASTER branch of this repository.", 5 | "main": "gulpfile.js", 6 | "dependencies": { 7 | "concat": "^1.0.3" 8 | }, 9 | "devDependencies": { 10 | "gulp": "^4.0.2", 11 | "gulp-concat": "^2.6.1", 12 | "gulp-merge": "^0.1.1", 13 | "gulp-watch": "^5.0.1", 14 | "yargs-parser": ">=13.1.2" 15 | }, 16 | "scripts": { 17 | "test": "echo \"Error: no test specified\" && exit 1" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/ethayer/lock-manager.git" 22 | }, 23 | "author": "", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/ethayer/lock-manager/issues" 27 | }, 28 | "homepage": "https://github.com/ethayer/lock-manager#readme" 29 | } 30 | -------------------------------------------------------------------------------- /source/api.groovy: -------------------------------------------------------------------------------- 1 | mappings { 2 | path("/locks") { 3 | action: [ 4 | GET: "listLocks" 5 | ] 6 | } 7 | path("/token") { 8 | action: [ 9 | POST: "gotAccountToken" 10 | ] 11 | } 12 | path("/update-slot") { 13 | action: [ 14 | POST: "updateSlot" 15 | ] 16 | } 17 | 18 | // Big Mirror 19 | path("/switches") { 20 | action: [ 21 | GET: "listSwitches" 22 | ] 23 | } 24 | path("/update-switch") { 25 | action: [ 26 | POST: "updateSwitch" 27 | ] 28 | } 29 | } 30 | 31 | def apiSetupPage() { 32 | dynamicPage(name: 'apiSetupPage', title: 'Setup API', uninstall: true, install: true) { 33 | section('API service') { 34 | input(name: 'enableAPI', title: 'Enabled?', type: 'bool', required: true, defaultValue: true, description: 'Enable API integration?') 35 | if (state.accountToken) { 36 | paragraph 'Token: ' + state.accountToken 37 | } 38 | } 39 | section('Entanglements') { 40 | paragraph 'Switches:' 41 | input(name: 'theSwitches', title: 'Which Switches?', type: 'capability.switch', multiple: true, required: true) 42 | } 43 | } 44 | } 45 | 46 | def lockObject(lockApp) { 47 | def usage = lockApp.totalUsage() 48 | def pinLength = lockApp.pinLength() 49 | def slotCount = lockApp.lockCodeSlots() 50 | def slotData = lockApp.codeData(); 51 | def lockState = lockApp.lockState(); 52 | return [ 53 | name: lockApp.lock.displayName, 54 | value: lockApp.lock.id, 55 | usage_count: usage, 56 | pin_length: pinLength, 57 | slot_count: slotCount, 58 | lock_state: lockState, 59 | slot_data: slotData 60 | ] 61 | } 62 | 63 | def listLocks() { 64 | def locks = [] 65 | def lockApps = getLockApps() 66 | 67 | lockApps.each { app -> 68 | locks << lockObject(app) 69 | } 70 | debugger(locks) 71 | return locks 72 | } 73 | 74 | def listSlots() { 75 | def slots = [] 76 | def lockApps = parent.getLockApps() 77 | 78 | lockApps.each { app -> 79 | locks << lockObject(app) 80 | } 81 | return locks 82 | } 83 | 84 | def switchObject(theSwitch) { 85 | return [ 86 | name: theSwitch.displayName, 87 | key: theSwitch.id, 88 | state: theSwitch.currentValue("switch") 89 | ] 90 | } 91 | 92 | def listSwitches() { 93 | def list = [] 94 | parent.theSwitches.each { theSwitch -> 95 | list << switchObject(theSwitch) 96 | } 97 | return list 98 | } 99 | 100 | def codeUsed(lockApp, action, slot) { 101 | def params = [ 102 | uri: 'https://api.lockmanager.io/', 103 | path: 'v1/events/code-used', 104 | body: [ 105 | token: state.accountToken, 106 | lock: lockObject(lockApp), 107 | action_event: action, 108 | slot: slot 109 | ] 110 | ] 111 | debugger('send switcheroo!') 112 | asynchttp_v1.post(processResponse, params) 113 | } 114 | 115 | def processResponse(response, data) { 116 | log.debug(data) 117 | } 118 | 119 | def updateSlot() { 120 | def slot = request.JSON?.slot 121 | def control = request.JSON?.control 122 | def code = request.JSON?.code 123 | def lock_id = request.JSON?.lock_key 124 | def lockApp = parent.getLockAppById(lock_id) 125 | // slot, code, control 126 | lockApp.apiCodeUpdate(slot, code, control) 127 | } 128 | 129 | def gotAccountToken() { 130 | log.debug('got called! ' + request.JSON?.token + ' ' + parent?.id) 131 | state.accountToken = request.JSON?.token 132 | setAccountToken(request.JSON?.token) 133 | return [data: "OK"] 134 | } 135 | 136 | def updateSwitch() { 137 | def action = request.JSON?.state 138 | def switchID = request.JSON?.key 139 | log.debug "got update! ${action} ${switchID}" 140 | parent.theSwitches.each { theSwitch -> 141 | if (theSwitch.id == switchID) { 142 | if (action == 'on') { 143 | theSwitch.on() 144 | } 145 | if (action == 'off') { 146 | theSwitch.off() 147 | } 148 | } 149 | } 150 | } 151 | 152 | def switchOnHandler(evt) { 153 | def params = [ 154 | uri: 'https://api.lockmanager.io/', 155 | path: '/events/switch-change', 156 | body: [ 157 | token: state.accountToken, 158 | key: evt.deviceId, 159 | state: 'on' 160 | ] 161 | ] 162 | asynchttp_v1.post(processResponse, params) 163 | } 164 | 165 | def switchOffHandler(evt) { 166 | def params = [ 167 | uri: 'https://api.lockmanager.io/', 168 | path: '/events/switch-change', 169 | body: [ 170 | token: state.accountToken, 171 | key: evt.deviceId, 172 | state: 'off' 173 | ] 174 | ] 175 | asynchttp_v1.post(processResponse, params) 176 | } 177 | -------------------------------------------------------------------------------- /source/keypad.groovy: -------------------------------------------------------------------------------- 1 | def installedKeypad() { 2 | debugger("Keypad Installed with settings: ${settings}") 3 | initializeKeypad() 4 | } 5 | 6 | def updatedKeypad() { 7 | debugger("Keypad Updated with settings: ${settings}") 8 | initializeKeypad() 9 | } 10 | 11 | def initializeKeypad() { 12 | // reset listeners 13 | unsubscribe() 14 | atomicState.tries = 0 15 | atomicState.installComplete = true 16 | 17 | if (keypad) { 18 | subscribe(location, 'alarmSystemStatus', alarmStatusHandler) 19 | subscribe(keypad, 'codeEntered', codeEntryHandler) 20 | } 21 | } 22 | 23 | def isUniqueKeypad() { 24 | def unique = true 25 | if (!atomicState.installComplete) { 26 | // only look if we're not initialized yet. 27 | def keypadApps = parent.getKeypadApps() 28 | keypadApps.each { keypadApp -> 29 | if (keypadApp.keypad.id == keypad.id) { 30 | unique = false 31 | } 32 | } 33 | } 34 | return unique 35 | } 36 | 37 | def keypadLandingPage() { 38 | if (keypad) { 39 | def unique = isUniqueKeypad() 40 | if (unique){ 41 | keypadMainPage() 42 | } else { 43 | keypadErrorPage() 44 | } 45 | } else { 46 | keypadSetupPage() 47 | } 48 | } 49 | 50 | def keypadSetupPage() { 51 | dynamicPage(name: 'keypadSetupPage', title: 'Setup Keypad', nextPage: 'keypadLandingPage', uninstall: true) { 52 | section('NOTE:') { 53 | def p = 'Locks with keypads ARE NOT KEYPADS in this context.\n\n' 54 | p += 'This integration works with stand-alone keypads only!' 55 | paragraph p 56 | paragraph 'For locks, use the Lock child-app.' 57 | } 58 | section('Keypad App Label') { 59 | label(title: "Name for App", required: true) 60 | } 61 | section('Choose keypad for this app') { 62 | input(name: 'keypad', title: 'Which keypad?', type: 'capability.lockCodes', multiple: false, required: true) 63 | } 64 | } 65 | } 66 | 67 | def keypadErrorPage() { 68 | dynamicPage(name: 'keypadErrorPage', title: 'Keypad Duplicate', uninstall: true, nextPage: 'keypadLandingPage') { 69 | section('Oops!') { 70 | paragraph 'The keypad that you selected is already installed. Please choose a different keypad or choose Remove' 71 | } 72 | section('Choose keypad for this app') { 73 | input(name: 'keypad', title: 'Which keypad?', type: 'capability.lockCodes', multiple: false, required: true) 74 | } 75 | section('Keypad App Label') { 76 | label(title: "Name for App", required: true) 77 | } 78 | } 79 | } 80 | 81 | def keypadMainPage() { 82 | dynamicPage(name: 'keypadMainPage',title: 'Keypad Settings (optional)', install: true, uninstall: true) { 83 | def actions = location.helloHome?.getPhrases()*.label 84 | actions?.sort() 85 | section('Routines') { 86 | paragraph 'settings here are for this keypad only. Global keypad settings, use parent app.' 87 | input(name: 'runDefaultAlarm', title: 'Act as SHM device?', type: 'bool', defaultValue: true, description: 'Toggle this off if actions should not effect SHM' ) 88 | input(name: 'armRoutine', title: 'Arm/Away routine', type: 'enum', options: actions, required: false, multiple: true) 89 | input(name: 'disarmRoutine', title: 'Disarm routine', type: 'enum', options: actions, required: false, multiple: true) 90 | input(name: 'stayRoutine', title: 'Arm/Stay routine', type: 'enum', options: actions, required: false, multiple: true) 91 | input(name: 'nightRoutine', title: 'Arm/Night routine', type: 'enum', options: actions, required: false, multiple: true) 92 | input(name: 'armDelay', title: 'Arm Delay (in seconds)', type: 'number', required: false) 93 | input(name: 'notifyIncorrectPin', title: 'Notify you when incorrect code is used?', type: 'bool', required: false) 94 | input(name: 'attemptTollerance', title: 'How many times can incorrect code be used before notification?', type: 'number', defaultValue: 3, required: true) 95 | } 96 | section('Setup', hideable: true, hidden: true) { 97 | input(name: 'keypad', title: 'Keypad', type: 'capability.lockCodes', multiple: false, required: true) 98 | label title: 'Label', defaultValue: "Keypad: ${keypad.label}", required: false, description: 'recommended to start with Keypad:' 99 | } 100 | } 101 | } 102 | 103 | def alarmStatusHandler(event) { 104 | debugger("Keypad manager caught alarm status change: ${event.value}") 105 | if (runDefaultAlarm && event.value == 'off'){ 106 | keypad?.setDisarmed() 107 | } 108 | else if (runDefaultAlarm && event.value == 'away'){ 109 | keypad?.setArmedAway() 110 | } 111 | else if (runDefaultAlarm && event.value == 'stay') { 112 | keypad?.setArmedStay() 113 | } 114 | } 115 | 116 | def codeEntryHandler(evt) { 117 | //do stuff 118 | debugger("Caught code entry event! ${evt.value.value}") 119 | 120 | def codeEntered = evt.value as String 121 | def data = evt.data as Integer 122 | def currentarmMode = keypad.currentValue('armMode') 123 | def correctUser = parent.keypadMatchingUser(codeEntered) 124 | 125 | if (correctUser) { 126 | atomicState.tries = 0 127 | debugger('Correct PIN entered.') 128 | armCommand(data, correctUser, codeEntered) 129 | } else { 130 | debugger('Incorrect code!') 131 | atomicState.tries = atomicState.tries + 1 132 | if (atomicState.tries >= attemptTollerance) { 133 | keypad.sendInvalidKeycodeResponse() 134 | atomicState.tries = 0 135 | } 136 | } 137 | } 138 | 139 | def armCommand(value, correctUser, enteredCode) { 140 | def armMode 141 | def action 142 | keypad.acknowledgeArmRequest(value) 143 | switch (value) { 144 | case 0: 145 | armMode = 'off' 146 | action = 'disarmed' 147 | break 148 | case 1: 149 | armMode = 'stay' 150 | action = 'armed to \'Stay\'' 151 | break 152 | case 2: 153 | armMode = 'night' 154 | action = 'armed to \'Night\'' 155 | break 156 | case 3: 157 | armMode = 'away' 158 | action = 'armed to \'Away\'' 159 | break 160 | default: 161 | log.error "${app.label}: Unexpected arm mode sent by keypad!" 162 | armMode = false 163 | break 164 | } 165 | 166 | // only delay on ARM actions 167 | def useDelay = 0 168 | if (armMode != 'off' && armMode != 'stay') { 169 | useDelay = armDelay 170 | } 171 | 172 | if (useDelay > 0) { 173 | keypad.setExitDelay(useDelay) 174 | } 175 | if (armMode) { 176 | // set values for delayed event 177 | atomicState.codeEntered = enteredCode 178 | atomicState.armMode = armMode 179 | 180 | runIn(useDelay, execRoutine) 181 | } 182 | 183 | def message = "${keypad.label} was ${action} by ${correctUser.label}" 184 | 185 | debugger(message) 186 | correctUser.sendUserMessage(message) 187 | } 188 | 189 | def execRoutine() { 190 | debugger('executing keypad actions') 191 | def armMode = atomicState.armMode 192 | def userApp = parent.keypadMatchingUser(atomicState.codeEntered) 193 | 194 | sendSHMEvent(armMode) 195 | 196 | // run hello home actions 197 | if (armMode == 'away') { 198 | if (armRoutine) { 199 | location.helloHome?.execute(armRoutine) 200 | } 201 | if (userApp.armRoutine) { 202 | location.helloHome?.execute(userApp.armRoutine) 203 | } 204 | if (parent.armRoutine) { 205 | location.helloHome?.execute(parent.armRoutine) 206 | } 207 | } else if (armMode == 'stay') { 208 | if (stayRoutine) { 209 | location.helloHome?.execute(stayRoutine) 210 | } 211 | if (userApp.stayRoutine) { 212 | location.helloHome?.execute(userApp.stayRoutine) 213 | } 214 | if (parent.stayRoutine) { 215 | location.helloHome?.execute(parent.stayRoutine) 216 | } 217 | } else if (armMode == 'off') { 218 | if (disarmRoutine) { 219 | location.helloHome?.execute(disarmRoutine) 220 | } 221 | if (userApp.disarmRoutine) { 222 | location.helloHome?.execute(userApp.disarmRoutine) 223 | } 224 | if (parent.disarmRoutine) { 225 | location.helloHome?.execute(parent.disarmRoutine) 226 | } 227 | } else if (armMode == 'night') { 228 | if (nightRoutine) { 229 | location.helloHome?.execute(nightRoutine) 230 | } 231 | if (userApp.nightRoutine) { 232 | location.helloHome?.execute(userApp.nightRoutine) 233 | } 234 | if (parent.nightRoutine) { 235 | location.helloHome?.execute(parent.nightRoutine) 236 | } 237 | } 238 | } 239 | 240 | def sendSHMEvent(armMode) { 241 | def event = [ 242 | name:'alarmSystemStatus', 243 | value: armMode, 244 | displayed: true, 245 | description: "System Status is ${armMode}" 246 | ] 247 | debugger("Event: ${event}") 248 | if (runDefaultAlarm) { 249 | sendLocationEvent(event) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /source/lock.groovy: -------------------------------------------------------------------------------- 1 | def lockInstalled() { 2 | debugger("Lock Installed with settings: ${settings}") 3 | lockInitialize() 4 | } 5 | 6 | def lockUpdated() { 7 | debugger("Lock Updated with settings: ${settings}") 8 | lockInitialize() 9 | } 10 | 11 | def lockInitialize() { 12 | // reset listeners 13 | unsubscribe() 14 | unschedule() 15 | subscribe(lock, 'codeChanged', updateCode, [filterEvents:false]) 16 | subscribe(lock, "lock", lockEvent) 17 | // Allow save and run setup in headless mode 18 | queSetupLockData() 19 | } 20 | 21 | 22 | def isUniqueLock() { 23 | def unique = true 24 | if (!state.installComplete) { 25 | // only look if we're not initialized yet. 26 | def lockApps = parent.getLockApps() 27 | lockApps.each { lockApp -> 28 | debugger(lockApp.lock.id) 29 | if (lockApp.lock.id == lock.id) { 30 | unique = false 31 | } 32 | } 33 | } 34 | return unique 35 | } 36 | 37 | def lockLandingPage() { 38 | if (lock) { 39 | def unique = isUniqueLock() 40 | if (unique){ 41 | lockMainPage() 42 | } else { 43 | lockErrorPage() 44 | } 45 | } else { 46 | lockSetupPage() 47 | } 48 | } 49 | 50 | def lockSetupPage() { 51 | dynamicPage(name: "lockSetupPage", title: "Setup Lock", nextPage: "lockLandingPage", uninstall: true) { 52 | section('Lock App Label') { 53 | label(title: "Name for App", required: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 54 | } 55 | section("Choose devices for this lock") { 56 | input(name: "lock", title: "Which Lock?", type: "capability.Lock", multiple: false, required: true) 57 | input(name: "contactSensor", title: "Which contact sensor?", type: "capability.contactSensor", multiple: false, required: false) 58 | } 59 | } 60 | } 61 | 62 | def lockMainPage() { 63 | dynamicPage(name: "lockMainPage", title: "Lock Settings", install: true, uninstall: true) { 64 | getLockMaxCodes() 65 | section("Settings") { 66 | if (state.installComplete) { 67 | if (state.sweepMode == 'Enabled') { 68 | def completeCount = sweepProgress() 69 | def totalSlots = lockCodeSlots() 70 | def percent = Math.round((completeCount/totalSlots) * 100) 71 | def estimatedMinutes = ((totalSlots - completeCount) * 6) / 60 72 | def p = "" 73 | p += "${percent}%\n" 74 | p += 'Sweep is in progress.\n' 75 | p += "Progress: ${completeCount}/${totalSlots}\n\n" 76 | 77 | p += "Estimated time left: ${estimatedMinutes} Minutes\n" 78 | p += "Lock will set codes after sweep is complete." 79 | paragraph p 80 | } 81 | } else { 82 | def totalSlots = lockCodeSlots() 83 | def estimatedMinutes = (totalSlots * 6) / 60 84 | def para = "" 85 | if (!skipSweep) { 86 | para += "This lock will take about \n${estimatedMinutes} Minutes to install.\n\n" 87 | para += "You may skip the sweep process and save this time." 88 | } else { 89 | para += "WARNING:\n" 90 | para += "You have choosen to skip the sweep process.\n\n" 91 | para += "This will save about ${estimatedMinutes} Minutes.\n\n" 92 | para += "Do this at your own risk. The sweep process will prevent conflicts." 93 | } 94 | paragraph para 95 | input(name: 'skipSweep', title: 'Skip Sweep?', type: 'bool', required: true, description: 'Skip Process', defaultValue: false, submitOnChange: true) 96 | } 97 | 98 | def actions = location.helloHome?.getPhrases()*.label 99 | href(name: 'toNotificationPage', page: 'lockNotificationPage', title: 'Notification Settings', image: 'http://images.lockmanager.io/app/v1/images/bullhorn.png') 100 | if (actions) { 101 | href(name: 'toLockHelloHomePage', page: 'lockHelloHomePage', title: 'Hello Home Settings', image: 'http://images.lockmanager.io/app/v1/images/home.png') 102 | } 103 | } 104 | section('Setup', hideable: true, hidden: true) { 105 | label title: 'Label', defaultValue: "Lock: ${lock.label}", required: false, description: 'recommended to start with Lock:' 106 | input(name: 'lock', title: 'Which Lock?', type: 'capability.lock', multiple: false, required: true) 107 | input(name: 'contactSensor', title: 'Which contact sensor?', type: "capability.contactSensor", multiple: false, required: false) 108 | input(name: 'slotCount', title: 'How many slots?', type: 'number', multiple: false, required: false, description: 'Overwrite number of slots supported.') 109 | } 110 | } 111 | } 112 | 113 | def lockHelloHomePage() { 114 | dynamicPage(name: 'helloHomePage', title: 'Hello Home Settings (optional)') { 115 | def actions = location.helloHome?.getPhrases()*.label 116 | actions?.sort() 117 | section('Hello Home Phrases') { 118 | input(name: 'manualUnlockRoutine', title: 'On Manual Unlock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 119 | input(name: 'manualLockRoutine', title: 'On Manual Lock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 120 | 121 | input(name: 'codeUnlockRoutine', title: 'On Code Unlock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png' ) 122 | 123 | paragraph 'Supported on some locks:' 124 | input(name: 'codeLockRoutine', title: 'On Code Lock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 125 | input(name: 'keypadLockRoutine', title: 'On Keypad Lock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 126 | 127 | paragraph 'These restrictions apply to all the above:' 128 | input "userNoRunPresence", "capability.presenceSensor", title: "DO NOT run Actions if any of these are present:", multiple: true, required: false 129 | input "userDoRunPresence", "capability.presenceSensor", title: "ONLY run Actions if any of these are present:", multiple: true, required: false 130 | } 131 | } 132 | } 133 | 134 | def getLockMaxCodes() { 135 | // Check to see if the Lock Handler knows how many slots there are 136 | if (lock?.hasAttribute('maxCodes')) { 137 | def slotCount = lock.latestValue('maxCodes') 138 | state.codeSlots = slotCount 139 | } 140 | } 141 | 142 | def isInit() { 143 | return (state.initializeComplete) 144 | } 145 | 146 | def lockErrorPage() { 147 | dynamicPage(name: 'lockErrorPage', title: 'Lock Duplicate', uninstall: true, nextPage: 'lockLandingPage') { 148 | section('Oops!') { 149 | paragraph 'The lock that you selected is already installed. Please choose a different Lock or choose Remove' 150 | } 151 | section('Choose devices for this lock') { 152 | input(name: 'lock', title: 'Which Lock?', type: 'capability.lock', multiple: false, required: true) 153 | input(name: 'contactSensor', title: 'Which contact sensor?', type: 'capability.contactSensor', multiple: false, required: false) 154 | } 155 | section('Lock App Label') { 156 | label(title: "Name for App", required: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 157 | } 158 | } 159 | } 160 | 161 | def lockNotificationPage() { 162 | dynamicPage(name: 'lockNotificationPage', title: 'Notification Settings') { 163 | section { 164 | paragraph 'Some options only work on select locks' 165 | if (!state.supportsKeypadData) { 166 | paragraph 'This lock only reports manual messages.\n It does not support code on lock or lock on keypad.' 167 | } 168 | if (phone == null && !notification && !recipients) { 169 | input(name: 'muteLock', title: 'Mute this lock?', type: 'bool', required: false, submitOnChange: true, defaultValue: false, description: 'Mute notifications for this user if notifications are set globally', image: 'http://images.lockmanager.io/app/v1/images/bell-slash-o.png') 170 | } 171 | if (!muteLock) { 172 | input('recipients', 'contact', title: 'Send notifications to', submitOnChange: true, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/book.png') 173 | href(name: 'toAskAlexaPage', title: 'Ask Alexa', page: 'lockAskAlexaPage', image: 'http://images.lockmanager.io/app/v1/images/Alexa.png') 174 | if (!recipients) { 175 | input(name: 'phone', type: 'text', title: 'Text This Number', description: 'Phone number', required: false, submitOnChange: true) 176 | paragraph 'For multiple SMS recipients, separate phone numbers with a semicolon(;)' 177 | input(name: 'notification', type: 'bool', title: 'Send A Push Notification', description: 'Notification', required: false, submitOnChange: true) 178 | } 179 | if (phone != null || notification || recipients) { 180 | input(name: 'notifyManualLock', title: 'On Manual Turn (Lock)', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 181 | input(name: 'notifyManualUnlock', title: 'On Manual Turn (Unlock)', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 182 | if (state.supportsKeypadData) { 183 | input(name: 'notifyKeypadLock', title: 'On Keypad Press to Lock', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 184 | } 185 | } 186 | } 187 | } 188 | if (!muteLock) { 189 | section('Only During These Times (optional)') { 190 | input(name: 'notificationStartTime', type: 'time', title: 'Notify Starting At This Time', description: null, required: false) 191 | input(name: 'notificationEndTime', type: 'time', title: 'Notify Ending At This Time', description: null, required: false) 192 | } 193 | } 194 | } 195 | } 196 | 197 | 198 | def queSetupLockData() { 199 | state.installComplete = true 200 | runIn(10, setupLockData) 201 | } 202 | 203 | def setupLockData() { 204 | debugger('run lock data setup') 205 | 206 | def lockUsers = parent.getUserApps() 207 | lockUsers.each { lockUser -> 208 | // initialize data attributes for this lock. 209 | lockUser.initializeLockData() 210 | } 211 | if (state.requestCount == null) { 212 | state.requestCount = 0 213 | } 214 | 215 | initSlots() 216 | } 217 | 218 | def initSlots() { 219 | def codeState = 'unknown' 220 | if (state.codes == null) { 221 | // new install! Start learning! 222 | state.codes = [:] 223 | state.requestCount = 0 224 | // skipSweep may be null 225 | if (skipSweep != true) { 226 | state.sweepMode = 'Enabled' 227 | codeState = 'sweep' 228 | } 229 | state.refreshComplete = true 230 | state.supportsKeypadData = true 231 | state.pinLength = false 232 | } 233 | if (lock?.hasAttribute('pinLength')) { 234 | state.pinLength = lock.latestValue('pinLength') 235 | } 236 | 237 | // Check to see if the Lock Handler knows how many slots there are 238 | if (lock?.hasAttribute('maxCodes')) { 239 | def slotCount = lock.latestValue('maxCodes') 240 | state.codeSlots = slotCount 241 | } 242 | 243 | def userCodeSlots = getUserSlotList() 244 | def codeSlots = lockCodeSlots() 245 | 246 | (1..codeSlots).each { slot -> 247 | def control = 'available' 248 | if (state.codes["slot${slot}"] == null) { 249 | state.codes["slot${slot}"] = [:] 250 | state.codes["slot${slot}"].slot = slot 251 | state.codes["slot${slot}"].code = null 252 | // set attempts 253 | state.codes["slot${slot}"].attempts = 0 254 | state.codes["slot${slot}"].recoveryAttempts = 0 255 | state.codes["slot${slot}"].namedSlot = false 256 | state.codes["slot${slot}"].codeState = codeState 257 | state.codes["slot${slot}"].control = control 258 | } 259 | 260 | // manage controll type 261 | def currentControl = state.codes["slot${slot}"].control 262 | switch (currentControl) { 263 | case 'available': 264 | case 'controller': 265 | if (userCodeSlots.contains(slot.toInteger())) { 266 | control = 'controller' 267 | } 268 | break 269 | case 'api': 270 | default: 271 | // nothing to do 272 | break 273 | } 274 | state.codes["slot${slot}"].control = control 275 | } 276 | if (state.sweepMode == 'Enabled') { 277 | state.sweepProgress = 0 278 | sweepSequance() 279 | } else { 280 | setCodes() 281 | } 282 | } 283 | 284 | def sweepSequance() { 285 | def codeSlots = lockCodeSlots() 286 | def array = [] 287 | def count = 0 288 | def completeCount = 0 289 | (1..codeSlots).each { slot -> 290 | // sweep in packages of 10 291 | if (count == 10) { 292 | // do nothing ~ We're going to stop adding codes for now. 293 | } else { 294 | def slotData = state.codes["slot${slot}"] 295 | if (slotData.codeState == 'sweep') { 296 | count++ 297 | array << ["code${slotData.slot}", null] 298 | } else { 299 | // This code is already known/unset! 300 | completeCount++ 301 | state.sweepProgress = completeCount 302 | } 303 | } 304 | } 305 | 306 | // allow 10 and 5 seconds per code delete 307 | def timeOut = 10 + (count * 6) 308 | 309 | def json = new groovy.json.JsonBuilder(array).toString() 310 | if (json != '[]') { 311 | debugger("Progress: ${completeCount}/${codeSlots} Data: ${json}") 312 | lock.updateCodes(json) 313 | runIn(timeOut, sweepSequance) 314 | } else { 315 | debugger('Sweep Completed!') 316 | state.sweepMode = 'Disabled' 317 | // Allow some cooldown time to prevent conflicts 318 | runIn(15, setCodes) 319 | } 320 | } 321 | 322 | def withinAllowed() { 323 | return (state.requestCount <= allowedAttempts()) 324 | } 325 | 326 | def allowedAttempts() { 327 | return lockCodeSlots() * 2 328 | } 329 | 330 | def updateCode(event) { 331 | def data = new JsonSlurper().parseText(event.data) 332 | def name = event.name 333 | def description = event.descriptionText 334 | def activity = event.value =~ /(\d{1,3}).(\w*)/ 335 | def slot = activity[0][1].toInteger() 336 | def activityType = activity[0][2] 337 | def previousCode = state.codes["slot${slot}"].code 338 | 339 | debugger("name: ${name} slot: ${slot} data: ${data} description: ${description} activity: ${activity[0]}") 340 | 341 | def code = null 342 | def userApp = findSlotUserApp(slot) 343 | if (userApp) { 344 | code = userApp.userCode 345 | } 346 | 347 | def codeState 348 | def previousCodeState = state.codes["slot${slot}"].codeState 349 | switch (slot) { 350 | case 251: 351 | // code is duplicate of master 352 | if (state.incorrectSlots.size() == 1) { 353 | // the only slot to set must be the incorrect one! 354 | def errorSlot = state.incorrectSlots[0] 355 | userApp = findSlotUserApp(errorSlot) 356 | // We can set this reason code immediatly 357 | userApp.disableAndSetReason(lock.id, 'Conflicts with Master Code') 358 | 359 | state.codes["slot${errorSlot}"].code = null 360 | state.codes["slot${errorSlot}"].codeState = 'known' 361 | } 362 | break 363 | default: 364 | switch (activityType) { 365 | case 'unset': 366 | debugger("Slot:${slot} is no longer set!") 367 | if (previousCodeState == 'unset' || state.sweepMode) { 368 | codeState = 'correct' 369 | } else { 370 | // We were not expecting an unset! 371 | codeState = 'unexpected' 372 | } 373 | state.codes["slot${slot}"].code = null 374 | state.codes["slot${slot}"].codeState = codeState 375 | break 376 | case 'changed': 377 | case 'set': 378 | switch(previousCodeState) { 379 | case 'set': 380 | case 'recovery': 381 | codeState = 'correct' 382 | debugger("Slot:${slot} is set!") 383 | break 384 | default: 385 | // We didnt expect a set, lets unset it and set the correct code 386 | debugger("Slot:${slot} unexpected set!") 387 | if (userApp) { 388 | // we have to delete it and set it again, 389 | // because if it's the same as user's PIN 390 | // it will error 391 | failRecovery(slot, previousCodeState, userApp) 392 | } else { 393 | // We can just delete the code because we don't want anything there 394 | code = 'invalid' 395 | codeState = 'unexpected' 396 | } 397 | 398 | break 399 | } 400 | state.codes["slot${slot}"].code = code 401 | state.codes["slot${slot}"].codeState = codeState 402 | break 403 | case 'failed': 404 | failRecovery(slot, previousCodeState, userApp); 405 | break 406 | default: 407 | // unknown action 408 | break 409 | } 410 | } //end switch(slot) 411 | 412 | switch (codeState) { 413 | case 'correct': 414 | if (previousCodeState == 'set') { 415 | codeInform(slot, 'access') 416 | } else if (previousCodeState == 'unset') { 417 | codeInform(slot, 'revoked') 418 | } 419 | 420 | break 421 | case 'unexpected': 422 | // run code logic, and reset code 423 | switch (activityType) { 424 | case 'set': 425 | case 'changed': 426 | codeInform(slot, 'unexpected-set') 427 | break 428 | case 'unset': 429 | default: 430 | codeInform(slot, 'unexpected-unset') 431 | break 432 | } 433 | debugger('Unexpected change! Scheduling code logic.') 434 | runIn(25, setCodes) 435 | break 436 | case 'failed': 437 | if (previousCodeState == 'unset') { 438 | // I'm not sure if this would ever happen... 439 | codeInform(slot, 'failed-unset') 440 | } else if (previousCodeState == 'set') { 441 | codeInform(slot, 'failed-set') 442 | } 443 | } 444 | } 445 | 446 | def failRecovery(slot, previousCodeState, userApp) { 447 | def attempts = state.codes["slot${slot}"].recoveryAttempts 448 | if (attempts > 3) { 449 | if (userApp) { 450 | userApp.disableAndSetReason(lock.id, 'Code failed to set. Possible duplicate or invalid PIN') 451 | } 452 | debugger("Slot:${slot} failed! Recovery failed.") 453 | state.codes["slot${slot}"].code = 'invalid' 454 | state.codes["slot${slot}"].codeState = 'failed' 455 | } else { 456 | debugger("Slot:${slot} failed, attempting recovery.") 457 | state.codes["slot${slot}"].recoveryAttempts = attempts + 1 458 | state.codes["slot${slot}"].code = 'invalid' 459 | state.codes["slot${slot}"].codeState = 'recovery' 460 | } 461 | } 462 | 463 | def lockEvent(evt) { 464 | def data = new JsonSlurper().parseText(evt.data) 465 | debugger("Lock event. ${data}") 466 | 467 | switch(data.method) { 468 | case 'keypad': 469 | keypadLockEvent(evt, data) 470 | break 471 | case 'manual': 472 | manualUnlock(evt) 473 | break 474 | case 'command': 475 | commandUnlock(evt) 476 | break 477 | case 'auto': 478 | autoLock(evt) 479 | break 480 | } 481 | } 482 | 483 | def keypadLockEvent(evt, data) { 484 | def message 485 | def userApp = findSlotUserApp(data.usedCode) 486 | if (evt.value == 'locked') { 487 | if (userApp) { 488 | userDidLock(userApp) 489 | } else { 490 | message = "${lock.label} was locked by keypad" 491 | debugger(message) 492 | if (keypadLockRoutine) { 493 | executeHelloPresenceCheck(keypadLockRoutine) 494 | } 495 | if (notifyKeypadLock) { 496 | sendLockMessage(message) 497 | } 498 | if (alexaKeypadLock) { 499 | askAlexaLock(message) 500 | } 501 | } 502 | } else if (evt.value == 'unlocked') { 503 | if (userApp) { 504 | userDidUnlock(userApp) 505 | } else { 506 | debugger('Lock was locked by unknown user!') 507 | // unlocked by unknown user? 508 | } 509 | } 510 | } 511 | 512 | def userDidLock(userApp) { 513 | def message = "${lock.label} was locked by ${userApp.userName}" 514 | debugger(message) 515 | // user specific 516 | if (userApp.userLockPhrase) { 517 | userApp.executeHelloPresenceCheck(userApp.userLockPhrase) 518 | } 519 | // lock specific 520 | if (codeLockRoutine) { 521 | userApp.executeHelloPresenceCheck(codeLockRoutine) 522 | } 523 | // global 524 | if (parent.codeLockRoutine) { 525 | parent.executeHelloPresenceCheck(parent.codeLockRoutine) 526 | } 527 | 528 | // messages 529 | if (userApp.notifyLock || parent.notifyLock) { 530 | userApp.sendUserMessage(message) 531 | } 532 | if (userApp.alexaLock || parent.alexaLock) { 533 | userApp.sendAskAlexaLock(message) 534 | } 535 | } 536 | 537 | def userDidUnlock(userApp) { 538 | def message 539 | message = "${lock.label} was unlocked by ${userApp.userName}" 540 | debugger(message) 541 | userApp.incrementLockUsage(lock.id) 542 | if (!userApp.isNotBurned()) { 543 | parent.setAccess() 544 | message += '. Now burning code.' 545 | } 546 | // user specific 547 | if (userApp.userUnlockPhrase) { 548 | userApp.executeHelloPresenceCheck(userApp.userUnlockPhrase) 549 | } 550 | // lock specific 551 | if (codeUnlockRoutine) { 552 | userApp.executeHelloPresenceCheck(codeUnlockRoutine) 553 | } 554 | // global 555 | if (parent.codeUnlockRoutine) { 556 | parent.executeHelloPresenceCheck(parent.codeUnlockRoutine) 557 | } 558 | 559 | //Send Message 560 | if (userApp.notifyAccess || parent.notifyAccess) { 561 | userApp.sendUserMessage(message) 562 | } 563 | if (userApp.alexaAccess || parent.alexaAccess) { 564 | userApp.sendAskAlexaLock(message) 565 | } 566 | } 567 | 568 | def manualUnlock(evt) { 569 | def message 570 | if (evt.value == 'locked') { 571 | // locked manually 572 | message = "${lock.label} was locked manually" 573 | debugger(message) 574 | // lock specific 575 | if (manualLockRoutine) { 576 | executeHelloPresenceCheck(manualLockRoutine) 577 | } 578 | // global 579 | if (parent.manualLockRoutine) { 580 | parent.executeHelloPresenceCheck(parent.manualLockRoutine) 581 | } 582 | 583 | if (notifyManualLock) { 584 | sendLockMessage(message) 585 | } 586 | if (alexaManualLock) { 587 | askAlexaLock(message) 588 | } 589 | } else if (evt.value == 'unlocked') { 590 | message = "${lock.label} was unlocked manually" 591 | debugger(message) 592 | // lock specific 593 | if (manualUnlockRoutine) { 594 | executeHelloPresenceCheck(manualUnlockRoutine) 595 | } 596 | // global 597 | if (parent.manualUnlockRoutine) { 598 | parent.executeHelloPresenceCheck(parent.manualUnlockRoutine) 599 | } 600 | } 601 | } 602 | 603 | def commandUnlock(evt) { 604 | // no options for this scenario yet 605 | } 606 | def autoLock(evt) { 607 | // no options for this scenario yet 608 | } 609 | 610 | def setCodes() { 611 | def setValue 612 | def name 613 | // set what each slot should be in memory 614 | if (state.sweepMode == 'Enabled') { 615 | debugger('Not running code logic, Sweep mode is Enabled') 616 | return false 617 | } 618 | // set incorrect slot array to blank 619 | state.incorrectSlots = [] 620 | 621 | debugger('run code logic') 622 | def codes = state.codes 623 | codes.each { data -> 624 | data = data.value 625 | name = false 626 | switch(data.control) { 627 | case 'controller': 628 | def lockUser = findSlotUserApp(data.slot) 629 | def codeState = state.codes["slot${data.slot}"].codeState 630 | if (lockUser?.isActive(lock.id) && codeState != 'recovery') { 631 | // is active, should be set 632 | setValue = lockUser.userCode.toString() 633 | state.codes["slot${data.slot}"].correctValue = setValue 634 | if (data.code.toString() != setValue) { 635 | state.codes["slot${data.slot}"].codeState = 'set' 636 | } else { 637 | // set name only if code is already set 638 | name = lockUser.userName 639 | } 640 | } else { 641 | // is inactive, should not be set 642 | setValue = null 643 | state.codes["slot${data.slot}"].correctValue = null 644 | if (data.code != setValue) { 645 | state.codes["slot${data.slot}"].codeState = 'unset' 646 | } 647 | } 648 | break 649 | case 'api': 650 | if (data.correctCode != null) { 651 | if (data.correctCode != data.code) { 652 | state.codes["slot${data.slot}"].codeState = 'unset' 653 | } 654 | } else if (data.correctCode.toString() != data.code.toString()) { 655 | state.codes["slot${data.slot}"].codeState = 'set' 656 | } 657 | 658 | // do nothing, correct code set by API service 659 | break 660 | default: 661 | // only overwrite if enabled 662 | if (parent.overwriteMode) { 663 | state.codes["slot${data.slot}"].correctValue = null 664 | } 665 | break 666 | } 667 | // ensure name is set correctly 668 | nameSlot(data.slot, name) 669 | } 670 | // After setting code data, send to the lock 671 | runIn(15, loadCodes) 672 | } 673 | 674 | def loadCodes() { 675 | // send codes to lock 676 | debugger('running load codes') 677 | def codesToSet 678 | def unsetCodes = collectCodesToUnset() 679 | // do this so we unset codes first 680 | if (unsetCodes.size > 0) { 681 | codesToSet = unsetCodes 682 | } else { 683 | codesToSet = collectCodesToSet() 684 | } 685 | 686 | def json = new groovy.json.JsonBuilder(codesToSet).toString() 687 | if (json != '[]') { 688 | debugger("update: ${json}") 689 | lock.updateCodes(json) 690 | // After sending codes, run memory logic again 691 | def timeOut = (codesToSet.size() * 6) + 10 692 | runIn(timeOut, setCodes) 693 | } else { 694 | // All done, codes should be correct 695 | debugger('No codes to set') 696 | } 697 | } 698 | 699 | def collectCodesToUnset() { 700 | def codes = state.codes 701 | 702 | def incorrectSlots = [] 703 | def array = [] 704 | def count = 0 705 | 706 | codes.each { data -> 707 | data = data.value 708 | 709 | if (count < 10) { 710 | def currentCode = data.code 711 | def correctCode = data.correctValue 712 | 713 | if (correctCode == null && currentCode != null) { 714 | array << ["code${data.slot}", code] 715 | incorrectSlots << data.slot 716 | count++ 717 | } 718 | } 719 | } 720 | 721 | state.incorrectSlots = incorrectSlots 722 | return array 723 | } 724 | 725 | def collectCodesToSet() { 726 | def codes = state.codes 727 | 728 | def incorrectSlots = [] 729 | def array = [] 730 | def count = 0 731 | 732 | codes.each { data -> 733 | data = data.value 734 | 735 | if (count < 10) { 736 | def currentCode = data.code.toString() 737 | def correctCode = data.correctValue.toString() 738 | 739 | if (correctCode != currentCode && state.codes["slot${data.slot}"].attempts < 10) { 740 | array << ["code${data.slot}", correctCode] 741 | incorrectSlots << data.slot 742 | // increment attempt count 743 | state.codes["slot${data.slot}"].attempts = data.attempts + 1 744 | count++ 745 | } else if (correctCode != currentCode && state.codes["slot${data.slot}"].attempts >= 10) { 746 | state.codes["slot${data.slot}"].attempts = 0 747 | // we've tried this slot 10 times, time to disable it 748 | def userApp = findSlotUserApp(data.slot) 749 | userApp?.disableLock(lock.id) 750 | } else { 751 | // code is correct 752 | state.codes["slot${data.slot}"].attempts = 0 753 | } 754 | } 755 | } 756 | 757 | state.incorrectSlots = incorrectSlots 758 | return array 759 | } 760 | 761 | def getUserSlotList() { 762 | def userSlots = [] 763 | def lockUsers = parent.getUserApps() 764 | lockUsers.each { lockUser -> 765 | userSlots << lockUser.userSlot.toInteger() 766 | } 767 | return userSlots 768 | } 769 | 770 | def findSlotUserApp(slot) { 771 | if (slot) { 772 | def lockUsers = parent.getUserApps() 773 | return lockUsers.find { app -> app.userSlot.toInteger() == slot.toInteger() } 774 | } else { 775 | return false 776 | } 777 | } 778 | 779 | def codeInform(slot, action) { 780 | def userApp = findSlotUserApp(slot) 781 | if (userApp) { 782 | def message = '' 783 | def shouldSend = false 784 | switch(action) { 785 | case 'access': 786 | message = "${userApp.userName} now has access to ${lock.label}" 787 | // add name 788 | nameSlot(slot, userApp.userName) 789 | if (userApp.notifyAccessStart || parent.notifyAccessStart) { 790 | shouldSend = true 791 | } 792 | break 793 | case 'revoke': 794 | // remove name 795 | nameSlot(slot, false) 796 | message = "${userApp.userName} no longer has access to ${lock.label}" 797 | if (userApp.notifyAccessEnd || parent.notifyAccessEnd) { 798 | shouldSend = true 799 | } 800 | break 801 | case 'unexpected-set': 802 | message = "Unexpected code in Slot:${slot}. ${userApp.userName} may not have valid access to ${lock.label}. Checking for issues." 803 | shouldSend = true 804 | break 805 | case 'unexpected-unset': 806 | message = "Unexpected code delete Slot:${slot}. ${userApp.userName} may not have valid access to ${lock.label}. Checking for issues." 807 | shouldSend = true 808 | break 809 | case 'failed-set': 810 | def disabledReason = userApp.disabledReason() 811 | message = "Controller failed to set code for ${userApp.name}. ${disabledReason}" 812 | shouldSend = true 813 | break 814 | } 815 | 816 | if (shouldSend) { 817 | userApp.sendUserMessage(message) 818 | } 819 | debugger(message) 820 | } else { 821 | // remove set user name, no app 822 | nameSlot(slot, false) 823 | } 824 | } 825 | 826 | def isCodeComplete() { 827 | if (state.sweepMode == 'Enabled') { 828 | return false 829 | } else { 830 | return true 831 | } 832 | } 833 | 834 | def doorOpenCheck() { 835 | def currentState = contact.contactState 836 | if (currentState?.value == 'open') { 837 | def msg = "${contact.displayName} is open. Scheduled lock failed." 838 | log.info msg 839 | if (sendPushMessage) { 840 | sendPush msg 841 | } 842 | if (phone) { 843 | sendSms phone, msg 844 | } 845 | } else { 846 | lockMessage() 847 | lock.lock() 848 | } 849 | } 850 | 851 | def lockMessage() { 852 | def msg = "Locking ${lock.displayName} due to scheduled lock." 853 | log.info msg 854 | } 855 | 856 | 857 | def sendLockMessage(message) { 858 | if (notificationStartTime != null && notificationEndTime != null) { 859 | def start = timeToday(notificationStartTime) 860 | def stop = timeToday(notificationEndTime) 861 | def now = rightNow() 862 | if (start.before(now) && stop.after(now)){ 863 | sendMessage(message) 864 | } 865 | } else { 866 | sendMessage(message) 867 | } 868 | } 869 | def sendMessage(message) { 870 | if (!muteLock) { 871 | if (recipients) { 872 | sendNotificationToContacts(message, recipients) 873 | } else { 874 | if (notification) { 875 | sendPush(message) 876 | } else { 877 | sendNotificationEvent(message) 878 | } 879 | if (phone) { 880 | if ( phone.indexOf(';') > 1){ 881 | def phones = phone.split(';') 882 | for ( def i = 0; i < phones.size(); i++) { 883 | sendSms(phones[i], message) 884 | } 885 | } 886 | else { 887 | sendSms(phone, message) 888 | } 889 | } 890 | } 891 | } else { 892 | sendNotificationEvent(message) 893 | } 894 | } 895 | 896 | def nameSlot(slot, name) { 897 | if (state.codes["slot${slot}"].namedSlot != name) { 898 | state.codes["slot${slot}"].namedSlot = name 899 | lock.nameSlot(slot, name) 900 | } 901 | } 902 | 903 | def askAlexaLock(message) { 904 | if (!muteLock) { 905 | if (alexaStartTime != null && alexaEndTime != null) { 906 | def start = timeToday(alexaStartTime) 907 | def stop = timeToday(alexaEndTime) 908 | def now = rightNow() 909 | if (start.before(now) && stop.after(now)){ 910 | sendAskAlexa(message) 911 | } 912 | } else { 913 | sendAskAlexa(message) 914 | } 915 | } 916 | } 917 | 918 | def sendAskAlexaLock(message) { 919 | sendLocationEvent(name: 'AskAlexaMsgQueue', 920 | value: 'LockManager/Lock', 921 | isStateChange: true, 922 | descriptionText: message, 923 | unit: "Lock//${lock.label}") 924 | } 925 | 926 | def apiCodeUpdate(slot, code, control) { 927 | state.codes["slot${slot}"]['correctValue'] = code 928 | state.codes["slot${slot}"]['control'] = control 929 | setCodes() 930 | } 931 | 932 | def isRefreshComplete() { 933 | return state.refreshComplete 934 | } 935 | 936 | def totalUsage() { 937 | def usage = 0 938 | def userApps = parent.getUserApps() 939 | userApps.each { userApp -> 940 | def lockUsage = userApp.getLockUsage(lock.id) 941 | usage = usage + lockUsage 942 | } 943 | return usage 944 | } 945 | 946 | def lockCodeSlots() { 947 | // default to 30 948 | def codeSlots = 30 949 | if (slotCount) { 950 | // return the user defined value 951 | codeSlots = slotCount 952 | } else if (state?.codeSlots) { 953 | codeSlots = state.codeSlots.toInteger() 954 | } 955 | return codeSlots 956 | } 957 | 958 | def codeData() { 959 | return state.codes 960 | } 961 | 962 | def userPageCount() { 963 | def sortData = state.codes.sort{it.value.slot} 964 | def data = sortData.collect{ it } 965 | return data.collate(30).size() 966 | } 967 | 968 | def codeDataPaginated(page) { 969 | // collect a paginated list to prevent rate limit issues 970 | def sortData = state.codes.sort{it.value.slot} 971 | def data = sortData.collect{ it } 972 | return data.collate(30)[page] 973 | } 974 | 975 | def slotData(slot) { 976 | state.codes["slot${slot}"] 977 | } 978 | 979 | def lockState() { 980 | state.lockState 981 | } 982 | 983 | def lockSort() { 984 | if (lock) { 985 | return lock.id 986 | } else { 987 | return 1 988 | } 989 | } 990 | 991 | def sweepProgress() { 992 | state.sweepProgress 993 | } 994 | 995 | def enableUser(slot) { 996 | state.codes["slot${slot}"].attempts = 0 997 | runIn(10, setCodes) 998 | } 999 | 1000 | def pinLength() { 1001 | return state.pinLength 1002 | } 1003 | -------------------------------------------------------------------------------- /source/main.groovy: -------------------------------------------------------------------------------- 1 | definition( 2 | name: 'Lock Manager', 3 | namespace: 'ethayer', 4 | author: 'Erik Thayer', 5 | parent: parent ? "ethayer: Lock Manager" : null, 6 | description: 'Manage locks and users', 7 | category: 'Safety & Security', 8 | singleInstance: true, 9 | iconUrl: 'http://images.lockmanager.io/app/v1/images/lm.jpg', 10 | iconX2Url: 'http://images.lockmanager.io/app/v1/images/lm2x.jpg', 11 | iconX3Url: 'http://images.lockmanager.io/app/v1/images/lm3x.jpg' 12 | ) 13 | import groovy.json.JsonSlurper 14 | import groovy.json.JsonBuilder 15 | import java.util.regex.* 16 | include 'asynchttp_v1' 17 | 18 | preferences { 19 | // Wizard 20 | page name: 'appPageWizard' 21 | 22 | // Manager === 23 | page name: 'mainLandingPage' 24 | page name: 'mainSetupPage', title: 'Installed', install: true, uninstall: true, submitOnChange: true 25 | page name: 'mainPage', title: 'Lock Manager', install: true, uninstall: true, submitOnChange: true 26 | page name: 'infoRefreshPage' 27 | page name: 'notificationPage' 28 | page name: 'helloHomePage' 29 | page name: 'lockInfoPage' 30 | page name: 'keypadPage' 31 | page name: 'askAlexaPage' 32 | 33 | // Lock ==== 34 | page name: 'lockLandingPage' 35 | page name: 'lockSetupPage' 36 | page name: 'lockMainPage' 37 | page name: 'lockErrorPage' 38 | page name: 'lockNotificationPage' 39 | page name: 'lockHelloHomePage' 40 | page name: 'lockInfoRefreshPage' 41 | page name: 'lockAskAlexaPage' 42 | 43 | // User ==== 44 | page name: 'userLandingPage' 45 | page name: 'userSetupPage' 46 | page name: 'userMainPage' 47 | page name: 'userLockPage', title: 'Manage Lock', install: false, uninstall: false 48 | page name: 'schedulingPage', title: 'Schedule User', install: false, uninstall: false 49 | page name: 'calendarPage', title: 'Calendar', install: false, uninstall: false 50 | page name: 'userNotificationPage' 51 | page name: 'reEnableUserLockPage' 52 | page name: 'lockResetPage' 53 | page name: 'userKeypadPage' 54 | page name: 'userAskAlexaPage' 55 | 56 | // Keypad ==== 57 | page name: 'keypadLandingPage' 58 | page name: 'keypadSetupPage' 59 | page name: 'keypadMainPage' 60 | page name: 'keypadErrorPage' 61 | 62 | // API ==== 63 | page name: 'apiSetupPage' 64 | } 65 | 66 | def appPageWizard(params) { 67 | def appType = state.appType 68 | 69 | if (!appType) { 70 | if (params.type) { 71 | // inital set app type 72 | debugger("Param set is: ${params.type}") 73 | appType = params.type 74 | } 75 | if (parent) { 76 | appType = parent.theNewChild() 77 | } 78 | debugger("App Type: ${appType}") 79 | setAppType(appType) 80 | } 81 | 82 | // find the correct landing page 83 | switch (appType) { 84 | case 'lock': 85 | lockLandingPage() 86 | break 87 | case 'user': 88 | userLandingPage() 89 | break 90 | case 'keypad': 91 | keypadLandingPage() 92 | break 93 | case 'api': 94 | apiSetupPage() 95 | break 96 | default: 97 | mainLangingPage() 98 | break 99 | } 100 | } 101 | 102 | def installed() { 103 | // find the correct installer 104 | switch (state.appType) { 105 | case 'lock': 106 | lockInstalled() 107 | break 108 | case 'user': 109 | userInstalled() 110 | break 111 | case 'keypad': 112 | installedKeypad() 113 | break 114 | case 'api': 115 | installedApi() 116 | break 117 | default: 118 | debugger("Installed with settings: ${settings}") 119 | installedMain() 120 | break 121 | } 122 | } 123 | 124 | def updated() { 125 | // find the correct updater 126 | switch (state.appType) { 127 | case 'lock': 128 | lockUpdated() 129 | break 130 | case 'user': 131 | userUpdated() 132 | break 133 | case 'keypad': 134 | updatedKeypad() 135 | break 136 | case 'api': 137 | updatedApi() 138 | break 139 | default: 140 | debugger("Installed with settings: ${settings}") 141 | updatedMain() 142 | break 143 | } 144 | } 145 | 146 | def uninstalled() { 147 | switch (state.appType) { 148 | case 'lock': 149 | break 150 | case 'user': 151 | userUninstalled() 152 | break 153 | case 'keypad': 154 | break 155 | case 'api': 156 | break 157 | default: 158 | break 159 | } 160 | } 161 | 162 | 163 | def installedMain() { 164 | initializeMain() 165 | } 166 | 167 | def updatedMain() { 168 | log.debug "Main Updated with settings: ${settings}" 169 | unsubscribe() 170 | initializeMain() 171 | } 172 | 173 | def initializeMain() { 174 | def children = getLockApps() 175 | log.debug "there are ${children.size()} locks" 176 | 177 | state.initializeComplete = true 178 | state.appVersion = "2.1.3" 179 | 180 | subscribe(location, "mode", locationHandler) 181 | } 182 | 183 | def mainLangingPage() { 184 | if (state.initializeComplete) { 185 | mainPage() 186 | } else { 187 | mainSetupPage() 188 | } 189 | } 190 | 191 | def mainSetupPage() { 192 | dynamicPage(name: 'mainSetupPage', title: 'Lock Manager', install: true, uninstall: true, submitOnChange: true) { 193 | section('Initial Setup') { 194 | label(title: 'Label this SmartApp', required: false, defaultValue: 'Lock Manager') 195 | paragraph 'Lock Manager © 2021 v2.1.3' 196 | } 197 | } 198 | } 199 | 200 | 201 | def mainPage() { 202 | dynamicPage(name: 'mainPage', title: 'Lock Manager', install: true, uninstall: true, submitOnChange: true) { 203 | section('Create New Integration') { 204 | input name: "appType", type: "enum", title: "Choose Type", options: ['Lock', 'User', 'Keypad'], description: "Select the integration you need", submitOnChange: true 205 | if (settings.appType) { 206 | def appTypeString = settings.appType 207 | def miniTypeString = appTypeString.toLowerCase() 208 | debugger("New Param: ${miniTypeString}") 209 | app(name: 'newChild', params: [type: miniTypeString], appName: 'Lock Manager', namespace: 'ethayer', title: "Create New ${appTypeString}", multiple: true, image: "http://images.lockmanager.io/app/v1/images/new-${miniTypeString}.png") 210 | } 211 | } 212 | section('Locks') { 213 | def lockApps = getLockApps() 214 | lockApps = lockApps.sort{ it.lockSort() } 215 | if (lockApps) { 216 | def i = 0 217 | lockApps.each { lockApp -> 218 | i++ 219 | href(name: "toLockInfoPage${i}", page: 'lockInfoPage', params: [id: lockApp.lockSort()], required: false, title: lockApp.label, image: 'http://images.lockmanager.io/app/v1/images/lock.png' ) 220 | } 221 | } 222 | } 223 | section('Global Settings') { 224 | href(name: 'toNotificationPage', page: 'notificationPage', title: 'Notification Settings', description: notificationPageDescription(), state: notificationPageDescription() ? 'complete' : '', image: 'http://images.lockmanager.io/app/v1/images/bullhorn.png') 225 | 226 | def actions = location.helloHome?.getPhrases()*.label 227 | if (actions) { 228 | href(name: 'toHelloHomePage', page: 'helloHomePage', title: 'Hello Home Settings', image: 'http://images.lockmanager.io/app/v1/images/home.png') 229 | } 230 | 231 | def keypadApps = getKeypadApps() 232 | if (keypadApps) { 233 | href(name: 'toKeypadPage', page: 'keypadPage', title: 'Keypad Routines (optional)', image: 'http://images.lockmanager.io/app/v1/images/keypad.png') 234 | } 235 | } 236 | 237 | // section('API') { 238 | // href(name: 'toApiPage', page: 'apiSetupPage', title: 'API Options', image: 'http://images.lockmanager.io/app/v1/images/keypad.png') 239 | // } 240 | 241 | section('Advanced', hideable: true, hidden: true) { 242 | input(name: 'overwriteMode', title: 'Overwrite?', type: 'bool', required: true, defaultValue: true, description: 'Overwrite mode automatically deletes codes not in the users list') 243 | input(name: 'enableDebug', title: 'Enable IDE debug messages?', type: 'bool', required: true, defaultValue: false, description: 'Show activity from Lock Manger in logs for debugging.') 244 | label(title: 'Label this SmartApp', required: false, defaultValue: 'Lock Manager') 245 | paragraph 'Lock Manager © 2021 v2.1.3' 246 | } 247 | } 248 | } 249 | 250 | def setAppType(appType) { 251 | if (!state.appType) { 252 | state.appType = appType 253 | } 254 | } 255 | 256 | def userPageOptions(count) { 257 | def options = [] 258 | (1..count).each { page-> 259 | options << ["${page}": "Page ${page}"] 260 | } 261 | return options 262 | } 263 | 264 | def determinePage(pageCount) { 265 | if (selectedUserPage) { 266 | if (pageCount < selectedUserPage.toInteger()) { 267 | return 0 268 | } else { 269 | return selectedUserPage.toInteger() - 1 270 | } 271 | } else { 272 | return 0 273 | } 274 | } 275 | 276 | def lockInfoPage(params) { 277 | dynamicPage(name:"lockInfoPage", title:"Lock Info") { 278 | def lockApp = getLockAppByIndex(params) 279 | if (lockApp) { 280 | section("${lockApp.label}") { 281 | def complete = lockApp.isCodeComplete() 282 | if (!complete) { 283 | def completeCount = lockApp.sweepProgress() 284 | def totalSlots = lockApp.lockCodeSlots() 285 | def percent = Math.round((completeCount/totalSlots) * 100) 286 | def estimatedMinutes = ((totalSlots - completeCount) * 6) / 60 287 | def p = "" 288 | p += "${percent}%\n" 289 | p += 'Sweep is in progress.\n' 290 | p += "Progress: ${completeCount}/${totalSlots}\n\n" 291 | 292 | p += "Estimated time left: ${estimatedMinutes} Minutes\n" 293 | p += "Lock will set codes after sweep is complete" 294 | paragraph p 295 | } else { 296 | def pageCount = lockApp.userPageCount() 297 | if (pageCount > 1) { 298 | input(name: 'selectedUserPage', title: 'Select the visible user page', type: 'enum', required: true, defaultValue: 1, description: 'Select Page', 299 | options: userPageOptions(pageCount), submitOnChange: true) 300 | } 301 | // def codeData = lockApp.codeData() 302 | def thePage = determinePage(pageCount) 303 | debugger("Page count: ${pageCount} Page: ${thePage}") 304 | 305 | def codeData = lockApp.codeDataPaginated(thePage) 306 | debugger(codeData) 307 | if (codeData) { 308 | def setCode = '' 309 | def usage 310 | def para 311 | def image 312 | codeData.each { data -> 313 | data = data.value 314 | if (data.codeState != 'unknown') { 315 | def userApp = lockApp.findSlotUserApp(data.slot) 316 | para = "Slot ${data.slot}" 317 | if (data.code) { 318 | para = para + "\nCode: ${data.code}" 319 | } 320 | if (userApp) { 321 | para = para + userApp.getLockUserInfo(lockApp.lock) 322 | image = userApp.lockInfoPageImage(lockApp.lock) 323 | } else { 324 | image = 'http://images.lockmanager.io/app/v1/images/times-circle-o.png' 325 | } 326 | if (data.codeState == 'refresh') { 327 | para = para +'\nPending refresh...' 328 | } 329 | if (data.control) { 330 | para = para +"\nControl: ${data.control}" 331 | } 332 | paragraph para, image: image 333 | } 334 | } 335 | } 336 | } 337 | } 338 | section('Lock Settings') { 339 | def pinLength = lockApp.pinLength() 340 | def lockCodeSlots = lockApp.lockCodeSlots() 341 | if (pinLength) { 342 | paragraph "Required Length: ${pinLength}" 343 | } 344 | paragraph "Slot Count: ${lockCodeSlots}" 345 | } 346 | } else { 347 | section() { 348 | paragraph 'Error: Can\'t find lock!' 349 | } 350 | } 351 | } 352 | } 353 | 354 | def notificationPage() { 355 | dynamicPage(name: 'notificationPage', title: 'Global Notification Settings') { 356 | section { 357 | paragraph 'These settings will apply to all users. Settings on individual users will override these settings' 358 | 359 | input('recipients', 'contact', title: 'Send notifications to', submitOnChange: true, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/book.png') 360 | href(name: 'toAskAlexaPage', title: 'Ask Alexa', page: 'askAlexaPage', image: 'http://images.lockmanager.io/app/v1/images/Alexa.png') 361 | if (!recipients) { 362 | input(name: 'phone', type: 'text', title: 'Text This Number', description: 'Phone number', required: false, submitOnChange: true) 363 | paragraph 'For multiple SMS recipients, separate phone numbers with a semicolon(;)' 364 | input(name: 'notification', type: 'bool', title: 'Send A Push Notification', description: 'Notification', required: false, submitOnChange: true) 365 | } 366 | 367 | if (phone != null || notification || recipients) { 368 | input(name: 'notifyAccess', title: 'on User Entry', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 369 | input(name: 'notifyLock', title: 'on Lock', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 370 | input(name: 'notifyAccessStart', title: 'when granting access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/check-circle-o.png') 371 | input(name: 'notifyAccessEnd', title: 'when revoking access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/times-circle-o.png') 372 | } 373 | } 374 | section('Only During These Times (optional)') { 375 | input(name: 'notificationStartTime', type: 'time', title: 'Notify Starting At This Time', description: null, required: false) 376 | input(name: 'notificationEndTime', type: 'time', title: 'Notify Ending At This Time', description: null, required: false) 377 | } 378 | } 379 | } 380 | 381 | def helloHomePage() { 382 | dynamicPage(name: 'helloHomePage', title: 'Global Hello Home Settings (optional)') { 383 | def actions = location.helloHome?.getPhrases()*.label 384 | actions?.sort() 385 | section('Hello Home Phrases') { 386 | input(name: 'manualUnlockRoutine', title: 'On Manual Unlock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 387 | input(name: 'manualLockRoutine', title: 'On Manual Lock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 388 | 389 | input(name: 'codeUnlockRoutine', title: 'On Code Unlock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png' ) 390 | 391 | paragraph 'Supported on some locks:' 392 | input(name: 'codeLockRoutine', title: 'On Code Lock', type: 'enum', options: actions, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 393 | 394 | paragraph 'These restrictions apply to all the above:' 395 | input "userNoRunPresence", "capability.presenceSensor", title: "DO NOT run Actions if any of these are present:", multiple: true, required: false 396 | input "userDoRunPresence", "capability.presenceSensor", title: "ONLY run Actions if any of these are present:", multiple: true, required: false 397 | } 398 | } 399 | } 400 | 401 | def askAlexaPage() { 402 | dynamicPage(name: 'askAlexaPage', title: 'Ask Alexa Message Settings') { 403 | section('Que Messages with the Ask Alexa app') { 404 | paragraph 'These settings apply to all users. These settings are overridable on the user level' 405 | input(name: 'alexaAccess', title: 'on User Entry', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 406 | input(name: 'alexaLock', title: 'on Lock', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 407 | input(name: 'alexaAccessStart', title: 'when granting access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/check-circle-o.png') 408 | input(name: 'alexaAccessEnd', title: 'when revoking access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/times-circle-o.png') 409 | } 410 | section('Only During These Times (optional)') { 411 | input(name: 'alexaStartTime', type: 'time', title: 'Notify Starting At This Time', description: null, required: false) 412 | input(name: 'alexaEndTime', type: 'time', title: 'Notify Ending At This Time', description: null, required: false) 413 | } 414 | } 415 | } 416 | 417 | def keypadPage() { 418 | dynamicPage(name: 'keypadPage',title: 'Keypad Settings (optional)', install: true, uninstall: true) { 419 | def actions = location.helloHome?.getPhrases()*.label 420 | actions?.sort() 421 | section("Settings") { 422 | paragraph 'settings here are for all users. When any user enters their passcode, run these routines' 423 | input(name: 'armRoutine', title: 'Arm/Away routine', type: 'enum', options: actions, required: false, multiple: true) 424 | input(name: 'disarmRoutine', title: 'Disarm routine', type: 'enum', options: actions, required: false, multiple: true) 425 | input(name: 'stayRoutine', title: 'Arm/Stay routine', type: 'enum', options: actions, required: false, multiple: true) 426 | input(name: 'nightRoutine', title: 'Arm/Night routine', type: 'enum', options: actions, required: false, multiple: true) 427 | } 428 | } 429 | } 430 | 431 | def fancyString(listOfStrings) { 432 | listOfStrings.removeAll([null]) 433 | def fancify = { list -> 434 | return list.collect { 435 | def label = it 436 | if (list.size() > 1 && it == list[-1]) { 437 | label = "and ${label}" 438 | } 439 | label 440 | }.join(", ") 441 | } 442 | 443 | return fancify(listOfStrings) 444 | } 445 | 446 | def notificationPageDescription() { 447 | def parts = [] 448 | def msg = "" 449 | if (settings.phone) { 450 | parts << "SMS to ${phone}" 451 | } 452 | if (settings.recipients) { 453 | parts << 'Sent to Address Book' 454 | } 455 | if (settings.notification) { 456 | parts << 'Push Notification' 457 | } 458 | msg += fancyString(parts) 459 | parts = [] 460 | 461 | if (settings.notifyAccess) { 462 | parts << 'on entry' 463 | } 464 | if (settings.notifyLock) { 465 | parts << 'on lock' 466 | } 467 | if (settings.notifyAccessStart) { 468 | parts << 'when granting access' 469 | } 470 | if (settings.notifyAccessEnd) { 471 | parts << 'when revoking access' 472 | } 473 | if (settings.notificationStartTime) { 474 | parts << "starting at ${settings.notificationStartTime}" 475 | } 476 | if (settings.notificationEndTime) { 477 | parts << "ending at ${settings.notificationEndTime}" 478 | } 479 | if (parts.size()) { 480 | msg += ': ' 481 | msg += fancyString(parts) 482 | } 483 | return msg 484 | } 485 | 486 | def getLockAppById(id) { 487 | def lockApp = false 488 | def lockApps = getLockApps() 489 | if (lockApps) { 490 | def i = 0 491 | lockApps.each { app -> 492 | if (app.lock.id == id) { 493 | lockApp = app 494 | } 495 | } 496 | } 497 | return lockApp 498 | } 499 | 500 | def getLockAppByIndex(params) { 501 | def id = '' 502 | // Assign params to id. Sometimes parameters are double nested. 503 | if (params.id) { 504 | id = params.id 505 | } else if (params.params){ 506 | id = params.params.id 507 | } else if (state.lastLock) { 508 | id = state.lastLock 509 | } 510 | state.lastLock = id 511 | 512 | def lockApp = false 513 | def lockApps = getLockApps() 514 | if (lockApps) { 515 | def i = 0 516 | lockApps.each { app -> 517 | if (app.lock.id == state.lastLock) { 518 | lockApp = app 519 | } 520 | } 521 | } 522 | 523 | return lockApp 524 | } 525 | 526 | def availableSlots(selectedSlot) { 527 | def options = [] 528 | def userApps = getUserApps() 529 | def lockApps = getLockApps() 530 | def slotCount = 30 531 | def usedSlots = [] 532 | 533 | userApps.each { userApp -> 534 | def userSlot = userApp.userSlot.toInteger() 535 | // do not remove the currently selected slot 536 | if (selectedSlot?.toInteger() != userSlot) { 537 | usedSlots << userSlot 538 | } 539 | } 540 | 541 | // set slot count to the max available 542 | lockApps.each { lockApp -> 543 | def appSlotCount = lockApp.lockCodeSlots() 544 | // do not remove the currently selected slot 545 | if (appSlotCount > slotCount) { 546 | slotCount = appSlotCount 547 | } 548 | } 549 | 550 | (1..slotCount).each { slot-> 551 | if (usedSlots.contains(slot)) { 552 | // do nothing 553 | } else { 554 | options << ["${slot}": "Slot ${slot}"] 555 | } 556 | } 557 | return options 558 | } 559 | 560 | def keypadMatchingUser(usedCode){ 561 | def correctUser = false 562 | def userApps = getUserApps() 563 | userApps.each { userApp -> 564 | def code 565 | log.debug userApp.userCode 566 | if (userApp.isActiveKeypad()) { 567 | code = userApp.userCode.take(4) 568 | log.debug "code: ${code} used: ${usedCode}" 569 | if (code.toInteger() == usedCode.toInteger()) { 570 | correctUser = userApp 571 | } 572 | } 573 | } 574 | return correctUser 575 | } 576 | 577 | def findAssignedChildApp(lock, slot) { 578 | def childApp 579 | def userApps = getUserApps() 580 | userApps.each { child -> 581 | if (child.userSlot?.toInteger() == slot) { 582 | childApp = child 583 | } 584 | } 585 | return childApp 586 | } 587 | 588 | def getUserApps() { 589 | def childApps = [] 590 | def children = getChildApps() 591 | children.each { child -> 592 | if (child.theAppType() == 'user') { 593 | childApps.push(child) 594 | } 595 | } 596 | return childApps 597 | } 598 | 599 | def getKeypadApps() { 600 | def childApps = [] 601 | def children = getChildApps() 602 | children.each { child -> 603 | if (child.theAppType() == 'keypad') { 604 | childApps.push(child) 605 | } 606 | } 607 | return childApps 608 | } 609 | 610 | def getLockApps() { 611 | def childApps = [] 612 | def children = getChildApps() 613 | children.each { child -> 614 | if (child.theAppType() == 'lock') { 615 | childApps.push(child) 616 | } 617 | } 618 | return childApps 619 | } 620 | 621 | def setAccess() { 622 | def lockApps = getLockApps() 623 | lockApps.each { lockApp -> 624 | lockApp.setCodes() 625 | } 626 | } 627 | 628 | def locationHandler(evt) { 629 | setAccess() 630 | } 631 | 632 | def theNewChild() { 633 | return appType.toLowerCase() 634 | } 635 | 636 | def anyoneHome(sensors) { 637 | def result = false 638 | if(sensors.findAll { it?.currentPresence == "present" }) { 639 | result = true 640 | } 641 | result 642 | } 643 | 644 | def apiApp() { 645 | def app = false 646 | def children = getChildApps() 647 | children.each { child -> 648 | if (child.enableAPI) { 649 | app = child 650 | } 651 | } 652 | return app 653 | } 654 | 655 | def executeHelloPresenceCheck(routines) { 656 | if (userNoRunPresence && userDoRunPresence == null) { 657 | if (!anyoneHome(userNoRunPresence)) { 658 | location.helloHome.execute(routines) 659 | } 660 | } else if (userDoRunPresence && userNoRunPresence == null) { 661 | if (anyoneHome(userDoRunPresence)) { 662 | location.helloHome.execute(routines) 663 | } 664 | } else if (userDoRunPresence && userNoRunPresence) { 665 | if (anyoneHome(userDoRunPresence) && !anyoneHome(userNoRunPresence)) { 666 | location.helloHome.execute(routines) 667 | } 668 | } else { 669 | location.helloHome.execute(routines) 670 | } 671 | } 672 | 673 | def debuggerOn() { 674 | // needed for child apps 675 | return enableDebug 676 | } 677 | 678 | def theAppType() { 679 | if (parent) { 680 | return state.appType 681 | } else { 682 | return 'main' 683 | } 684 | } 685 | 686 | def debugger(message) { 687 | return log.debug(message) 688 | // if (parent) { 689 | // def doDebugger = parent.debuggerOn() 690 | // if (doDebugger) { 691 | // log.debug(message) 692 | // } 693 | // } else { 694 | // def doDebugger = debuggerOn() 695 | // if (enableDebug) { 696 | // return log.debug(message) 697 | // } 698 | // } 699 | } 700 | -------------------------------------------------------------------------------- /source/user.groovy: -------------------------------------------------------------------------------- 1 | def userInstalled() { 2 | log.debug "Installed with settings: ${settings}" 3 | userInitialize() 4 | } 5 | 6 | def userUpdated() { 7 | log.debug "Updated with settings: ${settings}" 8 | userInitialize() 9 | } 10 | 11 | def userInitialize() { 12 | // reset listeners 13 | unsubscribe() 14 | unschedule() 15 | 16 | // setup data 17 | initializeLockData() 18 | initializeLocks() 19 | 20 | // set listeners 21 | subscribeToSchedule() 22 | } 23 | 24 | def userUninstalled() { 25 | unschedule() 26 | 27 | // prompt locks to delete this user 28 | initializeLocks() 29 | } 30 | 31 | def subscribeToSchedule() { 32 | if (startTime) { 33 | // sechedule time of start! 34 | log.debug 'scheduling time start' 35 | schedule(startTime, 'scheduledStartTime') 36 | } 37 | if (endTime) { 38 | // sechedule time of end! 39 | log.debug 'scheduling time end' 40 | schedule(endTime, 'scheduledEndTime') 41 | } 42 | if (startDateTime()) { 43 | // schedule calendar start! 44 | log.debug 'scheduling calendar start' 45 | runOnce(startDateTime().format(smartThingsDateFormat(), timeZone()), 'calendarStart') 46 | } 47 | if (endDateTime()) { 48 | // schedule calendar end! 49 | log.debug 'scheduling calendar end' 50 | runOnce(endDateTime().format(smartThingsDateFormat(), timeZone()), 'calendarEnd') 51 | } 52 | } 53 | 54 | def scheduledStartTime() { 55 | parent.setAccess() 56 | } 57 | def scheduledEndTime() { 58 | parent.setAccess() 59 | } 60 | def calendarStart() { 61 | parent.setAccess() 62 | if (calStartPhrase) { 63 | location.helloHome.execute(calStartPhrase) 64 | } 65 | } 66 | def calendarEnd() { 67 | parent.setAccess() 68 | if (calEndPhrase) { 69 | location.helloHome.execute(calEndPhrase) 70 | } 71 | } 72 | 73 | def initializeLockData() { 74 | debugger('Initialize lock data for user.') 75 | def lockApps = parent.getLockApps() 76 | lockApps.each { lockApp -> 77 | def lockId = lockApp.lock.id 78 | if (state."lock${lockId}" == null) { 79 | state."lock${lockId}" = [:] 80 | state."lock${lockId}".enabled = true 81 | state."lock${lockId}".usage = 0 82 | } 83 | } 84 | } 85 | 86 | def initializeLocks() { 87 | debugger('User asking for lock init') 88 | def lockApps = parent.getLockApps() 89 | lockApps.each { lockApp -> 90 | lockApp.queSetupLockData() 91 | } 92 | } 93 | 94 | def incrementLockUsage(lockId) { 95 | // this is called by a lock app when this user 96 | // used their code to lock the door 97 | state."lock${lockId}".usage = state."lock${lockId}".usage + 1 98 | } 99 | 100 | def lockReset(lockId) { 101 | state."lock${lockId}".enabled = true 102 | state."lock${lockId}".disabledReason = '' 103 | def lockApp = parent.getLockAppById(lockId) 104 | lockApp.enableUser(userSlot) 105 | } 106 | 107 | def userLandingPage() { 108 | if (userName) { 109 | userMainPage() 110 | } else { 111 | userSetupPage() 112 | } 113 | } 114 | 115 | def userSetupPage() { 116 | dynamicPage(name: 'userSetupPage', title: 'Setup Lock', nextPage: 'userMainPage', uninstall: true) { 117 | section('User App Label') { 118 | label(title: "Name for App", required: true, image: 'http://images.lockmanager.io/app/v1/images/user.png') 119 | } 120 | section('Choose details for this user') { 121 | input(name: 'userName', type: 'text', title: 'Name for User', required: true) 122 | input(name: 'userCode', type: 'text', title: userCodeInputTitle(), required: false, defaultValue: settings.'userCode', refreshAfterSelection: true) 123 | input(name: 'userSlot', type: 'enum', options: parent.availableSlots(settings.userSlot), title: 'Select slot', required: true, refreshAfterSelection: true ) 124 | } 125 | } 126 | } 127 | 128 | def userMainPage() { 129 | //reset errors on each load 130 | dynamicPage(name: 'userMainPage', title: '', install: true, uninstall: true) { 131 | section('User Settings') { 132 | def usage = getAllLocksUsage() 133 | def text 134 | if (isActive()) { 135 | text = 'active' 136 | } else { 137 | text = 'inactive' 138 | } 139 | paragraph "${text}/${usage}" 140 | input(name: 'userCode', type: 'text', title: userCodeInputTitle(), required: false, defaultValue: settings.'userCode', refreshAfterSelection: true) 141 | input(name: 'userEnabled', type: 'bool', title: "User Enabled?", required: false, defaultValue: true, refreshAfterSelection: true) 142 | } 143 | section('Additional Settings') { 144 | def actions = location.helloHome?.getPhrases()*.label 145 | if (actions) { 146 | actions.sort() 147 | input name: 'userUnlockPhrase', type: 'enum', title: 'Hello Home Phrase on unlock', multiple: true, required: false, options: actions, refreshAfterSelection: true, image: 'http://images.lockmanager.io/app/v1/images/home.png' 148 | input name: 'userLockPhrase', type: 'enum', title: 'Hello Home Phrase on lock', description: 'Available on select locks only', multiple: true, required: false, options: actions, refreshAfterSelection: true, image: 'http://images.lockmanager.io/app/v1/images/home.png' 149 | 150 | input "userNoRunPresence", "capability.presenceSensor", title: "DO NOT run Actions if any of these are present:", multiple: true, required: false 151 | input "userDoRunPresence", "capability.presenceSensor", title: "ONLY run Actions if any of these are present:", multiple: true, required: false 152 | } 153 | input(name: 'burnAfterInt', title: 'How many uses before burn?', type: 'number', required: false, description: 'Blank or zero is infinite', image: 'http://images.lockmanager.io/app/v1/images/fire.png') 154 | href(name: 'toSchedulingPage', page: 'schedulingPage', title: 'Schedule (optional)', description: schedulingHrefDescription(), state: schedulingHrefDescription() ? 'complete' : '', image: 'http://images.lockmanager.io/app/v1/images/calendar.png') 155 | href(name: 'toNotificationPage', page: 'userNotificationPage', title: 'Notification Settings', description: userNotificationPageDescription(), state: userNotificationPageDescription() ? 'complete' : '', image: 'http://images.lockmanager.io/app/v1/images/bullhorn.png') 156 | href(name: 'toUserKeypadPage', page: 'userKeypadPage', title: 'Keypad Routines (optional)', image: 'http://images.lockmanager.io/app/v1/images/keypad.png') 157 | } 158 | section('Locks') { 159 | initializeLockData() 160 | def lockApps = parent.getLockApps() 161 | 162 | lockApps.each { app -> 163 | href(name: "toLockPage${app.lock.id}", page: 'userLockPage', params: [id: app.lock.id], description: lockPageDescription(app.lock.id), required: false, title: app.lock.label, image: lockPageImage(app.lock) ) 164 | } 165 | } 166 | section('Setup', hideable: true, hidden: true) { 167 | label(title: "Name for App", defaultValue: 'User: ' + userName, required: true, image: 'http://images.lockmanager.io/app/v1/images/user.png') 168 | input name: 'userName', type: "text", title: "Name for user", required: true, image: 'http://images.lockmanager.io/app/v1/images/user.png' 169 | input(name: "userSlot", type: "enum", options: parent.availableSlots(settings.userSlot), title: "Select slot", required: true, refreshAfterSelection: true ) 170 | } 171 | } 172 | } 173 | 174 | def userCodeInputTitle() { 175 | def title = 'Code 4-8 digits' 176 | def pinLength 177 | def lockApps = parent.getLockApps() 178 | lockApps.each { lockApp -> 179 | pinLength = lockApp.pinLength() 180 | if (pinLength) { 181 | title = "Code (Must be ${lockApp.lock.latestValue('pinLength')} digits)" 182 | } 183 | } 184 | return title 185 | } 186 | 187 | def lockPageImage(lock) { 188 | if (!state."lock${lock.id}".enabled || settings."lockDisabled${lock.id}") { 189 | return 'http://images.lockmanager.io/app/v1/images/ban.png' 190 | } else { 191 | return 'http://images.lockmanager.io/app/v1/images/lock.png' 192 | } 193 | } 194 | 195 | def lockInfoPageImage(lock) { 196 | if (!state."lock${lock.id}".enabled || settings."lockDisabled${lock.id}") { 197 | return 'http://images.lockmanager.io/app/v1/images/user-times.png' 198 | } else { 199 | return 'http://images.lockmanager.io/app/v1/images/user.png' 200 | } 201 | } 202 | 203 | def userLockPage(params) { 204 | dynamicPage(name:"userLockPage", title:"Lock Settings") { 205 | debugger('current params: ' + params) 206 | def lock = getLock(params) 207 | def lockApp = parent.getLockAppById(lock.id) 208 | def slotData = lockApp.slotData(userSlot) 209 | 210 | def usage = state."lock${lock.id}".usage 211 | 212 | debugger('found lock id?: ' + lock?.id) 213 | 214 | if (!state."lock${lock.id}".enabled) { 215 | section { 216 | paragraph "WARNING:\n\nThis user has been disabled.\n${state."lock${lock.id}".disabledReason}", image: 'http://images.lockmanager.io/app/v1/images/ban.png' 217 | href(name: 'toReEnableUserLockPage', page: 'reEnableUserLockPage', title: 'Reset User', description: 'Retry setting this user.', params: [id: lock.id], image: 'http://images.lockmanager.io/app/v1/images/refresh.png' ) 218 | } 219 | } 220 | section("${deviceLabel(lock)} settings for ${app.label}") { 221 | if (slotData.code) { 222 | paragraph "Lock is currently set to ${slotData.code}" 223 | } 224 | paragraph "User unlock count: ${usage}" 225 | if(slotData.attempts > 0) { 226 | paragraph "Lock set failed try ${slotData.attempts}/10" 227 | } 228 | input(name: "lockDisabled${lock.id}", type: 'bool', title: 'Disable lock for this user?', required: false, defaultValue: settings."lockDisabled${lock.id}", refreshAfterSelection: true, image: 'http://images.lockmanager.io/app/v1/images/ban.png' ) 229 | href(name: 'toLockResetPage', page: 'lockResetPage', title: 'Reset Lock', description: 'Reset lock data for this user.', params: [id: lock.id], image: 'http://images.lockmanager.io/app/v1/images/refresh.png' ) 230 | } 231 | } 232 | } 233 | 234 | def userKeypadPage() { 235 | dynamicPage(name: 'userKeypadPage',title: 'Keypad Settings (optional)', install: true, uninstall: true) { 236 | def actions = location.helloHome?.getPhrases()*.label 237 | actions?.sort() 238 | section("Settings") { 239 | paragraph 'settings here are for this user only. When this user enters their passcode, run these routines' 240 | input(name: 'armRoutine', title: 'Arm/Away routine', type: 'enum', options: actions, required: false, multiple: true) 241 | input(name: 'disarmRoutine', title: 'Disarm routine', type: 'enum', options: actions, required: false, multiple: true) 242 | input(name: 'stayRoutine', title: 'Arm/Stay routine', type: 'enum', options: actions, required: false, multiple: true) 243 | input(name: 'nightRoutine', title: 'Arm/Night routine', type: 'enum', options: actions, required: false, multiple: true) 244 | } 245 | } 246 | } 247 | 248 | def lockPageDescription(lock_id) { 249 | def usage = state."lock${lock_id}".usage 250 | def description = "Entries: ${usage} " 251 | if (!state."lock${lock_id}".enabled) { 252 | description += '// ERROR//DISABLED' 253 | } 254 | if (settings."lockDisabled${lock_id}") { 255 | description += ' DISABLED' 256 | } 257 | description 258 | } 259 | 260 | def reEnableUserLockPage(params) { 261 | // do reset 262 | def lock = getLock(params) 263 | lockReset(lock.id) 264 | 265 | dynamicPage(name:'reEnableUserLockPage', title:'User re-enabled') { 266 | section { 267 | paragraph 'Lock has been reset.' 268 | } 269 | section { 270 | href(name: 'toMainPage', title: 'Back To Setup', page: 'userMainPage') 271 | } 272 | } 273 | } 274 | 275 | def lockResetPage(params) { 276 | // do reset 277 | def lock = getLock(params) 278 | 279 | state."lock${lock.id}".usage = 0 280 | lockReset(lock.id) 281 | 282 | dynamicPage(name:'lockResetPage', title:'Lock reset') { 283 | section { 284 | paragraph 'Lock has been reset.' 285 | } 286 | section { 287 | href(name: 'toMainPage', title: 'Back To Setup', page: 'userMainPage') 288 | } 289 | } 290 | } 291 | 292 | def schedulingPage() { 293 | dynamicPage(name: 'schedulingPage', title: 'Rules For Access Scheduling') { 294 | 295 | section { 296 | href(name: 'toCalendarPage', title: 'Calendar', page: 'calendarPage', description: calendarHrefDescription(), state: calendarHrefDescription() ? 'complete' : '') 297 | } 298 | 299 | section { 300 | 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) 301 | } 302 | section { 303 | input(name: 'activeModes', title: 'Allow Access only when in any of these modes', type: 'mode', required: false, multiple: true, submitOnChange: true) 304 | } 305 | section { 306 | input(name: 'startTime', type: 'time', title: 'Start Time', description: null, required: false) 307 | input(name: 'endTime', type: 'time', title: 'End Time', description: null, required: false) 308 | } 309 | } 310 | } 311 | 312 | def calendarPage() { 313 | dynamicPage(name: "calendarPage", title: "Calendar Access") { 314 | section() { 315 | paragraph "Enter each field carefully." 316 | } 317 | def actions = location.helloHome?.getPhrases()*.label 318 | section("Start Date") { 319 | input name: "startDay", type: "number", title: "Day", required: false 320 | input name: "startMonth", type: "number", title: "Month", required: false 321 | input name: "startYear", type: "number", description: "Format(yyyy)", title: "Year", required: false 322 | input name: "calStartTime", type: "time", title: "Start Time", description: null, required: false 323 | if (actions) { 324 | actions.sort() 325 | input name: "calStartPhrase", type: "enum", title: "Hello Home Phrase", multiple: true, required: false, options: actions, refreshAfterSelection: true 326 | } 327 | } 328 | section("End Date") { 329 | input name: "endDay", type: "number", title: "Day", required: false 330 | input name: "endMonth", type: "number", title: "Month", required: false 331 | input name: "endYear", type: "number", description: "Format(yyyy)", title: "Year", required: false 332 | input name: "calEndTime", type: "time", title: "End Time", description: null, required: false 333 | if (actions) { 334 | actions.sort() 335 | input name: "calEndPhrase", type: "enum", title: "Hello Home Phrase", multiple: true, required: false, options: actions, refreshAfterSelection: true 336 | } 337 | } 338 | } 339 | } 340 | 341 | def userNotificationPage() { 342 | dynamicPage(name: 'userNotificationPage', title: 'Notification Settings') { 343 | 344 | section { 345 | if (phone == null && !notification && !recipients) { 346 | input(name: 'muteUser', title: 'Mute this user?', type: 'bool', required: false, submitOnChange: true, defaultValue: false, description: 'Mute notifications for this user if notifications are set globally', image: 'http://images.lockmanager.io/app/v1/images/bell-slash-o.png') 347 | } 348 | if (!muteUser) { 349 | input('recipients', 'contact', title: 'Send notifications to', submitOnChange: true, required: false, multiple: true, image: 'http://images.lockmanager.io/app/v1/images/book.png') 350 | href(name: 'toAskAlexaPage', title: 'Ask Alexa', page: 'userAskAlexaPage', image: 'http://images.lockmanager.io/app/v1/images/Alexa.png') 351 | if (!recipients) { 352 | input(name: 'phone', type: 'text', title: 'Text This Number', description: 'Phone number', required: false, submitOnChange: true) 353 | paragraph 'For multiple SMS recipients, separate phone numbers with a semicolon(;)' 354 | input(name: 'notification', type: 'bool', title: 'Send A Push Notification', description: 'Notification', required: false, submitOnChange: true) 355 | } 356 | if (phone != null || notification || recipients) { 357 | input(name: 'notifyAccess', title: 'on User Entry', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 358 | input(name: 'notifyLock', title: 'on Lock', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 359 | input(name: 'notifyAccessStart', title: 'when granting access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/check-circle-o.png') 360 | input(name: 'notifyAccessEnd', title: 'when revoking access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/times-circle-o.png') 361 | } 362 | } 363 | } 364 | if (!muteUser) { 365 | section('Only During These Times (optional)') { 366 | input(name: 'notificationStartTime', type: 'time', title: 'Notify Starting At This Time', description: null, required: false) 367 | input(name: 'notificationEndTime', type: 'time', title: 'Notify Ending At This Time', description: null, required: false) 368 | } 369 | } 370 | } 371 | } 372 | 373 | def userAskAlexaPage() { 374 | dynamicPage(name: 'userAskAlexaPage', title: 'Ask Alexa Message Settings') { 375 | section('Que Messages with the Ask Alexa app') { 376 | input(name: 'alexaAccess', title: 'on User Entry', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/unlock-alt.png') 377 | input(name: 'alexaLock', title: 'on Lock', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/lock.png') 378 | input(name: 'alexaAccessStart', title: 'when granting access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/check-circle-o.png') 379 | input(name: 'alexaAccessEnd', title: 'when revoking access', type: 'bool', required: false, image: 'http://images.lockmanager.io/app/v1/images/times-circle-o.png') 380 | } 381 | section('Only During These Times (optional)') { 382 | input(name: 'alexaStartTime', type: 'time', title: 'Notify Starting At This Time', description: null, required: false) 383 | input(name: 'alexaEndTime', type: 'time', title: 'Notify Ending At This Time', description: null, required: false) 384 | } 385 | } 386 | } 387 | 388 | def timeZone() { 389 | def zone 390 | if(location.timeZone) { 391 | zone = location.timeZone 392 | } else { 393 | zone = TimeZone.getDefault() 394 | } 395 | return zone 396 | } 397 | 398 | public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } 399 | 400 | public humanReadableStartDate() { 401 | new Date().parse(smartThingsDateFormat(), startTime).format('h:mm a', timeZone(startTime)) 402 | } 403 | public humanReadableEndDate() { 404 | new Date().parse(smartThingsDateFormat(), endTime).format('h:mm a', timeZone(endTime)) 405 | } 406 | 407 | def readableDateTime(date) { 408 | new Date().parse(smartThingsDateFormat(), date.format(smartThingsDateFormat(), timeZone())).format("EEE, MMM d yyyy 'at' h:mma", timeZone()) 409 | } 410 | 411 | 412 | def getLockUsage(lock_id) { 413 | return state."lock${lock_id}".usage 414 | } 415 | 416 | def getAllLocksUsage() { 417 | def usage = 0 418 | def lockApps = parent.getLockApps() 419 | lockApps.each { lockApp -> 420 | if (state."lock${lockApp.lock.id}"?.usage) { 421 | usage = usage + state."lock${lockApp.lock.id}".usage 422 | } 423 | } 424 | return usage 425 | } 426 | 427 | def calendarHrefDescription() { 428 | def dateStart = startDateTime() 429 | def dateEnd = endDateTime() 430 | if (dateEnd && dateStart) { 431 | def startReadableTime = readableDateTime(dateStart) 432 | def endReadableTime = readableDateTime(dateEnd) 433 | return "Accessible from ${startReadableTime} until ${endReadableTime}" 434 | } else if (!dateEnd && dateStart) { 435 | def startReadableTime = readableDateTime(dateStart) 436 | return "Accessible on ${startReadableTime}" 437 | } else if (dateEnd && !dateStart){ 438 | def endReadableTime = readableDateTime(dateEnd) 439 | return "Accessible until ${endReadableTime}" 440 | } 441 | } 442 | 443 | def userNotificationPageDescription() { 444 | def parts = [] 445 | def msg = '' 446 | if (settings.phone) { 447 | parts << "SMS to ${phone}" 448 | } 449 | if (settings.notification) { 450 | parts << 'Push Notification' 451 | } 452 | if (settings.recipients) { 453 | parts << 'Sent to Address Book' 454 | } 455 | if (parts.size()) { 456 | msg += fancyString(parts) 457 | } 458 | parts = [] 459 | 460 | if (settings.notifyAccess) { 461 | parts << 'on entry' 462 | } 463 | if (settings.notifyLock) { 464 | parts << 'on lock' 465 | } 466 | if (settings.notifyAccessStart) { 467 | parts << 'when granting access' 468 | } 469 | if (settings.notifyAccessEnd) { 470 | parts << 'when revoking access' 471 | } 472 | if (settings.notificationStartTime) { 473 | parts << "starting at ${settings.notificationStartTime}" 474 | } 475 | if (settings.notificationEndTime) { 476 | parts << "ending at ${settings.notificationEndTime}" 477 | } 478 | if (parts.size()) { 479 | msg += ': ' 480 | msg += fancyString(parts) 481 | } 482 | if (muteUser) { 483 | msg = 'User Muted' 484 | } 485 | return msg 486 | } 487 | 488 | def deviceLabel(device) { 489 | return device.label ?: device.name 490 | } 491 | 492 | def schedulingHrefDescription() { 493 | def descriptionParts = [] 494 | if (startDateTime() || endDateTime()) { 495 | descriptionParts << calendarHrefDescription() 496 | } 497 | if (days) { 498 | descriptionParts << "On ${fancyString(days)}," 499 | } 500 | if ((andOrTime != null) || (activeModes == null)) { 501 | if (startTime) { 502 | descriptionParts << "at ${humanReadableStartDate()}" 503 | } 504 | if (endTime) { 505 | descriptionParts << "until ${humanReadableEndDate()}" 506 | } 507 | } 508 | if (activeModes) { 509 | descriptionParts << "and when ${location.name} enters any of '${activeModes}' modes" 510 | } 511 | if (descriptionParts.size() <= 1) { 512 | // locks will be in the list no matter what. No rules are set if only locks are in the list 513 | return null 514 | } 515 | return descriptionParts.join(" ") 516 | } 517 | 518 | def isActive(lockId) { 519 | if ( 520 | isUserEnabled() && 521 | isValidCode() && 522 | isNotBurned() && 523 | isEnabled(lockId) && 524 | userLockEnabled(lockId) && 525 | isCorrectDay() && 526 | isInCalendarRange() && 527 | isCorrectMode() && 528 | isInScheduledTime() 529 | ) { 530 | return true 531 | } else { 532 | return false 533 | } 534 | } 535 | 536 | def isActiveKeypad() { 537 | if ( 538 | isUserEnabled() && 539 | isValidCode() && 540 | isNotBurned() && 541 | isCorrectDay() && 542 | isInCalendarRange() && 543 | isCorrectMode() && 544 | isInScheduledTime() 545 | ) { 546 | return true 547 | } else { 548 | return false 549 | } 550 | } 551 | 552 | def isUserEnabled() { 553 | if (userEnabled == null || userEnabled) { //If true or unset, return true 554 | return true 555 | } else { 556 | return false 557 | } 558 | } 559 | 560 | def isValidCode() { 561 | if (userCode?.isNumber()) { 562 | return true 563 | } else { 564 | return false 565 | } 566 | } 567 | 568 | def isNotBurned() { 569 | if (burnAfterInt == null || burnAfterInt == 0) { 570 | return true // is not a burnable user 571 | } else { 572 | def totalUsage = getAllLocksUsage() 573 | if (totalUsage >= burnAfterInt) { 574 | // usage number is met! 575 | return false 576 | } else { 577 | // dont burn this user yet 578 | return true 579 | } 580 | } 581 | } 582 | 583 | def isEnabled(lockId) { 584 | if (state."lock${lockId}" == null) { 585 | return true 586 | } else if (state."lock${lockId}".enabled == null) { 587 | return true 588 | } else { 589 | return state."lock${lockId}".enabled 590 | } 591 | } 592 | 593 | def userLockEnabled(lockId) { 594 | def lockDisabled = settings."lockDisabled${lockId}" 595 | if (lockDisabled == null) { 596 | return true 597 | } else if (lockDisabled == true) { 598 | return false 599 | } else { 600 | return true 601 | } 602 | } 603 | 604 | def isCorrectDay() { 605 | def today = new Date().format("EEEE", timeZone()) 606 | if (!days || days.contains(today)) { 607 | // if no days, assume every day 608 | return true 609 | } 610 | return false 611 | } 612 | 613 | def isInCalendarRange() { 614 | def dateStart = startDateTime() 615 | def dateEnd = endDateTime() 616 | def now = rightNow().getTime() 617 | if (dateStart && dateEnd) { 618 | // There's both an end time, and a start time. Allow access between them. 619 | if (dateStart.getTime() < now && dateEnd.getTime() > now) { 620 | // It's in calendar times 621 | return true 622 | } else { 623 | // It's not in calendar times 624 | return false 625 | } 626 | } else if (dateEnd && !dateStart) { 627 | // There's a end time, but no start time. Allow access until end 628 | if (dateStart.getTime() > now) { 629 | // It's after the start time 630 | return true 631 | } else { 632 | // It's before the start time 633 | return false 634 | } 635 | } else if (!dateEnd && dateStart) { 636 | // There's a start time, but no end time. Allow access after start 637 | if (dateStart.getTime() < now) { 638 | // It's after the start time 639 | return true 640 | } else { 641 | // It's before the start time 642 | return false 643 | } 644 | } else { 645 | // there's no calendar 646 | return true 647 | } 648 | } 649 | 650 | def isCorrectMode() { 651 | if (activeModes) { 652 | // mode check is on 653 | if (activeModes.contains(location.mode)) { 654 | // we're in a right mode 655 | return true 656 | } else { 657 | // we're in the wrong mode 658 | return false 659 | } 660 | } else { 661 | // mode check is off 662 | return true 663 | } 664 | } 665 | 666 | def isInScheduledTime() { 667 | def now = rightNow() 668 | 669 | if (startTime && endTime) { 670 | def start = timeToday(startTime) 671 | def stop = timeToday(endTime) 672 | 673 | // there's both start time and end time 674 | if (start.before(now) && stop.after(now)){ 675 | // It's between the times 676 | return true 677 | } else { 678 | // It's not between the times 679 | return false 680 | } 681 | } else if (startTime && !endTime){ 682 | // there's a start time, but no end time 683 | def start = timeToday(startTime) 684 | if (start.before(now)) { 685 | // it's after start time 686 | return true 687 | } else { 688 | //it's before start time 689 | return false 690 | } 691 | } else if (!startTime && endTime) { 692 | // there's an end time but no start time 693 | def stop = timeToday(endTime) 694 | if (stop.after(now)) { 695 | // it's still before end time 696 | return true 697 | } else { 698 | // it's after end time 699 | return false 700 | } 701 | } else { 702 | // there are no times 703 | return true 704 | } 705 | } 706 | 707 | def startDateTime() { 708 | if (startDay && startMonth && startYear && calStartTime) { 709 | def time = new Date().parse(smartThingsDateFormat(), calStartTime).format("'T'HH:mm:ss.SSSZ", timeZone(calStartTime)) 710 | return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${startYear}-${startMonth}-${startDay}${time}") 711 | } else { 712 | // Start Date Time not set 713 | return false 714 | } 715 | } 716 | 717 | def endDateTime() { 718 | if (endDay && endMonth && endYear && calEndTime) { 719 | def time = new Date().parse(smartThingsDateFormat(), calEndTime).format("'T'HH:mm:ss.SSSZ", timeZone(calEndTime)) 720 | return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${endYear}-${endMonth}-${endDay}${time}") 721 | } else { 722 | // End Date Time not set 723 | return false 724 | } 725 | } 726 | 727 | def rightNow() { 728 | def now = new Date().format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", timeZone()) 729 | return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", now) 730 | } 731 | 732 | def getLockById(params) { 733 | return parent.locks.find{it.id == id} 734 | } 735 | 736 | def getLock(params) { 737 | def id = '' 738 | // Assign params to id. Sometimes parameters are double nested. 739 | debugger('params: ' + params) 740 | debugger('last: ' + state.lastLock) 741 | if (params?.id) { 742 | id = params.id 743 | } else if (params?.params){ 744 | id = params.params.id 745 | } 746 | def lockApp = parent.getLockAppById(id) 747 | if (!lockApp) { 748 | lockApp = parent.getLockAppById(state.lastLock) 749 | } 750 | 751 | if (lockApp) { 752 | state.lastLock = lockApp.lock.id 753 | return lockApp.lock 754 | } else { 755 | return false 756 | } 757 | } 758 | 759 | def userNotificationSettings() { 760 | def userSettings = false 761 | if (phone != null || notification || muteUser || recipients) { 762 | // user has it's own settings! 763 | userSettings = true 764 | } 765 | return userSettings 766 | } 767 | 768 | def sendUserMessage(msg) { 769 | if (userNotificationSettings()) { 770 | checkIfNotifyUser(msg) 771 | } else { 772 | checkIfNotifyGlobal(msg) 773 | } 774 | } 775 | 776 | def checkIfNotifyUser(msg) { 777 | if (notificationStartTime != null && notificationEndTime != null) { 778 | def start = timeToday(notificationStartTime) 779 | def stop = timeToday(notificationEndTime) 780 | def now = rightNow() 781 | if (start.before(now) && stop.after(now)){ 782 | sendMessageViaUser(msg) 783 | } 784 | } else { 785 | sendMessageViaUser(msg) 786 | } 787 | } 788 | 789 | def checkIfNotifyGlobal(msg) { 790 | if (parent.notificationStartTime != null && parent.notificationEndTime != null) { 791 | def start = timeToday(parent.notificationStartTime) 792 | def stop = timeToday(parent.notificationEndTime) 793 | def now = rightNow() 794 | if (start.before(now) && stop.after(now)){ 795 | sendMessageViaParent(msg) 796 | } 797 | } else { 798 | sendMessageViaParent(msg) 799 | } 800 | } 801 | 802 | def sendMessageViaParent(msg) { 803 | if (parent.recipients) { 804 | sendNotificationToContacts(msg, parent.recipients) 805 | } else { 806 | if (parent.notification) { 807 | sendPush(msg) 808 | } else { 809 | sendNotificationEvent(msg) 810 | } 811 | if (parent.phone) { 812 | if ( parent.phone.indexOf(";") > 1){ 813 | def phones = parent.phone.split(";") 814 | for ( def i = 0; i < phones.size(); i++) { 815 | sendSms(phones[i], msg) 816 | } 817 | } 818 | else { 819 | sendSms(parent.phone, msg) 820 | } 821 | } 822 | } 823 | } 824 | 825 | def sendMessageViaUser(msg) { 826 | if (recipients) { 827 | sendNotificationToContacts(msg, recipients) 828 | } else { 829 | if (notification) { 830 | sendPush(msg) 831 | } else { 832 | sendNotificationEvent(msg) 833 | } 834 | if (phone) { 835 | if ( phone.indexOf(";") > 1){ 836 | def phones = phone.split(";") 837 | for ( def i = 0; i < phones.size(); i++) { 838 | sendSms(phones[i], msg) 839 | } 840 | } 841 | else { 842 | sendSms(phone, msg) 843 | } 844 | } 845 | } 846 | } 847 | 848 | def disableAndSetReason(lockID, reason) { 849 | state."lock${lockID}".enabled = false 850 | state."lock${lockID}".disabledReason = reason 851 | } 852 | 853 | def disableLock(lockID) { 854 | state."lock${lockID}".enabled = false 855 | state."lock${lockID}".disabledReason = 'Controller failed to set user code.' 856 | } 857 | 858 | def enableLock(lockID) { 859 | state."lock${lockID}".enabled = true 860 | state."lock${lockID}".disabledReason = null 861 | } 862 | 863 | def disabledReason() { 864 | state."lock${lockID}".disabledReason 865 | } 866 | 867 | def getLockUserInfo(lock) { 868 | def para = "\n${app.label}" 869 | if (settings."lockDisabled${lock.id}") { 870 | para += " DISABLED" 871 | } 872 | def usage = state."lock${lock.id}".usage 873 | para += " // Entries: ${usage}" 874 | if (!state."lock${lock.id}".enabled) { 875 | def reason = state."lock${lock.id}".disabledReason 876 | para += "\n ${reason}" 877 | } 878 | para 879 | } 880 | 881 | // User Ask Alexa 882 | 883 | def userAlexaSettings() { 884 | if (alexaAccess || alexaLock || alexaAccessStart || alexaAccessEnd || alexaStartTime || alexaEndTime) { 885 | // user has it's own settings! 886 | return true 887 | } 888 | // user doesn't ! 889 | return false 890 | } 891 | 892 | def askAlexaUser(msg) { 893 | if (userAlexaSettings()) { 894 | checkIfAlexaUser(msg) 895 | } else { 896 | checkIfAlexaGlobal(msg) 897 | } 898 | } 899 | 900 | def checkIfAlexaUser(message) { 901 | if (!muteUser) { 902 | if (alexaStartTime != null && alexaEndTime != null) { 903 | def start = timeToday(alexaStartTime) 904 | def stop = timeToday(alexaEndTime) 905 | def now = rightNow() 906 | if (start.before(now) && stop.after(now)){ 907 | sendAskAlexaUser(message) 908 | } 909 | } else { 910 | sendAskAlexaUser(message) 911 | } 912 | } 913 | } 914 | 915 | def checkIfAlexaGlobal(message) { 916 | if (parent.alexaStartTime != null && parent.alexaEndTime != null) { 917 | def start = timeToday(parent.alexaStartTime) 918 | def stop = timeToday(parent.alexaEndTime) 919 | def now = rightNow() 920 | if (start.before(now) && stop.after(now)){ 921 | sendAskAlexaUser(message) 922 | } 923 | } else { 924 | sendAskAlexaUser(message) 925 | } 926 | } 927 | 928 | def sendAskAlexaUser(message) { 929 | sendLocationEvent(name: 'AskAlexaMsgQueue', 930 | value: 'LockManager/User', 931 | isStateChange: true, 932 | descriptionText: message, 933 | unit: "User//${userName}") 934 | } 935 | --------------------------------------------------------------------------------