diff --git a/devicetypes/adamheinmiller/utilitech-glass-break-sensor.src/utilitech-glass-break-sensor.groovy b/devicetypes/adamheinmiller/utilitech-glass-break-sensor.src/utilitech-glass-break-sensor.groovy new file mode 100644 index 0000000..42e07d2 --- /dev/null +++ b/devicetypes/adamheinmiller/utilitech-glass-break-sensor.src/utilitech-glass-break-sensor.groovy @@ -0,0 +1,171 @@ +/** + * Utilitech Glass Break Sensor + * + * Author: Adam Heinmiller + * + * Date: 2014-11-09 + */ + +metadata +{ + definition (namespace: "adamheinmiller", name: "Utilitech Glass Break Sensor", author: "Adam Heinmiller") + { + capability "Contact Sensor" + capability "Battery" + + fingerprint deviceId:"0xA102", inClusters:"0x20, 0x9C, 0x80, 0x82, 0x84, 0x87, 0x85, 0x72, 0x86, 0x5A" + } + + simulator + { + status "Activate Sensor": "command: 9C02, payload: 26 00 FF 00 00" + status "Reset Sensor": "command: 9C02, payload: 26 00 00 00 00" + + status "Battery Status 25%": "command: 8003, payload: 19" + status "Battery Status 50%": "command: 8003, payload: 32" + status "Battery Status 75%": "command: 8003, payload: 4B" + status "Battery Status 100%": "command: 8003, payload: 64" + } + + tiles + { + standardTile("contact", "device.contact", width: 2, height: 2) + { + state "closed", label: '${name}', icon: "st.contact.contact.closed", backgroundColor: "#79b821" + state "open", label: '${name}', icon: "st.contact.contact.open", backgroundColor: "#FF0000" + } + + valueTile("battery", "device.battery", inactiveLabel: false, decoration: "flat") + { + state "battery", label:'${currentValue}% battery', unit:"" + } + + main "contact" + details(["contact", "battery"]) + } +} + +def installed() +{ + + updated() +} + +def updated() +{ + +} + +def getTimestamp() +{ + return new Date().time +} + +def getBatteryLevel(int pNewLevel) +{ + def bl = state.BatteryLevel ?: [pNewLevel, pNewLevel, pNewLevel] as int[] + + def iAvg = 4 + ((int)(pNewLevel + bl[0] + bl[1] + bl[2]) / 4) + + state.BatteryLevel = [pNewLevel, bl[0], bl[1]] + + //log.debug "New Bat Level: ${iAvg - (iAvg % 5)}, $state.BatteryLevel" + + return iAvg - (iAvg % 5) +} + + + +def parse(String description) +{ + def result = [] + + // "0x20, 0x9C, 0x80, 0x82, 0x84, 0x87, 0x85, 0x72, 0x86, 0x5A" + + def cmd = zwave.parse(description) + + + //log.debug "Parse: Desc: $description, CMD: $cmd" + + if (cmd) + { + result << zwaveEvent(cmd) + } + + return result +} + + +def zwaveEvent(physicalgraph.zwave.commands.wakeupv2.WakeUpNotification cmd) +{ + logCommand(cmd) + + def result = [] + + + result << response(zwave.wakeUpV2.wakeUpNoMoreInformation()) + + return result +} + +def zwaveEvent(physicalgraph.zwave.commands.hailv1.Hail cmd) +{ + logCommand(cmd) + + def result = [] + + return result +} + +def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicReport cmd) +{ + logCommand(cmd) + + def result = [] + + return result +} + +def zwaveEvent(physicalgraph.zwave.commands.batteryv1.BatteryReport cmd) +{ + logCommand(cmd) + + def result = [name: "battery", unit: "%", value: getBatteryLevel(cmd.batteryLevel)] + + return createEvent(result) +} + + +def zwaveEvent(physicalgraph.zwave.commands.sensoralarmv1.SensorAlarmReport cmd) +{ + logCommand(cmd) + + + def result = [name: "contact"] + + if (cmd.sensorState == 0) + { + result += [value: "closed", descriptionText: "${device.displayName} has reset"] + } + else if (cmd.sensorState == 255) + { + result += [value: "open", descriptionText: "${device.displayName} detected broken glass"] + } + + + return createEvent(result) +} + + +def zwaveEvent(physicalgraph.zwave.Command cmd) +{ + logCommand("**Unhandled**: $cmd") + + return createEvent([descriptionText: "Unhandled: ${device.displayName}: ${cmd}", displayed: false]) +} + + +def logCommand(cmd) +{ + log.debug "Device Command: $cmd" +} \ No newline at end of file diff --git a/devicetypes/ethayer/user-lock-manager.src/user-lock-manager.groovy b/devicetypes/ethayer/user-lock-manager.src/user-lock-manager.groovy new file mode 100644 index 0000000..5300752 --- /dev/null +++ b/devicetypes/ethayer/user-lock-manager.src/user-lock-manager.groovy @@ -0,0 +1,1545 @@ +/** + * User Lock Manager v4.1.5 + * + * Copyright 2015 Erik Thayer + * Keypad support added by BLebson + * + */ +definition( + name: "User Lock Manager", + namespace: "ethayer", + author: "Erik Thayer", + description: "This app allows you to change, delete, and schedule user access.", + category: "Safety & Security", + iconUrl: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanager.png", + iconX2Url: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanagerx2.png", + iconX3Url: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanagerx3.png") + + import groovy.json.JsonSlurper + import groovy.json.JsonBuilder + +preferences { + page(name: "rootPage") + page(name: "setupPage") + page(name: "userPage") + page(name: "notificationPage") + page(name: "onUnlockPage") + page(name: "schedulingPage") + page(name: "calendarPage") + page(name: "resetAllCodeUsagePage") + page(name: "resetCodeUsagePage") + page(name: "reEnableUserPage") + page(name: "infoPage") + page(name: "keypadPage") + page(name: "infoRefreshPage") + page(name: "lockInfoPage") +} + +def rootPage() { + //reset errors on each load + dynamicPage(name: "rootPage", title: "", install: true, uninstall: true) { + + section("Which Locks?") { + input "theLocks","capability.lockCodes", title: "Select Locks", required: true, multiple: true, submitOnChange: true + } + + if (theLocks) { + initalizeLockData() + + section { + input name: "maxUsers", title: "Number of users", type: "number", multiple: false, refreshAfterSelection: true, submitOnChange: true + href(name: "toSetupPage", title: "User Settings", page: "setupPage", description: setupPageDescription(), state: setupPageDescription() ? "complete" : "") + href(name: "toInfoPage", page: "infoPage", title: "Lock Info") + href(name: "toKeypadPage", page: "keypadPage", title: "Keypad Info (optional)") + href(name: "toNotificationPage", page: "notificationPage", title: "Notification Settings", description: notificationPageDescription(), state: notificationPageDescription() ? "complete" : "") + href(name: "toSchedulingPage", page: "schedulingPage", title: "Schedule (optional)", description: schedulingHrefDescription(), state: schedulingHrefDescription() ? "complete" : "") + href(name: "toOnUnlockPage", page: "onUnlockPage", title: "Global Hello Home") + } + section { + label(title: "Label this SmartApp", required: false, defaultValue: "") + } + } + } +} + +def setupPage() { + dynamicPage(name:"setupPage", title:"User Settings") { + if (maxUsers > 0) { + section('Users') { + (1..maxUsers).each { user-> + if (!state."userState${user}") { + //there's no values, so reset + resetCodeUsage(user) + } + if (settings."userCode${user}" && settings."userSlot${user}") { + getConflicts(settings."userSlot${user}") + } + href(name: "toUserPage${user}", page: "userPage", params: [number: user], required: false, description: userHrefDescription(user), title: userHrefTitle(user), state: userPageState(user) ) + } + } + section { + href(name: "toResetAllCodeUsage", title: "Reset Code Usage", page: "resetAllCodeUsagePage", description: "Tap to reset") + } + } else { + section("Users") { + paragraph "Users are set to zero. Please go back to the main page and change the number of users to at least 1." + } + } + } +} + +def userPage(params) { + dynamicPage(name:"userPage", title:"User Settings") { + def i = getUser(params); + + if (!state."userState${i}".enabled) { + section { + paragraph "WARNING:\n\nThis user has been disabled.\nReason: ${state."userState${i}".disabledReason}" + href(name: "toreEnableUserPage", title: "Reset User", page: "reEnableUserPage", params: [number: i], description: "Tap to reset") + } + } + if (settings."userCode${i}" && settings."userSlot${i}") { + def conflict = getConflicts(settings."userSlot${i}") + if (conflict.has_conflict) { + section("Conflicts:") { + theLocks.each { lock-> + if (conflict."lock${lock.id}" && conflict."lock${lock.id}".conflicts != []) { + paragraph "${lock.displayName} slot ${fancyString(conflict."lock${lock.id}".conflicts)}" + } + } + } + } + } + section("Code #${i}") { + input(name: "userName${i}", type: "text", title: "Name for User", defaultValue: settings."userName${i}") + def title = "Code (4 to 8 digits)" + theLocks.each { lock-> + if (lock.hasAttribute('pinLength')) { + title = "Code (Must be ${lock.latestValue('pinLength')} digits)" + } + } + input(name: "userCode${i}", type: "text", title: title, required: false, defaultValue: settings."userCode${i}", refreshAfterSelection: true) + input(name: "userSlot${i}", type: "number", title: "Slot (1 through 30)", defaultValue: preSlectedCode(i)) + } + section { + input(name: "dontNotify${i}", title: "Mute entry notification?", type: "bool", required: false, defaultValue: settings."dontNotify${i}") + input(name: "burnCode${i}", title: "Burn after use?", type: "bool", required: false, defaultValue: settings."burnCode${i}") + input(name: "userEnabled${i}", title: "Enabled?", type: "bool", required: false, defaultValue: settings."userEnabled${i}") + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + if (hhPhrases) { + hhPhrases.sort() + input name: "userHomePhrases${i}", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, defaultValue: settings."userHomePhrases${i}", refreshAfterSelection: true + input "userNoRunPresence${i}", "capability.presenceSensor", title: "Don't run Actions if any of these are present:", multiple: true, required: false, defaultValue: settings."userNoRunPresence${i}" || false + input "userDoRunPresence${i}", "capability.presenceSensor", title: "Run Actions only if any of these are present:", multiple: true, required: false, defaultValue: settings."userDoRunPresence${i}" || false + } + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + href(name: "toResetCodeUsagePage", title: "Reset Code Usage", page: "resetCodeUsagePage", params: [number: i], description: "Tap to reset") + } + } +} + +def preSlectedCode(i) { + if (settings."userSlot${i}" != null) { + return settings."userSlot${i}" + } else { + return i + } +} + +def notificationPage() { + dynamicPage(name: "notificationPage", title: "Notification Settings") { + + section { + input(name: "phone", type: "text", title: "Text This Number", description: "Phone number", required: false, submitOnChange: true) + paragraph "For multiple SMS recipients, separate phone numbers with a semicolon(;)" + input(name: "notification", type: "bool", title: "Send A Push Notification", description: "Notification", required: false, submitOnChange: true) + if (phone != null || notification || sendevent) { + input(name: "notifyAccess", title: "on User Entry", type: "bool", required: false) + input(name: "notifyLock", title: "on Lock", type: "bool", required: false) + input(name: "notifyAccessStart", title: "when granting access", type: "bool", required: false) + input(name: "notifyAccessEnd", title: "when revoking access", type: "bool", required: false) + } + } + + section("Only During These Times (optional)") { + input(name: "notificationStartTime", type: "time", title: "Notify Starting At This Time", description: null, required: false) + input(name: "notificationEndTime", type: "time", title: "Notify Ending At This Time", description: null, required: false) + } + } +} + +def schedulingPage() { + dynamicPage(name: "schedulingPage", title: "Rules For Access Scheduling") { + if (!days) { + section { + href(name: "toCalendarPage", title: "Calendar", page: "calendarPage", description: calendarHrefDescription(), state: calendarHrefDescription() ? "complete" : "") + } + } + if (!startDay && !startMonth && !startYear && !endDay && !endMonth && !endYear) { + section { + input(name: "days", type: "enum", title: "Allow User Access On These Days", description: "Every day", required: false, multiple: true, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], submitOnChange: true) + } + section { + input(name: "modeStart", title: "Allow Access when in this mode", type: "mode", required: false, mutliple: false, submitOnChange: true) + } + section { + if (modeStart) { + input "andOrTime", "enum", title: "[And/Or] at a set time?", metadata:[values:["and", "or"]], required: false, submitOnChange: true + } + if ((modeStart == null) || andOrTime) { + input(name: "startTime", type: "time", title: "Start Time", description: null, required: false) + input(name: "endTime", type: "time", title: "End Time", description: null, required: false) + } + } + } + } +} + +def calendarPage() { + dynamicPage(name: "calendarPage", title: "Calendar Access") { + section() { + paragraph "This page is for advanced users only. You must enter each field carefully." + paragraph "Calendar use does not support daily grant/deny OR Modes. You cannot both have a date here, and allow access only on certain days/modes." + } + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + section("Start Date") { + input name: "startDay", type: "number", title: "Day", required: false + input name: "startMonth", type: "number", title: "Month", required: false + input name: "startYear", type: "number", description: "Format(yyyy)", title: "Year", required: false + input name: "startTime", type: "time", title: "Start Time", description: null, required: false + if (hhPhrases) { + hhPhrases.sort() + input name: "calStartPhrase", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, refreshAfterSelection: true + } + } + section("End Date") { + input name: "endDay", type: "number", title: "Day", required: false + input name: "endMonth", type: "number", title: "Month", required: false + input name: "endYear", type: "number", description: "Format(yyyy)", title: "Year", required: false + input name: "endTime", type: "time", title: "End Time", description: null, required: false + if (hhPhrases) { + hhPhrases.sort() + input name: "calEndPhrase", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, refreshAfterSelection: true + } + } + } +} + +def onUnlockPage() { + dynamicPage(name:"onUnlockPage", title:"Global Actions (Any Code)") { + section("Actions") { + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + if (hhPhrases) { + hhPhrases.sort() + input name: "homePhrases", type: "enum", title: "Home Mode Phrase", multiple: true, required: false, options: hhPhrases, refreshAfterSelection: true, submitOnChange: true + if (homePhrases) { + input "noRunPresence", "capability.presenceSensor", title: "Don't run Actions if any of these are present:", multiple: true, required: false + input "doRunPresence", "capability.presenceSensor", title: "Run Actions only if any of these are present:", multiple: true, required: false + input name: "manualUnlock", title: "Initiate phrase on manual unlock also?", type: "bool", defaultValue: false, refreshAfterSelection: true + input(name: "modeIgnore", title: "Do not run Routine when in this mode", type: "mode", required: false, mutliple: false) + } + } + } + } +} + +def resetCodeUsagePage(params) { + def i = getUser(params) + // do reset + resetCodeUsage(i) + + dynamicPage(name:"resetCodeUsagePage", title:"User Usage Reset") { + section { + paragraph "User code usage has been reset." + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + } + } +} + +def resetAllCodeUsagePage() { + // do resetAll + resetAllCodeUsage() + dynamicPage(name:"resetAllCodeUsagePage", title:"User Settings") { + section { + paragraph "All user code usages have been reset." + } + section("Users") { + href(name: "toSetupPage", title: "Back to Users", page: "setupPage") + href(name: "toRootPage", title: "Main Page", page: "rootPage") + } + } +} + +def reEnableUserPage(params) { + // do reset + def i = getUser(params) + enableUser(i) + lockErrorLoopReset() + dynamicPage(name:"reEnableUserPage", title:"User re-enabled") { + section { + paragraph "User has been enabled." + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + } + } +} + +def getUser(params) { + def i = 1 + // Assign params to i. Sometimes parameters are double nested. + if (params.number) { + i = params.number + } else if (params.params){ + i = params.params.number + } else if (state.lastUser) { + i = state.lastUser + } + + //Make sure i is a round number, not a float. + if ( ! i.isNumber() ) { + i = i.toInteger(); + } else if ( i.isNumber() ) { + i = Math.round(i * 100) / 100 + } + state.lastUser = i + return i +} + +def getLock(params) { + def id = '' + // Assign params to id. Sometimes parameters are double nested. + if (params.id) { + id = params.id + } else if (params.params){ + id = params.params.id + } else if (state.lastLock) { + id = state.lastLock + } + + state.lastLock = id + return theLocks.find{it.id == id} +} + +def infoPage() { + dynamicPage(name:"infoPage", title:"Lock Info") { + section() { + href(name: "toInfoRefreshPage", page: "infoRefreshPage", title: "Refresh Lock Data", description: 'Tap to refresh') + } + section("Locks") { + if (theLocks) { + def i = 0 + theLocks.each { lock-> + i++ + href(name: "toLockInfoPage${i}", page: "lockInfoPage", params: [id: lock.id], required: false, title: lock.displayName ) + } + } + } + } +} + +def infoRefreshPage() { + dynamicPage(name:"infoRefreshPage", title:"Lock Info") { + section() { + manualPoll() + paragraph "Lock info refreshing soon." + href(name: "toInfoPage", page: "infoPage", title: "Back to Lock Info") + } + } +} + +def lockInfoPage(params) { + dynamicPage(name:"lockInfoPage", title:"Lock Info") { + + def lock = getLock(params) + if (lock) { + section("${lock.displayName}") { + if (state."lock${lock.id}".codes != null) { + def i = 0 + def pass = '' + state."lock${lock.id}".codes.each { code-> + i++ + pass = state."lock${lock.id}".codes."slot${i}" + paragraph "Slot ${i}\nCode: ${pass}" + } + } else { + paragraph "No Lock data received yet. Requires custom device driver. Will be populated on next poll event." + } + } + } + } +} + + +def keypadPage() { + dynamicPage(name: "keypadPage",title: "Keypad Settings (optional)") { + section("Settings") { + // TODO: put inputs here + input(name: "keypad", title: "Keypad", type: "capability.lockCodes", multiple: true, required: false) + input(name: "keypadstatus", title: "Send status to keypad?", type: "bool", multiple: false, required: true, defaultValue: true) + } + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + hhPhrases?.sort() + section("Routines", hideable: true, hidden: true) { + input(name: "armRoutine", title: "Arm/Away routine", type: "enum", options: hhPhrases, required: false) + input(name: "disarmRoutine", title: "Disarm routine", type: "enum", options: hhPhrases, required: false) + input(name: "stayRoutine", title: "Arm/Stay routine", type: "enum", options: hhPhrases, required: false) + input(name: "nightRoutine", title: "Arm/Night routine", type: "enum", options: hhPhrases, required: false) + input(name: "armDelay", title: "Arm Delay (in seconds)", type: "number", required: false) + input(name: "notifyIncorrectPin", title: "Notify you when incorrect code is used?", type: "bool", required: false) + } + } +} + +public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } + +public humanReadableStartDate() { + new Date().parse(smartThingsDateFormat(), startTime).format("h:mm a", timeZone(startTime)) +} +public humanReadableEndDate() { + new Date().parse(smartThingsDateFormat(), endTime).format("h:mm a", timeZone(endTime)) +} + +def manualPoll() { + theLocks.poll() +} + +def getConflicts(i) { + def currentCode = settings."userCode${i}" + def currentSlot = settings."userSlot${i}" + def conflict = [:] + conflict.has_conflict = false + + + theLocks.each { lock-> + if (state."lock${lock.id}".codes) { + conflict."lock${lock.id}" = [:] + conflict."lock${lock.id}".conflicts = [] + def ind = 0 + state."lock${lock.id}".codes.each { code -> + ind++ + if (currentSlot?.toInteger() != ind.toInteger() && !isUnique(currentCode, state."lock${lock.id}".codes."slot${ind}")) { + conflict.has_conflict = true + state."userState${i}".enabled = false + state."userState${i}".disabledReason = "Code Conflict Detected" + conflict."lock${lock.id}".conflicts << ind + } + } + } + } + + return conflict +} + +def isUnique(newInt, oldInt) { + + if (newInt == null || oldInt == null) { + // if either number is null, break here. + return true + } + + if (!newInt.isInteger() || !oldInt.isInteger()) { + // number is not an integer, can't check. + return true + } + + def newArray = [] + def oldArray = [] + def result = true + + def i = 0 + // Get a normalized sequence, at the same length + newInt.toString().toList().collect { + i++ + if (i <= oldInt.length()) { + newArray << normalizeNumber(it.toInteger()) + } + } + + i = 0 + oldInt.toString().toList().collect { + i++ + if (i <= oldInt.length()) { + oldArray << normalizeNumber(it.toInteger()) + } + } + + i = 0 + newArray.each { num-> + i++ + if (newArray.join() == oldArray.join()) { + // The normalized numbers are the same! + result = false + } + } + return result +} + +def normalizeNumber(number) { + def result = null + // RULE: Since some locks share buttons, make sure unique. + // Even locks with 10-keys follow this rule! (annoyingly) + switch (number) { + case [1,2]: + result = 1 + break + case [3,4]: + result = 2 + break + case [5,6]: + result = 3 + break + case [7,8]: + result = 4 + break + case [9,0]: + result = 5 + break + } + return result +} + +def setupPageDescription(){ + def parts = [] + for (int i = 1; i <= settings.maxUsers; i++) { + parts << settings."userName${i}" + } + return fancyString(parts) +} + +def notificationPageDescription() { + def parts = [] + def msg = "" + if (settings.phone) { + parts << "SMS to ${phone}" + } + if (settings.sendevent) { + parts << "Event Notification" + } + if (settings.notification) { + parts << "Push Notification" + } + msg += fancyString(parts) + parts = [] + + if (settings.notifyAccess) { + parts << "on entry" + } + if (settings.notifyLock) { + parts << "on lock" + } + if (settings.notifyAccessStart) { + parts << "when granting access" + } + if (settings.notifyAccessEnd) { + parts << "when revoking access" + } + if (settings.notificationStartTime) { + parts << "starting at ${settings.notificationStartTime}" + } + if (settings.notificationEndTime) { + parts << "ending at ${settings.notificationEndTime}" + } + if (parts.size()) { + msg += ": " + msg += fancyString(parts) + } + return msg +} + +def calendarHrefDescription() { + def dateStart = startDateTime() + def dateEnd = endDateTime() + if (dateEnd && dateStart) { + def startReadableTime = readableDateTime(dateStart) + def endReadableTime = readableDateTime(dateEnd) + return "Accessible from ${startReadableTime} until ${endReadableTime}" + } else if (!dateEnd && dateStart) { + def startReadableTime = readableDateTime(dateStart) + return "Accessible on ${startReadableTime}" + } else if (dateEnd && !dateStart){ + def endReadableTime = readableDateTime(dateEnd) + return "Accessible until ${endReadableTime}" + } +} + +def readableDateTime(date) { + new Date().parse(smartThingsDateFormat(), date.format(smartThingsDateFormat(), location.timeZone)).format("EEE, MMM d yyyy 'at' h:mma", location.timeZone) +} + +def userHrefTitle(i) { + def title = "User ${i}" + if (settings."userName${i}") { + title = settings."userName${i}" + } + return title +} + +def userHrefDescription(i) { + def uc = settings."userCode${i}" + def us = settings."userSlot${i}" + def usage = state."userState${i}".usage + def description = "" + if (us != null) { + description += "Slot: ${us}" + } + if (uc != null) { + description += " / ${uc}" + if(settings."burnCode${i}") { + description += ' [Single Use]' + } + } + if (usage != null) { + description += " [Usage: ${usage}]" + } + return description +} + +def userPageState(i) { + if (settings."userCode${i}" && userIsEnabled(i)) { + if (settings."burnCode${i}") { + if (state."userState${i}".usage > 0) { + return 'incomplete' + } else { + return 'complete' + } + } else { + return 'complete' + } + + } else if (settings."userCode${i}" && !settings."userEnabled${i}") { + return 'incomplete' + } else { + return 'incomplete' + } +} + +def userIsEnabled(i) { + if (settings."userEnabled${i}" && (settings."userCode${i}" != null) && (state."userState${i}".enabled != false)) { + return true + } else { + return false + } +} + +def fancyDeviceString(devices = []) { + fancyString(devices.collect { deviceLabel(it) }) +} + +def deviceLabel(device) { + return device.label ?: device.name +} + +def fancyString(listOfStrings) { + listOfStrings.removeAll([null]) + def fancify = { list -> + return list.collect { + def label = it + if (list.size() > 1 && it == list[-1]) { + label = "and ${label}" + } + label + }.join(", ") + } + + return fancify(listOfStrings) +} + +def schedulingHrefDescription() { + if (startDateTime() || endDateTime()) { + calendarHrefDescription() + } else { + def descriptionParts = [] + if (days) { + descriptionParts << "On ${fancyString(days)}," + } + + descriptionParts << "${fancyDeviceString(theLocks)} will be accessible" + if ((andOrTime != null) || (modeStart == null)) { + if (startTime) { + descriptionParts << "at ${humanReadableStartDate()}" + } + if (endTime) { + descriptionParts << "until ${humanReadableEndDate()}" + } + } + + if (modeStart) { + if (startTime && andOrTime) { + descriptionParts << andOrTime + } + descriptionParts << "when ${location.name} enters '${modeStart}' mode" + } + + if (descriptionParts.size() <= 1) { + // locks will be in the list no matter what. No rules are set if only locks are in the list + return null + } + return descriptionParts.join(" ") + } +} + +def installed() { + log.debug "Installing 'Locks' with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updating 'Locks' with settings: ${settings}" + initialize() +} + +private initialize() { + unsubscribe() + unschedule() + if (startTime && !startDateTime()) { + log.debug "scheduling access routine to run at ${startTime}" + schedule(startTime, "reconcileCodesStart") + } else if (startDateTime()) { + // There's a start date, so let's run then + log.debug "scheduling RUNONCE start" + runOnce(startDateTime().format(smartThingsDateFormat(), location.timeZone), "reconcileCodesStart") + } + + if (endTime && !endDateTime()) { + log.debug "scheduling access denial routine to run at ${endTime}" + schedule(endTime, "reconcileCodesEnd") + } else if (endDateTime()) { + // There's a end date, so let's run then + log.debug "scheduling RUNONCE end" + runOnce(endDateTime().format(smartThingsDateFormat(), location.timeZone), "reconcileCodesEnd") + } + + subscribe(location, locationHandler) + + subscribe(theLocks, "codeReport", codereturn) + subscribe(theLocks, "lock", codeUsed) + subscribe(theLocks, "reportAllCodes", pollCodeReport, [filterEvents:false]) + if (keypad) { + subscribe(location,"alarmSystemStatus",alarmStatusHandler) + subscribe(keypad,"codeEntered",codeEntryHandler) + } + + revokeDisabledUsers() + reconcileCodes() + lockErrorLoopReset() + initalizeLockData() + + log.debug "state: ${state}" +} + +def resetAllCodeUsage() { + for (int i = 1; i <= settings.maxUsers; i++) { + lockErrorLoopReset() + resetCodeUsage(i) + } + log.debug "reseting all code usage" +} + +def resetCodeUsage(i) { + if(state."userState${i}" == null) { + state."userState${i}" = [:] + state."userState${i}".enabled = true + } + state."userState${i}".usage = 0 +} + +def enableUser(i) { + state."userState${i}".enabled = true +} + +def initalizeLockData() { + theLocks.each { lock-> + if (state."lock${lock.id}" == null) { + state."lock${lock.id}" = [:] + } + } +} + +def lockErrorLoopReset() { + state.error_loop_count = 0 + theLocks.each { lock-> + if (state."lock${lock.id}" == null) { + state."lock${lock.id}" = [:] + } + state."lock${lock.id}".error_loop = false + } +} + + +def locationHandler(evt) { + log.debug "locationHandler evt: ${evt.value}" + if (modeStart) { + reconcileCodes() + } +} + +def reconcileCodes() { + if (isAbleToStart()) { + grantAccess() + } else { + revokeAccess() + } +} + +def reconcileCodesStart() { + // schedule start of reconcileCodes + reconcileCodes() + if (calStartPhrase) { + location.helloHome.execute(calStartPhrase) + } +} + +def reconcileCodesEnd() { + // schedule end of reconcileCodes + reconcileCodes() + if (calEndPhrase) { + location.helloHome.execute(calEndPhrase) + } +} + +def isAbleToStart() { + def dateStart = startDateTime() + def dateEnd = endDateTime() + + if (dateStart || dateEnd) { + // calendar schedule above all + return checkCalendarSchedule(dateStart, dateEnd) + } else if (modeStart || startTime || endTime || days) { + // No calendar set, check daily schedule + if (isCorrectDay()) { + // it's the right day + checkDailySchedule() + } else { + // it's the wrong day + return false + } + } else { + // no schedule + return true + } +} + +def checkDailySchedule() { + if (andOrTime && modeStart && (isCorrectMode() || isInScheduledTime())) { + // in correct mode or time with and/or switch + if (andOrTime == 'and') { + // must be both + if (isCorrectMode() && isInScheduledTime()) { + // is both + return true + } else { + // is not both + return false + } + } else { + // could be either + if (isCorrectMode() || isInScheduledTime()) { + // it is either mode or time + return true + } else { + // is not either mode or time + return false + } + } + } else { + // Allow either mode or time, no andOrTime is set + if (isCorrectMode() || isInScheduledTime()) { + // it is either mode or time + return true + } else { + // is not either mode or time + return false + } + } +} + +def checkCalendarSchedule(dateStart, dateEnd) { + def now = rightNow().getTime() + if (dateStart && !dateEnd) { + // There's a start time, but no end time. Allow access after start + if (dateStart.getTime() > now) { + // It's after the start time + return true + } else { + // It's before the start time + return false + } + + } else if (dateEnd && !dateStart) { + // There's a end time, but no start time. Allow access until end + if (dateStart.getTime() > now) { + // It's after the start time + return true + } else { + // It's before the start time + return false + } + + } else { + // There's both an end time, and a start time. Allow access between them. + if (dateStart.getTime() < now && dateEnd.getTime() > now) { + // It's in calendar times + return true + } else { + // It's not in calendar times + return false + } + } +} + +def isCorrectMode() { + if (modeStart) { + // mode check is on + if (location.mode == modeStart) { + // we're in the right one mode + return true + } else { + // we're in the wrong mode + return false + } + } else { + // mode check is off + return false + } +} + +def isInScheduledTime() { + def now = new Date() + if (startTime && endTime) { + def start = timeToday(startTime) + def stop = timeToday(endTime) + + // there's both start time and end time + if (start.before(now) && stop.after(now)){ + // It's between the times + return true + } else { + // It's not between the times + return false + } + } else if (startTime && !endTime){ + // there's a start time, but no end time + def start = timeToday(startTime) + if (start.before(now)) { + // it's after start time + return true + } else { + //it's before start time + return false + } + } else if (!startTime && endTime) { + // there's an end time but no start time + def stop = timeToday(endTime) + if (stop.after(now)) { + // it's still before end time + return true + } else { + // it's after end time + return false + } + } else { + // there are no times + return false + } +} + +def startDateTime() { + if (startDay && startMonth && startYear && startTime) { + def time = new Date().parse(smartThingsDateFormat(), startTime).format("'T'HH:mm:ss.SSSZ", timeZone(startTime)) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${startYear}-${startMonth}-${startDay}${time}") + } else { + // Start Date Time not set + return false + } +} + +def endDateTime() { + if (endDay && endMonth && endYear && endTime) { + def time = new Date().parse(smartThingsDateFormat(), endTime).format("'T'HH:mm:ss.SSSZ", timeZone(endTime)) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${endYear}-${endMonth}-${endDay}${time}") + } else { + // End Date Time not set + return false + } +} + +def rightNow() { + def now = new Date().format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", now) +} + +def isCorrectDay() { + def today = new Date().format("EEEE", location.timeZone) + log.debug "today: ${today}, days: ${days}" + if (!days || days.contains(today)) { + // if no days, assume every day + return true + } + log.trace "should not allow access - Not correct Day" + return false +} + +def userSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (settings."userSlot${i}") { + array << settings."userSlot${i}".toInteger() + } + } + return array +} + +def enabledUsersArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (userIsEnabled(i)) { + array << i + } + } + return array +} +def enabledUsersSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (userIsEnabled(i)) { + def userSlot = settings."userSlot${i}" + array << userSlot.toInteger() + } + } + return array +} + +def disabledUsersSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (!userIsEnabled(i)) { + if (settings."userSlot${i}") { + array << settings."userSlot${i}".toInteger() + } + } + } + return array +} + +def codereturn(evt) { + def codeNumber = evt.data.replaceAll("\\D+","") + def codeSlot = evt.value + if (notifyAccessEnd || notifyAccessStart) { + if (userSlotArray().contains(evt.integerValue.toInteger())) { + def userName = settings."userName${usedUserIndex(evt.integerValue)}" + if (codeNumber == "") { + if (notifyAccessEnd) { + def message = "${userName} no longer has access to ${evt.displayName}" + if (codeNumber.isNumber()) { + state."lock${evt.deviceId}".codes."slot${codeSlot}" = codeNumber + } + send(message) + } + } else { + if (notifyAccessStart) { + def message = "${userName} now has access to ${evt.displayName}" + if (codeNumber.isNumber()) { + state."lock${evt.deviceId}".codes."slot${codeSlot}" = codeNumber + } + send(message) + } + } + } + } +} + +def usedUserIndex(usedSlot) { + for (int i = 1; i <= settings.maxUsers; i++) { + if (settings."userSlot${i}" && settings."userSlot${i}".toInteger() == usedSlot.toInteger()) { + return i + } + } + return false +} + +def codeUsed(evt) { + // check the status of the lock, helpful for some schlage locks. + runIn(10, doPoll) + log.debug("codeUsed evt.value: " + evt.value + ". evt.data: " + evt.data) + def message = null + + if(evt.value == "unlocked" && evt.data) { + def codeData = new JsonSlurper().parseText(evt.data) + if(codeData.usedCode && codeData.usedCode.isNumber() && userSlotArray().contains(codeData.usedCode.toInteger())) { + def usedIndex = usedUserIndex(codeData.usedCode).toInteger() + def unlockUserName = settings."userName${usedIndex}" + message = "${evt.displayName} was unlocked by ${unlockUserName}" + // increment usage + state."userState${usedIndex}".usage = state."userState${usedIndex}".usage + 1 + if(settings."userHomePhrases${usedIndex}") { + // Specific User Hello Home + if (settings."userNoRunPresence${usedIndex}" && settings."userDoRunPresence${usedIndex}" == null) { + if (!anyoneHome(settings."userNoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else if (settings."userDoRunPresence${usedIndex}" && settings."userNoRunPresence${usedIndex}" == null) { + if (anyoneHome(settings."userDoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else if (settings."userDoRunPresence${usedIndex}" && settings."userNoRunPresence${usedIndex}") { + if (anyoneHome(settings."userDoRunPresence${usedIndex}") && !anyoneHome(settings."userNoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } + if(settings."burnCode${usedIndex}") { + theLocks.deleteCode(codeData.usedCode) + runIn(60*2, doPoll) + message += ". Now burning code." + } + //Don't send notification if muted + if(settings."dontNotify${usedIndex}" == true) { + message = null + } + } + } else if(evt.value == "locked" && settings.notifyLock) { + message = "${evt.displayName} has been locked" + } + + if (message) { + log.debug("Sending message: " + message) + send(message) + } + + if (homePhrases) { + performActions(evt) + } +} + +def performActions(evt) { + if(evt.value == "unlocked" && evt.data) { + def codeData = new JsonSlurper().parseText(evt.data) + if(enabledUsersArray().contains(codeData.usedCode) || isManualUnlock(codeData)) { + // Global Hello Home + if(location.currentMode != modeIgnore) { + if (noRunPresence && doRunPresence == null) { + if (!anyoneHome(noRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else if (doRunPresence && noRunPresence == null) { + if (anyoneHome(doRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else if (doRunPresence && noRunPresence) { + if (anyoneHome(doRunPresence) && !anyoneHome(noRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else { + location.helloHome.execute(homePhrases) + } + } else { + def routineMessage = "Already in ${modeIgnore} mode, skipping execution of ${homePhrases} routine." + log.debug routineMessage + send(routineMessage) + } + } + } +} + +def revokeDisabledUsers() { + def array = [] + disabledUsersSlotArray().each { slot -> + array << ["code${slot}", ""] + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def doPoll() { + // this gets codes if custom device is installed + if (!allCodesDone()) { + state.error_loop_count = state.error_loop_count + 1 + } + theLocks.poll() +} + +def grantAccess() { + def array = [] + enabledUsersArray().each { user-> + def userSlot = settings."userSlot${user}" + if (settings."userCode${user}" != null) { + def newCode = settings."userCode${user}" + array << ["code${userSlot}", "${newCode}"] + } else { + array << ["code${userSlot}", ""] + } + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def revokeAccess() { + def array = [] + enabledUsersArray().each { user-> + def userSlot = settings."userSlot${user}" + array << ["code${userSlot}", ""] + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def isManualUnlock(codeData) { + // check to see if the user wants this + if (manualUnlock) { + // garyd9's device type returns 'manual' + if ((codeData.usedCode == "") || (codeData.usedCode == null) || (codeData.usedCode == 'manual')) { + // no code used on unlock! + return true + } else { + // probably a code we're not dealing with here + return false + } + } else { + return false + } +} + +def isActiveBurnCode(slot) { + if (settings."burnCode${slot}" && state."userState${slot}".usage > 0) { + return false + } else { + // not a burn code / not yet used + return true + } +} + +def pollCodeReport(evt) { + def active = isAbleToStart() + def codeData = new JsonSlurper().parseText(evt.data) + def numberOfCodes = codeData.codes + def userSlots = userSlotArray() + + def array = [] + + (1..maxUsers).each { user-> + def slot = settings."userSlot${user}" + def code = codeData."code${slot}" + def correctCode = settings."userCode${user}" + if (active) { + if (userIsEnabled(user) && isActiveBurnCode(user)) { + if (code == settings."userCode${user}") { + // Code is Active, We should be active. Nothing to do + } else { + // Code is incorrect, We should be active. + array << ["code${slot}", settings."userCode${user}"] + } + } else { + if (code != '') { + // Code is set, user is disabled, We should be disabled. + array << ["code${slot}", ""] + } else { + // Code is not set, user is disabled. Nothing to do + } + } + } else { + if (code != '') { + // Code is set, We should be disabled. + array << ["code${slot}", ""] + } else { + // Code is not active, We should be disabled. Nothing to do + } + } + } + + def currentLock = theLocks.find{it.id == evt.deviceId} + populateDiscovery(codeData, currentLock) + + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + runIn(60*2, doPoll) + + //Lock is in an error state + state."lock${currentLock.id}".error_loop = true + def error_number = state.error_loop_count + 1 + if (error_number <= 10) { + log.debug "sendCodes fix is: ${json} Error: ${error_number}/10" + currentLock.updateCodes(json) + } else { + log.debug "kill fix is: ${json}" + currentLock.updateCodes(json) + json = new JsonSlurper().parseText(json) + def n = 0 + json.each { code -> + n = code[0][4..-1].toInteger() + def usedIndex = usedUserIndex(n) + def name = settings."userName${usedIndex}" + if (state."userState${usedIndex}".enabled) { + state."userState${usedIndex}".enabled = false + state."userState${usedIndex}".disabledReason = "Controller failed to set code" + send("Controller failed to set code for ${name}") + } + } + } + } else { + state."lock${currentLock.id}".error_loop = false + if (allCodesDone) { + lockErrorLoopReset() + } else { + runIn(60, doPoll) + } + } +} + +def allCodesDone() { + def i = 0 + def codeComplete = true + theLocks.each { lock-> + i++ + if (state."lock${lock.id}".error_loop == true) { + codeComplete = false + } + } + return codeComplete +} + +private anyoneHome(sensors) { + def result = false + if(sensors.findAll { it?.currentPresence == "present" }) { + result = true + } + result +} + +private send(msg) { + if (notificationStartTime != null && notificationEndTime != null) { + def start = timeToday(notificationStartTime) + def stop = timeToday(notificationEndTime) + def now = new Date() + if (start.before(now) && stop.after(now)){ + sendMessage(msg) + } + } else { + sendMessage(msg) + } +} + +private sendMessage(msg) { + if (notification) { + sendPush(msg) + } else { + sendNotificationEvent(msg) + } + if (phone) { + if ( phone.indexOf(";") > 1){ + def phones = phone.split(";") + for ( def i = 0; i < phones.size(); i++) { + sendSms(phones[i], msg) + } + } + else { + sendSms(phone, msg) + } + } +} + +def populateDiscovery(codeData, lock) { + def codes = [:] + def codeSlots = 30 + if (codeData.codes) { + codeSlots = codeData.codes + } + (1..codeSlots).each { slot-> + codes."slot${slot}" = codeData."code${slot}" + } + atomicState."lock${lock.id}".codes = codes +} + +private String getPIN() { + return settings.pin.value.toString().padLeft(4,'0') +} + +def alarmStatusHandler(event) { + log.debug "Keypad manager caught alarm status change: "+event.value + if(keypadstatus) + { + if (event.value == "off"){ + keypad?.each() { it.setDisarmed() } + } + else if (event.value == "away"){ + keypad?.each() { it.setArmedAway() } + } + else if (event.value == "stay") { + keypad?.each() { it.setArmedStay() } + } + } +} + +private sendSHMEvent(String shmState) { + def event = [ + name:"alarmSystemStatus", + value: shmState, + displayed: true, + description: "System Status is ${shmState}" + ] + log.debug "test ${event}" + sendLocationEvent(event) +} + +private execRoutine(armMode) { + if (armMode == 'away') { + location.helloHome?.execute(settings.armRoutine) + } else if (armMode == 'stay') { + location.helloHome?.execute(settings.stayRoutine) + } else if (armMode == 'off') { + location.helloHome?.execute(settings.disarmRoutine) + } +} + +def codeEntryHandler(evt) { + //do stuff + log.debug "Caught code entry event! ${evt.value.value}" + + def codeEntered = evt.value as String + + def data = evt.data as String + def armMode = '' + def currentarmMode = keypad.currentValue("armMode") + def changedMode = 0 + + if (data == '0') { + armMode = 'off' + } + else if (data == '3') { + armMode = 'away' + } + else if (data == '1') { + armMode = 'stay' + } + else if (data == '2') { + armMode = 'stay' //Currently no separate night mode for SHM, set to 'stay' + } else { + log.error "${app.label}: Unexpected arm mode sent by keypad!: "+data + return [] + } + + def i = settings.maxUsers + def message = " " + while (i > 0) { + log.debug "i =" + i + def correctCode = settings."userCode${i}" as String + + if (codeEntered == correctCode) { + + log.debug "User Enabled: " + state."userState${i}".enabled + + if (state."userState${i}".enabled == true) { + log.debug "Correct PIN entered. Change SHM state to ${armMode}" + //log.debug "Delay: ${armDelay}" + //log.debug "Data: ${data}" + //log.debug "armMode: ${armMode}" + + def unlockUserName = settings."userName${i}" + + if (data == "0") { + //log.debug "sendDisarmCommand" + runIn(0, "sendDisarmCommand") + message = "${evt.displayName} was disarmed by ${unlockUserName}" + } + else if (data == "1") { + //log.debug "sendStayCommand" + if(armDelay && keypadstatus) { + keypad?.each() { it.setExitDelay(armDelay) } + } + runIn(armDelay, "sendStayCommand") + message = "${evt.displayName} was armed to 'Stay' by ${unlockUserName}" + } + else if (data == "2") { + //log.debug "sendNightCommand" + if(armDelay && keypadstatus) { + keypad?.each() { it.setExitDelay(armDelay) } + } + runIn(armDelay, "sendNightCommand") + message = "${evt.displayName} was armed to 'Night' by ${unlockUserName}" + } + else if (data == "3") { + //log.debug "sendArmCommand" + if(armDelay && keypadstatus) { + keypad?.each() { it.setExitDelay(armDelay) } + } + runIn(armDelay, "sendArmCommand") + message = "${evt.displayName} was armed to 'Away' by ${unlockUserName}" + } + + if(settings."burnCode${i}") { + state."userState${i}".enabled = false + message += ". Now burning code." + } + + log.debug "${message}" + //log.debug "Initial Usage Count:" + state."userState${i}".usage + state."userState${i}".usage = state."userState${i}".usage + 1 + //log.debug "Final Usage Count:" + state."userState${i}".usage + send(message) + i = 0 + } else if (state."userState${i}".enabled == false){ + log.debug "PIN Disabled" + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + //keypad.sendInvalidKeycodeResponse() + } + } + changedMode = 1 + i-- + } + if (changedMode == 1 && i == 0) { + def errorMsg = "Incorrect Code Entered: ${codeEntered}" + if (notifyIncorrectPin) { + log.debug "Incorrect PIN" + send(errorMsg) + } + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + keypad.sendInvalidKeycodeResponse() + } +} +def sendArmCommand() { + log.debug "Sending Arm Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(3) } + } + sendSHMEvent("away") + execRoutine("away") +} +def sendDisarmCommand() { + log.debug "Sending Disarm Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(0) } + } + sendSHMEvent("off") + execRoutine("off") +} +def sendStayCommand() { + log.debug "Sending Stay Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(1) } + } + sendSHMEvent("stay") + execRoutine("stay") +} +def sendNightCommand() { + log.debug "Sending Night Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(2) } + } + sendSHMEvent("stay") + execRoutine("stay") +} \ No newline at end of file diff --git a/devicetypes/mitchpond/centralite-keypad.src/centralite-keypad.groovy b/devicetypes/mitchpond/centralite-keypad.src/centralite-keypad.groovy new file mode 100644 index 0000000..4536ba8 --- /dev/null +++ b/devicetypes/mitchpond/centralite-keypad.src/centralite-keypad.groovy @@ -0,0 +1,618 @@ +/** + * Centralite Keypad + * + * Copyright 2015-2016 Mitch Pond, Zack Cornelius + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ +metadata { + definition (name: "Centralite Keypad", namespace: "mitchpond", author: "Mitch Pond") { + + capability "Battery" + capability "Configuration" + capability "Motion Sensor" + capability "Sensor" + capability "Temperature Measurement" + capability "Refresh" + capability "Lock Codes" + capability "Tamper Alert" + capability "Tone" + capability "button" + capability "polling" + capability "Contact Sensor" + + attribute "armMode", "String" + attribute "lastUpdate", "String" + + command "setDisarmed" + command "setArmedAway" + command "setArmedStay" + command "setArmedNight" + command "setExitDelay", ['number'] + command "setEntryDelay", ['number'] + command "testCmd" + command "sendInvalidKeycodeResponse" + command "acknowledgeArmRequest" + + fingerprint endpointId: "01", profileId: "0104", deviceId: "0401", inClusters: "0000,0001,0003,0020,0402,0500,0B05", outClusters: "0019,0501", manufacturer: "CentraLite", model: "3400", deviceJoinName: "Xfinity 3400-X Keypad" + fingerprint endpointId: "01", profileId: "0104", deviceId: "0401", inClusters: "0000,0001,0003,0020,0402,0500,0501,0B05,FC04", outClusters: "0019,0501", manufacturer: "CentraLite", model: "3405-L", deviceJoinName: "Iris 3405-L Keypad" + } + + preferences{ + input ("tempOffset", "number", title: "Enter an offset to adjust the reported temperature", + defaultValue: 0, displayDuringSetup: false) + input ("beepLength", "number", title: "Enter length of beep in seconds", + defaultValue: 1, displayDuringSetup: false) + + input ("motionTime", "number", title: "Time in seconds for Motion to become Inactive (Default:10, 0=disabled)", defaultValue: 10, displayDuringSetup: false) + } + + tiles (scale: 2) { + multiAttributeTile(name: "keypad", type:"generic", width:6, height:4, canChangeIcon: true) { + tileAttribute ("device.armMode", key: "PRIMARY_CONTROL") { + attributeState("disarmed", label:'${currentValue}', icon:"st.Home.home2", backgroundColor:"#44b621") + attributeState("armedStay", label:'ARMED/STAY', icon:"st.Home.home3", backgroundColor:"#ffa81e") + attributeState("armedAway", label:'ARMED/AWAY', icon:"st.nest.nest-away", backgroundColor:"#d04e00") + } + tileAttribute("device.lastUpdate", key: "SECONDARY_CONTROL") { + attributeState("default", label:'Updated: ${currentValue}') + } + /* + tileAttribute("device.battery", key: "SECONDARY_CONTROL") { + attributeState("default", label:'Battery: ${currentValue}%', unit:"%") + } + tileAttribute("device.battery", key: "VALUE_CONTROL") { + attributeState "VALUE_UP", action: "refresh" + attributeState "VALUE_DOWN", action: "refresh" + } + */ + tileAttribute("device.temperature", key: "VALUE_CONTROL") { + attributeState "VALUE_UP", action: "refresh" + attributeState "VALUE_DOWN", action: "refresh" + } + } + + valueTile("temperature", "device.temperature", width: 2, height: 2) { + state "temperature", label: '${currentValue}°', + backgroundColors:[ + [value: 31, color: "#153591"], + [value: 44, color: "#1e9cbb"], + [value: 59, color: "#90d2a7"], + [value: 74, color: "#44b621"], + [value: 84, color: "#f1d801"], + [value: 95, color: "#d04e00"], + [value: 96, color: "#bc2323"] + ] + } + + standardTile("motion", "device.motion", decoration: "flat", canChangeBackground: true, width: 2, height: 2) { + state "active", label:'motion',icon:"st.motion.motion.active", backgroundColor:"#53a7c0" + state "inactive", label:'no motion',icon:"st.motion.motion.inactive", backgroundColor:"#ffffff" + } + standardTile("tamper", "device.tamper", decoration: "flat", canChangeBackground: true, width: 2, height: 2) { + state "clear", label: 'Tamper', icon:"st.motion.acceleration.inactive", backgroundColor: "#ffffff" + state "detected", label: 'Tamper', icon:"st.motion.acceleration.active", backgroundColor:"#cc5c5c" + } + standardTile("Panic", "device.contact", decoration: "flat", canChangeBackground: true, width: 2, height: 2) { + state "open", label: 'Panic', icon:"st.security.alarm.alarm", backgroundColor: "#ffffff" + state "closed", label: 'Panic', icon:"st.security.alarm.clear", backgroundColor:"#bc2323" + } + + standardTile("Mode", "device.armMode", decoration: "flat", canChangeBackground: true, width: 2, height: 2) { + state "disarmed", label:'OFF', icon:"st.Home.home2", backgroundColor:"#44b621" + state "armedStay", label:'OFF', icon:"st.Home.home3", backgroundColor:"#ffffff" + state "armedAway", label:'OFF', icon:"st.net.nest-away", backgroundColor:"#ffffff" + } + + standardTile("beep", "device.beep", decoration: "flat", width: 2, height: 2) { + state "default", action:"tone.beep", icon:"st.secondary.beep", backgroundColor:"#ffffff" + } + valueTile("battery", "device.battery", decoration: "flat", width: 2, height: 2) { + state "battery", label:'${currentValue}% battery', unit:"" + } + standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", action:"refresh.refresh", icon:"st.secondary.refresh" + } + standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", action:"configuration.configure", icon:"st.secondary.configure" + } + valueTile("armMode", "device.armMode", decoration: "flat", width: 2, height: 2) { + state "armMode", label: '${currentValue}' + } + + main (["keypad"]) + details (["keypad","motion","tamper","Panic","Mode","beep","refresh","battery"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'"; + def results = []; + + //------Miscellaneous Zigbee message------// + if (description?.startsWith('catchall:')) { + + //log.debug zigbee.parse(description); + + def message = zigbee.parse(description); + + //------Profile-wide command (rattr responses, errors, etc.)------// + if (message?.isClusterSpecific == false) { + //------Default response------// + if (message?.command == 0x0B) { + if (message?.data[1] == 0x81) + log.error "Device: unrecognized command: "+description; + else if (message?.data[1] == 0x80) + log.error "Device: malformed command: "+description; + } + //------Read attributes responses------// + else if (message?.command == 0x01) { + if (message?.clusterId == 0x0402) { + log.debug "Device: read attribute response: "+description; + + results = parseTempAttributeMsg(message) + }} + else + log.debug "Unhandled profile-wide command: "+description; + } + //------Cluster specific commands------// + else if (message?.isClusterSpecific) { + //------IAS ACE------// + if (message?.clusterId == 0x0501) { + if (message?.command == 0x07) { + motionON() + } + else if (message?.command == 0x04) { + results = createEvent(name: "button", value: "pushed", data: [buttonNumber: 1], descriptionText: "$device.displayName panic button was pushed", isStateChange: true) + panicContact() + } + else if (message?.command == 0x00) { + results = handleArmRequest(message) + log.trace results + } + } + else log.debug "Unhandled cluster-specific command: "+description + } + } + //------IAS Zone Enroll request------// + else if (description?.startsWith('enroll request')) { + log.debug "Sending IAS enroll response..." + results = zigbee.enrollResponse() + } + //------Read Attribute response------// + else if (description?.startsWith('read attr -')) { + results = parseReportAttributeMessage(description) + } + //------Temperature Report------// + else if (description?.startsWith('temperature: ')) { + log.debug "Got ST-style temperature report.." + results = createEvent(getTemperatureResult(zigbee.parseHATemperatureValue(description, "temperature: ", getTemperatureScale()))) + log.debug results + } + else if (description?.startsWith('zone status ')) { + results = parseIasMessage(description) + } + return results +} + + +def configure() { + log.debug "--- Configure Called" + String hubZigbeeId = swapEndianHex(device.hub.zigbeeEui) + def cmd = [ + //------IAS Zone/CIE setup------// + "zcl global write 0x500 0x10 0xf0 {${hubZigbeeId}}", "delay 100", + "send 0x${device.deviceNetworkId} 1 1", "delay 200", + + //------Set up binding------// + "zdo bind 0x${device.deviceNetworkId} 1 1 0x500 {${device.zigbeeId}} {}", "delay 200", + "zdo bind 0x${device.deviceNetworkId} 1 1 0x501 {${device.zigbeeId}} {}", "delay 200", + + ] + + zigbee.configureReporting(1,0x20,0x20,3600,43200,0x01) + + zigbee.configureReporting(0x0402,0x00,0x29,30,3600,0x0064) + + return cmd + refresh() +} + +def poll() { + refresh() +} + +def refresh() { + return sendStatusToDevice() + + zigbee.readAttribute(0x0001,0x20) + + zigbee.readAttribute(0x0402,0x00) +} + +private formatLocalTime(time, format = "EEE, MMM d yyyy @ h:mm a z") { + if (time instanceof Long) { + time = new Date(time) + } + if (time instanceof String) { + //get UTC time + time = timeToday(time, location.timeZone) + } + if (!(time instanceof Date)) { + return null + } + def formatter = new java.text.SimpleDateFormat(format) + formatter.setTimeZone(location.timeZone) + return formatter.format(time) +} + +private parseReportAttributeMessage(String description) { + Map descMap = (description - "read attr - ").split(",").inject([:]) { map, param -> + def nameAndValue = param.split(":") + map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] + } + //log.debug "Desc Map: $descMap" + + def results = [] + + if (descMap.cluster == "0001" && descMap.attrId == "0020") { + log.debug "Received battery level report" + results = createEvent(getBatteryResult(Integer.parseInt(descMap.value, 16))) + } + else if (descMap.cluster == "0001" && descMap.attrId == "0034") + { + log.debug "Received Battery Rated Voltage: ${descMap.value}" + } + else if (descMap.cluster == "0001" && descMap.attrId == "0036") + { + log.debug "Received Battery Alarm Voltage: ${descMap.value}" + } + else if (descMap.cluster == "0402" && descMap.attrId == "0000") { + def value = getTemperature(descMap.value) + results = createEvent(getTemperatureResult(value)) + } + + return results +} + +private parseTempAttributeMsg(message) { + byte[] temp = message.data[-2..-1].reverse() + createEvent(getTemperatureResult(getTemperature(temp.encodeHex() as String))) +} + +private Map parseIasMessage(String description) { + List parsedMsg = description.split(' ') + String msgCode = parsedMsg[2] + + Map resultMap = [:] + switch(msgCode) { + case '0x0020': // Closed/No Motion/Dry + resultMap = getContactResult('closed') + break + + case '0x0021': // Open/Motion/Wet + resultMap = getContactResult('open') + break + + case '0x0022': // Tamper Alarm + break + + case '0x0023': // Battery Alarm + break + + case '0x0024': // Supervision Report + resultMap = getContactResult('closed') + break + + case '0x0025': // Restore Report + resultMap = getContactResult('open') + break + + case '0x0026': // Trouble/Failure + break + + case '0x0028': // Test Mode + break + case '0x0000': + resultMap = createEvent(name: "tamper", value: "clear", isStateChange: true, displayed: false) + break + case '0x0004': + resultMap = createEvent(name: "tamper", value: "detected", isStateChange: true, displayed: false) + break; + default: + log.debug "Invalid message code in IAS message: ${msgCode}" + } + return resultMap +} + + +private Map getMotionResult(value) { + String linkText = getLinkText(device) + String descriptionText = value == 'active' ? "${linkText} detected motion" : "${linkText} motion has stopped" + return [ + name: 'motion', + value: value, + descriptionText: descriptionText + ] +} +def motionON() { + log.debug "--- Motion Detected" + sendEvent(name: "motion", value: "active", displayed:true, isStateChange: true) + + //-- Calculate Inactive timeout value + def motionTimeRun = (settings.motionTime?:0).toInteger() + + //-- If Inactive timeout was configured + if (motionTimeRun > 0) { + log.debug "--- Will become inactive in $motionTimeRun seconds" + runIn(motionTimeRun, "motionOFF") + } +} + +def motionOFF() { + log.debug "--- Motion Inactive (OFF)" + sendEvent(name: "motion", value: "inactive", displayed:true, isStateChange: true) +} + +def panicContact() { + log.debug "--- Panic button hit" + sendEvent(name: "contact", value: "open", displayed: true, isStateChange: true) + runIn(3, "panicContactClose") +} + +def panicContactClose() +{ + sendEvent(name: "contact", value: "closed", displayed: true, isStateChange: true) +} + +//TODO: find actual good battery voltage range and update this method with proper values for min/max +// +//Converts the battery level response into a percentage to display in ST +//and creates appropriate message for given level + +private getBatteryResult(rawValue) { + def linkText = getLinkText(device) + + def result = [name: 'battery'] + + def volts = rawValue / 10 + def descriptionText + if (volts > 3.5) { + result.descriptionText = "${linkText} battery has too much power (${volts} volts)." + } + else { + def minVolts = 2.5 + def maxVolts = 3.0 + def pct = (volts - minVolts) / (maxVolts - minVolts) + result.value = Math.min(100, (int) pct * 100) + result.descriptionText = "${linkText} battery was ${result.value}%" + } + + return result +} + +private getTemperature(value) { + def celcius = Integer.parseInt(value, 16).shortValue() / 100 + if(getTemperatureScale() == "C"){ + return celcius + } else { + return celsiusToFahrenheit(celcius) as Integer + } +} + +private Map getTemperatureResult(value) { + log.debug 'TEMP' + def linkText = getLinkText(device) + if (tempOffset) { + def offset = tempOffset as int + def v = value as int + value = v + offset + } + def descriptionText = "${linkText} was ${value}°${temperatureScale}" + return [ + name: 'temperature', + value: value, + descriptionText: descriptionText + ] +} + +//------Command handlers------// +private handleArmRequest(message){ + def keycode = new String(message.data[2..-2] as byte[],'UTF-8') + def reqArmMode = message.data[0] + //state.lastKeycode = keycode + log.debug "Received arm command with keycode/armMode: ${keycode}/${reqArmMode}" + + //Acknowledge the command. This may not be *technically* correct, but it works + /*List cmds = [ + "raw 0x501 {09 01 00 0${reqArmMode}}", "delay 200", + "send 0x${device.deviceNetworkId} 1 1", "delay 500" + ] + def results = cmds?.collect { new physicalgraph.device.HubAction(it) } + createCodeEntryEvent(keycode, reqArmMode) + */ + def results = createCodeEntryEvent(keycode, reqArmMode) + log.trace "Method: handleArmRequest(message): "+results + return results +} + +def createCodeEntryEvent(keycode, armMode) { + createEvent(name: "codeEntered", value: keycode as String, data: armMode as String, + isStateChange: true, displayed: false) +} + +// +//The keypad seems to be expecting responses that are not in-line with the HA 1.2 spec. Maybe HA 1.3 or Zigbee 3.0?? +// +private sendStatusToDevice() { + log.debug 'Sending status to device...' + def armMode = device.currentValue("armMode") + log.trace 'Arm mode: '+armMode + def status = '' + if (armMode == null || armMode == 'disarmed') status = 0 + else if (armMode == 'armedAway') status = 3 + else if (armMode == 'armedStay') status = 1 + else if (armMode == 'armedNight') status = 2 + + // If we're not in one of the 4 basic modes, don't update the status, don't want to override beep timings, exit delay is dependent on it being correct + if (status != '') + { + return sendRawStatus(status) + } + else + { + return [] + } +} + + +// Statuses: +// 00 - Disarmed +// 01 - Armed partial +// 02 - Armed partial +// 03 - Armed Away +// 04 - ? +// 05 - Fast beep (1 per second) +// 05 - Entry delay (Uses seconds) Appears to keep the status lights as it was +// 06 - Amber status blink (Ignores seconds) +// 07 - ? +// 08 - Red status blink +// 09 - ? +// 10 - Exit delay Slow beep (2 per second, accelerating to 1 beep per second for the last 10 seconds) - With red flashing status - Uses seconds +// 11 - ? +// 12 - ? +// 13 - ? + +private sendRawStatus(status, seconds = 00) { + log.debug "Sending Status ${zigbee.convertToHexString(status)}${zigbee.convertToHexString(seconds)} to device..." + + // Seems to require frame control 9, which indicates a "Server to client" cluster specific command (which seems backward? I thought the keypad was the server) + List cmds = ["raw 0x501 {09 01 04 ${zigbee.convertToHexString(status)}${zigbee.convertToHexString(seconds)}}", + "send 0x${device.deviceNetworkId} 1 1", 'delay 100'] + + def results = cmds?.collect { new physicalgraph.device.HubAction(it) }; + return results +} + +def notifyPanelStatusChanged(status) { + //TODO: not yet implemented. May not be needed. +} +//------------------------// + +def setDisarmed() { setModeHelper("disarmed",0) } +def setArmedAway(def delay=0) { setModeHelper("armedAway",delay) } +def setArmedStay(def delay=0) { setModeHelper("armedStay",delay) } +def setArmedNight(def delay=0) { setModeHelper("armedNight",delay) } + +def setEntryDelay(delay) { + setModeHelper("entryDelay", delay) + sendRawStatus(5, delay) // Entry delay beeps +} + +def setExitDelay(delay) { + setModeHelper("exitDelay", delay) + sendRawStatus(10, delay) // Exit delay +} + +private setModeHelper(String armMode, delay) { + sendEvent([name: "armMode", value: armMode, data: [delay: delay as int], isStateChange: true]) + def lastUpdate = formatLocalTime(now()) + sendEvent(name: "lastUpdate", value: lastUpdate, displayed: false) + sendStatusToDevice() +} + +private setKeypadArmMode(armMode){ + Map mode = [disarmed: '00', armedAway: '03', armedStay: '01', armedNight: '02', entryDelay: '', exitDelay: ''] + if (mode[armMode] != '') + { + return ["raw 0x501 {09 01 04 ${mode[armMode]}00}", + "send 0x${device.deviceNetworkId} 1 1", 'delay 100'] + } +} + +def acknowledgeArmRequest(armMode){ + List cmds = [ + "raw 0x501 {09 01 00 0${armMode}}", + "send 0x${device.deviceNetworkId} 1 1", "delay 100" + ] + def results = cmds?.collect { new physicalgraph.device.HubAction(it) } + log.trace "Method: acknowledgeArmRequest(armMode): "+results + return results +} + +def sendInvalidKeycodeResponse(){ + List cmds = [ + "raw 0x501 {09 01 00 04}", + "send 0x${device.deviceNetworkId} 1 1", "delay 100" + ] + + log.trace 'Method: sendInvalidKeycodeResponse(): '+cmds + return (cmds?.collect { new physicalgraph.device.HubAction(it) }) + sendStatusToDevice() +} + +def beep(def beepLength = settings.beepLength) { + if ( beepLength == null ) + { + beepLength = 0 + } + def len = zigbee.convertToHexString(beepLength, 2) + List cmds = ["raw 0x501 {09 01 04 05${len}}", 'delay 200', + "send 0x${device.deviceNetworkId} 1 1", 'delay 500'] + cmds +} + +//------Utility methods------// + +private String swapEndianHex(String hex) { + reverseArray(hex.decodeHex()).encodeHex() +} + +private byte[] reverseArray(byte[] array) { + int i = 0; + int j = array.length - 1; + byte tmp; + while (j > i) { + tmp = array[j]; + array[j] = array[i]; + array[i] = tmp; + j--; + i++; + } + return array +} +//------------------------// + +private testCmd(){ + //log.trace zigbee.parse('catchall: 0104 0501 01 01 0140 00 4F2D 01 00 0000 07 00 ') + //beep(10) + //test exit delay + //log.debug device.zigbeeId + //testingTesting() + //discoverCmds() + //zigbee.configureReporting(1,0x20,0x20,3600,43200,0x01) //battery reporting + //["raw 0x0001 {00 00 06 00 2000 20 100E FEFF 01}", + //"send 0x${device.deviceNetworkId} 1 1"] + //zigbee.command(0x0003, 0x00, "0500") //Identify: blinks connection light + + //log.debug //temperature reporting + + return zigbee.readAttribute(0x0020,0x01) + + zigbee.readAttribute(0x0020,0x02) + + zigbee.readAttribute(0x0020,0x03) +} + +private discoverCmds(){ + List cmds = ["raw 0x0501 {08 01 11 0011}", 'delay 200', + "send 0x${device.deviceNetworkId} 1 1", 'delay 500'] + cmds +} + +private testingTesting() { + log.debug "Delay: "+device.currentState("armMode").toString() + List cmds = ["raw 0x501 {09 01 04 050A}", 'delay 200', + "send 0x${device.deviceNetworkId} 1 1", 'delay 500'] + cmds +} \ No newline at end of file diff --git a/smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy b/smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy new file mode 100644 index 0000000..5300752 --- /dev/null +++ b/smartapps/ethayer/user-lock-manager.src/user-lock-manager.groovy @@ -0,0 +1,1545 @@ +/** + * User Lock Manager v4.1.5 + * + * Copyright 2015 Erik Thayer + * Keypad support added by BLebson + * + */ +definition( + name: "User Lock Manager", + namespace: "ethayer", + author: "Erik Thayer", + description: "This app allows you to change, delete, and schedule user access.", + category: "Safety & Security", + iconUrl: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanager.png", + iconX2Url: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanagerx2.png", + iconX3Url: "https://dl.dropboxusercontent.com/u/54190708/LockManager/lockmanagerx3.png") + + import groovy.json.JsonSlurper + import groovy.json.JsonBuilder + +preferences { + page(name: "rootPage") + page(name: "setupPage") + page(name: "userPage") + page(name: "notificationPage") + page(name: "onUnlockPage") + page(name: "schedulingPage") + page(name: "calendarPage") + page(name: "resetAllCodeUsagePage") + page(name: "resetCodeUsagePage") + page(name: "reEnableUserPage") + page(name: "infoPage") + page(name: "keypadPage") + page(name: "infoRefreshPage") + page(name: "lockInfoPage") +} + +def rootPage() { + //reset errors on each load + dynamicPage(name: "rootPage", title: "", install: true, uninstall: true) { + + section("Which Locks?") { + input "theLocks","capability.lockCodes", title: "Select Locks", required: true, multiple: true, submitOnChange: true + } + + if (theLocks) { + initalizeLockData() + + section { + input name: "maxUsers", title: "Number of users", type: "number", multiple: false, refreshAfterSelection: true, submitOnChange: true + href(name: "toSetupPage", title: "User Settings", page: "setupPage", description: setupPageDescription(), state: setupPageDescription() ? "complete" : "") + href(name: "toInfoPage", page: "infoPage", title: "Lock Info") + href(name: "toKeypadPage", page: "keypadPage", title: "Keypad Info (optional)") + href(name: "toNotificationPage", page: "notificationPage", title: "Notification Settings", description: notificationPageDescription(), state: notificationPageDescription() ? "complete" : "") + href(name: "toSchedulingPage", page: "schedulingPage", title: "Schedule (optional)", description: schedulingHrefDescription(), state: schedulingHrefDescription() ? "complete" : "") + href(name: "toOnUnlockPage", page: "onUnlockPage", title: "Global Hello Home") + } + section { + label(title: "Label this SmartApp", required: false, defaultValue: "") + } + } + } +} + +def setupPage() { + dynamicPage(name:"setupPage", title:"User Settings") { + if (maxUsers > 0) { + section('Users') { + (1..maxUsers).each { user-> + if (!state."userState${user}") { + //there's no values, so reset + resetCodeUsage(user) + } + if (settings."userCode${user}" && settings."userSlot${user}") { + getConflicts(settings."userSlot${user}") + } + href(name: "toUserPage${user}", page: "userPage", params: [number: user], required: false, description: userHrefDescription(user), title: userHrefTitle(user), state: userPageState(user) ) + } + } + section { + href(name: "toResetAllCodeUsage", title: "Reset Code Usage", page: "resetAllCodeUsagePage", description: "Tap to reset") + } + } else { + section("Users") { + paragraph "Users are set to zero. Please go back to the main page and change the number of users to at least 1." + } + } + } +} + +def userPage(params) { + dynamicPage(name:"userPage", title:"User Settings") { + def i = getUser(params); + + if (!state."userState${i}".enabled) { + section { + paragraph "WARNING:\n\nThis user has been disabled.\nReason: ${state."userState${i}".disabledReason}" + href(name: "toreEnableUserPage", title: "Reset User", page: "reEnableUserPage", params: [number: i], description: "Tap to reset") + } + } + if (settings."userCode${i}" && settings."userSlot${i}") { + def conflict = getConflicts(settings."userSlot${i}") + if (conflict.has_conflict) { + section("Conflicts:") { + theLocks.each { lock-> + if (conflict."lock${lock.id}" && conflict."lock${lock.id}".conflicts != []) { + paragraph "${lock.displayName} slot ${fancyString(conflict."lock${lock.id}".conflicts)}" + } + } + } + } + } + section("Code #${i}") { + input(name: "userName${i}", type: "text", title: "Name for User", defaultValue: settings."userName${i}") + def title = "Code (4 to 8 digits)" + theLocks.each { lock-> + if (lock.hasAttribute('pinLength')) { + title = "Code (Must be ${lock.latestValue('pinLength')} digits)" + } + } + input(name: "userCode${i}", type: "text", title: title, required: false, defaultValue: settings."userCode${i}", refreshAfterSelection: true) + input(name: "userSlot${i}", type: "number", title: "Slot (1 through 30)", defaultValue: preSlectedCode(i)) + } + section { + input(name: "dontNotify${i}", title: "Mute entry notification?", type: "bool", required: false, defaultValue: settings."dontNotify${i}") + input(name: "burnCode${i}", title: "Burn after use?", type: "bool", required: false, defaultValue: settings."burnCode${i}") + input(name: "userEnabled${i}", title: "Enabled?", type: "bool", required: false, defaultValue: settings."userEnabled${i}") + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + if (hhPhrases) { + hhPhrases.sort() + input name: "userHomePhrases${i}", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, defaultValue: settings."userHomePhrases${i}", refreshAfterSelection: true + input "userNoRunPresence${i}", "capability.presenceSensor", title: "Don't run Actions if any of these are present:", multiple: true, required: false, defaultValue: settings."userNoRunPresence${i}" || false + input "userDoRunPresence${i}", "capability.presenceSensor", title: "Run Actions only if any of these are present:", multiple: true, required: false, defaultValue: settings."userDoRunPresence${i}" || false + } + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + href(name: "toResetCodeUsagePage", title: "Reset Code Usage", page: "resetCodeUsagePage", params: [number: i], description: "Tap to reset") + } + } +} + +def preSlectedCode(i) { + if (settings."userSlot${i}" != null) { + return settings."userSlot${i}" + } else { + return i + } +} + +def notificationPage() { + dynamicPage(name: "notificationPage", title: "Notification Settings") { + + section { + input(name: "phone", type: "text", title: "Text This Number", description: "Phone number", required: false, submitOnChange: true) + paragraph "For multiple SMS recipients, separate phone numbers with a semicolon(;)" + input(name: "notification", type: "bool", title: "Send A Push Notification", description: "Notification", required: false, submitOnChange: true) + if (phone != null || notification || sendevent) { + input(name: "notifyAccess", title: "on User Entry", type: "bool", required: false) + input(name: "notifyLock", title: "on Lock", type: "bool", required: false) + input(name: "notifyAccessStart", title: "when granting access", type: "bool", required: false) + input(name: "notifyAccessEnd", title: "when revoking access", type: "bool", required: false) + } + } + + section("Only During These Times (optional)") { + input(name: "notificationStartTime", type: "time", title: "Notify Starting At This Time", description: null, required: false) + input(name: "notificationEndTime", type: "time", title: "Notify Ending At This Time", description: null, required: false) + } + } +} + +def schedulingPage() { + dynamicPage(name: "schedulingPage", title: "Rules For Access Scheduling") { + if (!days) { + section { + href(name: "toCalendarPage", title: "Calendar", page: "calendarPage", description: calendarHrefDescription(), state: calendarHrefDescription() ? "complete" : "") + } + } + if (!startDay && !startMonth && !startYear && !endDay && !endMonth && !endYear) { + section { + input(name: "days", type: "enum", title: "Allow User Access On These Days", description: "Every day", required: false, multiple: true, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], submitOnChange: true) + } + section { + input(name: "modeStart", title: "Allow Access when in this mode", type: "mode", required: false, mutliple: false, submitOnChange: true) + } + section { + if (modeStart) { + input "andOrTime", "enum", title: "[And/Or] at a set time?", metadata:[values:["and", "or"]], required: false, submitOnChange: true + } + if ((modeStart == null) || andOrTime) { + input(name: "startTime", type: "time", title: "Start Time", description: null, required: false) + input(name: "endTime", type: "time", title: "End Time", description: null, required: false) + } + } + } + } +} + +def calendarPage() { + dynamicPage(name: "calendarPage", title: "Calendar Access") { + section() { + paragraph "This page is for advanced users only. You must enter each field carefully." + paragraph "Calendar use does not support daily grant/deny OR Modes. You cannot both have a date here, and allow access only on certain days/modes." + } + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + section("Start Date") { + input name: "startDay", type: "number", title: "Day", required: false + input name: "startMonth", type: "number", title: "Month", required: false + input name: "startYear", type: "number", description: "Format(yyyy)", title: "Year", required: false + input name: "startTime", type: "time", title: "Start Time", description: null, required: false + if (hhPhrases) { + hhPhrases.sort() + input name: "calStartPhrase", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, refreshAfterSelection: true + } + } + section("End Date") { + input name: "endDay", type: "number", title: "Day", required: false + input name: "endMonth", type: "number", title: "Month", required: false + input name: "endYear", type: "number", description: "Format(yyyy)", title: "Year", required: false + input name: "endTime", type: "time", title: "End Time", description: null, required: false + if (hhPhrases) { + hhPhrases.sort() + input name: "calEndPhrase", type: "enum", title: "Hello Home Phrase", multiple: true,required: false, options: hhPhrases, refreshAfterSelection: true + } + } + } +} + +def onUnlockPage() { + dynamicPage(name:"onUnlockPage", title:"Global Actions (Any Code)") { + section("Actions") { + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + if (hhPhrases) { + hhPhrases.sort() + input name: "homePhrases", type: "enum", title: "Home Mode Phrase", multiple: true, required: false, options: hhPhrases, refreshAfterSelection: true, submitOnChange: true + if (homePhrases) { + input "noRunPresence", "capability.presenceSensor", title: "Don't run Actions if any of these are present:", multiple: true, required: false + input "doRunPresence", "capability.presenceSensor", title: "Run Actions only if any of these are present:", multiple: true, required: false + input name: "manualUnlock", title: "Initiate phrase on manual unlock also?", type: "bool", defaultValue: false, refreshAfterSelection: true + input(name: "modeIgnore", title: "Do not run Routine when in this mode", type: "mode", required: false, mutliple: false) + } + } + } + } +} + +def resetCodeUsagePage(params) { + def i = getUser(params) + // do reset + resetCodeUsage(i) + + dynamicPage(name:"resetCodeUsagePage", title:"User Usage Reset") { + section { + paragraph "User code usage has been reset." + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + } + } +} + +def resetAllCodeUsagePage() { + // do resetAll + resetAllCodeUsage() + dynamicPage(name:"resetAllCodeUsagePage", title:"User Settings") { + section { + paragraph "All user code usages have been reset." + } + section("Users") { + href(name: "toSetupPage", title: "Back to Users", page: "setupPage") + href(name: "toRootPage", title: "Main Page", page: "rootPage") + } + } +} + +def reEnableUserPage(params) { + // do reset + def i = getUser(params) + enableUser(i) + lockErrorLoopReset() + dynamicPage(name:"reEnableUserPage", title:"User re-enabled") { + section { + paragraph "User has been enabled." + } + section { + href(name: "toSetupPage", title: "Back To Users", page: "setupPage") + } + } +} + +def getUser(params) { + def i = 1 + // Assign params to i. Sometimes parameters are double nested. + if (params.number) { + i = params.number + } else if (params.params){ + i = params.params.number + } else if (state.lastUser) { + i = state.lastUser + } + + //Make sure i is a round number, not a float. + if ( ! i.isNumber() ) { + i = i.toInteger(); + } else if ( i.isNumber() ) { + i = Math.round(i * 100) / 100 + } + state.lastUser = i + return i +} + +def getLock(params) { + def id = '' + // Assign params to id. Sometimes parameters are double nested. + if (params.id) { + id = params.id + } else if (params.params){ + id = params.params.id + } else if (state.lastLock) { + id = state.lastLock + } + + state.lastLock = id + return theLocks.find{it.id == id} +} + +def infoPage() { + dynamicPage(name:"infoPage", title:"Lock Info") { + section() { + href(name: "toInfoRefreshPage", page: "infoRefreshPage", title: "Refresh Lock Data", description: 'Tap to refresh') + } + section("Locks") { + if (theLocks) { + def i = 0 + theLocks.each { lock-> + i++ + href(name: "toLockInfoPage${i}", page: "lockInfoPage", params: [id: lock.id], required: false, title: lock.displayName ) + } + } + } + } +} + +def infoRefreshPage() { + dynamicPage(name:"infoRefreshPage", title:"Lock Info") { + section() { + manualPoll() + paragraph "Lock info refreshing soon." + href(name: "toInfoPage", page: "infoPage", title: "Back to Lock Info") + } + } +} + +def lockInfoPage(params) { + dynamicPage(name:"lockInfoPage", title:"Lock Info") { + + def lock = getLock(params) + if (lock) { + section("${lock.displayName}") { + if (state."lock${lock.id}".codes != null) { + def i = 0 + def pass = '' + state."lock${lock.id}".codes.each { code-> + i++ + pass = state."lock${lock.id}".codes."slot${i}" + paragraph "Slot ${i}\nCode: ${pass}" + } + } else { + paragraph "No Lock data received yet. Requires custom device driver. Will be populated on next poll event." + } + } + } + } +} + + +def keypadPage() { + dynamicPage(name: "keypadPage",title: "Keypad Settings (optional)") { + section("Settings") { + // TODO: put inputs here + input(name: "keypad", title: "Keypad", type: "capability.lockCodes", multiple: true, required: false) + input(name: "keypadstatus", title: "Send status to keypad?", type: "bool", multiple: false, required: true, defaultValue: true) + } + def hhPhrases = location.getHelloHome()?.getPhrases()*.label + hhPhrases?.sort() + section("Routines", hideable: true, hidden: true) { + input(name: "armRoutine", title: "Arm/Away routine", type: "enum", options: hhPhrases, required: false) + input(name: "disarmRoutine", title: "Disarm routine", type: "enum", options: hhPhrases, required: false) + input(name: "stayRoutine", title: "Arm/Stay routine", type: "enum", options: hhPhrases, required: false) + input(name: "nightRoutine", title: "Arm/Night routine", type: "enum", options: hhPhrases, required: false) + input(name: "armDelay", title: "Arm Delay (in seconds)", type: "number", required: false) + input(name: "notifyIncorrectPin", title: "Notify you when incorrect code is used?", type: "bool", required: false) + } + } +} + +public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } + +public humanReadableStartDate() { + new Date().parse(smartThingsDateFormat(), startTime).format("h:mm a", timeZone(startTime)) +} +public humanReadableEndDate() { + new Date().parse(smartThingsDateFormat(), endTime).format("h:mm a", timeZone(endTime)) +} + +def manualPoll() { + theLocks.poll() +} + +def getConflicts(i) { + def currentCode = settings."userCode${i}" + def currentSlot = settings."userSlot${i}" + def conflict = [:] + conflict.has_conflict = false + + + theLocks.each { lock-> + if (state."lock${lock.id}".codes) { + conflict."lock${lock.id}" = [:] + conflict."lock${lock.id}".conflicts = [] + def ind = 0 + state."lock${lock.id}".codes.each { code -> + ind++ + if (currentSlot?.toInteger() != ind.toInteger() && !isUnique(currentCode, state."lock${lock.id}".codes."slot${ind}")) { + conflict.has_conflict = true + state."userState${i}".enabled = false + state."userState${i}".disabledReason = "Code Conflict Detected" + conflict."lock${lock.id}".conflicts << ind + } + } + } + } + + return conflict +} + +def isUnique(newInt, oldInt) { + + if (newInt == null || oldInt == null) { + // if either number is null, break here. + return true + } + + if (!newInt.isInteger() || !oldInt.isInteger()) { + // number is not an integer, can't check. + return true + } + + def newArray = [] + def oldArray = [] + def result = true + + def i = 0 + // Get a normalized sequence, at the same length + newInt.toString().toList().collect { + i++ + if (i <= oldInt.length()) { + newArray << normalizeNumber(it.toInteger()) + } + } + + i = 0 + oldInt.toString().toList().collect { + i++ + if (i <= oldInt.length()) { + oldArray << normalizeNumber(it.toInteger()) + } + } + + i = 0 + newArray.each { num-> + i++ + if (newArray.join() == oldArray.join()) { + // The normalized numbers are the same! + result = false + } + } + return result +} + +def normalizeNumber(number) { + def result = null + // RULE: Since some locks share buttons, make sure unique. + // Even locks with 10-keys follow this rule! (annoyingly) + switch (number) { + case [1,2]: + result = 1 + break + case [3,4]: + result = 2 + break + case [5,6]: + result = 3 + break + case [7,8]: + result = 4 + break + case [9,0]: + result = 5 + break + } + return result +} + +def setupPageDescription(){ + def parts = [] + for (int i = 1; i <= settings.maxUsers; i++) { + parts << settings."userName${i}" + } + return fancyString(parts) +} + +def notificationPageDescription() { + def parts = [] + def msg = "" + if (settings.phone) { + parts << "SMS to ${phone}" + } + if (settings.sendevent) { + parts << "Event Notification" + } + if (settings.notification) { + parts << "Push Notification" + } + msg += fancyString(parts) + parts = [] + + if (settings.notifyAccess) { + parts << "on entry" + } + if (settings.notifyLock) { + parts << "on lock" + } + if (settings.notifyAccessStart) { + parts << "when granting access" + } + if (settings.notifyAccessEnd) { + parts << "when revoking access" + } + if (settings.notificationStartTime) { + parts << "starting at ${settings.notificationStartTime}" + } + if (settings.notificationEndTime) { + parts << "ending at ${settings.notificationEndTime}" + } + if (parts.size()) { + msg += ": " + msg += fancyString(parts) + } + return msg +} + +def calendarHrefDescription() { + def dateStart = startDateTime() + def dateEnd = endDateTime() + if (dateEnd && dateStart) { + def startReadableTime = readableDateTime(dateStart) + def endReadableTime = readableDateTime(dateEnd) + return "Accessible from ${startReadableTime} until ${endReadableTime}" + } else if (!dateEnd && dateStart) { + def startReadableTime = readableDateTime(dateStart) + return "Accessible on ${startReadableTime}" + } else if (dateEnd && !dateStart){ + def endReadableTime = readableDateTime(dateEnd) + return "Accessible until ${endReadableTime}" + } +} + +def readableDateTime(date) { + new Date().parse(smartThingsDateFormat(), date.format(smartThingsDateFormat(), location.timeZone)).format("EEE, MMM d yyyy 'at' h:mma", location.timeZone) +} + +def userHrefTitle(i) { + def title = "User ${i}" + if (settings."userName${i}") { + title = settings."userName${i}" + } + return title +} + +def userHrefDescription(i) { + def uc = settings."userCode${i}" + def us = settings."userSlot${i}" + def usage = state."userState${i}".usage + def description = "" + if (us != null) { + description += "Slot: ${us}" + } + if (uc != null) { + description += " / ${uc}" + if(settings."burnCode${i}") { + description += ' [Single Use]' + } + } + if (usage != null) { + description += " [Usage: ${usage}]" + } + return description +} + +def userPageState(i) { + if (settings."userCode${i}" && userIsEnabled(i)) { + if (settings."burnCode${i}") { + if (state."userState${i}".usage > 0) { + return 'incomplete' + } else { + return 'complete' + } + } else { + return 'complete' + } + + } else if (settings."userCode${i}" && !settings."userEnabled${i}") { + return 'incomplete' + } else { + return 'incomplete' + } +} + +def userIsEnabled(i) { + if (settings."userEnabled${i}" && (settings."userCode${i}" != null) && (state."userState${i}".enabled != false)) { + return true + } else { + return false + } +} + +def fancyDeviceString(devices = []) { + fancyString(devices.collect { deviceLabel(it) }) +} + +def deviceLabel(device) { + return device.label ?: device.name +} + +def fancyString(listOfStrings) { + listOfStrings.removeAll([null]) + def fancify = { list -> + return list.collect { + def label = it + if (list.size() > 1 && it == list[-1]) { + label = "and ${label}" + } + label + }.join(", ") + } + + return fancify(listOfStrings) +} + +def schedulingHrefDescription() { + if (startDateTime() || endDateTime()) { + calendarHrefDescription() + } else { + def descriptionParts = [] + if (days) { + descriptionParts << "On ${fancyString(days)}," + } + + descriptionParts << "${fancyDeviceString(theLocks)} will be accessible" + if ((andOrTime != null) || (modeStart == null)) { + if (startTime) { + descriptionParts << "at ${humanReadableStartDate()}" + } + if (endTime) { + descriptionParts << "until ${humanReadableEndDate()}" + } + } + + if (modeStart) { + if (startTime && andOrTime) { + descriptionParts << andOrTime + } + descriptionParts << "when ${location.name} enters '${modeStart}' mode" + } + + if (descriptionParts.size() <= 1) { + // locks will be in the list no matter what. No rules are set if only locks are in the list + return null + } + return descriptionParts.join(" ") + } +} + +def installed() { + log.debug "Installing 'Locks' with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updating 'Locks' with settings: ${settings}" + initialize() +} + +private initialize() { + unsubscribe() + unschedule() + if (startTime && !startDateTime()) { + log.debug "scheduling access routine to run at ${startTime}" + schedule(startTime, "reconcileCodesStart") + } else if (startDateTime()) { + // There's a start date, so let's run then + log.debug "scheduling RUNONCE start" + runOnce(startDateTime().format(smartThingsDateFormat(), location.timeZone), "reconcileCodesStart") + } + + if (endTime && !endDateTime()) { + log.debug "scheduling access denial routine to run at ${endTime}" + schedule(endTime, "reconcileCodesEnd") + } else if (endDateTime()) { + // There's a end date, so let's run then + log.debug "scheduling RUNONCE end" + runOnce(endDateTime().format(smartThingsDateFormat(), location.timeZone), "reconcileCodesEnd") + } + + subscribe(location, locationHandler) + + subscribe(theLocks, "codeReport", codereturn) + subscribe(theLocks, "lock", codeUsed) + subscribe(theLocks, "reportAllCodes", pollCodeReport, [filterEvents:false]) + if (keypad) { + subscribe(location,"alarmSystemStatus",alarmStatusHandler) + subscribe(keypad,"codeEntered",codeEntryHandler) + } + + revokeDisabledUsers() + reconcileCodes() + lockErrorLoopReset() + initalizeLockData() + + log.debug "state: ${state}" +} + +def resetAllCodeUsage() { + for (int i = 1; i <= settings.maxUsers; i++) { + lockErrorLoopReset() + resetCodeUsage(i) + } + log.debug "reseting all code usage" +} + +def resetCodeUsage(i) { + if(state."userState${i}" == null) { + state."userState${i}" = [:] + state."userState${i}".enabled = true + } + state."userState${i}".usage = 0 +} + +def enableUser(i) { + state."userState${i}".enabled = true +} + +def initalizeLockData() { + theLocks.each { lock-> + if (state."lock${lock.id}" == null) { + state."lock${lock.id}" = [:] + } + } +} + +def lockErrorLoopReset() { + state.error_loop_count = 0 + theLocks.each { lock-> + if (state."lock${lock.id}" == null) { + state."lock${lock.id}" = [:] + } + state."lock${lock.id}".error_loop = false + } +} + + +def locationHandler(evt) { + log.debug "locationHandler evt: ${evt.value}" + if (modeStart) { + reconcileCodes() + } +} + +def reconcileCodes() { + if (isAbleToStart()) { + grantAccess() + } else { + revokeAccess() + } +} + +def reconcileCodesStart() { + // schedule start of reconcileCodes + reconcileCodes() + if (calStartPhrase) { + location.helloHome.execute(calStartPhrase) + } +} + +def reconcileCodesEnd() { + // schedule end of reconcileCodes + reconcileCodes() + if (calEndPhrase) { + location.helloHome.execute(calEndPhrase) + } +} + +def isAbleToStart() { + def dateStart = startDateTime() + def dateEnd = endDateTime() + + if (dateStart || dateEnd) { + // calendar schedule above all + return checkCalendarSchedule(dateStart, dateEnd) + } else if (modeStart || startTime || endTime || days) { + // No calendar set, check daily schedule + if (isCorrectDay()) { + // it's the right day + checkDailySchedule() + } else { + // it's the wrong day + return false + } + } else { + // no schedule + return true + } +} + +def checkDailySchedule() { + if (andOrTime && modeStart && (isCorrectMode() || isInScheduledTime())) { + // in correct mode or time with and/or switch + if (andOrTime == 'and') { + // must be both + if (isCorrectMode() && isInScheduledTime()) { + // is both + return true + } else { + // is not both + return false + } + } else { + // could be either + if (isCorrectMode() || isInScheduledTime()) { + // it is either mode or time + return true + } else { + // is not either mode or time + return false + } + } + } else { + // Allow either mode or time, no andOrTime is set + if (isCorrectMode() || isInScheduledTime()) { + // it is either mode or time + return true + } else { + // is not either mode or time + return false + } + } +} + +def checkCalendarSchedule(dateStart, dateEnd) { + def now = rightNow().getTime() + if (dateStart && !dateEnd) { + // There's a start time, but no end time. Allow access after start + if (dateStart.getTime() > now) { + // It's after the start time + return true + } else { + // It's before the start time + return false + } + + } else if (dateEnd && !dateStart) { + // There's a end time, but no start time. Allow access until end + if (dateStart.getTime() > now) { + // It's after the start time + return true + } else { + // It's before the start time + return false + } + + } else { + // There's both an end time, and a start time. Allow access between them. + if (dateStart.getTime() < now && dateEnd.getTime() > now) { + // It's in calendar times + return true + } else { + // It's not in calendar times + return false + } + } +} + +def isCorrectMode() { + if (modeStart) { + // mode check is on + if (location.mode == modeStart) { + // we're in the right one mode + return true + } else { + // we're in the wrong mode + return false + } + } else { + // mode check is off + return false + } +} + +def isInScheduledTime() { + def now = new Date() + if (startTime && endTime) { + def start = timeToday(startTime) + def stop = timeToday(endTime) + + // there's both start time and end time + if (start.before(now) && stop.after(now)){ + // It's between the times + return true + } else { + // It's not between the times + return false + } + } else if (startTime && !endTime){ + // there's a start time, but no end time + def start = timeToday(startTime) + if (start.before(now)) { + // it's after start time + return true + } else { + //it's before start time + return false + } + } else if (!startTime && endTime) { + // there's an end time but no start time + def stop = timeToday(endTime) + if (stop.after(now)) { + // it's still before end time + return true + } else { + // it's after end time + return false + } + } else { + // there are no times + return false + } +} + +def startDateTime() { + if (startDay && startMonth && startYear && startTime) { + def time = new Date().parse(smartThingsDateFormat(), startTime).format("'T'HH:mm:ss.SSSZ", timeZone(startTime)) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${startYear}-${startMonth}-${startDay}${time}") + } else { + // Start Date Time not set + return false + } +} + +def endDateTime() { + if (endDay && endMonth && endYear && endTime) { + def time = new Date().parse(smartThingsDateFormat(), endTime).format("'T'HH:mm:ss.SSSZ", timeZone(endTime)) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "${endYear}-${endMonth}-${endDay}${time}") + } else { + // End Date Time not set + return false + } +} + +def rightNow() { + def now = new Date().format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone) + return Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", now) +} + +def isCorrectDay() { + def today = new Date().format("EEEE", location.timeZone) + log.debug "today: ${today}, days: ${days}" + if (!days || days.contains(today)) { + // if no days, assume every day + return true + } + log.trace "should not allow access - Not correct Day" + return false +} + +def userSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (settings."userSlot${i}") { + array << settings."userSlot${i}".toInteger() + } + } + return array +} + +def enabledUsersArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (userIsEnabled(i)) { + array << i + } + } + return array +} +def enabledUsersSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (userIsEnabled(i)) { + def userSlot = settings."userSlot${i}" + array << userSlot.toInteger() + } + } + return array +} + +def disabledUsersSlotArray() { + def array = [] + for (int i = 1; i <= settings.maxUsers; i++) { + if (!userIsEnabled(i)) { + if (settings."userSlot${i}") { + array << settings."userSlot${i}".toInteger() + } + } + } + return array +} + +def codereturn(evt) { + def codeNumber = evt.data.replaceAll("\\D+","") + def codeSlot = evt.value + if (notifyAccessEnd || notifyAccessStart) { + if (userSlotArray().contains(evt.integerValue.toInteger())) { + def userName = settings."userName${usedUserIndex(evt.integerValue)}" + if (codeNumber == "") { + if (notifyAccessEnd) { + def message = "${userName} no longer has access to ${evt.displayName}" + if (codeNumber.isNumber()) { + state."lock${evt.deviceId}".codes."slot${codeSlot}" = codeNumber + } + send(message) + } + } else { + if (notifyAccessStart) { + def message = "${userName} now has access to ${evt.displayName}" + if (codeNumber.isNumber()) { + state."lock${evt.deviceId}".codes."slot${codeSlot}" = codeNumber + } + send(message) + } + } + } + } +} + +def usedUserIndex(usedSlot) { + for (int i = 1; i <= settings.maxUsers; i++) { + if (settings."userSlot${i}" && settings."userSlot${i}".toInteger() == usedSlot.toInteger()) { + return i + } + } + return false +} + +def codeUsed(evt) { + // check the status of the lock, helpful for some schlage locks. + runIn(10, doPoll) + log.debug("codeUsed evt.value: " + evt.value + ". evt.data: " + evt.data) + def message = null + + if(evt.value == "unlocked" && evt.data) { + def codeData = new JsonSlurper().parseText(evt.data) + if(codeData.usedCode && codeData.usedCode.isNumber() && userSlotArray().contains(codeData.usedCode.toInteger())) { + def usedIndex = usedUserIndex(codeData.usedCode).toInteger() + def unlockUserName = settings."userName${usedIndex}" + message = "${evt.displayName} was unlocked by ${unlockUserName}" + // increment usage + state."userState${usedIndex}".usage = state."userState${usedIndex}".usage + 1 + if(settings."userHomePhrases${usedIndex}") { + // Specific User Hello Home + if (settings."userNoRunPresence${usedIndex}" && settings."userDoRunPresence${usedIndex}" == null) { + if (!anyoneHome(settings."userNoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else if (settings."userDoRunPresence${usedIndex}" && settings."userNoRunPresence${usedIndex}" == null) { + if (anyoneHome(settings."userDoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else if (settings."userDoRunPresence${usedIndex}" && settings."userNoRunPresence${usedIndex}") { + if (anyoneHome(settings."userDoRunPresence${usedIndex}") && !anyoneHome(settings."userNoRunPresence${usedIndex}")) { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } else { + location.helloHome.execute(settings."userHomePhrases${usedIndex}") + } + } + if(settings."burnCode${usedIndex}") { + theLocks.deleteCode(codeData.usedCode) + runIn(60*2, doPoll) + message += ". Now burning code." + } + //Don't send notification if muted + if(settings."dontNotify${usedIndex}" == true) { + message = null + } + } + } else if(evt.value == "locked" && settings.notifyLock) { + message = "${evt.displayName} has been locked" + } + + if (message) { + log.debug("Sending message: " + message) + send(message) + } + + if (homePhrases) { + performActions(evt) + } +} + +def performActions(evt) { + if(evt.value == "unlocked" && evt.data) { + def codeData = new JsonSlurper().parseText(evt.data) + if(enabledUsersArray().contains(codeData.usedCode) || isManualUnlock(codeData)) { + // Global Hello Home + if(location.currentMode != modeIgnore) { + if (noRunPresence && doRunPresence == null) { + if (!anyoneHome(noRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else if (doRunPresence && noRunPresence == null) { + if (anyoneHome(doRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else if (doRunPresence && noRunPresence) { + if (anyoneHome(doRunPresence) && !anyoneHome(noRunPresence)) { + location.helloHome.execute(homePhrases) + } + } else { + location.helloHome.execute(homePhrases) + } + } else { + def routineMessage = "Already in ${modeIgnore} mode, skipping execution of ${homePhrases} routine." + log.debug routineMessage + send(routineMessage) + } + } + } +} + +def revokeDisabledUsers() { + def array = [] + disabledUsersSlotArray().each { slot -> + array << ["code${slot}", ""] + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def doPoll() { + // this gets codes if custom device is installed + if (!allCodesDone()) { + state.error_loop_count = state.error_loop_count + 1 + } + theLocks.poll() +} + +def grantAccess() { + def array = [] + enabledUsersArray().each { user-> + def userSlot = settings."userSlot${user}" + if (settings."userCode${user}" != null) { + def newCode = settings."userCode${user}" + array << ["code${userSlot}", "${newCode}"] + } else { + array << ["code${userSlot}", ""] + } + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def revokeAccess() { + def array = [] + enabledUsersArray().each { user-> + def userSlot = settings."userSlot${user}" + array << ["code${userSlot}", ""] + } + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + theLocks.updateCodes(json) + runIn(60*2, doPoll) + } +} + +def isManualUnlock(codeData) { + // check to see if the user wants this + if (manualUnlock) { + // garyd9's device type returns 'manual' + if ((codeData.usedCode == "") || (codeData.usedCode == null) || (codeData.usedCode == 'manual')) { + // no code used on unlock! + return true + } else { + // probably a code we're not dealing with here + return false + } + } else { + return false + } +} + +def isActiveBurnCode(slot) { + if (settings."burnCode${slot}" && state."userState${slot}".usage > 0) { + return false + } else { + // not a burn code / not yet used + return true + } +} + +def pollCodeReport(evt) { + def active = isAbleToStart() + def codeData = new JsonSlurper().parseText(evt.data) + def numberOfCodes = codeData.codes + def userSlots = userSlotArray() + + def array = [] + + (1..maxUsers).each { user-> + def slot = settings."userSlot${user}" + def code = codeData."code${slot}" + def correctCode = settings."userCode${user}" + if (active) { + if (userIsEnabled(user) && isActiveBurnCode(user)) { + if (code == settings."userCode${user}") { + // Code is Active, We should be active. Nothing to do + } else { + // Code is incorrect, We should be active. + array << ["code${slot}", settings."userCode${user}"] + } + } else { + if (code != '') { + // Code is set, user is disabled, We should be disabled. + array << ["code${slot}", ""] + } else { + // Code is not set, user is disabled. Nothing to do + } + } + } else { + if (code != '') { + // Code is set, We should be disabled. + array << ["code${slot}", ""] + } else { + // Code is not active, We should be disabled. Nothing to do + } + } + } + + def currentLock = theLocks.find{it.id == evt.deviceId} + populateDiscovery(codeData, currentLock) + + def json = new groovy.json.JsonBuilder(array).toString() + if (json != '[]') { + runIn(60*2, doPoll) + + //Lock is in an error state + state."lock${currentLock.id}".error_loop = true + def error_number = state.error_loop_count + 1 + if (error_number <= 10) { + log.debug "sendCodes fix is: ${json} Error: ${error_number}/10" + currentLock.updateCodes(json) + } else { + log.debug "kill fix is: ${json}" + currentLock.updateCodes(json) + json = new JsonSlurper().parseText(json) + def n = 0 + json.each { code -> + n = code[0][4..-1].toInteger() + def usedIndex = usedUserIndex(n) + def name = settings."userName${usedIndex}" + if (state."userState${usedIndex}".enabled) { + state."userState${usedIndex}".enabled = false + state."userState${usedIndex}".disabledReason = "Controller failed to set code" + send("Controller failed to set code for ${name}") + } + } + } + } else { + state."lock${currentLock.id}".error_loop = false + if (allCodesDone) { + lockErrorLoopReset() + } else { + runIn(60, doPoll) + } + } +} + +def allCodesDone() { + def i = 0 + def codeComplete = true + theLocks.each { lock-> + i++ + if (state."lock${lock.id}".error_loop == true) { + codeComplete = false + } + } + return codeComplete +} + +private anyoneHome(sensors) { + def result = false + if(sensors.findAll { it?.currentPresence == "present" }) { + result = true + } + result +} + +private send(msg) { + if (notificationStartTime != null && notificationEndTime != null) { + def start = timeToday(notificationStartTime) + def stop = timeToday(notificationEndTime) + def now = new Date() + if (start.before(now) && stop.after(now)){ + sendMessage(msg) + } + } else { + sendMessage(msg) + } +} + +private sendMessage(msg) { + if (notification) { + sendPush(msg) + } else { + sendNotificationEvent(msg) + } + if (phone) { + if ( phone.indexOf(";") > 1){ + def phones = phone.split(";") + for ( def i = 0; i < phones.size(); i++) { + sendSms(phones[i], msg) + } + } + else { + sendSms(phone, msg) + } + } +} + +def populateDiscovery(codeData, lock) { + def codes = [:] + def codeSlots = 30 + if (codeData.codes) { + codeSlots = codeData.codes + } + (1..codeSlots).each { slot-> + codes."slot${slot}" = codeData."code${slot}" + } + atomicState."lock${lock.id}".codes = codes +} + +private String getPIN() { + return settings.pin.value.toString().padLeft(4,'0') +} + +def alarmStatusHandler(event) { + log.debug "Keypad manager caught alarm status change: "+event.value + if(keypadstatus) + { + if (event.value == "off"){ + keypad?.each() { it.setDisarmed() } + } + else if (event.value == "away"){ + keypad?.each() { it.setArmedAway() } + } + else if (event.value == "stay") { + keypad?.each() { it.setArmedStay() } + } + } +} + +private sendSHMEvent(String shmState) { + def event = [ + name:"alarmSystemStatus", + value: shmState, + displayed: true, + description: "System Status is ${shmState}" + ] + log.debug "test ${event}" + sendLocationEvent(event) +} + +private execRoutine(armMode) { + if (armMode == 'away') { + location.helloHome?.execute(settings.armRoutine) + } else if (armMode == 'stay') { + location.helloHome?.execute(settings.stayRoutine) + } else if (armMode == 'off') { + location.helloHome?.execute(settings.disarmRoutine) + } +} + +def codeEntryHandler(evt) { + //do stuff + log.debug "Caught code entry event! ${evt.value.value}" + + def codeEntered = evt.value as String + + def data = evt.data as String + def armMode = '' + def currentarmMode = keypad.currentValue("armMode") + def changedMode = 0 + + if (data == '0') { + armMode = 'off' + } + else if (data == '3') { + armMode = 'away' + } + else if (data == '1') { + armMode = 'stay' + } + else if (data == '2') { + armMode = 'stay' //Currently no separate night mode for SHM, set to 'stay' + } else { + log.error "${app.label}: Unexpected arm mode sent by keypad!: "+data + return [] + } + + def i = settings.maxUsers + def message = " " + while (i > 0) { + log.debug "i =" + i + def correctCode = settings."userCode${i}" as String + + if (codeEntered == correctCode) { + + log.debug "User Enabled: " + state."userState${i}".enabled + + if (state."userState${i}".enabled == true) { + log.debug "Correct PIN entered. Change SHM state to ${armMode}" + //log.debug "Delay: ${armDelay}" + //log.debug "Data: ${data}" + //log.debug "armMode: ${armMode}" + + def unlockUserName = settings."userName${i}" + + if (data == "0") { + //log.debug "sendDisarmCommand" + runIn(0, "sendDisarmCommand") + message = "${evt.displayName} was disarmed by ${unlockUserName}" + } + else if (data == "1") { + //log.debug "sendStayCommand" + if(armDelay && keypadstatus) { + keypad?.each() { it.setExitDelay(armDelay) } + } + runIn(armDelay, "sendStayCommand") + message = "${evt.displayName} was armed to 'Stay' by ${unlockUserName}" + } + else if (data == "2") { + //log.debug "sendNightCommand" + if(armDelay && keypadstatus) { + keypad?.each() { it.setExitDelay(armDelay) } + } + runIn(armDelay, "sendNightCommand") + message = "${evt.displayName} was armed to 'Night' by ${unlockUserName}" + } + else if (data == "3") { + //log.debug "sendArmCommand" + if(armDelay && keypadstatus) { + keypad?.each() { it.setExitDelay(armDelay) } + } + runIn(armDelay, "sendArmCommand") + message = "${evt.displayName} was armed to 'Away' by ${unlockUserName}" + } + + if(settings."burnCode${i}") { + state."userState${i}".enabled = false + message += ". Now burning code." + } + + log.debug "${message}" + //log.debug "Initial Usage Count:" + state."userState${i}".usage + state."userState${i}".usage = state."userState${i}".usage + 1 + //log.debug "Final Usage Count:" + state."userState${i}".usage + send(message) + i = 0 + } else if (state."userState${i}".enabled == false){ + log.debug "PIN Disabled" + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + //keypad.sendInvalidKeycodeResponse() + } + } + changedMode = 1 + i-- + } + if (changedMode == 1 && i == 0) { + def errorMsg = "Incorrect Code Entered: ${codeEntered}" + if (notifyIncorrectPin) { + log.debug "Incorrect PIN" + send(errorMsg) + } + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + keypad.sendInvalidKeycodeResponse() + } +} +def sendArmCommand() { + log.debug "Sending Arm Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(3) } + } + sendSHMEvent("away") + execRoutine("away") +} +def sendDisarmCommand() { + log.debug "Sending Disarm Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(0) } + } + sendSHMEvent("off") + execRoutine("off") +} +def sendStayCommand() { + log.debug "Sending Stay Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(1) } + } + sendSHMEvent("stay") + execRoutine("stay") +} +def sendNightCommand() { + log.debug "Sending Night Command." + if (keypadstatus) { + keypad?.each() { it.acknowledgeArmRequest(2) } + } + sendSHMEvent("stay") + execRoutine("stay") +} \ No newline at end of file diff --git a/smartapps/statusbits/smart-alarm.src/smart-alarm.groovy b/smartapps/statusbits/smart-alarm.src/smart-alarm.groovy index 89306d1..e82b66d 100644 --- a/smartapps/statusbits/smart-alarm.src/smart-alarm.groovy +++ b/smartapps/statusbits/smart-alarm.src/smart-alarm.groovy @@ -296,7 +296,10 @@ def pageHistory() { if (history.size() == 0) { paragraph "No history available." } else { - paragraph "Not implemented" + history.each() { + def text = "" + new Date(it.time + location.timeZone.rawOffset ).format("yyyy-MM-dd HH:mm") + ": " + it.event + " - " + it.description + paragraph text + } } } } @@ -405,6 +408,7 @@ def pageConfigureZones() { section("${it.displayName} (contact)") { input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"exterior" input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:true + input "chime_${devId}", "bool", title:"Chime on open", defaultValue:true } } } @@ -416,6 +420,7 @@ def pageConfigureZones() { section("${it.displayName} (motion)") { input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"interior" input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + input "chime_${devId}", "bool", title:"Chime on motion", defaultValue:false } } } @@ -427,6 +432,7 @@ def pageConfigureZones() { section("${it.displayName} (movement)") { input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"interior" input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + input "chime_${devId}", "bool", title:"Chime on movement", defaultValue:false } } } @@ -438,6 +444,7 @@ def pageConfigureZones() { section("${it.displayName} (smoke)") { input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"alert" input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + input "chime_${devId}", "bool", title:"Chime on smoke", defaultValue:false } } } @@ -449,6 +456,7 @@ def pageConfigureZones() { section("${it.displayName} (moisture)") { input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"alert" input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + input "chime_${devId}", "bool", title:"Chime on water", defaultValue:false } } } @@ -471,6 +479,14 @@ def pageArmingOptions() { "is armed without setting off an alarm. You can optionally disable " + "entry and exit delay when the alarm is armed in Stay mode." + def inputKeypads = [ + name: "keypads", + type: "capability.lockCodes", + title: "Keypads for Exit / Entry delay", + multiple: true, + required: false + ] + def inputAwayModes = [ name: "awayModes", type: "mode", @@ -507,10 +523,19 @@ def pageArmingOptions() { def inputDelayStay = [ name: "stayDelayOff", type: "bool", - title: "Disable delays in Stay mode", + title: "Disable alarm (entry) delay in Stay mode", defaultValue: false, required: true ] + + def inputExitDelayStay = [ + name: "stayExitDelayOff", + type: "bool", + title: "Disable arming (exit) delay in Stay mode", + defaultValue: true, + required: true + ] + def pageProperties = [ name: "pageArmingOptions", @@ -524,16 +549,21 @@ def pageArmingOptions() { paragraph helpArming } + section("Keypads") { + input inputKeypads + } + section("Modes") { input inputAwayModes input inputStayModes input inputDisarmModes } - + section("Exit and Entry Delay") { paragraph helpDelay input inputDelay input inputDelayStay + input inputExitDelayStay } } } @@ -563,6 +593,14 @@ def pageAlarmOptions() { defaultValue: "Both" ] + def inputSirenEntryStrobe = [ + name: "sirenEntryStrobe", + type: "bool", + title: "Strobe siren during entry delay", + defaultValue: true, + required: true + ] + def inputSwitches = [ name: "switches", type: "capability.switch", @@ -602,6 +640,7 @@ def pageAlarmOptions() { section("Sirens") { input inputAlarms input inputSirenMode + input inputSirenEntryStrobe } section("Switches") { input inputSwitches @@ -624,7 +663,49 @@ def pageNotifications() { "disarmed or when an alarm is set off. Notifications can be send " + "using either Push messages, SMS (text) messages and Pushbullet " + "messaging service. Smart Alarm can also notify you with sounds or " + - "voice alerts using compatible audio devices, such as Sonos." + "voice alerts using compatible audio devices, such as Sonos." + + "Or using a SmartAlarm dashboard virtual device." + + def inputNotificationDevice = [ + name: "notificationDevice", + type: "capability.notification", + title: "Which smart alarm notification device?", + multiple: false, + required: false + ] + + def inputChimeDevices = [ + name: "chimeDevices", + type: "capability.tone", + title: "Which Chime Devices?", + multiple: true, + required: false + ] + + + def inputSirenOnWaterAlert = [ + name: "sirenOnWaterAlert", + type: "bool", + title: "Use Siren for Water Leak?", + defaultValue: true, + required: true + ] + + def inputSirenOnSmokeAlert = [ + name: "sirenOnSmokeAlert", + type: "bool", + title: "Use Siren for Smoke Alert?", + defaultValue: true, + required: true + ] + + def inputSirenOnIntrusionAlert = [ + name: "sirenOnIntrusionAlert", + type: "bool", + title: "Use Siren for Intrusion Alert?", + defaultValue: true, + required: true + ] def inputPushAlarm = [ name: "pushMessage", @@ -807,6 +888,19 @@ def pageNotifications() { section("Notification Options") { paragraph helpAbout } + section("Notification Device") + { + input inputNotificationDevice + } + section("Chime Devices") { + input inputChimeDevices + } + section("Siren Notifcations") + { + input inputSirenOnWaterAlert + input inputSirenOnSmokeAlert + input inputSirenOnIntrusionAlert + } section("Push Notifications") { input inputPushAlarm input inputPushStatus @@ -1047,6 +1141,7 @@ private def setupInit() { state.zones = [] state.alarms = [] state.history = [] + state.alertType = "None" } else { def version = state.version as String if (version == null || version.startsWith('1')) { @@ -1065,7 +1160,7 @@ private def initialize() { clearAlarm() state.delay = settings.delay?.toInteger() ?: 30 state.offSwitches = [] - state.history = [] + //state.history = [] if (settings.awayModes?.contains(location.mode)) { state.armed = true @@ -1082,8 +1177,20 @@ private def initialize() { initButtons() initRestApi() subscribe(location, onLocation) + + if (settings.notificationDevice) + { + subscribe(settings.notificationDevice, "switch.off", gotDismissMessage) + } STATE() + reportStatus() +} + +def gotDismissMessage(evt) +{ + log.debug "Got the dismiss message from the notification device.. clearing alarm!" + clearAlarm() } private def clearAlarm() { @@ -1103,6 +1210,7 @@ private def clearAlarm() { } state.offSwitches = [] } + reportStatus() } private def initZones() { @@ -1123,7 +1231,8 @@ private def initZones() { deviceId: it.id, sensorType: "contact", zoneType: settings["type_${it.id}"] ?: "exterior", - delay: settings["delay_${it.id}"] + delay: settings["delay_${it.id}"], + chime: settings["chime_${it.id}"] ] } subscribe(settings.z_contact, "contact.open", onContact) @@ -1135,7 +1244,8 @@ private def initZones() { deviceId: it.id, sensorType: "motion", zoneType: settings["type_${it.id}"] ?: "interior", - delay: settings["delay_${it.id}"] + delay: settings["delay_${it.id}"], + chime: settings["chime_${it.id}"] ] } subscribe(settings.z_motion, "motion.active", onMotion) @@ -1147,7 +1257,8 @@ private def initZones() { deviceId: it.id, sensorType: "acceleration", zoneType: settings["type_${it.id}"] ?: "interior", - delay: settings["delay_${it.id}"] + delay: settings["delay_${it.id}"], + chime: settings["chime_${it.id}"] ] } subscribe(settings.z_movement, "acceleration.active", onMovement) @@ -1159,7 +1270,8 @@ private def initZones() { deviceId: it.id, sensorType: "smoke", zoneType: settings["type_${it.id}"] ?: "alert", - delay: settings["delay_${it.id}"] + delay: settings["delay_${it.id}"], + chime: settings["chime_${it.id}"] ] } subscribe(settings.z_smoke, "smoke.detected", onSmoke) @@ -1174,7 +1286,8 @@ private def initZones() { deviceId: it.id, sensorType: "water", zoneType: settings["type_${it.id}"] ?: "alert", - delay: settings["delay_${it.id}"] + delay: settings["delay_${it.id}"], + chime: settings["chime_${it.id}"] ] } subscribe(settings.z_water, "water.wet", onWater) @@ -1257,20 +1370,37 @@ def onWater(evt) { onZoneEvent(evt, "water") } private def onZoneEvent(evt, sensorType) { LOG("onZoneEvent(${evt.displayName}, ${sensorType})") + state.alertType = sensorType def zone = getZoneForDevice(evt.deviceId, sensorType) if (!zone) { log.warn "Cannot find zone for device ${evt.deviceId}" + state.alertType = "None" return } if (zone.armed) { state.alarms << evt.displayName if (zone.zoneType == "alert" || !zone.delay || (state.stay && settings.stayDelayOff)) { + history("Alarm", "Alarm triggered by ${sensorType} sensor ${evt.displayName}") activateAlarm() } else { + history("Entry Delay", "Entry delay triggered by ${sensorType} sensor ${evt.displayName}") + if(settings.sirenEntryStrobe) + { + settings.alarms*.strobe() + } + keypads?.each() { it.setEntryDelay(state.delay) } myRunIn(state.delay, activateAlarm) } } + else if (zone.chime) + { + chimeDevices?.each() { + it.beep() + } + } + + } def onLocation(evt) { @@ -1311,23 +1441,27 @@ def onButtonEvent(evt) { def armAway() { LOG("armAway()") + history("Armed Away", "Alarm armed away") if (!atomicState.armed || atomicState.stay) { armPanel(false) } + reportStatus() } def armStay() { LOG("armStay()") + history("Armed Stay", "Alarm armed stay") if (!atomicState.armed || !atomicState.stay) { armPanel(true) } + reportStatus() } def disarm() { LOG("disarm()") - + history("Disarmed", "Alarm disarmed") if (atomicState.armed) { state.armed = false state.zones.each() { @@ -1335,9 +1469,12 @@ def disarm() { it.armed = false } } + + keypads?.each() { it.setDisarmed() } reset() } + reportStatus() } def panic() { @@ -1364,6 +1501,7 @@ def reset() { notify(msg) notifyVoice() + reportStatus() } def exitDelayExpired() { @@ -1383,6 +1521,16 @@ def exitDelayExpired() { it.armed = true } } + + if(stay) + { + keypads?.each() { it.setArmedStay() } + } + else + { + keypads?.each() { it.setArmedAway() } + } + def msg = "${location.name}: all " if (stay) { @@ -1406,7 +1554,7 @@ private def armPanel(stay) { state.zones.each() { def zoneType = it.zoneType if (zoneType == "exterior") { - if (it.delay) { + if (it.delay && !(stay && settings.stayExitDelayOff)) { it.armed = false armDelay = true } else { @@ -1424,10 +1572,22 @@ private def armPanel(stay) { } } - def delay = armDelay && !(stay && settings.stayDelayOff) ? atomicState.delay : 0 + def delay = armDelay && !(stay && settings.stayExitDelayOff) ? atomicState.delay : 0 if (delay) { + keypads?.each() { it.setExitDelay(delay) } myRunIn(delay, exitDelayExpired) } + else + { + if(stay) + { + keypads?.each() { it.setArmedStay() } + } + else + { + keypads?.each() { it.setArmedAway() } + } + } def mode = stay ? "STAY" : "AWAY" def msg = "${location.name} " @@ -1532,21 +1692,50 @@ def activateAlarm() { log.warn "activateAlarm: false alarm" return } + history("Alarm", "Alarm Triggered") - switch (settings.sirenMode) { - case "Siren": - settings.alarms*.siren() - break - - case "Strobe": - settings.alarms*.strobe() - break - - case "Both": - settings.alarms*.both() - break + if(settings.sirenEntryStrobe) + { + settings.alarms*.off() } + + def atype = state.alertType + if ((atype == "Water" && settings.sirenOnWaterAlert) || + (atype == "Smoke" && settings.sirenOnSmokeAlert) || + ((atype == "contact" || atype == "acceleration" || atype == "motion") && settings.sirenOnIntrusionAlert)) + { + switch (settings.sirenMode) { + case "Siren": + settings.alarms*.siren() + break + + case "Strobe": + settings.alarms*.strobe() + break + + case "Both": + settings.alarms*.both() + break + } + } + else + { + log.debug "No siren for $atype Alert" + } +} + +def activateAlarmPostDelay(String lastAlertType) +{ +// no alarm check here as if door opens only for second with delay and system is not disarmed +// we still want alarm even if door is closed after delay.. Basically like real alarm the delay is only +// to disarm the system. Otherwise someone can open door come it, quickly close and there is not alarm LGK. + +// issue here is that after delay we could have lost the alert type so pass it in + log.debug "activate alarm post delay check - alert type = $lastAlertType" + + activateSirenAfterCheck(lastAlertType) + // Only turn on those switches that are currently off def switchesOn = settings.switches?.findAll { it?.currentSwitch == "off" } LOG("switchesOn: ${switchesOn}") @@ -1570,6 +1759,8 @@ def activateAlarm() { notify(msg) notifyVoice() + reportStatus() + myRunIn(180, reset) } @@ -1668,12 +1859,61 @@ private def notifyVoice() { } } +def reportStatus() +{ + log.debug "in report status" + log.debug "notification device = ${settings.notificationDevice}" + + if (settings.notificationDevice) + { + def phrase = "" + if (state.alarms.size()) + { + phrase = "Alert: Alarm at ${location.name}!" + notificationDevice.deviceNotification(phrase) + log.debug "sending notification alert: = $phrase" + def zones = "Zones: " + state.alarms.each() + { + //log.debug "in loop it" + //log.debug "it = $it" + zones = "Zones: " + zones += " $it" +"\n" + } + notificationDevice.deviceNotification(zones) + log.debug "sending nofication zones = $zones" + + // send zone type + phrase = "AlertType: " + def atype = state.alertType + if (atype == null) + atype = "None" + phrase += " $atype" + notificationDevice.deviceNotification(phrase) + log.debug "sending nofication alert type = $phrase" + } + else + { + phrase = "Status: " + if (state.armed) + { + def mode = state.stay ? "Armed - Stay" : "Armed - Away" + phrase += "${mode}" + } else { + phrase += "Disarmed" + } + log.debug "sending notification status = $phrase" + notificationDevice.deviceNotification(phrase) + } + } + } + private def history(String event, String description = "") { LOG("history(${event}, ${description})") def history = atomicState.history history << [time: now(), event: event, description: description] - if (history.size() > 10) { + if (history.size() > 20) { history = history.sort{it.time} history = history[1..-1] } @@ -1850,3 +2090,27 @@ private def LOG(message) { private def STATE() { //log.trace "state: ${state}" } + +def onAlarmSystemStatus(evt) { + LOG("Alarm System Status has been changed to '${evt.value}'") + String mode = evt.value.toLowerCase() + if (mode == "away") { + armAway() + } else if (mode == "stay") { + armStay() + } else if (mode == "off") { + disarm() + } +} + +def setAlarmMode(name) { + LOG("Alarm System Status will be set to '${name}'") + def event = [ + name: "alarmSystemStatus", + value: name, + isStateChange: true, + displayed: true, + description: "alarm system status is ${name}", + ] + sendLocationEvent(event) +} \ No newline at end of file