/** * Virtual Basic Keypad * * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * * Change History: * * Date Who What * ---- --- ---- * 10-06-20 mbarone initial release * 10-06-20 mbarone added attribute Notification, which updates with a bad code input message if enabled. this can be used to trigger a RM notification if you watch this attribute for *changed* * 03-10-21 mbarone removed the SecurityKeypad capability as this was causing issues when present with the LockCodes capability and is not needed for the functionality of this addon * 11-04-21 mbarone fixed security issue found by @arnb, and added an option to enable or disable.. enabled by default */ import groovy.json.JsonSlurper import groovy.json.JsonOutput def setVersion(){ state.name = "Virtual Basic Keypad" state.version = "1.0.4" } metadata { definition (name: "Virtual Basic Keypad", namespace: "mbarone", author: "mbarone", importUrl: "https://raw.githubusercontent.com/michaelbarone/hubitat/master/drivers/virtualBasicKeypad.groovy") { //commented out as this is not required for this addon to function. It may be wanted by some users, but this causes issues when both the SecurityKeypad and LockCodes capability are enabled which breaks the ability to set lock codes on this device. //enable only if you need for your setup //capability "SecurityKeypad" capability "LockCodes" capability "PushableButton" capability "Momentary" capability "Actuator" capability "TamperAlert" capability "Contact Sensor" } preferences { input name: "optEncrypt", type: "bool", title: "Enable lockCode encryption", defaultValue: false, description: "" input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true input name: "cancelAlertsOnDisarm", type: "bool", title: "Cancel Alerts on Disarm", defaultValue: true input name: "src", type: "text", title: "iFrame Url", required: false, description: "paste the direct dashboard url for this keypad's dashboard" input name: "noCodeRequired", type: "text", title: "No Code Required", required: false, description: "These HSM commands require no code. Separate multiple by comma: armAway,armHome" input name: "noCodeRequiredDisarmedOnly", type: "bool", title: "HSM Disarmed required for No Code commands", required: true,defaultValue: true, description: "When 'On', HSM will need to be 'disarmed' to execute commands that do not require a code." input name: "armDelaySeconds", type: "number", title: "Command Delay", required: true, defaultValue: 0, description: "number of seconds before sending command after successful code entry" input name: "armDelaySecondsGroup", type: "text", title: "Commands to Delay", required: false, description: "These HSM commands will be delayed by the set Command Delay. Separate multiple by comma: armAway,armHome" input name: "alertOnFailedAttempts", type: "number", title: "Failed Attempts before Notify", required: true, defaultValue: 0, description: "Number of Failed Code entries before the 'Notification' attribute is updated with the failed notice. 0 = disabled" } attribute "Details","string" attribute "lastCodeName","string" attribute "InputDisplay","string" attribute "Keypad", "text" attribute "Notification", "text" } def logsOff(){ log.warn "debug logging disabled..." device.updateSetting("logEnable",[value:"false",type:"bool"]) } def installed() { clearCode() resetInputDisplay() updated() sendEvent(name:"maxCodes",value:20) sendEvent(name:"codeLength",value:4) state.panicPressCount = 0 state.code = "" } def updated() { clearCode() close() clearDetails() state.armDelaySeconds = armDelaySeconds state.alertOnFailedAttemptsCount = 0 sendEvent(name: "Notification", value: "") if(src){ sendEvent(name: "Keypad", value: "
") } if (logEnable) { log.warn "debug logging enabled..." runIn(1800,logsOff) } } def updateInputDisplay(text){ sendEvent(name: "InputDisplay", value: text, displayed: false) } def commandHSM(action){ if(checkInputCode(action)){ if (logEnable) log.debug "commandHSM - ${action}" // cancel HSM alerts if set and mode has disarm in name if(cancelAlertsOnDisarm == true && action.toLowerCase().contains("disarm")){ cancelHSMAlerts() } if(state.armDelaySeconds > 0 && armDelaySecondsGroup.contains(action)){ def timeLeft = state.armDelaySeconds state.armDelaySeconds.times{ timeLeft = timeLeft - 1 updateInputDisplay("Setting ${action} in ${timeLeft} seconds") pauseExecution(1000) } } sendLocationEvent(name: "hsmSetArm", value: action, descriptionText: "Keypad Event ${action}") updateInputDisplay("Success. Executing ${action}") } else { updateInputDisplay("Input Denied") } timeoutClearCode() } def timeoutClearCode(){ unschedule(clearCode) clearCode() unschedule(resetInputDisplay) runIn(5,resetInputDisplay) } def checkInputCode(action){ if (logEnable) log.debug "checkInputCode - ${action}" def codeAccepted = false if(noCodeRequired.contains(action) && (!noCodeRequiredDisarmedOnly || noCodeRequiredDisarmedOnly && location.hsmStatus == 'disarmed')) { codeAccepted = true sendEvent(name:"UserInput", value: "Success", descriptionText: "No code was required to execute " + action, displayed: true) if (logEnable) log.debug "${action} executed with no entered code" return codeAccepted } if(state.code == ""){ if (logEnable) log.debug "code is blank, returning false" return codeAccepted } Object lockCode = lockCodes.find{ it.value.code.toInteger() == state.code.toInteger() } if (lockCode){ Map data = ["${lockCode.key}":lockCode.value] String descriptionText = "${device.displayName} executed ${action} by ${lockCode.value.name}" if (txtEnable) log.info "${descriptionText}" //if (optEncrypt) data = encrypt(JsonOutput.toJson(data)) //sendEvent(name:"lock",value:"unlocked",descriptionText: descriptionText, type:"physical",data:data, isStateChange: true) sendEvent(name:"lastCodeName", value: lockCode.value.name, descriptionText: descriptionText, isStateChange: true, displayed: true) sendEvent(name:"UserInput", value: "Success", descriptionText: descriptionText + " " + action, displayed: true) codeAccepted = true if (logEnable) log.debug "${action} code accepted" state.alertOnFailedAttemptsCount = 0 } else { state.alertOnFailedAttemptsCount = state.alertOnFailedAttemptsCount + 1 unschedule(clearAlertOnFailedAttemptsCount) runIn(600,clearAlertOnFailedAttemptsCount) if(alertOnFailedAttempts > 0 && state.alertOnFailedAttemptsCount >= alertOnFailedAttempts){ unschedule(clearAlertOnFailedAttemptsCount) state.alertOnFailedAttemptsCount = 0 sendEvent(name: "Notification", value: "There have been ${alertOnFailedAttempts} failed code attempts.") unschedule(clearNotification) runIn(10,clearNotification) } sendEvent(name:"UserInput", value: "Failed", descriptionText: "Code input Failed for ${action} ("+state.code+")", displayed: true) if (logEnable) log.debug "${action} code NOT accepted" } return codeAccepted } def clearDetails(){ sendEvent(name:"Details", value:"Running Normally.") } def clearNotification(){ sendEvent(name:"Notification", value:"") } def clearAlertOnFailedAttemptsCount(){ state.alertOnFailedAttemptsCount = 0 } def clearCode(){ if (logEnable) log.debug "clearCode" state.panicPressCount = 0 state.code = "" state.codeInput = "Enter Code" //state.codeInput = "HSM: ${location.hsmStatus} | Mode: ${location.mode} Enter Code" } def resetInputDisplay(){ if (logEnable) log.debug "resetInputDisplay" updateInputDisplay(state.codeInput) } def panicAlarm(){ state.panicPressCount = state.panicPressCount + 1 if (logEnable) log.debug "panicAlarm press "+ state.panicPressCount if(state.panicPressCount<2){ updateInputDisplay("Press Panic Again to Trigger") } else { // use contact and tamper: tamperAlert() open() updateInputDisplay("Panic Alarm Triggered") state.panicPressCount = 0 } unschedule(clearPanicCount) runIn(5,clearPanicCount) } def clearPanicCount(){ if (logEnable) log.debug "clearPanicCount" unschedule(clearCode) clearCode() unschedule(resetInputDisplay) resetInputDisplay() } def cancelHSMAlerts(){ if (logEnable) log.debug "cancelHSMAlerts" sendLocationEvent(name: "hsmSetArm", value: "cancelAlerts", descriptionText: "Keypad Event HSM cancelAlerts") } def push(evt) { if(evt==null){ return } unschedule(clearPanicCount) unschedule(clearCode) unschedule(resetInputDisplay) if(evt=="Clear"){ clearCode() resetInputDisplay() return } if(evt=="Panic"){ panicAlarm() return } if(evt.isNumber()){ state.code = state.code+""+evt if(!state.codeInput.contains("*")){ state.codeInput = "*" } else { state.codeInput = state.codeInput+"*" } updateInputDisplay(state.codeInput) unschedule(clearCode) runIn(30,clearCode) unschedule(resetInputDisplay) runIn(30,resetInputDisplay) return } commandHSM(evt) return } def open() { if (logEnable) log.debug "open() called" sendEvent(name: "contact", value: "open") runIn(5,close) } def close() { if (logEnable) log.debug "close() called" sendEvent(name: "contact", value: "closed") } def tamperAlert(){ if (logEnable) log.debug "tamperAlert() called" sendEvent(name: "tamper", value: "detected", isStateChange : true) runIn(5,clearTamperAlert) } def clearTamperAlert(){ if (logEnable) log.debug "clearTamperAlert() called" sendEvent(name: "tamper", value: "clear", isStateChange : true) } void setCodeLength(length){ /* on install/configure/change name value codeLength length */ String descriptionText = "${device.displayName} codeLength set to ${length}" if (txtEnable) log.info "${descriptionText}" sendEvent(name:"codeLength",value:length,descriptionText:descriptionText) } void setCode(codeNumber, code, name = null) { /* on sucess name value data notes codeChanged added | changed [":["code":"", "name":""]] default name to code # lockCodes JSON map of all lockCode */ if (codeNumber == null || codeNumber == 0 || code == null) return if (logEnable) log.debug "setCode- ${codeNumber}" if (!name) name = "code #${codeNumber}" Map lockCodes = getLockCodes() Map codeMap = getCodeMap(lockCodes,codeNumber) if (!changeIsValid(lockCodes,codeMap,codeNumber,code,name)) return Map data = [:] String value if (logEnable) log.debug "setting code ${codeNumber} to ${code} for lock code name ${name}" if (codeMap) { if (codeMap.name != name || codeMap.code != code) { codeMap = ["name":"${name}", "code":"${code}"] lockCodes."${codeNumber}" = codeMap data = ["${codeNumber}":codeMap] if (optEncrypt) data = encrypt(JsonOutput.toJson(data)) value = "changed" } } else { codeMap = ["name":"${name}", "code":"${code}"] data = ["${codeNumber}":codeMap] lockCodes << data if (optEncrypt) data = encrypt(JsonOutput.toJson(data)) value = "added" } updateLockCodes(lockCodes) sendEvent(name:"codeChanged",value:value,data:data, isStateChange: true) } void deleteCode(codeNumber) { /* on sucess name value data codeChanged deleted [":["code":"", "name":""]] lockCodes [":["code":"", "name":""],":["code":"", "name":""]] */ Map codeMap = getCodeMap(lockCodes,"${codeNumber}") if (codeMap) { Map result = [:] //build new lockCode map, exclude deleted code lockCodes.each{ if (it.key != "${codeNumber}"){ result << it } } updateLockCodes(result) Map data = ["${codeNumber}":codeMap] //encrypt lockCode data is requested if (optEncrypt) data = encrypt(JsonOutput.toJson(data)) sendEvent(name:"codeChanged",value:"deleted",data:data, isStateChange: true) } } //helpers Boolean changeIsValid(lockCodes,codeMap,codeNumber,code,name){ //validate proposed lockCode change Boolean result = true Integer maxCodeLength = device.currentValue("codeLength")?.toInteger() ?: 4 Integer maxCodes = device.currentValue("maxCodes")?.toInteger() ?: 20 Boolean isBadLength = code.size() > maxCodeLength Boolean isBadCodeNum = maxCodes < codeNumber if (lockCodes) { List nameSet = lockCodes.collect{ it.value.name } List codeSet = lockCodes.collect{ it.value.code } if (codeMap) { nameSet = nameSet.findAll{ it != codeMap.name } codeSet = codeSet.findAll{ it != codeMap.code } } Boolean nameInUse = name in nameSet Boolean codeInUse = code in codeSet if (nameInUse || codeInUse) { if (nameInUse) { log.warn "changeIsValid:false, name:${name} is in use:${ lockCodes.find{ it.value.name == "${name}" } }" } if (codeInUse) { log.warn "changeIsValid:false, code:${code} is in use:${ lockCodes.find{ it.value.code == "${code}" } }" } result = false } } if (isBadLength || isBadCodeNum) { if (isBadLength) { log.warn "changeIsValid:false, length of code ${code} does not match codeLength of ${maxCodeLength}" } if (isBadCodeNum) { log.warn "changeIsValid:false, codeNumber ${codeNumber} is larger than maxCodes of ${maxCodes}" } result = false } return result } Map getCodeMap(lockCodes,codeNumber){ Map codeMap = [:] Map lockCode = lockCodes?."${codeNumber}" if (lockCode) { codeMap = ["name":"${lockCode.name}", "code":"${lockCode.code}"] } return codeMap } Map getLockCodes() { /* on a real lock we would fetch these from the response to a userCode report request */ String lockCodes = device.currentValue("lockCodes") Map result = [:] if (lockCodes) { //decrypt codes if they're encrypted if (lockCodes[0] == "{") result = new JsonSlurper().parseText(lockCodes) else result = new JsonSlurper().parseText(decrypt(lockCodes)) } return result } void getCodes() { //no op } void updateLockCodes(lockCodes){ /* whenever a code changes we update the lockCodes event */ if (logEnable) log.debug "updateLockCodes: ${lockCodes}" String strCodes = JsonOutput.toJson(lockCodes) if (optEncrypt) { strCodes = encrypt(strCodes) } sendEvent(name:"lockCodes", value:strCodes, isStateChange:true) } void updateEncryption(){ /* resend lockCodes map when the encryption option is changed */ String lockCodes = device.currentValue("lockCodes") //encrypted or decrypted if (lockCodes){ if (optEncrypt && lockCodes[0] == "{") { //resend encrypted sendEvent(name:"lockCodes",value: encrypt(lockCodes)) } else if (!optEncrypt && lockCodes[0] != "{") { //resend decrypted sendEvent(name:"lockCodes",value: decrypt(lockCodes)) } } }