From 29c9db1eb8c5f0e10e545b692227d4244fb308a7 Mon Sep 17 00:00:00 2001 From: Andrey Georgiev Date: Thu, 13 Apr 2023 01:38:56 +0300 Subject: [PATCH] s --- smartapps/ady624/core.src/core.groovy | 10998 ++++++++++++++++ smartapps/ady624/webcore.src/webcore.groovy | 2979 +++++ .../shm-delay-child.groovy | 1328 ++ .../shm-delay-modefix.groovy | 440 + .../shm-delay-simkypd-child.groovy | 362 + .../shm-delay-talker-child.groovy | 369 + .../shm-delay-user.src/shm-delay-user.groovy | 754 ++ .../arnbme/shm-delay.src/shm-delay.groovy | 1700 +++ .../sensibo-connect.groovy | 1094 ++ .../sonoff-connect.src/sonoff-connect.groovy | 415 + .../garadget-connect.groovy | 412 + .../sonos-door-knocker.groovy | 110 + .../tado-connect.src/tado-connect.groovy | 2135 +++ .../tasmota-connect.groovy | 753 ++ .../plex-manager.src/plex-manager.groovy | 495 + .../vacation-lighting-director.groovy | 620 + .../smart-porch-light.groovy | 705 + .../thermostat-manager.groovy | 974 ++ .../konnected-connect.groovy | 85 + .../konnected-service-manager.groovy | 545 + .../group.src/group.groovy | 312 + .../trend-setter.src/trend-setter.groovy | 55 + .../siren-beep.src/siren-beep.groovy | 45 + .../ask-alexa.src/ask-alexa.groovy | 4084 ++++++ .../smart-room-lighting-and-dimming.groovy | 133 + .../3400-x-keypad-manager.groovy | 129 + .../alexa-connect.src/alexa-connect.groovy | 439 + .../mediarenderer-connect.groovy | 587 + .../generic-video-camera-child.groovy | 82 + .../generic-video-camera-connect.groovy | 57 + .../bigtalker2.src/bigtalker2.groovy | 3557 +++++ ...e-door-open-and-close-automatically.groovy | 675 + .../lock-user-management.groovy | 4295 ++++++ ...ow-battery-monitor-and-notification.groovy | 405 + .../mode-change-actions.groovy | 977 ++ .../routines-backup.groovy | 846 ++ .../smartweather-station-tile-updater.groovy | 193 + ...lock-door-notifications-and-actions.groovy | 2000 +++ .../hubconnect-remote-client.groovy | 2397 ++++ .../kodi-manager-callbacks.groovy | 494 + .../echo-speaks.src/echo-speaks.groovy | 2534 ++++ .../st-community-installer.groovy | 162 + .../kuku-harmony.src/kuku-harmony.groovy | 821 ++ 43 files changed, 52552 insertions(+) create mode 100644 smartapps/ady624/core.src/core.groovy create mode 100644 smartapps/ady624/webcore.src/webcore.groovy create mode 100644 smartapps/arnbme/shm-delay-child.src/shm-delay-child.groovy create mode 100644 smartapps/arnbme/shm-delay-modefix.src/shm-delay-modefix.groovy create mode 100644 smartapps/arnbme/shm-delay-simkypd-child.src/shm-delay-simkypd-child.groovy create mode 100644 smartapps/arnbme/shm-delay-talker-child.src/shm-delay-talker-child.groovy create mode 100644 smartapps/arnbme/shm-delay-user.src/shm-delay-user.groovy create mode 100644 smartapps/arnbme/shm-delay.src/shm-delay.groovy create mode 100644 smartapps/ericg66/sensibo-connect.src/sensibo-connect.groovy create mode 100644 smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy create mode 100644 smartapps/fuzzysb/garadget-connect.src/garadget-connect.groovy create mode 100644 smartapps/fuzzysb/sonos-door-knocker.src/sonos-door-knocker.groovy create mode 100644 smartapps/fuzzysb/tado-connect.src/tado-connect.groovy create mode 100644 smartapps/hongtat/tasmota-connect.src/tasmota-connect.groovy create mode 100644 smartapps/ibeech/plex-manager.src/plex-manager.groovy create mode 100644 smartapps/imnotbob/vacation-lighting-director.src/vacation-lighting-director.groovy create mode 100644 smartapps/jdiben/smart-porch-light.src/smart-porch-light.groovy create mode 100644 smartapps/jmarkwell/thermostat-manager.src/thermostat-manager.groovy create mode 100644 smartapps/konnected-io/konnected-connect.src/konnected-connect.groovy create mode 100644 smartapps/konnected-io/konnected-service-manager.src/konnected-service-manager.groovy create mode 100644 smartapps/kriskit-trendsetter/group.src/group.groovy create mode 100644 smartapps/kriskit-trendsetter/trend-setter.src/trend-setter.groovy create mode 100644 smartapps/kristopherkubicki/siren-beep.src/siren-beep.groovy create mode 100644 smartapps/michaelstruck/ask-alexa.src/ask-alexa.groovy create mode 100644 smartapps/michaelstruck/smart-room-lighting-and-dimming.src/smart-room-lighting-and-dimming.groovy create mode 100644 smartapps/mitchpond/3400-x-keypad-manager.src/3400-x-keypad-manager.groovy create mode 100644 smartapps/mujica/alexa-connect.src/alexa-connect.groovy create mode 100644 smartapps/mujica/mediarenderer-connect.src/mediarenderer-connect.groovy create mode 100644 smartapps/pstuart/generic-video-camera-child.src/generic-video-camera-child.groovy create mode 100644 smartapps/pstuart/generic-video-camera-connect.src/generic-video-camera-connect.groovy create mode 100644 smartapps/rayzurbock/bigtalker2.src/bigtalker2.groovy create mode 100644 smartapps/rboy/garage-door-open-and-close-automatically.src/garage-door-open-and-close-automatically.groovy create mode 100644 smartapps/rboy/lock-user-management.src/lock-user-management.groovy create mode 100644 smartapps/rboy/low-battery-monitor-and-notification.src/low-battery-monitor-and-notification.groovy create mode 100644 smartapps/rboy/mode-change-actions.src/mode-change-actions.groovy create mode 100644 smartapps/rboy/routines-backup.src/routines-backup.groovy create mode 100644 smartapps/rboy/smartweather-station-tile-updater.src/smartweather-station-tile-updater.groovy create mode 100644 smartapps/rboy/user-unlock-lock-door-notifications-and-actions.src/user-unlock-lock-door-notifications-and-actions.groovy create mode 100644 smartapps/shackrat/hubconnect-remote-client.src/hubconnect-remote-client.groovy create mode 100644 smartapps/toliver182/kodi-manager-callbacks.src/kodi-manager-callbacks.groovy create mode 100644 smartapps/tonesto7/echo-speaks.src/echo-speaks.groovy create mode 100644 smartapps/tonesto7/st-community-installer.src/st-community-installer.groovy create mode 100644 smartapps/turlvo/kuku-harmony.src/kuku-harmony.groovy diff --git a/smartapps/ady624/core.src/core.groovy b/smartapps/ady624/core.src/core.groovy new file mode 100644 index 00000000000..0f936dfb777 --- /dev/null +++ b/smartapps/ady624/core.src/core.groovy @@ -0,0 +1,10998 @@ +/** + * CoRE - Community's own Rule Engine + * + * Copyright 2016 Adrian Caramaliu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Version history + */ +def version() { return "v0.3.16f.20180908" } +/* + * 09/08/2018 >>> v0.3.16f.20180908 - RC - Fixed blank screens while creating or editing pistons caused by a minor incompatibility with Groovy upgrade + * 12/19/2017 >>> v0.3.16e.20171219 - RC - Replaced recovery safety nets using unschedule() with a much more optimized method that does not affect ST as much as unschedule() does + * 08/28/2017 >>> v0.3.16d.20170828 - RC - Fixed a problem where the value for emergencyHeat() was mistakenly set to "emergencyHeat" instead of "emergency heat" - thanks @RBoy + * 06/07/2017 >>> v0.3.16c.20170607 - RC - Extended setVideoLength to 120s for Blink cameras + * 05/15/2017 >>> v0.3.16b.20170515 - RC - Disable running/paused piston counts on main page to speed up load process + * 04/17/2017 >>> v0.3.16a.20170417 - RC - Fixed a problem with internal HTTP requests passing query strings instead of body - thank you @destructure00 + * 01/04/2017 >>> v0.3.169.20170104 - RC - Moved colors() Map into core ST color utility to reduce Class file size and avoid Class file too large errors + * 12/20/2016 >>> v0.3.168.20161220 - RC - Fixed a bug with loading attributes coolingSetpoint and heatingSetpoint from variables, thank you @bridaus for pointing it out, also extended conditional time options to 360 minutes + * 12/06/2016 >>> v0.3.167.20161206 - RC - Added some capabilities back - Light Bulb - removed Step Sensor as there is no more room :( + * 11/21/2016 >>> v0.3.166.20161120 - RC - Added some capabilities back - had to remove some to make room for EchoSistant - CoRE is now reaching the max code base limit + * 11/20/2016 >>> v0.3.165.20161120 - RC - DO NOT UPGRADE TO THIS UNLESS REQUESTED TO - Added support for EchoSistant, also fixed some bug with httpRequest (and added some extra logs) + * 11/18/2016 >>> v0.3.164.20161118 - RC - Fixed a loose type casting causing Android ST 2.2.2 to fail - thank you @rappleg for the fix, also now encoding uri for web requests - may break things + * 11/02/2016 >>> v0.3.163.20161102 - RC - Adjustments to better fit the Ring integration - assuming 1 button if no numberOfButtons (may break other DTH implementations), assuming button #1 pushed if no buttonNumber is provided + * 10/28/2016 >>> v0.3.162.20161028 - RC - Minor speed improvement for getNextConditionId() + * 10/27/2016 >>> v0.3.161.20161027 - RC - Fixed a bug affecting the queueAskAlexaMessage virtual command task + * 10/14/2016 >>> v0.3.160.20161014 - RC - Fixed a bug not allowing Set color to work when using HSL instead of a simple color. Compliments to @simonselmer + * 10/04/2016 >>> v0.3.15f.20161004 - RC - Code trim down to avoid "Class file too large!" error in JVM + * 10/03/2016 >>> v0.3.15e.20161003 - RC - Fixed a problem where latching pistons would not allow both conditional blocks to run for Simulate, Execute, Follow Up + * 10/02/2016 >>> v0.3.15d.20161002 - RC - Added some logging for LIFX integration + * 10/01/2016 >>> v0.3.15c.20161001 - RC - Added LIFX integration + * 9/28/2016 >>> v0.3.15b.20160928 - RC - Fix for internal web requests - take 2 + * 9/28/2016 >>> v0.3.15a.20160928 - RC - Fix for internal web requests + * 9/28/2016 >>> v0.3.159.20160928 - RC - Added low(), med(), and high() support (standard command instead of custom) for the zwave fan speed control + * 9/28/2016 >>> v0.3.158.20160928 - RC - Minor fixes where state.app or state.config.app was not yet initialized - though I could not replicate the issue + * 9/28/2016 >>> v0.3.157.20160928 - RC - Added support for local http requests - simply use a local IP in the HTTP request and CoRE will use the hub for that request - don't expect any results back yet :( + * 9/27/2016 >>> v0.3.156.20160927 - RC - Fixed a bug that was bleeding the time from offset into the time to for piston restrictions + * 9/26/2016 >>> v0.3.155.20160926 - RC - Added lock user codes support and cancel on condition state change + * 9/21/2016 >>> v0.3.154.20160921 - RC - DO NOT UPDATE TO THIS UNLESS REQUESTED TO - Lock user codes tested OK, adding "Cancel on condition state change", testing + * 9/21/2016 >>> v0.3.153.20160921 - RC - DO NOT UPDATE TO THIS UNLESS REQUESTED TO - Improved support for lock user codes + * 9/21/2016 >>> v0.3.152.20160921 - RC - DO NOT UPDATE TO THIS UNLESS REQUESTED TO - Added support for lock user codes + * 9/20/2016 >>> v0.3.151.20160920 - RC - Release Candidate is here! Added Pause/Resume Piston tasks + */ + +/******************************************************************************/ +/*** CoRE DEFINITION ***/ +/******************************************************************************/ + +definition( + name: "CoRE${parent ? " - Piston" : ""}", + namespace: "ady624", + author: "Adrian Caramaliu", + description: "CoRE - Community's own Rule Engine", + singleInstance: true, + parent: parent ? "ady624.CoRE" : null, + category: "Convenience", + iconUrl: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/app-CoRE.png", + iconX2Url: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/app-CoRE@2x.png", + iconX3Url: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/app-CoRE@2x.png" +) + +preferences { + //common pages + page(name: "pageMain") + page(name: "pageViewVariable") + page(name: "pageDeleteVariable") + page(name: "pageRemove") + + //CoRE pages + page(name: "pageInitializeDashboard") + page(name: "pageStatistics") + page(name: "pagePistonStatistics") + page(name: "pageChart") + page(name: "pageGlobalVariables") + page(name: "pageGeneralSettings") + page(name: "pageDashboardTaps") + page(name: "pageDashboardTap") + page(name: "pageIntegrateIFTTT") + page(name: "pageIntegrateIFTTTConfirm") + page(name: "pageIntegrateLIFX") + page(name: "pageIntegrateLIFXConfirm") + page(name: "pageResetSecurityToken") + page(name: "pageResetSecurityTokenConfirm") + page(name: "pageRecoverAllPistons") + page(name: "pageRebuildAllPistons") + + //Piston pages + page(name: "pageIf") + page(name: "pageIfOther") + page(name: "pageThen") + page(name: "pageElse") + page(name: "pageCondition") + page(name: "pageConditionGroupL1") + page(name: "pageConditionGroupL2") + page(name: "pageConditionGroupL3") + page(name: "pageConditionGroupL4") + page(name: "pageConditionGroupL5") + page(name: "pageActionGroup") + page(name: "pageAction") + page(name: "pageActionDevices") + page(name: "pageVariables") + page(name: "pageSetVariable") + page(name: "pageSimulate") + page(name: "pageRebuild") + page(name: "pageToggleEnabled") + page(name: "pageInitializeVariable") + page(name: "pageInitializedVariable") + page(name: "pageInitializeVariable") + page(name: "pageInitializedVariable") +} + +/******************************************************************************/ +/*** CoRE CONSTANTS ***/ +/******************************************************************************/ + +private triggerPrefix() { return "● " } + +private conditionPrefix() { return "◦ " } + +private virtualCommandPrefix() { return "● " } + +private customAttributePrefix() { return "⌂ " } + +private customCommandPrefix() { return "⌂ " } + +private customCommandSuffix() { return "(..)" } + +/******************************************************************************/ +/*** ***/ +/*** CONFIGURATION PAGES ***/ +/*** ***/ +/******************************************************************************/ + +/******************************************************************************/ +/*** COMMON PAGES ***/ +/******************************************************************************/ +def pageMain() { + parent ? pageMainCoREPiston() : pageMainCoRE() +} + +def pageViewVariable(params) { + def var = params?.var + dynamicPage(name: "pageViewVariable", title: "", uninstall: false, install: false) { + if (var) { + section() { + paragraph var, title: "Variable name", required: false + def value = getVariable(var) + if (value == null) { + paragraph "Undefined value (null)", title: "Oh-oh", required: false + } else { + def type = "string" + if (value instanceof Boolean) { + type = "boolean" + } else if ((value instanceof Long) && (value >= 999999999999)) { + type = "time" + } else if ((value instanceof Float) || ((value instanceof String) && value.isFloat())) { + type = "decimal" + } else if ((value instanceof Integer) || ((value instanceof String) && value.isInteger())) { + type = "number" + } + paragraph "$type", title: "Data type", required: false + paragraph "$value", title: "Raw value", required: false + value = getVariable(var, true) + paragraph "$value", title: "Display value", required: false + } + if (!var.startsWith("\$")) { + href "pageDeleteVariable", title: "Delete variable", description: "CAUTION: Tapping here will delete this variable and its value", params: [var: var], required: false + } + } + } else { + section() { + paragraph "Sorry, variable not found.", required: false + } + } + } +} + +def pageDeleteVariable(params) { + def var = params?.var + dynamicPage(name: "pageInitializedVariable", title: "", uninstall: false, install: false) { + if (var != null) { + section() { + deleteVariable(var) + paragraph "Variable {$var} was successfully deleted.\n\nPlease tap < or Done to continue.", title: "Success", required: false + } + } else { + section() { + paragraph "Sorry, variable not found.", required: false + } + } + } +} + +def pageRemove() { + dynamicPage(name: "pageRemove", title: "", install: false, uninstall: true) { + section() { + paragraph parent ? "CAUTION: You are about to remove the '${app.label}' piston. This action is irreversible. If you are sure you want to do this, please tap on the Remove button below." : "CAUTION: You are about to completely remove CoRE and all of its pistons. This action is irreversible. If you are sure you want to do this, please tap on the Remove button below.", required: true, state: null + } + } +} + +/******************************************************************************/ +/*** CoRE PAGES ***/ +/******************************************************************************/ +private pageMainCoRE() { + initializeCoREStore() + rebuildTaps() + //CoRE main page + dynamicPage(name: "pageMain", title: "", install: true, uninstall: false) { + section() { + if (!state.endpoint) { + href "pageInitializeDashboard", title: "CoRE Dashboard", description: "Tap here to initialize the CoRE dashboard", image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/icons/dashboard.png", required: false + } else { + //reinitialize endpoint + initializeCoREEndpoint() + def url = "${state.endpoint}dashboard" + debug "Dashboard URL: $url *** DO NOT SHARE THIS LINK WITH ANYONE ***", null, "info" + href "", title: "CoRE Dashboard", style: "external", url: url, image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/icons/dashboard.png", required: false + } + } + /* removed to allow execution with large number of pistons - ST lowered the timeout from 60s to 20s, causing this to fail. A LOT. + section() { + def apps = getChildApps().sort{ it.label } + def running = apps.findAll{ it.getPistonEnabled() }.size() + def paused = apps.size - running + if (running + paused == 0) { + paragraph "You have not created any pistons yet.", required: false + } else { + paragraph "You have ${running ? running + ' running ' + (paused ? ' and ' : '') : ''}${paused ? paused + ' paused ' : ''}piston${running + paused > 0 ? 's' : ''}.", required: false + } + } + */ + section() { + app( name: "pistons", title: "Add a CoRE piston...", appName: "CoRE", namespace: "ady624", multiple: true, uninstall: false, image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/icons/piston.png") + } + + section(title:"Application Info") { + href "pageGlobalVariables", title: "Global Variables", image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/icons/variables.png", required: false + href "pageStatistics", title: "Runtime Statistics", image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/icons/statistics.png", required: false + } + + section(title:"") { + href "pageGeneralSettings", title: "Settings", image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/icons/settings.png", required: false + } + + } +} + +private pageInitializeDashboard() { + //CoRE Dashboard initialization + def success = initializeCoREEndpoint() + dynamicPage(name: "pageInitializeDashboard", title: "") { + section() { + if (success) { + paragraph "Success! Your CoRE dashboard is now enabled. Tap Done to continue", required: false + } else { + paragraph "Please go to your SmartThings IDE, select the My SmartApps section, click the 'Edit Properties' button of the CoRE app, open the OAuth section and click the 'Enable OAuth in Smart App' button. Click the Update button to finish.\n\nOnce finished, tap Done and try again.", title: "Please enable OAuth for CoRE", required: true, state: null + } + } + } +} + +def pageGeneralSettings(params) { + dynamicPage(name: "pageGeneralSettings", title: "General Settings", install: false, uninstall: false) { + section("About") { + paragraph app.version(), title: "CoRE Version", required: false + label name: "name", title: "Name", state: (name ? "complete" : null), defaultValue: app.name, required: false + } + + section(title: "Dashboard") { + href "pageDashboardTaps", title: "Taps", description: "Edit the list of taps on the dashboard", required: false, image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/tap.png" + input "dashboardTheme", "enum", options: ["Classic", "Experimental"], title: "Dashboard theme", defaultValue: "Experimental", required: false + } + + section(title: "Expert Features") { + input "expertMode", "bool", title: "Expert Mode", defaultValue: false, submitOnChange: true, required: false + } + + section(title: "Debugging") { + input "debugging", "bool", title: "Enable debugging", defaultValue: false, submitOnChange: true, required: false + def debugging = settings.debugging + if (debugging) { + input "log#info", "bool", title: "Log info messages", defaultValue: true, required: false + input "log#trace", "bool", title: "Log trace messages", defaultValue: true, required: false + input "log#debug", "bool", title: "Log debug messages", defaultValue: false, required: false + input "log#warn", "bool", title: "Log warning messages", defaultValue: true, required: false + input "log#error", "bool", title: "Log error messages", defaultValue: true, required: false + } + } + + section("CoRE Integrations") { + def iftttConnected = state.modules && state.modules["IFTTT"] && settings["iftttEnabled"] && state.modules["IFTTT"].connected + href "pageIntegrateIFTTT", title: "IFTTT", description: iftttConnected ? "Connected" : "Not configured", state: (iftttConnected ? "complete" : null), submitOnChange: true, required: false + def lifxConnected = state.modules && state.modules["LIFX"] && settings["lifxEnabled"] && state.modules["LIFX"].connected + href "pageIntegrateLIFX", title: "LIFX", description: lifxConnected ? "Connected" : "Not configured", state: (lifxConnected ? "complete" : null), submitOnChange: true, required: false + } + + section("Piston Recovery") { + paragraph "Recovery allows pistons that have been left behind by missed ST events to recover and resume their work", required: false + input "recovery#1", "enum", options: ["Disabled", "Every 1 hour", "Every 3 hours"], title: "Stage 1 recovery", defaultValue: "Every 3 hours", required: false + input "recovery#2", "enum", options: ["Disabled", "Every 2 hours", "Every 4 hours", "Every 6 hours", "Every 12 hours", "Every 1 day", "Every 2 days", "Every 3 days"], title: "Stage 2 recovery", defaultValue: "Every 1 day", required: false + input "recoveryNotifications", "bool", title: "Send recovery notifications via ST UI", required: false + input "recoveryPushNotifications", "bool", title: "Send recovery notifications via PUSH", required: false + href "pageRecoverAllPistons", title: "Recover all pistons", description: "Use this option when you have pistons displaying large 'past due' times in the dashboard.", required: false + href "pageRebuildAllPistons", title: "Rebuild all pistons", description: "Use this option if there is a problem with your pistons, including when the dashboard is no longer working (blank).", required: false + } + + section("Security") { + href "pageResetSecurityToken", title: "", description: "Reset security token", required: false + } + + section("Remove CoRE") { + href "pageRemove", title: "", description: "Remove CoRE", required: false + } + + } +} + +def pageDashboardTaps() { + rebuildTaps() + dynamicPage(name: "pageDashboardTaps", title: "Dashboard Taps", install: false, uninstall: false) { + def taps = state.taps + section("") { + href "pageDashboardTap", title: "Add a new tap", required: false, params: [id: 0], image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/tap.png" + } + if (taps.size()) { + section("Taps") { + for (tap in taps) { + href "pageDashboardTap", title: tap.n, description: "Runs ${buildNameList(tap.p, "and")}", required: false, params: [id: tap.i], image: "https://cdn.rawgit.com/ady624/CoRE/master/resources/images/tap.png" + } + } + } + } +} + +def pageDashboardTap(params) { + def tapId = (int) (params?.id != null ? params.id : state.tapId) + if (!tapId) { + //generate new tap id + tapId = 1 + def existingTaps = settings.findAll{ it.key.startsWith("tapName") } + for (tap in existingTaps) { + def id = tap.key.replace("tapName", "") + if (id.isInteger()) { + id = id.toInteger() + if (id >= tapId) tapId = (int) (id + 1) + } + } + } + state.tapId = tapId + dynamicPage(name: "pageDashboardTap", title: "Dashboard Tap", install: false, uninstall: false) { + section("") { + input "tapName${tapId}", "string", title: "Name", description: "Enter a name for this tap", required: false, defaultValue: "Tap #${tapId}" + input "tapPistons${tapId}", "enum", title: "Pistons", options: listPistons(), description: "Select the pistons to be executed when tapped", required: false, multiple: true + } + section("") { + paragraph "NOTE: To delete this dashboard tap, clear its name and list of pistons and then tap Done" + } + } +} + +def pageGlobalVariables() { + dynamicPage(name: "pageGlobalVariables", title: "Global Variables", install: false, uninstall: false) { + section("Initialize variables") { + href "pageInitializeVariable", title: "Initialize variables", required: false + } + section() { + def cnt = 0 + //initialize the store if it doesn't yet exist + if (!state.store) state.store = [:] + for (def variable in state.store.sort{ it.key }) { + def value = getVariable(variable.key, true) + href "pageViewVariable", description: "$value", title: "${variable.key}", params: [var: variable.key], required: false + cnt++ + } + if (!cnt) { + paragraph "No global variables yet", required: false + } + } + } +} + +def pageStatistics() { + dynamicPage(name: "pageStatistics", title: "", install: false, uninstall: false) { + def apps = getChildApps().sort{ it.label } + def running = apps.findAll{ it.getPistonEnabled() }.size() + section(title: "CoRE") { + paragraph mem(), title: "Memory Usage", required: false + paragraph "${running}", title: "Running pistons", required: false + paragraph "${apps.size - running}", title: "Paused pistons", required: false + paragraph "${apps.size}", title: "Total pistons", required: false + } + + updateChart("delay", null) + section(title: "Event delay (15 minute average, last 2h)") { + def text = "" + def chart = state.charts["delay"] + def totalAvg = 0 + for (def i = 0; i < 8; i++) { + def value = Math.ceil((chart["$i"].c ? chart["$i"].t / chart["$i"].c : 0) / 100) / 10 + def time = chart["$i"].q + def hour = time.mod(3600000) == 0 ? formatLocalTime(time, "h a") : "\t" + def avg = Math.ceil(value / 1) + totalAvg += avg + if (avg > 10) { + avg = 10 + } + def graph = avg == 0 ? "□" : "".padLeft(avg, "■") + " ${value}s" + text += "$hour\t${graph}\n" + } + totalAvg = totalAvg / 8 + href "pageChart", params: [chart: "delay", title: "Event delay"], title: "", description: text, required: true, state: totalAvg < 5 ? "complete" : null + } + + updateChart("exec", null) + section(title: "Execution time (15 minute average, last 2h)") { + def text = "" + def chart = state.charts["exec"] + def totalAvg = 0 + for (def i = 0; i < 8; i++) { + def value = Math.ceil((chart["$i"].c ? chart["$i"].t / chart["$i"].c : 0) / 100) / 10 + def time = chart["$i"].q + def hour = time.mod(3600000) == 0 ? formatLocalTime(time, "h a") : "\t" + def avg = Math.ceil(value / 1) + totalAvg += avg + if (avg > 10) avg = 10 + def graph = avg == 0 ? "□" : "".padLeft(avg, "■") + " ${value}s" + text += "$hour\t${graph}\n" + } + totalAvg = totalAvg / 8 + href "pageChart", params: [chart: "exec", title: "Execution time"], title: "", description: text, required: true, state: totalAvg < 5 ? "complete" : null + } + + def i = 0 + if (apps && apps.size()) { + section("Pistons") { + for (app in apps.sort{ it.label }) { + href "pagePistonStatistics", params: [pistonId: app.id], title: app.label ?: app.name, required: false + } + } + } else { + section() { + paragraph "No pistons running", required: false + } + } + } +} + +def pagePistonStatistics(params) { + def pistonId = params?.pistonId ?: state.pistonId + state.pistonId = pistonId + dynamicPage(name: "pagePistonStatistics", title: "", install: false, uninstall: false) { + def app = getChildApps().find{ it.id == pistonId } + if (app) { + def mode = app.getMode() + def version = app.version() + def currentState = app.getCurrentState() + def stateSince = app.getCurrentStateSince() + def runStats = app.getRunStats() + def conditionStats = app.getConditionStats() + def subscribedDevices = app.getDeviceSubscriptionCount() + stateSince = stateSince ? formatLocalTime(stateSince) : null + def description = "Piston mode: ${mode ? mode : "unknown"}" + description += "\nPiston version: $version" + description += "\nSubscribed devices: $subscribedDevices" + description += "\nCondition count: ${conditionStats.conditions}" + description += "\nTrigger count: ${conditionStats.triggers}" + description += "\n\nCurrent state: ${currentState == null ? "unknown" : currentState}" + description += "\nSince: " + (stateSince ? stateSince : "(never run)") + description += "\n\nMemory usage: " + app.mem() + if (runStats) { + def executionSince = runStats.executionSince ? formatLocalTime(runStats.executionSince) : null + description += "\n\nEvaluated: ${runStats.executionCount} time${runStats.executionCount == 1 ? "" : "s"}" + description += "\nSince: " + (executionSince ? executionSince : "(unknown)") + description += "\n\nTotal evaluation time: ${Math.round(runStats.executionTime / 1000)}s" + description += "\nLast evaluation time: ${runStats.lastExecutionTime}ms" + if (runStats.executionCount > 0) { + description += "\nMin evaluation time: ${runStats.minExecutionTime}ms" + description += "\nAvg evaluation time: ${Math.round(runStats.executionTime / runStats.executionCount)}ms" + description += "\nMax evaluation time: ${runStats.maxExecutionTime}ms" + } + if (runStats.eventDelay) { + description += "\n\nLast event delay: ${runStats.lastEventDelay}ms" + if (runStats.executionCount > 0) { + description += "\nMin event delay time: ${runStats.minEventDelay}ms" + description += "\nAvg event delay time: ${Math.round(runStats.eventDelay / runStats.executionCount)}ms" + description += "\nMax event delay time: ${runStats.maxEventDelay}ms" + } + } + } + section(app.label ?: app.name) { + paragraph description, required: currentState != null, state: currentState ? "complete" : null + } + } else { + section() { + paragraph "Sorry, the piston you selected cannot be found", required: false + } + } + } +} + +def pageChart(params) { + def chartName = params?.chart ?: state.chartName + def chartTitle = params?.title ?: state.chartTitle + state.chartName = chartName + state.chartTitle = chartTitle + dynamicPage(name: "pageChart", title: "", install: false, uninstall: false) { + if (chartName) { + updateChart(chartName, null) + section(title: "$chartTitle (15 minute average, last 24h)\nData is calculated across all pistons") { + def text = "" + def chart = state.charts[chartName] + def totalAvg = 0 + for (def i = 0; i < 96; i++) { + def value = Math.ceil((chart["$i"].c ? chart["$i"].t / chart["$i"].c : 0) / 100) / 10 + def time = chart["$i"].q + def hour = time.mod(3600000) == 0 ? formatLocalTime(time, "h a") : "\t" + def avg = Math.ceil(value / 1) + totalAvg += avg + if (avg > 10) avg = 10 + def graph = avg == 0 ? "□" : "".padLeft(avg, "■") + " ${value}s" + text += "$hour\t${graph}\n" + } + totalAvg = totalAvg / 96 + paragraph text, required: true, state: totalAvg < 5 ? "complete" : null + } + } + } +} + +def pageIntegrateIFTTT() { + return dynamicPage(name: "pageIntegrateIFTTT", title: "IFTTT Integration", nextPage: settings.iftttEnabled ? "pageIntegrateIFTTTConfirm" : null) { + section() { + paragraph "CoRE can optionally integrate with IFTTT (IF This Then That) via the Maker channel, triggering immediate events to IFTTT. To enable IFTTT, please login to your IFTTT account and connect the Maker channel. You will be provided with a key that needs to be entered below", required: false + input "iftttEnabled", "bool", title: "Enable IFTTT", submitOnChange: true, required: false + if (settings.iftttEnabled) href name: "", title: "IFTTT Maker channel", required: false, style: "external", url: "https://www.ifttt.com/maker", description: "tap to go to IFTTT and connect the Maker channel" + } + if (settings.iftttEnabled) { + section("IFTTT Maker key"){ + input("iftttKey", "string", title: "Key", description: "Your IFTTT Maker key", required: false) + } + } + } +} + +def pageIntegrateIFTTTConfirm() { + if (testIFTTT()) { + return dynamicPage(name: "pageIntegrateIFTTTConfirm", title: "IFTTT Integration") { + section(){ + paragraph "Congratulations! You have successfully connected CoRE to IFTTT." + } + } + } else { + return dynamicPage(name: "pageIntegrateIFTTTConfirm", title: "IFTTT Integration") { + section(){ + paragraph "Sorry, the credentials you provided for IFTTT are invalid. Please go back and try again." + } + } + } +} + +def pageIntegrateLIFX() { + return dynamicPage(name: "pageIntegrateLIFX", title: "LIFX Integration", nextPage: settings.lifxEnabled ? "pageIntegrateLIFXConfirm" : null) { + section() { + paragraph "CoRE can optionally integrate with LIFX, allowing you to run scenes directly into your LIFX environment. To enable LIFX, please login to your LIFX cloud account and go to Settings under your account. Tap on Generate New Token and copy the generated token into the field below", required: false + input "lifxEnabled", "bool", title: "Enable LIFX", submitOnChange: true, required: false + if (settings.lifxEnabled) href name: "", title: "LIFX Cloud Account", required: false, style: "external", url: "https://cloud.lifx.com", description: "tap to go to LIFX Cloud and generate an access token" + } + if (settings.lifxEnabled) { + section("LIFX Access Token"){ + input("lifxToken", "string", title: "Token", description: "Your LIFX Access Token", required: false) + } + } + } +} + +def pageIntegrateLIFXConfirm() { + if (testLIFX()) { + return dynamicPage(name: "pageIntegrateLIFXConfirm", title: "LIFX Integration") { + section(){ + paragraph "Congratulations! You have successfully connected CoRE to LIFX." + } + } + } else { + return dynamicPage(name: "pageIntegrateLIFXConfirm", title: "LIFX Integration") { + section(){ + paragraph "Sorry, the access token you provided for LIFX is invalid. Please go back and try again." + } + } + } +} + +def pageResetSecurityToken() { + return dynamicPage(name: "pageResetSecurityToken", title: "CoRE Security Token") { + section() { + paragraph "CAUTION: Resetting the security token is an ireversible action. Once done, any integrations that rely on the security token, such as the CoRE Dashboard, the IFTTT Maker channel used as an action, etc. will STOP working and will require your attention. You will need to update the security token everywhere you are currently using it.", required: true + href "pageResetSecurityTokenConfirm", title: "", description: "Reset security token", required: true + } + } +} + +def pageResetSecurityTokenConfirm() { + state.endpoint = null + initializeCoREEndpoint() + return dynamicPage(name: "pageResetSecurityTokenConfirm", title: "CoRE Security Token") { + section() { + paragraph "Your security token has been reset. Please make sure to update it wherever needed." + } + } +} + +def pageRecoverAllPistons() { + return dynamicPage(name: "pageRecoverAllPistons", title: "Recover all pistons") { + section() { + recoverPistons(true) + paragraph "Done. All your pistons have been sent a recovery request." + } + } +} + +def pageRebuildAllPistons() { + return dynamicPage(name: "pageRebuildAllPistons", title: "Rebuild all pistons") { + section() { + rebuildPistons() + paragraph "Done. All your pistons have been sent a rebuild request." + } + } +} + +/******************************************************************************/ +/*** CoRE PISTON PAGES ***/ +/******************************************************************************/ + +private pageMainCoREPiston() { + //CoRE Piston main page + state.run = "config" + configApp() + cleanUpConditions(true) + dynamicPage(name: "pageMain", title: "", install: true, uninstall: false) { + def currentState = state.currentState + section() { + def enabled = !!state.config.app.enabled + def pistonModes = ["Do", "Basic", "Simple", "Latching", "And-If", "Or-If"] + if (!getConditionTriggerCount(state.config.app.otherConditions)) pistonModes += ["Then-If", "Else-If"] + if (listActions(0).size() || getConditionCount(state.config.app)) pistonModes.remove("Do") + if (listActions(-2).size()) pistonModes.remove("Basic") + if (listActions(-1).size()) { + pistonModes.remove("Do") + pistonModes.remove("Basic") + pistonModes.remove("Simple") + } else pistonModes.add("Follow-Up") + href "pageToggleEnabled", description: enabled ? "Current state: ${currentState == null ? "unknown" : currentState}\nCPU: ${cpu()}\t\tMEM: ${mem(false)}" : "", title: "Status: ${enabled ? "RUNNING" : "PAUSED"}", submitOnChange: true, required: false, state: "complete" + input "mode", "enum", title: "Piston Mode", required: true, state: null, options: pistonModes, defaultValue: "Basic", submitOnChange: true + } + + if (state.config.app.mode != "Do") { + section() { + href "pageIf", title: "If...", description: (state.config.app.conditions.children.size() ? "Tap here to add more conditions" : "Tap here to add a condition") + buildIfContent() + } + + section() { + def actions = listActions(0) + def desc = actions.size() ? "Tap here to add more actions" : "Tap here to add an action" + href "pageActionGroup", params:[conditionId: 0], title: "Then...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + } + + def title = "" + switch (settings.mode) { + case "Latching": + title = "But if..." + break + case "And-If": + title = "And if..." + break + case "Or-If": + title = "Or if..." + break + case "Then-If": + title = "Then if..." + break + case "Else-If": + title = "Else if..." + break + } + if (title) { + section() { + href "pageIfOther", title: title, description: (state.config.app.otherConditions.children.size() ? "Tap here to add more conditions" : "Tap here to add a condition") + buildIfOtherContent() + } + section() { + def actions = listActions(-1) + def desc = actions.size() ? "Tap here to add more actions" : "Tap here to add an action" + href "pageActionGroup", params:[conditionId: -1], title: "Then...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + } + } + } + if (!(state.config.app.mode in ["Basic", "Latching"])) { + section() { + def actions = listActions(-2) + def desc = actions.size() ? "Tap here to add more actions" : "Tap here to add an action" + href "pageActionGroup", params:[conditionId: -2], title: state.config.app.mode == "Do" ? "Do" : "Else...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + } + } + + def hasRestrictions = settings["restrictionMode"] || settings["restrictionAlarm"] || settings["restrictionVariable"] || settings["restrictionDOW"] || settings["restrictionTimeFrom"] || settings["restrictionSwitchOn"] || settings["restrictionSwitchOff"] + section(title: "Piston Restrictions", hideable: true, hidden: !hasRestrictions) { + input "restrictionMode", "mode", title: "Only execute in these modes", description: "Any location mode", required: false, multiple: true + input "restrictionAlarm", "enum", options: getAlarmSystemStatusOptions(), title: "Only execute during these alarm states", description: "Any alarm state", required: false, multiple: true + input "restrictionVariable", "enum", options: listVariables(true), title: "Only execute when variable matches", description: "Tap to choose a variable", required: false, multiple: false, submitOnChange: true + def rVar = settings["restrictionVariable"] + if (rVar) { + def options = ["is equal to", "is not equal to", "is less than", "is less than or equal to", "is greater than", "is greater than or equal to"] + input "restrictionComparison", "enum", options: options, title: "Comparison", description: "Tap to choose a comparison", required: true, multiple: false + input "restrictionValue", "string", title: "Value", description: "Tap to choose a value to compare", required: false, multiple: false, capitalization: "none" + } + input "restrictionDOW", "enum", options: timeDayOfWeekOptions(), title: "Only execute on these days", description: "Any week day", required: false, multiple: true + def timeFrom = settings["restrictionTimeFrom"] + input "restrictionTimeFrom", "enum", title: (timeFrom ? "Only execute if time is between" : "Only execute during this time interval"), options: timeComparisonOptionValues(false, false), required: false, multiple: false, submitOnChange: true + if (timeFrom) { + if (timeFrom.contains("custom")) { + input "restrictionTimeFromCustom", "time", title: "Custom time", required: true, multiple: false + } else { + input "restrictionTimeFromOffset", "number", title: "Offset (+/- minutes)", range: "*..*", required: true, multiple: false, defaultValue: 0 + } + def timeTo = settings["restrictionTimeTo"] + input "restrictionTimeTo", "enum", title: "And", options: timeComparisonOptionValues(false, false), required: true, multiple: false, submitOnChange: true + if (timeTo && (timeTo.contains("custom"))) { + input "restrictionTimeToCustom", "time", title: "Custom time", required: true, multiple: false + } else { + input "restrictionTimeToOffset", "number", title: "Offset (+/- minutes)", range: "*..*", required: true, multiple: false, defaultValue: 0 + } + } + input "restrictionSwitchOn", "capability.switch", title: "Only execute when these switches are all on", description: "Always", required: false, multiple: true + input "restrictionSwitchOff", "capability.switch", title: "Only execute when these switches are all off", description: "Always", required: false, multiple: true + input "restrictionPreventTaskExecution", "bool", title: "Prevent already scheduled tasks from executing during restrictions", required: true, defaultValue: false + } + + section() { + href "pageSimulate", title: "Simulate", description: "Allows you to test the actions manually", state: complete + } + + section(title:"Application Info") { + label name: "name", title: "Name", required: true, state: (name ? "complete" : null), defaultValue: parent.generatePistonName() + input "description", "string", title: "Description", required: false, state: (description ? "complete" : null), capitalization: "sentences" + paragraph version(), title: "Version" + paragraph mem(), title: "Memory Usage" + href "pageVariables", title: "Local Variables" + } + section(title: "Advanced Options", hideable: !settings.debugging, hidden: true) { + input "debugging", "bool", title: "Enable debugging", defaultValue: false, submitOnChange: true + def debugging = settings.debugging + if (debugging) { + input "log#info", "bool", title: "Log info messages", defaultValue: true + input "log#trace", "bool", title: "Log trace messages", defaultValue: true + input "log#debug", "bool", title: "Log debug messages", defaultValue: false + input "log#warn", "bool", title: "Log warning messages", defaultValue: true + input "log#error", "bool", title: "Log error messages", defaultValue: true + } + input "disableCO", "bool", title: "Disable command optimizations", defaultValue: false + href "pageRebuild", title: "Rebuild this CoRE piston", description: "Only use this option if your piston has been corrupted." + } + + section("Rebuild or remove piston") { + href "pageRemove", title: "", description: "Remove this CoRE piston" + } + } +} + +def pageIf(params) { + state.run = "config" + cleanUpConditions(false) + def condition = state.config.app.conditions + dynamicPage(name: "pageIf", title: "Main Condition Group", uninstall: false, install: false) { + getConditionGroupPageContent(params, condition) + } +} + +def pageIfOther(params) { + state.run = "config" + cleanUpConditions(false) + def condition = state.config.app.otherConditions + dynamicPage(name: "pageIfOther", title: "Main Group", uninstall: false, install: false) { + getConditionGroupPageContent(params, condition) + } +} + +def pageConditionGroupL1(params) { + pageConditionGroup(params, 1) +} + +def pageConditionGroupL2(params) { + pageConditionGroup(params, 2) +} + +def pageConditionGroupL3(params) { + pageConditionGroup(params, 3) +} + +def pageConditionGroupL4(params) { + pageConditionGroup(params, 4) +} + +def pageConditionGroupL5(params) { + pageConditionGroup(params, 5) +} + +//helper function for condition group paging +def pageConditionGroup(params, level) { + state.run = "config" + cleanUpConditions(false) + def condition = null + if (params?.command == "add") { + condition = createCondition(params?.parentConditionId, true) + } else { + condition = getCondition(params?.conditionId ? (int) params?.conditionId : state.config["conditionGroupIdL$level"]) + } + if (condition) { + def id = (int) condition.id + state.config["conditionGroupIdL$level"] = id + def pid = (int) condition.parentId + dynamicPage(name: "pageConditionGroupL$level", title: "Group $id (level $level)", uninstall: false, install: false) { + getConditionGroupPageContent(params, condition) + } + } +} + +private getConditionGroupPageContent(params, condition) { + try { + if (condition) { + def id = (int) condition.id + def pid = condition.parentId ? (int) condition.parentId : (int)condition.id + def nextLevel = (int) (condition.level ? condition.level : 0) + 1 + def cnt = 0 + section() { + if (settings["condNegate$id"]) { + paragraph "NOT (" + } + for (c in condition.children) { + if (cnt > 0) { + if (cnt == 1) { + input "condGrouping$id", "enum", title: "", description: "Choose the logical operation to be applied between all conditions in this group", options: groupOptions(), defaultValue: "AND", required: true, submitOnChange: true + } else { + paragraph settings["condGrouping$id"], state: "complete" + } + } + def cid = c?.id + def conditionType = (c.trg ? "trigger" : "condition") + if (c.children != null) { + href "pageConditionGroupL${nextLevel}", params: ["conditionId": cid], title: "Group #$cid", description: getConditionDescription(cid), state: "complete", required: false, submitOnChange: false + } else { + href "pageCondition", params: ["conditionId": cid], title: (c.trg ? "Trigger" : "Condition") + " #$cid", description: getConditionDescription(cid), state: "complete", required: false, submitOnChange: false + } + //when true - individual actions + def actions = listActions(c.id) + def sz = actions.size() - 1 + def i = 0 + def tab = " " + for (action in actions) { + href "pageAction", params: ["actionId": action.id], title: "", description: (i == 0 ? "${tab}╠═(when true)══ {\n" : "") + "${tab}║ " + getActionDescription(action).trim().replace("\n", "\n${tab}║") + (i == sz ? "\n${tab}╚════════ }" : ""), state: null, required: false, submitOnChange: false + i = i + 1 + } + + cnt++ + } + if (settings["condNegate$id"]) { + paragraph ")", state: "complete" + } + } + section() { + href "pageCondition", params:["command": "add", "parentConditionId": id], title: "Add a condition", description: "A condition watches the state of one or multiple similar devices", state: "complete", submitOnChange: true + if (nextLevel <= 5) { + href "pageConditionGroupL${nextLevel}", params:["command": "add", "parentConditionId": id], title: "Add a group", description: "A group is a container for multiple conditions and/or triggers, allowing for more complex logical operations, such as evaluating [A AND (B OR C)]", state: "complete", submitOnChange: true + } + } + + if (condition.children.size()) { + section(title: "Group Overview") { + def value = evaluateCondition(condition) + paragraph getConditionDescription(id), required: true, state: ( value ? "complete" : null ) + paragraph "Current evaluation: $value", required: true, state: ( value ? "complete" : null ) + } + } + + if (id > 0) { + def actions = listActions(id) + if (actions.size() || state.config.expertMode) { + section(title: "Individual actions") { + actions = listActions(id, true) + def desc = actions.size() ? "" : "Tap to select actions" + href "pageActionGroup", params:[conditionId: id, onState: true], title: "When true, do...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + actions = listActions(id, false) + desc = actions.size() ? "" : "Tap to select actions" + href "pageActionGroup", params:[conditionId: id, onState: false], title: "When false, do...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + } + } + } + + section(title: "Advanced options") { + input "condNegate$id", "bool", title: "Negate Group", description: "Apply a logical NOT to the whole group", defaultValue: false, state: null, submitOnChange: true + } + if (state.config.expertMode) { + section("Set variables") { + input "condVarD$id", "string", title: "Save last evaluation date", description: "Enter a variable name to store the date in", required: false, capitalization: "none" + input "condVarS$id", "string", title: "Save last evaluation result", description: "Enter a variable name to store the truth result in", required: false, capitalization: "none" + } + section("Set variables on true") { + input "condVarT$id", "string", title: "Save event date on true", description: "Enter a variable name to store the date in", required: false, capitalization: "none" + input "condVarV$id", "string", title: "Save event value on true", description: "Enter a variable name to store the value in", required: false, capitalization: "none" + } + section("Set variables on false") { + input "condVarF$id", "string", title: "Save event date on false", description: "Enter a variable name to store the date in", required: false, capitalization: "none" + input "condVarW$id", "string", title: "Save event value on false", description: "Enter a variable name to store the value in", required: false, capitalization: "none" + } + } + + if (id > 0) { + section(title: "Required data - do not change", hideable: true, hidden: true) { + input "condParent$id", "number", title: "Parent ID", description: "Value needs to be $pid, do not change", range: "-2..${pid+1}", defaultValue: pid + } + } + } + } catch(e) { + debug "ERROR: Error while executing getConditionGroupPageContent: ", null, "error", e + } +} + +def pageCondition(params) { + try { + state.run = "config" + //get the current edited condition + def condition = null + if (params?.command == "add") { + condition = createCondition(params?.parentConditionId, false) + } else { + condition = getCondition(params?.conditionId ? params?.conditionId : state.config.conditionId) + } + if (condition) { + updateCondition(condition) + cleanUpActions() + def id = (int) condition.id + state.config.conditionId = id + def pid = (int) condition.parentId + def overrideAttributeType = null + def showDateTimeFilter = false + def showDateTimeRepeat = false + def showParameters = false + def recurring = false + def trigger = false + def validCondition = false + def capability + def branchId = getConditionMasterId(condition.id) + def supportsTriggers = (settings.mode != "Follow-Up") && ((branchId == 0) || (settings.mode in ["Latching", "And-If", "Or-If"])) + dynamicPage(name: "pageCondition", title: (condition.trg ? "Trigger" : "Condition") + " #$id", uninstall: false, install: false) { + section() { + if (!settings["condDevices$id"] || (settings["condDevices$id"].size() == 0)) { + //only display capability selection if no devices already selected + input "condCap$id", "enum", title: "Capability", options: listCapabilities(true, false), submitOnChange: true, required: false + } + if (settings["condCap$id"]) { + //define variables + def devices + def attribute + def attr + def comparison + def allowDeviceComparisons = true + + capability = getCapabilityByDisplay(settings["condCap$id"]) + if (capability) { + if (capability.virtualDevice) { + attribute = capability.attribute + attr = getAttributeByName(attribute) + if (attribute == "time") { + //Date & Time support + comparison = cleanUpComparison(settings["condComp$id"]) + input "condComp$id", "enum", title: "Comparison", options: listComparisonOptions(attribute, supportsTriggers), required: true, multiple: false, submitOnChange: true + if (comparison) { + def comp = getComparisonOption(attribute, comparison) + if (attr && comp) { + validCondition = true + //we have a valid comparison object + trigger = (comp.trigger == comparison) + //if no parameters, show the filters + def varList = listVariables(true) + showDateTimeFilter = comp.parameters == 0 + for (def i = 1; i <= comp.parameters; i++) { + input "condValue$id#$i", "enum", title: (comp.parameters == 1 ? "Value" : (i == 1 ? "Time" : "And")), options: timeComparisonOptionValues(trigger), required: true, multiple: false, submitOnChange: true + def value = settings["condValue$id#$i"] ? "${settings["condValue$id#$i"]}" : "" + if (value) { + showDateTimeFilter = true + if (value.contains("custom")) { + //using a time offset + input "condTime$id#$i", "time", title: "Custom time", required: true, multiple: false, submitOnChange: true + } + if (value.contains("variable")) { + //using a time offset + def var = settings["condVar$id#$i"] + input "condVar$id#$i", "enum", options: varList, title: "Variable${ var ? " [${getVariable(var, true)}]" : ""}", required: true, multiple: false, submitOnChange: true + } + if (comparison && value && ((comparison.contains("around") || !(value.contains('every') || value.contains('custom'))))) { + //using a time offset + input "condOffset$id#$i", "number", title: (comparison.contains("around") ? "Give or take minutes" : "Offset (+/- minutes)"), range: (comparison.contains("around") ? "1..1440" : "-1440..1440"), required: true, multiple: false, defaultValue: (comparison.contains("around") ? 5 : 0), submitOnChange: true + } + + if (value.contains("minute") || value.contains("date and time")) recurring = true + + if (value.contains("number")) { + //using a time offset + input "condEvery$id", "number", title: value.replace("every n", "N"), range: "1..*", required: true, multiple: false, defaultValue: 5, submitOnChange: true + recurring = true + } + + if (value.contains("hour")) { + //using a time offset + input "condMinute$id", "enum", title: "At this minute", options: timeMinuteOfHourOptions(), required: true, multiple: false, submitOnChange: true + recurring = true + } + + } + } + if (trigger && !recurring) showDateTimeRepeat = true + } + } + } else { + //Location Mode, Smart Home Monitor support + validCondition = false + if (attribute == "variable") { + def dataType = settings["condDataType$id"] + overrideAttributeType = dataType ? dataType : "string" + input "condDataType$id", "enum", title: "Data Type", options: ["boolean", "string", "number", "decimal"], required: true, multiple: false, submitOnChange: true + input "condVar$id", "enum", title: "Variable name", options: listVariables(true, overrideAttributeType) , required: true, multiple: false, submitOnChange: true + def variable = settings["condVar$id"] + if (!"$variable".startsWith("@")) supportsTriggers = false + } else { + //do not allow device comparisons for location related capabilities, except variables + allowDeviceComparisons = false + } + if ((capability.name == "askAlexaMacro") && (!listAskAlexaMacros().size())) { + paragraph "It looks like you don't have the Ask Alexa SmartApp installed, or you haven't created any macros yet. To use this capability, please install Ask Alexa or, if already installed, create some macros first, then try again.", title: "Oh-oh!" + href "", title: "Ask Alexa", description: "Tap here for more information on Ask Alexa", style: "external", url: "https://community.smartthings.com/t/release-ask-alexa/46786" + showParameters = false + } else if ((capability.name == "echoSistantProfile") && (!listEchoSistantProfiles().size())) { + paragraph "It looks like you don't have the EchoSistant SmartApp installed, or you haven't created any profiles yet. To use this capability, please install EchoSistant or, if already installed, create some profiles first, then try again.", title: "Oh-oh!" + href "", title: "EchoSistant", description: "Tap here for more information on EchoSistant", style: "external", url: "https://community.smartthings.com/t/release-echosistant-version-1-2-0/62109" + showParameters = false + } else { + def options = listComparisonOptions(attribute, supportsTriggers, overrideAttributeType) + def defaultValue = (options.size() == 1 ? options[0] : null) + input "condComp$id", "enum", title: "Comparison", options: options, defaultValue: defaultValue, required: true, multiple: false, submitOnChange: true + comparison = cleanUpComparison(settings["condComp$id"] ?: defaultValue) + if (comparison) { + showParameters = true + validCondition = true + } + } + } + } else { + //physical device support + validCondition = false + devices = settings["condDevices$id"] + input "condDevices$id", "capability.${capability.name}", title: "${capability.display} list", required: false, state: (devices ? "complete" : null), multiple: capability.multiple, submitOnChange: true + if (devices && devices.size()) { + if (!condition.trg && (devices.size() > 1)) { + input "condMode$id", "enum", title: "Evaluation mode", options: ["Any", "All"], required: true, multiple: false, defaultValue: "All", submitOnChange: true + } + def evalMode = (settings["condMode$id"] == "All" && !condition.trg) ? "All" : "Any" + + //Attribute + attribute = cleanUpAttribute(settings["condAttr$id"]) + if (attribute == null) attribute = capability.attribute + //display the Attribute only in expert mode or in basic mode if it differs from the default capability attribute + if ((attribute != capability.attribute) || capability.showAttribute || state.config.expertMode) { + input "condAttr$id", "enum", title: "Attribute", options: listCommonDeviceAttributes(devices), required: true, multiple: false, defaultValue: capability.attribute, submitOnChange: true + } + + if (capability.count && (attribute != "lock")) { + def subDevices = capability.count && (attribute == capability.attribute) ? listCommonDeviceSubDevices(devices, capability.count, "") : [] + if (subDevices.size()) { + input "condSubDev$id", "enum", title: "${capability.subDisplay ?: capability.display}(s)", options: subDevices, defaultValue: subDevices.size() ? subDevices[0] : null, required: true, multiple: true, submitOnChange: true + } + } + if (attribute) { + //Condition + attr = getAttributeByName(attribute, devices && devices.size() ? devices[0] : null) + comparison = cleanUpComparison(settings["condComp$id"]) + input "condComp$id", "enum", title: "Comparison", options: listComparisonOptions(attribute, supportsTriggers, attr.momentary ? "momentary" : null, devices && devices.size() ? devices[0] : null), required: true, multiple: false, submitOnChange: true + if (comparison) { + //Value + showParameters = true + validCondition = true + } + } + } + } + } + + if (showParameters) { + //build the parameters inputs for all physical capabilities and variables + def comp = getComparisonOption(attribute, comparison, overrideAttributeType, devices && devices.size() ? devices[0] : null) + if (attr && comp) { + trigger = (comp.trigger == comparison) + def extraComparisons = !comparison.contains("one of") + def varList = (extraComparisons ? listVariables(true, overrideAttributeType) : []) + def type = overrideAttributeType ? overrideAttributeType : (attr.valueType ? attr.valueType : attr.type) + + for (def i = 1; i <= comp.parameters; i++) { + //input "condValue$id#1", type, title: "Value", options: attr.options, range: attr.range, required: true, multiple: comp.multiple, submitOnChange: true + def value = settings["condValue$id#$i"] + def device = settings["condDev$id#$i"] + def variable = settings["condVar$id#$i"] + if (variable) { + value = null + device = null + } + if (device) value = null + if (!extraComparisons || ((device == null) && (variable == null))) { + input "condValue$id#$i", type == "boolean" ? "enum" : type, title: (comp.parameters == 1 ? "Value" : "${i == 1 ? "From" : "To"} value"), options: type == "boolean" ? ["true", "false"] : attr.options, range: attr.range, required: true, multiple: type == "boolean" ? false : comp.multiple, submitOnChange: true + } + if (extraComparisons) { + if ((value == null) && (device == null)) { + input "condVar$id#$i", "enum", options: varList, title: (variable == null ? "... or choose a variable to compare ..." : (comp.parameters == 1 ? "Variable value${ variable ? " [${getVariable(variable, true)}]" : ""}" : "${i == 1 ? "From" : "To"} variable value${ variable ? " [${getVariable(variable, true)}]" : ""}")), required: true, multiple: comp.multiple, submitOnChange: true, capitalization: "none" + } + if ((value == null) && (variable == null) && (allowDeviceComparisons)) { + input "condDev$id#$i", "capability.${capability && capability.name ? capability.name : (type == "boolean" ? "switch" : "sensor")}", title: (device == null ? "... or choose a device to compare ..." : (comp.parameters == 1 ? "Device value" : "${i == 1 ? "From" : "To"} device value")), required: true, multiple: false, submitOnChange: true + if (device) { + input "condAttr$id#$i", "enum", title: "Attribute", options: listCommonDeviceAttributes([device]), required: true, multiple: false, submitOnChange: true, defaultValue: attribute + } + } + if (((variable != null) || (device != null)) && ((type == "number") || (type == "decimal"))) { + input "condOffset$id#$i", type, range: "*..*", title: "Offset (+/-" + (attr.unit ? " ${attr.unit})" : ")"), required: true, multiple: false, defaultValue: 0, submitOnChange: true + } + } + } + + if (comp.timed) { + if (comparison.contains("change")) { + input "condTime$id", "enum", title: "In the last", options: timeOptions(true), required: true, multiple: false, submitOnChange: true + } else if (comparison.contains("stays")) { + input "condTime$id", "enum", title: "For", options: timeOptions(true), required: true, multiple: false, submitOnChange: true + } else { + input "condFor$id", "enum", title: "Time restriction", options: ["for at least", "for less than"], required: true, multiple: false, submitOnChange: true + input "condTime$id", "enum", title: "Interval", options: timeOptions(), required: true, multiple: false, submitOnChange: true + } + } + + if (trigger && attr.interactive) { + //Interaction + def interaction = settings["condInteraction$id"] + def defaultInteraction = "Any" + if (interaction == null) { + interaction = defaultInteraction + } + //display the Interaction only in expert mode or in basic mode if it differs from the default capability attribute + if ((interaction != defaultInteraction) || state.config.expertMode) { + input "condInteraction$id", "enum", title: "Interaction", options: ["Any", "Physical", "Programmatic"], required: true, multiple: false, defaultValue: defaultInteraction, submitOnChange: true + } + } + if (capability.count && (attribute == "lock") && (settings["condValue$id#1"] == "unlocked")) { + def subDevices = capability.count && (attribute == capability.attribute) ? ["(none)"] + listCommonDeviceSubDevices(devices, capability.count, "") : [] + if (subDevices.size()) { + input "condSubDev$id", "enum", title: "${capability.subDisplay ?: capability.display}(s)", options: subDevices, required: false, multiple: true, submitOnChange: false + } + } + } + } + } + } + + if (capability && (capability.name == "variable")) { + section("Variables") { + href "pageVariables", title: "View current variables" + href "pageInitializeVariable", title: "Initialize a variable" + } + } + + if (showDateTimeRepeat) { + section(title: "Repeat this trigger...") { + input "condRepeat$id", "enum", title: "Repeat", options: timeRepeatOptions(), required: true, multiple: false, defaultValue: "every day", submitOnChange: true + def repeat = settings["condRepeat$id"] + if (repeat) { + def incremental = repeat.contains("number") + if (incremental) { + //using a time offset + input "condRepeatEvery$id", "number", title: repeat.replace("every n", "N"), range: "1..*", required: true, multiple: false, defaultValue: 2, submitOnChange: true + recurring = true + } + def monthOfYear = null + if (repeat.contains("week")) { + input "condRepeatDayOfWeek$id", "enum", title: "Day of the week", options: timeDayOfWeekOptions(), required: true, multiple: false, submitOnChange: true + } + if (repeat.contains("month") || repeat.contains("year")) { + //oh-oh, monthly + input "condRepeatDay$id", "enum", title: "On", options: timeDayOfMonthOptions(), required: true, multiple: false, submitOnChange: true + def dayOfMonth = settings["condRepeatDay$id"] + def certainDay = false + def dayOfWeek = null + if (dayOfMonth) { + if (dayOfMonth.contains("week")) { + certainDay = true + input "condRepeatDayOfWeek$id", "enum", title: "Day of the week", options: timeDayOfWeekOptions(), required: true, multiple: false, submitOnChange: true + dayOfWeek = settings["condDOWOM$id"] + } + } + if (repeat.contains("year")) {// && (dayOfMonth) && (!certainDay || dayOfWeek)) { + //oh-oh, yearly + input "condRepeatMonth$id", "enum", title: "Of", options: timeMonthOfYearOptions(), required: true, multiple: false, submitOnChange: true + monthOfYear = settings["condRepeatMonth$id"] + } + } + } + } + } + + if (validCondition) { + section(title: (condition.trg ? "Trigger" : "Condition") + " Overview") { + def value = evaluateCondition(condition) + paragraph getConditionDescription(id), required: true, state: ( value ? "complete" : null ) + paragraph "Current evaluation: $value", required: true, state: ( value ? "complete" : null ) + if (condition.attr == "time") { + def v = "" + def nextTime = null + def lastTime = null + for (def i = 0; i < (condition.trg ? 3 : 1); i++) { + nextTime = condition.trg ? getNextTimeTriggerTime(condition, nextTime) : getNextTimeConditionTime(condition, nextTime) + if (nextTime) { + if (lastTime && nextTime && (nextTime - lastTime < 5000)) { + break + } + lastTime = nextTime + v = v + ( v ? "\n" : "") + formatLocalTime(nextTime) + } else { + break + } + } + + paragraph v ? v : "(not happening any time soon)", title: "Next scheduled event${i ? "s" : ""}", required: true, state: ( v ? "complete" : null ) + } + } + + if (showDateTimeFilter) { + section(title: "Date & Time Filters", hideable: !state.config.expertMode, hidden: !(state.config.expertMode || settings["condMOH$id"] || settings["condHOD$id"] || settings["condDOW$id"] || settings["condDOM$id"] || settings["condMOY$id"] || settings["condY$id"])) { + paragraph "But only on these..." + input "condMOH$id", "enum", title: "Minute of the hour", description: 'Any minute of the hour', options: timeMinuteOfHourOptions(), required: false, multiple: true, submitOnChange: true + input "condHOD$id", "enum", title: "Hour of the day", description: 'Any hour of the day', options: timeHourOfDayOptions(), required: false, multiple: true, submitOnChange: true + input "condDOW$id", "enum", title: "Day of the week", description: 'Any day of the week', options: timeDayOfWeekOptions(), required: false, multiple: true, submitOnChange: true + input "condDOM$id", "enum", title: "Day of the month", description: 'Any day of the month', options: timeDayOfMonthOptions2(), required: false, multiple: true, submitOnChange: true + input "condWOM$id", "enum", title: "Week of the month", description: 'Any week of the month', options: timeWeekOfMonthOptions(), required: false, multiple: true, submitOnChange: true + input "condMOY$id", "enum", title: "Month of the year", description: 'Any month of the year', options: timeMonthOfYearOptions(), required: false, multiple: true, submitOnChange: true + input "condY$id", "enum", title: "Year", description: 'Any year', options: timeYearOptions(), required: false, multiple: true, submitOnChange: true + } + } + + if (id > 0) { + def actions = listActions(id) + if (actions.size() || state.config.expertMode) { + section(title: "Individual actions") { + actions = listActions(id, true) + def desc = actions.size() ? "" : "Tap to select actions" + href "pageActionGroup", params:[conditionId: id, onState: true], title: "When true, do...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + actions = listActions(id, false) + desc = actions.size() ? "" : "Tap to select actions" + href "pageActionGroup", params:[conditionId: id, onState: false], title: "When false, do...", description: desc, state: null, submitOnChange: false + if (actions.size()) { + for (action in actions) { + href "pageAction", params:[actionId: action.id], title: "", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + } + } + } + section(title: "Advanced options") { + input "condNegate$id", "bool", title: "Negate ${condition.trg ? "trigger" : "condition"}", description: "Apply a logical NOT to the ${condition.trg ? "trigger" : "condition"}", defaultValue: false, state: null, submitOnChange: true + } + if (state.config.expertMode) { + section("Set variables") { + input "condVarD$id", "string", title: "Save last evaluation date", description: "Enter a variable name to store the date in", required: false, capitalization: "none" + input "condVarS$id", "string", title: "Save last evaluation result", description: "Enter a variable name to store the truth result in", required: false, capitalization: "none" + input "condVarM$id", "string", title: "Save matching device list", description: "Enter a variable name to store the list of devices that match the condition", required: false, capitalization: "none" + input "condVarN$id", "string", title: "Save non-matching device list", description: "Enter a variable name to store the list of devices that do not match the condition", required: false, capitalization: "none" + } + section("Set variables on true") { + input "condVarT$id", "string", title: "Save event date on true", description: "Enter a variable name to store the date in", required: false, capitalization: "none" + input "condVarV$id", "string", title: "Save event value on true", description: "Enter a variable name to store the value in", required: false, capitalization: "none" + input "condImportT$id", "bool", title: "Import event data on true", required: false, submitOnChange: true + if (settings["condImportT$id"]) input "condImportTP$id", "string", title: "Variables prefix for import", description: "Choose a prefix that you want to use for event data parameters", required: false + } + section("Set variables on false") { + input "condVarF$id", "string", title: "Save event date on false", description: "Enter a variable name to store the date in", required: false, capitalization: "none" + input "condVarW$id", "string", title: "Save event value on false", description: "Enter a variable name to store the value in", required: false, capitalization: "none" + input "condImportF$id", "bool", title: "Import event data on false", required: false, submitOnChange: true + if (settings["condImportF$id"]) input "condImportFP$id", "string", title: "Variables prefix for import", description: "Choose a prefix that you want to use for event data parameters", required: false + } + } + } + section() { + paragraph (capability && capability.virtualDevice ? "NOTE: To delete this condition, unselect the ${capability.display} option from the Capability input above and tap Done" : "NOTE: To delete this condition, simply remove all the devices from the Device list above and tap Done") + } + + section(title: "Required data - do not change", hideable: true, hidden: true) { + input "condParent$id", "number", title: "Parent ID", description: "Value needs to be $pid, do not change condParent$id", range: "-2..${pid+1}", defaultValue: pid + } + } + } + } catch(e) { + debug "ERROR: Error while executing pageCondition: ", null, "error", e + } +} + +def pageVariables() { + state.run = "config" + dynamicPage(name: "pageVariables", title: "", install: false, uninstall: false) { + section("Initialize variables") { + href "pageInitializeVariable", title: "Initialize a variable" + } + section("Local Variables") { + def cnt = 0 + for (def variable in state.store.sort{ it.key }) { + def value = getVariable(variable.key, true) + href "pageViewVariable", description: "$value", title: "${variable.key}", params: [var: variable.key] + cnt++ + } + if (!cnt) { + paragraph "No local variables yet" + } + } + section("System Variables") { + for (def variable in state.systemStore.sort{ it.key }) { + def value = getVariable(variable.key, true) + href "pageViewVariable", description: "$value", title: "${variable.key}", params: [var: variable.key] + } + } + } +} + +def pageActionGroup(params) { + state.run = "config" + def conditionId = params?.conditionId != null ? (int) params?.conditionId : (int) state.config.actionConditionId + def onState = conditionId > 0 ? (params?.onState != null ? (boolean) params?.onState : (boolean) state.config.onState) : true + state.config.actionConditionId = conditionId + state.config.onState = (boolean) onState + def value = conditionId < -1 ? false : true + def block = conditionId > 0 ? "WHEN ${onState ? "TRUE" : "FALSE"}, DO ..." : "IF" + if (conditionId < 0) { + switch (settings.mode) { + case "Do": + case "Basic": + case "Simple": + case "Follow-Up": + block = "" + value = false + break + case "And-If": + block = "AND IF" + break + case "Or-If": + block = "OR IF" + break + case "Then-If": + block = "THEN IF" + break + case "Else-If": + block = "ELSE IF" + break + case "Latching": + block = "BUT IF" + break + } + } + + switch (conditionId) { + case 0: + block = "IF (condition) THEN ..." + break + case -1: + block = "IF (condition) $block (condition) THEN ..." + break + case -2: + block = "IF (condition) ${block ? "$block (condition) " : ""}ELSE ..." + break + } + + cleanUpActions() + dynamicPage(name: "pageActionGroup", title: "$block", uninstall: false, install: false) { + def actions = listActions(conditionId, onState) + if (actions.size()) { + section() { + for(def action in actions) { + href "pageAction", params:[actionId: action.id], title: "Action #${action.id}", description: getActionDescription(action), required: true, state: "complete", submitOnChange: true + } + } + } + + section() { + href "pageAction", params:[command: "add", conditionId: conditionId, onState: onState], title: "Add an action", required: !actions.size(), state: (actions.size() ? null : "complete"), submitOnChange: true + } + + } +} + +def pageAction(params) { + state.run = "config" + //this page has a dual purpose, either action wizard or task manager + //if no devices have been previously selected, the page acts as a wizard, guiding the use through the selection of devices + //if at least one device has been previously selected, the page will guide the user through setting up tasks for selected devices + def action = null + if (params?.command == "add") { + action = createAction(params?.conditionId, params?.onState) + } else { + action = getAction(params?.actionId ? params?.actionId : state.config.actionId) + } + if (action) { + updateAction(action) + def id = action.id + state.config.actionId = id + def pid = action.pid + + dynamicPage(name: "pageAction", title: "Action #$id", uninstall: false, install: false) { + def devices = [] + def usedCapabilities = [] + //did we get any devices? search all capabilities + for(def capability in capabilities()) { + if (capability.devices) { + //only if the capability published any devices - it wouldn't be here otherwise + def dev = settings["actDev$id#${capability.name}"] + if (dev && dev.size()) { + devices = devices + dev + //add to used capabilities - needed later + if (!(capability.name in usedCapabilities)) { + usedCapabilities.push(capability.name) + } + } + } + } + def locationAction = !!settings["actDev$id#location"] + def deviceAction = !!devices.size() + def actionUsed = deviceAction || locationAction + if (!actionUsed) { + //category selection page + for(def category in listCommandCategories()) { + section(title: category) { + def options = [] + for(def command in listCategoryCommands(category)) { + def option = getCommandGroupName(command) + if (option && !(option in options)) { + options.push option + if (option.contains("location mode")) { + def controlLocation = settings["actDev$id#location"] + input "actDev$id#location", "bool", title: option, defaultValue: false, submitOnChange: true + } else { + href "pageActionDevices", params:[actionId: id, command: command], title: option, submitOnChange: true + } + } + } + } + } + section(title: "All devices") { + href "pageActionDevices", params:[actionId: id, command: ""], title: "Control any device", submitOnChange: true + } + } else { + //actual action page + if (true || deviceAction) { + section() { + def names=[] + if (deviceAction) { + for(device in devices) { + def label = getDeviceLabel(device) + if (!(label in names)) { + names.push(label) + } + } + href "pageActionDevices", title: "Using...", params:[actionId: id, capabilities: usedCapabilities], description: "${buildNameList(names, "and")}", state: "complete", submitOnChange: true + } else { + names.push "location" + input "actDev$id#location", "bool", title: "Using location...", state: "complete", defaultValue: true, submitOnChange: true + } + } + def prefix = "actTask$id#" + def tasks = settings.findAll{it.key.startsWith(prefix)} + def maxId = 1 + def ids = [] + //we need to get a list of all existing ids that are used + for (task in tasks) { + if (task.value) { + def tid = task.key.replace(prefix, "") + if (tid.isInteger()) { + tid = tid.toInteger() + maxId = tid >= maxId ? tid + 1 : maxId + ids.push(tid) + } + } + } + //sort the ids, we really want to have these in the proper order + ids = ids.sort() + def availableCommands = (deviceAction ? listCommonDeviceCommands(devices, usedCapabilities) : []) + def flowCommands = [] + def cmds = virtualCommands() + for (vcmd in cmds.sort{ it.display }) { + if ((!(vcmd.display in availableCommands)) && (vcmd.location || deviceAction)) { + def ok = true + if (vcmd.requires && vcmd.requires.size()) { + //we have requirements, let's make sure they're fulfilled + for (device in devices) { + for (cmd in vcmd.requires) { + if (!device.hasCommand(cmd)) { + ok = false + break + } + } + if (!ok) break + } + } + //single device support - some virtual commands require only one device, can't handle more at a time + if (ok && (!vcmd.singleDevice || (devices.size() == 1))) { + if (vcmd.flow) { + flowCommands.push(virtualCommandPrefix() + vcmd.display) + } else { + availableCommands.push(virtualCommandPrefix() + vcmd.display) + } + } + } + } + if (state.config.expertMode) { + availableCommands = availableCommands + flowCommands + } + def idx = 0 + if (ids.size()) { + for (tid in ids) { + section(title: idx == 0 ? "First," : "And then") { + //display each + input "$prefix$tid", "enum", options: availableCommands, title: "", required: true, state: "complete", submitOnChange: true + //parameters + def cmd = settings["$prefix$tid"] + def virtual = (cmd && cmd.startsWith(virtualCommandPrefix())) + def custom = (cmd && cmd.startsWith(customCommandPrefix())) + cmd = cleanUpCommand(cmd) + def command = null + if (virtual) { + //dealing with a virtual command + command = getVirtualCommandByDisplay(cmd) + } else { + command = getCommandByDisplay(cmd) + } + if (command) { + if (command.parameters) { + def i = 0 + for (def parameter in command.parameters) { + def param = parseCommandParameter(parameter) + if (param) { + if ((command.parameters.size() == 1) && (param.type == "var")) { + def task = getActionTask(action, tid) + //we don't need any indents + state.taskIndent = 0 + def desc = getTaskDescription(task) + desc = "$desc".tokenize("=") + def title = desc && desc.size() == 2 ? desc[0].trim() : "Set variable..." + def description = desc && desc.size() == 2 ? desc[1].trim() : null + href "pageSetVariable", params: [actionId: id, taskId: tid], title: title, description: description, required: true, state: description ? "complete" : null, submitOnChange: true + if (description) { + def value = task_vcmd_setVariable(null, action, task, true) + paragraph "Current evaluation: " + value + } + break + } + if (param.type == "attribute") { + input "actParam$id#$tid-$i", "enum", options: listCommonDeviceAttributes(devices), title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "attributes") { + input "actParam$id#$tid-$i", "enum", options: listCommonDeviceAttributes(devices), title: param.title, required: param.required, submitOnChange: param.last, multiple: true + } else if (param.type == "contact") { + input "actParam$id#$tid-$i", "contact", title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "contacts") { + input "actParam$id#$tid-$i", "contact", title: param.title, required: param.required, submitOnChange: param.last, multiple: true + } else if (param.type == "variable") { + input "actParam$id#$tid-$i", "enum", options: listVariables(true), title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "variables") { + input "actParam$id#$tid-$i", "enum", options: listVariables(true), title: param.title, required: param.required, submitOnChange: param.last, multiple: true + } else if (param.type == "stateVariable") { + input "actParam$id#$tid-$i", "enum", options: listStateVariables(true), title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "stateVariables") { + input "actParam$id#$tid-$i", "enum", options: listStateVariables(true), title: param.title, required: param.required, submitOnChange: param.last, multiple: true + } else if (param.type == "lifxScenes") { + input "actParam$id#$tid-$i", "enum", options: listLifxScenes(), title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "piston") { + def pistons = parent.listPistons(state.config.expertMode || command.name.contains("follow") ? null : app.label) + input "actParam$id#$tid-$i", "enum", options: pistons, title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "routine") { + def routines = location.helloHome?.getPhrases()*.label + input "actParam$id#$tid-$i", "enum", options: routines, title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "aggregation") { + def aggregationOptions = ["First", "Last", "Min", "Avg", "Max", "Sum", "Count", "Boolean And", "Boolean Or", "Boolean True Count", "Boolean False Count"] + input "actParam$id#$tid-$i", "enum", options: aggregationOptions, title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else if (param.type == "dataType") { + def dataTypeOptions = ["boolean", "decimal", "number", "string"] + input "actParam$id#$tid-$i", "enum", options: dataTypeOptions, title: param.title, required: param.required, submitOnChange: param.last, multiple: false + } else { + input "actParam$id#$tid-$i", param.type, range: param.range, options: param.options, title: param.title, required: param.required, multiple: param.multiple, submitOnChange: param.last || (i == command.varEntry), capitalization: "none" + } + if (param.last && settings["actParam$id#$tid-$i"]) { + //this is the last parameter, if filled in + break + } + } else { + paragraph "Invalid parameter definition for $parameter" + } + i += 1 + } + } + if (!command.flow) { + input "actParamMode$id#$tid", "enum", options: getLocationModeOptions(), title: "Only during these modes", description: "Any", required: false, multiple: true + input "actParamDOW$id#$tid", "enum", options: timeDayOfWeekOptions(), title: "Only on these days", description: "Any", required: false, multiple: true + } + } else if (custom) { + //custom command parameters... complicated stuff + def i = (int) 1 + while (true) { + def type = settings["actParam$id#$tid-$i"] + if (type && (!(type instanceof String) || !(type in ["boolean", "decimal", "number", "string"]))) { + type = "string" + } + def j = (int) Math.floor((i - 1)/2) + 1 + input "actParam$id#$tid-$i", "enum", options: ["boolean", "decimal", "number", "string"], title: type ? "Parameter #$j type" : "Add a parameter", required: false, submitOnChange: true, multiple: false + if (!type) break + i += 1 + input "actParam$id#$tid-$i", type, range: "*..*", title: "Parameter #$j value", required: true, submitOnChange: true, multiple: false + i += 1 + } + input "actParamMode$id#$tid", "enum", options: getLocationModeOptions(), title: "Only during these modes", description: "Any", required: false, multiple: true + } + idx += 1 + } + } + } + + section() { + input "$prefix$maxId", "enum", options: availableCommands, title: "Add a task", required: !ids.size(), submitOnChange: true + } + } + } + + if (actionUsed) { + section(title: "Action Restrictions") { + input "actRStateChange$id", "bool", title: action.pid > 0 ? "Only execute on condition state change" : "Only execute on piston state change", required: false + input "actRMode$id", "mode", title: "Only execute in these modes", description: "Any location mode", required: false, multiple: true + input "actRAlarm$id", "enum", options: getAlarmSystemStatusOptions(), title: "Only execute during these alarm states", description: "Any alarm state", required: false, multiple: true + input "actRVariable$id", "enum", options: listVariables(true), title: "Only execute when variable matches", description: "Tap to choose a variable", required: false, multiple: false, submitOnChange: true + def rVar = settings["actRVariable$id"] + if (rVar) { + def options = ["is equal to", "is not equal to", "is less than", "is less than or equal to", "is greater than", "is greater than or equal to"] + input "actRComparison$id", "enum", options: options, title: "Comparison", description: "Tap to choose a comparison", required: true, multiple: false + input "actRValue$id", "string", title: "Value", description: "Tap to choose a value to compare", required: false, multiple: false, capitalization: "none" + } + input "actRDOW$id", "enum", options: timeDayOfWeekOptions(), title: "Only execute on these days", description: "Any week day", required: false, multiple: true + def timeFrom = settings["actRTimeFrom$id"] + input "actRTimeFrom$id", "enum", title: (timeFrom ? "Only execute if time is between" : "Only execute during this time interval"), options: timeComparisonOptionValues(false, false), required: false, multiple: false, submitOnChange: true + if (timeFrom) { + if (timeFrom.contains("custom")) { + input "actRTimeFromCustom$id", "time", title: "Custom time", required: true, multiple: false + } else { + input "actRTimeFromOffset$id", "number", title: "Offset (+/- minutes)", range: "*..*", required: true, multiple: false, defaultValue: 0 + } + def timeTo = settings["actRTimeTo$id"] + input "actRTimeTo$id", "enum", title: "And", options: timeComparisonOptionValues(false, false), required: true, multiple: false, submitOnChange: true + if (timeTo && (timeTo.contains("custom"))) { + input "actRTimeToCustom$id", "time", title: "Custom time", required: true, multiple: false + } else { + input "actRTimeToOffset$id", "number", title: "Offset (+/- minutes)", range: "*..*", required: true, multiple: false, defaultValue: 0 + } + } + input "actRSwitchOn$id", "capability.switch", title: "Only execute when these switches are all on", description: "Always", required: false, multiple: true + input "actRSwitchOff$id", "capability.switch", title: "Only execute when these switches are all off", description: "Always", required: false, multiple: true + if (action.pid > 0) { + input "actRState$id", "enum", options:["true", "false"], defaultValue: action.rs == false ? "false" : "true", title: action.pid > 0 ? "Only execute when condition state is" : "Only execute on piston state change", required: true + } + } + + section(title: "Advanced options") { + paragraph "When an action schedules tasks for a certain device or devices, these new tasks may cause a conflict with pending future scheduled tasks for the same device or devices. The task override scope defines how these conflicts are handled. Depending on your choice, the following pending tasks are cancelled:\n ● None - no pending task is cancelled\n ● Action - only tasks scheduled by the same action are cancelled\n ● Local - only local tasks (scheduled by the same piston) are cancelled (default)\n ● Global - all global tasks (scheduled by any piston in the CoRE) are cancelled" + input "actTOS$id", "enum", title: "Task override scope", options:["None", "Action", "Local", "Global"], defaultValue: "Local", required: true + input "actTCP$id", "enum", title: "Task cancellation policy", options:["None", "Cancel on piston state change"] + (id > 0 ? ["Cancel on condition state change", "Cancel on condition or piston state change"] : []), defaultValue: "None", required: true + } + + if (id) { + section(title: "Required data - do not change", hideable: true, hidden: true) { + input "actParent$id", "number", title: "Parent ID", description: "Value needs to be $pid, do not change", range: "-2..${pid+1}", defaultValue: pid + } + } + } + } + } +} + +def pageActionDevices(params) { + state.run = "config" + def actionId = params?.actionId + if (!actionId) return + //convert this to an int - Android thinks this is a float + actionId = (int) actionId + def command = params?.command + def caps = params?.capabilities + def capabilities = capabilities().findAll{ it.devices } + if (caps && caps.size()) { + capabilities = [] + //we don't have a list of capabilities to filter by, let's figure things out by using the command + for(def cap in caps) { + def capability = getCapabilityByName(cap) + if (capability && !(capability in capabilities)) capabilities.push(capability) + } + } else { + if (command) capabilities = listCommandCapabilities(command) + } + + if (!capabilities) return + dynamicPage(name: "pageActionDevices", title: "", uninstall: false, install: false) { + caps = [:] + //we got a list of capabilities to display + def used = [] + for(def capability in capabilities.sort{ it.devices.toLowerCase() }) { + //go through each and look for "devices" - the user-friendly name of what kind of devices the capability stands for + if (capability.devices) { + if (!(capability.devices in used)) { + used.push capability.devices + def cap = caps[capability.name] ? caps[capability.name] : [] + if (!(capability.devices in cap)) cap.push(capability.devices) + caps[capability.name] = cap + } + } + } + if (caps.size()) { + section() { + paragraph "Please select devices from the list${caps.size() > 1 ? "s" : ""} below. When done, please tap the Done to continue" + } + for(cap in caps) { + section() { + input "actDev$actionId#${cap.key}", "capability.${cap.key}", title: "Select ${buildNameList(cap.value, "or")}", multiple: true, required: false + } + } + } + } +} + +private pageSetVariable(params) { + state.run = "config" + def aid = params?.actionId ? (int) params?.actionId : (int) state.actionId + def tid = params?.taskId ? (int) params?.taskId : (int) state.taskId + state.actionId = aid + state.taskId = tid + if (!aid) return + if (!tid) return + dynamicPage(name: "pageSetVariable", title: "", uninstall: false, install: false) { + section("Variable") { + input "actParam$aid#$tid-0", "text", title: "Variable name", required: true, submitOnChange: true, capitalization: "none" + input "actParam$aid#$tid-1", "enum", title: "Variable data type", options: ["boolean", "decimal", "number", "string", "time"], required: true, submitOnChange: true + input "actParam$aid#$tid-2", "bool", title: "Execute during evaluation stage", required: true, defaultValue: false + //input "actParam$aid#$tid-3", "text", title: "Formula", required: true, submitOnChange: true + } + def immediate = settings["actParam$aid#$tid-2"] + def dataType = settings["actParam$aid#$tid-1"] + def i = 1 + def operation = "" + while (dataType) { + def a1 = i * 4 + def a2 = a1 + 1 + def a3 = a2 + 1 + def op = a3 + 1 + def secondaryDataType = (i == 1 ? dataType : (dataType == "time" ? "decimal" : dataType)) + section(formatOrdinalNumberName(i).capitalize() + " operand") { + def val = settings["actParam$aid#$tid-$a1"] != null + def var = settings["actParam$aid#$tid-$a2"] + if (val || (val == 0) || !var) { + def inputType = secondaryDataType == "boolean" ? "enum" : secondaryDataType + input "actParam$aid#$tid-$a1", inputType, range: (i == 1 ? "*..*" : "0..*"), title: "Value", options: ["false", "true"], required: dataType != "string", submitOnChange: true, capitalization: "none" + } + if (var || !val) { + input "actParam$aid#$tid-$a2", "enum", options: listVariables(true, secondaryDataType), title: (var ? "Variable value" : "...or variable value...") + (var ? "\n[${getVariable(var, true)}]" : ""), required: dataType != "string", submitOnChange: true + } + if ((dataType == "time") && (i > 1) && !(operation.contains("*") || operation.contains("÷"))) { + input "actParam$aid#$tid-$a3", "enum", options: ["milliseconds", "seconds", "minutes", "hours", "days", "weeks", "months", "years"], title: "Time unit", required: true, submitOnChange: true, defaultValue: "minutes" + } + } + operation = settings["actParam$aid#$tid-$op"] + if (operation) operation = "$operation" + section(title: operation ? "" : "Add operation") { + def opts = [] + switch (dataType) { + case "boolean": + opts += ["AND", "OR"] + break + case "string": + opts += ["+ (concatenate)"] + break + case "number": + case "decimal": + case "time": + opts += ["+ (add)", "- (subtract)", "* (multiply)", "÷ (divide)"] + break + } + input "actParam$aid#$tid-$op", "enum", title: "Operation", options: opts, required: false, submitOnChange: true + } + i += 1 + if (!operation || i > 10) break + } + section("Initialize variables") { + href "pageInitializeVariable", title: "Initialize a variable" + } + + } +} + +def pageSimulate() { + state.run = "config" + dynamicPage(name: "pageSimulate", title: "", uninstall: false, install: false) { + section("") { + paragraph "Preparing to simulate piston..." + paragraph "Current piston state is: ${state.currentState}" + if (!state.config.app.enabled) { + paragraph "Piston is currently PAUSED", state: null, required: true + } + } + state.sim = [ evals: [], cmds: [] ] + def error + + //prepare some stuff + state.debugLevel = 0 + state.globalVars = [:] + state.tasker = state.tasker ? state.tasker : [] + + def perf = now() + try { + broadcastEvent([name: "simulate", date: new Date(), deviceId: "time", conditionId: null], true, false) + processTasks() + } catch(all) { + error = all + } + perf = now() - perf + def evals = state.sim.evals + def cmds = state.sim.cmds + exitPoint(perf) + + section("") { + paragraph "Simulation ended in ${perf}ms.", state: "complete" + paragraph "New piston state is: ${state.currentState}" + if (error) { + paragraph error, required: true, state: null + } + } + section("Evaluations performed") { + if (evals.size()) { + for(msg in evals) { + paragraph msg, state: "complete" + } + } else { + paragraph "No evaluations have been performed." + } + } + section("Commands executed") { + if (cmds.size()) { + for(msg in cmds) { + paragraph msg, state: "complete" + } + } else { + paragraph "No commands have been executed." + } + } + + section("Scheduled ST job") { + def time = getVariable("\$nextScheduledTime") + paragraph time ? formatLocalTime(time) : "No ST job has been scheduled.", state: time ? "complete" : null + } + + def tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + section("Pending tasks") { + if (!tasks.size()) { + paragraph "No tasks are currently scheduled." + } else { + for(task in tasks.sort { it.value.time } ) { + def time = formatLocalTime(task.value.time) + if (task.value.type == "evt") { + paragraph "EVENT - $time\n$task.value" + } else { + paragraph "COMMAND - $time\n$task.value" + } + } + } + } + } +} + +def pageRebuild() { + dynamicPage(name: "pageRebuild", title: "", uninstall: false, install: false) { + section("") { + paragraph "Rebuilding piston..." + rebuildPiston() + configApp() + state.run = "config" + paragraph "Rebuilding is now finished. Please tap Done to go back." + } + } +} + +def pageToggleEnabled() { + state.config.app.enabled = !state.config.app.enabled + if (state.app) state.app.enabled = !!state.config.app.enabled + dynamicPage(name: "pageToggleEnabled", title: "", uninstall: false, install: false) { + section() { + paragraph "The piston is now ${state.config.app.enabled ? "running" : "paused"}." + } + } +} + +def pageInitializeVariable() { + dynamicPage(name: "pageInitializeVariable", title: "", uninstall: false, install: false) { + section("Initialize variable") { + input "varName", "string", title: "Variable to initialize", required: true, capitalization: "none" + input "varValue", "string", title: "Initial value", required: true, capitalization: "none" + href "pageInitializedVariable", title: "Initialize!" + } + } +} + +def pageInitializedVariable() { + dynamicPage(name: "pageInitializedVariable", title: "", uninstall: false, install: false) { + section() { + def var = settings.varName + def val = settings.varValue + if ((var != null) && (val != null)) { + setVariable(var, val) + paragraph "Variable {$var} successfully initialized to value '$val'.\n\nPlease tap < or Done to continue.", title: "Success" + } + } + } +} + +private buildIfContent() { + buildIfContent(state.config.app.conditions.id, 0) +} + +private buildIfOtherContent() { + buildIfContent(state.config.app.otherConditions.id, 0) +} + +private buildIfContent(id, level) { + def condition = getCondition(id) + if (!condition) { + return null + } + def conditionGroup = (condition.children != null) + def conditionType = (condition.trg ? "trigger" : "condition") + level = (level ? level : 0) + def pre = "" + def preNot = "" + def tab = "" + def aft = "" + switch (level) { + case 1: + pre = " ┌ (" + preNot = " ┌ NOT (" + tab = " │ " + aft = " └ )" + break; + case 2: + pre = " │ ┌ [" + preNot = " │ ┌ NOT [" + tab = " │ │ " + aft = " │ └ ]" + break; + case 3: + pre = " │ │ ┌ <" + preNot = " │ │ ┌ NOT {" + tab = " │ │ │ " + aft = " │ │ └ >" + break; + } + if (!conditionGroup) { + href "pageCondition", params: ["conditionId": id], title: "", description: tab + getConditionDescription(id).trim(), state: "complete", required: false, submitOnChange: false + } else { + + def grouping = settings["condGrouping$id"] + def negate = settings["condNegate$id"] + + if (pre) { + href "pageConditionGroupL${level}", params: ["conditionId": id], title: "", description: (negate? preNot : pre), state: "complete", required: true, submitOnChange: false + } + + def cnt = 0 + for (child in condition.children) { + buildIfContent(child.id, level + (child.children == null ? 0 : 1)) + cnt++ + if (cnt < condition.children.size()) { + def page = (level ? "pageConditionGroupL${level}" : (id == 0 ? "pageIf" : "pageIfOther")) + href page, params: ["conditionId": id], title: "", description: tab + grouping, state: "complete", required: true, submitOnChange: false + } + } + + if (aft) { + href "pageConditionGroupL${level}", params: ["conditionId": id], title: "", description: aft, state: "complete", required: true, submitOnChange: false + } + } + if (condition.id > 0) { + //when true - individual actions + def actions = listActions(id, true) + def sz = actions.size() - 1 + def i = 0 + for (action in actions) { + href "pageAction", params: ["actionId": action.id], title: "", description: (i == 0 ? "${tab}╠═(when true)══ {\n" : "") + "${tab}║ " + getActionDescription(action).trim().replace("\n", "\n${tab}║") + (i == sz ? "\n${tab}╚════════ }" : ""), state: null, required: false, submitOnChange: false + i = i + 1 + } + actions = listActions(id, false) + sz = actions.size() - 1 + i = 0 + for (action in actions) { + href "pageAction", params: ["actionId": action.id], title: "", description: (i == 0 ? "${tab}╠═(when false)══ {\n" : "") + "${tab}║ " + getActionDescription(action).trim().replace("\n", "\n${tab}║") + (i == sz ? "\n${tab}╚════════ }" : ""), state: null, required: false, submitOnChange: false + i = i + 1 + } + } else { + def value = evaluateCondition(condition) + paragraph "Current evaluation: $value", required: true, state: ( value ? "complete" : null ) + } +} + +/********** COMMON INITIALIZATION METHODS **********/ +def installed() { + initialize() + return true +} + +def updated() { + unsubscribe() + initialize() + return true +} + +def initialize() { + parent ? initializeCoREPiston() : initializeCoRE() +} + +/******************************************************************************/ +/*** ***/ +/*** COMMON PUBLISHED METHODS ***/ +/*** ***/ +/******************************************************************************/ + +def mem(showBytes = true) { + def bytes = state.toString().length() + return Math.round(100.00 * (bytes/ 100000.00)) + "%${showBytes ? " ($bytes bytes)" : ""}" +} + +def cpu() { + if (state.lastExecutionTime == null) { + return "N/A" + } else { + def cpu = Math.round(state.lastExecutionTime / 20000) + if (cpu > 100) { + cpu = 100 + } + return "$cpu%" + } +} + +def getVariable(name, forDisplay) { + def value = getVariable(name) + if (forDisplay && (value instanceof Long) && (value >= 999999999999)) return formatLocalTime(value) + return value +} + +def getVariable(name) { + name = sanitizeVariableName(name) + switch (name) { + case "\$now": return now() + case "\$hour24": return adjustTime().hours + case "\$hour": + def h = adjustTime().hours + return (h == 0 ? 12 : (h > 12 ? h - 12 : h)) + case "\$meridian": + def h = adjustTime().hours + return ( h < 12 ? "AM" : "PM") + case "\$meridianWithDots": + def h = adjustTime().hours + return ( h <12 ? "A.M." : "P.M.") + case "\$minute": return adjustTime().minutes + case "\$second": return adjustTime().seconds + case "\$time": + def t = adjustTime() + def h = t.hours + def m = t.minutes + return (h == 0 ? 12 : (h > 12 ? h - 12 : h)) + ":" + (m < 10 ? "0$m" : "$m") + " " + (h <12 ? "A.M." : "P.M.") + case "\$time24": + def t = adjustTime() + def h = t.hours + def m = t.minutes + return h + ":" + (m < 10 ? "0$m" : "$m") + case "\$day": return adjustTime().date + case "\$dayOfWeek": return getDayOfWeekNumber() + case "\$dayOfWeekName": return getDayOfWeekName() + case "\$month": return adjustTime().month + 1 + case "\$monthName": return getMonthName() + case "\$year": return adjustTime().year + 1900 + case "\$now": return now() + case "\$random": + def result = getRandomValue(name) ?: (float)Math.random() + setRandomValue(name, result) + return result + case "\$randomColor": + def result = getRandomValue(name) ?: getColorByName("Random").rgb + setRandomValue(name, result) + return result + case "\$randomColorName": + def result = getRandomValue(name) ?: getColorByName("Random").name + setRandomValue(name, result) + return result + case "\$randomLevel": + def result = getRandomValue(name) ?: (int)Math.round(100 * Math.random()) + setRandomValue(name, result) + return result + case "\$randomHue": + def result = getRandomValue(name) ?: (int)Math.round(360 * Math.random()) + setRandomValue(name, result) + return result + case "\$randomSaturation": + def result = getRandomValue(name) ?: (int)Math.round(50 + (50 * Math.random())) + setRandomValue(name, result) + return result + case "\$midnight": + def rightNow = adjustTime().time + return convertDateToUnixTime(rightNow - rightNow.mod(86400000)) + case "\$nextMidnight": + def rightNow = adjustTime().time + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + 86400000) + case "\$noon": + def rightNow = adjustTime().time + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + 43200000) + case "\$nextNoon": + def rightNow = adjustTime().time + if (rightNow - rightNow.mod(86400000) + 43200000 < rightNow) rightNow += 86400000 + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + 43200000) + case "\$sunrise": + def sunrise = getSunrise() + def rightNow = adjustTime().time + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + sunrise.hours * 3600000 + sunrise.minutes * 60000) + case "\$nextSunrise": + def sunrise = getSunrise() + def rightNow = adjustTime().time + if (sunrise.time < rightNow) rightNow += 86400000 + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + sunrise.hours * 3600000 + sunrise.minutes * 60000) + case "\$sunset": + def sunset = getSunset() + def rightNow = adjustTime().time + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + sunset.hours * 3600000 + sunset.minutes * 60000) + case "\$nextSunset": + def sunset = getSunset() + def rightNow = adjustTime().time + if (sunset.time < rightNow) rightNow += 86400000 + return convertDateToUnixTime(rightNow - rightNow.mod(86400000) + sunset.hours * 3600000 + sunset.minutes * 60000) + case "\$currentStateDuration": + try { + return state.systemStore["\$currentStateSince"] ? now() - (new Date(state.systemStore["\$currentStateSince"])).time : null + } catch(all) { + return null + } + return null + case "\$locationMode": + return location.mode + case "\$shmStatus": + return getAlarmSystemStatus() + } + if (!name) return null + if (parent && name.startsWith("@")) { + return parent.getVariable(name) + } else { + if (name.startsWith("\$")) { + return state.systemStore[name] + } else { + if (parent) return state.store[name] + return atomicState.store[name] + } + } +} + +def setVariable(name, value, system = false, globalVars = null) { + name = sanitizeVariableName(name) + if (!name) return + if (name.contains(",")) { + //multi variables + def vars = name.tokenize(",") + for (var in vars) { + setVariable(var, value, system, globalVars) + } + return + } + if (parent && name.startsWith("@")) { + def gv = state.globalVars instanceof Map ? state.globalVars : [:] + parent.setVariable(name, value, false, gv) + state.globalVars = gv + } else { + if (name.startsWith("\$")) { + if (system) { + state.systemStore[name] = value + } + } else { + debug "Storing variable $name with value $value" + if (!parent) { + //we're using atomic state in parent app + def store = atomicState.store + def oldValue = store[name] + store[name] = value + atomicState.store = store + //save var name for broadcasting events + if (globalVars.containsKey(name)) { + globalVars[name].newValue = value + } else { + globalVars[name] = [oldValue: oldValue, newValue: value] + } + } else { + state.store[name] = value + } + } + } +} + +def publishVariables() { + if (!parent) return null + //we're saving the atomic store to our regular store to prevent race conditions + def globalVars = state.globalVars + for (variable in globalVars) { + def name = variable.key + def oldValue = variable.value.oldValue + def newValue = variable.value.newValue + if (oldValue != newValue) { + sendLocationEvent(name: "variable", value: name, displayed: true, linkText: "CoRE Global Variable", isStateChange: true, descriptionText: "Variable $name changed from '$oldValue' to '$newValue'", data: [app: "CoRE", oldValue: oldValue, value: newValue]) + } + } + state.globalVars = [:] +} + +def deleteVariable(name) { + //used during config, safe to use state + name = sanitizeVariableName(name) + if (!name) return + if (parent && name.startsWith("@")) { + parent.deleteVariable(name) + } else { + if (state.store) { + state.store.remove(name) + } + } +} + +def getStateVariable(name, global = false) { + name = sanitizeVariableName(name) + if (!name) return null + if (parent && global) { + return parent.getStateVariable(name) + } else { + if (parent) return state.stateStore[name] + return atomicState.stateStore[name] + } +} + +def setStateVariable(name, value, global = false) { + name = sanitizeVariableName(name) + if (!name) { + return + } + if (parent && global) { + parent.setStateVariable(name, value) + } else { + debug "Storing state variable $name with value $value" + if (parent) { + def store = atomicState.stateStore + store[name] = value + atomicState.stateStore = store + } else { + //using atomic state for globals + def store = atomicState.stateStore + store[name] = value + atomicState.stateStore = store + } + } +} + +private getRandomValue(name) { + state.temp = state.temp ?: [:] + state.temp.randoms = state.temp.randoms ?: [:] + return state.temp?.randoms[name] +} + +private setRandomValue(name, value) { + state.temp = state.temp ?: [:] + state.temp.randoms = state.temp.randoms ?: [:] + state.temp.randoms[name] = value +} + +private resetRandomValues() { + state.temp = state.temp ?: [:] + state.temp.randoms = [:] +} + +private testDataType(value, dataType) { + if (!dataType || !value) return true + switch (dataType) { + case "bool": + case "boolean": + case "string": + return true + case "time": + return (value instanceof Long) && (value > 999999999999) + case "number": + case "decimal": + return !((value instanceof Long) && (value > 999999999999)) && ("$value".isInteger() || "$value".isFloat()) + } + return false +} + +def listVariablesInBulk() { + def result = [:] + for(variable in listVariables()) { + result[variable] = getVariable(variable, true) + } + return result.sort{ it.key.substring(0, 1) in ["\$", "@"] ? it.key : "!${it.key}" } +} + +def listVariables(config = false, dataType = null, listLocal = true, listGlobal = true, listSystem = true) { + def result = [] + def parentResult = null + def systemResult = [] + if (listLocal) { + for (variable in state.store) { + if (!dataType || testDataType(variable.value, dataType)) { + result.push(variable.key) + } + } + } + if (parent && listSystem) { + for (variable in state.systemStore) { + if (!dataType || testDataType(variable.value, dataType)) { + systemResult.push(variable.key) + } + } + } + if (listGlobal) { + if (parent) { + parentResult = parent.listVariables(config, dataType) + } + } + if (parent && config) { + //look for variables set during conditions + def list = settings.findAll{it.key.startsWith("condVar") && !it.key.contains("#")} + for (it in list) { + if (it.value instanceof String) { + def vars = sanitizeVariableName(it.value) + if (vars instanceof String) vars = vars.tokenize(",") + for (var in vars) { + if (var.startsWith("@")) { + //global + if (listGlobal && !(var in parentResult)) { + if (!dataType || testDataType(it.value, dataType)) { + parentResult.push(var) + } + } + } else { + //local + if (listLocal && !(var in result)) { + if (!dataType || testDataType(it.value, dataType)) { + result.push(var) + } + } + } + } + } + } + //look for tasks that set variables... + list = settings.findAll{it.key.startsWith("actTask")} + for (it in list) { + if (it.value instanceof String) { + def virtualCommand = getVirtualCommandByDisplay(cleanUpCommand(it.value)) + if (virtualCommand && (virtualCommand.varEntry != null)) { + def vars = sanitizeVariableName(settings[it.key.replace("actTask", "actParam") + "-${virtualCommand.varEntry}"]) + if (vars instanceof String) vars = vars.tokenize(",") + for (var in vars) { + if (var.startsWith("@")) { + //global + if (!(var in parentResult)) { + parentResult.push(var) + } + } else { + //local + if (!(var in result)) { + result.push(var) + } + } + } + } + } + } + } + return result.sort() + (parentResult ? parentResult.sort() : []) + systemResult.sort() +} + +def listStateVariables(config = false, dataType = null, listLocal = true, listGlobal = true) { + def result = [] + def parentResult = null + if (listLocal) { + for (variable in state.stateStore) { + if (!variable.key.contains(":::")) { + if (!dataType || testDataType(variable.value, dataType)) { + result.push(variable.key) + } + } + } + } + if (listGlobal) { + if (parent) { + parentResult = parent.listStateVariables(config, dataType) + } + } + if (parent && config) { + //look for variables set during conditions + def list = settings.findAll{it.key.startsWith("actTask")} + for (it in list) { + if (it.value instanceof String) { + def virtualCommand = getVirtualCommandByDisplay(cleanUpCommand(it.value)) + if (virtualCommand && (virtualCommand.stateVarEntry != null)) { + def vars = sanitizeVariableName(settings[it.key.replace("actTask", "actParam") + "-${virtualCommand.stateVarEntry}"]).tokenize(",") + for (var in vars) { + if (var.startsWith("@")) { + //global + if (!(var in parentResult)) { + parentResult.push(var) + } + } else { + //local + if (!(var in result)) { + result.push(var) + } + } + } + } + } + } + } + return result.sort() + (parentResult ? parentResult.sort() : []) +} + +/******************************************************************************/ +/*** ***/ +/*** CoRE CODE ***/ +/*** ***/ +/******************************************************************************/ + +/******************************************************************************/ +/*** CoRE INITIALIZATION METHODS ***/ +/******************************************************************************/ + +def initializeCoRE() { + initializeCoREStore() + refreshPistons() + subscribe(location, "CoRE", coreHandler) + subscribe(location, "askAlexa", askAlexaHandler) + subscribe(location, "echoSistant", echoSistantHandler) + subscribe(app, appTouchHandler) + /* temporary - remove old handlers */ + unschedule(recovery1) + unschedule(recovery2) +// subscribe(null, "intrusion", intrusionHandler, [filterEvents: false]) +// subscribe(null, "newIncident", intrusionHandler, [filterEvents: false]) +// subscribe(null, "newMessage", intrusionHandler, [filterEvents: false]) + switch (settings["recovery#1"]) { + case "Disabled": + unschedule(recovery1Handler) + break + case "Every 1 hour": + runEvery1Hour(recovery1Handler) + break + default: + runEvery3Hours(recovery1Handler) + break + } + + def t = new Date(now()) + def sch = "${t.seconds} ${t.minutes}" + def sch2 = "$sch ${t.hours}" + switch (settings["recovery#2"]) { + case "Disabled": + unschedule(recovery2) + break + case "Every 2 hours": + schedule("$sch 0/2 1/1 * ? *", recovery2Handler) + break + case "Every 4 hours": + schedule("$sch 0/4 1/1 * ? *", recovery2Handler) + break + case "Every 6 hours": + schedule("$sch 0/6 1/1 * ? *", recovery2Handler) + break + case "Every 12 hours": + schedule("$sch 0/12 1/1 * ? *", recovery2Handler) + break + case "Every 2 days": + schedule("$sch2 1/2 * ? *", recovery2Handler) + break + case "Every 3 days": + schedule("$sch2 1/3 * ? *", recovery2Handler) + break + default: + schedule("$sch2 1/1 * ? *", recovery2Handler) + break + } +} + +def intrusionHandler(evt) { + //not working yet +} + +def initializeCoREStore() { + state.store = state.store ? state.store : [:] + state.modes = state.modes ? state.modes : [:] + state.modules = state.modules ? state.modules : [:] + state.stateStore = state.stateStore ? state.stateStore : [:] + state.askAlexaMacros = state.askAlexaMacros ? state.askAlexaMacros : [] + state.echoSistantProfiles = state.echoSistantProfiles ? state.echoSistantProfiles : [] + state.globalVars = state.globalVars ? state.globalVars : [] +} + + +def coreHandler(evt) { + if (!evt) return + switch (evt.value) { + case "execute": + if (evt.jsonData && evt.jsonData?.pistonName) { + execute(evt.jsonData.pistonName) + } + break + } +} + +def askAlexaHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + atomicState.askAlexaMacros = evt.jsonData && evt.jsonData?.macros ? evt.jsonData.macros : [] + break + } +} + +def echoSistantHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + atomicState.echoSistantProfiles = evt.jsonData && evt.jsonData?.profiles ? evt.jsonData.profiles : [] + break + } +} + +def appTouchHandler(evt) { + recoverPistons(true) +} + +def childUninstalled() { + refreshPistons() +} + +private recoverPistons(recoverAll = false, excludeAppId = null) { + if (recoverAll) debug "Piston recovery initiated...", null, "trace" + int count = 0 + def recovery = atomicState.recovery + if (!(recovery instanceof Map)) recovery = [:] + def threshold = now() - 30000 + def apps = getChildApps() + for(app in apps) { + if ((recoverAll || (recovery[app.id] && (recovery[app.id] < threshold))) && (!excludeAppId || (excludeAppId != app.id))) { + count += 1 + if (recoverAll || excludeAppId) { + sendLocationEvent(name: "CoRE Recovery [${app.id}]", value: "", displayed: true, linkText: "CoRE/${app.label} Recovery", isStateChange: true) + } else { + def message = "Found CoRE Piston '${app.label ?: app.name}' about ${Math.round((now() - recovery[app.id])/ 1000)} seconds past due, attempting recovery" + int n = (int) (settings["recoveryNotifications"] ? 1 : 0) + (int) (settings["recoveryPushNotifications"] ? 2 : 0) + switch (n) { + case 1: + sendNotificationEvent(message) + break + case 2: + sendPushMessage(message) + break + case 3: + sendPush(message) + break + } + app.recoveryHandler(null, false) + } + subscribeToRecovery(app.id, null) + } + } + if (recoverAll || (count > 0)) debug "Piston recovery finished, $count piston${count == 1 ? " was" : "s were"} recovered.", null, "trace" + if (recoverAll) refreshPistons(false) + return true +} + +def rebuildPistons() { + debug "Initializing piston rebuild...", null, trace + for(app in getChildApps()) { + debug "Rebuilding piston ${app.label ?: app.name}", null, trace + sendLocationEvent(name: "CoRE Recovery [${app.id}]", value: "", displayed: true, linkText: "CoRE/${app.label} Recovery", isStateChange: true, data: [rebuild: true]) + //app.rebuildPiston(true) + } + debug "Done rebuilding pistons.", null, trace +} + +//temporary - to be removed after 2018/01/01 +def recovery1() { + recovery1Handler() +} +//temporary +def recovery2() { + recovery2Handler() +} + +def recovery1Handler() { + debug "Received a recovery stage 1 event", null, "trace" + recoverPistons(true) +} + +def recovery2Handler() { + debug "Received a recovery stage 2 event", null, "trace" + recoverPistons(true) +} + +private initializeCoREEndpoint() { + if (!state.endpoint) { + try { + def accessToken = createAccessToken() + if (accessToken) { + state.endpoint = apiServerUrl("/api/token/${accessToken}/smartapps/installations/${app.id}/") + } + } catch(e) { + state.endpoint = null + } + } + return state.endpoint +} + +mappings { + path("/dashboard") {action: [GET: "api_dashboard"]} + path("/getDashboardData") {action: [GET: "api_getDashboardData"]} + path("/ifttt/:eventName") {action: [GET: "api_ifttt", POST: "api_ifttt"]} + path("/execute") {action: [POST: "api_execute"]} + path("/execute/:pistonName") {action: [GET: "api_execute", POST: "api_execute"]} + path("/tap") {action: [POST: "api_tap"]} + path("/tap/:tapId") {action: [GET: "api_tap"]} + path("/pause") {action: [POST: "api_pause"]} + path("/resume") {action: [POST: "api_resume"]} + path("/piston") {action: [POST: "api_piston"]} +} + +def api_dashboard() { + def cdn = "https://core.webcore.co/dashboard" + def theme = (settings["dashboardTheme"] ?: "experimental").toLowerCase() + render contentType: "text/html", data: "" +} + +def api_getDashboardData() { + def result = [ pistons: [] ] + def pistons = atomicState.pistons + if (!pistons) { + refreshPistons(false) + pistons = atomicState.pistons + } + for(piston in pistons) { + result.pistons.push piston.value + } + //sort the pistons + result.pistons = result.pistons.sort { it.l } + result.variables = [:] + for(variable in atomicState.store) { + result.variables[variable.key] = getVariable(variable.key, true) + } + result.variables = result.variables.sort{ it.key } + result.version = version() + result.taps = state.taps + result.now = now() + return result +} + +def api_pause() { + def data = request?.JSON + def pistonId = data?.pistonId + if (pistonId) { + def child = getChildApps()?.find { it.id == pistonId } + if (child) { + child.pause() + def pistons = atomicState.pistons ?: [:] + pistons[child.id] = child.getSummary() + atomicState.pistons = pistons + } + } + return api_getDashboardData() +} + +def api_execute() { + def data = request?.JSON + def pistonName = params?.pistonName ?: data?.pistonName + def result = "Sorry, piston $pistonName could not be found." + def d = debug("Received an API execute request for piston '$pistonName' with data: $data") + if (pistonName) { + data.remove "pistonName" + result = execute(pistonName, data) + result = "Piston $pistonName is now being executed." + } + render contentType: "text/html", data: "$result" +} + +def api_tap() { + def data = request?.JSON + def tapId = params?.tapId ?: data?.tapId + def tap = state.taps.find{ "${it.i}" == tapId } + def result = "" + if (tap && tap.p) { + for(pistonName in tap.p) { + execute(pistonName) + result += "Piston $pistonName is now being executed.
" + } + } + def d = debug("Received an API tap request for tapID $tapId") + render contentType: "text/html", data: "$result" +} + +def api_ifttt() { + def data = request?.JSON + def eventName = params?.eventName + if (eventName) { + sendLocationEvent([name: "ifttt", value: eventName, isStateChange: true, linkText: "IFTTT event", descriptionText: "CoRE has received an IFTTT event: $eventName", data: data]) + } + render contentType: "text/html", data: "Received event $eventName." +} + +def api_resume() { + def data = request?.JSON + def pistonId = data?.pistonId + if (pistonId) { + def child = getChildApps().find { it.id == pistonId } + if (child) { + child.resume() + def pistons = atomicState.pistons ?: [:] + pistons[child.id] = child.getSummary() + atomicState.pistons = pistons + } + } + return api_getDashboardData() +} + +def api_piston() { + def data = request?.JSON + def pistonId = data?.pistonId + if (pistonId) { + def child = getChildApps().find { it.id == pistonId } + if (child) { + def result = [ + app: child.getPistonApp(), + tasks: child.getPistonTasks(), + summary: child.getSummary() + ] + if (result.app.conditions) withEachCondition(result.app.conditions, "api_piston_prepare", child) + if (result.app.otherConditions) withEachCondition(result.app.otherConditions, "api_piston_prepare", child) + if (result.app.actions) { + for(def action in result.app.actions) { + action.desc = child.getActionDeviceList(action) + state.taskIndent = 0 + if (action.t) { + for(def task in action.t) { + task.desc = getTaskDescription(task) + } + action.t = action.t.sort{ it.i } + } + } + result.app.actions = result.app.actions.sort{ (it.rs == false ? -1 : 1) * it.id } + } + result.variables = child.listVariablesInBulk() + return result + } + } + return null +} + +private api_piston_prepare(condition, child) { + condition.desc = child.getPistonConditionDescription(condition) +} + +/******************************************************************************/ +/*** CoRE PUBLISHED METHODS ***/ +/******************************************************************************/ + +def expertMode() { + return !!settings["expertMode"] +} + +def listPistons(excludeApp = null, type = null) { + if (!type) { + return getChildApps()*.label.findAll{ it != excludeApp }.sort { it } + } + def result = [] + def pistons = getChildApps() + for (piston in pistons) { + if ((piston.getPistonType() == type) && (piston.label != excludeApp)) { + result.push piston.label + } + } + return result.sort{ it } +} + +def execute(pistonName, data = null) { + if (parent) { + //if a child executes a piston, we need to save the variables to the atomic state to make them show in the new piston execution + //def store = state.store + //state.store = store + //atomicState.store = store + return parent.execute(pistonName) + } else { + def piston = getChildApps().find{ it.label == pistonName } + if (piston) { + //fire up the piston + return piston.executeHandler(data) + } + return null + } +} + +def updateChart(name, value) { + def charts = atomicState.charts + charts = charts ? charts : [:] + def modified = false + def lastQuarter = getPreviousQuarterHour() + def chart = charts[name] + if (!chart) { + //create a log with that name + chart = [:] + //create the log for the last 96 quarter-hours + def quarter = lastQuarter + for (def i = 0; i < 96; i++) { + chart["$i"] = [q: quarter, t: 0, c: 0] + //chart["q$i"].q = quarter + //chart["q$i"].t = 0 + //chart["q$i"].c = 0 + quarter = quarter - 900000 + } + charts[name] = chart + modified = true + } + if (lastQuarter != chart["0"].q) { + //we need to advance the log + def steps = Math.floor((lastQuarter - chart["0"].q) / 900000).toInteger() + if (steps != 0) { + modified = true + //we need to shift the log, we're in a different current quarter + if ((steps < 1) || (steps > 95)) { + //in case of weird things, we reset the whole log + steps = 96 + } + if (steps < 96) { + //reset the log as it seems we have a problem + for (def i = 95; i >= steps; i--) { + chart["$i"] = chart["${i-steps}"] + //chart["q$i"].q = chart["q${i-steps}"].q + //chart["q$i"].c = chart["q${i-steps}"].c + //chart["q$i"].t = chart["q${i-steps}"].t + } + } + //reset the new quarters + def quarter = lastQuarter + for (def i = 0; i < steps; i++) { + chart["$i"] = [q: quarter, t: 0, c:0] + //chart["q$i"].t = 0 + //chart["q$i"].c = 0 + quarter = quarter - 900000 + } + } + } + if (value) { + modified = true + chart["0"].t = chart["0"].t + value + chart["0"].c = chart["0"].c + 1 + } + if (modified) { + charts[name] = chart + atomicState.charts = charts + } + return null +} + +def subscribeToRecovery(appId, recoveryTime) { + if (parent) { + parent.subscribeToRecovery(appId, recoveryTime); + } else { + def recovery = atomicState.recovery + if (!(recovery instanceof Map)) recovery = [:] + if (recoveryTime) debug "Subscribing app $appId to recovery in about ${Math.round((recoveryTime - now() + 30000)/1000)} seconds" + recovery[appId] = recoveryTime + atomicState.recovery = recovery + //kick start all other dead pistons, use location events... + if (recoveryTime != null) recoverPistons(false, appId) + } +} + +private onChildExitPoint(piston, lastEvent, duration, nextScheduledTime, summary) { + if (parent) { + parent.onChildExitPoint(piston, lastEvent, duration, nextScheduledTime, summary) + } else { + if (lastEvent) updateChart("delay", lastEvent.delay) + updateChart("exec", duration) + subscribeToRecovery(piston.id, nextScheduledTime ?: 0) + def pistons = atomicState.pistons ?: [:] + pistons[piston.id] = summary + atomicState.pistons = pistons + } +} + +def generatePistonName() { + if (parent) { + return null + } + def apps = getChildApps() + def i = 1 + while (true) { + def name = i == 5 ? "Mambo No. 5" : "CoRE Piston #$i" + def found = false + for (app in apps) { + if (app.label == name) { + found = true + break + } + } + if (found) { + i++ + continue + } + return name + } +} + +def refreshPistons(event = true) { + if (event) sendLocationEvent([name: "CoRE", value: "refresh", isStateChange: true, linkText: "CoRE Refresh", descriptionText: "CoRE has an updated list of pistons", data: [pistons: listPistons()]]) + def pistons = [:] + for(app in getChildApps()) { + pistons[app.id] = app.getSummary() + } + atomicState.pistons = pistons +} + +def listAskAlexaMacros() { + if (parent) return parent.listAskAlexaMacros() + return state.askAlexaMacros ? state.askAlexaMacros : [] +} + +def listEchoSistantProfiles() { + if (parent) return parent.listEchoSistantProfiles() + return state.echoSistantProfiles ? state.echoSistantProfiles : [] +} + +def getIftttKey() { + if (parent) return parent.getIftttKey() + def module = atomicState.modules?.IFTTT + return (module && module.connected ? module.key : null) +} + +def getLifxToken() { + if (parent) return parent.getLifxToken() + def module = atomicState.modules?.LIFX + return (module && module.connected ? module.token : null) +} + +def listLifxScenes() { + if (parent) return parent.listLifxScenes() + def modules = atomicState.modules + if (modules && modules["LIFX"] && modules["LIFX"].connected) { + return modules["LIFX"]?.scenes*.name + } + return [] +} + +def getLifxSceneId(name) { + if (parent) return parent.getLifxSceneId(name) + def modules = atomicState.modules + if (modules && modules["LIFX"] && modules["LIFX"].connected) { + def scene = modules["LIFX"]?.scenes.find { it.name == name } + if (scene) return scene.id + } + return null +} + +/******************************************************************************/ +/*** ***/ +/*** CoRE PISTON CODE ***/ +/*** ***/ +/******************************************************************************/ + +/******************************************************************************/ +/*** CoRE PISTON INITIALIZATION METHODS ***/ +/******************************************************************************/ + +def initializeCoREPiston() { + // TODO: subscribe to attributes, devices, locations, etc. + //move app to production + state.run = "config" + state.debugLevel = 0 + debug "Initializing app...", 1 + cleanUpConditions(true) + state.app = state.config ? state.config.app : state.app + //save misc + state.app.mode = settings.mode + state.app.debugging = settings.debugging + state.app.disableCO = settings.disableCO + state.app.description = settings.description + state.app.restrictions = cleanUpMap([ + a: settings["restrictionAlarm"], + m: settings["restrictionMode"], + v: settings["restrictionVariable"], + vc: settings["restrictionComparison"], + vv: settings["restrictionValue"] != null ? settings["restrictionValue"] : "", + tf: settings["restrictionTimeFrom"], + tfc: settings["restrictionTimeFromCustom"], + tfo: settings["restrictionTimeFromOffset"], + tt: settings["restrictionTimeTo"], + ttc: settings["restrictionTimeToCustom"], + tto: settings["restrictionTimeToOffset"], + w: settings["restrictionDOW"], + s1: buildDeviceNameList(settings["restrictionSwitchOn"], "and"), + s0: buildDeviceNameList(settings["restrictionSwitchOff"], "and"), + pe: settings["restrictionPreventTaskExecution"], + ]) + state.lastInitialized = now() + setVariable("\$lastInitialized", state.lastInitialized, true) + setVariable("\$currentState", state.currentState, true) + setVariable("\$currentStateSince", state.currentStateSince, true) + + if (state.app.enabled) { + resume() + } + + state.remove("config") + state.remove("temp") + + debug "Done", -1 + parent.refreshPistons() + //we need to finalize to write atomic state + //save all atomic states to state + //to avoid race conditions +} + +def initializeCoREPistonStore() { + state.temp = state.temp ?: [:] + state.cache = [:] + state.tasks = state.tasks ? state.tasks : [:] + state.store = state.store ? state.store : [:] + state.stateStore = state.stateStore ? state.stateStore : [:] + state.systemStore = state.systemStore ? state.systemStore : initialSystemStore() + for (var in initialSystemStore()) { + if (!state.containsKey(var.key)) { + state.systemStore[var.key] = null + } + } +} + +/* prepare configuration version of app */ +private configApp() { + initializeCoREPistonStore() + if (!state.config) { + //initiate config app, since we have no running version yet (not yet installed) + state.config = [:] + state.config.conditionId = 0 + state.config.app = state.app && (state.app.conditions != null) && (state.app.otherConditions != null) && (state.app.actions != null) ? state.app : null + if (!state.config.app) { + state.config.app = [:] + //create the root condition + state.config.app.conditions = createCondition(true) + state.config.app.conditions.id = 0 + state.config.app.otherConditions = createCondition(true) + state.config.app.otherConditions.id = -1 + state.config.app.actions = [] + state.config.app.enabled = true + state.config.app.created = now() + state.config.app.version = version() + rebuildConditions() + rebuildActions() + } + } + //get expert savvy + state.config.expertMode = parent.expertMode() + state.config.app.mode = settings.mode ? settings.mode : "Basic" + state.config.app.description = settings.description + state.config.app.enabled = !!state.config.app.enabled + if (!state.app) state.app = [:] +} + +private subscribeToAll(appData) { + debug "Initializing subscriptions...", 1 + state.deviceSubscriptions = 0 + def hasTriggers = getConditionHasTriggers(appData.conditions) + def hasLatchingTriggers = false + if (settings.mode in ["Latching", "And-If", "Or-If"]) { + //we really get the count + hasLatchingTriggers = getConditionHasTriggers(appData.otherConditions) + //simulate subscribing to both lists + def subscriptions = subscribeToDevices(appData.conditions, hasTriggers, null, null, null, null) + def latchingSubscriptions = subscribeToDevices(appData.otherConditions, hasLatchingTriggers, null, null, null, null) + //we now have the two lists that we'd be subscribing to, let's figure out the common elements + def commonSubscriptions = [:] + for (subscription in subscriptions) { + if (latchingSubscriptions.containsKey(subscription.key)) { + //found a common subscription, save it + commonSubscriptions[subscription.key] = true + } + } + //perform subscriptions + subscribeToDevices(appData.conditions, false, bothDeviceHandler, null, commonSubscriptions, null) + subscribeToDevices(appData.conditions, hasTriggers, deviceHandler, null, null, commonSubscriptions) + subscribeToDevices(appData.otherConditions, hasLatchingTriggers, latchingDeviceHandler, null, null, commonSubscriptions) + } else { + //simple IF case, no worries here + subscribeToDevices(appData.conditions, hasTriggers, deviceHandler, null, null, null) + } + subscribe(location, "CoRE Recovery [${app.id}]", recoveryHandler) + debug "Finished subscribing", -1 +} + +private subscribeToDevices(condition, triggersOnly, handler, subscriptions, onlySubscriptions, excludeSubscriptions) { + if (subscriptions == null) { + subscriptions = [:] + } + def result = 0 + if (condition) { + if (condition.children != null) { + //we're dealing with a group + for (child in condition.children) { + subscribeToDevices(child, triggersOnly, handler, subscriptions, onlySubscriptions, excludeSubscriptions) + } + } else { + if (condition.trg || !triggersOnly) { + //get the details + def capability = getCapabilityByDisplay(condition.cap) + def devices = capability.virtualDevice ? (capability.attribute == "time" ? [] : [capability.virtualDevice]) : settings["condDevices${condition.id}"] + def attribute = capability.virtualDevice ? capability.attribute : condition.attr + def attr = getAttributeByName(attribute) + if (attr && attr.subscribe) { + attribute = attr.subscribe + } + if (capability && (capability.name == "variable") && (!condition.var || !condition.var.startsWith('@'))) { + //we don't want to subscribe to local variables + devices = null + } + if (devices) { + for (device in devices) { + def subscription = "${device.id}-${attribute}" + if ((excludeSubscriptions == null) || !(excludeSubscriptions[subscription])) { + //if we're provided with an exclusion list, we don't subscribe to those devices/attributes events + if ((onlySubscriptions == null) || onlySubscriptions[subscription]) { + //if we're provided with a restriction list, we use it + if (!subscriptions[subscription]) { + subscriptions[subscription] = true //[deviceId: device.id, attribute: attribute] + if (handler) { + //we only subscribe to the device if we're provided a handler (not simulating) + debug "Subscribing to events from $device for attribute $attribute, handler is $handler", null, "trace" + debug "Subscribing to events from $device for attribute $attribute, handler is $handler" + subscribe(device, attribute, handler) + state.deviceSubscriptions = state.deviceSubscriptions ? state.deviceSubscriptions + 1 : 1 + //initialize the cache for the device - this will allow the triggers to work properly on first firing + state.cache[device.id + "-" + attribute] = [v: device.currentValue(attribute), t: now()] + } + } + } + } + } + } else { + return + } + } + } + } + return subscriptions +} + +/******************************************************************************/ +/*** CoRE PISTON CONFIGURATION METHODS ***/ +/******************************************************************************/ + +def testIFTTT() { + //setup our security descriptor + state.modules["IFTTT"] = [ + key: settings.iftttKey, + connected: false + ] + if (settings.iftttKey) { + //verify the key + return httpGet("https://maker.ifttt.com/trigger/test/with/key/" + settings.iftttKey) { response -> + if (response.status == 200) { + if (response.data == "Congratulations! You've fired the test event") + state.modules["IFTTT"].connected = true + return true; + } + return false; + } + } + return false +} + +def testLIFX() { + if ((!settings.lifxToken) || (!settings.lifxEnabled)) return false + //setup our security descriptor + state.modules["LIFX"] = [ + token: settings.lifxToken, + connected: false + ] + if (settings.lifxToken) { + //verify the key + def requestParams = [ + uri: "https://api.lifx.com", + path: "/v1/scenes", + headers: [ + "Authorization": "Bearer ${settings.lifxToken}" + ], + requestContentType: "application/json" + ] + try { + return httpGet(requestParams) { response -> + if (response.status == 200) { + if (response.data instanceof List) { + state.modules["LIFX"].connected = true + def ss = [] + for(scene in response.data) { + def s = [ + id: scene.uuid, + name: scene.name + ] + ss.push(s) + } + state.modules["LIFX"].scenes = ss + } + return true; + } + return false; + } + } + catch(all) { + return false + } + } + return false +} + +//creates a condition (grouped or not) +private createCondition(group) { + def condition = [:] + //give the new condition an id + condition.id = (int) getNextConditionId() + //initiate the condition type + if (group) { + //initiate children + condition.children = [] + condition.actions = [] + } else { + condition.type = null + } + return condition +} + +//creates a condition and adds it to a parent +private createCondition(parentConditionId, group, conditionId = null) { + def parent = getCondition(parentConditionId) + if (parent) { + def condition = createCondition(group) + if (conditionId != null) condition.id = conditionId + //preserve the parentId so we can rebuild the app from settings + condition.parentId = parent ? (int) parent.id : null + //calculate depth for new condition + condition.level = (parent.level ? parent.level : 0) + 1 + //add the new condition to its parent, if any + //set the parent for upwards traversal + //if (!parent.children) parent = getCondition(0) + parent.children.push(condition) + //return the newly created condition + return condition + } + return null +} + +//deletes a condition +private deleteCondition(conditionId) { + def condition = getCondition(conditionId) + if (condition) { + def parent = getCondition(condition.parentId) + if (parent) { + parent.children.remove(condition); + } + } +} + +private updateCondition(condition) { + condition.cap = settings["condCap${condition.id}"] + condition.dev = [] + condition.sdev = settings["condSubDev${condition.id}"] + condition.attr = cleanUpAttribute(settings["condAttr${condition.id}"]) + condition.iact = settings["condInteraction${condition.id}"] + switch (condition.cap) { + case "Ask Alexa Macro": + condition.attr = "askAlexaMacro" + condition.dev.push "location" + break + case "EchoSistant Profile": + condition.attr = "echoSistantProfile" + condition.dev.push "location" + break + case "IFTTT": + condition.attr = "ifttt" + condition.dev.push "location" + break + case "Time": + case "Date & Time": + condition.attr = "time" + condition.dev.push "time" + break + case "Mode": + case "Location Mode": + condition.attr = "mode" + condition.dev.push "location" + break + case "Smart Home Monitor": + condition.attr = "alarmSystemStatus" + condition.dev.push "location" + break + case "CoRE Piston": + case "Piston": + condition.attr = "piston" + condition.dev.push "location" + break + case "Routine": + condition.attr = "routineExecuted" + condition.dev.push "location" + break + case "Variable": + condition.attr = "variable" + condition.dev.push "location" + break + } + if (!condition.attr) { + def cap = getCapabilityByDisplay(condition.cap) + if (cap && cap.attribute) { + condition.attr = cap.attribute + if (cap.virtualDevice) condition.dev.push(cap.virtualDevice) + } + } + def dev + for (device in settings["condDevices${condition.id}"]) + { + //save the list of device IDs - we can't have the actual device objects in the state + dev = device + condition.dev.push(device.id) + } + condition.comp = cleanUpComparison(settings["condComp${condition.id}"]) + condition.var = settings["condVar${condition.id}"] + condition.dt = settings["condDataType${condition.id}"] + condition.trg = !!isComparisonOptionTrigger(condition.attr, condition.comp, condition.attr == "variable" ? condition.dt : null, dev) + condition.mode = condition.trg ? "Any" : (settings["condMode${condition.id}"] ? settings["condMode${condition.id}"] : "Any") + condition.var1 = settings["condVar${condition.id}#1"] + condition.dev1 = condition.var1 ? null : settings["condDev${condition.id}#1"] ? getDeviceLabel(settings["condDev${condition.id}#1"]) : null + condition.attr1 = condition.var1 ? null : settings["condAttr${condition.id}#1"] ? getDeviceLabel(settings["condAttr${condition.id}#1"]) : null + condition.val1 = (condition.attr != "time") && (condition.var1 || condition.dev1) ? null : settings["condValue${condition.id}#1"] + condition.var2 = settings["condVar${condition.id}#2"] + condition.dev2 = condition.var2 ? null : settings["condDev${condition.id}#2"] ? getDeviceLabel(settings["condDev${condition.id}#2"]) : null + condition.attr2 = condition.var2 ? null : settings["condAttr${condition.id}#2"] ? getDeviceLabel(settings["condAttr${condition.id}#2"]) : null + condition.val2 = (condition.attr != "time") && (condition.var2 || condition.dev2) ? null : settings["condValue${condition.id}#2"] + condition.for = settings["condFor${condition.id}"] + condition.fort = settings["condTime${condition.id}"] + condition.t1 = settings["condTime${condition.id}#1"] + condition.t2 = settings["condTime${condition.id}#2"] + condition.o1 = settings["condOffset${condition.id}#1"] + condition.o2 = settings["condOffset${condition.id}#2"] + condition.e = settings["condEvery${condition.id}"] + condition.e = condition.e ? condition.e : 5 + condition.m = settings["condMinute${condition.id}"] + //time repeat + condition.r = settings["condRepeat${condition.id}"] + condition.re = settings["condRepeatEvery${condition.id}"] + condition.re = condition.re ? condition.re : 2 + condition.rd = settings["condRepeatDay${condition.id}"] + condition.rdw = settings["condRepeatDayOfWeek${condition.id}"] + condition.rm = settings["condRepeatMonth${condition.id}"] + + //time filters + condition.fmh = settings["condMOH${condition.id}"] + condition.fhd = settings["condHOD${condition.id}"] + condition.fdw = settings["condDOW${condition.id}"] + condition.fdm = settings["condDOM${condition.id}"] + condition.fwm = settings["condWOM${condition.id}"] + condition.fmy = settings["condMOY${condition.id}"] + condition.fy = settings["condY${condition.id}"] + + condition.grp = settings["condGrouping${condition.id}"] + condition.grp = condition.grp && condition.grp.size() ? condition.grp : "AND" + condition.not = !!settings["condNegate${condition.id}"] + + //variables + condition.vd = settings["condVarD${condition.id}"] + condition.vs = settings["condVarS${condition.id}"] + condition.vm = settings["condVarM${condition.id}"] + condition.vn = settings["condVarN${condition.id}"] + condition.vt = settings["condVarT${condition.id}"] + condition.vv = settings["condVarV${condition.id}"] + condition.vf = settings["condVarF${condition.id}"] + condition.vw = settings["condVarW${condition.id}"] + + condition.it = settings["condImportT${condition.id}"] + condition.itp = settings["condImportTP${condition.id}"] + condition.if = settings["condImportF${condition.id}"] + condition.ifp = settings["condImportFP${condition.id}"] + + condition = cleanUpMap(condition) + return null +} + +//used to get the next id for a condition, action, etc - looks into settings to make sure we're not reusing a previously used id +private getNextConditionId() { + def nextId = getLastConditionId(state.config.app.conditions) + 1 + def otherNextId = getLastConditionId(state.config.app.otherConditions) + 1 + nextId = nextId > otherNextId ? nextId : otherNextId + def keys = settings.findAll { it.key.startsWith("condParent") } + while (keys.find { it.key == "condParent" + nextId }) { + nextId++ + } + return (int) nextId +} + +//helper function for getNextId +private getLastConditionId(parent) { + if (!parent) return -1 + def lastId = parent?.id + for (child in parent.children) { + def childLastId = getLastConditionId(child) + lastId = lastId > childLastId ? lastId : childLastId + } + return lastId +} + +//creates a condition (grouped or not) +private createAction(parentId, onState = true, actionId = null) { + def action = [:] + //give the new condition an id + action.id = actionId == null ? getNextActionId() : actionId + action.pid = (int) parentId + action.rs = !!onState + state.config.app.actions.push(action) + return action +} + +private getNextActionId() { + def nextId = 1 + for(action in state.config.app.actions) { + if (action.id > nextId) { + nextId = action.id + 1 + } + } + while (settings.findAll { it.key == "actParent" + nextId }) { + nextId++ + } + return (int) nextId +} + +private updateAction(action) { + if (!action) return null + def id = action.id + def devices = [] + def usedCapabilities = [] + //did we get any devices? search all capabilities + for(def capability in capabilities()) { + if (capability.devices) { + //only if the capability published any devices - it wouldn't be here otherwise + def dev = settings["actDev$id#${capability.name}"] + if (dev && dev.size()) { + devices = devices + dev + //add to used capabilities - needed later + if (!(capability.name in usedCapabilities)) { + usedCapabilities.push(capability.name) + } + } + } + } + action.d = [] + for(device in devices) { + if (!(device.id in action.d)) { + action.d.push(device.id) + } + } + action.l = settings["actDev$id#location"] + + //restrictions + action.rc = settings["actRStateChange$id"] + action.rs = cast(action.pid > 0 ? (settings["actRState$id"] != null ? settings["actRState$id"] : (action.rs == null ? true : action.rs)) : true, "boolean") + action.ra = settings["actRAlarm$id"] + action.rm = settings["actRMode$id"] + action.rv = settings["actRVariable$id"] + action.rvc = settings["actRComparison$id"] + action.rvv = settings["actRValue$id"] != null ? settings["actRValue$id"] : "" + action.rw = settings["actRDOW$id"] + action.rtf = settings["actRTimeFrom$id"] + action.rtfc = settings["actRTimeFromCustom$id"] + action.rtfo = settings["actRTimeFromOffset$id"] + action.rtt = settings["actRTimeTo$id"] + action.rttc = settings["actRTimeToCustom$id"] + action.rtto = settings["actRTimeToOffset$id"] + action.rs1 = [] + for (device in settings["actRSwitchOn$id"]) { action.rs1.push(device.id) } + action.rs0 = [] + for (device in settings["actRSwitchOff$id"]) { action.rs0.push(device.id) } + action.tos = settings["actTOS$id"] + action.tcp = settings["actTCP$id"] + + //look for tasks + action.t = [] + def prefix = "actTask$id#" + def tasks = settings.findAll{it.key.startsWith(prefix)} + def ids = [] + //we need to get a list of all existing ids that are used + for (item in tasks) { + if (item.value) { + def tid = item.key.replace(prefix, "") + if (tid.isInteger()) { + tid = tid.toInteger() + def task = [ i: tid + 0 ] + //get task data + //get command + def cmd = settings["$prefix$tid"] + task.c = cmd + task.p = [] + task.m = settings["actParamMode$id#$tid"] + task.d = settings["actParamDOW$id#$tid"] + def virtual = (cmd && cmd.startsWith(virtualCommandPrefix())) + def custom = (cmd && cmd.startsWith(customCommandPrefix())) + cmd = cleanUpCommand(cmd) + def command = null + if (virtual) { + //dealing with a virtual command + command = getVirtualCommandByDisplay(cmd) + } else { + command = getCommandByDisplay(cmd) + } + if (command) { + if (command.name == "setVariable") { + //setVariable is different, we've got a variable number of parameters... + //variable name + task.p.push([i: 0, t: "variable", d: settings["actParam$id#$tid-0"], v: 1]) + //data type + def dataType = settings["actParam$id#$tid-1"] + task.p.push([i: 1, t: "text", d: dataType]) + //immediate + task.p.push([i: 2, t: "bool", d: !!settings["actParam$id#$tid-2"]]) + //formula + task.p.push([i: 3, t: "text", d: settings["actParam$id#$tid-3"]]) + def i = 4 + while (true) { + //value + def val = settings["actParam$id#$tid-$i"] + def var = settings["actParam$id#$tid-${i + 1}"] + if ((dataType == "string") && (val == null) && (var == null)) val = "" + task.p.push([i: i, t: dataType, d: val]) + //variable name + task.p.push([i: i + 1, t: "text", d: var]) + //variable name + task.p.push([i: i + 2, t: "text", d: settings["actParam$id#$tid-${i + 2}"]]) + //next operation + def operation = settings["actParam$id#$tid-${i + 3}"] + if (!operation) break + task.p.push([i: i + 3, t: "text", d: operation]) + if (dataType == "time") dataType = "decimal" + i = i + 4 + } + } else if (command.parameters) { + def i = 0 + for (def parameter in command.parameters) { + def param = parseCommandParameter(parameter) + if (param) { + def type = param.type + def data = settings["actParam$id#$tid-$i"] + //so ST silently!!! fails if we're having a list and that list contains wrappers (like contacts!) + if ((data instanceof ArrayList)) { + def items = [] + for(it in data) { + items.push("$it") + } + data = items + } + def var = (command.varEntry == i) + if (var) { + task.p.push([i: i, t: type, d: data, v: 1]) + } else { + task.p.push([i: i, t: type, d: data]) + } + } + i++ + } + } + } else if (custom) { + //custom parameters + def i = 1 + while (true) { + //value + def type = settings["actParam$id#$tid-$i"] + if (type) { + //parameter type + task.p.push([i: i, t: "string", d: settings["actParam$id#$tid-$i"]]) + //parameter value + task.p.push([i: i + 1, t: type, d: settings["actParam$id#$tid-${i + 1}"]]) + } else { + break + } + i += 2 + } + + } + action.t.push(task) + } + } + } + //clean up for memory optimization + action = cleanUpMap(action) +} + +private cleanUpActions() { + for(action in state.config.app.actions) { + updateAction(action) + } + def washer = [] + for(action in state.config.app.actions) { + if (!((action.d && action.d.size()) || action.l)) { + washer.push(action) + } + } + for (action in washer) { + state.config.app.actions.remove(action) + } + washer = null + + /* + def dirty = true + while (dirty) { + dirty = false + for(action in state.config.app.actions) { + if (!((action.d && action.d.size()) || action.l)) { + state.config.app.actions.remove(action) + dirty = true + break + } + } + } + */ +} + +private listActionDevices(actionId) { + def devices = [] + //did we get any devices? search all capabilities + for(def capability in capabilities()) { + if (capability.devices) { + //only if the capability published any devices - it wouldn't be here otherwise + def dev = settings["actDev$actionId#${capability.name}"] + for (d in dev) { + if (!(d in devices)) { + devices.push(d) + } + } + } + } + return devices +} + +private getActionDescription(action) { + if (!action) return null + def devices = (action.l ? ["location"] : listActionDevices(action.id)) + def result = "" + if (action.rc) { + result += "® If ${action.pid > 0 ? "condition" : "piston"} state changes...\n" + } + if (action.rm) { + result += "® If mode is ${buildNameList(action.rm, "or")}...\n" + } + if (action.ra) { + result += "® If alarm is ${buildNameList(action.ra, "or")}...\n" + } + if (action.rv) { + result += "® If {${action.rv}} ${action.rvc} ${action.rvv}...\n" + } + if (action.rw) { + result += "® If day is ${buildNameList(action.rw, "or")}...\n" + } + if (action.rtf && action.rtt) { + result += "® If time is between ${action.rtf == "custom time" ? formatTime(action.rtfc) : (action.rtfo ? (action.rtfo < 0 ? "${-action.rtfo} minutes before " : "${action.rtfo} minutes after ") : "") + action.rtf} and ${action.rtt == "custom time" ? formatTime(action.rttc) : (action.rtto ? (action.rtto < 0 ? "${-action.rtto} minutes before " : "${action.rtto} minutes after ") : "") + action.rtt}...\n" + } + if (action.rs1) { + result += "® If each of ${buildDeviceNameList(settings["actRSwitchOn${action.id}"], "and")} is on" + } + if (action.rs0) { + result += "® If each of ${buildDeviceNameList(settings["actRSwitchOff${action.id}"], "and")} is off" + } + result += (result ? "\n" : "") + "Using " + buildDeviceNameList(devices, "and")+ "..." + state.taskIndent = 0 + def tasks = action.t.sort{it.i} + for (task in tasks) { + def t = cleanUpCommand(task.c) + if (task.p && task.p.size()) { + t += " [" + def i = 0 + for(param in task.p.sort{ it.i }) { + t += (i > 0 ? ", " : "") + (param.v ? "{${param.d}}" : "${param.d}") + i++ + } + t += "]" + + } + result += "\n " + getTaskDescription(task, '► ') + } + return result +} + +def getActionDeviceList(action) { + if (!action) return null + def devices = (action.l ? ["location"] : listActionDevices(action.id)) + return buildDeviceNameList(devices, "and") +} + +private getTaskDescription(task, prfx = '') { + if (!task) return "[ERROR]" + state.taskIndent = state.taskIndent ? state.taskIndent : 0 + def virtual = (task.c && task.c.startsWith(virtualCommandPrefix())) + def custom = (task.c && task.c.startsWith(customCommandPrefix())) + def command = cleanUpCommand(task.c) + + def selfIndent = 0 + def indent = 0 + + def result = "" + if (custom) { + result = task.c.replace(customCommandSuffix(), "") + "(" + for (int i=0; i < task.p.size() / 2; i++) { + if (i > 0) result += ", " + int j = i * 2 + 1 + if (task.p[j].t == "string") { + result += "\"${task.p[j].d}\"" + } else { + result += "${task.p[j].d}" + } + } + result = result + ")" + } else { + def cmd = (virtual ? getVirtualCommandByDisplay(command) : getCommandByDisplay(command)) + if (!cmd) { + result = "[ERROR]" + } else { + indent = cmd.indent ? cmd.indent : 0 + selfIndent = cmd.selfIndent ? cmd.selfIndent : 0 + if (cmd.name == "setVariable") { + if (task.p.size() < 7) return "[ERROR]" + def name = task.p[0].d + def dataType = task.p[1].d + def immediate = !!task.p[2].d + if (!name || !dataType) return "[ERROR]" + result = "${immediate ? "Immediately set" : "Set"} $dataType variable {$name} = " + def i = 4 + def grouping = false + def groupingUnit = "" + while (true) { + def value = task.p[i].d + //null strings are really blanks + if ((dataType == "string") && (value == null)) value = "" + if ((dataType == "time") && (i == 4) && (value != null)) value = formatTime(value) + def variable = value != null ? (dataType == "string" ? "\"$value\"" : "$value") : "${task.p[i + 1].d}" + def unit = (dataType == "time" ? task.p[i + 2].d : null) + def operation = task.p.size() > i + 3 ? "${task.p[i + 3].d} ".tokenize(" ")[0] : null + def needsGrouping = (operation == "*") || (operation == "÷") || (operation == "AND") + if (needsGrouping) { + //these operations require grouping i.e. (a * b * c) seconds + if (!grouping) { + grouping = true + groupingUnit = unit + result += "(" + } + } + //add the value/variable + result += variable + (!grouping && unit ? " $unit" : "") + if (grouping && !needsGrouping) { + //these operations do NOT require grouping + grouping = false + result += ")${groupingUnit ? " $groupingUnit" : ""}" + } + if (!operation) break + result += " $operation " + i += 4 + } + } else if (cmd.name == "setColor") { + result = "Set color to " + if (task.p[0].d) { + result = result + "\"${task.p[0].d}\"" + } else if (task.p[1].d) { + result = result + "RGB(${task.p[1].d})" + } else { + result = result + "HSL(${task.p[2].d}°, ${task.p[3].d}%, ${task.p[4].d}%)" + } + } else { + result = formatMessage(cmd.description ?: cmd.display, task.p) + } + } + } + def currentIndent = state.taskIndent + selfIndent + def prefix = "".padLeft(currentIndent > 0 ? currentIndent * 3 : 0, "│ ") + state.taskIndent = state.taskIndent + indent + return prefix + (prfx ?: '') + result + (task.m && task.m.size() ? " (only for ${buildNameList(task.m, "or")})" : "") + (task.d && task.d.size() ? " (only on ${buildNameList(task.d, "or")})" : "") +} + +/******************************************************************************/ +/*** ENTRY AND EXIT POINT HANDLERS ***/ +/******************************************************************************/ + +def deviceHandler(evt) { + entryPoint() + if (!preAuthorizeEvent(evt)) return + //executes whenever a device in the primary if block has an event + //starting primary IF block evaluation + def perf = now() + debug "Received a primary block device event", 1, "trace" + broadcastEvent(evt, true, false) + //process tasks + processTasks() + exitPoint(now() - perf) + perf = now() - perf + debug "Piston done in ${perf}ms", -1, "trace" +} + +def latchingDeviceHandler(evt) { + entryPoint() + if (!preAuthorizeEvent(evt)) return + //executes whenever a device in the primary if block has an event + //starting primary IF block evaluation + def perf = now() + debug "Received a secondary block device event", 1, "trace" + broadcastEvent(evt, false, true) + //process tasks + processTasks() + exitPoint(now() - perf) + perf = now() - perf + debug "Piston done in ${perf}ms", -1, "trace" +} + +def bothDeviceHandler(evt) { + entryPoint() + if (!preAuthorizeEvent(evt)) return + //executes whenever a common use device has an event + //broadcast to both IF blocks + def perf = now() + debug "Received a dual block device event", 1, "trace" + broadcastEvent(evt, true, true) + //process tasks + processTasks() + exitPoint(now() - perf) + perf = now() - perf + debug "Piston done in ${perf}ms", -1, "trace" +} + +def timeHandler() { + entryPoint() + //executes whenever a device in the primary if block has an event + //starting primary IF block evaluation + def perf = now() + debug "Received a time event", 1, "trace" + processTasks() + exitPoint(now() - perf) + perf = now() - perf + debug "Piston done in ${perf}ms", -1, "trace" +} + +def timeoutRecoveryHandler_CoRE() { + recoveryHandler(null, true) +} + +def recoveryHandler(evt = null, showWarning = true) { + if (evt) { + if (evt.jsonData && evt.jsonData.rebuild) { + debug "Received a REBUILD request...", null, "info" + rebuildPiston(true) + return + } else { + debug "Received a RECOVER request...", null, "info" + } + } + entryPoint() + //executes whenever a device in the primary if block has an event + //starting primary IF block evaluation + def perf = now() + //removed 2017/11/13 to alleviate tombstone issues + //debug "Received a recovery request", 1, "trace" + if (!evt && showWarning) debug "CAUTION: Received a recovery event", 1, "warn" + //reset markers for all tasks, the owner of the task probably crashed :) + def tasks = atomicState.tasks + for(task in tasks.findAll{ it.value.marker != null }) { + task.value.marker = null + } + atomicState.tasks = tasks + processTasks() + exitPoint(now() - perf) + perf = now() - perf + debug "Piston done in ${perf}ms", -1, "trace" +} + +def executeHandler(data = null) { + entryPoint() + //executes whenever a device in the primary if block has an event + //starting primary IF block evaluation + def perf = now() + if (data instanceof Map) { + for(item in data) { + setVariable(item.key, item.value) + } + } + debug "Received an execute request", 1, "trace" + broadcastEvent([name: "execute", date: new Date(), deviceId: "time", conditionId: null], true, false) + processTasks() + exitPoint(now() - perf) + perf = now() - perf + debug "Piston done in ${perf}ms", -1, "trace" + return state.currentState +} + +private preAuthorizeEvent(evt) { + if (!(evt.name in ["piston", "routineExecuted", "askAlexaMacro", "echoSistantProfile", "ifttt", "variable"])) return true + //prevent one piston from retriggering itself + if (evt && (evt.name == "piston") && (evt.value == app.label)) return false + state.filterEvent = true + if (evt.name == "variable") { + withEachCondition(state.app.conditions, "preAuthorizeTrigger", evt) + if (state.filterEvent) withEachCondition(state.app.otherConditions, "preAuthorizeTrigger", evt) + } else { + withEachTrigger(state.app.conditions, "preAuthorizeTrigger", evt) + if (state.filterEvent) withEachTrigger(state.app.otherConditions, "preAuthorizeTrigger", evt) + } + if (state.filterEvent) debug "Received a '${evt.name}' event, but no trigger matches it, so we're not going to execute at this time." + return !state.filterEvent +} + +private preAuthorizeTrigger(condition, evt) { + if (!state.filterEvent) return + def attribute = evt.name + def value = evt.value + switch (evt.name) { + case "routineExecuted": + value = evt.displayName + break + } + if ((condition.attr == attribute) && (attribute == "variable" ? condition.var == value : ((condition.var1 ? getVariable(condition.var1) : condition.val1) == value))) state.filterEvent = false + return +} + +private entryPoint() { + //initialize whenever app runs + //use the "app" version throughout + state.run = "app" + state.sim = null + state.debugLevel = 0 + state.globalVars = [:] + state.tasker = [] + //state.tasker = state.tasker ? state.tasker : [] +} + +private exitPoint(milliseconds) { + def perf = now() + def appData = state.run == "config" ? state.config.app : state.app + def runStats = atomicState.runStats + if (runStats == null) runStats = [:] + runStats.executionSince = runStats.executionSince ? runStats.executionSince : now() + runStats.executionCount = runStats.executionCount ? runStats.executionCount + 1 : 1 + runStats.executionTime = runStats.executionTime ? runStats.executionTime + milliseconds : milliseconds + runStats.minExecutionTime = runStats.minExecutionTime && runStats.minExecutionTime < milliseconds ? runStats.minExecutionTime : milliseconds + runStats.maxExecutionTime = runStats.maxExecutionTime && runStats.maxExecutionTime > milliseconds ? runStats.maxExecutionTime : milliseconds + runStats.lastExecutionTime = milliseconds + + def lastEvent = state.lastEvent + if (lastEvent && lastEvent.delay) { + runStats.eventDelay = runStats.eventDelay ? runStats.eventDelay + lastEvent.delay : lastEvent.delay + runStats.minEventDelay = runStats.minEventDelay && runStats.minEventDelay < lastEvent.delay ? runStats.minEventDelay : lastEvent.delay + runStats.maxEventDelay = runStats.maxEventDelay && runStats.maxEventDelay > lastEvent.delay ? runStats.maxEventDelay : lastEvent.delay + runStats.lastEventDelay = lastEvent.delay + } + setVariable("\$previousEventExecutionTime", milliseconds, true) + state.lastExecutionTime = milliseconds + + try { + state.nextScheduledTime = atomicState.nextScheduledTime + parent.onChildExitPoint(app, lastEvent, milliseconds, state.nextScheduledTime, getSummary()) + } catch(e) { + debug "ERROR: Could not update parent app: ", null, "error", e + } + atomicState.runStats = runStats + + if (lastEvent && lastEvent.event) { + if (lastEvent.event.name != "piston") { + sendLocationEvent(name: "piston", value: "${app.label}", displayed: true, linkText: "CoRE/${app.label}", isStateChange: true, descriptionText: "${appData.mode} piston executed in ${milliseconds}ms", data: [app: "CoRE", state: state.currentState, restricted: state.restricted, executionTime: milliseconds, event: lastEvent]) + } + } + + //give a chance to variable events + publishVariables() + + //save all atomic states to state + //to avoid race conditions + state.cache = atomicState.cache + state.tasks = atomicState.tasks + state.stateStore = atomicState.stateStore + state.runStats = atomicState.runStats + state.currentState = atomicState.currentState + state.currentStateSince = atomicState.currentStateSince + state.temp = null + state.sim = null + +} + +/******************************************************************************/ +/*** EVENT MANAGEMENT FUNCTIONS ***/ +/******************************************************************************/ + +private broadcastEvent(evt, primary, secondary) { + //filter duplicate events and broadcast event to proper IF blocks + def perf = now() + def delay = perf - evt.date.getTime() + def app = state.run == "config" ? state.config.app : state.app + debug "Processing event ${evt.name}${evt.device ? " for device ${evt.device}" : ""}${evt.deviceId ? " with id ${evt.deviceId}" : ""}${evt.value ? ", value ${evt.value}" : ""}, generated on ${evt.date}, about ${delay}ms ago (${version()})", 1, "trace" + def allowed = true + def restriction + def initialState = atomicState.currentState + def initialStateSince = atomicState.currentStateSince + if (evt && app.restrictions) { + //check restrictions + restriction = checkPistonRestriction() + allowed = (restriction == null) + } + //save previous event + setVariable("\$previousEventReceived", getVariable("\$currentEventReceived"), true) + setVariable("\$previousEventDevice", getVariable("\$currentEventDevice"), true) + setVariable("\$previousEventDeviceIndex", getVariable("\$currentEventDeviceIndex"), true) + setVariable("\$previousEventDevicePhysical", getVariable("\$currentEventDevicePhysical"), true) + setVariable("\$previousEventAttribute", getVariable("\$currentEventAttribute"), true) + setVariable("\$previousEventValue", getVariable("\$currentEventValue"), true) + setVariable("\$previousEventDate", getVariable("\$currentEventDate"), true) + setVariable("\$previousEventDelay", getVariable("\$currentEventDelay"), true) + def lastEvent = [ + event: [ + device: evt.device ? "${evt.device}" : evt.deviceId, + name: evt.name, + value: evt.value, + date: evt.date + ], + delay: delay + ] + state.lastEvent = lastEvent + setVariable("\$currentEventReceived", perf, true) + setVariable("\$currentEventDevice", lastEvent.event.device, true) + setVariable("\$currentEventDeviceIndex", 0, true) + setVariable("\$currentEventDevicePhysical", 0, true) + setVariable("\$currentEventAttribute", lastEvent.event.name, true) + setVariable("\$currentEventValue", lastEvent.event.value, true) + setVariable("\$currentEventDate", lastEvent.event.date && lastEvent.event.date instanceof Date ? lastEvent.event.date.time : null, true) + setVariable("\$currentEventDelay", lastEvent.delay, true) + if (!(evt.name in ["askAlexaMacro", "echoSistantProfile", "ifttt", "piston", "routineExecuted", "variable", "time"])) { + def cache = atomicState.cache + cache = cache ? cache : [:] + def deviceId = evt.deviceId ? evt.deviceId : location.id + def cachedValue = cache[deviceId + '-' + evt.name] + def eventTime = evt.date.getTime() + cache[deviceId + '-' + evt.name] = [o: cachedValue ? cachedValue.v : null, v: evt.value, q: cachedValue ? cachedValue.p : null, p: !!evt.physical, t: eventTime ] + if (evt.name == "threeAxis") { + cachedValue = cache[deviceId + '-orientation'] + cache[deviceId + '-orientation'] = [o: cachedValue ? cachedValue.v : null, v: getThreeAxisOrientation(evt.xyzValue), q: cachedValue ? cachedValue.p : null, p: !!evt.physical, t: eventTime ] + } + atomicState.cache = cache + state.cache = cache + if (cachedValue) { + if ((cachedValue.v == evt.value) && (!evt.jsonData) && (/*(cachedValue.v instanceof String) || */(eventTime < cachedValue.t) || (cachedValue.t + 1000 > eventTime))) { + //duplicate event + debug "WARNING: Received duplicate event for device ${evt.device}, attribute ${evt.name}='${evt.value}', ignoring...", null, "warn" + evt = null + } + } + } + if (allowed) { + try { + resetConditionState(app.conditions) + resetConditionState(app.otherConditions) + if (evt) { + //broadcast to primary IF block + def result1 = null + def result2 = null + //some piston modes require evaluation of secondary conditions regardless of eligibility - we use force then + def force = false + def mode = app.mode + switch (mode) { + case "And-If": + case "Or-If": + case "Latching": + //these three modes always evaluate both blocks + primary = true + secondary = true + force = true + break + case "Do": + primary = false + secondary = false + force = false + result1 = false + result2 = false + break + } + //override eligibility concerns when dealing with Follow-Up pistons, or when dealing with "execute" and "simulate" events + force = force || app.mode == "Follow-Up" || (evt && evt.name in ["execute", "simulate", "time"]) + if (primary) { + result1 = !!evaluateConditionSet(evt, true, force) + state.lastPrimaryEvaluationResult = result1 + state.lastPrimaryEvaluationDate = now() + def msg = "Primary IF block evaluation result is $result1" + if (state.sim) state.sim.evals.push(msg) + debug msg + + switch (mode) { + case "Then-If": + //execute the secondary branch if the primary one is true + secondary = result1 + force = true + break + case "Else-If": + //execute the second branch if the primary one is false + secondary = !result1 + force = true + break + } + } + + //broadcast to secondary IF block + if (secondary) { + result2 = !!evaluateConditionSet(evt, false, force) + state.lastSecondaryEvaluationResult = result2 + state.lastSecondaryEvaluationDate = now() + def msg = "Secondary IF block evaluation result is $result2" + if (state.sim) state.sim.evals.push(msg) + debug msg + } + def currentState = initialState + def currentStateSince = initialStateSince + + def stateMsg = null + + switch (mode) { + case "Latching": + if (initialState in [null, false]) { + if (result1) { + //flip on + currentState = true + currentStateSince = now() + stateMsg = "♦ Latching Piston changed state to true ♦" + } + } + if (initialState in [null, true]) { + if (result2) { + //flip off + currentState = false + currentStateSince = now() + stateMsg = "♦ Latching Piston changed state to false ♦" + } + } + break + case "Do": + currentState = false + currentStateSince = now() + stateMsg = "♦ $mode Piston changed state to $result1 ♦" + break + case "Basic": + case "Simple": + case "Follow-Up": + result2 = !result1 + if (initialState != result1) { + currentState = result1 + currentStateSince = now() + stateMsg = "♦ $mode Piston changed state to $result1 ♦" + } + break + case "And-If": + def newState = result1 && result2 + if (initialState != newState) { + currentState = newState + currentStateSince = now() + stateMsg = "♦ And-If Piston changed state to $newState ♦" + } + break + case "Or-If": + def newState = result1 || result2 + if (initialState != newState) { + currentState = newState + currentStateSince = now() + stateMsg = "♦ Or-If Piston changed state to $newState ♦" + } + break + case "Then-If": + def newState = result1 && result2 + if (initialState != newState) { + currentState = newState + currentStateSince = now() + stateMsg = "♦ Then-If Piston changed state to $newState ♦" + } + break + case "Else-If": + def newState = result1 || result2 + if (initialState != newState) { + currentState = newState + currentStateSince = now() + stateMsg = "♦ Else-If Piston changed state to $newState ♦" + } + break + } + if (stateMsg) { + if (state.sim) state.sim.evals.push stateMsg + debug stateMsg, null, "info" + } + def stateChanged = false + if (currentState != initialState) { + stateChanged = true + //we have a state change + setVariable("\$previousState", initialState, true) + setVariable("\$previousStateSince", initialStateSince, true) + setVariable("\$previousStateDuration", initialStateSince && currentStateSince ? currentStateSince - initialStateSince : null, true) + setVariable("\$currentState", currentState, true) + setVariable("\$currentStateSince", currentStateSince, true) + //new state + atomicState.currentState = currentState + atomicState.currentStateSince = currentStateSince + state.currentState = currentState + state.currentStateSince = currentStateSince + //resume all tasks that are waiting for a state change + cancelTasks(currentState) + resumeTasks(currentState) + } + //execute the DO EVERY TIME actions + if (mode != "Do") { + if (result1) scheduleActions(0, stateChanged) + if (result2) scheduleActions(-1, stateChanged) + } + if (!(mode in ["Basic", "Latching"]) && (!currentState)) { + //execute the else branch + scheduleActions(-2, stateChanged) + } + } + } catch(e) { + debug "ERROR: An error occurred while processing event $evt: ", null, "error", e + } + } else { + def msg = "Piston evaluation was prevented by ${restriction}." + if (state.sim) state.sim.evals.push(msg) + debug msg, null, "trace" + } + perf = now() - perf + if (evt) debug "Event processing took ${perf}ms", -1, "trace" +} + +private checkPistonRestriction() { + def restriction + def app = state.run == "config" ? state.config.app : state.app + + if (app.restrictions.m && app.restrictions.m.size() && !(location.mode in app.restrictions.m)) { + restriction = "a mode mismatch" + } else if (app.restrictions.a && app.restrictions.a.size() && !(getAlarmSystemStatus() in app.restrictions.a)) { + restriction = "an alarm status mismatch" + } else if (app.restrictions.v && !(checkVariableCondition(app.restrictions.v, app.restrictions.vc, app.restrictions.vv))) { + restriction = "variable condition {${app.restrictions.v}} ${app.restrictions.vc} '${app.restrictions.vv}'" + } else if (app.restrictions.w && app.restrictions.w.size() && !(getDayOfWeekName() in app.restrictions.w)) { + restriction = "a day of week mismatch" + } else if (app.restrictions.tf && app.restrictions.tt && !(checkTimeCondition(app.restrictions.tf, app.restrictions.tfc, app.restrictions.tfo, app.restrictions.tt, app.restrictions.ttc, app.restrictions.tto))) { + restriction = "a time of day mismatch" + } else { + if (settings["restrictionSwitchOn"]) { + for(sw in settings["restrictionSwitchOn"]) { + if (sw.currentValue("switch") != "on") { + restriction = "switch ${sw} being ${sw.currentValue("switch")}" + break + } + } + } + if (!restriction && settings["restrictionSwitchOff"]) { + for(sw in settings["restrictionSwitchOff"]) { + if (sw.currentValue("switch") != "off") { + restriction = "switch ${sw} being ${sw.currentValue("switch")}" + break + } + } + } + } + return restriction +} + +private checkEventEligibility(condition, evt) { + //we have a quad-state result + // -2 means we're using triggers and the event does not match any of the used triggers + // -1 means we're using conditions only and the event does not match any of the used conditions + // 1 means we're using conditions only and the event does match at least one of the used conditions + // 2 means we're using triggers and the event does match at least one of the used triggers + // any positive value means the event is eligible for evaluation + def result = -1 //assuming conditions only, no match + if (condition) { + if (condition.children != null) { + //we're dealing with a group + for (child in condition.children) { + def v = checkEventEligibility(child, evt) + switch (v) { + case -2: + result = v + break + case -1: + break + case 1: + if (result == -1) { + result = v + } + break + case 2: + //if we already found a matching trigger, we're out + return v + } + } + } else { + if (condition.trg) { + if (result < 2) { + //if we haven't already found a trigger + result = -2 // we are using triggers + } + } + for (deviceId in condition.dev) { + if ((evt.deviceId ? evt.deviceId : "location" == deviceId) && (evt.name == (condition.attr in ["orientation", "axisX", "axisY", "axisZ"] ? "threeAxis" : condition.attr))) { + if (condition.trg) { + //we found a trigger that matches the event, exit immediately + return 2 + } else { + if (result == -1) { + //we found a condition that matches the event, still looking for triggers though + result = 1 + } + } + } + } + } + } + return result +} + +/******************************************************************************/ +/*** CONDITION EVALUATION FUNCTIONS ***/ +/******************************************************************************/ + +private evaluateConditionSet(evt, primary, force = false) { + //executes whenever a device in the primary or secondary if block has an event + def perf = now() + //debug "Event received by the ${primary ? "primary" : "secondary"} IF block evaluation for device ${evt.device}, attribute ${evt.name}='${evt.value}', isStateChange=${evt.isStateChange()}, currentValue=${evt.device.currentValue(evt.name)}, determining eligibility" + //check for triggers - if the primary IF block has triggers and the event is not related to any trigger + //then we don't want to evaluate anything, as only triggers should be executed + //this check ensures that an event that is used in both blocks, but as different types, one as a trigger + //and one as a condition do not interfere with each other + def app = state.run == "config" ? state.config.app : state.app + //reset last condition state + def eligibilityStatus = force ? 1 : checkEventEligibility(primary ? app.conditions: app.otherConditions , evt) + def evaluation = null + if (!force) { + debug "Event eligibility for the ${primary ? "primary" : "secondary"} IF block is $eligibilityStatus - ${eligibilityStatus > 0 ? "ELIGIBLE" : "INELIGIBLE"} (" + (eligibilityStatus == 2 ? "triggers required, event is a trigger" : (eligibilityStatus == 1 ? "triggers not required, event is a condition" : (eligibilityStatus == -2 ? "triggers required, but event is a condition" : "something is messed up"))) + ")" + } + if (eligibilityStatus > 0) { + evaluation = evaluateCondition(primary ? app.conditions: app.otherConditions, evt) + } else { + //ignore the event + } + perf = now() - perf + if (evaluation != null) { + if (primary) { + app.conditions.eval = evaluation + app.conditions.state = evaluation + } else { + app.otherConditions.eval = evaluation + app.otherConditions.state = evaluation + } + } + return evaluation +} + +private resetConditionState(condition) { + if (!condition) return + condition.eval = null + if (condition.children) { + for (cond in condition.children) resetConditionState(cond) + } +} + +private evaluateCondition(condition, evt = null) { + try { + //evaluates a condition + def perf = now() + def result = false + if (condition.children == null) { + //we evaluate a real condition here + //several types of conditions, device, mode, SMH, time, etc. + if (condition.attr == "time") { + result = evaluateTimeCondition(condition, evt) + } else { + result = evaluateDeviceCondition(condition, evt) + } + } else { + //we evaluate a group + result = (condition.grp in ["AND", "THEN IF", "ELSE IF", "FOLLOWED BY"]) && (condition.children.size()) //we need to start with a true when doing AND or with a false when doing OR/XOR + def i = 0 + def lastChild = condition.children.size() - 1 + def followedBy = (condition.grp == "FOLLOWED BY") + def resetLadder = true + for (child in condition.children.sort { it.id }) { + def interrupt = false + //evaluate the child + //if we have a follwed by, we skip all conditions that are already true, step ladder... + if (!followedBy || !child.state) { + def subResult = evaluateCondition(child, evt) + //apply it to the composite result + switch (condition.grp) { + case "AND": + result = result && subResult + break + case "OR": + result = result || subResult + break + case "XOR": + result = result ^ subResult + break + case "THEN IF": + result = result && subResult + interrupt = !result + break + case "ELSE IF": + result = subResult + interrupt = result + break + case "FOLLOWED BY": + //we're true when all children are true + result = subResult && (i == lastChild) + resetLadder = !subResult + interrupt = true + break + } + } + i += 1 + if (interrupt) break + } + + if (followedBy && (result || resetLadder)) { + //we either completed the ladder or failed miserably, so let's reset it + for (child in condition.children) child.state = false + } + } + //apply the NOT, if needed + result = condition.not ? !result : result + def oldState = condition.state + condition.eval = result + condition.state = result + + //store variables (only if evt is available, i.e. not simulating) + if (evt) { + if (condition.vd) setVariable(condition.vd, now()) + if (condition.vs) setVariable(condition.vs, result) + if (condition.vt && result) setVariable(condition.vt, evt.date.getTime()) + if (condition.vv && result) setVariable(condition.vv, evt.value) + if (condition.vf && !result) setVariable(condition.vf, evt.date.getTime()) + if (condition.vw && !result) setVariable(condition.vw, evt.value) + if (condition.it && result && evt.jsonData) { + def prefix = condition.itp ?: "" + if (evt.jsonData instanceof Map) { + importVariables(evt.jsonData, prefix) + } + } + if (condition.if && !result && evt.jsonData) { + def prefix = condition.ifp ?: "" + if (evt.jsonData instanceof Map) { + importVariables(evt.jsonData, prefix) + } + } + if (condition.id > 0) { + if (oldState != result) { + //cancel all actions that need to be canceled on condition state change + unscheduleActions(condition.id) + } + scheduleActions(condition.id, oldState != result, result) + } + } + perf = now() - perf + return result + } catch(e) { + debug "ERROR: Error evaluating condition: ", null, "error", e + } + return false +} + +private evaluateDeviceCondition(condition, evt) { + //evaluates a condition + //we need true when dealing with All + def mode = condition.mode == "All" ? "All" : "Any" + def result = mode == "All" ? true : false + def currentValue = null + + //get list of devices + def devices = settings["condDevices${condition.id}"] + def eventDeviceId = evt && evt.deviceId ? evt.deviceId : location.id + def virtualCurrentValue = null + def attribute = condition.attr + switch (condition.cap) { + case "Ask Alexa Macro": + devices = [location] + virtualCurrentValue = evt ? evt.value : "<<>>" + attribute = "askAlexaMacro" + break + case "EchoSistant Profile": + devices = [location] + virtualCurrentValue = evt ? evt.value : "<<>>" + attribute = "echoSistantProfile" + break + case "IFTTT": + devices = [location] + virtualCurrentValue = evt ? evt.value : "<<>>" + attribute = "ifttt" + break + case "Mode": + case "Location Mode": + devices = [location] + virtualCurrentValue = location.mode + attribute = "mode" + break + case "Smart Home Monitor": + devices = [location] + virtualCurrentValue = getAlarmSystemStatus() + attribute = "alarmSystemStatus" + break + case "CoRE Piston": + case "Piston": + devices = [location] + virtualCurrentValue = evt ? evt.value : "<<>>" + attribute = "piston" + break + case "Routine": + devices = [location] + virtualCurrentValue = evt ? evt.displayName : "<<>>" + attribute = "routineExecuted" + break + case "Variable": + devices = [location] + virtualCurrentValue = getVariable(condition.var) + attribute = "variable" + break + } + + if (!devices) { + //something went wrong + return false + } + def attr = getAttributeByName(attribute) + //get capability if the attribute suggests one + def capability = attr && attr.capability ? getCapabilityByName(attr.capability) : null + def hasSubDevices = false + def matchesSubDevice = false + if (evt && capability && capability.count && capability.data) { + //at this point we won't evaluate this condition unless we have the right sub device below + hasSubDevices = true + def idx = cast(evt.jsonData ? evt.jsonData[capability.data] : 0, "number") + //if button index is 0, make it 1 + if ((attr.name == "button") && (idx == 0)) idx = 1 + setVariable("\$currentEventDeviceIndex", idx, true) + def subDeviceId = "#$idx".trim() + def subDevices = condition.sdev ?: [] + if (subDeviceId == "#0") subDeviceId = "(none)" + if (subDevices && subDevices.size()) { + //are we expecting that button? + //subDeviceId in subDevices didn't seem to work?! + for(subDevice in subDevices) { + if (subDevice == subDeviceId) { + matchesSubDevice = true + break + } + } + } else { + matchesSubDevice = true + } + } + + //is this a momentary event? + def momentary = attr ? !!attr.momentary : false + def physical = false + def oldPhysical = false + //if we're dealing with a momentary capability, we can only expect one of the devices to be true at any time + if (momentary) { + mode = "Any" + } + + //matching devices list + def vm = [] + //non-matching devices list + def vn = [] + //the real deal goes here + for (device in devices) { + def comp = getComparisonOption(attribute, condition.comp, (attribute == "variable" ? condition.dt : null), device) + if (comp) { + //if event is about the same device/attribute, use the event's value as the current value, otherwise, fetch the current value from the device + def deviceResult = false + def ownsEvent = evt && (eventDeviceId == device.id) && ((evt.name == attribute) || ((evt.name == "time") && (condition.id == evt.conditionId)) || ((evt.name == "threeAxis") && (attribute == "orientation"))) + if (ownsEvent && (evt.name == "time") && (condition.id == evt.conditionId)) { + //stays trigger, we need to use the current device value + virtualCurrentValue = device.currentValue(attribute) + } + + def oldValue = null + def oldValueSince = null + if (evt && !(evt.name in ["askAlexaMacro", "echoSistantProfile", "ifttt", "piston", "routineExecuted", "variable", "time"])) { + def cache = state.cache ? state.cache : [:] + def cachedValue = cache[device.id + "-" + attribute] + if (cachedValue) { + physical = cachedValue.p + oldPhysical = cachedValue.q + oldValue = cachedValue.o + oldValueSince = cachedValue.t + } + //get the physical from the event, if that's related to this trigger + if (ownsEvent) { + physical = !!evt.physical + setVariable("\$currentEventDevicePhysical", physical, true) + + } + } + + //if we have a variable event and we're at a variable condition, let's get the old value + if (evt && (evt.name == "variable") && (attr.name == "variable") && (evt.jsonData) && (evt.value == condition.var)) { + oldValue = evt.jsonData.oldValue + } + def type = attr.name == "variable" ? (condition.dt ? condition.dt : attr.type) : attr.type + //if we're dealing with an owned event, use that event's value + //if we're dealing with a virtual device, get the virtual value + oldValue = cast(oldValue, type) + + switch (attribute) { + case "orientation": + virtualCurrentValue = evt && ownsEvent ? evt.xyzValue : device.currentValue("threeAxis") + setVariable("\$currentEventDeviceIndex", getThreeAxisOrientation(virtualCurrentValue, true), true) + break + case "axisX": + virtualCurrentValue = evt && ownsEvent ? evt.xyzValue?.x : device.currentValue("threeAxis").x + break + case "axisY": + virtualCurrentValue = evt && ownsEvent ? evt.xyzValue?.y : device.currentValue("threeAxis").y + break + case "axisZ": + virtualCurrentValue = evt && ownsEvent ? evt.xyzValue?.z : device.currentValue("threeAxis").z + break + } + + currentValue = cast(virtualCurrentValue != null ? virtualCurrentValue : (evt && ownsEvent ? evt.value : device.currentValue(attribute)), type) + def value1 + def offset1 + def value2 + def offset2 + if (comp.parameters > 0) { + value1 = cast(condition.var1 ? getVariable(condition.var1) : (condition.dev1 && settings["condDev${condition.id}#1"] ? settings["condDev${condition.id}#1"].currentValue(condition.attr1 ? condition.attr1 : attribute) : condition.val1), type) + offset1 = cast(condition.var1 || condition.dev1 ? condition.o1 : 0, type) + if (comp.parameters > 1) { + value2 = cast(condition.var2 ? getVariable(condition.var2) : (condition.dev2 && settings["condDev${condition.id}#2"] ? settings["condDev${condition.id}#2"].currentValue(condition.attr2 ? condition.attr2 : attribute) : condition.val2), type) + offset2 = cast(condition.var1 || condition.dev1 ? condition.o2 : 0, type) + } + } + switch (type) { + case "number": + case "decimal": + if (comp.parameters > 0) { + value1 += cast(condition.var1 || condition.dev1 ? condition.o1 : 0, type) + if (comp.parameters > 1) { + value2 += cast(condition.var1 || condition.dev1 ? condition.o2 : 0, type) + } + } + break + } + + def interactionMatched = true + if (attr.interactive) { + interactionMatched = (physical && (condition.iact != "Programmatic")) || (!physical && (condition.iact != "Physical")) + if (!interactionMatched) { + debug "Condition evaluation interrupted due to interaction method mismatch. Event is ${evt.physical ? "physical" : "programmatic"}, expecting ${condition.iact}." + } + } + if ((condition.trg && !ownsEvent) || !interactionMatched) { + //all triggers should own the event, otherwise be false + deviceResult = false + } else { + def function = "eval_" + (condition.trg ? "trg" : "cond") + "_" + sanitizeCommandName(condition.comp) + //if we have a momentary capability and the event is not owned, there's no need to evaluate the function + //also, if there are subdevices and the one we're looking for does not match, no need to evaluate the function either + if ((momentary && !ownsEvent) || (hasSubDevices && !matchesSubDevice)) { + deviceResult = false + def msg = "${deviceResult ? "♣" : "♠"} Evaluation for ${momentary ? "momentary " : ""}$device's ${attribute} [$currentValue] ${condition.comp} '$value1${comp.parameters == 2 ? " - $value2" : ""}' returned $deviceResult" + if (state.sim) state.sim.evals.push(msg) + debug msg + } else { + deviceResult = "$function"(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, ownsEvent ? evt : null, evt, momentary, type) + def msg = "${deviceResult ? "♣" : "♠"} Function $function for $device's ${attribute} [$currentValue] ${condition.comp} '$value1${comp.parameters == 2 ? " - $value2" : ""}' returned $deviceResult" + if (state.sim) state.sim.evals.push(msg) + debug msg + } + + } + + if (deviceResult) { + if (condition.vm) vm.push "$device" + } else { + if (condition.vn) vn.push "$device" + } + + //compound the result, depending on mode + def finalResult = false + switch (mode) { + case "All": + result = result && deviceResult + finalResult = !result + break + case "Any": + result = result || deviceResult + finalResult = result + break + } + //optimize the loop to exit when we find a result that's going to be the final one (AND encountered a false, or OR encountered a true) + if (finalResult && !(condition.vm || condition.vn)) break + } + } + + if (evt) { + if (condition.vm) setVariable(condition.vm, buildNameList(vm, "and")) + if (condition.vn) setVariable(condition.vn, buildNameList(vn, "and")) + } + return result +} + +private evaluateTimeCondition(condition, evt = null, unixTime = null, getNextEventTime = false) { + //we sometimes optimize this and sent the comparison text and object + //no condition? not time condition? false! + if (!condition || (condition.attr != "time")) { + return false + } + //get UTC now if no unixTime is provided + unixTime = unixTime ? unixTime : now() + //convert that to location's timezone, for comparison + def attr = getAttributeByName(condition.attr) + def comparison = cleanUpComparison(condition.comp) + def comp = getComparisonOption(condition.attr, comparison) + //if we can't find the attribute (can't be...) or the comparison object, or we're dealing with a trigger, exit stage false + if (!attr || !comp) { + return false + } + + if (comp.trigger == comparison) { + if (evt) { + //trigger + if (evt && (evt.deviceId == "time") && (evt.conditionId == condition.id)) { + condition.lt = evt.date.time + //we have a time event returning as a result of a trigger, assume true + return true + } else { + if (comparison.contains("stay")) { + //we have a stay condition + } + } + } + return false + } + + def time = adjustTime(unixTime) + + //check comparison + def result = true + if (comparison.contains("any")) { + //we match any time + } else { + //convert times to number of minutes since midnight for easy comparison + //add one minute if we're within 3 seconds of the next minute + def m = time ? time.hours * 60 + time.minutes : 0 + (time.seconds >= 57 ? 1 : 0) + def m1 = null + def m2 = null + //go through each parameter + def o1 = condition.o1 ? condition.o1 : 0 + def o2 = condition.o2 ? condition.o2 : 0 + def useDate1 = false + def useDate2 = false + for (def i = 1; i <= comp.parameters; i++) { + def val = i == 1 ? condition.val1 : condition.val2 + def t = null + def v = 0 + def useDate = false + switch (val) { + case "custom time": + t = (i == 1 ? (condition.t1 ? adjustTime(condition.t1) : null) : (condition.t2 ? adjustTime(condition.t2) : null)) + if (t) { + v = t ? t.getHours() * 60 + t.getMinutes() : null + } + if (!comparison.contains("around")) { + switch (i) { + case 1: + o1 = 0 + break + case 2: + o2 = 0 + break + } + } + break + case "midnight": + v = (i == 1 ? 0 : 1440) + break + case "sunrise": + t = getSunrise() + v = t ? t.hours * 60 + t.minutes : null + break + case "noon": + v = 12 * 60 //noon is 720 minutes away from midnight + break + case "sunset": + t = getSunset() + v = t ? t.hours * 60 + t.minutes : null + break + case "time of variable": + t = adjustTime(getVariable(i == 1 ? condition.var1 : condition.var2)) + v = t ? t.hours * 60 + t.minutes : null + break + case "date and time of variable": + t = adjustTime(getVariable(i == 1 ? condition.var1 : condition.var2)) + v = t ? t.hours * 60 + t.minutes : null + useDate = true + break + } + if (i == 1) { + useDate1 = useDate + m1 = useDate ? (t ? t.time - t.time.mod(60000) : 0) : v + } else { + useDate2 = useDate + m2 = useDate ? (t ? t.time - t.time.mod(60000) : 0) : v + } + } + + //add one minute if we're within 3 seconds of the next minute + def rightNow = adjustTime() + rightNow = rightNow.time - rightNow.time.mod(60000) + (rightNow.seconds >= 57 ? 60000 : 0) + def lastMidnight = rightNow - rightNow.mod(86400000) + def nextMidnight = lastMidnight + 86400000 + + //we need to ensure we have a full condition + if (getNextEventTime) { + if ((m1 == null) || ((comp.parameters == 2) && (m2 == null))) { + return null + } + } + switch (comparison) { + case { comparison.contains("before") }: + if ((m1 == null) || (useDate1 ? rightNow > m1 + o1 * 60000 : m >= addOffsetToMinutes(m1, o1))) { + //m before m1? + result = false + } + if (getNextEventTime) { + if (result) { + //we're looking for the next time when time is not before given amount, that's exactly the time we're looking at + return convertDateToUnixTime(useDate1 ? m1 + o1 * 60000 : lastMidnight + addOffsetToMinutes(m1, o1) * 60000) + } else { + //the next time time is before a certain time is... next midnight... + return useDate1 ? null : convertDateToUnixTime(nextMidnight) + } + } + if (!result) return false + break + case { comparison.contains("after") }: + if ((m1 == null) || (useDate1 ? rightNow < m1 + o1 * 60000 : m < addOffsetToMinutes(m1, o1))) { + //m after m1? + result = false + } + if (getNextEventTime) { + if (result) { + //we're looking for the next time when time is not after given amount, next midnight + return useDate1 ? null : convertDateToUnixTime(nextMidnight) + } else { + //the next time time is before a certain time is... next midnight... + return convertDateToUnixTime(useDate1 ? m1 + o1 * 60000 : lastMidnight + addOffsetToMinutes(m1, o1) * 60000) + } + } + if (!result) return result + break + case { comparison.contains("around") }: + //if no offset, we can't really match anything + def a1 = useDate1 ? m1 - o1 * 60000 : addOffsetToMinutes(m1, -o1) + def a2 = useDate1 ? m1 + o1 * 60000 : addOffsetToMinutes(m1, +o1) + def mm = useDate1 ? rightNow : m + if (a1 < a2 ? (mm < a1) || (mm >= a2) : (mm >= a2) && (mm < a1)) { + result = false + } + if (getNextEventTime) { + if (result) { + //we're in between the +/- time, the a2 is the next time we are looking for + return useDate1 ? null : convertDateToUnixTime(lastMidnight + a2 * 60000) + } else { + //return a1 time either today or tomorrow + return convertDateToUnixTime(useDate1 ? (a1 > time.time ? a1 : null) : (a1 < mm ? nextMidnight : lastMidnight) + a1 * 60000) + } + } + if (!result) return result + break + case { comparison.contains("between") }: + def a1 = useDate1 ? m1 + o1 * 60000 : (useDate2 ? m2 - m2.mod(86400000) : lastMidnight) + addOffsetToMinutes(m1, o1) * 60000 + def a2 = useDate2 ? m2 + o2 * 60000 : (useDate1 ? m1 - m1.mod(86400000) : lastMidnight) + addOffsetToMinutes(m2, o2) * 60000 + def mm = rightNow + if ((a1 > a2) && (!useDate1 || !useDate2)) { + //if a1 is after a2, and we haven't specified dates for both, increment a2 with 1 day to bring it after a1 + if ((mm < a2) || (useDate2)) { + a1 = a1 - 86400000 + } else { + a2 = a2 + 86400000 + } + } + def eval = (mm < a1) || (mm >= a2) + if (getNextEventTime) { + if (!eval) { + //we're in between the a1 and a2 + return convertDateToUnixTime(a2) + } else { + //we're not in between the a1 and a2 + return convertDateToUnixTime(a1 <= mm ? (a2 <= mm ? (useDate1 ? null : a1 + 86400000) : a2) : a1) + } + } + if (comparison.contains("not")) { + eval = !eval + } + if (eval) { + result = false + } + if (!result) return result + break + } + } + + if (getNextEventTime) { + return null + } + return result && testDateTimeFilters(condition, time) +} + +private testDateTimeFilters(condition, now) { + //if we made it this far, let's check on filters + if (condition.fmh || condition.fhd || condition.fdw || condition.fdm || condition.fwm || condition.fmy || condition.fy) { + //check minute filter + if (condition.fmh) { + def m = now.minutes.toString().padLeft(2, "0") + if (!(m in condition.fmh)) { + return false + } + } + + //check hour filter + if (condition.fhd) { + def h = formatHour(now.hours) + if (!(h in condition.fhd)) { + return false + } + } + + if (condition.fdw) { + def dow = getDayOfWeekName(now) + if (!(dow in condition.fdw)) { + return false + } + } + + if (condition.fwm) { + def weekNo = "the ${formatOrdinalNumberName(getWeekOfMonth(now))} week" + def lastWeekNo = "the ${formatOrdinalNumberName(getWeekOfMonth(now, reverse))} week" + if (!((weekNo in condition.fwm) || (lastWeekNo in condition.fwm))) { + return false + } + } + if (condition.fdm) { + def dayNo = "the " + formatOrdinalNumber(getDayOfMonth(now)) + def lastDayNo = "the " + formatOrdinalNumberName(getDayOfMonth(now, true)) + " day of the month" + if (!((dayNo in condition.fdm) || (lastDayNo in condition.fdm))) { + return false + } + } + + if (condition.fmy) { + if (!(getMonthName(now) in condition.fmy)) { + return false + } + } + + if (condition.fy) { + def year = now.year + 1900 + def yearOddEven = year.mod(2) + def odd = "odd years" in condition.fy + def even = "even years" in condition.fy + def leap = "leap years" in condition.fy + if (!(((yearOddEven == 0) && even) || ((yearOddEven == 1) && odd) || ((year.mod(4) == 0) && leap) || ("$year" in condition.fy))) { + return false + } + } + } + return true +} + +/* low-level evaluation functions */ +private eval_cond_is_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue == value1 +} + +private eval_cond_is_not_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue != value1 +} + +private eval_cond_is(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_is_not(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_not_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_is_true(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, true, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_is_false(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_true(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_is_one_of(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def v = "$currentValue".trim() + for(def value in value1) { + if ("$value".trim() == v) + return true + } + return false +} + +private eval_cond_is_not_one_of(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_one_of(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_is_less_than(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue < value1 +} + +private eval_cond_is_less_than_or_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue <= value1 +} + +private eval_cond_is_greater_than(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue > value1 +} + +private eval_cond_is_greater_than_or_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue >= value1 +} + +private eval_cond_is_even(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + try { + return Math.round(currentValue).mod(2) == 0 + } catch(all) {} + return false +} + +private eval_cond_is_odd(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + try { + return Math.round(currentValue).mod(2) == 1 + } catch(all) {} + return false +} + +private eval_cond_is_inside_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + if (value1 < value2) { + return (currentValue >= value1) && (currentValue <= value2) + } else { + return (currentValue >= value2) && (currentValue <= value1) + } +} + +private eval_cond_is_outside_of_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + if (value1 < value2) { + return (currentValue < value1) || (currentValue > value2) + } else { + return (currentValue < value2) || (currentValue > value1) + } +} + +private listPreviousStates(device, attribute, currentValue, minutes, excludeLast) { + def result = [] + if (!(device instanceof physicalgraph.app.DeviceWrapper)) return result + def events = device.events([all: true, max: 100]).findAll{it.name == attribute} + //if we got any events, let's go through them + //if we need to exclude last event, we start at the second event, as the first one is the event that triggered this function. The attribute's value has to be different from the current one to qualify for quiet + def value = currentValue + def thresholdTime = now() - minutes * 60000 + def endTime = now() + for(def i = 0; i < events.size(); i++) { + def startTime = events[i].date.getTime() + def duration = endTime - startTime + if ((duration >= 1000) && ((i > 0) || !excludeLast)) { + result.push([value: events[i].value, startTime: startTime, duration: duration]) + } + if (startTime < thresholdTime) + break + endTime = startTime + } + return result +} + +private eval_cond_changed(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def minutes = timeToMinutes(condition.fort) + def events = device.eventsSince(new Date(now() - minutes * 60000)).findAll{it.name == attribute} + return (events.size() > 0) +} + +private eval_cond_did_not_change(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_changed(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_was(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_was_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_was_not(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + eval_cond_was_not_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_cond_was_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, dataType) == value1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_not_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, dataType) != value1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_less_than(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, dataType) < value1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_less_than_or_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, dataType) <= value1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_greater_than(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, dataType) > value1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_greater_than_or_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, dataType) >= value1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_even(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, "number").mod(2) == 0) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_odd(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + if (cast(state.value, "number").mod(2) == 1) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_inside_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + def v = cast(state.value, dataType) + if (value1 < value2 ? (v >= value1) && (v <= value2) : (v >= value2) && (v <= value1)) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +private eval_cond_was_outside_of_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def time = timeToMinutes(condition.fort) + def states = listPreviousStates(device, attribute, currentValue, time, evt ? 1 : 0) + def thresholdTime = time * 60000 + def stableTime = 0 + for (state in states) { + def v = cast(state.value, dataType) + if (value1 < value2 ? (v < value1) || (v > value2) : (v < value2) || (v > value1)) { + stableTime += state.duration + } else { + break + } + } + return (stableTime > 0) && (condition.for == "for at least" ? stableTime >= thresholdTime : stableTime < thresholdTime) +} + +/* triggers */ +private eval_trg_changes(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return true +} + +private eval_trg_changes_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_equal_to(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_changes_to_one_of(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_not_one_of(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_one_of(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_changes_away_from(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_equal_to(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_not_equal_to(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_changes_away_from_one_of(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_cond_is_one_of(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_not_one_of(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_drops(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue < oldValue +} + +private eval_trg_drops_below(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_less_than(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_less_than(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_drops_to_or_below(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_less_than_or_equal_to(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_less_than_or_equal_to(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_raises(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return currentValue > oldValue +} + +private eval_trg_raises_above(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_greater_than(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_greater_than(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_raises_to_or_above(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_greater_than_or_equal_to(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_greater_than_or_equal_to(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_changes_to_even(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_even(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_even(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_changes_to_odd(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_odd(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_odd(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_enters_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_inside_range(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_inside_range(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_exits_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return !eval_cond_is_outside_of_range(condition, device, attribute, null, null, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) && + eval_cond_is_outside_of_range(condition, device, attribute, null, null, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_executed(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return (currentValue == value1) +} + +private eval_trg_stays(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_away_from(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_not", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_equal_to", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_not_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_not_equal_to", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_less_than(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_less_than", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_less_than_or_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_less_than_or_equal_to", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_greater_than(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_greater_than", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_greater_than_or_equal_to(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_greater_than_or_equal_to", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_in_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_in_range", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_outside_of_range(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_outside_of_range", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_even(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_even", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_odd(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + return eval_trg_stays_common("is_odd", condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) +} + +private eval_trg_stays_common(func, condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) { + def result = "eval_cond_$func"(condition, device, attribute, oldValue, oldValueSince, currentValue, value1, value2, evt, sourceEvt, momentary, dataType) + if (evt.name == attribute) { + //initial event + if (result) { + //true, let's schedule time... + //if there is no event task currently scheduled for this, so we need to schedule one, but wait... + //was the old value not matching? because if it was, then we need to inhibit this... + def oldResult = "eval_cond_$func"(condition, device, attribute, oldValue, oldValueSince, oldValue, value1, value2, evt, sourceEvt, momentary, dataType) + if (oldResult != result) { + def tasks = state.tasks + if (!tasks || !tasks.find{ (it.value?.type == "evt") && (it.value?.ownerId == condition.id) && (it.value?.deviceId == device.id) }) { + def time = now() + timeToMinutes(condition.fort) * 60000 + scheduleTask("evt", condition.id, device.id, null, time) + } + } + } else { + unscheduleTask("evt", condition.id, device.id) + } + return false + } + //timed event + return result +} + +/******************************************************************************/ +/*** SCHEDULER FUNCTIONS - TIMING BELT ***/ +/******************************************************************************/ + +private scheduleTimeTriggers() { + debug "Rescheduling time triggers", null, "trace" + //remove all pending events + unscheduleTask("evt", null, "time") + def app = state.run == "config" ? state.config.app : state.app + if (getTriggerCount(app) > 0) { + withEachTrigger(app.conditions, "scheduleTimeTrigger") + if (app.mode in ["Latching", "And-If", "Or-If"]) { + withEachTrigger(app.otherConditions, "scheduleTimeTrigger") + } + } else { + //we're not using triggers, let's mess up with time conditions + withEachCondition(app.conditions, "scheduleTimeTrigger") + if (app.mode in ["Latching", "And-If", "Or-If"]) { + withEachCondition(app.otherConditions, "scheduleTimeTrigger") + } + } +} + +private scheduleTimeTrigger(condition, data = null) { + if (!condition || !(condition.attr) || (condition.attr != "time")) return + def time = condition.trg ? getNextTimeTriggerTime(condition, condition.lt) : getNextTimeConditionTime(condition, condition.lt) + condition.nt = time + if ((time instanceof Long) && (time > 0)) { + scheduleTask("evt", condition.id, "time", null, time) + } +} + +private scheduleActions(conditionId, stateChanged = false, currentState = true) { + //debug "Scheduling actions for condition #${conditionId}. State did${stateChanged ? "" : " NOT"} change." + def actions = listActions(conditionId).sort{ it.id } + for (action in actions) { + //restrict on state changed + if (action.rc && !stateChanged) continue + if ((action.pid > 0) && ((action.rs != false ? true : false) != currentState)) continue + if (action.rm && action.rm.size() && !(location.mode in action.rm)) continue + if (action.ra && action.ra.size() && !(getAlarmSystemStatus() in action.ra)) continue + if (action.rv && !(checkVariableCondition(action.rv, action.rvc, action.rvv))) continue + if (action.rw && action.rw.size() && !(getDayOfWeekName() in action.rw)) continue + if (action.rtf && action.rtt && !(checkTimeCondition(action.rtf, action.rtfc, action.rtfo, action.rtt, action.rttc, action.rtto))) continue + if (action.rs1) { + def r = false + for(sw in settings["actRSwitchOn${action.id}"]) { + if (sw.currentValue("switch") != "on") { + r = true + break + } + } + if (r) continue + } + if (action.rs0) { + def r = false + for(sw in settings["actRSwitchOff${action.id}"]) { + if (sw.currentValue("switch") != "off") { + r = true + break + } + } + if (r) continue + } + //we survived all restrictions, pfew + scheduleAction(action) + } +} + +private unscheduleActions(conditionId) { + def tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + while (true) { + def item = tasks.find{ (it.value.type == "cmd") && (it.value.data && it.value.data.cc == conditionId)} + if (item) { + tasks.remove(item.key) + } else { + break + } + } + atomicState.tasks = tasks +} + +private scheduleAction(action) { + if (!action) return null + def deviceIds = action.l ? ["location"] : (action.d ? action.d : []) + def tos = action.tos ? action.tos : "Action" + if (tos != "None") { + def aid = (tos == "Action") ? action.id : null + unscheduleTask("cmd", action.id, null) + for (deviceId in deviceIds) { + //remove all tasks for all involved devices + unscheduleTask("cmd", aid, deviceId) + } + if (tos == "Global") { + debug "WARNING: Task override policy for Global is not yet implemented", null, "warn" + } + } + def rightNow = now() + def time = rightNow + def waitFor = null + def waitSince = null + def flowChart + if (action.t && action.t.size() && deviceIds.size() ) { + def tasks = action.t.sort{ it.i } + def x = 0 + def cnt = 0 + while (true) { + resetRandomValues() + //make sure x is within task list + if ((x == null) || (x < 0) || (x >= tasks.size())) break + cnt += 1 + def task = tasks[x] + def cmd = task.c + def virtual = (cmd && cmd.startsWith(virtualCommandPrefix())) + def custom = (cmd && cmd.startsWith(customCommandPrefix())) + cmd = cleanUpCommand(cmd) + def command = null + if (virtual) { + //dealing with a virtual command + command = getVirtualCommandByDisplay(cmd) + if (command && command.flow) { + //build the flowchart + if (!flowChart) flowChart = buildFlowChart(tasks) + //flow control logic + def flow = flowChart[x] + if (flow) { + switch (flow.action) { + case "begin": + switch (flow.mode) { + case "if": + case "else": + //begin an if block + if (flow.isElse) { + //if we're dealing with an Else If, we need to figure out if the true side executed, or the else if can run + def startFlow = (flow.startIdx != null ? flowChart[flow.startIdx] : null) + if (startFlow) { + if (startFlow.eval) { + //pretend we're true, so that if there's a next Else If it skips too + flow.eval = true + //the true side of the previous IF just finished, jump to end + x = flow.endIdx + continue + } + } + } + //if it's an else, we go through it, the previous IF was false + if (flow.mode == "else") { + x += 1 + continue + } + //if (condition) + flow.eval = checkFlowCondition(task) + if (flow.eval) { + //continue to next line + x += 1 + continue + } else { + //move on to the else or move to the end, if no else is present + def newX = flow.elseIdx ? flow.elseIdx : flow.endIdx + if (newX) { + x = newX + 1 + continue + } + } + break + case "switch": + if (flow.caseIdxs) { + def val = getVariable(task.p[0].d) + def found = false + for(def y = 0; y < flow.caseIdxs.size(); y++) { + //get the index of the next case + def xx = flow.caseIdxs[y] + //little Windex here + def newFlow = flowChart[xx] + //check to see if the case matches + newFlow.eval = checkFlowCaseCondition(val, tasks[xx].p[0].d) + if (newFlow.eval) { + //if it matches, go there + x = xx + 1 + found = true + break + } + } + if (found) continue + } + //no case found, skip + x = flow.endIdx + 1 + continue + case "case": + //if we got here, we need to skip to the end + //a matching case probably just finished + x = flow.endIdx + continue + case "loop": + if (flow.isWhile) { + //while loops are simple :) + flow.eval = checkFlowCondition(task) + if (flow.eval) { + x += 1 + } else { + x = flow.endIdx + 1 + } + continue + } + if (!flow.active) { + def start + def end + def step + if (flow.isSimple) { + //initialize the simple loop + flow.varName = null + flow.start = 0 + flow.end = Math.abs(cast(formatMessage(task.p[0].d), "number")) - 1 + setVariable("\$index", flow.start, true) + if (flow.end < flow.start) { + flow.active = false + x = flow.endIdx + 1 + continue + } + } else { + flow.varName = task.p[0].d + flow.start = cast(formatMessage(task.p[1].d), "number") + flow.end = cast(formatMessage(task.p[2].d), "number") + //set the variable + setVariable(flow.varName, flow.start) + } + flow.step = (flow.end >= flow.start ? 1 : -1) + flow.pos = flow.start + //start the loop + flow.active = true + scheduleTask("cmd", action.id, deviceId, task.i, command.delay ? command.delay : time, [variable: flow.varName, value: flow.pos]) + x += 1 + continue + } else { + //loop is already in progress + //if we're using a variable, get its current value + if (flow.varName) flow.pos = getVariable(flow.varName) + //then increment int + flow.pos = flow.pos + flow.step + //if we're using a variable, update it + if (flow.varName) setVariable(flow.varName, flow.pos) + setVariable("\$index", flow.pos, true) + scheduleTask("cmd", action.id, deviceId, task.i, command.delay ? command.delay : time, [variable: flow.varName, value: flow.pos]) + if (flow.step > 0 ? (flow.pos > flow.end) : (flow.pos < flow.end)) { + //loop ended, jump over the end + //jmp endIdx + 0x0001 :D + flow.active = null + flow.varName = null + x = flow.endIdx + 1 + continue + } + //another loop cycle, moving on... + x = x + 1 + continue + } + break + } + break + case "break": + //we need to find the closest earlier loop or switch and get out of it + if (flow.isIf) { + flow.eval = checkFlowCondition(task) + if (!flow.eval) { + //if the break condition is not met, we skip that + x += 1 + continue + } + } + for (def y = x - 1; y >= 0; y--) { + def startFlow = flowChart[y] + if ((startFlow.action == "begin") && (startFlow.isLoop || (startFlow.isSwitch && !startFlow.isCase))) { + startFlow.active = null + startFlow.varName = null + x = startFlow.endIdx + 1 + continue + } + } + break + case "end": + if (flow.isLoop) { + if (task.p && (task.p.size() == 1)) { + //delay the loop + time = time + cast(task.p[0].d, "number") * 1000 + } + //if this is the end of a loop, we cycle back to the start + //that loop start will automatically jump over the end if the loop is finished + x = flow.startIdx + continue + } + x = x + 1 + continue + break + case "exit": + //we need to find the closest earlier loop or switch and get out of it + x = null + continue + } + } + + //ignore the command + command = null + x += 1 + continue + } else if (command && command.immediate) { + //only execute task in certain modes? + //only execute task on certain days? + def restricted = (task.m && !(location.mode in task.m)) || (task.d && task.d.size() && !(getDayOfWeekName() in task.d)) + def function = "cmd_${sanitizeCommandName(command.name)}" + def result = "$function"(action, task, time) + if (!restricted) { + time = (result && result.time) ? result.time : time + command.delay = (result && result.delay) ? result.delay : 0 + if (result && result.waitFor) { + waitFor = result.waitFor + waitSince = time + } + } + if (!result.schedule) { + command = null + } + } + } else { + if (custom) { + command = [name: cmd] + } else { + command = getCommandByDisplay(cmd) + } + } + if (command) { + for (deviceId in deviceIds) { + def data = task.p && task.p.size() ? [p: task.p] : null + if (waitFor) { + data = data ? data : [:] + data.w = waitFor //what to wait for + data.o = time - waitSince //delay after state change + } + if (action.tcp && action.tcp != "None") { + data = data ? data : [:] + data.c = action.tcp.contains("piston") + data.cc = action.tcp.contains("condition") ? action.pid : null + } + if (command.aggregated) { + //an aggregated command schedules one command task for the whole group + deviceId = null + } + def restricted = (task.m && !(location.mode in task.m)) || (task.d && task.d.size() && !(getDayOfWeekName() in task.d)) + if (!restricted && (!command.delay) && (time == rightNow) && (command.name == "setVariable") && (data.p) && (data.p.size() >= 3) && (data.p[2].d)) { + //due to popular demand, we need to execute setVariable right during the condition evaluation so that subsequent evaluations can use the new values + task_vcmd_setVariable(null, action, [data: data]) + } else { + scheduleTask("cmd", action.id, deviceId, task.i, command.delay ? command.delay : time, data) + } + //an aggregated command schedules one command task for the whole group, so there's only one scheduled task, exit + if (command.aggregated) break + } + } + x += 1 + //exit when we reached the end + if (x >= tasks.size()) { + break + } + } + } +} + +private checkFlowCondition(task) { + if (task.p && (task.p.size() == 3)) { + def variable = task.p[0].d + def comparison = task.p[1].d + def value = formatMessage(task.p[2].d) + return checkVariableCondition(variable, comparison, value) + } + return false +} + +private checkVariableCondition(variable, comparison, value) { + def varValue = getVariable(variable) + return checkValueCondition(varValue, comparison, value) +} + +private checkValueCondition(value1, comparison, value2) { + value2 = formatMessage(value2) + if (value1 instanceof String) { + value2 = cast(value2, "string") + } else if (value1 instanceof Boolean) { + value2 = cast(value2, "boolean") + } else if (value1 instanceof Integer) { + value2 = cast(value2, "number") + } else if (value1 instanceof Float) { + value2 = cast(value2, "decimal") + } else { + value1 = cast(value1, "string") + value2 = cast(value2, "string") + } + + def func = "eval_cond_${sanitizeCommandName(comparison)}" + def result = false + try { + result = "$func"(null, null, null, null, null, value1, value2, null, null, null, null, null) + } catch (all) { + result = false + } + return result +} + +private checkFlowCaseCondition(value, caseValue) { + return checkValueCondition(value, "is_equal_to", caseValue) +} + +private checkTimeCondition(timeFrom, timeFromCustom, timeFromOffset, timeTo, timeToCustom, timeToOffset) { + def time = adjustTime() + //convert to minutes since midnight + def tc = time.hours * 60 + time.minutes + def tf + def tt + def i = 0 + while (i < 2) { + def t = null + def h = null + def m = null + switch(i == 0 ? timeFrom : timeTo) { + case "custom time": + t = adjustTime(i == 0 ? timeFromCustom : timeToCustom) + if (i == 0) { + timeFromOffset = 0 + } else { + timeToOffset = 0 + } + break + case "sunrise": + t = getSunrise() + break + case "sunset": + t = getSunset() + break + case "noon": + h = 12 + break + case "midnight": + h = (i == 0 ? 0 : 24) + break + } + if (h != null) { + m = 0 + } else { + h = t.hours + m = t.minutes + } + switch (i) { + case 0: + tf = h * 60 + m + cast(timeFromOffset, "number") + break + case 1: + tt = h * 60 + m + cast(timeToOffset, "number") + break + } + i += 1 + } + //due to offsets, let's make sure all times are within 0-1440 minutes + while (tf < 0) tf += 1440 + while (tf > 1440) tf -= 1440 + while (tt < 0) tt += 1440 + while (tt > 1440) tt -= 1440 + if (tf < tt) { + return (tc >= tf) && (tc < tt) + } else { + return (tc < tt) || (tc >= tf) + } +} + +private buildFlowChart(tasks) { + def result = [] + def indent = 0 + def idx = 0 + for (task in tasks) { + def cmd = task.c + def flow = [:] + def found = false + if (cmd && cmd.startsWith(virtualCommandPrefix())) { + def command = getVirtualCommandByDisplay(cleanUpCommand(cmd)) + if (command && command.flow) { + def c = command.name.toLowerCase() + flow.c = c + flow.action = (c.startsWith("begin") ? "begin" : (c.startsWith("end") ? "end" : (c.startsWith("break") ? "break" : (c.startsWith("exit") ? "exit" : null)))) + if (flow.action) { + flow.isFor = c.contains("for") + flow.isSimple = c.contains("simple") + flow.isWhile = c.contains("while") + flow.isLoop = c.contains("loop") + flow.isIf = c.contains("if") + flow.isElse = c.contains("else") + flow.isSwitch = c.contains("switch") + flow.isCase = c.contains("case") + flow.loopType = (flow.isFor ? "for" : (flow.isWhile ? "while" : null)) + flow.ifType = (flow.isElse ? "else" : (flow.isIf ? "if" : null)) + flow.mode = (flow.isLoop ? "loop" : (flow.isIf ? "if" : (flow.isCase ? "case" : (flow.isSwitch ? "switch" : null)))) + if (flow.mode) { + //ending flows need the indent applied before + indent = indent + (command.indent && (command.indent < 0) ? command.indent : 0) + flow.indent = indent - (flow.isElse ? 1 : (flow.isCase ? 2 : 0)) + flow.taskId = task.i + flow.idx = idx + found = true + //beginning flows need the indent applied after + indent = indent + (command.indent && (command.indent > 0) ? command.indent : 0) + } + } + } + } + result.push(found ? flow : null) + idx += 1 + } + for (flow in result) { + //initialize case array + if (flow) { + if (flow.isSwitch && !(flow.isCase) && (flow.action == "begin")) flow.caseIdxs = [] + for (def i = flow.idx + 1; i < result.size(); i++) { + if (result[i] && (result[i].indent == flow.indent)) { + def endFlow = result[i] + def breakFor = false + switch (flow.action) { + case "begin": + switch (flow.mode) { + case "if": + if (endFlow.isElse) { + flow.elseIdx = i + endFlow.startIdx = flow.idx + } + if (endFlow.isIf) { + flow.endIdx = i + endFlow.startIdx = flow.idx + } + break + case "loop": + if (endFlow.mode == "loop") { + flow.endIdx = i + endFlow.startIdx = flow.idx + } + break + case "case": + endFlow.startIdx = flow.idx + flow.endIdx = i + break + case "switch": + if ((flow.caseIdxs != null) && (endFlow.isCase)) { + endFlow.startIdx = flow.idx + flow.caseIdxs.push i + } else if (endFlow.mode == "switch") { + endFlow.startIdx = flow.idx + flow.endIdx = i + } + break + } + case "break": + break + } + } + if (flow.endIdx) break + } + } + } + return result +} + +private cmd_followUp(action, task, time) { + def result = cmd_wait(action, task, time) + result.schedule = true + result.delay = result.time + result.time = null + return result +} + +private cmd_wait(action, task, time) { + def result = [:] + if (task && task.p && task.p.size() >= 2) { + def unit = 60000 + switch (task.p[1].d) { + case "seconds": + unit = 1000 + break + case "minutes": + unit = 60000 + break + case "hours": + unit = 3600000 + break + } + def offset = task.p[0].d * unit + result.time = time + offset + } + return result +} + +private cmd_waitVariable(action, task, time) { + def result = [:] + if (task && task.p && task.p.size() >= 2) { + def unit = 60000 + switch (task.p[1].d) { + case "seconds": + unit = 1000 + break + case "minutes": + unit = 60000 + break + case "hours": + unit = 3600000 + break + } + def offset = (int) Math.round(cast(getVariable(task.p[0].d), "decimal") * unit) + result.time = time + offset + } + return result +} + +private cmd_waitRandom(action, task, time) { + def result = [:] + if (task && task.p && task.p.size() == 3) { + def unit = 60000 + switch (task.p[2].d) { + case "seconds": + unit = 1000 + break + case "minutes": + unit = 60000 + break + case "hours": + unit = 3600000 + break + } + def min = task.p[0].d * unit + def max = task.p[1].d * unit + if (min > max) { + //swap the numbers + def x = min + min = max + max = x + } + def offset = (long)(min + Math.round(Math.random() * (max - min))) + result.time = time + offset + } + return result +} + +private cmd_waitTime(action, task, time) { + def result = [time: time] + if (task && task.p && task.p.size() == 3) { + def t = cast(task.p[0].d, "string") + def offset = cast(task.p[1].d, "number") + def days = task.p[2].d + if (!days || !days.size()) { + return result + } + def newTime = getVariable("\$next" + t.capitalize()) + def rightNow = now() + newTime += offset * 60000 + def date = adjustTime(newTime) + def count = 10 + while ((newTime < rightNow) || (newTime < time) || !(getDayOfWeekName(date) in days)) { + newTime += 86400000 + date = adjustTime(newTime) + count -= 1 + if (count == 0) { + return result + } + } + result.time = newTime + } + return result +} + +private cmd_waitCustomTime(action, task, time) { + def result = [time: time] + if (task && task.p && task.p.size() == 2) { + def newTime = convertDateToUnixTime(adjustTime(task.p[0].d)) + def days = task.p[1].d + if (!days || !days.size()) { + return result + } + def date = adjustTime(newTime) + def rightNow = now() + def count = 10 + while ((newTime < rightNow) || (newTime < time) || !(getDayOfWeekName(date) in days)) { + newTime += 86400000 + date = adjustTime(newTime) + count -= 1 + if (count == 0) { + return result + } + } + result.time = newTime + } + return result +} + +private cmd_waitState(action, task, time) { + def result = [:] + if (task && task.p && task.p.size() == 1) { + def state = "${task.p[0].d}" + if (state.contains("any")) { + result.waitFor = "a" + } + if (state.contains("true")) { + result.waitFor = "t" + } + if (state.contains("false")) { + result.waitFor = "f" + } + } + return result +} + +private scheduleTask(task, ownerId, deviceId, taskId, unixTime, data = null) { + if (!unixTime) return false + if (!state.tasker) { + state.tasker = [] + state.taskerIdx = 0 + } + //get next index for task ordering + def idx = state.taskerIdx + state.taskerIdx = idx + 1 + state.tasker.push([idx: idx, add: task, ownerId: ownerId, deviceId: deviceId, taskId: taskId, data: data, time: unixTime, created: now()]) + return true +} + +private unscheduleTask(task, ownerId, deviceId) { + if (!state.tasker) { + state.tasker = [] + state.taskerIdx = 0 + } + def idx = state.taskerIdx + state.taskerIdx = idx + 1 + state.tasker.push([idx: idx, del: task, ownerId: ownerId, deviceId: deviceId, created: now()]) +} + +private getNextTimeConditionTime(condition, startTime = null) { + def perf = now() + + //no condition? not time condition? false! + if (!condition || (condition.attr != "time")) { + return null + } + //get UTC now if no unixTime is provided + def unixTime = startTime ? startTime : now() + //remove the seconds... + unixTime = unixTime - unixTime.mod(60000) + //we give it up to 25 hours to find the next time when the condition state would change + //optimized procedure - limitations : this will only trigger on strict condition times, without actually accounting for time restrictions... + return evaluateTimeCondition(condition, null, unixTime, true) +} + +private getNextTimeTriggerTime(condition, startTime = null) { + //no condition? not time condition? false! + if (!condition || (condition.attr != "time")) { + return null + } + //get UTC now if no unixTime is provided + def unixTime = startTime ? startTime : now() + //convert that to location's timezone, for comparison + def currentTime = adjustTime() + def now = adjustTime(unixTime) + def attr = getAttributeByName(condition.attr) + def comparison = cleanUpComparison(condition.comp) + def comp = getComparisonOption(condition.attr, comparison) + //if we can't find the attribute (can't be...) or the comparison object, or we're not dealing with a trigger, exit stage null + if (!attr || !comp || comp.trigger != comparison) { + return null + } + + def val1 = "${condition.val1}" + def repeat = (condition.val1 && val1.contains("every") ? val1 : "${condition.r}") + if (!repeat) { + return null + } + def interval = cast((repeat.contains("number") ? (condition.val1 && "${condition.val1}".contains("every") ? condition.e : condition.re) : 1), "number") + if (!interval) { + return null + } + repeat = repeat.replace("every ", "").replace("number of ", "").replace("s", "") + + //do the work + def maxCycles = null + while ((maxCycles == null) || (maxCycles > 0)) { + def cycles = 1 + def repeatCycle = false + if (repeat == "minute") { + //increment minutes + //we need to catch up with the present + def pastMinutes = (long) (Math.floor((currentTime.time - now.time) / 60000)) + if (pastMinutes > interval) { + if (interval > 0) { + now = new Date(now.time + interval * (long) Math.floor(pastMinutes / interval) * 60000) + } else { + now = new Date(now.time + pastMinutes * 60000) + } + } + now = new Date(now.time + interval * 60000) + cycles = 1500 //up to 25 hours + } else if (repeat == "hour") { + //increment hours + def m = now.minutes + def rm = (condition.m ? condition.m : "0").toInteger() + def pastHours = (long) (Math.floor((currentTime.time - now.time) / 3600000)) + if (pastHours > interval) { + if (interval > 0) { + now = new Date(now.time + interval * (long) Math.floor(pastHours / interval) * 60000) + } else { + now = new Date(now.time + pastHours * 60000) + } + } + now = new Date(now.time + (m < rm ? interval - 1 : interval) * 3600000) + now = new Date(now.year, now.month, now.date, now.hours, rm, 0) + cycles = 744 + } else { + //we're repeating at a granularity larger or equal to a day + //we need the time of the day at which things happen + def h = 0 + def m = 0 + def offset = 0 + def customTime = null + def useDate = false + switch (val1) { + case "custom time": + if (!condition.t1) { + return null + } + customTime = adjustTime(condition.t1) + break + case "sunrise": + customTime = getSunrise() + offset = condition.o1 ? condition.o1 : 0 + break + case "sunset": + customTime = getSunset() + offset = condition.o1 ? condition.o1 : 0 + break + case "noon": + h = 12 + offset = condition.o1 ? condition.o1 : 0 + break + case "midnight": + offset = condition.o1 ? condition.o1 : 0 + break + case "time of variable": + customTime = adjustTime(getVariable(condition.var1)) + offset = condition.o1 ? condition.o1 : 0 + break + case "date and time of variable": + customTime = adjustTime(getVariable(condition.var1)) + offset = condition.o1 ? condition.o1 : 0 + useDate = true + repeat = "none" + break + } + + if (customTime) { + h = customTime.hours + m = customTime.minutes + } + //we now have the time of the day + //let's figure out the next day + + //we need a - one day offset if now is before the required time + //since today could still be a candidate + now = (now.hours * 60 - h * 60 + now.minutes - m - offset < 0) ? now - 1 : now + now = useDate ? customTime : new Date(now.year, now.month, now.date, h, m, 0) + + //apply the offset + if (offset) { + now = new Date(now.time + offset * 60000) + } + + if (useDate && (now < currentTime)) { + //using date and that date is past... + return null + } + + switch (repeat) { + case "day": + now = now + interval + cycles = 1095 + break + case "week": + def dow = now.day + def rdow = getDayOfWeekNumber(condition.rdw) + if (rdow == null) { + return null + } + now = now + (rdow <= dow ? rdow + 7 - dow : rdow - dow) + (interval - 1) * 7 + cycles = 520 + break + case "month": + def day = condition.rd + if (!day) { + return null + } + if (day.contains("week")) { + def rdow = getDayOfWeekNumber(condition.rdw) + if (rdow == null) { + return null + } + //we're using Nth week day of month + def week = 1 + if (day.contains("first")) { + week = 1 + } else if (day.contains("second")) { + week = 2 + } else if (day.contains("third")) { + week = 3 + } else if (day.contains("fourth")) { + week = 4 + } else if (day.contains("fifth")) { + week = 5 + } + if (day.contains("last")) { + week = -week + } + def intervalOffset = 0 + def d = getDayInWeekOfMonth(now, week, rdow) + //get a possible date this month + if (d && (new Date(now.year, now.month, d, now.hours, now.minutes, 0) > now)) { + //at this point, the next month is this month (lol), we need to remove one from the interval + intervalOffset = 1 + } + + //get the day of the next required month + d = getDayInWeekOfMonth(new Date(now.year, now.month + interval - intervalOffset, 1, now.hours, now.minutes, 0), week, rdow) + if (d) { + now = new Date(now.year, now.month + interval - intervalOffset, d, now.hours, now.minutes, 0) + } else { + now = new Date(now.year, now.month + interval - intervalOffset, 1, now.hours, now.minutes, 0) + repeatCycle = true + } + } else { + //we're specifying a day + def d = 1 + if (day.contains("last")) { + //going backwards + if (day.contains("third")) { + d = -2 + } else if (day.contains("third")) { + d = -1 + } else { + d = 0 + } + def intervalOffset = 0 + //get the last day of this month + def dd = (new Date(now.year, now.month + 1, d)).date + if (new Date(now.year, now.month, dd, now.hours, now.minutes, 0) > now) { + //at this point, the next month is this month (lol), we need to remove one from the interval + intervalOffset = 1 + } + //get the day of the next required month + d = (new Date(now.year, now.month + interval - intervalOffset + 1, d)).date + now = new Date(now.year, now.month + interval - intervalOffset, d, now.hours, now.minutes, 0) + } else { + //the day is in the string + day = day.replace("on the ", "").replace("st", "").replace("nd", "").replace("rd", "").replace("th", "") + if (!day.isInteger()) { + //error + return null + } + d = day.toInteger() + now = new Date(now.year, now.month + interval - (d > now.date ? 1 : 0), d, now.hours, now.minutes, 0) + if (d > now.date) { + //we went overboard, this month does not have so many days, repeat the cycle to move on to the next month that does + repeatCycle = true + } + } + } + cycles = 36 + break + case "year": + def day = condition.rd + if (!day) { + return null + } + if (!condition.rm) { + return null + } + def mo = getMonthNumber(condition.rm) + if (mo == null) { + return null + } + mo-- + if (day.contains("week")) { + def rdow = getDayOfWeekNumber(condition.rdw) + if (rdow == null) { + return null + } + //we're using Nth week day of month + def week = 1 + if (day.contains("first")) { + week = 1 + } else if (day.contains("second")) { + week = 2 + } else if (day.contains("third")) { + week = 3 + } else if (day.contains("fourth")) { + week = 4 + } else if (day.contains("fifth")) { + week = 5 + } + if (day.contains("last")) { + week = -week + } + def intervalOffset = 0 + def d = getDayInWeekOfMonth(new Date(now.year, mo, now.date, now.hours, now.minutes, 0), week, rdow) + //get a possible date this year + if (d && (new Date(now.year, mo, d, now.hours, now.minutes, 0) > now)) { + //at this point, the next month is this month (lol), we need to remove one from the interval + intervalOffset = 1 + } + + //get the day of the next required month + d = getDayInWeekOfMonth(new Date(now.year + interval - intervalOffset, mo, 1, now.hours, now.minutes, 0), week, rdow) + if (d) { + now = new Date(now.year + interval - intervalOffset, mo, d, now.hours, now.minutes, 0) + } else { + now = new Date(now.year + interval - intervalOffset, mo, 1, now.hours, now.minutes, 0) + repeatCycle = true + } + } else { + //we're specifying a day + def d = 1 + if (day.contains("last")) { + //going backwards + if (day.contains("third")) { + d = -2 + } else if (day.contains("third")) { + d = -1 + } else { + d = 0 + } + def intervalOffset = 0 + //get the last day of specified month + def dd = (new Date(now.year, mo + 1, d)).date + if (new Date(now.year, mo, dd, now.hours, now.minutes, 0) > now) { + //at this point, the next month is this month (lol), we need to remove one from the interval + intervalOffset = 1 + } + //get the day of the next required month + d = (new Date(now.year + interval - intervalOffset, mo + 1, d)).date + now = new Date(now.year + interval - intervalOffset, mo, d, now.hours, now.minutes, 0) + } else { + //the day is in the string + day = day.replace("on the ", "").replace("st", "").replace("nd", "").replace("rd", "").replace("th", "") + if (!day.isInteger()) { + //error + return null + } + d = day.toInteger() + now = new Date(now.year + interval - ((d > now.date) && (now.month == mo) ? 1 : 0), mo, d, now.hours, now.minutes, 0) + if (d > now.date) { + //we went overboard, this month does not have so many days, repeat the cycle to move on to the next month that does + if (d > 29) { + //no year ever will have this day on the selected month + return null + } + repeatCycle = true + } + } + } + cycles = 10 + break + } + } + + //check if we have to repeat or exit + if ((!repeatCycle) && testDateTimeFilters(condition, now)) { + //make it UTC Unix Time + def result = convertDateToUnixTime(now) + //we only provide a time in the future + //if we weren't, we'd be hogging everyone trying to keep up + if (result >= (new Date()).time + 2000) { + return result + } + } + maxCycles = (maxCycles == null ? cycles : maxCycles) - 1 + } +} + +def keepAlive() { + state.run = "app" + processTasks() +} + +private processTasks() { + //pfew, off to process tasks + //first, we make a variable to help us pick up where we left off + state.rerunSchedule = false + //new safety net, ST will execute this method if we timeout + setTimeoutRecoveryHandler("timeoutRecoveryHandler_CoRE") + def appData = state.run == "config" ? state.config.app : state.app + def tasks = null + def perf = now() + def marker = now() + debug "Processing tasks (${version()})", 1, "trace" + try { + //find out if we need to execute the tasks + def restricted = (checkPistonRestriction() != null) + state.restricted = restricted + def executeTasks = !appData.restrictions?.pe || !restricted + + //let's give now() a 2s bump up so that if anything is due within 2s, we do it now rather than scheduling ST + def threshold = 2000 + + //we're off to process any pending immediate EVENTS ONLY + //we loop a seemingly infinite loop + //no worries, we'll break out of it, maybe :) + while (true) { + //we need to read the list every time we get here because the loop itself takes time. + //we always need to work with a fresh list. + tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + for (item in tasks.findAll{it.value?.type == "evt"}.sort{ it.value?.time }) { + def task = item.value + if (task.time <= now() + threshold) { + //remove from tasks + tasks.remove(item.key) + atomicState.tasks = tasks + //throw away the task list as this procedure below may take time, making our list stale + //not to worry, we'll read it again on our next iteration + tasks = null + //trigger an event + if (!restricted) { + if (getCondition(task.ownerId, true)) { + //look for condition in primary block + debug "Broadcasting time event for primary IF block, condition #${task.ownerId}, task = $task", null, "trace" + broadcastEvent([name: "time", date: new Date(task.time), deviceId: task.deviceId ? task.deviceId : "time", conditionId: task.ownerId], true, false) + } else if (getCondition(task.ownerId, false)) { + //look for condition in secondary block + debug "Broadcasting time event for secondary IF block, condition #${task.ownerId}", null, "trace" + broadcastEvent([name: "time", date: new Date(task.time), deviceId: task.deviceId ? task.deviceId : "time", conditionId: task.ownerId], false, true) + } else { + debug "ERROR: Time event cannot be processed because condition #${task.ownerId} does not exist", null, "error" + } + } else { + debug "Not broadcasting event due to restrictions" + } + //continue the loop + break + } + } + //well, if we got here, it means there's nothing to do anymore + if (tasks != null) break + } + + //okay, now let's give the time triggers a chance to readjust + if (state.app?.enabled && (state.app?.mode != "Follow-Up")) { + scheduleTimeTriggers() + } + + //read the tasks + tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + def idx = 1 + //find the last index + for(task in tasks) { + if ((task.value?.idx) && (task.value?.idx >= idx)) { + idx = task.value?.idx + 1 + } + } + + def repeatCount = 0 + + while (repeatCount < 2) { + //we allow some tasks to rerun this code because they're altering our task list... + //then if there's any pending tasks in the tasker, we look them up too and merge them to the task list + tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + if (state.tasker && state.tasker.size()) { + for (task in state.tasker.sort{ it.idx }) { + if (task.add) { + def t = cleanUpMap([type: task.add, idx: idx, ownerId: task.ownerId, deviceId: task.deviceId, taskId: task.taskId, time: task.time, created: task.created, data: task.data, marker: (task.time < now() + threshold ? marker : null)]) + //def n = "${task.add}:${task.ownerId}${task.deviceId ? ":${task.deviceId}" : ""}${task.taskId ? "#${task.taskId}" : ""}:${task.idx}:$idx" + def n = "t$idx" + idx += 1 + tasks[n] = t + } else if (task.del) { + //delete a task + def washer = [] + for (it in tasks) { + if ( + (it.value?.type == task.del) && + (!task.ownerId || (it.value?.ownerId == task.ownerId)) && + //(task.ownerId || (task.deviceId != "location")) && //do not unschedule location commands unless an action Id is provided + (!task.deviceId || (task.deviceId == it.value?.deviceId)) && + (!task.taskId || (task.taskId == it.value?.taskId)) + ) { + washer.push(it.key) + } + } + for (it in washer) { + tasks.remove(it) + } + washer = null + /* + def dirty = true + while (dirty) { + dirty = false + for (it in tasks) { + if ( + (it.value?.type == task.del) && + (!task.ownerId || (it.value?.ownerId == task.ownerId)) && + //(task.ownerId || (task.deviceId != "location")) && //do not unschedule location commands unless an action Id is provided + (!task.deviceId || (task.deviceId == it.value?.deviceId)) && + (!task.taskId || (task.taskId == it.value?.taskId)) + ) { + tasks.remove(it.key) + dirty = true + break + } + } + } + */ + } + } + //we save the tasks list atomically, ouch + //this is to avoid spending too much time with the tasks list on our hands and having other instances + //running and modifying the old list that we picked up above + state.tasksProcessed = now() + atomicState.tasks = tasks + //state.tasks = tasks + state.tasker = null + } + + //time to see if there is any ST schedule needed for the future + def nextTime = null + def immediateTasks = 0 + def thresholdTime = now() + threshold + for (item in tasks) { + def task = item.value + //if a command task is waiting, we ignore it + if (!task.data || !task.data.w) { + //if a task is already due, we keep track of it + if (task.time <= thresholdTime) { + if (task.marker in [null, marker]) { + //we only handle our own tasks or no ones tasks + immediateTasks += 1 + } + } else { + //we try to get the nearest time in the future + nextTime = (nextTime == null) || (nextTime > task.time) ? task.time : nextTime + } + } + } + //if we found a time that's after + if (nextTime) { + def seconds = Math.ceil((nextTime - now()) / 1000) + runIn(seconds, timeHandler) + atomicState.nextScheduledTime = nextTime + state.nextScheduledTime = nextTime + setVariable("\$nextScheduledTime", nextTime, true) + debug "Scheduling ST job to run in ${seconds}s, at ${formatLocalTime(nextTime)}", null, "info" + } else { + setVariable("\$nextScheduledTime", null, true) + atomicState.nextScheduledTime = null + state.nextScheduledTime = nextTime + //removed 2017/11/13 to alleviate tombstone issues + //unschedule(timeHandler) + } + + //we're done with the scheduling, let's do some real work, if we have any + if (immediateTasks) { + debug "Found $immediateTasks task${immediateTasks > 1 ? "s" : ""} due at this time" + //we loop a seemingly infinite loop + //no worries, we'll break out of it, maybe :) + def found = true + while (found) { + found = false + //we need to read the list every time we get here because the loop itself takes time. + //we always need to work with a fresh list. Using a ? would not read the list the first time around (optimal, right?) + tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + def firstTask = tasks.sort{ it.value.time }.find{ (it.value.type == "cmd") && (!it.value.data || !it.value.data.w) && (it.value.time <= (now() + threshold)) && (it.value.marker in [null, marker]) } + if (firstTask) { + def firstSubTask = tasks.sort{ it.value.idx }.find{ (it.value.type == "cmd") && (!it.value.data || !it.value.data.w) && (it.value.time == firstTask.value.time) && (it.value.marker in [null, marker]) } + if (firstSubTask) { + def task = firstSubTask.value + //remove from tasks + tasks = atomicState.tasks + tasks.remove(firstSubTask.key) + atomicState.tasks = tasks + //throw away the task list as this procedure below may take time, making our list stale + //not to worry, we'll read it again on our next iteration + tasks = null + //do some work + + + def enabled = (state.app && (state.app.enabled != null) ? !!state.app.enabled : true) && executeTasks + + if (enabled && (task.type == "cmd")) { + debug "Processing command task $task" + try { + processCommandTask(task) + } catch (e) { + debug "ERROR: Error while processing command task: ", null, "error", e + } + } + //repeat the while since we just modified the task + found = true + } + } + } + } + if (!state.rerunSchedule) break + repeatCount += 1 + } + //would you look at that, we finished! + //remove the safety net, wasn't worth the investment + + //remove the markers + tasks = atomicState.tasks + def found = false + for(it in tasks.findAll{ it.value.marker == marker }) { + def task = it.value + task.marker = null + tasks[it.key] = task + found = true + } + if (found) atomicState.tasks = tasks + + //DO NOT REMOVE THE NEXT LINE - we need this line for instances that do not run the exitPoint() + state.tasks = tasks + + //removed 2017/11/13 to alleviate tombstone issues + //debug "Removing any existing ST safety nets", null, "trace" + //unschedule(recoveryHandler) + } catch (e) { + debug "ERROR: Error while executing processTasks: ", null, "error", e + } + state.tasker = null + //end of processTasks + perf = now() - perf + debug "Task processing took ${perf}ms", -1, "trace" + return true +} + +private cancelTasks(state) { + def tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + //debug "Resuming tasks on piston state change, resumable states are $resumableStates", null, "trace" + while (true) { + def item = tasks.find{ (it.value.type == "cmd") && (it.value.data && it.value.data.c)} + if (item) { + tasks.remove(item.key) + } else { + break + } + } + atomicState.tasks = tasks +} + +private resumeTasks(state) { + def tasks = atomicState.tasks + tasks = tasks ? tasks : [:] + def resumableStates = ["a", (state ? "t" : "f")] + //debug "Resuming tasks on piston state change, resumable states are $resumableStates", null, "trace" + def time = now() + def list = tasks.findAll{ (it.value.type == "cmd") && (it.value.data && (it.value.data.w in resumableStates))} + //todo: support for multiple wait for state commands during same action + if (list.size()) { + for (item in list) { + tasks[item.key].time = time + (tasks[item.key].data.o ? tasks[item.key].data.o : 0) + tasks[item.key].data.w = null + tasks[item.key].data.o = null + } + atomicState.tasks = tasks + } +} + +//the heavy lifting of commands +//this executes each and every single command we have to give +private processCommandTask(task) { + def action = getAction(task.ownerId) + if (!action) return false + if (!action.t) return false + def devices = listActionDevices(action.id) + def device = devices.find{ it.id == task.deviceId } + def t = action.t.find{ it.i == task.taskId } + if (!t) return false + //only execute task in certain modes? + if (t.m && !(location.mode in t.m)) return false + //only execute task on certain days? + if (t.d && t.d.size() && !(getDayOfWeekName() in t.d)) return false + //found the actual task, let's figure out what command we're running + def cmd = t.c + def virtual = (cmd && cmd.startsWith(virtualCommandPrefix())) + def custom = (cmd && cmd.startsWith(customCommandPrefix())) + cmd = cleanUpCommand(cmd) + def command = null + + if (virtual) { + //dealing with a virtual command + command = getVirtualCommandByDisplay(cmd) + if (command) { + //we can't run immediate tasks here + //execute the virtual task + def cn = command.name + def suffix = "" + if (cn.contains("#")) { + //multi command + def parts = cn.tokenize("#") + if (parts.size() == 2) { + cn = parts[0] + suffix = parts[1] + } + } + def msg = "Executing virtual command ${cn}" + def function = "task_vcmd_${sanitizeCommandName(cn)}" + def perf = now() + try { + def result = "$function"(command.aggregated ? devices : device, action, task, suffix) + } catch (all) { + msg += " (ERROR EXECUTING TASK $task: $all)" + } + msg += " (${now() - perf}ms)" + if (state.sim) state.sim.cmds.push(msg) + debug msg, null, "info" + return result + } + } else { + if (custom) { + def availableParams = t.p ? t.p.size() : 0 + def params = [] + if (availableParams && (availableParams.mod(2) == 0)) { + for (def i = 0; i < Math.floor(availableParams / 2); i++) { + def type = t.p[i * 2].d + def value = t.p[i * 2 + 1].d + params.push cast(value, type) + } + } + def msg = "Executing custom command: [${device}].${cmd}(${params.size() ? params : ""})" + def perf = now() + try { + if (params.size()) { + device."${cmd}"(params as Object[]) + } else { + device."${cmd}"() + } + } catch (all) { + msg += " (ERROR EXECUTING TASK $task: $all)" + } + msg += " (${now() - perf}ms)" + if (state.sim) state.sim.cmds.push(msg) + debug msg, null, "info" + return true + } + command = getCommandByDisplay(cmd) + if (command) { + def cn = command.name + if (cn && cn.contains(".")) { + def parts = cn.tokenize(".") + cn = parts[parts.size() - 1] + } + if (device.hasCommand(cn)) { + def requiredParams = command.parameters ? command.parameters.size() : 0 + def availableParams = t.p ? t.p.size() : 0 + if (requiredParams == availableParams) { + def params = [] + t.p.sort{ it.i }.findAll() { + params.push(it.d instanceof String ? formatMessage(it.d) : it.d) + } + if (params.size()) { + if ((cn == "setColor") && (params.size() == 5)) { + //using a little bit of a hack here + //we should have 5 parameters: + //color name + //color rgb + //hue + //saturation + //lightness + def name = params[0] + def hex = params[1] + def hue = (int) Math.round(params[2] instanceof Integer ? params[2] / 3.6 : 0) + def saturation = params[3] + def lightness = params[4] + def p = [:] + if (name) { + def color = getColorByName(name, task.ownerId, task.taskId) + p.hue = (int) Math.round(color.h / 3.6) + p.saturation = color.s + //ST wrongly calls this level - it's lightness + p.level = color.l + } else if (hex) { + p.hex = hex + } else { + p.hue = hue + p.saturation = saturation + p.level = lightness + } + def msg = "Executing command: [${device}].${cn}($p)" + def perf = now() + try { + device."${cn}"(p) + } catch(all) { + msg += " (ERROR EXECUTING TASK $task: $all)" + } + msg += " (${now() - perf}ms)" + if (state.sim) state.sim.cmds.push(msg) + debug msg, null, "info" + } else { + def perf = now() + if ((cn == "setHue") && (params.size() == 1)) { + //ST expects hue in 0.100, in reality, it is 0..360 + params[0] = cast(params[0], "decimal") / 3.6 + } + def doIt = true + def msg + if (!state.app?.disableCO && command.attribute && (params.size() == 1)) { + //we may be able to avoid executing this command + def currentValue = "${device.currentValue(command.attribute)}" + if (cn == "setLevel") { + //setLevel is handled differently. Even if we have the same value, but the switch would flip, we need to let it execute + if (device.currentValue("switch") == (params[0] > 0 ? "off" : "on")) currentValue = null //we fake the current value to allow execution + } + if (currentValue == "${params[0]}") { + doIt = false + msg = "Preventing execution of command [${getDeviceLabel(device)}].${command.name}($params) because current value is the same" + } + } + if (doIt) { + msg = "Executing command: [${getDeviceLabel(device)}].${cn}($params)" + try { + device."${cn}"(params as Object[]) + } catch(all) { + msg += " (ERROR EXECUTING TASK $task: $all)" + } + msg += " (${now() - perf}ms)" + } + if (state.sim) state.sim.cmds.push(msg) + debug msg, null, "info" + } + return true + } else { + def doIt = true + def msg + if (!state.app?.disableCO && command.attribute && command.value) { + //we may be able to avoid executing this command + def currentValue = "${device.currentValue(command.attribute)}" + if (currentValue == command.value) { + doIt = false + msg = "Preventing execution of command [${getDeviceLabel(device)}].${command.name}() because current value is the same" + } + } + if (doIt) { + msg = "Executing command: [${getDeviceLabel(device)}].${cn}()" + def perf = now() + try { + device."${cn}"() + } catch(all) { + msg += " (ERROR EXECUTING TASK $task: $all)" + } + msg += " (${now() - perf}ms)" + } + if (state.sim) state.sim.cmds.push(msg) + debug msg, null, "info" + return true + } + } + } + } + } + return false +} + +private task_vcmd_toggle(device, action, task, suffix = "") { + if (!device || !device.hasCommand("on$suffix") || !device.hasCommand("off$suffix")) { + //we need a device that has both on and off commands + return false + } + if (device.currentValue("switch") == "on") { + device."off$suffix"() + } else { + device."on$suffix"() + } + return true +} + +private task_vcmd_toggleLevel(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("on$suffix") || !device.hasCommand("off$suffix") || !device.hasCommand("setLevel") || (params.size() != 1)) { + //we need a device that has both on and off commands + return false + } + def level = params[0].d + if (device.currentValue("switch") == "on") { + device."off$suffix"() + } else { + device.setLevel(level) + device."on$suffix"() + } + return true +} + +private task_vcmd_delayedToggle(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("on$suffix") || !device.hasCommand("off$suffix") || (params.size() != 1)) { + //we need a device that has both on and off commands + return false + } + def delay = params[0].d + if (device.currentValue("switch") == "on") { + device."off$suffix"([delay: delay]) + } else { + device."on$suffix"([delay: delay]) + } + return true +} + +private task_vcmd_delayedOn(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("on$suffix") || (params.size() != 1)) { + //we need a device that has both on and off commands + return false + } + def delay = params[0].d + device."on$suffix"([delay: delay]) + return true +} + +private task_vcmd_delayedOff(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("off$suffix") || (params.size() != 1)) { + //we need a device that has both on and off commands + return false + } + def delay = params[0].d + device."off$suffix"([delay: delay]) + return true +} + +private task_vcmd_fadeLevelHW(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setLevel$suffix") || (params.size() != 2)) { + return false + } + def level = cast(params[0].d, params[1].t) + def duration = cast(params[1].d, params[1].t) + //we're trying with a delay, not all devices support this + try { + device."setLevel$suffix"(level, duration) + } catch(all) { + //if not supported, we fallback onto the normal setLevel + device."setLevel$suffix"(level) + } + return true +} + +private task_vcmd_fadeLevelVariable(device, action, task, suffix = "") { + return task_vcmd_fadeLevel(device, action, task, suffix, true) +} + +private task_vcmd_fadeLevel(device, action, task, suffix = "", variables = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setLevel$suffix") || (params.size() != 3)) { + return false + } + def currentLevel = variables ? (params[0].d ? cast(getVariable(params[0].d), "number") : null) : cast(params[0].d, params[0].t) + if (currentLevel == null) currentLevel = cast(device.currentValue('level'), "number") + def level = variables? cast(getVariable(params[1].d), "number") : cast(params[1].d, params[1].t) + def duration = cast(params[2].d, params[2].t) + def delta = level - currentLevel + if (delta == 0) return + //we try to achieve 10 steps + def interval = Math.round(duration * 10) + def minInterval = 1000 //min interval is 1s + interval = interval > minInterval ? interval : minInterval + def steps = Math.ceil(duration * 1000 / interval) + //we're trying with a delay, not all devices support this + if (steps > 1) { + def oldLevel = currentLevel + for(def i = 1; i <= steps; i++) { + def newLevel = Math.round(currentLevel + delta * i / steps) + if (oldLevel != newLevel) { + device."setLevel$suffix"(newLevel, [delay: i * interval]) + } + oldLevel = newLevel + } + } + return true +} + +private task_vcmd_setLevelIf(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setLevel$suffix") || (params.size() != 2)) { + return false + } + def currentSwitch = cast(device.currentValue("switch"), "string") + def level = cast(params[0].d, params[0].t) + if (currentSwitch == cast(params[1].d, "string")) { + device."setLevel$suffix"(level) + } + return true +} + +private task_vcmd_adjustLevelVariable(device, action, task, suffix = "") { + return task_vcmd_adjustLevel(device, action, task, suffix, true) +} + +private task_vcmd_adjustLevel(device, action, task, suffix = "", variables = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setLevel$suffix") || (params.size() != 1)) { + return false + } + def currentLevel = cast(device.currentValue('level'), "number") + def level = currentLevel + (variables ? cast(getVariable(params[0].d), "number") : cast(params[0].d, params[0].t)) + level = (level < 0 ? 0 : (level > 100 ? 100 : level)) + if (level == currentLevel) return + device."setLevel$suffix"(level) + return true +} + +private task_vcmd_setLevelVariable(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setLevel$suffix") || (params.size() != 1)) { + return false + } + def level = cast(getVariable(params[0].d), "number") + level = (level < 0 ? 0 : (level > 100 ? 100 : level)) + device."setLevel$suffix"(level) + return true +} + +private task_vcmd_setSaturationVariable(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setSaturation$suffix") || (params.size() != 1)) { + return false + } + def saturation = cast(getVariable(params[0].d), "number") + saturation = (saturation < 0 ? 0 : (saturation > 100 ? 100 : saturation)) + device."setSaturation$suffix"(level) + return true +} + +private task_vcmd_fadeSaturationVariable(device, action, task, suffix = "") { + return task_vcmd_fadeSaturation(device, action, task, suffix, true) +} + +private task_vcmd_fadeSaturation(device, action, task, suffix = "", variables = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setSaturation$suffix") || (params.size() != 3)) { + return false + } + + def currentSaturation = variables ? (params[0].d ? cast(getVariable(params[0].d), "number") : null) : cast(params[0].d, params[0].t) + if (currentSaturation == null) currentSaturation = cast(device.currentValue('saturation'), "number") + def saturation = variables? cast(getVariable(params[1].d), "number") : cast(params[1].d, params[1].t) + def duration = cast(params[2].d, params[2].t) + def delta = saturation - currentSaturation + if (delta == 0) return + //we try to achieve 10 steps + def interval = Math.round(duration * 10) + def minInterval = 1000 //min interval is 1s + interval = interval > minInterval ? interval : minInterval + def steps = Math.ceil(duration * 1000 / interval) + //we're trying with a delay, not all devices support this + if (steps > 1) { + def oldSaturation = currentSaturation + for(def i = 1; i <= steps; i++) { + def newSaturation = Math.round(currentSaturation + delta * i / steps) + if (oldSaturation != newSaturation) { + device."setSaturation$suffix"(newSaturation, [delay: i * interval]) + } + oldSaturation = newSaturation + } + } + return true +} + +private task_vcmd_adjustSaturationVariable(device, action, task, suffix = "") { + return task_vcmd_adjustSaturation(device, action, task, suffix, true) +} + +private task_vcmd_adjustSaturation(device, action, task, suffix = "", variables = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setSaturation$suffix") || (params.size() != 1)) { + return false + } + def currentSaturation = cast(device.currentValue('saturation'), "number") + def saturation = currentSaturation + (variables ? cast(getVariable(params[0].d), "number") : cast(params[0].d, params[0].t)) + saturation = (saturation < 0 ? 0 : (saturation > 100 ? 100 : saturation)) + if (saturation == currentSaturation) return + device."setSaturation$suffix"(saturation) + return true +} + +private task_vcmd_fadeHueVariable(device, action, task, suffix = "") { + return task_vcmd_fadeHue(device, action, task, suffix, true) +} + +private task_vcmd_fadeHue(device, action, task, suffix = "", variables = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setHue$suffix") || (params.size() != 3)) { + return false + } + def currentHue = variables ? (params[0].d ? cast(getVariable(params[0].d), "number") : null) : cast(params[0].d, params[0].t) + if (currentHue == null) currentHue = (int) Math.round(cast(device.currentValue('hue'), "number") * 3.6) + def hue = variables ? cast(getVariable(params[1].d), "number") : cast(params[1].d, params[1].t) + def duration = cast(params[2].d, params[2].t) + def delta = hue - currentHue + if (delta == 0) return + //we try to achieve 10 steps + def interval = Math.round(duration * 10) + def minInterval = 1000 //min interval is 1s + interval = interval > minInterval ? interval : minInterval + def steps = Math.ceil(duration * 1000 / interval) + //we're trying with a delay, not all devices support this + if (steps > 1) { + def oldHue = currentHue + for(def i = 1; i <= steps; i++) { + def newHue = Math.round(currentHue + delta * i / steps) + if (oldHue != newHue) { + device."setHue$suffix"((int) Math.round(newHue / 3.6), [delay: i * interval]) + } + oldHue = newHue + } + } + return true +} + +private task_vcmd_adjustHueVariable(device, action, task, suffix = "") { + return task_vcmd_adjustHue(device, action, task, suffix, true) +} + +private task_vcmd_adjustHue(device, action, task, suffix = "", variables = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setHue$suffix") || (params.size() != 1)) { + return false + } + def currentHue = cast(device.currentValue('hue'), "decimal") * 3.6 + def hue = currentHue + (variables ? cast(getVariable(params[0].d), "number") : cast(params[0].d, params[0].t)) + while (hue < 0) hue += 360 + while (hue >= 360) hue -= 360 + if (hue == currentHue) return + device."setHue$suffix"((int) Math.round(hue / 3.6)) + return true +} + +private task_vcmd_setHueVariable(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("setHue$suffix") || (params.size() != 1)) { + return false + } + def hue = cast(getVariable(params[0].d), "number") + while (hue < 0) hue += 360 + while (hue >= 360) hue -= 360 + device."setHue$suffix"((int) Math.round(hue / 3.6)) + return true +} + +private task_vcmd_flash(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.hasCommand("on$suffix") || !device.hasCommand("off$suffix") || (params.size() != 3)) { + //we need a device that has both on and off commands + //we also need three parameters + //p[0] represents the on interval + //p[1] represents the off interval + //p[2] represents the number of flashes + return false + } + def onInterval = params[0].d + def offInterval = params[1].d + def flashes = params[2].d + def delay = 0 + def originalState = device.currentValue("switch") + for (def i = 0; i < flashes; i++) { + device."on$suffix"([delay: delay]) + delay = delay + onInterval + device."off$suffix"([delay: delay]) + delay = delay + offInterval + } + if (originalState == "on") { + device."on$suffix"([delay: delay]) + } + return true +} + +private task_vcmd_setLocationMode(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def mode = params[0].d + if (location.mode != mode) { + location.setMode(mode) + return true + } else { + debug "Not changing location mode because location is already in the $mode mode" + } + return false +} + +private task_vcmd_setAlarmSystemStatus(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def status = params[0].d + if (getAlarmSystemStatus() != status) { + setAlarmSystemStatus(status) + return true + } else { + debug "WARNING: Not changing SHM's status because it already is $status", null, "warn" + } + return false +} + +private task_vcmd_sendNotification(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def message = formatMessage(params[0].d) + sendNotificationEvent(message) +} + +private task_vcmd_sendPushNotification(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 2) { + return false + } + def message = formatMessage(params[0].d) + def saveNotification = !!params[1].d + if (saveNotification) { + sendPush(message) + } else { + sendPushMessage(message) + } +} + +private task_vcmd_sendSMSNotification(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 3) { + return false + } + def message = formatMessage(params[0].d) + def phones = "${params[1].d}".replace(" ", "").replace("-", "").replace("(", "").replace(")", "").tokenize(",;*|").unique() + def saveNotification = !!params[2].d + for(def phone in phones) { + if (saveNotification) { + sendSms(phone, message) + } else { + sendSmsMessage(phone, message) + } + //we only need one notification + saveNotification = false + } +} + +private task_vcmd_sendNotificationToContacts(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 3) { + return false + } + def message = formatMessage(params[0].d) + def recipients = settings["actParam${task.ownerId}#${task.taskId}-1"] + def saveNotification = !!params[2].d + try { + sendNotificationToContacts(message, recipients, [event: saveNotification]) + } catch(all) {} +} + +private task_vcmd_queueAskAlexaMessage(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if ((params.size() < 2) || (params.size() > 3)) { + return false + } + def message = formatMessage(params[0].d) + def unit = formatMessage(params[1].d) + def appName = (params.size() == 3 ? formatMessage(params[2].d) : null) ?: (app.label ?: app.name) + sendLocationEvent name: "AskAlexaMsgQueue", value: appName , isStateChange: true, descriptionText: message, unit: unit +} + +private task_vcmd_deleteAskAlexaMessages(device, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if ((params.size() < 1) || (params.size() > 2)) { + return false + } + def unit = formatMessage(params[0].d) + def appName = (params.size() == 2 ? formatMessage(param[1].d) : null) ?: (app.label ?: app.name) + sendLocationEvent name: "AskAlexaMsgQueueDelete", value: appName, isStateChange: true, unit: unit +} + +private task_vcmd_executeRoutine(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def routine = formatMessage(params[0].d) + location.helloHome?.execute(routine) + return true +} + +private task_vcmd_followUp(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 4) { + return false + } + def piston = params[2].d + def result = execute(piston) + //state.store = atomicState.store + state.nextScheduledTime = atomicState.nextScheduledTime + if (params[3].d) { + setVariable(params[3].d, result) + } + return true +} + +private task_vcmd_executePiston(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 2) { + return false + } + def piston = params[0].d + def result = execute(piston) + //state.store = atomicState.store + state.nextScheduledTime = atomicState.nextScheduledTime + if (params[1].d) { + setVariable(params[1].d, result) + } + return true +} + +private task_vcmd_pausePiston(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def piston = params[0].d + def result = pausePiston(piston) + return true +} + +private task_vcmd_resumePiston(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def piston = params[0].d + def result = resumePiston(piston) + return true +} + +private task_vcmd_lifxScene(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 1) { + return false + } + def sceneName = params[0].d + def sceneId = getLifxSceneId(sceneName) + def token = getLifxToken() + if (sceneId != null) { + def requestParams = [ + uri: "https://api.lifx.com", + path: "/v1/scenes/scene_id:${sceneId}/activate", + headers: [ + "Authorization": "Bearer $token" + ] + ] + try { + return httpPut(requestParams) { response -> + if (response.status == 200) { + return true; + } + return false; + } + } + catch(all) { + return false + } + } else { + debug "WARNING: LIFX Scene $sceneName could not be found", null, "warn" + } + return false +} + +private task_vcmd_iftttMaker(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if ((params.size() < 1) || (params.size() > 4)) { + return false + } + def event = params[0].d + def value1 + def value2 + def value3 + if (params.size() == 4) { + value1 = formatMessage(params[1].d) + value2 = formatMessage(params[2].d) + value3 = formatMessage(params[3].d) + } + if (value1 || value2 || value3) { + def requestParams = [ + uri: "https://maker.ifttt.com/trigger/${event}/with/key/" + getIftttKey(), + requestContentType: "application/json", + body: [value1: value1, value2: value2, value3: value3] + ] + httpPost(requestParams){ response -> + setVariable("\$iftttStatusCode", response.status, true) + setVariable("\$iftttStatusOk", response.status == 200, true) + } + } else { + httpGet("https://maker.ifttt.com/trigger/${event}/with/key/" + getIftttKey()){ response -> + setVariable("\$iftttStatusCode", response.status, true) + setVariable("\$iftttStatusOk", response.status == 200, true) + } + } + return true +} + +private task_vcmd_httpRequest(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 6) return false + def uri = formatMessage(params[0].d).replace(" ", "%20") + def method = params[1].d + def contentType = params[2].d + def variables = params[3].d + def importData = !!params[4].d + def importPrefix = params[5].d ?: "" + if (!uri) return false + def protocol = "https" + def uriParts = uri.split("://").toList() + if (uriParts.size() > 2) { + debug "Invalid URI for web request: $uri", null, "warn" + return false + } + if (uriParts.size() == 2) { + //remove the httpX:// from the uri + protocol = uriParts[0].toLowerCase() + uri = uriParts[1] + } + def internal = uri.startsWith("10.") || uri.startsWith("192.168.") + if ((!internal) && uri.startsWith("172.")) { + //check for the 172.16.x.x/12 class + def b = uri.substring(4,2) + if (b.isInteger()) { + b = b.toInteger() + internal = (b >= 16) && (b <= 31) + } + } + def data = [:] + for(variable in variables) { + data[variable] = getVariable(variable) + } + if (internal) { + try { + debug "Sending internal web request to: $uri", null, "info" + sendHubCommand(new physicalgraph.device.HubAction( + method: method, + path: (uri.indexOf("/") > 0) ? uri.substring(uri.indexOf("/")) : "", + headers: [ + HOST: (uri.indexOf("/") > 0) ? uri.substring(0, uri.indexOf("/")) : uri, + ], + query: method == "GET" ? data : null, //thank you @destructure00 + body: method != "GET" ? data : null //thank you @destructure00 + )) + } catch (all) { + debug "Error executing internal web request: $all", null, "error" + } + } else { + try { + debug "Sending external web request to: $uri", null, "info" + def requestParams = [ + uri: "${protocol}://${uri}", + requestContentType: (method != "GET") && (contentType == "JSON") ? "application/json" : "application/x-www-form-urlencoded", + query: method == "GET" ? data : null, + body: method != "GET" ? data : null + ] + def func = "" + switch(method) { + case "GET": + func = "httpGet" + break + case "POST": + func = "httpPost" + break + case "PUT": + func = "httpPut" + break + case "DELETE": + func = "httpDelete" + break + case "HEAD": + func = "httpHead" + break + } + if (func) { + "$func"(requestParams) { response -> + setVariable("\$httpStatusCode", response.status, true) + setVariable("\$httpStatusOk", response.status == 200, true) + if (importData && (response.status == 200) && response.data) { + try { + def jsonData = response.data instanceof Map ? response.data : new groovy.json.JsonSlurper().parseText(response.data) + importVariables(jsonData, importPrefix) + } catch (all) { + debug "Error parsing JSON response for web request: $all", null, "error" + } + } + } + } + } catch (all) { + debug "Error executing external web request: $all", null, "error" + } + } + return true +} + +private task_vcmd_httpRequest_backup(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 6) return false + def uri = params[0].d + def method = params[1].d + def contentType = params[2].d + def variables = params[3].d + def importData = !!params[4].d + def importPrefix = params[5].d ?: "" + if (!uri) return false + def protocol = "" + switch (uri.substring(0, 7).toLowerCase()) { + case "http://": + protocol = "http" + break + case "https:/": + protocol = "https" + break + default: + protocol = "https" + uri = "https://" + uri + break + } + def data = [:] + for(variable in variables) { + data[variable] = getVariable(variable) + } + def requestParams = [ + uri: uri, + query: method == "GET" ? data : null, + requestContentType: (method != "GET") && (contentType == "JSON") ? "application/json" : "application/x-www-form-urlencoded", + body: method != "GET" ? data : null + ] + try { + def func = "" + switch(method) { + case "GET": + func = "httpGet" + break + case "POST": + func = "httpPost" + break + case "PUT": + func = "httpPut" + break + case "DELETE": + func = "httpDelete" + break + case "HEAD": + func = "httpHead" + break + } + if (func) { + "$func"(requestParams) { response -> + setVariable("\$httpStatusCode", response.status, true) + setVariable("\$httpStatusOk", response.status == 200, true) + if (importData && (response.status == 200) && response.data) { + try { + def jsonData = response.data instanceof Map ? response.data : new groovy.json.JsonSlurper().parseText(response.data) + importVariables(jsonData, importPrefix) + } catch (all) { + debug "Error parsing JSON response for web request: $all", null, "error" + } + } + } + } + } catch (all) { + debug "Error executing external web request: $all", null, "error" + } + return true +} + +private task_vcmd_wolRequest(devices, action, task, suffix = "") { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (params.size() != 2) return false + def mac = params[0].d ?: "" + def secureCode = params[1].d + mac = mac.replace(":", "").replace("-", "").replace(".", "").replace(" ", "").toLowerCase() + return sendHubCommand(new physicalgraph.device.HubAction( + "wake on lan $mac", + physicalgraph.device.Protocol.LAN, + null, + secureCode ? [secureCode: secureCode] : [:] + )) +} + +private task_vcmd_cancelPendingTasks(device, action, task, suffix = "") { + state.rerunSchedule = true + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || (params.size() != 1)) { + return false + } + unscheduleTask("cmd", null, device.id) + if (params[0].d == "Global") { + debug "WARNING: Global cancellation not yet implemented", null, "warn" + } + return true +} + +private task_vcmd_beginSimpleForLoop(device, action, task, suffix = "") { + if (task && task.data) { + setVariable("\$index", task.data.value, true) + } +} + +private task_vcmd_beginForLoop(device, action, task, suffix = "") { + if (task && task.data && task.data.variable) { + setVariable(task.data.variable, task.data.value) + } +} + +private task_vcmd_loadAttribute(device, action, task, simulate = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || (params.size() != 4)) { + return false + } + def attribute = cleanUpAttribute(params[0].d) + def variable = params[1].d + def allowTranslations = !!params[2].d + def negateTranslations = !!params[3].d + //work, work, work + //get the real value + def value = getVariable(variable) + setAttributeValue(device, attribute, value, allowTranslations, negateTranslations) + return true +} + +private task_vcmd_loadState(device, action, task, simulate = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || (params.size() != 4)) { + return false + } + def attributes = params[0].d + def variable = params[1].d + def values = getStateVariable(variable) + def allowTranslations = !!params[2].d + def negateTranslations = !!params[3].d + //work, work, work + //get the real value + for(attribute in attributes.sort{ it }) { + def cleanAttribute = cleanUpAttribute(attribute) + def value = values[cleanAttribute] + if (value != null) { + setAttributeValue(device, cleanAttribute, value, allowTranslations, negateTranslations) + } + } + return true +} + +private task_vcmd_loadStateLocally(device, action, task, simulate = false, global = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.id || (params.size() < 1) || (params.size() > 2)) { + return false + } + def attributes = params[0].d + def emptyState = params.size() == 2 ? !!params[1].d : false + def values = getStateVariable("${ global ? "@" : "" }:::${device.id}:::") + debug "Load from state: attributes are $attributes, values are $values" + if (values instanceof Map) { + for(attribute in attributes.sort { it }) { + def cleanAttribute = cleanUpAttribute(attribute) + def value = values[cleanAttribute] + if (value != null) { + setAttributeValue(device, cleanAttribute, value, false, false) + } + } + } + if (emptyState) { + setStateVariable("${ global ? "@" : "" }:::${device.id}:::", null) + } + return true +} + +private task_vcmd_loadStateGlobally(device, action, task, simulate = false) { + return task_vcmd_loadStateLocally(device, action, task, simulate, true) +} + +private setAttributeValue(device, attribute, value, allowTranslations, negateTranslations) { + def commands = commands().findAll{ (it.attribute == attribute) && it.value } + //oh boy, we can pick and choose... + for (command in commands) { + if (command.value.startsWith("*")) { + if (command.parameters && (command.parameters.size() == 1)) { + def parts = command.value.tokenize(":") + def v = value + if (parts.size() == 2) { + v = cast(v, parts[1]) + } else { + def attr = getAttributeByName(attribute) + if (attr) { + v = cast(v, attr.type) + } + } + if (attribute == "hue") { + v = cast(v, "decimal") / 3.6 + } + if (device.hasCommand(command.name)) { + def currentValue = "${device.currentValue(attribute)}" + if (command.name == "setLevel") { + //setLevel is handled differently. Even if we have the same value, but the switch would flip, we need to let it execute + if (device.currentValue("switch") == (v > 0 ? "off" : "on")) currentValue = null //we fake the current value to allow execution + } + if (!state.app?.disableCO && (currentValue == "$v")) { + debug "Preventing execution of [${getDeviceLabel(device)}].${command.name}($v) because current value is the same", null, "info" + } else { + debug "Executing [${getDeviceLabel(device)}].${command.name}($v)", null, "info" + device."${command.name}"(v) + } + return true + } + } + } else { + if ((command.value == value) && (!command.parameters)) { + //found an exact match, let's do it + if (device.hasCommand(command.name)) { + def currentValue = "${device.currentValue(attribute)}" + if (!state.app?.disableCO && (currentValue == "$value")) { + debug "Preventing execution of [${getDeviceLabel(device)}].${command.name}() because current value is the same", null, "info" + } else { + debug "Executing [${getDeviceLabel(device)}].${command.name}()", null, "info" + } + device."${command.name}"() + return true + } + } + } + } + //boolean stuff goes here + if (!allowTranslations) return false + def v = cast(value, "boolean") + if (negateTranslations) v = !v + for (command in commands) { + if (!command.value.startsWith("*")) { + if ((cast(command.value, "boolean") == v) && (!command.parameters)) { + //found an exact match, let's do it + if (device.hasCommand(command.name)) { + debug "Executing [${getDeviceLabel(device)}].${command.name}() (boolean translation)", null, "info" + device."${command.name}"() + return true + } + } + } + } +} + +private task_vcmd_saveAttribute(devices, action, task, simulate = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!devices || (params.size() != 4)) { + return false + } + def attribute = cleanUpAttribute(params[0].d) + def aggregation = params[1].d + if (!aggregation) aggregation = "First" + def dataType = params[2].d + def variable = params[3].d + //work, work, work + def result = getAggregatedAttributeValue(devices, attribute, aggregation, dataType) + setVariable(variable, result) + return true +} + +private task_vcmd_saveState(devices, action, task, simulate = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!devices || (params.size() != 4)) { + return false + } + def attributes = params[0].d + def aggregation = params[1].d + def dataType = params[2].d + def variable = params[3].d + //work, work, work + def values = [:] + for (attribute in attributes) { + def cleanAttribute = cleanUpAttribute(attribute) + values[cleanAttribute] = getAggregatedAttributeValue(devices, cleanAttribute, aggregation, dataType) + } + setStateVariable(variable, values) + return true +} + +private task_vcmd_saveStateLocally(device, action, task, simulate = false, global = false) { + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!device || !device.id || (params.size() < 1) || (params.size() > 2)) { + return false + } + def attributes = params[0].d + def needsEmptyState = params.size() == 2 ? !!params[1].d : false + if (needsEmptyState) { + //check to ensure state is empty + def values = getStateVariable("${ global ? "@" : "" }:::${device.id}:::") + if (values != null) return false + } + def values = [:] + for (attribute in attributes) { + def cleanAttribute = cleanUpAttribute(attribute) + values[cleanAttribute] = cleanAttribute == "hue" ? device.currentValue(cleanAttribute) * 3.6 : device.currentValue(cleanAttribute) + } + debug "Save to state: attributes are $attributes, values are $values" + setStateVariable("${ global ? "@" : "" }:::${device.id}:::", values) + return true +} + +private task_vcmd_saveStateGlobally(device, action, task, simulate = false) { + return task_vcmd_saveStateLocally(device, action, task, simulate, true) +} + +private getAggregatedAttributeValue(devices, attribute, aggregation, dataType) { + def result + def attr = getAttributeByName(attribute) + if (attr) { + def type = attr.type + result = cast("", attr.type) + def values = [] + for (device in devices) { + def val = cast(device.currentValue(attribute), type) + if (attribute == "hue") { + val = cast(val, "decimal") * 3.6 + } + values.push val + } + if (values.size()) { + switch (aggregation) { + case "First": + result = null + for(value in values) { + result = value + break + } + break + case "Last": + result = null + for(value in values) { + result = value + } + break + case "Min": + result = null + for(value in values) { + if ((result == null) || (value < result)) result = value + } + break + case "Max": + result = null + for(value in values) { + if ((result == null) || (value > result)) result = value + } + break + case "Avg": + result = null + if (attr.type in ["number", "decimal"]) { + for(value in values) { + result = result == null ? value : result + value + } + result = cast(result / values.size(), attr.type) + } else { + //average will act differently on strings and booleans + //we look for the value that is used most and we consider that the average + def map = [:] + for (value in values) { + map[value] = map[value] ? map[value] + 1 : 1 + } + for (item in map.sort { - it.value }) { + result = cast(item.key, attr.type) + break + } + } + break + case "Sum": + result = null + if (attr.type in ["number", "decimal"]) { + for(value in values) { + result = result == null ? value : result + value + } + } else { + //sum will act differently on strings and booleans + result = buildNameList(values, "") + } + break + case "Count": + result = (int) values.size() + break + case "Boolean And": + result = true + for (value in values) { + result = result && cast(value, "boolean") + if (!result) break + } + break + case "Boolean Or": + result = false + for (value in values) { + result = result || cast(value, "boolean") + if (result) break + } + break + case "Boolean True Count": + result = (int) 0 + for (value in values) { + if (cast(value, "boolean")) result += 1 + } + break + case "Boolean True Count": + result = (int) 0 + for (value in values) { + if (!cast(value, "boolean")) result += 1 + } + break + } + } + } + + if (dataType) { + //if user wants a certain data type, we comply + result = cast(result, dataType) + } + + return result +} + +private task_vcmd_setVariable(devices, action, task, simulate = false) { + def params = simulate ? ((task && task.p && task.p.size()) ? task.p : []) : ((task && task.data && task.data.p && task.data.p.size()) ? task.data.p : []) + //we need at least 7 params + if (params.size() < 7) { + return simulate ? null : false + } + def name = params[0].d + def dataType = params[1].d + if (!name || !dataType) return simulate ? null : false + def result = "" + switch (dataType) { + case "time": + result = adjustTime() + break + case "number": + //we need to use long numbers + dataType = "long" + case "long": + case "decimal": + result = 0 + break + } + def immediate = !!params[2].d + try { + def i = 4 + def grouping = false + def groupingUnit = "" + def groupingIndex = null + def groupingResult = null + def groupingOperation = null + def previousOperation = null + def operation = null + def subDataType = dataType == "long" ? "decimal" : dataType + def idx = 0 + while (true) { + def value = params[i].d + def variable = params[i + 1].d + if (!value) { + //we get the value of the variable + if (subDataType in ["time"]) { + value = adjustTime(getVariable(variable)).time + } else { + value = cast(getVariable(variable, dataType in ["string", "text"]), subDataType) + } + } else { + if (subDataType in ["time"]) { + //we need to bring the value to today + def time = adjustTime(value) + if (time) { + def h = time.hours + def m = time.minutes + def lastMidnight = adjustTime().time + lastMidnight = lastMidnight - lastMidnight.mod(86400000) + value = lastMidnight + h * 3600000 + m * 60000 + + } + } + value = cast(value, subDataType) + } + if (i == 4) { + //initial values + result = cast(value, dataType) + } + def unit = (dataType == "time" ? params[i + 2].d : null) + previousOperation = operation + operation = params.size() > i + 3 ? "${params[i + 3].d} ".tokenize(" ")[0] : null + def needsGrouping = (operation == "*") || (operation == "÷") || (operation == "AND") + def skip = idx == 0 + if (needsGrouping) { + //these operations require grouping i.e. (a * b * c) seconds + if (!grouping) { + grouping = true + groupingIndex = idx + groupingUnit = unit + groupingOperation = previousOperation + groupingResult = value + skip = true + } + } + //add the value/variable + subDataType = subDataType == "time" ? "long" : subDataType + if (!skip) { + def operand1 = grouping ? groupingResult : result + def operand2 = value + if (groupingUnit ? groupingUnit : unit) { + switch (unit) { + case "seconds": + operand2 = operand2 * 1000 + break + case "minutes": + operand2 = operand2 * 60000 + break + case "hours": + operand2 = operand2 * 3600000 + break + case "days": + operand2 = operand2 * 86400000 + break + case "weeks": + operand2 = operand2 * 604800000 + break + case "months": + operand2 = operand2 * 2592000000 + break + case "years": + operand2 = operand2 * 31536000000 + break + } + } + //reset the group unit - we only apply it once + groupingUnit = null + def res = null + switch (previousOperation) { + case "AND": + res = cast(operand1 && operand2, subDataType) + break + case "OR": + res = cast(operand1 || operand2, subDataType) + break + case "+": + res = cast(operand1 + operand2, subDataType) + break + case "-": + res = cast(operand1 - operand2, subDataType) + break + case "*": + res = cast(operand1 * operand2, subDataType) + break + case "÷": + if (!operand2) return null + res = cast(operand1 / operand2, subDataType) + break + } + if (grouping) { + groupingResult = res + } else { + result = res + } + } + skip = false + if (grouping && !needsGrouping) { + //these operations do NOT require grouping + //ungroup + if (!groupingOperation) { + result = groupingResult + } else { + def operand1 = result + def operand2 = groupingResult + + switch (groupingOperation) { + case "AND": + result = cast(operand1 && operand2, subDataType) + break + case "OR": + result = cast(operand1 || operand2, subDataType) + break + case "+": + result = cast(operand1 + operand2, subDataType) + break + case "-": + result = cast(operand1 - operand2, subDataType) + break + case "*": + result = cast(operand1 * operand2, subDataType) + break + case "÷": + if (!operand2) return null + result = cast(operand1 / operand2, subDataType) + break + } + } + grouping = false + } + if (!operation) break + i += 4 + idx += 1 + } + } catch (e) { + return simulate ? null : false + } + if (dataType in ["string", "text"]) { + result = formatMessage(result) + } else if (dataType in ["time"]) { + result = simulate ? formatLocalTime(convertTimeToUnixTime(result)) : convertTimeToUnixTime(result) + } else { + result = cast(result, dataType) + } + setVariable(name, result) + if (simulate) { + return result + } + return true +} + +private cast(value, dataType) { + def trueStrings = ["1", "on", "open", "locked", "active", "wet", "detected", "present", "occupied", "muted", "sleeping"] + def falseStrings = ["0", "false", "off", "closed", "unlocked", "inactive", "dry", "clear", "not detected", "not present", "not occupied", "unmuted", "not sleeping"] + switch (dataType) { + case "string": + case "text": + if (value instanceof Boolean) { + return value ? "true" : "false" + } + return value ? "$value" : "" + case "number": + if (value == null) return (int) 0 + if (value instanceof String) { + if (value.isInteger()) + return value.toInteger() + if (value.isFloat()) + return (int) Math.floor(value.toFloat()) + if (value in trueStrings) + return (int) 1 + } + def result = (int) 0 + try { + result = (int) value + } catch(all) { + result = (int) 0 + } + return result ? result : (int) 0 + case "long": + if (value == null) return (long) 0 + if (value instanceof String) { + if (value.isInteger()) + return (long) value.toInteger() + if (value.isFloat()) + return (long) Math.round(value.toFloat()) + if (value in trueStrings) + return (long) 1 + } + def result = (long) 0 + try { + result = (long) value + } catch(all) { + } + return result ? result : (long) 0 + case "decimal": + if (value == null) return (float) 0 + if (value instanceof String) { + if (value.isFloat()) + return (float) value.toFloat() + if (value.isInteger()) + return (float) value.toInteger() + if (value in trueStrings) + return (float) 1 + } + def result = (float) 0 + try { + result = (float) value + } catch(all) { + } + return result ? result : (float) 0 + case "boolean": + if (value instanceof String) { + if (!value || (value in falseStrings)) + return false + return true + } + return !!value + case "time": + return value instanceof String ? adjustTime(value).time : cast(value, "long") + case "vector3": + return value instanceof String ? adjustTime(value).time : cast(value, "long") + case "orientation": + return getThreeAxisOrientation(value) + } + //anything else... + return value +} + +/******************************************************************************/ +/*** CoRE PISTON PUBLISHED METHODS ***/ +/******************************************************************************/ + +def getLastPrimaryEvaluationDate() { + return state.lastPrimaryEvaluationDate +} + +def getLastPrimaryEvaluationResult() { + return state.lastPrimaryEvaluationResult +} + +def getLastSecondaryEvaluationDate() { + return state.lastSecondaryEvaluationDate +} + +def getLastSecondaryEvaluationResult() { + return state.lastSecondaryEvaluationResult +} + +def getCurrentState() { + return state.currentState +} + +def getMode() { + return state.app ? state.app.mode : null +} + +def getDeviceSubscriptionCount() { + return state.deviceSubscriptions ? state.deviceSubscriptions : 0 +} + +def getCurrentStateSince() { + return state.currentStateSince +} + +def getRunStats() { + return state.runStats +} + +def resetRunStats() { + atomicState.runStats = null + state.runStats = null +} + +def getConditionStats() { + return [ + conditions: getConditionCount(state.app), + triggers: getTriggerCount(state.app) + ] +} + +def getPistonApp() { + return state.app +} + +def getPistonType() { + return state.app.mode +} + +def getPistonTasks() { + return atomicState.tasks +} + +def getPistonEnabled() { + return !!state.app?.enabled +} + +def getPistonConditionDescription(condition) { + return (condition ? getConditionDescription(condition.id) : null) +} + +def getSummary() { + if (!state.app) { + log.warn "Piston ${app.label} is not complete, please open it and save it" + } + def stateApp = (state.app ?: state.config?.app) + return [ + i: app.id, + l: app.label, + d: stateApp?.description, + e: !!stateApp?.enabled, + m: stateApp?.mode, + s: state.currentState, + ss: state.currentStateSince, + n: state.nextScheduledTime, + d: state.deviceSubscriptions ? state.deviceSubscriptions : 0, + c: getConditionCount(stateApp), + t: getTriggerCount(stateApp), + le: state.lastEvent, + lx: state.lastExecutionTime, + cd: formatLocalTime(stateApp?.created), + cv: stateApp?.version, + md: formatLocalTime(state.lastInitialized), + ] +} + +def pausePiston(pistonName) { + if (parent) { + return parent.pausePiston(pistonName) + } else { + def piston = getChildApps().find{ it.label == pistonName } + if (piston) { + //fire up the piston + return piston.pause() + } + return null + } +} + +def pause() { + if (!parent) return null + if (!state.app) return null + state.app.enabled = false + if (state.config && state.config.app) state.config.app.enabled = false + unsubscribe() + state.tasks = [:] +} + +def resumePiston(pistonName) { + if (parent) { + return parent.resumePiston(pistonName) + } else { + def piston = getChildApps().find{ it.label == pistonName } + if (piston) { + //fire up the piston + return piston.resume() + } + return null + } +} + +def resume() { + if (!parent) return null + if (!state.app) return null + state.app.enabled = true + if (state.config && state.config.app) state.config.app.enabled = true + state.run = "app" + initializeCoREPistonStore() + if (state.app.mode != "Follow-Up") { + //follow-up pistons don't subscribe to anything + subscribeToAll(state.app) + } + processTasks() +} + +/******************************************************************************/ +/*** ***/ +/*** UTILITIES ***/ +/*** ***/ +/******************************************************************************/ + +/******************************************************************************/ +/*** DEBUG FUNCTIONS ***/ +/******************************************************************************/ + +private debug(message, shift = null, cmd = null, err = null) { + def debugging = settings.debugging + if (!debugging) { + return + } + cmd = cmd ? cmd : "debug" + if (!settings["log#$cmd"]) { + return + } + //mode is + // 0 - initialize level, level set to 1 + // 1 - start of routine, level up + // -1 - end of routine, level down + // anything else - nothing happens + def maxLevel = 4 + def level = state.debugLevel ? state.debugLevel : 0 + def levelDelta = 0 + def prefix = "║" + def pad = "░" + switch (shift) { + case 0: + level = 0 + prefix = "" + break + case 1: + level += 1 + prefix = "╚" + pad = "═" + break + case -1: + levelDelta = -(level > 0 ? 1 : 0) + pad = "═" + prefix = "╔" + break + } + + if (level > 0) { + prefix = prefix.padLeft(level, "║").padRight(maxLevel, pad) + } + + level += levelDelta + state.debugLevel = level + + if (debugging) { + prefix += " " + } else { + prefix = "" + } + + if (cmd == "info") { + log.info "$prefix$message", err + } else if (cmd == "trace") { + log.trace "$prefix$message", err + } else if (cmd == "warn") { + log.warn "$prefix$message", err + } else if (cmd == "error") { + log.error "$prefix$message", err + } else { + log.debug "$prefix$message", err + } +} + +/******************************************************************************/ +/*** DATE & TIME FUNCTIONS ***/ +/******************************************************************************/ +private getPreviousQuarterHour(unixTime = now()) { + return unixTime - unixTime.mod(900000) +} + +//adjusts the time to local timezone +private adjustTime(time = null) { + if (time instanceof String) { + //get UTC time + time = timeToday(time, location.timeZone).getTime() + } + if (time instanceof Date) { + //get unix time + time = time.getTime() + } + if (!time) { + time = now() + } + if (time) { + return new Date(time + location.timeZone.getOffset(time)) + } + return null +} + +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 convertDateToUnixTime(date) { + if (!date) { + return null + } + if (!(date instanceof Date)) { + date = new Date(date) + } + return date.time - location.timeZone.getOffset(date.time) +} + +private convertTimeToUnixTime(time) { + if (!time) { + return null + } + return time - location.timeZone.getOffset(time) +} + +private formatTime(time) { + //we accept both a Date or a settings' Time + return formatLocalTime(time, "h:mm a z") +} + +private formatHour(h) { + return (h == 0 ? "midnight" : (h < 12 ? "${h} AM" : (h == 12 ? "noon" : "${h-12} PM"))).toString() +} + +private formatDayOfMonth(dom, dow) { + if (dom) { + if (dom.contains("week")) { + //relative day of week + return dom.replace("week", dow) + } else { + //dealing with a certain day of the month + if (dom.contains("last")) { + //relative day value + return dom + } else { + //absolute day value + def day = dom.replace("on the ", "").replace("st", "").replace("nd", "").replace("rd", "").replace("th", "").toInteger() + return "on the ${formatOrdinalNumber(day)}" + } + } + } + return "[ERROR]" +} + +//return the number of occurrences of same day of week up until the date or from the end of the month if backwards, i.e. last Sunday is -1, second-last Sunday is -2 +private getWeekOfMonth(date = null, backwards = false) { + if (!date) { + date = adjustTime(now()) + } + def day = date.date + if (backwards) { + def month = date.month + def year = date.year + def lastDayOfMonth = (new Date(year, month + 1, 0)).date + return -(1 + Math.floor((lastDayOfMonth - day) / 7)) + } else { + return 1 + Math.floor((day - 1) / 7) //1 based + } +} + +//returns the number of day in a month, 1 based, or -1 based if backwards (last day of the month) +private getDayOfMonth(date = null, backwards = false) { + if (!date) { + date = adjustTime(now()) + } + def day = date.date + if (backwards) { + def month = date.month + def year = date.year + def lastDayOfMonth = (new Date(year, month + 1, 0)).date + return day - lastDayOfMonth - 1 + } else { + return day + } +} + +//for a given month, returns the Nth instance of a certain day of the week within that month. week ranges from 1 through 5 and -1 through -5 +private getDayInWeekOfMonth(date, week, dow) { + if (!date || (dow == null)) { + return null + } + def lastDayOfMonth = (new Date(date.year, date.month + 1, 0)).date + if (week > 0) { + //going forward + def firstDayOfMonthDOW = (new Date(date.year, date.month, 1)).day + //find the first matching day + def firstMatch = 1 + dow - firstDayOfMonthDOW + (dow < firstDayOfMonthDOW ? 7 : 0) + def result = firstMatch + 7 * (week - 1) + return result <= lastDayOfMonth ? result : null + } + if (week < 0) { + //going backwards + def lastDayOfMonthDOW = (new Date(date.year, date.month + 1, 0)).day + //find the first matching day + def firstMatch = lastDayOfMonth + dow - lastDayOfMonthDOW - (dow > lastDayOfMonthDOW ? 7 : 0) + def result = firstMatch + 7 * (week + 1) + return result >= 1 ? result : null + } + return null +} + +private getDayOfWeekName(date = null) { + if (!date) { + date = adjustTime(now()) + } + switch (date.day) { + case 0: return "Sunday" + case 1: return "Monday" + case 2: return "Tuesday" + case 3: return "Wednesday" + case 4: return "Thursday" + case 5: return "Friday" + case 6: return "Saturday" + } + return null +} + +private getDayOfWeekNumber(date = null) { + if (!date) { + date = adjustTime(now()) + } + if (date instanceof Date) { + return date.day + } + switch (date) { + case "Sunday": return 0 + case "Monday": return 1 + case "Tuesday": return 2 + case "Wednesday": return 3 + case "Thursday": return 4 + case "Friday": return 5 + case "Saturday": return 6 + } + return null +} + +private getMonthName(date = null) { + if (!date) { + date = adjustTime(now()) + } + def month = date.month + 1 + switch (month) { + case 1: return "January" + case 2: return "February" + case 3: return "March" + case 4: return "April" + case 5: return "May" + case 6: return "June" + case 7: return "July" + case 8: return "August" + case 9: return "September" + case 10: return "October" + case 11: return "November" + case 12: return "December" + } + return null +} + +private getMonthNumber(date = null) { + if (!date) { + date = adjustTime(now()) + } + if (date instanceof Date) { + return date.month + 1 + } + switch (date) { + case "January": return 1 + case "February": return 2 + case "March": return 3 + case "April": return 4 + case "May": return 5 + case "June": return 6 + case "July": return 7 + case "August": return 8 + case "September": return 9 + case "October": return 10 + case "November": return 11 + case "December": return 12 + } + return null +} + +private getSunrise() { + if (!(state.sunrise instanceof Date)) { + def sunTimes = getSunriseAndSunset() + state.sunrise = adjustTime(sunTimes.sunrise) + state.sunset = adjustTime(sunTimes.sunset) + } + return state.sunrise +} + +private getSunset() { + if (!(state.sunset instanceof Date)) { + def sunTimes = getSunriseAndSunset() + state.sunrise = adjustTime(sunTimes.sunrise) + state.sunset = adjustTime(sunTimes.sunset) + } + return state.sunset +} + +private addOffsetToMinutes(minutes, offset) { + if (minutes == null) { + return null + } + if (offset == null) { + return minutes + } + minutes = minutes + offset + while (minutes >= 1440) { + minutes -= 1440 + } + while (minutes < 0) { + minutes += 1440 + } + return minutes +} + +private timeComparisonOptionValues(trigger, supportVariables = true) { + return ["custom time", "midnight", "sunrise", "noon", "sunset"] + (supportVariables ? ["time of variable", "date and time of variable"] : []) + (trigger ? ["every minute", "every number of minutes", "every hour", "every number of hours"] : []) +} + +private groupOptions() { + return ["AND", "OR", "XOR", "THEN IF", "ELSE IF", "FOLLOWED BY"] +} + +private threeAxisOrientations() { + return ["rear side up", "down side up", "left side up", "front side up", "up side up", "right side up"] +} + +private threeAxisOrientationCoordinates() { + return ["rear side up", "down side up", "left side up", "front side up", "up side up", "right side up"] +} + +private getThreeAxisDistance(coord1, coord2) { + if (coord1 && coord2){ + def dX = coord1.x - coord2.x + def dY = coord1.y - coord2.y + def dZ = coord1.z - coord2.z + def s = Math.pow(dX,2) + Math.pow(dY,2) + Math.pow(dZ,2) + def dist = Math.pow(s,0.5) + return dist.toInteger() + } else return null +} + +private getThreeAxisOrientation(value, getIndex = false) { + if (value instanceof Map) { + if ((value.x != null) && (value.y != null) && (value.z != null)) { + def orientations = threeAxisOrientations() + def x = Math.abs(value.x) + def y = Math.abs(value.y) + def z = Math.abs(value.z) + def side = (x > y ? (x > z ? 0 : 2) : (y > z ? 1 : 2)) + side = side + (((side == 0) && (value.x < 0)) || ((side == 1) && (value.y < 0)) || ((side == 2) && (value.z < 0)) ? 3 : 0) + def result = getIndex ? side : orientations[side] + return result + } + } + return value +} + +private timeOptions(trigger = false) { + def result = ["1 minute"] + for (def i =2; i <= (trigger ? 360 : 360); i++) { + result.push("$i minutes") + } + return result +} + +private timeRepeatOptions() { + return ["every day", "every number of days", "every week", "every number of weeks", "every month", "every number of months", "every year", "every number of years"] +} + +private timeMinuteOfHourOptions() { + def result = [] + for (def i =0; i <= 59; i++) { + result.push("$i".padLeft(2, "0")) + } + return result +} + +private timeHourOfDayOptions() { + def result = [] + for (def i =0; i <= 23; i++) { + result.push(formatHour(i)) + } + return result +} + +private timeDayOfMonthOptions() { + def result = [] + for (def i =1; i <= 31; i++) { + result.push("on the ${formatOrdinalNumber(i)}") + } + return result + ["on the last day", "on the second-last day", "on the third-last day", "on the first week", "on the second week", "on the third week", "on the fourth week", "on the fifth week", "on the last week", "on the second-last week", "on the third-last week"] +} + +private timeDayOfMonthOptions2() { + def result = [] + for (def i =1; i <= 31; i++) { + result.push("the ${formatOrdinalNumber(i)}") + } + return result + ["the last day of the month", "the second-last day of the month", "the third-last day of the month"] +} + +private timeDayOfWeekOptions() { + return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] +} + +private timeWeekOfMonthOptions() { + return ["the first week", "the second week", "the third week", "the fourth week", "the fifth week", "the last week", "the second-last week"] +} + +private timeMonthOfYearOptions() { + return ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] +} + +private timeYearOptions() { + def result = ["even years", "odd years", "leap years"] + def year = 1900 + (new Date()).getYear() + for (def i = year; i <= 2099; i++) { + result.push("$i") + } + for (def i = 2016; i < year; i++) { + result.push("$i") + } + return result +} + +private timeToMinutes(time) { + if (!(time instanceof String)) return 0 + def value = time.replace(" minutes", "").replace(" minute", "") + if (value.isInteger()) { + return value.toInteger() + } + debug "ERROR: Time '$time' could not be parsed", null, "error" + return 0 +} + +/******************************************************************************/ +/*** NUMBER FUNCTIONS ***/ +/******************************************************************************/ + +private formatOrdinalNumber(number) { + def hm = number.mod(100) + if ((hm < 10) || (hm > 20)) { + switch (number.mod(10)) { + case 1: + return "${number}st" + case 2: + return "${number}nd" + case 3: + return "${number}rd" + } + } + return "${number}th" +} + +private formatOrdinalNumberName(number) { + def prefix = "" + if ((number >= 100) || (number <= -100)) { + return "NOT_IMPLEMENTED" + } + if (number < -1) { + return formatOrdinalNumberName(-number) + "-last" + } + if (number >= 20) { + def tens = Math.floor(number / 10) + switch (tens) { + case 2: + prefix = "twenty" + break + case 3: + prefix = "thirty" + break + case 4: + prefix = "fourty" + break + case 5: + prefix = "fifty" + break + case 6: + prefix = "sixty" + break + case 7: + prefix = "seventy" + break + case 8: + prefix = "eighty" + break + case 9: + prefix = "ninety" + break + } + if (prefix) { + if (number.mod(10) > 0) { + prefix = prefix + "-" + } + number = number - tens * 10 + } + } + switch (number) { + case -1: return "${prefix}last" + case 0: return prefix + case 1: return "${prefix}first" + case 2: return "${prefix}second" + case 3: return "${prefix}third" + case 4: return "${prefix}fourth" + case 5: return "${prefix}fifth" + case 6: return "${prefix}sixth" + case 7: return "${prefix}seventh" + case 8: return "${prefix}eighth" + case 9: return "${prefix}nineth" + case 10: return "${prefix}tenth" + case 11: return "${prefix}eleventh" + case 12: return "${prefix}twelveth" + case 13: return "${prefix}thirteenth" + case 14: return "${prefix}fourteenth" + case 15: return "${prefix}fifteenth" + case 16: return "${prefix}sixteenth" + case 17: return "${prefix}seventeenth" + case 18: return "${prefix}eighteenth" + case 19: return "${prefix}nineteenth" + } +} + +/******************************************************************************/ +/*** CONDITION FUNCTIONS ***/ +/******************************************************************************/ + +//finds and returns the condition object for the given condition Id +//fixed by @rappleg to use strict type casting - fix for Android ST 2.2.2 app +private _traverseConditions(parent, conditionId) { + Integer conditionIdInt = conditionId instanceof String ? Integer.valueOf(conditionId) : conditionId + if (parent.id == conditionIdInt) { + return parent + } + for (condition in parent.children) { + def result = _traverseConditions(condition, conditionIdInt) + if (result) { + return result + } + } + return null +} + +//returns a condition based on its ID +private getCondition(conditionId, primary = null) { + def result = null + def parent = (state.run == "config" ? state.config : state) + if (parent && (primary in [null, true]) && parent.app && parent.app.conditions) { + result =_traverseConditions(parent.app.conditions, conditionId) + } + if (!result && parent && (primary in [null, false]) && parent.app && parent.app.otherConditions) { + result = _traverseConditions(parent.app.otherConditions, conditionId) + } + return result +} + +private getConditionMasterId(conditionId) { + if (conditionId <= 0) return conditionId + def condition = getCondition(conditionId) + if (condition && (condition.parentId != null)) return getConditionMasterId(condition.parentId) + return condition.id +} + +//optimized version that returns true if any trigger is detected +private getConditionHasTriggers(condition) { + def result = 0 + if (condition) { + if (condition.children != null) { + //we're dealing with a group + for (child in condition.children) { + if (getConditionHasTriggers(child)) { + //if we detect a trigger we exit immediately + return true + } + } + } else { + return !!condition.trg + } + } + return false +} + +private getConditionTriggerCount(condition) { + def result = 0 + if (condition) { + if (condition.children != null) { + //we're dealing with a group + for (child in condition.children) { + result += getConditionTriggerCount(child) + } + } else { + if (condition.trg) { + def devices = settings["condDevices${condition.id}"] + if (devices) { + return devices.size() + } else { + return 1 + } + } + } + } + return result +} + +private withEachCondition(condition, callback, data = null, includeGroups = false) { + def result = 0 + if (condition) { + if (condition.children != null) { + //we're dealing with a group + if (includeGroups) "$callback"(condition, data) + for (child in condition.children) { + withEachCondition(child, callback, data) + } + } else { + "$callback"(condition, data) + } + } + return result +} + +private withEachTrigger(condition, callback, data = null) { + def result = 0 + if (condition) { + if (condition.children != null) { + //we're dealing with a group + for (child in condition.children) { + withEachTrigger(child, callback, data) + } + } else { + if (condition.trg) { + "$callback"(condition, data) + } + } + } + return result +} + +private getTriggerCount(app) { + return app ? getConditionTriggerCount(app.conditions) + (settings.mode in ["Latching", "And-If", "Or-If"] ? getConditionTriggerCount(app.otherConditions) : 0) : 0 +} + +private getConditionConditionCount(condition) { + def result = 0 + if (condition) { + if (condition.children != null) { + //we're dealing with a group + for (child in condition.children) { + result += getConditionConditionCount(child) + } + } else { + if (!condition.trg) { + def devices = settings["condDevices${condition.id}"] + if (devices) { + return devices.size() + } else { + return 1 + } + } + } + } + return result +} + +private getConditionCount(app) { + return app ? getConditionConditionCount(app.conditions) + (!(settings.mode in ["Basic", "Simple", "Follow-Up"]) ? getConditionConditionCount(app.otherConditions) : 0) : 0 +} + +def rebuildPiston(update = false) { + configApp() + state.config.app.conditions = createCondition(true) + state.config.app.conditions.id = 0 + state.config.app.otherConditions = createCondition(true) + state.config.app.otherConditions.id = -1 + state.config.app.actions = [] + rebuildConditions() + rebuildActions() + if (update) { + debug "Finished rebuilding piston, updating SmartApp...", null, "trace" + updated() + } +} + +private rebuildConditions() { + def conditions = settings.findAll{it.key.startsWith("condParent")}.sort{ it.key.replace("condParent", "").toInteger() } + boolean keepGoing = true + while (keepGoing) { + keepGoing = false + for(condition in conditions) { + if (condition.value != null) { + int parentId = condition.value.toInteger() + int conditionId = condition.key.replace("condParent", "").toInteger() + parentId = conditionId == parentId ? 0 : parentId + def parentCondition = getCondition(parentId) + if (parentCondition != null) { + //let's see if it's a group + def c = null + if (settings["condGrouping${conditionId}"] || conditions.find{ (it.key != "condParent${conditionId}") && it.value != null && (it.value.toInteger() == conditionId) }) { + //group + c = createCondition(parentId, true, conditionId) + } else { + //condition + c = createCondition(parentId, false, conditionId) + } + if (c) updateCondition(c) + keepGoing = true + condition.value = null + } + } + } + } + cleanUpConditions(true) +} + +private rebuildActions() { + def actions = settings.findAll{it.key.startsWith("actParent")}.sort{ it.key.replace("actParent", "").toInteger() } + for(action in actions) { + if (action.value != null) { + def parentId = action.value.toInteger() + def actionId = action.key.replace("actParent", "").toInteger() + def rs = !!settings["actRState${actionId}"] + def a = createAction(parentId, rs, actionId) + if (a) updateAction(a) + } + } + cleanUpActions() +} + +private rebuildTaps() { + def taps = settings.findAll{it.key.startsWith("tapName")} + state.taps = [] + for(tap in taps) { + def id = tap.key.replace("tapName", "") + if (id.isInteger()) { + if (tap.value != null) { + def name = tap.value + def pistons = settings["tapPistons${id}"] + if (name || pistons) { + def t = [ + i: id.toInteger(), + n: name, + p: settings["tapPistons${id}"] + ] + state.taps.push t + } + } + } + } +} + +//cleans up conditions - this may be replaced by a complete rebuild of the app object from the settings +private cleanUpConditions(deleteGroups) { + //go through each condition in the state config and delete it if no associated settings exist + if (!state.config || !state.config.app) return + _cleanUpCondition(state.config.app.conditions, deleteGroups) + _cleanUpCondition(state.config.app.otherConditions, deleteGroups) + cleanUpActions() +} + +//helper function for _cleanUpConditions +private _cleanUpCondition(condition, deleteGroups) { + def perf = now() + def result = false + + if (condition.children) { + //we cannot use a for each due to concurrent modifications + //we're using a while instead + def deleted = true + while (deleted) { + deleted = false + for (def child in condition.children) { + deleted = _cleanUpCondition(child, deleteGroups) + result = result || deleted + if (deleted) { + break + } + } + } + } + + //if non-root condition + if (condition.id > 0) { + if (condition.children == null) { + //if regular condition + if (!(condition.cap in ["Ask Alexa Macro", "EchoSistant Profile", "IFTTT", "Piston", "CoRE Piston", "Mode", "Location Mode", "Smart Home Monitor", "Date & Time", "Time", "Routine", "Variable"]) && settings["condDevices${condition.id}"] == null) { + deleteCondition(condition.id); + return true + //} else { + // updateCondition(condition) + } + } else { + //if condition group + if (deleteGroups && (condition.children.size() == 0)) { + deleteCondition(condition.id); + return true + } + } + } + updateCondition(condition) + return result +} + +private getConditionDescription(id, level = 0) { + def condition = getCondition(id) + def pre = "" + def preNot = "" + def tab = "" + def aft = "" + def conditionGroup = (condition.children != null) + switch (level) { + case 1: + pre = " ┌ (" + preNot = " ┌ NOT (" + tab = " │ " + aft = " └ )" + break; + case 2: + pre = " │ ┌ [" + preNot = " │ ┌ NOT [" + tab = " │ │ " + aft = " │ └ ]" + break; + case 3: + pre = " │ │ ┌ <" + preNot = " │ │ ┌ NOT {" + tab = " │ │ │ " + aft = " │ │ └ >" + break; + } + if (!conditionGroup) { + //single condition + if (condition.attr == "time") { + return getTimeConditionDescription(condition) + } + def capability = getCapabilityByDisplay(condition.cap) + def virtualDevice = capability ? capability.virtualDevice : null + def devices = virtualDevice ? null : settings["condDevices$id"] + if (virtualDevice || (devices && devices.size())) { + def evaluation = (virtualDevice ? "" : (devices.size() > 1 ? (condition.mode == "All" ? "Each of " : "Any of ") : "")) + def deviceList = (virtualDevice ? (capability.virtualDeviceName ? capability.virtualDeviceName : virtualDevice.name) : buildDeviceNameList(devices, "or")) + " " + def attr + //some conditions use virtual devices (mainly location) + if (virtualDevice) { + attr = getAttributeByName(capability.attribute) + } else { + attr = getAttributeByName(condition.attr) + } + def attribute = attr.name + " " + def unit = (attr && attr.unit ? attr.unit : "") + def comparison = cleanUpComparison(condition.comp) + //override comparison option type if we're dealing with a variable - take the variable's data type + def comp = getComparisonOption(condition.attr, comparison, attr.name == "variable" ? condition.dt : null, devices && devices.size() ? devices[0] : null) + def subDevices = capability.count && attr && (attr.name == capability.attribute) ? buildNameList(condition.sdev, "or") + " " : "" + def values = " [ERROR]" + def time = "" + if (comp) { + switch (comp.parameters) { + case 0: + values = "" + break + case 1: + def o1 = condition.o1 ? (condition.o1 < 0 ? " - " : " + ") + condition.o1.abs() : "" + values = " ${(condition.var1 ? "{" + condition.var1 + o1 + "}$unit" : (condition.dev1 ? "{[" + condition.dev1 + "'s ${condition.attr1 ? condition.attr1 : attr.name}]" + o1 + "}$unit" : (comparison.contains("one of") ? '[ ' + buildNameList(condition.val1, "or") + " ]" : condition.val1) + unit))}" + break + case 2: + def o1 = condition.o1 ? (condition.o1 < 0 ? " - " : " + ") + condition.o1.abs() : "" + def o2 = condition.o2 ? (condition.o2 < 0 ? " - " : " + ") + condition.o2.abs() : "" + values = " ${(condition.var1 ? "{" + condition.var1 + o1 + "}$unit" : (condition.dev1 ? "{[" + condition.dev1 + "'s ${condition.attr1 ? condition.attr1 : attr.name}]" + o1 + "}$unit" : condition.val1 + unit)) + " - " + (condition.var2 ? "{" + condition.var2 + o2 + "}$unit" : (condition.dev2 ? "{[" + condition.dev2 + "'s ${condition.attr2 ? condition.attr2 : attr.name}]" + o2 + "}$unit" : condition.val2 + unit))}" + break + } + if (comp.timed) { + time = " for [ERROR]" + if (comparison.contains("change")) { + time = " in the last " + (condition.fort ? condition.fort : "[ERROR]") + } else if (comparison.contains("stays")) { + time = " for " + (condition.fort ? condition.fort : "[ERROR]") + } else if (condition.for && condition.fort) { + time = " " + condition.for + " " + condition.fort + } + } + } + if (virtualDevice) { + attribute = "" + } + + //post formatting + switch (capability.name) { + case "askAlexaMacro": + case "echoSistantProfile": + case "piston": + case "routine": + deviceList = "${capability.display} '${values.trim()}' was " + values = "" + break + case "ifttt": + deviceList = "IFTTT event '${values.trim()}' was " + values = "" + break + case "variable": + deviceList = "Variable ${condition.var ? "{${condition.var}}" : ""} (as ${condition.dt}) " + break + } + + return tab + (condition.not ? "!" : "") + (condition.trg ? triggerPrefix() : conditionPrefix()) + evaluation + deviceList + attribute + subDevices + comparison + values + time + } + return "Sorry, incomplete rule" + } else { + //condition group + def grouping = condition.grp + def negate = condition.not + def result = (negate ? preNot : pre) + "\n" + def cnt = 1 + for (child in condition.children) { + result += getConditionDescription(child.id, level + (child.children == null ? 0 : 1)) + "\n" + (cnt < condition.children.size() ? tab + grouping + "\n" : "") + cnt++ + } + result += aft + return result + } +} + +private getTimeConditionDescription(condition) { + if (condition.attr != "time") { + return "[ERROR]" + } + def attr = getAttributeByName(condition.attr) + def comparison = cleanUpComparison(condition.comp) + def comp = getComparisonOption(condition.attr, comparison) + def result = (condition.trg ? triggerPrefix() + "Trigger " : conditionPrefix() + "Time ") + comparison + def val1 = condition.val1 ? condition.val1 : "" + def val2 = condition.val2 ? condition.val2 : "" + if (attr && comp) { + //is the condition a trigger? + def trigger = (comp.trigger == comparison) + def repeating = trigger + for (def i = 1; i <= comp.parameters; i++) { + def val = "${i == 1 ? val1 : val2}" + def recurring = false + def preciseTime = false + + if (val.contains("custom")) { + //custom time + val = formatTime(i == 1 ? condition.t1 : condition.t2) + preciseTime = true + //def hour = condition.t1.getHour() + //def minute = condition.t2.getMinute() + } else if (val.contains("time of variable")) { + //custom time + val = "$val {${condition.var1}}" + repeating = !val.contains("date and time") + //def hour = condition.t1.getHour() + //def minute = condition.t2.getMinute() + } else if (val.contains("every")) { + recurring = true + repeating = false + //take out the "happens at" and replace it with "happens "... every [something] + result = result.replace("happens at", "happens") + if (val.contains("number")) { + //multiple minutes or hours + val = "every ${condition.e} ${val.contains("minute") ? "minutes" : "hours"}" + } else { + //one minute or one hour + //no change to val + } + } else { + //simple, no change to val + } + + if (comparison.contains("around")) { + def range = i == 1 ? condition.o1 : condition.o2 + val += " ± $range minute${range > 1 ? "s" : ""}" + } else { + if ((!preciseTime) && (!recurring)) { + def offset = i == 1 ? condition.o1 : condition.o2 + if (offset == null) { + offset = 0 + } + def after = offset >= 0 + offset = offset.abs() + if (offset != 0) { + result = result.replace("happens at", "happens") + val = "${offset} minute${offset > 1 ? "s" : ""} ${after ? "after" : "before"} $val" + } + } + } + + if (i == 1) { + val1 = val + } else { + val2 = val + } + + } + + switch (comp.parameters) { + case 1: + result += " $val1" + break + case 2: + result += " $val1 and $val2" + break + } + + //repeat options + if (repeating) { + def repeat = condition.r + if (repeat) { + if (repeat.contains("day")) { + //every day + //every N days + if (repeat.contains("number")) { + result += ", ${repeat.replace("number of ", condition.re > 2 ? "${condition.re} " : (condition.re == 2 ? "other " : "")).replace("days", condition.re > 2 ? "days" : "day")}" + } else { + result += ", $repeat" + } + } + if (repeat.contains("week")) { + //every day + //every N days + def dow = condition.rdw ? condition.rdw : "[ERROR]" + if (repeat.contains("number")) { + result += ", ${repeat.replace("number of ", condition.re > 2 ? "${condition.re} " : (condition.re == 2 ? "other " : "")).replace("weeks", condition.re > 2 ? "weeks" : "week").replace("week", "${dow}")}" + } else { + result += ", every $dow" + } + } + if (repeat.contains("month")) { + //every Nth of the month + //every Nth of every N months + //every first/second/last [dayofweek] of the month + //every first/second/last [dayofweek] of every N months + if (repeat.contains("number")) { + result += ", " + formatDayOfMonth(condition.rd, condition.rdw) + " of ${repeat.replace("number of ", condition.re > 2 ? "${condition.re} " : (condition.re == 2 ? "other " : "")).replace("months", condition.re > 2 ? "months" : "month")}" + } else { + result += ", " + formatDayOfMonth(condition.rd, condition.rdw).replace("the", "every") + } + } + if (repeat.contains("year")) { + //oh boy, we got years too! + def month = condition.rm ? condition.rm : "[ERROR]" + if (repeat.contains("number")) { + result += ", " + formatDayOfMonth(condition.rd, condition.rdw) + " of ${month} of ${repeat.replace("number of ", condition.re > 2 ? "${condition.re} " : (condition.re == 2 ? "other " : "")).replace("years", condition.re > 2 ? "years" : "year")}" + } else { + result += ", " + formatDayOfMonth(condition.rd, condition.rdw).replace("the", "every") + " of ${month}" + } + } + } else { + result += " [REPEAT INCOMPLETE]" + } + } + + //filters + if (condition.fmh || condition.fhd || condition.fdw || condition.fdm || condition.fwm || condition.fmy || condition.fy) { + //we have some filters + /* + condition.fmh = settings["condMOH${condition.id}"] + condition.fhd = settings["condHOD${condition.id}"] + condition.fdw = settings["condDOW${condition.id}"] + condition.fdm = settings["condDOM${condition.id}"] + condition.fmy = settings["condMOY${condition.id}"] + condition.fy = settings["condY${condition.id}"] + */ + result += ", but only if" + def i = 0 + if (condition.fmh) { + result += "${i > 0 ? ", and" : ""} the minute is ${buildNameList(condition.fmh, "or")}" + i++ + } + if (condition.fhd) { + result += "${i > 0 ? ", and" : ""} the hour is ${buildNameList(condition.fhd, "or")}" + i++ + } + if (condition.fdw) { + result += "${i > 0 ? ", and" : ""} the day of the week is ${buildNameList(condition.fdw, "or")}" + i++ + } + if (condition.fwm) { + result += "${i > 0 ? ", and" : ""} the week is ${buildNameList(condition.fwm, "or")} of the month" + i++ + } + if (condition.fdm) { + result += "${i > 0 ? ", and" : ""} the day is ${buildNameList(condition.fdm, "or")} of the month" + i++ + } + if (condition.fmy) { + result += "${i > 0 ? ", and" : ""} the month is ${buildNameList(condition.fmy, "or")}" + i++ + } + if (condition.fy) { + def odd = "odd years" in condition.fy + def even = "even years" in condition.fy + def leap = "leap years" in condition.fy + def list = [] + //if we have both odd and even selected, that would match all years, so get out + if (!(even && odd)) { + if (odd || even || leap) { + if (odd) list.push("odd") + if (even) list.push("even") + if (leap) list.push("leap") + } + } + for(year in condition.fy) { + if (!year.contains("year")) { + list.push(year) + } + } + if (list.size()) { + result += "${i > 0 ? ", and" : ""} the year is ${buildNameList(list, "or")}" + } + } + + } + } + return result +} + +/******************************************************************************/ +/*** ACTION FUNCTIONS ***/ +/******************************************************************************/ + +private getAction(actionId) { + def parent = (state.run == "config" ? state.config : state) + for(action in parent.app.actions) { + if (action.id == actionId) { + return action + } + } + return null +} + +private listActions(conditionId, onState = null) { + def result = [] + def parent = (state.run == "config" ? state.config : state) + + //all actions for main groups + if (conditionId <= 0) onState = null + + for(action in parent.app.actions) { + if ((action.pid == conditionId) && ((onState == null) || ((action.rs == null ? true : action.rs) == onState))) { + result.push(action) + } + } + return result +} + +private getActionTask(action, taskId) { + if (!action) return null + if (!(taskId instanceof Integer)) return null + for (task in action.t) { + if (task.i == taskId) { + return task + } + } + return null +} + +/******************************************************************************/ +/*** OTHER FUNCTIONS ***/ +/******************************************************************************/ + +private sanitizeVariableName(name) { + name = name ? "$name".trim().replace(" ", "_") : null +} + +private sanitizeCommandName(name) { + name = name ? "$name".trim().replace(" ", "_").replace("(", "_").replace(")", "_").replace("&", "_").replace("#", "_") : null +} + +private importVariables(collection, prefix) { + for(item in collection) { + if (item.value instanceof Map) { + importVariables(item.value, "${prefix}${item.key}.") + } else { + setVariable(prefix + item.key, item.value) + } + } +} + +private cleanUpMap(map) { + def washer = [] + //find dirty laundry + for (item in map) { + if (item.value == null) washer.push(item.key) + } + //clean it + for (item in washer) { + map.remove(item) + } + washer = null + return map +} + +private cleanUpAttribute(attribute) { + if (attribute) { + return attribute.replace(customAttributePrefix(), "") + } + return null +} + +private cleanUpCommand(command) { + if (command) { + return command.replace(customCommandPrefix(), "").replace(virtualCommandPrefix(), "").replace(customCommandSuffix(), "") + } + return null +} + +private cleanUpComparison(comparison) { + if (comparison) { + return comparison.replace(triggerPrefix(), "").replace(conditionPrefix(), "") + } + return null +} + +private buildDeviceNameList(devices, suffix) { + def cnt = 1 + def result = "" + for (device in devices) { + def label = getDeviceLabel(device) + result += "$label" + (cnt < devices.size() ? (cnt == devices.size() - 1 ? " $suffix " : ", ") : "") + cnt++ + } + return result; +} + +private buildNameList(list, suffix) { + def cnt = 1 + def result = "" + for (item in list) { + result += item + (cnt < list.size() ? (cnt == list.size() - 1 ? "${list.size() > 2 ? "," : ""} $suffix " : ", ") : "") + cnt++ + } + return result; +} + +private getDeviceLabel(device) { + return device instanceof String ? device : (device ? ( device.label ? device.label : (device.name ? device.name : "$device")) : "Unknown device") +} + +private getAlarmSystemStatus(value) { + switch (value ? value : location.currentState("alarmSystemStatus")?.value) { + case "off": + return getAlarmSystemStatusOptions()[0] + case "stay": + return getAlarmSystemStatusOptions()[1] + case "away": + return getAlarmSystemStatusOptions()[2] + } + return null +} + +private setAlarmSystemStatus(status) { + def value = null + def options = getAlarmSystemStatusOptions() + switch (status) { + case options[0]: + value = "off" + break + case options[1]: + value = "stay" + break + case options[2]: + value = "away" + break + } + if (value && (value != location.currentState("alarmSystemStatus")?.value)) { + sendLocationEvent(name: 'alarmSystemStatus', value: value) + return true + } + debug "WARNING: Could not set SHM status to '$status' because that status does not exist.", null, "warn" + return false +} + +private formatMessage(message, params = null) { + if (message == null) { + return message + } + message = "$message" + def variables = message.findAll(/\{([^\{\}]*)?\}*/) + def varMap = [:] + for (variable in variables) { + if (!(variable in varMap)) { + def var = variable.replace("{", "").replace("}", "") + def idx = var.isInteger() ? var.toInteger() : null + def value = "" + if (params && (idx >= 0) && (idx < params.size())) { + value = "${params[idx].d != null ? params[idx].d : "(not set)"}" + } else { + value = getVariable(var, true) + } + varMap[variable] = value + } + } + for(var in varMap) { + if (var.value != null) { + message = message.replace(var.key, "${var.value}") + } + } + return message.toString().replace("|[", "{").replace("]|", "}") +} + +/******************************************************************************/ +/*** DATABASE FUNCTIONS ***/ +/******************************************************************************/ +//returns a list of all available capabilities +private listCapabilities(requireAttributes, requireCommands) { + def result = [] + for (capability in capabilities()) { + if ((requireAttributes && capability.attribute) || (requireCommands && capability.commands) || !(requireAttributes || requireCommands)) { + result.push(capability.display) + } + } + return result +} + +//returns a list of all available attributes +private listAttributes() { + def result = [] + for (attribute in attributes()) { + result.push(attribute.name) + } + return result.sort() +} + +//returns a list of possible comparison options for a selected attribute +private listComparisonOptions(attributeName, allowTriggers, overrideAttributeType = null, device = null) { + def conditions = [] + def triggers = [] + def attribute = getAttributeByName(attributeName, device) + def allowTimedComparisons = !(attributeName in ["askAlexaMacro", "echoSistantProfile", "mode", "ifttt", "alarmSystemStatus", "piston", "routineExecuted", "variable"]) + if (attribute) { + def optionCount = attribute.options ? attribute.options.size() : 0 + def attributeType = overrideAttributeType ? overrideAttributeType : attribute.type + for (comparison in comparisons()) { + if (comparison.type == attributeType) { + for (option in comparison.options) { + if (option.condition && (!option.minOptions || option.minOptions <= optionCount) && (allowTimedComparisons || !option.timed)) { + conditions.push(conditionPrefix() + option.condition) + } + if (allowTriggers && option.trigger && (!option.minOptions || option.minOptions <= optionCount) && (allowTimedComparisons || !option.timed)) { + triggers.push(triggerPrefix() + option.trigger) + } + } + } + } + } + return conditions.sort() + triggers.sort() +} + +//returns the comparison option object for the given attribute and selected comparison +private getComparisonOption(attributeName, comparisonOption, overrideAttributeType = null, device = null) { + def attribute = getAttributeByName(attributeName, device) + if (attribute && comparisonOption) { + def attributeType = overrideAttributeType ? overrideAttributeType : (attributeName == "variable" ? "variable" : attribute.type) + for (comparison in comparisons()) { + if (comparison.type == attributeType) { + for (option in comparison.options) { + if (option.condition == comparisonOption) { + return option + } + if (option.trigger == comparisonOption) { + return option + } + } + } + } + } + return null +} + +//returns true if the comparisonOption selected for the given attribute is a trigger-type condition +private isComparisonOptionTrigger(attributeName, comparisonOption, overrideAttributeType = null, device = null) { + def attribute = getAttributeByName(attributeName, device) + if (attribute) { + def attributeType = overrideAttributeType ? overrideAttributeType : (attributeName == "variable" ? "variable" : attribute.type) + for (comparison in comparisons()) { + if (comparison.type == attributeType) { + for (option in comparison.options) { + if (option.condition == comparisonOption) { + return false + } + if (option.trigger == comparisonOption) { + return true + } + } + } + } + } + return false +} + +//returns the list of attributes that exist for all devices in the provided list +private listCommonDeviceAttributes(devices) { + def list = [:] + def customList = [:] + //build the list of standard attributes + for (attribute in attributes()) { + if (attribute.name.contains("*")) { + for (def i = 1; i <= 32; i++) { + list[attribute.name.replace("*", "$i")] = 0 + } + } else { + list[attribute.name] = 0 + } + } + + for (device in devices) { + if (device.hasCommand("describeAttributes")) { + def payload = [attributes: null] + device.describeAttributes(payload) + if ((payload.attributes instanceof List) && payload.attributes.size()) { + if (!state.customAttributes) state.customAttributes = [:] + //save the custom attributes + for( def customAttribute in payload.attributes) { + if (customAttribute.name && customAttribute.type) { + state.customAttributes[customAttribute.name] = customAttribute + } + } + } + } + } + + //add known custom attributes to the standard list + if (state.customAttributes) { + for(def customAttribute in state.customAttributes) { + list[customAttribute.key] = 0 + } + } + + //get supported attributes + for (device in devices) { + def attrs = device.supportedAttributes + for (attr in attrs) { + if (list.containsKey(attr.name)) { + //if attribute exists in standard list, increment its usage count + list[attr.name] = list[attr.name] + 1 + if (attr.name == "threeAxis") { + list["orientation"] = list["orientation"] + 1 + list["axisX"] = list["axisX"] + 1 + list["axisY"] = list["axisY"] + 1 + list["axisZ"] = list["axisZ"] + 1 + } + } else { + //otherwise increment the usage count in the custom list + customList[attr.name] = customList[attr.name] ? customList[attr.name] + 1 : 1 + } + } + } + def result = [] + //get all common attributes from the standard list + for (item in list) { + //ZWave Lock reports lock twice - others may do the same, so let's allow multiple instances + if (item.value >= devices.size()) { + result.push(item.key) + } + } + //get all common attributes from the custom list + for (item in customList) { + //ZWave Lock reports lock twice - others may do the same, so let's allow multiple instances + if (item.value >= devices.size()) { + result.push(customAttributePrefix() + item.key) + } + } + //return the sorted list + return result.sort() +} + +private listCommonDeviceSubDevices(devices, countAttributes, prefix = "") { + def result = [] + def subDeviceCount = null + def hasMainSubDevice = false + //get supported attributes + if (countAttributes) { + countAttributes = "$countAttributes".tokenize(",") + } else { + countAttributes = [] + } + for (device in devices) { + def cnt = device.name.toLowerCase().contains("lock") ? 32 : 1 + switch (device.name) { + case "Aeon Minimote": + case "Aeon Key Fob": + case "Simulated Minimote": + cnt = 4 + break + } + if (countAttributes.size()) { + for(countAttribute in countAttributes) { + def c = cast(device.currentValue(countAttribute), "number") + if (c) { + cnt = c + break + } + } + } + if (cnt instanceof String) { + cnt = cnt.isInteger() ? cnt.toInteger() : 0 + } + if (cnt instanceof Integer) { + subDeviceCount = (subDeviceCount == null) || (cnt < subDeviceCount) ? (int) cnt : subDeviceCount + } + } + if (subDeviceCount >= 2) { + if (hasMainSubDevice) { + result.push "Main ${prefix.toLowerCase()}" + } + for(def i = 1; i <= subDeviceCount; i++) { + result.push "$prefix #$i".trim() + } + } + //return the sorted list + return result +} + +private listCommonDeviceCommands(devices, capabilities) { + def list = [:] + def customList = [:] + //build the list of standard attributes + for (command in commands()) { + list[command.name] = 0 + } + //get supported attributes + for (device in devices) { + def cmds = device.supportedCommands + for (cmd in cmds) { + def found = false + for (capability in capabilities) { + def name = capability + "." + cmd.name + if (list.containsKey(name)) { + //if attribute exists in standard list, increment its usage count + list[name] = list[name] + 1 + found = true + } else { + name = name.replaceAll("[\\d]", "") + "*" + if (list.containsKey(name)) { + list[name] = list[name] + 1 + found = true + } + } + } + if (!found && list.containsKey(cmd.name)) { + //if attribute exists in standard list, increment its usage count + list[cmd.name] = list[cmd.name] + 1 + found = true + } + if (!found) { + //otherwise increment the usage count in the custom list + customList[cmd.name] = customList[cmd.name] ? customList[cmd.name] + 1 : 1 + } + } + } + + def result = [] + //get all common attributes from the standard list + for (item in list) { + //ZWave Lock reports lock twice - others may do the same, so let's allow multiple instances + if (item.value >= devices.size()) { + def command = getCommandByName(item.key) + if (command && command.display) { + result.push(command.display) + } + } + } + //get all common attributes from the custom list + for (item in customList) { + //ZWave Lock reports lock twice - others may do the same, so let's allow multiple instances + if (item.value >= devices.size()) { + result.push(customCommandPrefix() + item.key + customCommandSuffix()) + } + } + //return the sorted list + return result.sort() +} + +private getCapabilityByName(name) { + for (capability in capabilities()) { + if (capability.name == name) { + return capability + } + } + return null +} + +private getCapabilityByDisplay(display) { + for (capability in capabilities()) { + if (capability.display == display) { + return capability + } + } + return null +} + +private getAttributeByName(name, device = null) { + def name2 = name instanceof String ? name.replaceAll("[\\d]", "").trim() + "*" : null + def attribute = attributes().find{ (it.name == name) || (name2 && (it.name == name2)) } + if (attribute) return attribute + if (state.customAttributes) { + def item = state.customAttributes.find{ it.key == name } + if (item) return item.value + } + //give up, return whatever... + if (device) { + def attr = device.supportedAttributes.find{ it.name == name } + if (attr) { + return [ name: attr.name, type: attr.dataType.toLowerCase(), range: null, unit: null, options: attr.values ] + } + } + return [ name: name, type: "text", range: null, unit: null, options: null] +} + +//returns all available command categories +private listCommandCategories() { + def categories = [] + for(def command in commands()) { + if (command.category && command.group && !(command.category in categories)) { + categories.push(command.category) + } + } + return categories +} + +//returns all available commands in a category +private listCategoryCommands(category) { + def result = [] + for(def command in commands()) { + if ((command.category == category) && command.group && !(command.name in result)) { + result.push(command) + } + } + return result +} + +//gets a category and command and returns the user friendly display name +private getCommand(category, name) { + for(def command in commands()) { + if ((command.category == category) && (command.name == name)) { + return command + } + } + return null +} + +private getCommandByName(name) { + for(def command in commands()) { + if (command.name == name) { + return command + } + } + return null +} + +private getVirtualCommandByName(name) { + def cmds = virtualCommands() + for(def command in cmds) { + if (command.name == name) { + return command + } + } + return null +} + +private getCommandByDisplay(display) { + def cmds = commands() + for(def command in cmds) { + if (command.display == display) { + return command + } + } + return null +} + +private getVirtualCommandByDisplay(display) { + def cmds = virtualCommands() + for(def command in cmds) { + if (command.display == display) { + return command + } + } + return null +} + +//gets a category and command and returns the user friendly display name +private getCommandGroupName(category, name) { + def command = getCommand(category, name) + return getCommandGroupName(command) +} + +private getCommandGroupName(command) { + if (!command) { + return null + } + if (!command.group) { + return null + } + if (command.group.contains("[devices]")) { + def list = [] + for (capability in listCommandCapabilities(command)) { + if ((capability.devices) && !(capability.devices in list)){ + list.push(capability.devices) + } + } + return command.group.replace("[devices]", buildNameList(list, "or")) + } else { + return command.group + } +} + +//gets a category and command and returns the user friendly display name +private listCommandCapabilities(command) { + //first off, find all commands that are capability-custom (i.e. name is of format .) + //we need to exclude these capabilities + //if our name is of form . + if (command.name.contains(".")) { + //easy, we only have one capability + def cap = getCapabilityByName(command.name.tokenize(".")[0]) + if (!cap) { + return [] + } + return [cap] + } + def excludeList = [] + for(def c in commands()) { + if (c.name.endsWith(".${command.name}")) { + //get the capability and add it to an exclude list + excludeList.push(c.name.tokenize(".")[0]) + } + } + //now get the capability names + def result = [] + for(def c in capabilities()) { + if (!(c.name in excludeList) && c.commands && (command.name in c.commands) && !(c in result)) { + result.push(c) + } + } + return result +} + +private parseCommandParameter(parameter) { + if (!parameter) { + return null + } + + def required = !(parameter && parameter.startsWith("?")) + if (!required) { + parameter = parameter.substring(1) + } + + def last = (parameter && parameter.startsWith("*")) + if (last) { + parameter = parameter.substring(1) + } + + //split by : + def tokens = parameter.tokenize(":") + if (tokens.size() < 2) { + return [title: tokens[0], type: "text", required: required, last: last] + } + def title = "" + def dataType = "" + if (tokens.size() == 2) { + title = tokens[0] + dataType = tokens[1] + } else { + //title contains at least one :, so we rebuild it + for(def i=0; i < tokens.size() - 1; i++) { + title += (title ? ":" : "") + tokens[i] + } + dataType = tokens[tokens.size() - 1] + } + + if (dataType in ["askAlexaMacro", "echoSistantProfile", "ifttt", "attribute", "attributes", "contact", "contacts", "variable", "variables", "lifxScenes", "stateVariable", "stateVariables", "routine", "piston", "aggregation", "dataType"]) { + //special case handled internally + return [title: title, type: dataType, required: required, last: last] + } + tokens = dataType.tokenize("[]") + if (tokens.size()) { + dataType = tokens[0] + switch (tokens.size()) { + case 1: + switch (dataType) { + case "string": + case "text": + return [title: title, type: "text", required: required, last: last] + case "bool": + case "email": + case "time": + case "phone": + case "contact": + case "number": + case "decimal": + case "var": + return [title: title, type: dataType, required: required, last: last] + case "color": + return [title: title, type: "enum", options: colorOptions(), required: required, last: last] + } + break + case 2: + switch (dataType) { + case "string": + case "text": + return [title: title, type: "text", required: required, last: last] + case "bool": + case "email": + case "time": + case "phone": + case "contact": + case "number": + case "decimal": + return [title: title, type: dataType, range: tokens[1], required: required, last: last] + case "enum": + return [title: title, type: dataType, options: tokens[1].tokenize(","), required: required, last: last] + case "enums": + return [title: title, type: "enum", options: tokens[1].tokenize(","), required: required, last: last, multiple: true] + } + break + } + } + + //check to see if dataType is an attribute, we use the attribute declaration then + def attr = getAttributeByName(dataType) + if (attr) { + return [title: title + (attr.unit ? " (${attr.unit})" : ""), type: attr.type, range: attr.range, options: attr.options, required: required, last: last] + } + + //give up + return null +} + +/******************************************************************************/ +/*** DATABASE ***/ +/******************************************************************************/ + +private capabilities() { + return [ + [ name: "accelerationSensor", display: "Acceleration Sensor", attribute: "acceleration", multiple: true, devices: "acceleration sensors", ], + [ name: "alarm", display: "Alarm", attribute: "alarm", commands: ["off", "strobe", "siren", "both"], multiple: true, devices: "sirens", ], + [ name: "askAlexaMacro", display: "Ask Alexa Macro", attribute: "askAlexaMacro", commands: [], multiple: true, virtualDevice: location, virtualDeviceName: "Ask Alexa Macro" ], + [ name: "audioNotification", display: "Audio Notification", commands: ["playText", "playSoundAndTrack", "playText", "playTextAndResume", "playTextAndRestore", "playTrack", "playTrackAndResume", "playTrackAndRestore", "playTrackAtVolume"], multiple: true, devices: "audio notification devices", ], + [ name: "doorControl", display: "Automatic Door", attribute: "door", commands: ["open", "close"], multiple: true, devices: "doors", ], + [ name: "garageDoorControl", display: "Automatic Garage Door", attribute: "door", commands: ["open", "close"], multiple: true, devices: "garage doors", ], + [ name: "battery", display: "Battery", attribute: "battery", multiple: true, devices: "battery powered devices", ], + [ name: "beacon", display: "Beacon", attribute: "presence", multiple: true, devices: "beacons", ], + [ name: "switch", display: "Bulb", attribute: "switch", commands: ["on", "off"], multiple: true, devices: "lights", ], + [ name: "button", display: "Button", attribute: "button", multiple: true, devices: "buttons", count: "numberOfButtons,numButtons", data: "buttonNumber", momentary: true], + [ name: "imageCapture", display: "Camera", attribute: "image", commands: ["take"], multiple: true, devices: "cameras", ], + [ name: "carbonDioxideMeasurement", display: "Carbon Dioxide Measurement", attribute: "carbonDioxide", multiple: true, devices: "carbon dioxide sensors", ], + [ name: "carbonMonoxideDetector", display: "Carbon Monoxide Detector", attribute: "carbonMonoxide", multiple: true, devices: "carbon monoxide detectors", ], + [ name: "colorControl", display: "Color Control", attribute: "color", commands: ["setColor", "setHue", "setSaturation"], multiple: true, devices: "RGB/W lights" ], + [ name: "colorTemperature", display: "Color Temperature", attribute: "colorTemperature", commands: ["setColorTemperature"], multiple: true, devices: "RGB/W lights", ], + [ name: "configure", display: "Configure", commands: ["configure"], multiple: true, devices: "configurable devices", ], + [ name: "consumable", display: "Consumable", attribute: "consumable", commands: ["setConsumableStatus"], multiple: true, devices: "consumables", ], + [ name: "contactSensor", display: "Contact Sensor", attribute: "contact", multiple: true, devices: "contact sensors", ], + [ name: "piston", display: "CoRE Piston", attribute: "piston", commands: ["executePiston"], multiple: true, virtualDevice: location, virtualDeviceName: "Piston" ], + [ name: "dateAndTime", display: "Date & Time", attribute: "time", commands: null, /* wish we could control time */ multiple: true, , virtualDevice: [id: "time", name: "time"], virtualDeviceName: "Date & Time" ], + [ name: "switchLevel", display: "Dimmable Light", attribute: "level", commands: ["setLevel"], multiple: true, devices: "dimmable lights", ], + [ name: "switchLevel", display: "Dimmer", attribute: "level", commands: ["setLevel"], multiple: true, devices: "dimmable lights", ], + [ name: "echoSistantProfile", display: "EchoSistant Profile", attribute: "echoSistantProfile", commands: [], multiple: true, virtualDevice: location, virtualDeviceName: "EchoSistant Profile" ], + [ name: "energyMeter", display: "Energy Meter", attribute: "energy", multiple: true, devices: "energy meters"], + [ name: "ifttt", display: "IFTTT", attribute: "ifttt", commands: [], multiple: false, virtualDevice: location, virtualDeviceName: "IFTTT" ], + [ name: "illuminanceMeasurement", display: "Illuminance Measurement", attribute: "illuminance", multiple: true, devices: "illuminance sensors", ], + [ name: "imageCapture", display: "Image Capture", attribute: "image", commands: ["take"], multiple: true, devices: "cameras"], + [ name: "indicator", display: "Indicator", attribute: "indicatorStatus", multiple: true, devices: "indicator devices"], + [ name: "waterSensor", display: "Leak Sensor", attribute: "water", multiple: true, devices: "leak sensors", ], + [ name: "switch", display: "Light Bulb", attribute: "switch", commands: ["on", "off"], multiple: true, devices: "lights", ], + [ name: "locationMode", display: "Location Mode", attribute: "mode", commands: ["setMode"], multiple: false, devices: "location", virtualDevice: location ], + [ name: "lock", display: "Lock", attribute: "lock", commands: ["lock", "unlock"], count: "numberOfCodes,numCodes", data: "usedCode", subDisplay: "By user code", multiple: true, devices: "electronic locks", ], + [ name: "mediaController", display: "Media Controller", attribute: "currentActivity", commands: ["startActivity", "getAllActivities", "getCurrentActivity"], multiple: true, devices: "media controllers"], + [ name: "locationMode", display: "Mode", attribute: "mode", commands: ["setMode"], multiple: false, devices: "location", virtualDevice: location ], + [ name: "momentary", display: "Momentary", commands: ["push"], multiple: true, devices: "momentary switches"], + [ name: "motionSensor", display: "Motion Sensor", attribute: "motion", multiple: true, devices: "motion sensors", ], + [ name: "musicPlayer", display: "Music Player", attribute: "status", commands: ["play", "pause", "stop", "nextTrack", "playTrack", "setLevel", "playText", "mute", "previousTrack", "unmute", "setTrack", "resumeTrack", "restoreTrack"], multiple: true, devices: "music players", ], + [ name: "notification", display: "Notification", commands: ["deviceNotification"], multiple: true, devices: "notification devices", ], + [ name: "pHMeasurement", display: "pH Measurement", attribute: "pH", multiple: true, devices: "pH sensors", ], + [ name: "occupancy", display: "Occupancy", attribute: "occupancy", multiple: true, devices: "occupancy detectors", ], + [ name: "piston", display: "Piston", attribute: "piston", commands: ["executePiston"], multiple: true, virtualDevice: location, virtualDeviceName: "Piston" ], + [ name: "polling", display: "Polling", commands: ["poll"], multiple: true, devices: "pollable devices", ], + [ name: "powerMeter", display: "Power Meter", attribute: "power", multiple: true, devices: "power meters", ], + [ name: "power", display: "Power", attribute: "powerSource", multiple: true, devices: "powered devices", ], + [ name: "presenceSensor", display: "Presence Sensor", attribute: "presence", multiple: true, devices: "presence sensors", ], + [ name: "refresh", display: "Refresh", commands: ["refresh"], multiple: true, devices: "refreshable devices", ], + [ name: "relativeHumidityMeasurement", display: "Relative Humidity Measurement", attribute: "humidity", multiple: true, devices: "humidity sensors", ], + [ name: "relaySwitch", display: "Relay Switch", attribute: "switch", commands: ["on", "off"], multiple: true, devices: "relays", ], + [ name: "routine", display: "Routine", attribute: "routineExecuted", commands: ["executeRoutine"], multiple: true, virtualDevice: location, virtualDeviceName: "Routine" ], + [ name: "sensor", display: "Sensor", attribute: "sensor", multiple: true, devices: "sensors", ], + [ name: "shockSensor", display: "Shock Sensor", attribute: "shock", multiple: true, devices: "shock sensors", ], + [ name: "signalStrength", display: "Signal Strength", attribute: "lqi", multiple: true, devices: "wireless devices", ], + [ name: "alarm", display: "Siren", attribute: "alarm", commands: ["off", "strobe", "siren", "both"], multiple: true, devices: "sirens", ], + [ name: "sleepSensor", display: "Sleep Sensor", attribute: "sleeping", multiple: true, devices: "sleep sensors", ], + [ name: "smartHomeMonitor", display: "Smart Home Monitor", attribute: "alarmSystemStatus", commands: ["setAlarmSystemStatus"], multiple: true, , virtualDevice: location, virtualDeviceName: "Smart Home Monitor" ], + [ name: "smokeDetector", display: "Smoke Detector", attribute: "smoke", multiple: true, devices: "smoke detectors", ], + [ name: "soundSensor", display: "Sound Sensor", attribute: "sound", multiple: true, devices: "sound sensors", ], + [ name: "speechSynthesis", display: "Speech Synthesis", commands: ["speak"], multiple: true, devices: "speech synthesizers", ], + [ name: "switch", display: "Switch", attribute: "switch", commands: ["on", "off"], multiple: true, devices: "switches", ], + [ name: "switchLevel", display: "Switch Level", attribute: "level", commands: ["setLevel"], multiple: true, devices: "dimmers" ], + [ name: "soundPressureLevel", display: "Sound Pressure Level", attribute: "soundPressureLevel", multiple: true, devices: "sound pressure sensors", ], + [ name: "consumable", display: "Stock Management", attribute: "consumable", multiple: true, devices: "consumables", ], + [ name: "tamperAlert", display: "Tamper Alert", attribute: "tamper", multiple: true, devices: "tamper sensors", ], + [ name: "temperatureMeasurement", display: "Temperature Measurement", attribute: "temperature", multiple: true, devices: "temperature sensors", ], + [ name: "thermostat", display: "Thermostat", attribute: "temperature", commands: ["setHeatingSetpoint", "setCoolingSetpoint", "off", "heat", "emergencyHeat", "cool", "setThermostatMode", "fanOn", "fanAuto", "fanCirculate", "setThermostatFanMode", "auto"], multiple: true, devices: "thermostats", showAttribute: true], + [ name: "thermostatCoolingSetpoint", display: "Thermostat Cooling Setpoint", attribute: "coolingSetpoint", commands: ["setCoolingSetpoint"], multiple: true, ], + [ name: "thermostatFanMode", display: "Thermostat Fan Mode", attribute: "thermostatFanMode", commands: ["fanOn", "fanAuto", "fanCirculate", "setThermostatFanMode"], multiple: true, devices: "fans", ], + [ name: "thermostatHeatingSetpoint", display: "Thermostat Heating Setpoint", attribute: "heatingSetpoint", commands: ["setHeatingSetpoint"], multiple: true, ], + [ name: "thermostatMode", display: "Thermostat Mode", attribute: "thermostatMode", commands: ["off", "heat", "emergencyHeat", "cool", "auto", "setThermostatMode"], multiple: true, ], + [ name: "thermostatOperatingState", display: "Thermostat Operating State", attribute: "thermostatOperatingState", multiple: true, ], + [ name: "thermostatSetpoint", display: "Thermostat Setpoint", attribute: "thermostatSetpoint", multiple: true, ], + [ name: "threeAxis", display: "Three Axis Sensor", attribute: "orientation", multiple: true, devices: "three axis sensors", ], + [ name: "dateAndTime", display: "Time", attribute: "time", multiple: true, , virtualDevice: [id: "time", name: "time"], virtualDeviceName: "Date & Time" ], + [ name: "timedSession", display: "Timed Session", attribute: "sessionStatus", commands: ["setTimeRemaining", "start", "stop", "pause", "cancel"], multiple: true, devices: "timed sessions"], + [ name: "tone", display: "Tone Generator", commands: ["beep"], multiple: true, devices: "tone generators", ], + [ name: "touchSensor", display: "Touch Sensor", attribute: "touch", multiple: true, ], + [ name: "valve", display: "Valve", attribute: "contact", commands: ["open", "close"], multiple: true, devices: "valves", ], + [ name: "variable", display: "Variable", attribute: "variable", commands: ["setVariable"], multiple: true, virtualDevice: location, virtualDeviceName: "Variable" ], + [ name: "voltageMeasurement", display: "Voltage Measurement", attribute: "voltage", multiple: true, devices: "volt meters", ], + [ name: "waterSensor", display: "Water Sensor", attribute: "water", multiple: true, devices: "leak sensors", ], + [ name: "windowShade", display: "Window Shade", attribute: "windowShade", commands: ["open", "close", "presetPosition"], multiple: true, devices: "window shades", ], + ] +} + +private commands() { + def tempUnit = "°" + location.temperatureScale + def defGroup = "Control [devices]" + return [ + [ name: "locationMode.setMode", category: "Location", group: "Control location mode, Smart Home Monitor, routines, pistons, variables, and more...", display: "Set location mode", ], + [ name: "smartHomeMonitor.setAlarmSystemStatus", category: "Location", group: "Control location mode, Smart Home Monitor, routines, pistons, variables, and more...", display: "Set Smart Home Monitor status",], + [ name: "on", category: "Convenience", group: defGroup, display: "Turn on", attribute: "switch", value: "on", ], + [ name: "on1", display: "Turn on #1", attribute: "switch1", value: "on", ], + [ name: "on2", display: "Turn on #2", attribute: "switch2", value: "on", ], + [ name: "on3", display: "Turn on #3", attribute: "switch3", value: "on", ], + [ name: "on4", display: "Turn on #4", attribute: "switch4", value: "on", ], + [ name: "on5", display: "Turn on #5", attribute: "switch5", value: "on", ], + [ name: "on6", display: "Turn on #6", attribute: "switch6", value: "on", ], + [ name: "on7", display: "Turn on #7", attribute: "switch7", value: "on", ], + [ name: "on8", display: "Turn on #8", attribute: "switch8", value: "on", ], + [ name: "off", category: "Convenience", group: defGroup, display: "Turn off", attribute: "switch", value: "off", ], + [ name: "off1", display: "Turn off #1", attribute: "switch1", value: "off", ], + [ name: "off2", display: "Turn off #2", attribute: "switch2", value: "off", ], + [ name: "off3", display: "Turn off #3", attribute: "switch3", value: "off", ], + [ name: "off4", display: "Turn off #4", attribute: "switch4", value: "off", ], + [ name: "off5", display: "Turn off #5", attribute: "switch5", value: "off", ], + [ name: "off6", display: "Turn off #6", attribute: "switch6", value: "off", ], + [ name: "off7", display: "Turn off #7", attribute: "switch7", value: "off", ], + [ name: "off8", display: "Turn off #8", attribute: "switch8", value: "off", ], + [ name: "toggle", display: "Toggle", ], + [ name: "toggle1", display: "Toggle #1", ], + [ name: "toggle2", display: "Toggle #1", ], + [ name: "toggle3", display: "Toggle #1", ], + [ name: "toggle4", display: "Toggle #1", ], + [ name: "toggle5", display: "Toggle #1", ], + [ name: "toggle6", display: "Toggle #1", ], + [ name: "toggle7", display: "Toggle #1", ], + [ name: "toggle8", display: "Toggle #1", ], + [ name: "setColor", category: "Convenience", group: defGroup, display: "Set color", parameters: ["?*Color:color","?*RGB:text","Hue:hue","Saturation:saturation","Lightness:level"], attribute: "color", value: "*|color", ], + [ name: "setLevel", category: "Convenience", group: defGroup, display: "Set level", parameters: ["Level:level"], description: "Set level to {0}%", attribute: "level", value: "*|number", ], + [ name: "setHue", category: "Convenience", group: defGroup, display: "Set hue", parameters: ["Hue:hue"], description: "Set hue to {0}°", attribute: "hue", value: "*|number", ], + [ name: "setSaturation", category: "Convenience", group: defGroup, display: "Set saturation", parameters: ["Saturation:saturation"], description: "Set saturation to {0}%", attribute: "saturation", value: "*|number", ], + [ name: "setColorTemperature", category: "Convenience", group: defGroup, display: "Set color temperature", parameters: ["Color Temperature:colorTemperature"], description: "Set color temperature to {0}°K", attribute: "colorTemperature", value: "*|number", ], + [ name: "open", category: "Convenience", group: defGroup, display: "Open", attribute: "door", value: "open", ], + [ name: "close", category: "Convenience", group: defGroup, display: "Close", attribute: "door", value: "close", ], + [ name: "windowShade.open", category: "Convenience", group: defGroup, display: "Open fully", ], + [ name: "windowShade.close", category: "Convenience", group: defGroup, display: "Close fully", ], + [ name: "windowShade.presetPosition", category: "Convenience", group: defGroup, display: "Move to preset position", ], + [ name: "lock", category: "Safety and Security", group: defGroup, display: "Lock", attribute: "lock", value: "locked", ], + [ name: "unlock", category: "Safety and Security", group: defGroup, display: "Unlock", attribute: "lock", value: "unlocked", ], + [ name: "take", category: "Safety and Security", group: defGroup, display: "Take a picture", ], + [ name: "alarm.off", category: "Safety and Security", group: defGroup, display: "Stop", attribute: "alarm", value: "off", ], + [ name: "alarm.strobe", category: "Safety and Security", group: defGroup, display: "Strobe", attribute: "alarm", value: "strobe", ], + [ name: "alarm.siren", category: "Safety and Security", group: defGroup, display: "Siren", attribute: "alarm", value: "siren", ], + [ name: "alarm.both", category: "Safety and Security", group: defGroup, display: "Strobe and Siren", attribute: "alarm", value: "both", ], + [ name: "thermostat.off", category: "Comfort", group: defGroup, display: "Set to Off", attribute: "thermostatMode", value: "off", ], + [ name: "thermostat.heat", category: "Comfort", group: defGroup, display: "Set to Heat", attribute: "thermostatMode", value: "heat", ], + [ name: "thermostat.cool", category: "Comfort", group: defGroup, display: "Set to Cool", attribute: "thermostatMode", value: "cool", ], + [ name: "thermostat.auto", category: "Comfort", group: defGroup, display: "Set to Auto", attribute: "thermostatMode", value: "auto", ], + [ name: "thermostat.emergencyHeat", category: "Comfort", group: defGroup, display: "Set to Emergency Heat", attribute: "thermostatMode", value: "emergency heat", ], + [ name: "thermostat.quickSetHeat", category: "Comfort", group: defGroup, display: "Quick set heating point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set quick heating point at {0}$tempUnit", ], + [ name: "thermostat.quickSetCool", category: "Comfort", group: defGroup, display: "Quick set cooling point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set quick cooling point at {0}$tempUnit", ], + [ name: "thermostat.setHeatingSetpoint", category: "Comfort", group: defGroup, display: "Set heating point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set heating point at {0}$tempUnit", attribute: "heatingSetpoint", value: "*|decimal", ], + [ name: "thermostat.setCoolingSetpoint", category: "Comfort", group: defGroup, display: "Set cooling point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set cooling point at {0}$tempUnit", attribute: "coolingSetpoint", value: "*|decimal", ], + [ name: "thermostat.setThermostatMode", category: "Comfort", group: defGroup, display: "Set thermostat mode", parameters: ["Mode:thermostatMode"], description: "Set thermostat mode to {0}", attribute: "thermostatMode", value: "*|string", ], + [ name: "fanOn", category: "Comfort", group: defGroup, display: "Set fan to On", ], + [ name: "fanCirculate", category: "Comfort", group: defGroup, display: "Set fan to Circulate", ], + [ name: "fanAuto", category: "Comfort", group: defGroup, display: "Set fan to Auto", ], + [ name: "setThermostatFanMode", category: "Comfort", group: defGroup, display: "Set fan mode", parameters: ["Fan mode:thermostatFanMode"], description: "Set fan mode to {0}", ], + [ name: "play", category: "Entertainment", group: defGroup, display: "Play", ], + [ name: "pause", category: "Entertainment", group: defGroup, display: "Pause", ], + [ name: "stop", category: "Entertainment", group: defGroup, display: "Stop", ], + [ name: "nextTrack", category: "Entertainment", group: defGroup, display: "Next track", ], + [ name: "previousTrack", category: "Entertainment", group: defGroup, display: "Previous track", ], + [ name: "mute", category: "Entertainment", group: defGroup, display: "Mute", ], + [ name: "unmute", category: "Entertainment", group: defGroup, display: "Unmute", ], + [ name: "musicPlayer.setLevel", category: "Entertainment", group: defGroup, display: "Set volume", parameters: ["Level:level"], description: "Set volume to {0}%", ], + [ name: "playText", category: "Entertainment", group: defGroup, display: "Speak text", parameters: ["Text:string", "?Volume:level"], description: "Speak text \"{0}\" at volume {1}", ], + [ name: "playTextAndRestore", display: "Speak text and restore", parameters: ["Text:string","?Volume:level"], description: "Speak text \"{0}\" at volume {1} and restore", ], + [ name: "playTextAndResume", display: "Speak text and resume", parameters: ["Text:string","?Volume:level"], description: "Speak text \"{0}\" at volume {1} and resume", ], + [ name: "playTrack", category: "Entertainment", group: defGroup, display: "Play track", parameters: ["Track URI:string","?Volume:level"], description: "Play track \"{0}\" at volume {1}", ], + [ name: "playTrackAtVolume", display: "Play track at volume", parameters: ["Track URI:string","Volume:level"],description: "Play track \"{0}\" at volume {1}", ], + [ name: "playTrackAndRestore", display: "Play track and restore", parameters: ["Track URI:string","?Volume:level"], description: "Play track \"{0}\" at volume {1} and restore", ], + [ name: "playTrackAndResume", display: "Play track and resume", parameters: ["Track URI:string","?Volume:level"], description: "Play track \"{0}\" at volume {1} and resume", ], + [ name: "setTrack", category: "Entertainment", group: defGroup, parameters: ["Track URI:string"], display: "Set track to '{0}'", ], + [ name: "setLocalLevel",display: "Set local level", parameters: ["Level:level"], description: "Set local level to {0}", ], + [ name: "resumeTrack", category: "Entertainment", group: defGroup, display: "Resume track", ], + [ name: "restoreTrack", category: "Entertainment", group: defGroup, display: "Restore track", ], + [ name: "speak", category: "Entertainment", group: defGroup, display: "Speak", parameters: ["Message:string"], description: "Speak \"{0}\"", ], + [ name: "startActivity", category: "Entertainment", group: defGroup, display: "Start activity", parameters: ["Activity:string"], description: "Start activity\"{0}\"", ], + [ name: "getCurrentActivity", category: "Entertainment", group: defGroup, display: "Get current activity", ], + [ name: "getAllActivities", category: "Entertainment", group: defGroup, display: "Get all activities", ], + [ name: "push", category: "Other", group: defGroup, display: "Push", ], + [ name: "beep", category: "Other", group: defGroup, display: "Beep", ], + [ name: "timedSession.setTimeRemaining", category: "Other", group: defGroup, display: "Set remaining time", parameters: ["Remaining time [s]:number"], description: "Set remaining time to {0}s", ], + [ name: "timedSession.start", category: "Other", group: defGroup, display: "Start timed session", ], + [ name: "timedSession.stop", category: "Other", group: defGroup, display: "Stop timed session", ], + [ name: "timedSession.pause", category: "Other", group: defGroup, display: "Pause timed session", ], + [ name: "timedSession.cancel", category: "Other", group: defGroup, display: "Cancel timed session", ], + [ name: "setConsumableStatus", category: "Other", group: defGroup, display: "Set consumable status", parameters: ["Status:consumable"], description: "Set consumable status to {0}", ], + [ name: "configure", display: "Configure", ], + [ name: "poll", display: "Poll", ], + [ name: "refresh", display: "Refresh", ], + /* predfined commands below */ + //general + [ name: "reset", display: "Reset", ], + //hue + [ name: "startLoop", display: "Start color loop", ], + [ name: "stopLoop", display: "Stop color loop", ], + [ name: "setLoopTime", display: "Set loop duration", parameters: ["Duration [s]:number[1..*]"], description: "Set loop duration to {0}s"], + [ name: "setDirection", display: "Switch loop direction", description: "Set loop duration to {0}s"], + [ name: "alert", display: "Alert with lights", parameters: ["Method:enum[Blink,Breathe,Okay,Stop]"], description: "Alert with lights: {0}"], + [ name: "setAdjustedColor",display: "Transition to color", parameters: ["Color:color","Duration [s]:number[1..60]"], description: "Transition to color {0} in {1}s"], + //harmony + [ name: "allOn", display: "Turn all on", ], + [ name: "allOff", display: "Turn all off", ], + [ name: "hubOn", display: "Turn hub on", ], + [ name: "hubOff", display: "Turn hub off", ], + //blink camera + [ name: "enableCamera", display: "Enable camera", ], + [ name: "disableCamera",display: "Disable camera", ], + [ name: "monitorOn", display: "Turn monitor on", ], + [ name: "monitorOff", display: "Turn monitor off", ], + [ name: "ledOn", display: "Turn LED on", ], + [ name: "ledOff", display: "Turn LED off", ], + [ name: "ledAuto", display: "Set LED to Auto", ], + [ name: "setVideoLength",display: "Set video length", parameters: ["Seconds:number[1..120]"], description: "Set video length to {0}s", ], + //dlink camera + [ name: "pirOn", display: "Enable PIR motion detection", ], + [ name: "pirOff", display: "Disable PIR motion detection",], + [ name: "nvOn", display: "Set Night Vision to On", ], + [ name: "nvOff", display: "Set Night Vision to Off", ], + [ name: "nvAuto", display: "Set Night Vision to Auto", ], + [ name: "vrOn", display: "Enable local video recording",], + [ name: "vrOff", display: "Disable local video recording",], + [ name: "left", display: "Pan camera left", ], + [ name: "right", display: "Pan camera right", ], + [ name: "up", display: "Pan camera up", ], + [ name: "down", display: "Pan camera down", ], + [ name: "home", display: "Pan camera to the Home", ], + [ name: "presetOne", display: "Pan camera to preset #1", ], + [ name: "presetTwo", display: "Pan camera to preset #2", ], + [ name: "presetThree", display: "Pan camera to preset #3", ], + [ name: "presetFour", display: "Pan camera to preset #4", ], + [ name: "presetFive", display: "Pan camera to preset #5", ], + [ name: "presetSix", display: "Pan camera to preset #6", ], + [ name: "presetSeven", display: "Pan camera to preset #7", ], + [ name: "presetEight", display: "Pan camera to preset #8", ], + [ name: "presetCommand",display: "Pan camera to custom preset", parameters: ["Preset #:number[1..99]"], description: "Pan camera to preset #{0}", ], + //zwave fan speed control by @pmjoen + [ name: "low", display: "Set to Low"], + [ name: "med", display: "Set to Medium"], + [ name: "high", display: "Set to High"], + ] +} + +private virtualCommands() { + def cmds = [ + [ name: "wait", display: "Wait", parameters: ["Time:number[1..1440]","Unit:enum[seconds,minutes,hours]"], immediate: true, location: true, description: "Wait {0} {1}", ], + [ name: "waitVariable", display: "Wait (variable)", parameters: ["Time (variable):variable","Unit:enum[seconds,minutes,hours]"], immediate: true, location: true, description: "Wait |[{0}]| {1}", ], + [ name: "waitRandom", display: "Wait (random)", parameters: ["At least:number[1..1440]","At most:number[1..1440]","Unit:enum[seconds,minutes,hours]"], immediate: true, location: true, description: "Wait {0}-{1} {2}", ], + [ name: "waitState", display: "Wait for piston state change", parameters: ["Change to:enum[any,false,true]"], immediate: true, location: true, description: "Wait for {0} state"], + [ name: "waitTime", display: "Wait for common time", parameters: ["Time:enum[midnight,sunrise,noon,sunset]","?Offset [minutes]:number[-1440..1440]","Days of week:enums[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]"], immediate: true, location: true, description: "Wait for next {0} (offset {1} min), on {2}"], + [ name: "waitCustomTime", display: "Wait for custom time", parameters: ["Time:time","Days of week:enums[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]"], immediate: true, location: true, description: "Wait for {0}, on {1}"], + [ name: "toggle", requires: ["on", "off"], display: "Toggle", ], + [ name: "toggle#1", requires: ["on1", "off1"], display: "Toggle #1", ], + [ name: "toggle#2", requires: ["on2", "off2"], display: "Toggle #2", ], + [ name: "toggle#3", requires: ["on3", "off3"], display: "Toggle #3", ], + [ name: "toggle#4", requires: ["on4", "off4"], display: "Toggle #4", ], + [ name: "toggle#5", requires: ["on5", "off5"], display: "Toggle #5", ], + [ name: "toggle#6", requires: ["on6", "off6"], display: "Toggle #6", ], + [ name: "toggle#7", requires: ["on7", "off7"], display: "Toggle #7", ], + [ name: "toggle#8", requires: ["on8", "off8"], display: "Toggle #8", ], + [ name: "toggleLevel", requires: ["on", "off", "setLevel"],display: "Toggle level", parameters: ["Level:level"], description: "Toggle level between 0% and {0}%", ], + [ name: "delayedOn", requires: ["on"], display: "Turn on (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on after {0}ms", ], + [ name: "delayedOn#1", requires: ["on1"], display: "Turn on #1 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #1 after {0}ms", ], + [ name: "delayedOn#2", requires: ["on2"], display: "Turn on #2 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #2 after {0}ms", ], + [ name: "delayedOn#3", requires: ["on3"], display: "Turn on #3 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #3 after {0}ms", ], + [ name: "delayedOn#4", requires: ["on4"], display: "Turn on #4 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #4 after {0}ms", ], + [ name: "delayedOn#5", requires: ["on5"], display: "Turn on #5 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #5 after {0}ms", ], + [ name: "delayedOn#6", requires: ["on6"], display: "Turn on #6 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #6 after {0}ms", ], + [ name: "delayedOn#7", requires: ["on7"], display: "Turn on #7 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #7 after {0}ms", ], + [ name: "delayedOn#8", requires: ["on8"], display: "Turn on #8 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn on #8 after {0}ms", ], + [ name: "delayedOff", requires: ["off"], display: "Turn off (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off after {0}ms", ], + [ name: "delayedOff#1", requires: ["off1"], display: "Turn off #1 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #1 after {0}ms", ], + [ name: "delayedOff#2", requires: ["off2"], display: "Turn off #2 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #2 after {0}ms", ], + [ name: "delayedOff#3", requires: ["off3"], display: "Turn off #3 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #3 after {0}ms", ], + [ name: "delayedOff#4", requires: ["off4"], display: "Turn off #4 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #4 after {0}ms", ], + [ name: "delayedOff#5", requires: ["off5"], display: "Turn off #5 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #5 after {0}ms", ], + [ name: "delayedOff#6", requires: ["off7"], display: "Turn off #6 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #6 after {0}ms", ], + [ name: "delayedOff#7", requires: ["off7"], display: "Turn off #7 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #7 after {0}ms", ], + [ name: "delayedOff#8", requires: ["off8"], display: "Turn off #8 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Turn off #8 after {0}ms", ], + [ name: "delayedToggle", requires: ["on", "off"], display: "Toggle (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle after {0}ms", ], + [ name: "delayedToggle#1", requires: ["on1", "off1"], display: "Toggle #1 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #1 after {0}ms", ], + [ name: "delayedToggle#2", requires: ["on2", "off2"], display: "Toggle #2 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #2 after {0}ms", ], + [ name: "delayedToggle#3", requires: ["on3", "off3"], display: "Toggle #3 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #3 after {0}ms", ], + [ name: "delayedToggle#4", requires: ["on4", "off4"], display: "Toggle #4 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #4 after {0}ms", ], + [ name: "delayedToggle#5", requires: ["on5", "off5"], display: "Toggle #5 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #5 after {0}ms", ], + [ name: "delayedToggle#6", requires: ["on6", "off6"], display: "Toggle #6 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #6 after {0}ms", ], + [ name: "delayedToggle#7", requires: ["on7", "off7"], display: "Toggle #7 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #7 after {0}ms", ], + [ name: "delayedToggle#8", requires: ["on8", "off8"], display: "Toggle #8 (delayed)", parameters: ["Delay (ms):number[1..60000]"], description: "Toggle #8 after {0}ms", ], + [ name: "setLevelVariable", requires: ["setLevel"], display: "Set level (variable)", parameters: ["Level:variable"], description: "Set level to {0}%"], + [ name: "setSaturationVariable", requires: ["setSaturation"], display: "Set saturation (variable)", parameters: ["Saturation:variable"], description: "Set saturation to {0}%"], + [ name: "setHueVariable", requires: ["setHue"], display: "Set hue (variable)", parameters: ["Hue:variable"], description: "Set hue to {0}°"], + [ name: "fadeLevelHW", requires: ["setLevel"], display: "Fade to level (hardware)", parameters: ["Target level:level","Duration (ms):number[1..60000]"], description: "Fade to {0}% in {1}ms", ], + [ name: "fadeLevel", requires: ["setLevel"], display: "Fade to level", parameters: ["?Start level (optional):level","Target level:level","Duration (seconds):number[1..600]"], description: "Fade level from {0}% to {1}% in {2}s", ], + [ name: "fadeLevelVariable", requires: ["setLevel"], display: "Fade to level (variable)", parameters: ["?Start level (optional):variable","Target level:variable","Duration (seconds):number[1..600]"], description: "Fade level from {0}% to {1}% in {2}s", ], + [ name: "setLevelIf", category: "Convenience", group: "Control [devices]", display: "Set level (advanced)", parameters: ["Level:level","Only if switch state is:enum[on,off]"], description: "Set level to {0}% if switch is {1}", attribute: "level", value: "*|number", ], + [ name: "adjustLevel", requires: ["setLevel"], display: "Adjust level", parameters: ["Adjustment (+/-):number[-100..100]"], description: "Adjust level by {0}%", ], + [ name: "adjustLevelVariable", requires: ["setLevel"], display: "Adjust level (variable)", parameters: ["Adjustment (+/-):variable"], description: "Adjust level by {0}%", ], + [ name: "fadeSaturation", requires: ["setSaturation"], display: "Fade to saturation", parameters: ["?Start saturation (optional):saturation","Target saturation:saturation","Duration (seconds):number[1..600]"], description: "Fade saturation from {0}% to {1}% in {2}s", ], + [ name: "fadeSaturationVariable",requires: ["setSaturation"], display: "Fade to saturation (variable)", parameters: ["?Start saturation (optional):variable","Target saturation:variable","Duration (seconds):number[1..600]"], description: "Fade saturation from {0}% to {1}% in {2}s", ], + [ name: "adjustSaturation", requires: ["setSaturation"], display: "Adjust saturation", parameters: ["Adjustment (+/-):number[-100..100]"], description: "Adjust saturation by {0}%", ], + [ name: "adjustSaturationVariable", requires: ["setSaturation"], display: "Adjust saturation (variable)", parameters: ["Adjustment (+/-):variable"], description: "Adjust saturation by {0}%", ], + [ name: "fadeHue", requires: ["setHue"], display: "Fade to hue", parameters: ["?Start hue (optional):hue","Target hue:hue","Duration (seconds):number[1..600]"], description: "Fade hue from {0}° to {1}° in {2}s", ], + [ name: "fadeHueVariable", requires: ["setHue"], display: "Fade to hue (variable)", parameters: ["?Start hue (optional):variable","Target hue:variable","Duration (seconds):number[1..600]"], description: "Fade hue from {0}° to {1}° in {2}s", ], + [ name: "adjustHue", requires: ["setHue"], display: "Adjust hue", parameters: ["Adjustment (+/-):number[-360..360]"], description: "Adjust hue by {0}°", ], + [ name: "adjustHueVariable", requires: ["setHue"], display: "Adjust hue (variable)", parameters: ["Adjustment (+/-):variable"], description: "Adjust hue by {0}°", ], + [ name: "flash", requires: ["on", "off"], display: "Flash", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#1", requires: ["on1", "off1"], display: "Flash #1", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #1 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#2", requires: ["on2", "off2"], display: "Flash #2", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #2 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#3", requires: ["on3", "off3"], display: "Flash #3", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #3 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#4", requires: ["on4", "off4"], display: "Flash #4", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #4 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#5", requires: ["on5", "off5"], display: "Flash #5", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #5 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#6", requires: ["on6", "off6"], display: "Flash #6", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #6 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#7", requires: ["on7", "off7"], display: "Flash #7", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #7 {0}ms/{1}ms for {2} time(s)", ], + [ name: "flash#8", requires: ["on8", "off8"], display: "Flash #8", parameters: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], description: "Flash #8 {0}ms/{1}ms for {2} time(s)", ], + [ name: "setVariable", display: "Set variable", parameters: ["Variable:var"], varEntry: 0, location: true, aggregated: true, ], + [ name: "saveAttribute", display: "Save attribute to variable", parameters: ["Attribute:attribute","Aggregation:aggregation","?Convert to data type:dataType","Save to variable:string"], varEntry: 3, description: "Save attribute '{0}' to variable |[{3}]|'", aggregated: true, ], + [ name: "saveState", display: "Save state to variable", parameters: ["Attributes:attributes","Aggregation:aggregation","?Convert to data type:dataType","Save to state variable:string"], stateVarEntry: 3, description: "Save state of attributes {0} to variable |[{3}]|'", aggregated: true, ], + [ name: "saveStateLocally", display: "Capture state to local store", parameters: ["Attributes:attributes","?Only if state is empty:bool"], description: "Capture state of attributes {0} to local store", ], + [ name: "saveStateGlobally",display: "Capture state to global store", parameters: ["Attributes:attributes","?Only if state is empty:bool"], description: "Capture state of attributes {0} to global store", ], + [ name: "loadAttribute", display: "Load attribute from variable", parameters: ["Attribute:attribute","Load from variable:variable","Allow translations:bool","Negate translation:bool"], description: "Load attribute '{0}' from variable |[{1}]|", ], + [ name: "loadState", display: "Load state from variable", parameters: ["Attributes:attributes","Load from state variable:stateVariable","Allow translations:bool","Negate translation:bool"], description: "Load state of attributes {0} from variable |[{1}]|" ], + [ name: "loadStateLocally", display: "Restore state from local store", parameters: ["Attributes:attributes","?Empty the state:bool"], description: "Restore state of attributes {0} from local store", ], + [ name: "loadStateGlobally",display: "Restore state from global store", parameters: ["Attributes:attributes","?Empty the state:bool"], description: "Restore state of attributes {0} from global store", ], + [ name: "setLocationMode", display: "Set location mode", parameters: ["Mode:mode"], location: true, description: "Set location mode to '{0}'", aggregated: true, ], + [ name: "setAlarmSystemStatus",display: "Set Smart Home Monitor status", parameters: ["Status:alarmSystemStatus"], location: true, description: "Set SHM alarm to '{0}'", aggregated: true, ], + [ name: "sendNotification", display: "Send notification", parameters: ["Message:text"], location: true, description: "Send notification '{0}' in notifications page", aggregated: true, ], + [ name: "sendPushNotification",display: "Send Push notification", parameters: ["Message:text","Show in notifications page:bool"], location: true, description: "Send Push notification '{0}'", aggregated: true, ], + [ name: "sendSMSNotification",display: "Send SMS notification", parameters: ["Message:text","Phone number:phone","Show in notifications page:bool"], location: true, description: "Send SMS notification '{0}' to {1}",aggregated: true, ], + [ name: "queueAskAlexaMessage",display: "Queue AskAlexa message", parameters: ["Message:text", "?Unit:text", "?Application:text"], location: true, description: "Queue AskAlexa message '{0}' in unit {1}",aggregated: true, ], + [ name: "deleteAskAlexaMessages",display: "Delete AskAlexa messages", parameters: ["Unit:text", "?Application:text"], location: true, description: "Delete AskAlexa messages in unit {1}",aggregated: true, ], + [ name: "executeRoutine", display: "Execute routine", parameters: ["Routine:routine"], location: true, description: "Execute routine '{0}'", aggregated: true, ], + [ name: "cancelPendingTasks",display: "Cancel pending tasks", parameters: ["Scope:enum[Local,Global]"], description: "Cancel all pending {0} tasks", ], + [ name: "followUp", display: "Follow up with piston", parameters: ["Delay:number[1..1440]","Unit:enum[seconds,minutes,hours]","Piston:piston","?Save state into variable:string"], immediate: true, varEntry: 3, location: true, description: "Follow up with piston '{2}' after {0} {1}", aggregated: true], + [ name: "executePiston", display: "Execute piston", parameters: ["Piston:piston","?Save state into variable:string"], varEntry: 1, location: true, description: "Execute piston '{0}'", aggregated: true], + [ name: "pausePiston", display: "Pause piston", parameters: ["Piston:piston"], location: true, description: "Pause piston '{0}'", aggregated: true], + [ name: "resumePiston", display: "Resume piston", parameters: ["Piston:piston"], location: true, description: "Resume piston '{0}'", aggregated: true], + [ name: "httpRequest", display: "Make a web request", parameters: ["URL:string","Method:enum[GET,POST,PUT,DELETE,HEAD]","Content Type:enum[JSON,FORM]","?Variables to send:variables","Import response data into variables:bool","?Variable import name prefix (optional):string"], location: true, description: "Make a {1} web request to {0}", aggregated: true], + [ name: "wolRequest", display: "Wake a LAN device", parameters: ["MAC address:string","?Secure code:string"], location: true, description: "Wake LAN device at address {0} with secure code {1}", aggregated: true], + //flow control commands + [ name: "beginSimpleForLoop", display: "Begin FOR loop (simple)", parameters: ["Number of cycles:string"], location: true, description: "FOR {0} CYCLES DO", flow: true, indent: 1, ], + [ name: "beginForLoop", display: "Begin FOR loop", parameters: ["Variable to use:string","From value:string","To value:string"], varEntry: 0, location: true, description: "FOR {0} = {1} TO {2} DO", flow: true, indent: 1, ], + [ name: "beginWhileLoop", display: "Begin WHILE loop", parameters: ["Variable to test:variable","Comparison:enum[is equal to,is not equal to,is less than,is less than or equal to,is greater than,is greater than or equal to]","Value:string"], location: true, description: "WHILE (|[{0}]| {1} {2}) DO", flow: true, indent: 1, ], + [ name: "breakLoop", display: "Break loop", location: true, description: "BREAK", flow: true, ], + [ name: "breakLoopIf", display: "Break loop (conditional)", parameters: ["Variable to test:variable","Comparison:enum[is equal to,is not equal to,is less than,is less than or equal to,is greater than,is greater than or equal to]","Value:string"], location: true, description: "BREAK IF ({0} {1} {2})", flow: true, ], + [ name: "exitAction", display: "Exit Action", location: true, description: "EXIT", flow: true, ], + [ name: "endLoop", display: "End loop", parameters: ["Delay (seconds):number[0..*]"], location: true, description: "LOOP AFTER {0}s", flow: true, selfIndent: -1, indent: -1, ], + [ name: "beginIfBlock", display: "Begin IF block", parameters: ["Variable to test:variable","Comparison:enum[is equal to,is not equal to,is less than,is less than or equal to,is greater than,is greater than or equal to]","Value:string"], location: true, description: "IF (|[{0}]| {1} {2}) THEN", flow: true, indent: 1, ], + [ name: "beginElseIfBlock", display: "Begin ELSE IF block", parameters: ["Variable to test:variable","Comparison:enum[is equal to,is not equal to,is less than,is less than or equal to,is greater than,is greater than or equal to]","Value:string"], location: true, description: "ELSE IF (|[{0}]| {1} {2}) THEN", flow: true, selfIndent: -1, ], + [ name: "beginElseBlock", display: "Begin ELSE block", location: true, description: "ELSE", flow: true, selfIndent: -1, ], + [ name: "endIfBlock", display: "End IF block", location: true, description: "END IF", flow: true, selfIndent: -1, indent: -1, ], + [ name: "beginSwitchBlock", display: "Begin SWITCH block", parameters: ["Variable to test:variable"], location: true, description: "SWITCH (|[{0}]|) DO", flow: true, indent: 2, ], + [ name: "beginSwitchCase", display: "Begin CASE block", parameters: ["Value:string"], location: true, description: "CASE '{0}':", flow: true, selfIndent: -1, ], + [ name: "endSwitchBlock", display: "End SWITCH block", location: true, description: "END SWITCH", flow: true, selfIndent: -2, indent: -2, ], + ] + if (location.contactBookEnabled) { + cmds.push([ name: "sendNotificationToContacts", display: "Send notification to contacts", parameters: ["Message:text","Contacts:contacts","Save notification:bool"], location: true, description: "Send notification '{0}' to {1}", aggregated: true]) + } + if (getIftttKey()) { + cmds.push([ name: "iftttMaker", display: "Send IFTTT Maker event", parameters: ["Event:text", "?Value1:string", "?Value2:string", "?Value3:string"], location: true, description: "Send IFTTT Maker event '{0}' with parameters '{1}', '{2}', and '{3}'", aggregated: true]) + } + if (getLifxToken()) { + cmds.push([ name: "lifxScene", display: "Activate LIFX scene", parameters: ["Scene:lifxScenes"], location: true, description: "Activate LIFX Scene '{0}'", aggregated: true]) + } + return cmds +} + +private attributes() { + if (state.temp && state.temp.attributes) return state.temp.attributes + def tempUnit = "°" + location.temperatureScale + state.temp = state.temp ?: [:] + state.temp.attributes = [ + [ name: "acceleration", type: "enum", options: ["active", "inactive"], ], + [ name: "alarm", type: "enum", options: ["off", "strobe", "siren", "both"], ], + [ name: "battery", type: "number", range: "0..100", unit: "%", ], + [ name: "beacon", type: "enum", options: ["present", "not present"], ], + [ name: "button", type: "enum", options: ["held", "pushed"], capability: "button", momentary: true], //default capability so that we can figure out multi sub devices + [ name: "carbonDioxide", type: "decimal", range: "0..*", ], + [ name: "carbonMonoxide", type: "enum", options: ["clear", "detected", "tested"], ], + [ name: "color", type: "color", unit: "#RRGGBB", ], + [ name: "hue", type: "number", range: "0..360", unit: "°", ], + [ name: "saturation", type: "number", range: "0..100", unit: "%", ], + [ name: "hex", type: "hexcolor", ], + [ name: "saturation", type: "number", range: "0..100", unit: "%", ], + [ name: "level", type: "number", range: "0..100", unit: "%", ], + [ name: "switch", type: "enum", options: ["on", "off"], interactive: true, ], + [ name: "switch*", type: "enum", options: ["on", "off"], interactive: true, ], + [ name: "colorTemperature", type: "number", range: "1700..27000", unit: "°K", ], + [ name: "consumable", type: "enum", options: ["missing", "good", "replace", "maintenance_required", "order"], ], + [ name: "contact", type: "enum", options: ["open", "closed"], ], + [ name: "door", type: "enum", options: ["unknown", "closed", "open", "closing", "opening"], interactive: true, ], + [ name: "energy", type: "decimal", range: "0..*", unit: "kWh", ], + [ name: "energy*", type: "decimal", range: "0..*", unit: "kWh", ], + [ name: "indicatorStatus", type: "enum", options: ["when off", "when on", "never"], ], + [ name: "illuminance", type: "number", range: "0..*", unit: "lux", ], + [ name: "image", type: "image", ], + [ name: "lock", type: "enum", options: ["locked", "unlocked"], capability: "lock", interactive: true, ], + [ name: "activities", type: "string", ], + [ name: "currentActivity", type: "string", ], + [ name: "motion", type: "enum", options: ["active", "inactive"], ], + [ name: "status", type: "string", ], + [ name: "mute", type: "enum", options: ["muted", "unmuted"], ], + [ name: "pH", type: "decimal", range: "0..14", ], + [ name: "power", type: "decimal", range: "0..*", unit: "W", ], + [ name: "power*", type: "decimal", range: "0..*", unit: "W", ], + [ name: "occupancy", type: "enum", options: ["occupied", "not occupied"], ], + [ name: "presence", type: "enum", options: ["present", "not present"], ], + [ name: "humidity", type: "number", range: "0..100", unit: "%", ], + [ name: "shock", type: "enum", options: ["detected", "clear"], ], + [ name: "lqi", type: "number", range: "0..255", ], + [ name: "rssi", type: "number", range: "0..100", unit: "%", ], + [ name: "sleeping", type: "enum", options: ["sleeping", "not sleeping"], ], + [ name: "smoke", type: "enum", options: ["clear", "detected", "tested"], ], + [ name: "sound", type: "enum", options: ["detected", "not detected"], ], + [ name: "steps", type: "number", range: "0..*", ], + [ name: "goal", type: "number", range: "0..*", ], + [ name: "soundPressureLevel", type: "number", range: "0..*", ], + [ name: "tamper", type: "enum", options: ["clear", "detected"], ], + [ name: "temperature", type: "decimal", range: "*..*", unit: tempUnit, ], + [ name: "thermostatMode", type: "enum", options: ["off", "auto", "cool", "heat", "emergency heat"], ], + [ name: "thermostatFanMode", type: "enum", options: ["auto", "on", "circulate"], ], + [ name: "thermostatOperatingState", type: "enum", options: ["idle", "pending cool", "cooling", "pending heat", "heating", "fan only", "vent economizer"], ], + [ name: "coolingSetpoint", type: "decimal", range: "-127..127", unit: tempUnit, ], + [ name: "heatingSetpoint", type: "decimal", range: "-127..127", unit: tempUnit, ], + [ name: "thermostatSetpoint", type: "decimal", range: "-127..127", unit: tempUnit, ], + [ name: "sessionStatus", type: "enum", options: ["paused", "stopped", "running", "canceled"], ], + [ name: "threeAxis", type: "vector3", ], + [ name: "orientation", type: "orientation", options: threeAxisOrientations(), valueType: "enum", subscribe: "threeAxis", ], + [ name: "axisX", type: "number", range: "-1024..1024", subscribe: "threeAxis", ], + [ name: "axisY", type: "number", range: "-1024..1024", subscribe: "threeAxis", ], + [ name: "axisZ", type: "number", range: "-1024..1024", subscribe: "threeAxis", ], + [ name: "touch", type: "enum", options: ["touched"], ], + [ name: "valve", type: "enum", options: ["open", "closed"], ], + [ name: "voltage", type: "decimal", range: "*..*", unit: "V", ], + [ name: "water", type: "enum", options: ["dry", "wet"], ], + [ name: "windowShade", type: "enum", options: ["unknown", "open", "closed", "opening", "closing", "partially open"], ], + [ name: "mode", type: "mode", options: state.run == "config" ? getLocationModeOptions() : [], ], + [ name: "alarmSystemStatus", type: "enum", options: state.run == "config" ? getAlarmSystemStatusOptions() : [], ], + [ name: "routineExecuted", type: "routine", options: state.run == "config" ? location.helloHome?.getPhrases()*.label : [], valueType: "enum", ], + [ name: "piston", type: "piston", options: state.run == "config" ? parent.listPistons(state.config.expertMode ? null : app.label) : [], valueType: "enum", ], + [ name: "variable", type: "enum", options: state.run == "config" ? listVariables(true) : [], valueType: "enum", ], + [ name: "time", type: "time", ], + [ name: "askAlexaMacro", type: "askAlexaMacro", options: state.run == "config" ? listAskAlexaMacros() : [], valueType: "enum"], + [ name: "echoSistantProfile", type: "echoSistantProfile", options: state.run == "config" ? listEchoSistantProfiles() : [], valueType: "enum"], + [ name: "ifttt", type: "ifttt", valueType: "string"], + ] + return state.temp.attributes +} + +private comparisons() { + def optionsEnum = [ + [ condition: "is", trigger: "changes to", parameters: 1, timed: false], + [ condition: "is not", trigger: "changes away from", parameters: 1, timed: false], + [ condition: "is one of", trigger: "changes to one of", parameters: 1, timed: false, multiple: true, minOptions: 2], + [ condition: "is not one of", trigger: "changes away from one of", parameters: 1, timed: false, multiple: true, minOptions: 2], + [ condition: "was", trigger: "stays", parameters: 1, timed: true], + [ condition: "was not", trigger: "stays away from", parameters: 1, timed: true], + [ trigger: "changes", parameters: 0, timed: false], + [ condition: "changed", parameters: 0, timed: true], + [ condition: "did not change", parameters: 0, timed: true], + ] + + def optionsMomentary = [ + [ condition: "is", trigger: "changes to", parameters: 1, timed: false], + ] + + def optionsBool = [ + [ condition: "is equal to", parameters: 1, timed: false], + [ condition: "is not equal to", parameters: 1, timed: false], + [ condition: "is true", parameters: 0, timed: false], + [ condition: "is false", parameters: 0, timed: false], + ] + def optionsEvents = [ + [ trigger: "executed", parameters: 1, timed: false], + ] + def optionsNumber = [ + [ condition: "is equal to", trigger: "changes to", parameters: 1, timed: false], + [ condition: "is not equal to", trigger: "changes away from", parameters: 1, timed: false], + [ condition: "is less than", trigger: "drops below", parameters: 1, timed: false], + [ condition: "is less than or equal to", trigger: "drops to or below", parameters: 1, timed: false], + [ condition: "is greater than", trigger: "raises above", parameters: 1, timed: false], + [ condition: "is greater than or equal to", trigger: "raises to or above", parameters: 1, timed: false], + [ condition: "is inside range", trigger: "enters range", parameters: 2, timed: false], + [ condition: "is outside of range", trigger: "exits range", parameters: 2, timed: false], + [ condition: "is even", trigger: "changes to an even value", parameters: 0, timed: false], + [ condition: "is odd", trigger: "changes to an odd value", parameters: 0, timed: false], + [ condition: "was equal to", trigger: "stays equal to", parameters: 1, timed: true], + [ condition: "was not equal to", trigger: "stays not equal to", parameters: 1, timed: true], + [ condition: "was less than", trigger: "stays less than", parameters: 1, timed: true], + [ condition: "was less than or equal to", trigger: "stays less than or equal to", parameters: 1, timed: true], + [ condition: "was greater than", trigger: "stays greater than", parameters: 1, timed: true], + [ condition: "was greater than or equal to", trigger: "stays greater than or equal to", parameters: 1, timed: true], + [ condition: "was inside range",trigger: "stays inside range", parameters: 2, timed: true], + [ condition: "was outside of range", trigger: "stays outside of range", parameters: 2, timed: true], + [ condition: "was even", trigger: "stays even", parameters: 0, timed: true], + [ condition: "was odd", trigger: "stays odd", parameters: 0, timed: true], + [ trigger: "changes", parameters: 0, timed: false], + [ trigger: "raises", parameters: 0, timed: false], + [ trigger: "drops", parameters: 0, timed: false], + [ condition: "changed", parameters: 0, timed: true], + [ condition: "did not change", parameters: 0, timed: true], + ] + def optionsTime = [ + [ trigger: "happens at", parameters: 1], + [ condition: "is any time of day", parameters: 0], + [ condition: "is around", parameters: 1], + [ condition: "is before", parameters: 1], + [ condition: "is after", parameters: 1], + [ condition: "is between", parameters: 2], + [ condition: "is not between", parameters: 2], + ] + return [ + [ type: "bool", options: optionsBool, ], + [ type: "boolean", options: optionsBool, ], + [ type: "vector3", options: optionsEnum, ], + [ type: "orientation", options: optionsEnum, ], + [ type: "string", options: optionsEnum, ], + [ type: "text", options: optionsEnum, ], + [ type: "enum", options: optionsEnum, ], + [ type: "mode", options: optionsEnum, ], + [ type: "alarmSystemStatus", options: optionsEnum, ], + [ type: "routine", options: optionsEvents ], + [ type: "piston", options: optionsEvents ], + [ type: "askAlexaMacro", options: optionsEvents ], + [ type: "echoSistantProfile", options: optionsEvents ], + [ type: "ifttt", options: optionsEvents ], + [ type: "number", options: optionsNumber, ], + [ type: "variable", options: optionsNumber, ], + [ type: "decimal", options: optionsNumber ], + [ type: "time", options: optionsTime, ], + [ type: "momentary", options: optionsMomentary, ], + ] +} + +private getLocationModeOptions() { + def result = [] + for (mode in location.modes) { + if (mode) result.push("$mode") + } + return result +} +private getAlarmSystemStatusOptions() { + return ["Disarmed", "Armed/Stay", "Armed/Away"] +} + +private initialSystemStore() { + return [ + "\$currentEventAttribute": null, + "\$currentEventDate": null, + "\$currentEventDelay": 0, + "\$currentEventDevice": null, + "\$currentEventDeviceIndex": 0, + "\$currentEventDevicePhysical": false, + "\$currentEventReceived": null, + "\$currentEventValue": null, + "\$currentState": null, + "\$currentStateDuration": 0, + "\$currentStateSince": null, + "\$currentStateSince": null, + "\$nextScheduledTime": null, + "\$now": 999999999999, + "\$hour": 0, + "\$hour24": 0, + "\$minute": 0, + "\$second": 0, + "\$meridian": "", + "\$meridianWithDots": "", + "\$day": 0, + "\$dayOfWeek": 0, + "\$dayOfWeekName": "", + "\$month": 0, + "\$monthName": "", + "\$index": 0, + "\$year": 0, + "\$meridianWithDots": "", + "\$previousEventAttribute": null, + "\$previousEventDate": null, + "\$previousEventDelay": 0, + "\$previousEventDevice": null, + "\$previousEventDeviceIndex": 0, + "\$previousEventDevicePhysical": 0, + "\$previousEventExecutionTime": 0, + "\$previousEventReceived": null, + "\$previousEventValue": null, + "\$previousState": null, + "\$previousStateDuration": 0, + "\$previousStateSince": null, + "\$random": 0, + "\$randomColor": "#FFFFFF", + "\$randomColorName": "White", + "\$randomLevel": 0, + "\$randomSaturation": 0, + "\$randomHue": 0, + "\$midnight": 999999999999, + "\$noon": 999999999999, + "\$sunrise": 999999999999, + "\$sunset": 999999999999, + "\$nextMidnight": 999999999999, + "\$nextNoon": 999999999999, + "\$nextSunrise": 999999999999, + "\$nextSunset": 999999999999, + "\$time": "", + "\$time24": "", + "\$httpStatusCode": 0, + "\$httpStatusOk": true, + "\$iftttStatusCode": 0, + "\$iftttStatusOk": true, + "\$locationMode": "", + "\$shmStatus": "" + ] +} + +private List colorOptions() { + return allColors()*.name +} + +private List allColors() { + return [randomColor(), *colorUtil.ALL] +} + +private Map randomColor() { + [name: "Random", rgb: "#000000", h: 0, s: 0, l: 0] +} + +private getColorByName(name, ownerId = null, taskId = null) { + if (name == "Random") { + //randomize the color + String valName = "$ownerId-$taskId" + def result = getRandomValue(valName) ?: colorUtil.RANDOM + setRandomValue(valName, result) + return result + } + return colorUtil.findByName(name) ?: colorUtil.WHITE +} + +/******************************************************************************/ +/*** DEVELOPMENT AREA ***/ +/*** Write code here and then move it to its proper location ***/ +/******************************************************************************/ \ No newline at end of file diff --git a/smartapps/ady624/webcore.src/webcore.groovy b/smartapps/ady624/webcore.src/webcore.groovy new file mode 100644 index 00000000000..34cf15d2a2c --- /dev/null +++ b/smartapps/ady624/webcore.src/webcore.groovy @@ -0,0 +1,2979 @@ +/* + * webCoRE - Community's own Rule Engine - Web Edition + * + * Copyright 2016 Adrian Caramaliu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Version history +*/ +public static String version() { return "v0.3.109.20181207" } +/* + * 12/07/2018 >>> v0.3.109.20181207 - BETA M3 - Dirty fix for dashboard timeouts: seems like ST has a lot of trouble reading the list of devices/commands/attributes/values these days, so giving up on reading values makes this much faster - temporarily?! + * 09/06/2018 >>> v0.3.108.20180906 - BETA M3 - Restore pistons from backup file, hide "(unknown)" SHM status, fixed string to date across DST thanks @bangali, null routines, integer trailing zero cast, saving large pistons and disappearing variables on mobile + * 08/06/2018 >>> v0.3.107.20180806 - BETA M3 - Font Awesome 5 icons, expanding textareas to fix expression scrolling, boolean date and datetime global variable editor fixes + * 07/31/2018 >>> v0.3.106.20180731 - BETA M3 - Contact Book removal support + * 06/28/2018 >>> v0.3.105.20180628 - BETA M3 - Reorder variables, collapse fuel streams, custom web request body, json and urlEncode functions + * 03/23/2018 >>> v0.3.104.20180323 - BETA M3 - Fixed unexpected dashboard logouts, updating image urls in tiles, 12 am/pm in time(), unary negation following another operator + * 02/24/2018 >>> v0.3.000.20180224 - BETA M3 - Dashboard redesign by @acd37, collapsible sidebar, fix "was" conditions on decimal attributes and log failures due to duration threshold + * 01/16/2018 >>> v0.2.102.20180116 - BETA M2 - Fixed IE 11 script error, display of offset expression evaluation, blank device lists on piston restore, avoid error and log a warning when ST sunrise/sunset is blank + * 12/27/2017 >>> v0.2.101.20171227 - BETA M2 - Fixed 172.x.x.x web requests thanks to @tbam, fixed array subscripting with 0.0 decimal value as in a for loop using $index + * 12/11/2017 >>> v0.2.100.20171211 - BETA M2 - Replaced the scheduler-based timeout recovery handling to ease up on resource usage + * 11/29/2017 >>> v0.2.0ff.20171129 - BETA M2 - Fixed missing conditions and triggers for several device attributes, new comparison group for binary files + * 11/09/2017 >>> v0.2.0fe.20171109 - BETA M2 - Fixed on events subscription for global and superglobal variables + * 11/05/2017 >>> v0.2.0fd.20171105 - BETA M2 - Further DST fixes + * 11/05/2017 >>> v0.2.0fc.20171105 - BETA M2 - DST fixes + * 10/26/2017 >>> v0.2.0fb.20171026 - BETA M2 - Partial support for super global variables - works within same location - no inter-location comms yet + * 10/11/2017 >>> v0.2.0fa.20171010 - BETA M2 - Various bug fixes and improvements - fixed the mid() and random() functions + * 10/07/2017 >>> v0.2.0f9.20171007 - BETA M2 - Added previous location attribute support and methods to calculate distance between places, people, fixed locations... + * 10/06/2017 >>> v0.2.0f8.20171006 - BETA M2 - Added support for Android geofence filtering depending on horizontal accuracy + * 10/04/2017 >>> v0.2.0f7.20171004 - BETA M2 - Added speed and bearing support + * 10/04/2017 >>> v0.2.0f6.20171004 - BETA M2 - Bug fixes for geofencing + * 10/04/2017 >>> v0.2.0f5.20171003 - BETA M2 - Bug fixes for geofencing + * 10/04/2017 >>> v0.2.0f4.20171003 - BETA M2 - Bug fixes for geofencing + * 10/03/2017 >>> v0.2.0f3.20171003 - BETA M2 - Bug fixes for geofencing + * 10/03/2017 >>> v0.2.0f2.20171003 - BETA M2 - Updated iOS app to add timestamps + * 10/01/2017 >>> v0.2.0f1.20171001 - BETA M2 - Added debugging options + * 09/30/2017 >>> v0.2.0f0.20170930 - BETA M2 - Added last update info for both geofences and location updates + * 09/30/2017 >>> v0.2.0ef.20170930 - BETA M2 - Minor fixes for Android + * 09/29/2017 >>> v0.2.0ed.20170929 - BETA M2 - Added support for Android presence + * 09/27/2017 >>> v0.2.0ec.20170927 - BETA M2 - Fixed a problem where the 'was' comparison would fail when the event had no device + * 09/25/2017 >>> v0.2.0eb.20170925 - BETA M2 - Added Sleep Sensor capability to the webCoRE Presence Sensor, thanks to @Cozdabuch and @bangali + * 09/24/2017 >>> v0.2.0ea.20170924 - BETA M2 - Fixed a problem where $nfl.schedule.thisWeek would only return one game, it now returns all games for the week. Same for lastWeek and nextWeek. + * 09/21/2017 >>> v0.2.0e9.20170921 - BETA M2 - Added support for the webCoRE Presence Sensor + * 09/18/2017 >>> v0.2.0e8.20170918 - BETA M2 - Alpha testing for presence + * 09/06/2017 >>> v0.2.0e7.20170906 - BETA M2 - Added support for the $nfl composite variable, fixed some bugs with boolean comparisons of null + * 08/30/2017 >>> v0.2.0e6.20170830 - BETA M2 - Minor fixes regarding some isNumber() errors and errors with static variables using non-defined variables, also updated installation to check for location/timezone setup + * 08/12/2017 >>> v0.2.0e5.20170812 - BETA M2 - Allowing global variables create device subscriptions (due to demand) + * 08/11/2017 >>> v0.2.0e4.20170811 - BETA M2 - Support for quick set of local variables + * 08/10/2017 >>> v0.2.0e3.20170810 - BETA M2 - Improved support for threeAxis and added support for axisX, axisY, and axisZ as decimal values + * 08/08/2017 >>> v0.2.0e2.20170808 - BETA M2 - Fixed a bug with time restrictions for conditions/triggers (not timers) where day of week, hour, etc. would be compared against UTC making edge comparisons fail (Sun 11pm would look like a Mon 3am for EST, therefore not on a Sunday anymore) + * 07/28/2017 >>> v0.2.0e1.20170728 - BETA M2 - Added the rainbowValue function to provide dynamic colors in a range + * 07/26/2017 >>> v0.2.0e0.20170726 - BETA M2 - Added support for rangeValue() which allows quick inline conversion of decimal ranges to values coresponding to them (i.e. translate level or temperature into a color) + * 07/25/2017 >>> v0.2.0df.20170725 - BETA M2 - Minor bug fixes and improvements - decimal display is now using a dynamic decimal place count + * 07/24/2017 >>> v0.2.0de.20170724 - BETA M2 - Minor fixes regarding lists and is_equal_to can now compare strings as well as numbers + * 07/22/2017 >>> v0.2.0dd.20170722 - BETA M2 - Added support for the Authentication header in HTTP(S) requests, support for image in local network requests (does not work yet) + * 07/22/2017 >>> v0.2.0dc.20170722 - BETA M2 - Progress towards bi-directional emails and support for storing media (paid feature) + * 07/17/2017 >>> v0.2.0db.20170717 - BETA M2 - Added two more functions abs(number) and hslToHex(hue(0-360°), saturation(0-100%), level(0-100%)), fixed a bug with LIFX when not passing a period + * 07/16/2017 >>> v0.2.0da.20170716 - BETA M2 - Fixed a bug where clearing tiles higher than 8 would not work + * 07/14/2017 >>> v0.2.0d9.20170714 - BETA M2 - Adds support for waiting on piston executions as long as the caller and callee are in the same webCoRE instance + * 07/13/2017 >>> v0.2.0d8.20170713 - BETA M2 - Fixes for orientation triggers, variable lists referenced with $index, a weird condition where negative numbers would be inverted to absolute values, extended tiles to 16 + * 07/13/2017 >>> v0.2.0d7.20170713 - BETA M2 - Unknown feature added to tiles + * 07/13/2017 >>> v0.2.0d6.20170713 - BETA M2 - Updated tiles to allow for multiple tiles and footers - this update breaks all previous tiles, sorry + * 07/12/2017 >>> v0.2.0d5.20170712 - BETA M2 - Bug fixes and fixed a bug that where piston tile state would not be preserved during a piston save + * 07/12/2017 >>> v0.2.0d4.20170712 - BETA M2 - Added categories support and piston tile support + * 07/11/2017 >>> v0.2.0d3.20170711 - BETA M2 - Lots of bug fixes and improvements + * 07/10/2017 >>> v0.2.0d2.20170710 - BETA M2 - Added long integer support to variables and fixed a bug where time comparisons would apply a previously set offset to custom times + * 07/08/2017 >>> v0.2.0d1.20170708 - BETA M2 - Added Piston recovery procedures to the main app + * 07/08/2017 >>> v0.2.0d0.20170708 - BETA M2 - Fixed a bug allowing the script to continue outside of timers, added Followed By support - basic tests performed + * 07/06/2017 >>> v0.2.0cf.20170706 - BETA M2 - Fix for parsing string date and times, implemented local http request response support - local web requests will wait for a response for up to 20 seconds - JSON response, if any, is available via $response + * 06/29/2017 >>> v0.2.0ce.20170629 - BETA M2 - Fix for broken time scheduling and device variables + * 06/29/2017 >>> v0.2.0cd.20170629 - BETA M2 - [DO NOT UPDATE UNLESS REQUESTED TO] - Adds typed list support + * 06/29/2017 >>> v0.2.0cc.20170629 - BETA M2 - Fixes to date, datetime, and time - datetime(string) was returning a 0, fixed it + * 06/26/2017 >>> v0.2.0cb.20170626 - BETA M2 - Minor bug fixes (including a fix with json data arrays), and added string functions trim, trimLeft/ltrim, and trimRight/rtrim + * 06/23/2017 >>> v0.2.0ca.20170623 - BETA M2 - Minor bug and fixes, UI support for followed by - SmartApp does not yet implement it + * 06/22/2017 >>> v0.2.0c9.20170622 - BETA M2 - Added orientation support (not fully tested) + * 06/22/2017 >>> v0.2.0c8.20170622 - BETA M2 - Improved support for JSON parsing, including support for named properties $json[element] - element can be an integer index, a variable name, or a string (no quotes), fixed a bug with Wait for time + * 06/21/2017 >>> v0.2.0c7.20170621 - BETA M2 - A bug fix for boolean and dynamic types - thoroughly inspect their values rather than rely on the data type + * 06/20/2017 >>> v0.2.0c6.20170620 - BETA M2 - Bug fix for timers - last time refactoring affected timers (timezone offset miscalculations) + * 06/20/2017 >>> v0.2.0c5.20170620 - BETA M2 - Refactored date and time to be more user friendly and consistent to their data type. Added formatDateTime - see https://docs.oracle.com/javase/tutorial/i18n/format/simpleDateFormat.html for more details + * 06/19/2017 >>> v0.2.0c4.20170619 - BETA M2 - Fixed a bug with LIFX scenes, added more functions: weekDayName, monthName, arrayItem + * 06/18/2017 >>> v0.2.0c3.20170618 - BETA M2 - Added more LIFX methods like set, toggle, breath, pulse + * 06/16/2017 >>> v0.2.0c2.20170616 - BETA M2 - Added support for lock codes, physical interaction + * 06/16/2017 >>> v0.2.0c1.20170616 - BETA M2 - Added support for the emulated $status device attribute, cancel all pending tasks, allow pre-scheduled tasks to execute during restrictions + * 06/14/2017 >>> v0.2.0c0.20170614 - BETA M2 - Added support for $weather and external execution of pistons + * 06/14/2017 >>> v0.2.0bf.20170614 - BETA M2 - Some fixes (typo found by @DThompson10), added support for JSON arrays, as well as Parse JSON data task + * 06/13/2017 >>> v0.2.0be.20170613 - BETA M2 - 0be happy - capture/restore is here + * 06/12/2017 >>> v0.2.0bd.20170612 - BETA M2 - More bug fixes, work started on capture/restore, DO NOT USE them yet + * 06/11/2017 >>> v0.2.0bc.20170611 - BETA M2 - More bug fixes + * 06/09/2017 >>> v0.2.0bb.20170609 - BETA M2 - Added support for the webCoRE Connector - an easy way for developers to integrate with webCoRE + * 06/09/2017 >>> v0.2.0ba.20170609 - BETA M2 - More bug fixes + * 06/08/2017 >>> v0.2.0b9.20170608 - BETA M2 - Added location mode, SHM mode and hub info to the dashboard + * 06/07/2017 >>> v0.2.0b8.20170607 - BETA M2 - Movin' on up + * 06/03/2017 >>> v0.1.0b7.20170603 - BETA M1 - Even more bug fixes - fixed issues with cancel on piston state change, rescheduling timers when ST decides to run early + * 06/02/2017 >>> v0.1.0b6.20170602 - BETA M1 - More bug fixes + * 05/31/2017 >>> v0.1.0b5.20170531 - BETA M1 - Bug fixes + * 05/31/2017 >>> v0.1.0b4.20170531 - BETA M1 - Implemented $response and the special $response. variables to read response data from HTTP requests + * 05/30/2017 >>> v0.1.0b3.20170530 - BETA M1 - Various speed improvements - MAY BREAK THINGS + * 05/30/2017 >>> v0.1.0b2.20170530 - BETA M1 - Various fixes, added IFTTT query string params support in $args + * 05/24/2017 >>> v0.1.0b1.20170524 - BETA M1 - Fixes regarding trigger initialization and a situation where time triggers may cancel tasks that should not be cancelled + * 05/23/2017 >>> v0.1.0b0.20170523 - BETA M1 - Minor fixes and improvements to command optimizations + * 05/22/2017 >>> v0.1.0af.20170522 - BETA M1 - Minor fixes (stays away from trigger, contacts not found, etc.), implemented Command Optimizations (turned on by default) and Flash + * 05/22/2017 >>> v0.1.0ae.20170522 - BETA M1 - Minor fix for very small decimal numbers + * 05/19/2017 >>> v0.1.0ad.20170519 - BETA M1 - Various bug fixes, including broken while loops with a preceeding exit statement (exit and break statements conflicted with async runs) + * 05/18/2017 >>> v0.1.0ac.20170518 - BETA M1 - Preparing the grounds for advanced engine blocks + * 05/17/2017 >>> v0.1.0ab.20170517 - BETA M1 - Fixed a bug affecting some users, regarding the new LIFX integration + * 05/17/2017 >>> v0.1.0aa.20170517 - BETA M1 - Added egress LIFX integration + * 05/17/2017 >>> v0.1.0a9.20170517 - BETA M1 - Added egress IFTTT integration + * 05/16/2017 >>> v0.1.0a8.20170516 - BETA M1 - Improved emoji support + * 05/15/2017 >>> v0.1.0a7.20170515 - BETA M1 - Added a way to test pistons from the UI - Fixed a bug in UI values where decimal values were converted to integers - those values need to be re-edited to be fixed + * 05/12/2017 >>> v0.1.0a6.20170512 - BETA M1 - Pistons can now (again) access devices stored in global variables + * 05/11/2017 >>> v0.1.0a5.20170511 - BETA M1 - Fixed a bug with time scheduling offsets + * 05/09/2017 >>> v0.1.0a4.20170509 - BETA M1 - Many structural changes to fix issues like startup-spin-up-time for instances having a lot of devices, as well as wrong name displayed in the device's Recent activity tab. New helper app added, needs to be installed/published. Pause/Resume of all active pistons is required. + * 05/09/2017 >>> v0.1.0a3.20170509 - BETA M1 - DO NOT INSTALL THIS UNLESS ASKED TO - IT WILL BREAK YOUR ENVIRONMENT - IF YOU DID INSTALL IT, DO NOT GO BACK TO A PREVIOUS VERSION + * 05/07/2017 >>> v0.1.0a2.20170507 - BETA M1 - Added the random() expression function. + * 05/06/2017 >>> v0.1.0a1.20170506 - BETA M1 - Kill switch was a killer. Killed it. + * 05/05/2017 >>> v0.1.0a0.20170505 - BETA M1 - Happy Cinco de Mayo + * 05/03/2017 >>> v0.1.09e.20170503 - BETA M1 - Added the formatDuration function, added volume to playText, playTextAndResume, and playTextAndRestore + * 05/03/2017 >>> v0.1.09d.20170503 - BETA M1 - Fixed a problem where async blocks inside async blocks were not working correctly. + * 05/03/2017 >>> v0.1.09c.20170503 - BETA M1 - Fixes for race conditions where a second almost simultaneous event would miss cache updates from the first event, also improvements on timeout recovery + * 05/02/2017 >>> v0.1.09b.20170502 - BETA M1 - Fixes for async elements as well as setColor hue inconsistencies + * 05/01/2017 >>> v0.1.09a.20170501 - BETA M1 - Some visual UI fixes, added ternary operator support in expressions ( condition ? trueValue : falseValue ) - even with Groovy-style support for ( object ?: falseValue) + * 05/01/2017 >>> v0.1.099.20170501 - BETA M1 - Lots of fixes and improvements - expressions now accept more logical operators like !, !!, ==, !=, <, >, <=, >= and some new math operators like \ (integer division) and % (modulo) + * 04/30/2017 >>> v0.1.098.20170430 - BETA M1 - Minor bug fixes + * 04/29/2017 >>> v0.1.097.20170429 - BETA M1 - First Beta Milestone 1! + * 04/29/2017 >>> v0.0.096.20170429 - ALPHA - Various bug fixes, added options to disable certain statements, as per @eibyer's original idea and @RobinWinbourne's annoying persistance :) + * 04/29/2017 >>> v0.0.095.20170429 - ALPHA - Fully implemented the on event statements + * 04/28/2017 >>> v0.0.094.20170428 - ALPHA - Fixed a bug preventing timers from scheduling properly. Added the on statement and the do statement + * 04/28/2017 >>> v0.0.093.20170428 - ALPHA - Fixed bugs (piston state issues, time condition schedules ignored offsets). Implemented more virtual commands (the fade suite) + * 04/27/2017 >>> v0.0.092.20170427 - ALPHA - Added time trigger happens daily at... + * 04/27/2017 >>> v0.0.091.20170427 - ALPHA - Various improvements and fixes + * 04/26/2017 >>> v0.0.090.20170426 - ALPHA - Minor fixes for variables and the eq() function + * 04/26/2017 >>> v0.0.08f.20170426 - ALPHA - Implemented $args and the special $args. variables to read arguments from events. Bonus: ability to parse JSON data to read subitem by using $args.item.subitem (no array support yet) + * 04/26/2017 >>> v0.0.08e.20170426 - ALPHA - Implemented Send notification to contacts + * 04/26/2017 >>> v0.0.08d.20170426 - ALPHA - Timed triggers should now play nice with multiple devices (any/all) + * 04/25/2017 >>> v0.0.08c.20170425 - ALPHA - Various fixes and improvements and implemented custom commands with parameters + * 04/24/2017 >>> v0.0.08b.20170424 - ALPHA - Fixed a bug preventing subscription to IFTTT events + * 04/24/2017 >>> v0.0.08a.20170424 - ALPHA - Implemented Routine/AskAlexa/EchoSistant/IFTTT integrations - arguments (where available) are not processed yet - not tested + * 04/24/2017 >>> v0.0.089.20170424 - ALPHA - Added variables in conditions and matching/non-matching device variable output + * 04/23/2017 >>> v0.0.088.20170423 - ALPHA - Time condition offsets + * 04/23/2017 >>> v0.0.087.20170423 - ALPHA - Timed triggers (stay/stays) implemented - need additional work to get them to play nicely with "Any of devices stays..." - this never worked in CoRE, but proved to might-have-been-helpful + * 04/23/2017 >>> v0.0.086.20170423 - ALPHA - Subscriptions to @global variables + * 04/22/2017 >>> v0.0.085.20170422 - ALPHA - Fixed a bug with virtual device options + * 04/22/2017 >>> v0.0.084.20170422 - ALPHA - NFL integration complete LOL (not really, implemented global variables though) + * 04/21/2017 >>> v0.0.083.20170421 - ALPHA - Fixed a bug introduced during device-typed variable refactoring, $currentEventDevice was not properly stored as a List of device Ids + * 04/21/2017 >>> v0.0.082.20170421 - ALPHA - Fixed a pseudo-bug where older pistons (created before some parameters were added) are missing some operands and that causes errors during evaluations + * 04/21/2017 >>> v0.0.081.20170421 - ALPHA - Fixed a bug preventing a for-each to work with device-typed variables + * 04/21/2017 >>> v0.0.080.20170421 - ALPHA - Fixed a newly introduced bug where function parameters were parsed as strings, also fixed functions time, date, and datetime's timezone + * 04/21/2017 >>> v0.0.07f.20170421 - ALPHA - Fixed an inconsistency in setting device variable (array) - this was in the UI and may require resetting the variables + * 04/21/2017 >>> v0.0.07e.20170421 - ALPHA - Fixed a bug with local variables introduced in 07d + * 04/21/2017 >>> v0.0.07d.20170421 - ALPHA - Lots of improvements for device variables + * 04/20/2017 >>> v0.0.07c.20170420 - ALPHA - Timed conditions are finally working (was* and changed/not changed), basic tests performed + * 04/19/2017 >>> v0.0.07b.20170419 - ALPHA - First attempt to get 'was' conditions up and running + * 04/19/2017 >>> v0.0.07a.20170419 - ALPHA - Minor bug fixes, triggers inside timers no longer subscribe to events (the timer is a trigger itself) - triggers should not normally be used inside timers + * 04/19/2017 >>> v0.0.079.20170419 - ALPHA - Time condition restrictions are now working, added date and date&time conditions, offsets still missing + * 04/18/2017 >>> v0.0.078.20170418 - ALPHA - Time conditions now subscribe for time events - added restrictions to UI dialog, but not yet implemented + * 04/18/2017 >>> v0.0.077.20170418 - ALPHA - Implemented time conditions - no date or datetime yet, also, no subscriptions for time events yet + * 04/18/2017 >>> v0.0.076.20170418 - ALPHA - Implemented task mode restrictions and added setColor using HSL + * 04/17/2017 >>> v0.0.075.20170417 - ALPHA - Fixed a problem with $sunrise and $sunset pointing to the wrong date + * 04/17/2017 >>> v0.0.074.20170417 - ALPHA - Implemented HTTP requests, importing response data not working yet, need to figure out a way to specify what data goes into which variables + * 04/17/2017 >>> v0.0.073.20170417 - ALPHA - isBetween fix - use three params, not two, thanks to @c1arkbar + * 04/16/2017 >>> v0.0.072.20170416 - ALPHA - Quick fix for isBetween + * 04/16/2017 >>> v0.0.071.20170416 - ALPHA - Added the ability to execute routines + * 04/16/2017 >>> v0.0.070.20170416 - ALPHA - Added support for multiple-choice comparisons (any of), added more improvements like the ability to disable event subscriptions (follow up pistons) + * 04/15/2017 >>> v0.0.06f.20170415 - ALPHA - Fix for wait for date&time + * 04/15/2017 >>> v0.0.06e.20170415 - ALPHA - Attempt to fix a race condition where device value would change before we even executed - using event's value instead + * 04/15/2017 >>> v0.0.06d.20170415 - ALPHA - Various fixes and improvements, added the ability to execute pistons in the same location (arguments not working yet) + * 04/15/2017 >>> v0.0.06c.20170415 - ALPHA - Fixed a bug with daily timers and day of week restrictions + * 04/14/2017 >>> v0.0.06b.20170414 - ALPHA - Added more functions: date(value), time(value), if(condition, valueIfTrue, valueIfFalse), not(value), isEmpty(value), addSeconds(dateTime, seconds), addMinutes(dateTime, minutes), addHours(dateTime, hours), addDays(dateTime, days), addWeeks(dateTime, weeks) + * 04/14/2017 >>> v0.0.06a.20170414 - ALPHA - Fixed a bug where multiple timers would cancel each other's actions out, implemented (not extensively tested yet) the TCP and TEP + * 04/13/2017 >>> v0.0.069.20170413 - ALPHA - Various bug fixes and improvements + * 04/12/2017 >>> v0.0.068.20170412 - ALPHA - Fixed a bug with colors from presets + * 04/12/2017 >>> v0.0.067.20170412 - ALPHA - Fixed a bug introduced in 066 and implemented setColor + * 04/12/2017 >>> v0.0.066.20170412 - ALPHA - Fixed hourly timers and implemented setInfraredLevel, setHue, setSaturation, setColorTemperature + * 04/11/2017 >>> v0.0.065.20170411 - ALPHA - Fix for long waits being converted to scientific notation, causing the scheduler to misunderstand them and wait 1ms instead + * 04/11/2017 >>> v0.0.064.20170411 - ALPHA - Fix for timer restrictions error + * 04/11/2017 >>> v0.0.063.20170411 - ALPHA - Some fixes for timers, implemented all timers, implemented all timer restrictions. + * 04/10/2017 >>> v0.0.062.20170410 - ALPHA - Some fixes for timers, implemented all timers, their restrictions still not active. + * 04/07/2017 >>> v0.0.061.20170407 - ALPHA - Some fixes for timers (waits inside timers) and implemented weekly timers. Months/years not working yet. Should be more stable. + * 04/06/2017 >>> v0.0.060.20170406 - ALPHA - Timers for second/minute/hour/day are in. week/month/year not working yet. May be VERY quirky, still. * 03/30/2017 >>> v0.0.05f.20170329 - ALPHA - Attempt to fix setLocation, added Twilio integration (dialog support coming soon) + * 03/30/2017 >>> v0.0.05f.20170329 - ALPHA - Attempt to fix setLocation, added Twilio integration (dialog support coming soon) + * 03/29/2017 >>> v0.0.05e.20170329 - ALPHA - Added sendEmail + * 03/29/2017 >>> v0.0.05d.20170329 - ALPHA - Minor typo fixes, thanks to @rayzurbock + * 03/28/2017 >>> v0.0.05c.20170328 - ALPHA - Minor fixes regarding location subscriptions + * 03/28/2017 >>> v0.0.05b.20170328 - ALPHA - Minor fixes for setting location mode + * 03/27/2017 >>> v0.0.05a.20170327 - ALPHA - Minor fixes - location events do not have a device by default, overriding with location + * 03/27/2017 >>> v0.0.059.20170327 - ALPHA - Completed SHM status and location mode. Can get/set, can subscribe to changes, any existing condition in pistons needs to be revisited and fixed + * 03/25/2017 >>> v0.0.058.20170325 - ALPHA - Fixes for major issues introduced due to the new comparison editor (you need to re-edit all comparisons to fix them), added log multiline support, use \r or \n or \r\n in a string + * 03/24/2017 >>> v0.0.057.20170324 - ALPHA - Improved installation experience, preventing direct installation of child app, location mode and shm status finally working + * 03/23/2017 >>> v0.0.056.20170323 - ALPHA - Various fixes for restrictions + * 03/22/2017 >>> v0.0.055.20170322 - ALPHA - Various improvements, including a revamp of the comparison dialog, also moved the dashboard website to https://dashboard.webcore.co + * 03/21/2017 >>> v0.0.054.20170321 - ALPHA - Moved the dashboard website to https://webcore.homecloudhub.com/dashboard/ + * 03/21/2017 >>> v0.0.053.20170321 - ALPHA - Fixed a bug where variables containing expressions would be cast to the variable type outside of evaluateExpression (the right way) + * 03/20/2017 >>> v0.0.052.20170320 - ALPHA - Fixed $shmStatus + * 03/20/2017 >>> v0.0.051.20170320 - ALPHA - Fixed a problem where start values for variables would not be correctly picked up from atomicState (used state by mistake) + * 03/20/2017 >>> v0.0.050.20170320 - ALPHA - Introducing parallelism, a semaphore mechanism to allow synchronization of multiple simultaneous executions, disabled by default (pistons wait at a semaphore) + * 03/20/2017 >>> v0.0.04f.20170320 - ALPHA - Minor fixes for device typed variables (lost attribute) and counter variable in for each + * 03/20/2017 >>> v0.0.04e.20170320 - ALPHA - Major operand/expression/cast refactoring to allow for arrays of devices - may break things. Also introduced for each loops and actions on device typed variables + * 03/19/2017 >>> v0.0.04d.20170319 - ALPHA - Fixes for functions and device typed variables + * 03/19/2017 >>> v0.0.04c.20170319 - ALPHA - Device typed variables now enabled - not yet possible to use them in conditions or in actions, but getting there + * 03/18/2017 >>> v0.0.04b.20170318 - ALPHA - Various fixes + * 03/18/2017 >>> v0.0.04a.20170318 - ALPHA - Enabled manual piston status and added the set piston status task as well as the exit statement + * 03/18/2017 >>> v0.0.049.20170318 - ALPHA - Third attempt to fix switch + * 03/18/2017 >>> v0.0.048.20170318 - ALPHA - Second attempt to fix switch fallbacks with wait breaks, wait in secondary cases were not working + * 03/18/2017 >>> v0.0.047.20170318 - ALPHA - Attempt to fix switch fallbacks with wait breaks + * 03/18/2017 >>> v0.0.046.20170318 - ALPHA - Various critical fixes - including issues with setLevel without a required state + * 03/18/2017 >>> v0.0.045.20170318 - ALPHA - Fixed a newly introduced bug for Toggle (missing parameters) + * 03/17/2017 >>> v0.0.044.20170317 - ALPHA - Cleanup ghost else-ifs on piston save + * 03/17/2017 >>> v0.0.043.20170317 - ALPHA - Added "View piston in dashboard" to child app UI + * 03/17/2017 >>> v0.0.042.20170317 - ALPHA - Various fixes and enabled restrictions - UI for conditions and restrictions needs refactoring to use the new operand editor + * 03/16/2017 >>> v0.0.041.20170316 - ALPHA - Various fixes + * 03/16/2017 >>> v0.0.040.20170316 - ALPHA - Fixed a bug where optional parameters were not correctly interpreted, leading to setLevel not working, added functions startsWith, endsWith, contains, eq, le, lt, ge, gt + * 03/16/2017 >>> v0.0.03f.20170316 - ALPHA - Completely refactored task parameters and enabled variables. Dynamically assigned variables act as functions - it can be defined as an expression and reuse it in lieu of that expression + * 03/15/2017 >>> v0.0.03e.20170315 - ALPHA - Various improvements + * 03/14/2017 >>> v0.0.03d.20170314 - ALPHA - Fixed a bug with caching operands for triggers + * 03/14/2017 >>> v0.0.03c.20170314 - ALPHA - Fixed a bug with switches + * 03/14/2017 >>> v0.0.03b.20170314 - ALPHA - For statement finally getting some love + * 03/14/2017 >>> v0.0.03a.20170314 - ALPHA - Added more functions (age, previousAge, newer, older, previousValue) and fixed a bug where operand caching stopped working after earlier code refactorings + * 03/13/2017 >>> v0.0.039.20170313 - ALPHA - The Switch statement should now be functional - UI validation not fully done + * 03/12/2017 >>> v0.0.038.20170312 - ALPHA - Traversing else ifs and else statements in search for devices to subscribe to + * 03/12/2017 >>> v0.0.037.20170312 - ALPHA - Added support for break and exit (partial, piston state is not set on exit) - fixed some comparison data type incompatibilities + * 03/12/2017 >>> v0.0.036.20170312 - ALPHA - Added TCP = cancel on condition change and TOS = Action - no other values implemented yet, also, WHILE loops are now working, please remember to add a WAIT in it... + * 03/11/2017 >>> v0.0.035.20170311 - ALPHA - A little error creeped into the conditions, fixed it + * 03/11/2017 >>> v0.0.034.20170311 - ALPHA - Multiple device selection aggregation now working properly. COUNT(device list's contact) rises above 1 will be true when at least two doors in the list are open :D + * 03/11/2017 >>> v0.0.033.20170311 - ALPHA - Implemented all conditions except "was..." and all triggers except "stays..." + * 03/11/2017 >>> v0.0.032.20170311 - ALPHA - Fixed setLevel null params and added version checking + * 03/11/2017 >>> v0.0.031.20170310 - ALPHA - Various fixes including null optional parameters, conditional groups, first attempt at piston restrictions (statement restrictions not enabled yet), fixed a problem with subscribing device bolt indicators only showing for one instance of each device/attribute pair, fixed sendPushNotification + * 03/10/2017 >>> v0.0.030.20170310 - ALPHA - Fixed a bug in scheduler introduced in 02e/02f + * 03/10/2017 >>> v0.0.02f.20170310 - ALPHA - Various improvements, added toggle and toggleLevel + * 03/10/2017 >>> v0.0.02e.20170310 - ALPHA - Fixed a problem where long expiration settings prevented logins (integer overflow) + * 03/10/2017 >>> v0.0.02d.20170310 - ALPHA - Reporting version to JS + * 03/10/2017 >>> v0.0.02c.20170310 - ALPHA - Various improvements and a new virtual command: Log to console. Powerful. + * 03/10/2017 >>> v0.0.02b.20170310 - ALPHA - Implemented device versioning to correctly handle multiple browsers accessing the same dashboard after a device selection was performed, enabled security token expiry + * 03/09/2017 >>> v0.0.02a.20170309 - ALPHA - Fixed parameter issues, added support for expressions in all parameters, added notification virtual tasks + * 03/09/2017 >>> v0.0.029.20170309 - ALPHA - More execution flow fixes, sticky trace lines fixed + * 03/08/2017 >>> v0.0.028.20170308 - ALPHA - Scheduler fixes + * 03/08/2017 >>> v0.0.027.20170308 - ALPHA - Very early implementation of wait/delay scheduling, needs extensive testing + * 03/08/2017 >>> v0.0.026.20170308 - ALPHA - More bug fixes, trace enhancements + * 03/07/2017 >>> v0.0.025.20170307 - ALPHA - Improved logs and traces, added basic time event handler + * 03/07/2017 >>> v0.0.024.20170307 - ALPHA - Improved logs (reverse order and live updates) and added trace support + * 03/06/2017 >>> v0.0.023.20170306 - ALPHA - Added logs to the dashboard + * 03/05/2017 >>> v0.0.022.20170305 - ALPHA - Some tasks are now executed. UI has an issue with initializing params on editing a task, will get fixed soon. + * 03/01/2017 >>> v0.0.021.20170301 - ALPHA - Most conditions (and no triggers yet) are now parsed and evaluated during events - action tasks not yet executed, but getting close, very close + * 02/28/2017 >>> v0.0.020.20170228 - ALPHA - Added runtime data - pistons are now aware of devices and global variables - expressions can query devices and variables (though not all system variables are ready yet) + * 02/27/2017 >>> v0.0.01f.20170227 - ALPHA - Added support for a bunch more functions + * 02/27/2017 >>> v0.0.01e.20170227 - ALPHA - Fixed a bug in expression parser where integer + integer would result in a string + * 02/27/2017 >>> v0.0.01d.20170227 - ALPHA - Made progress evaluating expressions + * 02/24/2017 >>> v0.0.01c.20170224 - ALPHA - Added functions support to main app + * 02/06/2017 >>> v0.0.01b.20170206 - ALPHA - Fixed a problem with selecting thermostats + * 02/01/2017 >>> v0.0.01a.20170201 - ALPHA - Updated comparisons + * 01/30/2017 >>> v0.0.019.20170130 - ALPHA - Improved comparisons - ouch + * 01/29/2017 >>> v0.0.018.20170129 - ALPHA - Fixed a conditions where devices would not be sent over to the UI + * 01/28/2017 >>> v0.0.017.20170128 - ALPHA - Incremental update + * 01/27/2017 >>> v0.0.016.20170127 - ALPHA - Minor compatibility fixes + * 01/27/2017 >>> v0.0.015.20170127 - ALPHA - Updated capabilities, attributes, commands and refactored them into maps + * 01/26/2017 >>> v0.0.014.20170126 - ALPHA - Progress getting comparisons to work + * 01/25/2017 >>> v0.0.013.20170125 - ALPHA - Implemented the author field and more improvements to the piston editor + * 01/23/2017 >>> v0.0.012.20170123 - ALPHA - Implemented the "delete" piston + * 01/23/2017 >>> v0.0.011.20170123 - ALPHA - Fixed a bug where account id was not hashed + * 01/23/2017 >>> v0.0.010.20170123 - ALPHA - Duplicate piston and restore from automatic backup :) + * 01/23/2017 >>> v0.0.00f.20170123 - ALPHA - Automatic backup to myjson.com is now enabled. Restore is not implemented yet. + * 01/22/2017 >>> v0.0.00e.20170122 - ALPHA - Enabled device cache on main app to speed up dashboard when using large number of devices + * 01/22/2017 >>> v0.0.00d.20170122 - ALPHA - Optimized data usage for piston JSON class (might have broken some things), save now works + * 01/21/2017 >>> v0.0.00c.20170121 - ALPHA - Made more progress towards creating new pistons + * 01/21/2017 >>> v0.0.00b.20170121 - ALPHA - Made progress towards creating new pistons + * 01/20/2017 >>> v0.0.00a.20170120 - ALPHA - Fixed a problem with dashboard URL and shards other than na01 + * 01/20/2017 >>> v0.0.009.20170120 - ALPHA - Reenabled the new piston UI at new URL + * 01/20/2017 >>> v0.0.008.20170120 - ALPHA - Enabled html5 routing and rewrite to remove the /#/ contraption + * 01/20/2017 >>> v0.0.007.20170120 - ALPHA - Cleaned up CoRE ST UI and removed "default" theme from URL. + * 01/19/2017 >>> v0.0.006.20170119 - ALPHA - UI is now fully moved and security enabled - security password is now required + * 01/18/2017 >>> v0.0.005.20170118 - ALPHA - Moved UI to homecloudhub.com and added support for pretty url (core.homecloudhub.com) and web+core:// handle + * 01/17/2017 >>> v0.0.004.20170117 - ALPHA - Updated to allow multiple instances + * 01/17/2017 >>> v0.0.003.20170117 - ALPHA - Improved security, object ids are hashed, added multiple-location-multiple-instance support (CoRE will be able to work across multiple location and installed instances) + * 12/02/2016 >>> v0.0.002.20161202 - ALPHA - Small progress, Add new piston now points to the piston editor UI + * 10/28/2016 >>> v0.0.001.20161028 - ALPHA - Initial release + */ + +/******************************************************************************/ +/*** webCoRE DEFINITION ***/ +/******************************************************************************/ +private static String handle() { return "webCoRE" } +private static String domain() { return "webcore.co" } +include 'asynchttp_v1' +definition( + name: "${handle()}", + namespace: "ady624", + author: "Adrian Caramaliu", + description: "Tap here to install ${handle()} ${version()}", + category: "Convenience", + singleInstance: false, + /* icons courtesy of @chauger - thank you */ + iconUrl: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/app-CoRE.png", + iconX2Url: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/app-CoRE@2x.png", + iconX3Url: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/app-CoRE@3x.png" +) + + +preferences { + //UI pages + page(name: "pageMain") + page(name: "pageDisclaimer") + page(name: "pageEngineBlock") + page(name: "pageInitializeDashboard") + page(name: "pageFinishInstall") + page(name: "pageSelectDevices") + page(name: "pageSettings") + page(name: "pageChangePassword") + page(name: "pageSavePassword") + page(name: "pageRebuildCache") + page(name: "pageRemove") +} + + +/******************************************************************************/ +/*** webCoRE CONSTANTS ***/ +/******************************************************************************/ + + +/******************************************************************************/ +/*** ***/ +/*** CONFIGURATION PAGES ***/ +/*** ***/ +/******************************************************************************/ + +/******************************************************************************/ +/*** COMMON PAGES ***/ +/******************************************************************************/ +def pageMain() { + //webCoRE Dashboard initialization + def success = initializeWebCoREEndpoint() + if (!state.installed) { + return dynamicPage(name: "pageMain", title: "", install: false, uninstall: false, nextPage: "pageInitializeDashboard") { + section() { + paragraph "Welcome to ${handle()}" + paragraph "You will be guided through a few installation steps that should only take a minute." + } + if (success) { + if (!state.oAuthRequired) { + section('Note') { + paragraph "If you have previously installed ${handle()} and are trying to open it, please go back to the Automations tab and access ${handle()} from the SmartApps section.\r\n\r\nIf you are trying to install another instance of ${handle()} then please continue with the steps.", required: true + } + } + if (location.getTimeZone()) { + section() { + paragraph "It looks like you are ready to go, please tap Next" + } + } else { + section() { + paragraph "Your location is not correctly setup." + } + pageSectionTimeZoneInstructions() + } + } else { + section() { + paragraph "We'll start by configuring the dashboard. You need to setup OAuth in the SmartThings IDE for the ${handle()} SmartApp." + } + pageSectionInstructions() + section () { + paragraph "Once you have finished the steps above, tap Next", required: true + } + } + } + } + //webCoRE main page + dynamicPage(name: "pageMain", title: "", install: true, uninstall: false) { + if (settings.agreement == undefined) { + pageSectionDisclaimer() + } + + if (settings.agreement) { + section("Engine block") { + href "pageEngineBlock", title: "Cast iron", description: app.version(), image: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/app-CoRE.png", required: false + } + } + + section("Dashboard") { + if (!state.endpoint) { + href "pageInitializeDashboard", title: "Dashboard", description: "Tap to initialize", image: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/dashboard.png", required: false + } else { + //trace "*** DO NOT SHARE THIS LINK WITH ANYONE *** Dashboard URL: ${getDashboardInitUrl()}" + href "", title: "Dashboard", style: "external", url: getDashboardInitUrl(), description: "Tap to open", image: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/dashboard.png", required: false + href "", title: "Register a browser", style: "embedded", url: getDashboardInitUrl(true), description: "Tap to open", image: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/browser-reg.png", required: false + } + } + + section(title:"Settings") { + href "pageSettings", title: "Settings", image: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/settings.png", required: false + } + + } +} + +private pageSectionDisclaimer() { + section('Disclaimer') { + paragraph "Please read the following information carefully", required: true + paragraph "webCoRE is a web-enabled product, which means data travels across the internet. webCoRE is using TLS for encryption of data and NEVER provides real object IDs to any system outside of the SmartThings ecosystem. The IDs are hashed into a string of letters and numbers that cannot be 'decoded' back to their original value. These hashed IDs are stored by your browser and can be cleaned up by using the Logout action under the dashboard." + paragraph "Access to a webCoRE SmartApp is done through the browser using a security password provided during the installation of webCoRE. The browser never stores this password and it is only used during the initial registration and authentication of your browser. A security token is generated for each browser and is used for any subsequent communication. This token expires at a preset life length, or when the password is changed, or when the tokens are manually revoked from the webCoRE SmartApp's Settings menu." + } + section('Server-side features') { + paragraph "Some features require that a webcore.co server processes your data. Such features include emails (sending emails out, or triggering pistons with emails), inter-location communication for superglobal variables, fuel streams, backup bins." + paragraph "At no time does the server receive any real IDs of SmartThings objects, the instance security password, nor the instance security token that your browser uses to communicate with the SmartApp. The server is therefore unable to access any information that only an authenticated browser can." + } + section('Information collected by the server') { + paragraph "The webcore.co server(s) collect ANONYMIZED hashes of 1) your unique account identifier, 2) your locations, and 3) installed webCoRE instances. It also collects an encrypted version of your SmartApp instances' endpoints that allow the server to trigger pistons on emails (if you use that feature), proxy IFTTT requests to your pistons, or provide inter-location communication between your webCoRE instances, as well as data points provided by you when using the Fuel Stream feature. It also allows for automatic browser registration when you use another browser, by providing that browser basic information about your existing instances. You will still need to enter the password to access each of those instances, the server does not have the password, nor the security tokens." + } + section('Information NOT collected by the server') { + paragraph "The webcore.co server(s) do NOT intentionally collect any real object IDs from SmartThings, any names, phone numbers, email addresses, physical location information, addresses, or any other personally identifiable information." + } + section('Fuel Streams') { + paragraph "The information you provide while using the Fuel Stream feature is not encrypted and is not filtered in any way. Please avoid providing personally identifiable information in either the canister name, the fuel stream name, or the data point." + } + section('Agreement') { + paragraph "Certain advanced features may not work if you do not agree to the webcore.co servers collecting the anonymized information described above." + input "agreement", "bool", title: "Allow webcore.co to collect basic, anonymized, non-personally identifiable information", defaultValue: true + } +} + +private pageDisclaimer() { + dynamicPage(name: "pageDisclaimer", title: "") { + pageSectionDisclaimer() + } +} + +private pageSectionInstructions() { + state.oAuthRequired = true + section () { + paragraph "Please follow these steps:", required: true + paragraph "1. Go to your SmartThings IDE and log in", required: true + paragraph "2. Click on 'My SmartApps' and locate the 'ady624 : ${handle()}' SmartApp in the list", required: true + paragraph "3. Click the 'Edit Properties' button to the left of the SmartApp name (a notepad and pencil icon)", required: true + paragraph "4. Click on 'OAuth'", required: true + paragraph "5. Click the 'Enable OAuth in Smart App' button", required: true + paragraph "6. Click the 'Update' button", required: true + } +} + +private pageSectionTimeZoneInstructions() { + section () { + paragraph "Please follow these steps to setup your location timezone:", required: true + paragraph "1. Using your SmartThings mobile app, abort this installation and go to More section of the app (three horizontal bars)", required: true + paragraph "2. Click on the gear icon on the top right", required: true + paragraph "3. Click on the map to edit your location", required: true + paragraph "4. Find your location on the map and place the pin there, adjusting the desired radius", required: true + paragraph "5. Tap the Save button, then tap Done", required: true + paragraph "6. Try installing ${handle()} again", required: true + } +} + +private pageInitializeDashboard() { + //webCoRE Dashboard initialization + def success = initializeWebCoREEndpoint() + def hasTZ = !!location.getTimeZone() + dynamicPage(name: "pageInitializeDashboard", title: "", nextPage: success && hasTZ ? "pageSelectDevices" : null) { + if (!state.installed) { + if (success) { + if (hasTZ) { + section() { + paragraph "Great, the dashboard is ready to go." + } + section() { + paragraph "Now, please choose a name for this ${handle()} instance" + //label name: "name", title: "Name", defaultValue: "webCoRE", required: false + label name: "name", title: "Name", state: (name ? "complete" : null), defaultValue: app.name, required: false + + } + + pageSectionDisclaimer() + + section() { + paragraph "${state.installed ? "Tap Done to continue." : "Next, choose a security password for your dashboard. You will need to enter this password when accessing your dashboard for the first time, and possibly from time to time, depending on your settings."}", required: false + } + } else { + section() { + paragraph "Your location is not correctly setup." + } + pageSectionTimeZoneInstructions() + section () { + paragraph "Once you have finished the steps above, go back and try again", required: true + } + return + } + } else { + section() { + paragraph "Sorry, it looks like OAuth is not properly enabled." + } + pageSectionInstructions() + section () { + paragraph "Once you have finished the steps above, go back and try again", required: true + } + return + } + } + pageSectionPIN() + } +} + +private pageEngineBlock() { + dynamicPage(name: "pageEngineBlock", title: "") { + section() { + paragraph "Under construction. This will help you upgrade your engine block to get access to extra features such as email triggers, fuel streams, and more." + } + } +} + + +private pageSelectDevices() { + state.deviceVersion = now().toString() + dynamicPage(name: "pageSelectDevices", title: "", nextPage: state.installed ? null : "pageFinishInstall") { + section() { + paragraph "${state.installed ? "Select the devices you want ${handle()} to have access to." : "Great, now let's select some devices."}" + paragraph "It is a good idea to only select the devices you plan on using with ${handle()} pistons. Pistons will only have access to the devices you selected." + } + if (!state.installed) { + section (Note) { + paragraph "Remember, you can always come back to ${handle()} and add or remove devices as needed.", required: true + } + section() { + paragraph "So go ahead, select a few devices, then tap Next" + } + } + + section ('Select devices by type') { + paragraph "Most devices should fall into one of these two categories" + input "dev:actuator", "capability.actuator", multiple: true, title: "Which actuators", required: false + input "dev:sensor", "capability.sensor", multiple: true, title: "Which sensors", required: false + } + + section ('Select devices by capability') { + paragraph "If you cannot find a device by type, you may try looking for it by category below" + def d + for (capability in capabilities().findAll{ (!(it.value.d in [null, 'actuators', 'sensors'])) }.sort{ it.value.d }) { + if (capability.value.d != d) input "dev:${capability.key}", "capability.${capability.key}", multiple: true, title: "Which ${capability.value.d}", required: false + d = capability.value.d + } + } + } +} + +private pageFinishInstall() { + initTokens() + dynamicPage(name: "pageFinishInstall", title: "", install: true) { + section() { + paragraph "Excellent! You are now ready to use ${handle()}" + } + section("Note") { + paragraph "After you tap Done, go to the Automation tab, select the SmartApps section, and open the '${app.label}' SmartApp to access the dashboard.", required: true + paragraph "You can also access the dashboard on any another device by entering ${domain()} in the address bar of your browser.", required: true + } + section() { + paragraph "Now tap Done and enjoy ${handle()}!" + } + } +} + +def pageSettings() { + //clear devices cache + dynamicPage(name: "pageSettings", title: "", install: false, uninstall: false) { + section("General") { + label name: "name", title: "Name", state: (name ? "complete" : null), defaultValue: app.name, required: false + } + + def storageApp = getStorageApp() + if (storageApp) { + section("Available devices") { + app([title: 'Available devices', multiple: false, install: true, uninstall: false], 'storage', 'ady624', "${handle()} Storage") + } + } else { + section("Available devices") { + href "pageSelectDevices", title: "Available devices", description: "Tap here to select which devices are available to pistons" + } + } +/* section("Integrations") { + href "pageIntegrations", title: "Integrations with other services", description: "Tap here to configure your integrations" + }*/ + + section("Security") { + href "pageChangePassword", title: "Security", description: "Tap here to change your dashboard security settings" + } + +// section(title: "Logging") { +// input "logging", "enum", title: "Logging level", options: ["None", "Minimal", "Medium", "Full"], description: "Logs will be available in your dashboard if this feature is enabled", defaultValue: "None", required: false +// } + + section(title:"Privacy") { + href "pageDisclaimer", title: "Data Collection", image: "https://cdn.rawgit.com/ady624/${handle()}/master/resources/icons/settings.png", required: false + } + + section(title: "Maintenance") { + paragraph "Memory usage is at ${mem()}", required: false + input "redirectContactBook", "bool", title: "Redirect all Contact Book requests as PUSH notifications", description: "SmartThings has removed the Contact Book feature and as a result, all uses of Contact Book are by default ignored. By enabling this option, you will get all the existing Contact Book uses fall back onto the PUSH notification system, possibly allowing other people to receive these notifications.", defaultValue: false, required: true + input "disabled", "bool", title: "Disable all pistons", description: "Disable all pistons belonging to this instance", defaultValue: false, required: false + href "pageRebuildCache", title: "Clean up and rebuild data cache", description: "Tap here to change your clean up and rebuild your data cache" + } + + section(title: "Recovery") { + paragraph "webCoRE can run a recovery procedure every so often. This augments the built-in automatic recovery procedures that allows webCoRE to rely on all healthy pistons to keep the failed ones running." + input "recovery", "enum", title: "Run recovery", options: ["Never", "Every 5 minutes", "Every 10 minutes", "Every 15 minutes", "Every 30 minutes", "Every 1 hour", "Every 3 hours"], description: "Allows recovery procedures to run every so often", defaultValue: "Every 30 minutes", required: true + } + + section("Uninstall") { + href "pageRemove", title: "Uninstall ${handle()}", description: "Tap here to uninstall ${handle()}" + } + + } +} + +private pageChangePassword() { + dynamicPage(name: "pageChangePassword", title: "", nextPage: "pageSavePassword") { + section() { + paragraph "Choose a security password for your dashboard. You will need to enter this password when accessing your dashboard for the first time and possibly from time to time.", required: false + } + pageSectionPIN() + } +} + +private pageSectionPIN() { + section() { + input "PIN", "password", title: "Choose a security password for your dashboard", required: true + input "expiry", "enum", options: ["Every hour", "Every day", "Every week", "Every month (recommended)", "Every three months", "Never (not recommended)"], defaultValue: "Every month (recommended)", title: "Choose how often the dashboard login expires", required: true + } + +} + +private pageSavePassword() { + initTokens() + dynamicPage(name: "pageSavePassword", title: "") { + section() { + paragraph "Your password has been changed. Please note you may need to reauthenticate when opening the dashboard.", required: false + } + } +} + +def pageRebuildCache() { + cleanUp() + dynamicPage(name: "pageRebuildCache", title: "", install: false, uninstall: false) { + section() { + paragraph "Success! Data cache has been cleaned up and rebuilt." + } + } +} + +def pageIntegrations() { + //clear devices cache + dynamicPage(name: "pageIntegrations", title: "", install: false, uninstall: false) { + def twilio = settings.twilio_sid && settings.twilio_token && settings.twilio_number + section() { + paragraph "Integrate other services into webCoRE to extend its capabilities." + } + section("Available integrations") { + href "pageIntegrationAskAlexa", title: "Ask Alexa", description: "Allow interactions with AskAlexa" + href "pageIntegrationIFTTT", title: "IFTTT", description: "Allow IFTTT interactions with external services" + href "pageIntegrationTwilio", title: "Twilio", description: "Allows two-way SMS interactions", state: twilio ? 'complete' : null, required: twilio + } + } +} + + +def pageIntegrationIFTTT() { + return dynamicPage(name: "pageIntegrationIFTTT", title: "IFTTT Integration", nextPage: settings.iftttEnabled ? "pageIntegrationIFTTTConfirm" : null) { + section() { + paragraph "CoRE can optionally integrate with IFTTT (IF This Then That) via the Maker channel, triggering immediate events to IFTTT. To enable IFTTT, please login to your IFTTT account and connect the Maker channel. You will be provided with a key that needs to be entered below", required: false + input "iftttEnabled", "bool", title: "Enable IFTTT", submitOnChange: true, required: false + if (settings.iftttEnabled) href name: "", title: "IFTTT Maker channel", required: false, style: "external", url: "https://www.ifttt.com/maker", description: "tap to go to IFTTT and connect the Maker channel" + } + if (settings.iftttEnabled) { + section("IFTTT Maker key"){ + input("iftttKey", "string", title: "Key", description: "Your IFTTT Maker key", required: false) + } + } + } +} + +def pageIntegrationIFTTTConfirm() { + if (testIFTTT()) { + return dynamicPage(name: "pageIntegrationIFTTTConfirm", title: "IFTTT Integration") { + section(){ + paragraph "Congratulations! You have successfully connected CoRE to IFTTT." + } + } + } else { + return dynamicPage(name: "pageIntegrateIFTTTConfirm", title: "IFTTT Integration") { + section(){ + paragraph "Sorry, the credentials you provided for IFTTT are invalid. Please go back and try again." + } + } + } +} + +def pageIntegrationTwilio() { + //clear devices cache + dynamicPage(name: "pageIntegrationTwilio", title: "Twilio", install: false, uninstall: false) { + section() { + paragraph "Twilio allows two-way messaging between you and webCoRE, bringing interactivity to your automations." + paragraph "NOTE: Usage charges apply to your Twilio account and possibly your mobile phone bill.", required: true + } + section() { + paragraph "You will need to setup a Twilio account, purchase a number, and configure a Messaging Service to get this intergration working." + href "", title: "How to configure your Twilio account", style: "external", url: "${getWikiUrl()}Twilio", description: "Tap to open", required: false + } + section("Twilio settings") { + paragraph "Login to your Twilio and go to your console. Find the Account SID and the Auth Token and copy and paste them below:" + input "twilio_sid", "password", title: "Twilio account SID", required: true + input "twilio_token", "password", title: "Twilio authorization token", required: true + input "twilio_number", "text", title: "Twilio phone number (+E.164 format)", required: true, defaultValue: "+" + } + + section("Test your settings") { + paragraph "Once you have provided all details, test your integration here" + input "twilio_test_number", "text", title: "Your mobile phone number (+E.164 format)", defaultValue: "+" + input "twilio_test_message", "text", title: "A test message", defaultValue: "This is a test message from webCoRE" + href "pageIntegrationTwilioTest", title: "Test your Twilio account" + } + } +} + +def pageIntegrationTwilioTest() { + def data = [ + s: settings.twilio_sid, + t: settings.twilio_token, + n: settings.twilio_number, + p: settings.twilio_test_number, + m: settings.twilio_test_message + ] + def requestParams = [ + uri: "https://api.webcore.co/sms/send/", + query: null, + requestContentType: "application/json", + body: data + ] + def success = false + httpPost(requestParams) { response -> + if (response.status == 200) { + def jsonData = response.data instanceof Map ? response.data : (LinkedHashMap) new groovy.json.JsonSlurper().parseText(response.data) + if (jsonData && (jsonData.result == 'OK')) { + success = true + } + } + } + dynamicPage(name: "pageIntegrationTwilioTest", title: "Twilio Test", install: false, uninstall: false) { + section("Test result") { + if (success) { + paragraph "Congratulations! Your Twilio account is correctly setup." + } else { + paragraph "Oh-oh, something unexpected happened. Please check your settings and try again.", required: true + } + } + } +} + +def pageRemove() { + dynamicPage(name: "pageRemove", title: "", install: false, uninstall: true) { + section('CAUTION') { + paragraph "You are about to completely remove ${handle()} and all of its pistons.", required: true + paragraph "This action is irreversible.", required: true + paragraph "If you are sure you want to do this, please tap on the Remove button below.", required: true + } + } +} + + + + + + +/******************************************************************************/ +/*** ***/ +/*** INITIALIZATION ROUTINES ***/ +/*** ***/ +/******************************************************************************/ + + +def installed() { + state.installed = true + initialize() + return true +} + +def updated() { + warn "Updating webCoRE ${version()}" + unsubscribe() + unschedule() + initialize() + return true +} + +private initialize() { + subscribeAll() + state.vars = state.vars ?: [:] + state.version = version() + if (state.installed && settings.agreement) { + registerInstance() + } + def recoveryMethod = (settings.recovery ?: 'Every 30 minutes').replace('Every ', 'Every').replace(' minute', 'Minute').replace(' hour', 'Hour') + if (recoveryMethod != 'Never') { + try { + "run$recoveryMethod"(recoveryHandler) + } catch (all) { } + } + + //move lifx + if (state.settings && state.settings.lifx_scenes) { + state.lifx = [ + scenes: state.settings.lifx_scenes, + lights: state.settings.lifx_lights, + groups: state.settings.lifx_groups, + locations: state.settings.lifx_locations + ] + state.settings.remove('lifx_scenes') + state.settings.remove('lifx_lights') + state.settings.remove('lifx_groups') + state.settings.remove('lifx_locations') + } +} + +private initializeWebCoREEndpoint() { + try { + if (!state.endpoint) { + try { + def accessToken = createAccessToken() + if (accessToken) { + state.endpoint = hubUID ? apiServerUrl("$hubUID/apps/${app.id}/?access_token=${state.accessToken}") : apiServerUrl("/api/token/${accessToken}/smartapps/installations/${app.id}/") + } + } catch(e) { + state.endpoint = null + } + } + return state.endpoint + } catch (all) { + error "An error has occurred during endpoint initialization: ", all + } + return false +} + +private getHub() { + return location.getHubs().find{ it.getType().toString() == 'PHYSICAL' } +} + +private subscribeAll() { + subscribe(location, "${handle()}.poll", webCoREHandler) + subscribe(location, "${'@@' + handle()}", webCoREHandler) + subscribe(location, "askAlexa", askAlexaHandler) + subscribe(location, "echoSistant", echoSistantHandler) + subscribe(location, "HubUpdated", hubUpdatedHandler, [filterEvents: false]) + subscribe(location, "summary", summaryHandler, [filterEvents: false]) + setPowerSource(getHub()?.isBatteryInUse() ? 'battery' : 'mains') +} + +/******************************************************************************/ +/*** ***/ +/*** DASHBOARD MAPPINGS ***/ +/*** ***/ +/******************************************************************************/ + +mappings { + //path("/dashboard") {action: [GET: "api_dashboard"]} + path("/intf/dashboard/load") {action: [GET: "api_intf_dashboard_load"]} + path("/intf/dashboard/refresh") {action: [GET: "api_intf_dashboard_refresh"]} + path("/intf/dashboard/piston/new") {action: [GET: "api_intf_dashboard_piston_new"]} + path("/intf/dashboard/piston/create") {action: [GET: "api_intf_dashboard_piston_create"]} + path("/intf/dashboard/piston/backup") {action: [GET: "api_intf_dashboard_piston_backup"]} + path("/intf/dashboard/piston/get") {action: [GET: "api_intf_dashboard_piston_get"]} + path("/intf/dashboard/piston/set") {action: [GET: "api_intf_dashboard_piston_set"]} + path("/intf/dashboard/piston/set.start") {action: [GET: "api_intf_dashboard_piston_set_start"]} + path("/intf/dashboard/piston/set.chunk") {action: [GET: "api_intf_dashboard_piston_set_chunk"]} + path("/intf/dashboard/piston/set.end") {action: [GET: "api_intf_dashboard_piston_set_end"]} + path("/intf/dashboard/piston/pause") {action: [GET: "api_intf_dashboard_piston_pause"]} + path("/intf/dashboard/piston/resume") {action: [GET: "api_intf_dashboard_piston_resume"]} + path("/intf/dashboard/piston/set.bin") {action: [GET: "api_intf_dashboard_piston_set_bin"]} + path("/intf/dashboard/piston/tile") {action: [GET: "api_intf_dashboard_piston_tile"]} + path("/intf/dashboard/piston/set.category") {action: [GET: "api_intf_dashboard_piston_set_category"]} + path("/intf/dashboard/piston/logging") {action: [GET: "api_intf_dashboard_piston_logging"]} + path("/intf/dashboard/piston/clear.logs") {action: [GET: "api_intf_dashboard_piston_clear_logs"]} + path("/intf/dashboard/piston/delete") {action: [GET: "api_intf_dashboard_piston_delete"]} + path("/intf/dashboard/piston/evaluate") {action: [GET: "api_intf_dashboard_piston_evaluate"]} + path("/intf/dashboard/piston/test") {action: [GET: "api_intf_dashboard_piston_test"]} + path("/intf/dashboard/piston/activity") {action: [GET: "api_intf_dashboard_piston_activity"]} + path("/intf/dashboard/presence/create") {action: [GET: "api_intf_dashboard_presence_create"]} + path("/intf/dashboard/variable/set") {action: [GET: "api_intf_variable_set"]} + path("/intf/dashboard/settings/set") {action: [GET: "api_intf_settings_set"]} + path("/intf/location/entered") {action: [GET: "api_intf_location_entered"]} + path("/intf/location/exited") {action: [GET: "api_intf_location_exited"]} + path("/intf/location/updated") {action: [GET: "api_intf_location_updated"]} + path("/ifttt/:eventName") {action: [GET: "api_ifttt", POST: "api_ifttt"]} + path("/email/:pistonId") {action: [POST: "api_email"]} + path("/execute/:pistonIdOrName") {action: [GET: "api_execute", POST: "api_execute"]} + path("/tap") {action: [POST: "api_tap"]} + path("/tap/:tapId") {action: [GET: "api_tap"]} +} + +private api_get_error_result(error) { + return [ + name: location.name + ' \\ ' + (app.label ?: app.name), + error: error, + now: now() + ] +} + +private api_get_base_result(deviceVersion = 0, updateCache = false) { + def tz = location.getTimeZone() + def currentDeviceVersion = state.deviceVersion + def Boolean sendDevices = (deviceVersion != currentDeviceVersion) + def name = handle() + ' Piston' + def incidentThreshold = now() - 604800000 + return [ + name: location.name + ' \\ ' + (app.label ?: app.name), + instance: [ + account: [id: hashId(hubUID ?: app.getAccountId(), updateCache)], + pistons: getChildApps().findAll{ it.name == name }.sort{ it.label }.collect{ [ id: hashId(it.id, updateCache), 'name': it.label, 'meta': state[hashId(it.id, updateCache)] ] }, + id: hashId(app.id, updateCache), + locationId: hashId(location.id, updateCache), + name: app.label ?: app.name, + uri: state.endpoint, + deviceVersion: currentDeviceVersion, + coreVersion: version(), + enabled: !settings.disabled, + settings: state.settings ?: [:], + lifx: state.lifx ?: [:], + virtualDevices: virtualDevices(updateCache), + globalVars: listAvailableVariables(), + ] + (sendDevices ? [contacts: [:], devices: listAvailableDevices(false, updateCache)] : [:]), + location: [ + contactBookEnabled: location.getContactBookEnabled(), + hubs: location.getHubs().collect{ [id: hashId(it.id, updateCache), name: it.name, firmware: hubUID ? 'unknown' : it.getFirmwareVersionString(), physical: it.getType().toString().contains('PHYSICAL'), powerSource: it.isBatteryInUse() ? 'battery' : 'mains' ]}, + incidents: hubUID ? [] : location.activeIncidents.collect{[date: it.date.time, title: it.getTitle(), message: it.getMessage(), args: it.getMessageArgs(), sourceType: it.getSourceType()]}.findAll{ it.date >= incidentThreshold }, + id: hashId(location.id, updateCache), + mode: hashId(location.getCurrentMode().id, updateCache), + modes: location.getModes().collect{ [id: hashId(it.id, updateCache), name: it.name ]}, + shm: hubUID ? 'off' : location.currentState("alarmSystemStatus")?.value, + name: location.name, + temperatureScale: location.getTemperatureScale(), + timeZone: tz ? [ + id: tz.ID, + name: tz.displayName, + offset: tz.rawOffset + ] : null, + zipCode: location.getZipCode(), + ], + now: now(), + ] +} + +private api_intf_dashboard_load() { + def result + recoveryHandler() + //install storage app + def storageApp = getStorageApp(true) + //debug "Dashboard: Request received to initialize instance" + if (verifySecurityToken(params.token)) { + result = api_get_base_result(params.dev, true) + if (params.dashboard == "1") { + startDashboard() + } else { + stopDashboard() + } + } else { + if (params.pin) { + if (settings.PIN && (md5("pin:${settings.PIN}") == params.pin)) { + result = api_get_base_result() + result.instance.token = createSecurityToken() + } else { + error "Dashboard: Authentication failed due to an invalid PIN" + } + } + if (!result) result = api_get_error_result("ERR_INVALID_TOKEN") + } + //for accuracy, use the time as close as possible to the render + result.now = now() + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_refresh() { + startDashboard() + def result + if (verifySecurityToken(params.token)) { + def storageApp = getStorageApp(true) + result = storageApp ? storageApp.getDashboardData() : [:] + } else { + if (!result) result = api_get_error_result("ERR_INVALID_TOKEN") + } + //for accuracy, use the time as close as possible to the render + result.now = now() + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_piston_new() { + def result + debug "Dashboard: Request received to generate a new piston name" + if (verifySecurityToken(params.token)) { + result = [status: "ST_SUCCESS", name: generatePistonName()] + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_piston_create() { + def result + debug "Dashboard: Request received to generate a new piston name" + if (verifySecurityToken(params.token)) { + def piston = addChildApp("ady624", "${handle()} Piston", params.name?:generatePistonName()) + if (params.author || params.bin) { + piston.config([bin: params.bin, author: params.author, initialVersion: version()]) + } + if (hubUID) piston.installed() + result = [status: "ST_SUCCESS", id: hashId(piston.id)] + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_piston_get() { + def result + debug "Dashboard: Request received to get piston ${params?.id}" + if (verifySecurityToken(params.token)) { + def pistonId = params.id + def serverDbVersion = version() + def clientDbVersion = params.db + def requireDb = serverDbVersion != clientDbVersion + if (pistonId) { + result = api_get_base_result(requireDb ? 0 : params.dev, true) + def piston = getChildApps().find{ hashId(it.id) == pistonId }; + if (piston) { + result.data = piston.get() ?: [:] + } + if (requireDb) { + result.dbVersion = serverDbVersion + result.db = [ + capabilities: capabilities().sort{ it.value.d }, + commands: [ + physical: commands().sort{ it.value.d ?: it.value.n }, + virtual: virtualCommands().sort{ it.value.d ?: it.value.n } + ], + attributes: attributes().sort{ it.key }, + comparisons: comparisons(), + functions: functions(), + colors: [ + standard: colorUtil?.ALL + ], + ] + } + } else { + result = api_get_error_result("ERR_INVALID_ID") + } + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + //for accuracy, use the time as close as possible to the render + result.now = now() + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + + +private api_intf_dashboard_piston_backup() { + def result = [pistons: []] + debug "Dashboard: Request received to backup pistons ${params?.id}" + if (verifySecurityToken(params.token)) { + def pistonIds = (params.ids ?: '').tokenize(',') + for(pistonId in pistonIds) { + if (pistonId) { + def piston = getChildApps().find{ hashId(it.id) == pistonId }; + if (piston) { + def pd = piston.get(true) + pd.instance = [id: hashId(app.id), name: app.label] + if (pd) result.pistons.push(pd) + } + } + } + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + //for accuracy, use the time as close as possible to the render + result.now = now() + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private decodeEmoji(value) { + if (!value) return '' + return value.replaceAll(/(\:%[0-9A-F]{2}%[0-9A-F]{2}%[0-9A-F]{2}%[0-9A-F]{2}\:)/, { m -> URLDecoder.decode(m[0].substring(1, 13), 'UTF-8') }) +}; + + +private api_intf_dashboard_piston_set_save(id, data, chunks) { + def piston = getChildApps().find{ hashId(it.id) == id }; + if (piston) { + /* + def s = decodeEmoji(new String(data.decodeBase64(), "UTF-8")) + int cs = 512 + for (int a = 0; a <= Math.floor(s.size() / cs); a++) { + int x = a * cs + cs - 1; + if (x >= s.size()) x = s.size() - 1 + log.trace s.substring(a * cs, x) + } + */ + def p = (LinkedHashMap) new groovy.json.JsonSlurper().parseText(decodeEmoji(new String(data.decodeBase64(), "UTF-8"))) + def result = piston.setup(p, chunks); + broadcastPistonList() + return result + } + return false; +} + +//set is used for small pistons, for large data, using set.start, set.chunk, and set.end +private api_intf_dashboard_piston_set() { + def result + debug "Dashboard: Request received to set a piston" + if (verifySecurityToken(params.token)) { + def data = params?.data + //save the piston here + def saved = api_intf_dashboard_piston_set_save(params?.id, data, ['chunk:0' : data]) + if (saved) { + if (saved.rtData) { + updateRunTimeData(saved.rtData) + saved.rtData = null + } + result = [status: "ST_SUCCESS"] + saved + } else { + result = [status: "ST_ERROR", error: "ERR_UNKNOWN"] + } + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_piston_set_start() { + def result + debug "Dashboard: Request received to set a piston (chunked start)" + if (verifySecurityToken(params.token)) { + def chunks = "${params?.chunks}"; + chunks = chunks.isInteger() ? chunks.toInteger() : 0; + if ((chunks > 0) && (chunks < 100)) { + atomicState.hash = [:] + atomicState.chunks = [id: params?.id, count: chunks]; + result = [status: "ST_READY"] + } else { + result = [status: "ST_ERROR", error: "ERR_INVALID_CHUNK_COUNT"] + } + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_piston_set_chunk() { + def result + def chunk = "${params?.chunk}" + chunk = chunk.isInteger() ? chunk.toInteger() : -1 + debug "Dashboard: Request received to set a piston chunk (#${1 + chunk}/${state.chunks?.count})" + if (verifySecurityToken(params.token)) { + def data = params?.data + def chunks = state.chunks + if (chunks && chunks.count && (chunk >= 0) && (chunk < chunks.count)) { + chunks["chunk:$chunk"] = data; + atomicState.chunks = chunks; + result = [status: "ST_READY"] + } else { + result = [status: "ST_ERROR", error: "ERR_INVALID_CHUNK"] + } + } else { + result = api_get_error_result("ERR_INVALID_TOKEN") + } + render contentType: "application/javascript;charset=utf-8", data: "${params.callback}(${groovy.json.JsonOutput.toJson(result)})" +} + +private api_intf_dashboard_piston_set_end() { + def result + debug "Dashboard: Request received to set a piston (chunked end)" + if (verifySecurityToken(params.token)) { + def chunks = state.chunks + if (chunks && chunks.count) { + def ok = true + def data = "" + def i = 0; + def count = chunks.count; + while(iReceived event $eventName." +} + + +def api_email() { + def data = request?.JSON ?: [:] + def from = data.from ?: '' + def pistonId = params?.pistonId + if (pistonId) { + if (!hubUID) sendLocationEvent([name: "email", value: pistonId, isStateChange: true, linkText: "Email event", descriptionText: "${handle()} has received an email from $from", data: data]) + } + render contentType: "text/plain", data: "OK" +} + +private api_execute() { + def result = [:] + def data = [:] + def remoteAddr = request.getHeader("X-FORWARDED-FOR") ?: request.getRemoteAddr() + debug "Dashboard: Request received to execute a piston from IP $remoteAddr" + if (params) { + data = [:] + for(param in params) { + if (!(param.key in ['theAccessToken', 'appId', 'action', 'controller', 'pistonIdOrName'])) { + data[param.key] = param.value + } + } + } + data = data + (request?.JSON ?: [:]) + data.remoteAddr = remoteAddr + def pistonIdOrName = params?.pistonIdOrName + def piston = getChildApps().find{ (it.label == pistonIdOrName) || (hashId(it.id) == pistonIdOrName) }; + if (piston) { + if (!hubUID) sendLocationEvent(name: hashId(piston.id), value: remoteAddr, isStateChange: true, displayed: false, linkText: "Execute event", descriptionText: "External piston execute request from IP $remoteAddr", data: data) + result.result = 'OK' + } else { + result.result = 'ERROR' + } + result.timestamp = (new Date()).time + render contentType: "application/json", data: "${groovy.json.JsonOutput.toJson(result)}" +} + + + + + + +def recoveryHandler() { + def t = now() + def lastRecovered = state.lastRecovered + if (lastRecovered && (now() - lastRecovered < 30000)) return + atomicState.lastRecovered = now() + def name = handle() + ' Piston' + long threshold = now() - 30000 + def failedPistons = getChildApps().findAll{ it.name == name }.collect{ [ id: hashId(it.id, updateCache), 'name': it.label, 'meta': state[hashId(it.id, updateCache)] ] }.findAll{ it.meta && it.meta.a && it.meta.n && (it.meta.n < threshold) } + if (failedPistons.size()) { + for (piston in failedPistons) { + warn "Piston $piston.name was sent a recovery signal because it was ${now() - piston.meta.n}ms late" + if (!hubUID) sendLocationEvent(name: piston.id, value: 'recovery', isStateChange: true, displayed: false, linkText: "Recovery event", descriptionText: "Recovery event for piston $piston.name") + } + } + if (state.version != version()) { + //updated + atomicState.version = version() + updated() + } + //log.trace "RECOVERY took ${now() - t}ms" +} + + + + + + +/******************************************************************************/ +/*** ***/ +/*** PRIVATE METHODS ***/ +/*** ***/ +/******************************************************************************/ + +private cleanUp() { + try { + List pistons = getChildApps().collect{ hashId(it.id) } + for (item in state.findAll{ (it.key.startsWith('sph') && (it.value == 0)) || it.key.contains('-') || (it.key.startsWith(':') && !(it.key in pistons)) }) { + state.remove(item.key) + } + state.remove('chunks') + state.remove('hash') + state.remove('virtualDevices') + state.remove('updateDevices') + state.remove('semaphore') + state.remove('pong') + state.remove('modules') + state.remove('globalVars') + state.remove('devices') + api_get_base_result(1, true) + } catch (all) { + } +} + +private getStorageApp(install = false) { + def name = handle() + ' Storage' + def storageApp = getChildApps().find{ it.name == name } + if (storageApp) { + if (app.label != storageApp.label) { + storageApp.updateLabel(app.label) + } + return storageApp + } + if (!install) return null + try { + storageApp = addChildApp("ady624", name, app.label) + } catch (all) { + error "Please install the webCoRE Storage SmartApp for better performance" + return null + } + try { + storageApp.initData(settings.collect{ it.key.startsWith('dev:') ? it : null }, settings.contacts) + for (item in settings.collect{ it.key.startsWith('dev:') ? it : null }) { + if (item && item.key) { + app.updateSetting(item.key, [type: 'string', value: null]) + } + } + app.updateSetting('contacts', [type: 'string', value: null]) + } catch (all) { + } + return storageApp +} + +private getDashboardApp(install = false) { + def name = handle() + ' Dashboard' + def label = app.label + ' (dashboard)' + def dashboardApp = getChildApps().find{ it.name == name } + if (dashboardApp) { + if (label != dashboardApp.label) { + dashboardApp.updateLabel(label) + } + return dashboardApp + } + try { + dashboardApp = addChildApp("ady624", name, app.label) + } catch (all) { + return null + } + return dashboardApp +} + +private String getDashboardInitUrl(register = false) { + def url = register ? getDashboardRegistrationUrl() : getDashboardUrl() + if (!url) return null + return url + (register ? "register/" : "init/") + (apiServerUrl("").replace("https://", '').replace(".api.smartthings.com", "").replace(":443", "").replace("/", "") + ((hubUID ?: state.accessToken) + app.id).replace("-", "") + (hubUID ? '/?access_token=' + state.accessToken : '')).bytes.encodeBase64() +} + +private String getDashboardRegistrationUrl() { + if (!state.endpoint) return null + return "https://api.${domain()}/dashboard/" +} + +public Map listAvailableDevices(raw = false, updateCache = false) { + def storageApp = getStorageApp() + Map result = [:] + if (storageApp) { + result = storageApp.listAvailableDevices(raw) + } else { + if (raw) { + result = settings.findAll{ it.key.startsWith("dev:") }.collect{ it.value }.flatten().collectEntries{ dev -> [(hashId(dev.id, updateCache)): dev]} + } else { + result = settings.findAll{ it.key.startsWith("dev:") }.collect{ it.value }.flatten().collectEntries{ dev -> [(hashId(dev.id, updateCache)): dev]}.collectEntries{ id, dev -> [ (id): [ n: dev.getDisplayName(), cn: dev.getCapabilities()*.name, a: dev.getSupportedAttributes().unique{ it.name }.collect{def x = [n: it.name, t: it.getDataType(), o: it.getValues()]; try {x.v = dev.currentValue(x.n);} catch(all) {}; x}, c: dev.getSupportedCommands().unique{ it.getName() }.collect{[n: it.getName(), p: it.getArguments()]} ]]} + } + } + List presenceDevices = getChildDevices() + if (presenceDevices && presenceDevices.size()) { + if (raw) { + result << presenceDevices.collectEntries{ dev -> [(hashId(dev.id, updateCache)): dev]} + } else { + result << presenceDevices.collectEntries{ dev -> [(hashId(dev.id, updateCache)): dev]}.collectEntries{ id, dev -> [ (id): [ n: dev.getDisplayName(), cn: dev.getCapabilities()*.name, a: dev.getSupportedAttributes().unique{ it.name }.collect{def x = [n: it.name, t: it.getDataType(), o: it.getValues()]; try {x.v = dev.currentValue(x.n);} catch(all) {}; x}, c: dev.getSupportedCommands().unique{ it.getName() }.collect{[n: it.getName(), p: it.getArguments()]} ]]} + } + } + return result +} + +private setPowerSource(powerSource, atomic = true) { + if (state.powerSource == powerSource) return + if (atomic) { + atomicState.powerSource = powerSource + } else { + state.powerSource = powerSource + } + if (!hubUID) sendLocationEvent([name: 'powerSource', value: powerSource, isStateChange: true, linkText: "webCoRE power source event", descriptionText: "${handle()} has detected a new power source: $powerSource"]) +} + +private Map listAvailableVariables() { + return (state.vars ?: [:]).sort{ it.key } +} + +private void initTokens() { + debug "Dashboard: Initializing security tokens" + state.securityTokens = [:] +} + +private Boolean verifySecurityToken(tokenId) { + def tokens = state.securityTokens + if (!tokens) return false + def threshold = now() + def modified = false + //remove all expired tokens + for (token in tokens.findAll{ it.value < threshold }) { + tokens.remove(token.key) + modified = true + } + if (modified) { + atomicState.securityTokens = tokens + } + def token = tokens[tokenId] + if (!token || token < now()) { + error "Dashboard: Authentication failed due to an invalid token" + return false + } + return true +} + +private String createSecurityToken() { + trace "Dashboard: Generating new security token after a successful PIN authentication" + def token = UUID.randomUUID().toString() + def tokens = state.securityTokens ?: [:] + long expiry = 0 + def eo = "$settings.expiry".toLowerCase().replace("every ", "").replace("(recommended)", "").replace("(not recommended)", "").trim() + switch (eo) { + case "hour": expiry = 3600; break; + case "day": expiry = 86400; break; + case "week": expiry = 604800; break; + case "month": expiry = 2592000; break; + case "three months": expiry = 7776000; break; + case "never": expiry = 3110400000; break; //never means 100 years, okay? + } + tokens[token] = now() + (expiry * 1000) + state.securityTokens = tokens + //state.securityTokens = tokens + return token +} + +private String generatePistonName() { + def apps = getChildApps() + def i = 1 + while (true) { + def name = i == 5 ? "Mambo No. 5" : "${handle()} Piston #$i" + def found = false + for (app in apps) { + if (app.label == name) { + found = true + break + } + } + if (found) { + i++ + continue + } + return name + } +} + +private ping() { + if (!hubUID) sendLocationEvent( [name: handle(), value: 'ping', isStateChange: true, displayed: false, linkText: "${handle()} ping reply", descriptionText: "${handle()} has received a ping reply and is replying with a pong", data: [id: hashId(app.id), name: app.label]] ) +} + +private getLogging() { + def logging = settings.logging + return [ + error: true, + warn: true, + info: (logging != 'None'), + trace: (logging == 'Medium') || (logging == 'Full'), + debug: (logging == 'Full') + ] +} + +private boolean startDashboard() { + def storageApp = getStorageApp() + if (!storageApp) return false + def dashboardApp = getDashboardApp() + if (!dashboardApp) return false + dashboardApp.start(storageApp.listAvailableDevices(true).collect{ it.value }, hashId(app.id)) + if (state.dashboard != 'active') atomicState.dashboard = 'active' +} + +private boolean stopDashboard() { + def dashboardApp = getDashboardApp() + if (!dashboardApp) return false + dashboardApp.stop() + if (state.dashboard != 'inactive') atomicState.dashboard = 'inactive' +} + +private testIFTTT() { + //setup our security descriptor + state.modules = state.modules ?: [:] + state.modules["IFTTT"] = [ + key: settings.iftttKey, + connected: false + ] + if (settings.iftttKey) { + //verify the key + return httpGet("https://maker.ifttt.com/trigger/test/with/key/" + settings.iftttKey) { response -> + if (response.status == 200) { + if (response.data == "Congratulations! You've fired the test event") + state.modules["IFTTT"].connected = true + return true; + } + return false; + } + } + return false +} + +private testLifx() { + def token = state.settings?.lifx_token + if (!token) return false + def requestParams = [ + uri: "https://api.lifx.com", + path: "/v1/scenes", + headers: [ + "Authorization": "Bearer ${token}" + ], + requestContentType: "application/json" + ] + if (asynchttp_v1) asynchttp_v1.get(lifxHandler, requestParams, [request: 'scenes']) + pause(250) + requestParams.path = "/v1/lights/all" + if (asynchttp_v1) asynchttp_v1.get(lifxHandler, requestParams, [request: 'lights']) + return true +} + +private registerInstance() { + def accountId = hashId(hubUID ?: app.getAccountId()) + def locationId = hashId(location.id) + def instanceId = hashId(app.id) + def endpoint = state.endpoint + def region = endpoint.contains('graph-eu') ? 'eu' : 'us'; + def name = handle() + ' Piston' + def pistons = getChildApps().findAll{ it.name == name }.collect{ [ a: state[hashId(it.id, false)]?.a ] } + List lpa = pistons.findAll{ it.a }.collect{ it.id } + def pa = lpa.size() + List lpd = pistons.findAll{ !it.a }.collect{ it.id } + def pd = pistons.size() - pa + if (asynchttp_v1) asynchttp_v1.put(instanceRegistrationHandler, [ + uri: "https://api-${region}-${instanceId[32]}.webcore.co:9247", + path: '/instance/register', + headers: ['ST' : instanceId], + body: [ + a: accountId, + l: locationId, + i: instanceId, + e: endpoint, + v: version(), + r: region, + pa: pa, + lpa: lpa.join(','), + pd: pd, + lpd: lpd.join(',') + ] + ]) +} + +private initSunriseAndSunset() { + def sunTimes = app.getSunriseAndSunset() + if (!sunTimes.sunrise) { + warn "Actual sunrise and sunset times are unavailable; please reset the location for your hub", rtData + sunTimes.sunrise = new Date(getMidnightTime() + 7 * 3600000) + sunTimes.sunset = new Date(getMidnightTime() + 19 * 3600000) + } + state.sunTimes = [ + sunrise: sunTimes.sunrise.time, + sunset: sunTimes.sunset.time, + updated: now() + ] + return state.sunTimes +} + +private getSunTimes() { + def updated = state.sunTimes?.updated ?: 0 + //we require an update every 8 hours + if (!updated || (now() - updated < 28800000)) return state.sunTimes + return initSunriseAndSunset() +} + +private getMidnightTime(rtData) { + def rightNow = localTime() + return localToUtcTime(rightNow - rightNow.mod(86400000)) +} + +/******************************************************************************/ +/*** ***/ +/*** PUBLIC METHODS ***/ +/*** ***/ +/******************************************************************************/ +public Boolean isInstalled() { + return !!state.installed +} + +public String getDashboardUrl() { + if (!state.endpoint) return null + return "https://dashboard.${domain()}/" +} + +public refreshDevices() { + state.deviceVersion = now().toString() + testLifx() +} + +public String getWikiUrl() { + return "https://wiki.${domain()}/" +} +public String mem(showBytes = true) { + def bytes = state.toString().length() + return Math.round(100.00 * (bytes/ 100000.00)) + "%${showBytes ? " ($bytes bytes)" : ""}" +} + +public Map getRunTimeData(semaphore = null, fetchWrappers = false) { + def startTime = now() + semaphore = semaphore ?: 0 + def semaphoreDelay = 0 + def semaphoreName = semaphore ? "sph$semaphore" : '' + if (semaphore) { + def waited = false + //if we need to wait for a semaphore, we do it here + def lastSemaphore + while (semaphore) { + lastSemaphore = lastSemaphore ?: (atomicState[semaphoreName] ?: 0) + if (!lastSemaphore || (now() - lastSemaphore > 10000)) { + semaphoreDelay = waited ? now() - startTime : 0 + semaphore = now() + atomicState[semaphoreName] = semaphore + break + } + waited = true + pause(250) + } + } + def storageApp = !!fetchWrappers ? getStorageApp() : null + return [ + enabled: !settings.disabled, + attributes: attributes(), + semaphore: semaphore, + semaphoreName: semaphoreName, + semaphoreDelay: semaphoreDelay, + commands: [ + physical: commands(), + virtual: virtualCommands() + ], + comparisons: comparisons(), + coreVersion: version(), + contacts: [:], + devices: (!!fetchWrappers ? (storageApp ? storageApp.listAvailableDevices(true) : listAvailableDevices(true)) : [:]), + virtualDevices: virtualDevices(), + globalVars: listAvailableVariables(), + globalStore: state.store ?: [:], + settings: state.settings ?: [:], + lifx: state.lifx ?: [:], + powerSource: state.powerSource ?: 'mains', + region: state.endpoint.contains('graph-eu') ? 'eu' : 'us', + instanceId: hashId(app.id), + sunTimes: getSunTimes(), + started: startTime, + ended: now(), + generatedIn: now() - startTime, + redirectContactBook: settings.redirectContactBook + ] +} + +public void updateRunTimeData(data) { + if (!data || !data.id) return + List variableEvents = [] + if (data && data.gvCache) { + Map vars = atomicState.vars ?: [:] + def modified = false + for(var in data.gvCache) { + if (var.key && var.key.startsWith('@') && (vars[var.key]) && (var.value.v != vars[var.key].v)) { + variableEvents.push([name: var.key, oldValue: vars[var.key].v, value: var.value.v, type: var.value.t]) + vars[var.key].v = var.value.v + modified = true + } + } + if (modified) { + atomicState.vars = vars + } + } + if (data && data.gvStoreCache) { + Map store = atomicState.store ?: [:] + def modified = false + for(var in data.gvStoreCache) { + if (var.value == null) { + store.remove(var.key) + } else { + store[var.key] = var.value + } + modified = true + } + if (modified) { + atomicState.store = store + } + } + def id = data.id + //remove the old state as we don't need it + def st = [:] + data.state + st.remove('old') + Map piston = [ + a: data.active, + c: data.category, + t: now(), //last run + n: data.stats.nextSchedule, + z: data.piston.z, //description + s: st, //state + ] + atomicState[id] = piston + //broadcast variable change events + for (variable in variableEvents) { + sendVariableEvent(variable) + } + //release semaphores + if (data.semaphoreName && (atomicState[data.semaphoreName] <= data.semaphore)) { + //release the semaphore + atomicState[data.semaphoreName] = 0 + //atomicState.remove(data.semaphoreName) + } + //broadcast to dashboard + if (state.dashboard == 'active') { + def dashboardApp = getDashboardApp() + if (dashboardApp) dashboardApp.updatePiston(id, piston) + } + recoveryHandler() +} + +public pausePiston(pistonId) { + def piston = getChildApps().find{ hashId(it.id) == pistonId }; + if (piston) { + def rtData = piston.pause() + updateRunTimeData(rtData) + } +} + +public resumePiston(pistonId) { + def piston = getChildApps().find{ hashId(it.id) == pistonId }; + if (piston) { + def rtData = piston.resume() + updateRunTimeData(rtData) + } +} + +public executePiston(pistonId, data, source) { + def piston = getChildApps().find{ hashId(it.id) == pistonId }; + if (piston) { + piston.execute(data, source) + return true + } + return false +} + +private sendVariableEvent(variable) { + if (!hubUID) sendLocationEvent([name: variable.name.startsWith('@@') ? '@@' + handle() : hashId(app.id), value: variable.name, isStateChange: true, displayed: false, linkText: "${handle()} global variable ${variable.name} changed", descriptionText: "${handle()} global variable ${variable.name} changed", data: [id: hashId(app.id), name: app.label, event: 'variable', variable: variable]]) +} + +private broadcastPistonList() { + if (!hubUID) sendLocationEvent([name: handle(), value: 'pistonList', isStateChange: true, displayed: false, data: [id: hashId(app.id), name: app.label, pistons: getChildApps().findAll{ it.name == "${handle()} Piston" }.collect{[id: hashId(it.id), name: it.label]}]]) +} + +def webCoREHandler(event) { + if (!event || (!event.name.endsWith(handle()))) return; + def data = event.jsonData ?: null + log.error "GOT EVENT WITH DATA $data" + if (data && data.variable && (data.event == 'variable') && event.value && event.value.startsWith('@')) { + Map vars = atomicState.vars ?: [:] + Map variable = data.variable + def oldVar = vars[variable.name] ?: [t:'', v:''] + if ((oldVar.t != variable.type) || (oldVar.v != variable.value)) { + vars[variable.name] = [t: variable.type ? variable.type : 'dynamic', v: variable.value] + atomicState.vars = vars + } + return; + } + switch (event.value) { + case 'poll': + int delay = (int) Math.round(2000 * Math.random()) + pause(delay) + broadcastPistonList() + break; +/* case 'ping': + if (data && data.id && data.name && (data.id != hashId(app.id))) { + sendLocationEvent( [name: handle(), value: 'pong', isStateChange: true, displayed: false, linkText: "${handle()} ping reply", descriptionText: "${handle()} has received a ping reply and is replying with a pong", data: [id: hashId(app.id), name: app.label]] ) + } else { + break; + } + //fall through to pong + case 'pong': + /*if (data && data.id && data.name && (data.id != hashId(app.id))) { + def pong = atomicState.pong ?: [:] + pong[data.id] = data.name + atomicState.pong = pong + }*/ + } +} + +def instanceRegistrationHandler(response, callbackData) { +} + +def askAlexaHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + Map macros = [:] + for(macro in (evt.jsonData && evt.jsonData?.macros ? evt.jsonData.macros : [])) { + if (macro instanceof Map) { + macros[hashId(macro.id)] = macro.name + } else { + macros[hashId(macro)] = macro; + } + } + atomicState.askAlexaMacros = macros + break + } +} + +def echoSistantHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + Map profiles = [:] + for(profile in (evt.jsonData && evt.jsonData?.profiles ? evt.jsonData.profiles : [])) { + if (profile instanceof Map) { + profiles[hashId(profile.id)] = profile.name + } else { + profiles[hashId(profile)] = profile; + } + } + atomicState.echoSistantProfiles = profiles + break + } +} + +def hubUpdatedHandler(evt) { + if (evt.jsonData && (evt.jsonData.hubType == 'PHYSICAL') && evt.jsonData.data && evt.jsonData.data.batteryInUse) { + setPowerSource(evt.jsonData.data.batteryInUse ? 'battery' : 'mains') + } +} + +def summaryHandler(evt) { + //log.error "$evt.name >>> ${evt.jsonData}" +} + +def NewIncidentHandler(evt) { + //log.error "$evt.name >>> ${evt.jsonData}" +} + + + +def lifxHandler(response, cbkData) { + if ((response.status == 200)) { + def data = response.data instanceof List ? response.data : new groovy.json.JsonSlurper().parseText(response.data) + cbkData = cbkData instanceof Map ? cbkData : (LinkedHashMap) new groovy.json.JsonSlurper().parseText(cbkData) + if (data instanceof List) { + state.lifx = state.lifx ?: [:] + switch (cbkData.request) { + case 'scenes': + state.lifx.scenes = data.collectEntries{[(it.uuid): it.name]} + break + case 'lights': + state.lifx.lights = data.collectEntries{[(it.id): it.label]} + state.lifx.groups = data.collectEntries{[(it.group.id): it.group.name]} + state.lifx.locations = data.collectEntries{[(it.location.id): it.location.name]} + break + } + } + } +} + + + +/******************************************************************************/ +/*** ***/ +/*** SECURITY METHODS ***/ +/*** ***/ +/******************************************************************************/ +def String md5(String md5) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5") + byte[] array = md.digest(md5.getBytes()) + def result = "" + for (int i = 0; i < array.length; ++i) { + result += Integer.toHexString((array[i] & 0xFF) | 0x100).substring(1,3) + } + return result + } catch (java.security.NoSuchAlgorithmException e) { + } + return null; +} + +def String hashId(id, updateCache = false) { + //enabled hash caching for faster processing + def result = state.hash ? state.hash[id] : null + if (!result) { + result = ":${md5("core." + id)}:" + if (updateCache) { + def hash = state.hash ?: [:] + hash[id] = result + state.hash = hash + } + } + return result +} + +def String temperatureUnit() { + return "°" + location.temperatureScale; +} + +/******************************************************************************/ +/*** DEBUG FUNCTIONS ***/ +/******************************************************************************/ +private debug(message, shift = null, err = null, cmd = null) { + if (cmd == "timer") { + return [m: message, t: now(), s: shift, e: err] + } + if (message instanceof Map) { + shift = message.s + err = message.e + message = message.m + " (${now() - message.t}ms)" + } + if (!settings.logging && (cmd != "error")) { + return + } + cmd = cmd ? cmd : "debug" + //mode is + // 0 - initialize level, level set to 1 + // 1 - start of routine, level up + // -1 - end of routine, level down + // anything else - nothing happens + def maxLevel = 4 + def level = state.debugLevel ? state.debugLevel : 0 + def levelDelta = 0 + def prefix = "║" + def pad = "░" + switch (shift) { + case 0: + level = 0 + prefix = "" + break + case 1: + level += 1 + prefix = "╚" + pad = "═" + break + case -1: + levelDelta = -(level > 0 ? 1 : 0) + pad = "═" + prefix = "╔" + break + } + + if (level > 0) { + prefix = prefix.padLeft(level, "║").padRight(maxLevel, pad) + } + + level += levelDelta + state.debugLevel = level + + if (debugging) { + prefix += " " + } else { + prefix = "" + } + + if (cmd == "info") { + log.info "$prefix$message", err + } else if (cmd == "trace") { + log.trace "$prefix$message", err + } else if (cmd == "warn") { + log.warn "$prefix$message", err + } else if (cmd == "error") { + if (hubUID) { log.error "$prefix$message" } else { log.error "$prefix$message", err } + } else { + log.debug "$prefix$message", err + } +} +private info(message, shift = null, err = null) { debug message, shift, err, 'info' } +private trace(message, shift = null, err = null) { debug message, shift, err, 'trace' } +private warn(message, shift = null, err = null) { debug message, shift, err, 'warn' } +private error(message, shift = null, err = null) { debug message, shift, err, 'error' } +private timer(message, shift = null, err = null) { debug message, shift, err, 'timer' } + + + + + + + + + +/******************************************************************************/ +/*** DATABASE ***/ +/******************************************************************************/ + +private static Map capabilities() { + //n = name + //d = friendly devices name + //a = default attribute + //c = accepted commands + //m = momentary + //s = number of subdevices + //i = subdevice index in event data + return [ + accelerationSensor : [ n: "Acceleration Sensor", d: "acceleration sensors", a: "acceleration", ], + actuator : [ n: "Actuator", d: "actuators", ], + alarm : [ n: "Alarm", d: "alarms and sirens", a: "alarm", c: ["off", "strobe", "siren", "both"], ], + audioNotification : [ n: "Audio Notification", d: "audio notification devices", c: ["playText", "playTextAndResume", "playTextAndRestore", "playTrack", "playTrackAndResume", "playTrackAndRestore"], ], + battery : [ n: "Battery", d: "battery powered devices", a: "battery", ], + beacon : [ n: "Beacon", d: "beacons", a: "presence", ], + bulb : [ n: "Bulb", d: "bulbs", a: "switch", c: ["off", "on"], ], + button : [ n: "Button", d: "buttons", a: "button", m: true, s: "numberOfButtons,numButtons", i: "buttonNumber", ], + carbonDioxideMeasurement : [ n: "Carbon Dioxide Measurement", d: "carbon dioxide sensors", a: "carbonDioxide", ], + carbonMonoxideDetector : [ n: "Carbon Monoxide Detector", d: "carbon monoxide detectors", a: "carbonMonoxide", ], + colorControl : [ n: "Color Control", d: "adjustable color lights", a: "color", c: ["setColor", "setHue", "setSaturation"], ], + colorTemperature : [ n: "Color Temperature", d: "adjustable white lights", a: "colorTemperature", c: ["setColorTemperature"], ], + configuration : [ n: "Configuration", d: "configurable devices", c: ["configure"], ], + consumable : [ n: "Consumable", d: "consumables", a: "consumableStatus", c: ["setConsumableStatus"], ], + contactSensor : [ n: "Contact Sensor", d: "contact sensors", a: "contact", ], + doorControl : [ n: "Door Control", d: "automatic doors", a: "door", c: ["close", "open"], ], + energyMeter : [ n: "Energy Meter", d: "energy meters", a: "energy", ], + estimatedTimeOfArrival : [ n: "Estimated Time of Arrival", d: "moving devices (ETA)", a: "eta", ], + garageDoorControl : [ n: "Garage Door Control", d: "automatic garage doors", a: "door", c: ["close", "open"], ], + holdableButton : [ n: "Holdable Button", d: "holdable buttons", a: "button", m: true, s: "numberOfButtons,numButtons", i: "buttonNumber", ], + illuminanceMeasurement : [ n: "Illuminance Measurement", d: "illuminance sensors", a: "illuminance", ], + imageCapture : [ n: "Image Capture", d: "cameras, imaging devices", a: "image", c: ["take"], ], + indicator : [ n: "Indicator", d: "indicator devices", a: "indicatorStatus", c: ["indicatorNever", "indicatorWhenOn", "indicatorWhenOff"], ], + infraredLevel : [ n: "Infrared Level", d: "adjustable infrared lights", a: "infraredLevel", c: ["setInfraredLevel"], ], + light : [ n: "Light", d: "lights", a: "switch", c: ["off", "on"], ], + lock : [ n: "Lock", d: "electronic locks", a: "lock", c: ["lock", "unlock"], s:"numberOfCodes,numCodes", i: "usedCode", ], + lockOnly : [ n: "Lock Only", d: "electronic locks (lock only)", a: "lock", c: ["lock"], ], + mediaController : [ n: "Media Controller", d: "media controllers", a: "currentActivity", c: ["startActivity", "getAllActivities", "getCurrentActivity"], ], + momentary : [ n: "Momentary", d: "momentary switches", c: ["push"], ], + motionSensor : [ n: "Motion Sensor", d: "motion sensors", a: "motion", ], + musicPlayer : [ n: "Music Player", d: "music players", a: "status", c: ["mute", "nextTrack", "pause", "play", "playTrack", "previousTrack", "restoreTrack", "resumeTrack", "setLevel", "setTrack", "stop", "unmute"], ], + notification : [ n: "Notification", d: "notification devices", c: ["deviceNotification"], ], + outlet : [ n: "Outlet", d: "lights", a: "switch", c: ["off", "on"], ], + pHMeasurement : [ n: "pH Measurement", d: "pH sensors", a: "pH", ], + polling : [ n: "Polling", d: "pollable devices", c: ["poll"], ], + powerMeter : [ n: "Power Meter", d: "power meters", a: "power", ], + powerSource : [ n: "Power Source", d: "multisource powered devices", a: "powerSource", ], + presenceSensor : [ n: "Presence Sensor", d: "presence sensors", a: "presence", ], + refresh : [ n: "Refresh", d: "refreshable devices", c: ["refresh"], ], + relativeHumidityMeasurement : [ n: "Relative Humidity Measurement", d: "humidity sensors", a: "humidity", ], + relaySwitch : [ n: "Relay Switch", d: "relay switches", a: "switch", c: ["off", "on"], ], + sensor : [ n: "Sensor", d: "sensors", a: "sensor", ], + shockSensor : [ n: "Shock Sensor", d: "shock sensors", a: "shock", ], + signalStrength : [ n: "Signal Strength", d: "wireless devices", a: "rssi", ], + sleepSensor : [ n: "Sleep Sensor", d: "sleep sensors", a: "sleeping", ], + smokeDetector : [ n: "Smoke Detector", d: "smoke detectors", a: "smoke", ], + soundPressureLevel : [ n: "Sound Pressure Level", d: "sound pressure sensors", a: "soundPressureLevel", ], + soundSensor : [ n: "Sound Sensor", d: "sound sensors", a: "sound", ], + speechRecognition : [ n: "Speech Recognition", d: "speech recognition devices", a: "phraseSpoken", m: true, ], + speechSynthesis : [ n: "Speech Synthesis", d: "speech synthesizers", c: ["speak"], ], + stepSensor : [ n: "Step Sensor", d: "step counters", a: "steps", ], + switch : [ n: "Switch", d: "switches", a: "switch", c: ["off", "on"], ], + switchLevel : [ n: "Switch Level", d: "dimmers and dimmable lights", a: "level", c: ["setLevel"], ], + tamperAlert : [ n: "Tamper Alert", d: "tamper sensors", a: "tamper", ], + temperatureMeasurement : [ n: "Temperature Measurement", d: "temperature sensors", a: "temperature", ], + thermostat : [ n: "Thermostat", d: "thermostats", a: "thermostatMode", c: ["auto", "cool", "emergencyHeat", "fanAuto", "fanCirculate", "fanOn", "heat", "off", "setCoolingSetpoint", "setHeatingSetpoint", "setSchedule", "setThermostatFanMode", "setThermostatMode"], ], + thermostatCoolingSetpoint : [ n: "Thermostat Cooling Setpoint", d: "thermostats (cooling)", a: "coolingSetpoint", c: ["setCoolingSetpoint"], ], + thermostatFanMode : [ n: "Thermostat Fan Mode", d: "fans", a: "thermostatFanMode", c: ["fanAuto", "fanCirculate", "fanOn", "setThermostatFanMode"], ], + thermostatHeatingSetpoint : [ n: "Thermostat Heating Setpoint", d: "thermostats (heating)", a: "heatingSetpoint", c: ["setHeatingSetpoint"], ], + thermostatMode : [ n: "Thermostat Mode", a: "thermostatMode", c: ["auto", "cool", "emergencyHeat", "heat", "off", "setThermostatMode"], ], + thermostatOperatingState : [ n: "Thermostat Operating State", a: "thermostatOperatingState", ], + thermostatSetpoint : [ n: "Thermostat Setpoint", a: "thermostatSetpoint", ], + threeAxis : [ n: "Three Axis Sensor", d: "three axis sensors", a: "orientation", ], + timedSession : [ n: "Timed Session", d: "timers", a: "sessionStatus", c: ["cancel", "pause", "setTimeRemaining", "start", "stop", ], ], + tone : [ n: "Tone", d: "tone generators", c: ["beep"], ], + touchSensor : [ n: "Touch Sensor", d: "touch sensors", a: "touch", ], + ultravioletIndex : [ n: "Ultraviolet Index", d: "ultraviolet sensors", a: "ultravioletIndex", ], + valve : [ n: "Valve", d: "valves", a: "valve", c: ["close", "open"], ], + voltageMeasurement : [ n: "Voltage Measurement", d: "voltmeters", a: "voltage", ], + waterSensor : [ n: "Water Sensor", d: "water and leak sensors", a: "water", ], + windowShade : [ n: "Window Shade", d: "automatic window shades", a: "windowShade", c: ["close", "open", "presetPosition"], ], + ] +} + +private static Map attributes() { + return [ + acceleration : [ n: "acceleration", t: "enum", o: ["active", "inactive"], ], + activities : [ n: "activities", t: "object", ], + alarm : [ n: "alarm", t: "enum", o: ["both", "off", "siren", "strobe"], ], + axisX : [ n: "X axis", t: "integer", r: [-1024, 1024], s: "threeAxis", ], + axisY : [ n: "Y axis", t: "integer", r: [-1024, 1024], s: "threeAxis", ], + axisZ : [ n: "Z axis", t: "integer", r: [-1024, 1024], s: "threeAxis", ], + battery : [ n: "battery", t: "integer", r: [0, 100], u: "%", ], + button : [ n: "button", t: "enum", o: ["pushed", "held"], c: "button", m: true, s: "numberOfButtons,numButtons", i: "buttonNumber" ], + carbonDioxide : [ n: "carbon dioxide", t: "decimal", r: [0, null], ], + carbonMonoxide : [ n: "carbon monoxide", t: "enum", o: ["clear", "detected", "tested"], ], + color : [ n: "color", t: "color", ], + colorTemperature : [ n: "color temperature", t: "integer", r: [1000, 30000], u: "°K", ], + consumableStatus : [ n: "consumable status", t: "enum", o: ["good", "maintenance_required", "missing", "order", "replace"], ], + contact : [ n: "contact", t: "enum", o: ["closed", "open"], ], + coolingSetpoint : [ n: "cooling setpoint", t: "decimal", r: [-127, 127], u: '°?', ], + currentActivity : [ n: "current activity", t: "string", ], + door : [ n: "door", t: "enum", o: ["closed", "closing", "open", "opening", "unknown"], p: true, ], + energy : [ n: "energy", t: "decimal", r: [0, null], u: "kWh", ], + eta : [ n: "ETA", t: "datetime", ], + goal : [ n: "goal", t: "integer", r: [0, null], ], + heatingSetpoint : [ n: "heating setpoint", t: "decimal", r: [-127, 127], u: '°?', ], + hex : [ n: "hexadecimal code", t: "hexcolor", ], + holdableButton : [ n: "holdable button", t: "enum", o: ["held", "pushed"], c: "holdableButton", m: true, ], + hue : [ n: "hue", t: "integer", r: [0, 360], u: "°", ], + humidity : [ n: "relative humidity", t: "integer", r: [0, 100], u: "%", ], + illuminance : [ n: "illuminance", t: "integer", r: [0, null], u: "lux", ], + image : [ n: "image", t: "image", ], + indicatorStatus : [ n: "indicator status", t: "enum", o: ["never", "when off", "when on"], ], + infraredLevel : [ n: "infrared level", t: "integer", r: [0, 100], u: "%", ], + level : [ n: "level", t: "integer", r: [0, 100], u: "%", ], + lock : [ n: "lock", t: "enum", o: ["locked", "unknown", "unlocked", "unlocked with timeout"], c: "lock", s:"numberOfCodes,numCodes", i:"usedCode", sd: "user code" ], + lqi : [ n: "link quality", t: "integer", r: [0, 255], ], + motion : [ n: "motion", t: "enum", o: ["active", "inactive"], ], + mute : [ n: "mute", t: "enum", o: ["muted", "unmuted"], ], + orientation : [ n: "orientation", t: "enum", o: ["rear side up", "down side up", "left side up", "front side up", "up side up", "right side up"], ], + axisX : [ n: "axis X", t: "decimal", ], + axisY : [ n: "axis Y", t: "decimal", ], + axisZ : [ n: "axis Z", t: "decimal", ], + pH : [ n: "pH level", t: "decimal", r: [0, 14], ], + phraseSpoken : [ n: "phrase", t: "string", ], + power : [ n: "power", t: "decimal", u: "W", ], + powerSource : [ n: "power source", t: "enum", o: ["battery", "dc", "mains", "unknown"], ], + presence : [ n: "presence", t: "enum", o: ["not present", "present"], ], + rssi : [ n: "signal strength", t: "integer", r: [0, 100], u: "%", ], + saturation : [ n: "saturation", t: "integer", r: [0, 100], u: "%", ], + schedule : [ n: "schedule", t: "object", ], + sessionStatus : [ n: "session status", t: "enum", o: ["canceled", "paused", "running", "stopped"], ], + shock : [ n: "shock", t: "enum", o: ["clear", "detected"], ], + sleeping : [ n: "sleeping", t: "enum", o: ["not sleeping", "sleeping"], ], + smoke : [ n: "smoke", t: "enum", o: ["clear", "detected", "tested"], ], + sound : [ n: "sound", t: "enum", o: ["detected", "not detected"], ], + soundPressureLevel : [ n: "sound pressure level", t: "integer", r: [0, null], u: "dB", ], + status : [ n: "status", t: "string", ], + steps : [ n: "steps", t: "integer", r: [0, null], ], + switch : [ n: "switch", t: "enum", o: ["off", "on"], p: true, ], + tamper : [ n: "tamper", t: "enum", o: ["clear", "detected"], ], + temperature : [ n: "temperature", t: "decimal", r: [-460, 10000], u: '°?', ], + thermostatFanMode : [ n: "fan mode", t: "enum", o: ["auto", "circulate", "on"], ], + thermostatMode : [ n: "thermostat mode", t: "enum", o: ["auto", "cool", "emergency heat", "heat", "off"], ], + thermostatOperatingState : [ n: "operating state", t: "enum", o: ["cooling", "fan only", "heating", "idle", "pending cool", "pending heat", "vent economizer"], ], + thermostatSetpoint : [ n: "setpoint", t: "decimal", r: [-127, 127], u: '°?', ], + threeAxis : [ n: "vector", t: "vector3", ], + timeRemaining : [ n: "time remaining", t: "integer", r: [0, null], u: "s", ], + touch : [ n: "touch", t: "enum", o: ["touched"], ], + trackData : [ n: "track data", t: "object", ], + trackDescription : [ n: "track description", t: "string", ], + ultravioletIndex : [ n: "UV index", t: "integer", r: [0, null], ], + valve : [ n: "valve", t: "enum", o: ["closed", "open"], ], + voltage : [ n: "voltage", t: "decimal", r: [null, null], u: "V", ], + water : [ n: "water", t: "enum", o: ["dry", "wet"], ], + windowShade : [ n: "window shade", t: "enum", o: ["closed", "closing", "open", "opening", "partially open", "unknown"], ], + //webCoRE Presence Sensor + altitude : [ n: "altitude", t: "decimal", r: [null, null], u: "ft", ], + altitudeMetric : [ n: "altitude (metric)", t: "decimal", r: [null, null], u: "m", ], + floor : [ n: "floor", t: "integer", r: [null, null], ], + distance : [ n: "distance", t: "decimal", r: [null, null], u: "mi", ], + distanceMetric : [ n: "distance (metric)", t: "decimal", r: [null, null], u: "km", ], + currentPlace : [ n: "current place", t: "string", ], + previousPlace : [ n: "previous place", t: "string", ], + closestPlace : [ n: "closest place", t: "string", ], + arrivingAtPlace : [ n: "arriving at place", t: "string", ], + leavingPlace : [ n: "leaving place", t: "string", ], + places : [ n: "places", t: "string", ], + horizontalAccuracy : [ n: "horizontal accuracy", t: "decimal", r: [null, null], u: "ft", ], + verticalAccuracy : [ n: "vertical accuracy", t: "decimal", r: [null, null], u: "ft", ], + horizontalAccuracyMetric : [ n: "horizontal accuracy (metric)", t: "decimal", r: [null, null], u: "m", ], + verticalAccuracyMetric : [ n: "vertical accuracy (metric)", t: "decimal", r: [null, null], u: "m", ], + latitude : [ n: "latitude", t: "decimal", r: [null, null], u: "°", ], + longitude : [ n: "longitude", t: "decimal", r: [null, null], u: "°", ], + closestPlaceDistance : [ n: "distance to closest place", t: "decimal", r: [null, null], u: "mi", ], + closestPlaceDistanceMetric : [ n: "distance to closest place (metric)",t: "decimal", r: [null, null], u: "km", ], + speed : [ n: "speed", t: "decimal", r: [null, null], u: "ft/s", ], + speedMetric : [ n: "speed (metric)", t: "decimal", r: [null, null], u: "m/s", ], + bearing : [ n: "bearing", t: "decimal", r: [0, 360], u: "°", ], + ] +} + +private static Map commands() { + return [ + auto : [ n: "Set to Auto", a: "thermostatMode", v: "auto", ], + beep : [ n: "Beep", ], + both : [ n: "Strobe and Siren", a: "alarm", v: "both", ], + cancel : [ n: "Cancel", ], + close : [ n: "Close", a: "doore", v: "close", ], + configure : [ n: "Configure", i: 'cog', ], + cool : [ n: "Set to Cool", i: 'snowflake', is: 'l', a: "thermostatMode", v: "cool", ], + deviceNotification : [ n: "Send device notification...", d: "Send device notification \"{0}\"", p: [[n:"Message",t:"string"]], ], + emergencyHeat : [ n: "Set to Emergency Heat", a: "thermostatMode", v: "emergency heat", ], + fanAuto : [ n: "Set fan to Auto", a: "thermostatFanMode", v: "auto", ], + fanCirculate : [ n: "Set fan to Circulate", a: "thermostatFanMode", v: "circulate", ], + fanOn : [ n: "Set fan to On", a: "thermostatFanMode", v: "on", ], + getAllActivities : [ n: "Get all activities", ], + getCurrentActivity : [ n: "Get current activity", ], + heat : [ n: "Set to Heat", i: 'fire', a: "thermostatMode", v: "heat", ], + indicatorNever : [ n: "Disable indicator", ], + indicatorWhenOff : [ n: "Enable indicator when off", ], + indicatorWhenOn : [ n: "Enable indicator when on", ], + lock : [ n: "Lock", i: "lock", a: "lock", v: "locked", ], + mute : [ n: "Mute", i: 'volume-off', a: "mute", v: "muted", ], + nextTrack : [ n: "Next track", ], + off : [ n: "Turn off", i: 'circle-notch', a: "switch", v: "off", ], + on : [ n: "Turn on", i: "power-off", a: "switch", v: "on", ], + open : [ n: "Open", a: "door", v: "open", ], + pause : [ n: "Pause", ], + play : [ n: "Play", ], + playText : [ n: "Speak text...", d: "Speak text \"{0}\"", p: [[n:"Text",t:"string"], [n:"Volume", t:"level", d:" at volume {v}"]], ], + playTextAndRestore : [ n: "Speak text and restore...", d: "Speak text \"{0}\" and restore", p: [[n:"Text",t:"string"], [n:"Volume", t:"level", d:" at volume {v}"]], ], + playTextAndResume : [ n: "Speak text and resume...", d: "Speak text \"{0}\" and resume", p: [[n:"Text",t:"string"], [n:"Volume", t:"level", d:" at volume {v}"]], ], + playTrack : [ n: "Play track...", d: "Play track {0}{1}", p: [[n:"Track URL",t:"uri"], [n:"Volume", t:"level", d:" at volume {v}"]], ], + playTrackAndRestore : [ n: "Play track and restore...", d: "Play track {0}{1} and restore", p: [[n:"Track URL",t:"uri"], [n:"Volume", t:"level", d:" at volume {v}"]], ], + playTrackAndResume : [ n: "Play track and resume...", d: "Play track {0}{1} and resume", p: [[n:"Track URL",t:"uri"], [n:"Volume", t:"level", d:" at volume {v}"]], ], + poll : [ n: "Poll", i: 'question', ], + presetPosition : [ n: "Move to preset position", a: "windowShade", v: "partially open", ], + previousTrack : [ n: "Previous track", ], + push : [ n: "Push", ], + refresh : [ n: "Refresh", i: 'sync', ], + restoreTrack : [ n: "Restore track...", d: "Restore track {0}", p: [[n:"Track URL",t:"url"]], ], + resumeTrack : [ n: "Resume track...", d: "Resume track {0}", p: [[n:"Track URL",t:"url"]], ], + setColor : [ n: "Set color...", i: 'palette', is: "l", d: "Set color to {0}{1}", a: "color", p: [[n:"Color",t:"color"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setColorTemperature : [ n: "Set color temperature...", d: "Set color temperature to {0}°K{1}", a: "colorTemperature", p: [[n:"Color Temperature", t:"colorTemperature"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setConsumableStatus : [ n: "Set consumable status...", d: "Set consumable status to {0}", p: [[n:"Status", t:"consumable"]], ], + setCoolingSetpoint : [ n: "Set cooling point...", d: "Set cooling point at {0}{T}", a: "thermostatCoolingSetpoint", p: [[n:"Desired temperature", t:"thermostatSetpoint"]], ], + setHeatingSetpoint : [ n: "Set heating point...", d: "Set heating point at {0}{T}", a: "thermostatHeatingSetpoint", p: [[n:"Desired temperature", t:"thermostatSetpoint"]], ], + setHue : [ n: "Set hue...", i: 'palette', is: "l", d: "Set hue to {0}°{1}", a: "hue", p: [[n:"Hue", t:"hue"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setInfraredLevel : [ n: "Set infrared level...", i: 'signal', d: "Set infrared level to {0}%{1}", a: "infraredLevel", p: [[n:"Level",t:"infraredLevel"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setLevel : [ n: "Set level...", i: 'signal', d: "Set level to {0}%{1}", a: "level", p: [[n:"Level",t:"level"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setSaturation : [ n: "Set saturation...", d: "Set saturation to {0}{1}", a: "saturation", p: [[n:"Saturation", t:"saturation"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setSchedule : [ n: "Set thermostat schedule...", d: "Set schedule to {0}", a: "schedule", p: [[n:"Schedule", t:"object"]], ], + setThermostatFanMode : [ n: "Set fan mode...", d: "Set fan mode to {0}", a: "thermostatFanMode", p: [[n:"Fan mode", t:"thermostatFanMode"]], ], + setThermostatMode : [ n: "Set thermostat mode...", d: "Set thermostat mode to {0}", a: "thermostatMode", p: [[n:"Thermostat mode",t:"thermostatMode"]], ], + setTimeRemaining : [ n: "Set remaining time...", d: "Set remaining time to {0}s", a: "timeRemaining", p: [[n:"Remaining time [seconds]", t:"number"]], ], + setTrack : [ n: "Set track...", d: "Set track to {0}", p: [[n:"Track URL",t:"url"]], ], + siren : [ n: "Siren", a: "alarm", v: "siren", ], + speak : [ n: "Speak...", d: "Speak \"{0}\"", p: [[n:"Message", t:"string"]], ], + start : [ n: "Start", ], + startActivity : [ n: "Start activity...", d: "Start activity \"{0}\"", p: [[n:"Activity", t:"string"]], ], + stop : [ n: "Stop", ], + strobe : [ n: "Strobe", a: "alarm", v: "strobe", ], + take : [ n: "Take a picture", ], + unlock : [ n: "Unlock", i: 'unlock-alt', a: "lock", v: "unlocked", ], + unmute : [ n: "Unmute", i: 'volume-up', a: "mute", v: "unmuted", ], + /* predfined commands below */ + //general + quickSetCool : [ n: "Quick set cooling point...", d: "Set quick cooling point at {0}{T}", p: [[n:"Desired temperature",t:"thermostatSetpoint"]], ], + quickSetHeat : [ n: "Quick set heating point...", d: "Set quick heating point at {0}{T}", p: [[n:"Desired temperature",t:"thermostatSetpoint"]], ], + toggle : [ n: "Toggle", ], + reset : [ n: "Reset", ], + //hue + startLoop : [ n: "Start color loop", ], + stopLoop : [ n: "Stop color loop", ], + setLoopTime : [ n: "Set loop duration...", d: "Set loop duration to {0}", p: [[n:"Duration", t:"duration"]] ], + setDirection : [ n: "Switch loop direction", ], + alert : [ n: "Alert with lights...", d: "Alert \"{0}\" with lights", p: [[n:"Alert type", t:"enum", o:["Blink","Breathe","Okay","Stop"]]], ], + setAdjustedColor : [ n: "Transition to color...", d: "Transition to color {0} in {1}{2}", p: [[n:"Color", t:"color"], [n:"Duration",t:"duration"],[n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + setAdjustedHSLColor : [ n: "Transition to HSL color...", d: "Transition to color H:{0}° / S:{1}% / L:{2}% in {3}{4}", p: [[n:"Hue", t:"hue"],[n:"Saturation", t:"saturation"],[n:"Level", t:"level"],[n:"Duration",t:"duration"],[n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + //harmony + allOn : [ n: "Turn all on", ], + allOff : [ n: "Turn all off", ], + hubOn : [ n: "Turn hub on", ], + hubOff : [ n: "Turn hub off", ], + //blink camera + enableCamera : [ n: "Enable camera", ], + disableCamera : [ n: "Disable camera", ], + monitorOn : [ n: "Turn monitor on", ], + monitorOff : [ n: "Turn monitor off", ], + ledOn : [ n: "Turn LED on", ], + ledOff : [ n: "Turn LED off", ], + ledAuto : [ n: "Set LED to Auto", ], + setVideoLength : [ n: "Set video length...", d: "Set video length to {0}", p: [[n:"Duration", t:"duration"]], ], + //dlink camera + pirOn : [ n: "Enable PIR motion detection", ], + pirOff : [ n: "Disable PIR motion detection", ], + nvOn : [ n: "Set Night Vision to On", ], + nvOff : [ n: "Set Night Vision to Off", ], + nvAuto : [ n: "Set Night Vision to Auto", ], + vrOn : [ n: "Enable local video recording", ], + vrOff : [ n: "Disable local video recording", ], + left : [ n: "Pan camera left", ], + right : [ n: "Pan camera right", ], + up : [ n: "Pan camera up", ], + down : [ n: "Pan camera down", ], + home : [ n: "Pan camera to the Home", ], + presetOne : [ n: "Pan camera to preset #1", ], + presetTwo : [ n: "Pan camera to preset #2", ], + presetThree : [ n: "Pan camera to preset #3", ], + presetFour : [ n: "Pan camera to preset #4", ], + presetFive : [ n: "Pan camera to preset #5", ], + presetSix : [ n: "Pan camera to preset #6", ], + presetSeven : [ n: "Pan camera to preset #7", ], + presetEight : [ n: "Pan camera to preset #8", ], + presetCommand : [ n: "Pan camera to preset...", d: "Pan camera to preset #{0}", p: [[n:"Preset #", t:"integer",r:[1,99]]], ], + //zwave fan speed control by @pmjoen + low : [ n: "Set to Low", ], + med : [ n: "Set to Medium", ], + high : [ n: "Set to High", ], + ] +} + +private static Map virtualCommands() { + //a = aggregate + //d = display + //n = name + //t = type + List tileIndexes = ['1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16'] + return [ + noop : [ n: "No operation", a: true, i: "circle", d: "No operation", ], + wait : [ n: "Wait...", a: true, i: "clock", is: "r", d: "Wait {0}", p: [[n:"Duration", t:"duration"]], ], + waitRandom : [ n: "Wait randomly...", a: true, i: "clock", is: "r", d: "Wait randomly between {0} and {1}", p: [[n:"At least", t:"duration"],[n:"At most", t:"duration"]], ], + waitForTime : [ n: "Wait for time...", a: true, i: "clock", is: "r", d: "Wait until {0}", p: [[n:"Time", t:"time"]], ], + waitForDateTime : [ n: "Wait for date & time...", a: true, i: "clock", is: "r", d: "Wait until {0}", p: [[n:"Date & Time", t:"datetime"]], ], + executePiston : [ n: "Execute piston...", a: true, i: "clock", is: "r", d: "Execute piston \"{0}\"{1}", p: [[n:"Piston", t:"piston"], [n:"Arguments", t:"variables", d:" with arguments {v}"],[n:"Wait for execution",t:"boolean",d:" and wait for execution to finish",w:"webCoRE can only wait on piston executions of pistons within the same instance as the caller. Please note that global variables updated in the callee piston do NOT get reflected immediately in the caller piston, the new values will be available on the next run."]], ], + pausePiston : [ n: "Pause piston...", a: true, i: "clock", is: "r", d: "Pause piston \"{0}\"", p: [[n:"Piston", t:"piston"]], ], + resumePiston : [ n: "Resume piston...", a: true, i: "clock", is: "r", d: "Resume piston \"{0}\"", p: [[n:"Piston", t:"piston"]], ], + executeRoutine : [ n: "Execute routine...", a: true, i: "clock", is: "r", d: "Execute routine \"{0}\"", p: [[n:"Routine", t:"routine"]], ], + toggle : [ n: "Toggle", r: ["on", "off"], i: "toggle-on" ], + toggleRandom : [ n: "Random toggle", r: ["on", "off"], i: "toggle-on", d: "Random toggle{0}", p: [[n:"Probability for on", t:"level", d:" with a {v}% probability for on"]], ], + setSwitch : [ n: "Set switch...", r: ["on", "off"], i: "toggle-on", d: "Set switch to {0}", p: [[n:"Switch value", t:"switch"]], ], + setHSLColor : [ n: "Set color... (hsl)", i: "palette", is: "l", d: "Set color to H:{0}° / S:{1}% / L%:{2}{3}", r: ["setColor"], p: [[n:"Hue",t:"hue"], [n:"Saturation",t:"saturation"], [n:"Level",t:"level"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + toggleLevel : [ n: "Toggle level...", i: "toggle-off", d: "Toggle level between 0% and {0}%", r: ["on", "off", "setLevel"], p: [[n:"Level", t:"level"]], ], + sendNotification : [ n: "Send notification...", a: true, i: "comment-alt", is: "r", d: "Send notification \"{0}\"", p: [[n:"Message", t:"string"]], ], + sendPushNotification : [ n: "Send PUSH notification...", a: true, i: "comment-alt", is: "r", d: "Send PUSH notification \"{0}\"{1}", p: [[n:"Message", t:"string"],[n:"Store in Messages", t:"boolean", d:" and store in Messages", s:1]], ], + sendSMSNotification : [ n: "Send SMS notification...", a: true, i: "comment-alt", is: "r", d: "Send SMS notification \"{0}\" to {1}{2}", p: [[n:"Message", t:"string"],[n:"Phone number",t:"phone"],[n:"Store in Messages", t:"boolean", d:" and store in Messages", s:1]], ], + sendNotificationToContacts : [ n: "Send notification to contacts...",a: true,i: "comment-alt", is: "r", d: "Send notification \"{0}\" to {1}{2}", p: [[n:"Message", t:"string"],[n:"Contacts",t:"contacts"],[n:"Store in Messages", t:"boolean", d:" and store in Messages", s:1]], ], + log : [ n: "Log to console...", a: true, i: "bug", d: "Log {0} \"{1}\"{2}", p: [[n:"Log type", t:"enum", o:["info","trace","debug","warn","error"]],[n:"Message",t:"string"],[n:"Store in Messages", t:"boolean", d:" and store in Messages", s:1]], ], + httpRequest : [ n: "Make a web request", a: true, i: "anchor", is: "r", d: "Make a {1} request to {0}", p: [[n:"URL", t:"uri"],[n:"Method", t:"enum", o:["GET","POST","PUT","DELETE","HEAD"]],[n:"Request body type", t:"enum", o:["JSON","FORM","CUSTOM"]],[n:"Send variables", t:"variables", d:"data {v}"],[n:"Request body", t:"string", d:"data {v}"],[n:"Request content type", t:"enum", o:["text/plain","text/html","application/json","application/x-www-form-urlencoded","application/xml"]],[n:"Authorization header", t:"string", d:"{v}"]], ], + setVariable : [ n: "Set variable...", a: true, i: "superscript", is:"r", d: "Set variable {0} = {1}", p: [[n:"Variable",t:"variable"],[n:"Value", t:"dynamic"]], ], + setState : [ n: "Set piston state...", a: true, i: "align-left", is:"l", d: "Set piston state to \"{0}\"", p: [[n:"State",t:"string"]], ], + setTileColor : [ n: "Set piston tile colors...", a: true, i: "info-square", is:"l", d: "Set piston tile #{0} colors to {1} over {2}{3}", p: [[n:"Tile Index",t:"enum",o:tileIndexes],[n:"Text Color",t:"color"],[n:"Background Color",t:"color"],[n:"Flash mode",t:"boolean",d:" (flashing)"]], ], + setTileTitle : [ n: "Set piston tile title...", a: true, i: "info-square", is:"l", d: "Set piston tile #{0} title to \"{1}\"", p: [[n:"Tile Index",t:"enum",o:tileIndexes],[n:"Title",t:"string"]], ], + setTileText : [ n: "Set piston tile text...", a: true, i: "info-square", is:"l", d: "Set piston tile #{0} text to \"{1}\"", p: [[n:"Tile Index",t:"enum",o:tileIndexes],[n:"Text",t:"string"]], ], + setTileFooter : [ n: "Set piston tile footer...", a: true, i: "info-square", is:"l", d: "Set piston tile #{0} footer to \"{1}\"", p: [[n:"Tile Index",t:"enum",o:tileIndexes],[n:"Footer",t:"string"]], ], + setTile : [ n: "Set piston tile...", a: true, i: "info-square", is:"l", d: "Set piston tile #{0} title to \"{1}\", text to \"{2}\", footer to \"{3}\", and colors to {4} over {5}{6}", p: [[n:"Tile Index",t:"enum",o:tileIndexes],[n:"Title",t:"string"],[n:"Text",t:"string"],[n:"Footer",t:"string"],[n:"Text Color",t:"color"],[n:"Background Color",t:"color"],[n:"Flash mode",t:"boolean",d:" (flashing)"]], ], + clearTile : [ n: "Clear piston tile...", a: true, i: "info-square", is:"l", d: "Clear piston tile #{0}", p: [[n:"Tile Index",t:"enum",o:tileIndexes]], ], + setLocationMode : [ n: "Set location mode...", a: true, i: "", d: "Set location mode to {0}", p: [[n:"Mode",t:"mode"]], ], + setAlarmSystemStatus : [ n: "Set Smart Home Monitor status...", a: true, i: "", d: "Set Smart Home Monitor status to {0}", p: [[n:"Status", t:"alarmSystemStatus"]], ], + sendEmail : [ n: "Send email...", a: true, i: "envelope", d: "Send email with subject \"{1}\" to {0}", p: [[n:"Recipient",t:"email"],[n:"Subject",t:"string"],[n:"Message body",t:"string"]], ], + wolRequest : [ n: "Wake a LAN device", a: true, i: "", d: "Wake LAN device at address {0}{1}", p: [[n:"MAC address",t:"string"],[n:"Secure code",t:"string",d:" with secure code {v}"]], ], + adjustLevel : [ n: "Adjust level...", r: ["setLevel"], i: "toggle-on", d: "Adjust level by {0}%{1}", p: [[n:"Adjustment",t:"integer",r:[-100,100]], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + adjustInfraredLevel : [ n: "Adjust infrared level...", r: ["setInfraredLevel"], i: "toggle-on", d: "Adjust infrared level by {0}%{1}", p: [[n:"Adjustment",t:"integer",r:[-100,100]], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + adjustSaturation : [ n: "Adjust saturation...", r: ["setSaturation"], i: "toggle-on", d: "Adjust saturation by {0}%{1}", p: [[n:"Adjustment",t:"integer",r:[-100,100]], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + adjustHue : [ n: "Adjust hue...", r: ["setHue"], i: "toggle-on", d: "Adjust hue by {0}°{1}", p: [[n:"Adjustment",t:"integer",r:[-360,360]], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + adjustColorTemperature : [ n: "Adjust color temperature...", r: ["setColorTemperature"], i: "toggle-on", d: "Adjust color temperature by {0}°K%{1}", p: [[n:"Adjustment",t:"integer",r:[-29000,29000]], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + fadeLevel : [ n: "Fade level...", r: ["setLevel"], i: "toggle-on", d: "Fade level{0} to {1}% in {2}{3}", p: [[n:"Starting level",t:"level",d:" from {v}%"],[n:"Final level",t:"level"],[n:"Duration",t:"duration"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + fadeInfraredLevel : [ n: "Fade infrared level...", r: ["setInfraredLevel"], i: "toggle-on", d: "Fade infrared level{0} to {1}% in {2}{3}", p: [[n:"Starting infrared level",t:"level",d:" from {v}%"],[n:"Final infrared level",t:"level"],[n:"Duration",t:"duration"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + fadeSaturation : [ n: "Fade saturation...", r: ["setSaturation"], i: "toggle-on", d: "Fade saturation{0} to {1}% in {2}{3}", p: [[n:"Starting saturation",t:"level",d:" from {v}%"],[n:"Final saturation",t:"level"],[n:"Duration",t:"duration"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + fadeHue : [ n: "Fade hue...", r: ["setHue"], i: "toggle-on", d: "Fade hue{0} to {1}° in {2}{3}", p: [[n:"Starting hue",t:"hue",d:" from {v}°"],[n:"Final hue",t:"hue"],[n:"Duration",t:"duration"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + fadeColorTemperature : [ n: "Fade color temperature...", r: ["setColorTemperature"], i: "toggle-on", d: "Fade color temperature{0} to {1}°K in {2}{3}", p: [[n:"Starting color temperature",t:"colorTemperature",d:" from {v}°K"],[n:"Final color temperature",t:"colorTemperature"],[n:"Duration",t:"duration"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + flash : [ n: "Flash...", r: ["on", "off"], i: "toggle-on", d: "Flash on {0} / off {1} for {2} times{3}", p: [[n:"On duration",t:"duration"],[n:"Off duration",t:"duration"],[n:"Number of flashes",t:"integer"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + flashLevel : [ n: "Flash (level)...", r: ["setLevel"], i: "toggle-on", d: "Flash {0}% {1} / {2}% {3} for {4} times{5}", p: [[n:"Level 1", t:"level"],[n:"Duration 1",t:"duration"],[n:"Level 2", t:"level"],[n:"Duration 2",t:"duration"],[n:"Number of flashes",t:"integer"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + flashColor : [ n: "Flash (color)...", r: ["setColor"], i: "toggle-on", d: "Flash {0} {1} / {2} {3} for {4} times{5}", p: [[n:"Color 1", t:"color"],[n:"Duration 1",t:"duration"],[n:"Color 2", t:"color"],[n:"Duration 2",t:"duration"],[n:"Number of flashes",t:"integer"], [n:"Only if switch is...", t:"enum",o:["on","off"], d:" if already {v}"]], ], + iftttMaker : [ n: "Send an IFTTT Maker event...", a: true, d: "Send the {0} IFTTT Maker event{1}{2}{3}", p: [[n:"Event", t:"text"], [n:"Value 1", t:"string", d:", passing value1 = '{v}'"], [n:"Value 2", t:"string", d:", passing value2 = '{v}'"], [n:"Value 3", t:"string", d:", passing value3 = '{v}'"]], ], + lifxScene : [ n: "LIFX - Activate scene...", a: true, d: "Activate LIFX Scene '{0}'{1}", p: [[n: "Scene", t:"lifxScene"],[n: "Duration", t:"duration", d:" for {v}"]], ], + writeToFuelStream : [ n: "Write to fuel stream...", a: true, d: "Write data point '{2}' to fuel stream {0}{1}{3}", p: [[n: "Canister", t:"text", d:"{v} \\ "], [n:"Fuel stream name", t:"text"], [n: "Data", t:"dynamic"], [n: "Data source", t:"text", d:" from source '{v}'"]], ], + storeMedia : [ n: "Store media...", a: true, d: "Store media", p: [], ], + saveStateLocally : [ n: "Capture attributes to local store...", d: "Capture attributes {0} to local state{1}{2}", p: [[n: "Attributes", t:"attributes"],[n:'State container name',t:'string',d:' "{v}"'],[n:'Prevent overwriting existing state', t:'enum', o:['true','false'], d:' only if store is empty']], ], + saveStateGlobally : [ n: "Capture attributes to global store...", d: "Capture attributes {0} to global state{1}{2}", p: [[n: "Attributes", t:"attributes"],[n:'State container name',t:'string',d:' "{v}"'],[n:'Prevent overwriting existing state', t:'enum', o:['true','false'],, d:' only if store is empty']], ], + loadStateLocally : [ n: "Restore attributes from local store...", d: "Restore attributes {0} from local state{1}{2}", p: [[n: "Attributes", t:"attributes"],[n:'State container name',t:'string',d:' "{v}"'],[n:'Empty state after restore', t:'enum', o:['true','false'], d:' and empty the store']], ], + loadStateGlobally : [ n: "Restore attributes from global store...", d: "Restore attributes {0} from global state{1}{2}", p: [[n: "Attributes", t:"attributes"],[n:'State container name',t:'string',d:' "{v}"'],[n:'Empty state after restore', t:'enum', o:['true','false'], d:' and empty the store']], ], + parseJson : [ n: "Parse JSON data...", a: true, d: "Parse JSON data {0}", p: [[n: "JSON string", t:"string"]], ], + cancelTasks : [ n: "Cancel all pending tasks", a: true, d: "Cancel all pending tasks", p: [], ], + lifxState : [ n: "LIFX - Set State...", a: true, d: "Set LIFX lights matching {0} to {1}{2}{3}{4}{5}", p: [[n: "Selector", t:"lifxSelector"],[n: "Switch (power)",t:"enum",o:["on","off"],d:" switch '{v}'"],[n: "Color",t:"color",d:" color '{v}'"],[n: "Level (brightness)",t:"level",d:" level {v}%"],[n: "Infrared level",t:"infraredLevel",d:" infrared {v}%"],[n: "Duration",t:"duration",d:" in {v}"]], ], + lifxToggle : [ n: "LIFX - Toggle...", a: true, d: "Toggle LIFX lights matching {0}{1}", p: [[n: "Selector", t:"lifxSelector"],[n: "Duration",t:"duration",d:" in {v}"]], ], + lifxBreathe : [ n: "LIFX - Breathe...", a: true, d: "Breathe LIFX lights matching {0} to color {1}{2}{3}{4}{5}{6}{7}", p: [[n: "Selector", t:"lifxSelector"],[n: "Color",t:"color"],[n: "From color",t:"color",d:" from color '{v}'"],[n: "Period", t:"duration", d:" with a period of {v}"],[n: "Cycles", t:"integer", d:" for {v} cycles"],[n:"Peak",t:"level",d:" with a peak at {v}% of the period"],[n:"Power on",t:"boolean",d:" and power on at start"],[n:"Persist",t:"boolean",d:" and persist"] ], ], + lifxPulse : [ n: "LIFX - Pulse...", a: true, d: "Pulse LIFX lights matching {0} to color {1}{2}{3}{4}{5}{6}", p: [[n: "Selector", t:"lifxSelector"],[n: "Color",t:"color"],[n: "From color",t:"color",d:" from color '{v}'"],[n: "Period", t:"duration", d:" with a period of {v}"],[n: "Cycles", t:"integer", d:" for {v} cycles"],[n:"Power on",t:"boolean",d:" and power on at start"],[n:"Persist",t:"boolean",d:" and persist"] ], ], + //lifxCycle : [ n: "LIFX - Cycle...", a: true, d: "Cycle LIFX lights matching {0}", p: [[n: "Selector", t:"lifxSelector"]], ], +/* [ n: "waitState", d: "Wait for piston state change", p: ["Change to:enum[any,false,true]"], i: true, l: true, dd: "Wait for {0} state"], + [ n: "flash", r: ["on", "off"], d: "Flash", p: ["On interval (milliseconds):number[250..5000]","Off interval (milliseconds):number[250..5000]","Number of flashes:number[1..10]"], dd: "Flash {0}ms/{1}ms for {2} time(s)", ], + [ n: "saveState", d: "Save state to variable", p: ["Attributes:attributes","Aggregation:aggregation","?Convert to data t:dataType","Save to state variable:string"], stateVarEntry: 3, dd: "Save state of attributes {0} to variable |[{3}]|'", aggregated: true, ], + [ n: "saveStateLocally", d: "Capture state to local store", p: ["Attributes:attributes","?Only if state is empty:bool"], dd: "Capture state of attributes {0} to local store", ], + [ n: "saveStateGlobally",d: "Capture state to global store", p: ["Attributes:attributes","?Only if state is empty:bool"], dd: "Capture state of attributes {0} to global store", ], + [ n: "loadState", d: "Load state from variable", p: ["Attributes:attributes","Load from state variable:stateVariable","Allow translations:bool","Negate translation:bool"], dd: "Load state of attributes {0} from variable |[{1}]|" ], + [ n: "loadStateLocally", d: "Restore state from local store", p: ["Attributes:attributes","?Empty the state:bool"], dd: "Restore state of attributes {0} from local store", ], + [ n: "loadStateGlobally",d: "Restore state from global store", p: ["Attributes:attributes","?Empty the state:bool"], dd: "Restore state of attributes {0} from global store", ], + [ n: "queueAskAlexaMessage",d: "Queue AskAlexa message", p: ["Message:text", "?Unit:text", "?Application:text"], l: true, dd: "Queue AskAlexa message '{0}' in unit {1}",aggregated: true, ], + [ n: "deleteAskAlexaMessages",d: "Delete AskAlexa messages", p: ["Unit:text", "?Application:text"], l: true, dd: "Delete AskAlexa messages in unit {1}",aggregated: true, ], + [ n: "cancelPendingTasks",d: "Cancel pending tasks", p: ["Scope:enum[Local,Global]"], dd: "Cancel all pending {0} tasks", ], +*/ + ] +/* + (location.contactBookEnabled ? [ + sendNotificationToContacts : [n: "Send notification to contacts", p: ["Message:text","Contacts:contacts","Save notification:bool"], l: true, dd: "Send notification '{0}' to {1}", aggregated: true], + ] : [:]) + + (getIftttKey() ? [ + iftttMaker : [n: "Send IFTTT Maker event", p: ["Event:text", "?Value1:string", "?Value2:string", "?Value3:string"], l: true, dd: "Send IFTTT Maker event '{0}' with parameters '{1}', '{2}', and '{3}'", aggregated: true], + ] : [:]) + + (getLifxToken() ? [ + lifxScene: [n: "Activate LIFX scene", p: ["Scene:lifxScenes"], l: true, dd: "Activate LIFX Scene '{0}'", aggregated: true], + ] : [:])*/ +} + + + + +private static Map comparisons() { + return [ + conditions: [ + changed : [ d: "changed", g:"bdfis", t: 1, ], + did_not_change : [ d: "did not change", g:"bdfis", t: 1, ], + is : [ d: "is", dd: "are", g:"bs", p: 1 ], + is_not : [ d: "is not", dd: "are not", g:"bs", p: 1 ], + is_any_of : [ d: "is any of", dd: "are any of", g:"s", p: 1, m: true, ], + is_not_any_of : [ d: "is not any of", dd: "are not any of", g:"s", p: 1, m: true, ], + is_equal_to : [ d: "is equal to", dd: "are equal to", g:"di", p: 1 ], + is_different_than : [ d: "is different than", dd: "are different than", g:"di", p: 1 ], + is_less_than : [ d: "is less than", dd: "are less than", g:"di", p: 1 ], + is_less_than_or_equal_to : [ d: "is less than or equal to", dd: "are less than or equal to", g:"di", p: 1 ], + is_greater_than : [ d: "is greater than", dd: "are greater than", g:"di", p: 1 ], + is_greater_than_or_equal_to : [ d: "is greater than or equal to", dd: "are greater than or equal to", g:"di", p: 1 ], + is_inside_of_range : [ d: "is inside of range", dd: "are inside of range", g:"di", p: 2 ], + is_outside_of_range : [ d: "is outside of range", dd: "are outside of range", g:"di", p: 2 ], + is_even : [ d: "is even", dd: "are even", g:"di", ], + is_odd : [ d: "is odd", dd: "are odd", g:"di", ], + was : [ d: "was", dd: "were", g:"bs", p: 1, t: 2, ], + was_not : [ d: "was not", dd: "were not", g:"bs", p: 1, t: 2, ], + was_any_of : [ d: "was any of", dd: "were any of", g:"s", p: 1, m: true, t: 2, ], + was_not_any_of : [ d: "was not any of", dd: "were not any of", g:"s", p: 1, m: true, t: 2, ], + was_equal_to : [ d: "was equal to", dd: "were equal to", g:"di", p: 1, t: 2, ], + was_different_than : [ d: "was different than", dd: "were different than", g:"di", p: 1, t: 2, ], + was_less_than : [ d: "was less than", dd: "were less than", g:"di", p: 1, t: 2, ], + was_less_than_or_equal_to : [ d: "was less than or equal to", dd: "were less than or equal to", g:"di", p: 1, t: 2, ], + was_greater_than : [ d: "was greater than", dd: "were greater than", g:"di", p: 1, t: 2, ], + was_greater_than_or_equal_to : [ d: "was greater than or equal to", dd: "were greater than or equal to", g:"di", p: 1, t: 2, ], + was_inside_of_range : [ d: "was inside of range", dd: "were inside of range", g:"di", p: 2, t: 2, ], + was_outside_of_range : [ d: "was outside of range", dd: "were outside of range", g:"di", p: 2, t: 2, ], + was_even : [ d: "was even", dd: "were even", g:"di", t: 2, ], + was_odd : [ d: "was odd", dd: "were odd", g:"di", t: 2, ], + is_any : [ d: "is any", g:"t", p: 0 ], + is_before : [ d: "is before", g:"t", p: 1 ], + is_after : [ d: "is after", g:"t", p: 1 ], + is_between : [ d: "is between", g:"t", p: 2 ], + is_not_between : [ d: "is not between", g:"t", p: 2 ], + ], + triggers: [ + gets : [ d: "gets", g:"m", p: 1 ], + happens_daily_at : [ d: "happens daily at", g:"t", p: 1 ], + arrives : [ d: "arrives", g:"e", p: 2 ], + executes : [ d: "executes", g:"v", p: 1 ], + changes : [ d: "changes", dd: "change", g:"bdfis", ], + changes_to : [ d: "changes to", dd: "change to", g:"bdis", p: 1, ], + changes_away_from : [ d: "changes away from", dd: "change away from", g:"bdis", p: 1, ], + changes_to_any_of : [ d: "changes to any of", dd: "change to any of", g:"dis", p: 1, m: true, ], + changes_away_from_any_of : [ d: "changes away from any of", dd: "change away from any of", g:"dis", p: 1, m: true, ], + drops : [ d: "drops", dd: "drop", g:"di", ], + does_not_drop : [ d: "does not drop", dd: "do not drop", g:"di", ], + drops_below : [ d: "drops below", dd: "drop below", g:"di", p: 1, ], + drops_to_or_below : [ d: "drops to or below", dd: "drop to or below", g:"di", p: 1, ], + remains_below : [ d: "remains below", dd: "remains below", g:"di", p: 1, ], + remains_below_or_equal_to : [ d: "remains below or equal to", dd: "remains below or equal to", g:"di", p: 1, ], + rises : [ d: "rises", dd: "rise", g:"di", ], + does_not_rise : [ d: "does not rise", dd: "do not rise", g:"di", ], + rises_above : [ d: "rises above", dd: "rise above", g:"di", p: 1, ], + rises_to_or_above : [ d: "rises to or above", dd: "rise to or above", g:"di", p: 1, ], + remains_above : [ d: "remains above", dd: "remains above", g:"di", p: 1, ], + remains_above_or_equal_to : [ d: "remains above or equal to", dd: "remains above or equal to", g:"di", p: 1, ], + enters_range : [ d: "enters range", dd: "enter range", g:"di", p: 2, ], + remains_outside_of_range : [ d: "remains outside of range", dd: "remain outside of range", g:"di", p: 2, ], + exits_range : [ d: "exits range", dd: "exit range", g:"di", p: 2, ], + remains_inside_of_range : [ d: "remains inside of range", dd: "remain inside of range", g:"di", p: 2, ], + becomes_even : [ d: "becomes even", dd: "become even", g:"di", ], + remains_even : [ d: "remains even", dd: "remain even", g:"di", ], + becomes_odd : [ d: "becomes odd", dd: "become odd", g:"di", ], + remains_odd : [ d: "remains odd", dd: "remain odd", g:"di", ], + stays_unchanged : [ d: "stays unchanged", dd: "stay unchanged", g:"bdfis", t: 1, ], + stays : [ d: "stays", dd: "stay", g:"bdis", p: 1, t: 1, ], + stays_away_from : [ d: "stays away from", dd: "stay away from", g:"bdis", p: 1, t: 1, ], + stays_any_of : [ d: "stays any of", dd: "stay any of", g:"dis", p: 1, m: true, t: 1, ], + stays_away_from_any_of : [ d: "stays away from any of", dd: "stay away from any of", g:"bdis", p: 1, m: true, t: 1, ], + stays_equal_to : [ d: "stays equal to", dd: "stay equal to", g:"di", p: 1, t: 1, ], + stays_different_than : [ d: "stays different than", dd: "stay different than", g:"di", p: 1, t: 1, ], + stays_less_than : [ d: "stays less than", dd: "stay less than", g:"di", p: 1, t: 1, ], + stays_less_than_or_equal_to : [ d: "stays less than or equal to", dd: "stay less than or equal to", g:"di", p: 1, t: 1, ], + stays_greater_than : [ d: "stays greater than", dd: "stay greater than", g:"di", p: 1, t: 1, ], + stays_greater_than_or_equal_to : [ d: "stays greater than or equal to", dd: "stay greater than or equal to", g:"di", p: 1, t: 1, ], + stays_inside_of_range : [ d: "stays inside of range", dd: "stay inside of range", g:"di", p: 2, t: 1, ], + stays_outside_of_range : [ d: "stays outside of range", dd: "stay outside of range", g:"di", p: 2, t: 1, ], + stays_even : [ d: "stays even", dd: "stay even", g:"di", t: 1, ], + stays_odd : [ d: "stays odd", dd: "stay odd", g:"di", t: 1, ], + ] + ] +} + +private static Map functions() { + return [ + age : [ t: "integer", ], + previousage : [ t: "integer", d: "previousAge", ], + previousvalue : [ t: "dynamic", d: "previousValue", ], + newer : [ t: "integer", ], + older : [ t: "integer", ], + least : [ t: "dynamic", ], + most : [ t: "dynamic", ], + avg : [ t: "decimal", ], + variance : [ t: "decimal", ], + median : [ t: "decimal", ], + stdev : [ t: "decimal", ], + round : [ t: "decimal", ], + ceil : [ t: "decimal", ], + ceiling : [ t: "decimal", ], + floor : [ t: "decimal", ], + min : [ t: "decimal", ], + max : [ t: "decimal", ], + sum : [ t: "decimal", ], + count : [ t: "integer", ], + size : [ t: "integer", ], + left : [ t: "string", ], + right : [ t: "string", ], + mid : [ t: "string", ], + substring : [ t: "string", ], + sprintf : [ t: "string", ], + format : [ t: "string", ], + string : [ t: "string", ], + replace : [ t: "string", ], + indexof : [ t: "integer", d: "indexOf", ], + lastindexof : [ t: "integer", d: "lastIndexOf", ], + concat : [ t: "string", ], + text : [ t: "string", ], + lower : [ t: "string", ], + upper : [ t: "string", ], + title : [ t: "string", ], + int : [ t: "integer", ], + integer : [ t: "integer", ], + float : [ t: "decimal", ], + decimal : [ t: "decimal", ], + number : [ t: "decimal", ], + bool : [ t: "boolean", ], + boolean : [ t: "boolean", ], + power : [ t: "decimal", ], + sqr : [ t: "decimal", ], + sqrt : [ t: "decimal", ], + dewpoint : [ t: "decimal", d: "dewPoint", ], + fahrenheit : [ t: "decimal", ], + celsius : [ t: "decimal", ], + dateAdd : [ t: "time", d: "dateAdd", ], + startswith : [ t: "boolean", d: "startsWith", ], + endswith : [ t: "boolean", d: "endsWith", ], + contains : [ t: "boolean", ], + matches : [ t: "boolean", ], + eq : [ t: "boolean", ], + lt : [ t: "boolean", ], + le : [ t: "boolean", ], + gt : [ t: "boolean", ], + ge : [ t: "boolean", ], + not : [ t: "boolean", ], + isempty : [ t: "boolean", d: "isEmpty", ], + if : [ t: "dynamic", ], + datetime : [ t: "datetime", ], + date : [ t: "date", ], + time : [ t: "time", ], + addseconds : [ t: "datetime", d: "addSeconds" ], + addminutes : [ t: "datetime", d: "addMinutes" ], + addhours : [ t: "datetime", d: "addHours" ], + adddays : [ t: "datetime", d: "addDays" ], + addweeks : [ t: "datetime", d: "addWeeks" ], + isbetween : [ t: "boolean", d: "isBetween" ], + formatduration : [ t: "string", d: "formatDuration" ], + formatdatetime : [ t: "string", d: "formatDateTime" ], + random : [ t: "dynamic", ], + strlen : [ t: "integer", ], + length : [ t: "integer", ], + coalesce : [ t: "dynamic", ], + weekdayname : [ t: "string", d: "weekDayName" ], + monthname : [ t: "string", d: "monthName" ], + arrayitem : [ t: "dynamic", d: "arrayItem" ], + trim : [ t: "string" ], + trimleft : [ t: "string", d: "trimLeft" ], + ltrim : [ t: "string" ], + trimright : [ t: "string", d: "trimRight" ], + rtrim : [ t: "string" ], + hsltohex : [ t: "string", d: "hslToHex" ], + abs : [ t: "dynamic" ], + rangevalue : [ t: "dynamic", d: "rangeValue" ], + rainbowvalue : [ t: "string", d: "rainbowValue" ], + distance : [ t: "decimal" ], + json : [ t: "dynamic" ], + urlencode : [ t: "string", d: "urlEncode" ], + encodeuricomponent : [ t: "string", d: "encodeURIComponent" ], + ] +} + +def getIftttKey() { + def module = state.modules?.IFTTT + return (module && module.connected ? module.key : null) +} + +def getLifxToken() { + def module = state.modules?.LIFX + return (module && module.connected ? module.token : null) +} + +private Map getLocationModeOptions(updateCache = false) { + def result = [:] + for (mode in location.modes) { + if (mode) result[hashId(mode.id, updateCache)] = mode.name; + } + return result +} +private static Map getAlarmSystemStatusOptions() { + return [ + off: "Disarmed", + stay: "Armed/Stay", + away: "Armed/Away" + ] +} + +private Map getRoutineOptions(updateCache = false) { + def routines = location.helloHome?.getPhrases() + def result = [:] + if (routines) { + routines = routines.sort{ it?.label ?: '' } + for(routine in routines) { + if (routine && routine?.label) + result[hashId(routine.id, updateCache)] = routine.label + } + } + return result +} + +private Map getAskAlexaOptions() { + return state.askAlexaMacros ?: [null:"AskAlexa not installed - please install or open AskAlexa"] +} + +private Map getEchoSistantOptions() { + return state.echoSistantProfiles ?: [null:"EchoSistant not installed - please install or open EchoSistant"] +} + +private Map virtualDevices(updateCache = false) { + return [ + date: [ n: 'Date', t: 'date', ], + datetime: [ n: 'Date & Time', t: 'datetime', ], + time: [ n: 'Time', t: 'time', ], + askAlexa: [ n: 'Ask Alexa', t: 'enum', o: getAskAlexaOptions(), m: true ], + echoSistant: [ n: 'EchoSistant', t: 'enum', o: getEchoSistantOptions(), m: true ], + email: [ n: 'Email', t: 'email', m: true ], + powerSource: [ n: 'Hub power source', t: 'enum', o: [battery: 'battery', mains: 'mains'], x: true ], + ifttt: [ n: 'IFTTT', t: 'string', m: true ], + mode: [ n: 'Location mode', t: 'enum', o: getLocationModeOptions(updateCache), x: true], + tile: [ n: 'Piston tile', t: 'enum', o: ['1':'1','2':'2','3':'3','4':'4','5':'5','6':'6','7':'7','8':'8','9':'9','10':'10','11':'11','12':'12','13':'13','14':'14','15':'15','16':'16'], m: true ], + routine: [ n: 'Routine', t: 'enum', o: getRoutineOptions(updateCache), m: true], + alarmSystemStatus: [ n: 'Smart Home Monitor status', t: 'enum', o: getAlarmSystemStatusOptions(), x: true], + ] +} \ No newline at end of file diff --git a/smartapps/arnbme/shm-delay-child.src/shm-delay-child.groovy b/smartapps/arnbme/shm-delay-child.src/shm-delay-child.groovy new file mode 100644 index 00000000000..16d74b998ac --- /dev/null +++ b/smartapps/arnbme/shm-delay-child.src/shm-delay-child.groovy @@ -0,0 +1,1328 @@ +/** + * Smart Home Delay and Open Contact Monitor Child + * Functions: + * Simulate contact entry delay missing from SmartHome. + * Since contact is no longer monitored by SmartHome, monitor it for "0pen" status when system is armed + * Warning: SmartHome is fully armed during operation of this SmartApp. Tripping any non simulated sensor + * immediately triggers an intrusion alert + * + * + * Copyright 2017 Arn Burkhoff + * + * Changes to Apache License + * 4. Redistribution. Add paragraph 4e. + * 4e. This software is free for Private Use. All derivatives and copies of this software must be free of any charges, + * and cannot be used for commercial purposes. + * + * 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. + * May 14, 2019 v2.1.6 Add support for Xfinity branded UEI keypad + * Apr 07, 2019 v2.1.5 comment out logdebug "Modefix: ${modefix.id} ${modefix?.getInstallationState()}" + * modefix.id crashing user system and not used. + * Mar 14, 2019 v2.1.4 Change: Period not saved in Apple IOS, remove it as a phone number delimter + * Mar 12, 2019 v2.1.4 Added: phone number delimiters #, and Period (.) the semi colon no longer shows in android, nor is saved in IOS? + * Mar 05, 2019 V2.1.4 Added: Allow user to limit alarm state when profile is active + * Mar 05, 2019 V2.1.4 Added: Boolean flag for debug message logging, default false + * Jan 06, 2019 V2.1.3 Added: Support for 3400_G Centralite V3 + * Nov 30, 2018 v2.1.2 Add support for Iris V3 Dont check for 3405-L use 3400 again + * Oct 17, 2018 v2.1.1 Use user entry delay settings in ModeFix to control it there is an entry delay. + * Oct 15 2018 v2.1.0 move nonkeypad event creation to SHM Delay. Issue multiple messages issued + * Oct 10 2018 v2.0.9 Add Roby Dth support checking for not 3405-L vs = 3400 + * Jul 19 2018 v2.0.8 fix logic error created by 2.0.7 in new_monitor now has a true/false flag when called + * Jul 19 2018 v2.0.7 Send open door message immediately on arming Run CheckStatus in new_monitor + * Jun 27 2018 v2.0.6 Add logic to trigger SHM Delay Talker exitDelay when away mode triggered by non_keypad device + * Jun 26 2018 v2.0.6 Add logic to trigger SHM Delay Talker using a location event for entryDelay + * Jun 03 2018 v2.0.5 Show Entry Delay on simulated keypad. + * May 30 2018 v2.0.4 True Night Flag working in reverse of specification. + * modify doorsOpensHandler to correctly set armedNight or armedStay in currKeypadMode + * May 26 2018 v2.0.3 Editing crashed processing ikypd profile, adjust logic + * Apr 26 2018 v2.0.2 User unable to add simulated contact sensor + * modify pageOneVerify logic allowing real contact devices containing word simulated + * to be overridden, but leave simulated contact device logic as is + * Apr 25 2018 v2.0.1 When multiple delay profiles and motion sensor in multiple profiles triggers false alarm + * Use routine checkOtherDelayProfiles when user turns on globalDuplicateMotionSensors + * add Version routine + * Mar 21 2018 v2.0.0 add optional beep devices when door contact opens. + * Mar 04 2018 v2.0.0 Ignore User profiles in function iscontactUnique(). + * add support for globalDisable flag in first level event processing functions + * add logic supporting keypad entry tones and adjust definititions when globalKeypadControl + * Feb 03, 2018 v1.7.5 When Entry Delay time is 0, alarm did not trigger. Mostly an Exit Delay testing issue. + * in doorOpensHandler add a test for theentrydelay < 1 + * Jan 08, 2018 v1.7.4 In doorOpensHandler reduce overhead when monitored contact sensor opens by exiting when alarm=off and + * eliminate read of old events that is no longer needed or used + * Jan 04, 2018 v1.7.3 After having to issue 1.7.2 and 1.7.1, added optional user supplied override name field + * Hopefully ends this mishagas + * Simplify and slim down the error logic code by using trim() and non null start field + * Change use of siren "on" command to "siren" to be more correct + * Jan 03, 2018 v1.7.2 Allow RG Linear devices as real. Check for RG Linear in typeName + * Jan 02, 2018 v1.7.1 Allow ADT NOVA devices as real. Check for Nortek in typeName + * Jan 02, 2018 v1.7.0 Allow motion sensors to have a short delay. + * Sometimes a motion sensor sees a door and triggers alarm + * before the door contact sensor registers as open. See routine waitfordooropen + * added field themotiondelay in profile. + * Dec 31, 2017 v1.6.0 Allow for multiple Motion Sensors in profile, use global to set multiple motion sensors + * keeping current user profiles intact for existing users + * Per user request allow up to 90 seconds on exit and entry delays + * Dec 20, 2017 v1.5.2 Motion Sensor in Away mode during exit delay may trigger extraneous alarm + * when door not currently or not recently opened. + * Dec 02, 2017 v1.5.1 Motion Sensor in Away mode during entry delay time period, triggers extraneous alarm. + * When a followed motion sensor senses motion, and the contact sensor is closed after being open, + * and prior to disarming the alarm, a false alarm was issued + * In other words --- During away mode: you open a door starting entry delay time, walk in, then close the door, + * trigger a followed motion sensor prior to disarming, created a false intrusion alert. + * Nov 14, 2017 v1.5.0 error sendnotificationtocontacts always logging + * Nov 12, 2017 V1.5.0 Add support for Smartthings Contacts + * Oct 03, 2017 V1.4.2 in routine childalarmStatusHandler only issue setArmedNight for each Xfinity 3400 keypad, + * Iris does not have a night icon + * Sep 27, 2017 v1.4.1 soundalarm when open door at arming, then optional motion sensor trips, + * and door open for greater than entry delay seconds + * Sep 26, 2017 v1.4.0 Add optional motion sensor to silence when monitored contact sensor opens in away mode + * Sep 22, 2017 v1.3.0 Add logic for True Entry Delay + * Sep 22, 2017 v1.2.1c Modify allowing Connect and Konnect as "real" devices + * Sep 22, 2017 v1.2.1b Add Z-Wave in type as valid real device. User could not select as real + * Sep 22, 2017 v1.2.1a Konnect not being allowed as real device, add parens around Konnect|honeywell + * Sep 17, 2017 v1.2.1 In true night and stay modes alarm not sounding. + * soundalarm was not firing, perhaps encountered a RunOnce timing issue with server + * created then passed a map to soundalarm rather than issuing a RunOnce + * should greatly improve reliability of the instant trigger + * manually setting virtual in phone app did not trigger alarm (WTF) + * had to resave the SmartHome Security parameters to get it to fire + * + * Sep 02, 2017 v1.2.0 Repackage ModeFix into child module, skip running fix when bad 'night mode' is found + * Aug 31, 2017 v1.1.0e Add Honeywell to valid Simulated contacts + * Aug 31, 2017 v1.1.0f Simulate beep with on/off if no beep command, fails with GoControl Siren + * Aug 30, 2017 v1.1.0e keypad acts up when commands come in to fast. always use a one second delay on setarmed night + * Aug 30, 2017 v1.1.0e issue setArmedNight only when using upgraded Keypad + * Aug 30, 2017 v1.1.0d verify keypad can issue setEntryDelay, or issue error msg + * Aug 30, 2017 v1.1.0c verify siren has a beep command, or issue error msg + * Aug 30, 2017 v1.1.0b change passing of error data back to pages to a state field + * Aug 29, 2017 v1.1.0a add State of 'batteryStatus' when testing for real or simulated device + * Aug 28, 2017 v1.1.0 when globaFixMode is on, eliminate 2 second delay issuing keypad setArmedNight + * Aug 25, 2017 v1.1.0 Setting alarmstatus in Smart Home Monitor does not set Mode + * disabled testing mode with TrueNight and stay with 2 armed modes vs 3 available on keypad + * Aug 24, 2017 v1.1.0 SmartHome sends stay mode when going into night mode lighting the stay mode on + * the Xfinity keypad. Force keypad to show night mode and have no entry delay + * Aug 28, 2017 v1.0.9a Allow Konnect simulated sensors as only real devices + * Aug 24, 2017 v1.0.9 insure keypads cannot be used for any type of contact sensor + * Aug 23, 2017 v1.0.8 Add test device.typeName for Simulated, police numbers into intrusion message + * use standard routine for messages + * Aug 21, 2017 v1.0.7c Add logic to prevent installation this child module pageZero and PageZeroVerify + * Aug 20, 2017 v1.0.7b When globalIntrusionMsg is true suppress non unique sensor notice messages + * Aug 19, 2017 v1.0.7a A community created DTH did not set a manufacturer or model + * causing the device reject as a real device. Add test for battery. + * simulated devices dont have batteries (hopefully) + * Aug 19, 2017 v1.0.7 simulated sensor being unique or not is controlled by switch globalSimUnique in parent + * when globalIntrusionMsg is true, issue notifications + * Open door monitor failing due to single vs multiple sensor definition adjust code + * to run as single for now + * Aug 17, 2017 v1.0.6a require simulated sensor to be unique + * Aug 16, 2017 v1.0.6 add logic check if sensors for unique usage. Stop on real sensor, Warn on simulated + * Aug 16, 2017 v1.0.5 add verification editing on sensors and illogical conditions + * Aug 15, 2017 v1.0.4 fill Label with real sensor name + * Aug 14, 2017 v1.0.3 add exit delay time and logic: + * When away mode do not react to contact opens less than exit delay time + * Aug 12, 2017 v1.0.2 add log to notifications, fix push and sms not to log, add multiple SMS logic + * Aug 12, 2017 v1.0.1 Allow profile to be named by user with Label parameter on pageOne + * Aug 12, 2017 v1.0.0 Combine Smart Delay and Door Monitor into this single child SmartApp + * + */ +definition( + name: "SHM Delay Child", + namespace: "arnbme", + author: "Arn Burkhoff", + description: "(${version()}) Child Delay Profile, Smart Home Monitor Exit/Entry Delays", + category: "My Apps", + parent: "arnbme:SHM Delay", + iconUrl: "https://www.arnb.org/IMAGES/hourglass.png", + iconX2Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + iconX3Url: "https://www.arnb.org/IMAGES/hourglass@2x.png") + +preferences { + page(name: "pageZeroVerify") + page(name: "pageZero", nextPage: "pageZeroVerify") + page(name: "pageOne", nextPage: "pageOneVerify") + page(name: "pageOneVerify") + page(name: "pageTwo", nextPage: "pageTwoVerify") + page(name: "pageTwoVerify") + page(name: "pageThree", nextPage: "pageThreeVerify") + } + +def version() + { + return "2.1.6"; + } + +def pageZeroVerify() + { + if (parent && parent.getInstallationState()=='COMPLETE') + { + pageOne() + } + else + { + pageZero() + } + } + +def pageZero() + { + dynamicPage(name: "pageZero", title: "This App cannot be installed", uninstall: true, install:false) + { + section + { + paragraph "This SmartApp, SHMDelay Child, cannot be installed. Please install and use SHM Delay." + } + } + } + + +def pageOne() + { + dynamicPage(name: "pageOne", title: "The Sensors", uninstall: true) + { + section + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + input "logDebugs", "bool", required: false, defaultValue:false, + title: "Log debugging messages? Normally off/false" + input "thecontact", "capability.contactSensor", required: true, + title: "Real Contact Sensor (Remove from SmartHome Monitoring)", submitOnChange: true + } + section + { + input "thesimcontact", "capability.contactSensor", required: true, + title: "Simulated Contact Sensor (Must Monitor in SmartHome)" + } + section + { + input (name: "stateLimit", type:"enum", required: false, options: ["Away","Stay"], + title: "(Optional!) When system is armed, react to the real contact sensor opening only when armed: Away or Stay. Default: Reacts with Away and Stay") + } + section + { + if (parent?.globalMultipleMotion) + { + input "themotionsensors", "capability.motionSensor", required: false, multiple: true, + title: "(Optional!) Ignore these Motion Sensors during exit delay, and when the Real Contact Sensor opens during entry delay. These sensors are monitored in Alarm State: Away (Remove from SmartHome Security Armed (Away) Monitoring)" + } + else + { + input "themotionsensor", "capability.motionSensor", required: false, + title: "(Optional!) Ignore this Motion Sensor during exit delay, and when the Real Contact Sensor opens during entry delay. The sensor is monitored in Alarm State: Away (Remove from SmartHome Security Armed (Away) Monitoring)" + } + } + section + { + input "contactname", type: "text", required: false, + title: "(Optional!) Contact Name: When Real Contact Sensor is rejected as simulated, enter 4 to 8 alphanumeric characters from the IDE Device Type field to force accept device", submitOnChange: true + } + + if (thecontact) + { + section([mobileOnly:true]) + { + label title: "Profile name", defaultValue: "Profile: Delay: ${thecontact.displayName}", required: false + } + + } + else + { + section([mobileOnly:true]) + { + label title: "Profile name", required: false + } + } + } + } + + +def pageOneVerify() //edit page one info, go to pageTwo when valid + { + def error_data = "" + def pageTwoWarning + def ok_names = "(.*)(?i)((C|K)onnect|honeywell|Z[-]Wave|Nortek|RG Linear" + if (contactname) + { + def wknm=contactname.trim() + if (wknm.matches("([a-zA-Z0-9 ]{4,8})")) + {ok_names = ok_names + "|" + wknm + ")(.*)"} + else + { + ok_names = ok_names + ")(.*)" + error_data = "Contact Name length must be alphanumeric 4 to 8 characters, please reenter\n\n" + } + } + else + { + ok_names = ok_names + ")(.*)" +// logdebug "contact name field not provided" + } + if (thecontact) + { +/* logdebug "editing contact name ${thecontact.typeName}" + def txt = "xfinity 3400 Keypad xyz" //test code for failing match group test + def m + if ((m = txt =~ /(.*)(?i)(keypad)(.*)/)) { + logdebug "m $m" + def match = m.group(1) //fails with error message here + logdebug "MATCH=$match"} +*/ if (thecontact.typeName.matches("(.*)(?i)(keypad)(.*)")) + { + error_data+="Device: ${thecontact.displayName} is not a valid real contact sensor! Please select a differant device or tap 'Remove'\n\n" + } + else + if ((thecontact.typeName.matches("(.*)(?i)simulated(.*)") || + thecontact.getManufacturerName() == null && thecontact.getModelName()==null && + thecontact?.currentState("battery") == null && thecontact?.currentState("batteryStatus") == null) && + !thecontact.typeName.matches(ok_names)) + { + error_data+="The 'Real Contact Sensor' appears to be simulated. Please select a differant real contact sensor, or enter data into Contact Name field, or tap 'Remove'\n\n" +/* error_data="'${thecontact.displayName}' is simulated. Please select a differant real contact sensor or tap 'Remove'" + for some reason the prior line is not seen as a string +*/ } + else + if (!iscontactUnique()) + { + error_data+="The 'Real Contact Sensor' is already in use. Please select a differant real contact sensor or tap 'Remove'\n\n" + } + } + + if (thesimcontact) + { + if (thesimcontact.typeName.matches("(.*)(?i)keypad(.*)")) + { + error_data+="Device: ${thesimcontact.displayName} is not a valid simulated contact sensor! Please select a differant device or tap 'Remove'\n\n" + } + else + if (thesimcontact.typeName.matches("(.*)(?i)simulated(.*)") || + (thesimcontact.getManufacturerName() == null && thesimcontact.getModelName()==null && + thesimcontact.currentState("battery") == null && thesimcontact?.currentState("batteryStatus") == null && + !thesimcontact.typeName.matches(ok_names))) + { + if (!issimcontactUnique()) + { + if (parent?.globalSimUnique) + { + error_data+="The 'Simulated Contact Sensor' is already in use. Please select a differant simulated contact sensor or tap 'Remove'\n\n" + } + else + if (parent?.globalIntrusionMsg) + {} + else + if (error_data!="") + { + error_data+="Notice: Intrusion messages are off, but 'Simulated Contact Sensor' already in use. Ignore or tap 'Back' to change device\n\n" + } + else + { + pageTwoWarning="Notice: Intrusion messages are off, but 'Simulated Contact Sensor' already in use. Ignore or tap 'Back' to change device\n\n" + } + } + } + else + { + error_data+="The 'Simulated Contact Sensor' is real. Please select a differant simulated contact sensor or tap 'Remove'\n\n" + } + } + if (error_data!="") + { + state.error_data=error_data.trim() + pageOne() + } + else + { + if (pageTwoWarning!=null) + {state.error_data=error_data.trim()} + pageTwo() + } + } + +def iscontactUnique() + { + def unique = true + def children = parent?.getChildApps() +// logdebug "there are ${children.size()} apps" +// logdebug "this contact id: ${thecontact.getId()}" +// logdebug "app install: ${app.getInstallationState()}" +// logdebug "app id: ${app?.getId()}" +// def myState = app.currentState() +// logdebug "current app id: ${myState}" +// logdebug current app Id "${myState.getId()}" + children.each + { child -> + +// logdebug "child app id: ${child.getId()} ${child.getLabel()}" +// logdebug "child contact Id: ${child.thecontact.getId()}" + def childLabel = child.getLabel() + if (child.getName()!="SHM Delay Child") + {} + else + if (child.thecontact.getId() == thecontact.getId() && + child.getId() != app.getId()) + { + unique=false + } + } + return unique + } + +def issimcontactUnique() + { + def unique = true + def children = parent?.getChildApps() + children.each + { child -> + def childLabel = child.getLabel() + if (child.getName()!="SHM Delay Child") + {} + else + if (child.thesimcontact.getId() == thesimcontact.getId() && + child.getId() != app.getId()) + { + unique=false + } + } + return unique + } + +/* cant make this work in java +def isUnique(contact) + { + def unique = true + def children = parent?.getChildApps() + children.each + { child -> + if (child.${contact}.getId() == ${contact}.getId() && + child.getId() != app.getId()) + { + unique=false + } + } + return unique + } +*/ + +def pageTwo() + { + dynamicPage(name: "pageTwo", title: "Entry and Exit Data", uninstall: true) + { + section("") + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + input "theentrydelay", "number", required: true, range: "0..90", defaultValue: 30, + title: "Alarm entry delay time in seconds from 0 to 90" + if (parent.globalKeypadControl) + { + input "theexitdelay", "number", required: true, range: "0..90", defaultValue: 30, + title: "When arming in away mode without the keypad, set a simulated exit delay time in seconds from 0 to 90." + } + else + input "theexitdelay", "number", required: true, range: "0..90", defaultValue: 30, + title: "When arming in away mode set an exit delay time in seconds from 0 to 90." + input "themotiondelay", "number", required: true, range: "0..10", defaultValue: 0, + title: "When arming in away mode optional motion sensor entry delay time in seconds from 0 to 10, default:0. Usually not needed. Fixes a motion sensor reacting to door movement before contact sensor registers as open. Only when needed, suggested initial value is 5." + if (parent.globalKeypadControl) + paragraph "All keypads defined in parent module sound delay tones when supported by device" + else + { + input "thekeypad", "capability.button", required: false, multiple: true, + title: "Sound entry delay tones on these keypads (Optional)" + } + input "thesiren", "capability.alarm", required: false, multiple: true, + title: "Beep these devices on entry delay (Optional)" + input "thebeepers", "capability.tone", required: false, multiple: true, + title: "Beep/Chime these devices when real contact sensor opens, and Alarm State is Off (Optional)" + } + } + } + +def pageTwoVerify() //edit page one info, go to pageTwo when valid + { + def error_data="" + if (thekeypad) + { + thekeypad.each //fails when not defined as multiple contacts + { +// logdebug "Current Arm Mode: ${it.currentarmMode} ${it.getManufacturerName()}" + if (!it.hasCommand("setEntryDelay")) + { + error_data="Keypad: ${it.displayName} does not support entry tones. Please remove the device from keypads.\n\n" + } + } + } + if (thesiren) + { + thesiren.each //fails when not defined as multiple contacts + { + if (it.hasCommand("beep") || (it.hasCommand("siren") && it.hasCommand("off"))) + {} + else + { + error_data+="Entry Delay Beep Device: ${it.displayName} unable to create a beep with this device. Please remove the device from sirens.\n\n" + } + } + } + if (theentrydelay < 1 && theexitdelay < 1) + { + error_data+="Illogical condition: entry and exit delays are both zero\n\n" + } + if (error_data!="") + { + state.error_data=error_data.trim() + pageTwo() + } + else + { + pageThree() + } + } + + +def pageThree(error_data) + { + dynamicPage(name: "pageThree", title: "Open door monitor and notification settings", install: true, uninstall: true) + { + section("") + { + input "maxcycles", "number", required: false, range: "1..99", defaultValue: 2, + title: "Maximum number of open door warning messages" + input "themonitordelay", "number", required: false, range: "1..15", defaultValue: 1, + title: "Number of minutes between open door messages from 1 to 15" + paragraph "Following settings are used with Open Door and optional Intrusion messages" + input "theLog", "bool", required: false, defaultValue:true, + title: "Log to Notifications?" + if (location.contactBookEnabled) + { + input("recipients", "contact", title: "Notify Contacts",required:false,multiple:true) + input "thesendPush", "bool", required: false, defaultValue:false, + title: "Send Push Notification?" + } + else + { + input "thesendPush", "bool", required: false, defaultValue:true, + title: "Send Push Notification?" + } + input "phone", "phone", required: false, + title: "Send a text message to this number. For multiple SMS recipients, separate phone numbers with a pound sign(#), or semicolon(;)" + } + + } + } + +def pageThreeVerify() //edit page three info + { + def error_data + if (theLog || thesendPush || phone|| recipients) + {} + else + { + error_data="Please change settings to log the error message" + } + if (error_data!=null) + { + state.error_data=error_data + pageThree() + } +// else +// { +// pageOne() +// } + } + + +def installed() { + log.info "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.info "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() + { + subscribe(location, "alarmSystemStatus", childalarmStatusHandler) + subscribe(thecontact, "contact.open", doorOpensHandler) + subscribe(thecontact, "contact.closed", contactClosedHandler) //open door monitor + if (parent?.globalMultipleMotion) + { + if (themotionsensors) + { + subscribe(themotionsensors, "motion.active", motionActiveHandler) + } + } + else + { + if (themotionsensor) + { + subscribe(themotionsensor, "motion.active", motionActiveHandler) + } + } + } + +/******** Common Routine monitors the alarm state for changes ********/ + +def childalarmStatusHandler(evt) + { + if (parent.globalDisable) + return false + def theAlarm = evt.value + def delaydata=evt?.data +// logdebug "delaydata ${delaydata}" +// logdebug "changed ${evt.isStateChange()}" +// logdebug "alarm state changed stuff ${evt.value} ${evt?.description} ${evt?.name} ${evt.date.time} ${evt?.data} ${delaydata} " + if (delaydata=="shmtruedelay_rearm") //True entry delay, rearming is ignored here + { +// logdebug "childalarmStatusHandler ignoring the rearm request" + return false + } + else + if (delaydata=="shmtruedelay_away") //Process away mode with Entry true delay + { +// logdebug "childalarmStatusHandler prepare to rearm away" + prepare_to_soundalarm("away") + return false + } + else + if (delaydata=="shmtruedelay_stay") //Process away mode with Entry true delay + { +// logdebug "childalarmStatusHandler prepare to rearm stay" + prepare_to_soundalarm("stay") + return false + } + if (theAlarm == "night") //bad AlarmState processed once by Modefix thats enough + {return false} // and we get it almost immediately + +// Jun 27, 2018 add logic to sendLocationEvent for SHM Delay Talk when away mode triggered by non keypad device +/* Moved to SHm Delay due to multiple messages being issued V2.1.0 Oct 15, 2018 + def alarm = location.currentState("alarmSystemStatus") + def lastupdt = alarm?.date.time + def alarmSecs = Math.round( lastupdt / 1000) + def kSecs=0 + def kMap + def locevent = [name:"shmdelaytalk", value: "exitDelayNkypd", isStateChange: true, + displayed: true, descriptionText: "Issue exit delay talk event", linkText: "Issue exit delay talk event", + data: theexitdelay] + if (theAlarm == 'away' && theexitdelay > 0) + { + if (parent?.globalKeypadControl) + { + kMap=parent?.atomicState.kMap + kSecs = Math.round(kMap.dtim / 1000) +// logdebug "Talker fields $kSecs $alarmSecs $theexitdelay" + if (alarmSecs - kSecs > theexitdelay+4) //allow 4 second delay for ST delays due to cloud and internet + { + sendLocationEvent(locevent) +// logdebug "Away Talker from non keypad triggered" + } + } + else + { + sendLocationEvent(locevent) + } + } + +*/ + def theMode = location.currentMode + logdebug("childalarmStatusHandler1 Alarm: ${theAlarm} Mode: ${theMode} FixMode: ${parent?.globalFixMode}") + +// Optionally fix the mode to match the Alarm State. When user sets alarm from dashboard +// the Mode is not set, resulting in Smarthings having Schizophrenia or cognitive dissonance. + if (parent?.globalFixMode) + { + def modefix=parent.findChildAppByName("SHM Delay ModeFix") +// logdebug "Modefix: ${modefix?.id} ${modefix?.getInstallationState()}" + if (modefix?.getInstallationState() == 'COMPLETE') + { +// logdebug "going to modefix alarmstatushandler mode: ${theMode}" +// reset the map adding childid telling modefix not to log this to notifications + def evtMap = [value: evt.value, source: evt.source, childid: "childid"] +// logdebug "${evtMap}" + theMode=modefix.alarmStatusHandler(evtMap) +// theMode=modefix.alarmStatusHandler(evt) //deprecated Mar 11, 2018 + logdebug "returned from modefix alarmstatushandler mode: ${theMode}" + if (!theMode) + {theMode = location.currentMode} + } + } + + if (theAlarm=="off") + { + unschedule(soundalarm) //kill any lingering future tasks for delay or monitor + killit() //kill any lingering future tasks for delay or monitor + } + else + { + if (countopenContacts()==0) + { + killit() + } + else + { + new_monitor(false) + } + + if (parent?.globalKeypadControl) + { +/* if (theAlarm=="stay" && parent?.globalTrueNight && theMode=="Night") + { + parent?.globalKeypadDevices.each + { + if (it.getModelName()=="3400" && it.getManufacturerName()=="CentraLite") + { + logdebug "matched1, set armrednight issued: ${it.getModelName()} ${it.getManufacturerName()}" + it.setArmedNight([delay: 2000]) + } + } + } +*/ } + else + if (parent?.globalKeypad && theAlarm=="stay" && parent?.globalTrueNight && theMode=="Night" && thekeypad) + { +/* thekeypad.each + { + if (it.getModelName()=="3400" && it.getManufacturerName()=="CentraLite") + { + logdebug "matched2, set armrednight issued: ${it.getModelName()} ${it.getManufacturerName()}" + thekeypad.setArmedNight([delay: 2000]) + } + } +*/ } + } + } + +// log, send notification, SMS message +def doNotifications(message) + { + def localmsg=message+" at ${location.name}" + if (theLog) + { + sendNotificationEvent(localmsg) + } + if (location.contactBookEnabled && recipients) + { + sendNotificationToContacts(localmsg, recipients, [event: false]) //Nov 11, 2017 send to selected contacts no log + } + if (thesendPush) + { + sendPushMessage(localmsg) + } + if (phone) + { + def phones = phone.split("[;#]") +// logdebug "$phones" + for (def i = 0; i < phones.size(); i++) + { + sendSmsMessage(phones[i], localmsg) + } + } + } + +/******** SmartHome Entry Delay Logic ********/ + +def motionActiveHandler(evt) + { + if (parent.globalDisable) + return false +// A motion sensor shows motion + def triggerDevice = evt.getDevice() + logdebug "motionActiveHandler called: $evt by device : ${triggerDevice.displayName}" + +// if not in Away mode, ignore all motion sensor activity +// When alarm was set less than exit delay time, ignore the motion sensor activity +// else +// Entry delay alarm if contact sensor was not opened within entry delay time + +// get alarmstatus and time alarm set in seconds + def alarm = location.currentState("alarmSystemStatus") + def alarmstatus = alarm?.value + if (alarmstatus != "away") + {return false} + def lastupdt = alarm?.date.time + def alarmSecs = Math.round( lastupdt / 1000) + +// get current time in seconds + def currT = now() + def currSecs = Math.round(currT / 1000) //round back to seconds + +// get status of associated contact sensor +// def curr_contact = thecontact.currentContact (will be open or closed) not currently in use + + def kSecs=0 //if defined in if statment it is lost after the if + def kMap + if (parent?.globalKeypadControl) + { + kMap=parent?.atomicState.kMap + kSecs = Math.round(kMap.dtim / 1000) + } +// if (parent?.globalKeypadControl && kMap.mode=="Away" && theexitdelay > 0 && + if (parent.globalKeypadControl && theexitdelay > 0 && + alarmSecs - kSecs > 4 && currSecs - alarmSecs < theexitdelay) + { +// logdebug "motionActiveHandler return1" + return false + } + else + if (!parent?.globalKeypadControl && theexitdelay > 0 && currSecs - alarmSecs < theexitdelay) + { +// logdebug "motionActiveHandler return2" + return false + } + else + { +// process motion sensor event that may occur during an entrydelay +// get the last 10 contact sensor events, then find the time the contact was last opened, if open not found: soundalarm + def events=thecontact.events() + def esize=events.size() + def i = 0 +// logdebug "motionActiveHandler scanning events ${esize}" + def open_seconds=999999 + for(i; i < esize; i++) + { + if (events[i].value == "open"){ + open_seconds = Math.round((now() - events[i].date.getTime())/1000) + logdebug("value: ${events[i].value} now: ${now()} startTime: ${events[i].date.getTime()} seconds ${open_seconds}") + break; + } + } +// logdebug "motionActiveHandler scan done ${esize} ${open_seconds}" + if (open_seconds>theentrydelay) + { + + def aMap = [data: [lastupdt: lastupdt, shmtruedelay: false, motion: triggerDevice.displayName]] + if (themotiondelay > 0) + { + def now = new Date() + def runTime = new Date(now.getTime() + (themotiondelay * 1000)) + runOnce(runTime, waitfordooropen, [data: aMap]) + } + else + { + logdebug "*****testing duplicate sensor flag*******" + if (parent?.globalDuplicateMotionSensors) + { + logdebug "*****Calling checkOtherDelayProfile*******" + if (checkOtherDelayProfiles(thecontact, triggerDevice, theentrydelay)) + {return false} + } + } + logdebug "Away Mode: Intrusion caused by followed motion sensor at ${aMap.data.lastupdt}" + soundalarm(aMap.data) + } + } + + } + +def doorOpensHandler(evt) + { + if (parent.globalDisable) + return false +/* def latestDeviceState = thecontact.latestState("closed") deprecated Jan 08, 2018 data was not used + logdebug "latest closed state ${latestDeviceState}" + def events=thecontact.events() + for(def i = 0; i < events.size(); i++) { + def startTime = events[i].date.getTime() + logdebug("value: ${events[i].value} startTime: ${startTime}") + } +*/ + def alarm = location.currentState("alarmSystemStatus") + def alarmstatus = alarm?.value + logdebug "doorOpensHandler entered alarmstatus: $alarmstatus stateLimit: $stateLimit" +// logdebug "doorOpensHandler ${alarm} ${alarmstatus}" + if (alarmstatus == "off") + { + thebeepers?.each + { + if (it?.currentValue("armMode")=="exitDelay") //bypass keypads beeping exit delay tones + { +// def beepDevice = it?.getDevice() +// logdebug "skipped device on exit delay ${beepDevice.displayName} armMode: ${it?.currentValue('armMode')}" + } + else + {it.beep()} + } + return false + } + def lastupdt = alarm?.date.time + + def theMode = location.currentMode + logdebug "doorOpensHandler called: $evt.value $alarmstatus $lastupdt Mode: $theMode Truenight:${parent.globalTrueNight} " + +// get current time and alarm time in seconds + def currT = now() + def currSecs = Math.round(currT / 1000) //round back to seconds +// logdebug "${currSecs}" + def alarmSecs = Math.round( lastupdt / 1000) +// logdebug "${alarmSecs}" + +// alarmstaus values: off, stay, away +// check first if this is an exit delay in away mode, if yes monitor the door, else its an alarm + def kSecs=0 //if defined in if statment it is lost after the if + def kMap + def currkeypadmode="" + def daentrydelay=true + if (parent?.globalKeypadControl) + { + kMap=parent?.atomicState.kMap + kSecs = Math.round(kMap.dtim / 1000) +// Get the status of the first (non-Iris) 3400 keypad + parent?.globalKeypadDevices?.each + { +// if (it.getModelName()=="3400" && currkeypadmode=="") Oct 10, 2018 add Rboy DTH support +// if (it.getModelName()!="3405-L" && currkeypadmode=="") Nov 30, 2018 Rboy support for iris V3 +// if (it.getModelName()=="3400" && currkeypadmode=="") Jan 06, 2019 Centralite V3 support +// if (['3400','3400-G'].contains(keypad?.getModelName()) && currkeypadmode=="") V2.1.6 May 14, 2019 + if (currkeypadmode=="" && + ['3400','3400-G','URC4450BC0-X-R'].contains(keypad?.getModelName())) + { + currkeypadmode = it?.currentValue("armMode") +// logdebug "keypad set currkeypadmode to $currkeypadmode" + } + } + } + +// no 3400 keypad found or set currkeypad mode from globalTrueNight for stay mode +// updated V2.0.4 fixes incorrect operation of globalTrueNight flag + if (currkeypadmode=="") + { + if (parent?.globalTrueNight) + currkeypadmode='armedNight' + else + currkeypadmode='armedStay' +// logdebug "globalTrueNight set currkeypadmode to $currkeypadmode" + } +// if (alarmstatus == "away" && parent.globalKeypadControl && kMap.mode=="Away" && theexitdelay > 0 && + if (alarmstatus == "away" && parent.globalKeypadControl && theexitdelay > 0 && + alarmSecs - kSecs > 4 && currSecs - alarmSecs < theexitdelay) + { + new_monitor(true) + } + else + if (alarmstatus == "away" && !parent.globalKeypadControl && currSecs - alarmSecs < theexitdelay) + { + new_monitor(true) + } + else + if ((alarmstatus == "stay" && stateLimit && stateLimit == "Away") || + (alarmstatus == "away" && stateLimit && stateLimit == "Stay")) + { + logdebug "doorOpensHandler Alarm ignored alarmstatus: $alarmstatus stateLimit: $stateLimit" + } + else +// if (theentrydelay < 1 || (alarmstatus == "stay" && parent?.globalTrueNight && theMode=="Night")) Mar 23, 2018 +// if (theentrydelay < 1 || (alarmstatus == "stay" && currkeypadmode!="armedStay")) Oct 17, 2018 + if (theentrydelay < 1) + { + def aMap = [data: [lastupdt: lastupdt, shmtruedelay: false]] + if (theentrydelay<1) + {logdebug "EntryDelay is ${settings.theentrydelay}, instant on for alarm ${aMap.data.lastupdt}"} + else + {logdebug "Night Mode instant on for alarm ${aMap.data.lastupdt}"} + soundalarm(aMap.data) + } + else + if (alarmstatus == "stay" || alarmstatus == "away") + { + logdebug "doorOpensHandler Alarm honored alarmstatus: $alarmstatus stateLimit: $stateLimit" + def mf=parent?.findChildAppByName('SHM Delay ModeFix') +// logdebug "${mf.getInstallationState()} ${mf.version()}" + if (mf && mf.getInstallationState() == 'COMPLETE' && mf.version() > '0.1.4') + { + def am="${alarmstatus}Entry${theMode}" + daentrydelay = mf."${am}" + logdebug "Version ${mf.version()} the daentrydelay is ${daentrydelay}" + } + else + if (alarmstatus == "stay" && currkeypadmode!="armedStay") + {daentrydelay=false} + + if (daentrydelay) + {} + else + { + def aMap = [data: [lastupdt: lastupdt, shmtruedelay: false]] + soundalarm(aMap.data) + return false + } + if (themotiondelay > 0) + { + unschedule(waitfordooropen) + } + if (parent?.globalTrueEntryDelay) + { + logdebug "True Entry Mode enabled issuing event SmartHome off" +// note data object created here is a string not a map, for reasons unknown map field fails + def event = [ + name:'alarmSystemStatus', + value: "off", + displayed: true, + description: "SHM Delay True Entry Delay", + data: "shmtruedelay_"+alarmstatus] + logdebug "event ${event}" + sendLocationEvent(event) //change alarmstate to stay + } + else + {prepare_to_soundalarm(false)} + } + } + +def prepare_to_soundalarm(shmtruedelay) + { + def alarm = location.currentState("alarmSystemStatus") + def alarmstatus = alarm?.value + def lastupdt = alarm?.date.time + + logdebug "Prepare to sound alarm entered $shmtruedelay" +// When keypad is defined: Issue an entrydelay for the delay on keypad. Keypad beeps + if (parent?.globalKeypadControl) + { + parent.globalKeypadDevices.each() + { + if (it.hasCommand("setEntryDelay")) + { + if (shmtruedelay) + {it.setEntryDelay(theentrydelay, [delay: 2000])} + else + {it.setEntryDelay(theentrydelay)} + } + } + parent.qsse_status_mode(false,"Entry%20Delay") + } + else + { + if (settings.thekeypad) + { + if (shmtruedelay) + {thekeypad.setEntryDelay(theentrydelay, [delay: 2000])} + else + {thekeypad.setEntryDelay(theentrydelay)} + } + } +// when siren is defined: wait 2 seconds allowing people to get through door, then blast a siren warning beep +// Aug 31, 2017 add simulated beep when no beep command + if (settings.thesiren) + { + thesiren.each //fails when not defined as multiple contacts + { + if (it.hasCommand("beep")) + { + it.beep([delay: 2000]) + } + else + { + it.off([delay: 2500]) //double off the siren to hopefully shut it + it.siren([delay: 2000]) + it.off([delay: 2250]) + } + } + } + +// Trigger Alarm in theentrydelay seconds by opening the virtual sensor. +// Do not delay alarm when additional triggers occur by using overwrite: false + def now = new Date() + def runTime = new Date(now.getTime() + (theentrydelay * 1000)) + runOnce(runTime, soundalarm, [data: [lastupdt: lastupdt, shmtruedelay: shmtruedelay], overwrite: false]) + def locevent = [name:"shmdelaytalk", value: "entryDelay", isStateChange: true, + displayed: true, descriptionText: "Issue entry delay talk event", linkText: "Issue entry delay talk event", + data: theentrydelay] + sendLocationEvent(locevent) +// logdebug "sent location event for shmdelaytalk" + } + +// wait for door to open in themotiondelay seconds +def waitfordooropen(evt) + { + logdebug "waitfordooropen entered ${evt}" + soundalarm (evt.data) + } + +// Sound the Alarm +def soundalarm(data) + { + def alarm2 = location.currentState("alarmSystemStatus") + def alarmstatus2 = alarm2.value + def lastupdt = alarm2.date.time + logdebug "soundalarm called: $alarmstatus2 $data ${data.lastupdt} $lastupdt" + if (alarmstatus2=="off" && !data.shmtruedelay) + {} + else + if (data.lastupdt==lastupdt) //if this does not match, the system was set off then rearmed in delay period + { + if (data.shmtruedelay) + { + logdebug "soundalarm rearming in mode ${data.shmtruedelay}" + def event = [ + name:'alarmSystemStatus', + value: data.shmtruedelay, + displayed: true, + description: "SHM Delay True Delay ReArm in $data.shmtruedelay", + data: "shmtruedelay_rearm"] + sendLocationEvent(event) //change alarmstate to stay + thesimcontact.close([delay: 2000]) + logdebug "true entry delay alarm rearmed" + thesimcontact.open([delay:2000]) + parent.qsse_status_mode(false,"**Intrusion**") + } + else + { + thesimcontact.close() //must use a live simulated sensor or this fails in Simulator + logdebug "alarm triggered" + thesimcontact.open() + parent.qsse_status_mode(false,"**Intrusion**") + } +// Aug 19, 2017 issue optional intrusion notificaion messages + if (parent?.globalIntrusionMsg) + { +// logdebug "sending global intrusion message " +// get names of open contacts for message + def door_names = thecontact.displayName //name of each switch in a list(array) + def message = "${door_names} intrusion" + if (data?.motion){ + message="${data.motion} motion detected"} + if (parent?.global911 > "" || parent?.globalPolice) + { + def msg_emergency + if (parent?.global911 > "") + { + msg_emergency= ", call Police at ${parent?.global911}" +// shows as text msg_emergency= "${parent?.global911}" + } + if (parent?.globalPolice) + { + if (msg_emergency==null) + { + msg_emergency= ", call Police at ${parent?.globalPolice}" + } + else + { + msg_emergency+= " or ${parent?.globalPolice}" + } + } + + message+=msg_emergency + } + else + { + message+=" detected (SHM Delay App)" + } + doNotifications(message) + } + thesimcontact.close([delay: 4000]) + } + unschedule(soundalarm) //kill any lingering tasks caused by using overwrite false on runIn + } + +/******** Monitor for Open Doors when SmarthHome is initially Armed *********/ +// on July 19, 2018 changed to instant check when delay = false coming from open doors check at arming +// changed all executions to include true or false on new_monitor call +def new_monitor(delay) + { + logdebug "new_monitor called: cycles: $maxcycles" + unschedule(checkStatus) + state.cycles = maxcycles + if (!delay) + checkStatus() + else + { + def now = new Date() + def runTime = new Date(now.getTime() + (themonitordelay * 60000)) + runOnce (runTime, checkStatus) + } + } + +def killit() + { + logdebug "killit called" + state.remove('cycles') + unschedule(checkStatus) //kill any pending cycles + } + +def countopenContacts() { +// Aug 19, 2017 returning 0 on open door. comment out multipe support for now + def curr_contacts = thecontact.currentContact //status of each contact in a list(array) + logdebug "countopenContacts entered ${curr_contacts}" +// count open contacts +/* def open_contacts = curr_contacts.findAll + { + contactVal -> contactVal == "open" ? true : false + } + logdebug "countopenContacts exit with count: ${open_contacts.size()}" + return (open_contacts.size()) +*/ + if (curr_contacts == "open") + return 1 + else + return 0 + } + +def contactClosedHandler(evt) + { + if (parent.globalDisable) + return false + logdebug "contactClosedHandler called: $evt.value" + if (countopenContacts()==0) + killit() + } + +def checkStatus() + { + // get the current state for alarm system + def alarmstate = location.currentState("alarmSystemStatus") + def alarmvalue = alarmstate.value + def door_count=countopenContacts() //get open contact count + logdebug "In checkStatus: Alarm: $alarmvalue Doors Open: ${door_count} MessageCycles remaining: $state.cycles" + + +// Check if armed and one or more contacts are open + if ((alarmvalue == "stay" || alarmvalue == "away") && door_count>0) + { + state.cycles = state.cycles - 1 //decrement cycle count +// state.cycles-- note to self this does not work + +// calc standard next runOnce time + def now = new Date() + def runTime = new Date(now.getTime() + (themonitordelay * 60000)) + +// get names of open contacts for message + def curr_contacts= thecontact.currentContact //status of each switch in a list(array) +/* def name_contacts= thecontact.displayName //name of each switch in a list(array) + def door_names=""; + def door_sep=""; + def ikey=0 + curr_contacts.each //fails when not defined as multiple contacts + { value -> + if (value=="open") + { + door_names+=door_sep+name_contacts[ikey] + door_sep=", " + } + ikey++; + } + if (door_names>"") + { + if (door_count > 1) + door_names+=" are open" + else + door_names+=" is open" + } +*/ + def door_names = thecontact.displayName + def message = "${door_names} is open, system armed" + if (state.cycles<1) + message+=" (Final Warning)" + doNotifications(message) + if (themonitordelay>0 && state.cycles>0) + { + logdebug ("issued next checkStatus cycle $themonitordelay ${60*themonitordelay} seconds") + runOnce(runTime,checkStatus) + } + } + else + { + killit() + } + + } + +/* +When a motion sensor is defined in multiple delay profiles, it may trigger a false alarm when one of the contact +sensors opens, since the other profile's contact is closed giving an instant alarm. This routine is called prior to the +child motion sensor alert issueing an alarm. +return true = Suppress Alarm +otherwise return false + +Moved from parent to child. Makes debugging easier since debug messages are contained in single thread +*/ +def checkOtherDelayProfiles(baseContact, baseMotion, baseEntryDelay) + { + def ignoreSensor=false + logdebug "checkOtherDelayProfiles entered Contact: ${baseContact}, Motion: ${baseMotion}, Delay: ${baseEntryDelay}" + def profiles=parent.findAllChildAppsByName('SHM Delay Child') +// Beginning of ***FIND*** loop + profiles.find + { + if (it?.getInstallationState()!='COMPLETE') + { + logdebug "Incomplete profile skipped: ${it?.thecontact.displayName}" + return false //this continues the ***find*** loop, does not end function + } + + logdebug "looping on profile: ${it?.thecontact.displayName} Comparing: ${baseContact.displayName}" + if (it?.thecontact.displayName==baseContact.displayName) //is this the active profile + { + logdebug "Active Profile skipped" + return false //this continues the ***find*** loop, does not end function + } + + if (parent.globalMultipleMotion) + { + logdebug "finding motion in multiple motion profile: ${it?.themotionsensors} Comparing: ${baseMotion.displayName}" + if (it?.themotionsensors.displayName.contains(baseMotion.displayName)) //is this the active profile + {} + else + { + logdebug "Profile ${it?.thecontact.displayName} skipped motion sensor: baseMotion.displayName not found in multiple" + return false //this continues the ***find*** loop, does not end function + } + } + else + { + logdebug "finding motion in single motion profile: ${it?.themotionsensor.displayName} Comparing: ${baseMotion.displayName}" + if (it?.themotionsensor.displayName!=baseMotion.displayName) //is this the active profile + { + logdebug "Profile skipped motion sensor not found" + return false //this continues the ***find*** loop, does not end function + } + } + logdebug "Motion ${baseMotion.displayName} sensor was found in ${it?.thecontact.displayName} Profile that is ${it?.thecontact.currentContact}" + if (it?.thecontact.currentContact=="open") //ignore this motion sensor other profile contact is open + {} + else + { +// get the last 10 contact sensor events, then find the time the contact was last opened + def events=it.thecontact.events() + def esize=events.size() + def i = 0 +// logdebug "motionActiveHandler scanning events ${esize}" + def open_seconds=999999 + for(i; i < esize; i++) + { + if (events[i].value == "open"){ + open_seconds = Math.round((now() - events[i].date.getTime())/1000) +// logdebug("value: ${events[i].value} now: ${now()} startTime: ${events[i].date.getTime()} seconds ${open_seconds}") + break; + } + } +// logdebug "motionActiveHandler scan done ${esize} ${open_seconds}" + if (open_seconds > baseEntryDelay) + return false //this continues the ***find*** loop, does not end function + } + ignoreSensor=true //set to ignore this sensors motion + return true //this terminates the ***find*** loop, does not return to caller + } +// end of ***FIND*** loop logic + + return ignoreSensor //return to caller + } + +def logdebug(txt) + { + if (logDebugs) + log.debug ("${txt}") + } \ No newline at end of file diff --git a/smartapps/arnbme/shm-delay-modefix.src/shm-delay-modefix.groovy b/smartapps/arnbme/shm-delay-modefix.src/shm-delay-modefix.groovy new file mode 100644 index 00000000000..537d5bbe3c3 --- /dev/null +++ b/smartapps/arnbme/shm-delay-modefix.src/shm-delay-modefix.groovy @@ -0,0 +1,440 @@ +/* + * SHM Delay ModeFix + * Functions: Fix the mode when it is invalid, generally caused when using Dashboard to switch modes + * + * Copyright 2017 Arn Burkhoff + * + * Changes to Apache License + * 4. Redistribution. Add paragraph 4e. + * 4e. This software is free for Private Use. All derivatives and copies of this software must be free of any charges, + * and cannot be used for commercial purposes. + * + * Licensed under the Apache License with changes noted above, 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. + * + * May 14, 2019 V0.1.8 add support for Xfinity branded UEI keypad + * Mar 05, 2019 V0.1.7 Added: Boolean flag for debug message logging, default false + * + * Jan 06, 2019 V0.1.6 Added: Support for 3400_G Centralite V3 + * + * Oct 17, 2018 v0.1.5 Allow user to set if entry and exit delays occur for a state/mode combination + * + * Apr 24, 2018 v0.1.4 For Xfinity and Centralite model 3400 keypad on armed (Home) modes + * add device icon button to light Stay (Entry Delay) or Night (Instant Intrusion) + * + * Mar 11, 2018 v0.1.3 add logging to notifications when mode is changed. + * App issued changes are not showing in PhoneApp notifications + * Assumed system would log this but it does not + * Sep 23, 2017 v0.1.2 Ignore alarm changes caused by True Entry Delay in SHM Delay Child + * Sep 05, 2017 v0.1.1 minor code change to allow this module to run stand alone + * Sep 02, 2017 v0.1.0 add code to fix bad alarmstate set by unmodified Keypad module + * Sep 02, 2017 v0.1.0 Repackage logic that was in parent into this module for better reliability + * and control + * Aug 26/27, 2017 v0.0.0 Create + * + */ + +definition( + name: "SHM Delay ModeFix", + namespace: "arnbme", + author: "Arn Burkhoff", + description: "(${version()}) Fix the ST Mode and or Alarm State when using ST Dashboard to change AlarmState or Mode", + category: "My Apps", + parent: "arnbme:SHM Delay", + iconUrl: "https://www.arnb.org/IMAGES/hourglass.png", + iconX2Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + iconX3Url: "https://www.arnb.org/IMAGES/hourglass@2x.png") + +preferences { + page(name: "pageOne", nextPage: "pageOneVerify") + page(name: "pageOneVerify") + page(name: "pageTwo") + page(name: "aboutPage", nextPage: "pageOne") +} + +def version() + { + return "0.1.8"; + } + +def pageOne(error_msg) + { + dynamicPage(name: "pageOne", title: "For each alarm state, set valid modes and default modes.", install: false, uninstall: true) + { + section + { + if (error_msg instanceof String ) + { + paragraph error_msg + } + else + paragraph "Caution! Wrong settings may create havoc. If you don't fully understand Alarm States and Modes, read the Introduction and use the defaults!" + href(name: "href", + title: "Introduction", + required: false, + page: "aboutPage") + } + section ("Debugging messages") + { + input "logDebugs", "bool", required: false, defaultValue:false, + title: "Log debugging messages? Normally off/false" + } + section ("Alarm State: Disarmed / Off") + { + input "offModes", "mode", required: true, multiple: true, defaultValue: "Home", + title: "Valid Modes for: Disarmed" + input "offDefault", "mode", required: true, defaultValue: "Home", + title: "Default Mode for: Disarmed" + } + section ("Alarm State: Armed (Away)") + { + if (away_error_data instanceof String ) + { + paragraph away_error_data + } + input "awayModes", "mode", required: true, multiple: true, defaultValue: "Away", submitOnChange: true, + title: "Valid modes for: Armed Away" + input "awayDefault", "mode", required: true, defaultValue: "Away", + title: "Default Mode: Armed Away" + awayModes.each + { + input "awayExit${it.value}", "bool", required: true, defaultValue: true, + title: "Create Exit Delay for Armed (Away) ${it.value} mode" + input "awayEntry${it.value}", "bool", required: true, defaultValue: true, + title: "Create Entry Delay for Armed (Away) ${it.value} mode" + } + } + section ("Alarm State: Armed (Home) aka Stay or Night") + { + input "stayModes", "mode", required: true, multiple: true, defaultValue: "Night", submitOnChange: true, + title: "Valid Modes for Armed Home" + input "stayDefault", "mode", required: true, defaultValue: "Night", + title: "Default Mode for Armed Home" + stayModes.each + { + input "stayExit${it.value}", "bool", required: true, defaultValue: false, + title: "Create Exit Delay for Armed (Home) ${it.value} mode" + if (it.value =='Stay') + input "stayEntry${it.value}", "bool", required: true, defaultValue: true, + title: "Create Entry Delay for Armed (Home) ${it.value} mode" + else + input "stayEntry${it.value}", "bool", required: true, defaultValue: false, + title: "Create Entry Delay for Armed (Home) ${it.value} mode" + } + } + if (parent.globalKeypadControl) + { + def showLights=false; + parent.globalKeypadDevices.each + { keypad -> + logdebug "modefix ${keypad?.getModelName()} ${keypad?.getManufacturerName()}" +// if (keypad?.getModelName()=="3400" && keypad?.getManufacturerName()=="CentraLite") //Iris = 3405-L + if (['3400','3400-G','URC4450BC0-X-R'].contains(keypad?.getModelName())) + {showLights=true} + } + if (showLights) + { + section ("A model 3400 or UEI Keypad is defined\nSet the keypad Light Icon and smartapp action: Stay (Entry Delay) or Night (Instant Intrusion) when setting Armed (Night) from non-keypad source") + { + stayModes.each + { + input "stayLight${it.value}", "enum", options: ["Night", "Stay"], required: true, defaultValue: "Night", + title: "${it.value} Mode" + } + } + } + } + section + { + paragraph "SHM Delay Modefix ${version()}" + } + + } + } + +def pageOneVerify() //edit page One + { + +// Verify disarm/off data + def off_error="Disarmed / Off Default Mode not defined in Valid Modes" + def children = offModes + children.each + { child -> + if (offDefault == child) + { + off_error=null + } + } + +// Verify Away data + def away_error="Armed (Away) Default Mode not defined in Valid Modes" + children = awayModes + children.each + { child -> + if (awayDefault == child) + { + away_error=null + } + } + +// Verify Stay data + def stay_error="Armed (Home) Default Mode not defined in Valid Modes" + children = stayModes + children.each + { child -> + if (stayDefault == child) + { + stay_error=null + } + } + + if (off_error == null && away_error == null && stay_error == null) + { + pageTwo() + } + else + { + def error_msg="" + def newline="" + if (off_error>"") + { + error_msg=off_error + newline="\n" + } + if (away_error >"") + { + error_msg+=newline + away_error + newline="\n" + } + if (stay_error >"") + { + error_msg+=newline + stay_error + newline="\n" + } + pageOne(error_msg) + } + } + +def pageTwo() + { + dynamicPage(name: "pageTwo", title: "Mode settings verified, press 'Done/Save' to install, press '<' to change, ", install: true, uninstall: true) + { +/* section + { + href(name: "href", + title: "Introduction", + required: false, + page: "aboutPage") + } +*/ section ("Alarm State: Disarmed / Off") + { + input "offModes", "mode", required: true, multiple: true, defaultValue: "Home", + title: "Valid Modes for: Disarmed" + input "offDefault", "mode", required: true, defaultValue: "Home", + title: "Default Mode for: Disarmed" + } + section ("Alarm State: Armed (Away)") + { + input "awayModes", "mode", required: true, multiple: true, defaultValue: "Away", submitOnChange: true, + title: "Valid modes for: Armed Away" + input "awayDefault", "mode", required: true, defaultValue: "Away", + title: "Default Mode: Armed Away" + awayModes.each + { + input "awayExit${it.value}", "bool", required: true, defaultValue: true, + title: "Create Exit Delay for Armed (Away) ${it.value} mode" + input "awayEntry${it.value}", "bool", required: true, defaultValue: true, + title: "Create Entry Delay for Armed (Away) ${it.value} mode" + } + } + section ("Alarm State: Armed (Home) aka Stay or Night") + { + input "stayModes", "mode", required: true, multiple: true, defaultValue: "Night", submitOnChange: true, + title: "Valid Modes for Armed Home" + input "stayDefault", "mode", required: true, defaultValue: "Night", + title: "Default Mode for Armed Home" + stayModes.each + { + input "stayExit${it.value}", "bool", required: true, defaultValue: false, + title: "Create Exit Delay for Armed (Home) ${it.value} mode" + input "stayEntry${it.value}", "bool", required: true, defaultValue: false, + title: "Create Entry Delay for Armed (Home) ${it.value} mode" + } + + } + if (parent.globalKeypadControl) + { + def showLights=false; + parent.globalKeypadDevices.each + { keypad -> + logdebug "modefix ${keypad?.getModelName()} ${keypad?.getManufacturerName()}" +// if (keypad?.getModelName()=="3400" && keypad?.getManufacturerName()=="CentraLite") //Iris = 3405-L + if (['3400','3400-G','URC4450BC0-X-R'].contains(keypad?.getModelName())) + {showLights=true} + } + if (showLights) + { + section ("A model 3400 or UEI Keypad is defined\nSet the Light Icon and smartapp action: Stay (Entry Delay) or Night (Instant Intrusion) when setting Armed (Night) from non-keypad source") + { + stayModes.each + { + input "stayLight${it.value}", "enum", options: ["Night", "Stay"], required: true, defaultValue: "Night", + title: "${it.value} Mode" + } + } + } + } + section + { + paragraph "SHM Delay Modefix ${version()}" + } + } + } + + +def aboutPage() + { + dynamicPage(name: "aboutPage", title: "Introduction") + { + section + { + paragraph "Have you ever wondered why Mode restricted Routines, SmartApps, and Pistons sometimes fail to execute, or execute when they should not?\n\n"+ + "Perhaps you conflated AlarmState and Mode, however they are separate and independent SmartThings settings, "+ + "and when Alarm State is changed using the SmartThings Dashboard Home Solutions---surprise, Mode does not change!\n\n" + + "SmartHome routines generally, but not always, have a defined SystemAlarm and Mode settings. "+ + "Experienced SmartThings users seem to favor changing the AlarmState using SmartHome routines, avoiding use of the Dashboard's Home Solutions\n\n"+ + "If like me, you can't keep track of all this, or utilize the Dashboard to change the AlarmState, this app may be helpful.\n\n"+ + "For each AlarmState, set the Valid Mode states, and a Default Mode. This SmartApp attempts to correctly set the Mode by monitoring AlarmState for changes. When the current Mode is not defined as a Valid Mode for the AlarmState, the app sets Mode to the AlarmState's Default Mode\n\n"+ + "Please Note: This app does not, directly or (knowingly) indirectly, execute a SmartHome Routine" + } + } + } + + + +def installed() { + log.info "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.info "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() + { + subscribe(location, "alarmSystemStatus", alarmStatusHandler) + } + +def alarmStatusHandler(evt) + { +/* some entries to this function are direct from ST Events + others are from SHM Delay Child repackaged evt object + which passes a childid property stoppings multiple notifications from sending +*/ + def theAlarm = evt.value //off, stay, or away Alarm Mode set by event value + def fromST=true //event is assumed from Smarthings subscribe till proven otherwise + try + { + if (evt.childid) + fromST=false + } + catch (e) + {} +// logdebug "alarm status entry ${fromST} ${theAlarm} ${evt.source}" + if (theAlarm == "night") //bad AlarmState set by unmodified Keypad module + { + def event = [ + name:'alarmSystemStatus', + value: "stay", + displayed: true, + description: "SHM Delay Fix System Status from night to stay"] + sendLocationEvent(event) //change alarmstate to stay + setLocationMode("Night") //set the mode +// sendNotificationEvent("Change the Lock Manager Keypad module to version in github ARNBME lock-master SHMDelay") + log.warn "Change the Lock Manager Keypad module to version in github ARNBME lock-master SHMDelay ModeFix" + return "Night" + } + if (parent && !parent.globalFixMode) + {return false} + def theMode = location.currentMode + def oldMode = theMode + def delaydata=evt?.data + if (delaydata==null) + {} + else + if (delaydata.startsWith("shmtruedelay")) //ignore SHM Delay Child "true entry delay" alarm state changes + { + logdebug "Modefix ignoring True Entry Delay event, alarm state ${theAlarm}" + return false} + logdebug "ModeFix alarmStatusHandler entered alarm status change: ${theAlarm} Mode: ${theMode} " +// Fix the mode to match the Alarm State. When user sets alarm from dashboard +// the Mode is not set, resulting in Smarthings having Schizophrenia or cognitive dissonance. + def modeOK=false + if (theAlarm=="off") + { + offModes.each + { child -> + if (theMode == child) + {modeOK=true} + } + if (!modeOK) + { + if (fromST) + setLocationMode(offDefault) + theMode=offDefault + } + } + else + if (theAlarm=="stay") + { + stayModes.each + { child -> + if (theMode == child) + {modeOK=true} + } + if (!modeOK) + { + if (fromST) + setLocationMode(stayDefault) + theMode=stayDefault + } + } + else + if (theAlarm=="away") + { + awayModes.each + { child -> + if (theMode == child) + {modeOK=true} + } + if (!modeOK) + { + if (fromST) + setLocationMode(awayDefault) + theMode=awayDefault + } + } + else{ + log.error "ModeFix alarmStatusHandler Unknown alarm mode: ${theAlarm} in "} + if (theMode != oldMode) + { + if (fromST) + sendNotificationEvent("Modefix: Mode changed to ${theMode}. Cause: ${evt.source} set alarm to ${theAlarm}") + logdebug("ModeFix alarmStatusHandler Mode was changed From:$oldMode To:$theMode") + } + return theMode + } +def logdebug(txt) + { + if (logDebugs) + log.debug ("${txt}") + } \ No newline at end of file diff --git a/smartapps/arnbme/shm-delay-simkypd-child.src/shm-delay-simkypd-child.groovy b/smartapps/arnbme/shm-delay-simkypd-child.src/shm-delay-simkypd-child.groovy new file mode 100644 index 00000000000..eb2e2cb71d7 --- /dev/null +++ b/smartapps/arnbme/shm-delay-simkypd-child.src/shm-delay-simkypd-child.groovy @@ -0,0 +1,362 @@ +/** + * Smart Home Entry and Exit Delay, Internet Keypad SmartApp + * Functions: + * Acts as a container/controller for Internet Keypad simulation device: arnb.org/keypad.html + * + * Copyright 2018 Arn Burkhoff + * + * Changes to Apache License + * 4. Redistribution. Add paragraph 4e. + * 4e. This software is free for Private Use. All derivatives and copies of this software must be free of any charges, + * and cannot be used for commercial purposes. + * + * 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. + * + * Created from Ecobee Suite Service Manager, Original Author: scott Date: 2013 + * Updates by Barry A. Burke (storageanarchy@gmail.com) 2016, 2017, & 2018 + * All of the unused coded was removed, not much left. It was going to be a service manager, but ended up as a Smartapp + * + * Jun 02, 2018 v1.0.1 Add function getAtomic used by parent to get atomicState information, otherwise not possible + * May 25, 2018 v1.0.0 Convert to SHMDelay child app + * May 23, 2018 v1.0.0 Strip out all unused code, set version to 1.0.0 + * prepare for initial Beta release + * May 08, 2018 v0.0.0 Create + */ +import groovy.json.JsonOutput + +def version() + { + return "1.0.1"; + } +def VersionTitle() + { + return "(${version()}) Connect Internet Keypad to SmartThings" + } + +definition( + name: "SHM Delay Simkypd Child", + namespace: "arnbme", + author: "Arn Burkhoff", + description: "${VersionTitle()}", + parent: "arnbme:SHM Delay", + category: "My Apps", + iconUrl: "https://www.arnb.org/IMAGES/hourglass.png", + iconX2Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + iconX3Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + singleInstance: false) + +preferences + { + page(name: "pageZeroVerify") + page(name: "pageZero", nextPage: "pageZeroVerify") + page(name: "pageOne", nextPage: "pageOneVerify") + page(name: "pageTwo", nextPage: "pageTwo") //recap page when everything is valid. No changes allowed. + page(name: "removePage") + } + +mappings + { +// path("/oauth/initialize") {action: [GET: "oauthInitUrl"]} +// path("/oauth/callback") {action: [GET: "callback"]} + path("/keypost/:command") {action: [GET: "api_pinpost"]} + } + +// This is the entry point for phoneapp and browser posting and panic requests +def api_pinpost() + { + def String cmd = params.command +// log.debug "api-keypost command: $cmd" + if (cmd.matches("([0-3][0-9]{4})")) + { + def keycode = cmd.substring(1) + def armMode = cmd.substring(0,1) + log.debug "valid command: ${armMode} and pin received" + simkeypad.deviceNotification(keycode, armMode) //create an event in simulated keypad DTH +// log.debug "returned from simkeypad" + } + else + if (cmd=='1') + { + log.debug "kypd svcmgr panic received" +// simkeypad.devicePanic() //for reasons unknown, this fails with an error + simkeypad.deviceNotification('panic',1) //create a Panic Event in simulated keypad DTH + } + else + httpError(400, "$cmd is not a valid data") + } + +def pageZeroVerify() +// Verify this is installed as a child + { + if (parent && parent.getInstallationState()=='COMPLETE') + { + pageOne() + } + else + { + pageZero() + } + } + +def pageZero() + { + dynamicPage(name: "pageZero", title: "This App cannot be installed", uninstall: true, install:false) + { + section + { + paragraph "This SmartApp, SHM Delay User, must be used as a child app of SHM Delay." + } + } + } + +def pageOne() + { + dynamicPage(name: "pageOne", title: "${VersionTitle()}", install: false, uninstall: false) + { + if (state.error_data) + { + section ("${state.error_data}") + state.remove("error_data") + } + +// verify oauth is enabled for this app + if (!atomicState.accessToken) + { + try + { + createAccessToken() //seems to store into atomicState.accessToken along with state.accessToken + revokeAccessToken() //something weird going on with this + atomicState.accessToken=null + state.remove("accessToken") //do this or the atomicState.accessToken test works below. WTF + } + catch(Exception e) + { + section ("Please verify that OAuth has been enabled in " + + "the SmartThings IDE for the 'SHM Delay Simkypd Child' SmartApp, scroll down, click 'Update', go back and try again.") + section ("$e") + } + } + + if(atomicState.accessToken) + { + section("ID is\n${atomicState.accessToken.substring(0,8)}") + section() + { + href ("removePage", description: "Tap to revoke ID", title: "Revoke ID") + } + } + else + section("ID is generated when this profile is saved.") + + section + { + input "simkeypad", "device.InternetKeypad", multiple: false, required:true, submitOnChange: true, + title: "Simulated Keypad Device" + } + + if (simkeypad) + { + section([mobileOnly:true]) + { + label title: "Profile name", defaultValue: "Profile: Ikpd: ${simkeypad}", required: false + } + } + else + { + section([mobileOnly:true]) + { + label title: "Profile name", required: false + } + } + } + } + +// Verify the device and profile name are unique +page(name: "pageOneVerify") +def pageOneVerify() //edit page one info, go to pageTwo when valid + { + def error_data = "" + error_data=isunique() + if (error_data!="") + { + state.error_data=error_data.trim() + pageOne() + } + else + { + pageTwo() + } + } + +def isunique() + { + def unique = "" + def children = parent?.getChildApps() + children.each + { child -> + if (child.getName()=="SHM Delay Simkypd Child") //process only simulated keypad profiles + { +// log.debug "${child.simkeypad} ${child.getLabel()} ${child.getId()} ${simkeypad} ${app.getLabel()} ${app.getId()}" +// verify unique keypad id without as String it fails dont know why + if (child.simkeypad as String == simkeypad as String && child.getId() != app.getId()) + { + unique+='Simulated Keypad Device is already in use\n\n' + } +// verify unique label (profile name) + if (child.getLabel() == app.getLabel() && child.getId() != app.getId()) + { + unique+='Duplicate Profile Name\n' + } + } + } + return unique + } + +// This page summarizes the data prior to save +def pageTwo() + { + dynamicPage(name: "pageTwo", title: "Verify settings then tap Save, or tap < (back) to change settings", nextPage: "pageTwo", install: true, uninstall: false) + { + section + { + if(atomicState.accessToken) + { + paragraph "ID is\n${atomicState.accessToken.substring(0,8)}" + } + else + paragraph "ID will be generated when profile is saved. View profile after save to get the ID" + paragraph "Simulated Keypad Device is ${simkeypad}" + paragraph "Name: ${app.getLabel()}\nModule SHM Delay Simkypd Child ${version()}" + } + remove("Go back to page One to Remove") + } + } + +def removePage() + { + dynamicPage(name: "removePage", title: "Remove Keypad Authorization", install: false, uninstall: true) + { + if (atomicState.accessToken) + { + def b64= atomicState.accessToken.encodeAsBase64() + revokeAccessToken() + atomicState.accessToken=null + try { + def url='https://www.arnb.org/shmdelay/oauthkill_st.php' + url+='?k='+b64 + include 'asynchttp_v1' + asynchttp_v1.get('getResponseHandler', [uri: url]) + } + catch (e) + { + section() + { + paragraph ("Error initializing SHM Delay Keypad Kill: unable to connect to the database.\n\nIf this error persists, view Live Logging in the IDE for " + + "additional error information.") + paragraph ("Detailed Error: ${e}") + } + } + } + section ("ID was deactivacted and no longer useable. Tap Remove to delete") + } + } + +def create_oauth() + { + try + { + atomicState.accessToken = createAccessToken() + } + catch(Exception e) + { + if (atomicState.accessToken) + { + revokeAccessToken() + atomicState.accessToken=null + } + } + if (atomicState.accessToken) + { + def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}" +// log.debug ("${redirectUrl}") + def b64= redirectUrl.encodeAsBase64() +// log.debug ("${b64.size()} ${redirectUrl.encodeAsBase64()}") + try { + def url='https://www.arnb.org/shmdelay/oauthinit_st.php' + url+='?i='+b64 //stop this data from interacting + include 'asynchttp_v1' + asynchttp_v1.get('getResponseHandler', [uri: url]) + } + catch (e) + { + revokeAccessToken() + atomicState.accessToken=null +/* section() + { + paragraph ("Error initializing SHM Delay Keypad Svcmgr Authentication: unable to connect to the database.\n\nIf this error persists, view Live Logging in the IDE for " + + "additional error information.") + paragraph ("Detailed Error: ${e}") + } +*/ } + } + } + +def installed() { + log.debug "Installed with settings: ${settings}" + create_oauth() + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + + +def initialize() + { + subscribe (simkeypad, 'codeEntered', keypadCodeHandler) + subscribe (simkeypad, 'contact.open', keypadPanicHandler) + } + +def keypadCodeHandler(evt) + { + log.debug 'simkeypad enter keypadCodeHandler' + parent.keypadCodeHandler(evt) + } + +def keypadPanicHandler(evt) + { + log.debug 'simkeypad enter keypadPanicHandler' + parent.keypadPanicHandler(evt) + } + + +// Process response from async execution +def getResponseHandler(response, data) + { + if(response.getStatus() == 200) + { + def results = response.getJson() + log.debug "SHM Delay response ${results.msg}" + if (results.msg != 'OK') + sendNotificationEvent("${results.msg}") + } + else + sendNotificationEvent("SHM Delay, HTTP Error = ${response.getStatus()}") + } + +private def getServerUrl() { return "https://graph.api.smartthings.com" } +private def getShardUrl() { return getApiServerUrl() } +private def getCallbackUrl() { return "${serverUrl}/oauth/callback" } +private def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}"} +def getAtomic(field_name) + {return atomicState[field_name]} //allow parent to get atomic data from child \ No newline at end of file diff --git a/smartapps/arnbme/shm-delay-talker-child.src/shm-delay-talker-child.groovy b/smartapps/arnbme/shm-delay-talker-child.src/shm-delay-talker-child.groovy new file mode 100644 index 00000000000..af9ecf15eb4 --- /dev/null +++ b/smartapps/arnbme/shm-delay-talker-child.src/shm-delay-talker-child.groovy @@ -0,0 +1,369 @@ +/** + * SHM Delay Talker Child + * Supplements Big Talker adding speech when SHMDelay enters the Exit or Entry delay time period + * For LanNouncer Device: Chime, TTS text, Chime + * For speakers (such as Sonos) TTS text + * Supports TTS devices and speakers + * When devices use differant messages, install multiple copies of this code + * When speakers need different volumes, install multiple copies of this code + * + * + * Copyright 2017 Arn Burkhoff + * + * Changes to Apache License + * 4. Redistribution. Add paragraph 4e. + * 4e. This software is free for Private Use. All derivatives and copies of this software must be free of any charges, + * and cannot be used for commercial purposes. + * + * 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. + * + * Dec 17, 2018 v1.0.4 Change speaker capability audioNotification to musicPlayer. Did not select Sonos speakers + * Nov 04, 2018 v1.0.3 Add support for generic quiet time per user request on messages + * Delayed messages are super delayed by unknown cloud processing error, allow for no chime and instant speak + * Oct 21, 2018 v1.0.2 Support Arming Canceled messages from SHM Delay + * Jul 05, 2018 v1.0.1 correct non standard icon + * Jul 04, 2018 v1.0.1 Check for non Lannouner TTS devices and when true eliminate chime command + * Jun 26, 2018 V1.0.0 Create from standalone module Keypad ExitDelay Talker + */ +definition( + name: "SHM Delay Talker Child", + namespace: "arnbme", + author: "Arn Burkhoff", + description: "(${version()}) Speak during SHM Delay Exit and Entry Delay", + category: "My Apps", + parent: "arnbme:SHM Delay", + iconUrl: "https://www.arnb.org/IMAGES/hourglass.png", + iconX2Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + iconX3Url: "https://www.arnb.org/IMAGES/hourglass@2x.png") + +def version() + { + return "1.0.4"; + } + +preferences { + page(name: "pageZeroVerify") + page(name: "pageZero", nextPage: "pageZeroVerify") + page(name: "pageOne", nextPage: "pageOneVerify") + page(name: "pageOneVerify") + page(name: "pageTwo") //recap page when everything is valid. No changes allowed. + } + +def pageZeroVerify() +// Verify this is installed as a child + { + if (parent && parent.getInstallationState()=='COMPLETE') + { + pageOne() + } + else + { + pageZero() + } + } + +def pageZero() + { + dynamicPage(name: "pageZero", title: "This App cannot be installed", uninstall: true, install:false) + { + section + { + paragraph "This SmartApp, SHM Delay Talker, must be used as a child app of SHM Delay." + } + } + } + +def pageOne() + { + dynamicPage(name: "pageOne", title: "Talker Messages and Devices", install: false, uninstall: true) + { + section("The SHM Delay Message Settings") + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + paragraph "%nn in any message is replaced with the respective delay seconds" + input "theExitMsgKypd", "string", required: true, title: "Keypad initiated Exit message", + defaultValue: "Smart Home Monitor is arming in %nn seconds. Please exit the facility" + input "theExitMsgNkypd", "string", required: true, title: "Non-Keypad initiated Exit message", + defaultValue: "You have %nn seconds to exit the facility" + if (parent?.globalKeypadControl) + { + input "theEntryMsg", "string", required: false, title: "Entry message", + defaultValue: "Please enter your pin on the keypad" + } + else + { + input "theEntryMsg", "string", required: false, title: "Entry message", + defaultValue: "Please disarm Smart Home Monitor" + } + input(name: 'theStartTime', type: 'time', title: 'Do not talk: Start Time', required: false) + input(name: 'theEndTime', type: 'time', title: 'Do not talk: End Time', required: false) + input "theSoundChimes", "bool", defaultValue: true, required: false, + title: "Sound TTS Chimes with messages when using LanNouncer. If Cloud is slow and message delayed set false. Default: On/True" + input "theTTS", "capability.speechSynthesis", required: false, multiple: true, submitOnChange: true, + title: "LanNouncer/DLNA TTS Devices" + input "theSpeakers", "capability.musicPlayer", required: false, multiple: true, submitOnChange: true, + title: "Speaker Devices?" + input "theVolume", "number", required: true, range: "1..100", defaultValue: 40, + title: "Speaker Volume Level from 1 to 100" + } + +// Generate a unique Profile + if (app?.getLabel()) + { + section([mobileOnly:true]) + { + label title: "Profile name", defaultValue: app.getLabel(), required: true + } + } + + else + { + def namematch=true + def cid=0 + def talkers + def appLabel + while (namematch) + { + namematch=false + cid=cid+1 + appLabel="Profile: Talk: ${cid}" +// log.debug "applabel: ${appLabel}" + talkers = parent.findAllChildAppsByName("SHM Delay Talker Child") + talkers.each + { +// log.debug "child label: ${it?.getLabel()} ${it?.getInstallationState()}" + if (it.getInstallationState() == 'COMPLETE' && it.getLabel() == appLabel) + namematch=true +// else +// log.debug "no match ${it.getLabel()} $appLabel" + } + } + section([mobileOnly:true]) + { + label title: "Profile name", defaultValue: appLabel, required: true + } + } + } + } + +def pageOneVerify() //edit page one info, go to pageTwo when valid + { + def error_data = "" + if (theStartTime>"" && theEndTime>"") + {} + else + if (theStartTime>"") + error_data="Please set do not talk end time or clear do not talk start time" + else + if (theEndTime>"") + error_data="Please set do not talk start time or clear do not talk end time" + + if (error_data!="") + { + state.error_data=error_data.trim() + pageOne() + } + else + { + pageTwo() + } + } + +// This page summarizes the data prior to save +def pageTwo(error_data) + { + dynamicPage(name: "pageTwo", title: "Verify settings then tap Save, or tap < (back) to change settings", install: true, uninstall: true) + { + def chimes=true + def chimetxt='(Chime) ' + try + {chimes=theSoundChimes} + catch(Exception e) + {} + if (!chimes) + chimetxt='' + section + { + if (theExitMsgKypd) + paragraph "The Keypad Exit Delay Message:\n${chimetxt}${theExitMsgKypd} ${chimetxt}" + else + paragraph "The Keypad Exit Delay Message is not defined" + if (theExitMsgNkypd) + paragraph "The Non-Keypad Exit Delay Message:\n${chimetxt}${theExitMsgNkypd} ${chimetxt}" + else + paragraph "The Non-Keypad Exit Delay Message is not defined" + if (theEntryMsg) + paragraph "The Entry Delay Message:\n${theEntryMsg}" + else + paragraph "The Entry Delay Message is not defined" + if (theStartTime>"" && theEndTime>"") + paragraph "Quiet time active from ${theStartTime.substring(11,16)} to ${theEndTime.substring(11,16)}" + else + paragraph "Quiet time is inactive" + + if (!chimes) + paragraph "Chimes do not sound with messages" + if (theTTS) + paragraph "The Text To Speech Devices are ${theTTS}" + else + paragraph "No Text To Speech Devices are defined" + if (theSpeakers) + { + paragraph "The Text To Speech Devices are ${theSpeakers}" + paragraph "The Speaker Volume Level is ${theVolume}" + } + else + paragraph "No Speaker Devices are defined" + paragraph "${app.getLabel()}\nModule SHM Delay User ${version()}" + } + } + } + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(location, "shmdelaytalk", TalkerHandler) + } + +def TalkerHandler(evt) + { + log.debug("TalkerHandler entered, event: ${evt.value} ${evt?.data}") + def delaydata=evt?.data //get the delay time + def msgout + def nonnouncer=false + def chimes=true + +// 1.0.3 Nov 4, 2018 check time values for quiet + if (theStartTime>"" && theEndTime>"") + { + def between = timeOfDayIsBetween(theStartTime.substring(11,16), theEndTime.substring(11,16), new Date(), location.timeZone) + if (between) + { +// log.debug ("it is quiet time") + return false + } + } +// log.debug ("not quiet time") + +// 1.0.3 Nov 4, 2018 Set Chimes sound + try + {chimes=theSoundChimes} + catch(Exception e) + {} +// if (chimes) +// log.debug "chime on" +// else +// log.debug "chime off" + + if (theTTS) + { + theTTS.find + { + if (it.typeName != 'LANnouncer Alerter') + { + nonnouncer=true + return true //stop searching + } + else + return false + } + } + + + if (evt.value=="entryDelay" && theEntryMsg>"") + { + if (delaydata>"") + msgout=theEntryMsg.replaceAll("%nn",delaydata) + else + msgout=theEntryMsg + if (theTTS) + { + if (nonnouncer || chimes==false) + {theTTS.speak(msgout)} + else + { + theTTS.speak("@|ALARM=CHIME") + theTTS.speak(msgout,[delay: 1800]) + theTTS.speak("@|ALARM=CHIME", [delay: 5000]) + } + } + if (theSpeakers) + { + theSpeakers.playTextAndResume(msgout,theVolume) + } + } + else + if (evt.value=="exitDelay" && theExitMsgKypd>"") + { + if (delaydata>"") + msgout=theExitMsgKypd.replaceAll("%nn",delaydata) + else + msgout=theExitMsgKypd + if (theTTS) + { + if (nonnouncer || chimes==false) + {theTTS.speak(msgout)} + else + { + theTTS.speak("@|ALARM=CHIME") + theTTS.speak(msgout,[delay: 1800]) + theTTS.speak("@|ALARM=CHIME", [delay: 8000]) + } + } + if (theSpeakers) + { + theSpeakers.playTextAndResume(msgout,theVolume) + } + } + else + if (evt.value=="exitDelayNkypd" && theExitMsgNkypd>"") + { + if (delaydata>"") + msgout=theExitMsgNkypd.replaceAll("%nn",delaydata) + else + msgout=theExitMsgNkypd + if (theTTS) + { + if (nonnouncer || chimes==false) + theTTS.speak(msgout, [delay: 2000]) //allows Bigtalker to speak armed in away mode msg + else + { + theTTS.speak("@|ALARM=CHIME", [delay : 2000]) //allows BigTalker to speak armed in away mode msg + theTTS.speak(msgout, [delay: 3800]) + theTTS.speak("@|ALARM=CHIME", [delay: 8000]) + } + } + if (theSpeakers) + { + theSpeakers.playTextAndResume(msgout,theVolume) + } + } + if (evt.value=="ArmCancel" && delaydata>"") + { + if (theTTS) + {theTTS.speak(delaydata)} + if (theSpeakers) + {theSpeakers.playTextAndResume(delaydata,theVolume)} + } + } \ No newline at end of file diff --git a/smartapps/arnbme/shm-delay-user.src/shm-delay-user.groovy b/smartapps/arnbme/shm-delay-user.src/shm-delay-user.groovy new file mode 100644 index 00000000000..eaceb24f50d --- /dev/null +++ b/smartapps/arnbme/shm-delay-user.src/shm-delay-user.groovy @@ -0,0 +1,754 @@ +/** + * Smart Home Delay User Maintain + * Functions: + * Maintain User keypad pins for SHM Delay V2 + * + * Copyright 2018 Arn Burkhoff + * + * 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. + * + * Jul 21, 2018 v1.1.1 When pin 0000 is added as a user pin add flag to ignore it on OFF button + * allowing the IRIS keypad to have one touch arming + * depending upon firmware hitting off or partial once or twice with no pin enters a 0000 pin + * Jul 18, 2018 v1.1.0 Add individual pin message control that overrides global settings + * Jul 17, 2018 v1.0.0 Add multifunction pin allowing it to set SHM status, run a routine, and run a piston. + * Add ability to run a routine or piston based on mode entered on keypad + * Jun 13, 2018 v0.0.2 Add restrictions by mode and device, change pageThree to PageFour, add pageThree restrictions + * Apr 30, 2018 v0.0.1 Add dynamic Version Number to description and PageThree + * Mar 18, 2018 v0.0.0 Add Panic pin usage type + * Mar 01, 2018 v0.0.0 Create + * + */ +definition( + name: "SHM Delay User", + namespace: "arnbme", + author: "Arn Burkhoff", + description: "(${version()}) Maintain Users for SHM Delay. Child module", + category: "My Apps", + parent: "arnbme:SHM Delay", + iconUrl: "https://www.arnb.org/IMAGES/hourglass.png", + iconX2Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + iconX3Url: "https://www.arnb.org/IMAGES/hourglass@2x.png") + +//import java.text.ParseException; +import java.text.SimpleDateFormat; + +preferences { + page(name: "pageZeroVerify") + page(name: "pageZero", nextPage: "pageZeroVerify") + page(name: "pageOne", nextPage: "pageOneVerify") + page(name: "pageOneVerify") + page(name: "pageTwo", nextPage: "pageTwoVerify") //schedule page + page(name: "pageTwoVerify") + page(name: "pageThree", nextPage: "pageThreeVerify") //Restrictions page + page(name: "pageThreeVerify") + page(name: "pageFour", nextPage: "pageFourVerify") //Pin msg overrides + page(name: "pageFourVerify") + page(name: "pageFive") //recap page when everything is valid. No changes allowed. + } + +def version() + { + return "1.1.1"; + } + +def pageZeroVerify() +// Verify this is installed as a child + { + if (parent && parent.getInstallationState()=='COMPLETE') + { + pageOne() + } + else + { + pageZero() + } + } + +def pageZero() + { + dynamicPage(name: "pageZero", title: "This App cannot be installed", uninstall: true, install:false) + { + section + { + paragraph "This SmartApp, SHM Delay User, must be used as a child app of SHM Delay." + } + } + } + + +def pageOne() + { + dynamicPage(name: "pageOne", title: "Pin code and Settings", uninstall: true) + { + section + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + input "pinScheduled", "bool", required: false, defaultValue:false, + title: "Date, Day or Time Scheduled?" + input "pinRestricted", "bool", required: false, defaultValue:false, + title: "Restrict use by mode or to device?" + input "pinMsgOverride", "bool", required: false, defaultValue:false, + title: "Override Global Pin Msg Defaults?" + input "theuserpin", "text", required: true,submitOnChange: true, + title: "Four digit numeric code" + input "theusername", "text", required: true, submitOnChange: true, + title: "User Name" + input "thepinusage", "enum", options:["User", "UserRoutinePiston", "Routine", "Piston", "Panic", "Ignore", "Disabled"], + required: true, title: "Pin Usage", submitOnChange: true + if (theuserpin == '0000' && (thepinusage == 'User'|| thepinusage == 'UserRoutinePiston')) + { + input "thepinIgnoreOff","bool", required: false, defaultValue:true, + title: "When using any keypad ignore 0000 pin on Off?" + } + if (thepinusage == "Routine" || thepinusage == "UserRoutinePiston") + { + def routines = location.helloHome?.getPhrases()*.label + def actions = [] + routines.each + { + if (it=="Good Night!") + {} + else + if (it=="Goodbye!") + {} + else + if (it=="Good Morning!") + {} + else + if (it=="I'm Back!") + {} + else + if (!parent?.globalKeypadControl) + { + actions << it + } + else + if (it==parent?.globalOff) + {} + else + if (it==parent?.globalStay) + {} + else + if (it==parent?.globalNight) + {} + else + if (it==parent?.globalAway) + {} + else + { + actions << it + } + } + if (actions.size > 0) + { + actions?.sort() + input "thepinroutine", "enum", options: actions, required: false, multiple: true, + title: "All modes executes Smart Home Monitor Routine (optional)" + input "thepinroutineOff", "enum", options: actions, required: false, multiple: true, + title: "Off executes Smart Home Monitor Routine (optional)" + input "thepinroutineStay", "enum", options: actions, required: false, multiple: true, + title: "Stay executes Smart Home Monitor Routine (optional)" + input "thepinroutineAway", "enum", options: actions, required: false, multiple: true, + title: "Away executes Smart Home Monitor Routine (optional)" + } + else + paragraph "No Routines Available" + } + if (thepinusage == "Piston" || thepinusage == "UserRoutinePiston") + { + input "thepinpiston", "text", required: false, + title: "All modes executes WebCore Piston (optional)", description: "Copy/Paste External URL" + input "thepinpistonOff", "text", required: false, + title: "Off executes WebCore Piston (optional)", description: "Copy/Paste External URL" + input "thepinpistonStay", "text", required: false, + title: "Stay executes WebCore Piston (optional)", description: "Copy/Paste External URL" + input "thepinpistonAway", "text", required: false, + title: "Away executes WebCore Piston (optional)", description: "Copy/Paste External URL" + } + input "themaxcycles", "number", required: false, defaultValue: 0, submitOnChange: true, + title: "Maximum times pin may be used, unlimited when zero" + if (themaxcycles > 0) + { + def atomicUseId=app.getId()+'uses' //build unique atomic id for uses + if (parent.atomicState."${atomicUseId}" > 0) + { + input "resetburn", "bool", required: false, defaultValue:false, + title: "Reset use count to zero?" + } + if (parent.atomicState."${atomicUseId}") + { + def burnmsg="" + if (parent.atomicState."${atomicUseId}" >= themaxcycles) + burnmsg= " and pin is burned" + paragraph "Pin use count is "+parent.atomicState."${atomicUseId}"+burnmsg + } + } + } + if (theusername) + { + section([mobileOnly:true]) + { + label title: "Profile name", defaultValue: "Profile: User: ${theusername}", required: false + } + } + else + { + section([mobileOnly:true]) + { + label title: "Profile name", required: false + } + } + } + } + +def pageOneVerify() //edit page one info, go to pageTwo when valid + { + def error_data = "" + def pageTwoWarning + def unique_result="" + def routine_count=0 + def piston_count=0 + def size_error="" + if (theuserpin) + { + if (!theuserpin.matches("([0-9]{4})")) + { + error_data = "Pin must be four digits, please reenter\n\n" + } + } + if (error_data=="") + { + unique_result=isunique() + if (unique_result != "") + error_data+=unique_result + } + + if (thepinusage == "Routine" || thepinusage == "UserRoutinePiston") + { + if (thepinroutine) + { + routine_count++ + if (thepinroutine.size()>1) + size_error="All Modes" + } + if (thepinroutineOff) + { + routine_count++ + if (thepinroutineOff.size()>1) + size_error+=" Off" + } + if (thepinroutineStay) + { + routine_count++ + if (thepinroutineStay.size()>1) + size_error+=" Stay" + } + if (thepinroutineAway) + { + routine_count++ + if (thepinroutineAway.size()>1) + size_error+=" Away" + } + if (size_error!="") + error_data += "Only one routine may be selected for " + size_error.trim() + "\n\n" + if (routine_count==0 && thepinusage == "Routine") + error_data += "Please select a Routine\n\n" + else + if (thepinroutine && routine_count > 1) + error_data += "All modes selected, other routines not allowed\n\n" + } + if (thepinusage == "Piston" || thepinusage == "UserRoutinePiston") + { + size_error="" + if (thepinpiston) + { + piston_count++ + if (!thepinpiston.matches("https://graph-[^.]+[.]api.smartthings.com/api/token/[^/]+/smartapps/installations/[^/]+/execute/[:][^:]+[:]")) + { + size_error='All Modes' + } + } + if (thepinpistonOff) + { + piston_count++ + if (!thepinpistonOff.matches("https://graph-[^.]+[.]api.smartthings.com/api/token/[^/]+/smartapps/installations/[^/]+/execute/[:][^:]+[:]")) + { + size_error+=' Off' + } + } + if (thepinpistonStay) + { + piston_count++ + if (!thepinpistonStay.matches("https://graph-[^.]+[.]api.smartthings.com/api/token/[^/]+/smartapps/installations/[^/]+/execute/[:][^:]+[:]")) + { + size_error+=' Stay' + } + } + if (thepinpistonAway) + { + piston_count++ + if (!thepinpistonAway.matches("https://graph-[^.]+[.]api.smartthings.com/api/token/[^/]+/smartapps/installations/[^/]+/execute/[:][^:]+[:]")) + { + size_error+=' Away' + } + } + if (size_error!="") + error_data += "Please enter a valid Piston URL for " + size_error.trim() + "\n\n" + if (piston_count==0 && thepinusage == "Piston") + error_data += "Please enter at least one Piston\n\n" + else + if (thepinpiston && piston_count > 1) + error_data += "All Modes selected, other Pistons not allowed\n\n" + } + + if (error_data!="") + { + state.error_data=error_data.trim() + pageOne() + } + else + { + if (resetburn) + { + def atomicUseId=app.getId()+'uses' //build unique atomic id for uses + parent.atomicState."${atomicUseId}" = 0 //reset the burn count to zero +// resetburn=false; + } + if (pageTwoWarning!=null) + {state.error_data=error_data.trim()} + if (pinScheduled) + pageTwo() + else + if (pinRestricted) + pageThree() + else + if (pinMsgOverride) + pageFour() + else + pageFive() + } + } + +def isunique() + { + def unique = "" + def children = parent?.getChildApps() + children.each + { child -> + if (child.getName()=="SHM Delay User") //process only pin profiles + { +// verify unique name + if (child.theusername == theusername && + child.getId() != app.getId()) + { + unique+='Duplicate User Name, not allowed\n\n' + } +// verify unique pin + if (child.theuserpin == theuserpin && + child.getId() != app.getId()) + { + unique+='Pin in use by user '+child.theusername+'\n\n' + } +// verify unique label + if (child.getLabel() == app.getLabel() && + child.getId() != app.getId()) + { + unique+='Duplicate User Label used by user '+child.theusername+'\n\n' + } + } + } + return unique + } + +def pageTwo() + { + dynamicPage(name: 'pageTwo', title: 'Scheduling Rules, all fields are optional') + { + def numdt="" + section + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + input 'pinDays', 'enum', description: 'Valid all days', + options: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], + required: false, multiple: true, title: 'Select days pin may be used' + input(name: 'pinStartTime', type: 'time', title: 'Start Time', required: false) + input(name: 'pinEndTime', type: 'time', title: 'End Time', required: false) + if (pinStartDt>"") + { + numdt=dtEdit(pinStartDt) + if (state.dtedit) + { + numdt = "please correct" +// numdt = state.dtedit + state.remove("dtedit") + } + } + input name: "pinStartDt", type: "text", title: "Start Date $numdt", required: false, submitOnChange: true, + description: "Mth dd, yyyy" + numdt="" + if (pinEndDt>"") + { + numdt=dtEdit(pinEndDt) + if (state.dtedit) + { + numdt = "please correct" +// numdt = state.dtedit + state.remove("dtedit") + } + } + input name: "pinEndDt", type: "text", title: "End Date $numdt", required: false, submitOnChange: true, + description: "Mth dd, yyyy" + } + } + } + +def pageTwoVerify() //edit schedule data, go to pageThree when valid + { + def error_data = "" + def num_dtstart + def num_dtend + if (pinStartDt > "") + { + num_dtstart=dtEdit(pinStartDt) + if (state.dtedit) + { + error_data += state.dtedit + state.remove("dtedit") + } +// log.debug "Start edit ${pinStartDt} ${num_dtstart} ${error_data}" + } + if (pinEndDt > "") + { + num_dtend=dtEdit(pinEndDt) + if (state.dtedit) + { + error_data += state.dtedit + state.remove("dtedit") + } +// log.debug "End edit ${pinEndDt} ${num_dtend} ${error_data}" + } + + if (pinStartDt > "" && pinEndDt >"" && error_data == "") + { + if (num_dtend <= num_dtstart) + error_data += "End date: ${num_dtend} must be greater than Start date: ${num_dtstart}\n\n" + } + +// verify optional time data stored by system as 2018-03-13T11:30:00.000-0400 +// use only the time portion HH:MM when comparing and testing for when pin is entered in SHM Delay + if (pinStartTime > "" && pinEndTime >"") + { +// log.debug "times ${pinStartTime} ${pinEndTime}" + if (pinEndTime.substring(11,16) <= pinStartTime.substring(11,16)) + error_data += "End Time must be greater than Start Time\n\n" + } + else + if (pinStartTime > "") + error_data += "Please enter an End Time\n\n" + else + if (pinEndTime > "") + error_data += "Please enter a Start Time\n\n" + + + if (error_data!="") + { + state.error_data=error_data.trim() + pageTwo() + } + else + if (pinRestricted) + pageThree() + else + if (pinMsgOverride) + pageFour() + else + pageFive() + } + +// verify and format start and end date standard format is Jan 1, 2018 +// but logic allows for January 1 18 or Jan. 1 18 that create (seems a bit fickle) +def dtEdit(dt) + { + def input_mask = "MMM dd yy" //also allows a 4 digit year with SimpleDateFormat, usually fixes 2 digit year + def numdt_mask = "yyyyMMdd" //result date + def date + def numdt + try { + def sdf= new SimpleDateFormat(input_mask) + sdf.setLenient(false); //reject stuff like Mar 32, 2018 + date = sdf.parse(dt.replaceAll("[.,]"," ")) //get rid of junk, verify and format date + sdf.applyPattern(numdt_mask); //convert date to + numdt = sdf.format(date); // the yyyymmdd format for comparing and processing +// log.debug "Start ${date} is valid ${numdt}" + return numdt; //return valid date in useable format + } + catch (pe) + { + + state.dtedit = "$date ${pe}\n\n" //date is invalid + return false; + } + } + +def pageThree() //added Jun 13, 2018 + { + dynamicPage(name: 'pageThree', title: 'Mode and Device Restriction Rules, all fields are optional') + { + section + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + input "pinModes", "mode", required: false, multiple: true, + title: 'Allow only when mode is' + input "pinRealKeypads", "device.CentraliteKeypad", required: false, multiple: true, + title: "Real Keypads where pin may be used, null = all (Optional)" + input "pinSimKeypads", "device.InternetKeypad", required: false, multiple: true, + title: "Simulated Keypads where pin may be used, null = all (Optional)" + } + } + } + +def pageThreeVerify() //edit schedule data, go to pageThree when valid + { + def error_data = "" + if (error_data!="") + { + state.error_data=error_data.trim() + pageThree() + } + else + if (pinMsgOverride) + pageFour() + else + pageFive() + } + +def pageFour() //Pin msg overrides added Jul 18, 2018 + { + dynamicPage(name: 'pageFour', title: 'Pin Msg Overrides') + { + section + { + if (state.error_data) + { + paragraph "${state.error_data}" + state.remove("error_data") + } + input "UserPinLog", "bool", required: false, defaultValue: true, + title: "Log pin entries to notification log Default: On/True" + if (location.contactBookEnabled) + { + input("UserPinRecipients", "contact", title: "Pin Notify Contacts (When active ST system forces send to notification log, so set prior setting to false)",required:false,multiple:true) + input "UserPinPush", "bool", required: false, defaultValue:false, + title: "Send Pin Push Notification?" + } + else + { + input "UserPinPush", "bool", required: false, defaultValue:true, + title: "Send Pin Push Notification?" + } + input "UserPinPhone", "phone", required: false, + title: "Send Pin text message to this number. For multiple SMS recipients, separate phone numbers with a semicolon(;)" + } + } + } + +def pageFourVerify() //edit schedule data, go to pageThree when valid + { + def error_data = "" + if (error_data!="") + { + state.error_data=error_data.trim() + pageFour() + } + else + pageFive() + } + + +// This page summarizes the data prior to save +def pageFive(error_data) + { + dynamicPage(name: "pageFour", title: "Verify settings then tap Save, or tap < (back) to change settings", install: true, uninstall: true) + { + def rdata="" + section + { + paragraph "Pin Code is ${theuserpin}" + paragraph "User Name is ${theusername}" + switch (thepinusage) + { + case "User": + paragraph "The pin is assigned to a Person" + break + case "Ignore": + paragraph "The pin is Ignored" + break + case "Disabled": + paragraph "The pin is Disabled, processed as bad pin" + break + case "Routine": + if (thepinroutine) + rdata="All Modes:" + thepinroutine + "\n" + if (thepinroutineOff) + rdata+=" Off:" + thepinroutineOff + "\n" + if (thepinroutineStay) + rdata+=" Stay:" + thepinroutineStay + "\n" + if (thepinroutineAway) + rdata+="Away:" + thepinroutineAway + rdata=rdata.trim() + paragraph "The pin executes Routines\n $rdata" + break + case "Piston": + if (thepinpiston) + rdata="All Modes\n" + if (thepinpistonOff) + rdata+=" Off\n" + if (thepinpistonStay) + rdata+=" Stay\n" + if (thepinpistonAway) + rdata+="Away" + rdata=rdata.trim() + paragraph "The pin executes a WebCore Piston for: $rdata" + break + case "Panic": + paragraph "Panic pin triggers the SmartThings intrusion alarm" + break + case "UserRoutinePiston": + paragraph "Multi function pin assigned to a Person" + if (thepinroutine) + rdata="All Modes:" + thepinroutine + "\n" + if (thepinroutineOff) + rdata+=" Off:" + thepinroutineOff + "\n" + if (thepinroutineStay) + rdata+=" Stay:" + thepinroutineStay + "\n" + if (thepinroutine) + rdata+="Away:" + thepinroutineAway + rdata=rdata.trim() + if (rdata>"") + paragraph "The pin executes Routines\n $rdata" + rdata="" + if (thepinpiston) + rdata="All Modes\n" + if (thepinpistonOff) + rdata+=" Off\n" + if (thepinpistonStay) + rdata+=" Stay\n" + if (thepinpistonAway) + rdata+=" Away" + rdata=rdata.trim() + if (rdata>"") + paragraph "The pin executes a WebCore piston for: $rdata" + break + default: + paragraph "Pin usage not set, Person assumed" + } + if (pinMsgOverride) + paragraph "Pin messaging overrides global defaults" + else + paragraph "Pin messaging uses global defaults" + if (themaxcycles > 0) + { + paragraph "Max Cycles is ${themaxcycles}" + def atomicUseId=app.getId()+'uses' //build unique atomic id for uses + if (parent.atomicState."${atomicUseId}" && parent.atomicState."${atomicUseId}" > 0) + { + def burnmsg="" + if (parent.atomicState."${atomicUseId}" >= themaxcycles) + burnmsg= " and pin is burned" + paragraph "Pin use count is "+parent.atomicState."${atomicUseId}"+burnmsg + } + else + paragraph 'Pin use count is zero' + } + else + paragraph "Max Cycles is unlimited" + if (pinScheduled) + { + def df = new java.text.SimpleDateFormat("EEEE") //from ST groovy api documentation + df.setTimeZone(location.timeZone) + def day = df.format(new Date()) + def df2 = new java.text.SimpleDateFormat("yyyyMMdd") + df2.setTimeZone(location.timeZone) + def nowymd = df2.format(new Date()); // the yyyymmdd format for comparing and processing + def dtbetween=true + def num_dtstart + def num_dtend + if (pinStartDt > "") + num_dtstart=dtEdit(pinStartDt) + if (pinEndDt > "") + num_dtend=dtEdit(pinEndDt) + if (pinDays) + paragraph "Valid Days: ${pinDays}. Currently: ${pinDays.contains(day)}" + if (pinStartTime>"" && pinEndTime>"") + { + def between = timeOfDayIsBetween(pinStartTime.substring(11,16), pinEndTime.substring(11,16), new Date(), location.timeZone) + paragraph "Valid Hours: ${pinStartTime.substring(11,16)} to ${pinEndTime.substring(11,16)}. Currently: $between" + } + if (pinStartDt>"" && pinEndDt>"") + { + if (num_dtstart > nowymd || num_dtend < nowymd) + dtbetween=false + paragraph "Valid Dates: $pinStartDt to $pinEndDt. Currently: $dtbetween" + } + else + if (pinStartDt>"") + { + if (num_dtstart > nowymd) + dtbetween=false + paragraph "Valid From: $pinStartDt. Currently: $dtbetween" + } + else + if (pinEndDt>"") + { + if (num_dtend < nowymd) + dtbetween=false + paragraph "Valid Until: $pinEndDt. Currently: $dtbetween" + } + } + if (pinRestricted) + { + if (pinModes) + paragraph "Pin valid only in these modes: ${pinModes}" + if (pinRealKeypads) + paragraph "Pin valid only on these real devices: ${pinRealKeypads}" + if (pinSimKeypads) + paragraph "Pin valid only on these simulated devices: ${pinSimKeypads}" + } + paragraph "${app.getLabel()}\nModule SHM Delay User ${version()}" + } + } + } + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() + {} \ No newline at end of file diff --git a/smartapps/arnbme/shm-delay.src/shm-delay.groovy b/smartapps/arnbme/shm-delay.src/shm-delay.groovy new file mode 100644 index 00000000000..5f60b12613c --- /dev/null +++ b/smartapps/arnbme/shm-delay.src/shm-delay.groovy @@ -0,0 +1,1700 @@ +/* + * Smart Home Entry and Exit Delay and Open Contact Monitor, Parent + * Functions: + * Acts as a container/controller for Child module + * Process all Keypad activity + * + * Copyright 2017 Arn Burkhoff + * + * Changes to Apache License + * 4. Redistribution. Add paragraph 4e. + * 4e. This software is free for Private Use. All derivatives and copies of this software must be free of any charges, + * and cannot be used for commercial purposes. + * + * 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. + * + * May 08, 2019 v2.2.9 Undocumented ST Platform changes killed create and send events with data + * requires updated keypad driver and changed code in keypadCodeHandler + * Mar 26, 2019 v2.2.8 Corrected keypad lights not properly see around statement 1034/5 fakeEvt = [value: theMode] + * Mar 14, 2019 v2.2.8 Change: Period not saved in Apple IOS, remove it as a phone number delimter + * Mar 12, 2019 v2.2.8 add phone number delimiters pound sign(#) and period(.) the semi colon no longer shows in android, nor is saved in IOS? + * Mar 03, 2019 v2.2.8 add flag to turn debug messages on, default is off + * Mar 03, 2019 v2.2.8 log debugging for exit delay issue + * Feb 19, 2019 v2.2.7 globalPinPush was miscoded should have been globalBadPinPush around line 331 Send Bad Pin Push Notification + * Jan 06, 2019 V2.2.6 Added: Support for 3400_G Centralite V3 + * Jan 05, 2019 V2.2.5 Fixed: iPhone classic phone app crashes when attempting to set 3 character emergency number + * remove ,"" selection option + * Nov 30, 2018 V2.2.4 add additional panic subscribe when using RBoy DTH + * Nov 30, 2018 V2.2.4 Minor logic change for Iris V3 when testing for 3405-L + * Nov 19, 2018 V2.2.3 Test Modefix user settings for exit delay in verify version + * Nov 19, 2018 V2.2.2 User exit event not running in SHM Delay BuzzerSwitch, modify routine verify_version() + * Nov 03, 2018 v2.2.1 Adjust logic per Rboy suggestions + * Change Name of Rboy DTH + * When RBoy DTH do not issue: acknowledgeArmRequest and sendInvalidKeycodeResponse + * On install and Rboy DTH execute disableInvalidPinLogging(true) stops Rboy dth from issuing acknowledgement + * On uninstall execute disableInvalidPinLogging(false) when it exists in DTH + * Oct 27, 2018 v2.2.1 Fix bug selecting keypad devices with Rboy Dth, move Rboy input selector to place that makes sense + * Oct 26, 2018 v2.2.1 Fix bug testing globalKeypadDevices size when it doew not exist + * Oct 22, 2018 v2.2.1 Repackage some settings and adujst some text, no logic changes + * Oct 21, 2018 v2.2.1 Check for open user defined contacts prior to arming (will not arm or set exit delay) + * separate setting for away and stay alarm states + * Oct 17, 2018 v2.2.0 Use user exit delay settings in ModeFix to control exit delay on keypad + * When two or more keypads: each keypad gets unique exit delay time setting + * Oct 15, 2018 v2.1.9 Move non keypad exit delay from SHM Delay Child to routine verify_version + * symptom multiple non keypad exit delay messages being issued + * issue: when non keypad exit times vary in delay profiles the minimum number is announced + * Oct 10, 2018 v2.1.8 Add support for RBOY DTH, add global dthrboy + * Sep 20, 2018 v2.1.7 Change pin verification lookup reducing overhead in routine keypadcodehandler around line 376 + * Jul 24 2018 v2.1.7 Pin 0000 not User or UserRoutinePiston and ingore off was previously set, it was honored + * (released on Sep 20, 2018) + * Jul 21 2018 v2.1.6 add support for Iris Keypad quick arm with no pin and Off or Partial key + * sends a 0000 pin code + * Jul 19 2018 v2.1.5 add notification options on Bad Pin entry on global basis + * Jul 18 2018 v2.1.5 add notification options on Pin entry on global and each user pin + * Jul 17 2018 v2.1.4 Add support for multifunction UserRoutinePiston pins and + * Keypad mode selection on Routine, Piston, and UserRoutinePiston pins + * based upon code added to SHm Delay Users V1.0.0 to setup the fields + * Jul 11 2018 v2.1.3 Make all keypads sound Exit Delay tones when any keypad set to Exit Delay + * Change default for Multiple Motion Sensors to True + * Jul 02 2018 v2.1.2 Add code to verify simkypd and talker modules + * Jun 27 2018 v2.1.1 Add logic to trigger all SHM Delay Talker Child profiles + * with exitDelay when keypad enters exitdelay + * Jun 16, 2018 v2.1.0 Fix Error saving page caused by lack of event on call to veerify_version + * add dummy_evt to call + * Jun 13, 2018 v2.0.9 Add logic to process pin restrictions by mode and device + * Jun 03, 2018 v2.0.8 Show exit delay on internet keypad, and Panic when triggered + * When exit delay triggered by internet keypad sound exit delay on all real keypads + * Jun 01, 2018 v2.0.8 Add logic to queue pinstatus, ST status and ST mode for sse display in keypad.html + * May 29, 2018 v2.0.7 Add logic to KeypadLightHandler to process simulated keypads so DTH armMode is properly set + * Split original adding function KeypadLighton + * May 28, 2018 v2.0.7 Allow GlobalKeypadControl to be set when no real Keypads are defined + * fixes problem when using simulated keypads without a real keypad + * May 25, 2018 v2.0.7 Add Simulated Keypad Child App + * May 23, 2018 v2.0.6 Add Simulated Panic support. (deprecated, moved the simulated keypad childapp) + * Apr 30, 2018 v2.0.6 Add Simulated keypad support.(deprecated, moved the simulated keypad childapp) + * Apr 30, 2018 v2.0.5 Add Version verification when updating. + * Add subscribe for alarm state change that executes Version verification + * Apr 25, 2018 v2.0.4 Add Dynamic Version number; + * Use user defined armed (home) light mode for 3400 keypads defined in SHm Delay Modefix + * Add globalDuplicateMotionSensors used by SHM Delay Child to implement logic to handle + * false alarm issues when a motion sensors is defined in multiple delay profiles + * Apr 24, 2018 v2.0.3 When Modefix: on; then change mode with Action Tiles from night to stay; + * 3400 keypad night light did not change to stay + * Apr 23, 2018 v2.0.2b cleanup keypadModeHandler debug messages, see change in keypadModeHandler + * Apr 23, 2018 v2.0.2a reduce overhead by asking for keypad status as needed, may be creating keypad traffic collisions + * Apr 23, 2018 v2.0.2 when arming on Xfinity 3400 with Stay icon, light flipped to Night icon caused instant alarm + * See logic for Stay and Night in routine keypadLightHandler + * Apr 04, 2018 v2.0.1 Fix issue with burned pin, move all documentation to community forum release thread, + * change global keypad selection from capability to device + * Mar 20, 2018 v2.0.0 add reverse mode fix, user defined modes, set Alarm State when mode changes + * Mar 18, 2018 v2.0.0 add Panic option + * Mar 14, 2018 v2.0.0 add logic that executes a Routine for a pin + * Mar 13, 2018 v2.0.0 add logic for weekday, time and dates just added to SHM Delay User + * Mar 02, 2018 v2.0.0 add support for users and total keypad control + * Use mode vs alarmstatus to set Keypad mode lights, requires modefix be live + * Dec 31, 2017 v1.6.0 Add bool to allow Multiple Motion sensors in delay profile, + * without forcing existing users to update their profile data. + * Sep 23, 2017 v1.4.0 Document True Entry Delay and optional followed motion sensor in Delay Profile + * Sep 23, 2017 v1.3.0 Add Global setting for option True Entry Delay, default off/false + * Sep 06, 2017 v1.2.0b add custom app remove button and text + * Sep 02, 2017 v1.2.0a fix sorry there was an unexpected error due to having app name as modefixx from testing on + * one of the app connections + * Sep 02, 2017 v1.2.0 repackage Modefix logic back into child ModeFix module where it belongs + * Aug 30, 2017 v1.1.1 add global for using the upgraded Keypad module. + * Aug 27, 2017 v1.1.0 Add child module SHM Delay ModeFix for Mode fixup profiles and adjust menus to reflect status + * Aug 25, 2017 v1.1.0 SmartHome send stay mode when going into night mode. Force keypad to show + * night mode and have no entry delay. Add globalTrueNight for this option and globalFixMode + * Aug 23, 2017 v1.0.7 Add police 911 and telephone numbers as links in notification messages + * Aug 20, 2017 v1.0.6a Change default global options: non-unique to false, create intrusion messages to true + * update documentation + * Aug 19, 2017 v1.0.6 Add global options allowing non unique simulated sensors, and alarm trigger messages + * Aug 17, 2017 v1.0.5 Revise documentation prior to release + * Aug 14, 2017 v1.0.4 Revise documentation for exit delay, split about page into about and installation pages + * Aug 14, 2017 v1.0.3 Revise initial setup testing app.getInstallationState() for COMPLETE vs childApps.size + * done in v1.0.1 + * Aug 13, 2017 v1.0.2 Add documentation pages (Thanks to Stephan Hackett Button Controller) + * Aug 12, 2017 v1.0.1 Add warning on initial setup to install first (Thanks to E Thayer Lock Manager code) + * Aug 11, 2017 v1.0.0 Create from example in developer documentation + * + */ + +include 'asynchttp_v1' + +definition( + name: "SHM Delay", + namespace: "arnbme", + author: "Arn Burkhoff", + description: "(${version()}) Smart Home Monitor Exit/Entry Delays with optional Keypad support", + category: "My Apps", + iconUrl: "https://www.arnb.org/IMAGES/hourglass.png", + iconX2Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + iconX3Url: "https://www.arnb.org/IMAGES/hourglass@2x.png", + singleInstance: true) + +preferences { + page(name: "main") + page(name: "globalsPage", nextPage: "main") +} + +def version() + { + return "2.2.9"; + } +def main() + { + dynamicPage(name: "main", install: true, uninstall: true) + { + if (app.getInstallationState() == 'COMPLETE') //note documentation shows as lower case, but returns upper + { + def modeFixChild="Create" + def children = getChildApps() + children.each + { child -> + def childLabel = child.getLabel() + def appid=app.getId() +// logdebug "child label ${childLabel} ${appid}" + if (childLabel.matches("(.*)(?i)ModeFix(.*)")) + { + modeFixChild="Update" + } + } + def modeActive=" Inactive" + if (globalFixMode || globalKeypadControl) + {modeActive=" Active"} + def fixtitle = modeFixChild + modeActive + " Mode Fix Settings" + section + { + input "logDebugs", "bool", required: false, defaultValue:false, + title: "Log debugging messages? SHM Delay module only. Normally off/false" + } + section + { + app(name: "EntryDelayProfile", appName: "SHM Delay Child", namespace: "arnbme", title: "Create A New Delay Profile", multiple: true) + } + if (globalKeypadControl) + { + section + { + app(name: "UserProfile", appName: "SHM Delay User", namespace: "arnbme", title: "Create A New User Profile", multiple: true) + } + section + { + app(name: "SimKypdProfile", appName: "SHM Delay Simkypd Child", namespace: "arnbme", title: "Create A New Sim Keypad Profile", multiple: true) + } + } + section + { + app(name: "TalkerProfile", appName: "SHM Delay Talker Child", namespace: "arnbme", title: "Create A New Talker Profile", multiple: true) + } + section + { + href(name: 'toglobalsPage', page: 'globalsPage', title: 'Globals Settings') + } + section + { + if (globalFixMode && modeFixChild == "Create") + { + app(name: "ModeFixProfile", appName: "SHM Delay ModeFix", namespace: "arnbme", title: "${fixtitle}", multiple: false) + } + else + { + app(name: "ModeFixProfile", appName: "SHM Delay ModeFix", namespace: "arnbme", title: "${fixtitle}", multiple: false) + } + } + } + else + { + section + { + paragraph "Please review and set global settings, then complete the install by clicking 'Save' above. After the install completes, you may set Delay, User and Modefix profiles" + } + section + { + href(name: 'toglobalsPage', page: 'globalsPage', title: 'Globals Settings') + } + } +/* section + { + href (url: "https://community.smartthings.com/t/release-shm-delay-version-2-0/121800", + title: "Smartapp Documentation", + style: "external") + } +*/ section + { + paragraph "SHM Delay Version ${version()}" + } + remove("Uninstall SHM Delay","Warning!!","This will remove the ENTIRE SmartApp, including all profiles and settings.") + } + } + +def globalsPage() + { + dynamicPage(name: "globalsPage", title: "Global Settings") + { + section + { + input "globalDisable", "bool", required: true, defaultValue: false, + title: "Disable All Functions. Default: Off/False" + input "globalKeypadControl", "bool", required: true, defaultValue: false, submitOnChange: true, + title: "A real or simulated Keypad is used to arm and disarm Smart Home Monitor (SHM). Default: Off/False" + input "globalIntrusionMsg", "bool", required: false, defaultValue: true, + title: "This app issues an intrusion message with name of triggering real sensor? Default: On/True." + input (name: "global911", type:"enum", required: false, options: ["911","999","112"], + title: "Add 3 digit emergency call number on this app's intrusion message?") + input "globalPolice", "phone", required: false, + title: "Include this phone number as a link on this app's intrusion message? Separate multiple phone numbers with a pound sign(#), or semicolon(;)" + input "globalDuplicateMotionSensors", "bool", required: true, defaultValue: false, + title: "I have the same motion sensor defined in multiple delay profiles. Stop false motion sensor triggered alarms by cross checking for sensor in other delay profiles.\nDefault Off/False" + if (globalKeypadControl) + { + input "globalFixMode", "bool", required: true, defaultValue: true, + title: "Mode Fix when system armed from non keypad source: \nAlarm State change - verify and set a valid SHM mode\nSHM Mode change - verify and set Alarm state\nthen set keypad status and lights to match system.\nDefault: On/True" + } + else + { + input "globalFixMode", "bool", required: true, defaultValue: false, + title: "Mode Fix when system armed from non keypad source: \nAlarm State change - verify and set a valid SHM mode\nSHM Mode change - verify and set Alarm status.\nDefault: Off/False" + } +// input "globalKeypad", "bool", required: true, defaultValue: false, //deprecated Was used with Version1 +// title: "The upgraded Keypad module is installed Default: Off/False" + input "globalTrueNight", "bool", required: true, defaultValue: false, + title: "True Night Flag. When arming in Stay from a non keypad device, or Partial from an Iris keypad, and monitored sensor triggers:\nOn: Instant intrusion\nOff: Entry Delay" + if (globalKeypadControl) + { + input "globalRboyDth", "bool", required: false, defaultValue:false, submitOnChange: true, + title: "I am using the RBoy Apps Keypad DTH" + def actions = location.helloHome?.getPhrases()*.label + actions?.sort() + if (globalRboyDth) + { + input "globalKeypadDevices", "device.EnhancedZigbeeKeypadLock", required: false, multiple: true, submitOnChange: true, + title: "Real Keypads used to arm and disarm SHM" + } + else + { + input "globalKeypadDevices", "device.CentraliteKeypad", required: false, multiple: true, submitOnChange: true, + title: "Real Keypads used to arm and disarm SHM" + } + if (globalKeypadDevices && globalKeypadDevices.size() > 1) + { + def kpnm + globalKeypadDevices.each + { + kpnm=it.displayName.replaceAll(" ","_") + input "globalKeypadExitDelay${kpnm}", "number", required: true, range: "0..90", defaultValue: 30, + title: "Device: ${kpnm}, Exit delay seconds when arming with a delay from this keypad. range 0-90, default:30" + } + } + else + { + input "globalKeypadExitDelay", "number", required: true, range: "0..90", defaultValue: 30, + title: "Default True exit delay in seconds when arming with a delay. range 0-90, default:30" + } + input "globalOff", "enum", options: actions, required: true, defaultValue: "I'm Back!", + title: "Keypad Disarmed/OFF executes Routine. Default: I'm Back!" + input "globalStay", "enum", options: actions, required: true, defaultValue: "Good Night!", + title: "Keypad Stay/Partial executes Routine. Default: Good Night!" + input "globalNight", "enum", options: actions, required: true, defaultValue: "Good Night!", + title: "Keypad Night executes Routine. Default: Good Night!" + input "globalAway", "enum", options: actions, required: true, defaultValue: "Goodbye!", + title: "Keypad Away/On executes Routine. Default: Goodbye!" + input "globalPanic", "bool", required: true, defaultValue: true, + title: "Iris Panic Key is Monitored. No Panic key? Set this flag on, add a User Profile, Pin Usage: Panic. Default: On/True" +// input "globalBadpins", "number", required: true, range: "0..5", defaultValue: 1, +// title: "Sound invalid pin code tone on keypad after how many invalid pin code entries. 0 = disabled, range: 1-5, default: 1" +// input "globalBadpinsIntrusion", "number", required: true, range: "0..10", defaultValue: 4, +// title: "(Future enhancement) Create intrusion alert after how many invalid pin code entries. 0 = disabled, range: 1-10, default: 4" + input "globalPinMsgs", "bool", required: false, defaultValue: true, submitOnChange: true, + title: "Log pin entries. Default: On/True" + if (globalPinMsgs) + { + input "globalPinLog", "bool", required: false, defaultValue:true, + title: "Log Pin to Notifications?" + if (location.contactBookEnabled) + { + input("globalPinRecipients", "contact", title: "Pin Notify Contacts (When used ST system forces send to notification log, so set prior setting to false)",required:false,multiple:true) + input "globalPinPush", "bool", required: false, defaultValue:false, + title: "Send Pin Push Notification?" + } + else + { + input "globalPinPush", "bool", required: false, defaultValue:true, + title: "Send Pin Push Notification?" + } + input "globalPinPhone", "phone", required: false, + title: "Send Pin text message to this number. For multiple SMS recipients, separate phone numbers with a pound sign(#), or semicolon(;)" + } + input "globalBadPinMsgs", "bool", required: false, defaultValue: true, submitOnChange: true, + title: "Log invalid keypad entries, pins not found in a User Profile Default: On/True" + if (globalBadPinMsgs) + { + input "globalBadPinLog", "bool", required: false, defaultValue:true, + title: "Log Bad Pins to Notifications?" + if (location.contactBookEnabled) + { + input("globalBadPinRecipients", "contact", title: "Bad Pin Notify Contacts (When used ST system forces send to notification log, so set prior setting to false)",required:false,multiple:true) + input "globalBadPinPush", "bool", required: false, defaultValue:false, + title: "Send Bad Pin Push Notification?" + } + else + { + input "globalBadPinPush", "bool", required: false, defaultValue:true, + title: "Send Bad Pin Push Notification?" + } + input "globalBadPinPhone", "phone", required: false, + title: "Send Invalid Bad Pin text message to this number. For multiple SMS recipients, separate phone numbers with a pound sign(#), or semicolon(;)" + } + + input "globalAwayContacts", "capability.contactSensor", required: false, submitOnChange: true, multiple: true, + title: "(Optional!) Contacts must be closed prior to arming Away from a Keypad" + if (globalAwayContacts) + { + input (name: "globalAwayNotify", type:"enum", required: false, options: ["Notification log", "Push Msg", "SMS","Talk"],multiple:true, + title: "How to notify contact is open, arming Away") + } + input "globalStayContacts", "capability.contactSensor", required: false, submitOnChange: true, multiple:true, + title: "(Optional!) Contacts must be closed prior to arming Stay from a Keypad." + if (globalStayContacts) + { + input (name: "globalStayNotify", type:"enum", required: false, options: ["Notification log", "Push Msg", "SMS","Talk"],multiple:true, + title: "How to notify contact is open arming Stay") + } + } + input "globalSimUnique", "bool", required: false, defaultValue:false, + title: "Simulated sensors must be unique? Default: Off/False allows using a single simulated sensor." + input "globalTrueEntryDelay", "bool", required: true, defaultValue: false, + title: "True Entry Delay: This is a last resort when adding motion sensors to delay profile does not stop Intrusion Alert. AlarmState Away and Stay with an entry delay time, ignore triggers from all other sensors when Monitored Contact Sensor opens. Default: Off/False" + input "globalMultipleMotion", "bool", required: true, defaultValue: true, + title: "Allow Multiple Motion Sensors in Delay Profile. Default: On/True" + } + } + } + +def installed() { + log.info "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.info "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() + { + if (globalKeypadControl && !globalDisable) + { + subscribe(globalKeypadDevices, 'codeEntered', keypadCodeHandler) + subscribe (location, "mode", keypadModeHandler) + if (globalPanic) + { + if (globalRboyDth) + subscribe (globalKeypadDevices, "button.pushed", keypadPanicHandler) + else + subscribe (globalKeypadDevices, "contact.open", keypadPanicHandler) + } + globalKeypadDevices?.each + { + if (it.hasCommand("disableInvalidPinLogging")) + it.disableInvalidPinLogging(true) + } + } + subscribe(location, "alarmSystemStatus", verify_version) + verify_version("dummy_evt") + } + +def uninstalled() + { + globalKeypadDevices?.each + { + if (it.hasCommand("disableInvalidPinLogging")) + it.disableInvalidPinLogging(false) + } + } + +// --------------------------Keypad support added Mar 02, 2018 V2------------------------------- +/* Basic location modes are Home, Night, Away. This can be very confusing +Xfinity Default mode +Centralite Iris Location Default Triggers Xfinity Iris +Icon Button Mode AlarmStatus Routine Icon lit key lit +(off) Off Home off I'm Home (none) Off?? +Stay Partial Night stay GoodNight Stay Partial +Night Night stay GoodNight Stay Partial, but night key should not occur +Away On Away away GoodBye! Away Away + + +Xfinity When Location Stay mode is defined and SHM Stay routine defined for Xfinity only +Centralite Location Default Triggers Xfinity +Keypad Mode AlarmStatus Routine Icon lit +(off) Home off I'm Home (none) +Stay Stay stay Stay Stay +Night Night stay GoodNight Night +Away Away away GoodBye! Away +*/ +def keypadCodeHandler(evt) + { +// User entered a code on a keypad + if (!globalKeypadControl || globalDisable) + {return false} //just in case + def keypad = evt.getDevice(); +// logdebug "keypadCodeHandler called: $evt by device : ${keypad.displayName}" + def str = evt.value.split("[/]"); + def codeEntered = str[0] as String //the entered pin + def modeEntered = str[1] as Integer //the selected mode off(0), stay(1), night(2), away(3) + def itext = [dummy: "dummy"] //external find it data or dummy map to fake it when pin not found + def fireBadPin=true //flag to stop double band pin fire. Caused by timing issue +// with Routine, Piston and UserRoutinePiston processing + if (modeEntered < 0 || modeEntered> 3) //catch an unthinkable bad mode, this is catastrophic + { + log.error "${app.label}: Unexpected arm mode ${modeEntered} sent by keypad!" + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.sendInvalidKeycodeResponse() + return false + } +// def currentarmMode = keypad.currentValue('armMode') +// logdebug("Delayv2 codeentryhandler searching user apps for keypad ${keypad.displayName} ${evt.data} ${evt.value}") + def userName=false; + def badPin=true; + def badPin_message = keypad.displayName + "\nInvalid pin: " + codeEntered + def error_message="" + def info_message="" + def pinKeypadsOK=false; + def damap=[dummy: "dummy"] //dummy return map for Routine and Piston processing + +// Try to find a matching pin in the pin child apps +// def userApps = getChildApps() //gets all completed child apps Sep 20, 2018 + def userApps = findAllChildAppsByName('SHM Delay User') + userApps.find + { +// if (it.getName()=="SHM Delay User" && it.theuserpin == codeEntered) Sep 20, 2018 + if (it.getInstallationState()=='COMPLETE' && it.theuserpin == codeEntered) + { +// logdebug ("found the pin ${it.getName()} ${it.theuserpin} ${it.theusername} ") +// verify burn cycles + itext=it //save for use outside of find loop + if (it.themaxcycles > 0) //check if pin is burned + { + def atomicUseId=it.getId()+'uses' //build unique atomic id for uses + if (atomicState."${atomicUseId}" < 0) //initialize if never set + {atomicState."${atomicUseId}" = 1} + else + {atomicState."${atomicUseId}" = atomicState."${atomicUseId}" + 1} + if (atomicState."${atomicUseId}" > it.themaxcycles) + { + logdebug "pin $codeEntered burned" + error_message = keypad.displayName + " Burned pin entered for " + it.theusername + } + } + if (error_message == "" && codeEntered == '0000' && modeEntered == 0 && + (it.thepinusage=='User' || it.thepinusage=='UserRoutinePiston') && it?.thepinIgnoreOff) + { + badPin=true + error_message=badPin_message + } + else + { + badPin=false + badPin_message="" + } +// logdebug "matched pin ${it.theuserpin} $it.pinScheduled" +// When pin is scheduled verify Dates, Weekday and Time Range + if (error_message=="" && it.pinScheduled) + { +// keep this code in sync with similar code in SHM Delay Users + def df = new java.text.SimpleDateFormat("EEEE") //formatter for current time + df.setTimeZone(location.timeZone) + def day = df.format(new Date()) + def df2 = new java.text.SimpleDateFormat("yyyyMMdd") + df2.setTimeZone(location.timeZone) + def nowymd = df2.format(new Date()); // the yyyymmdd format for comparing and processing + def dtbetween=true + def num_dtstart + def num_dtend + if (it.pinStartDt > "") + num_dtstart=it.dtEdit(it.pinStartDt) + if (it.pinEndDt > "") + num_dtend=it.dtEdit(it.pinEndDt) +// logdebug "pin found with schedule $nowymd $num_dtstart $num_dtend" +// verify the dates + if (it.pinStartDt>"" && it.pinEndDt>"") + { + if (num_dtstart > nowymd || num_dtend < nowymd) + error_message = keypad.displayName + " dates out of range with pin for " + it.theusername + } + else + if (it.pinStartDt>"") + { + if (num_dtstart > nowymd) + error_message = keypad.displayName + " start date error with pin for " + it.theusername + } + else + if (it.pinEndDt>"") + { + if (num_dtend < nowymd) + error_message = keypad.displayName + " end date expired with pin for " + it.theusername + } + +// verify the weekdays + if (error_message=="" && it.pinDays) + { + if (!it.pinDays.contains(day)) + error_message = keypad.displayName + " not valid on $day with pin for " + it.theusername + } + +// verify the hours stored by system as 2018-03-13T11:30:00.000-0400 + if (error_message=="" && it.pinStartTime>"" && it.pinEndTime>"") + { + def between = timeOfDayIsBetween(it.pinStartTime.substring(11,16), it.pinEndTime.substring(11,16), new Date(), location.timeZone) + if (!between) + error_message = keypad.displayName + " time out of range with pin for " + it.theusername + } + } + +// Process pin mode and device restrictions + if (error_message=="" && it.pinRestricted) + { + if (it.pinModes) + { + if (!it.pinModes.contains(location.mode)) + error_message = keypad.displayName + " mode: "+ location.mode + " invalid with pin for " + it.theusername + } + if (error_message=="" && (it.pinRealKeypads || it.pinSimKeypads)) + { +// this wont work sigh if (it.pinSimKeypads.contains(keypad.displayName)) + it.pinRealKeypads.each + {kp -> + if (kp.displayName == keypad.displayName) + pinKeypadsOK=true + } + it.pinSimKeypads.each + {kp -> + if (kp.displayName == keypad.displayName) + pinKeypadsOK=true + } + if (!pinKeypadsOK) + error_message = keypad.displayName + " is unauthorized keypad with pin for " + it.theusername + } + } + +// Verify pin usage + if (error_message=="") + { +// logdebug "processing the pin for ${it.thepinusage}" + switch (it.thepinusage) + { + case 'User': + case 'UserRoutinePiston': //process arming now or get a possible keypad timeout + userName=it.theusername + break + case 'Disabled': + error_message = keypad.displayName + " disabled pin entered for " + it.theusername + break + case 'Ignore': + error_message = keypad.displayName + " ignored pin entered for " + it.theusername + break + case 'Routine': +// forced to do acknowledgeArmRequest here due to a hardware timeout on keypad + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.acknowledgeArmRequest(4) + acknowledgeArmRequest(4,keypad); + fireBadPin=false + damap=process_routine(it, modeEntered, keypad) + logdebug "Routine created ${damap}" + if (damap?.err) + error_message=damap.err + else + if (damap?.info) + info_message=damap.info + break + case 'Panic': + if (globalPanic) + { + error_message = keypad.displayName + " Panic entered with pin for " + it.theusername + keypadPanicHandler(evt) +// panicContactOpen() //unable to get this working use klunky method above + } + else + { + error_message = keypad.displayName + " Panic entered but globalPanic flag disabled with pin for " + it.theusername + } + break + case 'Piston': +// forced to do acknowledgeArmRequest here due to a possible hardware timeout on keypad + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.acknowledgeArmRequest(4) + acknowledgeArmRequest(4,keypad); + fireBadPin=false + damap=process_piston(it, modeEntered, keypad) + if (damap?.err) + error_message=damap.err + else + if (damap?.info) + info_message=damap.info + else + error_message = "Process Piston returned bad data: ${dmap} " + break + default: + userName=it.theusername + break + } + } + return true //this ends the ***find*** loop, not the function + } + else + {return false} //this continues the ***find*** loop, does not end function + } + +// Now done with find loop and editing the pin entered on the keypad + + if (error_message!="") // put out any messages to notification log + { + badPin=true +// logdebug "${error_message} info ${info_message}" + doPinNotifications(error_message, itext) + } + else + if (info_message!="") + { + doPinNotifications(info_message, itext) + } + +// Was pin not found +/* in theory acknowledgeArmRequest(4) and sendInvalidKeycodeResponse() send same command + but not working that way. Look at this when I get some time +*/ + + if (badPin) + { + if (fireBadPin) + { + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.acknowledgeArmRequest(4) //always issue badpin very long beep + acknowledgeArmRequest(4,keypad); + } + if (globalBadPinMsgs && badPin_message !="") + doBadPinNotifications (badPin_message, itext) +/* +** Deprecated this logic on Mar 18, 2018 for better overall operation + if (globalBadPins==1) + { + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.acknowledgeArmRequest(4) //sounds a very long beep + acknowledgeArmRequest(4,keypad); + } + else + { + if (atomicState.badpins < 0) //initialize if never set + {atomicState.badpins=0} + atomicState.badpins = atomicState.badpins + 1 + if (atomicState.badpins >= globalBadpins) + { + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.acknowledgeArmRequest(4) //sounds a very long beep + acknowledgeArmRequest(4,keypad); + atomicState.badpins = 0 + } + else + { + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.sendInvalidKeycodeResponse() //sounds a medium duration beep + acknowledgeArmRequest(4,keypad); + } + } +*/ return; + } + + +// was this pin associated with a person + if (!userName) //if not a user pin, no further processing + { + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.sendInvalidKeycodeResponse() //sounds a medium duration beep + return + } + +// Oct 21, 2018 verify contacts are closed prior to arming or exit delay +// Message sensor, sensor open, Arming cancelled + if (modeEntered > 0) + { + logdebug "checking for open contacts" + if (modeEntered == 3) + { + if (globalAwayContacts) + { + if (!checkOpenContacts(globalAwayContacts, globalAwayNotify, keypad)) + return + } + } + else + if (globalStayContacts) + { + if(!checkOpenContacts(globalStayContacts, globalStayNotify, keypad)) + return + } + } + + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.acknowledgeArmRequest(modeEntered) //keypad demands a followup light setting or all lights blink + acknowledgeArmRequest(modeEntered,keypad); + unschedule(execRoutine) //Attempt to handle rearming/disarming during exit delay by unscheduling any pending away tasks +// atomicState.badpins=0 //reset badpin count + def armModes=['Home','Stay','Night','Away'] + def alarmModes=['home','stay','stay','away'] + def message = keypad.displayName + "\nset mode to " + armModes[modeEntered] + "\nwith pin for " + userName + def aMap = [data: [codeEntered: codeEntered, armMode: armModes[modeEntered]]] + def mf + def am + def daexitdelay=false + def internalExitDelay=30 //set a default just in case + if (globalKeypadDevices && globalKeypadDevices.size() > 1) + { + def kpnm=keypad.displayName.replaceAll(" ","_") +// logdebug "keypad is $kpnm" + if ("globalKeypadExitDelay${kpnm}") + { + internalExitDelay=settings."globalKeypadExitDelay${kpnm}" +// logdebug "keypad ${kpnm} used, setting exit delay to ${internalExitDelay}" + } + else + if (globalKeypadExitDelay) + { +// logdebug "Did not find setting for ${kpnm} using global default setting" + internalExitDelay=globalKeypadExitDelay + } + } + else + if (globalKeypadExitDelay) + { +// logdebug "less than two keypads defined using global default" + internalExitDelay=globalKeypadExitDelay + } + + if (modeEntered > 0 && internalExitDelay > 0) + { + mf=findChildAppByName('SHM Delay ModeFix') +// logdebug "${mf.getInstallationState()} ${mf.version()}" + if (mf && mf.getInstallationState() == 'COMPLETE' && mf.version() > '0.1.4') + { + am="${alarmModes[modeEntered]}Exit${armModes[modeEntered]}" + daexitdelay = mf."${am}" +// logdebug "Version ${mf.version()} the daexitdelay is ${daexitdelay}" + } + else + if (modeEntered==3) + {daexitdelay=true} + } + if (daexitdelay) + { + logdebug "entered exit delay for $am delay: ${internalExitDelay}" + globalKeypadDevices.each + { + it.setExitDelay(internalExitDelay) + } + runIn(internalExitDelay, execRoutine, aMap) + def locevent = [name:"shmdelaytalk", value: "exitDelay", isStateChange: true, + displayed: true, descriptionText: "Issue exit delay talk event", linkText: "Issue exit delay talk event", + data: internalExitDelay] + sendLocationEvent(locevent) + qsse_status_mode(false,"Exit%20Delay") + } + else + {execRoutine(aMap.data)} + doPinNotifications(message,itext) + +// Process remainder of UserRoutinePiston settings + if (itext.thepinusage == 'UserRoutinePiston') + { + damap=process_routine(itext, modeEntered, keypad) + if (damap?.err) + { + if (damap.err != "nodata") + doPinNotifications(damap.err,itext) //no message when no routines where coded + } + else + if (damap?.info) + doPinNotifications(damap.info,itext) + else + doPinNotifications("Process Routine returned bad data: ${damap}",itext) + + damap=process_piston(itext, modeEntered, keypad) + if (damap?.err) + { + if (damap.err != "nodata") + doPinNotifications(damap.err,itext) //no message when no routines where coded + } + else + if (damap?.info) + doPinNotifications(damap.info,itext) + else + doPinNotifications("Process Piston returned bad data: ${damap}",itext) + } + } + +def acknowledgeArmRequest(armMode,keypad) +// Post the status of the pin to the shmdelay_oauth db table + { +// logdebug "acknowledgeArmRequest entererd ${keypad?.getTypeName()} ${keypad.name}" + if (keypad?.getTypeName()!="Internet Keypad") + {return false} +// keypad.properties.each { k,v -> logdebug "${k}: ${v}"} + def pinstatus + if (armMode < 0 || armMode > 3) + pinstatus="Rejected" + else + pinstatus ="Accepted" + def uri='https://www.arnb.org/shmdelay/qsse.php' + def simKeypadDevices=findAllChildAppsByName('SHM Delay Simkypd Child') + simKeypadDevices.each + { + + if (it.simkeypad.name == keypad.name) + { + uri+='?i='+it.getAtomic('accessToken').substring(0,8) + uri+='&p='+ pinstatus +// logdebug "firing php ${uri} ${it.simkeypad.name} ${it.getAtomic('accessToken')}" + try { + asynchttp_v1.get('ackResponseHandler', [uri: uri]) + } + catch (e) + { + logdebug "qsse.php Execution failed ${e}" + } + } + } + } + +def qsse_status_mode(status,mode) +// store the status of the ST status and mode to the shmdelay_oauth db table for all simulated keypads + { + def st_status=status + if (!st_status) + st_status = location.currentState("alarmSystemStatus").value + if (st_status=="off") + st_status="Disarmed" + else + st_status="Armed%20("+st_status+")" //need to base64 to get this to send + def st_mode=mode + if (!st_mode) + st_mode = location.currentMode + def uri + findAllChildAppsByName('SHM Delay Simkypd Child').each + { + if (it.getInstallationState()=='COMPLETE') + { + uri='https://www.arnb.org/shmdelay/qsse.php' + uri+='?i='+it.getAtomic('accessToken').substring(0,8) + uri+='&s='+ st_status + uri+='&m='+ st_mode +// logdebug "firing php ${uri} ${it.simkeypad.name} ${it.getAtomic('accessToken')}" + try { + asynchttp_v1.get('ackResponseHandler', [uri: uri]) + } + catch (e) + { + logdebug "qsse.php Execution failed ${e}" + } + } + } + } + +def ackResponseHandler(response, data) + { + if(response.getStatus() != 200) + sendNotificationEvent("SHM Delay qsse.php HTTP Error = ${response.getStatus()}") + } + +def execRoutine(aMap) +// Execute default SmartHome Monitor routine, setting ST AlarmStatus and SHM Mode + { + def armMode = aMap.armMode + def kMap = [mode: armMode, dtim: now()] //save mode dtim any keypad armed/disarmed the system for use with +// not ideal prefer alarmtime but its before new alarm time is set + def kMode=false //new keypad light setting, waiting for mode to change is a bit slow + def kbMap = [value: armMode, source: "keypad"] + logdebug "execRoutine aMap: ${aMap} kbMap: ${kbMap}" + if (armMode == 'Home') + { + keypadLightHandler(kbMap) + location.helloHome?.execute(globalOff) + } + else + if (armMode == 'Stay') + { + keypadLightHandler(kbMap) + location.helloHome?.execute(globalStay) + } + else + if (armMode == 'Night') + { + keypadLightHandler(kbMap) + location.helloHome?.execute(globalNight) + } + else + if (armMode == 'Away') + { + keypadLightHandler(kbMap) + location.helloHome?.execute(globalAway) + } + atomicState.kMap=kMap //SHM Delay Child DoorOpens and MotionSensor active functions + } + +def keypadModeHandler(evt) //react to all SHM Mode changes + { + if (globalDisable) + {return false} //just in case + def theMode=evt.value //Used to set the keypad button/icon lights + def theStatus=false //used to set SHM Alarm State + if (globalFixMode) //if global fix mode use data allowing for user defined modes + { + def it=findChildAppByName('SHM Delay ModeFix') + if (it?.getInstallationState()!='COMPLETE') + logdebug "keypadModeHandler: Modefix is not fully installed, please adjust data then save" + else + if (it.offDefault == theMode) + { + theStatus='off' + theMode='Home' + } + else + if (it.awayDefault == theMode) + { + theStatus='away' + theMode='Away' + } + else +// if (it.stayDefault == theMode) 2.0.4 Apr 24, 2018 commented out, handled to stayModes.contains below +// { +// theStatus='stay' +// theMode='Night' +// } +// else + if (it.offModes.contains(theMode)) + { + theStatus='off' + theMode='Home' + } + else + if (it.awayModes.contains(theMode)) + { + theStatus='away' + theMode='Away' + } + else + if (it.stayModes.contains(theMode)) + { + theStatus='stay' +// if (theMode!="Stay") //2.0.3 Apr 24, 2018 fix keypad light not changing from Night to Stay on 3400 keypad + if (it."stayLight${theMode}") //2.0.4 Apr 24, 2018 select Icon light from User set Modefix Icon light data + { + theMode=it."stayLight${theMode}" +// logdebug "Stay mode ${theMode} picked from settings" + } + else + { +// logdebug "Stay mode default night mode used" + theMode='Night' + } + } + } + logdebug "keypadModeHandler GlobalFix:${globalFixMode} theMode: $theMode theStatus: $theStatus" + + if (globalKeypadControl) //when we are controlling keypads, set lights + { + if (theMode=='Home' || theMode=='Away' || theMode=='Night' || theMode=='Stay') + { + def kMap=atomicState.kMap + def kDtim=now() + def kMode + logdebug "keypadModeHandler KeypadControl entered theMode: ${theMode} AtomicState.kMap: ${kMap}" + def setKeypadLights=true + if (kMap) + { + kDtim=kMap.dtim + kMode=kMap.mode +// logdebug "keypadModeHandler ${evt} ${theMode} ${kMode}" + if (theMode==kMode) + { + logdebug "Keypad lights are OK, no messages sent" + setKeypadLights=false + } + } + +// Reset the keypad lights and mode, keep time when atomicState previously set, time is last time real keypad set mode + if (setKeypadLights) + { + kMap = [mode: theMode, dtim: kDtim] //save mode dtim any keypad armed/disarmed the system for use with + atomicState.kMap=kMap //SHM Delay Child DoorOpens and MotionSensor active functions + logdebug "keypadModeHandler issuing keypadlightHandler ${evt} ${evt.value}" +// keypadLightHandler(evt) + def fakeEvt = [value: theMode] + keypadLightHandler(fakeEvt) + } + } + else + { + logdebug "keypadModeHandler mode $theMode cannot be used to set the keypad lights" + } + } + +// When SHM alarm state does not match the requested SHM alarm state, change it + if (theStatus) + { + def alarm = location.currentState("alarmSystemStatus") //get ST alarm status + def alarmstatus = alarm.value + if (alarmstatus != theStatus) + setSHM(theStatus) + } + qsse_status_mode(theStatus,theMode) + } + +def keypadLightHandler(evt) //set the Keypad lights + { + def theMode=evt.value //This should be a valid SHM Mode + def simkeypad + logdebug "keypadLightHandler entered ${evt} ${theMode} source: ${evt.source}" + def simKeypadDevices=findAllChildAppsByName('SHM Delay Simkypd Child') + simKeypadDevices.each + { + if (it?.getInstallationState()!='COMPLETE') + { + logdebug "${it.keypad} warning device not complete, please save the profile" + } + else + { + simkeypad=it.simkeypad //get device + keypadLighton(evt,theMode,simkeypad) + } + } + globalKeypadDevices.each + { keypad -> + keypadLighton(evt,theMode,keypad) + } + } + +def keypadLighton(evt,theMode,keypad) + { +// logdebug "keypadLighton entered $evt $theMode $keypad ${keypad?.getTypeName()}" + def currkeypadmode="" + if (theMode == 'Home') //Alarm is off + {keypad.setDisarmed()} + else + if (theMode == 'Stay') + { + keypad.setArmedStay() //lights Partial light on Iris, Stay Icon on Xfinity/Centralite +// deprecated on Apr 23, 2018 +// if (evt.source !="keypad" && globalTrueNight && keypad?.getModelName()=="3400" && keypad?.getManufacturerName()=="CentraLite") +// {keypad.setArmedNight()} +// else +// {keypad.setArmedStay()} //lights Partial light on Iris +// deprecated on Apr 23, 2018 2.0.2a +// if (keypad?.getModelName()=="3400" && keypad?.getManufacturerName()=="CentraLite" && currkeypadmode =="armedStay") +// {} +// else +// {keypad.setArmedStay()} //lights Partial light on Iris + } + else + if (theMode == 'Night') //Iris has no Night light set Partial on + { +// if (keypad?.getModelName()=="3400" && keypad?.getManufacturerName()=="CentraLite" || Oct 10, 2018 v2.1.8 +// if (keypad?.getModelName()!="3405-L" || V2.2.4 Nov 30, 2018 +// if (keypad?.getModelName()=="3400" || v2.2.6 Jan 06, 2019 + if (['3400','3400-G'].contains(keypad?.getModelName()) || + keypad?.getTypeName()=="Internet Keypad") + { + if (evt.source=="keypad") + {keypad.setArmedNight()} + else + { + currkeypadmode = keypad?.currentValue("armMode") + logdebug "keypadLightHandler LightRequest: ${theMode} model: ${keypad?.getModelName()} keypadmode: ${currkeypadmode}" + if (currkeypadmode =="armedStay") + { +// logdebug "keypadLightHandler model: ${keypad?.getModelName()} keypadmode: ${currkeypadmode} no lights unchanged" + } + else + {keypad.setArmedNight()} + } + } + else + {keypad.setArmedStay()} + } + else + if (theMode == 'Away') //lights ON light on Iris + {keypad.setArmedAway()} + } + +def keypadPanicHandler(evt) + { + if (!globalKeypadControl || globalDisable || !globalPanic) + {return false} //just in case + def alarm = location.currentState("alarmSystemStatus") //get ST alarm status + def alarmstatus = alarm.value + def keypad=evt.getDevice() //set the keypad name + def panic_map=[data:[cycles:5, keypad: keypad.name]] + logdebug "the initial panic map ${panic_map} ${keypad.name}" + if (alarmstatus == "off") + { +// location.helloHome?.execute(globalAway) //set alarm on deprecated Mar 20, 2018 + setSHM('away') //set alarm on in the fastest possible way I know + runIn(1, keypadPanicExecute,panic_map) + } + else + { + keypadPanicExecute(panic_map.data) //Panic routine only uses the device name, should be ok + } + } + +def keypadPanicExecute(panic_map) //Panic mode requested +/* When system is armed: Open simulated sensor +** When system is not armed: Wait for it to arm, open simulated sensor +** Limit time to 5 cycles around 9 seconds of waiting maximum +*/ + { + def alarm = location.currentState("alarmSystemStatus") //get ST alarm status + def alarmstatus = alarm.value + if (alarmstatus == "off") + { + logdebug "keypadPanicExecute entered $panic_map" + if (panic_map.cycles > 1) + { + def cycles=panic_map.cycles-1 + def keypad=panic_map.keypad + def newpanic_map=[data:[cycles: cycles, keypad: keypad]] + runIn(2, keypadPanicExecute,newpanic_map) + return false + } + } + +// prepare panic message, issued later + def message = "PANIC issued by $panic_map.keypad " + if (global911 > "" || globalPolice) + { + def msg_emergency + if (global911 > "") + { + msg_emergency= ", call Police at ${global911}" + } + if (globalPolice) + { + if (msg_emergency==null) + { + msg_emergency= ", call Police at ${globalPolice}" + } + else + { + msg_emergency+= " or ${globalPolice}" + } + } + message+=msg_emergency + } + else + { + message+=" by (SHM Delay App)" + } + +// find a delay profile for use with panic + logdebug "keypadPanicExecute searching for Delay profile" + + def childApps = getChildApps() //gets all completed child apps + def delayApp = false + childApps.find //change from each to find to speed up the search + { + if (!delayApp && it.getName()=="SHM Delay Child") + { + logdebug "keypadPanicExecute found Delay profile" + delayApp=true + if (alarmstatus == "off") + { + message+=" System did not arm in 10 seconds, unable to issue Panic intrusion" + it.doNotifications(message) //issue messages as per child profile + } + else + { + it.doNotifications(message) //issue messages as per child profile + it.thesimcontact.close() //trigger an intrusion + it.thesimcontact.open() + it.thesimcontact.close([delay: 4000]) + qsse_status_mode(false,"**Panic**") + + } + return true //this ends the **find** loop does not return to system + } + else + {return false} //this continues the **find** loop does not return + } + if (!delayApp) + { + message +=' Unable to create instrusion, no delay profile found' + sendNotificationEvent(message) //log to notification we are toast + } + } + +// Directly set the SHM alarm status input must be off, away or stay +def setSHM(state) + { + if (state=='off'|state=='away'||state=='stay') + { + def event = [name:"alarmSystemStatus", value: state, + displayed: true, description: "System Status is ${state}"] + sendLocationEvent(event) + } + } + + +/* +atempted to use this to trigger panic but it does not fire the subscribed event +and may create chaos when multiple keypad devices are defined +def panicContactOpen() { + logdebug "Enter panicContactOpen $globalKeypadDevices" + sendEvent(name: "contact", value: "open", displayed: true, isStateChange: true, Device: globalKeypadDevices) + runIn(3, "panicContactClose") +} + +def panicContactClose() + { + logdebug "Enter panicContactClose" + sendEvent(name: "contact", value: "closed", displayed: true, isStateChange: true, Device: globalKeypadDevices) + } +*/ + +// Process response from async execution of WebCore Piston +def getResponseHandler(response, data) + { + if(response.getStatus() != 200) + sendNotificationEvent("SHM Delay Piston HTTP Error = ${response.getStatus()}") + } + + +def verify_version(evt) //evt needed to stop error whne coming from subscribe to alarm change + { + logdebug "Entered Verify Version. evt data ${evt.getProperties().toString()}" + def uri='https://www.arnb.org/shmdelay/' +// uri+='?lat='+location.latitude //Removed May 01, 2018 deemed obtrusive +// uri+='&lon='+location.longitude + uri+='?hub='+location.hubs[0].encodeAsBase64() //May have quotes and other stuff + uri+='&zip='+location.zipCode + uri+='&cnty='+location.country + uri+='&eui='+location.hubs[0].zigbeeEui + def childApps = getChildApps() //gets all completed child apps + def vdelay=version() + def vchild='' + def vmodefix='' + def vuser='' + def vkpad='' + def vtalk='' + def vchildmindelay=9999 + def mf //modefix module + childApps.find //change from each to find to speed up the search + { +// logdebug "child ${it.getName()}" +// if (vchild>'' && vmodefix>'' && vuser>''&& vkpad>''&& vtalk>'') removed V2.1.9 Oct 16, 2018 +// return true not getting minimum nonkeypad delay time +// else + if (it.getName()=="SHM Delay Child") + { + if (vchild=='') + vchild=it?.version() + if (it?.theexitdelay < vchildmindelay) + vchildmindelay=it.theexitdelay //2.1.0 Oct 15, 2018 get delay profile exit delay time + return false + } + else + if (it.getName()=="SHM Delay ModeFix") //should only have 1 profile + { + mf=it //save app for later + vmodefix=it?.version() + return false + } + else + if (it.getName()=="SHM Delay Simkypd Child") + { + if (vkpad=='') + vkpad=it?.version() + return false + } + else + if (it.getName()=="SHM Delay Talker Child") + { + if (vtalk=='') + vtalk=it?.version() + return false + } + else + if (it.getName()=="SHM Delay User") + { + if (vuser=='') + vuser=it?.version() + return false + } + } + uri+="&p=${vdelay}" + uri+="&c=${vchild}" + uri+="&m=${vmodefix}" + uri+="&u=${vuser}" + uri+="&k=${vkpad}" + uri+="&t=${vtalk}" + logdebug "${uri}" + + try { + asynchttp_v1.get('versiongetResponseHandler', [uri: uri]) + } + catch (e) + { + logdebug "Execution failed ${e}" + } + qsse_status_mode(evt.value,false) + +// Moved exitdelay non-keypad talk message to here from SHM Delay Child, V2.1.9 Oct 15, 2018 + def vaway=evt?.value +// logdebug "Talker setup1 $vchildmindelay $vtalk $vaway" + +// Nov 19, 2018 V2.2.2 User exit event not running in SHM Delay BuzzerSwitch +// if (vtalk=='') //talker profile not defined, return +// return false + + logdebug "vchildmindelay: ${vchildmindelay}" + if (vchildmindelay < 1) //a nonkeypad time was set to 0 + return false; + + if (vchildmindelay == 9999) //no non-keypad exit delay time? + return false; + +// Nov 19, 2018 V2.2.3 Check Modefix data if State/Mode has an exit delay + def daexitdelay=false + + def theMode = location.currentMode + + if (evt?.value == "stay" || evt?.value == "away") + { + if (vmodefix > '0.1.4') + { + def am="${evt?.value}Exit${theMode}" + daexitdelay = mf."${am}" + logdebug "Modefix Version ${vmodefix} the daeexitdelay is ${daexitdelay} amtext: ${am}" + } + else + if (evt?.value == "away") + daexitdelay=true + } + + if (!daexitdelay) + return false + + def locevent = [name:"shmdelaytalk", value: "exitDelayNkypd", isStateChange: true, + displayed: true, descriptionText: "Issue exit delay talk event", linkText: "Issue exit delay talk event", + data: vchildmindelay] + + logdebug "Talker setup2 $vchildmindelay $vtalk" + def alarm = location.currentState("alarmSystemStatus") + def lastupdt = alarm?.date.time + def alarmSecs = Math.round( lastupdt / 1000) + +// get current time in seconds + def currT = now() + def currSecs = Math.round(currT / 1000) //round back to seconds + def kSecs=0 //if defined in if statment it is lost after the if + def kMap + def kduration + + logdebug "Talker fields $kSecs $alarmSecs $vchildmindelay" + if (globalKeypadControl) + { + kMap=atomicState['kMap'] //no data returns null + if (kMap>null) + { + kSecs = Math.round(kMap.dtim / 1000) + kduration=alarmSecs - kSecs +// logdebug "Talker fields $kSecs $alarmSecs $kduration $vchildmindelay" + if (kduration > 8) + { + sendLocationEvent(locevent) +// logdebug "Away Talker from non keypad triggered" + } + } + else // no atomic map issue message + { + sendLocationEvent(locevent) + } + } + else + { + logdebug "sending location event nonkeypad arming" + sendLocationEvent(locevent) + } + + } + +// Process response from async execution of version test to arnb.org +def versiongetResponseHandler(response, data) + { + if(response.getStatus() == 200) + { + def results = response.getJson() + logdebug "SHM Delay good response ${results.msg}" + if (results.msg != 'OK') + sendNotificationEvent("${results.msg}") + } + else + sendNotificationEvent("SHM Delay Version Check, HTTP Error = ${response.getStatus()}") + } + + +def process_routine(it, modeEntered, keypad) + { +// the initial msg in rmap is the default error message + def rmap = [err: "Process Routine " + keypad.displayName + " unknown keypad mode:" + modeEntered + " with pin for " + it.theusername] +// modeEntered: off(0), stay(1), night(2), away(3) + if (it?.thepinroutine) + { + rmap=fire_routine(it, modeEntered, keypad, it.thepinroutine[0], "All") + } + else + if (modeEntered == 0 && it?.thepinroutineOff) + { + rmap=fire_routine(it, modeEntered, keypad, it.thepinroutineOff[0], "Off") + } + else + if (modeEntered == 3 && it?.thepinroutineAway) + { + rmap=fire_routine(it, modeEntered, keypad, it.thepinroutineAway[0], "Away") + } + else + if ((modeEntered == 1 || modeEntered == 2) && it?.thepinroutineStay) + { + rmap=fire_routine(it, modeEntered, keypad, it.thepinroutineStay[0], "Stay") + } + else + if (it.pinuseage == "UserRoutinePiston" && modeEntered > -1 && modeEntered < 4 ) //nothing to process + { + rmap = [err: "nodata"] + } + return rmap //return with an err or info map message + } + +def fire_routine(it, modeEntered, keypad, theroutine, textmode) + { + def rmsg = keypad.displayName + " Mode:" + textmode + " executed routine " + theroutine + " with pin for " + it.theusername + def result + location.helloHome?.execute(theroutine) + if (it.thepinusage == "Routine") + result = [err: rmsg] + else + result = [info: rmsg] + return result + } + +def process_piston(it, modeEntered, keypad) + { + def rmap = [err: "Process Piston " + keypad.displayName + " unknown keypad mode:" + modeEntered + " with pin for " + it.theusername] +// modeEntered: off(0), stay(1), night(2), away(3) + if (it.thepinpiston) + { + rmap=fire_piston(it, modeEntered, keypad, it.thepinpiston, "All") + } + else + if (modeEntered == 0 && it.thepinpistonOff) + { + rmap=fire_piston(it, modeEntered, keypad, it.thepinpistonOff, "Off") + } + else + if (modeEntered == 3 && it.thepinpistonAway) + { + rmap=fire_piston(it, modeEntered, keypad, it.thepinpistonAway, "Away") + } + else + if ((modeEntered == 1 || modeEntered == 2) && it.thepinpistonStay) + { + rmap=fire_piston(it, modeEntered, keypad, it.thepinpistonStay, "Stay") + } + else + if (it.pinuseage == "UserRoutinePiston" && modeEntered > -1 && modeEntered < 4 ) //nothing to process + { + rmap = [err: "nodata"] + } + return rmap //return with an err or info map message + } + +def fire_piston(it, modeEntered, keypad, thepiston, textmode) + { + def rmsg = keypad.displayName + " Mode:" + textmode + " executed piston with pin for " + it.theusername + def result + try { + def params = [uri: thepiston] +// def params = [uri: "https://www.google.com"] //use to test + asynchttp_v1.get('getResponseHandler', params) + } + catch (e) + { + rmsg = rmsg + " Piston Failed: " + e + } + if (it.thepinusage == "Piston") + result = [err: rmsg] + else + result = [info: rmsg] + return result + + } + +// log, send notification, SMS message for pin entry, base code from SHM Delay Child +def doPinNotifications(localmsg, it) + { +// logdebug "doPinNotifications entered ${localmsg} ${it}" + if (it?.pinMsgOverride) + { +// logdebug "Pin msg override being used" + + if (it.UserPinLog) + { +// logdebug "sent to system log" + sendNotificationEvent(localmsg) + } + if (location.contactBookEnabled && it.UserPinRecipients) + { +// logdebug "sent to contact folks" + sendNotificationToContacts(localmsg, it.UserPinRecipients, [event: false]) + } + if (it.UserPinPush) + { + sendPushMessage(localmsg) + } + if (it.UserPinPhone) + { + def phones = it.UserPinPhone.split("[;#]") +// logdebug "$phones" + for (def i = 0; i < phones.size(); i++) + { + sendSmsMessage(phones[i], localmsg) + } + } + } + else + if (globalPinMsgs) + { +// logdebug "global Pin msg settings being used" + + if (globalPinLog) + { +// logdebug "log to notification log" + sendNotificationEvent(localmsg) + } + if (location.contactBookEnabled && globalPinRecipients) + { +// logdebug "global contacts being used" + sendNotificationToContacts(localmsg, globalPinRecipients, [event: false]) + } + if (globalPinPush) + { + sendPushMessage(localmsg) + } + if (globalPinPhone) + { + def phones = globalPinPhone.split("[;#]") + // logdebug "$phones" + for (def i = 0; i < phones.size(); i++) + { + sendSmsMessage(phones[i], localmsg) + } + } + } + else + if (globalPinMsgs && globalPinMsgs==false) + {} + else + { +// logdebug "default pin msg logic used, log to notifications" + sendNotificationEvent(localmsg) //log to notification when no settings available + } + } + +def doBadPinNotifications(localmsg, it) + { +// logdebug "doBadPinNotifications entered ${localmsg} ${it}" + if (globalBadPinLog) + { + sendNotificationEvent(localmsg) + } + if (location.contactBookEnabled && globalBadPinRecipients) + { + sendNotificationToContacts(localmsg, globalBadPinRecipients, [event: false]) + } + if (globalBadPinPush) + { + sendPushMessage(localmsg) + } + if (globalBadPinPhone) + { + def phones = globalBadPinPhone.split("[;#]") + for (def i = 0; i < phones.size(); i++) + { + sendSmsMessage(phones[i], localmsg) + } + } + } + +def checkOpenContacts (contactList, notifyOptions, keypad) + { + def contactmsg='' +// logdebug "contact list entered $contactList $notifyOptions $keypad" + contactList.each + { +// logdebug "${it} ${it.currentContact}" + if (it.currentContact=="open") + { + if (contactmsg == '') + { + if (!globalRboyDth) //Nov 3, 2018 rBoy DTH already issues the acknowledgement + keypad.sendInvalidKeycodeResponse() + contactmsg = 'Arming cancelled. Close '+it.displayName + } + else + contactmsg += ', '+it.displayName + } + } + if (contactmsg>'') + { + notifyOptions.each + { +// logdebug "$it" + if (it=='Notification log') + { + sendNotificationEvent(contactmsg) + } + else + if (it=='Push Msg') + {sendPushMessage(contactmsg)} + else + if (it=='SMS' && globalPinPhone) + { + def phones = globalPinPhone.split("[;#]") + for (def i = 0; i < phones.size(); i++) + { + sendSmsMessage(phones[i], contactmsg) + } + } + else + if (it=='Talk') + { + def loceventcan = [name:"shmdelaytalk", value: "ArmCancel", isStateChange: true, + displayed: true, descriptionText: "Issue exit delay talk event", linkText: "Issue exit delay talk event", + data: contactmsg] + sendLocationEvent(loceventcan) + } + } + return false + } + return true + } + +def logdebug(txt) + { + if (logDebugs) + log.debug ("${txt}") + } \ No newline at end of file diff --git a/smartapps/ericg66/sensibo-connect.src/sensibo-connect.groovy b/smartapps/ericg66/sensibo-connect.src/sensibo-connect.groovy new file mode 100644 index 00000000000..f25b97bca4c --- /dev/null +++ b/smartapps/ericg66/sensibo-connect.src/sensibo-connect.groovy @@ -0,0 +1,1094 @@ +/** + * Sensibo (Connect) + * + * Copyright 2015 Eric Gosselin + * + * 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. + * + */ + +definition( + name: "Sensibo (Connect)", + namespace: "EricG66", + author: "Eric Gosselin", + description: "Connect your Sensibo Pod to SmartThings.", + category: "Green Living", + iconUrl: "https://image.ibb.co/f8gMFQ/on_color_large_sm.png", + iconX2Url: "https://image.ibb.co/eOwA9k/on_color_large2x.png", + iconX3Url: "https://image.ibb.co/cq9V9k/on_color_large3x.png", + singleInstance: true) + +{ + appSetting "apikey" +} + +preferences { + page(name: "SelectAPIKey", title: "API Key", content: "setAPIKey", nextPage: "deviceList", install: false, uninstall: true) + page(name: "deviceList", title: "Sensibo", content:"SensiboPodList", install:true, uninstall: true) + page(name: "timePage") + page(name: "timePageEvent") +} + +def getServerUrl() { "https://home.sensibo.com" } +def getapikey() { apiKey } + +def setAPIKey() +{ + log.debug "setAPIKey()" + + def pod = appSettings.apikey + + def p = dynamicPage(name: "SelectAPIKey", title: "Enter your API Key", uninstall: true) { + section(""){ + paragraph "Please enter your API Key provided by Sensibo \n\nAvailable at: \nhttps://home.sensibo.com/me/api" + input(name: "apiKey", title:"", type: "text", required:true, multiple:false, description: "", defaultValue: pod) + } + } + return p +} + +def SensiboPodList() +{ + log.debug "SensiboPodList()" + + def stats = getSensiboPodList() + log.debug "device list: $stats" + + def p = dynamicPage(name: "deviceList", title: "Select Your Sensibo Pod", uninstall: true) { + section(""){ + paragraph "Tap below to see the list of Sensibo Pods available in your Sensibo account and select the ones you want to connect to SmartThings." + input(name: "SelectedSensiboPods", title:"Pods", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:stats]) + } + + section("Receive Pod sensors infos") { + input "boolnotifevery", "bool",submitOnChange: true, required: false, title: "Receive temperature, humidity and battery level notification every hour?" + href(name: "toTimePageEvent", + page: "timePageEvent", title:"Only during a certain time", require: false) + } + + section("Alert on sensors (threshold)") { + input "sendPushNotif", "bool",submitOnChange: true, required: false, title: "Receive alert on Sensibo Pod sensors based on threshold?" + } + + if (sendPushNotif) { + section("Select the temperature threshold",hideable: true) { + input "minTemperature", "decimal", title: "Min Temperature",required:false + input "maxTemperature", "decimal", title: "Max Temperature",required:false } + section("Select the humidity threshold",hideable: true) { + input "minHumidity", "decimal", title: "Min Humidity level",required:false + input "maxHumidity", "decimal", title: "Max Humidity level",required:false } + + section("How frequently?") { + input(name:"days", title: "Only on certain days of the week", type: "enum", required:false, multiple: true, options: ["Monday", "Tuesday", "Wednesday","Thursday","Friday","Saturday","Sunday"]) + } + section("") { + href(name: "toTimePage", + page: "timePage", title:"Only during a certain time", require: false) + } + } + + } + return p +} + +// page def must include a parameter for the params map! +def timePage() { + dynamicPage(name: "timePage", uninstall: false, install: false, title: "Only during a certain time") { + section("") { + input(name: "startTime", title: "Starting at : ", required:false, multiple: false, type:"time",) + input(name: "endTime", title: "Ending at : ", required:false, multiple: false, type:"time") + } + } +} + +// page def must include a parameter for the params map! +def timePageEvent() { + dynamicPage(name: "timePageEvent", uninstall: false, install: false, title: "Only during a certain time") { + section("") { + input(name: "startTimeEvent", title: "Starting at : ", required:false, multiple: false, type:"time",) + input(name: "endTimeEvent", title: "Ending at : ", required:false, multiple: false, type:"time") + } + } +} + +def getSensiboPodList() +{ + log.debug "getting device list" + + def deviceListParams = [ + uri: "${getServerUrl()}", + path: "/api/v2/users/me/pods", + requestContentType: "application/json", + query: [apiKey:"${getapikey()}", type:"json",fields:"id,room" ]] + + def pods = [:] + + try { + httpGet(deviceListParams) { resp -> + if(resp.status == 200) + { + resp.data.result.each { pod -> + def key = pod.id + def value = pod.room.name + + pods[key] = value + } + } + } + } + catch(Exception e) + { + log.debug "Exception Get Json: " + e + debugEvent ("Exception get JSON: " + e) + } + + log.debug "Sensibo Pods: $pods" + + return pods +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + state.lastTemperaturePush = null + state.lastHumidityPush = null + + initialize() + + def d = getAllChildDevices() + + if (boolnotifevery) { + //runEvery1Hour("hournotification") + schedule("0 0 * * * ?", "hournotification") + } + + //subscribe(d,"temperatureUnit",eTempUnitHandler) + + if (sendPushNotif) { + subscribe(d, "temperature", eTemperatureHandler) + subscribe(d, "humidity", eHumidityHandler) + } +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unschedule() + unsubscribe() + + state.lastTemperaturePush = null + state.lastHumidityPush = null + + initialize() + + def d = getAllChildDevices() + + if (boolnotifevery) { + //runEvery1Hour("hournotification") + schedule("0 0 * * * ?", "hournotification") + } + + //subscribe(d,"temperatureUnit",eTempUnitHandler) + + if (sendPushNotif) { + subscribe(d, "temperature", eTemperatureHandler) + subscribe(d, "humidity", eHumidityHandler) + } +} + +def hournotification() { + def hour = new Date() + def curHour = hour.format("HH:mm",location.timeZone) + def curDay = hour.format("EEEE",location.timeZone) + // Check the time Threshold + def stext = "" + if (startTimeEvent && endTimeEvent) { + def minHour = new Date().parse(smartThingsDateFormat(), startTimeEvent) + def endHour = new Date().parse(smartThingsDateFormat(), endTimeEvent) + + def minHourstr = minHour.format("HH:mm",location.timeZone) + def maxHourstr = endHour.format("HH:mm",location.timeZone) + + if (curHour >= minHourstr && curHour <= maxHourstr) + { + def devices = getAllChildDevices() + devices.each { d -> + log.debug "Notification every hour for device: ${d.id}" + def currentPod = d.displayName + def currentTemperature = d.currentState("temperature").value + def currentHumidity = d.currentState("humidity").value + def currentBattery = d.currentState("voltage").value + def sunit = d.currentState("temperatureUnit").value + stext = "${currentPod} - Temperature: ${currentTemperature} ${sunit} Humidity: ${currentHumidity}% Battery: ${currentBattery}" + + sendPush(stext) + } + } + } + else { + def devices = getAllChildDevices() + devices.each { d -> + log.debug "Notification every hour for device: ${d.id}" + def currentPod = d.displayName + def currentTemperature = d.currentState("temperature").value + def currentHumidity = d.currentState("humidity").value + def currentBattery = d.currentState("voltage").value + def sunit = d.currentState("temperatureUnit").value + stext = "${currentPod} - Temperature: ${currentTemperature} ${sunit} Humidity: ${currentHumidity}% Battery: ${currentBattery}" + + sendPush(stext) + } + } +} + +//def switchesHandler(evt) +//{ +// if (evt.value == "on") { +// log.debug "switch turned on!" +// } else if (evt.value == "off") { +// log.debug "switch turned off!" +// } +//} + +def eTempUnitHandler(evt) +{ + //refreshOneDevice(evt.device.displayName) +} + +def eTemperatureHandler(evt){ + def currentTemperature = evt.device.currentState("temperature").value + def currentPod = evt.device.displayName + def hour = new Date() + + if (inDateThreshold(evt,"temperature") == true) { + if(maxTemperature != null){ + if(currentTemperature.toDouble() > maxTemperature) + { + def stext = "Temperature level is too high at ${currentPod} : ${currentTemperature}" + sendEvent(name: "lastTemperaturePush", value: "${stext}", displayed : "true", descriptionText:"${stext}") + sendPush(stext) + + state.lastTemperaturePush = hour + } + } + if(minTemperature != null) { + if(currentTemperature.toDouble() < minTemperature) + { + def stext = "Temperature level is too low at ${currentPod} : ${currentTemperature}" + sendEvent(name: "lastTemperaturePush", value: "${stext}", displayed : "true", descriptionText:"${stext}") + sendPush(stext) + + state.lastTemperaturePush = hour + } + } + } +} + +def eHumidityHandler(evt){ + def currentHumidity = evt.device.currentState("humidity").value + def currentPod = evt.device.displayName + def hour = new Date() + if (inDateThreshold(evt,"humidity") == true) { + if(maxHumidity != null){ + if(currentHumidity.toDouble() > maxHumidity) + { + def stext = "Humidity level is too high at ${currentPod} : ${currentHumidity}" + sendEvent(name: "lastHumidityPush", value: "${stext}", displayed : "true", descriptionText:"${stext}") + sendPush(stext) + + state.lastHumidityPush = hour + } + } + if(minHumidity != null) { + if(currentHumidity.toDouble() < minHumidity) + { + def stext = "Humidity level is too low at ${currentPod} : ${currentHumidity}" + sendEvent(name: "lastHumidityPush", value: "${stext}", displayed : "true", descriptionText:"${stext}") + sendPush(stext) + + state.lastHumidityPush = hour + } + } + } +} + +public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } +public smartThingsDateFormatNoMilli() { "yyyy-MM-dd'T'HH:mm:ssZ" } + +def canPushNotification(currentPod, hour,sType) { + // Check if the client already received a push + if (sType == "temperature") { + if (sfrequency.toString().isInteger()) { + if (state.lastTemperaturePush != null) { + long unxNow = hour.time + + def before = new Date().parse(smartThingsDateFormatNoMilli(),state.lastTemperaturePush) + long unxEnd = before.time + + unxNow = unxNow/1000 + unxEnd = unxEnd/1000 + def timeDiff = Math.abs(unxNow-unxEnd) + timeDiff = timeDiff/60 + if (timeDiff <= sfrequency) + { + return false + } + } + } + } + else { + if (sfrequency.toString().isInteger()) { + if (state.lastHumidityPush != null) { + long unxNow = hour.time + + def before = new Date().parse(smartThingsDateFormatNoMilli(),state.lastHumidityPush) + long unxEnd = before.time + + unxNow = unxNow/1000 + unxEnd = unxEnd/1000 + def timeDiff = Math.abs(unxNow-unxEnd) + timeDiff = timeDiff/60 + + if (timeDiff <= sfrequency) + { + return false + } + } + } + } + + return true +} + +def inDateThreshold(evt,sType) { + def hour = new Date() + def curHour = hour.format("HH:mm",location.timeZone) + def curDay = hour.format("EEEE",location.timeZone) + def currentPod = evt.device.displayName + + // Check if the client already received a push + + def result = canPushNotification(currentPod,hour, sType) + if (!result) { + return false + } + + // Check the day of the week + if (days != null && !days.contains(curDay)) { + return false + } + + // Check the time Threshold + if (startTime && endTime) { + def minHour = new Date().parse(smartThingsDateFormat(), startTime) + def endHour = new Date().parse(smartThingsDateFormat(), endTime) + + def minHourstr = minHour.format("HH:mm",location.timeZone) + def maxHourstr = endHour.format("HH:mm",location.timeZone) + + if (curHour >= minHourstr && curHour < maxHourstr) + { + return true + } + else + { + return false + } + } + return true +} + +def refresh() { + log.debug "refresh() called" + + unschedule() + + refreshDevices() + runEvery15Minutes("refreshDevices") +} + + +def refreshOneDevice(dni) { + log.debug "refreshOneDevice() called" + def d = getChildDevice(dni) + d.refresh() +} + +def refreshDevices() { + log.debug "refreshDevices() called" + def devices = getAllChildDevices() + devices.each { d -> + log.debug "Calling refresh() on device: ${d.id}" + + d.refresh() + } +} + +def getChildNamespace() { "EricG66" } +def getChildTypeName() { "SensiboPod" } + +def initialize() { + log.debug "key "+ getapikey() + + atomicState.apikey = getapikey() + + log.debug "initialize" + + def devices = SelectedSensiboPods.collect { dni -> + log.debug dni + def d = getChildDevice(dni) + + if(!d) + { + + def name = getSensiboPodList().find( {key,value -> key == dni }) + log.debug "Pod : ${name.value} - Hub : ${location.hubs[0].name} - Type : " + getChildTypeName() + " - Namespace : " + getChildNamespace() + + d = addChildDevice(getChildNamespace(), getChildTypeName(), dni, location.hubs[0].id, [ + "label" : "Pod ${name.value}", + "name" : "Pod ${name.value}" + ]) + d.setIcon("on","on","https://image.ibb.co/bz9K25/on_color_large_on.png") + d.setIcon("off","on","https://image.ibb.co/k5S6h5/on_color_large.png") + d.save() + + log.debug "created ${d.displayName} with id $dni" + } + else + { + log.debug "found ${d.displayName} with id $dni already exists" + } + + return d + } + + log.debug "created ${devices.size()} Sensibo Pod" + + def delete + // Delete any that are no longer in settings + if(!SelectedSensiboPods) + { + log.debug "delete Sensibo" + delete = getAllChildDevices() + } + else + { + delete = getChildDevices().findAll { !SelectedSensiboPods.contains(it.deviceNetworkId) } + } + + log.debug "deleting ${delete.size()} Sensibo" + delete.each { deleteChildDevice(it.deviceNetworkId) } + + def PodList = getAllChildDevices() + + pollHandler() + + refreshDevices() + + runEvery15Minutes("refreshDevices") +} + + +// Subscribe functions + +def OnOffHandler(evt) { + log.debug "on activated " + debugEvent(evt.value) + + //def name = evt.device.displayName + + if (sendPush) { + if (evt.value == "on") { + //sendPush("The ${name} is turned on!") + } else if (evt.value == "off") { + //sendPush("The ${name} is turned off!") + } + } +} + +def getPollRateMillis() { return 45 * 1000 } + +// Poll Child is invoked from the Child Device itself as part of the Poll Capability +def pollChild( child ) +{ + log.debug "poll child" + debugEvent ("poll child") + def now = new Date().time + + debugEvent ("Last Poll Millis = ${atomicState.lastPollMillis}") + def last = atomicState.lastPollMillis ?: 0 + def next = last + pollRateMillis + + log.debug "pollChild( ${child.device.deviceNetworkId} ): $now > $next ?? w/ current state: ${atomicState.sensibo}" + debugEvent ("pollChild( ${child.device.deviceNetworkId} ): $now > $next ?? w/ current state: ${atomicState.sensibo}") + + //if( now > next ) + if( true ) // for now let's always poll/refresh + { + log.debug "polling children because $now > $next" + debugEvent("polling children because $now > $next") + + pollChildren(child.device.deviceNetworkId) + + log.debug "polled children and looking for ${child.device.deviceNetworkId} from ${atomicState.sensibo}" + debugEvent ("polled children and looking for ${child.device.deviceNetworkId} from ${atomicState.sensibo}") + + def currentTime = new Date().time + debugEvent ("Current Time = ${currentTime}") + atomicState.lastPollMillis = currentTime + + def tData = atomicState.sensibo[child.device.deviceNetworkId] + + if (tData == null) return + + log.debug "DEBUG - TDATA" + tData + debugEvent ("Error in Poll ${tData.data.Error}",false) + //tData.Error = false + //tData.data.Error = "Failed" + if(tData.data.Error != "Success") + { + log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling" + + // TODO: flag device as in error state + // child.errorState = true + + return null + } + + tData.updated = currentTime + + return tData.data + } + else if(atomicState.sensibo[child.device.deviceNetworkId] != null) + { + log.debug "not polling children, found child ${child.device.deviceNetworkId} " + + def tData = atomicState.sensibo[child.device.deviceNetworkId] + if(!tData.updated) + { + // we have pulled new data for this thermostat, but it has not asked us for it + // track it and return the data + tData.updated = new Date().time + return tData.data + } + return null + } + else if(atomicState.sensibo[child.device.deviceNetworkId] == null) + { + log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId}" + + // TODO: flag device as in error state + // child.errorState = true + + return null + } + else + { + // it's not time to poll again and this thermostat already has its latest values + } + + return null +} + +def setACStates(child,String PodUid, on, mode, targetTemperature, fanLevel, swingM, sUnit) +{ + log.debug "SetACStates for $PodUid ON: $on - MODE: $mode - Temp : $targetTemperature - FAN : $fanLevel - SWING MODE : $swingM - UNIT : $sUnit" + + //Return false if no values was read from Sensibo API + if (on == "--") { return false } + + def OnOff = (on == "on") ? true : false + //if (swingM == null) swingM = "stopped" + + log.debug "Target Temperature :" + targetTemperature + + def jsonRequestBody = '{"acState":{"on": ' + OnOff.toString() + ',"mode": "' + mode + '"' + + log.debug "Fan Level is :$fanLevel" + log.debug "Swing is :$swingM" + log.debug "Target Temperature is :$targetTemperature" + + if (fanLevel != "null") { + log.debug "Fan Level info is present" + jsonRequestBody += ',"fanLevel": "' + fanLevel + '"' + } + + if (targetTemperature != 0) { + jsonRequestBody += ',"targetTemperature": '+ targetTemperature + ',"temperatureUnit": "' + sUnit + '"' + } + if (swingM) + { + jsonRequestBody += ',"swing": "' + swingM + '"' + } + + jsonRequestBody += '}}' + + log.debug "Mode Request Body = ${jsonRequestBody}" + debugEvent ("Mode Request Body = ${jsonRequestBody}") + + def result = sendJson(PodUid, jsonRequestBody) + + if (result) { + def tData = atomicState.sensibo[child.device.deviceNetworkId] + + if (tData == null) return false + + tData.data.fanLevel = fanLevel + tData.data.thermostatFanMode = fanLevel + tData.data.on = on + tData.data.mode = mode + log.debug "Thermostat mode " + on + if (on=="off") { + tData.data.thermostatMode = "off" + } + else { + tData.data.thermostatMode = mode + } + tData.data.targetTemperature = targetTemperature + tData.data.coolingSetpoint = targetTemperature + tData.data.heatingSetpoint = targetTemperature + tData.data.thermostatSetpoint = targetTemperature + tData.data.temperatureUnit = sUnit + tData.data.swing = swingM + tData.data.Error = "Success" + } + else { + def tData = atomicState.sensibo[child.device.deviceNetworkId] + if (tData == null) return false + + tData.data.Error = "Failed" + } + + return(result) +} + +//Get the capabilities of the A/C Unit +def getCapabilities(PodUid, mode) +{ + def data = [:] + def pollParams = [ + uri: "${getServerUrl()}", + path: "/api/v2/pods/${PodUid}", + requestContentType: "application/json", + query: [apiKey:"${getapikey()}", type:"json", fields:"remoteCapabilities,productModel"]] + try { + + httpGet(pollParams) { resp -> + + if (resp.data) { + log.debug "Status : " + resp.status + if(resp.status == 200) { + //resp.data = [result: [remoteCapabilities: [modes: [heat: [swing: ["stopped", "fixedTop", "fixedMiddleTop", "fixedMiddle", "fixedMiddleBottom", "fixedBottom", "rangeTop", "rangeMiddle", "rangeBottom", "rangeFull"], temperatures: [C: ["isNative": true, "values": [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], F: ["isNative": false, "values": [61, 63, 64, 66, 68, 70, 72, 73, 75, 77, 79, 81, 82, 84, 86]]], fanLevels: ["low", "medium", "high", "auto"]], fan: [swing: ["stopped", "fixedMiddleTop", "fixedMiddle", "fixedMiddleBottom", "fixedBottom", "rangeTop", "rangeMiddle", "rangeBottom", "rangeFull"], temperatures: [C: ["isNative": true, "values": [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], F: ["isNative": false, "values": [61, 63, 64, 66, 68, 70, 72, 73, 75, 77, 79, 81, 82, 84, 86]]], fanLevels: ["low", "medium", "high", "auto"]], cool: [swing: ["stopped", "fixedTop", "fixedMiddleTop", "fixedMiddle", "fixedMiddleBottom", "fixedBottom", "rangeTop", "rangeMiddle", "rangeBottom", "rangeFull"], temperatures: ["C": ["isNative": true, "values": [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], F: ["isNative": false, "values": [61, 63, 64, 66, 68, 70, 72, 73, 75, 77, 79, 81, 82, 84, 86]]], fanLevels: ["low", "high", "auto"]]]]]] + //resp.data = ["result": ["productModel": "skyv2", "remoteCapabilities": ["modes": ["dry": ["temperatures": ["C": ["isNative": false, "values": [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], "F": ["isNative": true, "values": [62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86]]], "swing": ["stopped", "rangeFull"]], "auto": ["temperatures": ["C": ["isNative": false, "values": [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], "F": ["isNative": true, "values": [62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86]]], "swing": ["stopped", "rangeFull"]], "heat": ["swing": ["stopped", "rangeFull"], "temperatures": ["C": ["isNative": false, "values": [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], "F": ["isNative": true, "values": [62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86]]], "fanLevels": ["low", "medium", "high", "auto"]], "fan": ["swing": ["stopped", "rangeFull"], "temperatures": [], "fanLevels": ["low", "medium", "high", "auto"]], "cool": ["swing": ["stopped", "rangeFull"], "temperatures": ["C": ["isNative": false, "values": [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]], "F": ["isNative": true, "values": [62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86]]], "fanLevels": ["low", "medium", "high", "auto"]]]]]] + switch (mode){ + case "dry": + data = [ + remoteCapabilities : resp.data.result.remoteCapabilities.modes.dry, + productModel : resp.data.result.productModel + ] + break + case "cool": + data = [ + remoteCapabilities : resp.data.result.remoteCapabilities.modes.cool, + productModel : resp.data.result.productModel + ] + break + case "heat": + data = [ + remoteCapabilities : resp.data.result.remoteCapabilities.modes.heat, + productModel : resp.data.result.productModel + ] + break + case "fan": + data = [ + remoteCapabilities : resp.data.result.remoteCapabilities.modes.fan, + productModel : resp.data.result.productModel + ] + break + case "auto": + data = [ + remoteCapabilities : resp.data.result.remoteCapabilities.modes.auto, + productModel : resp.data.result.productModel + ] + break + case "modes": + data = [ + remoteCapabilities : resp.data.result.remoteCapabilities.modes, + productModel : resp.data.result.productModel + ] + break + } + return data + } + else { + log.debug "get remoteCapabilities Failed" + + data = [ + remoteCapabilities : "", + productModel : "" + ] + return data + } + } + } + return data + } + catch(Exception e) { + log.debug "get remoteCapabilities Failed" + + data = [ + remoteCapabilities : "", + productModel : "" + ] + return data + } +} + +// Get the latest state from the Sensibo Pod +def getACState(PodUid) +{ + def data = [:] + def pollParams = [ + uri: "${getServerUrl()}", + path: "/api/v2/pods/${PodUid}/acStates", + requestContentType: "application/json", + query: [apiKey:"${getapikey()}", type:"json", limit:1, fields:"status,acState,device"]] + + try { + httpGet(pollParams) { resp -> + + if (resp.data) { + debugEvent ("Response from Sensibo GET = ${resp.data}") + debugEvent ("Response Status = ${resp.status}") + } + + if(resp.status == 200) { + resp.data.result.any { stat -> + + if (stat.status == "Success") { + + log.debug "get ACState Success" + log.debug "PodUID : $PodUid : " + stat.acState + + def OnOff = stat.acState.on ? "on" : "off" + stat.acState.on = OnOff + + def stemp + if (stat.acState.targetTemperature == null) { + stemp = stat.device.measurements.temperature.toInteger() + } + else { + stemp = stat.acState.targetTemperature.toInteger() + } + + def tempUnit + if (stat.acState.temperatureUnit == null) { + tempUnit = stat.device.temperatureUnit + } + else { + tempUnit = stat.acState.temperatureUnit + } + + def tMode + if (OnOff=="off") { + tMode = "off" + } + else { + tMode = stat.acState.mode + } + + log.debug "product Model : " + stat.device.productModel + def battery = stat.device.productModel == "skyv1" ? "battery" : "mains" + + log.debug "swing Mode :" + stat.acState.swing + data = [ + targetTemperature : stemp, + fanLevel : stat.acState.fanLevel, + mode : stat.acState.mode, + on : OnOff.toString(), + switch: OnOff.toString(), + thermostatMode: tMode, + thermostatFanMode : stat.acState.fanLevel, + coolingSetpoint : stemp, + heatingSetpoint : stemp, + thermostatSetpoint : stemp, + temperatureUnit : tempUnit, + swing : stat.acState.swing, + powerSource : battery, + productModel : stat.device.productModel, + firmwareVersion : stat.device.firmwareVersion, + Error : "Success" + ] + + log.debug "On: ${data.on} targetTemp: ${data.targetTemperature} fanLevel: ${data.fanLevel} Thermostat mode: ${data.mode} swing: ${data.swing}" + return data + } + else { log.debug "get ACState Failed"} + } + } + else { + data = [ + targetTemperature : "0", + fanLevel : "--", + mode : "--", + on : "--", + switch : "--", + thermostatMode: "--", + thermostatFanMode : "--", + coolingSetpoint : "0", + heatingSetpoint : "0", + thermostatSetpoint : "0", + temperatureUnit : "", + swing : "--", + powerSource : "", + productModel : "", + firmwareVersion : "", + Error : "Failed" + ] + log.debug "get ACState Failed" + return data + } + } + return data + } + catch(Exception e) + { + log.debug "Exception Get Json: " + e + debugEvent ("Exception get JSON: " + e) + + data = [ + targetTemperature : "0", + fanLevel : "--", + mode : "--", + on : "--", + switch : "--", + thermostatMode: "--", + thermostatFanMode : "--", + coolingSetpoint : "0", + heatingSetpoint : "0", + thermostatSetpoint : "0", + temperatureUnit : "", + swing : "--", + powerSource : "", + productModel : "", + firmwareVersion : "", + Error : "Failed" + ] + log.debug "get ACState Failed" + return data + } +} + +// Send state to the Sensibo Pod +def sendJson(String PodUid, String jsonBody) +{ + log.debug "Request sent to Sensibo API(acStates) for PODUid : $PodUid - $jsonBody" + def cmdParams = [ + uri: "${getServerUrl()}", + path: "/api/v2/pods/${PodUid}/acStates", + headers: ["Content-Type": "application/json"], + query: [apiKey:"${getapikey()}", type:"json", fields:"acState"], + body: jsonBody] + + def returnStatus = -1 + try{ + httpPost(cmdParams) { resp -> + if(resp.status == 200) { + log.debug "updated ${resp.data}" + debugEvent("updated ${resp.data}") + returnStatus = resp.status + log.debug "Successful call to Sensibo API." + } + else { log.debug "Failed call to Sensibo API." } + } + } + catch(Exception e) + { + log.debug "Exception Sending Json: " + e + debugEvent ("Exception Sending JSON: " + e) + return false + } + + if (returnStatus == 200) + return true + else + return false +} + +def pollChildren(PodUid) +{ + log.debug "polling children" + + def thermostatIdsString = PodUid + + log.debug "polling children: $thermostatIdsString" + + def pollParams = [ + uri: "${getServerUrl()}", + path: "/api/v2/pods/${thermostatIdsString}/measurements", + requestContentType: "application/json", + query: [apiKey:"${getapikey()}", type:"json", fields:"batteryVoltage,temperature,humidity,time"]] + + debugEvent ("Before HTTPGET to Sensibo.") + + try{ + httpGet(pollParams) { resp -> + + if (resp.data) { + debugEvent ("Response from Sensibo GET = ${resp.data}") + debugEvent ("Response Status = ${resp.status}") + } + + if(resp.status == 200) { + log.debug "poll results returned" + + log.debug "DEBUG DATA RESULT" + resp.data.result + + if (resp.data.result == null || resp.data.result.empty) + { + log.debug "Cannot get measurement from the API, should ask Sensibo Support Team" + debugEvent ("Cannot get measurement from the API, should ask Sensibo Support Team",true) + } + + def setTemp = getACState(thermostatIdsString) + if (setTemp.Error != "Failed") { + + atomicState.sensibo = resp.data.result.inject([:]) { collector, stat -> + + def dni = thermostatIdsString + + log.debug "updating dni $dni" + + def stemp = stat.temperature.toDouble().round(1) + def shumidify = stat.humidity.toDouble().round() + + if (setTemp.temperatureUnit == "F") { + stemp = cToF(stemp).round(1) + } + + def tMode + if (setTemp.on=="off") { + tMode = "off" + } + else { + tMode = setTemp.mode + } + + def battpourcentage = 100 + def battVoltage = stat.batteryVoltage + + if (battVoltage == null) + { + battVoltage = 3000 + } + + if (battVoltage < 2850) battpourcentage = 10 + if (battVoltage > 2850 && battVoltage < 2950) battpourcentage = 50 + + def data = [ + temperature: stemp, + humidity: shumidify, + targetTemperature: setTemp.targetTemperature, + fanLevel: setTemp.fanLevel, + mode: setTemp.mode, + on: setTemp.on, + switch : setTemp.on, + thermostatMode: tMode, + thermostatFanMode: setTemp.fanLevel, + coolingSetpoint: setTemp.targetTemperature, + heatingSetpoint: setTemp.targetTemperature, + thermostatSetpoint: setTemp.targetTemperature, + temperatureUnit : setTemp.temperatureUnit, + voltage : battVoltage, + swing : setTemp.swing, + battery : battpourcentage, + powerSource : setTemp.powerSource, + productModel : setTemp.productModel, + firmwareVersion : setTemp.firmwareVersion, + Error: setTemp.Error + ] + + debugEvent ("Event Data = ${data}",false) + + collector[dni] = [data:data] + + return collector + } + } + + log.debug "updated ${atomicState.sensibo[thermostatIdsString].size()} stats: ${atomicState.sensibo[thermostatIdsString]}" + debugEvent ("updated ${atomicState.sensibo[thermostatIdsString]}",false) + } + else + { + log.error "polling children & got http status ${resp.status}" + } + } + + } + catch(Exception e) + { + log.debug "___exception polling children: " + e + debugEvent ("${e}") + } +} + +def pollHandler() { + + debugEvent ("in Poll() method.") + + // Hit the Sensibo API for update on all the Pod + + def PodList = getAllChildDevices() + + log.debug PodList + PodList.each { + log.debug "polling " + it.deviceNetworkId + pollChildren(it.deviceNetworkId) } + + atomicState.sensibo.each {stat -> + + def dni = stat.key + + log.debug ("DNI = ${dni}") + debugEvent ("DNI = ${dni}") + + def d = getChildDevice(dni) + + if(d) + { + log.debug ("Found Child Device.") + debugEvent ("Found Child Device.") + debugEvent("Event Data before generate event call = ${stat}") + log.debug atomicState.sensibo[dni] + d.generateEvent(atomicState.sensibo[dni].data) + } + } +} + +def debugEvent(message, displayEvent = false) { + + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent (results) + +} + +def cToF(temp) { + return (temp * 1.8 + 32).toDouble() +} + +def fToC(temp) { + return ((temp - 32) / 1.8).toDouble() +} \ No newline at end of file diff --git a/smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy b/smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy new file mode 100644 index 00000000000..5aecdbb027d --- /dev/null +++ b/smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy @@ -0,0 +1,415 @@ +/** + * Copyright 2016 Eric Maycock + * + * 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. + * + * Sonoff (Connect) + * + * Author: Eric Maycock (erocm123) + * Date: 2016-06-02 + */ + +definition( + name: "Sonoff (Connect)", + namespace: "erocm123", + author: "Eric Maycock (erocm123)", + description: "Service Manager for Sonoff switches", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/erocm123/SmartThingsPublic/master/smartapps/erocm123/sonoff-connect.src/sonoff-connect-icon.png", + iconX2Url: "https://raw.githubusercontent.com/erocm123/SmartThingsPublic/master/smartapps/erocm123/sonoff-connect.src/sonoff-connect-icon-2x.png", + iconX3Url: "https://raw.githubusercontent.com/erocm123/SmartThingsPublic/master/smartapps/erocm123/sonoff-connect.src/sonoff-connect-icon-3x.png" +) + +preferences { + page(name: "mainPage") + page(name: "configurePDevice") + page(name: "deletePDevice") + page(name: "changeName") + page(name: "discoveryPage", title: "Device Discovery", content: "discoveryPage", refreshTimeout:5) + page(name: "addDevices", title: "Add Sonoff Switches", content: "addDevices") + page(name: "manuallyAdd") + page(name: "manuallyAddConfirm") + page(name: "deviceDiscovery") +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "Manage your Sonoff switches", nextPage: null, uninstall: true, install: true) { + section("Configure"){ + href "deviceDiscovery", title:"Discover Devices", description:"" + href "manuallyAdd", title:"Manually Add Device", description:"" + } + section("Installed Devices"){ + getChildDevices().sort({ a, b -> a["deviceNetworkId"] <=> b["deviceNetworkId"] }).each { + href "configurePDevice", title:"$it.label", description:"", params: [did: it.deviceNetworkId] + } + } + } +} + +def configurePDevice(params){ + if (params?.did || params?.params?.did) { + if (params.did) { + state.currentDeviceId = params.did + state.currentDisplayName = getChildDevice(params.did)?.displayName + } else { + state.currentDeviceId = params.params.did + state.currentDisplayName = getChildDevice(params.params.did)?.displayName + } + } + if (getChildDevice(state.currentDeviceId) != null) getChildDevice(state.currentDeviceId).configure() + dynamicPage(name: "configurePDevice", title: "Configure Sonoff Switches created with this app", nextPage: null) { + section { + app.updateSetting("${state.currentDeviceId}_label", getChildDevice(state.currentDeviceId).label) + input "${state.currentDeviceId}_label", "text", title:"Device Name", description: "", required: false + href "changeName", title:"Change Device Name", description: "Edit the name above and click here to change it" + } + section { + href "deletePDevice", title:"Delete $state.currentDisplayName", description: "" + } + } +} + +def manuallyAdd(){ + dynamicPage(name: "manuallyAdd", title: "Manually add a Sonoff device", nextPage: "manuallyAddConfirm") { + section { + paragraph "This process will manually create a Sonoff device based on the entered IP address. The SmartApp needs to then communicate with the device to obtain additional information from it. Make sure the device is on and connected to your wifi network." + input "deviceType", "enum", title:"Device Type", description: "", required: false, options: ["Sonoff Wifi Switch","Sonoff TH Wifi Switch","Sonoff POW Wifi Switch","Sonoff Dual Wifi Switch","Sonoff 4CH Wifi Switch"] + input "ipAddress", "text", title:"IP Address", description: "", required: false + } + } +} + +def manuallyAddConfirm(){ + if ( ipAddress =~ /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/) { + log.debug "Creating Sonoff Wifi Switch with dni: ${convertIPtoHex(ipAddress)}:${convertPortToHex("80")}" + addChildDevice("erocm123", deviceType ? deviceType : "Sonoff Wifi Switch", "${convertIPtoHex(ipAddress)}:${convertPortToHex("80")}", location.hubs[0].id, [ + "label": (deviceType ? deviceType : "Sonoff Wifi Switch") + " (${ipAddress})", + "data": [ + "ip": ipAddress, + "port": "80" + ] + ]) + + app.updateSetting("ipAddress", "") + + dynamicPage(name: "manuallyAddConfirm", title: "Manually add a Sonoff device", nextPage: "mainPage") { + section { + paragraph "The device has been added. Press next to return to the main page." + } + } + } else { + dynamicPage(name: "manuallyAddConfirm", title: "Manually add a Sonoff device", nextPage: "mainPage") { + section { + paragraph "The entered ip address is not valid. Please try again." + } + } + } +} + +def deletePDevice(){ + try { + unsubscribe() + deleteChildDevice(state.currentDeviceId) + dynamicPage(name: "deletePDevice", title: "Deletion Summary", nextPage: "mainPage") { + section { + paragraph "The device has been deleted. Press next to continue" + } + } + + } catch (e) { + dynamicPage(name: "deletePDevice", title: "Deletion Summary", nextPage: "mainPage") { + section { + paragraph "Error: ${(e as String).split(":")[1]}." + } + } + + } +} + +def changeName(){ + def thisDevice = getChildDevice(state.currentDeviceId) + thisDevice.label = settings["${state.currentDeviceId}_label"] + + dynamicPage(name: "changeName", title: "Change Name Summary", nextPage: "mainPage") { + section { + paragraph "The device has been renamed. Press \"Next\" to continue" + } + } +} + +def discoveryPage(){ + return deviceDiscovery() +} + +def deviceDiscovery(params=[:]) +{ + def devices = devicesDiscovered() + + int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int + state.deviceRefreshCount = deviceRefreshCount + 1 + def refreshInterval = 3 + + def options = devices ?: [] + def numFound = options.size() ?: 0 + + if ((numFound == 0 && state.deviceRefreshCount > 25) || params.reset == "true") { + log.trace "Cleaning old device memory" + state.devices = [:] + state.deviceRefreshCount = 0 + app.updateSetting("selectedDevice", "") + } + + ssdpSubscribe() + + //sonoff discovery request every 15 //25 seconds + if((deviceRefreshCount % 5) == 0) { + discoverDevices() + } + + //setup.xml request every 3 seconds except on discoveries + if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 5) != 0)) { + verifyDevices() + } + + return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"addDevices", refreshInterval:refreshInterval, uninstall: true) { + section("Please wait while we discover your Sonoff devices. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedDevices", "enum", required:false, title:"Select Sonoff Switch (${numFound} found)", multiple:true, options:options + } + section("Options") { + href "deviceDiscovery", title:"Reset list of discovered devices", description:"", params: ["reset": "true"] + } + } +} + +Map devicesDiscovered() { + def vdevices = getVerifiedDevices() + def map = [:] + vdevices.each { + def value = "${it.value.name}" + def key = "${it.value.mac}" + map["${key}"] = value + } + map +} + +def getVerifiedDevices() { + getDevices().findAll{ it?.value?.verified == true } +} + +private discoverDevices() { + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:Basic:1", physicalgraph.device.Protocol.LAN)) +} + +def configured() { + +} + +def buttonConfigured(idx) { + return settings["lights_$idx"] +} + +def isConfigured(){ + if(getChildDevices().size() > 0) return true else return false +} + +def isVirtualConfigured(did){ + def foundDevice = false + getChildDevices().each { + if(it.deviceNetworkId != null){ + if(it.deviceNetworkId.startsWith("${did}/")) foundDevice = true + } + } + return foundDevice +} + +private virtualCreated(number) { + if (getChildDevice(getDeviceID(number))) { + return true + } else { + return false + } +} + +private getDeviceID(number) { + return "${state.currentDeviceId}/${app.id}/${number}" +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + ssdpSubscribe() + runEvery5Minutes("ssdpDiscover") +} + +void ssdpSubscribe() { + subscribe(location, "ssdpTerm.urn:schemas-upnp-org:device:Basic:1", ssdpHandler) +} + +void ssdpDiscover() { + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:Basic:1", physicalgraph.device.Protocol.LAN)) +} + +def ssdpHandler(evt) { + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseLanMessage(description) + parsedEvent << ["hub":hub] + + def devices = getDevices() + + String ssdpUSN = parsedEvent.ssdpUSN.toString() + + if (devices."${ssdpUSN}") { + def d = devices."${ssdpUSN}" + def child = getChildDevice(parsedEvent.mac) + def childIP + def childPort + if (child) { + childIP = child.getDeviceDataByName("ip") + childPort = child.getDeviceDataByName("port").toString() + log.debug "Device data: ($childIP:$childPort) - reporting data: (${convertHexToIP(parsedEvent.networkAddress)}:${convertHexToInt(parsedEvent.deviceAddress)})." + if("${convertHexToIP(parsedEvent.networkAddress)}" != "0.0.0.0"){ + if(childIP != convertHexToIP(parsedEvent.networkAddress) || childPort != convertHexToInt(parsedEvent.deviceAddress).toString()){ + log.debug "Device data (${child.getDeviceDataByName("ip")}) does not match what it is reporting(${convertHexToIP(parsedEvent.networkAddress)}). Attempting to update." + child.sync(convertHexToIP(parsedEvent.networkAddress), convertHexToInt(parsedEvent.deviceAddress).toString()) + } + } else { + log.debug "Device is reporting ip address of ${convertHexToIP(parsedEvent.networkAddress)}. Not updating." + } + } + + if (d.networkAddress != parsedEvent.networkAddress || d.deviceAddress != parsedEvent.deviceAddress) { + d.networkAddress = parsedEvent.networkAddress + d.deviceAddress = parsedEvent.deviceAddress + } + } else { + devices << ["${ssdpUSN}": parsedEvent] + } +} + +void verifyDevices() { + def devices = getDevices().findAll { it?.value?.verified != true } + devices.each { + def ip = convertHexToIP(it.value.networkAddress) + def port = convertHexToInt(it.value.deviceAddress) + String host = "${ip}:${port}" + sendHubCommand(new physicalgraph.device.HubAction("""GET ${it.value.ssdpPath} HTTP/1.1\r\nHOST: $host\r\n\r\n""", physicalgraph.device.Protocol.LAN, host, [callback: deviceDescriptionHandler])) + } +} + +def getDevices() { + state.devices = state.devices ?: [:] +} + +void deviceDescriptionHandler(physicalgraph.device.HubResponse hubResponse) { + log.trace "description.xml response (application/xml)" + def body = hubResponse.xml + log.debug body?.device?.friendlyName?.text() + if (body?.device?.modelName?.text().startsWith("Sonoff")) { + def devices = getDevices() + def device = devices.find {it?.key?.contains(body?.device?.UDN?.text())} + if (device) { + device.value << [name:body?.device?.friendlyName?.text() + " (" + convertHexToIP(hubResponse.ip) + ")", serialNumber:body?.device?.serialNumber?.text(), verified: true] + } else { + log.error "/description.xml returned a device that didn't exist" + } + } +} + +def addDevices() { + def devices = getDevices() + def sectionText = "" + + selectedDevices.each { dni ->bridgeLinking + def selectedDevice = devices.find { it.value.mac == dni } + def d + if (selectedDevice) { + d = getChildDevices()?.find { + it.deviceNetworkId == selectedDevice.value.mac + } + } + + if (!d) { + log.debug selectedDevice + log.debug "Creating Sonoff Switch with dni: ${selectedDevice.value.mac}" + + def deviceHandlerName + if (selectedDevice?.value?.name?.startsWith("Sonoff TH")) + deviceHandlerName = "Sonoff TH Wifi Switch" + else if (selectedDevice?.value?.name?.startsWith("Sonoff POW")) + deviceHandlerName = "Sonoff POW Wifi Switch" + else if (selectedDevice?.value?.name?.startsWith("Sonoff Dual")) + deviceHandlerName = "Sonoff Dual Wifi Switch" + else if (selectedDevice?.value?.name?.startsWith("Sonoff 4CH")) + deviceHandlerName = "Sonoff 4CH Wifi Switch" + else if (selectedDevice?.value?.name?.startsWith("Sonoff IFan02")) + deviceHandlerName = "Sonoff IFan02 Wifi Controller" + else + deviceHandlerName = "Sonoff Wifi Switch" + def newDevice = addChildDevice("erocm123", deviceHandlerName, selectedDevice.value.mac, selectedDevice?.value.hub, [ + "label": selectedDevice?.value?.name ?: "Sonoff Wifi Switch", + "data": [ + "mac": selectedDevice.value.mac, + "ip": convertHexToIP(selectedDevice.value.networkAddress), + "port": "" + Integer.parseInt(selectedDevice.value.deviceAddress,16) + ] + ]) + sectionText = sectionText + "Succesfully added Sonoff device with ip address ${convertHexToIP(selectedDevice.value.networkAddress)} \r\n" + } + + } + log.debug sectionText + return dynamicPage(name:"addDevices", title:"Devices Added", nextPage:"mainPage", uninstall: true) { + if(sectionText != ""){ + section("Add Sonoff Results:") { + paragraph sectionText + } + }else{ + section("No devices added") { + paragraph "All selected devices have previously been added" + } + } +} + } + +def uninstalled() { + unsubscribe() + getChildDevices().each { + deleteChildDevice(it.deviceNetworkId) + } +} + + + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String convertIPtoHex(ipAddress) { + String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join() + return hex +} + +private String convertPortToHex(port) { + String hexport = port.toString().format( '%04x', port.toInteger() ) + return hexport +} \ No newline at end of file diff --git a/smartapps/fuzzysb/garadget-connect.src/garadget-connect.groovy b/smartapps/fuzzysb/garadget-connect.src/garadget-connect.groovy new file mode 100644 index 00000000000..f6d47d31a77 --- /dev/null +++ b/smartapps/fuzzysb/garadget-connect.src/garadget-connect.groovy @@ -0,0 +1,412 @@ +/** + * Garadget Connect + * + * Copyright 2016 Stuart Buchanan + * + * 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. + * + * 12/12/2017 V1.5 fixed bug introduced in v1.4 on initialize function. + * 12/12/2017 V1.4 debug logging changes. - btrenbeath + * 21/04/2017 V1.3 added url encoding to username and password for when special characters are used, with thanks to pastygangster + * 20/03/2017 V1.2 updated to refresh the garadget devices every 1 minute which is the minimum schedule allowed in ST + * 13/02/2016 V1.1 added the correct call for API url for EU/US servers, left to do: cleanup child devices when removed from setup + * 12/02/2016 V1.0 initial release, left to do: cleanup child devices when removed from setup + */ + import java.net.URLEncoder + import java.text.DecimalFormat + import groovy.json.JsonSlurper + import groovy.json.JsonOutput + +private apiUrl() { "https://api.particle.io" } +private getVendorName() { "Garadget" } +private getVendorTokenPath(){ "https://api.particle.io/oauth/token" } +private getVendorIcon() { "https://dl.dropboxusercontent.com/s/lkrub180btbltm8/garadget_128.png" } +private getClientId() { appSettings.clientId } +private getClientSecret() { appSettings.clientSecret } +private getServerUrl() { if(!appSettings.serverUrl){return getApiServerUrl()} } + + + // Automatically generated. Make future change here. +definition( + name: "Garadget (Connect)", + namespace: "fuzzysb", + author: "Stuart Buchanan", + description: "Garadget Integration", + category: "SmartThings Labs", + iconUrl: "https://dl.dropboxusercontent.com/s/lkrub180btbltm8/garadget_128.png", + iconX2Url: "https://dl.dropboxusercontent.com/s/w8tvaedewwq56kr/garadget_256.png", + iconX3Url: "https://dl.dropboxusercontent.com/s/5hiec37e0y5py06/garadget_512.png", + oauth: true, + singleInstance: true +) { + appSetting "serverUrl" +} + +preferences { + page(name: "startPage", title: "Garadget Integration", content: "startPage", install: false) + page(name: "Credentials", title: "Fetch OAuth2 Credentials", content: "authPage", install: false) + page(name: "mainPage", title: "Garadget Integration", content: "mainPage") + page(name: "completePage", title: "${getVendorName()} is now connected to SmartThings!", content: "completePage") + page(name: "listDevices", title: "Garadget Devices", content: "listDevices", install: false) + page(name: "badCredentials", title: "Invalid Credentials", content: "badAuthPage", install: false) +} + +mappings { + path("/receivedToken"){action: [POST: "receivedToken", GET: "receivedToken"]} +} + +def startPage() { + if (state.garadgetAccessToken) { return mainPage() } + else { return authPage() } +} + +def mainPage(){ + + def result = [success:false] + + + if (!state.garadgetAccessToken) { + createAccessToken() + log.debug "About to create Smarthings Garadget access token." + getToken(garadgetUsername, garadgetPassword) + } + if (state.garadgetAccessToken){ + result.success = true + } + + + if(result.success == true) { + return completePage() + } else { + return badAuthPage() + } +} + + + +def completePage(){ + def description = "Tap 'Next' to proceed" + return dynamicPage(name: "completePage", title: "Credentials Accepted!", nextPage: listDevices , uninstall: true, install:false) { + section { href url: buildRedirectUrl("receivedToken"), style:"embedded", required:false, title:"${getVendorName()} is now connected to SmartThings!", description:description } + } +} + +def badAuthPage(){ + log.debug "In badAuthPage" + log.error "login result false" + return dynamicPage(name: "badCredentials", title: "Garadget", install:false, uninstall:true, nextPage: Credentials) { + section("") { + paragraph "Please check your username and password" + } + } +} + +def authPage() { + log.debug "In authPage" + if(canInstallLabs()) { + def description = null + + + log.debug "Prompting for Auth Details." + + description = "Tap to enter Credentials." + + return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage: mainPage, uninstall: false , install:false) { + section("Generate Username and Password") { + input "garadgetUsername", "text", title: "Your Garadget Username", required: true + input "garadgetPassword", "password", title: "Your Garadget Password", required: true + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + + return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section { + paragraph "$upgradeNeeded" + } + } + + } +} + +def createChildDevice(deviceFile, dni, name, label) { + log.debug "In createChildDevice" + try{ + def childDevice = addChildDevice("fuzzysb", deviceFile, dni, null, [name: name, label: label, completedSetup: true]) + } catch (e) { + log.error "Error creating device: ${e}" + } +} + +def listDevices() { + log.debug "In listDevices" + + def options = getDeviceList() + + dynamicPage(name: "listDevices", title: "Choose devices", install: true) { + section("Devices") { + input "devices", "enum", title: "Select Device(s)", required: false, multiple: true, options: options, submitOnChange: true + } + } +} + +def buildRedirectUrl(endPoint) { + log.debug "In buildRedirectUrl" + log.debug("returning: " + getServerUrl() + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/${endPoint}") + return getServerUrl() + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/${endPoint}" +} + +def receivedToken() { + log.debug "In receivedToken" + + def html = """ + + + + + ${getVendorName()} Connection + + + +
+ Vendor icon + connected device icon + SmartThings logo +

Tap 'Done' to continue to Devices.

+
+ + + """ + render contentType: 'text/html', data: html +} + +def getDeviceList() { + def garadgetDevices = [] + + httpGet( apiUrl() + "/v1/devices?access_token=${state.garadgetAccessToken}"){ resp -> + def restDevices = resp.data + restDevices.each { garadget -> + if (garadget.connected == true) + garadgetDevices << ["${garadget.id}|${garadget.name}":"${garadget.name}"] + } + + } + return garadgetDevices.sort() + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + initialize() +} + +def uninstalled() { + log.debug "Uninstalling Garadget (Connect)" + deleteToken() + removeChildDevices(getChildDevices()) + log.debug "Garadget (Connect) Uninstalled" + +} + +def initialize() { + log.debug "Initialized with settings: ${settings}" + // Pull the latest device info into state + getDeviceList(); + def children = getChildDevices() + if(settings.devices) { + settings.devices.each { device -> + def item = device.tokenize('|') + def deviceId = item[0] + def deviceName = item[1] + def existingDevices = children.find{ d -> d.deviceNetworkId.contains(deviceId) } + if(!existingDevices) { + try { + createChildDevice("Garadget", deviceId + ":" + state.garadgetAccessToken, "${deviceName}", deviceName) + } catch (Exception e) { + log.error "Error creating device: ${e}" + } + } + } + } + + + // Do the initial poll + poll() + // Schedule it to run every 1 minutes + runEvery1Minute("poll") +} + +def getToken(garadgetUsername, garadgetPassword){ + log.debug "Executing 'sendCommand.setState'" + def encodedUsername = URLEncoder.encode(garadgetUsername, "UTF-8") + def encodedPassword = URLEncoder.encode(garadgetPassword, "UTF-8") + def body = ("grant_type=password&username=${encodedUsername}&password=${encodedPassword}&expires_in=0") + sendCommand("createToken","particle","particle", body) +} + +private sendCommand(method, user, pass, command) { + def userpassascii = "${user}:${pass}" + def userpass = "Basic " + userpassascii.encodeAsBase64().toString() + def headers = [:] + headers.put("Authorization", userpass) + def methods = [ + 'createToken': [ + uri: getVendorTokenPath(), + requestContentType: "application/x-www-form-urlencoded", + headers: headers, + body: command + ], + 'deleteToken': [ + uri: apiUrl() + "/v1/access_tokens/${state.garadgetAccessToken}", + requestContentType: "application/x-www-form-urlencoded", + headers: headers, + ] + ] + def request = methods.getAt(method) + log.debug "Http Params ("+request+")" + + try{ + if (method == "createToken"){ + log.debug "Executing createToken 'sendCommand'" + httpPost(request) { resp -> + parseResponse(resp) + } + }else if (method == "deleteToken"){ + log.debug "Executing deleteToken 'sendCommand'" + httpDelete(request) { resp -> + parseResponse(resp) + } + }else{ + log.debug "Executing default HttpGet 'sendCommand'" + httpGet(request) { resp -> + parseResponse(resp) + } + } + } catch(Exception e){ + log.debug("___exception: " + e) + } +} + + +private parseResponse(resp) { + log.debug("Executing parseResponse: "+resp.data) + log.debug("Output status: "+resp.status) + if(resp.status == 200) { + log.debug("Executing parseResponse.successTrue") + state.garadgetAccessToken = resp.data.access_token + log.debug("Access Token: "+ state.garadgetAccessToken) + state.garadgetRefreshToken = resp.data.refresh_token + log.debug("Refresh Token: "+ state.garadgetRefreshToken) + state.garadgetTokenExpires = resp.data.expires_in + log.debug("Token Expires: "+ state.garadgetTokenExpires) + log.debug "Created new Garadget token" + }else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +def poll() { + log.debug "Executing - Service Manager - poll() - " + getDeviceList(); + getAllChildDevices().each { + it.statusCommand() + } +} + +private Boolean canInstallLabs() { + return hasAllHubsOver("000.011.00603") +} + +private List getRealHubFirmwareVersions() { + return location.hubs*.firmwareVersionString.findAll { it } +} + +private Boolean hasAllHubsOver(String desiredFirmware) { + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +void deleteToken() { +try{ + sendCommand("deleteToken","${garadgetUsername}","${garadgetPassword}",[]) + log.debug "Deleted the existing Garadget Access Token" + } catch (e) {log.debug "Couldn't delete Garadget Token, There was an error (${e}), moving on"} +} + +private removeChildDevices(delete) { + try { + delete.each { + deleteChildDevice(it.deviceNetworkId) + log.info "Successfully Removed Child Device: ${it.displayName} (${it.deviceNetworkId})" + } + } + catch (e) { log.error "There was an error (${e}) when trying to delete the child device" } +} \ No newline at end of file diff --git a/smartapps/fuzzysb/sonos-door-knocker.src/sonos-door-knocker.groovy b/smartapps/fuzzysb/sonos-door-knocker.src/sonos-door-knocker.groovy new file mode 100644 index 00000000000..a293d085df7 --- /dev/null +++ b/smartapps/fuzzysb/sonos-door-knocker.src/sonos-door-knocker.groovy @@ -0,0 +1,110 @@ +/** + * Door Knocker + * + * Author: stuart@broadbandtap.co.uk + * Date: 05/12/15 + * + * Let me know when someone knocks on the door via sonos custom message, but ignore + * when someone is opening the door. + */ + +definition( + name: "Sonos Door Knocker", + namespace: "fuzzysb ", + author: "Stuart Buchanan", + description: "Alert if door is knocked, but not opened.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("When Someone Knocks?") { + input name: "knockSensor", type: "capability.accelerationSensor", title: "Where?" + } + + section("But not when they open this door?") { + input name: "openSensor", type: "capability.contactSensor", title: "Where?" + } + + section("Knock Delay (defaults to 5s)?") { + input name: "knockDelay", type: "number", title: "How Long?", required: false + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + + section("Minimum time between messages (optional, defaults to every message)") { + input "frequency", "number", title: "Minutes", required: false + } + + section("Speaker to Play Sound") { + input "sonos", "capability.musicPlayer", title: "Sonos Device", required: true + input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false + } + + section("What message to you want to say?") { + input "textHere", "text", title: "Type in the message" + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + init() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + init() +} + +def init() { + state.lastClosed = 0 + subscribe(knockSensor, "acceleration.active", handleEvent) + subscribe(openSensor, "contact.closed", doorClosed) +} + +def doorClosed(evt) { + state.lastClosed = now() +} + + +def handleEvent(evt) { + def delay = knockDelay ?: 5 + runIn(delay, "doorKnock") +} + +def doorKnock() { + def frequency = frequency ?: 1 + log.debug "entering doorknock method, frequency is $frequency " + if((openSensor.latestValue("contact") == "closed") && (now() - (60 * 1000) > state.lastClosed)) { + log.debug("${knockSensor.label ?: knockSensor.name} detected a knock.") + /* send("${knockSensor.label ?: knockSensor.name} detected a knock.") */ + sonos.setLevel(volume) + sonos.playText(textHere) + log.debug "end of doorknock method" + } + else { + log.debug("${knockSensor.label ?: knockSensor.name} knocked, but looks like it was just someone opening the door.") + } +} + + + +private send(msg) { + if(sendPushMessage != "No") { + log.debug("Sending push message") + sendPush(msg) + } + + if(phone) { + log.debug("Sending text message") + sendSms(phone, msg) + } + + log.debug(msg) +} \ No newline at end of file diff --git a/smartapps/fuzzysb/tado-connect.src/tado-connect.groovy b/smartapps/fuzzysb/tado-connect.src/tado-connect.groovy new file mode 100644 index 00000000000..1efa6d9037a --- /dev/null +++ b/smartapps/fuzzysb/tado-connect.src/tado-connect.groovy @@ -0,0 +1,2135 @@ +/** + * Tado Connect + * + * Copyright 2016 Stuart Buchanan + * + * 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. + * +<<<<<<< HEAD + * 07/02/2018 v2.7 Added some new try catch blocks around parse capability as there were exceptioons after v2.1 occuring for ait conditioners, this now works correctly +======= + * 07/02/2018 v2.7 Added some new try catch blocks around parse capability as there were exceptioons after v2.1 occuring for air conditioners, this now works correctly +>>>>>>> origin/master + * 06/02/2018 v2.6 Fixed Commands for those with Heat Cool that do not support Fan Modes + * 08/06/2017 v2.5 Amended bug where Hot water type was set to WATER, Instead or HOT_WATER, with thanks to @invisiblemountain + * 08/06/2017 v2.4 Added Device name to DNI, trying to avaid issue with multiple devices in a single Zone + * 26/05/2017 v2.3 removed erronous jsonbody statements in the coolCommand Function. + * 26/05/2017 v2.2 Corrected bug with parseCapability function as this was returning the map instead of the value, this would account for lots of strange behaviour. + * 25/05/2017 v2.1 Added support for Air Condiioners which have a mandatory swing field for all Commands, corrected prevois bugs in v2.0, thanks again to @Jnick + * 20/05/2017 v2.0 Added support for Air Condiioners which have a mandatory swing field in the heating & cool Commands, thanks again to @Jnick + * 17/05/2017 v1.9 Corrected issue with the wrong temp unit being used on some thermostat functions when using Farenheit, many thanks again to @Jnick for getting the logs to help diagnose this. + * 04/05/2017 v1.8 Corrected issue with scheduling which was introduced in v1.7 with merge of pull request. Many thanks to @Jnick for getting the logs to help diagnose this. + * 17/04/2017 v1.7 General Bugfixes around Tado user presence with thanks to @sipuncher + * 14/04/2017 v1.6 fixed defects in user presence device polling + * 06/04/2017 v1.5 scheduled refresh of tado user status every minute (Thanks to @sipuncher for pointing out my mistake) + * 03/04/2017 v1.4 Added ability to have your Tado Users created as Smarthings Virtual Presence Sensors for use in routines etc.. + * 03/01/2017 v1.3 Corrected Cooling Commands and Set Points issue with incorrect DNI statement with thanks to Richard Gregg + * 03/12/2016 v1.2 Corrected Values for Heating and Hot Water set Points + * 03/12/2016 v1.1 Updated to Support Multiple Hubs, and fixed bug in device discovery and creation, however all device types need updated also. + * 26/11/2016 V1.0 initial release + */ + +import java.text.DecimalFormat +import groovy.json.JsonSlurper +import groovy.json.JsonOutput + +private apiUrl() { "https://my.tado.com" } +private getVendorName() { "Tado" } +private getVendorIcon() { "https://dl.dropboxusercontent.com/s/fvjrqcy5xjxsr31/tado_128.png" } +private getClientId() { appSettings.clientId } +private getClientSecret() { appSettings.clientSecret } +private getServerUrl() { if(!appSettings.serverUrl){return getApiServerUrl()} } + + // Automatically generated. Make future change here. +definition( + name: "Tado (Connect)", + namespace: "fuzzysb", + author: "Stuart Buchanan", + description: "Tado Integration, This SmartApp supports all Tado Products. (Heating Thermostats, Extension Kits, AC Cooling & Radiator Valves.)", + category: "SmartThings Labs", + iconUrl: "https://dl.dropboxusercontent.com/s/fvjrqcy5xjxsr31/tado_128.png", + iconX2Url: "https://dl.dropboxusercontent.com/s/jyad58wb28ibx2f/tado_256.png", + oauth: true, + singleInstance: false +) { + appSetting "clientId" + appSetting "clientSecret" + appSetting "serverUrl" +} + +preferences { + page(name: "startPage", title: "Tado (Connect) Integration", content: "startPage", install: false) + page(name: "Credentials", title: "Fetch Tado Credentials", content: "authPage", install: false) + page(name: "mainPage", title: "Tado (Connect) Integration", content: "mainPage") + page(name: "completePage", title: "${getVendorName()} is now connected to SmartThings!", content: "completePage") + page(name: "listDevices", title: "Tado Devices", content: "listDevices", install: false) + page(name: "listUsers", title: "Tado Users", content: "listUsers", install: false) + page(name: "advancedOptions", title: "Tado Advanced Options", content: "advancedOptions", install: false) + page(name: "badCredentials", title: "Invalid Credentials", content: "badAuthPage", install: false) +} +mappings { + path("/receivedHomeId"){action: [POST: "receivedHomeId", GET: "receivedHomeId"]} +} + +def startPage() { + if (state.homeId) { return mainPage() } + else { return authPage() } +} + +def authPage() { + log.debug "In authPage" + def description = null + log.debug "Prompting for Auth Details." + description = "Tap to enter Credentials." + return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:mainPage, uninstall: false , install:false) { + section("Generate Username and Password") { + input "username", "text", title: "Your Tado Username", required: true + input "password", "password", title: "Your Tado Password", required: true + } + } +} + +def mainPage() { + if (!state.accessToken){ + createAccessToken() + getToken() + } + getidCommand() + getTempUnitCommand() + log.debug "Logging debug: ${state.homeId}" + if (state.homeId) { + return completePage() + } else { + return badAuthPage() + } +} + +def completePage(){ + def description = "Tap 'Next' to proceed" + return dynamicPage(name: "completePage", title: "Credentials Accepted!", uninstall:true, install:false,nextPage: listDevices) { + section { href url: buildRedirectUrl("receivedHomeId"), style:"embedded", required:false, title:"${getVendorName()} is now connected to SmartThings!", description:description } + } +} + +def badAuthPage(){ + log.debug "In badAuthPage" + log.error "login result false" + return dynamicPage(name: "badCredentials", title: "Bad Tado Credentials", install:false, uninstall:true, nextPage: Credentials) { + section("") { + paragraph "Please check your username and password" + } + } +} + +def advancedOptions() { + log.debug "In Advanced Options" + def options = getDeviceList() + dynamicPage(name: "advancedOptions", title: "Select Advanced Options", install:true) { + section("Default temperatures for thermostat actions. Please enter desired temperatures") { + input("defHeatingTemp", "number", title: "Default Heating Temperature?", required: true) + input("defCoolingTemp", "number", title: "Default Cooling Temperature?", required: true) + } + section("Tado Override Method") { + input("manualmode", "enum", title: "Default Tado Manual Overide Method", options: ["TADO_MODE","MANUAL"], required: true) + } + section(){ + if (getHubID() == null){ + input( + name : "myHub" + ,type : "hub" + ,title : "Select your hub" + ,multiple : false + ,required : true + ,submitOnChange : true + ) + } else { + paragraph("Tap done to finish the initial installation.") + } + } + } +} + +def listDevices() { + log.debug "In listDevices" + def options = getDeviceList() + dynamicPage(name: "listDevices", title: "Choose devices", install:false, uninstall:true, nextPage: listUsers) { + section("Devices") { + input "devices", "enum", title: "Select Device(s)", required: false, multiple: true, options: options, submitOnChange: true + } + } +} + +def listUsers() { + log.debug "In listUsers" + def options = getUserList() + dynamicPage(name: "listUsers", title: "Choose users you wish to create a Virtual Smarthings Presence Sensors for", install:false, uninstall:true, nextPage: advancedOptions) { + section("Users") { + input "users", "enum", title: "Select User(s)", required: false, multiple: true, options: options, submitOnChange: true + } + } +} + +def getToken(){ + if (!state.accessToken) { + try { + getAccessToken() + DEBUG("Creating new Access Token: $state.accessToken") + } catch (ex) { + DEBUG("Did you forget to enable OAuth in SmartApp IDE settings") + DEBUG(ex) + } + } +} + +def receivedHomeId() { + log.debug "In receivedToken" + + def html = """ + + + + + ${getVendorName()} Connection + + + +
+ Vendor icon + connected device icon + SmartThings logo +

Tap 'Done' to continue to Devices.

+
+ + + """ + render contentType: 'text/html', data: html +} + +def buildRedirectUrl(endPoint) { + log.debug "In buildRedirectUrl" + log.debug("returning: " + getServerUrl() + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/${endPoint}") + return getServerUrl() + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/${endPoint}" +} + +def getDeviceList() { + def TadoDevices = getZonesCommand() + return TadoDevices.sort() +} + +def getUserList() { + def TadoUsers = getMobileDevicesCommand() + return TadoUsers.sort() +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + initialize() +} + +def uninstalled() { + log.debug "Uninstalling Tado (Connect)" + revokeAccessToken() + removeChildDevices(getChildDevices()) + log.debug "Tado (Connect) Uninstalled" +} + +def initialize() { + log.debug "Initialized with settings: ${settings}" + // Pull the latest device info into state + getDeviceList(); + def children = getChildDevices() + if(settings.devices) { + settings.devices.each { device -> + log.debug("Devices Inspected ${device.inspect()}") + def item = device.tokenize('|') + def deviceType = item[0] + def deviceId = item[1] + def deviceName = item[2] + def existingDevices = children.find{ d -> d.deviceNetworkId.contains(deviceId + "|" + deviceType) } + log.debug("existingDevices Inspected ${existingDevices.inspect()}") + if(!existingDevices) { + log.debug("Some Devices were not found....creating Child Device ${deviceName}") + try { + if (deviceType == "HOT_WATER") + { + log.debug("Creating Hot Water Device ${deviceName}") + createChildDevice("Tado Hot Water Control", deviceId + "|" + deviceType + "|" + state.accessToken + "|" + devicename, "${deviceName}", deviceName) + } + if (deviceType == "HEATING") + { + log.debug("Creating Heating Device ${deviceName}") + createChildDevice("Tado Heating Thermostat", deviceId + "|" + deviceType + "|" + state.accessToken + "|" + devicename, "${deviceName}", deviceName) + } + if (deviceType == "AIR_CONDITIONING") + { + log.debug("Creating Air Conditioning Device ${deviceName}") + createChildDevice("Tado Cooling Thermostat", deviceId + "|" + deviceType + "|" + state.accessToken + "|" + devicename, "${deviceName}", deviceName) + } + } catch (Exception e) + { + log.error "Error creating device: ${e}" + } + } + } + } + + getUserList(); + if(settings.users) { + settings.users.each { user -> + log.debug("Devices Inspected ${user.inspect()}") + def item = user.tokenize('|') + def userId = item[0] + def userName = item[1] + def existingUsers = children.find{ d -> d.deviceNetworkId.contains(userId + "|" + userName) } + log.debug("existingUsers Inspected ${existingUsers.inspect()}") + if(!existingUsers) { + log.debug("Some Users were not found....creating Child presence Device ${userName}") + try + { + createChildDevice("Tado User Presence", userId + "|" + userName + "|" + state.accessToken, "${userName}", userName) + } catch (Exception e) + { + log.error "Error creating device: ${e}" + } + } + } + } + + + // Do the initial poll + getInititialDeviceInfo() + + // Schedule it to run every 5 minutes + runEvery5Minutes("poll") + runEvery1Minute("userPoll") +} + +def getInititialDeviceInfo(){ + log.debug "getInititialDeviceInfo" + getDeviceList(); + def children = getChildDevices() + if(settings.devices) { + settings.devices.each { device -> + log.debug("Devices Inspected ${device.inspect()}") + def item = device.tokenize('|') + def deviceType = item[0] + def deviceId = item[1] + def deviceName = item[2] + def existingDevices = children.find{ d -> d.deviceNetworkId.contains(deviceId + "|" + deviceType) } + if(existingDevices) { + existingDevices.getInitialDeviceinfo() + } + } + } + +} +def getHubID(){ + def hubID + if (myHub){ + hubID = myHub.id + } else { + def hubs = location.hubs.findAll{ it.type == physicalgraph.device.HubType.PHYSICAL } + if (hubs.size() == 1) hubID = hubs[0].id + } + return hubID +} + +def poll() { + log.debug "In Poll" + getDeviceList(); + def children = getChildDevices() + if(settings.devices) { + settings.devices.each { device -> + log.debug("Devices Inspected ${device.inspect()}") + def item = device.tokenize('|') + def deviceType = item[0] + def deviceId = item[1] + def deviceName = item[2] + def existingDevices = children.find{ d -> d.deviceNetworkId.contains(deviceId + "|" + deviceType) } + if(existingDevices) { + existingDevices.poll() + } + } + } +} + +def userPoll() { + log.debug "In UserPoll" + def children = getChildDevices(); + if(settings.users) { + settings.users.each { user -> + log.debug("Devices Inspected ${user.inspect()}") + def item = user.tokenize('|') + def userId = item[0] + def userName = item[1] + def existingUsers = children.find{ d -> d.deviceNetworkId.contains(userId + "|" + userName) } + log.debug("existingUsers Inspected ${existingUsers.inspect()}") + if(existingUsers) { + existingUsers.poll() + } + } + } +} + +def createChildDevice(deviceFile, dni, name, label) { + log.debug "In createChildDevice" + try{ + def childDevice = addChildDevice("fuzzysb", deviceFile, dni, getHubID(), [name: name, label: label, completedSetup: true]) + } catch (e) { + log.error "Error creating device: ${e}" + } +} + +private sendCommand(method,childDevice,args = []) { + def methods = [ + 'getid': [ + uri: apiUrl(), + path: "/api/v2/me", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'gettempunit': [ + uri: apiUrl(), + path: "/api/v2/homes/${state.homeId}", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'getzones': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/zones", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'getMobileDevices': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/mobileDevices", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'getcapabilities': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/zones/" + args[0] + "/capabilities", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'status': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/zones/" + args[0] + "/state", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'userStatus': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/mobileDevices", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'temperature': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/zones/" + args[0] + "/overlay", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password], + body: args[1] + ], + 'weatherStatus': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/weather", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password] + ], + 'deleteEntry': [ + uri: apiUrl(), + path: "/api/v2/homes/" + state.homeId + "/zones/" + args[0] + "/overlay", + requestContentType: "application/json", + query: [username:settings.username, password:settings.password], + ] + ] + + def request = methods.getAt(method) + log.debug "Http Params ("+request+")" + try{ + log.debug "Executing 'sendCommand'" + if (method == "getid"){ + httpGet(request) { resp -> + parseMeResponse(resp) + } + }else if (method == "gettempunit"){ + httpGet(request) { resp -> + parseTempResponse(resp) + } + }else if (method == "getzones"){ + httpGet(request) { resp -> + parseZonesResponse(resp) + } + }else if (method == "getMobileDevices"){ + httpGet(request) { resp -> + parseMobileDevicesResponse(resp) + } + }else if (method == "getcapabilities"){ + httpGet(request) { resp -> + parseCapabilitiesResponse(resp,childDevice) + } + }else if (method == "status"){ + httpGet(request) { resp -> + parseResponse(resp,childDevice) + } + }else if (method == "userStatus"){ + httpGet(request) { resp -> + parseUserResponse(resp,childDevice) + } + }else if (method == "temperature"){ + httpPut(request) { resp -> + parseputResponse(resp,childDevice) + } + }else if (method == "weatherStatus"){ + log.debug "calling weatherStatus Method" + httpGet(request) { resp -> + parseweatherResponse(resp,childDevice) + } + }else if (method == "deleteEntry"){ + httpDelete(request) { resp -> + parsedeleteResponse(resp,childDevice) + } + }else{ + httpGet(request) + } + } catch(Exception e){ + log.debug("___exception: " + e) + } +} + +// Parse incoming device messages to generate events +private parseMeResponse(resp) { + log.debug("Executing parseMeResponse: "+resp.data) + log.debug("Output status: "+resp.status) + if(resp.status == 200) { + log.debug("Executing parseMeResponse.successTrue") + state.homeId = resp.data.homes[0].id + log.debug("Got HomeID Value: " + state.homeId) + + }else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +private parseputResponse(resp,childDevice) { + log.debug("Executing parseputResponse: "+resp.data) + log.debug("Output status: "+resp.status) +} + +private parsedeleteResponse(resp,childDevice) { + log.debug("Executing parsedeleteResponse: "+resp.data) + log.debug("Output status: "+resp.status) +} + +private parseUserResponse(resp,childDevice) { + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def userId = item[0] + def userName = item[1] + log.debug("Executing parseUserResponse: "+resp.data) + log.debug("Output status: "+resp.status) + if(resp.status == 200) { + def restUsers = resp.data + log.debug("Executing parseUserResponse.successTrue") + log.debug("UserId is ${userId} and userName is ${userName}") + for (TadoUser in restUsers) { + log.debug("TadoUserId is ${TadoUser.id}") + if ((TadoUser.id).toString() == (userId).toString()) + { + log.debug("Entering presence Assesment for User Id: ${userId}") + if (TadoUser.settings.geoTrackingEnabled == true) + { + log.debug("GeoTracking is Enabled for User Id: ${userId}") + if (TadoUser.location.atHome == true) + { + log.debug("Send presence Home Event Fired") + childDevice?.sendEvent(name:"presence",value: "present") + } else if (TadoUser.location.atHome == false) + { + log.debug("Send presence Away Event Fired") + childDevice?.sendEvent(name:"presence",value: "not present") + } + } + + } + } + } else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +private parseResponse(resp,childDevice) { + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + if (deviceType == "AIR_CONDITIONING") + { + log.debug("Executing parseResponse: "+resp.data) + log.debug("Output status: "+resp.status) + def temperatureUnit = state.tempunit + log.debug("Temperature Unit is ${temperatureUnit}") + def humidityUnit = "%" + def ACMode + def ACFanSpeed + def ACFanMode = "off" + def thermostatSetpoint + def tOperatingState + if(resp.status == 200) { + log.debug("Executing parseResponse.successTrue") + def temperature + if (temperatureUnit == "C") { + temperature = (Math.round(resp.data.sensorDataPoints.insideTemperature.celsius *10 ) / 10) + } + else if(temperatureUnit == "F"){ + temperature = (Math.round(resp.data.sensorDataPoints.insideTemperature.fahrenheit * 10) / 10) + } + log.debug("Read temperature: " + temperature) + childDevice?.sendEvent(name:"temperature",value:temperature,unit:temperatureUnit) + log.debug("Send Temperature Event Fired") + def autoOperation = "OFF" + if(resp.data.overlayType == null){ + autoOperation = resp.data.tadoMode + } + else if(resp.data.overlayType == "NO_FREEZE"){ + autoOperation = "OFF" + }else if(resp.data.overlayType == "MANUAL"){ + autoOperation = "MANUAL" + } + log.debug("Read tadoMode: " + autoOperation) + childDevice?.sendEvent(name:"tadoMode",value:autoOperation) + log.debug("Send thermostatMode Event Fired") + + def humidity + if (resp.data.sensorDataPoints.humidity.percentage != null){ + humidity = resp.data.sensorDataPoints.humidity.percentage + }else{ + humidity = "--" + } + log.debug("Read humidity: " + humidity) + childDevice?.sendEvent(name:"humidity",value:humidity,unit:humidityUnit) + + if (resp.data.setting.power == "OFF"){ + tOperatingState = "idle" + ACMode = "off" + ACFanMode = "off" + log.debug("Read thermostatMode: " + ACMode) + ACFanSpeed = "OFF" + log.debug("Read tadoFanSpeed: " + ACFanSpeed) + thermostatSetpoint = "--" + log.debug("Read thermostatSetpoint: " + thermostatSetpoint) + } + else if (resp.data.setting.power == "ON"){ + ACMode = (resp.data.setting.mode).toLowerCase() + log.debug("thermostatMode: " + ACMode) + ACFanSpeed = resp.data.setting.fanSpeed + if (ACFanSpeed == null) { + ACFanSpeed = "--" + } + if (resp.data.overlay != null){ + if (resp.data.overlay.termination.type == "TIMER"){ + if (resp.data.overlay.termination.durationInSeconds == "3600"){ + ACMode = "emergency heat" + log.debug("thermostatMode is heat, however duration shows the state is: " + ACMode) + } + } + } + switch (ACMode) { + case "off": + tOperatingState = "idle" + break + case "heat": + tOperatingState = "heating" + break + case "emergency heat": + tOperatingState = "heating" + break + case "cool": + tOperatingState = "cooling" + break + case "dry": + tOperatingState = "drying" + break + case "fan": + tOperatingState = "fan only" + break + case "auto": + tOperatingState = "heating|cooling" + break + } + log.debug("Read thermostatOperatingState: " + tOperatingState) + log.debug("Read tadoFanSpeed: " + ACFanSpeed) + + if (ACMode == "dry" || ACMode == "auto" || ACMode == "fan"){ + thermostatSetpoint = "--" + }else if(ACMode == "fan") { + ACFanMode = "auto" + }else{ + if (temperatureUnit == "C") { + thermostatSetpoint = Math.round(resp.data.setting.temperature.celsius) + } + else if(temperatureUnit == "F"){ + thermostatSetpoint = Math.round(resp.data.setting.temperature.fahrenheit) + } + } + log.debug("Read thermostatSetpoint: " + thermostatSetpoint) + } + }else{ + log.debug("Executing parseResponse.successFalse") + } + childDevice?.sendEvent(name:"thermostatOperatingState",value:tOperatingState) + log.debug("Send thermostatOperatingState Event Fired") + childDevice?.sendEvent(name:"tadoFanSpeed",value:ACFanSpeed) + log.debug("Send tadoFanSpeed Event Fired") + childDevice?.sendEvent(name:"thermostatFanMode",value:ACFanMode) + log.debug("Send thermostatFanMode Event Fired") + childDevice?.sendEvent(name:"thermostatMode",value:ACMode) + log.debug("Send thermostatMode Event Fired") + childDevice?.sendEvent(name:"thermostatSetpoint",value:thermostatSetpoint,unit:temperatureUnit) + log.debug("Send thermostatSetpoint Event Fired") + childDevice?.sendEvent(name:"heatingSetpoint",value:thermostatSetpoint,unit:temperatureUnit) + log.debug("Send heatingSetpoint Event Fired") + childDevice?.sendEvent(name:"coolingSetpoint",value:thermostatSetpoint,unit:temperatureUnit) + log.debug("Send coolingSetpoint Event Fired") + } + if (deviceType == "HEATING") + { + log.debug("Executing parseResponse: "+resp.data) + log.debug("Output status: "+resp.status) + def temperatureUnit = state.tempunit + log.debug("Temperature Unit is ${temperatureUnit}") + def humidityUnit = "%" + def ACMode + def ACFanSpeed + def thermostatSetpoint + def tOperatingState + if(resp.status == 200) { + log.debug("Executing parseResponse.successTrue") + def temperature + if (temperatureUnit == "C") { + temperature = (Math.round(resp.data.sensorDataPoints.insideTemperature.celsius * 10 ) / 10) + } + else if(temperatureUnit == "F"){ + temperature = (Math.round(resp.data.sensorDataPoints.insideTemperature.fahrenheit * 10) / 10) + } + log.debug("Read temperature: " + temperature) + childDevice?.sendEvent(name: 'temperature', value: temperature, unit: temperatureUnit) + log.debug("Send Temperature Event Fired") + def autoOperation = "OFF" + if(resp.data.overlayType == null){ + autoOperation = resp.data.tadoMode + } + else if(resp.data.overlayType == "NO_FREEZE"){ + autoOperation = "OFF" + }else if(resp.data.overlayType == "MANUAL"){ + autoOperation = "MANUAL" + } + log.debug("Read tadoMode: " + autoOperation) + childDevice?.sendEvent(name: 'tadoMode', value: autoOperation) + + if (resp.data.setting.power == "ON"){ + childDevice?.sendEvent(name: 'thermostatMode', value: "heat") + childDevice?.sendEvent(name: 'thermostatOperatingState', value: "heating") + log.debug("Send thermostatMode Event Fired") + if (temperatureUnit == "C") { + thermostatSetpoint = resp.data.setting.temperature.celsius + } + else if(temperatureUnit == "F"){ + thermostatSetpoint = resp.data.setting.temperature.fahrenheit + } + log.debug("Read thermostatSetpoint: " + thermostatSetpoint) + } else if(resp.data.setting.power == "OFF"){ + thermostatSetpoint = "--" + childDevice?.sendEvent(name: 'thermostatMode', value: "off") + childDevice?.sendEvent(name: 'thermostatOperatingState', value: "idle") + log.debug("Send thermostatMode Event Fired") + } + + def humidity + if (resp.data.sensorDataPoints.humidity.percentage != null){ + humidity = resp.data.sensorDataPoints.humidity.percentage + }else{ + humidity = "--" + } + log.debug("Read humidity: " + humidity) + + childDevice?.sendEvent(name: 'humidity', value: humidity,unit: humidityUnit) + + } + + else{ + log.debug("Executing parseResponse.successFalse") + } + + childDevice?.sendEvent(name: 'thermostatSetpoint', value: thermostatSetpoint, unit: temperatureUnit) + log.debug("Send thermostatSetpoint Event Fired") + childDevice?.sendEvent(name: 'heatingSetpoint', value: thermostatSetpoint, unit: temperatureUnit) + log.debug("Send heatingSetpoint Event Fired") + } + if (deviceType == "HOT_WATER") + { + log.debug("Executing parseResponse: "+resp.data) + log.debug("Output status: "+resp.status) + def temperatureUnit = state.tempunit + log.debug("Temperature Unit is ${temperatureUnit}") + def humidityUnit = "%" + def ACMode + def ACFanSpeed + def thermostatSetpoint + def tOperatingState + if(resp.status == 200) { + log.debug("Executing parseResponse.successTrue") + def temperature + if (state.supportsWaterTempControl == "true" && resp.data.tadoMode != null && resp.data.setting.power != "OFF"){ + if (temperatureUnit == "C") { + temperature = (Math.round(resp.data.setting.temperature.celsius * 10 ) / 10) + } + else if(temperatureUnit == "F"){ + temperature = (Math.round(resp.data.setting.temperature.fahrenheit * 10) / 10) + } + log.debug("Read temperature: " + temperature) + childDevice?.sendEvent(name: 'temperature', value: temperature, unit: temperatureUnit) + log.debug("Send Temperature Event Fired") + } else { + childDevice?.sendEvent(name: 'temperature', value: "--", unit: temperatureUnit) + log.debug("Send Temperature Event Fired") + } + def autoOperation = "OFF" + if(resp.data.overlayType == null){ + autoOperation = resp.data.tadoMode + } + else if(resp.data.overlayType == "NO_FREEZE"){ + autoOperation = "OFF" + }else if(resp.data.overlayType == "MANUAL"){ + autoOperation = "MANUAL" + } + log.debug("Read tadoMode: " + autoOperation) + childDevice?.sendEvent(name: 'tadoMode', value: autoOperation) + + if (resp.data.setting.power == "ON"){ + childDevice?.sendEvent(name: 'thermostatMode', value: "heat") + childDevice?.sendEvent(name: 'thermostatOperatingState', value: "heating") + log.debug("Send thermostatMode Event Fired") + } else if(resp.data.setting.power == "OFF"){ + childDevice?.sendEvent(name: 'thermostatMode', value: "off") + childDevice?.sendEvent(name: 'thermostatOperatingState', value: "idle") + log.debug("Send thermostatMode Event Fired") + } + log.debug("Send thermostatMode Event Fired") + if (state.supportsWaterTempControl == "true" && resp.data.tadoMode != null && resp.data.setting.power != "OFF"){ + if (temperatureUnit == "C") { + thermostatSetpoint = resp.data.setting.temperature.celsius + } + else if(temperatureUnit == "F"){ + thermostatSetpoint = resp.data.setting.temperature.fahrenheit + } + log.debug("Read thermostatSetpoint: " + thermostatSetpoint) + } else { + thermostatSetpoint = "--" + } + } + + else{ + log.debug("Executing parseResponse.successFalse") + } + + childDevice?.sendEvent(name: 'thermostatSetpoint', value: thermostatSetpoint, unit: temperatureUnit) + log.debug("Send thermostatSetpoint Event Fired") + childDevice?.sendEvent(name: 'heatingSetpoint', value: thermostatSetpoint, unit: temperatureUnit) + log.debug("Send heatingSetpoint Event Fired") + } +} + +private parseTempResponse(resp) { + log.debug("Executing parseTempResponse: "+resp.data) + log.debug("Output status: "+resp.status) + if(resp.status == 200) { + log.debug("Executing parseTempResponse.successTrue") + def tempunitname = resp.data.temperatureUnit + if (tempunitname == "CELSIUS") { + log.debug("Setting Temp Unit to C") + state.tempunit = "C" + } + else if(tempunitname == "FAHRENHEIT"){ + log.debug("Setting Temp Unit to F") + state.tempunit = "F" + } + }else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +private parseZonesResponse(resp) { + log.debug("Executing parseZonesResponse: "+resp.data) + log.debug("Output status: "+resp.status) + if(resp.status == 200) { + def restDevices = resp.data + def TadoDevices = [] + log.debug("Executing parseZoneResponse.successTrue") + restDevices.each { Tado -> TadoDevices << ["${Tado.type}|${Tado.id}|${Tado.name}":"${Tado.name}"] } + log.debug(TadoDevices) + return TadoDevices + }else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +private parseMobileDevicesResponse(resp) { + log.debug("Executing parseMobileDevicesResponse: "+resp.data) + log.debug("Output status: "+resp.status) + if(resp.status == 200) { + def restUsers = resp.data + def TadoUsers = [] + log.debug("Executing parseMobileDevicesResponse.successTrue") + restUsers.each { TadoUser -> + if (TadoUser.settings.geoTrackingEnabled == true) + { + TadoUsers << ["${TadoUser.id}|${TadoUser.name}":"${TadoUser.name}"] + } + } + log.debug(TadoUsers) + return TadoUsers + }else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +private parseCapabilitiesResponse(resp,childDevice) { + log.debug("Executing parseCapabilitiesResponse: "+resp.data) + log.debug("Output status: " + resp.status) + if(resp.status == 200) { + try + { + log.debug("Executing parseResponse.successTrue") + childDevice?.setCapabilitytadoType(resp.data.type) + log.debug("Tado Type is ${resp.data.type}") + if(resp.data.type == "AIR_CONDITIONING") + { + try + { + if(resp.data.AUTO || (resp.data.AUTO).toString() == "[:]"){ + log.debug("settingautocapability state true") + childDevice?.setCapabilitySupportsAuto("true") + } else { + log.debug("settingautocapability state false") + childDevice?.setCapabilitySupportsAuto("false") + } + if(resp.data.AUTO.swings || (resp.data.AUTO.swings).toString() == "[:]") + { + log.debug("settingautoswingcapability state true") + childDevice?.setCapabilitySupportsAutoSwing("true") + } + else + { + log.debug("settingautoswingcapability state false") + childDevice?.setCapabilitySupportsAutoSwing("false") + } + } + catch(Exception e) + { + log.debug("___exception parsing Auto Capabiity: " + e) + } + try + { + if(resp.data.COOL || (resp.data.COOL).toString() == "[:]"){ + log.debug("setting COOL capability state true") + childDevice?.setCapabilitySupportsCool("true") + def coolfanmodelist = resp.data.COOL.fanSpeeds + if(resp.data.COOL.swings || (resp.data.COOL.swings).toString() == "[:]") + { + log.debug("settingcoolswingcapability state true") + childDevice?.setCapabilitySupportsCoolSwing("true") + } + else + { + log.debug("settingcoolswingcapability state false") + childDevice?.setCapabilitySupportsCoolSwing("false") + } + if(resp.data.COOL.fanSpeeds || (resp.data.COOL.fanSpeeds).toString() == "[:]") + { + childDevice?.setCapabilitySupportsCoolFanSpeed("true") + } + else + { + childDevice?.setCapabilitySupportsCoolFanSpeed("false") + } + if(coolfanmodelist.find { it == 'AUTO' }){ + log.debug("setting COOL Auto Fan Speed capability state true") + childDevice?.setCapabilitySupportsCoolAutoFanSpeed("true") + } else { + log.debug("setting COOL Auto Fan Speed capability state false") + childDevice?.setCapabilitySupportsCoolAutoFanSpeed("false") + } + if (state.tempunit == "C"){ + childDevice?.setCapabilityMaxCoolTemp(resp.data.COOL.temperatures.celsius.max) + childDevice?.setCapabilityMinCoolTemp(resp.data.COOL.temperatures.celsius.min) + } else if (state.tempunit == "F") { + childDevice?.setCapabilityMaxCoolTemp(resp.data.COOL.temperatures.fahrenheit.max) + childDevice?.setCapabilityMinCoolTemp(resp.data.COOL.temperatures.fahrenheit.min) + } + } else { + log.debug("setting COOL capability state false") + childDevice?.setCapabilitySupportsCool("false") + } + } + catch(Exception e) + { + log.debug("___exception parsing Cool Capabiity: " + e) + } + try + { + if(resp.data.DRY || (resp.data.DRY).toString() == "[:]"){ + log.debug("setting DRY capability state true") + childDevice?.setCapabilitySupportsDry("true") + } else { + log.debug("setting DRY capability state false") + childDevice?.setCapabilitySupportsDry("false") + } + if(resp.data.DRY.swings || (resp.data.DRY.swings).toString() == "[:]") + { + log.debug("settingdryswingcapability state true") + childDevice?.setCapabilitySupportsDrySwing("true") + } + else + { + log.debug("settingdryswingcapability state false") + childDevice?.setCapabilitySupportsDrySwing("false") + } + } + catch(Exception e) + { + log.debug("___exception parsing Dry Capabiity: " + e) + } + try + { + if(resp.data.FAN || (resp.data.FAN).toString() == "[:]"){ + log.debug("setting FAN capability state true") + childDevice?.setCapabilitySupportsFan("true") + } else { + log.debug("setting FAN capability state false") + childDevice?.setCapabilitySupportsFan("false") + } + if(resp.data.FAN.swings || (resp.data.FAN.swings).toString() == "[:]") + { + log.debug("settingfanswingcapability state true") + childDevice?.setCapabilitySupportsFanSwing("true") + } + else + { + log.debug("settingfanswingcapability state false") + childDevice?.setCapabilitySupportsFanSwing("false") + } + } + catch(Exception e) + { + log.debug("___exception parsing Fan Capabiity: " + e) + } + try + { + if(resp.data.HEAT || (resp.data.HEAT).toString() == "[:]"){ + log.debug("setting HEAT capability state true") + childDevice?.setCapabilitySupportsHeat("true") + def heatfanmodelist = resp.data.HEAT.fanSpeeds + if(resp.data.HEAT.swings || (resp.data.HEAT.swings).toString() == "[:]") + { + log.debug("settingheatswingcapability state true") + childDevice?.setCapabilitySupportsHeatSwing("true") + } + else + { + log.debug("settingheatswingcapability state false") + childDevice?.setCapabilitySupportsHeatSwing("false") + } + if(resp.data.HEAT.fanSpeeds || (resp.data.HEAT.fanSpeeds).toString() == "[:]") + { + childDevice?.setCapabilitySupportsHeatFanSpeed("true") + } + else + { + childDevice?.setCapabilitySupportsHeatFanSpeed("false") + } + if(heatfanmodelist.find { it == 'AUTO' }){ + log.debug("setting HEAT Auto Fan Speed capability state true") + childDevice?.setCapabilitySupportsHeatAutoFanSpeed("true") + } else { + log.debug("setting HEAT Auto Fan Speed capability state false") + childDevice?.setCapabilitySupportsHeatAutoFanSpeed("false") + } + if (state.tempunit == "C"){ + childDevice?.setCapabilityMaxHeatTemp(resp.data.HEAT.temperatures.celsius.max) + childDevice?.setCapabilityMinHeatTemp(resp.data.HEAT.temperatures.celsius.min) + } else if (state.tempunit == "F") { + childDevice?.setCapabilityMaxHeatTemp(resp.data.HEAT.temperatures.fahrenheit.max) + childDevice?.setCapabilityMinHeatTemp(resp.data.HEAT.temperatures.fahrenheit.min) + } + } else { + log.debug("setting HEAT capability state false") + childDevice?.setCapabilitySupportsHeat("false") + } + }catch(Exception e) + { + log.debug("___exception parsing Heat Capabiity: " + e) + } + } + if(resp.data.type == "HEATING") + { + if(resp.data.type == "HEATING") + { + log.debug("setting HEAT capability state true") + childDevice?.setCapabilitySupportsHeat("true") + if (state.tempunit == "C") + { + childDevice?.setCapabilityMaxHeatTemp(resp.data.temperatures.celsius.max) + childDevice?.setCapabilityMinHeatTemp(resp.data.temperatures.celsius.min) + } + else if (state.tempunit == "F") + { + childDevice?.setCapabilityMaxHeatTemp(resp.data.temperatures.fahrenheit.max) + childDevice?.setCapabilityMinHeatTemp(resp.data.temperatures.fahrenheit.min) + } + } + else + { + log.debug("setting HEAT capability state false") + childDevice?.setCapabilitySupportsHeat("false") + } + } + if(resp.data.type == "HOT_WATER") + { + if(resp.data.type == "HOT_WATER"){ + log.debug("setting WATER capability state true") + dchildDevice?.setCapabilitySupportsWater("true") + if (resp.data.canSetTemperature == true){ + childDevice?.setCapabilitySupportsWaterTempControl("true") + if (state.tempunit == "C") + { + childDevice?.setCapabilityMaxHeatTemp(resp.data.temperatures.celsius.max) + childDevice?.setCapabilityMinHeatTemp(resp.data.temperatures.celsius.min) + } + else if (state.tempunit == "F") + { + childDevice?.setCapabilityMaxHeatTemp(resp.data.temperatures.fahrenheit.max) + childDevice?.setCapabilityMinHeatTemp(resp.data.temperatures.fahrenheit.min) + } + } + else + { + childDevice?.setCapabilitySupportsWaterTempControl("false") + } + } + else + { + log.debug("setting Water capability state false") + childDevice?.setCapabilitySupportsWater("false") + } + } + } + catch(Exception e) + { + log.debug("___exception: " + e) + } + } + else if(resp.status == 201) + { + log.debug("Something was created/updated") + } +} + +private parseweatherResponse(resp,childDevice) { + log.debug("Executing parseweatherResponse: "+resp.data) + log.debug("Output status: "+resp.status) + def temperatureUnit = state.tempunit + log.debug("Temperature Unit is ${temperatureUnit}") + if(resp.status == 200) { + log.debug("Executing parseResponse.successTrue") + def outsidetemperature + if (temperatureUnit == "C") { + outsidetemperature = resp.data.outsideTemperature.celsius + } + else if(temperatureUnit == "F"){ + outsidetemperature = resp.data.outsideTemperature.fahrenheit + } + log.debug("Read outside temperature: " + outsidetemperature) + childDevice?.sendEvent(name: 'outsidetemperature', value: outsidetemperature, unit: temperatureUnit) + log.debug("Send Outside Temperature Event Fired") + return result + + }else if(resp.status == 201){ + log.debug("Something was created/updated") + } +} + +def getidCommand(){ + log.debug "Executing 'sendCommand.getidCommand'" + sendCommand("getid",null,[]) +} + +def getTempUnitCommand(){ + log.debug "Executing 'sendCommand.getidCommand'" + sendCommand("gettempunit",null,[]) +} + +def getZonesCommand(){ + log.debug "Executing 'sendCommand.getzones'" + sendCommand("getzones",null,[]) +} + +def getMobileDevicesCommand(){ + log.debug "Executing 'sendCommand.getMobileDevices'" + sendCommand("getMobileDevices",null,[]) +} + +def weatherStatusCommand(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + log.debug "Executing 'sendCommand.weatherStatusCommand'" + def result = sendCommand("weatherStatus",childDevice,[deviceId]) +} + +def getCapabilitiesCommand(childDevice, deviceDNI){ + log.debug("childDevice is: " + childDevice.inspect()) + log.debug("deviceDNI is: " + deviceDNI.inspect()) + def item = deviceDNI.tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + log.debug "Executing 'sendCommand.getcapabilities'" + sendCommand("getcapabilities",childDevice,[deviceId]) +} + +private removeChildDevices(delete) { + try { + delete.each { + deleteChildDevice(it.deviceNetworkId) + log.info "Successfully Removed Child Device: ${it.displayName} (${it.deviceNetworkId})" + } + } + catch (e) { log.error "There was an error (${e}) when trying to delete the child device" } +} + +def parseCapabilityData(Map results){ + log.debug "in parseCapabilityData" + def result + results.each { name, value -> + + if (name == "value") + { + log.debug "Map Name Returned, ${name} and Value is ${value}" + result = value.toString() + log.debug "Result is ${result}" + //return result + } + } + return result +} + +//Device Commands Below Here +def autoCommand(childDevice){ + log.debug "Executing 'sendCommand.autoCommand' on device ${childDevice.device.name}" + def terminationmode = settings.manualmode + def traperror + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + if (deviceType == "AIR_CONDITIONING") + { + def capabilitySupportsAuto = parseCapabilityData(childDevice.getCapabilitySupportsAuto()) + def capabilitySupportsAutoSwing = parseCapabilityData(childDevice.getCapabilitySupportsAutoSwing()) + def capabilitysupported = capabilitySupportsAuto + if (capabilitysupported == "true"){ + log.debug "Executing 'sendCommand.autoCommand' on device ${childDevice.device.name}" + def jsonbody + if (capabilitySupportsAutoSwing == "true") + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"AUTO", power:"ON", swing:"OFF", type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"AUTO", power:"ON", type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + sendCommand("temperature",dchildDevice,[deviceId,jsonbody]) + statusCommand(device) + } else { + log.debug("Sorry Auto Capability not supported on device ${childDevice.device.name}") + } + } + if(deviceType == "HEATING") + { + def initialsetpointtemp + try { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + } + catch (NumberFormatException e){ + traperror = 0 + } + if(traperror == 0){ + initialsetpointtemp = settings.defHeatingTemp + } else { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + def jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:initialsetpointtemp], type:"HEATING"], termination:[type:terminationmode]]) + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } + if (deviceType == "HOT_WATER") + { + log.debug "Executing 'sendCommand.autoCommand'" + def initialsetpointtemp + def jsonbody + def capabilitySupportsWaterTempControl = parseCapabilityData(childDevice.getCapabilitySupportsWaterTempControl()) + if(capabilitySupportsWaterTempControl == "true"){ + try { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + }catch (NumberFormatException e){ + traperror = 0 + } + if(traperror == 0){ + initialsetpointtemp = settings.defHeatingTemp + } else { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:initialsetpointtemp], type:"HOT_WATER"], termination:[type:terminationmode]]) + } else { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", type:"HOT_WATER"], termination:[type:terminationmode]]) + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } +} + +def dryCommand(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def capabilitySupportsDry = parseCapabilityData(childDevice.getCapabilitySupportsDry()) + def capabilitySupportsDrySwing = parseCapabilityData(childDevice.getCapabilitySupportsDrySwing()) + def capabilitysupported = capabilitySupportsDry + if (capabilitysupported == "true"){ + def terminationmode = settings.manualmode + log.debug "Executing 'sendCommand.dryCommand' on device ${childDevice.device.name}" + def jsonbody + if (capabilitySupportsDrySwing == "true") + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"DRY", power:"ON", swing:"OFF", type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"DRY", power:"ON", type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } else { + log.debug("Sorry Dry Capability not supported on device ${childDevice.device.name}") + } +} + +def fanAuto(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def capabilitySupportsFan = parseCapabilityData(childDevice.getCapabilitySupportsFan()) + def capabilitySupportsFanSwing = parseCapabilityData(childDevice.getCapabilitySupportsFanSwing()) + def capabilitysupported = capabilitySupportsFan + if (capabilitysupported == "true"){ + def terminationmode = settings.manualmode + log.debug "Executing 'sendCommand.fanAutoCommand' on device ${childDevice.device.name}" + def jsonbody + if (capabilitySupportsFanSwing == "true") + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"FAN", power:"ON", swing:"OFF", type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"FAN", power:"ON", type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } else { + log.debug("Sorry Fan Capability not supported by your HVAC Device") + } +} + +def endManualControl(childDevice){ + log.debug "Executing 'sendCommand.endManualControl' on device ${childDevice.device.name}" + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + sendCommand("deleteEntry",childDevice,[deviceId]) + statusCommand(childDevice) +} + +def cmdFanSpeedAuto(childDevice){ + def supportedfanspeed + def terminationmode = settings.manualmode + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def jsonbody + def capabilitySupportsCool = parseCapabilityData(childDevice.getCapabilitySupportsCool()) + def capabilitysupported = capabilitySupportsCool + def capabilitySupportsCoolAutoFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsCoolAutoFanSpeed()) + def fancapabilitysupported = capabilitySupportsCoolAutoFanSpeed + if (fancapabilitysupported == "true"){ + supportedfanspeed = "AUTO" + } else { + supportedfanspeed = "HIGH" + } + def curSetTemp = (childDevice.device.currentValue("thermostatSetpoint")) + def curMode = ((childDevice.device.currentValue("thermostatMode")).toUpperCase()) + if (curMode == "COOL" || curMode == "HEAT"){ + if (capabilitySupportsCoolSwing == "true" || capabilitySupportsHeatSwing == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + log.debug "Executing 'sendCommand.fanSpeedAuto' to ${supportedfanspeed}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } +} + +def cmdFanSpeedHigh(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def jsonbody + def supportedfanspeed = "HIGH" + def terminationmode = settings.manualmode + def curSetTemp = (childDevice.device.currentValue("thermostatSetpoint")) + def curMode = ((childDevice.device.currentValue("thermostatMode")).toUpperCase()) + if (curMode == "COOL" || curMode == "HEAT"){ + if (capabilitySupportsCoolSwing == "true" || capabilitySupportsHeatSwing == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + log.debug "Executing 'sendCommand.fanSpeedAuto' to ${supportedfanspeed}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } +} + +def cmdFanSpeedMid(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def supportedfanspeed = "MIDDLE" + def terminationmode = settings.manualmode + def jsonbody + def curSetTemp = (childDevice.device.currentValue("thermostatSetpoint")) + def curMode = ((childDevice.device.currentValue("thermostatMode")).toUpperCase()) + if (curMode == "COOL" || curMode == "HEAT"){ + if (capabilitySupportsCoolSwing == "true" || capabilitySupportsHeatSwing == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + log.debug "Executing 'sendCommand.fanSpeedMid' to ${supportedfanspeed}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } +} + +def cmdFanSpeedLow(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def capabilitySupportsCoolSwing = parseCapabilityData(childDevice.getCapabilitySupportsCoolSwing()) + def capabilitySupportsHeatSwing = parseCapabilityData(childDevice.getCapabilitySupportsHeatSwing()) + def supportedfanspeed = "LOW" + def terminationmode = settings.manualmode + def jsonbody + def curSetTemp = (childDevice.device.currentValue("thermostatSetpoint")) + def curMode = ((childDevice.device.currentValue("thermostatMode")).toUpperCase()) + if (curMode == "COOL" || curMode == "HEAT"){ + if (capabilitySupportsCoolSwing == "true" || capabilitySupportsHeatSwing == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", swing:"OFF", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[celsius:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:curMode, power:"ON", temperature:[fahrenheit:curSetTemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + log.debug "Executing 'sendCommand.fanSpeedLow' to ${supportedfanspeed}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } +} + +def setCoolingTempCommand(childDevice,targetTemperature){ + def terminationmode = settings.manualmode + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def supportedfanspeed + def capabilitySupportsCool = parseCapabilityData(childDevice.getCapabilitySupportsCool()) + def capabilitySupportsCoolSwing = parseCapabilityData(childDevice.getCapabilitySupportsCoolSwing()) + def capabilitysupported = capabilitySupportsCool + def capabilitySupportsCoolFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsCoolFanSpeed()) + def capabilitySupportsCoolAutoFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsCoolAutoFanSpeed()) + def fancapabilitysupported = capabilitySupportsCoolAutoFanSpeed + def jsonbody + if (fancapabilitysupported == "true"){ + supportedfanspeed = "AUTO" + } else { + supportedfanspeed = "HIGH" + } + if (capabilitySupportsCoolSwing == "true" && capabilitySupportsCoolFanSpeed == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", swing:"OFF", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", swing:"OFF", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsCoolSwing == "true" && capabilitySupportsCoolFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", swing:"OFF", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", swing:"OFF", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsCoolSwing == "false" && capabilitySupportsCoolFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + + log.debug "Executing 'sendCommand.setCoolingTempCommand' to ${targetTemperature} on device ${childDevice.device.name}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) +} + +def setHeatingTempCommand(childDevice,targetTemperature){ + def terminationmode = settings.manualmode + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + if(deviceType == "AIR_CONDITIONING") + { + def capabilitySupportsHeat = parseCapabilityData(childDevice.getCapabilitySupportsHeat()) + def capabilitysupported = capabilitySupportsHeat + def capabilitySupportsHeatSwing = parseCapabilityData(childDevice.getCapabilitySupportsHeatSwing()) + def capabilitySupportsHeatAutoFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsHeatAutoFanSpeed()) + def capabilitySupportsHeatFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsHeatFanSpeed()) + def fancapabilitysupported = capabilitySupportsHeatAutoFanSpeed + def supportedfanspeed + def jsonbody + if (fancapabilitysupported == "true") + { + supportedfanspeed = "AUTO" + } + else + { + supportedfanspeed = "HIGH" + } + if (capabilitySupportsHeatSwing == "true" && capabilitySupportsHeatFanSpeed == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", swing:"OFF", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", swing:"OFF", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsHeatSwing == "true" && capabilitySupportsHeatFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", swing:"OFF", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", swing:"OFF", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsHeatSwing == "false" && capabilitySupportsHeatFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", temperature:[celsius:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", temperature:[fahrenheit:targetTemperature], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + log.debug "Executing 'sendCommand.setHeatingTempCommand' to ${targetTemperature} on device ${childDevice.device.name}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + } + if(deviceType == "HEATING") + { + def jsonbody + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:targetTemperature], type:"HEATING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[fahrenheit:targetTemperature], type:"HEATING"], termination:[type:terminationmode]]) + } + log.debug "Executing 'sendCommand.setHeatingTempCommand' to ${targetTemperature} on device ${childDevice.device.name}" + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + } + if(deviceType == "HOT_WATER") + { + def jsonbody + def capabilitySupportsWaterTempControl = parseCapabilityData(childDevice.getCapabilitySupportsWaterTempControl()) + if(capabilitySupportsWaterTempControl == "true"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:targetTemperature], type:"HOT_WATER"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[fahrenheit:targetTemperature], type:"HOT_WATER"], termination:[type:terminationmode]]) + } + log.debug "Executing 'sendCommand.setHeatingTempCommand' to ${targetTemperature} on device ${childDevice.device.name}" + sendCommand("temperature",[jsonbody]) + } else { + log.debug "Hot Water Temperature Capability Not Supported on device ${childDevice.device.name}" + } + } +} + +def offCommand(childDevice){ + log.debug "Executing 'sendCommand.offCommand' on device ${childDevice.device.name}" + def terminationmode = settings.manualmode + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def jsonbody = new groovy.json.JsonOutput().toJson([setting:[type:deviceType, power:"OFF"], termination:[type:terminationmode]]) + sendCommand("temperature",childDevice,[deviceId,jsonbody]) +} + +def onCommand(childDevice){ + log.debug "Executing 'sendCommand.onCommand'" + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + if(deviceType == "AIR_CONDITIONING") + { + coolCommand(childDevice) + } + if(deviceType == "HEATING" || deviceType == "HOT_WATER") + { + heatCommand(childDevice) + } +} + +def coolCommand(childDevice){ + log.debug "Executing 'sendCommand.coolCommand'" + def terminationmode = settings.manualmode + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + def initialsetpointtemp + def supportedfanspeed + def capabilitySupportsCool = parseCapabilityData(childDevice.getCapabilitySupportsCool()) + def capabilitySupportsCoolSwing = parseCapabilityData(childDevice.getCapabilitySupportsCoolSwing()) + def capabilitysupported = capabilitySupportsCool + def capabilitySupportsCoolAutoFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsCoolAutoFanSpeed()) + def capabilitySupportsCoolFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsCoolFanSpeed()) + def fancapabilitysupported = capabilitySupportsCoolAutoFanSpeed + def traperror + try { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + }catch (NumberFormatException e){ + traperror = 0 + } + if (fancapabilitysupported == "true"){ + supportedfanspeed = "AUTO" + } else { + supportedfanspeed = "HIGH" + } + if(traperror == 0){ + initialsetpointtemp = settings.defCoolingTemp + } else { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + def jsonbody + if (capabilitySupportsCoolSwing == "true" && capabilitySupportsCoolFanSpeed == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", swing:"OFF", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", swing:"OFF", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsCoolSwing == "true" && capabilitySupportsCoolFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", swing:"OFF", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", swing:"OFF", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsCoolSwing == "false" && capabilitySupportsCoolFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"COOL", power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"COOL", power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) +} + +def heatCommand(childDevice){ + log.debug "Executing 'sendCommand.heatCommand' on device ${childDevice.device.name}" + def terminationmode = settings.manualmode + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + if(deviceType == "AIR_CONDITIONING") + { + def initialsetpointtemp + def supportedfanspeed + def traperror + def capabilitySupportsHeat = parseCapabilityData(childDevice.getCapabilitySupportsHeat()) + def capabilitySupportsHeatSwing = parseCapabilityData(childDevice.getCapabilitySupportsHeatSwing()) + def capabilitysupported = capabilitySupportsHeat + def capabilitySupportsHeatAutoFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsHeatAutoFanSpeed()) + def capabilitySupportsHeatFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsHeatFanSpeed()) + def fancapabilitysupported = capabilitySupportsHeatAutoFanSpeed + try + { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + } + catch (NumberFormatException e) + { + traperror = 0 + } + if (fancapabilitysupported == "true") + { + supportedfanspeed = "AUTO" + } + else + { + supportedfanspeed = "HIGH" + } + if(traperror == 0) + { + initialsetpointtemp = settings.defHeatingTemp + } + else + { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + def jsonbody + if (capabilitySupportsHeatSwing == "true" && capabilitySupportsHeatFanSpeed == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", swing:"OFF", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", swing:"OFF", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsHeatSwing == "true" && capabilitySupportsHeatFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", swing:"OFF", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", swing:"OFF", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsHeatSwing == "false" && capabilitySupportsHeatFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + } + if(deviceType == "HEATING") + { + def initialsetpointtemp + def traperror + try + { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + } + catch (NumberFormatException e) + { + traperror = 0 + } + if(traperror == 0) + { + initialsetpointtemp = settings.defHeatingTemp + } + else + { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + def jsonbody + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:initialsetpointtemp], type:"HEATING"], termination:[type:terminationmode]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"HEATING"], termination:[type:terminationmode]]) + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + } + if(deviceType == "HOT_WATER") + { + def jsonbody + def initialsetpointtemp + def traperror + def capabilitySupportsWaterTempControl = parseCapabilityData(childDevice.getCapabilitySupportsWaterTempControl()) + if(capabilitySupportsWaterTempControl == "true"){ + try { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + }catch (NumberFormatException e){ + traperror = 0 + } + if(traperror == 0){ + initialsetpointtemp = settings.defHeatingTemp + } else { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:initialsetpointtemp], type:"HOT_WATER"], termination:[type:terminationmode]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"HOT_WATER"], termination:[type:terminationmode]]) + } + } else { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", type:"HOT_WATER"], termination:[type:terminationmode]]) + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + } +} + +def emergencyHeat(childDevice){ + log.debug "Executing 'sendCommand.heatCommand' on device ${childDevice.device.name}" + def traperror + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + if(deviceType == "AIR_CONDITIONING") + { + def capabilitySupportsHeat = parseCapabilityData(childDevice.getCapabilitySupportsHeat()) + def capabilitysupported = capabilitySupportsHeat + def capabilitySupportsHeatSwing = parseCapabilityData(childDevice.getCapabilitySupportsHeatSwing()) + def capabilitySupportsHeatAutoFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsHeatAutoFanSpeed()) + def capabilitySupportsHeatFanSpeed = parseCapabilityData(childDevice.getCapabilitySupportsHeatFanSpeed()) + def fancapabilitysupported = capabilitySupportsHeatAutoFanSpeed + try + { + traperror = Integer.parseInt(childDevice.device.currentValue("thermostatSetpoint")) + } + catch (NumberFormatException e) + { + traperror = 0 + } + if (capabilitysupported == "true") + { + def initialsetpointtemp + def supportedfanspeed + if (fancapabilitysupported == "true") + { + supportedfanspeed = "AUTO" + } + else + { + supportedfanspeed = "HIGH" + } + if(traperror == 0) + { + initialsetpointtemp = settings.defHeatingTemp + } + else + { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + def jsonbody + if (capabilitySupportsHeatSwing == "true" && capabilitySupportsHeatFanSpeed == "true") + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", swing:"OFF", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", swing:"OFF", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + } + else if(capabilitySupportsHeatSwing == "true" && capabilitySupportsHeatFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", swing:"OFF", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", swing:"OFF", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else if(capabilitySupportsHeatSwing == "false" && capabilitySupportsHeatFanSpeed == "false"){ + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + else if(state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[mode:"HEAT", power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[type:terminationmode]]) + } + } + else + { + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", temperature:[celsius:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[fanSpeed:supportedfanspeed, mode:"HEAT", power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"AIR_CONDITIONING"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(device) + } + else + { + log.debug("Sorry Heat Capability not supported on device ${childDevice.device.name}") + } + } + if(deviceType == "HEATING") + { + def initialsetpointtemp + try + { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + } + catch (NumberFormatException e) + { + traperror = 0 + } + if(traperror == 0) + { + initialsetpointtemp = settings.defHeatingTemp + } + else + { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + def jsonbody + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:initialsetpointtemp], type:"HEATING"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"HEATING"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } + (deviceType == "HOT_WATER") + { + def initialsetpointtemp + def jsonbody + def capabilitySupportsWaterTempControl = parseCapabilityData(childDevice.getCapabilitySupportsWaterTempControl()) + if(capabilitySupportsWaterTempControl == "true"){ + try + { + traperror = ((childDevice.device.currentValue("thermostatSetpoint")).intValue()) + } + catch (NumberFormatException e) + { + traperror = 0 + } + if(traperror == 0) + { + initialsetpointtemp = settings.defHeatingTemp + } + else + { + initialsetpointtemp = childDevice.device.currentValue("thermostatSetpoint") + } + if (state.tempunit == "C") { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[celsius:initialsetpointtemp], type:"HOT_WATER"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + else if (state.tempunit == "F"){ + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", temperature:[fahrenheit:initialsetpointtemp], type:"HOT_WATER"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + } + else + { + jsonbody = new groovy.json.JsonOutput().toJson([setting:[power:"ON", type:"HOT_WATER"], termination:[durationInSeconds:"3600", type:"TIMER"]]) + } + sendCommand("temperature",childDevice,[deviceId,jsonbody]) + statusCommand(childDevice) + } +} + +def statusCommand(childDevice){ + def item = (childDevice.device.deviceNetworkId).tokenize('|') + def deviceId = item[0] + def deviceType = item[1] + def deviceToken = item[2] + log.debug "Executing 'sendCommand.statusCommand'" + sendCommand("status",childDevice,[deviceId]) +} + +def userStatusCommand(childDevice){ + try{ + log.debug "Executing 'sendCommand.userStatusCommand'" + sendCommand("userStatus",childDevice,[]) + } catch(Exception e) { log.debug("Failed in setting userStatusCommand: " + e) + } +} \ No newline at end of file diff --git a/smartapps/hongtat/tasmota-connect.src/tasmota-connect.groovy b/smartapps/hongtat/tasmota-connect.src/tasmota-connect.groovy new file mode 100644 index 00000000000..691b1273ed7 --- /dev/null +++ b/smartapps/hongtat/tasmota-connect.src/tasmota-connect.groovy @@ -0,0 +1,753 @@ +/** + * Tasmota (Connect) + * + * Copyright 2020 AwfullySmart.com - HongTat Tan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +String appVersion() { return "1.0.10" } + +import groovy.json.JsonSlurper +import groovy.json.JsonOutput +import groovy.transform.Field +definition( + name: "Tasmota (Connect)", + namespace: "hongtat", + author: "AwfullySmart", + description: "Allows you to integrate your Tasmota devices with SmartThings.", + iconUrl: "https://awfullysmart.github.io/st/awfullysmart-180.png", + iconX2Url: "https://awfullysmart.github.io/st/awfullysmart-180.png", + iconX3Url: "https://awfullysmart.github.io/st/awfullysmart-180.png", + singleInstance: true, + pausable: false +) + +preferences { + page(name: "mainPage", nextPage: "", uninstall: true, install: true) + page(name: "configureDevice") + page(name: "deleteDeviceConfirm") + page(name: "addDevice") + page(name: "addDeviceConfirm") +} + +def mainPage() { + if (state?.install) { + dynamicPage(name: "mainPage", title: "Tasmota (Connect) - v${appVersion()}") { + section(){ + href "addDevice", title:"New Tasmota Device", description:"" + } + section("Installed Devices"){ + getChildDevices().sort({ a, b -> a.label <=> b.label }).each { + String typeName = it.typeName + if (moduleMap().find{ it.value.type == "${typeName}" }?.value?.settings?.contains('ip')) { + String actualDni = it.deviceNetworkId + String descText = "" + if (childSetting(it.id, "ip") != null) { + String dni = it.state?.dni + descText = childSetting(it.id, "ip") + if ((dni != null && dni != actualDni) || (dni == null)) { + descText += ' (!!!)' + } + } else { + descText = "Tap to set IP address" + } + href "configureDevice", title:"$it.label", description: descText, params: [did: actualDni] + } else { + href "configureDevice", title:"$it.label", description: "", params: [did: it.deviceNetworkId] + } + } + } + section(title: "Settings") { + input("dateformat", "enum", + title: "Date Format", + description: "Set preferred data format", + options: ["MM/dd/yyyy h:mm", "MM-dd-yyyy h:mm", "dd/MM/yyyy h:mm", "dd-MM-yyyy h:mm"], + defaultValue: "MM/dd/yyyy h:mm", + required: false, submitOnChange: false) + input("frequency", "enum", + title: "Device Health Check", + description: "Check in on device health every so often", + options: ["Every 1 minute", "Every 5 minutes", "Every 10 minutes", "Every 15 minutes", "Every 30 minutes", "Every 1 hour"], + defaultValue: "Every 5 minutes", + required: false, submitOnChange: false) + } + section(title: "SmartThings Hub") { + String st = getHub()?.localIP + if (st == null || st == '') { + paragraph "Unable to find a SmartThings Hub. Do you have a physical SmartThings Hub (v2 or v3)?" + } else { + paragraph "IP Address: ${st}" + } + } + remove("Remove (Includes Devices)", "This will remove all devices.") + } + } else { + dynamicPage(name: "mainPage", title: "Tasmota (Connect)") { + section { + paragraph "Success!" + } + } + } +} + +def configureDevice(params){ + def t = params?.did + if (params?.did) { + atomicState?.curPageParams = params + } else { + t = atomicState?.curPageParams?.did + } + def d = getChildDevice(t) + state.currentDeviceId = d?.deviceNetworkId + state.currentId = d?.id + state.currentDisplayName = d?.displayName + state.currentTypeName = d?.typeName + state.currentVersion = (d?.currentVersion) ? (' - ' + d.currentVersion) : '' + + def moduleParameter = moduleMap().find{ it.value.type == "${state.currentTypeName}" }?.value + return dynamicPage(name: "configureDevice", install: false, uninstall: false, nextPage: "mainPage") { + section("${state.currentDisplayName} ${state.currentVersion}") {} + if (moduleParameter && moduleParameter.settings.contains('ip')) { + section("Device setup") { + input("dev:${state.currentId}:ip", "text", + title: "IP Address", + description: "IP Address", + defaultValue: "", + required: true, submitOnChange: true) + input("dev:${state.currentId}:username", "text", + title: "Username", + description: "Username", + defaultValue: "", + required: false, submitOnChange: true) + input("dev:${state.currentId}:password", "text", + title: "Password", + description: "Password", + defaultValue: "", + required: false, submitOnChange: true) + } + } + if (moduleParameter && moduleParameter.settings.contains('bridge')) { + section("RF/IR Bridge") { + input ("dev:${state.currentId}:bridge", "enum", + title: "RF/IR Bridge", + description: "Select a RF/IR bridge to communicate with RF/IR device", + multiple: false, required: false, options: childDevicesByType(["Tasmota RF Bridge", "Tasmota IR Bridge"]), submitOnChange: true) + } + } + // Whether to mark device as "always online" + if (moduleParameter && moduleParameter.settings.contains('healthState')) { + section("Health State") { + input ("dev:${state.currentId}:health_state", "bool", + title: "Health State", + description: "Mark device as Always Online", + defaultValue: false, required: false, submitOnChange: true) + } + } + // Virtual Switch + if (moduleParameter && moduleParameter.settings.contains('virtualSwitch') && childSetting(state.currentId, "bridge") != null) { + section("RF/IR Code") { + input("dev:${state.currentId}:command_on", "text", + title: "Command to send for 'ON'", + description: "Tap to set", + defaultValue: "", required: false, submitOnChange: true) + input("dev:${state.currentId}:command_off", "text", + title: "Command to send for 'OFF'", + description: "Tap to set", + defaultValue: "", required: false, submitOnChange: true) + input("dev:${state.currentId}:track_state", "bool", + title: "State tracking", + description: "Enable real-time tracking", + defaultValue: false, required: false, submitOnChange: true) + + if (childSetting(state.currentId, "track_state")) { + input("dev:${state.currentId}:payload_on", "text", + title: "Code that represents the 'ON' state", + description: "Tap to set", + defaultValue: "", required: false) + input("dev:${state.currentId}:payload_off", "text", + title: "Code that represents the 'OFF' state", + description: "Tap to set", + defaultValue: "", required: false) + } else { + deleteChildSetting(state.currentId, "payload_on") + deleteChildSetting(state.currentId, "payload_off") + } + } + } + // Virtual Shade + if (moduleParameter && moduleParameter.settings.contains('virtualShade') && childSetting(state.currentId, "bridge") != null) { + section("RF/IR Code") { + input("dev:${state.currentId}:command_open", "text", + title: "Command to send for 'OPEN'", + description: "Tap to set", + defaultValue: "", required: false, submitOnChange: true) + input("dev:${state.currentId}:command_close", "text", + title: "Command to send for 'CLOSE'", + description: "Tap to set", + defaultValue: "", required: false, submitOnChange: true) + input("dev:${state.currentId}:command_pause", "text", + title: "Command to send for 'PAUSE'", + description: "Tap to set", + defaultValue: "", required: false, submitOnChange: true) + input("dev:${state.currentId}:track_state", "bool", + title: "State tracking", + description: "Enable real-time tracking", + defaultValue: false, required: false, submitOnChange: true) + + if (childSetting(state.currentId, "track_state")) { + input("dev:${state.currentId}:payload_open", "text", + title: "Code that represents the 'OPEN' state", + description: "Tap to set", + defaultValue: "", required: false) + input("dev:${state.currentId}:payload_close", "text", + title: "Code that represents the 'CLOSE' state", + description: "Tap to set", + defaultValue: "", required: false) + input("dev:${state.currentId}:payload_pause", "text", + title: "Code that represents the 'PAUSE' state", + description: "Tap to set", + defaultValue: "", required: false) + } else { + deleteChildSetting(state.currentId, "payload_open") + deleteChildSetting(state.currentId, "payload_close") + deleteChildSetting(state.currentId, "payload_pause") + } + } + } + // Virtual Button + if (moduleParameter && moduleParameter.settings.contains('virtualButton') && childSetting(state.currentId, "bridge") != null) { + def numberOfButtons = moduleParameter?.channel ?: 1 + section("RF/IR Code") { + for (def buttonNumer : 1..numberOfButtons) { + input("dev:${state.currentId}:button_${buttonNumer}", "text", + title: "Button ${buttonNumer} 'pushed' state code", + description: "Tap to set", + defaultValue: "", required: false, submitOnChange: false) + } + } + } + // Virtual Contact Sensor + if (moduleParameter && moduleParameter.settings.contains('virtualContactSensor') && childSetting(state.currentId, "bridge") != null) { + section("RF/IR Code") { + input("dev:${state.currentId}:payload_open", "text", + title: "Code that represents the 'OPEN' state", + description: "Tap to set", + defaultValue: "", required: false) + input("dev:${state.currentId}:payload_close", "text", + title: "Code that represents the 'CLOSE' state", + description: "Tap to set", + defaultValue: "", required: false) + } + section("If your sensor does not report a 'CLOSE' state, you can set a delay of seconds (0: Disabled) after which the state will be updated to 'CLOSE'") { + input("dev:${state.currentId}:off_delay", "number", + title: "Number of seconds", + description: "Tap to set", + defaultValue: "0", required: false) + } + } + // Virtual Motion Sensor + if (moduleParameter && moduleParameter.settings.contains('virtualMotionSensor') && childSetting(state.currentId, "bridge") != null) { + section("RF/IR Code") { + input("dev:${state.currentId}:payload_active", "text", + title: "Code that represents the 'ACTIVE' state", + description: "Tap to set", + defaultValue: "", required: false) + input("dev:${state.currentId}:payload_inactive", "text", + title: "Code that represents the 'INACTIVE' state", + description: "Tap to set", + defaultValue: "", required: false) + } + section("If your sensor does not report an 'INACTIVE' state, you can set a delay of seconds (0: Disabled) after which the state will be updated to 'INACTIVE'") { + input("dev:${state.currentId}:off_delay", "number", + title: "Number of seconds", + description: "Tap to set", + defaultValue: "0", required: false) + } + } + // Virtual Air Con + if (moduleParameter && moduleParameter.settings.contains('virtualAircon') && childSetting(state.currentId, "bridge") != null) { + def irBridge = getChildDevices().find { it.id == childSetting(state.currentId, "bridge") }?: null + def supportedVendor = irBridge?.currentSupportedVendor + + if (irBridge != null && supportedVendor != null) { + def vendor = new JsonSlurper().parseText(supportedVendor) + if (vendor != null && vendor.size() > 0) { + section("Air Conditioner brand") { + input ("dev:${state.currentId}:hvac", "enum", + title: "Select air conditioner brand", + description: "Select air conditioner brand", + multiple: false, required: false, options: vendor, submitOnChange: true) + } + section("Open/Close Sensor") { + input ("dev:${state.currentId}:contactSensor", "capability.contactSensor", title: "Select Open/Close Sensor", + description: "Choose the sensor that senses if the AC is On or Off", + multiple: false, required: false) + } + } else { + deleteChildSetting(state.currentId, "hvac") + section("Air Conditioner brand") { + paragraph "Please select an IR Bridge with \"tasmota-ir\" firmware. This firmware has all the available IR protocols." + } + } + } else { + deleteChildSetting(state.currentId, "hvac") + section("Air Conditioner brand") { + paragraph "Please select an IR Bridge with \"tasmota-ir\" firmware. This firmware has all the available IR protocols." + } + } + } + + // Potential Problem + if (moduleParameter && moduleParameter.settings.contains('ip')) { + def dc = getChildDevice(state.currentDeviceId) + String dni = dc.state?.dni + String actualDni = dc.deviceNetworkId + if ((dni != null && dni != actualDni)) { + section("Potential Problem Detected") { + paragraph "It appears that this device is either offline or there is another device using the same IP address or Device Network ID." + } + } + } + + section("DANGER ZONE", hideable: true, hidden: true) { + href "deleteDeviceConfirm", title:"DELETE $state.currentDisplayName", description: "Tap here to delete this device." + } + } + +} + +def deleteDeviceConfirm(){ + try { + def d = getChildDevice(state.currentDeviceId) + unsubscribe(d) + deleteChildDevice(state.currentDeviceId, true) + deleteChildSetting(d.id) + dynamicPage(name: "deleteDeviceConfirm", title: "", nextPage: "mainPage") { + section { + paragraph "The device has been deleted." + } + } + } catch (e) { + dynamicPage(name: "deleteDeviceConfirm", title: "Deletion Summary", nextPage: "mainPage") { + section { + paragraph "Error: ${(e as String).split(":")[1]}." + } + } + } +} + +def addDevice(){ + Map deviceOptions = [:] + moduleMap().sort({a, b -> a.value.name <=> b.value.name}).each { k,v -> + deviceOptions[k] = v.name + } + dynamicPage(name: "addDevice", title: "", nextPage: "addDeviceConfirm") { + section ("New Tasmota device") { + input ("virtualDeviceType", "enum", + title: "Which device do you want to add?", + description: "", multiple: false, required: true, options: deviceOptions, submitOnChange: false + ) + input ("deviceName", title: "Device Name", defaultValue: "Tasmota device", required: true, submitOnChange: false) + } + } +} + +def addDeviceConfirm() { + def latestDni = state.nextDni + if (virtualDeviceType) { + def selectedDevice = moduleMap().find{ it.key == virtualDeviceType }.value + try { + def virtualParent = addChildDevice("hongtat", selectedDevice?.type, "AWFULLYSMART-tasmota-${latestDni}", getHub()?.id, [ + "completedSetup": true, + "label": deviceName + ]) + // Tracks all installed devices + def deviceList = state?.deviceList ?: [] + deviceList.push(virtualParent.id as String) + state?.deviceList = deviceList + + // Cross-device Messaging + //if (selectedDevice?.messaging == true) { + // subscribe(virtualParent, "messenger", crossDeviceMessaging) + //} + + // Does this have child device(s)? + def channel = selectedDevice?.channel + log.debug "channel: " + channel + if (channel != null && selectedDevice?.child != false) { + if (channel > 1) { + try { + def parentChildName = selectedDevice.child[0] + for (i in 2..channel) { + parentChildName = (selectedDevice.child[i-2]) ?: parentChildName + String dni = "${virtualParent.deviceNetworkId}-ep${i}" + def virtualParentChild = virtualParent.addChildDevice(parentChildName, dni, virtualParent.hub.id, + [completedSetup: true, label: "${virtualParent.displayName} ${i}", isComponent: false]) + log.debug "Created '${virtualParent.displayName}' - ${i}ch" + } + } catch (all) { + dynamicPage(name: "addDeviceConfirm", title: "Add a device", nextPage: "mainPage") { + section { + paragraph "Error: ${(all as String).split(":")[1]}." + } + } + } + } + } + if (channel != null) { + virtualParent.updateDataValue("endpoints", channel as String) + } + virtualParent.initialize() + latestDni++ + state.nextDni = latestDni + dynamicPage(name: "addDeviceConfirm", title: "Add a device", nextPage: "mainPage") { + section { + paragraph "The device has been added. Please proceed to configure device." + } + } + } catch (e) { + dynamicPage(name: "addDeviceConfirm", title: "Have you added all the device handlers?", nextPage: "mainPage") { + section { + paragraph "Please follow these steps:", required: true + paragraph "1. Sign in to your SmartThings IDE.", required: true + paragraph "2. Under 'My Device Handlers' > click 'Settings' > 'Add new repository' > enter the following", required: true + paragraph " Owner: hongtat, Name: tasmota-connect, Branch: master", required: true + paragraph "3. Under 'Update from Repo' > click 'tasmota-connect' > Select all files > Tick 'Publish' > then 'Execute Update'", required: true + paragraph "Error message: ${(e as String).split(":")[1]}.", required: true + } + } + } + } else { + dynamicPage(name: "addDeviceConfirm", title: "Add a device", nextPage: "mainPage") { + section { + paragraph "Please try again." + } + } + } +} + +def installed() { + state?.nextDni = 1 + state?.deviceList = [] + state?.install = true +} + +def uninstalled() { + // Delete all child devices upon uninstall + getAllChildDevices().each { + deleteChildDevice(it.deviceNetworkId, true) + } +} + +def updated() { + if (!state?.nextDni) { state?.nextDni = 1 } + if (!state?.deviceList) { state?.deviceList = [] } + //log.debug "Updated with settings: ${settings}" + + unsubscribe() + + // Subscription + getChildDevices().eachWithIndex { cd, i -> + // contactSensor + def cs = childSetting(cd.id, "contactSensor") + if (cs != null) { + subscribe(cs, "contact", contactHandler) + } + // crossDeviceMessaging + def selectedDevice = moduleMap().find{ it.value.type == cd.typeName }?.value + if (selectedDevice?.messaging == true) { + subscribe(cd, "messenger", crossDeviceMessaging) + } + cd.initialize() + } + + // Clean up uninstalled devices + def deviceList = state?.deviceList ?: [] + def newDeviceList = [] + deviceList.each { entry -> + if (!getChildDevices().find { it.id == entry }) { deleteChildSetting(entry) } + else { newDeviceList.push(entry as String) } + } + state?.deviceList = newDeviceList + + // Set new Tasmota devices values to default + settingUpdate("deviceName", "Tasmota Device", "text") + settingUpdate("virtualDeviceType", "", "enum") +} + +def initialize() { +} + +/** + * Call Tasmota + * @param childDevice + * @param command + * @return + */ +def callTasmota(childDevice, command) { + // Virtual device sends bridge's ID, find the actual device's object + if (childDevice instanceof String) { + childDevice = getChildDevices().find { it.id == childDevice }?: null + } + // Real device sends its object + if (childSetting(childDevice.device.id, "ip")) { + updateDeviceNetworkId(childDevice) + def hubAction = new physicalgraph.device.HubAction( + method: "POST", + headers: [HOST: childSetting(childDevice.device.id, "ip") + ":80"], + path: "/cm?user=" + (childSetting(childDevice.device.id, "username") ?: "") + "&password=" + (childSetting(childDevice.device.id, "password") ? URLEncoder.encode(childSetting(childDevice.device.id, "password")) : "") + "&cmnd=" + URLEncoder.encode(command), + null, + [callback: "calledBackHandler"] + ) + log.debug "${childDevice.device.displayName} (" + childSetting(childDevice.device.id, "ip") + ") called: " + command + childDevice.sendHubCommand(hubAction) + } else { + log.debug "Please add the IP address of ${childDevice.device.displayName}." + } + return +} + +/** + * Get the JSON value from the incoming + * @param str + * @return + */ +def getJson(str) { + def parts = [] + def json = null + if (str) { + str.eachLine { line, lineNumber -> + if (lineNumber == 0) { + parts = line.split(" ") + return + } + } + if ((parts.length == 3) && parts[1].startsWith('/?json=')) { + String rawCode = parts[1].split("json=")[1].trim().replace('%20', ' ') + if ((rawCode.startsWith("{") && rawCode.endsWith("}")) || (rawCode.startsWith("[") && rawCode.endsWith("]"))) { + json = new JsonSlurper().parseText(rawCode) + } + } + } + return json +} + +def setNetworkAddress(String mac) { + mac.toUpperCase().replaceAll(':', '') +} + +def updateDeviceNetworkId(childDevice) { + def actualDeviceNetworkId = childDevice.device.deviceNetworkId + if (childDevice.state.dni != null && childDevice.state.dni != "" && actualDeviceNetworkId != childDevice.state.dni) { + log.debug "Updated '${childDevice.device.displayName}' dni to '${childDevice.state.dni}'" + childDevice.device.deviceNetworkId = "${childDevice.state.dni}" + } +} + +def channelNumber(String dni) { + if (dni.indexOf("-ep") >= 0) { + dni.split("-ep")[-1] as Integer + } else { + "" + } +} + +/** + * Return a list of installed child devices that match the input list + * @param typeList + * @return + */ +def childDevicesByType(List typeList) { + List result = [] + if (typeList && typeList.size() > 0) { + getChildDevices().each { + if (it.typeName in typeList) { + result << [(it.id): "${it.displayName}"] + } + } + } + return result +} + +Map moduleMap() { + Map customModule = [ + "1": [name: ".Sonoff Basic / Mini / RF / SV", type: "Tasmota Generic Switch"], + "4": [name: ".Sonoff TH", type: "Tasmota Generic Switch", channel: 2, child: ["Tasmota Child Temp/Humidity Sensor"]], + "5": [name: ".Sonoff Dual / Dual R2", type: "Tasmota Generic Switch", channel: 2], + "6": [name: ".Sonoff Pow / Pow R2 / S31", type: "Tasmota Metering Switch"], + "25": [name: ".Sonoff Bridge", type: "Tasmota RF Bridge"], + "44": [name: ".Sonoff iFan", type: "Tasmota Fan Light", channel: 2], + "1000": [name: "Generic Switch (1ch)", type: "Tasmota Generic Switch"], + "1001": [name: "Generic Switch (2ch)", type: "Tasmota Generic Switch", channel: 2], + "1002": [name: "Generic Switch (3ch)", type: "Tasmota Generic Switch", channel: 3], + "1003": [name: "Generic Switch (4ch)", type: "Tasmota Generic Switch", channel: 4], + "1004": [name: "Generic Switch (5ch)", type: "Tasmota Generic Switch", channel: 5], + "1005": [name: "Generic Switch (6ch)", type: "Tasmota Generic Switch", channel: 6], + "1006": [name: "Generic Switch (7ch)", type: "Tasmota Generic Switch", channel: 7], + "1007": [name: "Generic Switch (8ch)", type: "Tasmota Generic Switch", channel: 8], + "1010": [name: "Generic Metering Switch (1ch)", type: "Tasmota Metering Switch"], + "1011": [name: "Generic Metering Switch (2ch)", type: "Tasmota Metering Switch", channel: 2], + "1012": [name: "Generic Metering Switch (3ch)", type: "Tasmota Metering Switch", channel: 3], + "1013": [name: "Generic Metering Switch (4ch)", type: "Tasmota Metering Switch", channel: 4], + "1014": [name: "Generic Metering Switch (5ch)", type: "Tasmota Metering Switch", channel: 5], + "1015": [name: "Generic Metering Switch (6ch)", type: "Tasmota Metering Switch", channel: 6], + "1016": [name: "Generic Metering Switch (7ch)", type: "Tasmota Metering Switch", channel: 7], + "1017": [name: "Generic Metering Switch (8ch)", type: "Tasmota Metering Switch", channel: 8], + "1020": [name: "Generic Dimmer Switch", type: "Tasmota Dimmer Switch"], + "1021": [name: "Generic IR Bridge", type: "Tasmota IR Bridge"], + "1022": [name: "Generic Light (RGBW)", type: "Tasmota RGBW Light"], + "1023": [name: "Generic Light (RGB)", type: "Tasmota RGB Light"], + "1024": [name: "Generic Light (CCT)", type: "Tasmota CCT Light"], + "1100": [name: "Virtual Switch", type: "Tasmota Virtual Switch"], + "1101": [name: "Virtual Shade/Blind", type: "Tasmota Virtual Shade"], + "1111": [name: "Virtual 1-button", type: "Tasmota Virtual 1 Button"], + "1112": [name: "Virtual 2-button", type: "Tasmota Virtual 2 Button"], + "1114": [name: "Virtual 4-button", type: "Tasmota Virtual 4 Button"], + "1116": [name: "Virtual 6-button", type: "Tasmota Virtual 6 Button"], + "1117": [name: "Virtual Contact Sensor", type: "Tasmota Virtual Contact Sensor"], + "1118": [name: "Virtual Motion Sensor", type: "Tasmota Virtual Motion Sensor"], + "1119": [name: "Virtual Air Conditioner", type: "Tasmota Virtual Air Conditioner"] + ] + Map defaultModule = [ + "Tasmota Generic Switch": [channel: 1, messaging: false, virtual: false, child: ["Tasmota Child Switch Device"], settings: ["ip"]], + "Tasmota Metering Switch": [channel: 1, messaging: false, virtual: false, child: ["Tasmota Child Switch Device"], settings: ["ip"]], + "Tasmota Dimmer Switch": [channel: 1, messaging: false, virtual: false, child: false, settings: ["ip"]], + "Tasmota RGBW Light": [channel: 1, messaging: false, virtual: false, child: false, settings: ["ip"]], + "Tasmota RGB Light": [channel: 1, messaging: false, virtual: false, child: false, settings: ["ip"]], + "Tasmota CCT Light": [channel: 1, messaging: false, virtual: false, child: false, settings: ["ip"]], + "Tasmota Fan Light": [channel: 2, messaging: false, virtual: false, child: ["Tasmota Child Switch Device"], settings: ["ip", "healthState"]], + "Tasmota RF Bridge": [channel: 1, messaging: true, virtual: false, child: false, settings: ["ip"]], + "Tasmota IR Bridge": [channel: 1, messaging: true, virtual: false, child: false, settings: ["ip"]], + "Tasmota Virtual Contact Sensor": [channel: 1, messaging: true, virtual: true, child: false, settings: ["virtualContactSensor", "bridge"]], + "Tasmota Virtual Motion Sensor": [channel: 1, messaging: true, virtual: true, child: false, settings: ["virtualMotionSensor", "bridge"]], + "Tasmota Virtual Switch": [channel: 1, messaging: true, virtual: true, child: false, settings: ["virtualSwitch", "bridge"]], + "Tasmota Virtual Shade": [channel: 1, messaging: true, virtual: true, child: false, settings: ["virtualShade", "bridge"]], + "Tasmota Virtual 1 Button": [channel: 1, messaging: true, virtual: true, child: false, settings: ["virtualButton", "bridge"]], + "Tasmota Virtual 2 Button": [channel: 2, messaging: true, virtual: true, child: false, settings: ["virtualButton", "bridge"]], + "Tasmota Virtual 4 Button": [channel: 4, messaging: true, virtual: true, child: false, settings: ["virtualButton", "bridge"]], + "Tasmota Virtual 6 Button": [channel: 6, messaging: true, virtual: true, child: false, settings: ["virtualButton", "bridge"]], + "Tasmota Virtual Air Conditioner": [channel: 1, messaging: true, virtual: true, child: false, settings: ["virtualAircon", "bridge"]] + ] + Map modules = [:] + customModule.each { k,v -> + modules[k] = defaultModule[v.type] + v + } + return modules +} + +/** + * Cross device messaging between child devices + * @param evt + * @return + */ +def crossDeviceMessaging(evt) { + def d = evt.getDevice() + + log.debug "CDM - value: ${evt.jsonValue} - DNI: ${d.deviceNetworkId} - ID: ${d.id}" + def virtualDevices = moduleMap().findAll{ it.value.virtual }?.collect { it.value.type } + getChildDevices().findAll { it.typeName in virtualDevices }?.each { + def bridge = childSetting(it.id, "bridge") + if (bridge && bridge == d.id) { + it.parseEvents(200, evt.jsonValue) + } + } +} + +void contactHandler(evt) { + def d = evt.getDevice() + def virtualDevices = moduleMap().findAll{ it.value.virtual }?.collect { it.value.type } + + Map message = [:] + message.contactSensor = evt.value + getChildDevices().findAll { it.typeName in virtualDevices }?.each { + def cs = childSetting(it.id, "contactSensor") + if (cs && cs.id == d.id) { + it.parseEvents(200, message) + } + } +} + +/** + * Get SmartApp's general setting value + * @param name + * @return String | null + */ +def generalSetting(String name) { + return (settings?."${name}") ?: null +} + +/** + * Health Check - online/offline + * @return Integer + */ +Integer checkInterval() { + Integer interval = ((generalSetting("frequency") ?: 'Every 1 minute').replace('Every ', '').replace(' minutes', '').replace(' minute', '').replace('1 hour', '60')) as Integer + if (interval < 15) { + return (interval * 2 * 60 + 1 * 60) + } else { + return (30 * 60 + 2 * 60) + } +} + +/** + * Get child setting - this is stored in SmartApp + * @param id String device ID + * @param name String | List + * @return + */ +def childSetting(String id, name) { + def v = null + if (name instanceof String) { + v = (settings?."dev:${id}:${name}")?: null + } else if (name instanceof List) { + v = [:] + name.each() { entry -> + v[entry] = (settings?."dev:${id}:${entry}")?.trim()?: null + } + } + return (v instanceof String) ? v.trim() : v +} + +/** + * Delete child setting from SmartApp + * @param id + * @param name + * @return + */ +def deleteChildSetting(id, name=null) { + // If a name is given, delete the K/V + if (id && name) { + if (settings?.containsKey("dev:${id}:${name}" as String)) { + app?.deleteSetting("dev:${id}:${name}" as String) + } + } else if (id && name==null) { + // otherwise, delete everything + ["ip", "username", "password", "bridge", "command_on", "command_off", "track_state", "payload_on", "payload_off", "off_delay", "command_open", "command_close", "command_pause", "payload_open", "payload_close", "payload_pause", "payload_active", "payload_inactive", "hvac", "contactSensor", "health_state"].each { n -> + app?.deleteSetting("dev:${id}:${n}" as String) + } + // button + for(def n : 1..6) { + app?.deleteSetting("dev:${id}:button_${n}" as String) + } + } +} + +def settingUpdate(name, value, type=null) { + if(name && type) { app?.updateSetting("$name", [type: "$type", value: value]) } + else if (name && type == null) { app?.updateSetting(name.toString(), value) } +} + +private getHub() { + return location.getHubs().find{ it.getType().toString() == 'PHYSICAL' } +} \ No newline at end of file diff --git a/smartapps/ibeech/plex-manager.src/plex-manager.groovy b/smartapps/ibeech/plex-manager.src/plex-manager.groovy new file mode 100644 index 00000000000..254b734ab97 --- /dev/null +++ b/smartapps/ibeech/plex-manager.src/plex-manager.groovy @@ -0,0 +1,495 @@ +/** + * Plex Manager + * + * Copyright 2016 iBeech + * + * 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. + * + * + * ===========INSTRUCTIONS=========== + 1) For UK go to: https://graph-eu01-euwest1.api.smartthings.com3 + 2) For US go to: https://graph.api.smartthings.com1 + 3) Click 'My SmartApps' + 4) Click the 'From Code' tab + 5) Paste in the code from: https://github.com/iBeech/SmartThings/blob/master/PlexManager/PlexManager.groovy + 6) Click 'Create' + 7) Click 'Publish -> For Me' + * + */ + +definition( + name: "Plex Manager", + namespace: "ibeech", + author: "ibeech", + description: "Add and Manage Plex Home Theatre endpoints", + category: "Safety & Security", + iconUrl: "http://download.easyicon.net/png/1126483/64/", + iconX2Url: "http://download.easyicon.net/png/1126483/128/", + iconX3Url: "http://download.easyicon.net/png/1126483/128/") + +preferences { + page(name: "startPage") + page(name: "authPage") + page(name: "clientPage") +} + +def startPage() { + if (state?.authenticationToken) { return clientPage() } + else { return authPage() } +} + +/* Auth Page */ +def authPage() { + return dynamicPage(name: "authPage", nextPage: clientPage, install: false) { + section("Plex Media Server") { + input "plexUserName", "text", "title": "Plex Username", multiple: false, required: true + input "plexPassword", "password", "title": "Plex Password", multiple: false, required: true + input "plexServerIP", "text", "title": "Server IP", multiple: false, required: true + input "theHub", "hub", title: "On which hub?", multiple: false, required: true + } + } +} + +def clientPage() { + if (!state.authenticationToken) { getAuthenticationToken() } + def showUninstall = state.appInstalled + def devs = getClientList() + return dynamicPage(name: "clientPage", uninstall: true, install: true) { + section("Client Selection Page") { + input "selectedClients", "enum", title: "Select Your Clients...", options: devs, multiple: true, required: false, submitOnChange: true + href "authPage", title:"Go Back to Auth Page", description: "Tap to edit..." + input "pollEnable", "bool", title: "Enable Polling", defaultValue: "true", submitOnChange: true + input "showAllDevs", "bool", title: "Show All Devices Regardless of Capability", defaultValue: "false", submitOnChange: true + + } + } +} + +def clientListOpt() { + getClientList().collect{[(it.key): it.value]} +} + +def getClientList() { + def devs = [:] + log.debug "Executing 'getClientList'" + + def params = [ + uri: "https://plex.tv/devices.xml", + contentType: 'application/xml', + headers: [ + 'X-Plex-Token': state.authenticationToken + ] + ] + + // GET 3rd level IP of Plex server + + def plexServerIPShort = settings.plexServerIP.substring(0 , plexServerIP.lastIndexOf(".")) + + httpGet(params) { resp -> + log.debug "Parsing plex.tv/devices.xml" + def devices = resp.data.Device + + def deviceNames = [] + devices.each { thing -> + + def capabilities = thing.@provides.text() + + // If these capabilities + if(capabilities.contains("player")||capabilities.contains("client")||settings.showAllDevs){ + + //Define name based on name unless blank then use device name + def whatToCallMe = "${thing.@name.text()}" + if("${thing.@name.text()}"==""){whatToCallMe = "${thing.@device.text()}"} + + // Create alternative name if same name + def tempName = whatToCallMe + for (int i = 2; i < 100; i++) { + if(deviceNames.contains(tempName)){ + tempName = "${whatToCallMe} #$i" + }else{ + whatToCallMe = tempName + break + } + } + + deviceNames << whatToCallMe + + def addressVal = "0.0.0.0" + + // Get IP Address for those with an IP in the same range as your Plex Server if connection IP available (will only return a single entry for the local device) + thing.Connection.each { con -> + + def uri = con.@uri.text() + def address = (uri =~ 'https?://([^:]+)')[0][1] + + //Check if IP on same range + if(plexServerIPShort == address.substring(0 , address.lastIndexOf("."))){ + addressVal = address + } + } + + // Add to list + if(devs.findIndexValues { it =~ /${thing.@clientIdentifier.text()}/ } == []){ + devs << ["${whatToCallMe}|${thing.@clientIdentifier.text()}|${addressVal}": whatToCallMe as String] + } + } + } + } + return devs.sort { a, b -> a.value.toLowerCase() <=> b.value.toLowerCase() } +} + +def installed() { + state.appInstalled = true + log.debug "Installed with settings: ${settings}" + initialize() +} + +def initialize() { + + if (pollEnable) { + runEvery1Minute(regularPolling) + } +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + + if(selectedClients) { + + selectedClients.each { client -> + def item = client.tokenize('|') + def name = item[0] + def address = item[1] + def uniqueIdentifier = item[2] + + updatePHT(name, address, uniqueIdentifier); + } + } + + + if (!state.authenticationToken) { + getAuthenticationToken() + } + + initialize() + + subscribe(location, null, response, [filterEvents:false]) + +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(delete) { + + try { + delete.each { + deleteChildDevice(it.deviceNetworkId) + log.info "Successfully Removed Child Device: ${it.displayName} (${it.deviceNetworkId})" + } + } + catch (e) { log.error "There was an error (${e}) when trying to delete the child device" } +} + +def response(evt) { + + log.trace "in response(evt)"; + + def msg = parseLanMessage(evt.description); + if(msg && msg.body && msg.body.startsWith(" + + log.debug "Checking $pht for updates" + + // Convert the devices full network id to just the IP address of the device + //def address = getPHTAddress(pht.deviceNetworkId); + def identifier = getPHTIdentifier(pht.deviceNetworkId); + + // Look at all the current content playing, and determine if anything is playing on this device + def currentPlayback = mediaContainer.Video.find { d -> d.Player.@machineIdentifier.text() == identifier } + + // If there is no content playing on this device, then the state is stopped + def playbackState = "stopped"; + + // If we found active content on this device, look up its current state (i.e. playing or paused) + if(currentPlayback) { + + playbackState = currentPlayback.Player.@state.text(); + } + + log.trace "Determined that $pht is: " + playbackState + + pht.setPlaybackState(playbackState); + + log.trace "Current playback type:" + currentPlayback.@type.text() + pht.playbackType(currentPlayback.@type.text()) + switch(currentPlayback.@type.text()) { + case "movie": + pht.setPlaybackTitle(currentPlayback.@title.text()); + break; + + case "": + pht.setPlaybackTitle("..."); + break; + + case "clip": + pht.setPlaybackTitle("Trailer"); + break; + + case "episode": + pht.setPlaybackTitle(currentPlayback.@grandparentTitle.text() + ": " + currentPlayback.@title.text()); + } + } + + } +} + +def updatePHT(phtName, phtIP, phtIdentifier){ + + if(phtName && phtIP && phtIdentifier) { + + log.info "Updating PHT: " + phtName + " with IP: " + phtIP + " and machine identifier: " + phtIdentifier + + def children = getChildDevices() + def child_deviceNetworkID = childDeviceID(phtIP, phtIdentifier); + + def pht = children.find{ d -> d.deviceNetworkId.contains(phtIP) } + + if(!pht){ + // The PHT does not exist, create it + log.debug "This PHT does not exist, creating a new one now" + pht = addChildDevice("ibeech", "Plex Home Theatre", child_deviceNetworkID, theHub.id, [label:phtName, name:phtName]) + } else { + + // Update the network device ID + if(pht.deviceNetworkId != child_deviceNetworkID) { + log.trace "Updating this devices network ID, so that it is consistant" + pht.deviceNetworkId = childDeviceID(phtIP, phtIdentifier); + } + } + + // Renew the subscription + subscribe(pht, "switch", switchChange) + } +} + +def String childDeviceID(phtIP, identifier) { + + def id = "pht." + settings.plexServerIP + "." + phtIP + "." + identifier + //log.trace "childDeviceID: $id"; + return id; +} +def String getPHTAddress(deviceNetworkId) { + + def parts = deviceNetworkId.tokenize('.'); + def part = parts[6] + "." + parts[7] + "." + parts[8] + "." + parts[9]; + //log.trace "PHTAddress: $part" + + return part; +} +def String getPHTIdentifier(deviceNetworkId) { + + def parts = deviceNetworkId.tokenize('.'); + def part = parts[5]; + //log.trace "PHTIdentifier: $part" + + return part; +} +def String getPHTCommand(deviceNetworkId) { + + def parts = deviceNetworkId.tokenize('.'); + def part = parts[10]; + //log.trace "PHTCommand: $part" + + return part +} +def String getPHTAttribute(deviceNetworkId) { + + def parts = deviceNetworkId.tokenize('.'); + def part = parts[11]; + //log.trace "PHTAttribute: $part" + + return parts[11]; +} + +def switchChange(evt) { + + // We are only interested in event data which contains + if(evt.value == "on" || evt.value == "off") return; + + log.debug "Plex Home Theatre event received: " + evt.value; + + def parts = evt.value.tokenize('.'); + + // Parse out the PHT IP address from the event data + def phtIP = getPHTAddress(evt.value); + + // Parse out the new switch state from the event data + def command = getPHTCommand(evt.value); + + //log.debug "phtIP: " + phtIP + log.debug "Command: $command" + + switch(command) { + case "next": + log.debug "Sending command 'next' to $phtIP" + next(phtIP); + break; + + case "previous": + log.debug "Sending command 'previous' to $phtIP" + previous(phtIP); + break; + + case "play": + case "pause": + // Toggle the play / pause button for this PHT + playpause(phtIP); + break; + + case "stop": + stop(phtIP); + break; + + case "scanNewClients": + getClients(); + + case "setVolume": + setVolume(phtIP, getPHTAttribute(evt.value)); + break; + } + + return; +} + +def setVolume(phtIP, level) { + log.debug "Executing 'setVolume'" + + executeRequest("/system/players/$phtIP/playback/setParameters?volume=$level", "GET"); +} + +def regularPolling() { + + + log.debug "Polling for PHT state" + + if(state.authenticationToken) { + updateClientStatus(); + } + +} + +def updateClientStatus(){ + log.debug "Executing 'updateClientStatus'" + + executeRequest("/status/sessions", "GET") +} + +def playpause(phtIP) { + log.debug "Executing 'playpause'" + + executeRequest("/system/players/" + phtIP + "/playback/play", "GET"); +} + +def stop(phtIP) { + log.debug "Executing 'stop'" + + executeRequest("/system/players/" + phtIP + "/playback/stop", "GET"); +} + +def next(phtIP) { + log.debug "Executing 'next'" + + executeRequest("/system/players/" + phtIP + "/playback/skipNext", "GET"); +} + +def previous(phtIP) { + log.debug "Executing 'next'" + + executeRequest("/system/players/" + phtIP + "/playback/skipPrevious", "GET"); +} + +def executeRequest(Path, method) { + + log.debug "The " + method + " path is: " + Path; + + // We don't have an authentication token + if(!state.authenticationToken) { + getAuthenticationToken() + } + + def headers = [:] + headers.put("HOST", "$settings.plexServerIP:32400") + headers.put("X-Plex-Token", state.authenticationToken) + + try { + def actualAction = new physicalgraph.device.HubAction( + method: method, + path: Path, + headers: headers) + + sendHubCommand(actualAction) + } + catch (Exception e) { + log.debug "Hit Exception $e on $hubAction" + } +} + +def getAuthenticationToken() { + + log.debug "Getting authentication token for Plex Server " + settings.plexServerIP + + def params = [ + uri: "https://plex.tv/users/sign_in.json?user%5Blogin%5D=" + settings.plexUserName + "&user%5Bpassword%5D=" + URLEncoder.encode(settings.plexPassword), + headers: [ + 'X-Plex-Client-Identifier': 'Plex', + 'X-Plex-Product': 'Device', + 'X-Plex-Version': '1.0' + ] + ] + + try { + httpPostJson(params) { resp -> + state.tokenUserName = settings.plexUserName + state.authenticationToken = resp.data.user.authentication_token; + log.debug "Token is: " + state.authenticationToken + } + } + catch (Exception e) { + log.debug "Hit Exception $e on $params" + } +} + +/* Helper functions to get the network device ID */ +private String NetworkDeviceId(){ + def iphex = convertIPtoHex(settings.piIP).toUpperCase() + def porthex = convertPortToHex(settings.piPort) + return "$iphex:$porthex" +} + +private String convertIPtoHex(ipAddress) { + String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join() + //log.debug "IP address entered is $ipAddress and the converted hex code is $hex" + return hex + +} + +private String convertPortToHex(port) { + String hexport = port.toString().format( '%04x', port.toInteger() ) + //log.debug hexport + return hexport +} \ No newline at end of file diff --git a/smartapps/imnotbob/vacation-lighting-director.src/vacation-lighting-director.groovy b/smartapps/imnotbob/vacation-lighting-director.src/vacation-lighting-director.groovy new file mode 100644 index 00000000000..ddc812a6984 --- /dev/null +++ b/smartapps/imnotbob/vacation-lighting-director.src/vacation-lighting-director.groovy @@ -0,0 +1,620 @@ +/** + * Vacation Lighting Director (based off of tslagle's original) + * Supports Longer interval times (up to 180 mins) + * Only turns off lights it turned on (vs calling to turn all off) + * + * Updated to turn on a set of lights during active time, and turn them off at end of vacation time + * + * Source code can be found here: + * https://github.com/imnotbob/vacation-lighting-director/blob/master/smartapps/imnotbob/vacation-lighting-director.src/vacation-lighting-director.groovy + * + * Copyright 2017 Eric Schott + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +import java.text.SimpleDateFormat + +// Automatically generated. Make future change here. +definition( + name: "Vacation Lighting Director", + namespace: "imnotbob", + author: "ERS", + category: "Safety & Security", + description: "Randomly turn on/off lights to simulate the appearance of a occupied home while you are away.", + iconUrl: "http://icons.iconarchive.com/icons/custom-icon-design/mono-general-2/512/settings-icon.png", + iconX2Url: "http://icons.iconarchive.com/icons/custom-icon-design/mono-general-2/512/settings-icon.png" +) + +preferences { + page(name:"pageSetup") + page(name:"Setup") + page(name:"Settings") + page(name:"timeIntervalPage") + +} + +// Show setup page +def pageSetup() { + + def pageProperties = [ + name: "pageSetup", + title: "Status", + nextPage: null, + install: true, + uninstall: true + ] + + return dynamicPage(pageProperties) { + section(""){ + paragraph "This app can be used to make your home seem occupied anytime you are away from your home. " + + "Please use each of the the sections below to setup the different preferences to your liking. " + } + section("Setup Menu") { + href "Setup", title: "Setup", description: "", state:greyedOut() + href "Settings", title: "Settings", description: "", state: greyedOutSettings() + } + section([title:"Options", mobileOnly:true]) { + label title:"Assign a name", required:false + } + } +} + +// Show "Setup" page +def Setup() { + + def newMode = [ + name: "newMode", + type: "mode", + title: "Modes", + multiple: true, + required: true + ] + def switches = [ + name: "switches", + type: "capability.switch", + title: "Switches", + multiple: true, + required: true + ] + + def frequency_minutes = [ + name: "frequency_minutes", + type: "number", + title: "Minutes? (5-180)", + range: "5..180", + required: true + ] + + def number_of_active_lights = [ + name: "number_of_active_lights", + type: "number", + title: "Number of active lights", + required: true, + ] + + def on_during_active_lights = [ + name: "on_during_active_lights", + type: "capability.switch", + title: "On during active times", + multiple: true, + required: false + ] + + def pageName = "Setup" + + def pageProperties = [ + name: "Setup", + title: "Setup", + nextPage: "pageSetup" + ] + + return dynamicPage(pageProperties) { + + section(""){ + paragraph "In this section you need to setup the deatils of how you want your lighting to be affected while " + + "you are away. All of these settings are required in order for the simulator to run correctly." + } + section("Simulator Triggers") { + input newMode + href "timeIntervalPage", title: "Times", description: timeIntervalLabel() //, refreshAfterSelection:true + } + section("Light switches to cycle on/off") { + input switches + } + section("How often to cycle the lights") { + input frequency_minutes + } + section("Number of active lights at any given time") { + input number_of_active_lights + } + section("Lights to be on during active times?") { + input on_during_active_lights + } + } +} + +// Show "Setup" page +def Settings() { + + def falseAlarmThreshold = [ + name: "falseAlarmThreshold", + type: "decimal", + title: "Default is 2 minutes", + required: false + ] + def days = [ + name: "days", + type: "enum", + title: "Only on certain days of the week", + multiple: true, + required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + ] + + def pageName = "Settings" + + def pageProperties = [ + name: "Settings", + title: "Settings", + nextPage: "pageSetup" + ] + + def people = [ + name: "people", + type: "capability.presenceSensor", + title: "If these people are home do not change light status", + required: false, + multiple: true + ] + + return dynamicPage(pageProperties) { + + section(""){ + paragraph "In this section you can restrict how your simulator runs. For instance you can restrict on which days it will run " + + "as well as a delay for the simulator to start after it is in the correct mode. Delaying the simulator helps with false starts based on a incorrect mode change." + } + section("Delay to start simulator") { + input falseAlarmThreshold + } + section("People") { + paragraph "Not using this setting may cause some lights to remain on when you arrive home" + input people + } + section("More options") { + input days + } + } +} + +def timeIntervalPage() { + dynamicPage(name: "timeIntervalPage", title: "Only during a certain time") { + section { + input "startTimeType", "enum", title: "Starting at", options: [["time": "A specific time"], ["sunrise": "Sunrise"], ["sunset": "Sunset"]], submitOnChange:true + if (startTimeType in ["sunrise","sunset"]) { + input "startTimeOffset", "number", title: "Offset in minutes (+/-)", range: "*..*", required: false + } + else { + input "starting", "time", title: "Start time", required: false + } + } + section { + input "endTimeType", "enum", title: "Ending at", options: [["time": "A specific time"], ["sunrise": "Sunrise"], ["sunset": "Sunset"]], submitOnChange:true + if (endTimeType in ["sunrise","sunset"]) { + input "endTimeOffset", "number", title: "Offset in minutes (+/-)", range: "*..*", required: false + } + else { + input "ending", "time", title: "End time", required: false + } + } + } +} + +def installed() { + atomicState.Running = false + atomicState.schedRunning = false + atomicState.startendRunning = false + initialize() +} + +def updated() { + unsubscribe(); + clearState(true) + initialize() +} + +def initialize(){ + if (newMode != null) { + subscribe(location, modeChangeHandler) + } + schedStartEnd() + if(people) { + subscribe(people, "presence", modeChangeHandler) + } + log.debug "Installed with settings: ${settings}" + setSched() +} + +def clearState(turnOff = false) { + if(turnOff && atomicState?.Running) { + switches.off() + atomicState.vacactive_switches = [] + if(on_during_active_lights) { + on_during_active_lights.off() + } + log.trace "All OFF" + } + atomicState.Running = false + atomicState.schedRunning = false + atomicState.startendRunning = false + atomicState.lastUpdDt = null + unschedule() +} + +def schedStartEnd() { + if (starting != null || startTimeType != null) { + def start = timeWindowStart(true) + schedule(start, startTimeCheck) + atomicState.startendRunning = true + } + if (ending != null || endTimeType != null) { + def end = timeWindowStop(true) + schedule(end, endTimeCheck) + atomicState.startendRunning = true + } +} + +def setSched() { + atomicState.schedRunning = true +/* + def maxMin = 60 + def timgcd = gcd([frequency_minutes, maxMin]) + atomicState.timegcd = timgcd + def random = new Random() + def random_int = random.nextInt(60) + def random_dint = random.nextInt(timgcd.toInteger()) + + def newDate = new Date() + def curMin = newDate.format("m", getTimeZone()) + + def timestr = "${random_dint}/${timgcd}" + if(timgcd == 60) { timestr = "${curMin}" } + + log.trace "scheduled using Cron (${random_int} ${timestr} * 1/1 * ? *)" + schedule("${random_int} ${timestr} * 1/1 * ? *", scheduleCheck) // this runs every timgcd minutes +*/ + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 2 * 60 + runIn(delay, initCheck) +} + +private gcd(a, b) { + while (b > 0) { + long temp = b; + b = a % b; + a = temp; + } + return a; +} + +private gcd(input = []) { + long result = input[0]; + for(int i = 1; i < input.size; i++) result = gcd(result, input[i]); + return result; +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler ${evt}" + setSched() +} + +def initCheck() { + scheduleCheck(null) +} + +def failsafe() { + scheduleCheck(null) +} + +def startTimeCheck() { + log.trace "startTimeCheck" + setSched() +} + +def endTimeCheck() { + log.trace "endTimeCheck" + scheduleCheck(null) +} + +def getDtNow() { + def now = new Date() + return formatDt(now) +} + +def formatDt(dt) { + def tf = new SimpleDateFormat("E MMM dd HH:mm:ss z yyyy") + if(getTimeZone()) { tf.setTimeZone(getTimeZone()) } + else { + log.warn "SmartThings TimeZone is not found or is not set... Please Try to open your ST location and Press Save..." + } + return tf.format(dt) +} + +def GetTimeDiffSeconds(lastDate) { + if(lastDate?.contains("dtNow")) { return 10000 } + def now = new Date() + def lastDt = Date.parse("E MMM dd HH:mm:ss z yyyy", lastDate) + def start = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(lastDt)).getTime() + def stop = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(now)).getTime() + def diff = (int) (long) (stop - start) / 1000 + return diff +} + +def getTimeZone() { + def tz = null + if (location?.timeZone) { tz = location?.timeZone } + if(!tz) { log.warn "getTimeZone: SmartThings TimeZone is not found or is not set... Please Try to open your ST location and Press Save..." } + return tz +} + +def getLastUpdSec() { return !atomicState?.lastUpdDt ? 100000 : GetTimeDiffSeconds(atomicState?.lastUpdDt).toInteger() } + +//Main logic to pick a random set of lights from the large set of lights to turn on and then turn the rest off + +def scheduleCheck(evt) { + if(allOk && getLastUpdSec() > ((frequency_minutes - 1) * 60) ) { + atomicState?.lastUpdDt = getDtNow() + log.debug("Running") + atomicState.Running = true + + // turn off switches + def inactive_switches = switches + def vacactive_switches = [] + if (atomicState.Running) { + if (atomicState?.vacactive_switches) { + vacactive_switches = atomicState.vacactive_switches + if (vacactive_switches?.size()) { + for (int i = 0; i < vacactive_switches.size() ; i++) { + inactive_switches[vacactive_switches[i]].off() + log.trace "turned off ${inactive_switches[vacactive_switches[i]]}" + } + } + } + atomicState.vacactive_switches = [] + } + + def random = new Random() + vacactive_switches = [] + def numlight = number_of_active_lights + if (numlight > inactive_switches.size()) { numlight = inactive_switches.size() } + log.trace "inactive switches: ${inactive_switches.size()} numlight: ${numlight}" + for (int i = 0 ; i < numlight ; i++) { + + // grab a random switch to turn on + def random_int = random.nextInt(inactive_switches.size()) + while (vacactive_switches?.contains(random_int)) { + random_int = random.nextInt(inactive_switches.size()) + } + vacactive_switches << random_int + } + for (int i = 0 ; i < vacactive_switches.size() ; i++) { + inactive_switches[vacactive_switches[i]].on() + log.trace "turned on ${inactive_switches[vacactive_switches[i]]}" + } + atomicState.vacactive_switches = vacactive_switches + //log.trace "vacactive ${vacactive_switches} inactive ${inactive_switches}" + + if(on_during_active_lights) { + on_during_active_lights.on() + log.trace "turned on ${on_during_active_lights}" + } + def delay = frequency_minutes + def random_int = random.nextInt(14) + log.trace "reschedule ${delay} + ${random_int} minutes" + runIn( (delay+random_int)*60, initCheck, [overwrite: true]) + runIn( (delay+random_int + 10)*60, failsafe, [overwrite: true]) + + } else if(allOk && getLastUpdSec() <= ((frequency_minutes - 1) * 60) ) { + log.trace "had to reschedule ${getLastUpdSec()}, ${frequency_minutes*60}" + runIn( (frequency_minutes*60-getLastUpdSec()), initCheck, [overwrite: true]) + } else if(people && someoneIsHome){ + //don't turn off lights if anyone is home + if (atomicState?.schedRunning) { + log.debug("Someone is home - Stopping Schedule Vacation Lights") + clearState() + } + } else if (!modeOk || !daysOk) { + if (atomicState?.Running || atomicState?.schedRunning) { + log.debug("wrong mode or day Stopping Vacation Lights") + clearState(true) + } + } else if (modeOk && daysOk && !timeOk) { + if (atomicState?.Running || atomicState?.schedRunning) { + log.debug("wrong time - Stopping Vacation Lights") + clearState(true) + } + } + if (!atomicState.startendRunning) { + schedStartEnd() + } + return true +} + +//below is used to check restrictions +private getAllOk() { + modeOk && daysOk && timeOk && homeIsEmpty +} + + +private getModeOk() { + def result = !newMode || newMode.contains(location.mode) + //log.trace "modeOk = $result" + result +} + +private getDaysOk() { + def result = true + if (days) { + def df = new java.text.SimpleDateFormat("EEEE") + if (getTimeZone()) { + df.setTimeZone(getTimeZone()) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + //log.trace "daysOk = $result" + result +} + +private getHomeIsEmpty() { + def result = true + + if(people?.findAll { it?.currentPresence == "present" }) { + result = false + } + + //log.debug("homeIsEmpty: ${result}") + + return result +} + +private getSomeoneIsHome() { + def result = false + + if(people?.findAll { it?.currentPresence == "present" }) { + result = true + } + //log.debug("someoneIsHome: ${result}") + return result +} + +private getTimeOk() { + def result = true + def start = timeWindowStart() + def stop = timeWindowStop(false, true) + if (start && stop && getTimeZone()) { + result = timeOfDayIsBetween( (start), (stop), new Date(), getTimeZone()) + } + //log.debug "timeOk = $result" + result +} + +private timeWindowStart(usehhmm=false) { + def result = null + if (startTimeType == "sunrise") { + result = location.currentState("sunriseTime")?.dateValue + if (result && startTimeOffset) { + result = new Date(result.time + Math.round(startTimeOffset * 60000)) + } + } + else if (startTimeType == "sunset") { + result = location.currentState("sunsetTime")?.dateValue + if (result && startTimeOffset) { + result = new Date(result.time + Math.round(startTimeOffset * 60000)) + } + } + else if (starting && getTimeZone()) { + if(usehhmm) { result = timeToday(hhmm(starting), getTimeZone()) } + else { result = timeToday(starting, getTimeZone()) } + } + //log.debug "timeWindowStart = ${result}" + result +} + +private timeWindowStop(usehhmm=false, adj=false) { + def result = null + if (endTimeType == "sunrise") { + result = location.currentState("sunriseTime")?.dateValue + if (result && endTimeOffset) { + result = new Date(result.time + Math.round(endTimeOffset * 60000)) + } + } + else if (endTimeType == "sunset") { + result = location.currentState("sunsetTime")?.dateValue + if (result && endTimeOffset) { + result = new Date(result.time + Math.round(endTimeOffset * 60000)) + } + } + else if (ending && getTimeZone()) { + if(usehhmm) { result = timeToday(hhmm(ending), getTimeZone()) } + else { result = timeToday(ending, getTimeZone()) } + } + def result1 + if(adj) { // small change for schedule skewing + result1 = new Date(result.time - (2*60*1000)) + log.debug "timeWindowStop = ${result} adjusted: ${result1}" + result = result1 + } + //log.debug "timeWindowStop = ${result} adjusted: ${result1}" + result +} + +private hhmm(time, fmt = "HH:mm") { + def t = timeToday(time, getTimeZone()) + def f = new java.text.SimpleDateFormat(fmt) + f.setTimeZone(getTimeZone() ?: timeZone(time)) + f.format(t) +} + +private timeIntervalLabel() { + def start = "" + switch (startTimeType) { + case "time": + if (starting) { + start += hhmm(starting) + } + break + case "sunrise": + case "sunset": + start += startTimeType[0].toUpperCase() + startTimeType[1..-1] + if (startTimeOffset) { + start += startTimeOffset > 0 ? "+${startTimeOffset} min" : "${startTimeOffset} min" + } + break + } + + def finish = "" + switch (endTimeType) { + case "time": + if (ending) { + finish += hhmm(ending) + } + break + case "sunrise": + case "sunset": + finish += endTimeType[0].toUpperCase() + endTimeType[1..-1] + if (endTimeOffset) { + finish += endTimeOffset > 0 ? "+${endTimeOffset} min" : "${endTimeOffset} min" + } + break + } + start && finish ? "${start} to ${finish}" : "" +} + +//sets complete/not complete for the setup section on the main dynamic page +def greyedOut(){ + def result = "" + if (switches) { + result = "complete" + } + result +} + +//sets complete/not complete for the settings section on the main dynamic page +def greyedOutSettings(){ + def result = "" + if (people || days || falseAlarmThreshold ) { + result = "complete" + } + result +} \ No newline at end of file diff --git a/smartapps/jdiben/smart-porch-light.src/smart-porch-light.groovy b/smartapps/jdiben/smart-porch-light.src/smart-porch-light.groovy new file mode 100644 index 00000000000..7e5a632a0b3 --- /dev/null +++ b/smartapps/jdiben/smart-porch-light.src/smart-porch-light.groovy @@ -0,0 +1,705 @@ +/** + * Porch Light + * + * Copyright 2015 Joseph DiBenedetto + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +import java.text.SimpleDateFormat +import groovy.time.TimeCategory + +definition( + name: "Smart Porch Light", + namespace: "jdiben", + author: "Joseph DiBenedetto", + description: "Turn on your porch light, or any other dimmable light, dimmed to a level you set at sunset and increase to full brightness when someone arrives or some other action is triggered. After a number of minutes the light will dim back to its original level. Optionally, set the light to turn off at a specified time while still turning on when someone arrives. This app runs from sunset to sunrise.\n\nSelect as many lights as you like. Additional triggers include motion detection, door knock, door open and app button. A door bell trigger will also be added in the future.", + category: "Safety & Security", + iconUrl: "http://apps.shiftedpixel.com/porchlight/porchlight.png", + iconX2Url: "http://apps.shiftedpixel.com/porchlight/porchlight@2x.png", + iconX3Url: "http://apps.shiftedpixel.com/porchlight/porchlight@2x.png" +) + +preferences { + page(name: "mainSettingsPage") + page(name: "scheduleSettingsPage") +} + +def mainSettingsPage() { + dynamicPage( + name: "mainSettingsPage", + title: "", + install: true, + uninstall: true + ) { + + //Lights/switches to use. Only allow dimmable lights + section("Control these switches") { + input ( + name: "switches", + type: "capability.switchLevel", + title: "Switches?", + required: true, + multiple: true + ) + } + + //Presence Sensors (including phones) + section("When these people arrive", hideable:true, hidden:(presence == null) ? true : false) { + input ( + name: "presence", + type: "capability.presenceSensor", + title: "Who?", + required: false, + multiple: true + ) + + input ( + name: "brightnessLevelPresence", + type: "number", + title: "Brightness Level (1-100)?", + required: false, + defaultValue: 100 + ) + } + + //Motion sensors + section("When motion is detected", hideable:true, hidden:(motion == null) ? true : false) { + input ( + name: "motion", + type: "capability.motionSensor", + title: "Which?", + required: false, + multiple: true + ) + + input ( + name: "brightnessLevelMotion", + type: "number", + title: "Brightness Level (1-100)?", + required: false, + defaultValue: 100 + ) + } + + //Contact sensors + section("When these doors are opened", hideable:true, hidden:(contact == null) ? true : false) { + input ( + name: "contact", + type: "capability.contactSensor", + title: "Which?", + required: false, + multiple: true + ) + + input ( + name: "brightnessLevelContact", + type: "number", + title: "Brightness Level (1-100)?", + required: false, + defaultValue: 100 + ) + } + + //Vibration sensors to detect a knock + section("When someone knocks on these doors", hideable:true, hidden:(acceleration == null) ? true : false) { + input ( + name: "acceleration", + type: "capability.accelerationSensor", + title: "Which?", + required: false, + multiple: true + ) + + input ( + name: "brightnessLevelAcceleration", + type: "number", + title: "Brightness Level (1-100)?", + required: false, + defaultValue: 100 + ) + } + + //Enable a button overlay on the app icon to trigger lights + section("When the app button is tapped", hideable:true, hidden:(appButton != true) ? true : false) { + input ( + name: "appButton", + type: "bool", + title: "Tap to brighten lights?", + defaultValue: false, + required: false + ) + + input ( + name: "brightnessLevelTap", + type: "number", + title: "Brightness Level (1-100)?", + required: false, + defaultValue: 100 + ) + } + + //Minutes after event is detected before lights are set to their standby levels + section("Dim after") { + paragraph "The number of minutes after an event is triggered before the lights are dimmed." + input ( + name: "autoOffMinutes", + type: "number", + title: "Minutes (0 - 30)", + required: false, + defaultValue: 5 + ) + } + + section("Standby Light Brightness") { + paragraph "The brightness level that the lights will be set to at sunset and whenever an event times out." + input ( + name: "brightnessLevelDefault", + type: "number", + title: "Brightness Level (1-100)?", + required: false, + defaultValue: 10 + ) + paragraph "If the standby brightness is changed manually, remember that level and override the standby level above until the next day. Due to the delay caused by SmartThings device polling, this may not always work as expected." + input ( + name: "rememberLevel", + type: "bool", + title: "Remember changes", + defaultValue: true + ) + } + + //Open the scheduling page + section("Schedule") { + href( + title: "Active from", + name: "toScheduleSettingsPage", + page: "scheduleSettingsPage", + description: readableSchedule(), //Display a more readable schedule description + state: "complete" + ) + } + + //Enable certain events to output to hello home + section("Use Notifications") { + input ( + name: "useHelloHome", + type: "bool", + title: "Show events in Notifications?", + defaultValue: true + ) + } + + //Specify a display name for this app (optional) + section("Assign a Name") { + label( + name: "appName", + title: "App Name (optional)", + required: false, + multiple: false + ) + } + } +} + +def scheduleSettingsPage() { + dynamicPage( + name: "scheduleSettingsPage", + install: false, + uninstall: false, + nextPage: "mainSettingsPage" + ) { + section("Schedule") { + paragraph "By default, the app runs from sunset to sunrise. You can offset both sunset and sunrise by up to +/- 2 hours" + + input ( + name: "sunsetOffset", + type: "enum", + title: "Sunset Offset in minutes?", + options: ['-120', '-105', '-90', '-75', '-60', '-45', '-30', '-15', '0', '15', '30', '45', '60', '75', '90', '105', '120'], + defaultValue: "0" + ) + + input ( + name: "sunriseOffset", + type: "enum", + title: "Sunrise Offset in minutes?", + options: ['-120', '-105', '-90', '-75', '-60', '-45', '-30', '-15', '0', '15', '30', '45', '60', '75', '90', '105', '120'], + defaultValue: "0" + ) + } + + section("Lights off override") { + paragraph "By default, the lights will turn off at sunrise when the app goes to sleep. Here, you can override the time that those lights are turned off. This will cause the light to turn off when no one is around instead of just dimming. The lights will still come on when someone arrives until sunrise. Leave the time blank to keep the lights on until sunrise." + input( + name: "timeDefaultEnd", + type: "time", + title: "Turn off at", + required: false, + defaultValue: null + ) + } + } +} + +def installed() { + debug("Installed with settings: ${settings}") + initialize() +} + +def updated() { + debug("Updated with settings: ${settings}") + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + state.inDefault = false //Are we in the default schedule. Meaning the lights go back to standby levels after an event + state.enabled = false //Is the app enabled for events (sunrise to sunset) + state.active = false //Is an event currently active + state.levels = [] + state.lastVerified = new Date() + state.nextEvent = 'sunset' + + def times = getSunriseAndSunset(sunsetOffset: formatOffset(sunsetOffset), sunriseOffset: formatOffset(sunriseOffset), date: new Date()) + def now = new Date() + if (now < times.sunrise || now > times.sunset) { + sunsetHandler() + } else { + scheduleNextEvent() + } + + scheduleDefaultOff() + + //Enable events + if (presence != null) subscribe(presence, "presence", presenceHandler) + if (motion != null) subscribe(motion, "motion", motionHandler) + if (contact != null) subscribe(contact, "contact", contactHandler) + if (acceleration != null) subscribe(acceleration, "acceleration.active", accelerationHandler) + if (appButton) subscribe(app, appTouchHandler) + + startVerificationSchedule() +} + +def sunsetHandler() { + + debug('Sunset') + + state.enabled = true + state.inDefault = true + state.active = false + state.levels.clear() + state.nextEvent = 'sunrise' + + def output = "Smart Porch Light is now active" + + if (brightnessLevelDefault != null && brightnessLevelDefault > 0) { + + lightSet(brightnessLevelDefault) + + output = output + " and has turned your light" + plural(switches.size())[0] + " on to " + brightnessLevelDefault + "%" + + } + + helloHome(output + ".") + + scheduleNextEvent() + +} + +def scheduleSunset() { + + def runTime = getSunriseAndSunset(sunsetOffset: formatOffset(sunsetOffset)) + def sunset = runTime.sunset.format("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + + state.nextEventTime = sunset + + debug("Sunset scheduled for " + sunset.toString()) + + String sunsetString = sunset.toString() + runOnce(sunsetString, "sunsetHandler") + + verifySchedule() +} + +def sunriseHandler() { + + debug('Sunrise') + + state.inDefault = false + state.active = false + state.enabled = false + state.nextEvent = 'sunset' + + defaultOff() + + if (timeDefaultEnd == null) { + helloHome("It's sunrise. Smart Porch Light has turned off your light" + plural(switches.size())[0] + " and is now inactive.") + } else { + helloHome("It's sunrise. Smart Porch Light is now inactive.") + } + + scheduleNextEvent() +} + +def scheduleSunrise() { + def runTime = getSunriseAndSunset(sunriseOffset: formatOffset(sunriseOffset)) + def sunrise = runTime.sunrise + + if (new Date() > runTime.sunrise) { + runTime = getSunriseAndSunset(sunriseOffset: formatOffset(sunriseOffset), date: new Date() + 1) + } + + sunrise = runTime.sunrise.format("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + + state.nextEventTime = sunrise + + debug("Sunrise scheduled for " + sunrise.toString()) + + String sunriseString = sunrise.toString() + runOnce(sunriseString, "sunriseHandler") + + verifySchedule() +} + +def defaultOffHandler() { + state.nextEvent = 'sunrise' + + defaultOff() + + helloHome("Smart Porch Light has turned off your light" + plural(switches.size())[0] + " as scheduled.") + + verifySchedule() +} + +def scheduleDefaultOff() { + if (brightnessLevelDefault != null && timeDefaultEnd != null) { + schedule(timeDefaultEnd, defaultOffHandler) + debug("Default off set for " + timeDefaultEnd) + } +} + +def scheduleNextEvent() { + if (state.nextEvent == 'sunset') { + debug("Next Event: Sunset") + scheduleSunset() + } else { + debug("Next Event: Sunrise") + scheduleSunrise() + } +} + + + +def presenceHandler(evt) { + + if(evt.value == "present" && state.enabled && !state.active) { + lightOnEvent(brightnessLevelPresence) + + scheduleAutoOff() + + helloHome(evt.displayName + " arrived. Smart Porch Light has turned your light" + plural(switches.size())[0] + " on to " + brightnessLevelPresence + "%.") + } + + verifySchedule() +} + +def motionHandler(evt) { + if (evt.value == "active" && state.enabled && !state.active) { + lightOnEvent(brightnessLevelMotion) + + scheduleAutoOff() + + helloHome(evt.displayName + " detected motion. Smart Porch Light has turned your light" + plural(switches.size())[0] + " on to " + brightnessLevelMotion + "%.") + } + verifySchedule() +} + +def contactHandler(evt) { + if (evt.value == "open" && state.enabled && !state.active) { + def reset=100 + if (brightnessLevelContact != null) reset = brightnessLevelContact + lightOnEvent(reset) + scheduleAutoOff() + + helloHome(evt.displayName + " opened. Smart Porch Light has turned your light" + plural(switches.size())[0] + " on to " + brightnessLevelContact + "%.") + } + verifySchedule() +} + +def accelerationHandler(evt) { + if (evt.value == "active" && state.enabled && !state.active) { + def reset=100 + if (brightnessLevelAcceleration != null) reset = brightnessLevelAcceleration + lightOnEvent(reset) + scheduleAutoOff() + + helloHome("Someone knocked on " + evt.displayName + ". Smart Porch Light has turned your light" + plural(switches.size())[0] + " on to " + brightnessLevelAcceleration + "%.") + } + verifySchedule() +} + +def appTouchHandler(evt) { + def reset=100 + if (brightnessLevelTap != null) reset = brightnessLevelTap + lightOnEvent(reset) + scheduleAutoOff() + verifySchedule() +} + + + +def verifySchedule() { + //This method is run every 15 minutes. It's also run each time a trigger is fired in case the schedule that controlls this fails too + + def currentTime = new Date() + use(TimeCategory) { + /* + Because we run this method at the time a schedule changes, it's possible that the next event time will not have been updated yet. + This could cause a false positive when checking to see if a schedule was missed. So we set the current time back 1 minute to give + it a sufficient buffer for comparison + */ + currentTime = currentTime - 1.minutes + } + + if ( + (state.nextEventTime instanceof Date && new Date() > state.nextEventTime) || + (state.nextEventTime instanceof String && new Date() > new Date().parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", state.nextEventTime)) + ) { + //If we get here it means we missed an event and need to restart the schedules + debug("SCHEDULE FAILED - MISSED EVENT") + restartVerifictionSchedule() + } + + if ( + (state.lastVerified instanceof Date && new Date() - state.lastVerified > 1200000) || + (state.lastVerified instanceof String && new Date() - new Date().parse("yyyy-MM-dd'T'HH:mm:ssZ", state.lastVerified) > 1200000) + ) { + //If we get here it means that it's been more then 20 minutes since this method was scheduled to run, so lets restart everything + debug("SCHEDULE FAILED - MISSED VERIFICATION") + restartVerifictionSchedule() + } + + state.lastVerified = currentTime +} + +def startVerificationSchedule() { + //Start the verifiction schedule that ensures everything is still on schedule + schedule("0 0/10 * * * ?", "verifySchedule") +} + +def restartVerifictionSchedule() { + //We're here because a schedule failed + helloHome("SCHEDULE FAILED - RESTARTING SMART PORCH LIGHT") + updated() //Restart the app to reset the schedules + + + //sendPushMessage('Smart Porch Ligt schedule failed and has been restarted') +} + +def scheduleAutoOff() { + //Schedule lights to dim after x minutes. + //This is executed after every event and is reset to x minutes on all subsequent events if this schedule hasn't yet run. + if (autoOffMinutes != null) { + debug('Auto off is scheduled for ' + autoOffMinutes + ' minutes') + + //Make sure that a valid number was specified. Adjust number if needed + if (autoOffMinutes < 1) { + autoOffMinutes = 1 + } else if (autoOffMinutes > 30) { + autoOffMinutes = 30 + } + runIn(60 * autoOffMinutes, autoOff) + } +} + +def autoOff() { + //Reset lights to default level + + state.active = false + + lightReset() + + def output = "It's been "+ autoOffMinutes + " minute" + plural(autoOffMinutes)[0] + ". " + if (state.inDefault) { + output += "Resetting your light" + plural(switches.size())[0] + " to standby." + } else { + output += "Turning your light" + plural(switches.size())[0] + " off." + } + helloHome(output) +} + +def lightSet(level) { + //Don't allow values above 100% for brightness + if (level > 100) { + level = 100 + } else if (level == null) { + level = 0 + } + + //Set lights to specified level + switches.setLevel(level) + debug('brightness set to ' + level) +} + +def lightOnEvent(level) { + state.active = true + if (rememberLevel) { + state.levels.clear() + switches.each { + state.levels.add(it.currentValue('level')) + } + } + lightSet(level) +} + +def lightReset() { + if (rememberLevel && state.levels.size() == switches.size()) { + switches.eachWithIndex { it, i -> + it.setLevel(state.levels[i]) + //helloHome("Light reset to " + state.levels[i] + "%") + } + } else { + //set default "reset" to 0% brightness + def reset = 0 + + //If brightness level is set, use that instead of the default set above + if (state.inDefault && brightnessLevelDefault != null) reset = brightnessLevelDefault + + debug('Auto off executed - reset to default level') + lightSet(reset) + } + + debug('reset lights') +} + + + +def defaultOn() { + //Enables app at sunset and turns lights on to default level + state.inDefault = true + lightReset() + debug('Default - schedule started') +} + +def defaultOff() { + //Disable app at sunrise or when scheduled and turn lights off + state.inDefault = false + state.active = false + switches.off() + + debug('Default - schedule ended') +} + + + +def readableSchedule() { + + //Create a more readable schedule description to display on the main settings page when setting on the schedule page are modified. + + def sunrise = (sunriseOffset == null) ? 0 : sunriseOffset.toInteger() + def sunset = (sunsetOffset == null) ? 0 : sunsetOffset.toInteger() + + //def output = "Active from\n" + def output = "" + + if (sunset != null && sunset !=0) output += convertMinutes(sunset) + ((sunset > 0) ? " after " : " before ") + output += "sunset to" + + if (sunrise != null && sunrise !=0) output += " " + convertMinutes(sunrise) + ((sunrise > 0) ? " after" : " before") + output += " sunrise." + + if (timeDefaultEnd != null) { + output += "\n\nStandby light" + plural(switches.size())[0] + " turn" + plural(switches.size(), true)[0] + " off at " + + def outputFormat = new SimpleDateFormat("h:mm a") + def inputFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS") + + def date = inputFormat.parse(timeDefaultEnd) + output += outputFormat.format(date) + } + + return output +} + +def convertMinutes(totalMinutes) { + + totalMinutes = totalMinutes.abs() + int hours = Math.floor(totalMinutes / 60).toInteger() + int minutes = (totalMinutes % 60) + + def output = "" + + if (hours > 0) { + output += hours + " hour" + plural(hours)[0] + if (minutes > 0) output += ' and ' + } + + if (minutes > 0) output += minutes + " minutes" + + return output +} + +def plural(count, r = false) { + //return an "s" to append to a word if "count" indicates zero or more than one + //Is this really necessary? No, but it makes me happy. + + def language = [] + + if ((count == 1 && !r) || (count != 1 && r)) { + language.addAll(['','is','was']) + } else { + language.addAll(['s','are','were']) + } + + return language +} + +def formatOffset(offset) { + def formatted ='' + if (offset == null || offset == 0) { + formatted = "00:00" + } else { + offset = offset.toInteger() + if (offset < 0) formatted += '-' + + def totalMinutes = offset.abs() + int hours = Math.floor(totalMinutes / 60).toInteger() + int minutes = (totalMinutes % 60) + + formatted += "0" + hours + ":" + + if (minutes == 0) { + formatted += "00" + } else if (minutes < 10) { + formatted += "0" + minutes + } else { + formatted += "" + minutes + } + } + + return formatted +} + +def debug(msg) { + //Enable debugging. Comment out line below to disable output. + log.debug(msg) + + //Uncomment the next line to send debugging messages to hello, home. I use this when live logging breaks, which is often for me, and when I need a way to view data that's logged when I'm not logged in. + //sendNotificationEvent("DEBUG: " + msg) +} + +def helloHome(msg) { + if (useHelloHome) sendNotificationEvent(msg) + log.debug("Hello, home: " + msg) +} \ No newline at end of file diff --git a/smartapps/jmarkwell/thermostat-manager.src/thermostat-manager.groovy b/smartapps/jmarkwell/thermostat-manager.src/thermostat-manager.groovy new file mode 100644 index 00000000000..131bf22d3fa --- /dev/null +++ b/smartapps/jmarkwell/thermostat-manager.src/thermostat-manager.groovy @@ -0,0 +1,974 @@ +/* + * Thermostat Manager + * Build 2021022104 + * + * Copyright 2021 Jordan Markwell + * + * 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. + * + * ChangeLog: + * + * 20210221 + * 01: tempHandler() will no longer set "heat" or "off" mode in the case that Externally Controlled Emergency Heat is enabled and + * the currentOutdoorTemp is below the emergencyHeatThreshold. + * 02: "off" mode can no longer be conditionally set when the currentTemp reaches the heatingThreshold in "emergency heat" mode. + * 03: Added currentOutdoorTemp to tempHandler() debug logging. + * 04: Updates to code comments. + * + * 20210218 + * 01: Discovered that Zen Thermostat's device handler doesn't have the emergencyHeat() command. Revised set commands to use + * parameter based setThermostatMode() and setThermostatFanMode() instead. + * + * 20210207 + * 01: Improvements to the error handling of the new dual capability system. + * 02: Users will now be notified when Thermostat Manager fails to make an automated change due to a missing device capability. + * 03: openContactPause() will now only set state.lastThermostatMode on the first time run per pause. + * 04: Updates to code comments. + * + * 20210202 + * 01: Selecting a capability.thermostat device is no longer a required preference item. Thanks to SmartThings Community member, + * hsbarrett for pointing this out. + * + * 20210131 + * 01: Adding support for thermostats that do not have capability.thermostat. Special thanks to SmartThings Community member, + * orangebucket for helping me iron out some of the kinks. + * 02: Updates to code comments. + * + * 20201114 + * 01: Redesigned Smart Home Monitor based setPoint enforcement into mode based setPoint enforcement. + * 02: Updates to preference text and code comments. + * + * 20200114 + * 01: Corrected an issue discovered by SmartThings Community member, gspitman, that causes a null pointer exception to be thrown + * in the case that a user has not disabled Energy Saver but has not selected a contact to monitor either. + * 02: Minor code optimizations. + * + * 20191106 + * 01: Added ability to use remote temperature sensor. + * 02: contactOpenHandler() can no longer schedule openContactPause() while the thermostat is in a paused state. + * 03: When esConflictResolver() executes ahead of state.pauseTime, rescheduled executions that follow will now execute one second + * after state.pauseTime. + * 04: Updated esConflictResolver() log output. + * 05: Corrected a mistake in outdoorTempHandler() log output. + * 06: Removed some dead code from the contactOpenHandler() function. + * + * 20190809 + * 01: Corrected an issue that caused the thermostat to be placed in, "off" mode for users who had intended for Thermostat Manager + * not to be setting modes. + * 02: The tempHandler() function will no longer be allowed to set, "heat" mode while the system is in, "emergency heat" mode. + * 03: Updated preference text, log output and code comments. + * + * 20190417 + * 01: Added a "hold-after" timer option to Energy Saver. If set, the hold-after timer will hold the thermostat in a paused status + * for a specified number of minutes after all contacts have been closed. + * 02: The default value of openContactMinutes is now 2. + * 03: Rearranged the condition checks in esConflictResolver() to reduce unnecessary processing. + * + * 20190405 + * 01: Energy Saver can now initiate paused status when the thermostat is in, "off" mode. This was done in order to cover a corner + * case in which the thermostat might be kicked on in the case that Energy Saver is enabled and a contact is opened after the + * thermostat has been manually turned off. + * + * 20190329 + * 01: Updated help text and code comments. + * 02: Temperature thresholds will no longer be rounded and the currentTemp and currentOutdoorTemp variables will be stored + * rounded. + * + * 20190327 + * 01: Added ability to enforce setPoints when the Smart Home Monitor security system is in an armed status. + * 02: Added ability to always enforce temperature setPoints. + * + * 20190308 + * 01: Adjusted the conditions that allow the tempHandler() function to initiate, "off" mode. + * 02: Modified code comments. + * + * 20190307 + * 01: Added slider to disable "heat" mode. + * 02: Added condition that the indoor temperature must be below the heatingThreshold (if it exists) in order to switch to + * "emergency heat" mode based on an outdoor temperature sensor. + * 03: Added slider to disable "cool" mode. + * 04: "Allow Manual Thermostat Off to Override Thermostat Manager" is now enabled by default. + * 05: Rearranged the code to prioritize heating mode. + * 06: Added heatingThreshold/coolingThreshold values to debug output. + * 07: Extended the descriptions of Heating/Cooling Threshold settings. + * + * 20181123 + * 01: Adding a feature requested by SmartThings Community member, richardjroy: A hold-down timer for Energy Saver. + * 02: Updated the debugging log output of the verifyAndEnforce() function. + * 03: Added new getSHMSetPoint() function. + * 04: Added verifyAndEnforce() functionality to Energy Saver functions. + * 05: Added, "auto" and "off" mode handling capabilities to the verifyAndEnforce() function. + * 06: contactOpenHandler() can no longer initiate thermostat pause countdown without the user having set a value for + * openContactMinutes. + * 07: openContactMinutes now has a default value. + * + * 20181120 + * 01: Added some conditions to the verify portion of the verifyAndEnforce() function. + * + * 20181109 + * 01: Changed verifyAndEnforce() function's thermostat mode change retry logging type from logNNotify() to debug. + * 02: Thermostat Manager will no longer set temperature setPoints unnecessarily following a mode change. + * 03: Correcting a spelling mistake in the changelog text. + * 04: Added more debug output to the verifyAndEnforce() function. + * 05: Modified some code comments. + * + * 20181108 + * 01: Consolidated the enforceCoolingSetPoint() and enforceHeatingSetPoint() functions into the new verifyAndEnforce() function. + * verifyAndEnforce() adds the capability to verify that a requested thermostat mode change has taken place and takes + * corrective action if it has not. + * + * 20181018 + * 01: Simplified contactClosedHandler() function. + * + * 20181017 + * 01: Disabled thermostat capability test in *empHandler() functions. + * + * 20181016 + * 01: Created esConflictResolver(), to resolve a race condition that can cause Energy Saver to permanently switch off the + * thermostat if manualOverride is enabled. + * 02: Changed debug log output. + * 03: Updated code comments. + * 04: Modified conditions for mode change commands called by esConflictResolver(). + * + * 20181012 + * 01: Added a toggle to disable externally controlled emergency heat. + * 02: Added capability for externally controlled emergency heat system to re-engage, "heat" mode if the temperature rises above + * the emergencyHeatThreshold. + * 03: Renamed extTempHandler() to outdoorTempHandler(). + * 04: Edited the wording of emergency heat menu items. + * + * 20181011 + * 01: Created a new menu page for emergency heat settings and moved the useEmergencyHeat toggle into it. + * 02: Renamed disableSHMSPEnforce to disableSHMSetPointEnforce. + * 03: Added capability to engage, "emergency heat" mode based on temperature readings from an outdoor thermometer. + * + * 20181010 + * 01: Added, "emergency heat" mode to contactClosedHandler(). + * 02: Added option to use, "emergency heat" mode in place of heat mode. + * 03: Set, "pausable" to true. + * 04: Added, "emergency heat" to heating modes condition check. + * + * 20180412 + * 01: A bit of code cleanup. + * + * 20180401 + * 01: Correcting a typo in logNNotify() that D_Gjorgjievski from the SmartThings Community forum discovered. + * 02: Corrected a problem with the openContact variable inside of the tempHandler() function. + * + * 20180327 + * 01: Now accounting for all possible thermostat modes in tempHandler(). + * 02: Disabling Thermostat Manager will now disable Energy Saver. + * 03: Logging and notifications will now continue to function even if a service is disabled (with the exception of the + * notification service itself). + * 04: General code cleanup. + * + * 20180326 + * 01: Now accounting for all possible thermostat modes in contactClosedHandler(). + * 02: Adding (thermostatMode != "off") condition to openContactPause(). + * + * 20180307 + * 01: If notifications are not configured or are disabled, quietly record qualifying events in the notification log. + * 02: Changed logNNotify() log level to, "info". + * + * 20180306 + * 01: Added temperature threshold recommendations for Celsius. + * 02: Correction to iconX2Url. + * 03: Adding pausable setting to definition. + * + * 20180109 + * 01: Verify that a monitored contact remains open before allowing Energy Saver to pause the thermostat. + * + * 20180102 + * 01: tempHandler() will now check to ensure that Energy Saver states do not contradict the status of the contacts being + * monitored. + * 02: Deleting a misplaced quotation mark. + * + * 20171218 + * 01: Don't set modes if Energy Saver has paused the thermostat due to open contacts. + * + * 20171216 + * 01: Apparently handler functions don't work without a parameter variable. + * 02: Turned setPoint enforcement into scheduled functions. + * + * 20171215 + * 01: Added capability to automatically set thermostat to "off" mode in the case that user selected contact sensors have remained + * open for longer than a user specified number of minutes. + * 02: Added ability to override Thermostat Manager by manually setting the thermostat to "off" mode. + * 03: Added push notification capability. + * 04: Modified logging behavior. Rearranged menus. General code cleanup. + * 05: Added ability to disable Smart Home Monitor based setPoint enforcement without having to remove user defined values. + * 06: Added ability to disable notifications without having to remove contacts. + * 07: Missed a comma. + * 08: Modifying notification messages. + * 09: Converting tempHandler's event.value to integer. + * 10: Returned to using thermostat.currentValue("temperature") instead of event.value.toInteger() for the currentTemp variable in + * the tempHandler() function. + * + * 20171213 + * 01: Standardized optional Smart Home Monitor based setPoint enforcement with corresponding preference settings. + * 02: Added notification capabilities. + * 03: Renamed from, "Simple Thermostat Manager" to, "Thermostat Manager". + * 04: Corrected an incorrect setPoint preference variable. + * 05: Edited the text of the text notification preference setting. + * 06: Menu cleanup. + * + * 20171212 + * 01: Added Hello Home mode value and Smart Home Monitor status value to debug logging. + * 02: Added a preliminary form of setPoint enforcement. + * + * 20171210 + * 01: Corrected a mistake in the help paragraph. + * 02: Reconfigured the placement of the help text. + * 03: Added the ability to have Simple Thermostat Manager ignore a temperature threshold by manually setting it to 0. + * + * 20171125 + * 01: Reverted system back to using user defined boundaries. + * 02: Changed fanMode state check to check for "auto" instead of "fanAuto". + * + * Earlier: + * Creation + * Modified to use established thermostat setPoints rather than user defined boundaries. + */ + +definition( + name: "Thermostat Manager", + namespace: "jmarkwell", + author: "Jordan Markwell", + description: "Automatically changes thermostat mode in response to changes in temperature that exceed user defined thresholds.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo@2x.png", + pausable: true +) + +preferences { + page(name: "mainPage") + page(name: "altThermostatConfig") + page(name: "setPointPage") + page(name: "notificationPage") + page(name: "energySaverPage") + page(name: "emergencyHeatPage") +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "Thermostat Manager", install: true, uninstall: true) { + section() { + paragraph "Automatically changes the thermostat mode in response to changes in temperature that exceed user defined thresholds." + } + section("Main Configuration") { + input "thermostat", "capability.thermostat", title: "Thermostat", multiple: false, required: false + paragraph "If you are unable to select your thermostat, try Alternative Thermostat Configuration." + href "altThermostatConfig", title: "Alternative Thermostat Configuration" + input "tempSensor", "capability.temperatureMeasurement", title: "Temperature Sensor", multiple: false, required: true + paragraph "When the temperature falls below the heating threshold, Thermostat Manager will set heating mode. This value must always be lower than the Cooling Threshold. Recommended value: 70F (21C)" + input name: "heatingThreshold", title: "Heating Threshold", type: "number", required: false + input name: "disableHeat", title: "Don't Set Heat Mode", type: "bool", defaultValue: false, required: true + paragraph "When the temperature rises higher than the cooling threshold, Thermostat Manager will set cooling mode. This value must always be higher than the Heating Threshold. Recommended value: 75F (24C)" + input name: "coolingThreshold", title: "Cooling Threshold", type: "number", required: false + input name: "disableCool", title: "Don't Set Cool Mode", type: "bool", defaultValue: false, required: true + } + section("Tips") { + paragraph "If you set the cooling threshold at the lowest setting you use in your modes and you set the heating threshold at the highest setting you use in your modes, you will not need to create multiple instances of Thermostat Manager." + } + section("Optional Settings") { + input name: "setFan", title: "Maintain Auto Fan Mode", type: "bool", defaultValue: true, required: true + input name: "manualOverride", title: "Allow Manual Thermostat Off to Override Thermostat Manager", type: "bool", defaultValue: true, required: true + input name: "debug", title: "Debug Logging", type: "bool", defaultValue: false, required: true + input name: "disable", title: "Disable Thermostat Manager", type: "bool", defaultValue: false, required: true + + href "setPointPage", title: "Mode Based SetPoint Enforcement" + href "energySaverPage", title: "Energy Saver" + href "emergencyHeatPage", title: "Emergency Heat Settings" + href "notificationPage", title: "Notification Settings" + + label(title: "Assign a name", required: false) + } + } +} + +def altThermostatConfig() { + dynamicPage(name: "altThermostatConfig", title: "Alternative Thermostat Configuration") { + section() { + input "tstatMode", "capability.thermostatMode", title: "Thermostat Mode Controller", multiple: false, required: false + input "thermostatHeatingSetpoint", "capability.thermostatHeatingSetpoint", title: "Thermostat Heating SetPoint Controller", multiple: false, required: false + input "thermostatCoolingSetpoint", "capability.thermostatCoolingSetpoint", title: "Thermostat Cooling SetPoint Controller", multiple: false, required: false + input "thermostatFanMode", "capability.thermostatFanMode", title: "Thermostat Fan Mode Controller", multiple: false, required: false + } + section() { + input name: "useAltThermostatConfig", title: "Use Alternative Thermostat Configuration", type: "bool", defaultValue: false, required: true + } + } +} + +def setPointPage() { + dynamicPage(name: "setPointPage", title: "Mode Based SetPoint Enforcement") { + section() { + paragraph "These optional settings allow you use Thermostat Manager to set your thermostat's cooling and heating setPoints based on your location's Hello Home mode. SetPoints will be set only when a thermostat mode change occurs (e.g. heating to cooling) and only the setPoint for the incoming mode will be set (e.g. A change from heating mode to cooling mode would prompt the cooling setPoint to be set)." + } + section("Tips") { + paragraph "Each mode configuration must be unique. Do not select the same mode in multiple configurations." + } + section("Mode Configuration 1") { + input "modeConfig1", title: "Modes", "mode", multiple: true, required: false + input name: "offHeatingSetPoint", title: "Heating SetPoint", type: "number", required: false + input name: "offCoolingSetPoint", title: "Cooling SetPoint", type: "number", required: false + } + section("Mode Configuration 2") { + input "modeConfig2", title: "Modes", "mode", multiple: true, required: false + input name: "stayHeatingSetPoint", title: "Heating SetPoint", type: "number", required: false + input name: "stayCoolingSetPoint", title: "Cooling SetPoint", type: "number", required: false + } + section("Mode Configuration 3") { + input "modeConfig3", title: "Modes", "mode", multiple: true, required: false + input name: "awayHeatingSetPoint", title: "Heating SetPoint", type: "number", required: false + input name: "awayCoolingSetPoint", title: "Cooling SetPoint", type: "number", required: false + } + section() { + input "armedModes", title: "Select all modes in which SmartThings Home Monitor is ARMED", "mode", multiple: true, required: true + input name: "enforceArmedSetPoints", title: "Enforce SetPoints in Armed Statuses", type: "bool", defaultValue: false, required: true + input name: "enforceSetPoints", title: "Always Enforce SetPoints", type: "bool", defaultValue: false, required: true + input name: "disableSHMSetPointEnforce", title: "Disable Mode Based SetPoint Enforcement", type: "bool", defaultValue: false, required: true + } + } +} + +def notificationPage() { + dynamicPage(name: "notificationPage", title: "Notification Settings") { + section() { + input(name: "recipients", title: "Select Notification Recipients", type: "contact", required: false) { + input name: "phone", title: "Enter Phone Number of Text Message Notification Recipient", type: "phone", required: false + } + input name: "pushNotify", title: "Send Push Notifications", type: "bool", defaultValue: false, required: true + } + section() { + input name: "disableNotifications", title: "Disable Notifications", type: "bool", defaultValue: false, required: true + } + } +} + +def energySaverPage() { + dynamicPage(name: "energySaverPage", title: "Energy Saver") { + section() { + paragraph "Energy Saver will temporarily pause the thermostat (by placing it in \"off\" mode) for a specified minimal amount of minutes in the case that any selected contact sensors are left open for a specified number of minutes." + input name: "contact", title: "Contact Sensors", type: "capability.contactSensor", multiple: true, required: false + paragraph "Open Contact Time must be set to a value of 1 or greater." + input name: "openContactMinutes", title: "Open Contact Time (minutes)", type: "number", defaultValue: 2, required: false + input name: "minPauseMinutes", title: "Minimum Pause Time (minutes)", type: "number", defaultValue: 2, required: false + paragraph "If Hold-After Time is specified/non-zero, once the thermostat enters a paused state it will remain paused for the specified number of minutes after all selected contacts have been closed." + input name: "holdAfterMinutes", title: "Hold-After Time (minutes)", type: "number", defaultValue: 2, required: false + } + section() { + input name: "disableEnergySaver", title: "Disable Energy Saver", type: "bool", defaultValue: false, required: true + } + } +} + +def emergencyHeatPage() { + dynamicPage(name: "emergencyHeatPage", title: "Emergency Heat Settings") { + section() { + paragraph "If you would like to have Thermostat Manager enable emergency heat mode based on the temperature outside, select a temperature sensor below." + input name: "outdoorTempSensor", title: "Outdoor Temperature Sensor", type: "capability.temperatureMeasurement", multiple: false, required: false + paragraph "When an outdoor temperature sensor reports a temperature lower than the emergency heat threshold, Thermostat Manager will set emergency heat mode. Set the emergency heat threshold at some value lower than the heating threshold." + input name: "emergencyHeatThreshold", title: "Emergency Heat Threshold", type: "number", required: false + input name: "disableExtEmergencyHeat", title: "Disable Externally Controlled Emergency Heat", type: "bool", defaultValue: false, required: true + } + section() { + input name: "useEmergencyHeat", title: "Always Use Emergency Heat Mode Instead of Heat Mode", type: "bool", defaultValue: false, required: true + } + } +} + +def installed() { + log.debug "Thermostat_Manager.installed(): ${settings}" + + initialize() +} + +def updated() { + state.clear() + + // if (disableHeat && manualOverride) { app.updateSetting("manualOverride", [type: "bool", value: false]) } + log.debug "Thermostat_Manager.updated(): ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(tempSensor, "temperature", tempHandler) + subscribe(contact, "contact.open", contactOpenHandler) + subscribe(contact, "contact.closed", contactClosedHandler) + subscribe(outdoorTempSensor, "temperature", outdoorTempHandler) +} + +def tempHandler(event) { + def openContact = contact?.currentValue("contact")?.contains("open") + def currentTemp = Math.round( tempSensor.currentValue("temperature") ) + def currentOutdoorTemp = Math.round( outdoorTempSensor?.currentValue("temperature") ) + def heatingSetpoint = getHeatingSetpoint() + def coolingSetpoint = getCoolingSetpoint() + def currentThermostatMode = getThermostatMode() + def fanMode = getFanMode() + def homeMode = location.mode + + def SHMSetPoint = getSHMSetPoint(currentThermostatMode) + + esConflictResolver() + + if (debug) { + // if (event?.device?.hasCapability("Thermostat")) { log.debug "Thermostat_Manager.tempHandler(): (${event.device.typeName}) IS A THERMOSTAT" } + // else { log.debug "Thermostat_Manager.tempHandler(): (${event.device.typeName}) IS NOT A THERMOSTAT" } + if (!disableEnergySaver && contact) { + log.debug "Thermostat_Manager.tempHandler(): At least one contact is open: ${openContact}" + if (state.lastThermostatMode) { log.debug "Thermostat_Manager.tempHandler(): Thermostat Manager is currently paused." } + } + log.debug "Thermostat_Manager.tempHandler(): Hello Home Mode: ${homeMode}" + log.debug "Thermostat_Manager.tempHandler(): Fan Mode: ${fanMode}" + log.debug "Thermostat_Manager.tempHandler(): Mode: ${currentThermostatMode}" + log.debug "Thermostat_Manager.tempHandler(): Heating Threshold: ${heatingThreshold} | Cooling Threshold: ${coolingThreshold}" + if (SHMSetPoint) { log.debug "Thermostat_Manager.tempHandler(): Mode Configuration SetPoint: ${SHMSetPoint}" } + log.debug "Thermostat_Manager.tempHandler(): Heating Setpoint: ${heatingSetpoint} | Cooling Setpoint: ${coolingSetpoint}" + if (currentOutdoorTemp) { log.debug "Thermostat_Manager.tempHandler(): Outdoor Temperature: ${currentOutdoorTemp}" } + log.debug "Thermostat_Manager.tempHandler(): Indoor Temperature: ${currentTemp}" + } + + if ( (!disable) && (setFan) && (fanMode != "auto") ) { + logNNotify("Thermostat Manager setting fan mode auto.") + setFanAuto() + } + + if ( + !disable && !disableHeat && + (disableEnergySaver || !state.lastThermostatMode) && + ( !manualOverride || ( manualOverride && ( (currentThermostatMode != "off") || state.ignoreOverride ) ) ) && + !useEmergencyHeat && (currentThermostatMode != "heat") && (currentThermostatMode != "emergency heat") && + heatingThreshold && (currentTemp < heatingThreshold) + ) { + + logNNotify("Thermostat Manager - The temperature has fallen to ${currentTemp}. Setting heat mode.") + setHeatMode() + + def setSetPoint = getSHMSetPoint("heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "heat", count: 1] ] ) + } + else if ( + !disable && !disableCool && + (disableEnergySaver || !state.lastThermostatMode) && + ( !manualOverride || ( manualOverride && ( (currentThermostatMode != "off") || state.ignoreOverride ) ) ) && + (currentThermostatMode != "cool") && + coolingThreshold && (currentTemp > coolingThreshold) + ) { + + logNNotify("Thermostat Manager - The temperature has risen to ${currentTemp}. Setting cooling mode.") + setCoolMode() + + def setSetPoint = getSHMSetPoint("cool") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "cool", count: 1] ] ) + } + else if ( + !disable && + (disableEnergySaver || !state.lastThermostatMode) && + ( !manualOverride || ( manualOverride && ( (currentThermostatMode != "off") || state.ignoreOverride ) ) ) && + useEmergencyHeat && (currentThermostatMode != "emergency heat") && + heatingThreshold && (currentTemp < heatingThreshold) + ) { + + logNNotify("Thermostat Manager - The temperature has fallen to ${currentTemp}. Setting emergency heat mode.") + setEmergencyHeatMode() + + def setSetPoint = getSHMSetPoint("emergency heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "emergency heat", count: 1] ] ) + } + else if ( + !disable && !disableHeat && + // If the thermostat is in, "emergency heat" mode, then it can't be (paused, or) in, "off" mode. + !useEmergencyHeat && (currentThermostatMode == "emergency heat") && + // Either cool mode is disabled or the temperature is between the heating and cooling thresholds. + ( (!coolingThreshold || disableCool) || ( coolingThreshold && (currentTemp < coolingThreshold) ) ) && + heatingThreshold && (currentTemp > heatingThreshold) && + ( // Either Externally Controlled Emergency Heat is disabled or the outdoor temperature is above the emergencyHeatThreshold. + disableExtEmergencyHeat || + ( !disableExtEmergencyHeat && (currentOutdoorTemp && emergencyHeatThreshold && (currentOutdoorTemp > emergencyHeatThreshold) ) ) + ) + ) { + + logNNotify("Thermostat Manager - The temperature has risen to ${currentTemp}. Setting heat mode.") + setHeatMode() + + def setSetPoint = getSHMSetPoint("heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "heat", count: 1] ] ) + } + else if ( // If disableSHMSetPointEnforce is enabled, or if the thermostat is (paused, or) in, "off" mode, SHMSetPoint will be null. + !disable && SHMSetPoint && + ( enforceSetPoints || ( enforceArmedSetPoints && armedModes.contains(location.mode) ) ) && + ( + ( ( (currentThermostatMode == "heat") || (currentThermostatMode == "emergency heat") ) && (heatingSetpoint != SHMSetPoint) ) || + ( (currentThermostatMode == "cool") && (coolingSetpoint != SHMSetPoint) ) + ) + ) { + + runIn( 60, verifyAndEnforce, [data: [setPoint: SHMSetPoint, mode: currentThermostatMode, count: 1] ] ) + } + else if (debug) { + log.debug "Thermostat_Manager.tempHandler(): Thermostat Manager standing by." + } +} + +def outdoorTempHandler(event) { + def openContact = contact?.currentValue("contact")?.contains("open") + def currentTemp = Math.round( tempSensor.currentValue("temperature") ) + def currentOutdoorTemp = Math.round( outdoorTempSensor.currentValue("temperature") ) + def heatingSetpoint = getHeatingSetpoint() + def coolingSetpoint = getCoolingSetpoint() + def currentThermostatMode = getThermostatMode() + def fanMode = getFanMode() + def homeMode = location.mode + + def SHMSetPoint = getSHMSetPoint(currentThermostatMode) + + esConflictResolver() + + if (debug) { + // if (event?.device?.hasCapability("Thermostat")) { log.debug "Thermostat_Manager.outdoorTempHandler(): (${event.device.typeName}) IS A THERMOSTAT" } + // else { log.debug "Thermostat_Manager.outdoorTempHandler(): (${event.device.typeName}) IS NOT A THERMOSTAT" } + if (!disableEnergySaver && contact) { + log.debug "Thermostat_Manager.outdoorTempHandler(): At least one contact is open: ${openContact}" + if (state.lastThermostatMode) { log.debug "Thermostat_Manager.outdoorTempHandler(): Thermostat Manager is currently paused." } + } + log.debug "Thermostat_Manager.outdoorTempHandler(): Hello Home Mode: ${homeMode}" + log.debug "Thermostat_Manager.outdoorTempHandler(): Fan Mode: ${fanMode}" + log.debug "Thermostat_Manager.outdoorTempHandler(): Mode: ${currentThermostatMode}" + log.debug "Thermostat_Manager.outdoorTempHandler(): Heating Threshold: ${heatingThreshold} | Cooling Threshold: ${coolingThreshold}" + if (SHMSetPoint) { log.debug "Thermostat_Manager.outdoorTempHandler(): Mode Configuration SetPoint: ${SHMSetPoint}" } + log.debug "Thermostat_Manager.outdoorTempHandler(): Heating Setpoint: ${heatingSetpoint} | Cooling Setpoint: ${coolingSetpoint}" + log.debug "Thermostat_Manager.outdoorTempHandler(): Outdoor Temperature: ${currentOutdoorTemp}" + log.debug "Thermostat_Manager.outdoorTempHandler(): Indoor Temperature: ${currentTemp}" + } + + if ( (!disable) && (setFan) && (fanMode != "auto") ) { + logNNotify("Thermostat Manager setting fan mode auto.") + setFanAuto() + } + + if ( + !disable && !disableExtEmergencyHeat && + (disableEnergySaver || !state.lastThermostatMode) && + ( !manualOverride || ( manualOverride && ( (currentThermostatMode != "off") || state.ignoreOverride ) ) ) && + (currentThermostatMode != "emergency heat") && + // If the indoor temperature is below the heatingThreshold and the outdoor temperature falls below the emergencyHeatThreshold. + ( !heatingThreshold || (heatingThreshold && (currentTemp < heatingThreshold) ) ) && + emergencyHeatThreshold && (currentOutdoorTemp < emergencyHeatThreshold) + ) { + + logNNotify("Thermostat Manager - Outdoor temperature has fallen to ${currentOutdoorTemp}. Setting emergency heat mode.") + setEmergencyHeatMode() + + def setSetPoint = getSHMSetPoint("emergency heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "emergency heat", count: 1] ] ) + } + else if ( + !disable && !disableExtEmergencyHeat && !disableHeat && + // If the thermostat is in, "emergency heat" mode, then it can't be (paused, or) in, "off" mode. + !useEmergencyHeat && (currentThermostatMode == "emergency heat") && + // Either cool mode is disabled or the temperature is between the heating and cooling thresholds. + ( (!coolingThreshold || disableCool) || ( coolingThreshold && (currentTemp < coolingThreshold) ) ) && + (heatingThreshold && (currentTemp < heatingThreshold) ) && + // If the outdoor temperature rises above the emergencyHeatThreshold. + emergencyHeatThreshold && (currentOutdoorTemp > emergencyHeatThreshold) + ) { + + logNNotify("Thermostat Manager - Outdoor temperature has risen to ${currentOutdoorTemp}. Setting heat mode.") + setHeatMode() + + def setSetPoint = getSHMSetPoint("heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "heat", count: 1] ] ) + } + else if (debug) { + log.debug "Thermostat_Manager.outdoorTempHandler(): Thermostat Manager standing by." + } +} + +def logNNotify(message) { + log.info message + if ( (!disableNotifications) && ( (location.contactBookEnabled && recipients) || phone || pushNotify ) ) { + if (location.contactBookEnabled && recipients) { + sendNotificationToContacts(message, recipients) + } + else if (phone) { + sendSms(phone, message) + } + + if (pushNotify) { + sendPush(message) + } + } + else { + sendNotificationEvent(message) + } +} + +def verifyAndEnforce(inMap) { + def currentThermostatMode = getThermostatMode() + + if (currentThermostatMode == inMap.mode) { // If the thermostat has properly changed over to the requested mode. + if (debug) { + log.debug "Thermostat_Manager.verifyAndEnforce(): Thermostat has successfully entered ${inMap.mode} mode. (${inMap.count}/3)" + } + + if ( (currentThermostatMode == "heat") || (currentThermostatMode == "cool") ) { + state.ignoreOverride = false + } + + if (inMap.setPoint) { // If mode based setPoint enforcement is in use. + def heatingSetpoint = getHeatingSetpoint() + def coolingSetpoint = getCoolingSetpoint() + + if ( ( (currentThermostatMode == "heat") || (currentThermostatMode == "emergency heat") ) && (heatingSetpoint != inMap.setPoint) ) { + logNNotify("Thermostat Manager is setting the heating setPoint to ${inMap.setPoint}.") + setHeatSetPoint(inMap.setPoint) + } + else if ( (currentThermostatMode == "cool") && (coolingSetpoint != inMap.setPoint) ) { + logNNotify("Thermostat Manager is setting the cooling setPoint to ${inMap.setPoint}.") + setCoolSetPoint(inMap.setPoint) + } + else if (debug) { // If setPoints do not need to be set. + log.debug "Thermostat_Manager.verifyAndEnforce(): Existing setPoints match user defined settings." + } + } + } + else if ( // If the thermostat has failed to change over to the requested mode and has not been subsequently paused or otherwise disabled. + !disable && + (disableEnergySaver || !state.lastThermostatMode) && + ( !manualOverride || ( manualOverride && ( (currentThermostatMode != "off") || state.ignoreOverride ) ) ) + ) { + + if (inMap.count <= 3) { // Retry 2 times for a maximum of 3 total tries. + log.debug "Thermostat_Manager.verifyAndEnforce(): Thermostat has failed to initiate ${inMap.mode} mode. (${inMap.count}/3) Trying again." + + switch (inMap.mode) { + case "heat": + setHeatMode() + break + case "cool": + setCoolMode() + break + case "emergency heat": + setEmergencyHeatMode() + break + case "auto": + setAutoMode() + break + case "off": + setOffMode() + break + } + + // SetPoints can only be set for the thermostat's currently active mode. + runIn( 60, verifyAndEnforce, [data: [setPoint: inMap.setPoint, mode: inMap.mode, count: ++inMap.count] ] ) + } + else { + logNNotify("Thermostat Manager - Thermostat failed to change to ${inMap.mode} mode.") + } + } +} + +def contactOpenHandler(event) { + if (debug) { + log.debug "Thermostat_Manager.contactOpenHandler(): A contact has been opened." + } + + if (!disable && !disableEnergySaver && !state.openContactReported) { + state.openContactReported = true + + if (openContactMinutes && !state.lastThermostatMode) { + runIn( (openContactMinutes * 60), openContactPause ) + log.debug "Thermostat_Manager.contactOpenHandler(): Initiating countdown to thermostat pause." + } + } +} + +def contactClosedHandler(event) { + if (debug) { + log.debug "Thermostat_Manager.contactClosedHandler(): A contact has been closed." + } + + esConflictResolver() +} + +def esConflictResolver() { // Remember that state values are not changed until the application has finished running. + if ( // If all monitored contacts are currently closed. + !disable && !disableEnergySaver && contact && !contact?.currentValue("contact")?.contains("open") && + // Don't waste time on this function if none of the following conditions are met. + (state.openContactReported || state.lastThermostatMode) + ) { + + def nowTime = now() + def pauseTime = state.pauseTime + + // If an open contact has been reported, discontinue any existing countdown. + if (state.openContactReported) { + log.debug "Thermostat_Manager.esConflictResolver(): All contacts have been closed. Discontinuing any existing thermostat pause countdown." + unschedule(openContactPause) + state.openContactReported = false + + if (state.lastThermostatMode && holdAfterMinutes) { + pauseTime = nowTime + (60000 * holdAfterMinutes) + if (pauseTime > state.pauseTime) { + state.pauseTime = pauseTime + } + else { + pauseTime = state.pauseTime + } + } + } + + if (state.lastThermostatMode) { + // If this block can be entered, the thermostat is paused and tempHandler() condition checks will properly fail. + if ( !pauseTime || (nowTime >= pauseTime) ) { + def currentThermostatMode = getThermostatMode() + + // If the thermostat is currently paused, restore it to its previous state. + if (currentThermostatMode == "off") { + if (state.lastThermostatMode == "heat") { + logNNotify("Thermostat Manager - All contacts have been closed. Restoring heat mode.") + setHeatMode() + + def setSetPoint = getSHMSetPoint("heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "heat", count: 1] ] ) + } + else if (state.lastThermostatMode == "cool") { + logNNotify("Thermostat Manager - All contacts have been closed. Restoring cooling mode.") + setCoolMode() + + def setSetPoint = getSHMSetPoint("cool") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "cool", count: 1] ] ) + } + else if (state.lastThermostatMode == "emergency heat") { + logNNotify("Thermostat Manager - All contacts have been closed. Restoring emergency heat mode.") + setEmergencyHeatMode() + + def setSetPoint = getSHMSetPoint("emergency heat") + runIn( 60, verifyAndEnforce, [data: [setPoint: setSetPoint, mode: "emergency heat", count: 1] ] ) + } + else if (state.lastThermostatMode == "auto") { + logNNotify("Thermostat Manager - All contacts have been closed. Restoring auto mode.") + setAutoMode() + + runIn( 60, verifyAndEnforce, [data: [setPoint: null, mode: "auto", count: 1] ] ) + } + } + state.lastThermostatMode = null + } + else if (pauseTime && (pauseTime > nowTime) ) { + def reRunTime = Math.round( ( (pauseTime + 1000) - nowTime) / 1000 ) + if (debug) { log.debug "Thermostat_Manager.esConflictResolver(): esConflictResolver() will be run again in ${reRunTime} seconds."} + runIn(reRunTime, esConflictResolver) + } + } + } +} + +def openContactPause() { + if ( contact?.currentValue("contact")?.contains("open") ) { // If any monitored contact is open. + def currentThermostatMode = getThermostatMode() + + if (!state.lastThermostatMode) { state.lastThermostatMode = currentThermostatMode } + if (minPauseMinutes) { state.pauseTime = now() + (60000 * minPauseMinutes) } + + if (currentThermostatMode != "off") { + logNNotify("Thermostat Manager is turning the thermostat off temporarily due to an open contact.") + setOffMode() + runIn( 60, verifyAndEnforce, [data: [setPoint: null, mode: "off", count: 1] ] ) + } + } + else { // If no monitored contacts remain open. + state.openContactReported = false + } +} + +def getSHMSetPoint(newMode) { + def setSetPoint = null + + if (!disableSHMSetPointEnforce) { + if ( (newMode == "heat") || (newMode == "emergency heat") ) { + if ( modeConfig1.contains(location.mode) && (offHeatingSetPoint) ) { + setSetPoint = offHeatingSetPoint + } + else if ( modeConfig2.contains(location.mode) && (stayHeatingSetPoint) ) { + setSetPoint = stayHeatingSetPoint + } + else if ( modeConfig3.contains(location.mode) && (awayHeatingSetPoint) ) { + setSetPoint = awayHeatingSetPoint + } + } + else if (newMode == "cool") { + if ( modeConfig1.contains(location.mode) && (offCoolingSetPoint) ) { + setSetPoint = offCoolingSetPoint + } + else if ( modeConfig2.contains(location.mode) && (stayCoolingSetPoint) ) { + setSetPoint = stayCoolingSetPoint + } + else if ( modeConfig3.contains(location.mode) && (awayCoolingSetPoint) ) { + setSetPoint = awayCoolingSetPoint + } + } + } + + return(setSetPoint) +} + +def getThermostatMode() { + def currentThermostatMode = null + + if (!useAltThermostatConfig && thermostat) { + currentThermostatMode = thermostat.currentValue("thermostatMode") + } + else if (useAltThermostatConfig && tstatMode) { + currentThermostatMode = tstatMode.currentValue("thermostatMode") + } + + return(currentThermostatMode) +} + +def getHeatingSetpoint() { + def heatingSetpoint = null + + if (!useAltThermostatConfig && thermostat) { + heatingSetpoint = thermostat.currentValue("heatingSetpoint") + } + else if (useAltThermostatConfig && thermostatHeatingSetpoint) { + heatingSetpoint = thermostatHeatingSetpoint.currentValue("heatingSetpoint") + } + + return(heatingSetpoint) +} + +def getCoolingSetpoint() { + def coolingSetpoint = null + + if (!useAltThermostatConfig && thermostat) { + coolingSetpoint = thermostat.currentValue("coolingSetpoint") + } + else if (useAltThermostatConfig && thermostatCoolingSetpoint) { + coolingSetpoint = thermostatCoolingSetpoint.currentValue("coolingSetpoint") + } + + return(coolingSetpoint) +} + +def getFanMode() { + def fanMode = null + + if (!useAltThermostatConfig && thermostat) { + fanMode = thermostat.currentValue("thermostatFanMode") + } + else if (useAltThermostatConfig && thermostatFanMode) { + fanMode = thermostatFanMode.currentValue("thermostatFanMode") + } + + return(fanMode) +} + +def setHeatMode() { + if (!useAltThermostatConfig && thermostat) { + thermostat.setThermostatMode("heat") + } + else if (useAltThermostatConfig && tstatMode) { + tstatMode.setThermostatMode("heat") + } + else { + logNNotify("Thermostat Manager - Cannot set thermostat mode. No thermostat or thermostatMode devices have been configured.") + } +} + +def setCoolMode() { + if (!useAltThermostatConfig && thermostat) { + thermostat.setThermostatMode("cool") + } + else if (useAltThermostatConfig && tstatMode) { + tstatMode.setThermostatMode("cool") + } + else { + logNNotify("Thermostat Manager - Cannot set thermostat mode. No thermostat or thermostatMode devices have been configured.") + } +} + +def setEmergencyHeatMode() { + if (!useAltThermostatConfig && thermostat) { + thermostat.setThermostatMode("emergency heat") + } + else if (useAltThermostatConfig && tstatMode) { + tstatMode.setThermostatMode("emergency heat") + } + else { + logNNotify("Thermostat Manager - Cannot set thermostat mode. No thermostat or thermostatMode devices have been configured.") + } +} + +def setAutoMode() { + if (!useAltThermostatConfig && thermostat) { + thermostat.setThermostatMode("auto") + } + else if (useAltThermostatConfig && tstatMode) { + tstatMode.setThermostatMode("auto") + } + else { + logNNotify("Thermostat Manager - Cannot set thermostat mode. No thermostat or thermostatMode devices have been configured.") + } +} + +def setOffMode() { + if (!useAltThermostatConfig && thermostat) { + thermostat.setThermostatMode("off") + } + else if (useAltThermostatConfig && tstatMode) { + tstatMode.setThermostatMode("off") + } + else { + logNNotify("Thermostat Manager - Cannot set thermostat mode. No thermostat or thermostatMode devices have been configured.") + } +} + +def setHeatSetPoint(setPoint) { + if (!useAltThermostatConfig && thermostat) { + thermostat.setHeatingSetpoint(setPoint) + } + else if (useAltThermostatConfig && thermostatHeatingSetpoint) { + thermostatHeatingSetpoint.setHeatingSetpoint(setPoint) + } + else { + logNNotify("Thermostat Manager - Cannot set heating setPoint. No thermostat or thermostatHeatingSetpoint devices have been configured. Select one or disable \"Mode Based SetPoint Enforcement\".") + } +} + +def setCoolSetPoint(setPoint) { + if (!useAltThermostatConfig && thermostat) { + thermostat.setCoolingSetpoint(setPoint) + } + else if (useAltThermostatConfig && thermostatCoolingSetpoint) { + thermostatCoolingSetpoint.setCoolingSetpoint(setPoint) + } + else { + logNNotify("Thermostat Manager - Cannot set cooling setPoint. No thermostat or thermostatCoolingSetpoint devices have been configured. Select one or disable \"Mode Based SetPoint Enforcement\".") + } +} + +def setFanAuto() { + if (!useAltThermostatConfig && thermostat) { + thermostat.setThermostatFanMode("auto") + } + else if (useAltThermostatConfig && thermostatFanMode) { + thermostatFanMode.setThermostatFanMode("auto") + } + else { + logNNotify("Thermostat Manager - Cannot set fan mode. No thermostat or thermostatFanMode devices have been configured. Select one or disable \"Maintain Auto Fan Mode\".") + } +} \ No newline at end of file diff --git a/smartapps/konnected-io/konnected-connect.src/konnected-connect.groovy b/smartapps/konnected-io/konnected-connect.src/konnected-connect.groovy new file mode 100644 index 00000000000..3e7b59b423c --- /dev/null +++ b/smartapps/konnected-io/konnected-connect.src/konnected-connect.groovy @@ -0,0 +1,85 @@ +/** + * Konnected + * + * Copyright 2017 konnected.io + * + * 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. + * + */ +public static String version() { return "2.2.3" } + +definition( + name: "Konnected (Connect)", + namespace: "konnected-io", + author: "konnected.io", + description: "Konnected devices bridge wired things with SmartThings", + category: "Safety & Security", + iconUrl: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/KonnectedSecurity.png", + iconX2Url: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/KonnectedSecurity@2x.png", + iconX3Url: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/KonnectedSecurity@3x.png", + singleInstance: true +) + +preferences { + page(name: "mainPage", title: "Konnected Devices", install: true, uninstall: true) { + section { + app(name: "childApps", appName: "Konnected Service Manager", namespace: "konnected-io", title: "Add a Konnected device", multiple: true) + paragraph "Konnected (Connect) v${version()}" + } + } +} + +def installed() { + log.info "installed(): Installing Konnected Parent SmartApp" + initialize() +} + +def updated() { + log.info "updated(): Updating Konnected SmartApp" + unschedule() + initialize() +} + +def uninstalled() { + log.info "uninstall(): Uninstalling Konnected SmartApp" +} + +def initialize() { + runEvery5Minutes(discoverySearch) +} + +// Device Discovery : Send M-Search to multicast +def discoverySearch() { + log.debug "Discovering Konnected devices on the network via SSDP" + sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${discoveryDeviceType()}", physicalgraph.device.Protocol.LAN)) +} + +def discoveryDeviceType() { + return "urn:schemas-konnected-io:device:Security:1" +} + +void registerKnownDevice(mac) { + if (state.knownDevices == null) { + state.knownDevices = [].toSet() + } + + if (isNewDevice(mac)) { + log.debug "Registering Konnected device ${mac}" + state.knownDevices.add(mac) + } +} + +void removeKnownDevice(mac) { + state.knownDevices?.remove(mac) +} + +Boolean isNewDevice(mac) { + return !state.knownDevices?.contains(mac) +} \ No newline at end of file diff --git a/smartapps/konnected-io/konnected-service-manager.src/konnected-service-manager.groovy b/smartapps/konnected-io/konnected-service-manager.src/konnected-service-manager.groovy new file mode 100644 index 00000000000..622c5e67b31 --- /dev/null +++ b/smartapps/konnected-io/konnected-service-manager.src/konnected-service-manager.groovy @@ -0,0 +1,545 @@ +/** + * Konnected + * + * Copyright 2018 konnected.io + * + * 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. + * + */ +public static String version() { return "2.2.3" } + +definition( + name: "Konnected Service Manager", + parent: "konnected-io:Konnected (Connect)", + namespace: "konnected-io", + author: "konnected.io", + description: "Do not install this directly, use Konnected (Connect) instead", + category: "Safety & Security", + iconUrl: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/KonnectedSecurity.png", + iconX2Url: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/KonnectedSecurity@2x.png", + iconX3Url: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/KonnectedSecurity@3x.png", + singleInstance: true +) + +mappings { + path("/device/:mac/:id/:deviceState") { action: [ PUT: "childDeviceStateUpdate"] } + path("/device/:mac") { action: [ PUT: "childDeviceStateUpdate", GET: "getDeviceState" ] } + path("/ping") { action: [ GET: "devicePing"] } +} + +preferences { + page(name: "pageWelcome", install: false, uninstall: true, content: "pageWelcome", nextPage: "pageConfiguration") + page(name: "pageDiscovery", install: false, content: "pageDiscovery" ) + page(name: "pageConfiguration", install: true, content: "pageConfiguration") +} + +def installed() { + log.info "installed(): Installing Konnected Device: " + state.device?.mac + parent.registerKnownDevice(state.device.mac) + initialize() +} + +def updated() { + log.info "updated(): Updating Konnected Device: " + state.device?.mac + unsubscribe() + unschedule() + initialize() +} + +def uninstalled() { + def device = state.device + log.info "uninstall(): Removing Konnected Device $device?.mac" + revokeAccessToken() + + def body = [ + token : "", + apiUrl : "", + sensors : [], + actuators : [], + dht_sensors: [], + ds18b20_sensors: [] + ] + + if (device) { + parent.removeKnownDevice(device.mac) + sendHubCommand(new physicalgraph.device.HubAction([ + method: "PUT", + path: "/settings", + headers: [ HOST: getDeviceIpAndPort(device), "Content-Type": "application/json" ], + body : groovy.json.JsonOutput.toJson(body) + ], getDeviceIpAndPort(device) )) + } +} + +def initialize() { + discoverySubscription() + if (app.label != deviceName()) { app.updateLabel(deviceName()) } + childDeviceConfiguration() + updateSettingsOnDevice() +} + +def deviceName() { + if (name) { + return name + } else if (state.device) { + return "konnected-" + state.device.mac[-6..-1] + } else { + return "New Konnected device" + } +} + +// Page : 1 : Welcome page - Manuals & links to devices +def pageWelcome() { + def device = state.device + dynamicPage( name: "pageWelcome", title: deviceName(), nextPage: "pageConfiguration") { + section() { + if (device) { + href( + name: "device_" + device.mac, + image: "https://docs.konnected.io/assets/favicons/apple-touch-icon.png", + title: "Device status", + description: getDeviceIpAndPort(device), + url: "http://" + getDeviceIpAndPort(device) + ) + } else { + href( + name: "discovery", + title: "Tap here to start discovery", + page: "pageDiscovery" + ) + } + } + + section("Help & Support") { + href( + name: "pageWelcomeManual", + title: "Instructions & Knowledge Base", + description: "Tap to view the support portal at help.konnected.io", + required: false, + image: "https://raw.githubusercontent.com/konnected-io/docs/master/assets/images/manual-icon.png", + url: "https://help.konnected.io" + ) + paragraph "Konnected Service Manager v${version()}" + } + } +} + +// Page : 2 : Discovery page +def pageDiscovery() { + if(!state.accessToken) { createAccessToken() } + + // begin discovery protocol if device has not been found yet + if (!state.device) { + discoverySubscription() + parent.discoverySearch() + } + + dynamicPage(name: "pageDiscovery", install: false, refreshInterval: 3) { + if (state.device?.verified) { + section() { + href( + name: "discoveryComplete", + title: "Found konnected-" + state.device.mac[-6..-1] + "!", + description: "Tap to continue", + page: "pageConfiguration" + ) + } + } else { + section("Please wait while we discover your device") { + paragraph "This may take up to a minute." + } + } + } +} + +// Page : 3 : Configure things wired to the Konnected board +def pageConfiguration(params) { + def setHwType = params?.hwType + if (setHwType) { state.hwType = setHwType } + state.hwType ? pageAssignPins() : pageSelectHwType() +} + +private pageSelectHwType() { + dynamicPage(name: "pageConfiguration") { + section(title: "Which wiring hardware do you have?") { + href( + name: "Konnected Alarm Panel", + title: "Konnected Alarm Panel", + description: "Tap to select", + page: "pageConfiguration", + params: [hwType: "alarmPanel"], + image: "https://s3.us-east-2.amazonaws.com/konnected-io/konnected-alarm-panel-st-icon-t.jpg", + ) + href( + name: "NodeMCU Base", + title: "NodeMCU Base", + description: "Tap to select", + page: "pageConfiguration", + params: [hwType: "nodemcu"], + image: "https://s3.us-east-2.amazonaws.com/konnected-io/icon-nodemcu.jpg", + ) + } + } +} + +private pageAssignPins() { + def device = state.device + dynamicPage(name: "pageConfiguration") { + section() { + input( + name: "name", + type: "text", + title: "Device name", + required: false, + defaultValue: "konnected-" + device?.mac[-6..-1] + ) + } + section(title: "Configure things wired to each zone or pin") { + pinMapping().each { i, label -> + def deviceTypeDefaultValue = (settings."deviceType_${i}") ? settings."deviceType_${i}" : "" + def deviceLabelDefaultValue = (settings."deviceLabel_${i}") ? settings."deviceLabel_${i}" : "" + + input( + name: "deviceType_${i}", + type: "enum", + title: label, + required: false, + multiple: false, + options: pageConfigurationGetDeviceType(i), + defaultValue: deviceTypeDefaultValue, + submitOnChange: true + ) + + if (settings."deviceType_${i}") { + input( + name: "deviceLabel_${i}", + type: "text", + title: "${label} device name", + description: "Name the device connected to ${label}", + required: (settings."deviceType_${i}" != null), + defaultValue: deviceLabelDefaultValue + ) + } + } + } + section(title: "Advanced settings") { + input( + name: "blink", + type: "bool", + title: "Blink LED on transmission", + required: false, + defaultValue: true + ) + input( + name: "enableDiscovery", + type: "bool", + title: "Enable device discovery", + required: false, + defaultValue: true + ) + } + } +} + +private Map pageConfigurationGetDeviceType(Integer i) { + def deviceTypes = [:] + def sensorPins = [1,2,5,6,7,9] + def digitalSensorPins = [1,2,3,5,6,7,9] + def actuatorPins = [1,2,5,6,7,8] + + if (sensorPins.contains(i)) { + deviceTypes << sensorsMap() + } + + if (actuatorPins.contains(i)) { + deviceTypes << actuatorsMap() + } + + if (digitalSensorPins.contains(i)) { + deviceTypes << digitalSensorsMap() + } + + return deviceTypes +} + +def getDeviceIpAndPort(device) { + "${convertHexToIP(device.networkAddress)}:${convertHexToInt(device.deviceAddress)}" +} + +// Device Discovery : Subscribe to SSDP events +def discoverySubscription() { + subscribe(location, "ssdpTerm.${parent.discoveryDeviceType()}", discoverySearchHandler, [filterEvents:false]) +} + +// Device Discovery : Handle search response +def discoverySearchHandler(evt) { + def event = parseLanMessage(evt.description) + event << ["hub":evt?.hubId] + String ssdpUSN = event.ssdpUSN.toString() + def device = state.device + if (device?.ssdpUSN == ssdpUSN) { + device.networkAddress = event.networkAddress + device.deviceAddress = event.deviceAddress + log.debug "Refreshed attributes of device $device" + } else if (device == null && parent.isNewDevice(event.mac)) { + state.device = event + log.debug "Discovered new device $event" + unsubscribe() + discoveryVerify(event) + } +} + +// Device Discovery : Verify a Device +def discoveryVerify(Map device) { + log.debug "Verifying communication with device $device" + String host = getDeviceIpAndPort(device) + sendHubCommand( + new physicalgraph.device.HubAction( + """GET ${device.ssdpPath} HTTP/1.1\r\nHOST: ${host}\r\n\r\n""", + physicalgraph.device.Protocol.LAN, + host, + [callback: discoveryVerificationHandler] + ) + ) +} + +//Device Discovery : Handle verification response +def discoveryVerificationHandler(physicalgraph.device.HubResponse hubResponse) { + def body = hubResponse.xml + def device = state.device + if (device?.ssdpUSN.contains(body?.device?.UDN?.text())) { + log.debug "Verification Success: $body" + device.name = body?.device?.roomName?.text() + device.model = body?.device?.modelName?.text() + device.serialNumber = body?.device?.serialNum?.text() + device.verified = true + } +} + +// Child Devices : create/delete child devices from SmartThings app selection +def childDeviceConfiguration() { + def device = state.device + settings.each { name , value -> + def nameValue = name.split("\\_") + if (nameValue[0] == "deviceType") { + def deviceDNI = [ device.mac, "${nameValue[1]}"].join('|') + def deviceLabel = settings."deviceLabel_${nameValue[1]}" + def deviceType = value + + // multiple ds18b20 sensors can be connected to one pin, so skip creating child devices here + // child devices will be created later when they report state for the first time + if (deviceType == "Konnected Temperature Probe (DS18B20)") { return } + + def deviceChild = getChildDevice(deviceDNI) + if (!deviceChild) { + if (deviceType != "") { + addChildDevice("konnected-io", deviceType, deviceDNI, device.hub, [ "label": deviceLabel ? deviceLabel : deviceType , "completedSetup": true ]) + } + } else { + // Change name if it's set here + if (deviceChild.label != deviceLabel) + deviceChild.label = deviceLabel + + // Change Type, you will lose the history of events. delete and add back the child + if (deviceChild.name != deviceType) { + deleteChildDevice(deviceDNI) + if (deviceType != "") { + addChildDevice("konnected-io", deviceType, deviceDNI, device.hub, [ "label": deviceLabel ? deviceLabel : deviceType , "completedSetup": true ]) + } + } + } + } + } + + def deleteChildDevices = getAllChildDevices().findAll { + settings."deviceType_${it.deviceNetworkId.split("\\|")[1]}" == null + } + + deleteChildDevices.each { + log.debug "Deleting device $it.deviceNetworkId" + deleteChildDevice(it.deviceNetworkId) + } +} + +// Child Devices : update state of child device sent from nodemcu +def childDeviceStateUpdate() { + def pin = params.id ?: request.JSON.pin + def addr = request.JSON?.addr?.replaceAll(':','') + def deviceId = params.mac.toUpperCase() + "|" + pin + if (addr) { deviceId = "$deviceId|$addr" } + def device = getChildDevice(deviceId) + if (device) { + if (request.JSON?.temp) { + log.debug "Temp: $request.JSON" + device.updateStates(request.JSON) + } else { + def newState = params.deviceState ?: request.JSON.state.toString() + log.debug "Received sensor update from Konnected device: $deviceId = $newState" + device.setStatus(newState) + } + } else { + if (addr) { + // New device found at this address, create it + log.debug "Adding new thing attached to Konnected: $deviceId" + device = addChildDevice("konnected-io", settings."deviceType_$pin", deviceId, state.device.hub, [ "label": addr , "completedSetup": true ]) + device.updateStates(request.JSON) + } else { + log.warn "Device $deviceId not found!" + } + } +} + +def getDeviceState() { + def pin = (params.pin ?: request.JSON?.pin).toInteger() + def deviceId = params.mac.toUpperCase() + "|" + pin + def device = getChildDevice(deviceId) + if (device) { + return [pin: pin, state: device.currentBinaryValue()] + } +} + +//Device: Ping from device +def devicePing() { + return "" +} + +//Device : update NodeMCU with token, url, sensors, actuators from SmartThings +def updateSettingsOnDevice() { + if(!state.accessToken) { createAccessToken() } + + def device = state.device + def sensors = [] + def actuators = [] + def dht_sensors = [] + def ds18b20_sensors = [] + def ip = getDeviceIpAndPort(device) + def mac = device.mac + + getAllChildDevices().each { + def pin = Integer.parseInt(it.deviceNetworkId.split("\\|")[1]) + if (it.name.contains("DHT")) { + dht_sensors = dht_sensors + [ pin : pin, poll_interval : it.pollInterval() ] + } else if (sensorsMap()[it.name]) { + sensors = sensors + [ pin : pin ] + } else if (actuatorsMap()[it.name]) { + actuators = actuators + [ pin : pin, trigger : it.triggerLevel() ] + } + } + + settings.each { name , value -> + def nameValue = name.split("\\_") + if (nameValue[0] == "deviceType" && value.contains("DS18B20")) { + ds18b20_sensors = ds18b20_sensors + [ pin : nameValue[1], poll_interval : 3 ] + } + } + + log.debug "Configured sensors on $mac: $sensors" + log.debug "Configured actuators on $mac: $actuators" + log.debug "Configured DHT sensors on $mac: $dht_sensors" + log.debug "Configured DS18B20 sensors on $mac: $ds18b20_sensors" + + log.debug "Blink is: ${settings.blink}" + def body = [ + token : state.accessToken, + apiUrl : apiServerUrl + "/api/smartapps/installations/" + app.id, + blink: settings.blink, + discovery: settings.enableDiscovery, + sensors : sensors, + actuators : actuators, + dht_sensors : dht_sensors, + ds18b20_sensors : ds18b20_sensors + ] + + log.debug "Updating settings on device $mac at $ip" + sendHubCommand(new physicalgraph.device.HubAction([ + method: "PUT", + path: "/settings", + headers: [ HOST: ip, "Content-Type": "application/json" ], + body : groovy.json.JsonOutput.toJson(body) + ], ip )) +} + +// Device: update NodeMCU with state of device changed from SmartThings +def deviceUpdateDeviceState(deviceDNI, deviceState, Map actuatorOptions = [:]) { + def deviceId = deviceDNI.split("\\|")[1] + def deviceMac = deviceDNI.split("\\|")[0] + def body = [ pin : deviceId, state : deviceState ] << actuatorOptions + def device = state.device + + if (device && device.mac == deviceMac) { + log.debug "Updating device $deviceMac pin $deviceId to $deviceState at " + getDeviceIpAndPort(device) + sendHubCommand(new physicalgraph.device.HubAction([ + method: "PUT", + path: "/device", + headers: [ HOST: getDeviceIpAndPort(device), "Content-Type": "application/json" ], + body : groovy.json.JsonOutput.toJson(body) + ], getDeviceIpAndPort(device), [callback: "syncChildPinState"])) + } +} + +void syncChildPinState(physicalgraph.device.HubResponse hubResponse) { + def device = getAllChildDevices().find { it.deviceNetworkId == hubResponse.mac + '|' + hubResponse.json.pin } + device?.updatePinState(hubResponse.json.state) +} + +private Map pinMapping() { + if (state.hwType == "alarmPanel") { + return [ + 1: "Zone 1", + 2: "Zone 2", + 5: "Zone 3", + 6: "Zone 4", + 7: "Zone 5", + 9: "Zone 6", + 8: "ALARM/OUT" + ] + } else { + return [ + 1: "Pin D1", + 2: "Pin D2", + 3: "Pin D3", + 5: "Pin D5", + 6: "Pin D6", + 7: "Pin D7", + 8: "Pin D8", + 9: "Pin RX" + ] + } +} + +private Map actuatorsMap() { + return [ + "Konnected Siren/Strobe" : "Siren/Strobe", + "Konnected Switch" : "Switch", + "Konnected Momentary Switch" : "Momentary Switch", + "Konnected Beep/Blink" : "Beep/Blink Switch" + ] +} + +private Map sensorsMap() { + return [ + "Konnected Contact Sensor" : "Open/Close Sensor", + "Konnected Motion Sensor" : "Motion Sensor", + "Konnected Smoke Sensor" : "Smoke Detector", + "Konnected CO Sensor" : "Carbon Monoxide Detector", + "Konnected Panic Button" : "Panic Button", + "Konnected Water Sensor" : "Water Sensor" + ] +} + +private Map digitalSensorsMap() { + return [ + "Konnected Temperature & Humidity Sensor (DHT)" : "Temperature & Humidity Sensor", + "Konnected Temperature Probe (DS18B20)" : "Temperature Probe(s)" + ] +} + +private Integer convertHexToInt(hex) { Integer.parseInt(hex,16) } +private String convertHexToIP(hex) { [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") } \ No newline at end of file diff --git a/smartapps/kriskit-trendsetter/group.src/group.groovy b/smartapps/kriskit-trendsetter/group.src/group.groovy new file mode 100644 index 00000000000..c1cc4da129a --- /dev/null +++ b/smartapps/kriskit-trendsetter/group.src/group.groovy @@ -0,0 +1,312 @@ +/** + * Trend Setter - Group + * + * Copyright 2015 Chris Kitch + * + * 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. + * + */ +definition( + name: "Group", + namespace: "kriskit.trendSetter", + author: "Chris Kitch", + description: "A child SmartApp for Trend Setter for handling a group of devices.", + category: "My Apps", + iconUrl: "https://cdn.rawgit.com/Kriskit/SmartThingsPublic/master/smartapps/kriskit/trendsetter/icon.png", + iconX2Url: "https://cdn.rawgit.com/Kriskit/SmartThingsPublic/master/smartapps/kriskit/trendsetter/icon@2x.png", + iconX3Url: "https://cdn.rawgit.com/Kriskit/SmartThingsPublic/master/smartapps/kriskit/trendsetter/icon@3x.png") + +def version() { + return "1.0" +} + +def typeDefinitions() { + return [ + [ + type: "switch", + singular: "Switch", + plural: "Switches", + deviceType: "Switch Group Device", + attributes: [ + [name: "switch"] + ] + ], + [ + type: "switchLevel", + singular: "Dimmer", + plural: "Dimmers", + deviceType: "Dimmer Group Device", + inherits: "switch", + attributes: [ + [name: "level"] + ] + ], + [ + type: "colorControl", + singular: "Colorful Light", + plural: "Colorful Lights", + deviceType: "Colorful Light Group Device", + inherits: "switchLevel", + attributes: [ + [name: "hue"], + [name: "saturation"], + [name: "color"] + ] + ], + [ + type: "powerMeter", + singular: "Power Meter", + plural: "Power Meters", + deviceType: "Power Meter Group Device", + attributes: [ + [name: "power"] + ] + ] + ] +} + +// Setup +preferences { + page(name: "configure") +} + +def configure() { + atomicState.typeDefinitions = null + def controller = getControllerDevice(); + + dynamicPage(name: "configure", uninstall: controller != null, install: true) { + if (!controller) { + section { + input "deviceType", "enum", title: "Device Type", required: true, submitOnChange: true, options: getDeviceTypeOptions() + paragraph "This cannot be changed once the group is created.", color: "#ffcc00" + } + } + + if (deviceType) { + def definition = getTypeDefinition(deviceType) + + section(title: controller == null ? "Grouping" : null) { + label title: "Group Name", required: true + + input "devices", "capability.${deviceType}", title: "${definition.plural}", multiple: true, required: true, submitOnChange: controller != null + + if (selectedDevicesContainsController()) { + paragraph "WARNING: You have selected the controller ${definition.singular.toLowerCase()} for this group. This will likely cause unexpected behaviour.\n\nPlease uncheck the '${controller.displayName}' from the selected ${definition.plural.toLowerCase()}.", + image: "https://cdn2.iconfinder.com/data/icons/freecns-cumulus/32/519791-101_Warning-512.png" + } + } + + if (controller == null) { + section(title: "Controller") { + input "deviceName", "text", title: "${definition.singular} Name", required: true, description: "For the controlling virtual ${definition.singular.toLowerCase()} to be created" + } + } + + if (definition.advanced) { + section(title: "Advanced", hidden: true, hideable: true) { + } + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + installControllerDevice() + initialize() +} + +def installControllerDevice() { + def definition = getTypeDefinition() + + log.debug "Installing switch group controller device..." + addChildDevice("kriskit.trendSetter", definition.deviceType, UUID.randomUUID().toString(), null, ["name": deviceName, "label": deviceName, completedSetup: true]) +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + def definition = getTypeDefinition() + addSubscriptions(definition) + def namesToCheck = definition.attributes?.collect { it.name } + updateControllerState(namesToCheck) +} + +def addSubscriptions(definition) { + def controller = getControllerDevice() + + definition.attributes?.each { + log.debug "Subscribing to ${it.name}..." + subscribe(devices, it.name, onDeviceAttributeChange) + } +} + +// Subscription Handlers +def onDeviceAttributeChange(evt) { + def namesToCheck = atomicState.namesToCheck ?: [] + + log.debug "Device state change: ${evt.device.displayName} -> ${evt.name} = ${evt.value}" + + if (!namesToCheck.any { it == evt.name }) + namesToCheck.push(evt.name) + + atomicState.namesToCheck = namesToCheck + runIn(1, "updateControllerState") +} + +def updateControllerState() { + def namesToCheck = atomicState.namesToCheck + updateControllerState(namesToCheck) + atomicState.namesToCheck = null +} + +def updateControllerState(namesToCheck) { + if (!namesToCheck) + return + + def controller = getControllerDevice() + namesToCheck?.each { name -> + def values = devices?.currentValue(name) + values?.removeAll([null]) + log.debug "Updating Controller State: $name -> $values" + controller.groupSync(name, values) + } +} + +def performGroupCommand(command, arguments = null) { + runCommand(devices, command, arguments ?: []) +} + +def runCommand(target, command, args) { + log.debug "Running command '${command}' with arguments ${args} on ${target}..." + $performCommand(target, command, args) +} + +def getGroupCurrentValues(name) { + return devices?.currentValue(name) +} + +// Utilities +def getTypeDefinitions() { + if (atomicState.version != version()) { + atomicState.typeDefinitions = null + atomicState.version = version() + } + + if (atomicState.typeDefinitions) + return atomicState.typeDefinitions + + log.debug "Building type definitions..." + + def result = [] + def definitions = typeDefinitions() + + definitions?.each { definition -> + if (definition.inherits) + definition = mergeAttributes(definition, definitions.find { it.type == definition.inherits }) + + result.push(definition) + } + + atomicState.typeDefinitions = result + + return result +} + +def mergeAttributes(definition, inheritedDefinition) { + inheritedDefinition.attributes?.each { attr -> + if (!definition.attributes?.any { it.name == attr.name }) + definition.attributes.push(attr) + } + + if (inheritedDefinition.inherits) { + def definitions = typeDefinitions() + definition = mergeAttributes(definition, definitions.find { it.type == inheritedDefinition.inherits }) + } + + return definition +} + +def getControllerDevice() { + return getChildDevices()?.find { true } +} + +def getTypeDefinition() { + return getTypeDefinition(deviceType) +} + +def getTypeDefinition(type) { + return getTypeDefinitions().find { + it.type == type + } +} + +def getDeviceTypeOptions() { + return getTypeDefinitions().collect { + ["${it.type}": it.singular] + } +} + +def selectedDevicesContainsController() { + def controller = getControllerDevice() + return devices?.any { + it.deviceNetworkId == controller.deviceNetworkId + } +} + +private $performCommand(target, command, args) { + switch(args?.size()) { + default: + target?."$command"() + break + + case 1: + target?."$command"(args[0]) + break + + case 2: + target?."$command"(args[0], args[1]) + break + + case 3: + target?."$command"(args[0], args[1], args[2]) + break + + case 4: + target?."$command"(args[0], args[1], args[2], args[3]) + break + + case 5: + target?."$command"(args[0], args[1], args[2], args[3], args[4], args[5]) + break + + case 6: + target?."$command"(args[0], args[1], args[2], args[3], args[4], args[5], args[6]) + break + + case 7: + target?."$command"(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7]) + break + + case 8: + target?."$command"(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7], args[8]) + break + + case 9: + target?."$command"(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7], args[8], args[9]) + break + } +} \ No newline at end of file diff --git a/smartapps/kriskit-trendsetter/trend-setter.src/trend-setter.groovy b/smartapps/kriskit-trendsetter/trend-setter.src/trend-setter.groovy new file mode 100644 index 00000000000..8dc811c86c6 --- /dev/null +++ b/smartapps/kriskit-trendsetter/trend-setter.src/trend-setter.groovy @@ -0,0 +1,55 @@ +/** + * Trend Setter + * + * Copyright 2015 Chris Kitch + * + * 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. + * + */ +definition( + name: "Trend Setter", + namespace: "kriskit.trendSetter", + author: "Chris Kitch", + description: "Uses virtual child devices to group other devices together and perform commands and aggregate data from the group.", + category: "My Apps", + iconUrl: "https://cdn.rawgit.com/Kriskit/SmartThingsPublic/master/smartapps/kriskit/trendsetter/icon.png", + iconX2Url: "https://cdn.rawgit.com/Kriskit/SmartThingsPublic/master/smartapps/kriskit/trendsetter/icon@2x.png", + iconX3Url: "https://cdn.rawgit.com/Kriskit/SmartThingsPublic/master/smartapps/kriskit/trendsetter/icon@3x.png", + singleInstance: true) + + +preferences { + page(name: "mainPage") +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "Your Groups", install: true, uninstall: true, submitOnChange: true) { + section { + app(name: "groups", appName: "Group", namespace: "kriskit.trendSetter", title: "Create Group...", multiple: true) + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. +} \ No newline at end of file diff --git a/smartapps/kristopherkubicki/siren-beep.src/siren-beep.groovy b/smartapps/kristopherkubicki/siren-beep.src/siren-beep.groovy new file mode 100644 index 00000000000..9ee10f4c965 --- /dev/null +++ b/smartapps/kristopherkubicki/siren-beep.src/siren-beep.groovy @@ -0,0 +1,45 @@ +/** + * Siren Beep + * + */ +definition( + name: "Siren Beep", + namespace: "KristopherKubicki", + author: "kristopher@acm.org", + description: "Quickly Pulse a Siren", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Developers/whole-house-fan.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Developers/whole-house-fan@2x.png") + + + +preferences { + section("Sirens"){ + input "sirens", "capability.alarm", title: "Which?", required: true, multiple: true + } + section("Virtual Switch"){ + input "dswitch", "capability.switch", title: "Which?", required: true, multiple: false + } +} + + +def installed() { + initialized() +} + +def updated() { + unsubscribe() + initialized() +} + +def initialized() { + subscribe(dswitch, "switch.on", switchHandler) + +} + +def switchHandler(evt) { + sirens?.siren() + dswitch.off() + sirens?.off() + +} \ No newline at end of file diff --git a/smartapps/michaelstruck/ask-alexa.src/ask-alexa.groovy b/smartapps/michaelstruck/ask-alexa.src/ask-alexa.groovy new file mode 100644 index 00000000000..d491a9dab00 --- /dev/null +++ b/smartapps/michaelstruck/ask-alexa.src/ask-alexa.groovy @@ -0,0 +1,4084 @@ +/** + * Ask Alexa + * + * Version 2.3.9f - 3/16/18 Copyright © 2018 Michael Struck + * Special thanks for Keith DeLong for overall code and assistance; jhamstead for Ecobee climate modes, Yves Racine for My Ecobee thermostat tips + * + * Version information prior to 2.3.8 listed here: https://github.com/MichaelStruck/SmartThingsPublic/blob/master/smartapps/michaelstruck/ask-alexa.src/Ask%20Alexa%20Version%20History.md + * + * Version 2.3.8 (2/8/18) Added occupancy sensors to main devices and macros, updated code for new ST actions and restrictions for playback, setup data now sends POST data + * Version 2.3.9f (3/16/18) Added Alexa speaker idenification to many aspects of the applications, extensions and restrictions + * + * 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. + * + */ +definition( + name: "Ask Alexa${parent ? " - Macro " : ""}", + namespace: "MichaelStruck", + //Change line below to 'false' to allow for multi app install (Advanced...see instructions) + singleInstance: true, + //----------------------------------------------------------- + author: "Michael Struck", + parent: parent ? "MichaelStruck.Ask Alexa" : null, + description: "Provide interfacing to control and report on SmartThings devices with the Amazon Echo ('Alexa').", + category: "My Apps", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/AskAlexa.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/AskAlexa@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/AskAlexa@2x.png", + oauth: true) +preferences { + page name:"pageMain" + //Parent Pages + page name:"mainPageParent" + page name:"pageMQGUI" + page name:"pageMsgDelete" + page name:"pageSwitches" + page name:"pageDoors" + page name:"pageEnviro" + page name:"pageSpeakers" + page name:"pageSensors" + page name:"pageHomeControl" + page name:"pageAliasMain" + page name:"pageAliasAdd" + page name: "pageAliasAddFinal" + page name:"pageAliasDel" + page name: "pageAliasDelFinal" + page name:"pageExtensions" + page name:"pageMacros" + page name:"pageMsgQue" + page name:"pagePriQueue" + page name:"pageWeather" + page name:"pageVoiceRPT" + page name:"pageSchdr" + page name:"pageRooms" + page name: "pageSchdList" + page name:"pageSettings" + page name:"pageDeviceVoice" + page name: "pagePINRestrictions" + page name:"pageReset" + page name:"pageConfirmation" + page name:"pageContCommands" + page name:"pageSetup" + page name:"pageGlobalOptions" + page name:"pageDefaultValue" + page name:"pageCustomColor" + page name:"pageLimitValue" + page name:"pageGlobalVariables" + page name:"pageWebCoREVar" + page name:"pagexParamAdd" + page name: "pagexParamAddFinal" + page name:"pagexParamDel" + page name: "pagexParamDelFinal" + page name:"pageAbout" + //Child Pages + page name:"mainPageChild" + page name:"pageCoRE" + page name:"pageGroupM" + page name:"pageMQExt" + page name:"pageControl" + page name:"pageSTDevices" + page name:"pageMQ" + page name:"pageHTTP" + page name:"pageOccupy" +} +def pageMain() { if (!parent) mainPageParent() else mainPageChild() } +def mainPageParent() { + dynamicPage(name: "mainPageParent", install: true, uninstall: false) { + def duplicates = deviceList.name.findAll{deviceList.name.count(it)>1}.unique() + def aliasDups = deviceAlias && state.aliasList ? deviceList.name.intersect(state.aliasList.aliasNameLC) : null + def macChildren = getAskAlexa(), macroCount = macChildren.size(), mqChildren = getAAMQ(), mqCount = mqChildren.size() + if (duplicates || findNullDevices() || (aliasDups && deviceAlias) || findDeviceReserverd()){ + section ("**WARNING**"){ + if (findDeviceReserverd()) paragraph "You have used the reserved words 'echo', 'room', 'this room', 'group', 'this group', 'here' or 'in here' in your device names or aliases. Please rename these devices to ensure Ask Alexa functions properly.", image: imgURL() + "caution.png" + if (duplicates) paragraph "You have the following device(s) used multiple times within Ask Alexa:\n\n${getList(duplicates)}\n\nA device should be uniquely named and appear only once in the categories below.", image: imgURL() + "caution.png" + if (aliasDups && deviceAlias) paragraph "The following alias(es) conflict with a device name already set up:\n\n${getList(aliasDups)}\n\nAliases should be uniquely named and appear only once within the Ask Alexa SmartApp.", image: imgURL() + "caution.png" + if (findNullDevices()) paragraph findNullDevices(), image: imgURL() + "caution.png" + } + } + if (msgQueueGUI && mqCounts(msgQueueGUI)) section ("Message Queues"){ href "pageMQGUI", title: "Message Queue", description: mqCounts(msgQueueGUI) + " - Tap to read", state: "complete", image:imgURL() + "mailbox.png" } + section("Items to interface to Alexa") { + href "pageSwitches", title: "Lighting/Switches", description:getDesc(switchesSel() || dimmersSel() || cLightsSel() || cLightsKSel()), state: switchesSel() || dimmersSel() || cLightsSel() || cLightsKSel() ? "complete" : null, image:imgURL() + "power.png" + href "pageDoors", title: "Doors/Windows/Locks", description: getDesc(doorsSel() || locksSel() || ocSensorsSel() || shadesSel()), state: doorsSel() || locksSel() || ocSensorsSel() || shadesSel() ? "complete" : null, image: imgURL() + "lock.png" + href "pageEnviro", title: "Environmentals", description:getDesc(tstatsSel() || tempsSel() || humidSel() || fooBotSel() || uvSel()), state: tstatsSel() || tempsSel() || humidSel() || fooBotSel() || uvSel()? "complete" : null, image: imgURL() + "temp.png" + href "pageSpeakers", title: "Connected Speakers", description: getDesc(speakersSel()), state: speakersSel() ? "complete" : null, image:imgURL() + "speaker.png" + href "pageSensors", title: "Other Sensors", description:getDesc(waterSel() || presenceSel() || motionSel() || accelerationSel() || occSel()), state: waterSel() || presenceSel() || motionSel() || accelerationSel() || occSel() ? "complete" : null, image: imgURL() + "sensor.png" + href "pageHomeControl", title: "Modes/SHM/Routines", description:getDesc(listModes || listRoutines || listSHM), state: (listModes|| listRoutines|| listSHM ? "complete" : null), image: imgURL() + "modes.png" + if (deviceAlias && mapDevices(true)) href "pageAliasMain", title: "Device Aliases", description:getDesc(state.aliasList), state: (state.aliasList ?"complete":null), image: imgURL() + "alias.png" + } + section("Ask Alexa extensions") {href "pageExtensions", title: "Ask Alexa Extensions", description: "Tap to add/edit Ask Alexa extensions", state: (macroCount || mqCount ? "complete" : null), image: imgURL() + "exadd.png" } + section("Options") { + href "pageSettings", title: "Settings", description: "Tap to configure application settings or to setup Ask Alexa", image: imgURL() + "settings.png" + href "pageAbout", title: "About ${textAppName()}", description: "Tap to get version information, license, instructions or to remove the application", image: imgURL() + "info.png" + } + } +} +def pageExtensions(){ + dynamicPage(name: "pageExtensions", install: false, uninstall: false) { + def macroCount = getAskAlexa().size(), mqCount = getAAMQ().size(), vrCount=getVR().size, schCount =getSCHD().size(), rmCount=getRM().size, wrCount =getWR().size + section { paragraph "Ask Alexa Extensions", image: imgURL() + "exadd.png" } + section("Installed extensions"){ + def macResCount = findMacroReserved() + findMQReserved() + def duplicates =getAskAlexa().label.findAll{getAskAlexa().label.count(it)>1}.unique() + duplicates +=getWR().label.findAll{getWR().label.count(it)>1}.unique() + duplicates +=getVR().label.findAll{getVR().label.count(it)>1}.unique() + duplicates +=getAAMQ().label.findAll{getAAMQ().label.count(it)>1}.unique() + duplicates +=getSCHD().label.findAll{getSCHD().label.count(it)>1}.unique() + duplicates +=getRM().label.findAll{getRM().label.count(it)>1}.unique() + if (duplicates) paragraph "You have two or more extensions that have the same name. Please ensure each extension has a unique name and also does not conflict with device or other extension names.", image: imgURL() + "caution.png" + if (macResCount) paragraph "You have used the reserved words 'echo', 'room', 'group' or 'here' in your extension names or aliases. Please change these extensions to ensure Ask Alexa functions properly.", image: imgURL() + "caution.png" + href "pageMacros", title: "Macros", description: macroDesc(macroCount), state: (macroCount ? "complete" : null), image: imgURL() + "speak.png" + href "pageMsgQue", title: "Message Queues", description: mqDesc(mqCount), state: "complete", image: imgURL() + "mailbox.png" + href "pageRooms", title: "Rooms/Groups", description: rmDesc(rmCount), state: (rmCount ? "complete" : null), image: imgURL() + "room.png" + href "pageSchdr", title: "Schedules", description: schDesc(schCount), state: (schCount ? "complete" : null), image: imgURL() + "schedule.png" + href "pageVoiceRPT", title: "Voice Reports", description:voiceDesc(vrCount), state: (vrCount ? "complete" : null), image: imgURL() + "voice.png" + href "pageWeather", title: "Weather Reports", description:weathDesc(wrCount), state: (wrCount ? "complete" : null), image: imgURL() + "weather.png" + } + } +} +def pageMQGUI(){ + dynamicPage(name: "pageMQGUI", install: false, uninstall: false) { + def msgRpt = "" + section { paragraph "Message Queues", image: imgURL() + "mailbox.png"} + if(msgQueueGUI.contains("Primary Message Queue") && state.msgQueue.size()){ + state.msgQueue.sort({it.date}) + if(msgQueueOrder) state.msgQueue.reverse(msgQueueOrder as int? true : false) + state.msgQueue.each{ + def msgData= timeDate(it.date) + msgRpt += "● ${msgData.msgDay} at ${msgData.msgTime} From: '${it.appName}' : '${it.msg}'\n" + } + section ("Primary Message Queue") { paragraph msgRpt } + } + msgQueueGUI.each{qID-> + def qNameRun = getAAMQ().find{it.id == qID}, msgCount = 0 + if (qNameRun) { + msgCount += qNameRun.qSize() + if (msgCount) { + msgRpt = qNameRun.MQGUI() + section ("${qNameRun.label} message queue"){ paragraph msgRpt } + } + } + } + section ("Options"){ + href "pageMsgQue", title: "Tap To Go To Messaging Queue Options", description: none + href "pageMsgDelete", title: "Tap To Delete All Messages In The Message Queues Above", description: none + } + } +} +def pageMsgDelete(){ + dynamicPage(name: "pageMsgDelete", install: false, uninstall: false) { + if(msgQueueGUI.contains("Primary Message Queue")) qDelete() + childQDelete(msgQueueGUI) + section { paragraph "All Message Successfully Deleted", image: imgURL() + "check.png" } + section (" "){ + href "pageMsgQue", title: "Tap To Go To Message Queue Options", description: none + href "mainPageParent", title: "Tap Here To Return To The Main Menu", description:none + } + } +} +def pageSchdr() { + dynamicPage(name: "pageSchdr", install: false, uninstall: false) { + section{ paragraph "Schedules", image: imgURL() + "schedule.png" } + def children = getSCHD(), schCount=children.size(), duplicates = children.label.findAll{children.label.count(it)>1}.unique(), aaSCVer="" + if (schCount){ + children.each { aaSCVer=it.versionInt()} + section ("Schedule option"){ + href "pageSchdList", title: "Tap For Schedule Status List", description: "" + input "schDeleteTime", "number", title: "Minutes After Schedule Expires/Delete Command To Override", range:"1..10", description: "If blank, will default to 2 minutes", required: false, defaultValue: 2 + } + } + if (duplicates || (schCount && (aaSCVer < schReq()))){ + section ("**Warning**"){ + if (duplicates) paragraph "You have two or more schedules with the same name. Please ensure each schedule has a unique name and also does not conflict with device or other extension names as well. ", image: imgURL() + "caution.png" + if (schCount && (aaSCVer < schReq())) paragraph "You are using an outdated version of the schedules extension. Please update the software and try again.", image: imgURL() + "caution.png" + } + } + if (schCount) section(schCount==1 ? "One schedule configured" : schCount + " schedules configured" ){} + section(" "){ + app(name: "childSCHD", appName: "Ask Alexa Schedule", namespace: "MichaelStruck", title: "Create A New Schedule...", description: "Tap to create a new schedule", multiple: true, image: imgURL() + "add.png") + } + } +} +def pageSchdList() { + dynamicPage(name: "pageSchdList", install: false, uninstall: false) { + section{ paragraph "Schedule List", image: imgURL() + "schedule.png" } + section(" "){ + getSCHD().each{ + def status = it.getStatus(), result, imageFile= status =="On" ? imgURL() + "on.png" : imgURL() + "off.png" + if (status =~/Expired|Invalid|Incomplete/) imageFile=imgURL() +"warning.png" + if (status==~/On|Off/) result = "${it.label}: Runs ${it.getShortDesc()}" + else if (status =="Expired") result ="${it.label}: ${status} ${it.getShortDesc()}" + else result ="${it.label}: ${status}" + paragraph result, image: imageFile + } + } + section ("Help"){ + paragraph "To change the on/off status of a schedule, either edit the schedule on the previous screen, or simple say: \"Alexa, tell ${invocationName} to turn {on/off} the {schedule name}\".\n\nIf you have "+ + "an expired or invalid schedule you will need to edit that schedule via the mobile app.", image: imgURL()+"info.png" + } + } +} +def pageSwitches(){ + dynamicPage(name: "pageSwitches", install: false, uninstall: false) { + section { paragraph "Lighting/Switches", image: imgURL() + "power.png"} + section("Choose the devices to interface", hideWhenEmpty: true) { + input "switches", "capability.switch", title: "Choose Switches (On/Off/Toggle/Status)", multiple: true, required: false + input "dimmers", "capability.switchLevel", title: "Choose Dimmers (On/Off/Toggle/Level/Status)", multiple: true, required: false + input "cLights", "capability.colorControl", title: "Choose Colored Lights (On/Off/Toggle/Level/Color/Status)", multiple: true, required: false, submitOnChange: true + input "cLightsK", "capability.colorTemperature", title: "Choose Temperature (Kelvin) Lights (On/Off/Toggle/Level/Temperature/Status)", multiple: true, required: false, submitOnChange: true + } + if (deviceAlias){ + section("Devices that can have aliases", hideWhenEmpty: true) { + input "switchesAlias", "capability.switch", title: "Choose Switches", multiple: true, required: false + input "dimmersAlias", "capability.switchLevel", title: "Choose Dimmers", multiple: true, required: false + input "cLightsAlias", "capability.colorControl", title: "Choose Colored Lights", multiple: true, required: false, submitOnChange: true + input "cLightsKAlias", "capability.colorTemperature", title: "Choose Temperature (Kelvin) Lights", multiple: true, required: false, submitOnChange: true + } + } + if (cLightsKSel()){ + section ("Notes on Temperature (Kelvin) Lights"){ + paragraph "The following color temperatures are valid:\nSoft White, Warm White, Cool White, Daylight White", image: imgURL() + "info.png" + } + } + if (cLightsSel()){ + section ("Colored Light model specific commands"){ + input "osramCMD", "bool", title: "OSRAM Specialized Device Handeler (Loop/Pulse)", defaultValue: false + } + } + } +} +def pageDoors() { + dynamicPage(name: "pageDoors", install: false, uninstall: false) { + section { paragraph "Doors/Windows/Locks", image: imgURL() + "lock.png" } + section("Choose the devices to interface", hideWhenEmpty: true) { + input "doors", "capability.doorControl", title: "Choose Door Controls (Open/Close/Status)" , multiple: true, required: false, submitOnChange: true + input "shades", "capability.windowShade", title: "Choose Window Shade Controls (Open/Close/Status)", multiple: true, required: false + input "ocSensors", "capability.contactSensor", title: "Choose Open/Close Sensors (Status)", multiple: true, required: false + input "locks", "capability.lock", title: "Choose Locks (Lock/Unlock/Status)", multiple: true, required: false, submitOnChange: true + } + if (deviceAlias){ + section("Devices that can have aliases", hideWhenEmpty: true) { + input "doorsAlias", "capability.doorControl", title: "Choose Door Controls" , multiple: true, required: false, submitOnChange: true + input "shadesAlias", "capability.windowShade", title: "Choose Window Shade Controls", multiple: true, required: false + input "ocSensorsAlias", "capability.contactSensor", title: "Choose Open/Close Sensors", multiple: true, required: false + input "locksAlias", "capability.lock", title: "Choose Locks", multiple: true, required: false, submitOnChange: true + } + } + if ((doorsSel() || locksSel())){ + section("Security"){ + if (doorsSel()){ + if (pwNeeded) input "doorPW", "bool", title: "Require PIN For Door Actions", defaultValue: false + if (!doorCloseDisable) input "doorOpenDisable", "bool", title: "Disable Door 'Open' Command", defaultValue: false, submitOnChange: true + if (!doorOpenDisable) input "doorCloseDisable", "bool", title: "Disable Door 'Close' Command", defaultValue: false, submitOnChange: true + } + if (locksSel()){ + if (pwNeeded) input "lockPW", "bool", title: "Require PIN For Lock Actions", defaultValue: false + if (!lockUnLockDisable) input "lockLockDisable", "bool", title: "Disable Lock 'Lock' Command", defaultValue: false, submitOnChange: true + if (!lockLockDisable) input "lockUnLockDisable", "bool", title: "Disable Lock 'Unlock' Command", defaultValue: false, submitOnChange: true + } + } + } + } +} +def pageEnviro(){ + dynamicPage(name: "pageEnviro", install: false, uninstall: false) { + section {paragraph "Environmentals", image: imgURL() + "temp.png"} + section("Choose the devices to interface", hideWhenEmpty: true) { + input "tstats", "capability.thermostat", title: "Choose Thermostats (Temperature Setpoint/Status)", multiple: true, required: false, submitOnChange:true + input "temps", "capability.temperatureMeasurement", title: "Choose Temperature Devices (Status)", multiple: true, required: false + input "humid", "capability.relativeHumidityMeasurement", title: "Choose Humidity Devices (Status)", multiple: true, required: false + input "fooBot", "capability.carbonDioxideMeasurement", title: "Choose Foobot Air Quality Monitor (Status)", multiple: true, required: false, submitOnChange:true + input "UV", "capability.ultravioletIndex", title: "Choose UV Index Devices (Status)", multiple: true, required: false, submitOnChange:true + } + if (deviceAlias){ + section("Devices that can have aliases", hideWhenEmpty: true) { + input "tstatsAlias", "capability.thermostat", title: "Choose Thermostats", multiple: true, required: false, submitOnChange:true + input "tempsAlias", "capability.temperatureMeasurement", title: "Choose Temperature Devices", multiple: true, required: false + input "humidAlias", "capability.relativeHumidityMeasurement", title: "Choose Humidity Devices", multiple: true, required: false + input "fooBotAlias", "capability.carbonDioxideMeasurement", title: "Choose Foobot Air Quality Monitor", multiple: true, required: false, submitOnChange: true + input "UVAlias", "capability.ultravioletIndex", title: "Choose UV Index Devices (Status)", multiple: true, required: false, submitOnChange:true + } + } + if (tstatsSel()){ + section("Default Thermostat Commands"){ + if (!tstatCool) input "tstatHeat", "bool", title: "Set Heating Setpoint By Default", defaultValue:false, submitOnChange:true + if (!tstatHeat) input "tstatCool", "bool", title: "Set Cooling Setpoint By Default", defaultValue:false, submitOnChange:true + } + section("Thermostat model specific commands"){ + input "ecobeeCMD", "bool", title: "Ecobee Specific Thermostat Modes\n(Home/Away/Sleep/Resume Program)", defaultValue: false, submitOnChange: true + if (ecobeeCMD) input "MyEcobeeCMD", "bool", title: "MyEcobee Specific Tips\n(Get Tips/Play Tips/Erase Tips)", defaultValue: false + input "nestCMD", "bool", title: "Nest-Specific Thermostat Presence Commands (Home/Away)", defaultValue: false, submitOnChange: true + if (nestCMD) input "nestMGRCMD", "bool", title: "NST Manager Specific Reports (Report)", defaultValue: false + input "stelproCMD", "bool", title: "Stelpro Baseboard\nThermostat Modes (Eco/Comfort)", defaultValue:false + if (nestCMD) input "MyNestCMD", "bool", title: "MyNextTstat Specific Tips\n(Get Tips/Play Tips/Erase Tips)", defaultValue: false + } + } + if (fooBotSel()) section("Foobot Refresh"){ input "fooBotPoll", "bool", title: "Refresh Foobot Data Before Speaking Status", defaultValue: false } + } +} +def pageSpeakers(){ + dynamicPage(name: "pageSpeakers", install: false, uninstall: false) { + section {paragraph "Connected Speakers", image: imgURL() + "speaker.png"} + section("Choose the devices to interface", hideWhenEmpty: true) { input "speakers", "capability.musicPlayer", title: "Choose Speakers (Speaker Control, Status)", multiple: true, required: false, submitOnChange: true } + if (deviceAlias) section("Devices that can have aliases", hideWhenEmpty: true) { input "speakersAlias", "capability.musicPlayer", title: "Choose Speakers", multiple: true, required: false, submitOnChange: true } + } +} +def pageSensors(){ + dynamicPage(name: "pageSensors", install: false, uninstall: false) { + section {paragraph "Other Sensors", image: imgURL() + "sensor.png"} + section("Choose the devices to interface", hideWhenEmpty: true) { + input "acceleration", "capability.accelerationSensor", title: "Choose Acceleration Sensors (Status)", multiple: true, required: false + input "motion", "capability.motionSensor", title: "Choose Motion Sensors (Status)", multiple: true, required: false + input "presence", "capability.presenceSensor", title: vPresenceCMD ? "Choose Presence Sensors (Status/Home/Away)" : "Choose Presence Sensors (Status)", multiple: true, required: false, submitOnChange: true + input "occupancy", "capability.beacon", title: "Occupancy Sensors (Status/State)", multiple: true, required: false + input "water", "capability.waterSensor", title: "Choose Water Sensors (Status)", multiple: true, required: false + } + if (deviceAlias){ + section("Devices that can have aliases", hideWhenEmpty: true) { + input "accelerationAlias", "capability.accelerationSensor", title: "Choose Acceleration Sensors", multiple: true, required: false + input "motionAlias", "capability.motionSensor", title: "Choose Motion Sensors", multiple: true, required: false + input "presenceAlias", "capability.presenceSensor", title: "Choose Presence Sensors", multiple: true, required: false, submitOnChange: true + input "occupancyAlias", "capability.beacon", title: "Occupancy Sensors (Status/State)", multiple: true, required: false + input "waterAlias", "capability.waterSensor", title: "Choose Water Sensors", multiple: true, required: false + } + } + if (presenceSel()){ + section("Presence sensor specific commands"){ + input "vPresenceCMD", "bool", title: "Virtual Presence (Home/Away)", defaultValue: false, submitOnChange: true + } + } + } +} +def pageHomeControl(){ + dynamicPage(name: "pageHomeControl", uninstall: false) { + def phrases =location.helloHome?.getPhrases()*.label.sort(), findNull=0, phrasesList=[],missingRtn=[],missingMode=[] + def modeList= location.modes?.name.sort() + if (phrases) phrases.each{if (!it) findNull++} + if (findNull) phrases.each{if (it) phrasesList< + if (!(phrasesList.find{it==rtnName})) { + phrasesList<<["${rtnName}":"**${rtnName}** - REMOVE"] + missingRtn< + log.debug (modeList.find{it==modeName}) + if (!(modeList.find{it==modeName})) { + modeList<<["${modeName}":"**${modeName}** - REMOVE"] + missingMode<1 ? state.aliasList.size() + " device aliases configured" : "") { + paragraph state.aliasList && state.aliasList.size()>0 ? getAliasDisplayList(): "There are no device aliases set up yet" + } + } +} +def pageAliasAdd(){ + dynamicPage(name: "pageAliasAdd", uninstall: false) { + def getList = [] + mapDevices(true).each {getList<1 ? "Delete Device Aliases" : "Delete Device Alias" + def descTxt = aliasDelete.size()>1 ? "Tap to delete the device aliases" : "Tap to delete the device alias" + section(" "){ href "pageAliasDelFinal", title: titleTxt, description: descTxt, image: imgURL() + "delete.png" } + section("Please note") { paragraph "Do not use the \"<\", \"Done\" or \"Save\" buttons on this page except to go back without deleting the alias.", image: imgURL() + "caution.png" } + } + } +} +def pageAliasDelFinal(){ + dynamicPage(name: "pageAliasDelFinal", uninstall: false) { + def text = "Successfully Deleted " + if (aliasDelete.size()){ + aliasDelete.each{name-> state.aliasList.removeAll{it.aliasName==name} } + text += aliasDelete.size()==1 ? "One Alias" : "${aliasDelete.size()} Aliases" + } + section { paragraph text, image: imgURL() + "check.png" } + section (" "){ + href "pageAliasMain", title: "Tap Here To Add/Delete Another Alias", description:none + href "mainPageParent", title: "Tap Here To Return To The Main Menu", description:none + } + section("Please note") { paragraph "Do not use the \"<\", \"Done\" or \"Save\" buttons on this page to go back in the interface. You may encounter undesired results. Please use the two buttons above to return to the alias area or main menu.", image: imgURL() + "caution.png" } + } +} +def pageVoiceRPT() { + dynamicPage(name: "pageVoiceRPT", install: false, uninstall: false) { + section{ paragraph "Voice Reports", image: imgURL() + "voice.png" } + def children = getVR(), vrCount = children.size(), duplicates = children.label.findAll{children.label.count(it)>1}.unique(), aaVRVer + if (vrCount) children.each { aaVRVer=it.versionInt()} + if (duplicates || (vrCount && (aaVRVer < vrReq()))){ + section ("**Warning**"){ + if (duplicates) paragraph "You have two or more voice reports with the same name. Please ensure each report has a unique name and also does not conflict with device names or other extensions.", image: imgURL() + "caution.png" + if (vrCount && (aaVRVer < vrReq())) paragraph "You are using an outdated version of the voice report extension. Please update the software and try again.", image: imgURL() + "caution.png" + } + } + if (vrCount) section(vrCount==1 ? "One voice report configured" : vrCount + " voice reports configured" ){} + section(" "){ + app(name: "childVR", appName: "Ask Alexa Voice Report", namespace: "MichaelStruck", title: "Create A New Voice Report...", description: "Tap to create a new report", multiple: true, image: imgURL() + "add.png") + } + } +} +def pageWeather() { + dynamicPage(name: "pageWeather", install: false, uninstall: false) { + section{ paragraph "Weather Reports", image: imgURL() + "weather.png" } + def children = getWR(), wrCount = children.size(), duplicates = children.label.findAll{children.label.count(it)>1}.unique(), aaWRVer + if (wrCount) children.each { aaWRVer=it.versionInt() } + if (duplicates || (wrCount && (aaWRVer < wrReq()))){ + section ("**Warning**"){ + if (duplicates) paragraph "You have two or more weather reports with the same name. Please ensure each report has a unique name and also does not conflict with device names or other extensions.", image: imgURL() + "caution.png" + if (wrCount && (aaWRVer < wrReq())) paragraph "You are using an outdated version of the weather report extension. Please update the software and try again.", image: imgURL() + "caution.png" + } + } + if (wrCount) section(wrCount==1 ? "One weather report configured" : wrCount + " weather reports configured" ){} + section(" "){ + app(name: "childWR", appName: "Ask Alexa Weather Report", namespace: "MichaelStruck", title: "Create A New Weather Report...", description: "Tap to create a new report", multiple: true, image: imgURL() + "add.png") + } + } +} +def pageMacros() { + dynamicPage(name: "pageMacros", install: false, uninstall: false) { + section{ paragraph "Macros", image: imgURL() + "speak.png" } + def children = getAskAlexa(), macroCount = children.size(), duplicates = children.label.findAll{children.label.count(it)>1}.unique() + if (duplicates){ + section ("**Warning**"){ paragraph "You have two or more macros with the same name. Please ensure each macro has a unique name and also does not conflict with device names or other extensions.", image: imgURL() + "caution.png" } + } + if (macroCount) section(macroCount==1 ? "One macro configured" : macroCount + " macros configured" ){} + section(" "){ + app(name: "childMacros", appName: "Ask Alexa", namespace: "MichaelStruck", title: "Create A New Macro...", description: "Tap to create a new macro", multiple: true, image: imgURL() + "add.png") + } + } +} +def pageRooms() { + dynamicPage(name: "pageRooms", install: false, uninstall: false) { + section{ paragraph "Rooms/Groups", image: imgURL() + "room.png" } + def children = getRM(), rmCount = children.size(), duplicates = children.label.findAll{children.label.count(it)>1}.unique() + if (duplicates || findRoomReserved()){ + section ("**Warning**") { + if (duplicates) paragraph "You have two or more rooms/groups with the same name. Please ensure each room/group has a unique name and also does not conflict with device names or other extensions.", image: imgURL() + "caution.png" + if (findRoomReserved()) paragraph "You have used the reserved words 'echo', 'room', 'this room', 'group', 'this group', 'here' or 'in here' as a room/group alias or name. Please rename these to ensure Ask Alexa functions properly.", image: imgURL() + "caution.png" + } + } + if (rmCount) section(rmCount==1 ? "One room / group configured" : rmCount + " rooms / groups configured" ){} + section(" "){ + app(name: "childRooms", appName: "Ask Alexa Rooms/Groups", namespace: "MichaelStruck", title: "Create A New Room/Group...", description: "Tap to create a room/group", multiple: true, image: imgURL() + "add.png") + } + } +} +def pageAbout(){ + dynamicPage(name: "pageAbout", uninstall: true) { + section { paragraph "${textAppName()}\n${textCopyright()}", image: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/AskAlexa@2x.png" } + section ("Version numbers") { paragraph "${textVersion()}" } + section (title: "Access token / Application ID", hideable: true, hidden: true){ + if (!state.accessToken) OAuthToken() + def msg = state.accessToken != null ? state.accessToken : "Could not create Access Token. OAuth may not be enabled. Go to the IDE settings to enable OAuth for Ask Alexa." + paragraph "Access token:\n${msg}\n\nApplication ID:\n${app.id}" + } + if (getRmLists().size()){ + section (title: "Echo / Room Associations", hideable: true, hidden: true){ + def roomList="", rmCount = 0 + if (getRM().size()) getRM().each {roomName-> + def nameList=roomName.getEchoAliasList() + nameList.each { + roomList += "${roomName.label} (${it[-10..-1]})" + rmCount ++ + roomList += rmCount < getRmLists().size()? "\n" :"" + } + } + paragraph roomList + } + } + section ("Apache license"){ paragraph textLicense() } + section("Instructions") { paragraph textHelp() } + section("Tap below to remove the application and all extensions"){} + remove ("Completely Remove ${textAppName()}","Warning","This will remove the entire application, including all of your macros, extensions and settings.\n\nYou will be unable to undo this action!") + } +} +def pageSettings(){ + dynamicPage(name: "pageSettings", uninstall: false){ + section { paragraph "Settings", image: imgURL() + "settings.png" } + section ("Setup") { + href "pageSetup", title: "Setup Ask Alexa", description: "Tap to setup Ask Alexa", image: imgURL() + "setup.png" + } + section ("Additional voice settings"){ + input "compoundCmd", "bool", title: "Accept Compound Commands", defaultValue: false + href "pageDeviceVoice", title: "Device/Event Voice Settings", description: "Tap to configure your device or event voice settings", image: imgURL() + "smartthings.png" + input "flash", "bool", title: "Enable Flash Briefing", defaultValue: false, submitOnChange: true + if (flash) input "flashRPT", "enum", title: "Choose Flash Briefing Output", options: getMacroList("flash",""), required: false, multiple: false,image:imgURL() + "flash.png" + href "pageContCommands", title: "Personalization", description: "Tap to personalize your overall voice experience", image: imgURL() + "personality.png" + } + section ("Other values / variables"){ + href "pageDefaultValue", title: "Default Lighting Command Values", description: "", state: "complete" + if (speakersSel() || tstatsSel()) href "pageLimitValue", title: "Device Minimum/Maximum Values", description: "", state: "complete" + if (!state.accessToken) OAuthToken() + href "pageGlobalVariables", title: "Text Field Variables", description: none, state: getGlobalVarState() ? "complete" : null + if (cLightsSel()) href "pageCustomColor", title: "Custom Color Setup", description: customName && customHue && customSat ? customName +" (Hue: "+customHue+", Saturation: "+ customSat+")":"Tap to enter custom color name and values", state: (customName && customHue && customSat ? "complete" : null), image: imgURL() + "colors.png" + href "pageWebCoREVar", title: "xParams For WebCoRE Macros", description: (state.wcp && state.wcp.size()>1) ? "${state.wcp.size()} xParams created" : (state.wcp && state.wcp.size()==1) ? "One xParam created" : "No xParams", state: state.wcp ? "complete" :null, + image: "https://cdn.rawgit.com/ady624/${webCoRE_handle()}/master/resources/icons/app-CoRE@2x.png" + } + section("Security"){ + href "pageConfirmation", title: "Revoke/Reset Access Token", description: "Tap to confirm this action", image: imgURL() + "warning.png" + input "pwNeeded", "bool", title: "Password (PIN) Option Enabled", defaultValue: false, submitOnChange: true + if (pwNeeded) { + input "password", "num", title: "Numeric Password (PIN)", description: "Enter short numeric PIN (i.e. 1234)", required: false + href "pagePINRestrictions", title: "PIN Restrictions", description: (pinDay || pinMode ||pinPeople || pinEcho || pinSwitchActive || pinSwitchNotActive || timeStartPIN || timeEndPIN) ? "Restrictions applied - Tap to edit" : "Tap to add/edit PIN restrictions", + image: imgURL()+"restrict.png", state: (pinDay || pinMode ||pinPeople || pinEcho || pinSwitchActive || pinSwitchNotActive || timeStartPIN || timeEndPIN) ? "complete" : null + } + } + section ("Advanced") { + input "deviceAlias", "bool", title: "Allow Device Aliases", defaultValue: false + label title:"SmartApp Name", required:false, defaultValue: "Ask Alexa" + } + } +} +def pagePINRestrictions(){ + dynamicPage(name: "pagePINRestrictions", install: false, uninstall: false) { + section { paragraph "PIN Restriction", image: imgURL()+"restrict.png" } + section (" "){ + input "pinDay", "enum", options: dayOfWeek(), title: "Only Certain Days Of The Week...", multiple: true, required: false, image: imgURL() + "calendar.png" + href "timePIN", title: "Only During Certain Times...", description: getTimeLabel(timeStartPIN, timeEndPIN), state: (timeStartPIN || timeEndPIN ? "complete":null), image: imgURL() + "clock.png" + input "pinMode", "mode", title: "Only In The Following Modes...", multiple: true, required: false, image: imgURL() + "modes.png" + input "pinPeople", "capability.presenceSensor", title: "Only When Present...", multiple: true, required: false, submitOnChange: true, image: imgURL() + "people.png" + if (pinPeople && pinPeople.size()>1) input "pinPresAll", "bool", title: "Off=Any Present; On=All Present", defaultValue: false + input "pinEcho", "enum", title:"Only From These Echo Devices...", options: rmCheck(runEchoMute), multiple: true, required: false, image: imgURL() + "echo.png" + input "pinSwitchActive", "capability.switch", title: "Only When Switches Are On...", multiple: true, required: false, image: imgURL() + "on.png" + input "pinSwitchNotActive", "capability.switch", title: "Only When Switches Are Off...", multiple: true, required: false, image: imgURL() + "off.png" + } + } +} +page(name: "timePIN", title: "PIN Required Only During These Times...") { + section { + input "timeStartPIN", "time", title: "Starting", required: false + input "timeEndPIN", "time", title: "Ending", required: false + } +} +def pageSetup(){ + dynamicPage(name: "pageSetup", install: false, uninstall: false) { + section { paragraph "Setup Ask Alexa", image: imgURL() + "setup.png"} + section("Setup parameters") { + //input "azRegion", "enum", title: "Select Amazon Region/Datacenter Location", description: "Choose the Amazon datecenter closest to you", options:["US-E":"US East","US-W":"US West","EU":"EU"], required: false, multiple: false, defaultValue:"US-E", submitOnChange: true + input "invocationName", title: "Skill Invocation Name", defaultValue: "Smart Things", required: false, submitOnChange: true + } + section("Manual Amazon Lambda and skill installation"){ + if (!state.accessToken) paragraph "**You must enable OAuth via the IDE to setup this app**" + else href url:"${getApiServerUrl()}/api/smartapps/installations/${app.id}/setupLink?access_token=${state.accessToken}", style:"embedded", required:false, title:"Setup Variables Link", + description: "Use Live Logging in the SmartThings IDE to obtain the URL for use on your computer browser.", image: imgURL() + "amazon.png" + } + /*if (azRegion && invocationName){ + section("Automated Amazon Lambda and skill installation"){ + input "createRoomSkills", "enum", title:"Create Secondary Room Skills", description: "Choose additional shortcut skills for rooms", options:["here":"Here", "room":"Room", "group":"Group"], required: false, multiple: true + href url: "xxx", title:"Auto-Deploy Lambda/Skills", description:"Coming soon", image: imgURL() + "deploy.png" + } + }*/ + section("Help") { + if (!state.accessToken) paragraph "**You must enable OAuth via the IDE to produce the command cheat sheet**" + else href url:"${getApiServerUrl()}/api/smartapps/installations/${app.id}/cheatLink?access_token=${state.accessToken}", style:"embedded", required:false, title:"Display Ask Alexa Cheat Sheet", + description: "Use Live Logging in the SmartThings IDE to obtain the URL for use on your computer browser.", image: imgURL() + "list.png" + } + } +} +def pageWebCoREVar(){ + dynamicPage(name: "pageWebCoREVar", install: false, uninstall: false) { + section { paragraph "xParams For WebCoRE Macros", image: "https://cdn.rawgit.com/ady624/${webCoRE_handle()}/master/resources/icons/app-CoRE@2x.png"} + section("Add / Remove xParams") { + href "pagexParamAdd", title: "Add xParam", description: "Tap to add a xParam", image: imgURL() + "add.png" + if (state.wcp) href "pagexParamDel", title: "Delete xParams", description: "Tap to delete xParams", image: imgURL() + "delete.png" + } + section(state.wcp && state.wcp.size()==1 ? "One xParam created" : state.wcp && state.wcp.size()>1 ? state.wcp.size() + " xParams created" : "") { + paragraph state.wcp && state.wcp.size()>0 ? getxParamList(): "There are no xParams created yet" + } + } +} +def pagexParamAdd(){ + dynamicPage(name: "pagexParamAdd", uninstall: false) { + section ("xParam information"){ input "xParamName", "text", title: "xParam Name" } + section(" "){ href "pagexParamAddFinal", title: "Add xParam", description: "Tap to add the above xParam to the list", image: imgURL() + "add.png" } + section("Please note") { paragraph "Do not use the \"<\", \"Done\" or \"Save\" buttons on this page except to go back without adding the xParam.", image: imgURL() + "caution.png" } + } +} +def pagexParamAddFinal(){ + dynamicPage(name: "pagexParamAddFinal", uninstall: false) { + if (!state.wcp && state.wcp!=[]) state.wcp=[] + def success=false, result = "" + if (xParamName && state.wcp.find {it.toLowerCase()==xParamName.toLowerCase()} ) result ="'${xParamName}' is already in the list. Please choose another name." + else if (!xParamName) result="You did not enter a xParam. Go back and ensure all fields are filled in." + else { + state.wcp<1 ? "Tap to delete the xParams" : "Tap to delete the xParam" + def titleTxt = xParamDelete.size()>1 ? "Delete xParams" : "Delete xParam" + section(" "){ href "pagexParamDelFinal", title: titleTxt, description: descTxt , image: imgURL() + "delete.png" } + section("Please note") { paragraph "Do not use the \"<\", \"Done\" or \"Save\" buttons on this page except to go back without deleting the xParam.", image: imgURL() + "caution.png" } + } + } +} +def pagexParamDelFinal(){ + dynamicPage(name: "pagexParamDelFinal", uninstall: false) { + def text ="Successfully Deleted " + if (xParamDelete.size()){ + xParamDelete.each{name-> state.wcp.removeAll{it==name} } + text += xParamDelete.size()==1 ? "One xParam" : "${xParamDelete.size()} xParams" + } + section { paragraph text, image: imgURL() + "check.png" } + section (" "){ + href "pageWebCoREVar", title: "Tap Here To Add/Delete Another xParam", description:none + href "mainPageParent", title: "Tap Here To Return To The Main Menu", description:none + } + section("Please note") { paragraph "Do not use the \"<\", \"Done\" or \"Save\" buttons on this page to go back in the interface. You may encounter undesired results. Please use the two buttons above to return to the xParam area or main menu.", image: imgURL() + "caution.png" } + } +} +def pageDeviceVoice(){ + dynamicPage(name: "pageDeviceVoice", install: false, uninstall: false) { + section { paragraph "Device/Event Voice Settings", image: imgURL() + "smartthings.png" } + section ("Device Settings") { + input "briefReply", "bool", title: "Give 'Brief' Device Action Reply", defaultValue: false, submitOnChange: true + if (briefReply) input "briefReplyTxt", "enum", title: "Brief Reply", options: ["No reply spoken", "Ok", "Done", "User-defined"], required:false, multiple:false, defaultValue: "Ok", submitOnChange: true + if (briefReply && briefReplyTxt=="User-defined") input "briefReplyCustom", "text", title:"User-defined Brief Reply", description:"Enter your brief reply", required: false, capitalization: "sentences" + input "otherStatus", "bool", title: "Speak Additional Device Status Attributes", defaultValue: false + input "healthWarn", "bool", title: "Speak Device Health When Offline", defaultValue: false + input "batteryWarn", "bool", title: "Speak Battery Level When Below Threshold", defaultValue: false, submitOnChange: true + if (batteryWarn) input "batteryThres", "enum", title: "Battery Status Threshold", required: false, defaultValue: 20, options: battOptions() + } + section ("Event Settings"){ input "eventCt", "enum", title: "Default Number Of Past Events to Report", options: optionCount(1,9), required: false, defaultValue: 1 } + } +} +def pagePriQueue(){ + dynamicPage(name: "pagePriQueue", uninstall: false){ + def children = getAAMQ(), mqCount = children.size() + section{ paragraph "Primary Message Queue", image: imgURL() + "mailbox.png" } + section ("Primary message queue options"){ + input "msgQueueOrder", "enum", title: "Message Play Back Order (Alexa)", options:[0:"Oldest to newest", 1:"Newest to oldest"], defaultValue: 0 + input "msgQueueDateSuppress", "bool", title: "Remove Time/Date From Message Review", defaultValue: false + } + section ("Message notification - Alexa", hideable: true, hidden: !(mqEcho)){ + input "mqEcho", "enum", title:"Choose Echo Devices", options: rmCheck(mqEcho), multiple: true, required: false + paragraph "This ability is not yet available - Coming soon!", image: imgURL() + "info.png" + } + section ("Message notification - audio", hideable: true, hidden: !(mqSpeaker||mqSynth)){ + input "mqSpeaker", "capability.musicPlayer", title: "Choose Speakers", multiple: true, required: false, submitOnChange: true + if (mqSpeaker) input "mqVolume", "number", title: "Speaker Volume", description: "0-100%", range:"0..100", required: false + input "mqSynth", "capability.speechSynthesis", title: "Choose Voice Synthesis Devices", multiple: true, required: false, hideWhenEmpty: true + if (mqSpeaker) input "mqAlertType", "enum", title:"Notification Type...",options:[0: "Verbal Notification and Message", 1: "Verbal Notification Only", 2: "Message Only", 3:"Notification Sound Effect"], defaultValue:0 , submitOnChange: true + if (mqSpeaker && mqAlertType !="3") input "mqVoice", "enum", title: "Choose Voice", options: voiceList(), defaultValue: "Salli", required: false + if (mqSpeaker && mqAlertType != "3") input "mqAppendSound", "bool", title: "Prepend Sound To Verbal Notification", defaultValue: false, submitOnChange: true + if (mqSpeaker && (mqAlertType == "3" || mqAppendSound)) input "mqAlertSound", "enum", title: "Sound Effect", required: mqAlertType == "3" ? true : false, options: soundFXList(), submitOnChange: true + if (mqSpeaker && (mqAlertType == "3" || mqAppendSound) && mqAlertSound=="custom") input "mqAlertCustom", "text", title:"URL/Location Of Custom Sound (Less Than 10 Seconds)...", required: false + if (mqSpeaker || mqSynth) input "restrictAudio", "bool", title: "Apply Restrictions To Audio Notification", defaultValue: false, submitOnChange: true + } + section ("Message Notification - visual", hideable: true, hidden:!(msgQueueNotifyLightsOn || msgQueueNotifycLightsOn)){ + input "msgQueueNotifyLightsOn", "capability.switch", title: "Turn On Lights When Messages Present", required:false, multiple:true, submitOnChange: true + input "msgQueueNotifycLightsOn", "capability.colorControl", title: "Turn On/Set Colored Lights When Messages Present", required:false, multiple:true, submitOnChange: true + if (msgQueueNotifycLightsOn) { + input "msgQueueNotifyColor", "enum", title: "Set Color of Message Notification", options: STColors().name, multiple:false, required:false + input "msgQueueNotifyLevel", "number", title: "Set Level of Message Notification", defaultValue:50, range:"1..100", required:false + } + if (msgQueueNotifyLightsOn || msgQueueNotifycLightsOn) { + input "msgQueueNotifyLightsOff", "bool", title: "Turn Off Lights When Message Queue Empty", defaultValue: false + input "restrictVisual", "bool", title: "Apply Restrictions To Visual Notification", defaultValue: false, submitOnChange: true + } + } + section ("Message notification - mobile", hideable: true, hidden:!(mqContacts||mqSMS||mqPush||mqFeed)){ + input ("mqContacts", "contact", title: "Send Notifications To...", required: false, submitOnChange: true) { + input "mqSMS", "phone", title: "Send SMS Message To (Phone Number)...", required: false, submitOnChange: true + input "mqPush", "bool", title: "Send Push Message", defaultValue: false, submitOnChange: true + } + input "mqFeed", "bool", title: "Post To Notification Feed", defaultValue: false, submitOnChange: true + if (mqFeed || mqSMS || mqPush || mqContacts) input "restrictMobile", "bool", title: "Apply Restrictions To Mobile Notification", defaultValue: false, submitOnChange: true + } + if (restrictMobile || restrictVisual || restrictAudio){ + section("Message notification restrictions", hideable: true, hidden: !(runDay || timeStart || timeEnd || runMode || runPeople || runSwitchActive || runSwitchNotActive )) { + input "runDay", "enum", options:dayOfWeek(), title: "Only Certain Days Of The Week...", multiple: true, required: false, image: imgURL() + "calendar.png" + href "timeIntervalInput", title: "Only During Certain Times...", description: getTimeLabel(timeStart, timeEnd), state: (timeStart || timeEnd ? "complete":null), image: imgURL() + "clock.png" + input "runMode", "mode", title: "Only In The Following Modes...", multiple: true, required: false, image: imgURL() + "modes.png" + input "runPeople", "capability.presenceSensor", title: "Only When Present...", multiple: true, required: false, submitOnChange: true, image: imgURL() + "people.png" + if (runPeople && runPeople.size()>1) input "runPresAll", "bool", title: "Off=Any Present; On=All Present", defaultValue: false + input "runSwitchActive", "capability.switch", title: "Only When Switches Are On...", multiple: true, required: false, image: imgURL() + "on.png" + input "runSwitchNotActive", "capability.switch", title: "Only When Switches Are Off...", multiple: true, required: false, image: imgURL() + "off.png" + } + } + section ("REST URL for this message queue", hideable: true, hidden:true){ + href url:"${getApiServerUrl()}/api/smartapps/installations/${app.id}/u?qName=Primary Message Queue&access_token=${state.accessToken}", style:"embedded", required:false, + title:"REST URL", description: "Tap to display the REST URL / send it to Live Logging", image: imgURL() + "network.png" + } + } +} +def pageMsgQue() { + dynamicPage(name: "pageMsgQue", install: false, uninstall: false) { + section{ paragraph "Message Queues", image: imgURL() + "mailbox.png" } + section ("Global message queue options"){ + input "msgQueueGUI", "enum", title: "Message Queues Displayed On Main Menu (When Messages Are Present)" , options: getMQListID(true), multiple:true, required:false + input "msgQueueDelete", "enum", title: "Allow External SmartApps To Delete Messages From These Queues", options: getMQListID(true), multiple:true, required:false + input "msgQueueNotify", "enum", title: "Append Alexa Output With Message Notification From These Queues", options: getMQListID(true), multiple:true, required:false + if (getMQListID(false).size()) input "msgQueueForward", "enum", title: "Forward Messages From Primary Message Queue To", options: getMQListID(false), multiple:true, required:false, submitOnChange: true + } + def children = getAAMQ(), duplicates = children.label.findAll{children.label.count(it)>1}.unique(), aaMQVer="" + if (children.size()) children.each { aaMQVer=it.versionInt()} + def mqCount = children.size() ? children.size() + 1 + " messsage queues configured" : "One message queue configured" + if (findMQReserved() || duplicates || children.size() && (aaMQVer < mqReq())){ + section ("**Warning**"){ + if (findMQReserved()) paragraph "You have used the reserved words 'echo', 'room', 'this room', 'group', 'this group', 'here' or 'in here' as a message queue name. Please rename these queues to ensure Ask Alexa functions properly.", image: imgURL() + "caution.png" + if (duplicates) paragraph "You have two or more message queues with the same name. Please ensure each queue has a unique name and also does not conflict with device names or other extensions.", image: imgURL() + "caution.png" + if (children.size() && (aaMQVer < mqReq())) paragraph "You are using an outdated version of the message queue extension. Please update the software and try again.", image: imgURL() + "caution.png" + } + } + section ("${mqCount}"){ + href "pagePriQueue", title: "Primary Message Queue", description: "", state:"complete" + } + section(" "){ + app(name: "childMQ", appName: "Ask Alexa Message Queue", namespace: "MichaelStruck", title: "Create A New Message Queue...", description: "Tap to create a new message queue", multiple: true, image: imgURL() + "add.png") + } + } +} +def pageDefaultValue(){ + dynamicPage(name: "pageDefaultValue", uninstall: false){ + section("Increase / Brighten / Decrease / Dim values\n(When no values are requested)"){ + input "lightAmt", "number", title: "Dimmer/Colored Lights", range:"1..100", defaultValue: 20, required: false + input "tstatAmt", "number", title: "Thermostat Temperature", range:"1..100",defaultValue: 5, required: false + input "speakerAmt", "number", title: "Speaker Volume", range:"1..100",defaultValue: 5, required: false + } + section("Low / Medium / High values (For dimmers or colored lights)") { + input "dimmerLow", "number", title: "\"Low\" Value",range:"1..100", defaultValue: 10, required: false + input "dimmerMed", "number", title: "\"Medium\" Value", range:"1..100", defaultValue: 50, required: false + input "dimmerHigh", "number", title: "\"High\" Value", range:"1..100", defaultValue: 100, required: false + } + section("Default temperature (Kelvin) values"){ + input "kSoftWhite", "number", title: "\"Soft White\" Value", range:"2700..6500",defaultValue: 2700, required: false + input "kWarmWhite", "number", title:"\"Warm White\" Value", range:"2700..6500", defaultValue: 3500, required: false + input "kCoolWhite" ,"number", title:"\"Cool White\" Value", range:"2700..6500", defaultValue: 4500, required: false + input "kDayWhite" ,"number", title:"\"Daylight White\" Value", range:"2700..6500", defaultValue: 6500, required: false + } + } +} +def pageContCommands(){ + dynamicPage(name: "pageContCommands", uninstall: false){ + section{ paragraph "Personalization", image: imgURL() + "personality.png" } + section ("Continuation of commands..."){ + input "contError", "bool", title: "After Error", defaultValue: false + input "contStatus", "bool", title: "After Status/List", defaultValue: false + input "contAction", "bool", title: "After Action/Event History", defaultValue: false, submitOnChange: true + if (contAction && briefReply) paragraph "Please note that 'Brief Device Action Reply' is turned on. "+ + "There will be no prompt for continuation commands, but Alexa will still be active waiting "+ + "for these additional commands with this option enabled.", image: imgURL()+"info.png" + input "contMacro", "bool", title: "After Macro/Extension Execution", defaultValue: false + } + section ("Personality"){ + input "Personality", "enum", title: "Response Personality Style", options: ["Normal","Courtesy","Snarky"], defaultValue: "Normal", submitOnChange: true + input "personalName", "text", title: "Name To Address You By (Optional)", description: "%people% variable is available if set up", required: false + if (Personality=="Snarky") input "randomSnarkName", "bool", title: "Randomize Snarky Response Name", defaultValue: false, submitOnChange: true + if (Personality=="Snarky" && randomSnarkName){ + input "snarkName1", "text", title: "Random Snarky Name 1", description: "Knucklehead", required: false + input "snarkName2", "text", title: "Random Snarky Name 2", description: "Silly", required: false + } + } + section("Global Options"){ + href "pageGlobalOptions", title: "Playback Options/Restrictions", description: playbackDesc(), state: (playbackDesc() !="Tap to set playback options/restrictions" ? "complete":null),image: imgURL() + "echoReact.png" + } + } +} +def pageGlobalOptions(){ + dynamicPage(name: "pageGlobalOptions", uninstall: false){ + section{ paragraph "Playback Options/Restrictions", image: imgURL() + "echoReact.png" } + section ("Command mute options"){ + if (!muteAll) input "cmdMute", "bool", title: "Perform Command With No Alexa Output", defaultValue: false, submitOnChange: true + if (!cmdMute) input "muteAll", "bool", title: "Disable All Commands With No Alexa Output", defaultValue: false, submitOnChange: true + } + if (!muteAll && !cmdMute) { + section ("Playback options"){ + input "speakSpeed", "enum", title: "Alexa Speaking Speed", options: ["x-slow":"Extra Slow", "slow":"Slow", "medium":"Default", "fast":"Fast", "x-fast":"Extra Fast"], defaultValue: "medium", required: false + input "speakPitch", "enum", title: "Alexa Speaking Pitch", options: ["x-low":"Extra Low", "low":"Low", "medium":"Default", "high":"High", "x-high":"Extra High"], defaultValue: "medium", submitOnChange: true, required: false + if (speakPitch=="medium" || !speakPitch) input "whisperMode", "bool", title: "Enable Whisper Mode", defaultValue: false, submitOnChange: true + } + if ((speakPitch=="medium" || !speakPitch) && whisperMode) { + section ("Whisper restrictions"){ + href "timeIntervalInputWhisper", title: "Whisper Only During These Times...", description: getTimeLabel(timeStartWhisper, timeEndWhisper), state: (timeStartWhisper || timeEndWhisper? "complete":null), image: imgURL() + "clock.png" + input "runModeWhisper", "mode", title: "Whisper Only In The Following Modes...", multiple: true, required: false, image: imgURL() + "modes.png" + } + } + } + if (muteAll || cmdMute){ + section("Command restrictions"){ + input "runDayMute", "enum", options: dayOfWeek(), title: "Only Certain Days Of The Week...", multiple: true, required: false, image: imgURL() + "calendar.png" + href "timeMuteCmd", title: "Only During Certain Times...", description: getTimeLabel(timeStartMute, timeEndMute), state: (timeStartMute || timeEndMute ? "complete":null), image: imgURL() + "clock.png" + input "runModeMute", "mode", title: "Only In The Following Modes...", multiple: true, required: false, image: imgURL() + "modes.png" + input "runPeopleMute", "capability.presenceSensor", title: "Only When Present...", multiple: true, required: false, submitOnChange: true, image: imgURL() + "people.png" + if (runPeopleMute && runPeopleMute.size()>1) input "runPresAllMute", "bool", title: "Off=Any Present; On=All Present", defaultValue: false + input "runEchoMute", "enum", title:"Only From These Echo Devices...", options: rmCheck(runEchoMute), multiple: true, required: false, image: imgURL() + "echo.png" + input "runSwitchActiveMute", "capability.switch", title: "Only When Switches Are On...", multiple: true, required: false, image: imgURL() + "on.png" + input "runSwitchNotActiveMute", "capability.switch", title: "Only When Switches Are Off...", multiple: true, required: false, image: imgURL() + "off.png" + } + } + } +} +page(name: "timeIntervalInputWhisper", title: "Whisper Only During These Times...") { + section { + input "timeStartWhisper", "time", title: "Starting", required: false + input "timeEndWhisper", "time", title: "Ending", required: false + } +} +page(name: "timeMuteCmd", title: "Restrict Only During These Times...") { + section { + input "timeStartMute", "time", title: "Starting", required: false + input "timeEndMute", "time", title: "Ending", required: false + } +} +def pageLimitValue(){ + dynamicPage(name: "pageLimitValue", uninstall: false){ + if (speakersSel()){ section("Speaker volume limits"){ input "speakerHighLimit", "number", title: "Speaker Maximum Volume", range:"0..100", defaultValue:20, required: false } } + if (tstatsSel()){ + section("Thermostat setpoint limits"){ + input "tstatLowLimit", "number", title: "Thermostat Minimum Value", range:"0..100", defaultValue: 55, required: false + input "tstatHighLimit", "number", title: "Thermostat Maximum Value", range:"0..100", defaultValue: 85, required: false + } + } + } +} +def pageGlobalVariables(){ + dynamicPage(name: "pageGlobalVariables", uninstall: false){ + section { paragraph "Text Field Variables", image: imgURL() + "variable.png" } + section ("Environmental") { + input "voiceTempVar", "capability.temperatureMeasurement", title: "Temperature Device Variable (%temp%)",multiple: true, required: false, submitOnChange: true + input "voiceHumidVar", "capability.relativeHumidityMeasurement", title:"Humidity Device Variable (%humid%)",multiple: true, required: false, submitOnChange: true + if ((voiceTempVar && voiceTempVar.size()>1) || (voiceHumidVar && voiceHumidVar.size()>1)) paragraph "Please note: When multiple temperature/humidity devices are selected above, the variable output will be an average of the device readings", image: imgURL() + "info.png" + } + section ("People"){ input "voicePresenceVar", "capability.presenceSensor", title: "Presence Sensor Variable (%people%)", multiple: true, required: false } + section ("Random responses"){ + href "pageRandom1", title: "Random Responses 1 (%random1%)", description: getRandDesc(1), state: random1A || random1B|| random1C? "complete" : null + href "pageRandom2", title: "Random Responses 2 (%random2%)", description: getRandDesc(2), state: random2A || random2B|| random2C? "complete" : null + href "pageRandom3", title: "Random Responses 3 (%random3%)", description: getRandDesc(3), state: random3A || random3B|| random3C? "complete" : null + } + section ("Built-in variables"){ + paragraph "The following variables are built in:\n\n%time% - Time the variable is called\n%day% - Day of the week\n%date% - Full date\n" + + "%macro% - Macro/Extension name\n%mtype% - Macro/Extension type\n%delay% - Control/WebCoRE macro delay\n%age% - Schedules age number\n%xParam% - Extra parameter" + } + if (getWR().size()){ + section ("Weather Report"){ + def list= "The following variables can be used to add weather reporting to any text field:\n\n", itemCount=getWR().size() as int + getWR().each{ list += "%${it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "")}%"; itemCount -- + list += itemCount>1 ? ", " : itemCount==1 ? " and " : "" + } + paragraph list + } + } + } +} +page(name: "pageRandom1", title: "Random Responses 1"){ + section{ + input "random1A", "text", title: "Random Response", required: false, capitalization: "sentences" + input "random1B", "text", title: "Random Response", required: false, capitalization: "sentences" + input "random1C", "text", title: "Random Response", required: false, capitalization: "sentences" + } +} +page(name: "pageRandom2", title: "Random Responses 2"){ + section{ + input "random2A", "text", title: "Random Response", required: false, capitalization: "sentences" + input "random2B", "text", title: "Random Response", required: false, capitalization: "sentences" + input "random2C", "text", title: "Random Response", required: false, capitalization: "sentences" + } +} +page(name: "pageRandom3", title: "Random Responses 3"){ + section{ + input "random3A", "text", title: "Random Response", required: false, capitalization: "sentences" + input "random3B", "text", title: "Random Response", required: false, capitalization: "sentences" + input "random3C", "text", title: "Random Response", required: false, capitalization: "sentences" + } +} +def pageCustomColor(){ + dynamicPage(name: "pageCustomColor", uninstall: false){ + section{ paragraph "Custom Color Setup", image: imgURL() + "colors.png" } + section("Custom Color") { + input "customName", "text", title: "Custom Color Name", required: false + input "customHue", "number", title: "Custom Color Hue",range:"0..100",required: false + input "customSat", "number", title: "Custom Color Saturation",range:"0..100", required: false + } + section("Notes about custom colors") { paragraph "Remember to update your Amazon Developer Slots to ensure this custom name is available to use.", image: imgURL() + "info.png" } + } +} +def pageConfirmation(){ + dynamicPage(name: "pageConfirmation", title: "Revoke/Reset Access Token Confirmation", uninstall: false){ + section { + href "pageReset", title: "Revoke/Reset Access Token", description: "Tap to take action - READ WARNING BELOW", image: imgURL() + "warning.png" + paragraph "PLEASE CONFIRM! By resetting the access token you will disable the ability to interface Ask Alexa with your Amazon Echo. You will need to copy the new access token to your Amazon Lambda code to re-enable access." + + "Tap below to go back to the main menu with out resetting the token. You may also tap \"Done\" or \"Save\" in the upper left corner." + } + section(" "){ href "mainPageParent", title: "Cancel And Go Back To Main Menu", description: none } + } +} +def pageReset(){ + dynamicPage(name: "pageReset", title: "Access Token Reset", uninstall: false){ + section{ + revokeAccessToken() + state.accessToken = null + OAuthToken() + def msg = state.accessToken != null ? "New access token:\n${state.accessToken}\n\nClick , \"Done\" or \"Save\" above to return to the previous menu." : "Could not reset Access Token. OAuth may not be enabled. Go to the IDE settings to enable OAuth for Ask Alexa." + paragraph "${msg}" + href "mainPageParent", title: "Tap Here To Return To The Main Menu", description: " " + } + } +} +//Child Pages---------------------------------------------------- +def mainPageChild(){ + dynamicPage(name: "mainPageChild", title: "Macro Options", install: true, uninstall: true) { + section { + label title:"Macro Name (Required)", required: true, image: imgURL() + "speak.png" + href "pageMacroAliases", title: "Macro Aliases", description: macroAliasDesc(), state: macroAliasState() + input "macroType", "enum", title: "Macro Type...", options: [["Control":"Control (Run/Execute)"],["CoRE":"WebCoRE Trigger (Run/Execute)"],["GroupM":"Extension Group (Run/Execute)"]], required: false, multiple: false, submitOnChange:true + def fullMacroName=[GroupM: "Extension Group",CoRE:"WebCoRE Trigger", Control:"Control"][macroType] ?: macroType + if (macroType) { + href "page${macroType}", title: "${fullMacroName} Settings", description: macroTypeDesc(), state: greyOutMacro() + if (parent.contMacro) { + input "overRideMsg", "bool", title: "Override Continuation Commands (Except Errors)", defaultValue: false, submitOnChange: true + if (!overRideMsg) input "suppressCont", "bool", title:"Suppress Continuation Messages (But Still Allow Continuation Commands)", defaultValue: false + } + } + } + if (macroType && macroType==~/Control|GroupM/ && macroTypeDesc() !="Status: UNCONFIGURED - Tap to configure macro" && app.label !="Ask Alexa") { + def secTxt = macroType=="Control" ? "Control Macro" : "Extension Group" + section ("REST URL for this ${secTxt}", hideable: true, hidden:true){ + href url:"${getApiServerUrl()}/api/smartapps/installations/${parent.app.id}/u?mName=${app.label}&access_token=${parent.state.accessToken}", style:"embedded", required:false, + title:"REST URL", description: "Tap to display the REST URL / send it to Live Logging", image: parent.imgURL() + "network.png" + } + } + if (macroType) { + section ("Switch trigger for this macro", hideable: true, hidden: !(macroTriggerSwitch)){ + input "macroTriggerSwitch", "capability.switch", title: "Trigger Switch", multiple: false, required: false, submitOnChange:true + if (macroType=="CoRE" && macroTriggerSwitch){ + input "triggerXParam", "text", title: "xParam To Send When Switch Triggered (optional)", description: "Use lower case for the xParam", required: false, capitalization: "none" + input "triggermNum", "number", title: "mNum To Send When Switch Triggered (optional)", description: "Enter a number ", required: false + } + paragraph "A momentary switch is recommended (but not required) to trigger a macro. You may associate the switch with other automations (including native Alexa Routines) to execute this macro when the switch state change. "+ + "Please note: No passwords are passed to this macro and no output from this macro will be heard unless you output it via a Message Queue.", image: imgURL()+"info.png" + } + } + if (macroType && macroType ==~/Control|CoRE/){ + section("Restrictions", hideable: true, hidden: !(runDay || timeStart || timeEnd || runMode || runEcho || runPeople || runSwitchActive || runSwitchNotActive)) { + input "runDay", "enum", options: dayOfWeek(), title: "Only Certain Days Of The Week...", multiple: true, required: false, image: imgURL() + "calendar.png" + href "timeIntervalInput", title: "Only During Certain Times...", description: getTimeLabel(timeStart, timeEnd), state: (timeStart || timeEnd ? "complete":null), image: imgURL() + "clock.png" + input "runMode", "mode", title: "Only In The Following Modes...", multiple: true, required: false, image: imgURL() + "modes.png" + input "runPeople", "capability.presenceSensor", title: "Only When Present...", multiple: true, required: false, submitOnChange: true, image: imgURL() + "people.png" + if (runPeople && runPeople.size()>1) input "runPresAll", "bool", title: "Off=Any Present; On=All Present", defaultValue: false + input "runEcho", "enum", title:"Only From These Echo Devices...", options: parent.rmCheck(runEcho), multiple: true, required: false, image: imgURL() + "echo.png" + input "runSwitchActive", "capability.switch", title: "Only When Switches Are On...", multiple: true, required: false, image: imgURL() + "on.png" + input "runSwitchNotActive", "capability.switch", title: "Only When Switches Are Off...", multiple: true, required: false, image: imgURL() + "off.png" + input "muteRestrictions", "bool", title: "Mute Restriction Messages In Extension Group", defaultValue: false + } + } + section("Tap below to remove this macro"){} + remove("Remove Macro" + (app.label ? ": ${app.label}" : ""),"PLEASE NOTE","This action will only remove this macro. Ask Alexa, other macros and extensions will remain.") + } +} +page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "timeStart", "time", title: "Starting", required: false + input "timeEnd", "time", title: "Ending", required: false + } +} +page(name: "pageMacroAliases", title: "Enter alias names for this macro"){ + section { for (int i = 1; i 1) outputTxt ="The name, '${dev}', is used multiple times in your Ask Alexa SmartApp. Please rename or remove duplicate items so I may properly utlize them. " + else if (op=="status" && dev=~/echo|alexa|device/ && !count){ + count=1 + def room="" + if (getRM().size()) getRM().each {roomName-> roomName.getEchoAliasList().each { if (echoID == it) room = "${roomName.label}" } } + if (room) outputTxt = "This echo device is associated with the group named: '${room}'. %2%" + else outputTxt = "This echo device is not yet setup with any room or group. To do that, simply say, 'associate', and then the group name. %1%" + } + else if (deviceList || aliasDeviceList) { + def proceed = true + def deviceObj=deviceList ? deviceList.devices.find{it.label.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase() == dev} : aliasDeviceObj + def devType=deviceList ? deviceList.type : aliasDeviceType + devIcon=deviceList ? deviceList.type : aliasDeviceType + if (devType =~/door|lock/ && (lockVoc().find{it==op} || doorVoc().find{it==op})) { + def currentState = deviceObj.currentValue(devType) + if ((devType == "door" && (currentState==op) || (currentState == "closed" && op=="close"))||(devType=="lock" && currentState == op + "ed")) outputTxt="The ${dev} is already ${currentState}. %3%" + else if (getOkPIN() && pwNeeded && password && num != password as int && ((devType =="door" && doorPW) || (devType =="lock" && lockPW))) { + if (num != password as int && num != 0) outputTxt = "I heard a password that is not valid. %P%" + if (num==0) outputTxt = "A valid password is needed to ${op} the ${dev}. %P%" + state.cmdFollowup = [return: "deviceAction", dev: dev, op: op, numVal: numVal, param: param, icon:devIcon] + } + else if ((op=="lock" && lockLockDisable) || (op=="unlock" && lockUnLockDisable) || (op=="open" && doorOpenDisable) || (op=="open" && doorOpenDisable)) outputTxt = "There are restrictions set up preventing the '${op}' command from being used on this ${devType}. %1%" + proceed = outputTxt ? false : true + } + if (proceed) outputTxt = getReply (deviceObj, devType, dev, op, num, param, echoID) + } + if (!count) { outputTxt = "I had some problems finding the device you specified. %1%" } + if (followup) return outputTxt + else sendJSON(outputTxt,devIcon) +} +def processObjectsAction(obj1, obj2, op1, op2, numVal1, numVal2, param1, param2, echoID){ + log.debug "-Compound command received-" + if (!(obj1 ==~/undefined|null|\?/)) log.debug "Obj1: " + obj1 + if (!(obj2 ==~/undefined|null|\?/)) log.debug "Obj2: " + obj2 + if (!(op1 ==~/undefined|null|\?/)) log.debug "Op1: " + op1 + if (!(op2 ==~/undefined|null|\?/)) log.debug "Op2: " + op2 + if (!(numVal1 ==~/undefined|null|\?/)) log.debug "Num1: " + numVal1 + if (!(numVal2 ==~/undefined|null|\?/)) log.debug "Num2: " + numVal2 + if (!(param1 ==~/undefined|null|\?/)) log.debug "Param1: " + param1 + if (!(param2 ==~/undefined|null|\?/)) log.debug "Param2: " + param2 + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + def num1 = numVal1 ==~/undefined|null|\?/ || !numVal1 ? 0 : numVal1 as int + def num2 = numVal2 ==~/undefined|null|\?/ || !numVal2 ? num1 as int : numVal2 as int + if (!(op2==~/undefined|null|\?/)) num2=0 + if (param2==~/undefined|null/) param2 = param1 + String outputTxt = "" + def deviceList1=[], count1 = 0, aliasDeviceType1, aliasDeviceObj1, aliasDeviceList1, aliasDeviceName1, deviceList2=[], count2 = 0, aliasDeviceType2, aliasDeviceObj2, aliasDeviceList2, aliasDeviceName2 + def extList1, extList2, extType1, extType2 + getDeviceList().each{if (it.name==obj1) {deviceList1=it; count1++}} + getDeviceList().each{if (it.name==obj2) {deviceList2=it; count2++}} + if (mapDevices(true) && deviceAlias && !count1 && !deviceList1){ + aliasDeviceList1 = state.aliasList.each { if (it.aliasNameLC==obj1) { aliasDeviceType1 = it.aliasType; aliasDeviceName1 = it.aliasDevice; count1++ } } + if (aliasDeviceType1) { + aliasDeviceList1=mapDevices(true).find {it.type==aliasDeviceType1} + if (aliasDeviceList1) aliasDeviceObj1=aliasDeviceList1.devices.find{it.label==aliasDeviceName1} + else outputTxt = "I had a problem finding the alias device you specified. Please check your Ask Alexa SmartApp settings to ensure you have a device associated with the alias name. %1%" + } + } + if (mapDevices(true) && deviceAlias && !count2 && !deviceList2){ + aliasDeviceList2 = state.aliasList.each { if (it.aliasNameLC==obj2) { aliasDeviceType2 = it.aliasType; aliasDeviceName2 = it.aliasDevice; count2++ } } + if (aliasDeviceType2) { + aliasDeviceList2=mapDevices(true).find {it.type==aliasDeviceType2} + if (aliasDeviceList2) aliasDeviceObj2=aliasDeviceList2.devices.find{it.label==aliasDeviceName2} + else outputTxt = "I had a problem finding the alias device you specified. Please check your Ask Alexa SmartApp settings to ensure you have a device associated with the alias name. %1%" + } + } + if (extObj("room", obj1).count) { extList1=extObj("room", obj1).list; count1 =count1 + extObj("room", obj1).count; extType1="room" } + if (extObj("room", obj2).count) { extList2=extObj("room", obj2).list; count2 =count2 + extObj("room", obj2).count; extType2="room" } + if (extObj("vr", obj1).count) { extList1=extObj("vr", obj1).list; count1 =count1 + extObj("vr", obj1).count; extType1="vr" } + if (extObj("vr", obj2).count) { extList2=extObj("vr", obj2).list; count2 =count2 + extObj("vr", obj2).count; extType2="vr" } + if (extObj("wr", obj1).count) { extList1=extObj("wr", obj1).list; count1 =count1 + extObj("wr", obj1).count; extType1="wr" } + if (extObj("wr", obj2).count) { extList2=extObj("wr", obj2).list; count2 =count2 + extObj("wr", obj2).count; extType2="wr" } + if (extObj("macro", obj1).count) { extList1=extObj("macro", obj1).list; count1 =count1 + extObj("macro", obj1).count; extType1="macro" } + if (extObj("macro", obj2).count) { extList2=extObj("macro", obj2).list; count2 =count2 + extObj("macro", obj2).count; extType2="macro" } + if (count1 > 1 && count2 ==1) outputTxt ="The name, '${obj1}', is used multiple times in your Ask Alexa SmartApp. Please rename or remove duplicate items so I may properly utlize them. " + else if (count2 > 1 && count1 ==1) outputTxt ="The name, '${obj2}', is used multiple times in your Ask Alexa SmartApp. Please rename or remove duplicate items so I may properly utlize them. " + else if (count2 > 1 && count1 > 1) outputTxt ="The names, '${obj1}' and '${obj2}', are used multiple times in your Ask Alexa SmartApp. Please rename or remove duplicate items so I may properly utlize them. " + else if (!count1 || !count2) { + if (!count1 && count2 && !(obj1==~/undefined|null|\?/)) outputTxt = "I had some problems finding, '${obj1}'. I did not take any action on this compound command. %1%" + else if (!count1 && count2 && obj1==~/undefined|null|\?/) outputTxt = "I had some problems finding one of the devices you specified. I did not take any action on this compound command. %1%" + else if (count1 && !count2) outputTxt = "I had some problems finding, '${obj2}'. I did not take any action on this compound command. %1%" + else if (!count1 && !count2) outputTxt = "I had some problems finding both '${obj1}' and '${obj2}'. I did not take any action on this compound command. %1%" + } + else if (deviceList1 || aliasDeviceList1 || deviceList2 || aliasDeviceList2 || extList1 || extList2) { + def proceed = true, actionObj1, actionObj2, objType1, objType2 + if (deviceList1 || aliasDeviceList1){ + actionObj1=deviceList1 ? deviceList1.devices.find{it.label.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase() == obj1} : aliasDeviceObj1 + objType1=deviceList1 ? deviceList1.type : aliasDeviceType1 + } + if (deviceList2 || aliasDeviceList2){ + actionObj2=deviceList2 ? deviceList2.devices.find{it.label.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase() == obj2} : aliasDeviceObj2 + objType2=deviceList2 ? deviceList2.type : aliasDeviceType2 + } + if (extList1) {actionObj1 = extList1; objType1=extType1} + if (extList2) {actionObj2 = extList2; objType2=extType2} + if ((objType1 =~/door|lock/ || (objType2 =~/door|lock/))) { + if ((objType1 =="door" || objType2 =="door") && doorPW && pwNeeded && getOkPIN()) outputTxt = "Doors are set up to require a password to operate and can not be used in a compound command. Restate your action with only the device you want to control and its password. I did not take any action for your current request. %1%" + if ((objType1 =="lock" || objType2 =="lock") && lockPW && pwNeeded && getOkPIN()) outputTxt = "Locks are set up to require a password to operate and can not be used in a compound command. Restate your action with only the device you want to control and its password. I did not take any action for your current request. %1%" + } + if (extList1 || extList2 && (op1==~/open|close/ || op1==~/lock|unlock/ || op2==~/open|close/ || op2==~/lock|unlock/)){ + def rm1 = getRM().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == extList1} + def rm2 = getRM().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == extList2} + if (getOkPIN() && pwNeeded && ((rm1 && op1==~/open|close/ && rm1.usePWDoor && rm1.doors) || (rm1 && op1==~/lock|unlock/ && rm1.usePWLock && rm1.locks) || + (rm2 && op2==~/open|close/ && rm2.usePWDoor && rm2.doors) || (rm2 && op2==~/lock|unlock/ && rm2.usePWLock && rm2.locks)) && mPW !=password){ + outputTxt = "Commands that require passwords can not be used in compound commands. Restate your action with only the device or group you want to control and its password. I did not take any action for your current request. %1%" + } + } + if (extList1 || extList2){ + def child1 = getAskAlexa().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == extList1} + def child2 = getAskAlexa().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == extList2} + if (((child1 && child1.usePW) || (child2 && child2.usePW)) && pwNeeded && password && getOkPIN()){ + outputTxt = "Macros that require passwords can not be used in compound commands. Restate your command with only the macro you want to run and its password. I did not take any action for your current request. %1%" + } + } + proceed = outputTxt ? false : true + if (proceed) { + def intOutput1, intOutput2 + if ((op1=="lock" && lockLockDisable) || (op1=="unlock" && lockUnLockDisable) || (op1=="open" && doorOpenDisable) || (op1=="open" && doorOpenDisable)) intOutput1 = "There are restrictions set up preventing the '${op1}' command from being used on this ${objType1}. %1%" + else if (objType1 =~/door|lock/){ + def currentState = actionObj1.currentValue(objType1) + if ((objType1 == "door" && (currentState==op1) || (currentState == "closed" && op1=="close"))||(objType1=="lock" && currentState == op1 + "ed")) intOutput1="The ${obj1} is already ${currentState}. %1%" + } + else if (deviceList1 || aliasDeviceList1) intOutput1 = getReply (actionObj1, objType1, obj1, op1, num1, param1, echoID) + else if (objType1=="room") intOutput1 = processRoom(actionObj1, num1, op1, param1 ,"", echoID) + else if (objType1=="vr") intOutput1 = processVoiceReport(actionObj1,echoID) + else if (objType1=="wr") intOutput1 = processWeatherReport(actionObj1,echoID) + else if (objType1=="macro") intOutput1 = processMacroAction(actionObj1, num1, "", true, "",echoID) + if ((op2=="lock" && lockLockDisable) || (op2=="unlock" && lockUnLockDisable) || (op2=="open" && doorOpenDisable) || (op2=="open" && doorOpenDisable)) intOutput2 = "There are restrictions set up preventing the '${op2}' command from being used on this ${objType2}. %1%" + else if (objType2 =~/door|lock/){ + def currentState = actionObj2.currentValue(objType2) + if ((objType2 == "door" && (currentState==op2) || (currentState == "closed" && op2=="close"))||(objType2=="lock" && currentState == op2 + "ed")) intOutput2="The ${obj2} is already ${currentState}. %1%" + } + else if (deviceList2 || aliasDeviceList2) intOutput2 = getReply (actionObj2, objType2, obj2, op2, num2, param2, echoID) + else if (objType2=="room") intOutput2 = processRoom(actionObj2, num2, op2 ,param2 ,"", echoID) + else if (objType2=="vr") intOutput2 = processVoiceReport(actionObj2,echoID) + else if (objType2=="wr") intOutput2 = processWeatherReport(actionObj2,echoID) + else if (objType2=="macro") intOutput2 = processMacroAction(actionObj2, num2, "", true, "",echoID) + if (intOutput1.endsWith("%1%") && intOutput2.endsWith("%1%")) outputTxt = "Both of the commands failed: " + intOutput1.replaceAll("%1%"," Also, ") + intOutput2.toLowerCase() + else if (intOutput1.endsWith("%1%") && !intOutput2.endsWith("%1%")) outputTxt = intOutput1.replaceAll(". %1%","; however, ") + intOutput2[0..-4]+" %1%" + else if (!intOutput1.endsWith("%1%") && intOutput2.endsWith("%1%")) outputTxt = intOutput2.replaceAll(". %1%","; however, ") + intOutput1[0..-4]+" %1%" + else if (intOutput1.endsWith("%2%") || intOutput2.endsWith("%2%")) outputTxt = intOutput1[0..-4] + intOutput2[0..-4] + "%3%" + else if (intOutput1.endsWith("%3%") && intOutput2.endsWith("%3%") && op1==op2 && !(op1 ==~/undefined|null|\?/)) { + if (op1==~/on|off/) outputTxt ="I am turning ${op1} the ${obj1} and the ${obj2}. %3%" + if (op1==~/close|open|toggle|lock|unlock/){ + def verb = op1=="close" ? "clos" : op1=="toggle" ? "toggl" : op1 + outputTxt ="I am ${verb}ing both the ${obj1} and the ${obj2}. %3%" + } + if (op1==~/undefined|null|\?/) outputTxt = intOutput1.replaceAll(". %3%"," ") + "and " + intOutput2.replaceAll("I "," ") + } + else if (intOutput1.endsWith("%3%") && intOutput2.endsWith("%3%") && param1==param2 && !(param1 ==~/undefined|null|\?/)) outputTxt ="I am setting the color of both ${obj1} and ${obj2} to ${param1}. %3%" + else if (intOutput1.endsWith("%3%") && intOutput2.endsWith("%3%") && num1>0 && num1==num2) outputTxt ="I am setting both the ${obj1} and the ${obj2} to ${num1}%. %3%" + else if (intOutput1.endsWith("%4%") || intOutput2.endsWith("%4%")) outputTxt = intOutput1[0..-4] + "In addition, " + intOutput2[0..-4]+" %3%" + else outputTxt = intOutput1[0..-6] + " and " + intOutput2[0..-4] + "%3%" + } + } + sendJSON (outputTxt,"compound") +} +def extObj(type, object){ + def count=0, extList, extType = (type==~/vr|wr|room/) ? "extAlias" : "macAlias" + def objType = type=="room" ? getRM() : (type=="vr") ? getVR() : (type=="wr") ? getWR() : getAskAlexa() + if (objType.size()) { + objType.each { + if (it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") ==object) { count ++; extList=object } + def extCount = (type==~/vr|wr|room/) ? it.extAliasCount() : it.macAliasCount() + for (int i = 1; i1 ? "The available modes include the following: " + getList(listModes) + ". " : listModes && listModes.size()==1 ? "You have one mode enabled for control named: " + getList(listModes) + ". " : "There are no modes defined within your Ask Alexa SmartApp. " + if (listType=~/security|smart home monitor|SHM/) {outputTxt= listSHM && listSHM.size() > 1? "#Smart Home Monitor commands#" : listSHM && listSHM.size() == 1 ? "@Smart Home Monitor command@":"%Smart Home Monitor commands%"; devices=listSHM } + if (listType=~/temperature/){ + if (temps) { outputTxt = "The devices you can get temperature readings from include the following: " + getList(temps) + ". "; aliasType="temperature" } + if (tstats && temps) { outputTxt +="In addition, the following thermostats can also give you temperature readings: " + getList(tstats) + ". "; aliasType="thermostat" } + if (tstats && tstats.size()>1 && !temps) { outputTxt ="The only devices you have selected for temperature readings are the following thermostats: " + getList(tstats) + ". " ;aliasType="thermostat" } + if (tstats && tstats.size()==1 && !temps) { outputTxt ="The only device you have selected for temperature readings is the " + getList(tstats) + ". " ; aliasType="thermostat" } + if (!tstats && !temps) outputTxt="You don't have any devices selected that will provide temperature readings. " + } + if (listType=~/thermostat/) { outputTxt = tstats && tstats.size()>1 ? "#thermostats#" : tstats && tstats.size()==1 ? "@thermostat@":"%thermostats%"; devices=tstats; aliasType="thermostat"} + if (listType=~/humidity/) { outputTxt = humid && humid.size()>1 ? "#humidity sensors#" : humid && humid.size()==1 ? "@humidity sensor@" : "%humidity sensors%"; devices=humid; aliasType="humidity" } + if (listType=~/presence/) { outputTxt = presence && presence.size()>1 ? "#presence sensors#" : presence && presence.size()==1 ? "@presence sensor available@" : "%presence sensors%"; devices=presence; aliasType="presence" } + if (listType=~/acceleration/) { outputTxt = acceleration && acceleration.size()>1 ? "#acceleration sensors#" : acceleration && acceleration.size()==1 ? "@acceleration sensor@" : "%acceleration sensors%"; devices = acceleration; aliasType="acceleration"} + if (listType=~/motion/) { outputTxt = motion && motion.size()>1 ? "#motion sensors#" : motion && motion.size()==1 ? "@motion sensor@" : "%motion sensors%" ; devices=motion; aliasType="motion" } + if (listType=~/open|close/) { outputTxt = ocSensors && ocSensors.size()>1 ? "#open close sensors#" : ocSensors && ocSensors.size()==1 ? "@open close sensor@" : "%open close sensors%"; devices = ocSensors; aliasType="contact" } + if (listType=~/dimmer/) { outputTxt = dimmers && dimmers.size()>1 ? "#dimmers#" : dimmers && dimmers.size()==1 ? "@dimmer@" : "%dimmmers%"; devices=dimmers; aliasType="level" } + if (listType=~/speaker/) { outputTxt = speakers && speakers.size()>1 ? "#speakers#" : speakers && speakers.size()==1 ? "@speaker@" : "%speakers%" ; devices=speakers; aliasType="music" } + if (listType=~/door/) { outputTxt = doors && doors.size()>1 ? "You have the following doors you can open or close: " + getList(doors) + ". " : doors && doors.size()==1 ? "You have one door, " + getList(doors)+ ", selected that you can open or close. " : "%doors%"; aliasType="door" } + if (listType=~/shade|window/) { outputTxt = shades && shades.size()>1 ? "#window shades#" : shades && shades.size()==1 ? "@window shade@" :"%window shades%"; devices = shades; aliasType="shade" } + if (listType=~/lock/) { outputTxt = locks && locks.size()>1 ? "#locks#" : locks && locks.size()==1 ? "@lock@" :"%locks%"; devices=locks; aliasType="lock" } + if (listType=~/colored light/) { outputTxt = cLights && cLights.size()>1 ? "#colored lights#": cLights && cLights.size()==1 ? "@colored light@" : "%colored lights%"; devices=cLights; aliasType="color" } + if (listType=~/temperature light|kelvin/) { outputTxt = cLightsK && cLightsK.size()>1 ? "#temperature lights#": cLightsK && cLightsK.size()==1 ? "@temperature light@" : "%temperature lights%"; devices=cLightsK; aliasType="kTemp" } + if (listType=~/switch/) { outputTxt = switches? "You can turn on, off or toggle the following switches: " + getList(switches) + ". " : "%switches%" ; aliasType="switch" } + if (listType=~/routine/) { outputTxt= listRoutines && listRoutines.size()>1 ? "#routines#":listRoutines && listRoutines.size()==1 ? "@routine@" : "%routines%"; devices=listRoutines } + if (listType=~/water/) { outputTxt= water && water.size()>1 ? "#water sensors#" : water && water.size()==1 ? "@water sensor@" : "%water sensors%" ; devices=water; aliasType="water" } + if (listType =~/report|voice/) outputTxt = parseMacroLists("Voice","voice report","play") + if (listType =~/schedule/) outputTxt = parseMacroLists("Schedule","schedule","") + if (listType =~/control/) outputTxt = parseMacroLists("Control","control macro","run") + if (listType =~/pollution|air|quality/) { outputTxt = fooBot && fooBot.size()>1 ? "#Foobot air quality monitors#" : fooBot && fooBot.size()==1 ? "@Foobot air quality monitor@" : "%Foobot air quality monitors%"; devices=fooBot; aliasType="pollution" } + if (listType =~/uv|index/) { outputTxt = UV && UV.size()>1 ? "#UV Index Devices#" : UV && UV.size()==1 ? "@UV Index Device@" : "%UV Index Devices%"; devices=UV; aliasType="uvIndex" } + if (listType =~/occupancy/) { outputTxt = occupancy && occupancy.size()>1 ? "#occupancy sensors#" : occupancy && occupancy.size()==1 ? "@occupancy sensor@" : "%occupancy sensors%"; devices=occupancy; aliasType="beacon" } + if (listType =~/core|webcore|trigger/) outputTxt = parseMacroLists("CoRE","WEBCORE trigger","run") + if (listType =~/extension group|group extention/) outputTxt = parseMacroLists("GroupM","extension group","run") + if (listType =~/weather/) outputTxt = parseMacroLists("Weather","weather report","play") + if (listType =~/message|queue/) outputTxt = parseMacroLists("MQ","message queue","play") + if (listType =~/event/) { + outputTxt = "To list events for a device, you must give me the name of that device. " + if (Math.abs(new Random().nextInt() % 2)==1) outputTxt += "For example, you could say, 'tell ${invocationName} to give me the last events for the Bedroom'. " + + "You may also include the number of events you would like to hear. An example would be, 'tell ${invocationName} to give me the last 4 events for the Bedroom'. " + } + if (listType =~/alias/) outputTxt = "You can not list aliases directly. To hear the aliases that are available, choose a specific device catagory to list. For example, if you list the available switch devices, any switch aliases you created will be listed as well. " + if (listType ==~/colors|color/) outputTxt = cLights ? "There are too many colors to list. Basic colors like red, blue, and green are availabe. For a full list of colors, it is recommended you print the Ask Alexa cheat sheet. " : "%colored lights%" + if (listType ==~/macro|macros/) outputTxt ="Please be a bit more specific about which macros you want me to list. You can ask me about 'webcore triggers', 'extension groups' and 'control macros'. %1%" + if (listType ==~/group|groups|room|rooms/) outputTxt = parseMacroLists("Rooms/Groups","group","take action") + if (listType ==~/extension|extensions/) outputTxt ="Please be a bit more specific about which extensions you want me to list. You can ask me about 'voice reports', 'weather reports', 'schedules', and 'message queues'. %1%" + if (listType ==~/sensor|sensors/) outputTxt ="Please be a bit more specific about what kind of sensors you want me to list. You can ask me to list items like 'water', 'occupancy', 'open close', 'presence', 'acceleration, or 'motion sensors'. %1%" + if (listType ==~/light|lights/) outputTxt ="Please be a bit more specific about what kind of lighting devices you want me to list. You can ask me to list devices like 'switches', 'dimmers' or 'colored lights'. %1%" + if (listType =~/echo|alexa|device/) { + def rmList=[] + if (getRM().size()) getRM().each {roomName-> + def nameList=roomName.getEchoAliasList() + nameList.each { rmList<1 ? "You have the following groups that have Echo devices associated with them: ${getList(rmList.unique())}. " : + rmList && rmList.unique().size()==1 ? "The only group with an Echo device associated with it is: '${getList(rmList.unique())}'. " : "You have no groups associated with any Echo devices. " + } + if (outputTxt.startsWith("%") && outputTxt.endsWith("%")) outputTxt = "There are no" + outputTxt.replaceAll("%", " ") + "set up within your Ask Alexa SmartApp. " + if (outputTxt.startsWith("@") && outputTxt.endsWith("@")){ + if (Math.abs(new Random().nextInt() % 2)==1) outputTxt = "The only available" + outputTxt.replaceAll("@", " ")+ "is named: '" + getList(devices) + "'. " + else outputTxt = "You only have one" + outputTxt.replaceAll("@", " ")+ "set up in your app named: '" + getList(devices) + "'. " + } + if (outputTxt.startsWith("#") && outputTxt.endsWith("#")) outputTxt = "The available" + outputTxt.replaceAll("#", " ") + "include the following: "+ getList(devices) + ". " + if (deviceAlias && aliasType){ + def aliases =state.aliasList.findAll{it.aliasType==aliasType} + def ss = aliases && aliases.size()>1 ? "s" : "" + def preText = outputTxt.startsWith("There are no") || outputTxt.startsWith("You don't") ? "However" : "In addition" + if (aliases) outputTxt += "${preText}, you have the following alias name${ss} set up for this device catagory: " + getList(aliases.aliasName) + ". " + } + if (outputTxt == "") { + outputTxt = "I didn't understand what you wanted information about. " + if (Math.abs(new Random().nextInt() % 3)==1) outputTxt += "Be sure you have populated the developer section with the device names. " + outputTxt += "%1%" + } + else if (!outputTxt.endsWith("%")) outputTxt += "%2%" + sendJSON(outputTxt,"list") +} +def parseMacroLists(type, noun, action){ + def macName = "", children = getAskAlexa(), count = children.count{it.macroType==type} + if (type == "MQ") count = getAAMQ().size() + 1 + else if (type == "Weather") count = getWR().size() + else if (type == "Voice") count = getVR().size() + else if (type =="Schedule") count = getSCHD().size() + else if (type =="Rooms/Groups") count = getRM().size() + def extraTxt = (type ==~/Control|CoRE/) && count ? "Please note: You can also delay the execution of ${noun}s by adding the number of minutes after the name. For example, you could say, 'tell ${invocationName} to run the Macro in 5 minutes'. " : "" + if (type!="Schedule") macName = count==1 ? "You only have one ${noun} called: " : count> 1 ? "You can ask me to ${action} the following ${noun}s: " : "You don't have any ${noun}s for me to ${action}" + else macName = count==1 ? "You only have one ${noun} called: " : count> 1 ? "You can ask me for detail about the following ${noun}s: " : "You don't have any ${noun}s created" + if (count && type !="MQ"){ + children.each{if (it.macroType==type){ + macName += "'" + it.label + "'" ; count -- + macName += count>1 ? ", " : count==1 ? " and " : "" + } + } + } + if (count && type == "MQ") macName += count ==2 ? "Primary Message Queue and " + getList(getAAMQ().label) : count ==1 ? "Primary Message Queue" : "Primary Message Queue, " + getList(getAAMQ().label) + if (count && type == "Weather") macName += getList(getWR().label) + if (count && type == "Voice") macName += getList(getVR().label) + if (count && type == "Schedule") macName += getList(getSCHD().label) + if (count && type == "Rooms/Groups") macName += getList(getRM().label) + return macName + ". " + extraTxt +} +//Extension Group +def processMacroGroup(macroList, msg, append, noMsg, macLabel, echoID){ + String result = "" + def runCount=0 + if (macroList){ + macroList.each{ + getAskAlexa().each{child-> + if (child.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == (it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))){ + result += child.getOkToRun() ? child.macroResults(0,"","","","","",echoID) : child.muteRestrictions ? "" : "You have restrictions on '${child.label}' that prevented it from running. %1%" + runCount++ + if (result.endsWith("%")) result = result[0..-4] + } + } + getWR().each{report-> + if (report.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == (it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))){ + result += processWeatherReport(it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""),echoID) + runCount++ + if (result.endsWith("%")) result = result[0..-4] + } + } + getVR().each{report-> + if (report.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == (it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))){ + result += processVoiceReport(it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""),echoID) + runCount++ + if (result.endsWith("%")) result = result[0..-4] + } + } + } + def extraTxt = runCount > 1 ? "extensions" : "extension" + if (runCount == macroList.size()) { + if (!noMsg) { + msg = replaceVoiceVar(msg,"","",macroType,app.label,0,"") + if (msg && append) result += msg + if (msg && !append) result = msg + if (append && !msg) result += "I ran ${runCount} ${extraTxt} in this extension group. " + if (!append && !msg) result = "I ran ${runCount} ${extraTxt} in this extension group. " + } + else result = " " + } + else result = "There was a problem running one or more of the extensions in the extension group. %1%" + } + else result="There were no extensions present within this extension group. Please check your Ask Alexa SmartApp and try again. %1%" + def data = result ? result.endsWith("%") ? [alexaOutput: result[0..-4]] : [alexaOutput: result] : [alexaOutput: "No Output"] + sendLocationEvent(name: "askAlexa", value: app.id, data: data, displayed: true, isStateChange: true, descriptionText: "Ask Alexa ran '${macLabel}' extension group.") + return result +} +def processOtherRpt(list,echoID){ + String result ="" + list.each{ + getWR().each{report-> + if (report.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == (it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))){ + result += processWeatherReport(it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""),echoID) + if (result.endsWith("%")) result = result[0..-4] + } + } + getVR().each{report-> + if (report.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == (it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))){ + result += processVoiceReport(it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""),echoID) + if (result.endsWith("%")) result = result[0..-4] + } + } + getAAMQ().each{report-> + if (report.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == (it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))){ + result += msgQueueReply("play",it.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "undefined")) + if (result.endsWith("%")) result = result[0..-4] + } + } + } + return result +} +//Message Queue Process + def processMQ() { + def queue = params.Queue.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") //Queue name + def cmd = params.MQCmd //Command + def echoID = params.echoID //Echo device being addressed + String outputTxt = "" + def children = getAAMQ(), count = children.count {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == queue} + if (count==0 && queue=~/primary/) count = 1 + if (count==0 && !(queue ==~/undefined|null/)) outputTxt="I could not find a message queue named '${queue}'. Please check the message queue name in your Ask Alexa SmartApp. %1%" + else if (queue ==~/undefined|null/ && cmd ==~/undefined|null/) outputTxt= "I could not understand what you are attempting to do. Be sure you have populated the developer section with the device and extension names. %1%" + else if (count<2) outputTxt = msgQueueReply(cmd, queue, echoID) + else outputTxt ="You have multiple message queues named '${queue}'. Please check your Ask Alexa SmartApp to fix this conflict. %1%" + sendJSON(outputTxt, "mailbox") +} +//External Message Queue Input +def extMQ(){ + def queue = params.queue //Queue name + def msg = params.msg //Message to send + def source = params.source //Sending source + if (queue && msg && source){ + if (queue == "Primary Message Queue") msgPMQ (new Date(now()), msg, "", source, false, 0, false, false, false) + else if (getAAMQ().find{it.label == queue}){ + def qNameRun = getAAMQ().find{it.label == queue} + if (qNameRun) qNameRun.msgHandler(new Date(now()), msg, "", source, false, 0, false, false, false) + } + else log.debug "An external source, '${source}', attempted to send a message to an invalid message queue name." + } + else log.debug "An external source attempted to send a message but did not have the correct parameters." +} +//Message Queue Reply +def msgQueueReply(cmd, queue, echoID){ + log.debug "-Message Queue Response-" + log.debug "Message Queue: " + queue + log.debug "Message Queue Command: " + cmd + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + String result = "" + def validQueue = getAAMQ().find{it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == queue} + if (validQueue) result=validQueue.msgQueueReply(cmd, echoID) + else { + def msgCount = state.msgQueue ? state.msgQueue.size() : 0, msgS= msgCount==0 || msgCount>1 ? " messages" : " message" + if (cmd =~/play|open|undefined|null/){ + if (msgCount==0) result = "You don't have any messages in the primary message queue. %M%" + else { + result = "You have " + msgCount + msgS + " in your primary message queue: " + state.msgQueue.sort({it.date}) + if (msgQueueOrder) state.msgQueue.reverse(msgQueueOrder as int? true : false) + state.msgQueue.each{ + def msgData= timeDate(it.date), msgTimeDate = msgQueueDateSuppress || it.suppressTimeDate ? "" : "${msgData.msgDay} at ${msgData.msgTime}, " + result += "${msgTimeDate}'${it.appName}' posted the message: '${it.msg}'. " + } + result +="%M%" + } + } + else if (cmd =~ /clear|delete|erase/) { + qDelete() + result="I have deleted all of the messages from the primary message queue. %M%" + } + else result="For the primary message queue, be sure to give a 'play' or 'delete' command. %1%" + } + return result +} +//Weather Report Reply +def processWeatherReport(rpt,echoID){ + log.debug "Weather Report Name: " + rpt + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + def wr = getWR().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == rpt}, outputTxt + if (wr.getOkToRun(echoID)){ + outputTxt = wr.getOutput() + def data = outputTxt ? [alexaOutput: outputTxt[0..-4]] : [alexaOutput: "No Output"] + sendLocationEvent(name: "askAlexa", value: wr.id, data: data, displayed: true, isStateChange: true, descriptionText: "Ask Alexa activated '${wr.label}' weather report.") + } + else outputTxt = wr.muteRestrictions ? "" : "You have restrictions on '${wr.label}' that prevent it from running. %1%" + return outputTxt +} +//Voice Report Reply +def processVoiceReport(rpt,echoID){ + log.debug "Voice Report Name: " + rpt + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + def vr = getVR().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == rpt}, outputTxt + if (vr.getOkToRun(echoID)){ + outputTxt = vr.getOutput(echoID) + def data = outputTxt ? [alexaOutput: outputTxt[0..-4]] : [alexaOutput: "No Output"] + sendLocationEvent(name: "askAlexa", value: vr.id, data: data, displayed: true, isStateChange: true, descriptionText: "Ask Alexa activated '${vr.label}' voice report.") + } + else outputTxt = vr.muteRestrictions ? "" : "You have restrictions on '${vr.label}' that prevent it from running. %1%" + return outputTxt +} +//Schedule Reply +def processSchedule(schedule,cmd, cancel){ + log.debug "Schedule Name: " + schedule + log.debug "Schedule Command: " + cmd + log.debug "Schedule Cancel Delete: ${cancel=="9999" ? "True" : "False"}" + def sd = getSCHD().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == schedule}, outputTxt + if (cmd==~/status|list|undefined|null/ && cancel != "9999" ) outputTxt = sd.getSchdDesc() + else if (cmd==~/toggle/) outputTxt = sd.toggle() + else if (cmd==~/on|off/) outputTxt = sd.onOff(cmd) + else if (cmd=="delete") outputTxt = sd.deleteSch() + else if (cmd==~/undefined|null/ && cancel == "9999") outputTxt = sd.notDeleteSch() + else outputTxt="I did not understand what you wanted to do the the schedule, '${schedule}'. You may query this schedule, delete it, or toggle its 'on' and 'off' state. %1%" + return outputTxt +} +//Room Reply +def processRoom(room, mNum, op, param, mPW, echoID){ + log.debug "Room/Group Name: " + room + log.debug "Room/Group Number Command: " + mNum + log.debug "Room/Group Command: " + op + log.debug "Room/Group Parameter: " + param + log.debug "Room/Group Password: " + mPW + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + def rm = getRM().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == room}, outputTxt + if (getOkPIN() && pwNeeded && ((op==~/open|close/ && rm.usePWDoor && rm.doors) || (op==~/lock|unlock/ && rm.usePWLock && rm.locks) && mPW !=password)){ + def pwExtra = mPW==~/undefined|null/ ? "A" : "The proper" + outputTxt = "${pwExtra} password is required to use the ${op} action in this group. %P%" + state.cmdFollowup=[return: "processRoom", room:room, mNum:mNum, op:op, param:param, mPW:0] + } + else { + outputTxt = rm.getOutput (room, mNum, op, param, mPW, echoID) + def data = outputTxt ? [alexaOutput: outputTxt[0..-4]] : [alexaOutput: "No Output"] + sendLocationEvent(name: "askAlexa", value: rm.id, data: data, displayed: true, isStateChange: true, descriptionText: "Ask Alexa triggered '${rm.label}' room/group extension.") + } + return outputTxt +} +//Macro Processing +def processMacro() { + log.debug "-Macro/extension command received-" + def mac = params.Macro.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") //Macro/extension name + def mNum = params.Num //Number variable-Typically delay to run + def cmd = params.Cmd //Room/Group Command + def mType =params.Type //For room/group: coming soon + def param = params.Param //Parameter + def mPW = params.MPW //Macro Password + def xParam = params.xParam //Additional parameters + def echoID = params.echoID //Echo device being addressed + String outputTxt = "" + def count = 0, macCount=0, wrCount=0, vrCount=0, rmCount=0, sdCount=0, macAlias + if (cmd ==~/associate|setup|link|sync/ && mac =="undefined") outputTxt="I did not hear the group you want to associate with this Echo device. Valid group names are: ${getList(getRM().label)}. %1%" + if (mac==~/room|here|group|this room|in here|this group/ && echoID !="undefined" && doRmCheck(echoID)) mac=doRmCheck(echoID).toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") + else if (mac==~/room|here|group|this room|in here|this group/ && echoID =="undefined") outputTxt="I could not identify the Echo device you are speaking to. %1%" + else if (mac==~/room|here|group|this room|in here|this group/ && echoID !="undefined" && !doRmCheck(echoID)) outputTxt ="You have not set up this Echo device with a room or group yet. To do that, simply say, 'associate', and the group name. %1%" + if (outputTxt) sendJSON(outputTxt,"caution") + else { + macCount = getAskAlexa().count {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == mac} + wrCount = getWR().count {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == mac} + vrCount = getVR().count {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == mac} + sdCount = getSCHD().count{it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == mac} + rmCount = getRM().count{it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == mac} + count = macCount + wrCount + vrCount + sdCount + rmCount + if (!count) { + getAskAlexa().each{ + for (int i = 1; i 1) outputTxt ="You have duplicate macros, aliases, or extensions named '${mac}'. Please check your Ask Alexa SmartApp to fix this conflict. %1%" + if (!count && mac && !(mac==~/null|undefined|\?/)) outputTxt = "I could not find a macro, alias or any extension named '${mac}'. %1%" + else if (!count && (mac==~/undefined|null|\?/ || !mac)) outputTxt = "I could not understand what you are attempting to do. Be sure you have populated the developer section with the device and extension names. %1%" + if (outputTxt) sendJSON(outputTxt,"caution") + else { + if (macCount) processMacroAction(mac, mNum, mPW, false, xParam, echoID) + else if (wrCount) sendJSON(processWeatherReport(mac,echoID),"weather") + else if (vrCount) sendJSON(processVoiceReport(mac,echoID),"voice") + else if (sdCount) sendJSON(processSchedule(mac,cmd,mNum),"schedule") + else if (rmCount) sendJSON(processRoom(mac, mNum, cmd, param, mPW, echoID), "room") + } + } +} +def processMacroAction(mac, mNum, mPW, followup, xParam, echoID){ + log.debug "Macro Name: " + mac + log.debug "mNum: " + mNum + log.debug "mPW: " + mPW + log.debug "xParam: " + xParam + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + def num = mNum ==~/undefined|null|\?/ || !mNum ? 0 : mNum as int + String outputTxt = "" + def macroType="", playContMsg, suppressContMsg + def macProceed= true, children = getAskAlexa(), child = children.find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == mac} + if (child.usePW && pwNeeded && getOkPIN() && password && mPW != password ){ + macProceed = false + def pwExtra = mPW==~/undefined|null/ ? "a" : "the proper" + if (child.macroType == "CoRE") outputTxt = "To activate this WebCore Trigger, you must use ${pwExtra} password. %P%" + if (child.macroType == "Control") outputTxt = "To activate this Control Macro, you must use ${pwExtra} password. %P%" + if (child.macroType == "GroupM") outputTxt = "To activate this Group Macro, you must use ${pwExtra} password. %P%" + state.cmdFollowup=[return: "macroAction", mac:mac, mNum:mNum,mPW:0,xParam:xParam] + } + if (child.macroType != "GroupM" && cmd=="list" && macProceed) { outputTxt = "You can not use the list command with this type of macro. %1%"; macProceed = false } + else if (child.macroType == "GroupM" && cmd=="list" && macProceed) { + def gMacros= child.groupMacros.size()==1 ? "extension" : "extensions" + outputTxt="You have the following ${gMacros} within the '${child.label}' extension group: " + getList(child.groupMacros) +". " + macProceed = false + } + if (macProceed){ + playContMsg = child.overRideMsg ? false : true + suppressContMsg = child.suppressCont && !child.overRideMsg && contMacro + def fullMacroName = [GroupM: "Extension Group",CoRE: "WebCoRE Trigger", Control:"Control Macro"][child.macroType] ?: child.macroType + if (child.macroType != "GroupM") outputTxt = child.getOkToRun() && child.getOkEcho(echoID)? child.macroResults(num, cmd, colorData, param, mNum,xParam,echoID) : "You have restrictions within the ${fullMacroName} named, '${child.label}', that prevent it from running. Check your settings and try again. %1%" + else { + outputTxt = processMacroGroup(child.groupMacros, child.voicePost, child.addPost, child.noAck, child.label,echoID) + if (child.ctlMsgQue && !child.noAck){ + def expireMin=child.ctlMQExpire ? child.ctlMQExpire as int : 0, expireSec=expireMin*60 + def overWrite =!child.ctlMQNotify && !child.ctlMQExpire && child.ctlMQOverwrite + def msgTxt = outputTxt ? outputTxt.endsWith("%") ? outputTxt[0..-4] : outputTxt : "No Output" + sendLocationEvent( + name: "AskAlexaMsgQueue", + value: "Ask Alexa Extension Group, '${child.label}'", + unit: "${app.id}", + isStateChange: true, + descriptionText: msgTxt, + data:[ + queues:child.ctlMsgQue, + overwrite: overWrite, + notifyOnly: child.ctlMQNotify, + expires: expireSec, + suppressTimeDate:child.ctlSuppressTD + ] + ) + } + } + } + if (outputTxt && !outputTxt.endsWith("%") && !outputTxt.endsWith(" ")) outputTxt += " " + if (outputTxt && !outputTxt.endsWith("%") && playContMsg && !suppressContMsg ) outputTxt += "%4%" + else if (outputTxt && !outputTxt.endsWith("%") && suppressContMsg ) outputTxt += "%X%" + if (followup) return outputTxt + else sendJSON(outputTxt,"macro") +} +//Smart Home Commands + def processSmartHome() { + log.debug "-Smart home command received-" + def cmd = params.SHCmd //Smart Home Command + def param = params.SHParam.toLowerCase() //Smart Home Parameter + def num = params.SHNum //Smart Home Password + def echoID = params.echoID //Echo device being addressed + log.debug "Cmd: " + cmd + log.debug "Param: " + param + log.debug "Num: " + num + log.debug "Echo ID: " + (echoID !="undefined"? "..."+echoID[-10..-1] : "Simulator or unknown device") + String outputTxt = "" + if (cmd ==~/undefined|null/) { + if (param=="off") outputTxt="Be sure to specify a device, or the word 'security', when using the 'off' command. %1%" + if (listModes?.find{it.toLowerCase()==param} && param != currMode) cmd = "mode" + if (param==~/list|arm|undefined|null/) cmd = "security" + def phrases = location.helloHome?.getPhrases()*.label + if (phrases.find{it.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase()==param}) cmd = "routine" + } + if (cmd == "mode") outputTxt=changeMode(num, param) + if (cmd==~/security|smart home|smart home monitor|SHM/) outputTxt=changeSHM(num, param) + if (cmd=="routine" && listRoutines) outputTxt=runRoutine(num, param) + if (!outputTxt) outputTxt = "I didn't understand what you wanted me to do. %1%" + if (outputTxt && !outputTxt.endsWith("%") && !outputTxt.endsWith(" ")) outputTxt += " " + if (outputTxt && !outputTxt.endsWith("%")) outputTxt +="%2%" + sendJSON(outputTxt,"smarthome") +} +private changeMode(num, param){ + String outputTxt = "" + def currMode = location.mode.toLowerCase() + if (param ==~/undefined|null/) outputTxt ="The current SmartThings mode is set to: '${currMode}'. %2%" + if (listModes && param !="undefined" && param !="null"){ + if (getOkPIN() && modesPW && pwNeeded && password && num ==~/undefined|null/) { + outputTxt = "You must use a password to change your SmartThings mode. %P%" + state.cmdFollowup=[return: "changeMode", num: num, param: param] + } + if (getOkPIN() && modesPW && pwNeeded && password && num!="undefined" && num!="null" && num != password) outputTxt="I did not hear the correct password to change your SmartThings mode. %1%" + if (!getOkPIN() || !modesPW || !pwNeeded || (getOkPIN() && modesPW && pwNeeded && num == password)){ + if (listModes?.find{it.toLowerCase()==param} && param != currMode) { + def newMode=listModes.find{it.toLowerCase()==param} + outputTxt ="I am setting the SmartThings mode to: '${newMode}'. %3%" + setLocationMode(newMode) + } + else if (param == currMode) outputTxt ="The current SmartThings mode is already set to: '${currMode}'. No changes are being made. %2%" + if (!outputTxt) outputTxt = "I did not understand the mode you wanted to set. For a list of available modes, simply say, 'ask ${invocationName} for mode list'. %1%" + } + } + else if (!outputTxt) outputTxt = "You can not change your mode to, '${param}', because you do not have this mode selected within your Ask Alexa SmartApp. Please enable this mode for control. %1%" + return outputTxt +} +private changeSHM(num, param){ + String outputTxt = "" + def SHMstatus = location.currentState("alarmSystemStatus")?.value + def SHMFullStat = [off : "disarmed", away: "armed away", stay: "armed home"][SHMstatus] ?: SHMstatus + def newSHM = "", SHMNewStat = "" + if (param==~/undefined|null/) outputTxt ="The Smart Home Monitor is currently set to, '${SHMFullStat}'. %2%" + if (listSHM && !(param ==~/undefined|null/)){ + if (getOkPIN() && shmPW && pwNeeded && password && num ==~ /undefined|null/) { + outputTxt = "You must use a password to change the Smart Home Monitor. %P%" + state.cmdFollowup=[return: "changeSHM", num:num, param: param] + } + if (getOkPIN() && shmPW && pwNeeded && password && !(num ==~ /undefined|null/) && num != password) outputTxt="I did not hear the correct password to change the Smart Home Monitor. %1%" + if (!getOkPIN() || !shmPW || !pwNeeded || (getOkPIN() && shmPW && pwNeeded && num == password)){ + if (param==~/arm|armed/ && (listSHM.find{it =="Armed (Away)"} || listSHM.find{it =="Armed (Home)"})) outputTxt ="I did not understand how you want me to arm the Smart Home Monitor. Be sure to say, 'armed home' or 'armed away', to properly change the setting. %1%" + if ((param ==~/off|disarm/) && listSHM.find{it =="Disarmed" }) newSHM="off" + if ((param ==~/away|armed away|arm away/) && listSHM.find{it =="Armed (Away)"}) newSHM="away" + if ((param ==~/home|armed home|arm home|stay|armed stay|arm stay/) && listSHM.find{it =="Armed (Home)"}) newSHM="stay" + if (newSHM && SHMstatus!=newSHM) { + sendLocationEvent(name: "alarmSystemStatus", value: newSHM) + SHMNewStat = [off : "disarmed", away: "armed away", stay: "armed home"][newSHM] ?: newSHM + outputTxt ="I am setting the Smart Home monitor to, '${SHMNewStat}'. %3%" + } + else if (SHMstatus==newSHM) outputTxt ="The Smart Home Monitor is already set to '${SHMFullStat}'. No changes are being made. %2%" + } + } + if (!outputTxt) outputTxt = "I was unable to change your Smart Home Monitor. Ensure you have the proper settings enabled within your Ask Alexa SmartApp. %1%" + return outputTxt +} +private runRoutine(num, param){ + String outputTxt = "" + if (param != "undefined" && param != "null") { + def whichRoutine = listRoutines.find{it.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase()==param} + if (whichRoutine) { + if (getOkPIN() && routinesPW && pwNeeded && password && num ==~ /undefined|null/) { + outputTxt = "You must use a password to run SmartThings routines. %P%" + state.cmdFollowup=[return: "runRoutine", num:num, param: param] + } + if (getOkPIN() && routinesPW && pwNeeded && password && num!="undefined" && num!="null" && num != password) outputTxt="I did not hear the correct password to run the SmartThings routines. %1%" + if (!getOkPIN() || !routinesPW || !pwNeeded || (getOkPIN() && routinesPW && pwNeeded && num == password)){ + location.helloHome?.execute(whichRoutine) + outputTxt="I am executing the '${param}' routine. %3%" + } + } + else outputTxt = "You can not run the SmartThings routine named, '${param}', because you do not have this routine enabled in the Ask Alexa SmartApp. %1%" + } + else outputTxt ="To run SmartThings' routines, ask me to run the routine by its full name. For a list of available routines, simply say: 'ask ${invocationName} to list routines'. %1%" + return outputTxt +} +def getReply(devices, type, STdeviceName, op, num, param, echoID){ + String result = "", batteryWarnTxt="" + try { + def STdevice = devices + def supportedCaps = STdevice.capabilities + def supportedCMDs = STdevice.getSupportedCommands() + if (op=="status") { + if (type == "beacon"){ + def occ = STdevice.currentValue("occupancy") + if (occ) result = "The '${STdeviceName}' occupancy sensor is reading: '${occ}'. " + } + else if (type == "temperature"){ + if (STdevice.currentValue(type)) { + def temp = roundValue(STdevice.currentValue(type)) + result = "The temperature of the ${STdeviceName} is ${temp} degrees" + if (otherStatus) { + def humidity = STdevice.currentValue("humidity"), wet=STdevice.currentValue("water"), contact=STdevice.currentValue("contact"), pollution = STdevice.currentValue("pollution") + result += humidity ? ", and the relative humidity is ${humidity}%. " : ". " + result += wet ? "Also, this device is a water sensor, and it is currently ${wet}. " : "" + result += contact ? "This device is also a contact sensor sensor, and it is currently reading ${contact}. " : "" + result += pollution ? "This device is also an air quality monitor that is currently reading: '${STdevice.currentValue("GPIstate")}', with a Global Pollution Index of ${pollution}%. " : "" + } + else result += ". " + } + else result ="The temperature of the ${STdeviceName} is reading null, which may indicate an issue with the device. %1%" + } + else if (type == "pollution") { + if (fooBotPoll) STdevice.poll() + if (STdevice.currentValue("GPIstate") || STdevice.currentValue(type)){ + result = "The Foobot air quality monitor, '${STdeviceName}', is reading: '${STdevice.currentValue("GPIstate")}', with a Global Pollution Index of ${STdevice.currentValue(type)}" + if (otherStatus){ + result += ". The carbon dioxide level is ${STdevice.currentValue("carbonDioxide")} parts per million, the volatile organic compounds reading is ${STdevice.currentVoc} parts per billion" + result += ", the particulate matter level reading is ${STdevice.currentParticle} µg/m³" + result += STdevice.currentValue("humidity") ? ", and the relative humidity is ${STdevice.currentValue("humidity")}%" : ". " + } + result += ". " + } + else result ="The Foobot air quality monitor, '${STdeviceName}', is reading null, which may indicate an issue with the device. %1%" + } + else if (type == "presence") result = "The presence sensor, ${STdeviceName}, is showing ${STdevice.currentValue(type)}. " + else if (type ==~/acceleration|motion/){ + def currVal =STdevice.currentValue(type), motionStat=[active : "movement", inactive: "no movement"][currVal] ?: currVal + result = "The ${type} sensor, ${STdeviceName}, is currently reading '${motionStat}'. " + } + else if (type =="lock") { + def currVal =STdevice.currentValue(type) + if (currVal ==~/locked|unlocked/) result = "The ${STdeviceName} is currently ${currVal}. " + else result = "The lock named, '${STdeviceName}', is currently in an unknown state. It may require manual intervention to resolve. %1%" + } + else if (type == "humidity"){ + if (STdevice.currentValue(type)) { + result = "The relative humidity at the ${STdeviceName} is ${STdevice.currentValue(type)}%" + if (otherStatus) { + def temp + if (STdevice.currentValue("temperature")) { + temp = roundValue(STdevice.currentValue("temperature")) + result += temp ? ", and the temperature is ${temp} degrees. " : ". " + } + else result = "The temperature of the ${STdeviceName} is reading null, which may indicate an issue with the device. %1%" + } + else result += ". " + } + else result ="The humidity reading of the '${STdeviceName}' is null, which may indicate an issue with the device. %1%" + } + else if (type ==~ /level|color|switch|kTemp/) { + def onOffStatus = STdevice.currentValue("switch") + result = "The ${STdeviceName} is ${onOffStatus}" + if (otherStatus) { + def level = STdevice.currentValue("level"), power = STdevice.currentValue("power") + result += onOffStatus == "on" && level ? ", and it's set to ${level}%" : "" + result += onOffStatus=="on" && power > 0 ? ", and is currently drawing ${power} watts of power. " : ". " + result += onOffStatus=="on" && type == "kTemp" ? "This light is set to " + STdevice.currentValue("colorTemperature") + " degrees Kelvin. " : "" + } + else result += ". " + } + else if (type == "thermostat"){ + def temp = roundValue(STdevice.currentValue("temperature")) + result = "The ${STdeviceName} temperature reading is currently ${temp} degrees" + if (otherStatus){ + def humidity = STdevice.currentValue("humidity"), opMode = STdevice.currentValue("thermostatMode") + def heat = opMode==~/heat|auto/ || stelproCMD ? STdevice.currentValue("heatingSetpoint") : "" + if (heat) heat = roundValue(heat) + def cool = opMode==~/cool|auto/ ? STdevice.currentValue("coolingSetpoint") : "" + if (cool) cool = roundValue(cool) + result += opMode ? ", and the thermostat's mode is: '${opMode}'. " : ". " + result += humidity ? " The relative humidity reading is ${humidity}%. " : "" + if (nestCMD && supportedCaps.name.contains("Presence Sensor")){ + result += " This thermostat's presence sensor is reading: " + result += STdevice.currentValue("presence")=="present" ? "'Home'. " : "'Away'. " + } + if ((ecobeeCMD && !MyEcobeeCMD) && STdevice.currentValue('currentProgramId') =~ /home|away|sleep/ ) result += " This thermostat's comfort setting is set to ${STdevice.currentValue('currentProgramId')}. " + if (MyEcobeeCMD && STdevice.currentValue('setClimate')) { + def climatename = STdevice.currentValue('setClimate'), climateList = STdevice.currentValue('climateList') + if (climateList.contains(climatename)) result += " This thermostat's comfort setting is set to ${climatename}. " + } + def opState = STdevice.currentValue("thermostatOperatingState") + if (opState) { + if ((opState=='fan only') || (opState=='vent economizer')) opState = 'running the ${opState}' // idle, heating, cooling, fan only, pending heat, pending cool or vent economizer + result += " The thermostat is currently ${opState}. " + } + result += heat ? " The heating setpoint is set to ${heat} degrees. " : "" + result += heat && cool ? "And finally, " : "" + result += cool ? " The cooling setpoint is set to ${cool} degrees. " : "" + + } + else result += ". " + } + else if (type == "contact") result = "The ${STdeviceName} is currently ${STdevice.currentValue(type)}. " + else if (type == "music"){ + def onOffStatus = STdevice.currentValue("status"), track = STdevice.currentValue("trackDescription"), level = STdevice.currentValue("level"), mute = STdevice.currentValue("mute") + result = "The ${STdeviceName} is currently ${onOffStatus}" + result += onOffStatus =="stopped" ? ". " : onOffStatus=="playing" && track ? ": '${track}'" : "" + result += onOffStatus == "playing" && level && mute =="unmuted" ? ", and it's volume is set to ${level}%. " : mute =="muted" ? ", and it's currently muted. " :"" + } + else if (type == "water") result = "The water sensor, '${STdeviceName}', is currently ${STdevice.currentValue(type)}. " + else if (type == "shade") result = "The window shade, '${STdeviceName}', is currently " + STdevice.currentValue('windowShade') +". " + else if (type == "uvIndex") { + def uvIndex = currValue<3 ? "low" : currValue < 6 && currValue > 2.9 ? "moderate" : + currValue < 8 && currValue > 5.9 ? "high" : currValue < 11 && currValue > 7.9 ? "very high" : + "extreme" + result = "The ${STdeviceName} is reading '${uvIndex}', with a UV index of ${STdevice.currentValue("ultravioletIndex")}. " + } + else result = "The ${STdeviceName} is currently ${STdevice.currentValue(type)}. " + } + else if (op =~/event/) { + def finalCount = num != 0 ? num as int : eventCt ? eventCt as int : 0 + if (finalCount>0 && finalCount < 10) result = getLastEvent(STdevice, finalCount, STdeviceName) + "%3%" + else if (!finalCount) { result = "You do not have the number of events you wish to hear specified in your Ask Alexa SmartApp, and you didn't specify a number in your request. %1%" } + else if (finalCount > 9) { result = "The maximum number of past events to list is nine. %1%" } + } + else { + if (type == "thermostat"){ + if (param =~/tip/ || op=~/tip/) { + if (MyEcobeeCMD && ecobeeCMD || (MyNextCMD)) { + if (op ==~/repeat|replay/) { + def currentTipNum= state.tipCount>0 ? state.tipCount : 1 + def previousTipNum= currentTipNum - 1 + def tipNum = previousTipNum>0 && previousTipNum <6 ? (previousTipNum) as int : 1 + def attribute="tip${tipNum}Text" + if (STdevice.currentValue(attribute)) result = "My Ecobee's current tip number ${tipNum} is '" + STdevice.currentValue(attribute) + "'. %2%" + else result ="Tip number is unavailable at this time for the ${STdeviceName}. Try erasing the tips, then issue the 'GET TIPS' command and try again. %1%" + } + else if (op==~/play|give/ || (op=="tip" && param==~/undefined|null/)) { + def tipNum= state.tipCount>0 ? state.tipCount : 1 + def attribute="tip${tipNum}Text" + if (!(STdevice.currentValue(attribute))) { + STdevice.getTips(1) + state.tipCount=1 + tipNum= 1 + attribute="tip${tipNum}Text" + } + if (STdevice.currentValue(attribute)) { + result = "My Ecobee's current tip number ${tipNum} is '" + STdevice.currentValue(attribute) + "'. %2%" + state.tipCount=state.tipCount+1 + } + else result ="Tip number is unavailable at this time for the ${STdeviceName}. Try erasing the tips, then issue the 'GET TIPS' command and try again. %1%" + } + else if (op ==~/get|load|reload/) { + def tipLevel = num>0 && num<5 ? num :1 + result = "I am loading level ${tipLevel} tips to ${STdeviceName}. %2%" + STdevice.getTips(tipLevel) + state.tipCount=1 + } + else if (op ==~/restart|erase|delete|clear|reset/) { + result = "I am resetting the tips from ${STdeviceName}. Be sure to ask for the 'GET TIP' command to reload your thermostat advice. %2%" + STdevice.resetTips() + state.tipCount=1 + } + else result = "I did not understand what you wanted me to do with the Ecobee tips. Valid commands are 'get', 'give', 'repeat' or 'erase' tips. %1%" + } + else result = "You do not have the Ecobee tips functionality enabled in your Ask Alexa SmartApp. %1%" + } + else { + if (param ==~/undefined|null/) param = tstatCool ? "cool" : tstatHeat ? "heat" : param + if ((op ==~/increase|raise|up|decrease|down|lower/)){ + def newValues = upDown(STdevice, type, op, num, STdeviceName) + num = newValues.newLevel + } + if (num>0) { + if (tstatHighLimit) num = num <= (tstatHighLimit as int) ? num : tstatHighLimit as int + if (tstatLowLimit) num = num >= (tstatLowLimit as int) ? num : tstatLowLimit as int + } + if (op =="maximum" && tstatHighLimit) num = tstatHighLimit as int + if (op =="minimum" && tstatLowLimit) num = tstatLowLimit as int + def ecobeeCustomRegEx = MyEcobeeCMD && ecobeeCMD ? getEcobeeCustomRegEx(STdevice) : null + if ((param==~/heat|heating|cool|cooling|auto|automatic|eco|AC|comfort|home|away|sleep|resume program/ || (ecobeeCustomRegEx && param =~ /${ecobeeCustomRegEx}/)) && num == 0 && op==~/undefined|null/) op="on" + if (op ==~/on|off/) { + if (param ==~/undefined|null/ && op == "on") result="You must designate 'heating mode' or 'cooling mode' when turning the ${STdeviceName} on. %1%" + if (param =~/heat/) {result="I am setting the ${STdeviceName} to 'heating' mode. "; STdevice.heat()} + if (param =~/cool|AC/) {result="I am setting the ${STdeviceName} to 'cooling' mode. "; STdevice.cool()} + if (param =~/auto/) {result="I am setting the ${STdeviceName} to 'auto' mode. Please note, to properly set the temperature in 'auto' mode, you must specify the heating or cooling setpoints separately. " ; STdevice.auto()} + if (param ==~/home|present/ && nestCMD) {result = "I am setting the ${STdeviceName} to 'home'. "; STdevice.present()} + if (param =="away" && nestCMD) {result = "I am setting the ${STdeviceName} to 'away'. Please note that Nest thermostats will not respond to temperature changes while in 'away' status. "; STdevice.away()} + if ((param ==~/home|away|sleep/ || (ecobeeCustomRegEx && param =~ /${ecobeeCustomRegEx}/)) && ecobeeCMD) { + result = "I am setting the ${STdeviceName} to '" + param + "'. " + if (STdevice.hasCommand("setThermostatProgram")) STdevice.setThermostatProgram("${param.capitalize()}") + else if (STdevice.hasCommand("setClimate")) STdevice.setThisTstatClimate("${param.capitalize()}") + else result ="There was an error setting your climate. %1%" + } + if (param =="resume program" && ecobeeCMD && !MyEcobeeCMD) {result = "I am resuming the climate program of the ${STdeviceName}. "; STdevice.resumeProgram()} + if (param =="resume program" && ecobeeCMD && MyEcobeeCMD) {result = "I am resuming the climate program of the ${STdeviceName}. "; STdevice.resumeThisTstat()} + if (op =="off") { result = "I am turning the ${STdeviceName} ${op}. "; STdevice.off() } + if (stelproCMD && param==~/eco|comfort/) { result="I am setting the ${STdeviceName} to '${param}' mode. "; STdevice.setThermostatMode("${param}") } + } + else if (op=~"report") { + if (nestCMD && nestMGRCMD) { STdevice.updateNestReportData(); result = STdevice.currentValue("nestReportData").toString() +" %2%" } + if (!nestCMD || (nestCMD && !nestMGRCMD) ) result ="The 'report' command is reserved for Nest Thermostats using the NST Manager SmartApp. " + + "You do not have the options enabled to use this command. Please check your settings within your smartapp. %1%" + if (result=="null") result = "The NST Manager returned no results for the ${STdeviceName}. Please check your settings within your smartapp. %1%" + } + else { + if (param ==~/undefined|null/ ){ + if (STdevice.currentValue("thermostatMode")=="heat") param = "heat" + else if (STdevice.currentValue("thermostatMode")=="cool") param = "cool" + else result = "You must designate a 'heating' or 'cooling' parameter when setting the temperature. The thermostat will not accept a generic setpoint in its current mode. "+ + "For example, you could simply say, 'ask ${invocationName} to set the ${STdeviceName} heating to 65 degrees'. %1%" + } + if ((op =="maximum" && !tstatHighLimit) || (op =="minimum" && !tstatLowLimit)) { + result = "You do not have a ${op} thermostat setpoint defined within your Ask Alexa SmartApp. %1%" + param = "undefined" + } + if ((param =~/heat/) && num > 0) { + result="I am setting the heating setpoint of the ${STdeviceName} to ${num} degrees. " + STdevice.setHeatingSetpoint(num) + try { if (stelproCMD) STdevice.applyNow() } + catch (e){ log.warn "An error was encountered when attempting to send the 'applyNow()' command to the '${STdeviceName}'. If you don't have any stelPro devices please disable this feature in the thermostat selection area."} + } + if ((param =~/cool|AC/) && num > 0) { + result="I am setting the cooling setpoint of the ${STdeviceName} to ${num} degrees. " + STdevice.setCoolingSetpoint(num) + } + if (param != "undefined" && param != "null" && tstatHighLimit && num >= (tstatHighLimit as int)) result += "This is the maximum temperature I can set for this device. %1%" + if (param != "undefined" && param != "null" && tstatLowLimit && num <= (tstatLowLimit as int)) result += "This is the minimum temperature I can set for this device. %1%" + } + } + } + if (type ==~ /color|level|switch|kTemp/){ + num = num < 0 ? 0 : num >99 ? 100 : num + def overRideMsg = "" + if (op == "maximum") num = 100 + if ((op ==~/increase|raise|up|decrease|down|lower|brighten|dim/) && (type ==~ /color|level|kTemp/)){ + def newValues = upDown(STdevice, type, op, num, STdeviceName) + num = newValues.newLevel + op= num > 0 ? "on" : "off" + overRideMsg = newValues.msg + } + if (op ==~/low|medium|high/ && type ==~ /color|level|kTemp/){ + if (op=="low" && dimmerLow) num = dimmerLow else if (op=="low" && dimmerLow=="") num =0 + if (op=="medium" && dimmerMed) num = dimmerMed else if (op=="medium" && !dimmerMed) num = 0 + if (op=="high" && dimmerHigh) num = dimmerHigh else if (op=="high" && !dimmerhigh) num = 0 + if (num>0) overRideMsg = "I am turning the ${STdeviceName} to ${op}, or a value of ${num}%. " + if (num==0) overRideMsg = "You don't have a default value set up for the '${op}' level. I am not making any changes to the ${STdeviceName}. %1%" + } + if ((type == "switch") || (type ==~ /color|level|kTemp/ && num==0 )){ + if (type ==~ /color|level|kTemp/ && num==0 && op==~/undefined|null/ && param==~/undefined|null/ ) op="off" + if (op==~/on|off/){ + STdevice."$op"() + result = overRideMsg ? overRideMsg: "I am turning the ${STdeviceName} ${op}. " + } + if (op=="toggle") { + def oldstate = STdevice.currentValue("switch"), newstate = oldstate == "off" ? "on" : "off" + STdevice."$newstate"() + result = "I am toggling the ${STdeviceName} from '${oldstate}' to '${newstate}'. " + } + } + if (type ==~ /color|level|kTemp/ && num > 0) { + STdevice.setLevel(num) + result = overRideMsg ? overRideMsg : num==100 ? "I am setting the ${STdeviceName} to its maximum value. " : "I am setting the ${STdeviceName} to ${num}%. " + } + if (type == "color" && param !="undefined" && param !="null" && supportedCaps.name.contains("Color Control")){ + def getColorData = STColors().find {it.name.toLowerCase()==param} + if (getColorData){ + def hueColor = Math.round (getColorData.h / 3.6), satLevel = getColorData.s,newLevel = num > 0 ? num : STdevice.currentValue("level") + def newValue = [hue: hueColor as int, saturation: satLevel as int] + STdevice?.setColor(newValue) + STdevice?.setLevel(newLevel) + result = "I am setting the color of the ${STdeviceName} to ${param}" + result += num>0 ? ", at a brightness level of ${num}%. " : ". " + } + } + if (type == "kTemp" && param !="undefined" && param !="null" && supportedCaps.name.contains("Color Temperature")){ + def sWhite = kSoftWhite ? kSoftWhite as int : 2700, wWhite= kWarmWhite ? kWarmWhite as int: 3500, cWhite= kCoolWhite ? kCoolWhite as int: 4500, dWhite= kDayWhite ? kDayWhite as int: 6500 + def kelvin = param=="soft white" ? sWhite : param=="warm white" ? wWhite : param=="cool white" ? cWhite : param=="daylight white" ? dWhite : 9999 + if (kelvin <9999){ + STdevice?.setColorTemperature(kelvin) + result = "I am setting the temperature of the ${STdeviceName} to ${param}, or ${kelvin} degrees Kelvin" + result += num>0 ? ", at a brightness level of ${num}%. " : ". " + } + else result = "I didn't understand the temperature you wanted to set the ${STdeviceName}. Valid temperatures are soft white, warm white, cool white and daylight white. %1%" + } + if (!result){ + if (type=="switch") result = "For the ${STdeviceName} switch, be sure to give an 'on', 'off' or 'toggle' command. %1%" + if (type=="level") result = overRideMsg ? overRideMsg: "For the ${STdeviceName} dimmer, the valid commands are: " + getList(vocab) + " or brightness a level setting. %1%" + if (type=="color") result = overRideMsg ? overRideMsg: "For the ${STdeviceName} color controller, remember it can be operated like a switch. You can ask me to turn it on, off, toggle "+ + "the on and off states, or set a brightness level. You can also set it to a variety of colors. For a listing of these colors, simply print out the Ask Alexa cheat sheet. %1%" + } + } + if (type == "music"){ + if ((op ==~/increase|raise|up|decrease|down|lower/)){ + def newValues = upDown(STdevice, type, op, num,STdeviceName) + num = newValues.newLevel + if (num==0) op= "off" + } + if ((num != 0 && speakerHighLimit && num > speakerHighLimit)|| (op=="maximum" && speakerHighLimit)) num = speakerHighLimit + if (op==~/off|stop/) { STdevice.stop(); result = "I am turning off the ${STdeviceName}. " } + else if (op ==~/play|on/ && param==~/undefined|null/) { + STdevice.play() + result = "I am playing the ${STdeviceName}. " + } + else if (op==~/mute|unmute|pause/) {STdevice."$op"(); result = "I am ${op[0..-2]}ing the ${STdeviceName}. " } + else if (op=="next track") { STdevice.nextTrack(); result = "I am playing the next track on the ${STdeviceName}. " } + else if (op=="previous track") { STdevice.previousTrack(); result = "I am playing the previous track on the ${STdeviceName}. " } + else result = "For the ${STdeviceName}, valid commands include 'play','pause','mute'. %1%" + if (num > 0) { STdevice.setLevel(num); result = "I am setting the volume of the ${STdeviceName} to ${num}%. " } + if (speakerHighLimit && num == speakerHighLimit) result += "This is the maximum volume level you have set up. %1%" + if (op=="maximum" && !speakerHighLimit) result = "You have not set a maximum volume level in the Ask Alexa SmartApp. %1%" + } + if (type=~/door|lock|shade/){ + if ((type == "door" && !doorVoc().find{it==op}) || (type == "shade" && !shadeVoc().find{it==op})) result= "For the ${STdeviceName}, you must give an 'open' or 'close' command. %1%" + else if (type == "lock" && !lockVoc().find{it==op}) result= "For the ${STdeviceName}, you must give a 'lock' or 'unlock' command. %1%" + else if (type == "shade" && STdevice.currentValue("windowShade")==op || STdevice.currentValue("windowShade")=="closed" && op=="close"){ + result = "The ${STdeviceName} is already ${currentShadeState}. %1%" + } + else { + STdevice."$op"() + def verb = op=="close" && type==~/shade|door/ ? "clos" : op + result = "I am ${verb}ing the ${STdeviceName}. " + } + } + if (type == "presence" && vPresenceCMD){ + def currentState = STdevice.currentValue(type) + if (((op=="on" || param=~/home|present|check in|checkin|arrive/) && currentState=="present") || ((op=="off" || param=~/away|not present|check out|checkout|depart|gone/) && currentState=="not present")) result = "The '${STdeviceName}' presence sensor is already set to '${currentState}'. %1%" + else if ((op=="on" || param=~/home|present|check in|arrive/) && (currentState=="not present" || !currentState)) { + if (supportedCMDs.name.contains("present")) STdevice.present() + if (supportedCMDs.name.contains("arrived")) STdevice.arrived() + result = (supportedCMDs.find{it.name==~/present|arrived/}) ? "I am setting the presence of, '${STdeviceName}, to 'present'. ":"" + } + else if ((op=="off" || param=~/away|not present|check out|checkout|depart|gone/) && (currentState=="present" || !currentState)) { + if (supportedCMDs.name.contains("away")) STdevice.away() + if (supportedCMDs.name.contains("departed")) STdevice.departed() + result = (supportedCMDs.find{it.name==~/away|departed/}) ? "I am setting the presence of, '${STdeviceName}, to 'away'. " :"" + } + } + if (type == "beacon"){ + if (op==~/asleep|vacant|locked|engaged|occupied/) { + STdevice."${op}"() + result = "I am setting the occupancy sensor, '${STdeviceName}', to '${op}'. " + } + else result ="I don't understand what you want me to do with the occupancy sensor, '${STdeviceName}'. Valid commands are: status, asleep, vacant, locked, engaged and occupied. %1%" + } + } + if (otherStatus && op=="status"){ + def temp = STdevice.currentValue("temperature"), accel=STdevice.currentValue("acceleration"), motion=STdevice.currentValue("motion"), lux =STdevice.currentValue("illuminance"), + uv=STdevice.currentValue("ultravioletIndex"), pressure=STdevice.currentPressure, humidity=STdevice.currentValue("humidity"), tamper=STdevice.currentValue("tamper") + result += lux != null ? "The illuminance at this device's location is ${lux} lux. " : "" + if (uv !=null && type !="uvIndex") result += "The UV Index at this device's location is '${uvIndexReading(uv)}', with a reading of ${uv}. " + if (pressure) result += "This device is also reading a barometric pressure of ${pressure}. " + if (humidity != null && !(type ==~/humidity|temperature|thermostat|pollution/)) result +="And the relative humidity at this location is ${humidity}%. " + result += temp && type != "thermostat" && type != "humidity" && type != "temperature" ? "In addition, the temperature reading from this device is ${roundValue(temp)} degrees. " : "" + result += motion == "active" && type != "motion" ? "This device is also a motion sensor, and it is currently reading movement. " : "" + result += accel == "active" ? "This device has a vibration sensor, and it is currently reading movement. " : "" + result += tamper == "detected" ? "This device is also a tamper detector and it is reading active. " : "" + } + if (healthWarn && STdevice.status=="OFFLINE") result +="This device's status is reporting offline. " + if (supportedCaps.name.contains("Battery") && batteryWarn){ + def battery = STdevice.currentValue("battery"), battThresLevel = batteryThres as int + if (battery && battery < battThresLevel) batteryWarnTxt += "Please note, the battery in this device is at ${battery}%. " + else if (battery !=0 && battery == null) batteryWarnTxt += "Please note, the battery in this device is reading null, which may indicate an issue with the device. " + else if (battery < 1) batteryWarnTxt += "Please note, the battery in this device is reading ${battery}%; time to change the battery. " + } + if (op !="status" && !result && type==~/motion|presence|humidity|water|contact|temperature/) result = "You attempted to take action on a device that can only give a status reading. %1%" + } + catch (e){ result = "I could not process your request for the '${STdeviceName}'. Ensure you are using the correct commands with the device. %1%" } + if (op=="status" && result && !result.endsWith("%")) result += batteryWarnTxt + "%2%" + else if (op!="status" && result && !result.endsWith("%")) result += batteryWarnTxt + "%3%" + if (result.endsWith("%3%") && briefReply) { + def reply = briefReplyTxt && briefReplyTxt !="No reply spoken" ? briefReplyTxt : "" + if (briefReplyTxt=="User-defined") reply = briefReplyCustom?:"No custom response entered in the Ask Alexa SmartApp. " + result = reply ? reply + ". " + batteryWarnTxt + "%7%" : batteryWarnTxt + "%7%" + } + if (!result) result = "I had a problem understanding your request. %1%" + return result +} +def displayData(display){ + render contentType: "text/html", data: """${display}""" +} +def displayRaw(display){ + render contentType: "text/html", data: "${display}" +} +def displayMiniHTML(display){ + render contentType: "text/html", data: "${display}" +} +//Child code pieces here---Macro Handler------------------------------------- +def macroResults(num, cmd, colorData, param, mNum,xParam,echoID){ + String result="" + if (macroType == "Control") result = controlResults(num) + if (macroType == "CoRE") result = WebCoREResults(num, xParam) + def data = result ? result.endsWith("%") ? [alexaOutput: result[0..-4]] : [alexaOutput: result] : [alexaOutput: "No Output"] + sendLocationEvent(name: "askAlexa", value: app.id, data: data, displayed: true, isStateChange: true, descriptionText: "Ask Alexa activated '${app.label}' macro.") + return result +} +def macroSwitchHandler(evt){ + def xParam= triggerXParam && macroType=="CoRE" ? triggerXParam.toLowerCase() : "" + def mNum = triggermNum && macroType=="CoRE" ? triggermNum as int : 0 + parent.processMacroAction(app.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""), mNum, 0, false, xParam,"undefined") +} +//WebCoRE Handler----------------------------------------------------------- +def WebCoREResults(sDelay,xParam){ + String result = "" + if (sendmNum && sDelay > 0){ + state.mNum=sDelay + if (xferMNum) xParam=sDelay + sDelay=0 + } + def delay + state.xParam = !(xParam==~/undefined|\?|null/) ? xParam : "" + def logOutput = state.xParam || state.mNum ? " with the extra parameter" : "" + logOutput += state.xParam && !state.mNum ? ": ${state.xParam}" : !state.xParam && state.mNum ? ": a number value of ${state.mNum}" : state.xParam && state.mNum ? "s: ${state.xParam} and a number value of ${state.mNum}" :"" + if (cDelay>0 || sDelay>0) delay = sDelay==0 ? cDelay as int : sDelay as int + if (!advWebCore || (advWebCore && (state.xParam || state.mNum))){ + result = (!delay || delay == 0) ? "I am triggering the WEBCORE macro named '${app.label}'${logOutput}. " : delay==1 ? "I'll trigger the '${app.label}' WEBCORE macro in ${delay} minute. " : "I'll trigger the '${app.label}' WEBCORE macro in ${delay} minutes. " + if (sDelay == 9999) { + result = "I am cancelling all scheduled executions of the WEBCORE macro, '${app.label}'. " + state.scheduled = false + unschedule() + } + if (!state.scheduled) { + def xParamVar = state.xParam + if (!delay || delay == 0) WebCoREHandler() + else if (delay < 9999) { runIn(delay*60, WebCoREHandler, [overwrite: true]) ; state.scheduled=true } + if (delay < 9999) result = voicePost && !noAck ? parent.replaceVoiceVar(voicePost, delay,"",macroType,app.label, 0, xParamVar) : noAck ? " " : result + } + else result = "The WEBCORE macro, '${app.label}', is already scheduled to run. You must cancel the execution or wait until it runs before you can run it again. %1%" + } + else { + if (advWebCore && voicePostAdv) result = parent.replaceVoiceVar(voicePostAdv, delay,"",macroType,app.label, 0, xParamVar) + else result = "I did not hear the required additional parameters required to execute the macro, '${app.label}'. The execution was aborted. %1%" + } + return result +} +def WebCoREHandler(){ + state.scheduled = false + def mNum = state.mNum ? state.mNum as int : 0 + def data = state.xParam ? [mName: app.label, mNum: mNum, xParam : state.xParam] : [mName: app.label, mNum: mNum, xParam : null] + parent.webCoRE_execute(CoREName,data) + state.xParam="" + state.mNum="" +} +//Control Handler----------------------------------------------------------- +def controlResults(sDelay){ + String result = "" + def delay + if (cDelay>0 || sDelay>0) delay = sDelay==0 ? cDelay as int : sDelay as int + if (macroTypeDesc() !="Status: UNCONFIGURED - Tap to configure macro"){ + result = (!delay || delay == 0) ? "I am running the '${app.label}' control macro. " : delay==1 ? "I'll run the '${app.label}' control macro in ${delay} minute. " : "I'll run the '${app.label}' control macro in ${delay} minutes. " + if (sDelay == 9999) { + result = "I am cancelling all scheduled executions of the control macro, '${app.label}'. " + state.scheduled = false + unschedule() + } + if (!state.scheduled) { + if (!delay || delay == 0) controlHandler() + else if (delay < 9999) { runIn(delay*60, controlHandler, [overwrite: true]) ; state.scheduled=true } + if (delay < 9999) result = voicePost && !noAck ? parent.replaceVoiceVar(voicePost, delay,"",macroType,app.label,0,"") : noAck ? "" : result + } + else result = "The control macro, '${app.label}', is already scheduled to run. You must cancel the execution or wait until it runs before you can run it again. %1%" + } + else result="The control macro, '${app.label}', is not properly configured. Use your Ask Alexa SmartApp to configure the macro. %1%" + return result +} +def controlHandler(){ + state.scheduled = false + def cmd = [switch: switchesCMD, dimmer: dimmersCMD, cLight: cLightsCMD, cLightK: cLightsKCMD, tstat: tstatsCMD, lock: locksCMD, garage: garagesCMD, shade: shadesCMD] + if (phrase) location.helloHome.execute(phrase) + if (setMode && location.mode != setMode) { + if (location.modes?.find{it.name == setMode}) setLocationMode(setMode) + else log.warn "Unable to change to undefined mode '${setMode}'" + } + if (switches && cmd.switch) cmd.switch == "toggle" ? toggleState(switches) : switches?."${cmd.switch}"() + if (dimmers && cmd.dimmer){ + if (cmd.dimmer == "set"){ + def level = dimmersLVL < 0 || !dimmersLVL ? 0 : dimmersLVL >100 ? 100 : dimmersLVL as int + dimmers?.setLevel(level) + } + else if (cmd.dimmer ==~/increase|decrease/) dimmers.each {upDownChild(it, cmd.dimmer, dimmersUpDown as int, "level")} + else cmd.dimmer == "toggle" ? toggleState(dimmers) : dimmers?."${cmd.dimmer}"() + } + if (cLights && cmd.cLight){ + if (cmd.cLight == "set"){ + if (cLightsCLR || cLightsLVL) { + def level = !cLightsLVL || cLightsLVL < 0 ? 0 : cLightsLVL >100 ? 100 : cLightsLVL as int + cLightsCLR ? parent.setColoredLights(cLights, cLightsCLR, level) : cLights?.setLevel(level) + } + } + else if (cmd.cLight ==~/increase|decrease/) cLights.each {upDownChild(it, cmd.cLight, cLightsUpDown as int, "level")} + else if (cmd.cLight == "toggle") toggleState(cLights) + else cLights?."${cmd.cLight}"() + } + if (cLightsK && cmd.cLightK){ + if (cmd.cLightK == "set"){ + if (cLightsKEL || cLightsKLVL) { + def level = !cLightsKLVL || cLightsKLVL < 0 ? 0 : cLightsKLVL >100 ? 100 : cLightsKLVL as int + if (cLightsKEL) cLightsK?.setColorTemperature(cLightsKEL as int) + if (level>0) cLightsK?.setLevel(level) + } + } + else if (cmd.cLightK ==~/increase|decrease/) cLightsK.each {upDownChild(it, cmd.cLightK, cLightsKUpDown as int, "level")} + else if (cmd.cLightK == "toggle") toggleState(cLightsK) + else cLightsK?."${cmd.cLightK}"() + } + if (locks && cmd.lock) locks?."${cmd.lock}"() + if (tstats && cmd.tstat){ + if ((cmd.tstat == "heat" || cmd.tstat == "cool") && tstatLVL) { + def tLevel = tstatLVL < 0 ? 0 : tstatLVL > 100 ? 100 : tstatLVL as int + cmd.tstat == "heat" ? tstats?.setHeatingSetpoint(tLevel) : tstats?.setCoolingSetpoint(tLevel) + } + else if (cmd.tstat=~/increase|decrease/) tstats.each {upDownChild(it, cmd.tstat, tstatUpDown as int, "temperature")} + def ecobeeCustomRegEx = parent.MyEcobeeCMD && parent.ecobeeCMD ? getEcobeeCustomRegEx(tstats) : null + if (cmd.tstat =~ /present|away|sleep|resumeProgram/ || (ecobeeCustomRegEx && cmd.tstat =~ /${ecobeeCustomRegEx}/)) tstats?."${cmd.tstat}"() + } + if (garages && cmd.garage) garages?."${cmd.garage}"() + if (shades && cmd.shade) shades?."${cmd.shade}"() + if (occupancyMacro && occupancyCMD) occupancyMacro.each{it."${occupancyCMD}"()} + if (extInt == "0" && http) httpGet(http) + if (extInt == "1" && ip && port && command){ + String hexIP = ip.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join() + String hexPort = port.toString().format( '%04x', port.toInteger() ) + def deviceHexID = hexIP +":"+ hexPort + log.info "Device Network Id set to ${deviceHexID}" + sendHubCommand(new physicalgraph.device.HubAction("""GET /${command} HTTP/1.1\r\nHOST: ${ip}:${port}\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceHexID}")) + } + if (SHM) sendLocationEvent(name: "alarmSystemStatus", value: SHM) + def data = [args: "I am activating the Control Macro: '${app.label}'."] + sendLocationEvent(name: "askAlexa", value: app.id, data: data, displayed: true, isStateChange: true, descriptionText: "Ask Alexa activated '${app.label}' macro.") + if (ctlMsgQue){ + def expireMin=ctlMQExpire ? ctlMQExpire as int : 0, expireSec=expireMin*60 + def overWrite =!ctlMQNotify && !ctlMQExpire && ctlMQOverwrite + def msgTxt = ttsMsg?: "Ask Alexa activated Control Macro: '${app.label}'." + sendLocationEvent( + name: "AskAlexaMsgQueue", + value: "Ask Alexa Control Macro, '${app.label}'", + unit: "${app.id}", + isStateChange: true, + descriptionText: msgTxt, + data:[ + queues:ctlMsgQue, + overwrite: overWrite, + notifyOnly: ctlMQNotify, + expires: expireSec, + suppressTimeDate:ctlSuppressTD + ] + ) + } +} +private getEcobeeCustomRegEx(myEcobeeGroup){ + def myCustomClimate = "" + try { + getEcobeeCustomList(myEcobeeGroup).each { myCustomClimate += "|${it}" } + myCustomClimate = myCustomClimate[1..myCustomClimate.length() - 1] + } + catch (e){ log.warn "An error was encountered when attempting to send commands to the '${myEcobeeGroup}'. If you don't have any Ecobee devices please disable these features in the thermostat selection area."} + return myCustomClimate +} +//Parent Code Access (from Child)----------------------------------------------------------- +def cLightsKCTLOptions(){ + return ["on":"Turn on","off":"Turn off","set":"Set temperature and level", "toggle":"Toggle the lights' on/off state","decrease": "Decrease current brightness","increase": "Increase current brightness"] +} +def cLightsCTLOptions(){ + def options=["on":"Turn on","off":"Turn off","set":"Set color and level", "toggle":"Toggle the lights' on/off state","decrease": "Decrease current brightness","increase": "Increase current brightness"] + if (osramCMD) options +=["loopOn":"Turn on color loop","loopOff":"Turn off color loop","pulseOn":"Turn on pulse","pulseOff":"Turn off pulse" ] + return options +} +def tStatCTLOptions(){ + def tstatOptions=["heat":"Set heating temperature","cool":"Set cooling temperature","increaseHeat":"Increase current heating setpoint","decreaseHeat":"Decrease current heating setpoint","increaseCool":"Increase current cooling setpoint", "decreaseCool":"Decrease current cooling setpoint"] + if (nestCMD) tstatOptions += ["away":"Nest 'Away' Presence","present":"Nest 'Home' Presence"] + if (ecobeeCMD) tstatOptions += ["away":"Ecobee 'Away' Climate","home":"Ecobee 'Home' Climate","sleep":"Ecobee 'Sleep' Climate","resumeProgram":"Ecobee 'Resume Program'"] + if (MyEcobeeCMD) getEcobeeCustomList(tstats).each { tstatOptions += ["${it}":"Ecobee '${it}' Climate"] } + return tstatOptions +} +//Common Code (Child and Parent) +private dayOfWeek(){ return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] } +def getRmLists(Map rmList=[:]){ + if (getRM().size()) getRM().each {roomName-> + def nameList=roomName.getEchoAliasList() + nameList.each { rmList[it]="${roomName.label} (${it[-10..-1]})"} + } + return rmList +} +def rmCheck(curList){ + def rmList=getRmLists() + if (curList) curList.each{echoID-> + def findRoom = 0 + rmList.each{if (it.key==echoID) findRoom++ } + if (!findRoom) rmList<<["${echoID}":"**Invalid Room** - REMOVE"] + } + return rmList +} +def doRmCheck(echoID){ + def rmName="" + if (getRM().size()) getRM().each {roomName-> + def nameList=roomName.getEchoAliasList() + nameList.each {if (echoID==it) rmName=roomName.label } + } + return rmName ?: false +} +def uvIndexReading(uvValue){ + def uv = uvValue as int + return uv<3 ? "low" : uv < 6 && uv > 2.9 ? "moderate" : uv < 8 && uv > 5.9 ? "high" : uv < 11 && uv > 7.9 ? "very high" : "extreme" +} +def mqRefresh(evt){ sendLocationEvent(name: "askAlexaMQ", value: "refresh", data: [queues: getMQListID(false)] , isStateChange: true, descriptionText: "Ask Alexa message queue list refresh") } +def childQDelete(qList){ + qList.each{qID-> + def qNameRun = getAAMQ().find{it.id == qID} + if (qNameRun) { qNameRun.qDelete() } + } +} +def qDelete(){ + def deleteList = state.msgQueue.findAll{it.trackDelete} + state.msgQueue=[] + if (deleteList){ + deleteList.each{ + sendLocationEvent(name:"askAlexaMQ", value: "${it.appName}.${it.id}",isStateChange: true, data:[[deleteType: "delete all"],[queue:"Primary message queue"]], descriptionText:"Ask Alexa deleted all messages from the Primary message queue") + } + } + if (msgQueueNotifyLightsOn && msgQueueNotifyLightsOff) msgQueueNotifyLightsOn?.off() + if (msgQueueNotifycLightsOn && msgQueueNotifyLightsOff) msgQueueNotifycLightsOn?.off() +} +def getOkToRun(){ + def result = (!runMode || runMode.contains(location.mode)) && getDayOk(runDay) && getTimeOk(timeStart,timeEnd) && getPeopleOk(runPeople,runPresAll) && switchesOnStatus(runSwitchActive) && switchesOffStatus(runSwitchNotActive) +} +def getOkEcho(echoID) { return !runEcho || runEcho.contains(echoID) } +def getOkPIN(){ + def result = (!pinMode || pinMode.contains(location.mode)) && getDayOk(pinDay) && getTimeOk(timeStartPIN,timeEndPIN) && getPeopleOk(pinPeople,pinPresAll) && switchesOnStatus(pinSwitchActive) && switchesOffStatus(pinSwitchNotActive) +} +def getOkEchoPIN(echoID) { return !pinEcho || pinEcho.contains(echoID) } +def getOkToRunMute(echoID){ + def result = (!runModeMute || runModeMute.contains(location.mode)) && getDayOk(runDayMute) && (!runEchoMute || runEchoMute.contains(echoID)) && getTimeOk(timeStartMute,timeEndMute) && getPeopleOk(runPeopleMute,runPresAllMute) && switchesOnStatus(runSwitchActiveMute) && switchesOffStatus(runSwitchNotActiveMute) +} +private switchesOnStatus(swGroup){ return swGroup && swGroup.find{it.currentValue("switch") == "off"} ? false : true } +private switchesOffStatus(swGroup){ return swGroup && swGroup.find{it.currentValue("switch") == "on"} ? false : true } +def battOptions() { return [5:"<5%",10:"<10%",20:"<20%",30:"<30%",40:"<40%",50:"<50%",60:"<60%",70:"<70%",80:"<80%",90:"<90%",101:"Always play battery level"] } +def kelvinOptions(){ return ["${parent.kSoftWhite}" : "Soft White (${parent.kSoftWhite}K)", "${parent.kWarmWhite}" : "Warm White (${parent.kWarmWhite}K)", + "${parent.kCoolWhite}": "Cool White (${parent.kCoolWhite}K)", "${parent.kDayWhite}" : "Daylight White (${parent.kDayWhite}K)"] +} +def imgURL() { return "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/img/" } +def getAskAlexa(){ return findAllChildAppsByNamespaceAndName("MichaelStruck", "Ask Alexa") } +def getAAMQ() { return findAllChildAppsByNamespaceAndName("MichaelStruck", "Ask Alexa Message Queue") } +def getSCHD() { return findAllChildAppsByNamespaceAndName("MichaelStruck", "Ask Alexa Schedule") } +def getWR() { return findAllChildAppsByNamespaceAndName("MichaelStruck", "Ask Alexa Weather Report") } +def getVR() { return findAllChildAppsByNamespaceAndName("MichaelStruck", "Ask Alexa Voice Report") } +def getRM() { return findAllChildAppsByNamespaceAndName("MichaelStruck", "Ask Alexa Rooms/Groups") } +def macAliasCount() { return 3 } +def getList(items){ + def result = "", itemCount=items.size() as int + items.each{ result += it; itemCount -- + result += itemCount>1 ? ", " : itemCount==1 ? " and " : "" + } + return result +} +void nestCmdPrep(dev) { + try { if(dev.currentValue("devTypeVer") != null) { return } else { dev.poll() } } + catch(e) { log.error "nestCmdPrep Exception: $e" } +} +private roundValue(num){ + def result + if (location.temperatureScale == "C") { + String n = num as String + if (n.endsWith(".0")) n = n - ".0" + result=n + } + else result = Math.round(num) + return result +} +private formatURL(url){ return url.replaceAll(/\s/,"%20") } +private getEcobeeCustomList(myEcobeeGroup){ + def myEcobeeList = [] + myEcobeeGroup.each {myTstat -> + if (myTstat.currentValue("climateList")) { + def myCustomClimateList = myTstat.currentValue("climateList").toString().minus('[').minus(']').minus('Away').minus('Home').minus('Sleep').tokenize(',').unique() + myCustomClimateList.each { myEcobeeList += [ it.toLowerCase() ].unique() } + } + } + return myEcobeeList +} +def sendMSG(num, msg, push, recipients){ + if (location.contactBookEnabled && recipients) sendNotificationToContacts(msg, recipients) + else { + if (num) {sendSmsMessage(num,"${msg}")} + if (push) {sendPushMessage("${msg}")} + } +} +def timeDate(dateNum){ + def today = new Date(now()).format("EEEE, MMMM d, yyyy", location.timeZone) + def msgDay = new Date(dateNum).format("EEEE, MMMM d, yyyy", location.timeZone) + def voiceDay = today == msgDay ? "Today" : msgDay + def msgTime = new Date(dateNum).format("h:mm aa", location.timeZone) + return ["msgTime": msgTime, "msgDay": voiceDay] +} +def soundFXList(){ + return [1:"Radio Announcer", 2:"Dr. Evil", 3:"George Carlin", 4:"Hal", 5:"Worf",6:"Pac Man",7:"R2-D2",8:"Yoda",9:"AOL",10:"Message Tone 1",11:"Message Tone 2",12:"Message Tone 3",13:"Message Tone 4","custom":"Custom-User Defined"] +} +def sfxLookup(sfx){ + def result + if (sfx=="1") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/checkyourmailbox.mp3", duration:"6"] + else if (sfx=="2") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/dr-evil-youve-got-freakin-mail.mp3", duration:"2"] + else if (sfx=="3") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/georgecarlin.mp3", duration:"3"] + else if (sfx=="4") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/hal2001.mp3", duration:"2"] + else if (sfx=="5") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/MAILWORF.mp3", duration:"4"] + else if (sfx=="6") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/pacman.mp3", duration:"5"] + else if (sfx=="7") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/R2D2-yeah.mp3", duration:"2"] + else if (sfx=="8") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/yoda-message-from-the-darkside.mp3", duration:"4"] + else if (sfx=="9") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/youve-got-mail-sound.mp3", duration:"2"] + else if (sfx=="10") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/Tone1.mp3", duration:"2"] + else if (sfx=="11") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/Tone2.mp3", duration:"4"] + else if (sfx=="12") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/Tone4.mp3", duration:"2"] + else if (sfx=="13") result = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/media/Tone4.mp3", duration:"3"] + else if (sfx=="custom") result = [uri:"${mqAlertCustom}",duration:"10"] + return result +} +def voiceList(){ + return ["Amy":"Amy (British)", "Brian":"Brian (British)", "Emma":"Emma (British)","Geraint":"Geraint (Welsh)","Ivy":"Ivy (American)","Justin":"Justin (American)", + "Kimberly":"Kimberly (American)", "Nicole":"Nicole (Australian)", "Raveena":"Raveena (Indian)", "Russell": "Russell(Australian)", "Salli": "Salli (American)"] +} +//Common Code(Child)----------------------------------------------------------- +def ctlMQDesc(){ + def result = "Tap to add/edit the message queue options" + if (ctlMsgQue){ + result = "Send to: ${translateMQid(ctlMsgQue)}" + result += ctlMQNotify ? "\nNotification Mode Only" : "" + result += ctlMQExpire ? "\nExpires in ${ctlMQExpire} minutes" : "" + result += ctlMQOverwrite ? "\nOverwrite all previous voice report messages" : "" + result += ctlSuppressTDRemind ? "\nSuppress Time and Date from Alexa Playback" : "" + } + return result +} +def translateMQid(mqIDList){ + def result=mqIDList.contains("Primary Message Queue")?["Primary Message Queue"]:[], qName + mqIDList.each{qID-> + qName = parent.getAAMQ().find{it.id == qID} + if (qName) result += qName.label + } + return parent.getList(result) +} +def deleteChild(id){ + def child = getChildApps().find{ it.id == id } + if (child) {log.info "Deleting schedule, '${child.label}'"; app.deleteChildApp(child) } +} +def upDownChild(device, op, num, type){ + def numChange, newLevel, currLevel, defMove + if (type=="level") defMove = parent.lightAmt as int ?: 0 ; currLevel = device.currentValue("switch")=="on" ? device.currentValue("level") as int : 0 + if (type=="temperature"){ + defMove = parent.tstatAmt as int ?: 5 + try{ + if (parent.nestCMD) nestCmdPrep(device) + if (op=~/Cool/ && (device.currentValue("thermostatMode")=="auto" || device.currentValue("thermostatMode")=="cool")) currLevel = device.currentValue("coolingSetpoint") + if (op=~/Heat/ && (device.currentValue("thermostatMode")=="auto" || device.currentValue("thermostatMode")=="heat")) currLevel = device.currentValue("heatingSetpoint") + } + catch (e) { log.warn "There was an error reading the thermostat. Ensure you have the proper mode set on the thermostat for the setpoint you are attempting to set." } + } + if (op =~/increase|raise|up|brighten/) numChange = num == 0 ? defMove : num > 0 ? num : 0 + if (op =~/decrease|down|lower|dim/) numChange = num == 0 ? -defMove : num > 0 ? -num : 0 + newLevel = currLevel + numChange; newLevel = newLevel > 100 ? 100 : newLevel < 0 ? 0 : newLevel + if (type=="level"){ + if (defMove>0) device.setLevel(newLevel) + if (newLevel==0) device.off() + } + if (type=="temperature"){ + if (op=~/Cool/) device.setCoolingSetpoint(newLevel) + else if (op=~/Heat/) device.setHeatingSetpoint(newLevel) + } +} +private getDayOk(dayList) { + def result = true + if (dayList) { + def df = new java.text.SimpleDateFormat("EEEE") + location.timeZone ? df.setTimeZone(location.timeZone) : df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + def day = df.format(new Date()) + result = dayList.contains(day) + } + return result +} +private getTimeOk(startTime, endTime) { + def result = true, currTime = now(), start = startTime ? timeToday(startTime).time : null, stop = endTime ? timeToday(endTime).time : null + if (startTime && endTime) result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start + else if (startTime) result = currTime >= start + else if (endTime) result = currTime <= stop + return result +} +private getPeopleOk(peopleList,presType){ + def result = true + if (presType && peopleList) result = peopleList.find {it.currentPresence == "not present"} ? false : true + else if (!presType && peopleList) result = peopleList.find {it.currentPresence == "present"} ? true : false + result +} +def getTimeLabel(start, end){ + def timeLabel = "Tap to set" + if(start && end) timeLabel = "Between " + timeParse("${start}", "h:mm a") + " and " + timeParse("${end}", "h:mm a") + else if (start) timeLabel = "Start at " + timeParse("${start}", "h:mm a") + else if (end) timeLabel = "End at " + timeParse("${end}", "h:mm a") + return timeLabel +} +def macroAliasDesc(result =""){ + for (int i= 1; i1 ? "; activates ${cDelay} minutes after triggered" : cDelay==1 ? "; activates one minute after triggered" : "" + if (macroType == "Control" && (phrase || setMode || SHM || getDeviceDesc() != "Status: UNCONFIGURED${PIN} - Tap to configure" || + getOccupyDesc() !="Status: UNCONFIGURED - Tap to configure" || getHTTPDesc() !="Status: UNCONFIGURED - Tap to configure" || ctlMsgQue)) desc= "Control Macro CONFIGURED${customAck}${PIN} - Tap to edit" + if (macroType =="GroupM" && groupMacros) { + customAck += addPost && !noAck ? " appended to the child macro messages" : noAck ? "" : " replacing the child macro messages" + def countDesc = groupMacros.size() == 1 ? "one macro" : groupMacros.size() + " macros" + desc = "Extension Group CONFIGURED with ${countDesc}${customAck}${PIN} - Tap to edit" + } + def webCoreName = parent.webCoRE_list().find{it.id==CoREName} + if (macroType =="CoRE" && CoREName && webCoreName) desc = "Trigger '${webCoreName.name}' piston${customAck}${PIN} - Tap to edit" + else if (macroType =="CoRE" && CoREName && !webCoreName) desc = "WebCoRE piston has changed or deleted; Macro will not operate - Tap to reselect the proper piston" + return desc ? desc : "Status: UNCONFIGURED - Tap to configure macro" +} +def playbackDesc() {return (cmdMute || muteAll || speakSpeed !="medium" || speakPitch !="medium" || whisperMode) ? "Tap to edit playback options/restrictions": "Tap to set playback options/restrictions"} +def greyOutMacro(){ return macroTypeDesc() == "Status: UNCONFIGURED - Tap to configure macro" ? "" : "complete" } +def greyOutStateHTTP(){ return getHTTPDesc() == "Status: UNCONFIGURED - Tap to configure" ? "" : "complete" } +def occupyGreyOut(){ return getOccupyDesc() == "Status: UNCONFIGURED - Tap to configure" ? "" : "complete" } +def deviceGreyOut(){ return getDeviceDesc() == "Status: UNCONFIGURED - Tap to configure" ? "" : "complete" } +def getDeviceDesc(){ + def result, cmd = [switch: switchesCMD, dimmer: dimmersCMD, cLight: cLightsCMD, cLightK: cLightsKCMD, tstat: tstatsCMD, lock: locksCMD, garage: garagesCMD, shade: shadesCMD] + def lvl = cmd.dimmer == "set" && dimmersLVL ? dimmersLVL as int : 0 + def cLvl = cmd.cLight == "set" && cLightsLVL ? cLightsLVL as int : 0 + def kLvl = cmd.cLightK == "set" && cLightsKLVL ? cLightsKLVL as int : 0 + def clr = cmd.cLight == "set" && cLightsCLR ? cLightsCLR : "" + def kTemp = cmd.cLightK == "set" && cLightsKEL ? cLightsKEL: "" + def tLvl = tstats ? tstatLVL : 0 + def dimUpDn = cmd.dimmer==~/increase|decrease/ && dimmersUpDown ? dimmersUpDown as int : 0 + def clUpDn = cmd.cLight ==~/increase|decrease/ && cLightsUpDown? cLightsUpDown as int : 0 + def klUpDn = cmd.cLightK ==~/increase|decrease/ && cLightsKUpDown? cLightsKUpDown as int : 0 + def tUpDn = cmd.tstat =~/increase|decrease/ && tstatUpDown ? tstatUpDown as int : 0 + lvl = lvl < 0 ? lvl = 0 : lvl >100 ? 100 : lvl + tLvl = tLvl < 0 ? 0 : tLvl >100 ? 100 : tLvl + cLvl = cLvl < 0 ? 0 : cLvl >100 ? 100 : cLvl + kLvl = kLvl < 0 ? 0 : kLvl >100 ? 100 : kLvl + dimUpDn = dimUpDn <0 ? 0 : dimUpDn>100 ? 100: dimUpDn + clUpDn == clUpDn <0 ? 0 : clUpDn>100 ? 100: clUpDn + klUpDn == klUpDn <0 ? 0 : klUpDn>100 ? 100: klUpDn + tUpDn == tUpDn <0 ? 0 : tUpDn>100 ? 100: tUpDn + if (switches || dimmers || cLights || cLightsK || tstats || locks || garages || shades) { + result = switches && cmd.switch ? "${switches} set to ${cmd.switch}" : "" + result += result && dimmers && cmd.dimmer ? "\n" : "" + result += dimmers && cmd.dimmer && cmd.dimmer != "set" && cmd.dimmer !="increase" && cmd.dimmer !="decrease" ? "${dimmers} set to ${cmd.dimmer}" : "" + result += dimmers && cmd.dimmer && cmd.dimmer == "set" ? "${dimmers} set to ${lvl}%" : "" + result += dimmers && cmd.dimmer && cmd.dimmer ==~/increase|decrease/ && dimUpDn>0 ? "${dimmers} ${cmd.dimmer} brightness by ${dimUpDn}%" : "" + result += result && cLights && cmd.cLight ? "\n" : "" + result += cLights && cmd.cLight && cmd.cLight != "set" && cmd.cLight !="increase" && cmd.cLight !="decrease" && cmd.cLight && cmd.cLight != "loopOn" && cmd.cLight != "loopOff" && cmd.cLight !="pulseOn" && cmd.cLight !="pulseOff"? "${cLights} set to ${cmd.cLight}": "" + result += cLights && cmd.cLight && cmd.cLight == "set" && (clr || cLvl) ? "${cLights} set to " : "" + result += cLights && cmd.cLight && cmd.cLight == "set" && clr ? "${clr} and " : "" + result += cLights && cmd.cLight && cmd.cLight == "set" && cLvl >0 ? "${cLvl}%" : "" + result += cLights && cmd.cLight && cmd.cLight == "set" && clr && (cLvl == 0 || !cLvl) ? "Color Default Brightness" : "" + result += cLights && cmd.cLight && cmd.cLight ==~/increase|decrease/ && clUpDn>0 ? "${cLights} ${cmd.cLight} brightness by ${clUpDn}%" : "" + result += cLights && cmd.cLight == "loopOn" ? "${cLights} turn on color loop" : "" + result += cLights && cmd.cLight == "loopOff" ? "${cLights} turn off color loop" : "" + result += cLights && cmd.cLight == "pulseOn" ? "${cLights} turn on pulse" : "" + result += cLights && cmd.cLight == "pulseOff" ? "${cLights} turn off pulse" : "" + result += result && cLightsK && cmd.cLightK ? "\n" : "" + result += cLightsK && cmd.cLightK && cmd.cLightK != "set" && cmd.cLightK !="increase" && cmd.cLightK !="decrease" ? "${cLightsK} set to ${cmd.cLightK}": "" + result += cLightsK && cmd.cLightK && cmd.cLightK == "set" ? "${cLightsK} set to " : "" + result += cLightsK && cmd.cLightK && cmd.cLightK == "set" && kTemp ? "${kTemp}K and " : "" + result += cLightsK && cmd.cLightK && cmd.cLightK == "set" && kLvl >0 ? "${kLvl}%" : "" + result += cLightsK && cmd.cLightK && cmd.cLightK == "set" && kTemp && (kLvl == 0 || !kLvl)? "Current Brightness" : "" + result += cLightsK && cmd.cLightK && cmd.cLightK ==~/increase|decrease/ && klUpDn>0 ? "${cLightsK} ${cmd.cLightK} brightness by ${klUpDn}%" : "" + result += result && tstats && (tLvl || tUpDn) ? "\n" : "" + result += tstats && cmd.tstat ==~/heat|cool/ && tLvl ? "${tstats} set to ${cmd.tstat}: ${tLvl} degrees" : "" + result += tstats && cmd.tstat =="increaseCool" && tUpDn ? "${tstats} increase cooling setpoint: ${tUpDn} degrees" : "" + result += tstats && cmd.tstat =="decreaseCool" && tUpDn ? "${tstats} decrease cooling setpoint: ${tUpDn} degrees" : "" + result += tstats && cmd.tstat =="increaseHeat" && tUpDn ? "${tstats} increase heating setpoint: ${tUpDn} degrees" : "" + result += tstats && cmd.tstat =="decreaseHeat" && tUpDn ? "${tstats} decrease heating setpoint: ${tUpDn} degrees" : "" + if (tstats && (parent.nestCMD || parent.ecobeeCMD || (parent.ecobeeCMD && parent.MyEcobeeCMD))) result += cmd.tstat && cmd.tstat =~/heat|cool|increase|decrease/ ? "": "${tstats} set to: ${cmd.tstat}" + result += result && locks && cmd.lock ? "\n":"" + result += locks && cmd.lock ? "${locks} set to ${cmd.lock}" : "" + result += result && garages && cmd.garage ? "\n" : "" + result += garages && cmd.garage ? "${garages} set to ${cmd.garage}" : "" + result += result && shades && cmd.shade ? "\n" : "" + result += shades && cmd.shade ? "${shades} set to ${cmd.shade}" : "" + } + return result ? result : "Status: UNCONFIGURED - Tap to configure" +} +def getHTTPDesc(){ + def result = "", param = [http:http, ip:ip, port:port, cmd:command] + if (extInt == "0" && param.http) result += param.http + else if (extInt == "1" && param.ip && param.port && param.cmd) result += "http://${param.ip}:${param.port}/${param.cmd}" + return result ? result : "Status: UNCONFIGURED - Tap to configure" +} +def getOccupyDesc(result = ""){ + if (occupancyMacro && occupancyCMD) result = "${occupancyMacro} set to ${occupancyCMD}" + return result ? result : "Status: UNCONFIGURED - Tap to configure" +} +private replaceVoiceVar(msg, delay, filter, type, name, age, xParam) { + def df = new java.text.SimpleDateFormat("EEEE") + location.timeZone ? df.setTimeZone(location.timeZone) : df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + def day = df.format(new Date()), time = parseDate("","h:mm a"), month = parseDate("","MMMM"), year = parseDate("","yyyy"), dayNum = parseDate("","d") + def varList = getVariableList(), temp = varList.temp != "undefined device" ? roundValue(varList.temp) + " degrees" : varList.temp + def humid = varList.humid, people = varList.people + def fullMacroType=[GroupM: "Extension Group", Control:"Control Macro", Voice:"Voice Report", Room: "Rooms and Groups"][type] ?: type + def delayMin = delay ? delay + " minutes" : "No delay specified" + msg = msg.replace('%mtype%', "${fullMacroType}") + msg = msg.replace('%macro%', "${name}") + msg = msg.replace('%day%', day) + msg = msg.replace('%date%', "${month} ${dayNum}, ${year}") + msg = msg.replace('%time%', "${time}") + msg = msg.replace('%temp%', "${temp}") + msg = msg.replace('%humid%', "${humid}") + msg = msg.replace('%people%', "${people}") + msg = msg.replace('%delay%',"${delayMin}") + msg = msg.replace('%age%',"${age}") + msg = msg.replace('%xParam%',"${xParam}") + if (msg.contains("%random")){ + def randomList = [], selectRand + if ((random1A || random1B || random1C) && msg.contains("%random1%")){ + if (random1A) randomList << random1A + if (random1B) randomList << random1B + if (random1C) randomList << random1C + selectRand = randomList[Math.abs(new Random().nextInt() % randomList.size() as int)] + msg = msg.replace('%random1%',"${selectRand}") + } + if ((random2A || random2B || random2C) && msg.contains("%random2%")){ + if (random2A) randomList << random2A + if (random2B) randomList << random2B + if (random2C) randomList << random2C + selectRand = randomList[Math.abs(new Random().nextInt() % randomList.size() as int)] + msg = msg.replace('%random2%',"${selectRand}") + } + if ((random3A || random3B || random3C) && msg.contains("%random3%")){ + if (random3A) randomList << random3A + if (random3B) randomList << random3B + if (random3C) randomList << random3C + selectRand = randomList[Math.abs(new Random().nextInt() % randomList.size() as int)] + msg = msg.replace('%random3%',"${selectRand}") + } + } + if (getWR().size()){ + getWR().each{ + def wrName = "%" + it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") + "%" + if (msg.contains(wrName)) msg = msg.replace(wrName,processWeatherReport(it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", ""))[0..-4]) + } + } + if (filter) { + def textFilter=filter.toLowerCase().tokenize(",") + textFilter.each{ msg = msg.toLowerCase().replace("${it}","") } + } + return msg +} +private timeParse(time, type) { return new Date().parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", time).format("${type}", location.timeZone)} +private parseDate(time, type){ + long longDate = time ? Long.valueOf(time).longValue() : now() + def formattedDate = new Date(longDate).format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone) + return new Date().parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", formattedDate).format("${type}", timeZone(formattedDate)) +} +//Various Functions----------------------------------------------------------- +def toggleState(swDevices){ swDevices.each{ it.currentValue("switch")=="off" ? it.on() : it.off() } } +private setColoredLights(switches, color, level){ + def getColorData = parent ? parent.STColors().find {it.name==color} : STColors().find {it.name==color} + def hueColor = getColorData ? Math.round(getColorData.h / 3.6) : 0, satLevel = getColorData ? getColorData.s:0, newLevel = level>0 ? level : getColorData.l + if (color == "Custom-User Defined"){ + hueColor = hueUserDefined ? hueUserDefined : 0 + satLevel = satUserDefined ? satUserDefined : 0 + hueColor = hueColor > 100 ? 100 : hueColor < 0 ? 0 : hueColor + satLevel = satLevel > 100 ? 100 : satLevel < 0 ? 0 : satLevel + } + def newValue = [hue: hueColor as int, saturation: satLevel as int] + def isOsram = parent ? parent.osramCMD : osramCMD + if (isOsram){ + try { switches?.loopOff() } + catch (e) { log.warn "You have attempted a command that is not compatible with the the device handler you are using. Try to turn off the Osram functions in the colored lights selection area." } + } + switches?.setColor(newValue) + switches?.setLevel(newLevel as int) +} +//Common Code (Parent)--------------------------------- +private webCoRE_init(pistonExecutedCbk){ + state.webCoRE=(state.webCoRE instanceof Map?state.webCoRE:[:])+(pistonExecutedCbk?[cbk:pistonExecutedCbk]:[:]) + subscribe(location,"${webCoRE_handle()}.pistonList",webCoRE_handler) + if(pistonExecutedCbk)subscribe(location,"${webCoRE_handle()}.pistonExecuted",webCoRE_handler) + webCoRE_poll() +} +private webCoRE_poll(){ sendLocationEvent([name: webCoRE_handle(),value:'poll',isStateChange:true,displayed:false]) } +public webCoRE_execute(pistonIdOrName,Map data=[:]){ + def i=(state.webCoRE?.pistons?:[]).find{(it.name==pistonIdOrName)||(it.id==pistonIdOrName)}?.id + if (i) sendLocationEvent([name:i,value:app.label,isStateChange:true,displayed:false,data:data]) +} +public webCoRE_list(mode){ + def p=state.webCoRE?.pistons + if (p) p.collect{ mode=='id' ? it.id :(mode=='name' ? it.name : mode=='enum' ? ["${it.id}":"${it.name}"] :[id:it.id,name:it.name]) } +} +public webCoRE_handler(evt){switch(evt.value){case 'pistonList':List p=state.webCoRE?.pistons?:[];Map d=evt.jsonData?:[:];if(d.id&&d.pistons&&(d.pistons instanceof List)){p.removeAll{it.iid==d.id};p+=d.pistons.collect{[iid:d.id]+it}.sort{it.name};state.webCoRE = [updated:now(),pistons:p];};break;case 'pistonExecuted':def cbk=state.webCoRE?.cbk;if(cbk&&evt.jsonData)"$cbk"(evt.jsonData);break;}} +def getRandDesc(num){ + def result = "Tap to add responses to %random${num}%" + if (settings."random${num}A" || settings."random${num}B"|| settings."random${num}C"){ + result = "" + if (settings."random${num}A") result += "1: ${settings."random${num}A"}" + if (settings."random${num}A" && (settings."random${num}B" || settings."random${num}C")) result +="\n" + if (settings."random${num}B") result += "2: ${settings."random${num}B"}" + if ((settings."random${num}A" || settings."random${num}B") && settings."random${num}C") result +="\n" + if (settings."random${num}C") result += "3: ${settings."random${num}C"}" + } + return result +} +def getGlobalVarState(){ + return voiceTempVar || voiceHumidVar || voicePresenceVar || getWR().size() || random1A || random2A || random3A || random1B || random2B || random3B || random1C || random2C || random3C +} +private switchesSel() { return switches || (deviceAlias && switchesAlias) } +private dimmersSel() { return dimmers || (deviceAlias && dimmersAlias) } +private cLightsSel() { return cLights || (deviceAlias && cLightsAlias) } +private cLightsKSel() { return cLightsK || (deviceAlias && cLightsKAlias) } +private doorsSel() { return doors || (deviceAlias && doorsAlias) } +private locksSel() { return locks || (deviceAlias && locksAlias) } +private ocSensorsSel() { return ocSensors || (deviceAlias && ocSensorsAlias) } +private shadesSel() { return shades || (deviceAlias && shadesAlias) } +private tstatsSel() { return tstats || (deviceAlias && tstatsAlias) } +private tempsSel() { return temps || (deviceAlias && tempsAlias) } +private humidSel() { return humid || (deviceAlias && humidAlias) } +private fooBotSel() { return fooBot || (deviceAlias && fooBotAlias) } +private uvSel() { return UV || (deviceAlias && UVAlias) } +private speakersSel() { return speakers || (deviceAlias && speakersAlias) } +private waterSel() { return water || (deviceAlias && waterAlias) } +private presenceSel() { return presence || (deviceAlias && presenceAlias) } +private motionSel() { return motion || (deviceAlias && motionAlias) } +private accelerationSel() { return acceleration || (deviceAlias && accelerationAlias) } +private occSel() { return occupancy || (deviceAlias && occupancyAlias)} +def getMacroList(type,exclude){ + def result=[] + if (type ==~/all|other/) { + if (type =="all") getAskAlexa().each{ if (it.label && it.macroType !="GroupM") result << ["${it.label}": "${it.label} (${it.macroType} Macro)"] } + getVR().each{ if (it.label && it.label != exclude) result << ["${it.label}": "${it.label} (Voice Report)"]} + getWR().each{ if (it.label) result << ["${it.label}": "${it.label} (Weather Report)"]} + if (type=="other") getAAMQ().each{ if (it.label) result << ["${it.label}": "${it.label} (Message Queue)"]} + } + else if (type =~/sched/){ + if (type =="schedV") getVR().each{ if (it.label) result << "${it.label}"} + else if (type =="schedW") getWR().each{ if (it.label) result << "${it.label}"} + else if (type=="schedM") getAskAlexa().each{ if (it.label) result << ["${it.label}": "${it.label} (${it.macroType=="GroupM"?"Extension Group":it.macroType} Macro)"] } + } + else if (type == "flash"){ + getAskAlexa().each{ if (it.macroType =="GroupM") result << ["${it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "")}%M%":"${it.label} (Extension Group)"] } + result<<["undefined%Q%": "Primary Message Queue"] + getAAMQ().each{ if (it.label) result<< ["${it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "")}%Q%": it.label +" (Message Queue)"]} + getVR().each{ if (it.label) result<< ["${it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "")}%V%": it.label +" (Voice Report)"]} + getWR().each{ if (it.label) result<< ["${it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "")}%W%": it.label +" (Weather Report)"]} + } + return result +} +def msgHandler(evt) { + def selQueues = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : [] + def overwrite = evt.jsonData && evt.jsonData?.overwrite ? true : false + def expires = evt.jsonData && evt.jsonData?.expires ? evt.jsonData.expires as int : 0 + def notifyOnly = evt.jsonData && evt.jsonData?.notifyOnly ? true : false + def suppressTimeDate = evt.jsonData && evt.jsonData?.suppressTimeDate ? true : false + def trackDelete = evt.jsonData && evt.jsonData?.trackDelete ? true : false + def expiration = expires && expires !=0 ? now() + (expires*1000) : 0 + if (selQueues.size()) { + selQueues.each{qID-> + def qNameRun = getAAMQ().find{it.id == qID} + if (qNameRun) qNameRun.msgHandler(evt.date, evt.descriptionText, evt.unit, evt.value, overwrite, expiration, notifyOnly, suppressTimeDate,trackDelete) + else if (qID=="Primary Message Queue") msgPMQ (evt.date, evt.descriptionText, evt.unit, evt.value, overwrite, expiration, notifyOnly, suppressTimeDate,trackDelete) + } + } + else msgPMQ (evt.date, evt.descriptionText, evt.unit, evt.value, overwrite, expiration, notifyOnly, suppressTimeDate,trackDelete) +} +def msgDeleteHandler(evt){ + def selQueues = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : [] + if (selQueues.size()) { + selQueues.each{qID-> + def qNameRun = getAAMQ().find{it.id == qID} + def qName = qNameRun ? qNameRun.label : "Primary Message Queue" + if (qNameRun && msgQueueDelete.contains(qID)) qNameRun.msgDeleteHandler(evt.unit, evt.value) + else if (qID=="Primary Message Queue" && msgQueueDelete.contains("Primary Message Queue")) msgDeletePMQ(evt.unit, evt.value) + else log.debug "The '${qName}' message queue does not have External SmartApp deletion turned on. No messages were deleted." + } + } + else msgDeletePMQ(evt.unit, evt.value) +} +def msgPMQ(date,descriptionText,unit,value,overwrite, expires, notifyOnly, suppressTimeDate,trackDelete){ + if (!state.msgQueue) state.msgQueue=[] + if (msgQueueForward){ + msgQueueForward.each{qID-> + def qNameRun = getAAMQ().find{it.id == qID} + if (qNameRun) qNameRun.msgHandler(date, descriptionText + " - This message was forwarded from the primary message queue.", unit, value, overwrite, expires, notifyOnly, suppressTimeDate,trackDelete); + } + } + else { + def msgTxt + if (overwrite && msgQueueDelete && msgQueueDelete.contains("Primary Message Queue")) msgDeletePMQ(unit, value) + else if (overwrite && (!msgQueueDelete || !msgQueueDelete.contains("Primary Message Queue"))) log.debug "An overwrite command was issued from '${value}', however, the option to allow deletions was not enabled for the Primary Message Queue." + if (!notifyOnly) log.debug "New message added to primary message queue from: " + value + if (!notifyOnly) state.msgQueue<<["date":date.getTime(),"appName":value,"msg":descriptionText,"id":unit, "expires": expires, "suppressTimeDate": suppressTimeDate,"trackDelete":trackDelete] + if (mqAlertType ==~/0|1|2/) { + msgTxt= !mqAlertType ||mqAlertType as int ==0 || mqAlertType as int ==1 ? "New message received in ${app.label} message queue from : " + value : "" + if (!mqAlertType || mqAlertType ==~/0|2/) msgTxt += msgTxt ? ": "+ descriptionText : descriptionText + } + if (mqSpeaker && mqVolume && ((restrictAudio && getOkToRun())||!restrictAudio)) { + def msgSFX, outputVoice = mqVoice ?: "Salli", msgVoice = msgTxt ? textToSpeech (msgTxt, outputVoice) : msgTxt + if (mqAlertType == "3" || mqAppendSound) msgSFX = sfxLookup(mqAlertSound) + mqSpeaker?.setLevel(mqVolume as int) + if (mqAlertType != "3" && !mqAppendSound) mqSpeaker?.playTrack (msgVoice.uri) + if (mqAlertType == "3") mqSpeaker?.playTrack (msgSFX.uri) + if (mqAlertType != "3" && mqAppendSound) mqSpeaker?.playSoundAndTrack(msgSFX.uri,msgSFX.duration,msgVoice.uri) + } + if (mqSynth && ((restrictAudio && getOkToRun())||!restrictAudio)) mqSynth?.speak(msgTxt) + if (mqPush || mqSMS || mqContacts && ((restrictMobile && getOkToRun())||!restrictMobile)){ + def mqMsg = "New message received by Ask Alexa in primary message queue from : " + value + ": "+ descriptionText + sendMSG(mqSMS, mqMsg , mqPush, mqContacts) + } + if (mqFeed && ((restrictMobile && getOkToRun())||!restrictMobile)) sendNotificationEvent("New message received by Ask Alexa in primary message queue from : " + value + ": "+ descriptionText) + if (msgQueueNotifyLightsOn && ((restrictVisual && getOkToRun())||!restrictVisual)) msgQueueNotifyLightsOn?.on() + if (msgQueueNotifycLightsOn && (msgQueueNotifyColor || msgQueueNotifyLevel) && ((restrictVisual && getOkToRun())||!restrictVisual)) { + def level = !msgQueueNotifyLevel || msgQueueNotifyLevel < 0 ? 50 : msgQueueNotifyLevel >100 ? 100 : msgQueueNotifyLevel as int + msgQueueNotifyColor ? setColoredLights(msgQueueNotifycLightsOn, msgQueueNotifyColor, msgQueueNotifyLevel) : msgQueueNotifycLightsOn?.setLevel(level) + } + } +} +def msgDeletePMQ(unit,value){ + if (state.msgQueue && state.msgQueue.size()>0){ + if (unit && value){ + log.debug value + " is requesting to delete messages from the primary message queue." + def deleteList = state.msgQueue.findAll{it.appName==value && it.id==unit && it.trackDelete} + state.msgQueue.removeAll{it.appName==value && it.id==unit} + if (deleteList){ + deleteList.each{ + sendLocationEvent(name:"askAlexaMQ", value: "${it.appName}.${it.id}",isStateChange: true, data:[[deleteType: "delete"],[queue:"Primary message queue"]], descriptionText:"Ask Alexa deleted messages from the Primary message queue") + } + } + if (msgQueueNotifyLightsOn && msgQueueNotifyLightsOff && !state.msgQueue) msgQueueNotifyLightsOn?.off() + if (msgQueueNotifycLightsOn && msgQueueNotifyLightsOff && !state.msgQueue) msgQueueNotifycLightsOn?.off() + } + else log.debug "Incorrect delete parameters sent to the primary message queue. Nothing was deleted." + } + else log.debug "The primary message queue is empty. No messages were deleted." +} +def mqCounts(list){ + def msgList=[], msgCountTxt="",queS="" + purgeMQ() + if (list){ + if (list.contains("Primary Message Queue") && state.msgQueue && state.msgQueue.size()) msgList<<"Primary Message Queue" + list.each{qID-> + def qNameRun = getAAMQ().find{it.id == qID} + def qName = qNameRun ? qNameRun.label : "Primary Message Queue" + if (qNameRun && qName !="Primary Message Queue" && qNameRun.qSize()) msgList<1 ? "queues" : "" + } + msgCountTxt = msgList ? "You have messages present in the following message ${queS}: " + getList(msgList):"" + } + return msgCountTxt +} +def purgeMQ(){ + if (!state.msgQueue) state.msgQueue=[] + def deleteList = state.msgQueue.findAll{it.expires !=0 && now() > it.expires && it.trackDelete} + state.msgQueue.removeAll{it.expires !=0 && now() > it.expires} + if (deleteList){ + deleteList.each{ + sendLocationEvent(name:"askAlexaMQ", value: "${it.appName}.${it.id}",isStateChange: true, data:[[deleteType: "expire"],[queue:"Primary message queue"]], descriptionText:"Ask Alexa expired messages from the Primary message queue") + } + log.debug "Ask Alexa is purging expired messages from the Primary Message Queue." + } + if (!state.msgQueue.size()){ + if (msgQueueNotifyLightsOn && msgQueueNotifyLightsOff && !state.msgQueue) msgQueueNotifyLightsOn?.off() + if (msgQueueNotifycLightsOn && msgQueueNotifyLightsOff && !state.msgQueue) msgQueueNotifycLightsOn?.off() + } +} +def getExtList(extList =[]){ + getWR().each {extList +=["${it.id}":"${it.label}"]} + getVR().each {extList +=["${it.id}":"${it.label}"]} + getSCHD().each {extList +=["${it.id}":"${it.label}"]} + getRM().each {extList +=["${it.id}":"${it.label}"]} + getAskAlexa().each {if (it.macroType !='CoRE') extList +=["${it.id}":"${it.label}"]} + return extList +} +def getMQListID(withPMQ){ + def outputMQlist = withPMQ ? ["Primary Message Queue":"Primary Message Queue"]:[] + getAAMQ().each{ outputMQlist +=["${it.id}":"${it.label}"] } + return outputMQlist +} +def getVariableList(){ + def temp = voiceTempVar ? getAverage(voiceTempVar, "temperature") : "undefined device" + def humid = voiceHumidVar ? getAverage(voiceHumidVar, "humidity") + " percent relative humidity" : "undefined device" + def present = voicePresenceVar.findAll{it.currentValue("presence")=="present"} + def people = present ? getList(present) : "No people present" + return [temp: temp, humid: humid, people: people] +} +private getAverage(device,type){ + def total = 0 + device.each { if (it.latestValue(type)) total += it.latestValue(type) } + return Math.round(total/device.size()) +} +def getTstatLimits() { return [hi:tstatHighLimit, lo: tstatLowLimit] } +def OAuthToken(){ + try { + createAccessToken() + log.debug "Creating new Access Token" + } catch (e) { log.error "Access Token not defined. OAuth may not be enabled. Go to the IDE settings to enable OAuth within Ask Alexa." } +} +def macroDesc(count){def results = count ? count==1 ? "One Macro Configured" : count + " Macros Configured" : "No Macros Configured\nTap to create a new macro"} +def mqDesc(count){def results = count ? count==1 ? "Primary + One Addition Message Queue Configured" : "Primary + " + count + " Message Queues Configured" : "Primary Messsage Queue"} +def schDesc(count) {def results = count ? count==1 ? "One Schedule Configured" : count + " Schedules Configured" : "No Schedules Configured\nTap to create a new schedule"} +def voiceDesc(count) {def results = count ? count==1 ? "One Voice Report Configured" : count + " Voice Reports Configured" : "No Voice Reports Configured\nTap to create a new report"} +def weathDesc(count) {def results = count ? count==1 ? "One Weather Report Configured" : count + " Weather Reports Configured" : "No Weather Reports Configured\nTap to create a new report"} +def rmDesc(count) {def results = count ? count==1 ? "One Room/Group Configured" : count + " Rooms/Groups Configured" : "No Rooms/Groups Configured\nTap to create a new report"} +def getDesc(param){ return param ? "Status: CONFIGURED - Tap to edit/view" : "Status: UNCONFIGURED - Tap to configure" } +def getAliasList(result =[]){ + state.aliasList.each{ result << ["${it.aliasName}":"${it.aliasName} (${it.aliasTypeFull})"] } + return result +} +def getAliasDisplayList(){ + def count = state.aliasList.size(), result="" + for (int i=0; i < count; i++) { + def deviceType = mapDevices(true).find{it.fullListName==state.aliasList.aliasTypeFull[i]}, deviceName, displayName + if (deviceType) deviceName = deviceType.devices.find {it=state.aliasList.aliasDevice[i]} + displayName= deviceType && deviceName ? state.aliasList.aliasDevice[i] : state.aliasList.aliasDevice[i]+" (missing)" + result += state.aliasList.aliasName[i] + " (" + state.aliasList.aliasTypeFull[i] + ") = "+ displayName + if (i < count-1) result +="\n" + } + return result +} +def getxParamList(){ + def count = state.wcp.size(), result="" + for (int i=0; i < count; i++) { + result += state.wcp[i] + if (i < count-1) result +="\n" + } + return result +} +def listxParam(result =[]){ + state.wcp.each{result << it } + return result +} +def getDeviceAliasList(aliasType){ + def result = mapDevices(true).find{it.fullListName==aliasType}, resultList =[] + if (result) result.devices.each{ resultList <<"${it}" } + return resultList +} +private List STColors() { if (customName) return [customColor(), *colorUtil.ALL] else return [*colorUtil.ALL] } +private Map customColor(){ + if (customName && (customHue > -1 && customerHue < 101) && (customSat > -1 && customerSat < 101)) return [name: customName, rgb: "#000000", h: customHue * 3.6, s: customSat, l:100] +} +def getDeviceList(result = []){ + try { + mapDevices(false).each{ + def devicesGroup = it.devices, devicesType = it.type + devicesGroup.collect{ result << [name: it.label.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase(), type: devicesType, devices: devicesGroup] } + } + } + catch (e) { log.warn "There was an issue parsing the device labels. Be sure all of the devices are uniquely named/labeled and that none of them are blank (null). " } + return result +} +def findNullDevices(result=""){ + mapDevices(false).each{devicesGroup-> + devicesGroup.devices.each { result += !it.label ? it.name + "\n" : "" } + } + if (result) result = "You have the following device(s) with a blank (null) label:\n\n" + result + "\nBe sure all of the devices are uniquely labeled and that none of them are blank(null)." + return result +} +def optionCount(start, end){ + def result =[] + for (int i=start; i < end+1; i++){ result << i } + return result +} +def mapDevices(isAlias){ + def result =[], ext= isAlias ? "Alias": "" + if (settings."switches${ext}") result << [devices: settings."switches${ext}", type : "switch",fullListName:"Switch", cmd:switchVoc()] + if (settings."dimmers${ext}") result << [devices: settings."dimmers${ext}", type : "level",fullListName:"Dimmer", cmd:levelVoc()] + if (settings."cLights${ext}") result << [devices: settings."cLights${ext}", type : "color",fullListName:"Colored Light", cmd:colorVoc()] + if (settings."cLightsK${ext}") result << [devices: settings."cLightsK${ext}", type : "kTemp", fullListName:"Temperature (Kelvin) Light", cmd:kTempVoc()] + if (settings."doors${ext}") result << [devices: settings."doors${ext}", type : "door",fullListName:"Door Control", cmd:doorVoc()] + if (settings."shades${ext}") result << [devices: settings."shades${ext}", type : "shade",fullListName:"Window Shade", cmd:shadeVoc()] + if (settings."locks${ext}") result << [devices: settings."locks${ext}", type : "lock",fullListName:"Lock", cmd:lockVoc()] + if (settings."tstats${ext}") result << [devices: settings."tstats${ext}", type : "thermostat",fullListName:"Thermostat",cmd:tstatVoc()] + if (settings."speakers${ext}") result << [devices: settings."speakers${ext}", type : "music",fullListName:"Speaker", cmd:speakerVoc()] + if (settings."temps${ext}") result << [devices: settings."temps${ext}", type : "temperature",fullListName:"Temperature Sensor", cmd: tempVoc()] + if (settings."humid${ext}") result << [devices: settings."humid${ext}", type : "humidity",fullListName:"Humidity Sensor", cmd: humidVoc()] + if (settings."ocSensors${ext}") result << [devices: settings."ocSensors${ext}", type : "contact",fullListName:"Open/Close Sensor", cmd: contactVoc()] + if (settings."water${ext}") result << [devices: settings."water${ext}", type : "water",fullListName:"Water Sensor", cmd: waterVoc()] + if (settings."motion${ext}") result << [devices: settings."motion${ext}", type : "motion",fullListName:"Motion Sensor", cmd: motionVoc()] + if (settings."presence${ext}") result << [devices: settings."presence${ext}", type : "presence",fullListName:"Presence Sensor", cmd: presenceVoc()] + if (settings."acceleration${ext}") result << [devices: settings."acceleration${ext}", type : "acceleration",fullListName:"Acceleration Sensor", cmd: accelVoc()] + if (settings."fooBot${ext}") result << [devices: settings."fooBot${ext}", type : "pollution", fullListName:"Foobot Air Quality Monitor", cmd: fooBotVoc()] + if (settings."UV${ext}") result << [devices: settings."UV${ext}", type : "uvIndex", fullListName:"UV Index Device", cmd: uvVoc()] + if (settings."occupancy${ext}") result << [devices: settings."occupancy${ext}", type : "beacon", fullListName:"Occupancy Sensor", cmd: occVoc()+basicVoc()] + return result +} +def basicVoc(){return ["status","event","events"]} +def switchVoc(){return ["on", "off", "toggle"]} +def levelVoc(){return switchVoc()+["low","medium","high","maximum","minimum","increase","raise","up","decrease","down","lower","brighten","dim"]} +def colorVoc(){return levelVoc()} +def kTempVoc(){return levelVoc()} +def doorVoc(){return ["open","close"]} +def lockVoc(){return ["lock","unlock"]} +def shadeVoc(){return ["open","close"]} +def presenceVoc(){return basicVoc()} +def waterVoc(){return basicVoc()} +def accelVoc(){return basicVoc()} +def motionVoc(){return basicVoc()} +def tempVoc(){return basicVoc()} +def humidVoc(){return basicVoc()} +def contactVoc(){return basicVoc()} +def fooBotVoc(){return basicVoc()} +def uvVoc(){return basicVoc()} +def occVoc(){return ["asleep","vacant", "locked","engaged","occupied"]} +def speakerVoc(){return basicVoc()+["play","pause","stop","next track","previous track","off","mute","on","unmute","status","low","medium","high","maximum","increase","raise","up","decrease","down","lower"]} +def tstatVoc(){ + def result =["increase","raise","up","decrease","down","lower","maximum","minimum","off"] + if (ecobeeCMD && MyEcobeeCMD) result+=ecobeeVOC() + if (nestCMD && nestMGRCMD) result +=["report"] + return result +} +def ecobeeVOC(){return ["erase","delete","clear","reset","get","restart","repeat","replay","play","give","load","reload"]} +def msgVoc(){ return ["play","open","erase","delete","clear"]} +def rmVoc(){ return["setup","link","associate","sync"]} +def upDown(device, type, op, num, deviceName){ + def numChange, newLevel, currLevel, defMove, txtRsp = "" + if (type==~/color|level|kTemp/) { defMove = lightAmt as int ?: 0 ; currLevel = device.currentValue("switch")=="on" ? device.currentValue("level") as int : 0 } + if (type=="music") { defMove = speakerAmt as int ?: 5 ;currLevel = device.currentValue("level") as int } + if (type=="thermostat") { defMove=tstatAmt as int ?: 5 ; currLevel =device.currentValue("temperature") as int } + if (op ==~/increase|raise|up|brighten/) numChange = num == 0 ? defMove : num > 0 ? num : 0 + if (op ==~/decrease|down|lower|dim/) numChange = num == 0 ? -defMove : num > 0 ? -num : 0 + newLevel = currLevel + numChange; newLevel = newLevel > 100 ? 100 : newLevel < 0 ? 0 : newLevel + if (type ==~/level|color/ && defMove > 0 ){ + if (device.currentValue("switch")=="on") { + if (newLevel < 100 && newLevel > 0 ) txtRsp="I am setting the ${deviceName} to a new value of ${newLevel}%. " + if (newLevel == 0) txtRsp= "The new value would be zero or below, so I am turning the ${device} off. " + } + if (device.currentValue("switch")=="off") { + if (newLevel == 0) txtRsp= "The ${deviceName} is off. I am taking no action. " + if (newLevel < 100 && newLevel > 0 ) txtRsp="I am turning the ${deviceName} on and setting it to a level of ${newLevel}%. " + } + if (newLevel == 100) txtRsp= currLevel < 99 ? "I am increasing the level of the ${deviceName} to its maximum level. " : "The ${deviceName} is at its maximum level. " + } + else if (defMove == 0) txtRsp = "The default increase or decrease value is set to zero within the Ask Alexa SmartApp. I am taking no action. " + return [newLevel: newLevel, msg:txtRsp] +} +def getLastEvent(device, count, deviceName) { + def lastEvt= device.events(), eDate, eDesc, today, eventDay, voiceDay, i , evtCount = 0, result = "" + for(i = 0 ; i < 10 ; i++ ) { + eDate = lastEvt.date[i].getTime() + eDesc = lastEvt.descriptionText[i] + if (eDesc) { + def msgData= timeDate(eDate) + voiceDay = msgData.msgDay == "Today" ? msgData.msgDay : "On " + msgData.msgDay + result += "${voiceDay} at ${msgData.msgTime} the event was: ${eDesc}. " + evtCount ++ + if (evtCount == count) break + } + } + def diff = count - evtCount + result = evtCount>1 ? "The following are the last ${evtCount} events for the ${deviceName}: " + result : "The last event for the ${deviceName} was: " + result + result += diff > 1 ? "There were ${diff} items that were skipped due to the description being empty. " : diff==1 ? "There was one item that was skipped due to the description being a null value. " : "" + if (evtCount==0) result="There were no past events in the device log. " + return result +} +def flash(){ + String outputTxt = "" + try { + if (flash && flashRPT && flashRPT.endsWith("%Q%")) outputTxt=msgQueueReply("play", flashRPT[0..-4], "undefined") + else if (flash && flashRPT && flashRPT.endsWith("%W%")) outputTxt=processWeatherReport(flashRPT[0..-4],"undefined") + else if (flash && flashRPT && flashRPT.endsWith("%V%")) outputTxt=processVoiceReport(flashRPT[0..-4],"undefined") + else if (flash && flashRPT && flashRPT.endsWith("%M%")){ + def child = getAskAlexa().find {it.label.toLowerCase().replaceAll("[^a-zA-Z0-9 ]", "") == flashRPT[0..-4]} + if (child.macroType != "GroupM") outputTxt = child.getOkToRun() ? child.macroResults("", "", "", "", "", "", "undefined") : "You have restrictions within the ${fullMacroName} named, '${child.label}', that prevent it from running. Check your settings and try again. %1%" + else outputTxt = processMacroGroup(child.groupMacros, child.voicePost, child.addPost, child.noAck, child.label,"undefined") + } + else outputTxt = "You do not have the flash briefing option enabled in your Ask Alexa Smart App, or you don't have any output selected for the briefing output. Go to Settings in your Ask Alexa SmartApp to fix this. " + } catch (e) { outputTxt ="There was an error producing the flash briefing report. Check your settings and try again." } + if (outputTxt.endsWith("%")) outputTxt=outputTxt[0..-4] + log.debug "Sending Flash Briefing Output: " + outputTxt + return ["uid": "1", "updateDate": new Date().format("yyyy-MM-dd'T'HH:mm:ss'.0Z'"), "titleText": "Ask Alexa Flash Briefing Report", "mainText": outputTxt, + "redirectionUrl": "https://graph.api.smartthings.com/", "description": "Ask Alexa Flash Briefing Report"] +} +def fillDevJSON(slotName, listData){ + def result = " {
\"name\": \"${slotName}\",
" + result += " \"values\": [
" + def count = listData.unique().size() + listData.unique().each { + result += " {
\"id\": null,
"+ + " \"name\": {
"+ + " \"value\": \"${it}\",
"+ + " \"synonyms\": []
}
}" + count -- + result += count ? ",
" : "
" + } + result+=" ]
}" + return result +} +def devSetup(){ + def result = fillDevJSON("CANCEL_CMDS", fillCancelList()) +",
" + result += fillDevJSON("DEVICE_TYPE_LIST", fillTypeList()) +",
" + result += fillDevJSON("LIST_OF_DEVICES", fillDeviceList()) +",
" + result += fillDevJSON("LIST_OF_FOLLOWUPS",fillFollowupList()) +",
" + result += fillDevJSON("LIST_OF_MACROS",fillMacroList()) +",
" + result += fillDevJSON("LIST_OF_MQ",fillMQList()) +",
" + result += fillDevJSON("LIST_OF_MQCMD",msgVoc()) +",
" + result += fillDevJSON("LIST_OF_OPERATORS",fillOperatorsList()) +",
" + result += fillDevJSON("LIST_OF_PARAMS",fillParamsList()) +",
" + result += fillDevJSON("LIST_OF_SHPARAM",fillSHParamList()) +",
" + result += fillDevJSON("LIST_OF_SHCMD",fillSHCMDList()) +",
" + result += fillDevJSON("LIST_OF_WCP",fillWCPList()) + displayRaw(result) +} +def devRoomSetup(){ + def result = fillDevJSON("LIST_OF_OPERATORS",fillOperatorsList()) +",
" + result += fillDevJSON("LIST_OF_PARAMS",fillParamsList())+",
" + result += fillDevJSON("LIST_OF_ROOMS",fillRoomList())+",
" + result += fillDevJSON("LIST_OF_FOLLOWUPS",fillFollowupList()) + displayRaw(result) +} +def cancelList(result=""){ + fillCancelList().each {result +="${it}
"} + displayMiniHTML(result) +} +def typeList(result=""){ + fillTypeList().each{result += it + "
"} + displayMiniHTML(result) +} +def deviceList(result=""){ + fillDeviceList().unique().each { result += it + "
" } + displayMiniHTML(result) +} +def followupList(result=""){ + fillFollowupList().each { result += it + "
" } + displayMiniHTML(result) +} +def operatorsList(result=""){ + fillOperatorsList().unique().each{result += it+"
"} + displayMiniHTML(result) +} +def paramsList(result=""){ + fillParamsList().unique().each{result += it+"
"} + displayMiniHTML(result) +} +def shparamList(result=""){ + fillSHParamList().unique().each{result += it + "
"} + displayMiniHTML(result) +} +def shcmdList(result=""){ + fillSHCMDList().each{ result += it + "
"} + displayMiniHTML(result) +} +def macrosList(result=""){ + fillMacroList().unique().each{ result += it + "
"} + displayMiniHTML(result) +} +def mqList(result=""){ + fillMQList().unique().each{ result += it + "
"} + displayMiniHTML(result) +} +def mqcmdList(result=""){ + msgVoc().each{result +=it + "
" } + displayMiniHTML(result) +} +def wcpList(result=""){ + fillWCPList().each{result+=it+"
"} + displayMiniHTML(result) +} +def roomsList(result=""){ + fillRoomList().unique().each{result+=it+"
"} + displayMiniHTML(result) +} +def setupLink() { showLink("setup") } +def cheatLink() { showLink("cheat") } +def showLink(type){ + def siteLink = "${getApiServerUrl()}/api/smartapps/installations/${app.id}/${type}?access_token=${state.accessToken}" + def siteType = type =="setup" ? "Setup web page" : "Cheat sheet web page" + log.info "${siteType} located at : ${siteLink}" + def result ="""

See your SmartThings IDE Live Logging for the URL of the ${siteType} so you may display it on your computer browser

+ ${siteType} URL:


+ +


Click '<' above to return to the Ask Alexa SmartApp.
""" + displayData(result) +} +def setupData(){ + def iName = invocationName ? invocationName.toLowerCase() : "smart things" + def httpPOSTAWS ="
" + + "
" + def httpPOSTAWSDL ="
"+ + "
" + def httpPOSTDev ="
"+ + "
" + def httpPOSTDevDL ="
"+ + "
" + def dupCounter=0, devCodeTxt = "Click the button below, copy the JSON code on the page, then paste to the Interaction Model Builder on the Amazon Developer page
${httpPOSTDev}" + def devDLTxt = "Or, click the botton below to download a text copy of the JSON code, then load into to the Interaction Model Builder on the Amazon Developer page
${httpPOSTDevDL}" + def result ="
One-step Personalized Code (Main Ask Alexa Skill):
" + result += "
Lambda Full Code:
Click button below, copy the code on the page, then paste to AWS Lambda
${httpPOSTAWS}" + result += "You can also download a text copy of the code for backup or to use your text editor to copy/paste to AWS Lambda. To download, click the button below
${httpPOSTAWSDL}" + result += "

Developer Code:
${devCodeTxt}${devDLTxt}
" + result += "--DevCodeWarn--" + result += "
Privacy Policy:
Click here to view the privacy policy regarding obtaining your personalized code.


" + result += "Lambda code variables:

var STappID = '${app.id}';
var STtoken = '${state.accessToken}';
" + result += "var url='${getApiServerUrl()}/api/smartapps/installations/' + STappID + '/' ;

" + result += flash ? "Amazon ASK Developer Flash Briefing Skill URL:

${getApiServerUrl()}/api/smartapps/installations/${app.id}/flash?access_token=${state.accessToken}


":"" + result += "Amazon ASK Developer Custom Slot Information (Main Ask Alexa Skill):

" + def DEVICES=fillDeviceList() + def duplicates = DEVICES.findAll{DEVICES.count(it)>1}.unique() + if (findDeviceReserverd()){ + dupCounter ++ + result += """**NOTICE: Some of your devices use the following reserved words:

+ echo, room, group, here, this room, this group, in here

+ Be sure not use these words when naming macros, extensions, aliases or devices.**

""" + } + else if (DEVICES && duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_DEVICES:

" + duplicates.each{result +="* " + it +" *

"} + result += "Be sure to have unique names for each device/alias and only use each name once within the parent app.**

" + } + result +=""" + + + +
CANCEL_CMDSDEVICE_TYPE_LISTLIST_OF_DEVICESLIST_OF_FOLLOWUPS



""" + def PARAMS=fillParamsList() + duplicates = PARAMS.findAll{PARAMS.count(it)>1}.unique() + if (duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_PARAMS:

" + duplicates.each{result +="* " + it +" *

"} + def objectName = [] + if (ecobeeCMD) objectName<<"Ecobee custom climates" + objectName <<"custom colors" + result += "Be sure to have unique names for your ${getList(objectName)}.**

" + } + def SHPARAM = fillSHParamList() + duplicates = SHPARAM.findAll{SHPARAM.count(it)>1}.unique() + if (duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_SHPARAM:

" + duplicates.each{result +="* " + it +" *

"} + result += "Be sure to have unique names for your SmartThings modes and routines and that they don't interfer with the Smart Home Monitor commands.**

" + } + result +=""" + + + + +
LIST_OF_OPERATORSLIST_OF_PARAMSLIST_OF_SHPARAMLIST_OF_SHCMD



""" + def MACROS=fillMacroList() + duplicates = MACROS.findAll{MACROS.count(it)>1}.unique() + if (findMacroReserved()){ + dupCounter ++ + result += """**NOTICE: Some of your macros or extensions use the following reserved words:

+ echo, room, group, here, this room, this group, in here

Be sure not use these words when naming macros, extensions, aliases or devices.**

""" + } + else if (duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_MACROS:

" + duplicates.each{result +="* " + it +" *

"} + result += "Be sure to have unique names for each macro, macro alias, and weather report and only use each name once within the app.**

" + } + def MQ=fillMQList() + duplicates = MQ.findAll{MQ.count(it)>1}.unique() + if (findMQReserved()){ + dupCounter ++ + result += """**NOTICE: Some of your message queues use the following reserved words:

" + echo, room, group, here, this room, this group, in here

Be sure not use these words when naming macros, extensions, aliases or devices.**

""" + } + else if (duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_MQ:

" + duplicates.each{result +="* " + it +" *

"} + result += "Be sure to have unique names for each message queue and only use each name once within the parent app.**

" + } + result +=""" + + + + +
LIST_OF_MACROSLIST_OF_MQLIST_OF_MQCMDLIST_OF_WCP


""" + def echoCount = 0 + if (getRM().size()) getRM().each {echoCount += it.getEchoAliasList().size() } + /*if (echoCount){ + httpPOSTDev ="
"+ + "
" + httpPOSTDevDL ="
"+ + "
" + devCodeTxt = "Click the button below, copy the JSON code on the page, then paste to the Interaction Model Builder on the Amazon Developer page
${httpPOSTDev}" + devDLTxt = "Or, click the botton below to download a text copy of the JSON code, then load into to the Interaction Model Builder on the Amazon Developer page
${httpPOSTDevDL}" + result +="

One-step Personalized Code (Secondary Ask Alexa Room Skills):
" + result += "
Developer Code:
${devCodeTxt}${devDLTxt}
" + result += "
Amazon ASK Developer Custom Slot Information (Secondary Ask Alexa Room Skills):

" + PARAMS=fillParamsList() + duplicates = PARAMS.findAll{PARAMS.count(it)>1}.unique() + if (duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_PARAMS:

" + duplicates.each{result +="* " + it +" *

"} + def objectName = [] + if (ecobeeCMD) objectName<<"Ecobee custom climates" + objectName <<"custom colors" + result += "Be sure to have unique names for your ${getList(objectName)}.**

" + } + def ROOMS=[] + if (getRM().size()){ ROOMS=fillRoomList() } else ROOM=["none"] + duplicates = ROOMS.findAll{ROOMS.count(it)>1}.unique() + if (findRoomReserved()){ + result += """**NOTICE: Some of your rooms/groups use the following reserved words:

+ echo, room, group, here, this room, this group, in here

Be sure not use these words when naming macros, extensions, aliases or devices.**

""" + dupCounter ++ + } + else if (duplicates.size()){ + dupCounter ++ + result += "**NOTICE: The following duplicate(s) are only listed once below in LIST_OF_ROOMS:

" + duplicates.each{result +="* " + it +" *

"} + result += "Be sure to have unique names for each room and only use each name once within the parent app.**

" + } + result +=""" + + + + +
LIST_OF_OPERATORSLIST_OF_PARAMSLIST_OF_ROOMSLIST_OF_FOLLOWUPS



+ +
DEVICE_TYPE_LIST

""" + }*/ + result += "


URL of this setup page:

${getApiServerUrl()}/api/smartapps/installations/${app.id}/setup?access_token=${state.accessToken}


" + result += "
Lastest version of the Lambda code:

https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/Node.js


" + result += "
Lastest version of the Sample Utterances:

https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/Sample%20Utterances


" + result += "
Lastest version of the Intent Schema:

https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/smartapps/michaelstruck/ask-alexa.src/Intent%20Schema


" + def warning = dupCounter ? "
* There were errors in your Ask Alexa setup. See the red items below to resolve
" : "" + if (!invocationName) warning="
* You are missing the invocation name within your Ask Alexa SmartApp. The code above will use 'smart things' as default.
" + result = result.replaceAll("--DevCodeWarn--", warning) + displayData(result) +} +def fillTypeList(){ + return ["reports","report","switches","switch","dimmers","dimmer","colored lights","color","colors","speakers","speaker","water sensor","water sensors","water","lock","locks","thermostats","thermostat", + "temperature sensors","modes","routines","smart home monitor","SHM","security","temperature","door","doors", "humidity", "humidity sensor", "humidity sensors","presence", "presence sensors", "motion", + "motion sensor", "motion sensors", "door sensor", "door sensors", "window sensor", "window sensors", "open close sensors","colored light", "events","macro", "macros", "group", "groups", "voice reports", + "voice report","control macro", "control macros","control", "controls","extension group","extension groups","core","core trigger","core macro","core macros","core triggers","sensor", "sensors","shades", + "window shades","shade", "window shade","acceleration", "acceleration sensor", "acceleration sensors", "alias","aliases","temperature light","temperature lights","kelvin light","kelvin lights","message queue", + "queue","message queues","queues","weather", "weather report", "weather reports","schedule","schedules","webcore","webcore trigger", "webcore macro","webcore macros","webcore triggers","pollution","air quality", + "room", "rooms","uv","uv index","occupancy","occupancy sensor", "occupancy sensors","lights","light","echo","echoes","alexa","alexas"] +} +def fillDeviceList(){ + def deviceList = getDeviceList(), DEVICES=["echo","alexa","echo device","alexa speakers", "alexa device"] + if (deviceList) deviceList.name.each{DEVICES << it } + if (deviceAlias && state.aliasList) state.aliasList.each{DEVICES << it.aliasNameLC} + return DEVICES +} +def fillFollowupList(){ return ["password","pin"] } +def fillCancelList(){ return ["cancel","stop","unschedule"] } +def fillMQList(){ + def MQ=["primary message queue","primary"] + if (getAAMQ().size()){ getAAMQ().each { MQ << it.label.replaceAll("[^a-zA-Z0-9 ]", "").toLowerCase() } } + //if (!MQ.size()) MQ<<"none" //Reenable when primary message queue is deprecated + return MQ +} +def fillOperatorsList(){ + def getDevList=mapDevices(false), deviceCMDlist=[] + if (deviceAlias) getDevList+=mapDevices(true) + basicVoc().each{deviceCMDlist<list.each{deviceCMDlist<" + } + return result +} +def findMacroReserved(macResCount=0){ + if (getAskAlexa().size()) getAskAlexa().each{ + if (it.label.toLowerCase()==~resList()) macResCount++ + for (int i = 1; i)"; switches.each{ result += it.label +"
" } } + if (getCheatDisplayList("switch") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("switch") +"
" } + if (dimmersSel()) { result += "

Dimmers (Valid Commands: {brightness number}, " + getList(levelVoc()+basicVoc()) + ")

"; dimmers.each{ result += it.label +"
" } } + if (getCheatDisplayList("level") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("level") +"
" } + if (cLightsSel()) { + result += "

Colored Lights (Valid Commands: {brightness number}, " + getList(colorVoc()+basicVoc()) + ")

"; cLights.each{ result += it.label +"
" } + result +="
Available Colors {color name}
" + getList(STColors().name) + "
" + } + if (getCheatDisplayList("color") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("color") +"
" } + if (cLightsKSel()) { + result += "

Temperature (Kelvin) Lights (Valid Commands: {brightness number}, " + getList(kTempVoc()+basicVoc()) + ")

"; cLightsK.each{ result += it.label +"

" } + result +="Available Temperatures {name}
Soft White, Warm White, Cool White, Daylight White
" + } + if (getCheatDisplayList("kTemp") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("kTemp") +"
" } + if (doorsSel()) { result += "

Doors (Valid Commands: "+ getList(doorVoc()+basicVoc()) +")

"; doors.each{ result += it.label +"
" } } + if (doorsSel() && pwNeeded && doorPW) {result += "
* Append 'Password ${password}' to activate your doors
" } + if (getCheatDisplayList("door") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("door") +"
" } + if (locksSel()) { result += "

Locks (Valid Commands: "+ getList(lockVoc()+basicVoc()) +")

"; locks.each{ result += it.label +"
" } } + if (locksSel() && pwNeeded && lockPW) {result += "
* Append 'Password ${password}' to activate your locks
" } + if (getCheatDisplayList("lock") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("lock") +"
" } + if (ocSensorsSel()) { result += "

Open/Close Sensors (Valid Command: "+ getList(contactVoc()) +")

"; ocSensors.each{ result += it.label +"
" } } + if (getCheatDisplayList("contact") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("contact") +"
" } + if (shadesSel()) { result += "

Shades (Valid Commands: "+ getList(shadeVoc()+basicVoc()) +")

"; shades.each{ result += it.label +"
" } } + if (getCheatDisplayList("shade") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("shade") +"
" } + if (tstatsSel()) { result += "

Thermostats (Valid Commands: {temperature setpoint}, "+ getList(tstatVoc()+basicVoc()) +")

"; tstats.each{ result += it.label +"
" } + if ((ecobeeCMD && MyEcobeeCMD) || (MyNextCMD)) result +="
* Please Note: Some commands are MyEcobee specific such as Get Tips {level}, Play Tips and Erase Tips
" + } + if (getCheatDisplayList("thermostat") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("thermostat") +"
" } + if (tempsSel()) { result += "

Temperature Sensors (Valid Commands: "+ getList(tempVoc()) +")

"; temps.each{ result += it.label +"
" } } + if (getCheatDisplayList("temperature") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("temperature") +"
" } + if (humidSel()) { result += "

Humidity Sensors (Valid Commands: "+ getList(humidVoc()) +")

"; humid.each{ result += it.label +"
" } } + if (getCheatDisplayList("humidity") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("humidity") +"
" } + if (fooBotSel()) { result += "

Foobot Air Quality Monitor (Valid Commands: "+ getList(fooBotVoc()) +")

"; fooBot.each{ result += it.label +"
" } } + if (getCheatDisplayList("pollution") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("pollution") +"
" } + if (uvSel()) { result += "

UV index devices (Valid Commands: "+ getList(uvVoc()) +")

"; UV.each{ result += it.label +"
" } } + if (getCheatDisplayList("uvIndex") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("uvIndex") +"
" } + if (speakersSel()) { result += "

Speakers (Valid Commands: {volume level}, "+ getList(speakerVoc()+basicVoc()) +")

"; speakers.each{ result += it.label +"
" } } + if (getCheatDisplayList("music") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("music") +"
" } + if (waterSel()) { result += "

Water Sensors (Valid Command: "+ getList(waterVoc()) +")

"; water.each{ result += it.label +"
" } } + if (getCheatDisplayList("water") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("water") +"
" } + if (presenceSel()) { + result += "

Presence Sensors (Valid Commands: "+ getList(presenceVoc()) + if (vPresenceCMD) result += ", check in, check out, arrive, depart, present, away, not present, gone" + result +=")

"; presence.each{ result += it.label +"
" } + if (vPresenceCMD) result +="
* Please Note: Not all presence sensor may respond to the check in/check out commands
" + } + if (getCheatDisplayList("presence") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("presence") +"
" } + if (accelerationSel()) { result += "

Acceleration Sensors (Valid Command: "+ getList(accelVoc()) +")

"; acceleration.each{ result += it.label +"
" } } + if (getCheatDisplayList("acceleration") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("acceleration") +"
" } + if (motionSel()) { result += "

Motion Sensors (Valid Command: "+ getList(motionVoc()) +")

"; motion.each{ result += it.label +"
" } } + if (getCheatDisplayList("motion") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("motion") +"
" } + if (listModes) { result += "

Modes (Valid Command: change/status)

"; listModes.each{ result += it +"
" } } + if (listModes && pwNeeded && modePW) {result += "
* Append 'password ${password}' to activate your modes
" } + if (occSel()){ result += "

Occupancy Sensors (Valid Commands: "+ getList(occVoc()+basicVoc()) +")

"; occupancy.each{ result += it.label +"
" } } + if (getCheatDisplayList("beacon") && deviceAlias) { result += "
Aliases
"; result += getCheatDisplayList("beacon") +"
" } + if (listSHM) { result += "

Smart Home Monitor (Valid Command: change/status)

"; listSHM.each{ result += it +"
" } } + if (listSHM && pwNeeded && shmPW) {result += "
* Append 'password ${password}' to change your Smart Home Monitor status
" } + if (listRoutines) { result += "

SmartThings Routines (Valid Command: run {routine name})

"; listRoutines.each{ result += it +"
" } } + if (listRoutines && pwNeeded && routinesPW) {result += "
* Append 'password ${password}' to activate your routines
" } + if (getAskAlexa().size() ) { + result += "

Ask Alexa Macros (Valid Command: run {macro name})

" + getAskAlexa().each { result += it.label + if (getAliasCheatList(it,'macro')) result += " (Aliases: ${getAliasCheatList(it,'macro')}}" + result += "
" + } + if (pwNeeded) {result += "
* Append 'password ${password}' if a macro is set up to use a password
" } + } + if (getVR().size()) { + result += "

Voice Reports (Valid Command: run {report name})

" + getVR().each {result += it.label + if (getAliasCheatList(it,"")) result += " (Aliases: ${getAliasCheatList(it,'')}}" + result += "
" + } + } + if (getRM().size()) { + def echoCount = 0 + result += "

Rooms/Groups (Valid Command: (Depending on the setup) on, off, set {brightness level}, lock, unlock, close, etc {room/group name})

" + getRM().each { result += it.label + if (it.getEchoAliasCount()) result +="*"; echoCount ++ + if (getAliasCheatList(it,"")) result += " (Aliases: ${getAliasCheatList(it,'')})" + result += "
" + } + if (echoCount) result +="
*=Rooms that have Echo devices associated with them
" + if (pwNeeded) result += "
** Append 'password ${password}' if a room/group has locks/doors that are set up to use a password
" + result += "
Please note:
You may use 'associate', 'sync', 'setup' or 'link' and the room name to associate the Echo device you are speaking to with the room. "+ + "
You can then replace the room name with 'in here', 'this group' or 'this room' such as 'Alexa, tell ${invocationName} to turn off the lights in here'.
" + } + if (getSCHD().size()) { + result += "

Schedules (Valid Command: on, off, list and status {schedule name})

" + getSCHD().each { result +="${it.label}
" } + } + if (getWR().size()) { + result += "

Weather Reports (Valid Command: run {report name})

" + getWR().each { result += it.label + if (getAliasCheatList(it,"")) result += " (Aliases: ${getAliasCheatList(it,'')}}" + result += "
" + } + } + result += "

Message Queues (Valid Commands: "+getList(msgVoc())+")

Primary Message Queue
" + if (getAAMQ().size()) getAAMQ().each { result += it.label+"
" } + result += "
Examples:
'Alexa, ask ${invocationName} to play messages in the {queue name}'
'Alexa, ask ${invocationName} to delete messages'
'Alexa, ask ${invocationName} to play {queue name} messages'
" + displayData(result) +} +private getAliasCheatList(obj, type){ + def aliases = "", aliasType = type =="macro" ? "macAlias" : "extAlias", count = type =="macro" ? macAliasCount() :obj.extAliasCount() + for (int i = 1; i + log.info "Installed Scenario: ${child.label}" + } +} + +//Version/Copyright/Information/Help + +private def textAppName() { + def text = "Smart Room Lighting and Dimming" +} + +private def textVersion() { + def version = "Parent App Version: 2.1.0 (03/19/2016)" + def childCount = childApps.size() + def childVersion = childCount ? childApps[0].textVersion() : "No scenarios installed" + return "${version}\n${childVersion}" +} + +private def textCopyright() { + def text = "Copyright © 2016 Michael Struck" +} + +private def textLicense() { + def text = + "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"+ + "\n\n"+ + " http://www.apache.org/licenses/LICENSE-2.0"+ + "\n\n"+ + "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." +} + +private def textHelp() { + def text = + "Within each scenario you create you can select motion sensors to control a set of lights. " + + "Each scenario can control dimmers or switches and can also be restricted " + + "to modes or between certain times and turned off after motion " + + "motion stops. Scenarios can also be limited to running once " + + "or to stop running if the physical switches are turned off."+ + "\n\nOn the dimmer options page, enter the 'on' or 'off' levels for the dimmers. You can choose to have the " + + "dimmers' level calculated between the 'on' and 'off' settings " + + "based on the current lux value. In other words, as it gets " + + "darker, the brighter the light level will be when motion is sensed." +} \ No newline at end of file diff --git a/smartapps/mitchpond/3400-x-keypad-manager.src/3400-x-keypad-manager.groovy b/smartapps/mitchpond/3400-x-keypad-manager.src/3400-x-keypad-manager.groovy new file mode 100644 index 00000000000..a6bb6adc677 --- /dev/null +++ b/smartapps/mitchpond/3400-x-keypad-manager.src/3400-x-keypad-manager.groovy @@ -0,0 +1,129 @@ +/** + * 3400-X Keypad Manager + * + * Copyright 2015 Mitch Pond + * + * 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. + * + */ +definition( + name: "3400-X Keypad Manager", + namespace: "mitchpond", + author: "Mitch Pond", + description: "Service manager for Centralite 3400-X security keypad. Keeps keypad state in sync with Smart Home Monitor", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + singleInstance: false) + + +preferences { + page(name: "setupPage") +} + +def setupPage() { + dynamicPage(name: "setupPage",title: "3400-X Keypad Manager", install: true, uninstall: true) { + section("Settings") { + input(name: "keypad", title: "Keypad", type: "capability.lockCodes", multiple: false, required: true) + input(name: "pin" , title: "PIN code", type: "number", range: "0000..9999", required: true) + paragraph "PIN should be four digits. Shorter PINs will be padded with leading zeroes. (42 becomes 0042)" + label(title: "Assign a name", required: false) + } + def routines = location.helloHome?.getPhrases()*.label + routines?.sort() + section("Routines", hideable: true, hidden: true) { + input(name: "armRoutine", title: "Arm/Away routine", type: "enum", options: routines, required: false) + input(name: "disarmRoutine", title: "Disarm routine", type: "enum", options: routines, required: false) + input(name: "stayRoutine", title: "Arm/Stay routine", type: "enum", options: routines, required: false) + //input(name: "nightRoutine", title: "Arm/Night routine", type: "enum", options: routines, required: false) + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. + subscribe(location,"alarmSystemStatus",alarmStatusHandler) + subscribe(keypad,"codeEntered",codeEntryHandler) + + //initialize keypad to correct state + def event = [name:"alarmSystemStatus", value: location.currentState("alarmSystemStatus").value, + displayed: true, description: "System Status is ${shmState}"] + alarmStatusHandler(event) +} + +//Returns the PIN padded with zeroes to 4 digits +private String getPIN(){ + return settings.pin.value.toString().padLeft(4,'0') +} + +// TODO: implement event handlers +def alarmStatusHandler(event) { + log.debug "Keypad manager caught alarm status change: "+event.value + if (event.value == "off") keypad?.setDisarmed() + else if (event.value == "away") keypad?.setArmedAway() + else if (event.value == "stay") keypad?.setArmedStay() +} + +private sendSHMEvent(String shmState){ + def event = [name:"alarmSystemStatus", value: shmState, + displayed: true, description: "System Status is ${shmState}"] + 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 correctCode = getPIN() + def data = evt.data as String + def armMode = '' + + 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 [] + } + + if (codeEntered == correctCode) { + log.debug "Correct PIN entered. Change SHM state to ${armMode}" + keypad.acknowledgeArmRequest(data) + sendSHMEvent(armMode) + execRoutine(armMode) + } + else { + log.debug "Invalid PIN" + //Could also call acknowledgeArmRequest() with a parameter of 4 to report invalid code. Opportunity to simplify code? + keypad.sendInvalidKeycodeResponse() + } +} \ No newline at end of file diff --git a/smartapps/mujica/alexa-connect.src/alexa-connect.groovy b/smartapps/mujica/alexa-connect.src/alexa-connect.groovy new file mode 100644 index 00000000000..388e0bd58f0 --- /dev/null +++ b/smartapps/mujica/alexa-connect.src/alexa-connect.groovy @@ -0,0 +1,439 @@ +/** + * + * 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. + * + * Alexa Connect + * + * Author: Ule + * Date: 2016-03-20 + * V 1.1 Added control Switch + * V 1.2 Added music selector, ex "play love music on dining room speaker" + * It use a radionomy music stations, you can create a natural voice name to every station, I have made a quick name like jazz, + * If there are more than 1 jazz station, a random station will be selected. + * V 1.3 Added strip junctions and default type in device name, like "play love music on dining room" now you can use just "dining room" when command play is triggered + * The same for lights, if you have a device named "the bedroom lights" the word "the" is removed, this is better for other languages like spanish. + * Now you can add more tags to station to find them easily like "kids, disney, french, Disney in french" + * V 1.4 Added Show Stations section + */ + + +definition( + name: "Alexa Connect", + namespace: "mujica", + author: "Ule", + description: "Connect external text command to Alexa", + category: "SmartThings Labs", + iconUrl: "http://urbansa.com/icons/mr.png", + iconX2Url: "http://urbansa.com/icons/mr@2x.png", + oauth: true) + +preferences { + page(name: "mainPage", title: "Alexa Connect", install: true, uninstall: true) + page(name: "stationsList", title: "Available Stations ...") +} + +def mainPage() { + dynamicPage(name: "mainPage") { + section("Authorize voice controlled devices"){ + input "switches", "capability.switch", title: "Switches", required: false ,multiple:true + input "speakers", "capability.musicPlayer", title: "Speakers", required:false ,multiple:true + } + + section() { + input "language", "enum", title: "Language?", required: true, defaultValue: "EN", options: ["EN","SP"] + input "mode", "enum", title: "Response Mode?", required: true, defaultValue: "Speaker",submitOnChange:true, options: ["Speaker","HTML Player"] + input "sonos", "capability.musicPlayer", title: "On this Speaker", required: mode == "Speaker"?true:false ,multiple:true + input "alexaApiKey", "text", title: "Alexa Access Key", required:true, defaultValue:"millave" + input "redirect", "bool", title: "Redirect?", defaultValue: false + input "urlRedirect", "text", title: "Url Redirect", defaultValue:"" + } + section(""){ + href "stationsList", title: "Stations",required: flase, description: "Show all available stations" + } + section("Web URL") { + paragraph "${state.webUrl?:""}" + } + section("Reset Token") { + paragraph "Activating this option, creates a new token when push the button in smart app list." + input "resetOauth", "bool", title: "Reset AOuth Access Token?", defaultValue: false + } + } +} + +def stationsList(){ + dynamicPage(name: "stationsList") { + section() { + paragraph getStationsList() + } + } +} + +def getStationsList(){ + def stationList = "" + def stations = getStations() + def stationLan = stations[language]?:stations["EN"] + stationLan.each{ stationTitle, stationNorm -> + stationList += stationTitle.capitalize() + " ("+stationNorm.capitalize()+")\r\n" + + } + stationList +} + + + +mappings { + path("/ui") { + action: [ + GET: "html", + ] + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + initialize() +} + +def initialize() { + subscribe(app, getURL) + if (!state.webUrl){ + getURL(null) + } +} + +def getURL(e) { + if (resetOauth) { + log.debug "Reseting Access Token" + state.accessToken = null + } + + if (!state.accessToken) { + createAccessToken() + log.debug "Creating new Access Token: $state.accessToken" + } + + state.webUrl = "https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/ui?access_token=${state.accessToken}&" + log.debug "WebUrl : ${state.webUrl}" +} + +def getStations(){ + [ + "EN":["70s 4u":"Seventies","80s 4u":"eighties","A 1 All Disney On Wide Radius":"kids,disney","A Better Christian Station":"Religious","A Better Classic Blues Vintage Station":"Classic","A Better Classic Rock Station":"Rock","A Better Classical Station":"Classic","A Better Love Songs Station":"love","A Blues Dream Classic & New Blues 24h":"Blues","A I R Radio Freestyle Slow":"Freestyle","A Jazz Dream Classic & New Jazz 24h":"Jazz","A Lounge Dream Relax 24h":"Lounge","A Slow Dream Rnb Soul 24h":"Soul","Aaa Soul":"Soul","Abaco Libros Y Cafe Radio":"Cafe","Abacus Fm Bach":"Bach","Abacus Fm Beethoven One":"Beethoven","Abacus Fm Chopin":"Chopin","Abacus Fm Mozart Piano":"Mozart","Abacus Fm Mozart Symphony":"Mozart","Abacus Fm Nature":"Nature","Abacus Fm Smooth Jazz":"Smooth Jazz","Abacus Fm Vintage Jazz":"Vintage Jazz","Abalone":"Abalone","Abc Classic":"Classic","Abc Jazz":"Jazz","Abc Lounge":"Lounge","Abc Love":"Love","Abc Piano":"Piano","Abc Relax":"Relax","Abc Symphony":"Classic","Abonni Cafe":"Cafe","Absolute Chillout":"Chillout","Aclassicfm Web":"Classic","Acoustic Fm":"Acoustic","Acoustic Guitar":"Acoustic","Adorale":"Adorale","Adventistinternetradio":"Religious","Air Jazz":"Jazz","Air Lounge":"Lounge","Alabanza Digital":"Religious","All 60s All The Time Keepfreemusic Com":"sixties","All Beethoven On Wide Radius":"Beethoven","All Jazz Radio":"Jazz","All Piano Radio":"Piano","All Smooth Jazz":"Smooth Jazz","Allegro Jazz":"Allegro Jazz","Alpenradio|volksmusik":"Alpenradio|volksmusik","Ambiance Classique":"Classic","Ambiance Lounge":"Lounge","Ambient And Lounge":"Lounge","American Roots":"American Roots","Anime Extremo Net":"Anime","Animusique":"Anime","Anunciandoelreinoradiocatolica Raphy Rey":"Religious","Atomik Radio":"Atomik Radio","Azur Blues":"Blues","Bachata Dominicana":"latin","Baladas Romanticas Radio":"Love","Barock Music":"Classic","Beautiful Music 101":"Classic","Beethoven Radio":"Beethoven","Best Blues I Know":"Blues","Best Of Slow":"Slow","Bestclubdance":"dance","Big Band Magic":"Big Band","Bob Marley":"Bob Marley","Boozik Jazzy":"Jazz","Cafe Romantico Radio":"love","Cantico Nuevo Stereo":"Religious","Chill 100":"Chill out","Chill Out Radio Gaia":"Chill Out","Chillout Classics":"Chill out","Chocolat Radio":"Hits","Christmas 1000":"Christmas","Christmas Wonderland Radio":"Christmas","Chronicles Christian Radio":"Religious","Cinemix":"movies","Cinescore":"movies","Classic Rock #1":"Rock","Classical Hits 1000":"hits","Classical Jazz Radio":"Jazz","Classicalmusicamerica Com":"Classic","Classicalways":"Classic","Cocktelera Blues":"Blues","Colombiabohemia":"latin","Colombiacrossover":"latin","Colombiaromantica":"latin","Colombiasalsarosa":"latin","Con Alma De Blues":"Blues","Cool Kids Radio":"Kids","Cristalrelax":"relax","Cumbias Inmortales":"latin","Dance90":"Dance","Dis Cover Radio":"Disc","Dlrpradio":"Dlrpradio","Dream Baby":"Dream Baby","Elium Rock":"Rock","Enchantedradio":"kids","Energiafmonline":"relax","Enjoystation":"relax","Esperanza 7":"Religious","Eure Du Blues":"Blues","F A Radio Animes":"Anime","Feeling Love":"Love","Feeling The Blues":"Blues","Feevermix":"Feevermix","Frequences Relaxation":"Relax","Funadosong":"Nature","Funky Blues":"Blues","Generikids":"kids","Gupshupcorner Com":"Gupshupcorner Com","Halloweenradio Net Kids":"Halloween","Healing Music Radio":"relax","Hippie Soul Radio":"Soul","Hit Radio 100":"Hits","Hit Radio Rocky Schlager":"Hits","Hits #1":"Hits","Hits 70s #1":"Seventies","Hits 80s #1":"eighties","Hits Classical Music 1000":"Classic","Hits My Music Zen":"relax","Hits Sweet Radio 1000":"Hits","Hot 40 Music":"Hits","Hotmixradio 80":"eighties","Hotmixradio 90":"nineties","Hotmixradio Baby":"kids","Hotmixradio Hits":"Hits","Hotmixradio Rock":"Rock","Illayarajavin Thevitatha Padalgal!":"Illayarajavin Thevitatha Padalgal!","Instrumental Hits":"classic","Instrumentals Forever":"classic","Intimisima Radio":"Intimisima Radio","Jamaican Roots Radio":"ethnic","Jazz Light":"Jazz","Jazz Lovers Radio":"Jazz","Jazz Swing Manouche Radio":"Jazz","Jazz Vespers Radio":"Jazz","Jazzclub":"Jazz","K Easy":"Easy","Kalasam Com":"Kalasam Com","Lagujawa":"Lagujawa","Las Grandes Bandas Radio":"Big band","Latina 104 Web":"Latin","Ledjam Radio":"Ledjam Radio","Les Grands Fan De Disney Radio":"kids","Los Grandes Grupos Radio":"Los Grandes Grupos Radio","Love Love Radio 2":"Love","Love Radio":"Love","Love Radio 2":"Love","Lovehitsradio":"Love","Lovemixradio":"Love","Made In Classic":"Classic","Martini In The Morning":"Martini In The Morning","Martini In The Morning (64k)":"Martini In The Morning (64k)","Martini In The Morning (mp3)":"Martini In The Morning (mp3)","Mocha":"Mocha","Mozart":"Mozart","Mozartiana":"Mozart","Musiconly Fm":"Musiconly Fm","Ocean Breeze":"Nature","Oldies 1000":"Oldies","Ourworld Pop":"Pop","Panda Pop Radio":"Pop","Panda Show Radio":"pop","Pop Y Rock En Español":"latin","Powerclub Station":"Powerclub Station","Que Viva Mexico":"latin","Radio Animex (musica Anime Y Mucho Mas)":"Anime","Radio Beats Fm":"Beat","Radio Cristiana Online":"Religious","Radio Dance #1":"Dance","Radio Hunter The Hitz Channel":"Radio Hunter The Hitz Channel","Radio Junior":"kids","Radio Love 141":"Love","Radio Mozart":"Mozart","Radio Nostalgia":"latin","Radio Otakus Dream":"Radio Otakus Dream","Radio Plenitude":"Radio Plenitude","Radio Stonata":"Radio Stonata","Radio Tango Velours":"Radio Tango Velours","Radiomyme Tv":"Radiomyme Tv","Radionomix":"Radionomix","Radiosky Music":"Radiosky Music","Radiounoplus":"Radiounoplus","Revolution Fm":"Revolution Fm","Rey De Corazones":"Rey De Corazones","Rjm Jazzy":"Jazz","Rjm Lounge":"Lounge","Romance Vos Plus Belle Chanson Damour":"Love","Romantica Digital":"Love","Sertanejo":"Sertanejo","Slowradio Com":"Slow","Smooth Jazz 101":"Smooth Jazz","Smooth Jazz 247":"Smooth Jazz","Smooth Jazz 4u":"Smooth Jazz","Smooth Riviera":"Smooth Riviera","Soft Riw Love Channel 100%":"Love","Sophisticated Easy Sounds":"Easy","Sorcerer Radio Disney Park Music":"Kids","The Great 80s":"eighties","Theneosoulcafe":"cafe","Topclub":"Topclub","Trop Beautiful":"Trop Beautiful","Trova Radio El Sentimiento Hecho Música":"latin","True Oldies Channel":"Oldies","Webradio Tirol":"Webradio Tirol","Wine Farm And Tourist Radio":"Wine Farm And Tourist Radio","Zen For You":"Relax"], + "SP":["70s 4u":"sesentas","80s 4u":"ochentas","A 1 All Disney On Wide Radius":"infantil ABC,disney","A Better Christian Station":"religiosa","A Better Classic Blues Vintage Station":"Blues","A Better Classic Rock Station":"Rock","A Better Classical Station":"Clásica","A Better Love Songs Station":"Romántica","A Blues Dream Classic & New Blues 24h":"blues","A I R Radio Freestyle Slow":"A I R Radio Freestyle Slow","A Jazz Dream Classic & New Jazz 24h":"Jazz","A Lounge Dream Relax 24h":"ambiente","A Slow Dream Rnb Soul 24h":"alma","Aaa Soul":"alma","Abaco Libros Y Cafe Radio":"café","Abacus Fm Bach":"Clásica","Abacus Fm Beethoven One":"Clásica","Abacus Fm Chopin":"Clásica","Abacus Fm Mozart Piano":"Piano","Abacus Fm Mozart Symphony":"Clásica","Abacus Fm Nature":"Ambiental","Abacus Fm Smooth Jazz":"Jazz","Abacus Fm Vintage Jazz":"Jazz","Abalone":"Abalone","Abc Classic":"Clásica","Abc Jazz":"Jazz","Abc Lounge":"Ambiente","Abc Love":"Romántica","Abc Piano":"Piano","Abc Relax":"Relajación","Abc Symphony":"Clásica","Abonni Cafe":"Café","Absolute Chillout":"ambiente","Aclassicfm Web":"Clásica","Acoustic Fm":"acústica","Acoustic Guitar":"acústica","Adorale":"Adorale","Adventistinternetradio":"religiosa","Air Jazz":"Jazz","Air Lounge":"ambiente","Alabanza Digital":"religiosa","All 60s All The Time Keepfreemusic Com":"sesentas","All Beethoven On Wide Radius":"Clásica","All Jazz Radio":"Jazz","All Piano Radio":"Piano","All Smooth Jazz":"Jazz","Allegro Jazz":"Jazz","Alpenradio|volksmusik":"Alpenradio|volksmusik","Ambiance Classique":"Clásica","Ambiance Lounge":"ambiente","Ambient And Lounge":"ambiente","American Roots":"etnica","Anime Extremo Net":"Anime Extremo Net","Animusique":"anime","Anunciandoelreinoradiocatolica Raphy Rey":"Anunciandoelreinoradiocatolica Raphy Rey","Atomik Radio":"Atomik Radio","Azur Blues":"Blues","Bachata Dominicana":"Bachata","Baladas Romanticas Radio":"Romántica","Barock Music":"Clásica","Beautiful Music 101":"hits","Beethoven Radio":"Clásica","Best Blues I Know":"Blues","Best Of Slow":"alma","Bestclubdance":"dance","Big Band Magic":"grandes bandas","Bob Marley":"Bob Marley","Boozik Jazzy":"Jazzy","Cafe Romantico Radio":"romántica","Cantico Nuevo Stereo":"religiosa","Chill 100":"ambiente","Chill Out Radio Gaia":"ambiente","Chillout Classics":"ambiente","Chocolat Radio":"café","Christmas 1000":"navidad","Christmas Wonderland Radio":"navidad","Chronicles Christian Radio":"religiosa","Cinemix":"cine","Cinescore":"cine","Classic Rock #1":"Rock","Classical Hits 1000":"Clásica","Classical Jazz Radio":"Jazz","Classicalmusicamerica Com":"Clásica","Classicalways":"Clásica","Cocktelera Blues":"Blues","Colombiabohemia":"Bohemia","Colombiacrossover":"Colombiana","Colombiaromantica":"Romántica","Colombiasalsarosa":"salsa","Con Alma De Blues":"Blues","Cool Kids Radio":"infantil","Cristalrelax":"relajación","Cumbias Inmortales":"Cumbias","Dance90":"Dance","Dis Cover Radio":"Disco","Dlrpradio":"Dlrpradio","Dream Baby":"Dream Baby","Elium Rock":"Rock","Enchantedradio":"infantil","Energiafmonline":"Relajación","Enjoystation":"Hits","Esperanza 7":"religiosa","Eure Du Blues":"Blues","F A Radio Animes":"Anime","Feeling Love":"Romántica","Feeling The Blues":"Blues","Feevermix":"Feevermix","Frequences Relaxation":"relajación","Funadosong":"Ambiental","Funky Blues":"Blues","Generikids":"infantil","Gupshupcorner Com":"Gupshupcorner Com","Halloweenradio Net Kids":"Halloween","Healing Music Radio":"Relajación","Hippie Soul Radio":"alma","Hit Radio 100":"Hits","Hit Radio Rocky Schlager":"Hits","Hits #1":"Hits","Hits 70s #1":"setentas","Hits 80s #1":"ochentas","Hits Classical Music 1000":"Clásica","Hits My Music Zen":"relajación","Hits Sweet Radio 1000":"Hits","Hot 40 Music":"Hits","Hotmixradio 80":"ochentas","Hotmixradio 90":"noventas","Hotmixradio Baby":"infantil","Hotmixradio Hits":"Hits","Hotmixradio Rock":"Rock","Illayarajavin Thevitatha Padalgal!":"Illayarajavin Thevitatha Padalgal!","Instrumental Hits":"Clásica","Instrumentals Forever":"Clásica","Intimisima Radio":"relajación","Jamaican Roots Radio":"Jamaica","Jazz Light":"Jazz","Jazz Lovers Radio":"Jazz","Jazz Swing Manouche Radio":"Jazz","Jazz Vespers Radio":"Jazz","Jazzclub":"Jazz","K Easy":"ambiente","Kalasam Com":"Kalasam Com","Lagujawa":"Lagujawa","Las Grandes Bandas Radio":"Grandes Bandas","Latina 104 Web":"Latina","Ledjam Radio":"Ledjam Radio","Les Grands Fan De Disney Radio":"Infantil","Los Grandes Grupos Radio":"Los Grandes Grupos Radio","Love Love Radio 2":"Romántica","Love Radio":"Romántica","Love Radio 2":"romántica","Lovehitsradio":"Romántica","Lovemixradio":"Romántica","Made In Classic":"Clásica","Martini In The Morning":"Martini In The Morning","Martini In The Morning (64k)":"Martini In The Morning (64k)","Martini In The Morning (mp3)":"Martini In The Morning (mp3)","Mocha":"Mocha","Mozart":"Clásica","Mozartiana":"Clásica","Musiconly Fm":"Musiconly Fm","Ocean Breeze":"Ambiental","Oldies 1000":"Antigua","Ourworld Pop":"Pop","Panda Pop Radio":"Pop","Panda Show Radio":"Panda Show Radio","Pop Y Rock En Español":"latina","Powerclub Station":"Powerclub Station","Que Viva Mexico":"latina","Radio Animex (musica Anime Y Mucho Mas)":"Anime","Radio Beats Fm":"Radio Beats Fm","Radio Cristiana Online":"religiosa","Radio Dance #1":"Dance","Radio Hunter The Hitz Channel":"Hits","Radio Junior":"infantil","Radio Love 141":"romántica","Radio Mozart":"Clásica","Radio Nostalgia":"antigua","Radio Otakus Dream":"Radio Otakus Dream","Radio Plenitude":"religiosa","Radio Stonata":"Radio Stonata","Radio Tango Velours":"Tango","Radiomyme Tv":"Radiomyme Tv","Radionomix":"hits","Radiosky Music":"Radiosky Music","Radiounoplus":"Radiounoplus","Revolution Fm":"Revolution Fm","Rey De Corazones":"latina","Rjm Jazzy":"Jazz","Rjm Lounge":"ambiente","Romance Vos Plus Belle Chanson Damour":"reomántica","Romantica Digital":"Romántica","Sertanejo":"Sertanejo","Slowradio Com":"Slowradio Com","Smooth Jazz 101":"Jazz","Smooth Jazz 247":"Jazz","Smooth Jazz 4u":"Jazz","Smooth Riviera":"Smooth Riviera","Soft Riw Love Channel 100%":"Romántica","Sophisticated Easy Sounds":"ambiente","Sorcerer Radio Disney Park Music":"infantil","The Great 80s":"ochentas","Theneosoulcafe":"café","Topclub":"hits","Trop Beautiful":"Trop Beautiful","Trova Radio El Sentimiento Hecho Música":"Romántica","True Oldies Channel":"antigua","Webradio Tirol":"Webradio Tirol","Wine Farm And Tourist Radio":"Wine Farm And Tourist Radio","Zen For You":"Relajación"] + ] +} + +def html() { + def text = params.text + def content + def order + def orderN + def command + def action + def device + def devices = [] + def metadata = "" + def matcher + def intensityN + def intensity + def speech + def supportCommand + def station = [] + def stationUri + def audioDevice + def len + def displayName + def defaultNames + def groupDevice + def stationTags + + + def radionomyStations = [["70s 4u":["key":"4u-70s","artURI":"https://i3.radionomy.com/radios/400/e9fa1d18-12eb-4202-b2b5-5bb9de4d7110.jpg","description":"70s 4u"]],["80s 4u":["key":"4u-80s","artURI":"https://i3.radionomy.com/radios/400/6d704e4a-3648-46c5-99e2-c09dad8a4b6b.jpg","description":"80s 4u"]],["A 1 All Disney On Wide Radius":["key":"a-1alldisneyonwideradius","artURI":"https://i3.radionomy.com/radios/400/7b0422c9-b5fe-43ab-ab88-d4bc7a8e12bf.jpg","description":"A 1 All Disney On Wide Radius"]],["A Better Christian Station":["key":"a-better-christian-station","artURI":"https://i3.radionomy.com/radios/400/e070105a-b8c4-46cd-a862-443eed752131.png","description":"A Better Christian Station"]],["A Better Classic Blues Vintage Station":["key":"a-better-classic-blues-vintage-station","artURI":"https://i3.radionomy.com/radios/400/e0c2baeb-a182-449c-bc40-033605b778b2.png","description":"A Better Classic Blues Vintage Station"]],["A Better Classic Rock Station":["key":"a-better-classic-rock-station","artURI":"https://i3.radionomy.com/radios/400/a7b7e31d-dfb3-41d6-95c0-2d4618ad94a0.png","description":"A Better Classic Rock Station"]],["A Better Classical Station":["key":"a-better-classical-station","artURI":"https://i3.radionomy.com/radios/400/b642f1d1-ff97-435b-b491-b72bd14101ed.png","description":"A Better Classical Station"]],["A Better Love Songs Station":["key":"a-better-love-songs-station","artURI":"https://i3.radionomy.com/radios/400/7a5a93a5-7c55-46dd-be18-7f2a602d95fa.png","description":"A Better Love Songs Station"]],["A Blues Dream Classic & New Blues 24h":["key":"abluesdream-classic-newblues24h","artURI":"https://i3.radionomy.com/radios/400/173e9156-fe49-4b1d-ab0b-c57033643c65.jpg","description":"A Blues Dream Classic & New Blues 24h"]],["A I R Radio Freestyle Slow":["key":"airradiofreestyleslow","artURI":"https://i3.radionomy.com/radios/400/72c08557-46ae-458d-8b73-fa7269c923c5.jpg","description":"A I R Radio Freestyle Slow"]],["A Jazz Dream Classic & New Jazz 24h":["key":"ajazzdream-classic-newjazz24h","artURI":"https://i3.radionomy.com/radios/400/ce2b5d0a-8e29-449c-ae63-2ec766a0beea.jpg","description":"A Jazz Dream Classic & New Jazz 24h"]],["A Lounge Dream Relax 24h":["key":"aloungedream-relax24h","artURI":"https://i3.radionomy.com/radios/400/3062eddb-fa11-437e-b087-307f18c9e72c.jpg","description":"A Lounge Dream Relax 24h"]],["A Slow Dream Rnb Soul 24h":["key":"aslowdream-rnbsoul24h","artURI":"https://i3.radionomy.com/radios/400/e3d49554-3298-4208-a29c-729b7da35203.jpg","description":"A Slow Dream Rnb Soul 24h"]],["Aaa Soul":["key":"aaa-soul","artURI":"https://i3.radionomy.com/radios/400/143dff74-42a8-43a8-aab4-6416f5d7bfd4.jpg","description":"Aaa Soul"]],["Abaco Libros Y Cafe Radio":["key":"abaco-libros-y-cafe-radio","artURI":"https://i3.radionomy.com/radios/400/6198c640-54d7-4158-8ed2-2910af286308.png","description":"Abaco Libros Y Cafe Radio"]],["Abacus Fm Bach":["key":"abacusfm-bach","artURI":"https://i3.radionomy.com/radios/400/df2e0bc8-43c7-4766-926a-37f2a66ab527.jpg","description":"Abacus Fm Bach"]],["Abacus Fm Beethoven One":["key":"abacusfm-beethoven-one","artURI":"https://i3.radionomy.com/radios/400/24a34658-a384-4d47-9512-180c855388c9.jpg","description":"Abacus Fm Beethoven One"]],["Abacus Fm Chopin":["key":"abacusfmchopin","artURI":"https://i3.radionomy.com/radios/400/543bd505-d716-45ab-8618-4e97bfb1083f.jpg","description":"Abacus Fm Chopin"]],["Abacus Fm Mozart Piano":["key":"abacusfm-mozart-piano","artURI":"https://i3.radionomy.com/radios/400/554cf332-7363-4629-a786-cf02290feb52.jpg","description":"Abacus Fm Mozart Piano"]],["Abacus Fm Mozart Symphony":["key":"abacusfm-mozart-symphony","artURI":"https://i3.radionomy.com/radios/400/9fe3af87-3c25-49fa-b324-76b2faa268fe.jpg","description":"Abacus Fm Mozart Symphony"]],["Abacus Fm Nature":["key":"abacusfm-nature","artURI":"https://i3.radionomy.com/radios/400/04041c84-31af-45d2-ac67-f8778352fed7.png","description":"Abacus Fm Nature"]],["Abacus Fm Smooth Jazz":["key":"abacusfmsmoothjazz","artURI":"https://i3.radionomy.com/radios/400/6f2050e2-b3b2-4246-8a95-29e4bab0e43c.png","description":"Abacus Fm Smooth Jazz"]],["Abacus Fm Vintage Jazz":["key":"abacusfm-vintage-jazz","artURI":"https://i3.radionomy.com/radios/400/d86503b6-5f94-425f-b258-a75686610051.jpg","description":"Abacus Fm Vintage Jazz"]],["Abalone":["key":"abalone","artURI":"https://i3.radionomy.com/radios/400/0cc98719-3a9b-4166-b477-70922abe8d3a.jpg","description":"Abalone"]],["Abc Classic":["key":"abc-classic","artURI":"https://i3.radionomy.com/radios/400/6fa656c3-1d22-4e0f-a269-72ca7c71b385.jpg","description":"Abc Classic"]],["Abc Jazz":["key":"abc-jazz","artURI":"https://i3.radionomy.com/radios/400/07bf66bf-fe82-4586-aa93-b53466827b45.jpeg","description":"Abc Jazz"]],["Abc Lounge":["key":"abc-lounge","artURI":"https://i3.radionomy.com/radios/400/e4e1f437-4350-4d5d-a0c2-a93b4d23f3fd.png","description":"Abc Lounge"]],["Abc Love":["key":"abc-love","artURI":"https://i3.radionomy.com/radios/400/697a534e-a139-45e1-8bca-b1108be32a5d.jpg","description":"Abc Love"]],["Abc Piano":["key":"abc-piano","artURI":"https://i3.radionomy.com/radios/400/bff35c96-8e0e-4c1b-9206-7ed3c5a9921e.jpg","description":"Abc Piano"]],["Abc Relax":["key":"abcrelax","artURI":"https://i3.radionomy.com/radios/400/ae8b5709-c336-4404-a5de-cfe3503dfa73.png","description":"Abc Relax"]],["Abc Symphony":["key":"abc-symphony","artURI":"https://i3.radionomy.com/radios/400/149454ad-e5bd-4a73-8adc-470eaf30994d.jpg","description":"Abc Symphony"]],["Abonni Cafe":["key":"abonnicafe","artURI":"https://i3.radionomy.com/radios/400/c654ca2e-7103-4863-83eb-fa9e50d26204.jpg","description":"Abonni Cafe"]],["Absolute Chillout":["key":"absolutechillout","artURI":"https://i3.radionomy.com/radios/400/ab56377f-4bfc-4efe-9959-5f59dd992048.jpg","description":"Absolute Chillout"]],["Aclassicfm Web":["key":"aclassicfm-web","artURI":"https://i3.radionomy.com/radios/400/c5f20273-70ff-48d3-b4cb-d4e17576d687.jpg","description":"Aclassicfm Web"]],["Acoustic Fm":["key":"acoustic-fm","artURI":"https://i3.radionomy.com/radios/400/3fa9cbc2-b21d-4ad7-9a5e-b7c60837e3eb.jpg","description":"Acoustic Fm"]],["Acoustic Guitar":["key":"acoustic-guitar","artURI":"https://i3.radionomy.com/radios/400/0b3ca480-a9c3-4e32-aa3b-09c497b6d37f.jpg","description":"Acoustic Guitar"]],["Adorale":["key":"adorale","artURI":"https://i3.radionomy.com/radios/400/41d94867-0227-469b-8120-8e92e97fd90e.jpg","description":"Adorale"]],["Adventistinternetradio":["key":"adventistinternetradio","artURI":"https://i3.radionomy.com/radios/400/895d5d33-5885-4e9c-aa3a-8d92e271c7ee.jpg","description":"Adventistinternetradio"]],["Air Jazz":["key":"air-jazz","artURI":"https://i3.radionomy.com/radios/400/3dfa939a-1477-4e53-a058-7e01342fb162.jpg","description":"Air Jazz"]],["Air Lounge":["key":"air-lounge","artURI":"https://i3.radionomy.com/radios/400/46facdd7-38aa-441b-9f4d-7287ff80cb19.jpg","description":"Air Lounge"]],["Alabanza Digital":["key":"alabanza-digital","artURI":"https://i3.radionomy.com/radios/400/d8ff6acb-da59-4346-a411-00a1a187d862.png","description":"Alabanza Digital"]],["All 60s All The Time Keepfreemusic Com":["key":"all60sallthetime-keepfreemusiccom","artURI":"https://i3.radionomy.com/radios/400/359538cf-c5a9-435f-abdf-5cd1b5c75b62.jpg","description":"All 60s All The Time Keepfreemusic Com"]],["All Beethoven On Wide Radius":["key":"allbeethovenonwideradius","artURI":"https://i3.radionomy.com/radios/400/854445cb-29d9-4739-861d-fbb0864da3af.jpg","description":"All Beethoven On Wide Radius"]],["All Jazz Radio":["key":"all-jazz-radio","artURI":"https://i3.radionomy.com/radios/400/dd1c1e7b-70a1-4909-a122-73b5ac32fb77.jpg","description":"All Jazz Radio"]],["All Piano Radio":["key":"all-piano-radio","artURI":"https://i3.radionomy.com/radios/400/f1b866cc-df0d-40fb-89b1-9eb4b8e46b4d.jpg","description":"All Piano Radio"]],["All Smooth Jazz":["key":"all-smooth-jazz","artURI":"https://i3.radionomy.com/radios/400/2f9ed072-d68f-484c-b31f-230d284ecdcb.jpg","description":"All Smooth Jazz"]],["Allegro Jazz":["key":"allegro-jazz","artURI":"https://i3.radionomy.com/radios/400/23f5ab68-531b-41a6-b6c0-0302472ece5c.jpg","description":"Allegro Jazz"]],["Alpenradio|volksmusik":["key":"alpenradio-volksmusik","artURI":"https://i3.radionomy.com/radios/400/4d1e1e24-6b78-44af-a7af-60dabd97b81d.png","description":"Alpenradio|volksmusik"]],["Ambiance Classique":["key":"ambiance-classique","artURI":"https://i3.radionomy.com/radios/400/9a7bc2ca-674d-4ce0-aa40-bdf81db040f4.jpg","description":"Ambiance Classique"]],["Ambiance Lounge":["key":"ambiance-lounge","artURI":"https://i3.radionomy.com/radios/400/605a9db6-19f1-4c14-8e6d-bb72758800c2.jpg","description":"Ambiance Lounge"]],["Ambient And Lounge":["key":"ambient-and-lounge","artURI":"https://i3.radionomy.com/radios/400/46ba1ce3-ab6f-4775-aec3-376b6a6b6829.jpg","description":"Ambient And Lounge"]],["American Roots":["key":"americanroots","artURI":"https://i3.radionomy.com/radios/400/fc6c6c3d-bf1c-43cd-8092-817e84dc9bcb.jpg","description":"American Roots"]],["Anime Extremo Net":["key":"animeextremonet","artURI":"https://i3.radionomy.com/radios/400/e1a274e2-fffc-400f-b862-b8f3c94072b9.png","description":"Anime Extremo Net"]],["Animusique":["key":"animusique","artURI":"https://i3.radionomy.com/radios/400/0ddf9941-70a6-48b4-a33a-4cca4271cc17.jpg","description":"Animusique"]],["Anunciandoelreinoradiocatolica Raphy Rey":["key":"anunciandoelreinoradiocatolica-raphy-rey","artURI":"https://i3.radionomy.com/radios/400/c8f940b2-717e-49ff-9423-81efd2833866.jpg","description":"Anunciandoelreinoradiocatolica Raphy Rey"]],["Atomik Radio":["key":"atomik_radio","artURI":"https://i3.radionomy.com/radios/400/fce739b2-1ec7-4556-aaad-f15c8c9fffbd.jpg","description":"Atomik Radio"]],["Azur Blues":["key":"azur-blues","artURI":"https://i3.radionomy.com/radios/400/31a8581f-f000-489a-9aaf-11ea86f374c0.jpg","description":"Azur Blues"]],["Bachata Dominicana":["key":"bachata-dominicana","artURI":"https://i3.radionomy.com/radios/400/0b119796-693d-44c4-97e7-748443d2d07d.jpg","description":"Bachata Dominicana"]],["Baladas Romanticas Radio":["key":"baladasromanticasradio","artURI":"https://i3.radionomy.com/radios/400/ab8031cd-6fec-4b7a-91ae-4cdb2aabbaef.jpg","description":"Baladas Romanticas Radio"]],["Barock Music":["key":"barock-music","artURI":"https://i3.radionomy.com/radios/400/e7427c6f-c075-4b79-8242-4ca41fa92e2a.jpg","description":"Barock Music"]],["Beautiful Music 101":["key":"beautifulmusic101","artURI":"https://i3.radionomy.com/radios/400/932214a6-5b9b-4817-8844-e7b9227452a2.jpg","description":"Beautiful Music 101"]],["Beethoven Radio":["key":"beethoven-radio","artURI":"https://i3.radionomy.com/radios/400/3f6b0238-bf48-4f38-a27c-191ed5c2f3b1.jpg","description":"Beethoven Radio"]],["Best Blues I Know":["key":"bestbluesiknow","artURI":"https://i3.radionomy.com/radios/400/236063d1-17ab-4b88-b825-874b78b539fb.jpg","description":"Best Blues I Know"]],["Best Of Slow":["key":"best-of-slow","artURI":"https://i3.radionomy.com/radios/400/1fc36fdc-efb9-48e3-83b5-fd209deea70d.jpg","description":"Best Of Slow"]],["Bestclubdance":["key":"bestclubdance","artURI":"https://i3.radionomy.com/radios/400/11f6de98-3f15-4245-91b7-cd984b5161a9.jpg","description":"Bestclubdance"]],["Big Band Magic":["key":"bigbandmagic","artURI":"https://i3.radionomy.com/radios/400/717fa084-749e-4640-8371-063504bd065a.jpg","description":"Big Band Magic"]],["Bob Marley":["key":"bob-marley","artURI":"https://i3.radionomy.com/radios/400/240d9432-d8d8-4444-b4c7-f55697c5f503.png","description":"Bob Marley"]],["Boozik Jazzy":["key":"boozik-jazzy","artURI":"https://i3.radionomy.com/radios/400/09c2fafd-1186-4aaf-b1c0-7b5e4c5a6d31.png","description":"Boozik Jazzy"]],["Cafe Romantico Radio":["key":"cafe-romantico-radio","artURI":"https://i3.radionomy.com/radios/400/4ca4c2c3-50e7-4410-9875-8c6c7a422014.jpg","description":"Cafe Romantico Radio"]],["Cantico Nuevo Stereo":["key":"canticonuevostereo","artURI":"https://i3.radionomy.com/radios/400/3711fe74-8079-47fe-a2ed-112967ba0518.png","description":"Cantico Nuevo Stereo"]],["Chill 100":["key":"100-chill","artURI":"https://i3.radionomy.com/radios/400/27d6729d-ca2a-4f93-9464-0034aa8b3c16.jpg","description":"Chill 100"]],["Chill Out Radio Gaia":["key":"chill-out-radio-gaia","artURI":"https://i3.radionomy.com/radios/400/11fbb7fe-a986-4832-a9af-f2bcb745dfd9.jpg","description":"Chill Out Radio Gaia"]],["Chillout Classics":["key":"chillout-classics","artURI":"https://i3.radionomy.com/radios/400/5341f69b-120e-4989-adac-49eeb09bcef2.png","description":"Chillout Classics"]],["Chocolat Radio":["key":"chocolat-radio","artURI":"https://i3.radionomy.com/radios/400/54e83c76-2bc4-49fa-814d-140fe9380a34.png","description":"Chocolat Radio"]],["Christmas 1000":["key":"1000christmas","artURI":"https://i3.radionomy.com/radios/400/69cd8c97-d19c-485b-9751-f46b7fd85a8e.jpg","description":"Christmas 1000"]],["Christmas Wonderland Radio":["key":"christmaswonderlandradio","artURI":"https://i3.radionomy.com/radios/400/4e2b9376-e115-4657-ad33-250f117c3069.png","description":"Christmas Wonderland Radio"]],["Chronicles Christian Radio":["key":"chronicles-christian-radio","artURI":"https://i3.radionomy.com/radios/400/fc3dc765-b64b-4822-837d-7e93e910745e.jpg","description":"Chronicles Christian Radio"]],["Cinemix":["key":"cinemix","artURI":"https://i3.radionomy.com/radios/400/e32b5e31-0ebe-42e8-bb90-6aa0dff1f787.jpg","description":"Cinemix"]],["Cinescore":["key":"cinescore","artURI":"https://i3.radionomy.com/radios/400/0f8ea7f3-8fd2-4a56-85e3-18bf80506c26.jpg","description":"Cinescore"]],["Classic Rock #1":["key":"-1classicrock","artURI":"https://i3.radionomy.com/radios/400/09b89235-5a9f-4e54-aa37-a55b43e28294.png","description":"Classic Rock #1"]],["Classical Hits 1000":["key":"1000classicalhits","artURI":"https://i3.radionomy.com/radios/400/dbeca4c5-99f6-480c-a1f3-39b4a44f9c8a.jpg","description":"Classical Hits 1000"]],["Classical Jazz Radio":["key":"classicaljazzradio","artURI":"https://i3.radionomy.com/radios/400/19585ea0-3c67-4858-8d18-4c583121e15e.png","description":"Classical Jazz Radio"]],["Classicalmusicamerica Com":["key":"classicalmusicamericacom","artURI":"https://i3.radionomy.com/radios/400/0d33ac4e-688d-479a-8709-54a1f4dd1fb2.png","description":"Classicalmusicamerica Com"]],["Classicalways":["key":"classicalways","artURI":"https://i3.radionomy.com/radios/400/072f5bc6-3932-435e-ad79-af1f5308b236.jpg","description":"Classicalways"]],["Cocktelera Blues":["key":"cocktelera-blues","artURI":"https://i3.radionomy.com/radios/400/561586b6-1eac-4dac-a094-1a030fffb378.jpg","description":"Cocktelera Blues"]],["Colombiabohemia":["key":"colombiabohemia","artURI":"https://i3.radionomy.com/radios/400/e7937666-062a-4044-97e5-155eb29bc29e.jpg","description":"Colombiabohemia"]],["Colombiacrossover":["key":"colombiacrossover","artURI":"https://i3.radionomy.com/radios/400/0b21c519-f70f-4a93-b278-4c39f98ac7a5.jpg","description":"Colombiacrossover"]],["Colombiaromantica":["key":"colombiaromantica","artURI":"https://i3.radionomy.com/radios/400/50c3a2a1-1728-4967-a0f4-ba2e294bfe52.jpg","description":"Colombiaromantica"]],["Colombiasalsarosa":["key":"colombiasalsarosa","artURI":"https://i3.radionomy.com/radios/400/3dc39dc4-181d-41f8-993e-14612d37c9bd.jpg","description":"Colombiasalsarosa"]],["Con Alma De Blues":["key":"con-alma-de-blues","artURI":"https://i3.radionomy.com/radios/400/3769d208-fc93-4ade-ba6c-7d19dba7a5a9.jpg","description":"Con Alma De Blues"]],["Cool Kids Radio":["key":"coolkidsradio","artURI":"https://i3.radionomy.com/radios/400/5d33658c-f48e-4bc1-a34c-d0d776869466.jpg","description":"Cool Kids Radio"]],["Cristalrelax":["key":"cristalrelax","artURI":"https://i3.radionomy.com/radios/400/9467e7e9-9ee6-48b7-90b2-b4d832563d84.jpg","description":"Cristalrelax"]],["Cumbias Inmortales":["key":"cumbias-inmortales","artURI":"https://i3.radionomy.com/radios/400/391d4165-ee5b-4447-a970-9f0713c1bef7.png","description":"Cumbias Inmortales"]],["Dance90":["key":"dance90","artURI":"https://i3.radionomy.com/radios/400/a24fd78c-3512-417d-83aa-67f87401762d.jpg","description":"Dance90"]],["Dis Cover Radio":["key":"dis--cover-radio","artURI":"https://i3.radionomy.com/radios/400/5fee7213-893d-4dce-8d35-707439e4fb16.jpg","description":"Dis Cover Radio"]],["Dlrpradio":["key":"dlrpradio","artURI":"https://i3.radionomy.com/radios/400/9b7a0890-b0d8-4438-bc30-7b44942b7cee.jpg","description":"Dlrpradio"]],["Dream Baby":["key":"dreambaby","artURI":"https://i3.radionomy.com/radios/400/e9aa4c40-64af-4a9e-bb2c-7b0731efa2b0.png","description":"Dream Baby"]],["Elium Rock":["key":"elium-rock","artURI":"https://i3.radionomy.com/radios/400/fbea3889-959a-4735-86ba-8446d1c5ce20.png","description":"Elium Rock"]],["Enchantedradio":["key":"enchantedradio","artURI":"https://i3.radionomy.com/radios/400/83c2bff8-ddb9-4720-9b08-a86baa38fa77.png","description":"Enchantedradio"]],["Energiafmonline":["key":"energiafmonline","artURI":"https://i3.radionomy.com/radios/400/22dbd426-c106-4ca4-9741-8de96e6c89fa.jpg","description":"Energiafmonline"]],["Enjoystation":["key":"EnjoyStation","artURI":"https://i3.radionomy.com/radios/400/96f07fcc-5b34-4afd-94ff-0148eff61c5a.jpg","description":"Enjoystation"]],["Esperanza 7":["key":"esperanza-7","artURI":"https://i3.radionomy.com/radios/400/f7456f4b-ed61-43d7-9baa-7009beff924b.jpg","description":"Esperanza 7"]],["Eure Du Blues":["key":"euredublues","artURI":"https://i3.radionomy.com/radios/400/04e20969-e960-4df2-914b-6ea88ba1356f.jpg","description":"Eure Du Blues"]],["F A Radio Animes":["key":"-f-a-radio-animes","artURI":"https://i3.radionomy.com/radios/400/42aa3bbe-7b37-4bc7-83fa-3523cddb434d.png","description":"F A Radio Animes"]],["Feeling Love":["key":"feeling-love","artURI":"https://i3.radionomy.com/radios/400/5983468e-ed0b-497c-b75f-cb62266394ba.jpg","description":"Feeling Love"]],["Feeling The Blues":["key":"feelingtheblues","artURI":"https://i3.radionomy.com/radios/400/dfdaf3ce-e15b-477e-8d9c-636620b089cd.jpg","description":"Feeling The Blues"]],["Feevermix":["key":"feevermix","artURI":"https://i3.radionomy.com/radios/400/3bcdd76a-9277-4a84-bbfb-0c462a92be88.jpg","description":"Feevermix"]],["Frequences Relaxation":["key":"frequences-relaxation","artURI":"https://i3.radionomy.com/radios/400/6f82e397-3cf8-4242-9be0-aa2b9cf23c2a.jpg","description":"Frequences Relaxation"]],["Funadosong":["key":"funadosong","artURI":"https://i3.radionomy.com/radios/400/a8dddb18-5c97-4a63-9aec-53ff9a10204b.jpg","description":"Funadosong"]],["Funky Blues":["key":"funkyblues","artURI":"https://i3.radionomy.com/radios/400/f605dafe-ff75-4388-a41b-a03e0ee44a9a.png","description":"Funky Blues"]],["Generikids":["key":"generikids","artURI":"https://i3.radionomy.com/radios/400/a9e9597d-5d91-48ec-a68a-6c5e7dc0a728.jpg","description":"Generikids"]],["Gupshupcorner Com":["key":"gupshupcornercom","artURI":"https://i3.radionomy.com/radios/400/a06b6831-2466-4d2a-b1f0-4a081d534270.png","description":"Gupshupcorner Com"]],["Halloweenradio Net Kids":["key":"halloweenradionet-kids","artURI":"https://i3.radionomy.com/radios/400/2752fb12-3a8d-486d-bcc9-59ea7ef969a9.jpg","description":"Halloweenradio Net Kids"]],["Healing Music Radio":["key":"healing-music-radio","artURI":"https://i3.radionomy.com/radios/400/a46a8f01-94ec-4a5f-8183-5d2bdb64d3b0.jpg","description":"Healing Music Radio"]],["Hippie Soul Radio":["key":"hippiesoulradio","artURI":"https://i3.radionomy.com/radios/400/ef6675f6-eb43-4414-a556-ffd48e476a2a.png","description":"Hippie Soul Radio"]],["Hit Radio 100":["key":"100-hit-radio","artURI":"https://i3.radionomy.com/radios/400/e6b3f820-49ce-4145-a74b-fd006cd7dfb0.jpg","description":"Hit Radio 100"]],["Hit Radio Rocky Schlager":["key":"hitradiorockyschlager","artURI":"https://i3.radionomy.com/radios/400/1e1fa0a1-599f-44ec-8b20-970fb80fc5f8.jpg","description":"Hit Radio Rocky Schlager"]],["Hits #1":["key":"-1hits","artURI":"https://i3.radionomy.com/radios/400/6382c82e-fc7f-46d3-b8ed-f679f653ba4f.jpg","description":"Hits #1"]],["Hits 70s #1":["key":"1hits70s","artURI":"https://i3.radionomy.com/radios/400/91cad0d3-649b-40ea-be1c-5ff6b341e187.jpg","description":"Hits 70s #1"]],["Hits 80s #1":["key":"1hits80s","artURI":"https://i3.radionomy.com/radios/400/cf0801cb-5de7-4037-be65-55b69842029f.jpg","description":"Hits 80s #1"]],["Hits Classical Music 1000":["key":"1000hitsclassicalmusic","artURI":"https://i3.radionomy.com/radios/400/c8fcd024-7e46-4b27-86fc-40ad3e0450e4.png","description":"Hits Classical Music 1000"]],["Hits My Music Zen":["key":"hit-s-my-music-zen","artURI":"https://i3.radionomy.com/radios/400/c9070eac-c93a-49a0-bb16-ead3aea656d9.jpg","description":"Hits My Music Zen"]],["Hits Sweet Radio 1000":["key":"1000-hits-sweet-radio","artURI":"https://i3.radionomy.com/radios/400/8f0a2e89-42a8-48bf-a629-1665c501b2bb.jpg","description":"Hits Sweet Radio 1000"]],["Hot 40 Music":["key":"hot40music","artURI":"https://i3.radionomy.com/radios/400/5158453a-6eb7-4307-9395-2c0079f9a3dd.png","description":"Hot 40 Music"]],["Hotmixradio 80":["key":"hotmixradio-80-128","artURI":"https://i3.radionomy.com/radios/400/2bf6e407-d028-4621-bc64-8964fa5e1d91.png","description":"Hotmixradio 80"]],["Hotmixradio 90":["key":"hotmixradio-90-128","artURI":"https://i3.radionomy.com/radios/400/b47f1666-c838-4231-ac36-ed1317d91d95.png","description":"Hotmixradio 90"]],["Hotmixradio Baby":["key":"hotmixradio-baby-128","artURI":"https://i3.radionomy.com/radios/400/f67022c7-e395-4678-929c-cd8ad90d4c91.png","description":"Hotmixradio Baby"]],["Hotmixradio Hits":["key":"hotmixradio-hits-128","artURI":"https://i3.radionomy.com/radios/400/8c5336b5-4818-4c22-80bc-874581587d1c.png","description":"Hotmixradio Hits"]],["Hotmixradio Rock":["key":"hotmixradio-rock-128","artURI":"https://i3.radionomy.com/radios/400/e53f5398-83cb-413f-a217-7588914a710c.png","description":"Hotmixradio Rock"]],["Illayarajavin Thevitatha Padalgal!":["key":"illayarajavinthevitathapadalgal-","artURI":"https://i3.radionomy.com/radios/400/d56cd94c-f969-4b6e-9c5a-74d37ffd849b.jpg","description":"Illayarajavin Thevitatha Padalgal!"]],["Instrumental Hits":["key":"instrumental-hits","artURI":"https://i3.radionomy.com/radios/400/d7d34d0a-3e01-4f2e-a4f9-ff0e9d9f33bd.jpg","description":"Instrumental Hits"]],["Instrumentals Forever":["key":"instrumentals-forever","artURI":"https://i3.radionomy.com/radios/400/352d97f2-3795-4920-864d-873bba4f759b.jpg","description":"Instrumentals Forever"]],["Intimisima Radio":["key":"intimisimaradio","artURI":"https://i3.radionomy.com/radios/400/5e8ce94c-fecc-4fca-b853-39a634408bc5.jpg","description":"Intimisima Radio"]],["Jamaican Roots Radio":["key":"jamaican-roots-radio","artURI":"https://i3.radionomy.com/radios/400/9e37a37b-80d8-4bdd-9407-f21ddbaaff87.png","description":"Jamaican Roots Radio"]],["Jazz Light":["key":"jazz-light","artURI":"https://i3.radionomy.com/radios/400/de35e244-b444-4df6-9b6e-d5b7b7c5a5c4.jpg","description":"Jazz Light"]],["Jazz Lovers Radio":["key":"jazzlovers","artURI":"https://i3.radionomy.com/radios/400/e30d917c-6914-41fa-bb81-a8aae9cf3267.png","description":"Jazz Lovers Radio"]],["Jazz Swing Manouche Radio":["key":"jazz-swing-manouche-radio","artURI":"https://i3.radionomy.com/radios/400/693ff831-ed35-4898-8cbb-2b6df182297a.jpg","description":"Jazz Swing Manouche Radio"]],["Jazz Vespers Radio":["key":"jazzvespersradio","artURI":"https://i3.radionomy.com/radios/400/1ae278f6-ea2f-4298-9f7e-5ecac3dc2cb3.png","description":"Jazz Vespers Radio"]],["Jazzclub":["key":"jazzclub","artURI":"https://i3.radionomy.com/radios/400/a1719ba1-ca0b-4149-8612-e79ef1cf9b0a.jpg","description":"Jazzclub"]],["K Easy":["key":"k-easy","artURI":"https://i3.radionomy.com/radios/400/94816175-e081-405f-a8d3-07a393398645.jpg","description":"K Easy"]],["Kalasam Com":["key":"kalasamcom","artURI":"https://i3.radionomy.com/radios/400/78f60f02-cffb-4a3b-973f-4b6df66dea0c.png","description":"Kalasam Com"]],["Lagujawa":["key":"lagujawa","artURI":"https://i3.radionomy.com/radios/400/b47b8539-1756-4406-8b86-6c2cfb4e085f.jpg","description":"Lagujawa"]],["Las Grandes Bandas Radio":["key":"lasgrandesbandasradio","artURI":"https://i3.radionomy.com/radios/400/f915b2fa-a0d0-474f-ae97-2f6586ce70cc.jpg","description":"Las Grandes Bandas Radio"]],["Latina 104 Web":["key":"latina-104web","artURI":"https://i3.radionomy.com/radios/400/d94e6eee-8712-489c-8d43-98322e265766.jpg","description":"Latina 104 Web"]],["Ledjam Radio":["key":"ledjamradio.mp3","artURI":"https://i3.radionomy.com/radios/400/704f3bbf-3500-4ae9-91da-984653ed5984.jpg","description":"Ledjam Radio"]],["Les Grands Fan De Disney Radio":["key":"lesgrandsfandedisneyradio","artURI":"https://i3.radionomy.com/radios/400/904dd749-7b72-415b-92f0-f43fc9613934.png","description":"Les Grands Fan De Disney Radio"]],["Los Grandes Grupos Radio":["key":"losgrandesgruposradio","artURI":"https://i3.radionomy.com/radios/400/865fa1ad-4471-4207-a183-42cbc780d3b6.jpg","description":"Los Grandes Grupos Radio"]],["Love Love Radio 2":["key":"love-2-love-radio","artURI":"https://i3.radionomy.com/radios/400/7b39d66d-9d0b-4855-b337-bb8bd6d30b89.jpg","description":"Love Love Radio 2"]],["Love Radio":["key":"love-radio","artURI":"https://i3.radionomy.com/radios/400/23d4546f-34a8-4d24-be01-00a88fb471f3.png","description":"Love Radio"]],["Love Radio":["key":"-loveradio","artURI":"https://i3.radionomy.com/radios/400/c3fb5ea3-f19d-40c6-bdf9-9da54de71cb0.jpg","description":"Love Radio"]],["Love Radio 2":["key":"2loveradio","artURI":"https://i3.radionomy.com/radios/400/f5b2ccea-dfe8-4ddd-9b24-5a72e2272349.png","description":"Love Radio 2"]],["Lovehitsradio":["key":"lovehitsradio","artURI":"https://i3.radionomy.com/radios/400/d1a22037-6db1-4991-8c18-e0bd4ee3d449.png","description":"Lovehitsradio"]],["Lovemixradio":["key":"LOVEMIXRADIO","artURI":"https://i3.radionomy.com/radios/400/9b0a3c68-d676-4259-8a42-cb5da9fe5cdb.jpg","description":"Lovemixradio"]],["Made In Classic":["key":"made-in-classic","artURI":"https://i3.radionomy.com/radios/400/6f10f0f5-081c-4745-adbf-f4408d1f7037.png","description":"Made In Classic"]],["Martini In The Morning":["key":"MartiniintheMorning","artURI":"https://i3.radionomy.com/radios/400/8b5cec18-2fcb-4276-9e67-4e19c85fab77.png","description":"Martini In The Morning"]],["Martini In The Morning (64k)":["key":"martiniinthemorning-64k-","artURI":"https://i3.radionomy.com/radios/400/bf8ec572-0d80-402c-8dbc-a7688878e409.png","description":"Martini In The Morning (64k)"]],["Martini In The Morning (mp3)":["key":"martiniinthemorning-mp3-","artURI":"https://i3.radionomy.com/radios/400/b83b9333-6fbc-472e-ac6b-502a5ac5b2dc.png","description":"Martini In The Morning (mp3)"]],["Mocha":["key":"mocha","artURI":"https://i3.radionomy.com/radios/400/90a8bc99-0411-46c0-8953-ab2a855fe4f7.jpg","description":"Mocha"]],["Mozart":["key":"mozart","artURI":"https://i3.radionomy.com/radios/400/6e3674e5-fa32-4d49-8fd1-0c6de28303f7.jpg","description":"Mozart"]],["Mozartiana":["key":"mozartiana","artURI":"https://i3.radionomy.com/radios/400/c6f09745-88b2-4697-bfed-41a14a08fce0.jpg","description":"Mozartiana"]],["Musiconly Fm":["key":"musiconly-fm","artURI":"https://i3.radionomy.com/radios/400/3ec58c83-f373-42a7-9d8a-a411da651990.png","description":"Musiconly Fm"]],["Ocean Breeze":["key":"oceanbreeze","artURI":"https://i3.radionomy.com/radios/400/db1e4df4-f662-41fc-aeaa-b2c6f4aadba5.jpg","description":"Ocean Breeze"]],["Oldies 1000":["key":"1000oldies","artURI":"https://i3.radionomy.com/radios/400/29b9457b-ba68-4f21-89b0-0ee470d42677.png","description":"Oldies 1000"]],["Ourworld Pop":["key":"ourworld-pop","artURI":"https://i3.radionomy.com/radios/400/8ded59c2-86ba-4610-babb-c797bbe24063.png","description":"Ourworld Pop"]],["Panda Pop Radio":["key":"pandapopradio","artURI":"https://i3.radionomy.com/radios/400/c8ec486d-52f6-4ce7-81b1-ca9a0104679e.jpg","description":"Panda Pop Radio"]],["Panda Show Radio":["key":"pandashowradio","artURI":"https://i3.radionomy.com/radios/400/a4770fa6-32a3-4a12-b785-edbcbf27d468.jpg","description":"Panda Show Radio"]],["Pop Y Rock En Español":["key":"popyrockenespanol","artURI":"https://i3.radionomy.com/radios/400/f19042fa-6270-477c-8f9e-6a10c8009104.jpg","description":"Pop Y Rock En Español"]],["Powerclub Station":["key":"powerclub-station","artURI":"https://i3.radionomy.com/radios/400/8d5b7483-8216-444b-b102-6293b165bb23.png","description":"Powerclub Station"]],["Que Viva Mexico":["key":"quevivamexico","artURI":"https://i3.radionomy.com/radios/400/7c075522-ce77-48c9-ad5e-be51f339618a.jpg","description":"Que Viva Mexico"]],["Radio Animex (musica Anime Y Mucho Mas)":["key":"radioanimex-musicaanimeymuchomas-","artURI":"https://i3.radionomy.com/radios/400/f1833d4a-bdbf-4c69-8281-2386f2ec4859.png","description":"Radio Animex (musica Anime Y Mucho Mas)"]],["Radio Beats Fm":["key":"radiobeatsfm","artURI":"https://i3.radionomy.com/radios/400/819a70bd-3a8c-4f8e-85ec-87e1015d3512.jpg","description":"Radio Beats Fm"]],["Radio Cristiana Online":["key":"radio-cristiana-online","artURI":"https://i3.radionomy.com/radios/400/ad09d178-e581-455b-bbc5-85db6144d458.png","description":"Radio Cristiana Online"]],["Radio Dance #1":["key":"1-radio-dance","artURI":"https://i3.radionomy.com/radios/400/15e76aee-7d15-41b8-b332-7a78c93d155e.jpg","description":"Radio Dance #1"]],["Radio Hunter The Hitz Channel":["key":"radiohunter-thehitzchannel","artURI":"https://i3.radionomy.com/radios/400/423ab2d6-d0ed-4060-9aef-a61be8a077f2.png","description":"Radio Hunter The Hitz Channel"]],["Radio Junior":["key":"radio-junior","artURI":"https://i3.radionomy.com/radios/400/17f8091f-b27e-4981-908a-b0e9800893c7.gif","description":"Radio Junior"]],["Radio Love 141":["key":"141radiolove","artURI":"https://i3.radionomy.com/radios/400/e6a4c728-396f-4c9f-a040-0af7954fb3f3.jpg","description":"Radio Love 141"]],["Radio Mozart":["key":"radio-mozart","artURI":"https://i3.radionomy.com/radios/400/0be77c4c-1f16-4eb2-8b8e-bef9be469c22.jpg","description":"Radio Mozart"]],["Radio Nostalgia":["key":"radio-nostalgia","artURI":"https://i3.radionomy.com/radios/400/93883daa-4b9a-4784-8108-05e7081d0bbc.png","description":"Radio Nostalgia"]],["Radio Otakus Dream":["key":"radio-otakus-dream","artURI":"https://i3.radionomy.com/radios/400/6f9e2777-52cb-491a-9ab7-966c40d72bbf.png","description":"Radio Otakus Dream"]],["Radio Plenitude":["key":"radio-plenitude","artURI":"https://i3.radionomy.com/radios/400/5312f4b9-7cff-4c88-8ede-1325c29d122a.jpg","description":"Radio Plenitude"]],["Radio Stonata":["key":"radio-stonata","artURI":"https://i3.radionomy.com/radios/400/e04950d7-27d8-45ab-9cc9-ec44a940b477.png","description":"Radio Stonata"]],["Radio Tango Velours":["key":"radio-tango-velours","artURI":"https://i3.radionomy.com/radios/400/1dd65dca-c1d5-4764-97bd-934f50c0b79e.jpg","description":"Radio Tango Velours"]],["Radiomyme Tv":["key":"radiomyme-tv","artURI":"https://i3.radionomy.com/radios/400/f9db390a-41f9-4e6d-8662-faa069aeb984.png","description":"Radiomyme Tv"]],["Radionomix":["key":"RadionoMiX","artURI":"https://i3.radionomy.com/radios/400/b596f9e0-e86b-44a8-a34c-9fc4eb790916.jpg","description":"Radionomix"]],["Radiosky Music":["key":"radiosky-music","artURI":"https://i3.radionomy.com/radios/400/d43c9a34-838d-4f60-bb61-a17b41488275.jpg","description":"Radiosky Music"]],["Radiounoplus":["key":"radiounoplus","artURI":"https://i3.radionomy.com/radios/400/d178a8a4-51fc-40f5-ba50-7fabf887c20b.jpg","description":"Radiounoplus"]],["Revolution Fm":["key":"revolution-fm","artURI":"https://i3.radionomy.com/radios/400/5e8fe722-3f30-45d7-ab7d-9993cd637343.png","description":"Revolution Fm"]],["Rey De Corazones":["key":"rey-de-corazones","artURI":"https://i3.radionomy.com/radios/400/d9294d11-b87f-4aa1-a796-5811d6ce8885.jpg","description":"Rey De Corazones"]],["Rjm Jazzy":["key":"rjm-jazzy","artURI":"https://i3.radionomy.com/radios/400/c27eae59-c53e-43ef-9ea0-27e43408dd34.jpg","description":"Rjm Jazzy"]],["Rjm Lounge":["key":"rjm-lounge","artURI":"https://i3.radionomy.com/radios/400/cde9d7e3-2b79-4dab-ab66-a99ab24dbc7c.jpg","description":"Rjm Lounge"]],["Romance Vos Plus Belle Chanson Damour":["key":"romance-vos-plus-belle-chanson-d-amour","artURI":"https://i3.radionomy.com/radios/400/a9d53e42-d38a-4177-b813-b3d5927ea2e1.png","description":"Romance Vos Plus Belle Chanson Damour"]],["Romantica Digital":["key":"romanticadigital","artURI":"https://i3.radionomy.com/radios/400/c5e8e9da-db8b-4f2c-a9ac-3b84435a7319.jpg","description":"Romantica Digital"]],["Sertanejo":["key":"-sertanejo","artURI":"https://i3.radionomy.com/radios/400/1fdb9fef-e0fa-4b4b-bb78-b5318405fcce.jpg","description":"Sertanejo"]],["Slowradio Com":["key":"slowradiocom","artURI":"https://i3.radionomy.com/radios/400/113409e1-7e4b-4fa8-a036-e57a9e974e10.jpg","description":"Slowradio Com"]],["Smooth Jazz 101":["key":"101smoothjazz","artURI":"https://i3.radionomy.com/radios/400/98489309-a9cf-4d10-a4fb-9e744f10e764.jpg","description":"Smooth Jazz 101"]],["Smooth Jazz 247":["key":"smoothjazz247","artURI":"https://i3.radionomy.com/radios/400/84e397e7-fdc2-4afe-bb70-d23b658708b1.jpg","description":"Smooth Jazz 247"]],["Smooth Jazz 4u":["key":"4u-smooth-jazz","artURI":"https://i3.radionomy.com/radios/400/6890d244-c476-49fe-9c6e-a504d2a06fcb.jpg","description":"Smooth Jazz 4u"]],["Smooth Riviera":["key":"smooth-riviera","artURI":"https://i3.radionomy.com/radios/400/3ac5a46b-a7fd-41ff-9217-51934076fc67.jpg","description":"Smooth Riviera"]],["Soft Riw Love Channel 100%":["key":"100-softriwlovechannel","artURI":"https://i3.radionomy.com/radios/400/38d8fac9-f169-4a6b-bbe9-5766f1e894c5.jpg","description":"Soft Riw Love Channel 100%"]],["Sophisticated Easy Sounds":["key":"sophisticated-easy-sounds","artURI":"https://i3.radionomy.com/radios/400/2085171d-c082-4809-80c1-1ec0635bed77.jpeg","description":"Sophisticated Easy Sounds"]],["Sorcerer Radio Disney Park Music":["key":"sorcererradio-disneyparkmusic","artURI":"https://i3.radionomy.com/radios/400/ca54f75e-1111-4fa7-85bc-55fefcc04c35.jpg","description":"Sorcerer Radio Disney Park Music"]],["The Great 80s":["key":"thegreat80s","artURI":"https://i3.radionomy.com/radios/400/048b1026-0978-4a5d-9e75-000536891282.jpg","description":"The Great 80s"]],["Theneosoulcafe":["key":"theneosoulcafe","artURI":"https://i3.radionomy.com/radios/400/0570e783-7561-4853-9c61-a2845862cbac.png","description":"Theneosoulcafe"]],["Topclub":["key":"topclub","artURI":"https://i3.radionomy.com/radios/400/29eea19a-2b84-4da5-99f0-3628b74c40f9.jpg","description":"Topclub"]],["Trop Beautiful":["key":"trop-beautiful","artURI":"https://i3.radionomy.com/radios/400/c059c321-b5b4-42e5-8ce8-ac1b00be0f93.jpg","description":"Trop Beautiful"]],["Trova Radio El Sentimiento Hecho Música":["key":"trova-radio---el-sentimiento-hecho-musica","artURI":"https://i3.radionomy.com/radios/400/26967808-f30d-4135-9e3f-63ba750ff06e.jpg","description":"Trova Radio El Sentimiento Hecho Música"]],["True Oldies Channel":["key":"trueoldieschannel","artURI":"https://i3.radionomy.com/radios/400/af4fdbde-1068-4c01-96d5-af5fcbfddc0b.jpg","description":"True Oldies Channel"]],["Webradio Tirol":["key":"webradio-tirol","artURI":"https://i3.radionomy.com/radios/400/e8641ee3-0f36-42af-85e6-e9446aeb6434.png","description":"Webradio Tirol"]],["Wine Farm And Tourist Radio":["key":"winefarmandtouristradio","artURI":"https://i3.radionomy.com/radios/400/ae13a8af-b363-4ddf-bdb7-e342117ffc7d.jpg","description":"Wine Farm And Tourist Radio"]],["Zen For You":["key":"zen-for-you","artURI":"https://i3.radionomy.com/radios/400/4f2f24e2-d4bd-4ad0-956c-26045dbcb338.jpg","description":"Zen For You"]]] + + /* You can use this example to fill your language commands */ + + /* s = start , e = ends , a = any , r = regex */ + + def commands = [ + "EN": ["on": ["s":["turn on","switch on"],"e":["on"],"default":["lights?"]],"off":["s":["turn off","switch off"],"e":["off"],"default":["lights?"]],"setLevel":["r":["turn on.+\\d+%","set.+\\d+%","dim.+\\d+%"],"default":["lights?"]],"playTrack": ["s":["play","start","tune"],"default":["speakers?"]],"stop": ["s":["stop"],"default":["speakers?"]],"setVolume":["r":["(turn up|turn down)(.*\\d+%)?(.*low)?(.*half)?(.*high)?","(mute)"],"default":["speakers?"]]], + "SP": ["on": ["s":["luces","enciende","ilumina"],"default":["lu(z|ces)"]],"off":["s":["apaga","oscurece","luces fuera"],"default":["lu(z|ces)"]],"setLevel":["r":["enciende.+\\d+%","ilumina.+\\d+%","disminuye.+\\d+%","ajusta.+\\d+%"],"default":["lu(z|ces)"]],"playTrack": ["s":["reproduce","toca", "reproducir","tocar","sintoniza","sintonizar"],"default":["bocinas?"]],"stop": ["s":["para","detén","corta","finaliza","acaba"],"default":["bocinas?"]],"setVolume":["r":["(sube|baja|configura|ajusta|ajuste).*volumen(.*\\d+%)?(.*bajo)?(.*medio)?(.*alto)?","(silenciar|silencio)"],"default":["bocinas?"]]] // "setVolumeLow":["r":["(baja|silencia).*volumen"] + ] + + def stations = getStations() + + def junctions = [ + "EN":["the","off","in","on"], + "SP":["el","del","de","la"] + ] + + def levels = [ + "EN":["mute":"0","low":"20","medium":"50","half":"50","high":"90"], + "SP":["silenciar":"0","silencio":"0","bajo":"20", "medio":"50","alto":"90"] + ] + + log.trace "text $text" + + if (text){ + order = text.toLowerCase() + commands[language].each { actions, actionsValues -> + actionsValues.each{key,value -> + switch(key){ + case "a": + value.each{ + if (order.contains(it)){ + action = actions + } + } + break + case "s": + value.each{ + if (order.startsWith(it)){ + action = actions + } + } + break + case "e": + value.each{ + if (order.endsWith(it)){ + action = actions + } + } + break + case "r": + value.each{ + matcher = order =~ /$it/ + if (matcher){ + action = actions + } + } + break + } + } + + } + + log.trace " action $action" + + + def stationLan = stations[language]?:stations["EN"] + stationLan.each{ stationTitle, stationNorm -> + if (order.contains(stationNorm.toLowerCase())){ + if (stationNorm.length() > len){ + station = [stationTitle] + len = stationNorm.length() + }else if(stationNorm.length() == len){ + station << stationTitle + } + } + } + + + len = 0 + orderN = order + junctions[language].each{junction -> + orderN = orderN.replaceAll(~/\b$junction\b/,"").replaceAll("\\s+", " ") + } + + if (action == "on" || action == "off" || action == "setLevel"){ + defaultNames = commands[language][action]["default"] + groupDevice="switches" + }else if (action == "playTrack" || action == "stop" || action == "setVolume"){ + defaultNames = commands[language][action]["default"] + groupDevice="speakers" + } + if (groupDevice){ + settings[groupDevice].each { + defaultNames = defaultNames ?:[""] + defaultNames.each{value -> + displayName = value ? it.displayName.toLowerCase().replaceAll(~/$value/, "").replaceAll("\\s+", " ").trim():it.displayName.toLowerCase() + } + junctions[language].each{junction -> + displayName = displayName.replaceAll(~/\b$junction\b/,"").replaceAll("\\s+", " ").trim() + } + if (orderN.contains(displayName)){ + if (it.displayName.length() > len){ + len = it.displayName.length() + device = it.displayName.toLowerCase() + if (action){ + it.supportedCommands.each {com -> + if (action.replaceAll("setVolume","setLevel") == com.name){ + supportCommand = true; + audioDevice = groupDevice="speakers" ? true :false; + } + } + } + } + } + } + } + log.trace text + } + + + if(action && !device){ + text = "nodevice" + } + if(!action && device){ + text = "noaction" + } + if(action && device && !supportCommand){ + text = "nosupportcommand" + } + if(action && device && supportCommand){ + text = "ok"; + switch(action){ + case "on": + switches.each { + if (it.displayName.toLowerCase() == device ){ + it.on() + } + } + break + case "off": + switches.each { + if (it.displayName.toLowerCase() == device ){ + it.off() + } + } + break + case "stop": + speakers.each { + if (it.displayName.toLowerCase() == device ){ + it.stop() + } + } + break + case "setLevel": + matcher = order =~ /\d+%/ + if (matcher){ + intensity = (matcher[0] =~ /\d+/)[0].toInteger() + intensity = intensity > 100 ? 100:intensity + } + if (intensity >= 0){ + switches.each { + if (it.displayName.toLowerCase() == device ){ + it.setLevel(intensity) + } + } + } + break + case "setVolume": + matcher = order =~ /\d+%/ + if (matcher){ + intensity = (matcher[0] =~ /\d+/)[0] + } + if (!intensity){ + levels[language].each{levelDesc, levelValue-> + if (order ==~ /.*$levelDesc.*/){ + intensity = levelValue + } + } + } + if (intensity){ + intensity = intensity.toInteger() > 100 ? 100:intensity.toInteger() + speakers.each { + if (it.displayName.toLowerCase() == device ){ + it.setLevel(intensity) + } + } + }else{ + text = "noaction" + } + break + case "playTrack": + len = 0; + stations[language].each { stationTitle, stationNorm -> + stationTags = stationNorm.tokenize(",") + stationTags.each{stationTag -> + if (order.contains(stationTag.toLowerCase().trim())){ + if (stationTag.length() > len){ + station = [stationTitle] + len = stationTag.length() + }else if(stationTag.length() == len){ + station << stationTitle + } + } + } + } + log.trace "station $station" + if (station){ + station.sort{Math.random()} + stationUri = "x-rincon-mp3radio://listen.radionomy.com/${radionomyStations[station[0]].key[0]}" + speakers.each { + if (it.displayName.toLowerCase() == device ){ + it.playTrack(stationUri,"object.item.audioItem.audioBroadcastRadionomy${groovy.xml.XmlUtil.escapeXml(radionomyStations[station[0]].description[0])}${groovy.xml.XmlUtil.escapeXml(radionomyStations[station[0]].artURI[0])}${groovy.xml.XmlUtil.escapeXml(station[0])}${groovy.xml.XmlUtil.escapeXml("x-rincon-mp3radio://listen.radionomy.com/${radionomyStations[station[0]].key[0]}")} ") + } + } + }else{ + text = "nomusic" + } + break + } + } + + + + + + + + content = "" + + + if (text){ + if (text == "ok" || text == "nodevice" || text == "noaction" || text == "nosupportcommand" || text == "nomusic"){ + speech = [uri: "x-rincon-mp3radio://tts.freeoda.com/sound/" + text + ".mp3", duration: "10"] + }else{ + speech = [uri: "x-rincon-mp3radio://tts.freeoda.com/alexa.php/" + "?key=$alexaApiKey&text=" + URLEncoder.encode(text, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-" , duration: "0"] + } + + + if (mode == "Speaker"){ + if(!audioDevice){ + sonos.playTrack(speech.uri) + } + if(redirect){ + metadata = "" + } + }else{ + content = "" + if(redirect){ + content = content + "
" + } + } + } + + + def info = "" + render contentType: "text/html", data: + + info + + + """ + + + + $metadata + + + + + + +
$content
+ + + """ +} \ No newline at end of file diff --git a/smartapps/mujica/mediarenderer-connect.src/mediarenderer-connect.groovy b/smartapps/mujica/mediarenderer-connect.src/mediarenderer-connect.groovy new file mode 100644 index 00000000000..a04de5d811e --- /dev/null +++ b/smartapps/mujica/mediarenderer-connect.src/mediarenderer-connect.groovy @@ -0,0 +1,587 @@ +/** + * MediaRenderer Service Manager v 2.1.0 + * + * Author: SmartThings - Ulises Mujica + */ + +definition( + name: "MediaRenderer (Connect)", + namespace: "mujica", + author: "SmartThings - Ulises Mujica", + description: "Allows you to control your Media Renderer from the SmartThings app. Perform basic functions like play, pause, stop, change track, and check artist and song name from the Things screen.", + category: "SmartThings Labs", + singleInstance: true, + iconUrl: "https://graph.api.smartthings.com/api/devices/icons/st.secondary.smartapps-tile?displaySize=2x", + iconX2Url: "https://graph.api.smartthings.com/api/devices/icons/st.secondary.smartapps-tile?displaySize=2x" +) + +preferences { + + page(name: "MainPage", title: "Search and config your Media Renderers", install:true, uninstall: true){ + section("") { + href(name: "discover",title: "Discovery process",required: false,page: "mediaRendererDiscovery",description: "tap to start searching") + } + section("Options", hideable: true, hidden: true) { + input("refreshMRInterval", "number", title:"Enter ip changes interval (min)",defaultValue:"15", required:false) + input("updateMRInterval", "number", title:"Enter refresh players interval (min)",defaultValue:"3", required:false) + } + section("") { + href(name: "watchDog",title: "WatchDog",required: false,page: "watchDogPage",description: "tap to config WatchDog") + } + } + page(name: "mediaRendererDiscovery", title:"Discovery Started!") + page(name: "watchDogPage", title:"WatchDog") +} + +def mediaRendererDiscovery() +{ + log.trace "mediaRendererDiscovery() state.subscribe ${state.subscribe}" + if(canInstallLabs()) + { + + int mediaRendererRefreshCount = !state.mediaRendererRefreshCount ? 0 : state.mediaRendererRefreshCount as int + state.mediaRendererRefreshCount = mediaRendererRefreshCount + 1 + def refreshInterval = 5 + + def options = mediaRenderersDiscovered() ?: [] + + def numFound = options.size() ?: 0 + + if(!state.subscribe) { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //mediaRenderer discovery request every 5 //25 seconds + if((mediaRendererRefreshCount % 8) == 0) { + discoverMediaRenderers() + } + + //setup.xml request every 3 seconds except on discoveries + if(((mediaRendererRefreshCount % 1) == 0) && ((mediaRendererRefreshCount % 8) != 0)) { + verifyMediaRendererPlayer() + } + + return dynamicPage(name:"mediaRendererDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval) { + section("Please wait while we discover your MediaRenderer. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedMediaRenderer", "enum", required:false, title:"Select Media Renderer (${numFound} found)", multiple:true, options:options + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + + To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"mediaRendererDiscovery", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + } +} + +private discoverMediaRenderers() +{ + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:MediaRenderer:1", physicalgraph.device.Protocol.LAN)) +} + + +private verifyMediaRendererPlayer() { + def devices = getMediaRendererPlayer().findAll { it?.value?.verified != true } + + devices.each { + verifyMediaRenderer((it?.value?.ip + ":" + it?.value?.port), it?.value?.ssdpPath) + } +} + +private verifyMediaRenderer(String deviceNetworkId, String ssdpPath) { + String ip = getHostAddress(deviceNetworkId) + if(!ssdpPath){ + ssdpPath = "/" + } + log.trace "verifyMediaRenderer($deviceNetworkId, $ssdpPath, $ip)" + sendHubCommand(new physicalgraph.device.HubAction("""GET $ssdpPath HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) +} + +Map mediaRenderersDiscovered() { + def vmediaRenderers = getVerifiedMediaRendererPlayer() + def map = [:] + vmediaRenderers.each { + def value = "${it.value.name}" + def key = it.value.ip + ":" + it.value.port + map["${key}"] = value + } + map +} + +def getMediaRendererPlayer() +{ + state.mediaRenderers = state.mediaRenderers ?: [:] +} + +def getVerifiedMediaRendererPlayer() +{ + getMediaRendererPlayer().findAll{ it?.value?.verified == true } +} + +def installed() { + log.trace "installed()" + //initialize() +} + +def updated() { + log.trace "updated()" + if (selectedMediaRenderer) addMediaRenderer() + unsubscribe() + state.subscribe = false + unschedule() + clearMediaRenderers() + scheduleTimer() + timerAll() + scheduleActions() + refreshAll() + syncDevices() + subscribeToEvents() +} + +def uninstalled() { + def devices = getChildDevices() + devices.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +def initialize() { + // remove location subscription aftwards + log.trace "initialize()" + //scheduledRefreshHandler() +} + + +def clearMediaRenderers(){ + log.trace "clearMediaRenderers()" + def devices = getChildDevices() + def player + def players = [:] + devices.each { device -> + player = getMediaRendererPlayer().find{ it?.value?.uuid == device.getDataValue('uuid') } + if (player){ + players << player + } + } + state.mediaRenderers = players + +} + +def scheduledRefreshHandler(){ + +} + +def scheduledTimerHandler() { + timerAll() +} + +def scheduledActionsHandler() { + syncDevices() + //runIn(60, scheduledRefreshHandler) +} + +private scheduleTimer() { + def cron = "0 0/1 * * * ?" + schedule(cron, scheduledTimerHandler) +} + +private scheduleActions() { + def minutes = Math.max(settings.refreshMRInterval.toInteger(),1) + def cron = "0 0/${minutes} * * * ?" + schedule(cron, scheduledActionsHandler) +} + +private syncDevices() { + if(!state.subscribe) { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + discoverMediaRenderers() +} + +private timerAll(){ + state.actionTime = new Date().time + childDevices*.poll() +} + +private refreshAll(){ + childDevices*.refresh() +} + +def addMediaRenderer() { + def players = getVerifiedMediaRendererPlayer() + def runSubscribe = false + selectedMediaRenderer.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newPlayer = players.find { (it.value.ip + ":" + it.value.port) == dni } + if (newPlayer){ + d = addChildDevice("mujica", "DLNA Player", dni, newPlayer?.value.hub, [label:"${newPlayer?.value.name} Speaker","data":["model":newPlayer?.value.model,"avtcurl":newPlayer?.value.avtcurl,"avteurl":newPlayer?.value.avteurl,"rccurl":newPlayer?.value.rccurl,"rceurl":newPlayer?.value.rceurl,"pcurl":newPlayer?.value.pcurl,"peurl":newPlayer?.value.peurl,"udn":newPlayer?.value.udn,"dni":dni]]) + } + runSubscribe = true + } + } +} + +def locationHandler(evt) { + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseEventMessage(description) + def msg = parseLanMessage(description) + + if (msg?.headers?.sid) + { + childDevices*.each { childDevice -> + if(childDevice.getDataValue('subscriptionId') == ((msg?.headers?.sid ?:"") - "uuid:")|| childDevice.getDataValue('subscriptionId1') == ((msg?.headers?.sid ?:"") - "uuid:")){ + childDevice.parse(description) + } + } + } + + parsedEvent << ["hub":hub] + + if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:MediaRenderer:1")) + { //SSDP DISCOVERY EVENTS + def mediaRenderers = getMediaRendererPlayer() + + if (!(mediaRenderers."${parsedEvent.ssdpUSN.toString()}")) + { //mediaRenderer does not exist + + mediaRenderers << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } + else + { // update the values + + def d = mediaRenderers."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + } + if (deviceChangedValues) { + def children = getChildDevices() + children.each { + if (parsedEvent.ssdpUSN.toString().contains(it.getDataValue("udn"))) { + it.setDeviceNetworkId((parsedEvent.ip + ":" + parsedEvent.port)) //could error if device with same dni already exists + it.updateDataValue("dni", (parsedEvent.ip + ":" + parsedEvent.port)) + it.refresh() + log.trace "Updated Device IP" + + } + } + } + } + } + else if (parsedEvent.headers && parsedEvent.body) + { // MEDIARENDER RESPONSES + def headerString = new String(parsedEvent?.headers?.decodeBase64()) + def bodyString = new String(parsedEvent.body.decodeBase64()) + + def type = (headerString =~ /Content-Type:.*/) ? (headerString =~ /Content-Type:.*/)[0] : null + def body + def device + if (bodyString?.contains("xml")) + { // description.xml response (application/xml) + body = new XmlSlurper().parseText(bodyString) + log.trace "MEDIARENDER RESPONSES ${body?.device?.modelName?.text()}" + // Avoid add sonos devices + device = body?.device + body?.device?.deviceList?.device?.each{ + if (it?.deviceType?.text().contains("urn:schemas-upnp-org:device:MediaRenderer:1")) { + device = it + } + } + if ( device?.deviceType?.text().contains("urn:schemas-upnp-org:device:MediaRenderer:1")) + { + def avtcurl = "" + def avteurl = "" + def rccurl = "" + def rceurl = "" + def pcurl = "" + def peurl = "" + + + device?.serviceList?.service?.each{ + if (it?.serviceType?.text().contains("AVTransport")) { + avtcurl = it?.controlURL?.text().startsWith("/")? it?.controlURL.text() : "/" + it?.controlURL.text() + avteurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL.text() : "/" + it?.eventSubURL.text() + } + if (it?.serviceType?.text().contains("RenderingControl")) { + rccurl = it?.controlURL?.text().startsWith("/")? it?.controlURL?.text() : "/" + it?.controlURL?.text() + rceurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL?.text() : "/" + it?.eventSubURL?.text() + } + if (it?.serviceType?.text().contains("Party")) { + pcurl = it?.controlURL?.text().startsWith("/")? it?.controlURL?.text() : "/" + it?.controlURL?.text() + peurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL?.text() : "/" + it?.eventSubURL?.text() + } + + } + + + def mediaRenderers = getMediaRendererPlayer() + def player = mediaRenderers.find {it?.key?.contains(device?.UDN?.text())} + if (player) + { + player.value << [name:device?.friendlyName?.text(),model:device?.modelName?.text(), serialNumber:device?.UDN?.text(), verified: true,avtcurl:avtcurl,avteurl:avteurl,rccurl:rccurl,rceurl:rceurl,pcurl:pcurl,peurl:peurl,udn:device?.UDN?.text()] + } + + } + } + else if(type?.contains("json")) + { //(application/json) + body = new groovy.json.JsonSlurper().parseText(bodyString) + } + } +} + +private def parseEventMessage(Map event) { + //handles mediaRenderer attribute events + return event +} + +private def parseEventMessage(String description) { + def event = [:] + def parts = description.split(',') + parts.each { part -> + part = part.trim() + if (part.startsWith('devicetype:')) { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) { + event.ssdpUSN = valueString + } + } + else if (part.startsWith('ssdpTerm:')) { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) { + part -= "headers:" + def valueString = part.trim() + if (valueString) { + event.headers = valueString + } + } + else if (part.startsWith('body')) { + part -= "body:" + def valueString = part.trim() + if (valueString) { + event.body = valueString + } + } + } + + if (event.devicetype == "04" && event.ssdpPath =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ && !event.ssdpUSN && !event.ssdpTerm){ + def matcher = event.ssdpPath =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ + def ssdpUSN = matcher[0] + event.ssdpUSN = "uuid:$ssdpUSN::urn:schemas-upnp-org:device:MediaRenderer:1" + event.ssdpTerm = "urn:schemas-upnp-org:device:MediaRenderer:1" + } + event +} + + +/////////CHILD DEVICE METHODS +def parse(childDevice, description) { + def parsedEvent = parseEventMessage(description) + + if (parsedEvent.headers && parsedEvent.body) { + def headerString = new String(parsedEvent.headers.decodeBase64()) + def bodyString = new String(parsedEvent.body.decodeBase64()) + + def body = new groovy.json.JsonSlurper().parseText(bodyString) + } else { + return [] + } +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private getHostAddress(d) { + def parts = d.split(":") + def ip = convertHexToIP(parts[0]) + def port = convertHexToInt(parts[1]) + return ip + ":" + port +} + +private Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} + + + +/*watch dog*/ + + +def watchDogPage() { + dynamicPage(name: "watchDogPage") { + def anythingSet = anythingSet() + + if (anythingSet) { + section("Verify Timer When"){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "temperature", "capability.temperatureMeasurement", title: "Temperature", required: false, multiple: true + ifSet "powerMeter", "capability.powerMeter", title: "Power Meter", required: false, multiple: true + ifSet "energyMeter", "capability.energyMeter", title: "Energy", required: false, multiple: true + ifSet "signalStrength", "capability.signalStrength", title: "Signal Strength", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + } + } + def hideable = anythingSet || app.installationState == "COMPLETE" + def sectionTitle = anythingSet ? "Select additional triggers" : "Verify Timer When..." + + section(sectionTitle, hideable: hideable, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "temperature", "capability.temperatureMeasurement", title: "Temperature", required: false, multiple: true + ifUnset "signalStrength", "capability.signalStrength", title: "Signal Strength", required: false, multiple: true + ifUnset "powerMeter", "capability.powerMeter", title: "Power Meter", required: false, multiple: true + ifUnset "energyMeter", "capability.energyMeter", title: "Energy Meter", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true + } + } +} + +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water", "temperature","signalStrength","powerMeter","energyMeter","button1","timeOfDay","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +private takeAction(evt) { + def eventTime = new Date().time + if (eventTime > ( 60000 + 3 * 1000 * 60 + state.actionTime?:0)) { + scheduleTimer() + timerAll() + } +} + +def eventHandler(evt) { + takeAction(evt) +} + +def modeChangeHandler(evt) { + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def subscribeToEvents() { + //subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(temperature, "temperature", eventHandler) + subscribe(powerMeter, "power", eventHandler) + subscribe(energyMeter, "energy", eventHandler) + subscribe(signalStrength, "lqi", eventHandler) + subscribe(signalStrength, "rssi", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + if (triggerModes) { + subscribe(location, modeChangeHandler) + } +} + +def getGXState(){ + childDevices*.refreshParty(4) +} \ No newline at end of file diff --git a/smartapps/pstuart/generic-video-camera-child.src/generic-video-camera-child.groovy b/smartapps/pstuart/generic-video-camera-child.src/generic-video-camera-child.groovy new file mode 100644 index 00000000000..e9eb471d38a --- /dev/null +++ b/smartapps/pstuart/generic-video-camera-child.src/generic-video-camera-child.groovy @@ -0,0 +1,82 @@ +/** +* Generic Video Camera Child +* +* Copyright 2016 Patrick Stuart +* +* 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. +* +*/ +definition( + name: "Generic Video Camera Child", + namespace: "pstuart", + author: "Patrick Stuart", + description: "Child Video Camera SmartApp", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + + +preferences { + page(name: "mainPage", title: "Install Video Camera", install: true, uninstall:true) { + section("Camera Name") { + label(name: "label", title: "Name This Camera", required: true, multiple: false, submitOnChange: true) + } + section("Add a Camera") { + input("CameraStreamPathList","enum", title: "Camera Stream Path", description: "Please enter your camera's streaming path", required:false, submitOnChange: true, + options: [ //add your camera urls here + ["rtsp://user:password@[ipaddress]/Streaming/Channels/1":"Name of Camera"], //hikvision + ["http://[ipaddress]:[port]/mjpeg.cgi?user=user&password=password&channel=1.mjpeg":"Name of Camera"], //dlink 932l + ["http://user:password@[ipaddress]/nphMotionJpeg?Resolution=640x480&Quality=Standard":"Name of Camera"] //panasonic bl-140c + ], displayDuringSetup: true) + + + input("CameraStreamPathCustom","string", title: "Camera Stream Path", description: "Please enter your camera's streaming path", defaultValue: settings?.CameraStreamPathList, required:false, displayDuringSetup: true) + + } + } + +} + +def installed() { + log.debug "Installed" + + initialize() +} + +def updated() { + log.debug "Updated" + + unsubscribe() + initialize() +} + +def initialize() { + log.debug "CameraStreamPathList is $CameraStreamPathList" + log.debug "CameraStreamPathCustom is $CameraStreamPathCustom" + if(CameraStreamPathList) { state.CameraStreamPath = CameraStreamPathList } + if(CameraStreamPathCustom) { state.CameraStreamPath = CameraStreamPathCustom } + try { + def DNI = (Math.abs(new Random().nextInt()) % 99999 + 1).toString() + def cameras = getChildDevices() + if (cameras) { + removeChildDevices(getChildDevices()) + } + def childDevice = addChildDevice("pstuart", "Generic Video Camera", DNI, null, [name: app.label, label: app.label, completedSetup: true]) + } catch (e) { + log.error "Error creating device: ${e}" + } +} + +private removeChildDevices(delete) { + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} \ No newline at end of file diff --git a/smartapps/pstuart/generic-video-camera-connect.src/generic-video-camera-connect.groovy b/smartapps/pstuart/generic-video-camera-connect.src/generic-video-camera-connect.groovy new file mode 100644 index 00000000000..d3c76aba476 --- /dev/null +++ b/smartapps/pstuart/generic-video-camera-connect.src/generic-video-camera-connect.groovy @@ -0,0 +1,57 @@ +/** +* Generic Video Camera Connect +* +* Copyright 2016 Patrick Stuart +* +* 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. +* +*/ +definition( + name: "Generic Video Camera Connect", + namespace: "pstuart", + author: "Patrick Stuart", + description: "This smartapp installs the Generic Video Camera Connect App so you can add multiple child video cameras", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + singleInstance: true) + + +preferences { + page(name: "mainPage", title: "Existing Camera", install: true, uninstall: true) { + if(state?.installed) { + section("Add a New Camera") { + app "Generic Video Camera Child", "pstuart", "Generic Video Camera Child", title: "New Camera", page: "mainPage", multiple: true, install: true + } + } else { + section("Initial Install") { + paragraph "This smartapp installs the Generic Video Camera Connect App so you can add multiple child video cameras. Click install / done then go to smartapps in the flyout menu and add new cameras or edit existing cameras." + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + state.installed = true +} \ No newline at end of file diff --git a/smartapps/rayzurbock/bigtalker2.src/bigtalker2.groovy b/smartapps/rayzurbock/bigtalker2.src/bigtalker2.groovy new file mode 100644 index 00000000000..1b0c919f80b --- /dev/null +++ b/smartapps/rayzurbock/bigtalker2.src/bigtalker2.groovy @@ -0,0 +1,3557 @@ +definition( + name: "BigTalker2", + namespace: "rayzurbock", + author: "rayzur@rayzurbock.com", + description: "Let's talk about mode changes, switches, motions, and so on.", + category: "Fun & Social", + singleInstance: true, + iconUrl: "http://lowrance.cc/ST/icons/BigTalker-2.0.6.png", + iconX2Url: "http://lowrance.cc/ST/icons/BigTalker@2x-2.0.6.png", + iconX3Url: "http://lowrance.cc/ST/icons/BigTalker@2x-2.0.6.png") + +preferences { + page(name: "pageStart") + page(name: "pageStatus") + page(name: "pageTalkNow") + page(name: "pageConfigureSpeechDeviceType") + page(name: "pageConfigureDefaults") + page(name: "pageHelpPhraseTokens") +//End preferences +} + +def pageStart(){ + state.childAppName = "BigTalker2-Child" + state.parentAppName = "BigTalker2" + state.namespace = "rayzurbock" + setAppVersion() + state.supportedVoices = ["Ivy(en-us)","Joanna(en-us)","Joey(en-us)","Justin(en-us)","Kendra(en-us)","Kimberly(en-us)","Salli(en-us)","Amy(en-gb)","Brian(en-gb)","Emma(en-gb)","Miguel(es-us)","Penelope(es-us)"] + if (checkConfig()) { + // Do nothing here, but run checkConfig() + } + dynamicPage(name: "pageStart", title: "Big Talker", install: false, uninstall: (app.getInstallationState() == "COMPLETE")){ + section(){ + LOGDEBUG("install state=${app.getInstallationState()}.") + def mydebug_pollnow = "" + if (!(state.configOK)) { + href "pageConfigureSpeechDeviceType", title:"Configure", description:"Tap to configure" + } else { + //V1Method href "pageConfigureEvents", title: "Configure Events", description: "Tap to configure events" + href "pageStatus", title:"Status", description:"Tap to view status" + href "pageConfigureDefaults", title: "Configure Defaults", description: "Tap to configure defaults" + href "pageTalkNow", title:"Talk Now", description:"Tap to setup talk now" + } + } + section("Event Groups") {} + section(){ + def apps = getChildApps()?.sort{ it.label } + if (state.configOK) { + if (apps?.size() == 0) { + paragraph "You have not configured any event groups yet." + app(name: "BTEvt-", appName: state.childAppName, namespace: state.namespace, title: "Add Event Group", description: "Tap to configure event triggers", multiple: true, uninstall: false) + } else { + app(name: "BTEvt-", appName: state.childAppName, namespace: state.namespace, title: "Add Event Group", description: "Tap to configure event triggers", multiple: true, uninstall: false) + } + } + } + section(){ + if ((settings?.debugmode == true) && (state.speechDeviceType == "capability.musicPlayer") && (settings?.resumePlay == true)) { + input name: "debug_pollnow", type: "bool", title: "DEBUG: Poll Now (simply toggle)", multiple: false, required: false, submitOnChange: true, defaultValue: false + if (!(settings.debug_pollnow == mydebug_pollnow)) { poll() } + } + } + section("About"){ + def AboutApp = "" + AboutApp += 'Big Talker is a SmartApp that can make your house talk depending on various triggered events.\n\n' + AboutApp += 'Pair with a SmartThings compatible audio device such as Sonos, Ubi, LANnouncer, VLC Thing (running on your computer or Raspberry Pi), a DLNA device using the "Generic MediaRenderer" SmartApp/Device and/or AskAlexa SmartApp\n\n' + AboutApp += 'You can contribute to the development of this SmartApp by making a PayPal donation to rayzur@rayzurbock.com or visit http://rayzurbock.com/store\n\n' + if (!(state.appversion == null)){ + AboutApp += "Big Talker ${state.appversion}\nhttp://www.github.com/rayzurbock\n" + } else { + AboutApp += 'Big Talker \nhttp://www.github.com/rayzurbock\n' + } + paragraph(AboutApp) + } + } +} + +def pageStatus(){ + //dynamicPage(name: "pageStatus", title: "Big Talker is configured as follows:", nextPage: "pageConfigure"){ + dynamicPage(name: "pageStatus", title: "Big Talker is configured as follows:", install: false, uninstall: false){ + String enabledDevices = "" + + //BEGIN STATUS DEFAULTS + enabledDevices = "Speech Device Mode:\n" + enabledDevices += " " + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "musicPlayer (Sonos, VLCThing, Generic DLNA)" + } + if (state.speechDeviceType == "capability.speechSynthesis") { + enabledDevices += "speechSynthesis (Ubi, LANnouncer)" + } + enabledDevices += "\n\n" + enabledDevices += "Default Speech Devices:\n" + enabledDevices += " " + settings.speechDeviceDefault?.each(){ + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.speechVolume && state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Adjust Volume To: ${settings.speechVolume}%" + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Default Resume Audio: ${settings?.resumePlay}" + enabledDevices += "\n\n" + } + enabledDevices += "Default Modes:\n" + enabledDevices += " " + settings.speechModesDefault.each(){ + enabledDevices += "${it}," + } + if (settings.defaultStartTime) { + enabledDevices += "\n\n" + def defStartTime = getTimeFromDateString(settings.defaultStartTime, true) + def defEndTime = getTimeFromDateString(settings.defaultEndTime, true) + enabledDevices += "Default Allowed Talk Time:\n ${defStartTime} - ${defEndTime}" + } + enabledDevices += "\n\n" + enabledDevices += "Hub ZipCode* for Weather: ${location.zipCode}\n" + enabledDevices += "*SmartThings uses GPS to ZipCode conversion; May not be exact" + + section ("Defaults:"){ + //NEEDS DEVELOPMENT + paragraph enabledDevices + //paragraph "Nothing else is viewable at this time. I'm working on the best method to expose this data from the child apps in one location, if possible." + } + enabledDevices = "" + //END STATUS DEFAULTS + + + //BEGIN STATUS TIME SCHEDULED EVENTS GROUP 1 + if (settings.timeSlotTime1){ + enabledDevices += "AT: ${getTimeFromDateString(settings.timeSlotTime1, true)} \n" + enabledDevices += "ON: \n" + enabledDevices += " " + def i = 0 + timeSlotDays1.each() { + enabledDevices += "${it}," + i += 1 + if (i == 3) { enabledDevices += "\n " } + } + enabledDevices += "\n" + enabledDevices += "SAY: \n" + enabledDevices += " ${timeSlotOnTime1}\n" + if (settings.timeSlotSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.timeSlotSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + enabledDevices += "\n\n" + } + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.timeSlotResumePlay1 == null)) ? settings.timeSlotResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.timeSlotModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.timeSlotModes1.each() { + enabledDevices += "${it}," + } + } + if (!(enabledDevices == "")) { + section ("Time Schedule 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS TIME SCHEDULED EVENTS GROUP 1 + //BEGIN STATUS TIME SCHEDULED EVENTS GROUP 2 + if (settings.timeSlotTime2){ + enabledDevices += "AT: ${getTimeFromDateString(settings.timeSlotTime2, true)} \n" + enabledDevices += "ON: \n" + enabledDevices += " " + def i = 0 + timeSlotDays2.each() { + enabledDevices += "${it}," + i += 1 + if (i == 3) { enabledDevices += "\n " } + } + enabledDevices += "\n" + enabledDevices += "SAY: \n" + enabledDevices += " ${timeSlotOnTime2}\n" + if (settings.timeSlotSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.timeSlotSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + enabledDevices += "\n\n" + } + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.timeSlotResumePlay2 == null)) ? settings.timeSlotResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.timeSlotModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.timeSlotModes2.each() { + enabledDevices += "${it}," + } + } + if (!(enabledDevices == "")) { + section ("Time Schedule 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS TIME SCHEDULED EVENTS GROUP 2 + //BEGIN STATUS TIME SCHEDULED EVENTS GROUP 3 + if (settings.timeSlotTime3){ + enabledDevices += "AT: ${getTimeFromDateString(settings.timeSlotTime3, true)} \n" + enabledDevices += "ON: \n" + enabledDevices += " " + def i = 0 + timeSlotDays3.each() { + enabledDevices += "${it}," + i += 1 + if (i == 3) { enabledDevices += "\n " } + } + enabledDevices += "\n" + enabledDevices += "SAY: \n" + enabledDevices += " ${timeSlotOnTime3}\n" + if (settings.timeSlotSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.timeSlotSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + enabledDevices += "\n\n" + } + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.timeSlotResumePlay3 == null)) ? settings.timeSlotResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.timeSlotModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.timeSlotModes3.each() { + enabledDevices += "${it}," + } + } + if (!(enabledDevices == "")) { + section ("Time Schedule 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS TIME SCHEDULED EVENTS GROUP 3 + + //BEGIN STATUS CONFIG MOTION GROUP 1 + if (settings.motionDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.motionDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n" + if (settings.motionTalkActive1) { + enabledDevices += "Say on active:\n ${settings.motionTalkActive1}\n\n" + } + if (settings.motionTalkInactive1) { + enabledDevices += "Say on inactive:\n ${settings.motionTalkInactive1}\n\n" + } + if (settings.motionSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.motionSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.motionResumePlay1 == null)) ? settings.motionResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.motionModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.motionModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.motionStartTime1) { + def customStartTime = getTimeFromDateString(settings.motionStartTime1, true) + def customEndTime = getTimeFromDateString(settings.motionEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Motion Sensor Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG MOTION GROUP 1 + //BEGIN STATUS CONFIG MOTION GROUP 2 + if (settings.motionDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.motionDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n" + if (settings.motionTalkActive2) { + enabledDevices += "Say on active:\n ${settings.motionTalkActive2}\n\n" + } + if (settings.motionTalkInactive2) { + enabledDevices += "Say on inactive:\n ${settings.motionTalkInactive2}\n\n" + } + if (settings.motionSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.motionSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.motionResumePlay2 == null)) ? settings.motionResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.motionModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.motionModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.motionStartTime2) { + def customStartTime = getTimeFromDateString(settings.motionStartTime2, true) + def customEndTime = getTimeFromDateString(settings.motionEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Motion Sensor Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG MOTION GROUP 2 + //BEGIN STATUS CONFIG MOTION GROUP 3 + if (settings.motionDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.motionDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n" + if (settings.motionTalkActive3) { + enabledDevices += "Say on active:\n ${settings.motionTalkActive3}\n\n" + } + if (settings.motionTalkInactive3) { + enabledDevices += "Say on inactive:\n ${settings.motionTalkInactive3}\n\n" + } + if (settings.motionSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.motionSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.motionResumePlay3 == null)) ? settings.motionResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.motionModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.motionModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.motionStartTime3) { + def customStartTime = getTimeFromDateString(settings.motionStartTime3, true) + def customEndTime = getTimeFromDateString(settings.motionEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Motion Sensor Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG MOTION GROUP 3 + + //BEGIN STATUS CONFIG SWITCH GROUP 1 + if (settings.switchDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.switchDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.switchTalkOn1) { + enabledDevices += "Say when switched ON:\n ${settings.switchTalkOn1}\n\n" + } + if (settings.switchTalkOff1) { + enabledDevices += "Say when switched OFF:\n ${settings.switchTalkOff1}\n\n" + } + if (settings.switchSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.switchSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.switchResumePlay1 == null)) ? settings.switchResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.switchModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.switchModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.switchStartTime1) { + def customStartTime = getTimeFromDateString(settings.switchStartTime1, true) + def customEndTime = getTimeFromDateString(settings.switchEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Switch Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SWITCH GROUP 1 + //BEGIN STATUS CONFIG SWITCH GROUP 2 + if (settings.switchDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.switchDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.switchTalkOn2) { + enabledDevices += "Say when switched ON:\n ${settings.switchTalkOn2}\n\n" + } + if (settings.switchTalkOff1) { + enabledDevices += "Say when switched OFF:\n ${settings.switchTalkOff2}\n\n" + } + if (settings.switchSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.switchSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.switchResumePlay2 == null)) ? settings.switchResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.switchModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.switchModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.switchStartTime2) { + def customStartTime = getTimeFromDateString(settings.switchStartTime2, true) + def customEndTime = getTimeFromDateString(settings.switchEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Switch Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SWITCH GROUP 2 + //BEGIN STATUS CONFIG SWITCH GROUP 3 + if (settings.switchDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.switchDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.switchTalkOn3) { + enabledDevices += "Say when switched ON:\n ${settings.switchTalkOn3}\n\n" + } + if (settings.switchTalkOff3) { + enabledDevices += "Say when switched OFF:\n ${settings.switchTalkOff3}\n\n" + } + if (settings.switchSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.switchSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.switchResumePlay3 == null)) ? settings.switchResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.switchModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.switchModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.switchStartTime3) { + def customStartTime = getTimeFromDateString(settings.switchStartTime3, true) + def customEndTime = getTimeFromDateString(settings.switchEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Switch Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SWITCH GROUP 3 + + //BEGIN STATUS CONFIG PRESENCE GROUP 1 + if (settings.presDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.presDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.presTalkOnArrive1) { + enabledDevices += "Say on arrive:\n ${settings.presTalkOnArrive1}\n\n" + } + if (settings.presTalkOnLeave1) { + enabledDevices += "Say on leave:\n ${settings.presTalkOnLeave1}\n\n" + } + if (settings.presSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.presSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.presResumePlay1 == null)) ? settings.presResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.presModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.presModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.presStartTime1) { + def customStartTime = getTimeFromDateString(settings.presStartTime1, true) + def customEndTime = getTimeFromDateString(settings.presEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Presence Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG PRESENCE GROUP 1 + //BEGIN STATUS CONFIG PRESENCE GROUP 2 + if (settings.presDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.presDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.presTalkOnArrive2) { + enabledDevices += "Say on arrive:\n ${settings.presTalkOnArrive2}\n\n" + } + if (settings.presTalkOnLeave2) { + enabledDevices += "Say on leave:\n ${settings.presTalkOnLeave2}\n\n" + } + if (settings.presSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.presSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.presResumePlay2 == null)) ? settings.presResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.presModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.presModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.presStartTime2) { + def customStartTime = getTimeFromDateString(settings.presStartTime2, true) + def customEndTime = getTimeFromDateString(settings.presEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Presence Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG PRESENCE GROUP 2 + //BEGIN STATUS CONFIG PRESENCE GROUP 3 + if (settings.presDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.presDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.presTalkOnArrive3) { + enabledDevices += "Say on arrive:\n ${settings.presTalkOnArrive3}\n\n" + } + if (settings.presTalkOnLeave3) { + enabledDevices += "Say on leave:\n ${settings.presTalkOnLeave3}\n\n" + } + if (settings.presSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.presSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.presResumePlay3 == null)) ? settings.presResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.presModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.presModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.presStartTime3) { + def customStartTime = getTimeFromDateString(settings.presStartTime3, true) + def customEndTime = getTimeFromDateString(settings.presEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Presence Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG PRESENCE GROUP 3 + + //BEGIN STATUS CONFIG LOCK GROUP 1 + if (settings.lockDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.lockDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.lockTalkOnLock1) { + enabledDevices += "Say when locked:\n ${settings.lockTalkOnLock1}\n\n" + } + if (settings.lockTalkOnUnlock1) { + enabledDevices += "Say when unlocked:\n ${settings.lockTalkOnUnlock1}\n\n" + } + if (settings.lockSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.lockSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.lockResumePlay1 == null)) ? settings.lockResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.lockModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.lockModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.lockStartTime1) { + def customStartTime = getTimeFromDateString(settings.lockStartTime1, true) + def customEndTime = getTimeFromDateString(settings.lockEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Lock Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG LOCK GROUP 1 + //BEGIN STATUS CONFIG LOCK GROUP 2 + if (settings.lockDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.lockDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.lockTalkOnLock2) { + enabledDevices += "Say when locked:\n ${settings.lockTalkOnLock2}\n\n" + } + if (settings.lockTalkOnUnlock2) { + enabledDevices += "Say when unlocked:\n ${settings.lockTalkOnUnlock2}\n\n" + } + if (settings.lockSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.lockSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.lockResumePlay2 == null)) ? settings.lockResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.lockModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.lockModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.lockStartTime2) { + def customStartTime = getTimeFromDateString(settings.lockStartTime2, true) + def customEndTime = getTimeFromDateString(settings.lockEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Lock Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG LOCK GROUP 2 + //BEGIN STATUS CONFIG LOCK GROUP 3 + if (settings.lockDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.lockDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.lockTalkOnLock3) { + enabledDevices += "Say when locked:\n ${settings.lockTalkOnLock1}\n\n" + } + if (settings.lockTalkOnUnlock3) { + enabledDevices += "Say when unlocked:\n ${settings.lockTalkOnUnlock1}\n\n" + } + if (settings.lockSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.lockSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.lockResumePlay3 == null)) ? settings.lockResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.lockModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.lockModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.lockStartTime3) { + def customStartTime = getTimeFromDateString(settings.lockStartTime3, true) + def customEndTime = getTimeFromDateString(settings.lockEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Lock Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG LOCK GROUP 3 + + //BEGIN STATUS CONFIG CONTACT GROUP 1 + if (settings.contactDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.contactDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.contactTalkOnOpen1) { + enabledDevices += "Say when opened:\n ${settings.contactTalkOnOpen1}\n\n" + } + if (settings.contactTalkOnClose1) { + enabledDevices += "Say when closed:\n ${settings.contactTalkOnClose1}\n\n" + } + if (settings.contactSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.contactSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.contactResumePlay1 == null)) ? settings.contactResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.contactModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.contactModes1.each() { + enabledDevices += "${it}," + } + } + if (settings.contactStartTime1) { + def customStartTime = getTimeFromDateString(settings.contactStartTime1, true) + def customEndTime = getTimeFromDateString(settings.contactEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Contact Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG CONTACT GROUP 1 + //BEGIN STATUS CONFIG CONTACT GROUP 2 + if (settings.contactDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.contactDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.contactTalkOnOpen2) { + enabledDevices += "Say when opened:\n ${settings.contactTalkOnOpen2}\n\n" + } + if (settings.contactTalkOnClose2) { + enabledDevices += "Say when closed:\n ${settings.contactTalkOnClose2}\n\n" + } + if (settings.contactSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.contactSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.contactResumePlay2 == null)) ? settings.contactResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.contactModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.contactModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.contactStartTime2) { + def customStartTime = getTimeFromDateString(settings.contactStartTime2, true) + def customEndTime = getTimeFromDateString(settings.contactEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Contact Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG CONTACT GROUP 2 + //BEGIN STATUS CONFIG CONTACT GROUP 3 + if (settings.contactDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.contactDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.contactTalkOnOpen3) { + enabledDevices += "Say when opened:\n ${settings.contactTalkOnOpen3}\n\n" + } + if (settings.contactTalkOnClose3) { + enabledDevices += "Say when closed:\n ${settings.contactTalkOnClose3}\n\n" + } + if (settings.contactSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.contactSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.contactResumePlay3 == null)) ? settings.contactResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.contactModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.contactModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.contactStartTime3) { + def customStartTime = getTimeFromDateString(settings.contactStartTime3, true) + def customEndTime = getTimeFromDateString(settings.contactEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Contact Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG CONTACT GROUP 3 + + //BEGIN STATUS CONFIG MODE CHANGE GROUP 1 + if (settings.modePhraseGroup1) { + enabledDevices += "Modes: \n" + enabledDevices += " " + settings.modePhraseGroup1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + if (settings.modeExcludePhraseGroup1) { + enabledDevices += "Remain silent if mode is changed from:\n " + enabledDevices += " " + settings.modeExcludePhraseGroup1.each(){ + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.contactTalkOnOpen1) { + enabledDevices += "Say when changed:\n ${settings.TalkOnModeChange1}\n\n" + } + if (settings.modeSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.contactSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.modePhraseResumePlay1 == null)) ? settings.modePhraseResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.modeStartTime1) { + def customStartTime = getTimeFromDateString(settings.modeStartTime1, true) + def customEndTime = getTimeFromDateString(settings.modeEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Mode Change:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG MODE CHANGE GROUP 1 + + //BEGIN STATUS CONFIG THERMOSTAT GROUP 1 + if (settings.thermostatDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.thermostatDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.thermostatTalkOnIdle1) { + enabledDevices += "Say when Idle:\n ${settings.thermostatTalkOnIdle1}\n\n" + } + if (settings.thermostatTalkOnHeating1) { + enabledDevices += "Say when Heating:\n ${settings.thermostatTalkOnHeating1}\n\n" + } + if (settings.thermostatTalkOnCooling1) { + enabledDevices += "Say when Cooling:\n ${settings.thermostatTalkOnCooling1}\n\n" + } + if (settings.thermostatTalkOnFan1) { + enabledDevices += "Say when Fan:\n ${settings.thermostatTalkOnFan1}\n\n" + } + if (settings.thermostatSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.thermostatSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.thermostatResumePlay1 == null)) ? settings.thermostatResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.thermostatModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.thermostatModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.thermostatStartTime1) { + def customStartTime = getTimeFromDateString(settings.thermostatStartTime1, true) + def customEndTime = getTimeFromDateString(settings.thermostatEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Thermostat Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG THERMOSTAT GROUP 1 + + //BEGIN STATUS CONFIG ACCELERATION GROUP 1 + if (settings.accelerationDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.accelerationDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.accelerationTalkOnActive1) { + enabledDevices += "Say when acceleration activated:\n ${settings.accelerationTalkOnActive1}\n\n" + } + if (settings.accelerationTalkOnInactive1) { + enabledDevices += "Say when acceleration stops:\n ${settings.accelerationTalkOnInactive1}\n\n" + } + if (settings.accelerationSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.accelerationSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.accelerationResumePlay1 == null)) ? settings.accelerationResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.accelerationModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.accelerationModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.accelerationStartTime1) { + def customStartTime = getTimeFromDateString(settings.accelerationStartTime1, true) + def customEndTime = getTimeFromDateString(settings.accelerationEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Acceleration Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG ACCELERATION GROUP 1 + //BEGIN STATUS CONFIG ACCELERATION GROUP 2 + if (settings.accelerationDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.accelerationDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.accelerationTalkOnActive2) { + enabledDevices += "Say when acceleration activated:\n ${settings.accelerationTalkOnActive2}\n\n" + } + if (settings.accelerationTalkOnInactive2) { + enabledDevices += "Say when acceleration stops:\n ${settings.accelerationTalkOnInactive2}\n\n" + } + if (settings.accelerationSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.accelerationSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.accelerationResumePlay2 == null)) ? settings.accelerationResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.accelerationModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.accelerationModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.accelerationStartTime2) { + def customStartTime = getTimeFromDateString(settings.accelerationStartTime2, true) + def customEndTime = getTimeFromDateString(settings.accelerationEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Acceleration Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG ACCELERATION GROUP 2 + //BEGIN STATUS CONFIG ACCELERATION GROUP 3 + if (settings.accelerationDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.accelerationDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.accelerationTalkOnActive3) { + enabledDevices += "Say when acceleration activated:\n ${settings.accelerationTalkOnActive3}\n\n" + } + if (settings.accelerationTalkOnInactive3) { + enabledDevices += "Say when acceleration stops:\n ${settings.accelerationTalkOnInactive3}\n\n" + } + if (settings.accelerationSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.accelerationSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.accelerationResumePlay3 == null)) ? settings.accelerationResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.accelerationModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.accelerationModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.accelerationStartTime3) { + def customStartTime = getTimeFromDateString(settings.accelerationStartTime3, true) + def customEndTime = getTimeFromDateString(settings.accelerationEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Acceleration Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG ACCELERATION GROUP 3 + + //BEGIN STATUS CONFIG WATER GROUP 1 + if (settings.waterDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.waterDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.waterTalkOnWet1) { + enabledDevices += "Say this when wet:\n ${settings.waterTalkOnWet1}\n\n" + } + if (settings.waterTalkOnWet1) { + enabledDevices += "Say this when dry:\n ${settings.waterTalkOnDry1}\n\n" + } + if (settings.waterSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.waterSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.waterResumePlay1 == null)) ? settings.waterResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.waterModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.waterModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.waterStartTime1) { + def customStartTime = getTimeFromDateString(settings.waterStartTime1, true) + def customEndTime = getTimeFromDateString(settings.waterEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Water Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG WATER GROUP 1 + //BEGIN STATUS CONFIG WATER GrOUP 2 + if (settings.waterDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.waterDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.waterTalkOnWet2) { + enabledDevices += "Say this when wet:\n ${settings.waterTalkOnWet2}\n\n" + } + if (settings.waterTalkOnWet2) { + enabledDevices += "Say this when dry:\n ${settings.waterTalkOnDry2}\n\n" + } + if (settings.waterSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.waterSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.waterResumePlay2 == null)) ? settings.waterResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.waterModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.waterModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.waterStartTime2) { + def customStartTime = getTimeFromDateString(settings.waterStartTime2, true) + def customEndTime = getTimeFromDateString(settings.waterEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Water Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG WATER GROUP 2 + //BEGIN STATUS CONFIG WATER GROUP 3 + if (settings.waterDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.waterDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.waterTalkOnWet3) { + enabledDevices += "Say this when wet:\n ${settings.waterTalkOnWet3}\n\n" + } + if (settings.waterTalkOnWet3) { + enabledDevices += "Say this when dry:\n ${settings.waterTalkOnDry3}\n\n" + } + if (settings.waterSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.waterSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.waterResumePlay3 == null)) ? settings.waterResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.waterModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.waterModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.waterStartTime3) { + def customStartTime = getTimeFromDateString(settings.waterStartTime3, true) + def customEndTime = getTimeFromDateString(settings.waterEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Water Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG WATER GROUP 3 + + //BEGIN STATUS CONFIG SMOKE GROUP 1 + if (settings.smokeDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.smokeDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.smokeTalkOnDetect1) { + enabledDevices += "Say this when smoke detected:\n ${settings.smokeTalkOnDetect1}\n\n" + } + if (settings.smokeTalkOnClear1) { + enabledDevices += "Say this when smoke cleared:\n ${settings.smokeTalkOnClear1}\n\n" + } + if (settings.smokeTalkOnTest1) { + enabledDevices += "Say this when smoke tested:\n ${settings.smokeTalkOnTest1}\n\n" + } + if (settings.smokeSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.smokeSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.smokeResumePlay1 == null)) ? settings.smokeResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.smokeModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.smokeModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.smokeStartTime1) { + def customStartTime = getTimeFromDateString(settings.smokeStartTime1, true) + def customEndTime = getTimeFromDateString(settings.smokeEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Smoke Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SMOKE GROUP 1 + //BEGIN STATUS CONFIG SMOKE GROUP 2 + if (settings.smokeDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.smokeDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.smokeTalkOnDetect2) { + enabledDevices += "Say this when smoke detected:\n ${settings.smokeTalkOnDetect2}\n\n" + } + if (settings.smokeTalkOnClear2) { + enabledDevices += "Say this when smoke cleared:\n ${settings.smokeTalkOnClear2}\n\n" + } + if (settings.smokeTalkOnTest2) { + enabledDevices += "Say this when smoke tested:\n ${settings.smokeTalkOnTest2}\n\n" + } + if (settings.smokeSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.smokeSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.smokeResumePlay2 == null)) ? settings.smokeResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.smokeModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.smokeModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.smokeStartTime2) { + def customStartTime = getTimeFromDateString(settings.smokeStartTime2, true) + def customEndTime = getTimeFromDateString(settings.smokeEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Smoke Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SMOKE GROUP 2 + //BEGIN STATUS CONFIG SMOKE GROUP 3 + if (settings.smokeDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.smokeDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.smokeTalkOnDetect3) { + enabledDevices += "Say this when smoke detected:\n ${settings.smokeTalkOnDetect3}\n\n" + } + if (settings.smokeTalkOnClear3) { + enabledDevices += "Say this when smoke cleared:\n ${settings.smokeTalkOnClear3}\n\n" + } + if (settings.smokeTalkOnTest3) { + enabledDevices += "Say this when smoke tested:\n ${settings.smokeTalkOnTest3}\n\n" + } + if (settings.smokeSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n" + enabledDevices += " " + settings.smokeSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.smokeResumePlay3 == null)) ? settings.smokeResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.smokeModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.smokeModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.smokeStartTime3) { + def customStartTime = getTimeFromDateString(settings.smokeStartTime3, true) + def customEndTime = getTimeFromDateString(settings.smokeEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Smoke Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SMOKE GROUP 3 + + //BEGIN STATUS CONFIG BUTTON GROUP 1 + if (settings.buttonDeviceGroup1) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.buttonDeviceGroup1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.buttonTalkOnDetect1) { + enabledDevices += "Say this when button pressed:\n ${settings.buttonTalkOnPress1}\n\n" + } + if (settings.buttonSpeechDevice1) { + enabledDevices += "Custom Speech Device(s):\n\n" + enabledDevices += " " + settings.buttonSpeechDevice1.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.buttonResumePlay1 == null)) ? settings.buttonResumePlay1 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.buttonModes1) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.buttonModes1.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.buttonStartTime1) { + def customStartTime = getTimeFromDateString(settings.buttonStartTime1, true) + def customEndTime = getTimeFromDateString(settings.buttonEndTime1, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Button Group 1:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG BUTTON GROUP 1 + //BEGIN STATUS CONFIG BUTTON GROUP 2 + if (settings.buttonDeviceGroup2) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.buttonDeviceGroup2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.buttonTalkOnDetect2) { + enabledDevices += "Say this when button pressed:\n ${settings.buttonTalkOnPress2}\n\n" + } + if (settings.buttonSpeechDevice2) { + enabledDevices += "Custom Speech Device(s):\n\n" + enabledDevices += " " + settings.buttonSpeechDevice2.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.buttonResumePlay2 == null)) ? settings.buttonResumePlay2 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.buttonModes2) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.buttonModes2.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.buttonStartTime2) { + def customStartTime = getTimeFromDateString(settings.buttonStartTime2, true) + def customEndTime = getTimeFromDateString(settings.buttonEndTime2, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Button Group 2:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG BUTTON GROUP 2 + //BEGIN STATUS CONFIG BUTTON GROUP 3 + if (settings.buttonDeviceGroup3) { + enabledDevices += "Devices: \n" + enabledDevices += " " + settings.buttonDeviceGroup3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + if (settings.buttonTalkOnDetect3) { + enabledDevices += "Say this when button pressed:\n ${settings.buttonTalkOnPress3}\n\n" + } + if (settings.buttonSpeechDevice3) { + enabledDevices += "Custom Speech Device(s):\n\n" + enabledDevices += " " + settings.buttonSpeechDevice3.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.buttonResumePlay3 == null)) ? settings.buttonResumePlay3 : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.buttonModes3) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.buttonModes3.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.buttonStartTime3) { + def customStartTime = getTimeFromDateString(settings.buttonStartTime3, true) + def customEndTime = getTimeFromDateString(settings.buttonEndTime3, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Button Group 3:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG BUTTON GROUP 3 + //BEGIN STATUS CONFIG SMART HOME MONITOR + if (settings.SHMDeviceGroup1) { + enabledDevices += "Smart Home Monitor Status Change: " + enabledDevices += "\n\n" + if (settings.SHMTalkOnAway) { + enabledDevices += "Say this when armed in Away mode:\n ${settings.SHMTalkOnAway}\n\n" + } + if (settings.SHMSpeechDeviceAway) { + enabledDevices += "Custom Speech Device(s):\n\n" + enabledDevices += " " + settings.SHAMSpeechDeviceAway.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.SHMResumePlayAway == null)) ? settings.SHMResumePlayAway : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.SHMModesAway) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.SHMModesAway.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.SHMStartTimeAway) { + def customStartTime = getTimeFromDateString(settings.SHMStartTimeAway, true) + def customEndTime = getTimeFromDateString(settings.SHMEndTimeAway, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Armed - Away:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + if (settings.SHMTalkOnHome) { + enabledDevices += "Say this when armed in Home mode:\n ${settings.SHMTalkOnHome}\n\n" + } + if (settings.SHMSpeechDeviceHome) { + enabledDevices += "Custom Speech Device(s):\n\n" + enabledDevices += " " + settings.SHAMSpeechDeviceHome.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.SHMResumePlayHome == null)) ? settings.SHMResumePlayHome : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.SHMModesHome) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.SHMModesHome.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.SHMStartTimeHome) { + def customStartTime = getTimeFromDateString(settings.SHMStartTimeHome, true) + def customEndTime = getTimeFromDateString(settings.SHMEndTimeHome, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Armed - Home:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + if (settings.SHMTalkOnDisarm) { + enabledDevices += "Say this when disarmed:\n ${settings.SHMTalkOnDisarm}\n\n" + } + if (settings.SHMSpeechDeviceDisarm) { + enabledDevices += "Custom Speech Device(s):\n\n" + enabledDevices += " " + settings.SHMSpeechDeviceDisarm.each() { + enabledDevices += "${it.displayName}," + } + enabledDevices += "\n\n" + } + if (state.speechDeviceType == "capability.musicPlayer") { + enabledDevices += "Resume Audio: ${(!(settings.SHMResumePlayDisarm == null)) ? settings.SHMResumePlayDisarm : settings.resumePlay}" + enabledDevices += "\n\n" + } + if (settings.SHMModesDisarm) { + enabledDevices += "Custom mode(s):\n" + enabledDevices += " " + settings.SHMModesDisarm.each() { + enabledDevices += "${it}," + } + enabledDevices += "\n\n" + } + if (settings.SHMStartTimeDisarm) { + def customStartTime = getTimeFromDateString(settings.SHMStartTimeDisarm, true) + def customEndTime = getTimeFromDateString(settings.SHMEndTimeDisarm, true) + enabledDevices += "Custom Allowed Talk Time:\n ${customStartTime} - ${customEndTime}" + customStartTime = "" + customEndTime = "" + } + if (!(enabledDevices == "")) { + section ("Disarmed:"){ + paragraph enabledDevices + } + } + enabledDevices = "" + } + //END STATUS CONFIG SMART HOME MONITOR + } +} + +def pageTalkNow(){ + dynamicPage(name: "pageTalkNow", title: "Talk Now", install: false, uninstall: false){ + section(""){ + def myTalkNowResume = false + paragraph ("Speak the following phrase:\nNote: must differ from the last spoken phrase\n") + if (state.speechDeviceType == "capability.musicPlayer") { + input name: "talkNowVolume", type: "number", title: "Set volume to (overrides default):", required: false, submitOnChange: true + input name: "talkNowResume", type: "bool", title: "Enable audio resume", multiple: true, required: false, submitOnChange: true, defaultValue: (settings?.resumePlay == false) ? false : true + input name: "talkNowVoice", type: "enum", title: "Select custom voice:", options: state.supportedVoices, required: false, submitOnChange: true + myTalkNowResume = settings.talkNowResume + } + input name: "speechTalkNow", type: text, title: "Speak phrase", required: false, submitOnChange: true + input name: "talkNowSpeechDevice", type: state.speechDeviceType, title: "Talk with these text-to-speech devices", multiple: true, required: false, submitOnChange: true + //LOGDEBUG("previoustext=${state.lastTalkNow} New=${settings.speechTalkNow}") + if (((!(state.lastTalkNow == settings.speechTalkNow)) && (settings.talkNowSpeechDevice)) || (settings.speechTalkNow?.contains("%askalexa%"))){ + //Say stuff! + if (state.speechDeviceType == "capability.musicPlayer") { + myTalkNowResume = (myTalkNowResume == "") ? settings.resumeAudio : true //use global setting if TalkNow is not set + if (settings?.talkNowResume == null) {mytalkNowResume = true} //default to true if not set. + } + def customevent = [displayName: 'BigTalker:TalkNow', name: 'TalkNow', value: 'TalkNow', descriptionText: "Talk Now"] + def myVolume = getDesiredVolume(settings?.talkNowVolume) + def myVoice = getMyVoice(settings.talkNowVoice) + //def myVoice = (!(talkNowVoice == null || talkNowVoice == "")) ? talkNowVoice : (settings?.speechVoice ? settings.speechVoice : "Sallie(en-us)") + def personality = false + LOGDEBUG ("TalkNow Voice=${myVoice}") + Talk("Talk Now", settings.speechTalkNow, settings.talkNowSpeechDevice, myVolume, myTalkNowResume, personality, myVoice, customevent) + state.lastTalkNow = settings.speechTalkNow + } + } + section("Help"){ + href "pageHelpPhraseTokens", title:"Phrase Tokens", description:"Tap for a list of phrase tokens" + } + } +} + +def getMyVoice(deviceVoice){ + def myVoice = "Not Used" + if (state?.speechDeviceType == "capability.musicPlayer") { + log.debug "getMyVoice[parent]: deviceVoice=${deviceVoice ? deviceVoice : "Not selected"}" + log.debug "getMyVoice[parent]: settings.speechVoice=${settings?.speechVoice}" + myVoice = (!(deviceVoice == null || deviceVoice == "")) ? deviceVoice : (settings?.speechVoice ? settings?.speechVoice : "Salli(en-us)") + } + return myVoice +} + +def pageHelpPhraseTokens(){ + //KEEP IN SYNC WITH CHILD! + dynamicPage(name: "pageHelpPhraseTokens", title: "Available Phrase Tokens", install: false, uninstall:false){ + section("The following tokens can be used in your event phrases and will be replaced as listed:"){ + def AvailTokens = "" + AvailTokens += "%askalexa% = Send phrase to AskAlexa SmartApp's message queue\n\n" + AvailTokens += "%groupname% = Name that you gave for the event group\n\n" + AvailTokens += "%date% = Current date; January 01\n\n" + AvailTokens += "%day% = Current day; Monday\n\n" + AvailTokens += "%devicename% = Triggering devices display name\n\n" + AvailTokens += "%devicetype% = Triggering device type; motion, switch, etc\n\n" + AvailTokens += "%devicechange% = State change that occurred; on/off, active/inactive, etc...\n\n" + AvailTokens += "%description% = The description of the event that is to be displayed to the user in the mobile application. \n\n" + AvailTokens += "%locationname% = Hub location name; home, work, etc\n\n" + AvailTokens += "%lastmode% = Last hub mode; home, away, etc\n\n" + AvailTokens += "%mode% = Current hub mode; home, away, etc\n\n" + AvailTokens += "%mp3(url)% = Play hosted MP3 file; URL should be http://www.domain.com/path/file.mp3 \n" + AvailTokens += "No other tokens or phrases can be used with %mp3(url)%\n\n" + AvailTokens += "%time% = Current hub time; HH:mm am/pm\n\n" + AvailTokens += "%shmstatus% = SmartHome Monitor Status (Disarmed, Armed Home, Armed Away)\n\n" + AvailTokens += "%weathercurrent% = Current weather based on hub location\n\n" + AvailTokens += "%weathercurrent(00000)% = Current weather* based on custom zipcode (replace 00000)\n\n" + AvailTokens += "%weathertoday% = Today's weather forecast* based on hub location\n\n" + AvailTokens += "%weathertoday(00000)% = Today's weather forecast* based on custom zipcode (replace 00000)\n\n" + AvailTokens += "%weathertonight% = Tonight's weather forecast* based on hub location\n\n" + AvailTokens += "%weathertonight(00000)% = Tonight's weather* forecast based on custom zipcode (replace 00000)\n\n" + AvailTokens += "%weathertomorrow% = Tomorrow's weather forecast* based on hub location\n\n" + AvailTokens += "%weathertomorrow(00000)% = Tomorrow's weather forecast* based on custom zipcode (replace 00000)\n\n" + AvailTokens += "\n*Weather forecasts provided by Weather Underground" + paragraph(AvailTokens) + } + } +} + +def pageConfigureSpeechDeviceType(){ + if (!(state.installed == true)) { state.installed = false; state.speechDeviceType = "capability.musicPlayer"} + dynamicPage(name: "pageConfigureSpeechDeviceType", title: "Configure", nextPage: "pageConfigureDefaults", install: false, uninstall: false) { + //section ("Speech Device Type Support"){ + section (){ + paragraph "${app.label} can support either 'Music Player' or 'Speech Synthesis' devices." + paragraph "'Music Player' typically supports devices such as Sonos, VLCThing, Generic Media Renderer.\n\n'Speech Synthesis' typically supports devices such as Ubi and LANnouncer.\n\nIf only using with AskAlexa this setting can be ignored.\n\nThis setting cannot be changed without reinstalling ${app.label}." + input "speechDeviceType", "bool", title: "ON=Music Player\nOFF=Speech Synthesis", required: true, defaultValue: true, submitOnChange: true + paragraph "Click Next (top right) to continue configuration...\n" + if (speechDeviceType == true) {state.speechDeviceType = "capability.musicPlayer"} + if (speechDeviceType == false) {state.speechDeviceType = "capability.speechSynthesis"} + } + } +//End pageConfigureSpeechDeviceType() +} + +def pageConfigureDefaults(){ + if (state?.installed == true) { + state.dynPageProperties = [ + name: "pageConfigureDefaults", + title: "Configure Defaults", + install: false, + uninstall: false, + //nextPage: "pageConfigureEvents" + ] + } else { + state.dynPageProperties = [ + name: "pageConfigureDefaults", + title: "Configure Defaults", + install: true, + uninstall: false + ] + } + return dynamicPage(state.dynPageProperties) { + //dynamicPage(name: "pageConfigureDefaults", title: "Configure Defaults", nextPage: "${myNextPage}", install: false, uninstall: false) { + section("Talk with:"){ + if (state.speechDeviceType == null || state.speechDeviceType == "") { state.speechDeviceType = "capability.musicPlayer" } + input "speechDeviceDefault", state.speechDeviceType, title: "Talk with these text-to-speech devices (default)", multiple: true, required: false, submitOnChange: false + } + if (state.speechDeviceType == "capability.musicPlayer") { + section ("Adjust volume during announcement (optional; Supports: Sonos, VLC-Thing):"){ + input "speechMinimumVolume", "number", title: "Minimum volume for announcement (0-100%, Default: 50%):", required: false + input "speechVolume", "number", title: "Set volume during announcement (0-100%):", required: false + input "speechVoice", "enum", title: "Select voice:", options: state.supportedVoices, required: true, defaultValue: "Salli(en-us)" + } + section ("Attempt to resume playing audio (optional; Supports: Sonos, VLC-Thing):"){ + input "resumePlay", "bool", title: "Resume Play:", required: true, defaultValue: true + input "allowScheduledPoll", "bool", title: "Enable polling device status (recommended)", required: true, defaultValue: true + } + } + section ("Talk only while in these modes:"){ + input "speechModesDefault", "mode", title: "Talk only while in these modes (default)", multiple: true, required: true, submitOnChange: false + } + section ("Only between these times:"){ + input "defaultStartTime", "time", title: "Don't talk before: ", required: false, submitOnChange: true + input "defaultEndTime", "time", title: "Don't talk after: ", required: (!(settings.defaultStartTime == null)), submitOnChange: true + } + section(){ + input "personalityMode", "bool", title: "Allow Personality?", required: true, defaultValue: false + input "debugmode", "bool", title: "Enable debug logging", required: true, defaultValue: false + } + } +} + +def installed() { + state.installed = true + //LOGTRACE("Installed with settings: ${settings}") + LOGTRACE("Installed (Parent Version: ${state.appVersion})") + initialize() + if (((settings?.allowScheduledPoll == true || state?.allowScheduledPoll == true)) || ((settings?.allowScheduledPoll == null) || (state?.allowScheduledPoll == null))){ + myRunIn(60, poll) + } +//End installed() +} + +def updated() { + unschedule() + state.installed = true + //LOGTRACE("Updated with settings: ${settings}") + LOGTRACE("Updated settings (Parent Version: ${state.appVersion})") + unsubscribe() + initialize() + if (((settings?.allowScheduledPoll == true || state?.allowScheduledPoll == true)) || ((settings?.allowScheduledPoll == null) || (state?.allowScheduledPoll == null))){ + myRunIn(60, poll) + } +//End updated() +} + +def checkConfig() { + def configErrorList = "" + if (!(state.speechDeviceType)){ + state.speechDeviceType = "capability.musicPlayer" //Set a default if the app was update and didn't contain settings.speechDeviceType + } + if ((settings?.allowScheduledPoll == true) && (settings?.resumePlay == true)) { state.allowScheduledPoll = true } + if ((settings?.allowScheduledPoll == null) || (settings?.resumePlay == null)) { state.allowScheduledPoll = true } + if ((settings?.allowScheduledPoll == false) || (settings?.resumePlay == false)) { state.allowScheduledPoll = false} +// if (!(settings.speechDeviceDefault)){ +// configErrorList += " ** Default speech device(s) not selected," +// } + if (!(state.installed == true)) { + configErrorList += " ** state.installed not True," + } + if (!(configErrorList == "")) { + LOGDEBUG ("checkConfig() returning FALSE (${configErrorList})") + state.configOK = false + return false //Errors occurred. Config check failed. + } else { + LOGDEBUG ("checkConfig() returning TRUE (${configErrorList})") + state.configOK = true + return true + } +} + +def initialize() { + if (!(checkConfig())) { + def msg = "" + msg = "ERROR: App not properly configured! Can't start.\n" + msg += "ERRORs:\n${state.configErrorList}" + LOGTRACE(msg) + sendNotificationEvent(msg) + state.polledDevices = "" + return //App not properly configured, exit, don't subscribe + } + LOGTRACE("Initialized (Parent Version: ${state.appVersion})") + sendNotificationEvent("${app.label.replace(" ","").toUpperCase()}: Settings activated") + state.lastMode = location.mode + state.lastTalkNow = settings.speechTalkNow +//End initialize() +} + +def processPhraseVariables(appname, phrase, evt){ + try { + def zipCode = location.zipCode + def mp3Url = "" + if (phrase.toLowerCase().contains("%mp3(")) { + if (phrase.toLowerCase().contains(".mp3)%")) { + def phraseMP3Start = (phrase.toLowerCase().indexOf("%mp3(") + 5) + def phraseMP3End = (phrase.toLowerCase().indexOf(".mp3)%")) + mp3Url = phrase.substring(phraseMP3Start, phraseMP3End) + LOGDEBUG("MP3 URL: ${mp3Url}") + phrase = phrase.replace("%mp3(","") + phrase = phrase.replace(".mp3)%", ".mp3") + phrase = phrase.replace (" ", "%20") + phrase = phrase.replace ("+", "%2B") + phrase = phrase.replace ("-", "%2D") + } else { + phrase = "Invalid M P 3 URL found in M P 3 token" + } + return phrase + } + if (phrase.toLowerCase().contains(" percent ")) { phrase = phrase.replace(" percent ","%") } + if (phrase.toLowerCase().contains("%groupname%")) { + phrase = phrase.toLowerCase().replace('%groupname%', appname) + } + if (phrase.toLowerCase().contains("%devicename%")) { + try { + phrase = phrase.toLowerCase().replace('%devicename%', evt.displayName) //User given name of the device triggering the event + } + catch (ex) { + LOGDEBUG("evt.displayName failed; trying evt.device.displayName") + try { + phrase = phrase.toLowerCase().replace('%devicename%', evt.device.displayName) //User given name of the device triggering the event + } + catch (ex2) { + LOGDEBUG("evt.device.displayName filed; trying evt.device.name") + try { + phrase = phrase.toLowerCase().replace('%devicename%', evt.device.name) //SmartThings name for the device triggering the event + } + catch (ex3) { + LOGDEBUG("evt.device.name filed; Giving up") + phrase = phrase.toLowerCase().replace('%devicename%', "Device Name Unknown") + } + } + } + } + if (phrase.toLowerCase().contains("%devicetype%")) {phrase = phrase.toLowerCase().replace('%devicetype%', evt.name)} //Device type: motion, switch, etc... + if (phrase.toLowerCase().contains("%devicechange%")) {phrase = phrase.toLowerCase().replace('%devicechange%', evt.value)} //State change that occurred: on/off, active/inactive, etc... + if (phrase.toLowerCase().contains("%description%")) {phrase = phrase.toLowerCase().replace('%description%', evt.descriptionText)} //Description of the event which occurred via device-specific text` + if (phrase.toLowerCase().contains("%locationname%")) {phrase = phrase.toLowerCase().replace('%locationname%', location.name)} + if (phrase.toLowerCase().contains("%lastmode%")) {phrase = phrase.toLowerCase().replace('%lastmode%', state.lastMode)} + if (phrase.toLowerCase().contains("%mode%")) {phrase = phrase.toLowerCase().replace('%mode%', location.mode)} + if (phrase.toLowerCase().contains("%time%")) { + phrase = phrase.toLowerCase().replace('%time%', getTimeFromCalendar(false,true)) + if ((phrase.toLowerCase().contains("00:")) && (phrase.toLowerCase().contains("am"))) {phrase = phrase.toLowerCase().replace('00:', "12:")} + if ((phrase.toLowerCase().contains("24:")) && (phrase.toLowerCase().contains("am"))) {phrase = phrase.toLowerCase().replace('24:', "12:")} + if ((phrase.toLowerCase().contains("0:")) && (!phrase.toLowerCase().contains("10:")) && (phrase.toLowerCase().contains("am"))) {phrase = phrase.toLowerCase().replace('0:', "12:")} + } + if (phrase.toLowerCase().contains("%weathercurrent%")) {phrase = phrase.toLowerCase().replace('%weathercurrent%', getWeather("current", zipCode)); phrase = adjustWeatherPhrase(phrase)} + if (phrase.toLowerCase().contains("%weathertoday%")) {phrase = phrase.toLowerCase().replace('%weathertoday%', getWeather("today", zipCode)); phrase = adjustWeatherPhrase(phrase)} + if (phrase.toLowerCase().contains("%weathertonight%")) {phrase = phrase.toLowerCase().replace('%weathertonight%', getWeather("tonight", zipCode));phrase = adjustWeatherPhrase(phrase)} + if (phrase.toLowerCase().contains("%weathertomorrow%")) {phrase = phrase.toLowerCase().replace('%weathertomorrow%', getWeather("tomorrow", zipCode));phrase = adjustWeatherPhrase(phrase)} + if (phrase.toLowerCase().contains("%weathercurrent(")) { + if (phrase.toLowerCase().contains(")%")) { + def phraseZipStart = (phrase.toLowerCase().indexOf("%weathercurrent(") + 16) + def phraseZipEnd = (phrase.toLowerCase().indexOf(")%")) + zipCode = phrase.substring(phraseZipStart, phraseZipEnd) + LOGDEBUG("Custom zipCode: ${zipCode}") + phrase = phrase.toLowerCase().replace("%weathercurrent(${zipCode.toLowerCase()})%", getWeather("current", zipCode.toLowerCase())) + phrase = adjustWeatherPhrase(phrase.toLowerCase()) + } else { + phrase = "Custom Zip Code format error in request for current weather" + } + } + if (phrase.toLowerCase().contains("%weathertoday(")) { + if (phrase.contains(")%")) { + def phraseZipStart = (phrase.toLowerCase().indexOf("%weathertoday(") + 14) + def phraseZipEnd = (phrase.toLowerCase().indexOf(")%")) + zipCode = phrase.substring(phraseZipStart, phraseZipEnd) + LOGDEBUG("Custom zipCode: ${zipCode}") + phrase = phrase.toLowerCase().replace("%weathertoday(${zipCode.toLowerCase()})%", getWeather("today", zipCode.toLowerCase())) + phrase = adjustWeatherPhrase(phrase.toLowerCase()) + } else { + phrase = "Custom Zip Code format error in request for today's weather" + } + } + if (phrase.toLowerCase().contains("%weathertonight(")) { + if (phrase.contains(")%")) { + def phraseZipStart = (phrase.toLowerCase().indexOf("%weathertonight(") + 16) + def phraseZipEnd = (phrase.toLowerCase().indexOf(")%")) + zipCode = phrase.substring(phraseZipStart, phraseZipEnd) + LOGDEBUG("Custom zipCode: ${zipCode}") + phrase = phrase.toLowerCase().replace("%weathertonight(${zipCode.toLowerCase()})%", getWeather("tonight", zipCode.toLowerCase())) + phrase = adjustWeatherPhrase(phrase) + } else { + phrase = "Custom Zip Code format error in request for tonight's weather" + } + } + if (phrase.toLowerCase().contains("%weathertomorrow(")) { + if (phrase.contains(")%")) { + def phraseZipStart = (phrase.toLowerCase().indexOf("%weathertomorrow(") + 17) + def phraseZipEnd = (phrase.toLowerCase().indexOf(")%")) + zipCode = phrase.substring(phraseZipStart, phraseZipEnd) + LOGDEBUG("Custom zipCode: ${zipCode}") + phrase = phrase.toLowerCase().replace("%weathertomorrow(${zipCode.toLowerCase()})%", getWeather("tomorrow", zipCode.toLowerCase())) + phrase = adjustWeatherPhrase(phrase) + } else { + phrase = "Custom ZipCode format error in request for tomorrow's weather" + } + } + if (state.speechDeviceType == "capability.speechSynthesis"){ + //ST TTS Engine pronunces "Dash", so only convert for speechSynthesis devices (LANnouncer) + if (phrase.contains(",")) { phrase = phrase.replace(","," - ") } + //if (phrase.contains(".")) { phrase = phrase.replace("."," - ") } + } + if (phrase.toLowerCase().contains("%shmstatus%")) { + def shmstatus = location.currentState("alarmSystemStatus")?.value + LOGDEBUG("SHMSTATUS=${shmstatus}") + def shmmessage = [off : "Disarmed", away: "Armed, away", home: "Armed, home"][shmstatus] ?: shmstatus + LOGDEBUG("SHMMESSAGE=${shmmessage}") + phrase = phrase.replace("%shmstatus%", shmmessage) + } + if (phrase.contains('"')) { phrase = phrase.replace('"',"") } + if (phrase.contains("'")) { phrase = phrase.replace("'","") } + if (phrase.toLowerCase().contains("10s")) { phrase = phrase.toLowerCase().replace("10s","tens") } + if (phrase.toLowerCase().contains("20s")) { phrase = phrase.toLowerCase().replace("20s","twenties") } + if (phrase.toLowerCase().contains("30s")) { phrase = phrase.toLowerCase().replace("30s","thirties") } + if (phrase.toLowerCase().contains("40s")) { phrase = phrase.toLowerCase().replace("40s","fourties") } + if (phrase.toLowerCase().contains("50s")) { phrase = phrase.toLowerCase().replace("50s","fifties") } + if (phrase.toLowerCase().contains("60s")) { phrase = phrase.toLowerCase().replace("60s","sixties") } + if (phrase.toLowerCase().contains("70s")) { phrase = phrase.toLowerCase().replace("70s","seventies") } + if (phrase.toLowerCase().contains("80s")) { phrase = phrase.toLowerCase().replace("80s","eighties") } + if (phrase.toLowerCase().contains("90s")) { phrase = phrase.toLowerCase().replace("90s","nineties") } + if (phrase.toLowerCase().contains("100s")) { phrase = phrase.toLowerCase().replace("100s","one hundreds") } + if (phrase.toLowerCase().contains("%askalexa%")) { + phrase=phrase.toLowerCase().replace("%askalexa%","") + if (!(phrase == "") && (!(phrase == null))){ + LOGTRACE("Sending to AskAlexa: ${phrase}.") + sendLocationEvent(name: "AskAlexaMsgQueue", value: "BigTalker", isStateChange: true, descriptionText: phrase) + }else{ + LOGERROR("Phrase only contained %askalexa%. Nothing to say/send.") + } + } + if (phrase.toLowerCase().contains("%date%")) { + phrase=phrase.toLowerCase().replace("%date%",(new Date().format( 'MMMM dd' ))) + } + if (phrase.toLowerCase().contains("%day%")) { + phrase=phrase.toLowerCase().replace("%day%",(new Date().format('EEEE',location.timeZone))) + } + if (phrase.contains("%")) { phrase = phrase.replace("%"," percent ") } + return phrase + } catch(ex) { + LOGTRACE("There was a problem processing your desired phrase: ${phrase}. ${ex}") + phrase = "Sorry, there was a problem processing your desired BigTalker phrase token." + return phrase + } +} + +def addPersonalityToPhrase(phrase, evt){ + LOGDEBUG("addPersonalityToPhrase(${phrase},${evt})") + def response = new String[20] + response[0] = "" + def options = 0 + def genericresponse = new String[20] + genericresponse[0] = "" + def genericoptions = 0 + def myRandom = 0 + //SWITCHES BEGIN + if (evt.value == "on") { + if (phrase.contains("light")){ + options = 12 + response[1] = "{POST}please don't forget to turn the light off" + response[2] = "{POST}night vision goggles would do the same but I guess they are more expensive." + response[3] = "{POST}Thanks Thomas Edison!" + response[4] = "{POST}Wow, this is bright!" + response[5] = "{POST}Where are my sunglasses." + response[6] = "{POST}there goes the electricity bill!" + response[7] = "{POST}the same old thing everyday." + response[8] = "{POST}It is about time it was awfully dark!" + response[9] = "{POST}Glad you are here, I was lonely" + response[10] = "{POST}It it time for us to play?" + response[11] = "{PRE}Oh, Hi" + response[12] = "{PRE}Oh, Hi there" + } else { + //Something turned on, but it wasn't a light + options = 4 + response[1] = "{POST}there goes the electricity bill!" + response[2] = "{POST}the same old thing everyday." + response[3] = "{PRE}Oh, Hi" + response[4] = "{PRE}Oh, Hi there" + } + } + if (evt.value == "off") { + if (phrase.contains("light")){ + options = 12 + response[1] = "{POST}It's about time!" + response[2] = "{POST}time to save some money!" + response[3] = "{POST}wow, it's dark" + response[4] = "{POST}going green are we?" + response[5] = "{POST}I'll still be here, in the dark." + response[6] = "{POST}Hey! You know I am afraid of the dark." + response[7] = "{POST}Please don't leave me alone in the dark." + response[8] = "{POST}Good thing you turned that off it was hurting my eyes!" + response[8] = "{POST}You really like saving money!" + response[10] = "{POST}Is it time to go to sleep?" + response[11] = "{PRE}Oh, Hi" + response[12] = "{PRE}Oh, Hi there" + } else { + //Something turned off, but it wasn't a light + options = 5 + response[1] = "{POST}It's about time!" + response[2] = "{POST}time to save some money!" + response[3] = "{POST}going green are we?" + response[4] = "{PRE}Oh, Hi" + response[5] = "{PRE}Oh, Hi there" + } + } + //SWITCHES END + def UseGenericRandom = 0 + myRandom = (new Random().nextInt(10)) + if (myRandom == 1 || myRandom == 4 || myRandom == 7) { + //GENERIC RESPONSES BEGIN + genericoptions = 4 + genericresponse[1] = "{PRE}Hey there" + genericresponse[2] = "{PRE}Don't mean to bother but" + genericresponse[3] = "{PRE}All I know is" + genericresponse[4] = "{POST}that is all I know." + //GENERIC RESPONSES END + myRandom = (new Random().nextInt(genericoptions)) + LOGDEBUG("genericoptions=${genericoptions};myRandom=${myRandom};phrase=${genericresponse[myRandom]}") + if (genericresponse[myRandom].contains("{PRE}")) { + genericresponse[myRandom] = genericresponse[myRandom].replace("{PRE}", "") + phrase = genericresponse[myRandom] + ", " + phrase + } + if (genericresponse[myRandom].contains("{POST}")) { + genericresponse[myRandom] = genericresponse[myRandom].replace("{POST}", "") + phrase = phrase + ", " + genericresponse[myRandom] + } + return phrase + } + if (options == 0) { return phrase } + myRandom = (new Random().nextInt(options)) + LOGDEBUG("options=${options};myRandom=${myRandom};phrase=${response[myRandom]}") + if (response[myRandom].contains("{PRE}")) { + response[myRandom] = response[myRandom].replace("{PRE}", "") + phrase = response[myRandom] + ", " + phrase + } + if (response[myRandom].contains("{POST}")) { + response[myRandom] = response[myRandom].replace("{POST}", "") + phrase = phrase + ", " + response[myRandom] + } + return phrase +} + +def adjustWeatherPhrase(phraseIn){ + def phraseOut = "" + phraseOut = phraseIn.toUpperCase() + phraseOut = phraseOut.replace(" N ", " North ") + phraseOut = phraseOut.replace(" S ", " South ") + phraseOut = phraseOut.replace(" E ", " East ") + phraseOut = phraseOut.replace(" W ", " West ") + phraseOut = phraseOut.replace(" NNE ", " North Northeast ") + phraseOut = phraseOut.replace(" NNW ", " North Northwest ") + phraseOut = phraseOut.replace(" SSE ", " South Southeast ") + phraseOut = phraseOut.replace(" SSW ", " South Southwest ") + phraseOut = phraseOut.replace(" ENE ", " East Northeast ") + phraseOut = phraseOut.replace(" ESE ", " East Southeast ") + phraseOut = phraseOut.replace(" WNW ", " West Northeast ") + phraseOut = phraseOut.replace(" WSW ", " West Southwest ") + phraseOut = phraseOut.replace(" MPH", " Miles Per Hour") + phraseOut = phraseOut.replace(" MM)", " Milimeters ") + LOGDEBUG ("Adjust Weather: In=${phraseIn} Out=${phraseOut}") + return phraseOut +} + +def Talk(appname, phrase, customSpeechDevice, volume, resume, personality, voice, evt){ + def myDelay = 100 + def myVoice = settings?.speechVoice + if (myVoice == "" || myVoice == null) { myVoice = "Salli(en-us)" } + if (!(voice == "" || voice == null)) { + myVoice = voice + } + myVoice = myVoice.replace("(en-us)","") + myVoice = myVoice.replace("(en-gb)","") + myVoice = myVoice.replace("(es-us)","") + if (state.speechDeviceType == "capability.musicPlayer") { + myDelay = TalkQueue(appname, phrase, customSpeechDevice, volume, resume, personality, voice, evt) + state.lastTalkTime = now() + } + def currentSpeechDevices = [] + def smartAppSpeechDevice = false + def playAudioFile = false + def spoke = false + LOGDEBUG ("TALK(app=${appname},customdevice=${customSpeechDevice},volume=${volume},resume=${resume},personality=${personality},myDelay=${myDelay},voice=${myVoice},evt=${evt},phrase=${phrase})") + if ((phrase?.toLowerCase())?.contains("%askalexa%")) {smartAppSpeechDevice = true} + if (!(phrase == null) && !(phrase == "")) { + phrase = processPhraseVariables(appname, phrase, evt) + if (personality && !(phrase.toLowerCase().contains(".mp3"))) { phrase = addPersonalityToPhrase(phrase, evt) } + } + if (phrase == null || phrase == "") { + LOGERROR(processPhraseVariables(appname, "BigTalker - Check configuration. Phrase is empty for %devicename%", evt)) + sendNotification(processPhraseVariables(appname, "BigTalker - Check configuration. Phrase is empty for %devicename%", evt)) + } + if (resume == null) { resume = true } + if ((state.speechDeviceType == "capability.musicPlayer") && (!( phrase==null ) && !(phrase==""))){ + state.sound = "" + state.ableToTalk = false + if (!(settings.speechDeviceDefault == null) || !(customSpeechDevice == null)) { + LOGTRACE("TALK(${appname}.${evt.name})|mP@|${volume} >> ${phrase}") + if (resume) { LOGTRACE("TALK(${appname}.${evt.name})|mP| Resume is desired") } else { LOGTRACE("TALK(${appname}.${evt.name})|mP| Resume is not desired") } + if (!(phrase.toLowerCase().contains(".mp3"))){ + try { + state.sound = textToSpeech(phrase instanceof List ? phrase[0] : phrase, myVoice) + state.ableToTalk = true + } catch(e) { + LOGERROR("TALK(${appname}.${evt.name})|mP| ST Platform issue (textToSpeech)? ${e}") + //Try Again + try { + LOGTRACE("TALK(${appname}.${evt.name})|mP| Trying textToSpeech function again...") + state.sound = textToSpeech(phrase instanceof List ? phrase[0] : phrase, myVoice) + state.ableToTalk = true + } catch(ex) { + LOGERROR("TALK(${appname}.${evt.name})|mP| ST Platform issue (textToSpeech)? I tried textToSpeech() twice, SmartThings wouldn't convert/process. I give up, Sorry..") + sendNotificationEvent("ST Platform issue? textToSpeech() failed.") + sendNotification("BigTalker couldn't announce: ${phrase}") + } //try again before final error(ableToTalk) + } //try (ableToTalk) + } else { + LOGTRACE("TALK(${appname}.${evt.name})|mP| MP3=${phrase}") + def sound = [uri:phrase, duration:10] + state.sound = sound + playAudioFile = true + state.ableToTalk = true + LOGTRACE("Sound=${state.sound}") + } + if ((state?.allowScheduledPoll == true || state?.allowScheduledPoll == null) && (resume)) { + unschedule("poll") + LOGDEBUG("TALK(${appname}.${evt.name})|mP| Delaying polling for 120 seconds") + myRunIn(120, poll) + } + if (state.ableToTalk){ + state.sound.duration = (state.sound.duration.toInteger() + 5).toString() //Try to prevent cutting out, add seconds to the duration + if (!(customSpeechDevice == null)) { + currentSpeechDevices = customSpeechDevice + } else { + //Use Default Speech Device + currentSpeechDevices = settings.speechDeviceDefault + } //if (!(customSpeechDevice == null)) + LOGTRACE("TALK(${appname}.${evt.name})|mP| Last poll: ${state.lastPoll}") + //Iterate Speech Devices and talk + def attrs = currentSpeechDevices.supportedAttributes + currentSpeechDevices.each(){ + LOGDEBUG("TALK(${appname}.${evt.name})|mP| attrs=${attrs}") + def currentStatus = "" + try { + currentStatus = it?.latestValue("status") + } catch (ex) { LOGDEBUG("ERROR getting device currentStatus") } + def currentTrack = "" + try { + currentTrack = it?.latestState("trackData")?.jsonValue + } catch (ex) { LOGDEBUG("ERROR getting device currentTrack") } + def currentVolume = 0 + try { + currentVolume = it?.latestState("level")?.integerValue ? it.latestState("level")?.integerValue : 0 + } catch (ex) { LOGDEBUG("ERROR getting device currentVolume") } + def minimumVolume = 50 + if (settings?.speechMinimumVolume >= 0) {minimumVolume = settings.speechMinimumVolume} + if (minimumVolume > 100) {minimumVolume = 100} + def desiredVolume = volume + //try { + // desiredVolume = settings?.speechVolume + //} catch (ex) { LOGDEBUG("ERROR getting desired default volume"); desiredVolume = -1 } + if (desiredVolume > 100) {desiredVolume = 100} + LOGDEBUG("TALK(${appname}.${evt.name})|mP| currentStatus:${currentStatus}") + LOGDEBUG("TALK(${appname}.${evt.name})|mP| currentTrack:${currentTrack}") + LOGDEBUG("TALK(${appname}.${evt.name})|mP| currentVolume:${currentVolume}") + LOGDEBUG("TALK(${appname}.${evt.name})|mP| Sound: ${state.sound.uri} , ${state.sound.duration}") + if (desiredVolume > -1){ + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it.displayName} | Volume: ${currentVolume}, Desired Volume: ${desiredVolume}") + } else { + if (!(currentVolume >= minimumVolume)) { + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it.displayName} | Volume: ${currentVolume}, Minimum Volume: ${minimumVolume}; adjusting.") + } else { + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it.displayName} | Volume: ${currentVolume}, Minimum Volume: ${minimumVolume}; acceptable.") + } + } + if (!(currentTrack == null)){ + //currentTrack has data + if (!(currentTrack?.status == null)) { LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it.displayName} | Current Status: ${currentStatus}, CurrentTrack: ${currentTrack}, CurrentTrack.Status: ${currentTrack.status}.") } + if (currentTrack?.status == null) { LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it.displayName} | Current Status: ${currentStatus}, CurrentTrack: ${currentTrack}.") } + if ((currentStatus == 'playing' || currentTrack?.status == 'playing') && (!((currentTrack?.status == 'stopped') || (currentTrack?.status == 'paused')))) { //Give currentTrack.status presidence if it exists, it seems more accurate + if (resume) { + LOGTRACE ("Sending playTrackandResume() 1") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT<>null | cS/cT=playing | Sending playTrackAndResume() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndResume(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndResume(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndResume(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndResume(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + } //if (desiredVolume) + } else { + //resume is not desired + LOGTRACE ("Sending playTrackandRestore() 2 - ${it?.displayName} - cVol = ${currentVolume}") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT<>null | cS/cT=playing | NoResume! | Sending playTrackAndRestore() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndRestore(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + } // if (desiredVolume) + } // if (resume) + } else { + if ((!currentTrack?.status == 'playing') && (currentStatus == 'playing')) { + LOGDEBUG "TALK(${appname}.${evt.name})|mP| ${it?.displayName} | Discrepency in CS/CT, going with CT! | CS= ${currentStatus} CT=${currentTrack.status}" + } + LOGTRACE ("Sending playTrackandRestore() 3 - to ${it?.displayName} - cVol = ${currentVolume}") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT<>null | cS/cT<>playing | Sending playTrackAndRestore() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndRestore(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + }// if (desiredVolume) + }// if ((currentStatus == 'playing' || currentTrack?.status == 'playing') && (!((currentTrack?.status == 'stopped') || (currentTrack?.status == 'paused')))) + } else { + //currentTrack==null. currentTrack doesn't have data or is not supported on this device + if (!(currentStatus == null)) { + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | (2) Current Status: ${currentStatus}.") + if (currentStatus == "disconnected") { + //VLCThing? + if (resume) { + LOGTRACE ("Sending playTrackandResume() 4") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT=null | cS=disconnected | Sending playTrackAndResume() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndResume(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndResume(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndResume(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndResume(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + } + } else { + //resume is not desired + LOGTRACE ("Sending playTrackandRestore() 5") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT=null | cS=disconnected | No Resume! | Sending playTrackAndRestore() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndRestore(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + }// if (desiredVolume) + }// if (resume) + } else { + if (currentStatus == "playing") { + if (resume) { + LOGTRACE ("Sending playTrackandResume() 6") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT=null | cS=playing | Sending playTrackAndResume() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndResume(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndResume(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndResume(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndResume(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + }// if (desiredVolume) + } else { + //resume not desired + LOGTRACE ("Sending playTrackandRestore() 7") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT=null | cS=playing | No Resume! | Sending playTrackAndRestore() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndRestore(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + }// if (desiredVolume) + }// if (resume) + } else { + //currentStatus <> "playing" + LOGTRACE ("Sending playTrackandRestore() 8") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it?.displayName} | cT=null | cS<>playing | Sending playTrackAndRestore() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndRestore(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + }// if (desiredVolume) + }// if (currentStatus == "playing") + }// if (currentStatus == "disconnected")) + } else { + //currentTrack and currentStatus are both null + LOGTRACE ("Sending playTrackandRestore() 9") + LOGTRACE("TALK(${appname}.${evt.name})|mP| ${it.displayName} | (3) cT=null | cS=null | Sending playTrackAndRestore() | CVol=${currentVolume} | SVol=${desiredVolume}") + if (desiredVolume > -1) { + if (desiredVolume == currentVolume){it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay])} + if (!(desiredVolume == currentVolume)){it.playTrackAndRestore(state.sound.uri, state.sound.duration, desiredVolume, [delay: myDelay])} + spoke = true + } else { + if (currentVolume >= minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, [delay: myDelay]) } + if (currentVolume < minimumVolume) { it.playTrackAndRestore(state.sound.uri, state.sound.duration, minimumVolume, [delay: myDelay]) } + spoke = true + } //if (desiredVolume) + } //currentStatus == null + } //currentTrack == null + } //currentSpeechDevices.each() + } //state.ableToTalk + } //if (!(settings.speechDeviceDefault == null) || !(customSpeechDevice == null)) + }// if (state.speechDeviceType=="capability.musicPlayer") + if ((state.speechDeviceType == "capability.speechSynthesis") && (!( phrase==null ) && !(phrase==""))){ + //capability.speechSynthesis is in use + if (!(settings?.speechDeviceDefault == null) || !(customSpeechDevice == null)) { + LOGTRACE("TALK(${appname}.${evt.name}) |sS| >> ${phrase}") + if (!(customSpeechDevice == null)) { + currentSpeechDevices = customSpeechDevice + } else { + //Use Default Speech Device + currentSpeechDevices = settings.speechDeviceDefault + }// If (!(customSpeechDevice == null)) + //Iterate Speech Devices and talk + def attrs = currentSpeechDevices.supportedAttributes + currentSpeechDevices.each(){ + // Determine device name either by it.displayName or it.device.displayName (whichever works) + try { + LOGTRACE("TALK(${appname}.${evt.name}) |sS| ${it.displayName} | Sending speak().") + } + catch (ex) { + LOGDEBUG("TALK(${appname}.${evt.name}) |sS| it.displayName failed, trying it.device.displayName") + try { + LOGTRACE("TALK(${appname}.${evt.name}) |sS| ${it.device.displayName} | Sending speak().") + } + catch (ex2) { + LOGDEBUG("TALK(${appname}.${evt.name}) |sS| it.device.displayName failed, trying it.device.name") + LOGTRACE("TALK(${appname}.${evt.name}) |sS| ${it.device.name} | Sending speak().") + } + } + spoke = true + it.speak(phrase) + }// currentSpeechDevices.each() + } //if (!(settings.speechDeviceDefault == null) || !(customSpeechDevice == null)) + } //if (state.speechDeviceType == "capability.speechSynthesis") + + if ((!(smartAppSpeechDevice) && !(spoke)) && (!(phrase == null) && !(phrase == "")) && !(playAudioFile)) { + //No musicPlayer, speechSynthesis, or smartAppSpeechDevices selected. No route to export speech! + LOGTRACE("TALK(${appname}.${evt.name}) |ERROR| No selected speech device or smartAppSpeechDevice token in phrase. ${phrase}") + } else { + if ((smartAppSpeechDevice && !spoke) && (!(phrase == null) && !(phrase == ""))){ + LOGTRACE("TALK(${appname}.${evt.name}) |sA| Sent to another smartApp.") + } + } + phrase = "" +}//Talk() + +def timeAllowed(devicetype,index){ + def now = new Date() + //Check Default Setting + //devicetype = mode, motion, switch, presence, lock, contact, thermostat, acceleration, water, smoke, button + switch (devicetype) { + case "mode": + if (index == 1 && (!(settings.modeStartTime1 == null))) { + if (timeOfDayIsBetween(settings.modeStartTime1, settings.modeEndTime1, now, location.timeZone)) { return true } else { return false } + } + case "motion": + if (index == 1 && (!(settings.motionStartTime1 == null))) { + if (timeOfDayIsBetween(settings.motionStartTime1, settings.motionEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.motionStartTime2 == null))) { + if (timeOfDayIsBetween(settings.motionStartTime2, settings.motionEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.motionStartTime3 == null))) { + if (timeOfDayIsBetween(settings.motionStartTime3, settings.motionEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "switch": + if (index == 1 && (!(settings.switchStartTime1 == null))) { + if (timeOfDayIsBetween(settings.switchStartTime1, settings.switchEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.switchStartTime2 == null))) { + if (timeOfDayIsBetween(settings.switchStartTime2, settings.switchEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.switchStartTime3 == null))) { + if (timeOfDayIsBetween(settings.switchStartTime3, settings.switchEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "presence": + if (index == 1 && (!(settings.presenceStartTime1 == null))) { + if (timeOfDayIsBetween(settings.presenceStartTime1, settings.presenceEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.presenceStartTime2 == null))) { + if (timeOfDayIsBetween(settings.presenceStartTime2, settings.presenceEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.presenceStartTime3 == null))) { + if (timeOfDayIsBetween(settings.presenceStartTime3, settings.presenceEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "lock": + if (index == 1 && (!(settings.lockStartTime1 == null))) { + if (timeOfDayIsBetween(settings.lockStartTime1, settings.lockEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.lockStartTime2 == null))) { + if (timeOfDayIsBetween(settings.lockStartTime2, settings.lockEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.lockStartTime3 == null))) { + if (timeOfDayIsBetween(settings.lockStartTime3, settings.lockEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "contact": + if (index == 1 && (!(settings.contactStartTime1 == null))) { + if (timeOfDayIsBetween(settings.contactStartTime1, settings.contactEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.contactStartTime2 == null))) { + if (timeOfDayIsBetween(settings.contactStartTime2, settings.contactEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.contactStartTime3 == null))) { + if (timeOfDayIsBetween(settings.contactStartTime3, settings.contactEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "thermostat": + if (index == 1 && (!(settings.thermostatStartTime1 == null))) { + if (timeOfDayIsBetween(settings.thermostatStartTime1, settings.thermostatEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.thermostatStartTime2 == null))) { + if (timeOfDayIsBetween(settings.thermostatStartTime2, settings.thermostatEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.thermostatStartTime3 == null))) { + if (timeOfDayIsBetween(settings.thermostatStartTime3, settings.thermostatEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "acceleration": + if (index == 1 && (!(settings.accelerationStartTime1 == null))) { + if (timeOfDayIsBetween(settings.accelerationStartTime1, settings.accelerationEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.accelerationStartTime2 == null))) { + if (timeOfDayIsBetween(settings.accelerationStartTime2, settings.accelerationEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.accelerationStartTime3 == null))) { + if (timeOfDayIsBetween(settings.accelerationStartTime3, settings.accelerationEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "water": + if (index == 1 && (!(settings.waterStartTime1 == null))) { + if (timeOfDayIsBetween(settings.waterStartTime1, settings.waterEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.waterStartTime2 == null))) { + if (timeOfDayIsBetween(settings.waterStartTime2, settings.waterEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.waterStartTime3 == null))) { + if (timeOfDayIsBetween(settings.waterStartTime3, settings.waterEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "smoke": + if (index == 1 && (!(settings.smokeStartTime1 == null))) { + if (timeOfDayIsBetween(settings.smokeStartTime1, settings.smokeEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.smokeStartTime2 == null))) { + if (timeOfDayIsBetween(settings.smokeStartTime2, settings.smokeEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.smokeStartTime3 == null))) { + if (timeOfDayIsBetween(settings.smokeStartTime3, settings.smokeEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "button": + if (index == 1 && (!(settings.buttonStartTime1 == null))) { + if (timeOfDayIsBetween(settings.buttonStartTime1, settings.buttonEndTime1, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.buttonStartTime2 == null))) { + if (timeOfDayIsBetween(settings.buttonStartTime2, settings.buttonEndTime2, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.buttonStartTime3 == null))) { + if (timeOfDayIsBetween(settings.buttonStartTime3, settings.buttonEndTime3, now, location.timeZone)) { return true } else { return false } + } + case "SHM": + if (index == 1 && (!(settings.SHMStartTimeAway == null))) { + if (timeOfDayIsBetween(settings.SHMStartTimeAway, settings.SHMEndTimeAway, now, location.timeZone)) { return true } else { return false } + } + if (index == 2 && (!(settings.SHMStartTimeHome == null))) { + if (timeOfDayIsBetween(settings.SHMStartTimeHome, settings.SHMEndTimeHome, now, location.timeZone)) { return true } else { return false } + } + if (index == 3 && (!(settings.SHMStartTimeDisarm == null))) { + if (timeOfDayIsBetween(settings.SHMStartTimeDisarm, settings.SHMEndTimeDisarm, now, location.timeZone)) { return true } else { return false } + } + } + + //No overrides have returned True, process Default + if (settings.defaultStartTime == null) { + return true + } else { + if (timeOfDayIsBetween(settings.defaultStartTime, settings.defaultEndTime, now, location.timeZone)) { return true } else { return false } + } +} + +def modeAllowed(devicetype,index) { + //Determine if we are allowed to speak in our current mode based on the calling device or default setting + //devicetype = motion, switch, presence, lock, contact, thermostat, acceleration, water, smoke, button + switch (devicetype) { + case "motion": + if (index == 1) { + //Motion Group 1 + if (settings.motionModes1) { + if (settings.motionModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Motion Group 2 + if (settings.motionModes2) { + if (settings.motionModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Motion Group 3 + if (settings.motionModes3) { + if (settings.motionModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "motion" + case "switch": + if (index == 1) { + //Switch Group 1 + if (settings.switchModes1) { + if (settings.switchModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Switch Group 2 + if (settings.switchModes2) { + if (settings.switchModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Switch Group 3 + if (settings.switchModes3) { + if (settings.switchModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "switch" + case "presence": + if (index == 1) { + //Presence Group 1 + if (settings.presenceModes1) { + if (settings.presenceModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Presence Group 2 + if (settings.presenceModes2) { + if (settings.presenceModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Presence Group 3 + if (settings.presenceModes3) { + if (settings.presenceModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "presence" + case "lock": + if (index == 1) { + //Lock Group 1 + if (settings.lockModes1) { + if (settings.lockModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Lock Group 2 + if (settings.lockModes2) { + if (settings.lockModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Lock Group 3 + if (settings.lockModes3) { + if (settings.lockModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "lock" + case "contact": + if (index == 1) { + //Contact Group 1 + if (settings.contactModes1) { + if (settings.contactModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Contact Group 2 + if (settings.contactModes2) { + if (settings.contactModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Contact Group 3 + if (settings.contactModes3) { + if (settings.contactModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "contact" + case "thermostat": + if (index == 1) { + //Thermostat Group 1 + if (settings.thermostatModes1) { + if (settings.thermostatModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Thermostat Group 2 + if (settings.thermostatModes2) { + if (settings.thermostatModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Thermostat Group 3 + if (settings.thermostatModes3) { + if (settings.thermostatModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "thermostat" + case "acceleration": + if (index == 1) { + //Acceleration Group 1 + if (settings.accelerationModes1) { + if (settings.accelerationModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Acceleration Group 2 + if (settings.accelerationModes2) { + if (settings.accelerationModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Acceleration Group 3 + if (settings.accelerationModes3) { + if (settings.accelerationModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "acceleration" + case "water": + if (index == 1) { + //Water Group 1 + if (settings.waterModes1) { + if (settings.waterModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Water Group 2 + if (settings.waterModes2) { + if (settings.waterModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Water Group 3 + if (settings.waterModes3) { + if (settings.waterModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "water" + case "smoke": + if (index == 1) { + //Smoke Group 1 + if (settings.smokeModes1) { + if (settings.smokeModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Smoke Group 2 + if (settings.smokeModes2) { + if (settings.smokeModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Smoke Group 3 + if (settings.smokeModes3) { + if (settings.smokeModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "smoke" + case "button": + if (index == 1) { + //Button Group 1 + if (settings.buttonModes1) { + if (settings.buttonModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //Button Group 2 + if (settings.buttonModes2) { + if (settings.buttonModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //Button Group 3 + if (settings.buttonModes3) { + if (settings.buttonModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "button" + case "SHM": + if (index == 1) { + //SHM Armed Away + if (settings.SHMModesAway) { + if (settings.SHMModesAway.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //SHM Armed Home + if (settings.SHMModesHome) { + if (settings.SHMModesHome.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //SHM Disarmed + if (settings.SHMModesDisarm) { + if (settings.SHMModesDisarm.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "SHM" + case "timeSlot": + if (index == 1) { + //TimeSlot Group 1 + if (settings.timeSlotModes1) { + if (settings.timeSlotModes1.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 2) { + //TimeSlot Group 2 + if (settings.timeSlotModes2) { + if (settings.timeSlotModes2.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + if (index == 3) { + //TimeSlot Group 3 + if (settings.timeSlotModes3) { + if (settings.timeSlotModes3.contains(location.mode)) { + //Custom mode for this event is in use and we are in one of those modes + return true + } else { + //Custom mode for this event is in use and we are not in one of those modes + return false + } + } else { + return (settings.speechModesDefault.contains(location.mode)) //True if we are in an allowed Default mode, False if not + } + } + //End: case "timeSlot" + } //End: switch (devicetype) +} + +def getTimeFromDateString(inputtime, includeAmPm){ + //I couldn't find the way to do this in ST / Groovy, so I made my own function + //Obtains the time from a supplied specifically formatted date string (ie: from a preference of type "time") + //LOGDEBUG "InputTime: ${inputtime}" + def outputtime = inputtime + def am_pm = "??" + outputtime = inputtime.substring(11,16) + if (includeAmPm) { + if ((outputtime.substring(0,2)).toInteger() < 12) { + am_pm = "am" + } else { + am_pm = "pm" + def newHH = ((outputtime.substring(0,2)).toInteger() - 12) + outputtime = newHH + outputtime.substring(2,5) + } + outputtime += am_pm + } + //LOGDEBUG "OutputTime: ${outputtime}" + return outputtime +} + +def getTimeFromCalendar(includeSeconds, includeAmPm){ + //Obtains the current time: HH:mm:ss am/pm + def calendar = Calendar.getInstance() + calendar.setTimeZone(location.timeZone) + def timeHH = calendar.get(Calendar.HOUR) + def timemm = calendar.get(Calendar.MINUTE) + def timess = calendar.get(Calendar.SECOND) + def timeampm = calendar.get(Calendar.AM_PM) ? "pm" : "am" + def timestring = "${timeHH}:${timemm}" + if (includeSeconds) { timestring += ":${timess}" } + if (includeAmPm) { timestring += " ${timeampm}" } + return timestring +} + +//myRunIn from ST:Geko / Statusbits SmartAlarm app http://statusbits.github.io/smartalarm/ +private def myRunIn(delay_s, func) { + //LOGDEBUG("myRunIn(${delay_s},${func})") + + if (delay_s > 0) { + def tms = now() + (delay_s * 1000) + def date = new Date(tms) + runOnce(date, func) + //LOGDEBUG("'${func}' scheduled to run at ${date}") + } +} + +def TalkQueue(appname, phrase, customSpeechDevice, volume, resume, personality, voice, evt){ + //IN DEVELOPMENT + // Already talking or just recently (within x seconds) started talking + // Queue up current request(s), give time for current action to complete, then speak and flush queue + def threshold = 0 + def minDelay = 6 //Minimum seconds between talking + try { + if (!(state?.sound?.duration == null)) { + threshold = state.sound.duration.toInteger() //Use the last musicPlayer sound duration from the last Talk call as the minimum delay + } + } catch (exception) { + threshold = 10 + } + def durationFromLastTalkReq = 9999 + //if (!(state.lastTalkTime == null)) { durationFromLastTalk = ((now() - state?.lastTalkTime)/1000).intValue() } + if (!(state.lastTalkRequest == null)) { durationFromLastTalkReq = ((now() - state?.lastTalkRequest)/1000).intValue() } + state.lastTalkRequest = now() + def tooSoon = (durationFromLastTalkReq < threshold) + def neededDelay = (((threshold - durationFromLastTalkReq) * 1000) + 1000) + LOGDEBUG ("TALKQUEUE(Threshold=${threshold},DurationFromLastTalkReq=${durationFromLastTalkReq},lastTalkReq=${state.lastTalkRequest},lastTalkTime=${state.lastTalkTime}, TooSoon=${tooSoon}, Calc=${neededDelay}") + if (tooSoon) { + if (neededDelay < 0) { + neededDelay = 0 + } else { + if (neededDelay < (minDelay * 1000)) { neededDelay = (minDelay * 1000) } + } + LOGDEBUG("TALKQUEUE()-Spoke too recently; delaying ${(neededDelay / 1000)} seconds.") + return neededDelay + } else { + LOGDEBUG("TALKQUEUE()-OK to speak; (${(durationFromLastTalkReq)})") + return 0 + } +} + +def getWeather(mode, zipCode) { + //Function derived from "Sonos Weather Forecast" SmartApp by Smartthings (modified) + LOGDEBUG("Processing: getWeather(${mode},${zipCode})") + def weather = getWeatherFeature("forecast", zipCode) + def current = getWeatherFeature("conditions", zipCode) + def isMetric = location.temperatureScale == "C" + def delim = "" + def sb = new StringBuilder() + if (mode == "current") { + if (isMetric) { + sb << "The current temperature is ${Math.round(current.current_observation.temp_c)} degrees." + } + else { + sb << "The current temperature is ${Math.round(current.current_observation.temp_f)} degrees." + } + delim = " " + } //mode == current + else if (mode == "today") { + sb << delim + sb << "Today's forecast is " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[0].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[0].fcttext + } + } //mode == today + else if (mode == "tonight") { + sb << delim + sb << "Tonight will be " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[1].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[1].fcttext + } + } //mode == tonight + else if (mode == "tomorrow") { + sb << delim + sb << "Tomorrow will be " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[2].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[2].fcttext + } + } //mode == tomorrow + else { + sb < "ERROR: Requested weather mode was not recognized." + }//mode = unknown + def msg = sb.toString() + msg = msg.replaceAll(/([0-9]+)C/,'$1 degrees celsius') + msg = msg.replaceAll(/([0-9]+)F/,'$1 degrees fahrenheit') + LOGDEBUG("msg = ${msg}") + return(msg) +} + +def poll(){ + if (settings?.resumePlay == true || settings?.resumePlay == null) { + unschedule("poll") + //LOGDEBUG("poll() settings=${settings?.allowScheduledPoll}") + //LOGDEBUG("poll() state=${state?.allowScheduledPoll}") + //LOGDEBUG("poll() resumePlay=${settings?.resumePlay}") + if (((settings?.allowScheduledPoll == true || state?.allowScheduledPoll == true)) || ((settings?.allowScheduledPoll == null) || (state?.allowScheduledPoll == null))) { + state.allowScheduledPoll = true + } else { + state.allowScheduledPoll = false + LOGDEBUG("Polling is not desired, disabling after this poll.") + } + if (state.speechDeviceType == "capability.musicPlayer") { + LOGDEBUG("Polling speech device(s) for latest status") + state.polledDevices = "" + try { + if (!(settings?.speechDeviceDefault == null)) {dopoll(settings.speechDeviceDefault)} + if (!(settings?.motionSpeechDevice1 == null)) {dopoll(settings.motionSpeechDevice1)} + if (!(settings?.motionSpeechDevice2 == null)) {dopoll(settings.motionSpeechDevice2)} + if (!(settings?.motionSpeechDevice3 == null)) {dopoll(settings.motionSpeechDevice3)} + if (!(settings?.switchSpeechDevice1 == null)) {dopoll(settings.switchSpeechDevice1)} + if (!(settings?.switchSpeechDevice2 == null)) {dopoll(settings.switchSpeechDevice2)} + if (!(settings?.switchSpeechDevice3 == null)) {dopoll(settings.switchSpeechDevice3)} + if (!(settings?.presSpeechDevice1 == null)) {dopoll(settings.presSpeechDevice1)} + if (!(settings?.presSpeechDevice2 == null)) {dopoll(settings.presSpeechDevice2)} + if (!(settings?.presSpeechDevice3 == null)) {dopoll(settings.presSpeechDevice3)} + if (!(settings?.lockSpeechDevice1 == null)) {dopoll(settings.lockSpeechDevice1)} + if (!(settings?.lockSpeechDevice2 == null)) {dopoll(settings.lockSpeechDevice2)} + if (!(settings?.lockSpeechDevice3 == null)) {dopoll(settings.lockSpeechDevice3)} + if (!(settings?.contactSpeechDevice1 == null)) {dopoll(settings.contactSpeechDevice1)} + if (!(settings?.contactSpeechDevice2 == null)) {dopoll(settings.contactSpeechDevice2)} + if (!(settings?.contactSpeechDevice3 == null)) {dopoll(settings.contactSpeechDevice3)} + if (!(settings?.modePhraseSpeechDevice1 == null)) {dopoll(settings.modePhraseSpeechDevice1)} + if (!(settings?.thermostatSpeechDevice1 == null)) {dopoll(settings.thermostatSpeechDevice1)} + if (!(settings?.accelerationSpeechDevice1 == null)) {dopoll(settings.accelerationSpeechDevice1)} + if (!(settings?.accelerationSpeechDevice2 == null)) {dopoll(settings.accelerationSpeechDevice2)} + if (!(settings?.accelerationSpeechDevice3 == null)) {dopoll(settings.accelerationSpeechDevice3)} + if (!(settings?.waterSpeechDevice1 == null)) {dopoll(settings.waterSpeechDevice1)} + if (!(settings?.waterSpeechDevice2 == null)) {dopoll(settings.waterSpeechDevice2)} + if (!(settings?.waterSpeechDevice3 == null)) {dopoll(settings.waterSpeechDevice3)} + if (!(settings?.smokeSpeechDevice1 == null)) {dopoll(settings.smokeSpeechDevice1)} + if (!(settings?.smokeSpeechDevice2 == null)) {dopoll(settings.smokeSpeechDevice2)} + if (!(settings?.smokeSpeechDevice3 == null)) {dopoll(settings.smokeSpeechDevice3)} + if (!(settings?.buttonSpeechDevice1 == null)) {dopoll(settings.buttonSpeechDevice1)} + if (!(settings?.buttonSpeechDevice2 == null)) {dopoll(settings.buttonSpeechDevice2)} + if (!(settings?.buttonSpeechDevice3 == null)) {dopoll(settings.buttonSpeechDevice3)} + if (!(settings?.timeslotSpeechDevice1 == null)) {dopoll(settings.timeslotSpeechDevice1)} + if (!(settings?.timeslotSpeechDevice2 == null)) {dopoll(settings.timeslotSpeechDevice2)} + if (!(settings?.timeslotSpeechDevice3 == null)) {dopoll(settings.timeslotSpeechDevice3)} + } catch(e) { + LOGERROR("One of your speech devices is not responding. Poll failed.") + } + state.lastPoll = getTimeFromCalendar(true,true) + //LOGDEBUG("poll: state.polledDevices == ${state?.polledDevices}") + if (!(state?.polledDevices == "")) { + //Reschedule next poll + if (((settings?.allowScheduledPoll == true || state?.allowScheduledPoll == true)) || ((settings?.allowScheduledPoll == null) || (state?.allowScheduledPoll == null))) { + LOGDEBUG("Rescheduling Poll") + myRunIn(60, poll) + } + } else { + LOGDEBUG("No speech devices polled. Cancelling polling.") + } + } + } +} +def dopoll(pollSpeechDevice){ + pollSpeechDevice.each(){ + def devicename = "" + try { + devicename = it.displayName + } catch (ex) {} + if (devicename == "") { + try { + devicename = it.device.displayName + } catch (ex) {} + } + if (devicename == "") { + LOGERROR("dopoll(${pollSpeechDevice}) - Unable to get devicename") + } + if (!(state?.polledDevices?.find("|${devicename}|"))) { + state.polledDevices = state?.polledDevices + "|${devicename}|" + LOGDEBUG("dopoll(${devicename}) Polling ") + state.refresh = false + state.poll = false + try { + //LOGTRACE("refresh()") + it.refresh() + state.refresh = true + } + catch (ex) { + LOGDEBUG("ERROR(informational): it.refresh: ${ex}") + state.refresh = false + } + if (!state.refresh) { + try { + //LOGTRACE("poll()") + it.poll() + state.poll = true + state.refresh = true + } + catch (ex) { + LOGDEBUG ("ERROR(informational): it.poll: ${ex}") + state.refresh = false + } + } + LOGDEBUG("dopoll(${it.displayName})cS=${it?.latestValue('status')},cT=${it?.latestState("trackData")?.jsonValue?.status},cV=${it?.latestState("level")?.integerValue ? it?.latestState("level")?.integerValue : 0}") + if (it?.latestValue('status') == "no_device_present") { LOGTRACE("During polling, the handler for ${devicename} indicated the device was not found.") } //VLCThing + } + LOGDEBUG("dopoll - polled devices: ${state?.polledDevices}") + } +} + +def getDesiredVolume(invol) { + def globalVolume = settings?.speechVolume + def globalMinimumVolume = settings?.speechMinimumVolume + def myVolume = invol + def finalVolume = -1 + if (myVolume > 0) { + finalVolume = myVolume + } else { + if (globalVolume > 0) { + finalVolume = globalVolume + } else { + if (globalMinimumVolume > 0) { + finalVolume = globalMinimumVolume + } else { + finalVolume = 50 //Default if no volume parameters are set + } + } + } + if (state.speechDeviceType == "capability.musicPlayer") { + LOGDEBUG("finalVolume: ${finalVolume}") + } + return finalVolume +} + +def setLastMode(mode){ + state.lastMode = mode +} + +def LOGDEBUG(txt){ + def msgfrom = "[PARENT] " + if (txt?.contains("[CHILD:")) { msgfrom = "" } + try { + if (settings.debugmode) { log.debug("${app.label.replace(" ","").toUpperCase()}(${state.appversion}) || ${msgfrom}${txt}") } + } catch(ex) { + log.error("LOGDEBUG unable to output requested data!") + } +} +def LOGTRACE(txt){ + def msgfrom = "[PARENT] " + if (txt?.contains("[CHILD:")) { msgfrom = "" } + try { + log.trace("${app.label.replace(" ","").toUpperCase()}(${state.appversion}) || ${msgfrom}${txt}") + } catch(ex) { + log.error("LOGTRACE unable to output requested data!") + } +} +def LOGERROR(txt){ + def msgfrom = "[PARENT] " + if (txt?.contains("[CHILD:")) { msgfrom = "" } + try { + log.error("${app.label.replace(" ","").toUpperCase()}(${state.appversion}) || ${msgfrom}ERROR: ${txt}") + } catch(ex) { + log.error("LOGERROR unable to output requested data!") + } +} + +def setAppVersion(){ + state.appversion = "P2.0.6" +} \ No newline at end of file diff --git a/smartapps/rboy/garage-door-open-and-close-automatically.src/garage-door-open-and-close-automatically.groovy b/smartapps/rboy/garage-door-open-and-close-automatically.src/garage-door-open-and-close-automatically.groovy new file mode 100644 index 00000000000..bea244f7db3 --- /dev/null +++ b/smartapps/rboy/garage-door-open-and-close-automatically.src/garage-door-open-and-close-automatically.groovy @@ -0,0 +1,675 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { + return "02.00.06" +} + +/* +* Garage Door Open and Close +* +* Copyright RBoy Apps, redistribution or reuse of code is not allowed without permission +* +* Change Log +* 2019-01-09 - (v02.00.06) Send closed notification if garage door was closed before the timer expired +* 2018-12-27 - (v02.00.05) Added icons +* 2018-12-21 - (v02.00.04) Added option for detailed notifications to reduce number of messages +* 2018-06-28 - (v02.00.02) Reset close time if door is closed and reopened and don't reset timer for delayed opening if multiple people arrive +* 2018-05-14 - (v02.00.01) Version update reinitialize fix +* 2018-04-20 - (v02.00.00) Updated to match new ST door control specifications, added timed closing options and revamped UI +* 2018-03-21 - (v01.08.03) Don't print message about turning on switches if no switches are selected +* 2018-02-13 - (v01.08.02) Added support for garage door control devices instead of door control devices +* 2017-09-06 - (v01.08.00) Added support for expirations past midnight +* 2017-07-24 - (v01.07.00) Added support for opening and closing mode selection +* 2017-05-26 - (v01.06.02) Multiple SMS numbers are now separate by a * +* 2017-04-29 - (v01.06.01) Patch for delayed opening of garage doors +* 2017-04-22 - (v01.06.00) Added support for delayed opening of garage doors +* 2016-11-05 - Added support for automatic code update notifications and fixed an issue with sms +* 2016-10-07 - Added support for Operating Schedule for arrival and departure +* 2016-08-17 - Added workaround for ST contact address book bug +* 2016-08-13 - Added support for sending SMS to multiple numbers by separating them with a + +* 2016-08-13 - Added support for contact address book from ST +* 2016-08-13 - Added support to turn on lights when someone arrives with option of doing it when it's dark outside +* 2016-02-14 - Only open/close doors if required and notify accordingly +* 2016-01-16 - Description correction +* 2016-01-16 - Added option to choose different garage doors/people for Open and Close actions +* 2016-01-15 - Added option for notitifications +* 2016-01-15 - Fix for missing handler +* 2015-10-26 - Fixed incorrect display text for arriving +* 2015-02-02 - Initial release +* +*/ +definition( + name: "Garage Door Open and Close Automatically", + namespace: "rboy", + author: "RBoy Apps", + description: "Open/close a garage door when someone arrives/leaves or after a specified time and execute actions", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact@2x.png") + +preferences { + page(name: "mainPage") + page(name: "arrivalPage") + page(name: "leavePage") + page(name: "schedulePage") + page(name: "closePage") +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "Garage Door Open and Close Automatically v${clientVersion()}", install: true, uninstall: true) { + section("Opening Garage Door(s)") { + href(name: "arrival", title: "Open when people arrive", page: "arrivalPage", description: (arrives && doorsOpen) ? "Configured" : "Not configured", required: false, image: "http://www.rboyapps.com/images/GarageOpen.png") + } + + section("Closing Garage Door(s)") { + href(name: "leave", title: "Close when people leave", page: "leavePage", description: (leaves && doorsClose) ? "Configured" : "Not configured", required: false, image: "http://www.rboyapps.com/images/GarageClosed.png") + href(name: "close", title: "Close when left open", page: "closePage", description: (doorsCloseTimed && closeTimed) ? "Configured" : "Not configured", required: false, image: "http://www.rboyapps.com/images/GarageClosedTimer.png") + } + section("Notifications") { + input("recipients", "contact", title: "Send notifications to", multiple: true, required: false, image: "http://www.rboyapps.com/images/Notifications.png") { + paragraph "You can enter multiple phone numbers by separating them with a '*'\nE.g. 5551234567*+448747654321" + input "sms", "phone", title: "Send SMS notification to", required: false, image: "http://www.rboyapps.com/images/Notifications.png" + input "push", "bool", title: "Send push notifications", defaultValue: true, required: false, image: "http://www.rboyapps.com/images/PushNotification.png" + } + input "audioDevices", "capability.audioNotification", title: "Play notifications on these devices", required: false, multiple: true, image: "http://www.rboyapps.com/images/Horn.png" + input "detailedNotifications", "bool", title: "Send detailed notifications", defaultValue: false, required: false + } + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "updateNotifications", title: "Check for new versions of the app", type: "bool", defaultValue: true, required: false + } + } +} + +def arrivalPage() { + dynamicPage(name: "arrivalPage", title: "Open Garage Doors When People Arrive", install: false, uninstall: false) { + section() { + input "arrives", "capability.presenceSensor", title: "When one of these arrive", description: "Which people arrive?", multiple: true, required: false + input "doorsOpen", "capability.doorControl", title: "Open these garage door(s)?", required: false, multiple: true + input "doorsOpenDelay", "number", title: "...after these seconds", required: false + input "arriveSwitches", "capability.switch", title: "...and turn on these switches", description: "Turn on lights", multiple: true, required: false, submitOnChange: true + if (arriveSwitches) { + input "arriveAfterDark", "bool", title: "...only if it's getting dark outside", description: "Turn on lights at night", required: false + } + input name: "openModes", type: "mode", title: "...only when in this mode(s)", required: false, multiple: true + + def hrefParams = [user: "A", schedule: 0 as String, passed: true] // use as String otherwise it won't work on Android + href name: "arrivalSchedule", params: hrefParams, title: "...only during this schedule", page: "schedulePage", description: scheduleDesc(hrefParams.user, hrefParams.schedule), required: false + } + } +} + +def leavePage() { + dynamicPage(name: "leavePage", title: "Close Garage Doors When People Leave", install: false, uninstall: false) { + section() { + input "leaves", "capability.presenceSensor", title: "When one of these leave", description: "Which people leave?", multiple: true, required: false + input "doorsClose", "capability.doorControl", title: "Close these garage door(s)?", required: false, multiple: true + input name: "closeModes", type: "mode", title: "...only when in this mode(s)", required: false, multiple: true + + def hrefParams = [user: "B", schedule: 0 as String, passed: true] // use as String otherwise it won't work on Android + href name: "leaveSchedule", params: hrefParams, title: "...only during this schedule", page: "schedulePage", description: scheduleDesc(hrefParams.user, hrefParams.schedule), required: false + } + } +} + +private scheduleDesc(schedule, i) { + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + log.error "Hub location/timezone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + sendPush "Hub location/timezone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + section("INVALID HUB LOCATION") { + paragraph title: "Hub location/timezone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the app to work properly", required: true, "" + } + } + + def retVal = "Everyday" + if (settings."userDayOfWeek${schedule}${i}") { + retVal = "" + settings."userDayOfWeek${schedule}${i}".each { retVal += (retVal ? ", " : "") + it }// DOW + } + if (settings."userStartTime${schedule}${i}" && settings."userEndTime${schedule}${i}") { + retVal += ": " + (settings."userStartTime${schedule}${i}" ? timeToday(settings."userStartTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : "") // Start Time + retVal += " - " + (settings."userEndTime${schedule}${i}" ? timeToday(settings."userEndTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : "") // EndTime + } else { + retVal = "Anytime" + } + + return retVal +} + +def schedulePage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + def schedule = "" + // Get user from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.user) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user $user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + // Get schedule from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.schedule) { + schedule = params.schedule ?: "" + log.trace "Passed from main page, using params lookup for schedule $schedule" + } else if (atomicState.params) { + schedule = atomicState.params.schedule ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for schedule $schedule" + } else { + log.error "Invalid params, no schedule found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Schedule Page, schedule:$schedule, user:$user, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"schedulePage", title: "Define operating schedule", uninstall: false, install: false) { + def usr = user + def i = schedule + def priorUserDayOfWeek = settings."userDayOfWeek${usr}${i}" + def priorUserStartTime = settings."userStartTime${usr}${i}" + def priorUserEndTime = settings."userEndTime${usr}${i}" + + section("Operating Schedule (optional)") { + input "userStartTime${usr}${i}", "time", title: "Start Time", required: false + input "userEndTime${usr}${i}", "time", title: "End Time", required: false + input name: "userDayOfWeek${usr}${i}", + type: "enum", + title: "Which day of the week?", + description: "All week", + required: false, + multiple: true, + options: [ + 'All Week', + 'Monday to Friday', + 'Saturday & Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday' + ], + defaultValue: priorUserDayOfWeek + } + } +} + +def closePage() { + dynamicPage(name: "closePage", title: "Close Garge Door When Left Opened", install: false, uninstall: false) { + section() { + input "doorsCloseTimed", "capability.doorControl", title: "Close these garage door(s)?", required: false, multiple: true + input "closeTimed", "number", title: "Close after (minutes)", description: "Close if left open", range: "1..*", required: false + input name: "closeModesTimed", type: "mode", title: "...only when in this mode(s)", required: false, multiple: true + + def hrefParams = [user: "C", schedule: 0 as String, passed: true] // use as String otherwise it won't work on Android + href name: "closeSchedule", params: hrefParams, title: "...only during this schedule", page: "schedulePage", description: scheduleDesc(hrefParams.user, hrefParams.schedule), required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + initialize() +} + +def initialize() { + state.clientVersion = clientVersion() // Update our local stored client version to detect code upgrades + + unschedule() + unsubscribe() + + atomicState.scheduledList = [:] // Reset any pending actions + + subscribe(arrives, "presence.present", arriveHandler) + subscribe(leaves, "presence.not present", leaveHandler) + subscribe(doorsCloseTimed, "door", timedDoorHandler) + + // Our handler runs every minute to check for pending actions + schedule("? 0/1 * * * ?", pendingActions) + + // Check for new versions of the code + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Integer randomDayOfWeek = random.nextInt(7-1) + 1 // 1 to 7 + schedule("0 0 " + randomHour + " ? * " + randomDayOfWeek, checkForCodeUpdate) // Check for code updates once a week at a random day and time between 10am and 6pm +} + +private getScheduledList() { atomicState.scheduledList = atomicState.scheduledList ?: [:] } // Get list of pending door closures + +def pendingActions() { + log.trace "Pending actions: $scheduledList" + + versionCheck() + + scheduledList.each { dni, timestamp -> + if (timestamp) { // If we have a valid timestamp for the device + def current = now() + if (current >= timestamp) { + def data = [deviceNetworkId: dni] + timedCloseDoors(data) + } else { + def door = doorsCloseTimed.find { it.deviceNetworkId == dni } + log.trace "${door} pending closure in ${(timestamp - current)/1000/60 as Integer} minute(s)" + } + } + } +} + +def timedDoorHandler(evt) { + log.debug "TimedDoorHandler $evt.displayName, $evt.name: $evt.value" + + versionCheck() + + def msg = "" + if (evt.value == "open") { + if (closeModesTimed ? !closeModesTimed.find{it == location.mode} : false) { // Check if we are within operating modes + log.warn "Out of operating mode, skipping closing door after timeout" + return + } + + if (!checkSchedule(0, "C")) { // Check if we are within operating Schedule to operating things + log.warn "Out of operating schedules, skipping closing door after timeout" + return + } + + if (closeTimed) { + msg += "$evt.displayName opened, closing garage door in $closeTimed minutes" + atomicState.scheduledList = (scheduledList << [(evt.device.deviceNetworkId):(now() + (closeTimed * 60 * 1000))]) // When to close door (keep only the latest open) + } + } else if (evt.value == "closed") { // If it's closed then reset the timer if present + if (closeTimed) { + log.trace "$evt.displayName closed, resetting close timer" + atomicState.scheduledList = (scheduledList << [(evt.device.deviceNetworkId):null]) // Reset + if (detailedNotifications) { + msg += "$evt.displayName closed" + } + } + } + + if (msg) { + log.info(msg) + sendNotifications(msg) + } +} + +def timedCloseDoors(data) { + log.debug "Timed close garage door id: $data.deviceNetworkId" + + def door = doorsCloseTimed.find { it.deviceNetworkId == data.deviceNetworkId } + log.trace "Garage door: $door.displayName" + + def msg = "" + + if (door.currentDoor != "closed") { + if (detailedNotifications) { + msg += "$closeTimed minutes over, closing $door" + } + door.close() + } else { + log.trace "$closeTimed minutes over, $door already closed" + } + + // Stop tracking it + atomicState.scheduledList = (scheduledList << [(door.deviceNetworkId):null]) // Reset it + + if (msg) { + log.info(msg) + sendNotifications(msg) + } +} + +def delayOpenDoors(data) { + log.debug "Delayed arriveHandler presenceId: $data.deviceNetworkId, type: $data.type" + + def device = arrives.find { it.deviceNetworkId == data.deviceNetworkId } + log.trace "Presence sensor: $device.displayName" + + def msg = "" + + if (device.currentPresence != "present") { + if (detailedNotifications) { + msg += ", $device.displayName not present, skipping opening doors" + } + } else { + for(door in doorsOpen) { + if (door.currentDoor == "closed") { + if (detailedNotifications) { + msg += ", opening $door" + } + door.open() + } else { + if (detailedNotifications) { + msg += ", $door already open" + } + } + } + } + + if (msg) { // If we have a message + msg = "$delayOpenDoors seconds over" + msg + } + + log.debug(msg) + sendNotifications(msg) +} + +def arriveHandler(evt) { + log.debug "arriveHandler $evt.displayName, $evt.name: $evt.value" + + versionCheck() + + if (openModes ? !openModes.find{it == location.mode} : false) { // Check if we are within operating modes + log.warn "Out of operating mode, skipping arrival handling" + return + } + + if (!checkSchedule(0, "A")) { // Check if we are within operating Schedule to operating things + log.warn "Out of operating schedules, skipping arrival handling" + return + } + + def msg = "" + + if (doorsOpenDelay) { + if (canSchedule()) { // ST only allows 6 schedules overall + msg += ", opening doors after $doorsOpenDelay seconds" + runIn(doorsOpenDelay, "delayOpenDoors", [data: [deviceNetworkId: evt.device.deviceNetworkId, type: evt.value], overwrite: false]) // If multiple people arrive take the first one + } else { + log.error "ERROR: Unable to schedule opening doors after $doorsOpenDelay seconds, not enough schedules available" + } + } else { + for(door in doorsOpen) { + if (door.currentDoor == "closed") { + msg += ", opening $door" + door.open() + } else { + if (detailedNotifications) { + msg += ", $door already open" + } + } + } + } + + if (arriveAfterDark && arriveSwitches) { + def cdt = new Date(now()) + def sunsetSunrise = getSunriseAndSunset(sunsetOffset: "-01:00") // Turn on 1 hour before sunset (dark) + log.trace "Current DT: $cdt, Sunset $sunsetSunrise.sunset, Sunrise $sunsetSunrise.sunrise" + if ((cdt >= sunsetSunrise.sunset) || (cdt <= sunsetSunrise.sunrise)) { + arriveSwitches?.on() // Turn on switches after dark + msg += ", turning on $arriveSwitches because it's getting dark outside" + } + } else if (arriveSwitches) { + arriveSwitches?.on() // Turn on switches on arrival + msg += ", turning on $arriveSwitches" + } + + if (msg) { // If we have a message + msg = "$evt.displayName arrived" + msg + } + + log.debug(msg) + sendNotifications(msg) +} + +def leaveHandler(evt) { + log.debug "leaveHandler $evt.displayName, $evt.name: $evt.value" + + versionCheck() + + if (closeModes ? !closeModes.find{it == location.mode} : false) { // Check if we are within operating modes + log.warn "Out of operating mode, skipping departure handling" + return + } + + if (!checkSchedule(0, "B")) { // Check if we are within operating Schedule to operating things + log.warn "Out of operating schedules, skipping departure handling" + return + } + + def msg = "" + for(door in doorsClose) { + if (door.currentDoor == "open") { + msg += ", closing $door" + door.close() + } else { + if (detailedNotifications) { + msg += ", $door already closed" + } + } + } + + if (msg) { // If we have a message + msg = "$evt.displayName left" + msg + } + + log.debug(msg) + sendNotifications(msg) +} + +private versionCheck() { + // Check if the user has upgraded the SmartApp and reinitailize if required + if (state.clientVersion != clientVersion()) { + def msg = "NOTE: ${app.label} detected a code upgrade. Updating configuration, please open the app and click on Save to re-validate your settings" + log.warn msg + runIn(1, initialize) // Reinitialize the app offline + sendNotifications(msg) // Do this in the end as it may timeout + return + } +} + +private void sendText(number, message) { + if (number) { + def phones = number.split("\\*") + for (phone in phones) { + sendSms(phone, message) + } + } +} + +private void sendNotifications(message) { + if (!message) { + return + } + + if (location.contactBookEnabled) { + sendNotificationToContacts(message, recipients) + } else { + if (push) { + sendPush message + } else { + sendNotificationEvent(message) + } + if (sms) { + sendText(sms, message) + } + } + if (audioDevices) { + audioDevices*.playTextAndResume(message) + } +} + +// Checks if we are within the current operating scheduled +// Inputs to the function are user (i) and schedule (x) (there can be multiple schedules) +// Preferences required in user input settings are: +// settings."userStartTime${x}${i}" +// settings."userEndTime${x}${i}" +// settings."userDayOfWeek${x}${i}" +private checkSchedule(def i, def x) { + log.trace("Checking operating schedule $x for user $i") + + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + log.error "Hub timeZone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + sendPush "Hub timeZone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + } + + def doChange = false + Calendar localCalendar = Calendar.getInstance(timeZone); + int currentDayOfWeek = localCalendar.get(Calendar.DAY_OF_WEEK); + def currentDT = new Date(now()) + + // some debugging in order to make sure things are working correclty + log.trace "Current time: ${currentDT.format("EEE MMM dd yyyy HH:mm z", timeZone)}" + + // Check if we are within operating times + if (settings."userStartTime${x}${i}" != null && settings."userEndTime${x}${i}" != null) { + def scheduledStart = timeToday(settings."userStartTime${x}${i}", timeZone) + def scheduledEnd = timeToday(settings."userEndTime${x}${i}", timeZone) + + if (scheduledEnd <= scheduledStart) { // End time is next day + def localHour = currentDT.getHours() + (int)(timeZone.getOffset(currentDT.getTime()) / 1000 / 60 / 60) + //log.trace "Local hour is $localHour" + if (( localHour >= 0) && (localHour < 12)) // If we between midnight and midday + { + log.debug "End time is before start time and we are past midnight, assuming start time is previous day" + scheduledStart = scheduledStart.previous() // Get the start time for yesterday + } else { + log.debug "End time is before start time and we are past midday, assuming end time is the next day" + scheduledEnd = scheduledEnd.next() // Get the end time for tomorrow + } + } + + log.trace("Operating Start ${scheduledStart.format("EEE MMM dd yyyy HH:mm z", timeZone)}, End ${scheduledEnd.format("EEE MMM dd yyyy HH:mm z", timeZone)}") + + if (currentDT < scheduledStart || currentDT > scheduledEnd) { + log.info("Outside operating time schedule") + return false + } + } + + // Check the condition under which we want this to run now + // This set allows the most flexibility. + log.trace("Operating DOW(s): ${settings."userDayOfWeek${x}${i}"}") + + if(settings."userDayOfWeek${x}${i}" == null) { + log.warn "Day of week not specified for operating schedule $x for user $i, assuming no schedule set, so we are within schedule" + return true + } else if(settings."userDayOfWeek${x}${i}".contains('All Week')) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Monday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.MONDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Tuesday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.TUESDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Wednesday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.WEDNESDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Thursday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.THURSDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Friday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.FRIDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Saturday') || settings."userDayOfWeek${x}${i}".contains('Saturday & Sunday')) && currentDayOfWeek == Calendar.instance.SATURDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Sunday') || settings."userDayOfWeek${x}${i}".contains('Saturday & Sunday')) && currentDayOfWeek == Calendar.instance.SUNDAY) { + doChange = true + } + + + // If we have hit the condition to schedule this then lets do it + if(doChange == true){ + log.info("Within operating schedule") + return true + } + else { + log.info("Outside operating schedule") + return false + } +} + +def checkForCodeUpdate(evt) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "Garage Door Open and Close Automatically when People Arrive/Leave" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def caps = [ arrives, doorsOpen, arriveSwitches, leaves, doorsClose, doorsCloseTimed ] + caps?.each { + def devices = it?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} + +// THIS IS THE END OF THE FILE \ No newline at end of file diff --git a/smartapps/rboy/lock-user-management.src/lock-user-management.groovy b/smartapps/rboy/lock-user-management.src/lock-user-management.groovy new file mode 100644 index 00000000000..ff9dd9dcfc8 --- /dev/null +++ b/smartapps/rboy/lock-user-management.src/lock-user-management.groovy @@ -0,0 +1,4295 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed to modify, share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { "07.14.02" } + +/** +* Add and remove multiple user codes for locks with Scheduling and notification options and local actions +* +* Copyright RBoy Apps, redistribution or reuse of code is not allowed without permission +* +* Change Log: +* 2020-10-06 - (v07.14.02) Don't enable ADT direct control by default when a keypad is detected +* 2020-10-05 - (v07.14.01) Fix 0 digit warning from some locks, added option to manually clear codes from the lock from advanced settings, show warning if user type isn't selected +* 2020-10-01 - (v07.13.11) Fix missing initial lock notification when using a delay timer +* 2020-09-15 - (v07.13.10) Update garage door controls to support new capabilities +* 2020-09-08 - (v07.13.09) Update for new platform capability model +* 2020-09-01 - (v07.13.08) Update for retirement of Classic app +* 2020-08-19 - (v07.13.07) Add option to arm ADT to away for manual locking +* 2020-08-17 - (v07.13.06) Only post one message when locks stop responding +* 2020-08-11 - (v07.13.05) Detect deleted routines after a migration +* 2020-08-05 - (v07.13.04) Dont' send code upgrade notifications +* 2020-07-29 - (v07.13.03) Fix for Door open/close actions when using multiple doors on Android Classic app +* 2020-07-26 - (v07.13.01) New app/platform improvements +* 2020-06-22 - (v07.12.01) Optimized page layout for door open/close actions when multiple locks are selected +* 2020-05-22 - (v07.12.00) Added option to lock/unlock locks for presence users and disabled auto unlock for open doors (security) +* 2020-05-01 - (v07.11.00) Added option to toggle switches on keypad lock/unlock/arm modes +* 2020-04-02 - (v07.10.04) Fix for keypad lock report for Enhanced ZigBee device handler +* 2020-01-20 - (v07.10.03) Update icons for broken ST Android app 2.18 +* 2019-11-26 - (v07.10.02) Update for changes in platform limits, optimize performance +* 2019-11-05 - (v07.10.00) Improved user status display logic, don't drop requests if retry fails, try again next time user saves the app +* 2019-10-11 - (v07.09.06) Added support for Sonos spoken notification with volume, added option to disable rechecking and notifying open door +* 2019-08-12 - (v07.09.05) Correct missing geolocation message text +* 2019-06-03 - (v07.09.04) Added support for new lock capabilities +* 2019-04-23 - (v07.09.03) Allow 0 users to enable a quick reset of all codes +* 2019-04-16 - (v07.09.02) Fix optional text for ADT +* 2019-04-12 - (v07.09.01) Check if user entered invalid characters for SMS number and notify on error +* 2019-04-03 - (v07.09.00) Enable exit beeping on keypads using direct control for SHM/ADT when delayed actions are enabled +* 2019-04-02 - (v07.08.12) Check for updates once a day and don't reset it everytime the user opens the app, allow user to save without selecting locks +* 2019-03-23 - (v07.08.10) Sometimes on a fresh install ST saves corrupted data, handle it +* 2019-03-20 - (v07.08.09) Detect if a new lock is added and Back is pressed instead of Save +* 2019-03-18 - (v07.08.08) Show active users in green instead of blue +* 2019-03-08 - (v07.08.07) Fixed extra notifications when using delayed lock actions +* 2019-02-28 - (v07.08.06) Enabled keypad SHM/ADT control by default when detected +* 2019-02-27 - (v07.08.05) Check for updates once a day +* 2019-01-28 - (v07.08.04) Fix for direct control option not showing for renamed keypads when individual door controls are enabled +* 2019-01-13 - (v07.08.03) Check for unsaved/unintialized user changes and reinitialize the app automatically +* 2018-12-13 - (v07.08.02) Reduce frequency to kickstarts since platform is more stable +* 2018-12-04 - (v07.08.01) Improve UI layout, separate page for notifications +* 2018-11-28 - (v07.08.00) Check for changes in settings/modes before notifying open doors for delayed checks and have separate actions for each security keypad button (away/stay etc) +* 2018-11-25 - (v07.07.08) Displaying status of lock codes when all locks aren't selected for a user and synchronize keypad states with SHM/ADT states +* 2018-10-18 - (v07.07.07) Handle rare bug with ST Android app when updating app maxUserName is not initialized +* 2018-09-11 - (v07.07.06) Improve notification timing for delayed lock actions +* 2018-09-08 - (v07.07.05) Clear excess users settings after reducing number of max users +* 2018-09-03 - (v07.07.04) Set an initial default max users +* 2018-08-27 - (v07.07.03) Renamed to Lock User Management and added current date as expiration date for new user +* 2018-08-20 - (v07.07.02) Optimized checking for app updates and improved text layout for automatic relocks +* 2018-08-01 - (v07.07.00) Added support for Arming/disarming ADT and resume playing the audio after notifications +* 2018-07-31 - (v07.06.07) More stingent validation for date entry format +* 2018-07-25 - (v07.06.06) Clean up comments, make Sure Programming Engine hardier, detect is someone added a code accidentally from another app and delete it +* 2018-07-09 - (v07.06.04) Handle invalid date inputs more gracefully +* 2018-07-02 - (v07.06.03) More accurate display message when detecting different pin code lengths for locks +* 2018-06-30 - (v07.06.02) Actively notify user if lock programming fails due to communication issues +* 2018-06-29 - (v07.06.01) Fix error deleting excess users when reducing count of users and fix user state when selecting locks +* 2018-06-27 - (v07.06.00) Added indications and messages about pending and failed code updates +* 2018-06-26 - (v07.05.05) Updated user lock selection description text, fixed custom user notifications +* 2018-05-31 - (v07.05.04) Correct RFID case to capitals +* 2018-05-29 - (v07.05.03) Save lock id's instead of names when selecting per user locks to avoid issue of renamed locked/locks with similar names (requires users to reselect locks) and use sensor name instead of lock name for reporting open doors +* 2018-05-11 - (v07.05.02) Update for platform not saving sms settings for individual users +* 2018-05-01 - (v07.05.01) Patch for removing burner codes when multiple locks are used simultaneously +* 2018-04-13 - (v07.05.00) Stop tracking lock users after they are moved from the list of programmed locks, added support for Keypad Locks, fixed bug with arming SHM to Home on lock action +* 2018-04-12 - (v07.04.04) Log/notify if lock failed to program users, show activate/expire time on summary screen +* 2018-04-07 - (v07.04.03) Improvement to sure programming engine - handle lock programming failures for missed code notifications with retries, this indicates a underlying failure of the mesh +* 2018-03-26 - (v07.04.02) Check if ST mobile app saved an invalid start time, handle timezone differences between ST mobile app and hub better +* 2018-03-19 - (v07.04.00) Added support for custom per user notifications, clarified user types text +* 2018-03-09 - (v07.03.00) Maintain support for the legacy ZWave / JHampstead device handlers, improved tracking of changed codes, added support for limiting number of code use notifications +* 2018-03-07 - (v07.02.02) Clarify user type descriptions for easier understanding +* 2018-03-01 - (v07.02.01) Clean up excess users, handle duplicate codes and lost codes better, fix nameSlot notifications +* 2018-02-28 - (v07.02.00) Allow no time for schedled users indicating all day, better identification and compatibility with legacy handler reporting, fix ST UI bug not showing code length and remove fix issue with names being resent to the lock due to spaces +* 2018-02-27 - (v07.01.05) Process code rename from SmartLocks and update name in app +* 2018-02-26 - (v07.01.04) Better validation for legacy device handlers and show names for inactive slots +* 2018-02-23 - (v07.01.03) Automatic reinitailize after detecting code upgrades to avoid errors, increased limit to 120 for code retry interval for legacy users +* 2018-02-23 - (v07.01.02) Improved pin code length checks +* 2018-02-21 - (v07.01.01) Added support to arm SHM to Home when locking via keypad +* 2018-02-14 - (v07.01.00) Fix for ST local DTH bug in updating lock names +* 2018-02-13 - (v07.00.01) Fix for ST error on update +* 2018-02-13 - (v07.00.00) User can define number of retries, optimize user pages, don't rewrite all codes only those that have changed +* 2018-02-05 - (v06.02.00) Added support for locking/unlocking locks and opening/closing garage doors for lock/unlock actions. Added support to change modes for lock actions. Door sensor is now optional for relocking with timeout +* 2018-02-01 - (v06.01.03) Fix for ST breaking selection of Chime devices with mobile app 2.4.13 +* 2018-01-22 - (v06.01.02) When deleting a user make removing the name optional, bugfix for ST Android mobile app not refreshing page when clearing the code +* 2018-01-21 - (v06.01.01) Added support for older device handlers to avoid a failure if users forgot to update the device handler +* 2018-01-17 - (v06.01.00) Added support for maxCodes, codeLength, adding names to SmartLocks and other new DTH features +* 2018-01-09 - (v06.00.03) Check if start and end time are entered for scheduled users, Patch for ST not showing separate door enable button value correctly the first time you enable it +* 2017-12-15 - (v06.00.01) Fix for manual unlock notifications not being sent +* 2017-12-04 - (v06.00.00) Added support for stock handler using lock codes, added support for manual lock and unlock actions +* 2017-11-14 - (v06.00.00) Added support for new ST stock DTH, improved code deletion confirmations +* 2017-11-08 - (v05.10.00) Added support for delayed lock actions and added support to arm SHM on lock and added icons and simplified the UI +* 2017-10-18 - (v05.09.00) Added check for disabling hardware autolock to use smartapp autorelock and autounlock features, added support for modes and presence based users, fixed bug with notify modes, simplified UI +* 2017-09-06 - (v05.08.01) Added support for code expirations past midnight +* 2017-08-01 - (v05.08.00) Fixed expire on with no start date/time, don't verify code deletion if disableRetry is enabled, added ablility to turn on/off switch on external keypad lock +* 2017-07-05 - (v05.07.00) Patch for ST platform breaking fast programing causing an error +* 2017-05-31 - (v05.06.00) Code hardering to avoid platform race conditions for one time codes, improved deadbolt automatic unlocking for Schlage locks, added support for playing back on audio systems, added support for bluetooth +* 2017-05-29 - (v05.05.03) Bugfix for unlocking and locking without codes throwing an error +* 2017-05-26 - (v05.05.02) Due to ST phone changes, now separate multiple SMS numbers with a * +* 2017-04-19 - (v05.05.01) Patch for reporting Master Codes +* 2017-04-11 - (v05.05.00) Added ability to select Chimes for when doors are opened and closed, improved user interface/text, added user presence based notifications +* 2017-04-11 - (v05.04.01) Fixed grammar for messages +* 2017-01-25 - (v5.4.0) Added ability to run lock/unlock actions on when the specified users aren't present or not in specific modes, also less verbose messages unless detailed notifications are enabled while running lock and unlock actions +* 2017-01-12 - (v5.3.1) Added ability to report and run actions on unknown users (some locks don't report use code when unlocking via keypad) and master codes +* 2016-11-14 - Improved reliability to programming during initial install to avoid lock getting in programming loop, changed multiple SMS separator from + to ;, added 3 programming schedules, improved smartapp update check +* 2016-10-30 - Added ability to check for new code versions automatically once a week, added ability to check for invalid pin lengths +* 2016-10-23 - Fixed a bug with expire/schedule code start date/time not working and added option to kick start timer with the arrow touch button on the smartapp +* 2016-09-28 - Fix for lock selection not working when lock names are changed +* 2016-09-26 - Fix for broken ST phrases returning null data +* 2016-09-17 - Fixed issue with cannot change notify settings, highlight error messages, improved invalid date checks, code clean up, added name details when deleting users +* 2016-09-16 - Fix for actions page not showing up when there are no routines defined on the hub, Fix for not being able to enter 0 in the code and fix for ST breaking HREF in 2.2.0 +* 2016-09-15 - Added support to select locks for individual users, Added option for details programming notifications and improved reliability of programming +* 2016-09-13 - Bug fix when mode changes and no sensor is defined for door +* 2016-09-07 - Auto locks and open door notifications will engage if requried when mode changes +* 2016-09-02 - Added ability to specify modes of operations for auto door lock +* 2016-08-30 - Fixed bug with disable all push notifications, it should not disable text notifications and should only work when there is no contact address book +* 2016-08-17 - Added workaround for ST contact address book bug +* 2016-08-06 - Added support to auto relock door if the door hasn't been opened after specified timeout +* 2016-08-05 - Fix for autoRelock and openDoor notifications errors when using large timeout values, Fix for rare condition when expire on not process correctly +* 2016-07-25 - Added support for working with RFID cards +* 2016-07-22 - Added support for contact address book for customers who have this feature enabled +* 2016-07-19 - Updated code to use harmonized universal DH with type event instead of outsideLockEvent +* 2016-07-17 - Improvement to the unlocking and relocking logic, Workaround for platform calling installed and updated when installing the SmartApp, Bugfix for checking if codes are reused (type in code), Clear codes on installing the app the first time to avoid conflict with lock and reduce unnecessary delay in programming, Put in a check to not enable automatic unlock if autolock in the door is enabled +* 2016-07-14 - Improved notifications +* 2016-07-13 - Added support for tamper events and when using user codes to lock the door from the keypad +* 2016-07-10 - Added support for running routine when the door is locked using external keypad lock button +* 2016-07-05 - Added support for notifications if door is left open, added support for delayed relock for multiple door and various minor UI improvements +* 2016-05-15 - Added check for no existent timezone and notify user +* 2016-04-20 - Added client version on main page +* 2016-04-08 - Added support for notification modes for unlocking (and improved UI), bugfix for jammed and manual lock notifications not coming +* 2016-03-21 - Added a heartbeat system to improve the reliability of the code check scheduler to compensate for the issues folks are seeing with the ST platform broken timers +* 2016-02-27 - Renamed option to retry to clarify, now it is to stop reverrification and overwriting all codes (default is incremental updates) +* 2016-02-25 - Added support for tracking deleted codes confirmation from lock and retrying if lock doesn't confirm code deletions +* 2016-02-20 - Added slot notification for unknown users +* 2016-02-15 - Added current date/time stamp on hub for debugging timezone mismatches, Default coding interval is now 60 seconds as the platform is crapping out in shorter intervals, Revamped code initialization to work around crappy ST timers and make programming more reliable. Using dual programming strategy now to make it more redundant +* 2016-01-23 - Added user specific unlock actions (override actions) +* 2016-01-19 - Fixed a debug error message about empty slots sharing a null code, Added comments +* 2016-01-17 - You can specify multiple sms phone numbers by putting a + to separate them +* 2016-01-15 - Validation check, same code cannot be used for more than one user +* 2016-01-13 - Debug message +* 2016-01-10 - Added option to disable retrying code programming on failure +* 2016-01-05 - Revamped code to verify with lock that codes have been successfully added and keep retrying until codes have been added +* 2015-12-18 - Added subcription to random events to kick start timers to work around buggy platform, use runEveryXMinutes where possible to hopefully improve the situation +* 2015-12-08 - Comment clarifications +* 2015-12-02 - Clean up +* 2015-11-28 - Added a delay to locking multiple doors to avoid mesh collisions, Use atomicState for automatic relocking to avoid race conditions, Select Door sensor independent of relock +* 2015-11-27 - Added support to relock doors after they have been closed with an optional delay timer and to retract deadbolts if door is open +* 2015-11-25 - Refresh the page after selecting the locks to that the individual options show up +* 2015-11-24 - Added support to show expired codes on configuration page +* 2015-11-22 - Added support for differentiate between manual lock and electronic lock (keypad/remote) while notifying user, Clarified input text for turning on lights after dark +* 2015-11-21 - Added support for individual door unlock actions, Added support to turn on lights 30 minutes before sunset (dark), Added support to send notitications when too many invalid codes are reported by the lock, Added option 'Inactive' to the list of code types so one can keep the name/code on the slot but not have it active +* 2015-11-08 - Added option to switch on lights after dark (sunset to next sunrise) +* 2015-10-28 - Optimized disarming SHM as the first action on unlocking to help avoid false alarms +* 2015-10-26 - Added error checking for empty codes +* 2015-10-26 - Fixed a bug with one time codes, Added support for start date/time for expiration codes, Improved reliability when controlling multiple locks with a single app instance +* 2015-09-28 - Added support for lock notifications +* 2015-09-26 - Added support to turn on/off switches when a user unlocks the door with a code, Fixed a bug with mode changes when unlocking +* 2015-09-18 - Added option to disarm Smart Home Monitor mode when unlocking, added option to select modes when notifying on manual unlock +* 2015-09-02 - Fixed issue, don't run home phrase or change mode on manual unlock +* 2015-09-01 - Improve text clarity +* 2015-08-26 - Added support for running a phrase and changing modes when doors are unlocked successfully +* 2015-07-25 - Potential fix for preventing the timers from dying +* 2015-07-23 - Fix for monitoring not working after a mode change +* 2015-07-22 - Added support for advanced scheduling options for individual users codes including permanent, one time, expiration and scheduled +* 2015-07-22 - Use the standard "unknown" lock state to check for jammed +* 2015-07-06 - Fixed issue with code expiry not working +* 2015-06-18 - Added option to change delay between sending codes for users who have issues with communications, Added notification for jammed door locks +* 2015-06-17 - Fix for dynamic preferences not working after ST platform update +* 2015-06-09 - Added support to disable push notifications only +* 2015-05-27 - Added support for expiration dates +* 2015-02-20 - Fixed issue with SMS not being sent +* 2015-01-09 - Improve reliability with coding +* 2015-01-01 - Created +* +*/ + +definition( + name: "Lock User Management", + namespace: "rboy", + author: "RBoy Apps", + description: "Manage Lock User Codes with Scheduling, Actions and Notifications", + category: "Safety & Security", + iconUrl: "https://www.rboyapps.com/images/LUM.png", + iconX2Url: "https://www.rboyapps.com/images/LUM.png" +) + +preferences { + page(name: "loginPage") + page(name: "loginPage2") + page(name: "setupApp") + page(name: "usersPage") + page(name: "notificationsPage") + page(name: "unlockLockActionsPage") + page(name: "unlockKeypadActionsPage") + page(name: "unlockManualActionsPage") + page(name: "armKeypadActionsPage") + page(name: "lockKeypadActionsPage") + page(name: "lockManualActionsPage") + page(name: "openCloseDoorPage") + page(name: "openCloseDoorPageSummary") + page(name: "scheduleCodesPage") + page(name: "userConfigPage") +} + +private getPlatformUsersLimit() { 300 } // Any more and the platform times out while trying to load the UI (max 300) +private getUsersLimit() { maxCodes ? Math.min(platformUsersLimit, maxCodes) : platformUsersLimit } // Don't exceed platform limits +private getDefaultUsers() { 10 } // Default number of users +private getDefaultSendDelay() { 15 } // Delay between code programming +private getDefaultRetries() { 5 } // Number of retries for failed / no response programming +private getMaxRetries() { retries == null ? defaultRetries : retries } +private getSchedulesSuffix() { ('A'..'C') } +private getCodeOptions() { + [ + "Permanent": "Permanent", + "One time": "One time (burner)", + "Expire on": "Start/end date and time", + "Scheduled": "Weekly/daily schedule(s)", + "Presence": "Activate on user presence", + "Modes": "Activate on mode(s)", + "Inactive": "Temporarily disabled" + ] +} +private getSchedulingOptions() { + [ + 'All Week', + 'Monday to Friday', + 'Saturday & Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday' + ] +} + +def loginPage() { + log.trace "Login page" + if (!state.loginSuccess && username) { + loginCheck() + } + if (state.loginSuccess) { + setupApp() + } else { + state.sendUpdate = true + loginSection("loginPage", "loginPage2") + } +} + +def loginPage2() { + log.trace "Login page2" + if (!state.loginSuccess && username) { + loginCheck() + } + if (state.loginSuccess) { + setupApp() + } else { + state.sendUpdate = true + loginSection("loginPage2", "loginPage") + } +} + +private loginSection(name, nextPage) { + dynamicPage(name: name, title: "Lock User Management v${clientVersion()}", install: state.loginSuccess, uninstall: true, nextPage: state.loginSuccess ? "" : nextPage) { + section() { + if (state.loginError) { + log.warn "Authenticating failed: ${state.loginError}" + paragraph title: "Login failed", image: "https://www.rboyapps.com/images/RBoyApps.png", required: true, "${state.loginError}" + } else { + log.debug "Check authentication credentials, Login: $username" + paragraph title: "Login", image: "https://www.rboyapps.com/images/RBoyApps.png", required: false, "Enter your RBoy Apps username\nYou can retrieve your username from www.rboyapps.com lost password page" + } + + input name: "username", type: "text", title: "Username", capitalization: "none", submitOnChange: false, required: false + } + } +} + +def setupApp() { + log.trace "$settings" + + dynamicPage(name: "setupApp", title: "Lock User Management v${clientVersion()}", install: true, uninstall: true) { + if (state.clientVersion) { // If the app has already been installed + section("Select Lock(s)") { + // Check if the lock pin code length match on all the locks + def pinDetails = getLockPinLengthDetails() + def pinLen = pinDetails.pinLen // Fixed pin code length + def maxPinLen = pinDetails.maxPinLen // Variable minimum pin code length + def minPinLen = pinDetails.minPinLen // Variable maximum pin code length + def pinLenError = pinDetails.pinLenError + log.trace "Configured lock fixed code length: $pinLen, max code length: $maxPinLen, min code length: $minPinLen" + if (pinLenError) { + def msg = "YOUR LOCKS ARE CONFIGURED TO ACCEPT DIFFERENT CODE DIGIT LENGTHS, PROGRAMMING MAY FAIL!" + paragraph title: msg, required: true, "" + } + input "locks", "capability.lock", title: "Lock(s)", required: false, multiple: true, submitOnChange: true, image: "https://www.rboyapps.com/images/HandleLock.png" + } + + section("User Management") { + log.trace "state.previousMaxUserNames: ${state.previousMaxUserNames?.inspect()}, maxUserNames: ${maxUserNames?.inspect()}" + // Bug in ST Classic app, if users presses back on new installation without pressing Save, it saves the default values as a String + if ((maxUserNames != null) && ((state.previousMaxUserNames as Integer) > (maxUserNames as Integer))) { // If the number of max users has reduced, then clear excess the slots + log.debug "Detected a reduction in number of maxUserNames, clearing user slots ${(maxUserNames as Integer) + 1} to ${(state.previousMaxUserNames as Integer)}" + startTimer(1, removeUsersOffline, [ data : [start: ((maxUserNames as Integer) + 1), end: (state.previousMaxUserNames as Integer)] ]) // Clear the slots + } // Clear excess users offline so it doesn't slow down the UI (do it while reducing users so that when you increase the slots are already cleared) + + state.previousMaxUserNames = maxUserNames as Integer // Reset it (bug on a fresh install ST sometimes stores it as a String) + + log.trace "Max common codes supported by locks ${maxCodes}" + input name: "maxUserNames", title: "Number of users${maxCodes ? " (0 to ${usersLimit})" : ""}", type: "number", defaultValue: defaultUsers, required: true, multiple: false, image: "https://www.rboyapps.com/images/Users.png", range: "0..${maxCodes ? usersLimit : platformUsersLimit}", submitOnChange: true + href(name: "users", title: "Manage users", page: "usersPage", description: "Create users and custom actions", required: false, image: "https://www.rboyapps.com/images/UserPage.png") + } + + section("General Settings") { + // Unlock actions for all users (global) + def hrefParams = [ + user: null, + passed: true + ] + href(name: "unlockLockActions", params: hrefParams, title: "Lock/unlock actions", page: "unlockLockActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/LockUnlock.png") + href(name: "openCloseDoorSummary", title: "Door open/close actions", page: "openCloseDoorPageSummary", description: "", required: false, image: "https://www.rboyapps.com/images/DoorOpenClose.png") + href(name: "notifications", params: hrefParams, title: "Notifications", page: "notificationsPage", description: "", required: false, image: "https://www.rboyapps.com/images/NotificationsD.png") + } + + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "updateNotifications", title: "Check for new versions of the app", type: "bool", defaultValue: true, required: false + } + + section("Advanced Programming Options (optional)", hideable: true, hidden: true) { + paragraph "Sure-Programming: To improve programming reliability, the app will keep trying to program the user codes until the lock confirms the programming or up to the maximum number of retries" + input name: "retries", title: "Maximum code programming retries", type: "number", defaultValue: "${defaultRetries}", range: "0..10", required: false + paragraph "Change this setting if all the user codes aren't being programmed on the lock correctly. This settings determines the time gap between sending each user code to the lock. If the codes are sent too fast, they may fail to be set properly" + input name: "sendDelay", title: "Delay between codes (seconds):", type: "number", defaultValue: "${defaultSendDelay}", range: "5..120", required: false + paragraph "Enable this to get additional detailed notifications like code programming, lock responses etc. NOTE: this can generate a lot of messages" + input name: "detailedNotifications", title: "Get detailed notifications", type: "bool", defaultValue: false, required: false + paragraph "" + paragraph title: "[WARNING] CLEAR USER CODES", "ENABLING THIS OPTION WILL CLEAR THE FIRST ${(maxUserNames as Integer) ?: 0} USER CODES FROM EACH OF THE LOCKS SELECTED ABOVE. AFTER ENABLING THIS OPTION, CLICK 'DONE' AND WAIT FOR THE CLEARING TO TAKE EFFECT", required: true + input name: "clearUserCodes", title: "CLEAR EXISTING USER CODES", type: "bool", defaultValue: false, required: false + } + } else { + section() { + paragraph "Click 'Done' to install the app. Then you can open it from the 'SmartApps' tab to finish configuring it.\r\n\r\nEnsure that there is a buffering device between your lock and hub. See FAQ page for more details." + label title: "Assign a name for this SmartApp (optional)", required: false + } + } + + section("Confidential", hideable: true, hidden: true) { + paragraph("RBoy Apps Username: " + (username?.toLowerCase() ?: "Unlicensed") + (state.loginSuccess ? "" : ", contact suppport")) + } + + remove("Uninstall") + } +} + +def notificationsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user $user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Notifications Page, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"notificationsPage", title: (user ? "Setup custom notifications for ${name ?: "user ${user}"}" : "Setup notification options"), uninstall: false, install: false) { + section { + input "audioDevices${user}", "capability.audioNotification", title: "Speak notifications on", required: false, multiple: true, submitOnChange: true, image: "https://www.rboyapps.com/images/Horn.png" + if (settings."audioDevices${user}") { + input "audioVolume${user}", "number", title: "...at this volume level (optional)", description: "keep current", required: false, range: "1..100" + } + input("recipients${user}", "contact", title: "Send notifications to", multiple: true, required: false, image: "https://www.rboyapps.com/images/Notifications.png") { + paragraph "You can enter multiple phone numbers by separating them with a '*'\nE.g. 5551234567*+18747654321" + input "sms${user}", "phone", title: "Send SMS notification to", required: false, image: "https://www.rboyapps.com/images/Notifications.png" + input "disableAllNotify${user}", "bool", title: "Disable all push notifications${user ? " for " + (name ?: "user ${user}") : ""}", defaultValue: false, required: false + } + } + } +} + +def openCloseDoorPageSummary() { + if (locks?.size() > 1) { + dynamicPage(name:"openCloseDoorPageSummary", title: "Select door open/close sensor and configure the automatic unlock, relock and notifications for each door", uninstall: false, install: false) { + section { + for (lock in locks) { + def hrefParams = [ + lockId: lock.id, + passed: true + ] + href(name: "openCloseDoor${lock}", params: hrefParams, title: "${lock}", page: "openCloseDoorPage", description: doorOpenCloseStatus(lock), required: false, image: "https://www.rboyapps.com/images/DoorOpenClose.png") + } + } + } + } else if(locks?.size() == 1) { + def hrefParams = [ + lockId: locks.first().id, + passed: true + ] + openCloseDoorPage(hrefParams) + } else { + dynamicPage(name:"openCloseDoorPageSummary", title: "Select door open/close sensor and configure the automatic unlock, relock and notifications for each door", uninstall: false, install: false) { + section("No locks/doors to configure") { + paragraph title: "First select locks on the previous page", required: true, "" + } + } + } +} + +private doorOpenCloseStatus(lock) { + ( + ((lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) ? false : (settings."relockDoor${lock}" ? (settings."relockImmediate${lock}" ?: settings."relockAfter${lock}") : false)) || + (settings."openNotifyBeep${lock}" && settings."sensor${lock}") || + (settings."openNotify${lock}" && settings."sensor${lock}" && settings."openNotifyTimeout${lock}") + ) ? "Configured" : "" +} + +def openCloseDoorPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + log.trace "Passed from main page, using params lookup ${params}" + } else if (atomicState.params) { + params = atomicState.params + log.trace "Passed from submitOnChange, atomicState lookup ${atomicState.params}" + } else { + log.error "Invalid params, no details found. Params: $params, saved params: $atomicState.params" + } + + def lock = params?.lockId ? locks.find { it.id == params?.lockId } : locks.first() + + log.trace "Door Open Close Page, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"openCloseDoorPage", title: "Door open/close actions for ${lock}", uninstall: false, install: false) { + section { + def priorRelockDoor = settings."relockDoor${lock}" + def priorRelockImmediate = settings."relockImmediate${lock}" + def priorRelockAfter = settings."relockAfter${lock}" + def priorRetractDeadbolt = false //settings."retractDeadbolt${lock}" + def priorNotifyOpen = settings."openNotify${lock}" + def priorNotifyOpenTimeout = settings."openNotifyTimeout${lock}" + def priorOpenNotifyModes = settings."openNotifyModes${lock}" + def priorRelockDoorModes = settings."relockDoorModes${lock}" + def priorNotifyBeep = settings."openNotifyBeep${lock}" + def priorSensor = settings."sensor${lock}" + def reqDoorSensor = priorRelockImmediate || priorRetractDeadbolt || priorNotifyOpen || priorNotifyBeep + + paragraph "Select door open/close sensor and configure the automatic unlock, relock and notifications" + if (priorRelockDoor || priorRetractDeadbolt || priorNotifyOpen || priorNotifyBeep) { + input "sensor${lock}", "capability.contactSensor", title: "Door open/close sensor${reqDoorSensor ? "" : " (optional)"}", required: ( reqDoorSensor ? true : false), submitOnChange: true // required for deadbolt, immediate relock or notifications + } + + // Sanity check do not offer AutoLock is hardware autoLock is engaged + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { + paragraph title: "Disable AutoLock on physical lock to use SmartApp AutoReLock features", required: true, "" + } else { + input "relockDoor${lock}", "bool", title: "Relock door automatically", defaultValue: priorRelockDoor, required: false, submitOnChange: true + if (priorRelockDoor) { + input "relockImmediate${lock}", "bool", title: "Relock immediately after closing", defaultValue: priorRelockImmediate, required: false, submitOnChange: true + if (!priorRelockImmediate) { + input "relockAfter${lock}", "number", title: "Relock after ${priorSensor ? "closing" : "unlocking"} (minutes)", defaultValue: priorRelockAfter, required: true + } + input "relockDoorModes${lock}", "mode", title: "...only when in this mode(s) (optional)", defaultValue: priorRelockDoorModes, required: false, multiple: true + } + if (priorRetractDeadbolt) { + paragraph "NOTE: Make sure the AutoLock feature on the lock is disabled to avoid an infinite locking/unlocking loop.", required: false, submitOnChange: true + } + //input "retractDeadbolt${lock}", "bool", title: "Unlock door if locked while open", defaultValue: priorRetractDeadbolt, description: "This retracts the deadbolt if it extends while the door is still open", required: false, submitOnChange: true + } + + input "openNotifyBeep${lock}", "capability.tone", title: "Ring chime when door is opened", multiple: true, required: false, submitOnChange: true + input "openNotify${lock}", "bool", title: "Notify if door has been left open", defaultValue: priorNotifyOpen, required: false, submitOnChange: true + if (priorNotifyOpen) { + input "openNotifyTimeout${lock}", "number", title: "...for (minutes)", defaultValue: priorNotifyOpenTimeout, required: true, range: "1..*" + input "openNotifyRepeat${lock}", "bool", title: "...recheck and notify", defaultValue: true, required: false + } + if (priorNotifyOpen || priorNotifyBeep) { + input "openNotifyModes${lock}", "mode", title: "...only when in this mode(s) (optional)", defaultValue: priorOpenNotifyModes, required: false, multiple: true + } + } + } +} + +def unlockLockActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user $user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Lock/Unlock Action Page, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"unlockLockActionsPage", title: (user ? "Setup custom actions/notifications for ${name ?: "user ${user}"}" : "Setup lock/unlock actions for each door"), uninstall: false, install: false) { + /*def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines*/ + def showActions = true + section { + if (user) { // User specific override options + paragraph "Enabling custom user actions and notifications will override over the general actions defined on the first page" + input "userOverrideUnlockActions${user}", "bool", title: "Define custom actions for ${name ?: "user ${user}"}", required: true, submitOnChange: true + if (!settings."userOverrideUnlockActions${user}") { // Check if user has enabled specific override actions then show menu + showActions = false + } + } + if (showActions && locks?.size() > 1) { + input "individualDoorActions${user}", "bool", title: "Separate actions for each door", required: true, submitOnChange: true + } + } + if (showActions) { // Do we need to show actions? + if (settings."individualDoorActions${user}") { + for (lock in locks) { + section ("$lock", hideable: false) { + def hrefParams = [ + user: user, + lock: lock as String, + passed: true + ] + href(name: "unlockKeypadActions${lock}", params: hrefParams, title: "Keypad Unlock Actions", page: "unlockKeypadActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/KeypadUnlocked.png") + href(name: "lockKeypadActions${lock}", params: hrefParams, title: "Keypad Lock Actions", page: "lockKeypadActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/KeypadLocked.png") + if (!user) { + href(name: "unlockManualActions${lock}", params: hrefParams, title: "Manual Unlock Actions", page: "unlockManualActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/ManualUnlocked.png") + href(name: "lockManualActions${lock}", params: hrefParams, title: "Manual Lock Actions", page: "lockManualActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/ManualLocked.png") + } + } + } + } else { + section("", hideable: false) { + def hrefParams = [ + user: user, + lock: "", + passed: true + ] + href(name: "unlockKeypadActions", params: hrefParams, title: "Keypad Unlock Actions", page: "unlockKeypadActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/KeypadUnlocked.png") + href(name: "lockKeypadActions", params: hrefParams, title: "Keypad Lock Actions", page: "lockKeypadActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/KeypadLocked.png") + if (!user) { + href(name: "unlockManualActions", params: hrefParams, title: "Manual Unlock Actions", page: "unlockManualActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/ManualUnlocked.png") + href(name: "lockManualActions", params: hrefParams, title: "Manual Lock Actions", page: "lockManualActionsPage", description: "", required: false, image: "https://www.rboyapps.com/images/ManualLocked.png") + } + } + } + } + section { + if (user && settings."userNotify${user}") { // User specific override notification options + def showCustomNotifications = true + input "userOverrideNotifications${user}", "bool", title: "Define custom notifications for ${name ?: "user ${user}"}", required: true, submitOnChange: true + if (!settings."userOverrideNotifications${user}") { // Check if user has enabled specific override actions then show menu + showCustomNotifications = false + } + if (showCustomNotifications) { + def hrefParams = [ + user: user, + passed: true + ] + href(name: "notifications", params: hrefParams, title: "Notifications", page: "notificationsPage", description: "", required: false, image: "https://www.rboyapps.com/images/NotificationsD.png") + } + } + } + } +} + +def unlockKeypadActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for user $user, lock $lock" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user, lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Keypad Unlock Action Page, user:$user, name:$name, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"unlockKeypadActionsPage", title: "Setup keypad unlock actions for doors" + (user ? " for user $name." : ""), uninstall: false, install: false) { + /*def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines*/ + + section ("Door Keypad Unlock Actions${lock ? " for $lock" : ""}") { + //def priorHomePhrase = settings."homePhrase${lock}${user}" + def priorHomeMode = settings."homeMode${lock}${user}" + def isLockKeypad = locks?.find{ it.displayName == lock }?.hasAttribute("armMode") // Check if the current lock (lock specific option) is a keypad + def isAnyLockKeypad = locks?.any { keypad -> keypad.hasAttribute("armMode") } // Check if any lock (for global options) is a keypad + def areAllLockKeypad = locks?.every { keypad -> keypad.hasAttribute("armMode") } // Check every lock (for global options) is a keypad + + paragraph "Run these actions when a user successfully unlocks the door using a code" + if (lock ? isLockKeypad : isAnyLockKeypad) { // Show only if we have a supported keypad (for selected lock or for general settings) + input "keypadArmDisarm${lock}${user}", "bool", title: "Control ADT using keypad", required: false, submitOnChange: true, defaultValue: false + } + if (lock ? (isLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true) : (areAllLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true)) { // Hide only if we have have a supported keypad for selected lock and using keypad to control SHM + //input "homeDisarm${lock}${user}", "bool", title: "Disarm Classic SHM", required: false + input "adtDisarm${lock}${user}", "bool", title: "Disarm ADT", required: false, submitOnChange: true + } + if (((lock ? (isLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true) : (areAllLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true)) && settings."adtDisarm${lock}${user}") || + ((lock ? isLockKeypad : isAnyLockKeypad) && (settings."keypadArmDisarm${lock}${user}"))) { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel${true || settings."adtDisarm${lock}${user}" ? "" : " (optional)"}", multiple: false, required: (true || settings."adtDisarm${lock}${user}" ? true : false) // Required if we select ADT + } + //input "homePhrase${lock}${user}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorHomePhrase + input "homeMode${lock}${user}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "turnOnSwitchesAfterSunset${lock}${user}", "capability.switch", title: "Turn on light(s) after dark", required: false, multiple: true + input "turnOnSwitches${lock}${user}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "turnOffSwitches${lock}${user}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "toggleSwitches${lock}${user}", "capability.switch", title: "Toggle switch(s)", required: false, multiple: true + input "unlockLocks${lock}${user}","capability.lock", title: "Unlock lock(s)", required: false, multiple: true + input "openGarage${lock}${user}","capability.doorControl", title: "Open garage door(s)", required: false, multiple: true + + paragraph title: "Do NOT run the above unlock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleUnlockActions${lock}${user}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeUnlockActions${lock}${user}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + } + } +} + +def unlockManualActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for lock $lock" + } else if (atomicState.params) { + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Manual Unlock Action Page, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"unlockManualActionsPage", title: "Setup manual unlock actions for doors", uninstall: false, install: false) { + /*def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines*/ + + section ("Door Manual Unlock Actions${lock ? " for $lock" : ""}") { + //def priorHomePhrase = settings."homePhraseManual${lock}" + def priorHomeMode = settings."homeModeManual${lock}" + def priorManualNotify = settings."manualNotify${lock}" + + paragraph "Run these actions when a user unlocks the door manually" + //input "homeDisarmManual${lock}", "bool", title: "Disarm Classic SHM", required: false + input "adtDisarmManual${lock}", "bool", title: "Disarm ADT", required: false, submitOnChange: true + if (settings."adtDisarmManual${lock}") { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel", multiple: false, required: true // Required if we select ADT + } + //input "homePhraseManual${lock}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorHomePhrase + input "homeModeManual${lock}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "turnOnSwitchesAfterSunsetManual${lock}", "capability.switch", title: "Turn on light(s) after dark", required: false, multiple: true + input "turnOnSwitchesManual${lock}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "turnOffSwitchesManual${lock}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "unlockLocksManual${lock}","capability.lock", title: "Unlock lock(s)", required: false, multiple: true + input "openGarageManual${lock}","capability.doorControl", title: "Open garage door(s)", required: false, multiple: true + + paragraph title: "Do NOT run the above unlock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleUnlockActionsManual${lock}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeUnlockActionsManual${lock}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + + paragraph "Unlock Notification Options" + input "manualNotify${lock}", "bool", title: "Notify on manual unlock", required: false, submitOnChange: true + if (priorManualNotify) { + input "manualNotifyModes${lock}", "mode", title: "...only when in this mode(s) (optional)", required: false, multiple: true + } + } + } +} + +def armKeypadActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + log.trace "Passed from main page, using params lookup ${params}" + } else if (atomicState.params) { + params = atomicState.params + log.trace "Passed from submitOnChange, atomicState lookup ${atomicState.params}" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def user = params?.user ?: "" + def lock = params?.lock ?: "" + def arm = params?.arm ?: "" + + def name = user ? settings."userNames${user}" : "" + + log.trace "Arm Keypad Action Page, user:$user, name:$name, lock $lock, arm $arm, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"armKeypadActionsPage", title: "Setup Arm ${arm?.capitalize()} button actions for ${lock ?: "keypad"}" + (user ? " for user $name." : ""), uninstall: false, install: false) { + /*def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines*/ + + section { + input "keypadArmActions${lock}${user}${arm}", "bool", title: "Enable custom actions", required: false, submitOnChange: true + if (settings."keypadArmActions${lock}${user}${arm}") { + //def priorLockPhrase = settings."externalLockPhrase${lock}${user}${arm}" + def priorHomeMode = settings."externalLockMode${lock}${user}${arm}" + + //input "externalLockPhrase${lock}${user}${arm}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorLockPhrase + input "externalLockMode${lock}${user}${arm}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "externalLockTurnOnSwitches${lock}${user}${arm}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "externalLockTurnOffSwitches${lock}${user}${arm}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "externalLockToggleSwitches${lock}${user}${arm}", "capability.switch", title: "Toggle switch(s)", required: false, multiple: true + input "lockLocks${lock}${user}${arm}","capability.lock", title: "Lock lock(s)", required: false, multiple: true + input "closeGarage${lock}${user}${arm}","capability.doorControl", title: "Close garage door(s)", required: false, multiple: true + } + } + } +} + +def lockKeypadActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for user $user, lock $lock" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user, lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Keypad Lock Action Page, user:$user, name:$name, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"lockKeypadActionsPage", title: "Setup keypad lock actions for doors" + (user ? " for user $name." : ""), uninstall: false, install: false) { + /*def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines*/ + + section ("Door Keypad Lock Actions${lock ? " for $lock" : ""}") { + //def priorLockPhrase = settings."externalLockPhrase${lock}${user}" + def priorHomeMode = settings."externalLockMode${lock}${user}" + def isLockKeypad = locks?.find{ it.displayName == lock }?.hasAttribute("armMode") // Check if the current lock (lock specific option) is a keypad + def isAnyLockKeypad = locks?.any { keypad -> keypad.hasAttribute("armMode") } // Check if any lock (for global options) is a keypad + def areAllLockKeypad = locks?.every { keypad -> keypad.hasAttribute("armMode") } // Check every lock (for global options) is a keypad + + paragraph "Some locks can be locked from the keypad outside${user ? " with user codes" : ""}. If your lock has his feature then you can assign actions to execute when it is locked ${user ? "with a user code" : "from the keypad"}" + if (lock ? isLockKeypad : isAnyLockKeypad) { // Show only if we have a supported keypad (for selected lock or for general settings) + input "keypadArmDisarm${lock}${user}", "bool", title: "Control ADT using keypad", required: false, submitOnChange: true, defaultValue: false + } + if (lock ? (isLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true) : (areAllLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true)) { // Hide only if we have have a supported keypad for selected lock and using keypad to control SHM + //input "homeArm${lock}${user}", "bool", title: "Arm Classic SHM to Away", required: false, submitOnChange: true + input "adtArm${lock}${user}", "bool", title: "Arm ADT to Away", required: false, submitOnChange: true + if (settings."adtArm${lock}${user}") { // || settings."homeArm${lock}${user}" + input "homeArmStay${lock}${user}", "bool", title: "...arm to Stay instead of Away", required: false + } + } + if (((lock ? (isLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true) : (areAllLockKeypad ? !(settings."keypadArmDisarm${lock}${user}") : true)) && settings."adtArm${lock}${user}") || + ((lock ? isLockKeypad : isAnyLockKeypad) && (settings."keypadArmDisarm${lock}${user}"))) { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel${true || settings."adtArm${lock}${user}" ? "" : " (optional)"}", multiple: false, required: (true || settings."adtArm${lock}${user}" ? true : false) // Required if we select ADT + } + if (lock ? isLockKeypad : isAnyLockKeypad) { // Show only if we have a supported keypad (for selected lock or for general settings) + def hrefParams = [ + user: user, + lock: lock as String, + passed: true + ] + href(name: "armAwayKeypadActions${lock}", params: hrefParams + [arm: "away"], title: "Away/On button actions", page: "armKeypadActionsPage", description: "", required: false, image: "") + href(name: "armStayKeypadActions${lock}", params: hrefParams + [arm: "stay"], title: "Stay/Partial button actions", page: "armKeypadActionsPage", description: "", required: false, image: "") + href(name: "armNightKeypadActions${lock}", params: hrefParams + [arm: "night"], title: "Night button actions", page: "armKeypadActionsPage", description: "", required: false, image: "") + } + //input "externalLockPhrase${lock}${user}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorLockPhrase + input "externalLockMode${lock}${user}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "externalLockTurnOnSwitches${lock}${user}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "externalLockTurnOffSwitches${lock}${user}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "externalLockToggleSwitches${lock}${user}", "capability.switch", title: "Toggle switch(s)", required: false, multiple: true + input "lockLocks${lock}${user}","capability.lock", title: "Lock lock(s)", required: false, multiple: true + input "closeGarage${lock}${user}","capability.doorControl", title: "Close garage door(s)", required: false, multiple: true + + input "delayLockActionsTime${lock}${user}", "number", title: "Delay running actions (minutes)", required: false, range: "0..*" + + paragraph title: "Do NOT run the above lock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleLockActions${lock}${user}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeLockActions${lock}${user}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + + if (!user) { // Users will use the user notify option + paragraph "Lock Notification Options" + input "externalLockNotify${lock}", "bool", title: "Notify on keypad lock", required: false, submitOnChange: true + if (settings."externalLockNotify${lock}") { + input "externalLockNotifyModes${lock}", "mode", title: "Only when in this mode(s) (optional)", required: false, multiple: true + } + input "jamNotify${lock}", "bool", title: "Notify on Lock Jam/Stuck", required: false + } + } + } +} + +def lockManualActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for lock $lock" + } else if (atomicState.params) { + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Manual Lock Action Page, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"lockManualActionsPage", title: "Setup manual lock actions for doors", uninstall: false, install: false) { + /*def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines*/ + + section ("Door Manual Lock Actions${lock ? " for $lock" : ""}") { + //def priorLockPhrase = settings."externalLockPhraseManual${lock}" + def priorHomeMode = settings."externalLockModeManual${lock}" + + //input "homeArmManual${lock}", "bool", title: "Arm Classic SHM to Stay", required: false + input "adtArmManual${lock}", "bool", title: "Arm ADT to Stay", required: false, submitOnChange: true + if (settings."adtArmManual${lock}") { // If we have a seleted an ADT option + input "homeArmAwayManual${lock}", "bool", title: "...arm to Away instead of Stay", required: false + input "adtDevices", "capability.battery", title: "Select ADT panel", multiple: false, required: true // Required if we select ADT + } + //input "externalLockPhraseManual${lock}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorLockPhrase + input "externalLockModeManual${lock}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "externalLockTurnOnSwitchesManual${lock}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "externalLockTurnOffSwitchesManual${lock}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "lockLocksManual${lock}","capability.lock", title: "Lock lock(s)", required: false, multiple: true + input "closeGarageManual${lock}","capability.doorControl", title: "Close garage door(s)", required: false, multiple: true + + input "delayLockActionsTimeManual${lock}", "number", title: "Delay running actions (minutes)", required: false, range: "0..*" + + paragraph title: "Do NOT run the above lock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleLockActionsManual${lock}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeLockActionsManual${lock}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + + paragraph "Lock Notification Options" + input "lockNotify${lock}", "bool", title: "Notify on manual/auto lock", required: false, submitOnChange: true + if (settings."lockNotify${lock}") { + input "lockNotifyModes${lock}", "mode", title: "...only when in this mode(s) (optional)", required: false, multiple: true + } + } + } +} + +def usersPage() { + dynamicPage(name:"usersPage", title: "User Names, Codes and Notification Setup", uninstall: false, install: false) { + + if (!maxUserNames) { + section("No users to configure") { + paragraph title: "First configure the number of users on the previous page", required: true, "" + } + } + + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + def msg = "Hub geolocation not set, using ${timeZone.getDisplayName()} timezone. Use the SmartThings app to set the Hub geolocation to identify the correct timezone." + log.error msg + sendPush msg + section("INVALID HUB LOCATION") { + paragraph title: msg, required: true, "" + } + } + + section() { + def allUserCodes = (1..(maxUserNames ?: 0)).collectEntries { [(it):settings."userCodes${it}"] } // Get all user codes to save db access time + for (int i = 1; i <= maxUserNames; i++) { + def priorName = settings."userNames${i}" + def priorCode = settings."userCodes${i}" + def priorExpireDate = settings."userExpireDate${i}" + def priorExpireTime = settings."userExpireTime${i}" + def priorStartDate = settings."userStartDate${i}" + def priorStartTime = settings."userStartTime${i}" + def priorUserType = settings."userType${i}" + def priorUserPresent = settings."userPresent${i}" // Get user presence + def priorUserNotPresent = settings."userNotPresent${i}" // Get user not presence + def priorUserModes = settings."userModes${i}" // Get user modes + def userLocks = (locks?.size() > 1) ? (settings."userLocks${i}" ?: locks*.id) : locks*.id // If not defined or only one lock then check all locks + def invalidStartDate = false + def invalidExpiryDate = false + def userSummary = "" + def userSlotActive = true + def userSlotProgrammed = false + def pendingUpdate = false + def failedUpdate = false + //log.trace "Initial $i Name: $priorName, Code: $priorCode, ExpireDate: $priorExpireDate, ExpireTime: $priorExpireTime, StartDate: $priorStartDate, StartTime: $priorStartTime, UserType: $priorUserType" + + // Check for errors and display messages + if (priorCode) { // Do all the checks only if user has been configured + // Sanity check, codes cannot be reused in the same lock (codes have to be unique to each slot) + getDuplicateCodeUsers(allUserCodes, i).each { j -> + def msg = "CHANGE CODE - THIS CODE HAS BEEN USED FOR USER $j" + log.warn "CHANGE CODE FOR USER $i - THIS CODE HAS BEEN USED FOR USER $j" + userSummary += (userSummary ? "\n" : "") + msg + } + + // Check if the user has entered a non digit string + if ((priorCode?.size() > 0) && !priorCode?.isNumber()) { + def msg = "WARNING: CODE IS NOT A NUMBER, PROGRAMMING WILL FAIL!" + log.warn msg + userSummary += (userSummary ? "\n" : "") + msg + } + + // Check if the lock pin code length match the pin code length entered by the user + def pinDetails = getLockPinLengthDetails((locks ?: []).findAll { userLocks.contains(it?.id) }) + def pinLen = pinDetails.pinLen // Fixed pin code length + def maxPinLen = pinDetails.maxPinLen // Variable minimum pin code length + def minPinLen = pinDetails.minPinLen // Variable maximum pin code length + def pinLenError = pinDetails.pinLenError + //log.trace "Configured lock fixed code length: $pinLen, max code length: $maxPinLen, min code length: $minPinLen" + + for (lock in locks) { + if (userLocks?.contains(lock.id) && (pinLen || (maxPinLen && minPinLen))) { // Check if the lock support reporting pin length and it has a valid number to report (not 0 or null) + if ((priorCode?.size() > 0) && (pinLen ? pinLen != priorCode.size() : ((priorCode.size() < minPinLen) || (priorCode.size() > maxPinLen)))) { // If we have a code to program + def msg = "$lock IS CONFIGURED TO ACCEPT ${pinLen ?: "${minPinLen}-${maxPinLen}"} DIGIT CODES ONLY, PROGRAMMING WILL FAIL!" + log.warn msg + userSummary += (userSummary ? "\n" : "") + msg + break // one message is enough + } + } + } + + // Sanity check for expiration date formats + switch (priorUserType) { + case 'Expire on': + if (priorStartDate) { + //log.trace "Found start date in setup" + try { + if (!(priorStartDate ==~ /^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/)) { // Check for valid date format (yyyy-MM-dd) + throw new RuntimeException("Invalid date format") + } + def df = Date.parse("yyyy-MM-ddHH:mm", priorStartDate + "00:00") // Test it + invalidStartDate = false + } + catch (Exception e) { + log.warn "Invalid start date for user $i" + invalidStartDate = true + } + } + if (priorExpireDate) { + //log.trace "Found expiry date in setup" + try { + if (!(priorExpireDate ==~ /^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/)) { // Check for valid date format (yyyy-MM-dd) + throw new RuntimeException("Invalid date format") + } + def df = Date.parse("yyyy-MM-ddHH:mm", priorExpireDate + "00:00") // Test it + invalidExpiryDate = false // We passed it's a valid date + } + catch (Exception e) { + log.warn "Invalid expiry date for user $i" + invalidExpiryDate = true + } + } + + if (!invalidExpiryDate && !invalidStartDate) { + if (priorExpireDate) { + def expired = false + if (priorExpireTime) { + // Parse the entire date/time including timezone since the Date object is converted and stored in UTC internally + def exp = Date.parse("yyyy-MM-ddHH:mmZ", priorExpireDate + timeToday(priorExpireTime, timeZone).format("HH:mmZ", timeZone)) + if (exp.getTime() < now()) { + def msg = "Code EXPIRED!" + userSummary += (userSummary ? "\n" : "") + msg + expired = true + userSlotActive = false + } else { + if (priorStartDate && priorStartTime && !expired) { + def start = Date.parse("yyyy-MM-ddHH:mmZ", priorStartDate + timeToday(priorStartTime, timeZone).format("HH:mmZ", timeZone)) + if (start.getTime() > now()) { + def msg = "Activates ${start.format("EEE MMM dd HH:mm", timeZone)}" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false + } + } + def msg = "Expires ${exp.format("EEE MMM dd HH:mm", timeZone)}" + userSummary += (userSummary ? "\n" : "") + msg + } + } + } + } else { + def msg = "INVALID Date!" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false + } + break + + case 'One time': + if (state.trackUsedOneTimeCodes?.contains(i as String)) { + def msg = "One time code USED!" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false + } + break + + case 'Scheduled': + if (!(schedulesSuffix.any { schedule -> settings."userDayOfWeek${schedule}${i}" })) { // If no schedules are defined + def msg = "No schedule defined!" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false + } else if (!schedulesSuffix.any { schedule -> checkSchedule(i, schedule) }) { // Check if we are outside operating schedule + userSlotActive = false + } + break + + case 'Presence': + if (!(priorUserPresent || priorUserNotPresent)) { // No conditions is specified + def msg = "No presence defined!" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false + } else if (!((priorUserPresent || priorUserNotPresent) && // No condition is true + (priorUserPresent ? priorUserPresent.any{it.currentPresence == "present"} : true) && + (priorUserNotPresent ? priorUserNotPresent.every{it.currentPresence != "present"} : true) + )) { + userSlotActive = false + } + break + + case 'Modes': + if (!priorUserModes?.find{it == location.mode}) { + userSlotActive = false + } + break + + case 'Inactive': + userSlotActive = false + break + + case 'Permanent': + break + + default: + def msg = "No user type selected!" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false // if there's no user type, it's deleted + break + } + + if (!userSummary) { // If there are no messages or warnings then indicate user type + userSummary += priorUserType + } + } else if (priorName) { // Incomplete configuration + def msg = "No code defined!" + userSummary += (userSummary ? "\n" : "") + msg + userSlotActive = false + } else { // Not configured + userSlotActive = false + } + + // Check if code has been changed and pending programming by lock + for (lock in locks) { + if (userLocks?.contains(lock.id)) { + if ((state.retryCodeCount != null) && (state.retryCodeCount[lock.id]?.(i as String) > (maxRetries + 1))) { // Failed to update + failedUpdate = true + break + } else if (userSlotActive && (state.lockCodes != null) && (state.lockCodes[lock.id]?.(i as String) != priorCode)) { // Programming pending update + pendingUpdate = true + break + } else if (!userSlotActive && (state.lockCodes != null) && state.lockCodes[lock.id]?.(i as String)) { // Deletion pending update + pendingUpdate = true + break + } + } else if ((state.retryCodeCount != null) && (state.retryCodeCount[lock.id]?.(i as String) > (maxRetries + 1))) { // Failed to update from lock not selected + failedUpdate = true + break + } else if ((state.lockCodes != null) && state.lockCodes[lock.id]?.(i as String)) { // Deletion pending update from a lock not selected + pendingUpdate = true + break + } + + if (!failedUpdate && !pendingUpdate) { // If it isn't failed/pending then it's active or inactive + if ((state.lockCodes != null) && state.lockCodes[lock.id]?.(i as String)) { // Still active + userSlotProgrammed = true + } + } + } + + // Params for user + def hrefParams = [ + user: i as String, + passed: true + ] + href(name: "userConfig${i}", params: hrefParams, title: "${priorName ?: "< empty >"}", page: "userConfigPage", description: userSummary, required: false, image: (failedUpdate ? "https://www.rboyapps.com/images/UserFailed.png" : (pendingUpdate ? "https://www.rboyapps.com/images/UserPending.png" : (userSlotProgrammed ? "https://www.rboyapps.com/images/User.png" : "https://www.rboyapps.com/images/UserInactive.png")))) + } + } + } +} + +def userConfigPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + // Get user from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.user) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user:$user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user:$user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + def i = user as Integer + + log.trace "User Codes Page, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"userConfigPage", title: "User Management Slot #${i}", uninstall: false, install: false) { + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + def msg = "Hub geolocation not set, using ${timeZone.getDisplayName()} timezone. Use the SmartThings app to set the Hub geolocation to identify the correct timezone." + log.error msg + sendPush msg + section("INVALID HUB LOCATION") { + paragraph title: msg, required: true, "" + } + } + + section() { + def priorName = settings."userNames${i}" + def priorCode = settings."userCodes${i}" + def priorNotify = settings."userNotify${i}" + def priorNotifyModes = settings."userNotifyModes${i}" + def priorExpireDate = settings."userExpireDate${i}" + def priorExpireTime = settings."userExpireTime${i}" + def priorStartDate = settings."userStartDate${i}" + def priorStartTime = settings."userStartTime${i}" + def priorUserType = settings."userType${i}" + def priorUserPresent = settings."userPresent${i}" // Get user presence + def priorUserNotPresent = settings."userNotPresent${i}" // Get user not presence + def priorUserModes = settings."userModes${i}" // Get user modes + def userLocks = (locks?.size() > 1) ? (settings."userLocks${i}" ?: locks*.id) : locks*.id // If not defined or only one lock then check all locks + def invalidStartDate = false + def invalidExpiryDate = false + def userSlotActive = true + + log.trace "Initial $i Name: $priorName, Code: $priorCode, Notify: $priorNotify, NotifyModes: $priorNotifyModes, ExpireDate: $priorExpireDate, ExpireTime: $priorExpireTime, StartDate: $priorStartDate, StartTime: $priorStartTime, UserType: $priorUserType" + + // Check if the lock pin code length match the pin code length entered by the user + def pinDetails = getLockPinLengthDetails((locks ?: []).findAll { userLocks.contains(it?.id) }) + def pinLen = pinDetails.pinLen // Fixed pin code length + def maxPinLen = pinDetails.maxPinLen // Variable minimum pin code length + def minPinLen = pinDetails.minPinLen // Variable maximum pin code length + def pinLenError = pinDetails.pinLenError + log.trace "Configured lock fixed code length: $pinLen, max code length: $maxPinLen, min code length: $minPinLen" + + // Check for errors and display messages + if (priorCode) { // Do all the checks only if user has been configured + // Sanity check, codes cannot be reused in the same lock (codes have to be unique to each slot) + def allUserCodes = (1..(maxUserNames ?: 0)).collectEntries { [(it):settings."userCodes${it}"] } // Get all user codes to save db access time + getDuplicateCodeUsers(allUserCodes, i).each { j -> + log.warn "CHANGE CODE FOR USER $i - THIS CODE HAS BEEN USED FOR USER $j" + paragraph title: "CHANGE CODE - THIS CODE HAS BEEN USED FOR USER $j", required: true, "" + } + + // Check if the user has entered a non digit string + if ((priorCode?.size() > 0) && !priorCode?.isNumber()) { + def msg = "WARNING: CODE IS NOT A NUMBER, PROGRAMMING WILL FAIL!" + paragraph title: msg, required: true, "" + } + + // Check if the lock pin code length match the pin code length entered by the user + for (lock in locks) { + if (userLocks?.contains(lock.id) && (pinLen || (maxPinLen && minPinLen))) { // Check if the lock support reporting pin length and it has a valid number to report (not 0 or null) + if ((priorCode?.size() > 0) && (pinLen ? pinLen != priorCode.size() : ((priorCode.size() < minPinLen) || (priorCode.size() > maxPinLen)))) { // If we have a code to program + def msg = "$lock IS CONFIGURED TO ACCEPT ${pinLen ?: "${minPinLen}-${maxPinLen}"} DIGIT CODES ONLY, PROGRAMMING WILL FAIL!" + paragraph title: msg, required: true, "" + break // one message is enough + } + } + } + + // Sanity check for expiration date formats + switch (priorUserType) { + case 'Expire on': + if (priorStartDate) { + //log.trace "Found start date in setup" + try { + if (!(priorStartDate ==~ /^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/)) { // Check for valid date format (yyyy-MM-dd) + throw new RuntimeException("Invalid date format") + } + def df = Date.parse("yyyy-MM-ddHH:mm", priorStartDate + "00:00") // Test it + log.trace "Start:" + df.format("EEE MMM dd yyyy") + invalidStartDate = false + } + catch (Exception e) { + log.warn "Invalid start date in setup" + invalidStartDate = true + } + } + if (priorExpireDate) { + //log.trace "Found expiry date in setup" + try { + if (!(priorExpireDate ==~ /^\d{4}\-(0[1-9]|1[012])\-(0[1-9]|[12][0-9]|3[01])$/)) { // Check for valid date format (yyyy-MM-dd) + throw new RuntimeException("Invalid date format") + } + def df = Date.parse("yyyy-MM-ddHH:mm", priorExpireDate + "00:00") // Test it + log.trace "Expire:" + df.format("EEE MMM dd yyyy") + invalidExpiryDate = false // We passed it's a valid date + } + catch (Exception e) { + log.warn "Invalid expiry date in setup" + invalidExpiryDate = true + } + } + + if (!invalidExpiryDate && !invalidStartDate) { + if (priorExpireDate) { + def expired = false + if (priorExpireTime) { + // Parse the entire date/time including timezone since the Date object is converted and stored in UTC internally + def exp = Date.parse("yyyy-MM-ddHH:mmZ", priorExpireDate + timeToday(priorExpireTime, timeZone).format("HH:mmZ", timeZone)) + if (exp.getTime() < now()) { + paragraph title: "Code EXPIRED!", required: true, "" + expired = true + userSlotActive = false + } else { + if (priorStartDate && priorStartTime && !expired) { + def start = Date.parse("yyyy-MM-ddHH:mmZ", priorStartDate + timeToday(priorStartTime, timeZone).format("HH:mmZ", timeZone)) + if (start.getTime() > now()) { + def startStr = start.format("EEE MMM dd yyyy HH:mm z", timeZone) + paragraph title: "Code activates on ${startStr}", required: true, "" + userSlotActive = false + } + } + } + } + } + } else { + paragraph title: "INVALID Date!", required: true, "" + userSlotActive = false + } + break + + case 'One time': + if (state.trackUsedOneTimeCodes?.contains(i as String)) { + def msg = "One time code USED!" + paragraph title: msg, required: true, "" + userSlotActive = false + } + break + + case 'Scheduled': + if (!(schedulesSuffix.any { schedule -> settings."userDayOfWeek${schedule}${i}" })) { // If no schedules are defined + def msg = "No schedule defined!" + paragraph title: msg, required: true, "" + userSlotActive = false + } else if (!schedulesSuffix.any { schedule -> checkSchedule(i, schedule) }) { // Check if we are outside operating schedule + userSlotActive = false + } + break + + case 'Presence': + if (!(priorUserPresent || priorUserNotPresent)) { // No conditions is specified + def msg = "No presence defined!" + paragraph title: msg, required: true, "" + userSlotActive = false + } else if (!((priorUserPresent || priorUserNotPresent) && // No condition is true + (priorUserPresent ? priorUserPresent.any{it.currentPresence == "present"} : true) && + (priorUserNotPresent ? priorUserNotPresent.every{it.currentPresence != "present"} : true) + )) { + userSlotActive = false + } + break + + case 'Modes': + if (!priorUserModes?.find{it == location.mode}) { + userSlotActive = false + } + break + + case 'Inactive': + userSlotActive = false + break + + case 'Permanent': + break + + default: + def msg = "No user type selected!" + paragraph title: msg, required: true, "" + userSlotActive = false // if there's no user type, it's deleted + break + } + } else if (priorName) { // Incomplete configuration + def msg = "No code defined!" + paragraph title: msg, required: true, "" + userSlotActive = false + } else { // Not configured + userSlotActive = false + } + + // Check if code has been changed and pending programming by lock + for (lock in locks) { + if (userLocks?.contains(lock.id)) { + if ((state.retryCodeCount != null) && (state.retryCodeCount[lock.id]?.(i as String) > (maxRetries + 1))) { // Failed to update + def msg = "No response from $lock" + paragraph title: msg, required: true, "" + } else if (userSlotActive && (state.lockCodes != null) && (state.lockCodes[lock.id]?.(i as String) != priorCode)) { // Programming pending update + def msg = "Pending addition confirmation from $lock" + paragraph msg + } else if (!userSlotActive && (state.lockCodes != null) && state.lockCodes[lock.id]?.(i as String)) { // Deletion pending update + def msg = "Pending deletion confirmation from $lock" + paragraph msg + } + } else if ((state.retryCodeCount != null) && (state.retryCodeCount[lock.id]?.(i as String) > (maxRetries + 1))) { // Failed to update from lock not selected + def msg = "No response from $lock" + paragraph title: msg, required: true, "" + } else if ((state.lockCodes != null) && state.lockCodes[lock.id]?.(i as String)) { // Deletion pending update from a lock not selected + def msg = "Pending deletion confirmation from $lock" + paragraph msg + } + } + + // User and code details/types + input "userNames${i}", "text", description: "Tap to set", title: "Name", multiple: false, required: (settings."userCodes${i}" ? true : false), submitOnChange: false, image: "https://www.rboyapps.com/images/UserPage.png" + input "userCodes${i}", "text", description: "Tap to set", title: "Code${pinLen ? " (${pinLen} digits)" : ((minPinLen && maxPinLen) ? " (${minPinLen}-${maxPinLen} digits)" : "")}", multiple: false, required: false, submitOnChange: true, image: "https://www.rboyapps.com/images/Code.png" // Input it type text otherwise users can't enter the number starting with 0 + + // Lock selection + if (locks?.size() > 1) { + input "userLocks${i}", "enum", description: "All locks", title: "Only on these lock(s)", options: selectLocks, multiple: true, required: false, image: "https://www.rboyapps.com/images/HandleLock.png" + } + + // User Type (Permanent, One Time, Scheduled, etc) + input "userType${i}", "enum", title: "Select User Type", required: true, multiple: false, options: codeOptions, defaultValue: 'Permanent', submitOnChange: true, image: "https://www.rboyapps.com/images/Schedule.png" + + // Expiration/Scheduling options + switch (priorUserType) { + case 'Expire on': + if (invalidStartDate == true) { + paragraph title: "INVALID START DATE - PLEASE CHECK YOUR DATE FORMAT", required: true, "" + } + input "userStartDate${i}", "text", title: "...code start date (YYYY-MM-DD) (optional)", description: "date on which the code should be enabled", required: false, submitOnChange: true + if (priorStartDate) { + input "userStartTime${i}", "time", title: "...code start time", description: "the code would be enabled within 2 minutes of this time", required: true, submitOnChange: false + } + if (invalidExpiryDate == true) { + paragraph title: "INVALID EXPIRY DATE - PLEASE CHECK YOUR DATE FORMAT", required: true, "" + } + input "userExpireDate${i}", "text", title: "...code expiration date (YYYY-MM-DD)", description: "date on which the code should be deleted", defaultValue: (new Date(now())).format("yyyy-MM-dd", timeZone), required: true, submitOnChange: true + input "userExpireTime${i}", "time", title: "...code expiration time", description: "the code would be deleted within 2 minutes of this time", required: true, submitOnChange: false + break + + case 'Scheduled': + // 3 schedule options for each user + schedulesSuffix.each { schedule -> + def hrefParams = [ + user: i as String, + schedule: schedule, + passed: true + ] + href(name: "schedule${schedule}", params: hrefParams, title: "...click here to define schedule ${schedule}", page: "scheduleCodesPage", description: getUserScheduleDescription(i, schedule, timeZone), required: false) + } + break + + case 'Presence': + input "userPresent${i}", "capability.presenceSensor", title: "...if any these people are present", description: "code should be active when any of these people are present", required: false, multiple: true + input "userNotPresent${i}", "capability.presenceSensor", title: "...and none of these people are present", description: "when all these people are not present", required: false, multiple: true + input "userPresenceLock${i}", "bool", title: "...lock automatically", description: "lock doors when deactivating user", required: false + input "userPresenceUnlock${i}", "bool", title: "...unlock automatically", description: "unlock doors when activating user", required: false + break + + case 'Modes': + input "userModes${i}", "mode", title: "...when in this mode(s)", description: "code should be active only during these modes", required: true, multiple: true + break + + default: + break + } + + // Notifications for each user + input "userNotify${i}", "bool", title: "Notify on use", defaultValue: true, required: false, submitOnChange: true, image: "https://www.rboyapps.com/images/Notifications.png" + if (priorNotify != false) { + input "userNotifyUseCount${i}", "number", title: "...limit to only this many times", description: "no limit", required: false, range: "1..*" + input "userNotifyModes${i}", "mode", title: "...only when in this mode(s)", description: "notify only when in any of these modes", required: false, multiple: true + input "userXNotifyPresence${i}", "capability.presenceSensor", title: "...and none of these people are present", description: "when all these people are not present", required: false, multiple: true + } + + // Unlock actions for each user + def hrefParams = [ + user: i as String, + passed: true + ] + href(name: "unlockLockActions", params: hrefParams, title: "Custom actions/notifications", page: "unlockLockActionsPage", description: (settings."userOverrideUnlockActions${user}" || (settings."userOverrideNotifications${user}" && settings."userNotify${user}")) ? "Configured" : "", required: false, image: "https://www.rboyapps.com/images/LockUnlock.png") + } + } +} + +private getSelectLocks() { + return (locks?.collectEntries { [ (it.id) : (it.displayName) ] })?.sort { it.value.toLowerCase() } // Get lock_id:lock_name and sort by name +} + +private getUserScheduleDescription(i, schedule, timeZone) { + def retVal = "Not defined" + if (settings."userDayOfWeek${schedule}${i}") { + retVal = "" + settings."userDayOfWeek${schedule}${i}".each { retVal += (retVal ? ", " : "") + it }// DOW + retVal += ": " + (settings."userStartTime${schedule}${i}" ? timeToday(settings."userStartTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : "") // Start Time + retVal += " - " + (settings."userEndTime${schedule}${i}" ? timeToday(settings."userEndTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : "") // EndTime + } + return retVal +} + +def scheduleCodesPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + def schedule = "" + // Get user from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.user) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user $user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + // Get schedule from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.schedule) { + schedule = params.schedule ?: "" + log.trace "Passed from main page, using params lookup for schedule $schedule" + } else if (atomicState.params) { + schedule = atomicState.params.schedule ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for schedule $schedule" + } else { + log.error "Invalid params, no schedule found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Schedule Codes Page, schedule:$schedule, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"scheduleCodesPage", title: "Define schedule ${schedule}" + (user ? " for user $name." : ""), uninstall: false, install: false) { + section() { + def i = user as Integer + def priorUserDayOfWeek = settings."userDayOfWeek${schedule}${i}" + def priorUserStartTime = settings."userStartTime${schedule}${i}" + def priorUserEndTime = settings."userEndTime${schedule}${i}" + log.trace "Schedule:$schedule, User:$i, Name: $name, UserDayOfWeek: $priorUserDayOfWeek, UserStartTime: $priorUserStartTime, UserEndTime: $priorUserEndTime" + + input "userStartTime${schedule}${i}", "time", title: "Start Time", required: false + input "userEndTime${schedule}${i}", "time", title: "End Time", required: false + input "userDayOfWeek${schedule}${i}", + "enum", + title: "Which day of the week?", + description: "Not defined", + required: false, + multiple: true, + options: schedulingOptions + } + } +} + +// Maximum number of codes supports by the locks (Maximum Common Number) +private getMaxCodes() { + def maxCommonCodes = 0 + for (lock in locks) { + def lockMax = (lock.hasAttribute("maxCodes") ? lock.currentValue("maxCodes") : 0) as Integer + //log.trace "$lock has max users: $lockMax" + maxCommonCodes = maxCommonCodes ? (lockMax ? Math.min(lockMax, maxCommonCodes) as Integer : maxCommonCodes) : (lockMax ?: 0) // Take the least amongst all selected locks + } + + //log.trace "Max users: $maxCommonCodes" + return maxCommonCodes +} + +// Check if the lock pin code length match on all the locks +// pinLen - Fixed pin code length +// maxPinLen - Variable minimum pin code length +// minPinLen - Variable maximum pin code length +// pinLenError - true / false +private getLockPinLengthDetails(subLocks = locks) { + def pinLen = null // Fixed pin code length + def maxPinLen = null // Variable minimum pin code length + def minPinLen = null // Variable maximum pin code length + def pinLenError = false + + for (lock in subLocks) { + def codeLen = (lock.currentValue("codeLength") ?: (lock.currentValue("pinLength") ?: null)) as Integer + def maxCodeLen = (lock.currentValue("maxCodeLength") ?: (lock.currentValue("maxPINLength") ?: null)) as Integer + def minCodeLen = (lock.currentValue("minCodeLength") ?: (lock.currentValue("minPINLength") ?: null)) as Integer + //log.trace "$lock fixed code length: $codeLen, max code length: $maxCodeLen, min code length: $minCodeLen" + if (codeLen && pinLen) { // If lock has fixed pin length and previous lock also had fixed pin length + if ((codeLen != pinLen) || (maxPinLen && (codeLen > maxPinLen)) || (minPinLen && (codeLen < minPinLen))) { // Check if we have pin mismatches + pinLenError = true // All locks must have the same pinLength + } + } else if (codeLen) { // If lock has fixed pin length + pinLen = codeLen // Save the length for future use + } else if (minCodeLen && maxCodeLen) { // Check for range validation + if (!minPinLen || (minCodeLen > minPinLen)) { + minPinLen = minCodeLen + } + if (!maxPinLen || (maxCodeLen < maxPinLen)) { + maxPinLen = maxCodeLen + } + } + } + + return [ pinLen: pinLen, maxPinLen: maxPinLen, minPinLen: minPinLen, pinLenError: pinLenError ] +} + + +def uninstalled() { + log.debug "Uninstall called" + authUpdate("uninstall") +} + +def installed() { + log.debug "Install Settings: $settings" + authUpdate("install") + state.sendUpdate = false + runIn(1, appTouch) // The platform calls update after installed, so avoid a duplicate run +} + +def updated() { + log.debug "Update Settings: $settings" + if (state.sendUpdate) { + authUpdate("update") + state.sendUpdate = false + } + runIn(1, appTouch) +} + +def appTouch() { + state.clientVersion = clientVersion() // Update our local stored client version to detect code upgrades + + unschedule() // clear all pending updates + unsubscribe() + + // Sanity check, codes cannot be reused in the same lock (codes have to be unique to each slot) + def allUserCodes = (1..(maxUserNames ?: 0)).collectEntries { [(it):settings."userCodes${it}"] } // Get all user codes to save db access time + for (int i = 1; i <= maxUserNames; i++) { + def code1 = allUserCodes[i] + getDuplicateCodeUsers(allUserCodes, i).each { j -> + def name1 = settings."userNames${i}" + def name2 = settings."userNames${j}" + def code2 = allUserCodes[j] + def msg = "CHANGE CODE - USER $name1 IN SLOT $i and USER $name2 IN SLOT $j SHARE THE SAME CODE $code1" + log.error msg + sendNotifications(msg) + } + + // Check if the user has entered a non digit string + if ((code1?.size() > 0) && !code1?.isNumber()) { + def name1 = settings."userNames${i}" + def msg = "CODE IS NOT A NUMBER, PROGRAMMING WILL FAIL - USER $name1 IN SLOT $i DOES NOT CONTAIN A NUMERIC PIN" + log.error msg + sendNotifications(msg) + } + + // Check if the lock pin code length match the pin code length entered by the user + def userLocks = (locks?.size() > 1) ? (settings."userLocks${i}" ?: locks*.id) : locks*.id // If not defined or only one lock then check all locks + for (lock in locks) { + def codeLen = (lock.currentValue("codeLength") ?: (lock.currentValue("pinLength") ?: null)) as Integer + def maxCodeLen = (lock.currentValue("maxCodeLength") ?: (lock.currentValue("maxPINLength") ?: null)) as Integer + def minCodeLen = (lock.currentValue("minCodeLength") ?: (lock.currentValue("minPINLength") ?: null)) as Integer + if (userLocks?.contains(lock.id) && (codeLen || (maxCodeLen && minCodeLen))) { // Check if the lock support reporting pin length and it has a valid number to report (not 0 or null) + if ((code1?.size() > 0) && (codeLen ? codeLen != code1.size() : ((code1.size() < minCodeLen) || (code1.size() > maxCodeLen)))) { // If we have a code to program + def name1 = settings."userNames${i}" + def msg = "CODE LENGTH DOES NOT MATCH $lock PROGRAMMING LENGTH, PROGRAMMING WILL FAIL - USER $name1 IN SLOT $i REQUIRES ${codeLen ?: "${minCodeLen}-${maxCodeLen}"} DIGITS FOR LOCK ${lock}" + log.error msg + sendNotifications(msg) + } + } + } + } + + // Initialize when we are going to check for code version updates + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + def msg = "Hub geolocation not set, using ${timeZone.getDisplayName()} timezone. Use the SmartThings app to set the Hub geolocation to identify the correct timezone." + log.error msg + sendPush msg + } + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Calendar localCalendar = Calendar.getInstance(timeZone) + localCalendar.set(Calendar.DAY_OF_WEEK, (new Date(now()))[Calendar.DAY_OF_WEEK]) // Starting today + localCalendar.set(Calendar.HOUR_OF_DAY, randomHour) // Check for code updates everyday at a random time between 10am and 6pm + localCalendar.set(Calendar.MINUTE, 3) // Offset to avoid ST platform timeout issue at top of hour + localCalendar.set(Calendar.SECOND, 0) + localCalendar.set(Calendar.MILLISECOND, 0) + if (localCalendar.getTimeInMillis() < now()) { // If it's in the past add one day to it + localCalendar.add(Calendar.DAY_OF_YEAR, 1) + } + state.nextCodeUpdateCheck = state.nextCodeUpdateCheck ?: localCalendar.getTimeInMillis() // If it's already set then don't update it + log.debug "Checking for next app update after ${(new Date(state.nextCodeUpdateCheck)).format("EEE MMM dd yyyy HH:mm z", timeZone)}" + + // subscribe to events to kick start timers and presence/mode events to update code states + subscribe(location, "mode", changeHandler) + subscribe(app, changeHandler) // Capture user intent to reinitialize timers + def presence = []// subscribe to presence + for (int i = 1; i <= maxUserNames; i++) { + if (settings."userType${i}" == "Presence") { + presence = (presence + (settings."userPresent${i}" ?: []) + (settings."userNotPresent${i}" ?: [])).unique() + } + } + log.trace "Subscribing to people: $presence" + subscribe(presence, "presence", changeHandler) + + subscribe(locks, "lock", lockHandler) // Subscribe to lock events to take action as defined as user + subscribe(locks, "tamper", lockHandler) // Subscribe to tamper events + subscribe(locks, "codeReport", codeResponse, [filterEvents:false]) // Subscribe to code report events to see if the code update was successful + subscribe(locks, "codeChanged", codeResponse, [filterEvents:false]) // Subscribe to code report events to see if the code update was successful + //subscribe(location, "alarmSystemStatus" , shmChangeHandler) // Subscribe to SHM state handler + if (adtDevices) { + subscribe(adtDevices, "securitySystemStatus" , adtChangeHandler) // Subscribe to ADT state handler + } + + // Reset the code update trackers and heartbeat system + state.lastCheck = 0 + state.lastHeartBeat = 0 + + locks.each { lock -> // check each lock individually + if (settings."sensor${lock}") { + log.trace "Subscribing to sensor ${settings."sensor${lock}"} for ${lock}" + subscribe(settings."sensor${lock}", "contact", sensorHandler) + } + if (lock.hasAttribute('invalidCode')) { + log.trace "Found attribute 'invalidCode' on $lock, enabled support for invalid code detection" + subscribe(lock, "invalidCode", lockHandler) + } + } + + state.usedOneTimeCodes = [:] + state.trackUsedOneTimeCodes = [] // Track for reporting purposes + state.retryCodeCount = [:] // Number of times a code programming has been retried + state.codeUseCount = [:] // Number of times codes were used + if (!state.lockCodes) { + state.lockCodes = [:] // Save list of programmed codes, initialize only if not already done + } + state.expiredLockList = [] + atomicState.reLocks = [:] // List of lock to relock after a timed delay + atomicState.notifyOpenDoors = [:] // List of locks to check for open notifications + atomicState.immediateLocks = [] // List of lock to lock immediately after a short delay + atomicState.unLocks = [] // List of lock to unlock after a short delay + for (lock in locks) { + state.codeUseCount[lock.id] = [:] // Number of times a code usage was used for this lock + state.usedOneTimeCodes[lock.id] = [] // List of used one time codes for this lock + state.retryCodeCount[lock.id] = [:] // Number of times a code programming has been retried for the lock + if (!state.lockCodes[lock.id]) { + state.lockCodes[lock.id] = [:] /// Track programmed codes for this lock, initialize only if not already done + } + state.expiredLockList.add(lock.id) // reset the state for each lock to be processed with expired + //log.trace "Added $lock id ${lock.id} to expire list ${state.expiredLockList}" + } + state.lockCodes.each { lockID, userMap -> + if (!locks?.any { lock -> lock.id == lockID }) { + log.debug "Lock with ID ${lockID} is no longer being programmed, stop tracking users from the lock" + state.lockCodes[lockID] = [:] /// Stop tracking codes from this lock once we exclude the lock from our main list + } + } + state.expiredNextCode = 1 // set next code to be set for the expired loop + + log.trace "Install complete" + + if (clearUserCodes) { + clearAllCodes() + } else { + kickStart() // Initialize codes + } +} + +// Handle changes, reinitialize the code check timers after a change, this is to workaround the issue of a buggy ST platform where the timers die randomly for some users +def changeHandler(evt) { + log.trace "Reinitializing code check timer on event notification, name: ${evt?.name}, value: ${evt?.value}, device: ${evt?.device}" + + if (evt?.name == "mode") { // Mode change notification + for (lock in locks) { // Check all locks + def sensor = settings."sensor${lock}" // Find the lock for this sensor, match by ID and not objects + if (sensor) { + log.trace "Checking for any pending door sensor activites that need to be done for lock $lock with sensor $sensor in mode ${evt.value}" + def sensorEvt = [name: sensor.name, displayName: sensor.displayName, value: sensor.latestValue("contact"), device: sensor] + sensorHandler(sensorEvt) + } + } + } + + if (evt?.name == "presence") { // User arrives/leaves + log.trace "Checking for presence ${evt?.device} actions" + def msgs = [:] + for (int i = 1; i <= maxUserNames; i++) { + def code = settings."userCodes${i}" as String + def type = settings."userType${i}" + def userPresent = settings."userPresent${i}" + def userNotPresent = settings."userNotPresent${i}" + def presenceLock = settings."userPresenceLock${i}" + def presenceUnlock = settings."userPresenceUnlock${i}" + if ((code != null) && (type == "Presence") && (presenceLock || presenceUnlock) && ((userPresent?.any { evt?.device?.id == it.id }) || (userNotPresent?.any { evt?.device?.id == it.id }))) { // Find all programmed users controlled by presence and need to lock/unlock on presence + def userLocks = (locks?.size() > 1) ? (settings."userLocks${i}" ?: locks*.id) : locks*.id // If not defined or only one lock then check all locks + def name = settings."userNames${i}" // Get the name for the slot + def doAdd = false + // Any of the 'present' users AND none of the 'not present' users are there then the code is active so unlock OR lock as required + if ((userPresent ? userPresent.any{it.currentPresence == "present"} : true) && + (userNotPresent ? userNotPresent.every{it.currentPresence != "present"} : true) + ) { + doAdd = true + } else { + doAdd = false + } + + for (lock in locks) { + if (userLocks.contains(lock.id)) { + if (!doAdd && presenceLock) { + def msg = "Locking ${lock} for user ${name} because ${evt.device.displayName} ${evt.value == "present" ? "arrived" : "left"}" + if (lock.currentValue("lock") != "locked") { + log.info msg + msgs += [ "${msg}" : (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "" ] // TODO: For now ALWAYS notify if locking/unlocking for security reasons, can use custom notifications settings + lock.lock() + } else { + log.debug "${lock} already locked, skipping ${msg}" + } + } else if (doAdd && presenceUnlock) { + def msg = "Unlocking ${lock} for user ${name} because ${evt.device.displayName} ${evt.value == "present" ? "arrived" : "left"}" + if (lock.currentValue("lock") != "unlocked") { + log.info msg + msgs += [ "${msg}" : (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "" ] // TODO: For now ALWAYS notify if locking/unlocking for security reasons, can use custom notifications settings + lock.unlock() + } else { + log.debug "${lock} already unlocked, skipping ${msg}" + } + } + } + } + } + } + // Last thing to do because it can timeout + msgs.each { msg, user -> + sendNotifications(msg, user) + } + } + + // Do a code check and restart the scheduler if required + // Reset the lock list and start from 1st code in first lock and check all codes + state.expiredLockList = [] + state.expiredNextCode = 1 // reset back to 1 for the next lock + for (lock in locks) { + state.expiredLockList.add(lock.id) // reset the state for each lock to be processed + //log.trace "Added $lock id ${lock.id} back to unprocessed locks list ${state.expiredLockList}" + } + + kickStart() +} + +// Handle changes to ADT states +def adtChangeHandler(evt) { + log.trace "ADT state change notification, name: ${evt?.name}, value: ${evt?.value}" + + def msg = "" + def keypads = locks?.findAll{ it.hasAttribute("armMode") } // Get all keypads and sync state with ADT + // We don't check for individual user custom actions for keypads since synchronization needs to happen at the keypad level + keypads = (settings."individualDoorActions${""}" ? keypads.findAll { keypad -> (settings."keypadArmDisarm${keypad}${""}") } : (settings."keypadArmDisarm${""}${""}" ? keypads : null)) // Get keypads with direct control enabled + def mode = settings."adtDevices"?.currentState("securitySystemStatus")?.value // This should the new ADT state + if (keypads) { + switch (mode) { + case "armedAway": + msg = "Detected ADT mode change, setting $keypads to Armed Away" + keypads*.setArmedAway() + break + + case "armedStay": + msg = "Detected ADT mode change, setting $keypads to Armed Stay" + keypads*.setArmedStay() + break + + case "disarmed": + msg = "Detected ADT mode change, setting $keypads to Disarmed" + keypads*.setDisarmed() + break + + default: + log.error "Unknown ADT mode $mode" + break + } + } else { + log.trace "No keypads found under direct ADT control" + } + + if (keypads && msg) { + log.info msg + } +} + +// Handle changes to SHM states +def shmChangeHandler(evt) { + log.trace "SHM state change notification, name: ${evt?.name}, value: ${evt?.value}" + + def msg = "" + def keypads = locks?.findAll{ it.hasAttribute("armMode") } // Get all keypads and sync state with SHM + // We don't check for individual user custom actions for keypads since synchronization needs to happen at the keypad level + keypads = (settings."individualDoorActions${""}" ? keypads.findAll { keypad -> (settings."keypadArmDisarm${keypad}${""}") } : (settings."keypadArmDisarm${""}${""}" ? keypads : null)) // Get keypads with direct control enabled + def mode = location.currentState("alarmSystemStatus")?.value // This should the new SHM state + if (keypads) { + switch (mode) { + case "away": + msg = "Detected SHM mode change, setting $keypads to Armed Away" + keypads*.setArmedAway() + break + + case "stay": + msg = "Detected SHM mode change, setting $keypads to Armed Stay" + keypads*.setArmedStay() + break + + case "off": + msg = "Detected SHM mode change, setting $keypads to Disarmed" + keypads*.setDisarmed() + break + + default: + log.error "Unknown SHM mode $mode" + break + } + } else { + log.trace "No keypads found under direct SHM control" + } + + if (keypads && msg) { + log.info msg + } +} + +def sensorHandler(evt) { + log.trace "Event name $evt.name, value $evt.value, device $evt.displayName" + + def sensor = evt.device + + def lock = locks.find { settings."sensor${it}"?.id == sensor.id } // Find the lock for this sensor, match by ID and not objects + log.trace "Sensor ${sensor} belongs to Lock ${lock}" + + if (evt.value == "closed") { // Door was closed + if (lock && settings."relockDoor${lock}" && (settings."relockDoorModes${lock}" ? settings."relockDoorModes${lock}".find{it == location.mode} : true)) { // Are we asked to reLock this door + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { + log.warn "Disable AutoLock on physical lock to use SmartApp AutoReLock and AutoUnlock features" + } else { + if (settings."relockImmediate${lock}") { + log.debug "Relocking ${lock} immediately in 3 seconds" + def immediatelocks = atomicState.immediateLocks ?: [] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + if (!immediatelocks.contains(lock.id)) { // Don't re add the same lock again + //log.trace "Adding ${lock.id} to the list of immediate locks" + immediatelocks.add(lock.id) // Atomic to ensure we get upto date info here + atomicState.immediateLocks = immediatelocks // Set it back, we can't work direct on atomicState + } + immediateLockDoor() // Lock it right away + } else if (settings."relockAfter${lock}") { + log.debug "Scheduling ${lock} to lock in ${settings."relockAfter${lock}"} minutes" + def reLocks = atomicState.reLocks ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Adding ${lock.id} to the list of relocks" + reLocks[lock.id] = now() // Atomic to ensure we get upto date info here, Update and Add work the same way here so we don't need to check before adding/updating + atomicState.reLocks = reLocks // Set it back, we can't work direct on atomicState + reLockDoor() // Call relock door it'll take of delaying the lock as required + } else { + log.error "Invalid configuration, no relock timeout defined" + } + } + } + } else { // Door was opened + // Chime bell + if (settings."openNotifyBeep${lock}") { + if (!settings."openNotifyModes${lock}" || (settings."openNotifyModes${lock}"?.find{it == location.mode})) { + log.debug "Door ${sensor} was opened, chiming bell ${settings."openNotifyBeep${lock}"}" + settings."openNotifyBeep${lock}".beep() // Beep + } else { + log.trace "${lock} chiming not set for Mode ${location.mode}" + } + } + + // Notify user + if (settings."openNotify${lock}") { + if (!settings."openNotifyModes${lock}" || (settings."openNotifyModes${lock}"?.find{it == location.mode})) { + log.debug "Scheduling ${lock} to notify user of open door in ${settings."openNotifyTimeout${lock}"} minutes" + //log.trace "Updating ${lock.id} timestamp in the list of notifyOpenDoors" + def notifyOpenDoors = atomicState.notifyOpenDoors ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors[lock.id] = now() // Atomic to ensure we get upto date info here, Update and Add work the same way here so we don't need to check before adding/updating + atomicState.notifyOpenDoors = notifyOpenDoors // Set it back, we can't work direct on atomicState + notifyOpenDoor() // Notify, it'll take of delaying it if it's too soon + } else { + log.trace "${lock} open notification not set for Mode ${location.mode}" + } + } + } +} + +// Check for any pending door unlocks +def unLockDoor() { + def unLocksIDs = atomicState.unLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Pending door unlocks ${unLocksIDs}" + + unLocksIDs?.each { lockid -> + def lock = locks.find { it.id == lockid } // find the lock + log.info "UnLocking the door ${lock} immediately" + lock.unlock() // unlock it + def unlocks = atomicState.unLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Removing ${lockid} from the list of pending unlocks" + unlocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.unLocks = unlocks // set it back to atomicState + //log.trace "Checking for any pending door unlocks in 3 seconds" + startTimer(3, unLockDoor) // Next immediate door lock in 3 seconds (give it some time for the mesh network) + return // We're done here + } +} + +// Check for any pending immediate door locks +def immediateLockDoor() { + def immediateLocksIDs = atomicState.immediateLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Pending immediate door locks ${immediateLocksIDs}" + + immediateLocksIDs?.each { lockid -> + def lock = locks.find { it.id == lockid } // find the lock + log.info "Locking the door ${lock} immediately" + lock.lock() // lock it + def immediatelocks = atomicState.immediateLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Removing ${lockid} from the list of pending immediate locks" + immediatelocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.immediateLocks = immediatelocks // set it back to atomicState + //log.trace "Checking for any pending immediate door locks in 3 seconds" + startTimer(3, immediateLockDoor) // Next immediate door lock in 3 seconds (give it some time for the mesh network) + return // We're done here + } +} + +// Check for any pending delayed door relocks +def reLockDoor() { + def reLocksIDs = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Checking door sensor state and relocking ${reLocksIDs}" + + Long shortestPendingTime = 0 // in seconds + + reLocksIDs?.each { lockid, timestamp -> + def lock = locks.find { it.id == lockid } // find the lock + def lockSensor = settings."sensor${lock}" // Get the sensor for the lock + Long timeLeft = (((60 * 1000 * settings."relockAfter${lock}") + timestamp) - now())/1000 // timestamp and now() is in ms + if (timeLeft <= 1) { // If we are within 1 second then go ahead since the timer isn't always 100% accurate + if (settings."relockDoorModes${lock}" ? settings."relockDoorModes${lock}".find{it == location.mode} : true) { // Check if the mode is still active + if (!lockSensor) { // If we don't have a sensor then just lock on schedule + log.info "No sensor found on ${lock} when closed, locking the door" + lock.lock() // lock it + //log.trace "Removing ${lockid} from the list of pending relocks" + def reLocks = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + reLocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.reLocks = reLocks // set it back to atomicState + //log.trace "Checking for any pending relocks in 3 seconds" + startTimer(3, reLockDoor) // Next pending relock in 3 seconds (give it some time for the mesh network) + return // We're done here + } else if (lockSensor.latestValue("contact") == "closed") { + log.info "Sensor ${lockSensor} is reporting door ${lock} is closed, locking the door" + lock.lock() // lock it + //log.trace "Removing ${lockid} from the list of pending relocks" + def reLocks = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + reLocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.reLocks = reLocks // set it back to atomicState + //log.trace "Checking for any pending relocks in 3 seconds" + startTimer(3, reLockDoor) // Next pending relock in 3 seconds (give it some time for the mesh network) + return // We're done here + } else { + log.debug "Sensor ${lockSensor} is reporting door ${lock} is not closed, will check again in 60 seconds" + startTimer(60, reLockDoor) // Check back again in some time + } + } else { + log.trace "Relock mode conditions not met, not executing relock" + def reLocks = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + reLocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.reLocks = reLocks // set it back to atomicState + } + } else { + log.trace "${lock} has not reached the time limit of ${settings."relockAfter${lock}"} minutes yet, ${timeLeft/60} minutes to go" + if (!shortestPendingTime || (timeLeft < shortestPendingTime)) { + log.trace "Settings shortest pending time to ${timeLeft} seconds" + shortestPendingTime = timeLeft + } + } + } + + if (shortestPendingTime) { + startTimer((shortestPendingTime < 1 ? 1 : shortestPendingTime), reLockDoor) // Check back again after shortest pending timeout + } +} + +// Notify if the doors are left open +def notifyOpenDoor() { + def notifyOpenDoorsIds = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Checking Locks ${notifyOpenDoorsIds} door sensor state" + + Long shortestPendingTime = 0 // in seconds + + notifyOpenDoorsIds?.each { lockid, timestamp -> + def lock = locks.find { it.id == lockid } // find the lock + def lockSensor = settings."sensor${lock}" // Get the sensor for the lock + + if (!settings."openNotify${lock}" || (settings."openNotifyModes${lock}" && !(settings."openNotifyModes${lock}"?.find{it == location.mode}))) { // Check if the settings have changed + log.trace "No need to monitor open sensor ${lockSensor} for door ${lock} as settings/modes have changed" + //log.trace "Removing ${lockid} from the list of pending notifications" + def notifyOpenDoors = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors.remove(lock.id) // We are done with this lock, remove it from the list + atomicState.notifyOpenDoors = notifyOpenDoors // set it back to atomicState + return // move on + } + + Long timeLeft = (((60 * 1000 * settings."openNotifyTimeout${lock}") + timestamp) - now())/1000 // timestamp and now() is in ms + if (timeLeft <= 1) { // If we are within 1 second then go ahead since the timer isn't always 100% accurate + if (lockSensor.latestValue("contact") == "closed") { + log.trace "Sensor ${lockSensor} is reporting door ${lock} is closed, no notification required" + //log.trace "Removing ${lockid} from the list of pending notifications" + def notifyOpenDoors = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors.remove(lock.id) // We are done with this lock, remove it from the list + atomicState.notifyOpenDoors = notifyOpenDoors // set it back to atomicState + } else { + log.info "Sensor ${lockSensor} is reporting door ${lock} is open, notifying user${settings."openNotifyRepeat${lock}" ? " and checking again after ${settings."openNotifyTimeout${lock}"} minutes" : ""}" + def msg = "$lockSensor has been open for ${settings."openNotifyTimeout${lock}"} minutes" + + //log.trace "Updating ${lock.id} timestamp in the list of notifyOpenDoors" + def notifyOpenDoors = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + if (settings."openNotifyRepeat${lock}") { + notifyOpenDoors[lock.id] = now() // Atomic to ensure we get upto date info here + } else { + notifyOpenDoors.remove(lock.id) // We are done with this lock, remove it from the list + } + atomicState.notifyOpenDoors = notifyOpenDoors // set it back to atomicState + + if (settings."openNotifyRepeat${lock}") { + startTimer(60, notifyOpenDoor) // Check back again after short timeout so we don't overwrite a short wait with a long wait + } + sendNotifications(msg) // Do it in the end to avoid a timeout + } + } else { + log.trace "${lock} has not reached the time limit of ${settings."openNotifyTimeout${lock}"} minutes yet, ${timeLeft/60} minutes to go" + if (!shortestPendingTime || (timeLeft < shortestPendingTime)) { + log.trace "Settings shortest pending time to ${timeLeft} seconds" + shortestPendingTime = timeLeft + } + } + } + + if (shortestPendingTime) { + startTimer((shortestPendingTime < 1 ? 1 : shortestPendingTime), notifyOpenDoor) // Check back again after shortest pending timeout + } +} + +def codeResponse(evt) { + def lock = evt.device + def user = evt.value?.isInteger() ? evt.value as Integer : null + def type = null + if (!user) { // For new handler codeChanged doesn't report the user slot, we need to extract it (we only subscribe to codeChanged and codeReport) + def value = evt.value?.split(" ")?.first()?.trim() + user = value?.isInteger() ? value as Integer : null + if (evt.value?.split(" ")?.size() > 1) { + type = evt.value?.split(" ")?.last()?.trim() // Get the transaction type + } + } + def code = evt.data ? parseJson(evt.data)?.code : "" // Not all locks return a code due to a bug in the base Z-Wave lock device code + def desc = evt.descriptionText // Description can have "is set" or "was added" or "changed" when code was added successfully + def name = settings."userNames${user}" + + log.trace "$lock code report ${evt.name} returned Name:${name ?: ""}, User:${user}, Code:${code}, Desc:${desc}, Value: ${evt.value}, Type: ${type}" + + if ((evt.name == "codeChanged") && (evt.value == "all deleted" || evt.value == "all")) { // Special case, when lock is reset all codes are deleted, we don't have a user id for this one + // First update tracking lists for used one time codes - to avoid a race condition with programmed codes + state.usedOneTimeCodes[lock.id] = [] // Reset list as all codes are deleted + state.codeUseCount[lock.id] = [:] // Reset code usage count + state.lockCodes[lock.id] = [:] // Reset list + state.retryCodeCount[lock.id] = [:] // Reset list + def msg = "All user codes were deleted from $lock" + log.info msg + sendNotifications(msg) // This is mandatory as a special exception + return // We're done here + } else if (!user) { + log.warn "No user slot/id found in code reponse from lock, ignoring report" + return // We're done here + } + + switch (type) { // For new device handler we already have a type and so lets use it + case "set": + case "changed": + type = "added" // Update the type + break + + case "deleted": + case "unset": + type = "deleted" + break + + case "failed": + if (desc?.contains("duplicate")) { // DTH inaccurately reports some failed programming codes as duplicate so check extended event for real reason + type = "duplicate" + } // If a previous code reponse notification was lost it will report failed, but don't try to add it because a genuine failure cannot be captures. This is an issue to lock communication with z-wave mesh which needs to be addressed + break + + case "renamed": + type = "renamed" + break + + case null: // This is if we are using the device handlers which use codeReport + if (evt.name == "codeReport") { + if ((["is set", "added", "changed"].any { desc?.contains(it) }) && !(["unset"].any { desc?.contains(it) })) { // Bug with new ST handler uses the words changed and unset in CodeChanged event + type = "added" + } else if (["is not set", "deleted"].any { desc?.contains(it) }) { + type = "deleted" + } else if (["duplicate"].any { desc?.contains(it) }) { + type = "duplicate" + } + } + break + + default: + log.warn "Ignoring transaction from $lock for user $user: ${desc}" + return // We're done here + break + } + + if (!type) { + log.warn "Ignoring transaction from $lock for user $user: ${desc}" + return // We're done here + } + + def currentCode = settings."userCodes${user}" as String + + // Failed means lock cannot add code for multiple reasons, like wrong pin length or it's a duplicate (either from another slot or because the prior add confirmation was lost we didn't know) + // Do don't assume it's done and add back because the DTH can't tell the differnce between duplicates and lost responses or bad lengths + if (!(state.lockCodes[lock.id].(user as String)) && (type == "failed")) { // Only process duplicate code notitications if the user is not already programmed in our list + def msg = "$lock failed to add user $user. Retrying again, check pin length or try to change the code" + log.warn msg + detailedNotifications ? sendNotifications(msg) : sendNotificationEvent(msg) // It can fail if the user was added by other means, so we only report if needed + return // We're done here + } + + if (!(state.lockCodes[lock.id].(user as String)) && (type == "duplicate")) { // Only process duplicate code notitications if the user is not already programmed in our list + def msg = "$lock reported user $user is a duplicate code! Please clear extra codes from your lock" + log.warn msg + sendNotifications(msg) // This is mandatory, cannot ignore + return // We're done here + } + + if ((state.lockCodes[lock.id].(user as String)) && (type == "renamed")) { // Only rename slots that we have in our list + def newName = desc?.split('" to "')?.last()?.replaceAll('"', '') // Get new name and remove quotes + if (newName && (newName != name)) { // Cannot be blank and should be different + updateSetting("userNames${user}", newName) + def msg = "$lock renamed user $user from $name to $newName" + log.info msg + detailedNotifications ? sendNotifications(msg) : sendNotificationEvent(msg) + } + return // We're done here + } + + if ((!state.lockCodes[lock.id].(user as String) || (state.lockCodes[lock.id].(user as String) != currentCode)) && (type == "added")) { // We can get the notifications multiple times + state.lockCodes[lock.id][user as String] = currentCode ?: "1" // If the code doesn't exist then someone added the code externally, mark it a special code so it'll be deleted + state.retryCodeCount[lock.id][user as String] = 0 // Reset the retry + def msg = "Confirmed $lock added $name to user $user" + log.info msg + detailedNotifications ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We're done here + } + + if ((state.lockCodes[lock.id].(user as String)) && (type == "deleted")) { // We can get the notifications multiple times (don't track "was reset" as that's an intermediary notification while setting a code) + // First update tracking lists for used one time codes - to avoid a race condition with programmed codes + if (state.usedOneTimeCodes[lock.id].contains(user as String)) { + state.usedOneTimeCodes[lock.id].remove(user as String) + log.trace "Deleted code was a used one time code, removing it from list of used one time codes" + } + state.codeUseCount[lock.id].remove(user as String) // Don't track the usage for this code anymore + state.lockCodes[lock.id].remove(user as String) + state.retryCodeCount[lock.id][user as String] = 0 // Reset the retry + def msg = "Confirmed ${name ?: ""} user $user was deleted from $lock" + log.info msg + detailedNotifications ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We're done here + } +} + +// Lock event handler +def lockHandler(evt) { + def data = null + def lock = evt.device + + log.trace "Lock event name $evt.name, value $evt.value, device $evt.displayName, description $evt.descriptionText, data $evt.data" + + def evtMap = [name:evt.name, value:evt.value, displayName:evt.displayName, descriptionText:evt.descriptionText, data:evt.data, lockId: evt.device.id] // NOTE: Bug with ST, runIn passes a JSONObject instead of a map - https://community.smartthings.com/t/runin-json-vs-map/104442 so convert evt to a standard map and also we can't pass evt object to runIn + + if (evt.name == "lock") { // LOCK UNLOCK EVENTS + if (evt.value == "unlocked") { // UNLOCKED + unschedule(processLockActions) // If there was a pending delayed actions and user operated the lock then cancel it + processUnlockEvent(evtMap) + } else if (evt.value == "locked") { // LOCKED MANUALLY OR VIA KEYPAD OR ELECTRONICALLY + unschedule(processLockActions) // If there was a pending delayed actions and user operated the lock then cancel it + processLockEvent(evtMap) + } else if (evt.value == "unknown") { // JAMMED CODE EVENT + log.debug "Lock $evt.displayName Jammed!" + if ((!settings."individualDoorActions" && jamNotify) || + (settings."individualDoorActions" && settings."jamNotify${lock}")) { + def msg = "$evt.displayName lock is Jammed!" + sendNotifications(msg) + } + } + } else if (evt.name == "invalidCode") { // INVALID LOCK CODE EVENT + log.debug "Lock $evt.displayName, invalid user code: ${evt.value}" + def msg = "Invalid user code detected on $evt.displayName" + sendNotifications(msg) + } else if (evt.name == "tamper" && evt.value == "detected") { // Tampering of the lock + log.debug "Lock $evt.displayName tamper detected with description $evt.descriptionText" + def msg = "Tampering detected on $evt.displayName. ${evt.descriptionText ?: ""}" + sendNotifications(msg) + } +} + +def processUnlockEvent(evt) { + def data = null + def lock = locks.find { it.id == evt.lockId } + + log.trace "Processing $lock unlock event: $evt" + + // Check if we have delayed relock is enabled, if so then start the timer now just incase the user never opens the door (reLockDoor will take care of sensor if present, immediate relock should never happen without a sensor) + if (settings."relockDoor${lock}" && settings."relockAfter${lock}" && (settings."relockDoorModes${lock}" ? settings."relockDoorModes${lock}".find{it == location.mode} : true)) { // Are we asked to reLock this door + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { + log.warn "Disable AutoLock on physical lock to use SmartApp AutoReLock and AutoUnlock features" + } else { + log.debug "Scheduling ${lock} to lock in ${settings."relockAfter${lock}"} minutes" + def reLocks = atomicState.reLocks ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Adding ${lock.id} to the list of relocks" + reLocks[lock.id] = now() // Atomic to ensure we get upto date info here, Update and Add work the same way here so we don't need to check before adding/updating + atomicState.reLocks = reLocks // Set it back, we can't work direct on atomicState + reLockDoor() // Call relock door it'll take of delaying the lock as required + } + } else { + log.trace "Relock conditions not met, not scheduling relock" + } + + if (evt.data) { // Was it unlocked using a code + data = parseJson(evt.data) + } + + def user = (data?.usedCode as String) ?: ((data?.codeId as String) ?: "") // get the user if present + def i = ((data?.usedCode ?: 0) as Integer) ?: (((data?.codeId ?: 0) as Integer) ?: 0) // get the user if present + def lockMode = data?.type ?: (data?.method ?: (evt.descriptionText?.contains("manually") ? "manually" : "electronically")) + // Fix for proper grammar + switch (lockMode) { + case "manual": + lockMode = "manually" + break + + case "rfid": + lockMode = "via RFID" + break + + case "bluetooth": + lockMode = "via bluetooth" + break + + case "keypad": + lockMode = "via keypad" + break + + case "remote": + case "command": + lockMode = "remotely" + break + + case "auto": + lockMode = "via internal autolock" + break + + default: + break + } + + if (!user && !(["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) })) { // No extended data, must be a manual/auto/keyed unlock, NOTE: some locks don't send keypad user codes + log.trace "$evt.displayName was unlocked manually. Source type: $lockMode" + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions") { + lockStr = lock as String + } else { + lockStr = "" + } + + // First disarm SHM since it goes off due to other events + if (settings."runXPeopleUnlockActionsManual${lockStr}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleUnlockActionsManual${lockStr}"?.find{it.currentPresence == "present"}} is present, not running unlock actions for door $lock" + } else if (settings."runXModeUnlockActionsManual${lockStr}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running unlock actions for door $lock" + } else { + def msg = "$evt.displayName was unlocked $lockMode" + + /*if (settings."homeDisarmManual${lockStr}") { // Sync SHM + log.info "Disarming Smart Home Monitor" + sendLocationEvent(name: "alarmSystemStatus", value: "off") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming Smart Home Monitor" : "" + }*/ + + try { + if (settings."adtDisarmManual${lockStr}" && settings."adtDevices") { + log.info "Disarming ADT" + settings."adtDevices"?.disarm() // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming ADT" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error disarming ADT\n$e" + msg += ", error disarming ADT" + } + + if (settings."homeModeManual${lockStr}") { + log.info "Changing mode to ${settings."homeModeManual${lockStr}"}" + if (location.modes?.find{it.name == settings."homeModeManual${lockStr}"}) { + setLocationMode(settings."homeModeManual${lockStr}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."homeModeManual${lockStr}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."homeModeManual${lockStr}"}" : "" + } + + /*if (settings."homePhraseManual${lockStr}" && location.helloHome?.getPhrases()) { + log.info "$evt.displayName was unlocked successfully, running routine ${settings."homePhraseManual${lockStr}"}" + location.helloHome.execute(settings."homePhraseManual${lockStr}") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", running routine ${settings."homePhraseManual${lockStr}"}" : "" + }*/ + + if (settings."turnOnSwitchesAfterSunsetManual${lockStr}") { + def cdt = new Date(now()) + def sunsetSunrise = getSunriseAndSunset(sunsetOffset: "-00:30") // Turn on 30 minutes before sunset (dark) + log.trace "Current DT: $cdt, Sunset $sunsetSunrise.sunset, Sunrise $sunsetSunrise.sunrise" + if ((cdt >= sunsetSunrise.sunset) || (cdt <= sunsetSunrise.sunrise)) { + log.info "$evt.displayName was unlocked successfully, turning on lights ${settings."turnOnSwitchesAfterSunsetManual${lockStr}"} since it's after sunset but before sunrise" + settings."turnOnSwitchesAfterSunsetManual${lockStr}"?.on() + msg += detailedNotifications ? ", turning on lights ${settings."turnOnSwitchesAfterSunsetManual${lockStr}"}" : "" + } + } + + if (settings."turnOnSwitchesManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, turning on switches ${settings."turnOnSwitchesManual${lockStr}"}" + settings."turnOnSwitchesManual${lockStr}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."turnOnSwitchesManual${lockStr}"}" : "" + } + + if (settings."turnOffSwitchesManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, turning off switches ${settings."turnOffSwitchesManual${lockStr}"}" + settings."turnOffSwitchesManual${lockStr}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."turnOffSwitchesManual${lockStr}"}" : "" + } + + if (settings."unlockLocksManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, unlocking ${settings."unlockLocksManual${lockStr}"}" + settings."unlockLocksManual${lockStr}"?.unlock() + msg += detailedNotifications ? ", unlocking ${settings."unlockLocksManual${lockStr}"}" : "" + } + + if (settings."openGarageManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, opening ${settings."openGarageManual${lockStr}"}" + settings."openGarageManual${lockStr}"?.open() + msg += detailedNotifications ? ", opening ${settings."openGarageManual${lockStr}"}" : "" + } + + if (settings."manualNotify${lockStr}" && (settings."manualNotifyModes${lockStr}" ? settings."manualNotifyModes${lockStr}".find{it == location.mode} : true)) { + sendNotifications(msg) + } + } + } else { // KEYPAD / RFID UNLOCK + def name = settings."userNames${i}" + def notify = settings."userNotify${i}" + def notifyCount = settings."userNotifyUseCount${i}" + def notifyModes = settings."userNotifyModes${i}" + def notifyXPresence = settings."userXNotifyPresence${i}" + + log.trace "Lock $evt.displayName unlocked by $name, notify $notify, notify count: $notifyCount, notify modes $notifyModes, notify NOT present $notifyXPresence, Source type: $lockMode" + + def msg = "" + + if (i == 0) { + name = "Master Code" // Special case locks like Yale have a master code which isn't programmable and is code 0 + notify = true // always inform about master users + user = "" // Master code uses general actions + } + + if (!name) { // will handle usedCode null errors + notify = true // always inform about unknown users + msg = "$evt.displayName was unlocked by Unknown User from slot $i $lockMode" + } else { + msg = "$evt.displayName was unlocked by $name $lockMode" + } + + // Check if we have user override unlock actions defined + if (!settings."userOverrideUnlockActions${i as String}") { + log.trace "Did not find per user unlock actions, falling back to general actions" + user = "" + } + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions${user}") { + lockStr = lock as String + } else { + lockStr = "" + } + + // First disarm SHM since it goes off due to other events + if (settings."runXPeopleUnlockActions${lockStr}${user}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleUnlockActions${lockStr}${user}"?.find{it.currentPresence == "present"}} is present, not running unlock actions for door $lock" + } else if (settings."runXModeUnlockActions${lockStr}${user}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running unlock actions for door $lock" + } else { + // If we have a specific mode passed by the keypad lets use that otherwise use configured options + if ((settings."keypadArmDisarm${lockStr}${user}") && data?.armMode) { + switch (data.armMode) { // Set Keypad lock state + case "disarmed": + /*log.info "Disarming Smart Home Monitor" // Sync SHM + sendLocationEvent(name: "alarmSystemStatus", value: "off") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming Smart Home Monitor" : ""*/ + try { + if (settings."adtDevices") { + log.info "Disarming ADT" + settings."adtDevices"?.disarm() // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming ADT" : "" + startTimer(1, adtChangeHandler) // If this came from a keypad and direct control for ADT is enabled, then refresh the keypad state (incase exit code beeping needs to be cancelled) + //} else { + // startTimer(1, shmChangeHandler) // If this came from a keypad and direct control for SHM is enabled, then refresh the keypad state (incase exit code beeping needs to be cancelled) + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error disarming ADT\n$e" + msg += ", error disarming ADT" + } + break + + default: + log.warn "Invalid keypad mode detected: ${data.armMode}" + msg += ", invalid keypad mode ${data.armMode}" + break + } + } else { + /*if (settings."homeDisarm${lockStr}${user}") { // Sync SHM + log.info "Disarming Smart Home Monitor" + sendLocationEvent(name: "alarmSystemStatus", value: "off") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming Smart Home Monitor" : "" + }*/ + + try { + if (settings."adtDisarm${lockStr}${user}" && settings."adtDevices") { + log.info "Disarming ADT" + settings."adtDevices"?.disarm() // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming ADT" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error disarming ADT\n$e" + msg += ", error disarming ADT" + } + } + + if (settings."homeMode${lockStr}${user}") { + log.info "Changing mode to ${settings."homeMode${lockStr}${user}"}" + if (location.modes?.find{it.name == settings."homeMode${lockStr}${user}"}) { + setLocationMode(settings."homeMode${lockStr}${user}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."homeMode${lockStr}${user}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."homeMode${lockStr}${user}"}" : "" + } + + /*if (settings."homePhrase${lockStr}${user}" && location.helloHome?.getPhrases()) { + log.info "$evt.displayName was unlocked successfully, running routine ${settings."homePhrase${lockStr}${user}"}" + location.helloHome.execute(settings."homePhrase${lockStr}${user}") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", running routine ${settings."homePhrase${lockStr}${user}"}" : "" + }*/ + + if (settings."turnOnSwitchesAfterSunset${lockStr}${user}") { + def cdt = new Date(now()) + def sunsetSunrise = getSunriseAndSunset(sunsetOffset: "-00:30") // Turn on 30 minutes before sunset (dark) + log.trace "Current DT: $cdt, Sunset $sunsetSunrise.sunset, Sunrise $sunsetSunrise.sunrise" + if ((cdt >= sunsetSunrise.sunset) || (cdt <= sunsetSunrise.sunrise)) { + log.info "$evt.displayName was unlocked successfully, turning on lights ${settings."turnOnSwitchesAfterSunset${lockStr}${user}"} since it's after sunset but before sunrise" + settings."turnOnSwitchesAfterSunset${lockStr}${user}"?.on() + msg += detailedNotifications ? ", turning on lights ${settings."turnOnSwitchesAfterSunset${lockStr}${user}"}" : "" + } + } + + if (settings."turnOnSwitches${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, turning on switches ${settings."turnOnSwitches${lockStr}${user}"}" + settings."turnOnSwitches${lockStr}${user}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."turnOnSwitches${lockStr}${user}"}" : "" + } + + if (settings."turnOffSwitches${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, turning off switches ${settings."turnOffSwitches${lockStr}${user}"}" + settings."turnOffSwitches${lockStr}${user}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."turnOffSwitches${lockStr}${user}"}" : "" + } + + if (settings."toggleSwitches${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, toggling switches ${settings."toggleSwitches${lockStr}${user}"}" + settings."toggleSwitches${lockStr}${user}".each { dev -> + dev.currentValue("switch") == "on" ? dev?.off() : dev?.on() + } + msg += detailedNotifications ? ", toggling switches ${settings."toggleSwitches${lockStr}${user}"}" : "" + } + + if (settings."unlockLocks${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, unlocking ${settings."unlockLocks${lockStr}${user}"}" + settings."unlockLocks${lockStr}${user}"?.unlock() + msg += detailedNotifications ? ", unlocking ${settings."unlockLocks${lockStr}${user}"}" : "" + } + + if (settings."openGarage${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, opening ${settings."openGarage${lockStr}${user}"}" + settings."openGarage${lockStr}${user}"?.open() + msg += detailedNotifications ? ", opening ${settings."openGarage${lockStr}${user}"}" : "" + } + } + + // Check for one time codes and disable them if required + def userType = settings."userType${i}" // User type + def userLocks = (locks?.size() > 1) ? (settings."userLocks${i}" ?: locks*.id) : locks*.id // If not defined or only one lock then check all locks + if (userLocks?.contains(lock.id) && (userType == 'One time')) { + if (!state.usedOneTimeCodes[lock.id].contains(i as String)) { + log.trace "Marking one time code as used and requesting removal from lock" + state.usedOneTimeCodes[lock.id].add(i as String) // mark the user slot used + codeCheck() // Check the expired code and remove from lock + } else { + log.warn "One time code is ALREADY marked as used" + } + } + + // Send notifications + if (i) { // If we have a known user, increment the usage count + state.codeUseCount[lock.id][i as String] = (state.codeUseCount[lock.id][i as String] ?: 0) + 1 + } + if (notify && ( + (notifyModes ? notifyModes?.find{it == location.mode} : true) && + (notifyXPresence ? notifyXPresence.every{it.currentPresence != "present"} : true) + ) && ( + !i || (notifyCount ? (state.codeUseCount[lock.id][i as String] <= notifyCount) : true) + )) { + sendNotifications(msg, (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "") + } + } +} + +def processLockEvent(evt) { + def data = null + def lock = locks.find { it.id == evt.lockId } + + log.trace "Processing $lock lock event: $evt" + + def msgs = [] // Message to send + def user = "" // User slot used + def i = 0 // Slot used + + if (evt.data) { // Was it locked using a user code + data = parseJson(evt.data) + } + def lockMode = data?.type ?: (data?.method ?: (evt.descriptionText?.contains("manually") ? "manually" : "electronically")) + // Fix for proper grammar and additional lock types mapping + switch (lockMode) { + case "manual": + lockMode = "manually" + break + + case "rfid": + lockMode = "via RFID" + break + + case "bluetooth": + lockMode = "via bluetooth" + break + + case "keypad": + lockMode = "via keypad" + break + + case "remote": + case "command": + lockMode = "remotely" + break + + case "auto": + lockMode = "via internal autolock" + break + + default: + break + } + + evt.lockMode = lockMode // Save the lockMode calculated + evt.data = data // Update the data to be passed + user = (data?.usedCode as String) ?: ((data?.codeId as String) ?: "") // get the user if present + i = ((data?.usedCode ?: 0) as Integer) ?: (((data?.codeId ?: 0) as Integer) ?: 0) // get the user if present + log.trace "$lock locked by user $user $lockMode" + + if ((["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) }) || user) { // LOCKED VIA KEYPAD/RFID + def name, notify, notifyCount, notifyModes, notifyXPresence, extLockNotify, extLockNotifyModes, userOverrideActions + + if (user) { + if (i == 0) { + name = "Master Code" // Special case locks like Yale have a master code which isn't programmable and is code 0 + notify = true // always inform about master users + user = "" // Master code uses general actions + } else { + name = settings."userNames${i}" ?: "Unknown user" // Should have a name for the user otherwise it's unknown + notify = settings."userNotify${i}" + notifyCount = settings."userNotifyUseCount${i}" + notifyModes = settings."userNotifyModes${i}" + notifyXPresence = settings."userXNotifyPresence${i}" + userOverrideActions = settings."userOverrideUnlockActions${i}" + + // Check if we have user override lock actions defined + if (!userOverrideActions) { + log.trace "No user $name specific lock action found, falling back to general actions" + user = "" // We don't have a user specific action defined, fall back to general actions + } + } + } else { + log.trace "No usercode found in extended data for external user lock" + } + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions${user}") { + lockStr = lock as String + } else { + lockStr = "" + } + + // Check if we have a delayed action and process accordingly + if (settings."delayLockActionsTime${lockStr}${user}") { + extLockNotify = settings."externalLockNotify${lockStr}" + extLockNotifyModes = settings."externalLockNotifyModes${lockStr}" + + def msg = "$evt.displayName was locked ${name ? "by " + name + " " : ""}$lockMode, checking for actions in ${settings."delayLockActionsTime${lockStr}${user}"} minutes" // Default message to send + log.debug msg + if ((notify && ( + (notifyModes ? notifyModes?.find{it == location.mode} : true) && + (notifyXPresence ? notifyXPresence.every{it.currentPresence != "present"} : true) + ) && ( + !i || (notifyCount ? (state.codeUseCount[lock.id][i as String] <= notifyCount) : true) + )) || + (!i && extLockNotify && (extLockNotifyModes ? extLockNotifyModes.find{it == location.mode} : true))) { + msgs << msg + } + evt.sendNotifications = true // Since it's delayed we request notifications be sent + // If this came from a keypad and direct control is enabled, then start an exit code beep for all keypads with direct control + if (data?.armMode) { + if (settings."keypadArmDisarm${lock}${""}") { // If this keypad has direct control enabled + def keypads = locks?.findAll{ it.hasAttribute("armMode") } // Get all keypads + // We don't check for individual user custom actions for keypads since synchronization needs to happen at the keypad level + keypads = (settings."individualDoorActions${""}" ? keypads.findAll { keypad -> (settings."keypadArmDisarm${keypad}${""}") } : (settings."keypadArmDisarm${""}${""}" ? keypads : null)) // Get keypads with direct control enabled + if (keypads) { + log.trace "Direct keypad controls enabled, starting exit delay beeping for $keypads" + keypads*.setExitDelay(settings."delayLockActionsTime${lockStr}${user}" * 60) // Start exit delay beeping for delayed actions with direct control enabled + } + } + } + startTimer(settings."delayLockActionsTime${lockStr}${user}" * 60, processLockActions, evt) + } else { + msgs += processLockActions(evt) // Take the message back to send out + } + } else { // MANUAL LOCK + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions") { + lockStr = lock as String + } else { + lockStr = "" + } + + // Check if we have a delayed action and process accordingly + if (settings."delayLockActionsTimeManual${lockStr}") { + def msg = "$evt.displayName was locked $lockMode, checking for actions in ${settings."delayLockActionsTimeManual${lockStr}"} minutes" // Default message to send + log.debug msg + if (settings."lockNotify${lockStr}" && (!(["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) })) && (settings."lockNotifyModes${lockStr}" ? settings."lockNotifyModes${lockStr}".find{it == location.mode} : true)) { + msgs << msg + } + evt.sendNotifications = true // Since it's delayed we request notifications be sent + startTimer(settings."delayLockActionsTimeManual${lockStr}" * 60, processLockActions, evt) + } else { + msgs += processLockActions(evt) // Take the message back to send out + } + } + + // Check if we need to retract a deadbolt lock it was locked while the door was still open + /*if (settings."retractDeadbolt${lock}") { // SECURITY ISSUE - DISABLE + def sensor = settings."sensor${lock}" + if (sensor.latestValue("contact") == "open") { + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { // Do not unlock if autolock features on the lock are enabled, avoid infinite loop + def msg = "Disable AutoLock on $lock lock to avoid an infinite locking/unlocking loop while using the 'Unlock on door open' feature" + log.warn msg + msgs << msg + } else { + log.debug "$lock was locked while the door was still open, unlocking it in 10 seconds" + def unlocks = atomicState.unLocks ?: [] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + if (!unlocks.contains(lock.id)) { // Don't re add the same lock again + //log.trace "Adding ${lock.id} to the list of unlocks" + unlocks.add(lock.id) // Atomic to ensure we get upto date info here + atomicState.unLocks = unlocks // Set it back, we can't work direct on atomicState + } + startTimer(10, unLockDoor) // Schedule the unlock in 10 seconds since the door may have just locked and avoid Z-Wave conflict and some locks like Schlage deadbolt have timing limitations which cause a busy conflict if done too soon + } + } else { + log.trace "$lock was locked while the door was closed, we're good" + } + }*/ + + // Last thing to do because it can timeout + for (msg in msgs) { + sendNotifications(msg, (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "") + } +} + +def processLockActions(evt) { + def data = evt.data + def lock = locks.find { it.id == evt.lockId } + def msgs = [] // Message to send + def lockMode = evt.lockMode + def arm = "" // Security keypad arm mode (optional) + def user = (data?.usedCode as String) ?: ((data?.codeId as String) ?: "") // get the user if present + def i = ((data?.usedCode ?: 0) as Integer) ?: (((data?.codeId ?: 0) as Integer) ?: 0) // get the user if present + + log.trace "Processing $lock lock actions: $evt" + + if ((["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) }) || user) { // LOCKED VIA KEYPAD/RFID + def name, notify, notifyCount, notifyModes, notifyXPresence, extLockNotify, extLockNotifyModes, userOverrideActions + + if (user) { + if (i == 0) { + name = "Master Code" // Special case locks like Yale have a master code which isn't programmable and is code 0 + notify = true // always inform about master users + user = "" // Master code uses general actions + } else { + name = settings."userNames${i}" ?: "Unknown user" // Should have a name for the user otherwise it's unknown + notify = settings."userNotify${i}" + notifyCount = settings."userNotifyUseCount${i}" + notifyModes = settings."userNotifyModes${i}" + notifyXPresence = settings."userXNotifyPresence${i}" + userOverrideActions = settings."userOverrideUnlockActions${i}" + + // Check if we have user override lock actions defined + if (!userOverrideActions) { + log.trace "No user $name specific lock action found, falling back to general actions" + user = "" // We don't have a user specific action defined, fall back to general actions + } + } + } else { + log.trace "No usercode found in extended data for external user lock" + } + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions${user}") { + lockStr = lock as String + } else { + lockStr = "" + } + + extLockNotify = settings."externalLockNotify${lockStr}" + extLockNotifyModes = settings."externalLockNotifyModes${lockStr}" + + log.trace "Lock $evt.displayName locked by $name, user notify $notify, notify count: $notifyCount, user notify modes $notifyModes, notify NOT present $notifyXPresence, external notify $extLockNotify, external notify modes $extLockNotifyModes, user override action $userOverrideActions, Source type: $lockMode" + + def msg = evt.sendNotifications ? "Completing check for lock actions for $evt.displayName" : "$evt.displayName was locked ${name ? "by " + name + " " : ""}$lockMode" // Default message to send + + if (settings."runXPeopleLockActions${lockStr}${user}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleLockActions${lockStr}${user}"?.find{it.currentPresence == "present"}} is present, not running lock actions for door $lock" + } else if (settings."runXModeLockActions${lockStr}${user}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running lock actions for door $lock" + } else { + // If we have a specific mode passed by the keypad lets use that otherwise use configured options + if (data?.armMode) { + switch (data.armMode) { // Check for custom keypad arm actions + case "armedStay": + if (settings."keypadArmActions${lockStr}${user}${"stay"}") { + log.debug "Running custom actions for keypad stay/partial button" + arm = "stay" + } + break + + case "armedNight": + if (settings."keypadArmActions${lockStr}${user}${"night"}") { + log.debug "Running custom actions for keypad night button" + arm = "night" + } + break + + case "armedAway": + if (settings."keypadArmActions${lockStr}${user}${"away"}") { + log.debug "Running custom actions for keypad away/on button" + arm = "away" + } + break + + default: + log.warn "Invalid keypad Arm mode detected: ${data.armMode}" + msg += ", invalid keypad Arm mode ${data.armMode}" + break + } + } + + if ((settings."keypadArmDisarm${lockStr}${user}") && data?.armMode) { + switch (data.armMode) { // Set Keypad lock state + case "armedStay": + case "armedNight": + /*log.info "Arming Smart Home Monitor to Stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") // Sync SHM + msg += detailedNotifications ? ", Arming Smart Home Monitor to Stay" : ""*/ + try { + if (settings."adtDevices") { + log.info "Arming ADT to Stay" + settings."adtDevices"?.armStay('armedStay') + msg += detailedNotifications ? ", Arming ADT to Stay" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT to Stay\n$e" + msg += ", error arming ADT to Stay" + } + break + + case "armedAway": + /*log.info "Arming Smart Home Monitor to Away" + sendLocationEvent(name: "alarmSystemStatus", value: "away") // Sync SHM + msg += detailedNotifications ? ", Arming Smart Home Monitor to Away" : ""*/ + try { + if (settings."adtDevices") { + log.info "Arming ADT to Away" + settings."adtDevices"?.armAway('armedAway') + msg += detailedNotifications ? ", Arming ADT to Away" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT to Away\n$e" + msg += ", error arming ADT to Away" + } + break + + default: + log.warn "Invalid keypad mode detected: ${data.armMode}" + msg += ", invalid keypad mode ${data.armMode}" + break + } + } else { + /*if (settings."homeArm${lockStr}${user}") { // Sync SHM + if (settings."homeArmStay${lockStr}${user}") { + log.info "Arming Smart Home Monitor to Stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Stay" : "" + } else { + log.info "Arming Smart Home Monitor to Away" + sendLocationEvent(name: "alarmSystemStatus", value: "away") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Away" : "" + } + }*/ + + try { + if (settings."adtArm${lockStr}${user}" && settings."adtDevices") { + if (settings."homeArmStay${lockStr}${user}") { + log.info "Arming ADT to Stay" + settings."adtDevices"?.armStay('armedStay') + msg += detailedNotifications ? ", Arming ADT to Stay" : "" + } else { + log.info "Arming ADT to Away" + settings."adtDevices"?.armAway('armedAway') + msg += detailedNotifications ? ", Arming ADT to Away" : "" + } + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT\n$e" + msg += ", error arming ADT" + } + } + + if (settings."externalLockMode${lockStr}${user}${arm}") { + log.info "Changing mode to ${settings."externalLockMode${lockStr}${user}${arm}"}" + if (location.modes?.find{it.name == settings."externalLockMode${lockStr}${user}${arm}"}) { + setLocationMode(settings."externalLockMode${lockStr}${user}${arm}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."externalLockMode${lockStr}${user}${arm}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."externalLockMode${lockStr}${user}${arm}"}" : "" + } + + /*if (settings."externalLockPhrase${lockStr}${user}${arm}" && location.helloHome?.getPhrases()) { + log.info "$evt.displayName was locked successfully, running routine ${settings."externalLockPhrase${lockStr}${user}${arm}"}" + location.helloHome.execute(settings."externalLockPhrase${lockStr}${user}${arm}") + msg += detailedNotifications ? ", running ${settings."externalLockPhrase${lockStr}${user}${arm}"}" : "" + }*/ + + if (settings."externalLockTurnOnSwitches${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, turning on switches ${settings."externalLockTurnOnSwitches${lockStr}${user}${arm}"}" + settings."externalLockTurnOnSwitches${lockStr}${user}${arm}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."externalLockTurnOnSwitches${lockStr}${user}${arm}"}" : "" + } + + if (settings."externalLockTurnOffSwitches${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, turning off switches ${settings."externalLockTurnOffSwitches${lockStr}${user}${arm}"}" + settings."externalLockTurnOffSwitches${lockStr}${user}${arm}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."externalLockTurnOffSwitches${lockStr}${user}${arm}"}" : "" + } + + if (settings."externalLockToggleSwitches${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, toggling switches ${settings."externalLockToggleSwitches${lockStr}${user}${arm}"}" + settings."externalLockToggleSwitches${lockStr}${user}${arm}".each { dev -> + dev.currentValue("switch") == "on" ? dev?.off() : dev?.on() + } + msg += detailedNotifications ? ", toggling switches ${settings."externalLockToggleSwitches${lockStr}${user}${arm}"}" : "" + } + + if (settings."lockLocks${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, locking ${settings."lockLocks${lockStr}${user}${arm}"}" + settings."lockLocks${lockStr}${user}${arm}"?.lock() + msg += detailedNotifications ? ", locking ${settings."lockLocks${lockStr}${user}${arm}"}" : "" + } + + if (settings."closeGarage${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, closing garage ${settings."closeGarage${lockStr}${user}${arm}"}" + settings."closeGarage${lockStr}${user}${arm}"?.close() + msg += detailedNotifications ? ", closing garage ${settings."closeGarage${lockStr}${user}${arm}"}" : "" + } + } + + // Send a notification if required (message would be updated) + if (i) { // If we have a known user, increment the usage count + state.codeUseCount[lock.id][i as String] = (state.codeUseCount[lock.id][i as String] ?: 0) + 1 + } + if ((notify && ( + (notifyModes ? notifyModes?.find{it == location.mode} : true) && + (notifyXPresence ? notifyXPresence.every{it.currentPresence != "present"} : true) + ) && ( + !i || (notifyCount ? (state.codeUseCount[lock.id][i as String] <= notifyCount) : true) + )) || + (!i && extLockNotify && (extLockNotifyModes ? extLockNotifyModes.find{it == location.mode} : true))) { + msgs << msg + } + } else { // MANUAL LOCK + log.trace "Lock $evt.displayName locked manually, Source type: $lockMode" + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions") { + lockStr = lock as String + } else { + lockStr = "" + } + + def msg = evt.sendNotifications ? "Completing check for lock actions for $evt.displayName" : "$evt.displayName was locked $lockMode" // Default message to send + + if (settings."runXPeopleLockActionsManual${lockStr}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleLockActionsManual${lockStr}"?.find{it.currentPresence == "present"}} is present, not running lock actions for door $lock" + } else if (settings."runXModeLockActionsManual${lockStr}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running lock actions for door $lock" + } else { + /*if (settings."homeArmManual${lockStr}") { // Sync SHM + log.info "Arming Smart Home Monitor to Stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Stay" : "" + }*/ + + try { + if (settings."adtArmManual${lockStr}" && settings."adtDevices") { + if (settings."homeArmAwayManual${lockStr}") { + log.info "Arming ADT to Away" + settings."adtDevices"?.armAway('armedAway') + msg += detailedNotifications ? ", Arming ADT to Away" : "" + } else { + log.info "Arming ADT to Stay" + settings."adtDevices"?.armStay('armedStay') + msg += detailedNotifications ? ", Arming ADT to Stay" : "" + } + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT to Stay\n$e" + msg += ", error arming ADT to Stay" + } + + if (settings."externalLockModeManual${lockStr}") { + log.info "Changing mode to ${settings."externalLockModeManual${lockStr}"}" + if (location.modes?.find{it.name == settings."externalLockModeManual${lockStr}"}) { + setLocationMode(settings."externalLockModeManual${lockStr}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."externalLockModeManual${lockStr}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."externalLockModeManual${lockStr}"}" : "" + } + + /*if (settings."externalLockPhraseManual${lockStr}" && location.helloHome?.getPhrases()) { + log.info "$evt.displayName was locked successfully, running routine ${settings."externalLockPhraseManual${lockStr}"}" + location.helloHome.execute(settings."externalLockPhraseManual${lockStr}") + msg += detailedNotifications ? ", running ${settings."externalLockPhraseManual${lockStr}"}" : "" + }*/ + + if (settings."externalLockTurnOnSwitchesManual${lockStr}") { + log.info "$evt.displayName was locked successfully, turning on switches ${settings."externalLockTurnOnSwitchesManual${lockStr}"}" + settings."externalLockTurnOnSwitchesManual${lockStr}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."externalLockTurnOnSwitchesManual${lockStr}"}" : "" + } + + if (settings."externalLockTurnOffSwitchesManual${lockStr}") { + log.info "$evt.displayName was locked successfully, turning off switches ${settings."externalLockTurnOffSwitchesManual${lockStr}"}" + settings."externalLockTurnOffSwitchesManual${lockStr}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."externalLockTurnOffSwitchesManual${lockStr}"}" : "" + } + + if (settings."lockLocksManual${lockStr}") { + log.info "$evt.displayName was locked successfully, locking ${settings."lockLocksManual${lockStr}"}" + settings."lockLocksManual${lockStr}"?.lock() + msg += detailedNotifications ? ", locking ${settings."lockLocksManual${lockStr}"}" : "" + } + + if (settings."closeGarageManual${lockStr}") { + log.info "$evt.displayName was locked successfully, closing garage ${settings."closeGarageManual${lockStr}"}" + settings."closeGarageManual${lockStr}"?.close() + msg += detailedNotifications ? ", closing garage ${settings."closeGarageManual${lockStr}"}" : "" + } + } + + // Send notitications for manual and electronic locking only, keypad is handled above with lock actions + if (settings."lockNotify${lockStr}" && (!(["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) })) && (settings."lockNotifyModes${lockStr}" ? settings."lockNotifyModes${lockStr}".find{it == location.mode} : true)) { + msgs << msg + } + } + + // Check if we are asked to send the notifications or return them back + if (evt.sendNotifications) { + // Last thing to do because it can timeout + for (msg1 in msgs) { + sendNotifications(msg1, (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "") + } + } else { + return msgs + } +} + +def clearAllCodes() { + log.trace "Clearing codes from locks" + + def msgs = [] + + if ((maxUserNames != null) && ((maxUserNames as Integer) > 0)) { // Clear all users + log.debug "Clearing user slots 1 to ${maxUserNames}" + startTimer(1, removeUsersOffline, [ data : [start: 1, end: (maxUserNames as Integer)] ]) // Clear the slots + + for (lock in locks) { + def msg = "Marking first ${maxUserNames} users to be cleared from ${lock}" + def user = 1 + while (user <= maxUserNames) { + // ST can't clear too many codes at once, so lets mark the previous code as populated so the app will clear it eventually + state.lockCodes[lock.id][user as String] = "1" // Indicate (special) previous code so the app will clear it later + user++ + } + + //log.trace msg + //msgs << msg + } + + def totalClearingTime = ((((locks?.size() ?: 0) * (maxUserNames as Integer) * (sendDelay ?: 5)) / 60) as Integer) + 1 + if (totalClearingTime) { + def msg = "${app.label} may take about ${totalClearingTime} minutes to clear the users from the locks" + log.debug msg + msgs << msg + } + } // Clear excess users offline so it doesn't slow down the UI (do it while reducing users so that when you increase the slots are already cleared) + + deleteSetting("clearUserCodes") // We're done with clearing - reset it + + startTimer(1, kickStart) // It takes the lock about 15 seconds to clear the codes and finish up pending commands + + // Last thing to do since it could timeout + for (msg in msgs) { + //log.trace msg + sendNotifications(msg) + } +} + +def codeCheck() { + // Check if the user has upgraded the SmartApp and reinitailize if required + if (state.clientVersion && (state.clientVersion != clientVersion())) { // Check for platform outage (null) + def msg = "NOTE: ${app.label} detected a code upgrade. Updating configuration, please open the app and re-validate your settings" + log.warn msg + startTimer(1, appTouch) // Reinitialize the app offline to avoid a loop as appTouch calls codeCheck + //sendPush msg // Do this in the end as it may timeout + return + } + + log.warn "READ THIS BEFORE PROCCEDING: IT IS NORMAL TO SEE DEBUG MESSAGES EVERY MINUTE, IT CONFIRMS THAT THE APP IS HEALTHY AND RUNNING IN THE CLOUD. IT DOES NOT COMMUNICATE WITH THE LOCK UNLESS YOU SEE A MESSAGE BOX SAYING 'REQUESTED LOCK TO XXXXX'." + + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + def msg = "Hub geolocation not set, using ${timeZone.getDisplayName()} timezone. Use the SmartThings app to set the Hub geolocation to identify the correct timezone." + log.error msg + sendPush msg + } + + log.trace "The date/time on the hub now is ${(new Date(now())).format("EEE MMM dd yyyy HH:mm z", timeZone)}" + + // Hack for broken ST timers - Schedule the Heartbeat + if (((state.lastHeartBeat ?: 0) + ((10+5)*60*1000) < now()) && canSchedule()) { // Since we are scheduling the heartbeat every 10 minutes, give it a 5 minute grace + log.warn "Heartbeat not called in last 15 minutes, rescheduling heartbeat" + schedule("* */10 * * * ?", heartBeatMonitor) // run the heartbeat every 10 minutes + state.lastHeartBeat = now() // give it 10 minutes before you schedule it again + } + + // Update the last time we can code check + state.lastCheck = now() + + for (lock in locks) { + if ((state.lockCodes == null) || (state.lockCodes[lock.id] == null) || (state.retryCodeCount[lock.id] == null)) { // If we have a situation where the user added a new lock without tapping save reinitialize the app + def msg = "${app.label} detected an unsaved configuration change. Reinitializing the app, please open the app and re-validate your settings" + log.warn msg + startTimer(1, appTouch) // Reinitialize the app offline to avoid a loop as appTouch calls codeCheck + return // We're done here + } + + if (state.expiredLockList.contains(lock.id)) { // this lock codes hasn't been completely initiated + //log.trace "If you're seeing this every few minutes, then ST is alive and kicking - ST cloud codes status for $lock" + while (state.expiredNextCode <= maxUserNames) { // cycle through all the codes + //log.trace "ST Cloud status for code $state.expiredNextCode on $lock" + def i = state.expiredNextCode + def name = settings."userNames${i}"?.trim() // Get the name for the slot and clear and leading or trailing spaces + def code = settings."userCodes${i}" as String // Get the code for the slot + def userType = settings."userType${i}" // User type + def expDate = settings."userExpireDate${i}" // Get the expiration date + def expTime = settings."userExpireTime${i}" // Get the expiration time + def startDate = settings."userStartDate${i}" // Get the start date + def startTime = settings."userStartTime${i}" // Get the start time + def userPresent = settings."userPresent${i}" // Get user presence + def userNotPresent = settings."userNotPresent${i}" // Get user not presence + def userModes = settings."userModes${i}" // Get user modes + def userLocks = (locks?.size() > 1) ? (settings."userLocks${i}" ?: locks*.id) : locks*.id // If not defined or only one lock then check all locks + def user = i as Integer // which user slot are we using, convert to integer to be sure + def msg = "" + def extraNotifications = detailedNotifications + + //log.trace "CodeCheck $i, Name: $name, Code: $code, UserType: $userType, ExpireDate: $expDate, ExpireTime: $expTime, StartDate: $startDate, StartTime: $startTime, Present: $userPresent, Not Present: $userNotPresent, UserModes: $userModes, Locks: $userLocks" + + // Check if we have more than one lock and use has not selected this lock for programming then delete it + if (!userLocks?.contains(lock.id)) { + if (state.lockCodes[lock.id].(user as String)) { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete unconfigured user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user already unconfigured" + } + } else { + // Check code type + switch (userType) { + case 'Expire on': + if (code != null) { + def doAdd = false + if (expDate && expTime) { + try { + // Parse the entire date/time including timezone since the Date object is converted and stored in UTC internally + def exp = Date.parse("yyyy-MM-ddHH:mmZ", expDate + timeToday(expTime, timeZone).format("HH:mmZ", timeZone)) + def expStr = exp.format("EEE MMM dd yyyy HH:mm z", timeZone) + if (exp.getTime() > now()) { + if (startDate && startTime) { + try { + def start = Date.parse("yyyy-MM-ddHH:mmZ", startDate + timeToday(startTime, timeZone).format("HH:mmZ", timeZone)) + def startStr = start.format("EEE MMM dd yyyy HH:mm z", timeZone) + if (start.getTime() <= now()) { + msg = "Requesting $lock to add $name to user $user, code: $code, because it is scheduled to start at $startStr and expire on $expStr" + doAdd = true // we need to add the code + //log.trace "$lock User $user $name is scheduled to start at $startStr and expire on $expStr" + } else { + msg = "Requesting $lock to delete future user $user $name, start on $startStr" + //log.trace "$lock user $user $name's code is set to start in future on $startStr" + } + } catch (Exception e) { + log.error "User $user $name set to Start but does not have a valid Start Date: $startDate" + } + } else if (startDate && !startTime) { + log.error "User $user $name set to Start but does not have a valid Start Date/Time: $startDate or Time: $startTime" + } else { + msg = "Requesting $lock to add $name to user $user, code: $code, it is set to expire on $expStr" + doAdd = true // we need to add the code + //log.trace "$lock User $user $name is set to expire on $expStr" + } + } else { + msg = "Requesting $lock to delete expired user $user $name, expired on $expStr" + } + } catch (Exception e) { + log.error "User $user $name set to Expire but does not have a valid Expiry Date: $expDate or Time: $expTime" + } + } else { + log.error "$lock User $user $name set to Expire but does not have a Expiration Date: $expDate or Time: $expTime" + } + + if (doAdd) { + if (state.lockCodes[lock.id].(user as String) != code) { // Only if code has changed + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} addition not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + setCode(lock, user, code, name) + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + if (getCodeName(lock, user) && (getCodeName(lock, user) != name)) { // If the username has changed update it, if it's empty ignore it + updateCodeName(lock, user, name) + } + log.debug "$lock User $user $name is already active" + } + } else { + if (state.lockCodes[lock.id].(user as String)) { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = msg ?: "Requesting $lock to delete user invalid $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock User $user $name is already deleted" + } + } + } else if (state.lockCodes[lock.id].(user as String)) { // Code is null but the list shows programmed, i.e. we were asked to explicit send a delete command to the lock + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user already deleted" + } + break + + case 'One time': + if (code != null) { + if (state.usedOneTimeCodes[lock.id].contains(user as String)) { + if (!state.trackUsedOneTimeCodes.contains(user as String)) { + state.trackUsedOneTimeCodes.add(user as String) // track it for reporting purposes + } + + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete one time user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else if (!state.trackUsedOneTimeCodes.contains(user as String)) { // If it's not been used add it to the lock + if (state.lockCodes[lock.id].(user as String) != code) { // Only if code has changed + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} addition not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + setCode(lock, user, code, name) + msg = "Requesting $lock to add one time user $user ${name ?: ""}, code: $code" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + if (getCodeName(lock, user) && (getCodeName(lock, user) != name)) { // If the username has changed update it, if it's empty ignore it + updateCodeName(lock, user, name) + } + log.debug "$lock User $user $name is a one time code but it has not been used yet" + } + } else { + log.debug "$lock one time user $user $name is already used" + } + } else if (state.lockCodes[lock.id].(user as String)) { // Code is null but the list shows programmed, i.e. we were asked to explicit send a delete command to the lock + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user already deleted" + } + break + + case 'Scheduled': + if (code != null) { + def doAdd = false + + schedulesSuffix.each { schedule -> + if (checkSchedule(i, schedule)) { // Check if we are within operating schedule + doAdd = true + msg = "Schedule $schedule active $lock to add $name to user $user, code: $code, because it is scheduled to work between ${settings."userDayOfWeek${schedule}${i}"}: ${settings."userStartTime${schedule}${i}" ? timeToday(settings."userStartTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : ""} to ${settings."userEndTime${schedule}${i}" ? timeToday(settings."userEndTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : ""}" + log.trace msg + } else { + msg = "Schedule $schedule NOT active for $lock $name user $user, scheduled to work between ${settings."userDayOfWeek${schedule}${i}"}: ${settings."userStartTime${schedule}${i}" ? timeToday(settings."userStartTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : ""} to ${settings."userEndTime${schedule}${i}" ? timeToday(settings."userEndTime${schedule}${i}", timeZone).format("HH:mm z", timeZone) : ""}" + log.trace msg + } + } + + if (doAdd) { + if (state.lockCodes[lock.id].(user as String) == code) { // If code hasn't changed, don't add it + if (getCodeName(lock, user) && (getCodeName(lock, user) != name)) { // If the username has changed update it, if it's empty ignore it + updateCodeName(lock, user, name) + } + log.debug "$lock scheduled user $user $name is already active, not adding again" + } else { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} addition not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + setCode(lock, user, code, name) + msg = "Requesting $lock to add active scheduled user $user ${name ?: ""}, code: $code" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } + } else { // Outside operating schedule + if (!state.lockCodes[lock.id].(user as String)) { + log.debug "$lock scheduled user $user $name is already inactive, not removing again" + } else { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete inactive scheduled user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } + } + } else if (state.lockCodes[lock.id].(user as String)) { // Code is null but the list shows programmed, i.e. we were asked to explicit send a delete command to the lock + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user already deleted" + } + break + + case 'Permanent': + if (code != null) { + if (state.lockCodes[lock.id].(user as String) != code) { // Only if code has changed + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} addition not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + setCode(lock, user, code, name) + msg = "Requesting $lock to add permanent user $user ${name ?: ""}, code: $code" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + if (getCodeName(lock, user) && (getCodeName(lock, user) != name)) { // If the username has changed update it, if it's empty ignore it + updateCodeName(lock, user, name) + } + log.debug "$lock User $user $name is a permanent code and is already active" + } + } else if (state.lockCodes[lock.id].(user as String)) { // Code is null but the list shows programmed, i.e. we were asked to explicit send a delete command to the lock + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user already deleted" + } + break + + case 'Presence': + if (code != null) { + def doAdd = false + + // Any of the 'present' users AND none of the 'not present' users are there then the code is active + if ((userPresent || userNotPresent) && // Atleast one condition is specified + (userPresent ? userPresent.any{it.currentPresence == "present"} : true) && + (userNotPresent ? userNotPresent.every{it.currentPresence != "present"} : true) + ) { + doAdd = true // the code + msg = "$lock user $user $name is being added because ${userPresent ? "${userPresent.findAll{it.currentPresence == "present"}} are present" : (userNotPresent ? "${userNotPresent} are not present" : "")}" + log.debug msg + } else { + msg = "$lock user $user $name is being deleted because${(userPresent || userNotPresent) ? (userPresent.every{it.currentPresence != "present"} ? " ${userPresent} are not present" : (userNotPresent ? " ${userNotPresent.findAll{it.currentPresence == "present"}} are present" : "")) : " no user presence is defined"}" + log.debug msg + } + + if (doAdd) { + if (state.lockCodes[lock.id].(user as String) == code) { // If code hasn't changed, don't add it + if (getCodeName(lock, user) && (getCodeName(lock, user) != name)) { // If the username has changed update it, if it's empty ignore it + updateCodeName(lock, user, name) + } + log.debug "$lock presence user $user $name is already active, not adding again" + } else { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} addition not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + setCode(lock, user, code, name) + msg = "Requesting $lock to add presence based user $user ${name ?: ""}, code: $code" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } + } else { // Presence conditions not satisfied + if (!state.lockCodes[lock.id].(user as String)) { + log.debug "$lock presence user $user $name is already inactive, not removing again" + } else { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete presence based user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } + } + } + break + + case 'Modes': + if (code != null) { + def doAdd = false + + // Any of the selected modes are active then activate the codes + if (userModes?.find{it == location.mode}) { + doAdd = true // the code + msg = "$lock user $user $name is being added because mode ${location.mode} is active" + log.debug msg + } else { + msg = "$lock user $user $name is not being deleted because mode ${location.mode} is not in the selected modes" + log.debug msg + } + + if (doAdd) { + if (state.lockCodes[lock.id].(user as String) == code) { // If code hasn't changed, don't add it + if (getCodeName(lock, user) && (getCodeName(lock, user) != name)) { // If the username has changed update it, if it's empty ignore it + updateCodeName(lock, user, name) + } + log.debug "$lock mode user $user $name is already active, not adding again" + } else { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} addition not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + setCode(lock, user, code, name) + msg = "Requesting $lock to add mode based user $user ${name ?: ""}, code: $code" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } + } else { // Mode conditions not satisfied + if (!state.lockCodes[lock.id].(user as String)) { + log.debug "$lock mode user $user $name is already inactive, not removing again" + } else { + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete mode based user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } + } + } + break + + case 'Inactive': + if (state.lockCodes[lock.id].(user as String)) { // Delete the code is hasn't been deleted + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete inactive user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user already inactive" + } + break + + default: // No user type selected, it's empty delete code + if (state.lockCodes[lock.id].(user as String)) { // Delete the code is hasn't been deleted + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete empty user $user ${name ?: ""}" + log.debug msg + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return // We are done here, exit out as we've scheduled the next update + } else { + log.debug "$lock ${name ?: ""} user $user is empty, code already deleted" + } + break + } + } + + state.expiredNextCode = state.expiredNextCode + 1 // move onto the next code + } + + // We're done with all the programmed codes for this lock, check of see if any excess codes are left behind from a change in number of user slots and clean them up + if (state.lockCodes[lock.id]?.any { i, code -> + def msg = "" + def extraNotifications = detailedNotifications + def user = i as Integer + if (user > maxUserNames) { // This is an excess code, clean it up + if ((state.retryCodeCount[lock.id][user as String] = (state.retryCodeCount[lock.id][user as String] ?: 0) + 1) > (maxRetries + 1)) { + msg = "Retry programming exceeded, user $user ${name ?: ""} deletion not confirmed by lock $lock" + log.warn msg + if (state.retryCodeCount[lock.id][user as String] == (maxRetries + 2)) { // Only process it once until reset + extraNotifications = true // We need to inform the user + } else { + state.retryCodeCount[lock.id][user as String] = (maxRetries + 3) // Fix it so when maxRetries changes, it'll pick it up + msg = "" // Don't message endlessly + } + } else { + deleteCode(lock, user) + msg = "Requesting $lock to delete excess user $user ${name ?: ""}" + log.debug msg + } + + //log.trace "Scheduled next code check in ${sendDelay ?: defaultSendDelay} seconds" + startTimer((sendDelay ?: defaultSendDelay), codeCheck) // schedule the next code update after a few seconds otherwise it overloads locks and doesn't work + + // Last thing to do since it could timeout + (extraNotifications && msg) ? sendNotifications(msg) : sendNotificationEvent(msg) + return true// We are done here, exit out as we've scheduled the next update + } + }) { // If had a match then exit as we've scheduled the next iteration + return // We're done here + } + + state.expiredLockList.remove(lock.id) // we are done with this lock + state.expiredNextCode = 1 // reset back to 1 for the next lock + //log.trace "$lock id $lock.id code check complete, unprocessed locks ${state.expiredLockList}, reset next code update to $state.expiredNextCode" + } + } + + // All done now reset the lock list and add the locks back for next check cycle + state.expiredNextCode = 1 // reset back to 1 for the next lock + for (lock in locks) { + state.expiredLockList.add(lock.id) // reset the state for each lock to be processed + //log.trace "Added $lock id ${lock.id} back to unprocessed locks list ${state.expiredLockList}" + } +} + +// Sets the code on the lock and also updates the username +private setCode(lock, user, code, name) { + !(lock.hasAttribute("pinLength") || lock.hasCommand("deleteAllCodes")) ? lock.setCode(user, code, name) : lock.setCode(user, code) // Keep support for older device handlers +} + +// Deletes a code from the lock +private deleteCode(lock, user) { + lock.deleteCode(user) +} + +// Update the name on the lock for a user on a lock +private updateCodeName(lock, user, name) { + if (!(lock.hasAttribute("pinLength") || lock.hasCommand("deleteAllCodes"))) { // Older devices don't have this option + log.info "Updating user $user name to $name on $lock" + lock.nameSlot(user, name) + } +} + +// Gets the name of the code on the lock +private getCodeName(lock, user) { + def name = !(lock.hasAttribute("pinLength") || lock.hasCommand("deleteAllCodes")) ? lock.currentState("lockCodes")?.jsonValue?."$user"?.trim() : null // Older handlers don't support this + //log.trace "Got name $name from $lock for user $user" + return name +} + +// Kick start the code check routine +def kickStart() { + schedule("* */1 * * * ?", codeCheck) // run codeCheck every 1 minute + codeCheck() +} + +// Heartbeat system to ensure that the MonitorTask doesn't die when it's supposed to be running +def heartBeatMonitor() { + log.trace "Heartbeat monitor called" + + state.lastHeartBeat = now() // Save the last time we were executed + + log.trace "Last code check was done " + ((now() - (state.lastCheck ?: 0))/1000) + " seconds ago" + if (((state.lastCheck ?: 0) + (3*60*1000)) < now()) { // Kick start the motion detection monitor if didn't update for more than 3 minutes + log.warn "Code check hasn't been run a long time, calling it to kick start it" + kickStart() + } + + // We check for a code update everyday + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + def msg = "Hub geolocation not set, using ${timeZone.getDisplayName()} timezone. Use the SmartThings app to set the Hub geolocation to identify the correct timezone." + log.error msg + sendPush msg + } + if (now() >= state.nextCodeUpdateCheck) { + // Before checking for code update, calculate the next time we want to check + state.nextCodeUpdateCheck = (state.nextCodeUpdateCheck ?: now()) + (1*24*60*60*1000) // 1 day from now + log.info "Checking for next app update after ${(new Date(state.nextCodeUpdateCheck)).format("EEE MMM dd yyyy HH:mm z", timeZone)}" + + checkForCodeUpdate() // Check for code updates + } else { + log.trace "Checking for next app update after ${(new Date(state.nextCodeUpdateCheck)).format("EEE MMM dd yyyy HH:mm z", timeZone)}" + } +} + +def startTimer(seconds, function, dataMap = null) { + log.trace "Scheduled to run $function in $seconds seconds${dataMap ? " with data $dataMap" : ""}" + + //def runTime = new Date(now() + ((Long)seconds * 1000)) // for runOnce + //runOnce(runTime, function, [overwrite: true]) // runIn isn't reliable, runOnce is more reliable but isn't as accurate + if (dataMap) { + runIn(seconds, function, [overwrite: true, data: dataMap]) // runOnce is having issues with v2 hubs, hopefully runIn is more stable + } else { + runIn(seconds, function, [overwrite: true]) // runOnce is having issues with v2 hubs, hopefully runIn is more stable + } +} + +// Checks if we are within the current operating scheduled +// Inputs to the function are user (i) and schedule (x) (there can be multiple schedules) +// Preferences required in user input settings are: +// settings."userStartTime${x}${i}" - optional +// settings."userEndTime${x}${i}" - optional +// settings."userDayOfWeek${x}${i}" - required +private checkSchedule(def i, def x) { + log.trace "Checking operating schedule $x for user $i" + + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + def msg = "Hub geolocation not set, using ${timeZone.getDisplayName()} timezone. Use the SmartThings app to set the Hub geolocation to identify the correct timezone." + log.error msg + sendPush msg + } + + def doChange = false + Calendar localCalendar = Calendar.getInstance(timeZone) + int currentDayOfWeek = localCalendar.get(Calendar.DAY_OF_WEEK) + def currentDT = new Date(now()) + + // some debugging in order to make sure things are working correclty + log.trace "Current time: ${currentDT.format("EEE MMM dd yyyy HH:mm z", timeZone)}" + + // Check if we are within operating times + if (settings."userStartTime${x}${i}" && settings."userEndTime${x}${i}") { + def scheduledStart = timeToday(settings."userStartTime${x}${i}", timeZone) + def scheduledEnd = timeToday(settings."userEndTime${x}${i}", timeZone) + + if (scheduledEnd <= scheduledStart) { // End time is next day + def localHour = currentDT.getHours() + (int)(timeZone.getOffset(currentDT.getTime()) / 1000 / 60 / 60) + //log.trace "Local hour is $localHour" + if (( localHour >= 0) && (localHour < 12)) // If we between midnight and midday + { + log.trace "End time is before start time and we are past midnight, assuming start time is previous day" + scheduledStart = scheduledStart.previous() // Get the start time for yesterday + } else { + log.trace "End time is before start time and we are past midday, assuming end time is the next day" + scheduledEnd = scheduledEnd.next() // Get the end time for tomorrow + } + } + + log.trace "Operating Start ${scheduledStart.format("HH:mm z", timeZone)}, End ${scheduledEnd.format("HH:mm z", timeZone)}" + + if (currentDT < scheduledStart || currentDT > scheduledEnd) { + log.debug "Outside operating time schedule" + return false + } + } + + // Check the condition under which we want this to run now + // This set allows the most flexibility. + log.trace "Operating DOW(s): ${settings."userDayOfWeek${x}${i}"}" + + if(!settings."userDayOfWeek${x}${i}") { + log.debug "Day of week not specified for operating schedule $x for user $i" + return false + } else if(settings."userDayOfWeek${x}${i}".contains('All Week')) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Monday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.MONDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Tuesday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.TUESDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Wednesday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.WEDNESDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Thursday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.THURSDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Friday') || settings."userDayOfWeek${x}${i}".contains('Monday to Friday')) && currentDayOfWeek == Calendar.instance.FRIDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Saturday') || settings."userDayOfWeek${x}${i}".contains('Saturday & Sunday')) && currentDayOfWeek == Calendar.instance.SATURDAY) { + doChange = true + } else if((settings."userDayOfWeek${x}${i}".contains('Sunday') || settings."userDayOfWeek${x}${i}".contains('Saturday & Sunday')) && currentDayOfWeek == Calendar.instance.SUNDAY) { + doChange = true + } + + // If we have hit the condition to schedule this then lets do it + if(doChange == true){ + log.debug("Within operating schedule") + return true + } + else { + log.debug("Outside operating schedule") + return false + } +} + +private void sendText(number, message) { + if (number) { + def phones = number.replaceAll("[;,#]", "*").split("\\*") // Some users accidentally use ;,# instead of * and ST can't handle *,#+ in the number except for + at the beginning + for (phone in phones) { + try { + sendSms(phone, message) + } catch (Exception e) { + sendPush "Invalid phone number $phone" + } + } + } +} + +private void sendNotifications(message, user = "") { + if (!message) { + return + } + + if (location.contactBookEnabled) { + sendNotificationToContacts(message, settings."recipients${user}") + } else { + if (!settings."disableAllNotify${user}") { + sendPush message + } else { + sendNotificationEvent(message) + } + if (settings."sms${user}") { + sendText(settings."sms${user}", message) + } + } + + settings."audioDevices${user}"?.each { audioDevice -> // Play audio notifications + if (audioDevice.hasCommand("playText")) { // Check if it supports TTS + if (audioVolume) { // Only set volume if defined as it also resumes playback + audioDevice.playTextAndResume(message, audioVolume) + } else { + audioDevice.playText(message) + } + } else { + if (audioVolume) { // Only set volume if defined as it also resumes playback + audioDevice.playTrackAndResume(textToSpeech(message)?.uri, audioVolume) // No translations at this time + } else { + audioDevice.playTrack(textToSpeech(message)?.uri) // No translations at this time + } + } + } +} + +// Remove rental user settings called from a runIn offline +def removeUsersOffline(evt) { + log.trace "Offline removing settings users: ${(evt.data?.start)..(evt.data?.end)}" + removeUsers((evt.data?.start)..(evt.data?.end)) +} + +// Remove users settings in the app (array of users slot numbers) +private removeUsers(users) { + def map = [] // Consolidate and remove since it's faster with the DB otherwise it times out + + users?.each { user -> + //log.trace "Removing settings user: $user, code: $code, startDate: $startDate, startTime: $startTime, expDate: $expDate, expTime: $expTime" + map << "userNames${user}" // name + map << "userCodes${user}" // code + map << "userType${user}" // type + map << "userStartDate${user}" // start date + map << "userExpireDate${user}" // expire date + map << "userStartTime${user}" // start time + map << "userExpireTime${user}" // expire time + map << "userNotify${user}" // notifications for user + map << "userNotifyUseCount${user}" // how many notifications for user + map << "userLocks${user}" // user locks + map << "userOverrideUnlockActions${user}" // custom actions + map << "userOverrideNotifications${user}" // custom notifications + map << "userModes${user}" // User active modes + map << "userNotifyModes${user}" // User notify modes + schedulesSuffix.each { schedule -> // Weekly schedules + map << "userStartTime${schedule}${user}" // End time + map << "userEndTime${schedule}${user}" // Start time + map << "userDayOfWeek${schedule}${user}" // Set DOW + } + } + + if (map) { + deleteSettings(map) + } +} + +// Returns a list of users who have the same user code as the user +private getDuplicateCodeUsers(allUserCodes, i) { + allUserCodes.groupBy { it.value }.findAll { (it.key) && (it.value.size() > 1) && (it.value*.key).contains(i) }*.value*.keySet()?.flatten() - i +} + +// Override the user settings +// Update a single setting +private updateSetting(name, value) { + app.updateSetting(name, value) // For SmartApps UI - THIS IS A VERY SLOW TRANSACTION as it writes directly to the DB + settings[name] = value // For Device Handlers and SmartApps - much faster but only works on uninitialized value (once the user updates it this approach won't work) +} + +// Update multiple settings passed in a map +private updateSettings(map) { + app.updateSettings(map) + map.each { name, value -> // Force the DB to reload new values + settings[name] = value + } +} + +// Delete a single setting +private deleteSetting(name) { + //app.deleteSetting(name) // For SmartApps delete it, TODO: Gives and error - THIS IS A VERY SLOW TRANSACTION as it writes directly to the DB (don't mix app and settings approach or it causes corruption) + //settings.remove(name) // For Device Handlers + app.updateSetting(name, '') // For SmartApps - THIS IS A VERY SLOW TRANSACTION as it writes directly to the DB (don't mix app and settings approach or it causes corruption) + settings[name] = '' // For Device Handlers and SmartApps - much faster but only works on uninitialized value (once the user updates it this approach won't work) +} + +// Delete multiple settings passed in an array +private deleteSettings(map) { + def mapValues = [:] + map.each { mapValues[it] = '' } + app.updateSettings(mapValues) + map.each { name -> // Force the DB to reload new values + settings[name] = '' + } +} + +private loginCheck() { + log.trace "Login check" + + authUpdate("check") { resp -> + if (resp?.status == 401) { // Invalid username + state.loginError = "Invalid username" // No response from website - we should not be here + state.loginSuccess = false + } else if ((resp?.status == 200) && resp?.data) { + def ret = resp.data + if (ret?.Authenticated) { + state.loginError = "" + state.loginSuccess = true + } else { + state.loginError = ret?.Error + state.loginSuccess = false + } + } else { + state.loginError = "Unable to authenticate license, please try again later" // No response from website - we should not be here + state.loginSuccess = false + } + } +} + +private authUpdate(String action, Closure closure = null) { + if (!username) { + return + } + + def params = [ + uri: "https://auth.rboyapps.com/v1/license", + headers: [ + Authorization: "Basic ${"${username?.trim()?.toLowerCase()}:${username?.trim()?.toLowerCase()}".getBytes().encodeBase64()}", + ], + body: [ + AppId: app.id, + Timestamp: new Date(now()).format("yyyy-MM-dd'T'HH:mm:ssXXX", location.timeZone ?: TimeZone.getDefault()), // ISO_8601 + State: action, + Username: username?.trim()?.toLowerCase(), + LocationId: location.id, + LocationName: location.name, + AccountId: app.accountId, + AppName: "Lock User Management", + AppInstallName: app.label, + AppVersion: clientVersion(), + ] + ] + + log.trace "Calling AuthUpdate\n${params}" + + try { + httpPostJson(params) { resp -> + /*resp?.headers.each { + log.trace "${it.name} : ${it.value}" + } + log.trace "response contentType: ${resp?.contentType}"*/ + log.debug "response data: ${resp?.data}" + if (closure) { + closure(resp) + } + } + } catch (e) { + //log.error "Auth response:\n${e.response?.data}\n\n${e.response?.allHeaders}\n\n${e.response?.status}\n\n${e.response?.statusLine}\n\n$e" + if ("${e}"?.contains("HttpResponseException")) { // If it's a HTTP error with non 200 status + log.warn "Auth status: ${e?.response?.status}, response: ${e?.response?.statusLine}" + if (closure) { + closure(e?.response) + } + } else { // Some other error + log.error "Auth error: $e" + if (closure) { + closure(null) + } + } + } +} + +def checkForCodeUpdate(evt = null) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "Lock Multi User Code Management" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def devices = locks?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device handler for ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version of $deviceName." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} + +// THIS IS THE END OF THE FILE \ No newline at end of file diff --git a/smartapps/rboy/low-battery-monitor-and-notification.src/low-battery-monitor-and-notification.groovy b/smartapps/rboy/low-battery-monitor-and-notification.src/low-battery-monitor-and-notification.groovy new file mode 100644 index 00000000000..6ff33419f65 --- /dev/null +++ b/smartapps/rboy/low-battery-monitor-and-notification.src/low-battery-monitor-and-notification.groovy @@ -0,0 +1,405 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { + return "01.02.00" +} + +/** +* Low Battery Monitor and Notification +* +* Copyright RBoy Apps, redistribution of any changes or code is not allowed without permission +* 2017-5-15 - (v.01.02.00) Added support for notifying if devices don't report battery status within XX days, separate multiple SMS numbers with a * +* 2016-11-5 - Added support for automatic code update notifications and fixed an issue with sms +* 2016-10-11 - Initial Release +*/ + +definition( + name: "Low Battery Monitor and Notification", + namespace: "rboy", + author: "RBoy Apps", + description: "Monitor devices battery and send notifications when they reach various thresholds", + category: "Safety & Security", + iconUrl: "http://smartthings.rboyapps.com/images/BatteryAlert.png", + iconX2Url: "http://smartthings.rboyapps.com/images/BatteryAlert.png" +) + +preferences { + page(name: "setupAppPage") + page(name: "newMonitorRulePage") +} + + +def setupAppPage() { + log.trace "Settings $settings" + + dynamicPage(name: "setupAppPage", title: "Low Battery Monitor and Notification v${clientVersion()}", install: true, uninstall: true) { + if (!atomicState.rules) { + log.info "Initializing rules" + atomicState.rules = [] + } else { + log.trace "MainPage Rules " + atomicState.rules + } + + section("Battery Monitor Rules") { + // Sort the list by name + def rules = atomicState.rules ?: [] + rules.sort{settings."batteryUpper${it.index}"} + atomicState.rules = rules + log.info "Sorted rules $atomicState.rules" + + for (rule in atomicState.rules) { + if (settings."deleteRule${rule.index}" != false) { // If we have marked it false then save it otherwise delete (otherwise it can be null or true, either case we delete it) + rules = atomicState.rules + rules.remove(rule) + atomicState.rules = rules + log.info "Deleted rule ${rule.index}" + // Now get rid of the rules in the settings + deleteSetting("batteryUpper${rule.index}") + deleteSetting("monitorDevices${rule.index}") + deleteSetting("monitorDevicesReporting${rule.index}") + deleteSetting("monitorDevicesReportingDays${rule.index}") + deleteSetting("deleteRule${rule.index}") + deleteSetting("name${rule.index}") + log.trace "Updated Settings $settings" + log.trace "Updated Rules " + atomicState.rules + } else { // Otherwise show it + def hrefParams = [ + rule: rule, + passed: true + ] + href(name: "${rule.index}", params: hrefParams, title: "${settings."batteryUpper${rule.index}"}% ${settings."name${rule.index}" ?: ""}", page: "newMonitorRulePage", description: "", required: false) + } + } + def hrefParams = [ + rule: [index:now() as String], // Create a new rule to use (by default we'll delete this rule and settings unless the user confirms), use as String otherwise it won't work on Android + passed: true + ] + href(name: "NewMonitorRule", params: hrefParams, title: "+ Define a new battery monitor rule", page: "newMonitorRulePage", description: "", required: false) + } + + section("Notification Options") { + input "time", "time", title: "Check battery levels at this time everyday", required: true + input("recipients", "contact", title: "Send notifications to", multiple: true, required: false) { + paragraph "You can enter multiple phone numbers to send an SMS to by separating them with a '*'. E.g. 5551234567*4447654321" + input name: "sms", title: "Send SMS notification to (optional):", type: "phone", required: false + input name: "notify", title: "Send Push Notification", type: "bool", defaultValue: true + } + } + + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "disableUpdateNotifications", title: "Don't check for new versions of the app", type: "bool", required: false + } + } +} + +def newMonitorRulePage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def rule = [] + // Get user from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.rule) { + rule = params.rule + log.trace "Passed from main page, using params lookup for rule $rule" + } else if (atomicState.params) { + rule = atomicState.params.rule ?: [] + log.trace "Passed from submitOnChange, atomicState lookup for rule $rule" + } else { + log.error "Invalid params, no rule found. Params: $params, saved params: $atomicState.params" + } + + log.trace "New Battery Monitor Rule Page, rule:$rule, passed params: $params, saved params:$atomicState.params" + + def existingRule = atomicState.rules.find { it.index == rule.index } + if (!existingRule) { // If the rule doesn't exist then save it + def rules = atomicState.rules ?: [] + rules.add(rule) + atomicState.rules = rules + log.info "Added rule $rule to $atomicState.rules" + } + + dynamicPage(name:"newMonitorRulePage", title: "Battery monitor rule", uninstall: false, install: false) { + section { + log.trace "RulePage Rules:" + atomicState.rules + + def upper = settings."batteryUpper${rule.index}" + def devices = settings."monitorDevices${rule.index}" + def monitor = settings."monitorDevicesReporting${rule.index}" + def days = settings."monitorDevicesReportingDays${rule.index}" + def name = settings."name${rule.index}" + def deleteRule = settings."deleteRule${rule.index}" + + if (upper && devices) { + def msg = "" + // If the settings is `null` then it's not defined and will be deleted automatically otherwise it's been saved + if (deleteRule == false) { + msg = "This rule has been saved" + } else if (deleteRule == true) { + msg = "THIS RULE HAS BEEN DELETED!\nUNCHECK THE DELETE OPTION TO RESTORE THE RULE." + } + if (msg) { + paragraph msg, required: true + log.trace "Saved settings $settings" + } + } + + log.trace "upper: $upper, name: $name, monitor: $monitor, days: $days, deleteRule: $deleteRule, devices: $devices" + + input "batteryUpper${rule.index}", "number", title: "If the battery is below (%)", description: "1 to 100", required: true, range: "1..100", submitOnChange: true + input "monitorDevices${rule.index}", "capability.battery", title: "Monitor these devices", multiple: true, required: true, submitOnChange: true + + // Battery monitor is not reliable as of now - https://community.smartthings.com/t/release-configurable-low-battery-monitor-notification-and-device-monitoring/59780/25 + // UNCOMMENT THE NEXT 4 LINES TO ENABLE BATTERY REPORT MONITORING + //input "monitorDevicesReporting${rule.index}", "bool", title: "Notify if these devices don't report battery levels", multiple: false, required: true, submitOnChange: true + //if (monitor) { + // input "monitorDevicesReportingDays${rule.index}", "number", title: "...for these many days", range:"1..*", multiple: false, required: true, submitOnChange: true + //} + + if (upper && devices) { // Don't show this for a new rule + input "name${rule.index}", "text", title: "Name this rule (optional)", required: false, submitOnChange: true + input "deleteRule${rule.index}", "bool", title: "Delete this rule", required: false, submitOnChange: true + } + } + } +} + +def installed() { + log.trace "Install called with settings $settings" + initialize() +} + +def updated() { + log.trace "Updated called with settings $settings" + initialize() +} + +def initialize() { + log.trace "Initializing settings with rules $atomicState.rules" + + unsubscribe() + unschedule() + + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + log.error "Hub timeZone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + sendPush "Hub timeZone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + } + + // Subscribe to battery events to check for devices that may have stopped reporting + atomicState.batteryEvents = [:] // Clear all battery events + for (rule in atomicState.rules) { + def devices = settings."monitorDevices${rule.index}" + def monitor = settings."monitorDevicesReporting${rule.index}" + def days = settings."monitorDevicesReportingDays${rule.index}" + if (monitor && days) { + def batteryEvents = atomicState.batteryEvents ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + for (device in devices) { + batteryEvents[device.id] = now() // Track battery reporting for subscribed device starting now, comment this line to only track devices which report battery events atleast once starting now + } + atomicState.batteryEvents = batteryEvents + subscribe(devices, "battery", batteryEventHandler, [filterEvents: false]) // We want all events, if they are repeated with same value + } + } + + // Schedule battery level check + def timeNow = now() + log.trace("Current time is ${(new Date(timeNow)).format("EEE MMM dd yyyy HH:mm z", timeZone)}") + log.trace("Battery check schedule ${timeToday(time, timeZone).format("HH:mm z", timeZone)}") + schedule(timeToday(time, timeZone), checkBatteryLevels) + subscribe(app, appTouchMethod) + + // Check for new versions of the code + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Integer randomDayOfWeek = random.nextInt(7-1) + 1 // 1 to 7 + schedule("0 0 " + randomHour + " ? * " + randomDayOfWeek, checkForCodeUpdate) // Check for code updates once a week at a random day and time between 10am and 6pm + + checkBatteryLevels() // Do it now for sanity check +} + +def appTouchMethod(evt) { + log.debug "User requested battery level check" + checkBatteryLevels() +} + +def batteryEventHandler(evt) { + log.trace "Battery event device: ${evt.device}, value: ${evt.value}" + + def batteryEvents = atomicState.batteryEvents ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + batteryEvents[evt.device.id] = now() // We got the event now + atomicState.batteryEvents = batteryEvents + + //log.debug atomicState.batteryEvents +} + +def checkBatteryLevels() { + log.trace "Checking battery levels" + + TimeZone timeZone = location.timeZone + if (!timeZone) { + timeZone = TimeZone.getDefault() + log.error "Hub timeZone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + sendPush "Hub timeZone not set, using ${timeZone.getDisplayName()} timezone. Please set Hub location and timezone for the codes to work accurately" + } + + for (rule in atomicState.rules) { + def upper = settings."batteryUpper${rule.index}" as Integer + def devices = settings."monitorDevices${rule.index}" + def name = settings."name${rule.index}" + def monitor = settings."monitorDevicesReporting${rule.index}" + def days = settings."monitorDevicesReportingDays${rule.index}" + + //log.trace "upper: $upper, name: $name, devices: $devices" + + for (device in devices) { + // If we haven't received any battery notification events from devices in the last XX days then notify the user + def batteryEvents = atomicState.batteryEvents ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + def lastEvent = batteryEvents[device.id] + /*if (lastEvent) { // NOTE: TEST COMMENT BEFORE PRODUCTION + def tmpMsg = "Last battery event for ${device.displayName} reported was ${(new Date(lastEvent)).format("EEE MMM dd yyyy HH:mm z", timeZone)}" + log.debug tmpMsg + if (lastEvent < (now() - 1*24*60*60*1000)) { + sendNotificationMessage(tmpMsg) + } + }*/ + if (monitor && days && lastEvent && (lastEvent < (now() - days*24*60*60*1000))) { // XX days since last event + def msg = "${device.displayName} has not reported any battery levels since ${(new Date(lastEvent)).format("EEE MMM dd yyyy HH:mm z", timeZone)}, check device health" + log.warn msg + sendNotificationMessage(msg) + } else { // All good with battery event reporting, check battery levels + def batteryLevel = device.currentValue("battery") as Integer + def msg = "${device.displayName} battery level is $batteryLevel%" + //log.trace msg + if (batteryLevel < upper) + { + log.warn "${device.displayName} battery level $batteryLevel% below configured threshold of $upper%" + sendNotificationMessage(msg) + } + } + } + } +} + +private void sendText(number, message) { + if (number) { + def phones = number.split("\\*") + for (phone in phones) { + sendSms(phone, message) + } + } +} + +private void sendNotificationMessage(message) { + if (location.contactBookEnabled) { + log.debug "Sending message to $recipients" + sendNotificationToContacts(message, recipients) + } else { + log.debug "SMS: $sms, Push: $notify" + sms ? sendText(sms, message) : "" + notify ? sendPush(message) : sendNotificationEvent(message) + } +} + +// Temporarily override the user settings +private updateSetting(name, value) { + //app.updateSetting(name, value) // For SmartApps + settings[name] = value // For Device Handlers and SmartApps +} + +private deleteSetting(name) { + //app.deleteSetting(name) // For SmartApps delete it, TODO: Gives and error + //settings.remove(name) // For Device Handlers + clearSetting(name) // For SmartApps +} + +private clearSetting(name) { + app.updateSetting(name, '') // For SmartApps +} + +def checkForCodeUpdate(evt) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "Low Battery Monitor and Notification" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (!disableUpdateNotifications) { + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def caps = [] + for (rule in atomicState.rules) { + caps.add(settings."monitorDevices${rule.index}") + } + caps?.each { + def devices = it?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (!disableUpdateNotifications) { + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} \ No newline at end of file diff --git a/smartapps/rboy/mode-change-actions.src/mode-change-actions.groovy b/smartapps/rboy/mode-change-actions.src/mode-change-actions.groovy new file mode 100644 index 00000000000..5858629317f --- /dev/null +++ b/smartapps/rboy/mode-change-actions.src/mode-change-actions.groovy @@ -0,0 +1,977 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { return "01.10.00" } + +/** + * Mode Based Actions - Notify if any doors/windows/switches/lock/values/shades are open or closed on when a routine changes the mode and take actions + * + * Copyright RBoy Apps, redistribution of code is not allowed without permission + * + * 2020-09-28 - (v01.10.00) Added support for WiFi Garage door controllers + * 2020-01-20 - (v01.09.02) Update icons for broken ST Android app 2.18 + * 2019-10-11 - (v01.09.01) Add support for the new Sonos integration (auto detect) + * 2019-05-01 - (v01.09.00) Added option to partially open/close shades (where supported) + * 2019-04-09 - (v01.08.02) Add windows to message for doors + * 2018-12-11 - (v01.08.01) Fix for saved (dynamic) settings not showing due to change in ST app behavior + * 2018-09-26 - (v01.08.00) Added support for announcing messages over a TTS device + * 2018-07-27 - (v01.07.05) Consolidate messages to the end to avoid a timeout + * 2017-08-01 - (v01.07.04) Consolidate messages to reduce number of messages to one per group + * 2017-06-15 - (v01.07.03) Update for ST firmware bug for valves + * 2017-05-26 - (v01.07.02) Update to ST platform, now separate multiple SMS numbers with a * + * 2017-05-10 - (v01.07.01) Minor fix to text layout + * 2017-05-08 - (v01.07.00) Added ability to check for closed windows/doors/garge doors/locks when mode is changed, fixed bug with closing garage doors and ability for window shades and water valves + * 2016-11-05 - Added ability to check for new app and device versions, fixed an issue withe mode based notifications + * 2016-09-16 - Added support to check for switches left off which should be on + * 2016-09-16 - Patch for broken HREF in ST app 2.2.0 + * 2016-08-17 - Added workaround for ST contact address book bug + * 2016-07-30 - Added support for delayed actions + * 2016-07-30 - Added support for contact address books notifications + * 2016-06-06 - Fix for Android phones HREF bug + * 2016-06-03 - Fixed list of items to be checked shown on first page + * 2016-06-03 - Added support to notify if any garage doors are left open and option to close them + * 2016-06-03 - Added support to notify if any doors are left unlocked and option to lock them + * 2016-06-02 - Added support to notify if any switches are left on and option to turn them off + * 2016-06-01 - Initial release +*/ +definition( + name: "Mode Change Actions", + namespace: "rboy", + author: "RBoy Apps", + description: "Notify if there are any doors/window/switches that are open/unlocked and closes/locks them when a mode changes. E.g. when leaving the house the mode changes to away it will notify the user if any doors are left open.", + category: "Safety & Security", + iconUrl: "https://www.rboyapps.com/images/OpenDoor.png", + iconX2Url: "https://www.rboyapps.com/images/OpenDoor.png", + iconX3Url: "https://www.rboyapps.com/images/OpenDoor.png") + +preferences { + page(name: "loginPage") + page(name: "loginPage2") + page(name: "setupApp") + page(name: "modeDoorMonitorPage") +} + +def loginPage() { + log.trace "Login page" + if (!state.loginSuccess && username) { + loginCheck() + } + if (state.loginSuccess) { + setupApp() + } else { + state.sendUpdate = true + loginSection("loginPage", "loginPage2") + } +} + +def loginPage2() { + log.trace "Login page2" + if (!state.loginSuccess && username) { + loginCheck() + } + if (state.loginSuccess) { + setupApp() + } else { + state.sendUpdate = true + loginSection("loginPage2", "loginPage") + } +} + +private loginSection(name, nextPage) { + dynamicPage(name: name, title: "Mode Change Actions v${clientVersion()}", install: state.loginSuccess, uninstall: true, nextPage: state.loginSuccess ? "" : nextPage) { + section() { + if (state.loginError) { + log.warn "Authenticating failed: ${state.loginError}" + paragraph title: "Login failed", image: "https://www.rboyapps.com/images/RBoyApps.png", required: true, "${state.loginError}" + } else { + log.debug "Check authentication credentials, Login: $username" + paragraph title: "Login", image: "https://www.rboyapps.com/images/RBoyApps.png", required: false, "Enter your RBoy Apps username\nYou can retrieve your username from www.rboyapps.com lost password page" + } + + input name: "username", type: "text", title: "Username", capitalization: "none", submitOnChange: false, required: false + } + } +} + +def setupApp() { + dynamicPage(name: "setupApp", title: "Mode Change Actions v${clientVersion()}", install: true, uninstall: true) { + section() { + paragraph "Click on each Mode below to configure notification options and actions for doors/windows/switches/locks/valves/shades. When the hub changes to the selected mode and if any of the selected doors/windows/switches/locks/valves/shades are found open/closed/locked/unlocked, this app will notify you and take actions." + } + + def modes = location.modes + if (modes) { + for (mode in modes) { + section { + // Unlock actions for each mode + def hrefParams = [ + mode: mode as String, + passed: true + ] + log.trace "HREF Mode $mode" + href(name: "modeDoorMonitor", params: hrefParams, title: "When switching to mode ${mode}", page: "modeDoorMonitorPage", description: getCheckingDescription(mode), required: false) + } + } + + section("Delay Actions and Notifications") { + paragraph "Enable this option to allow the routines to complete their actions after a mode change before checking for any open doors/windows/switches. It waits for about a minute or two before checking" + input name: "delayAction", title: "Delay checking", type: "bool", required: false + } + } else { + section("Error initializing SmartApp") { + paragraph "No modes found on the Hub! Contact ST support" + } + } + + section("General Notification Options") { + input("recipients", "contact", title: "Send notifications to (optional)", multiple: true, required: false) { + input name: "notify", title: "Send push notifications", type: "bool", defaultValue: true, required: false + paragraph "You can enter multiple phone numbers to send an SMS to by separating them with a '*'. E.g. 5551234567*+18747654321" + input name: "sms", title: "Send SMS notification to (optional):", type: "phone", required: false + } + input "audioDevices", "capability.audioNotification", title: "Speak notifications on", required: false, multiple: true, submitOnChange: true, image: "https://www.rboyapps.com/images/Horn.png" + if (audioDevices) { + input "audioVolume", "number", title: "...at this volume level (optional)", description: "keep current", required: false, range: "1..100" + } + } + + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "updateNotifications", title: "Check for new versions of the app", type: "bool", defaultValue: true, required: false + } + section("Confidential", hideable: true, hidden: true) { + paragraph("RBoy Apps Username: " + (username?.toLowerCase() ?: "Unlicensed") + (state.loginSuccess ? "" : ", contact suppport")) + } + } +} + +private getCheckingDescription(mode) { + def description = "" + + def devices = [ + settings."doors${mode}", + settings."doorsflip${mode}", + settings."garagedoors${mode}", + settings."garagedoorsflip${mode}", + settings."locks${mode}", + settings."locksflip${mode}", + settings."switches${mode}", + settings."switchesflip${mode}", + settings."shades${mode}", + settings."shadesflip${mode}", + settings."valves${mode}", + settings."valvesflip${mode}", + ] + + for (device in devices) { + description += device ? (description ? "\n " : "") + device?.join("\n ") : "" + } + + if (!description) { // Nothing selected + description = "No doors/windows selected" + } else { + description = "Check:\n " + description // Add the header + } + + return description +} + +def modeDoorMonitorPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def mode = "" + // Get mode from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.mode) { + mode = params.mode + log.trace "Passed from main page, using params lookup for mode $mode" + } else if (atomicState.params) { + mode = atomicState.params.mode ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for mode $mode" + } else { + log.error "Invalid params, no mode found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Mode Door Monitor, mode:$mode, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"modeDoorMonitorPage", title: "Configure notification options when changing to Mode " + (mode ?: ""), uninstall: false, install: false) { + section("Choose Door(s) and Window(s)") { + paragraph "Notify if any of these doors or windows are left open/closed when the hub changes to $mode mode" + input "doors${mode}", "capability.contactSensor", title: "Check for Open doors/windows", required: false, multiple:true + input "doorsflip${mode}", "capability.contactSensor", title: "Check for Closed doors/windows", required: false, multiple:true + } + + section("Choose Garage Door(s)") { + paragraph "Notify if any of these selected garage doors are left open/closed when the hub changes to $mode mode" + input "garagedoors${mode}", "capability.doorControl", title: "Check for Open garage doors", required: false, multiple:true, submitOnChange: true + if (settings."garagedoors${mode}") { + input "garagedoorsoff${mode}", "bool", title: "...close them", required: false + } + input "garagedoorsflip${mode}", "capability.doorControl", title: "Check for Closed garage doors", required: false, multiple:true, submitOnChange: true + if (settings."garagedoorsflip${mode}") { + input "garagedoorsopen${mode}", "bool", title: "...open them", required: false + } + } + + section("Choose Lock(s)") { + paragraph "Notify if any of these selected locks are Unlocked/Locked when the hub changes to $mode mode" + input "locks${mode}", "capability.lock", title: "Check for Unlocked locks", required: false, multiple:true, submitOnChange: true + if (settings."locks${mode}") { + input "locksoff${mode}", "bool", title: "...lock them", required: false + } + input "locksflip${mode}", "capability.lock", title: "Check for Locked locks", required: false, multiple:true, submitOnChange: true + if (settings."locksflip${mode}") { + input "locksopen${mode}", "bool", title: "...unlock them", required: false + } + } + + section("Choose Switch(s)") { + paragraph "Notify if any of these selected switches are On/Off when the hub changes to $mode mode" + input "switches${mode}", "capability.switch", title: "Check for switches left On", required: false, multiple:true, submitOnChange: true + if (settings."switches${mode}") { + input "switchesoff${mode}", "bool", title: "...turn them off", required: false + } + input "switchesflip${mode}", "capability.switch", title: "Check for switches left Off", required: false, multiple:true, submitOnChange: true + if (settings."switchesflip${mode}") { + input "switcheson${mode}", "bool", title: "...turn them on", required: false + } + } + + section("Choose Windows Shade/Blind(s)") { + paragraph "Notify if any of these selected window shades/blinds are Open/Closed when the hub changes to $mode mode" + input "shades${mode}", "capability.windowShade", title: "Check for shades left Open", required: false, multiple:true, submitOnChange: true + if (settings."shades${mode}") { + input "shadesoff${mode}", "bool", title: "...close them", required: false, submitOnChange: true + if (settings."shadesoff${mode}") { + input "shadesofflevel${mode}", "number", title: "...partial close (%)", description: "optional (1-99)", range: "1..99", required: false + } + } + input "shadesflip${mode}", "capability.windowShade", title: "Check for shades left Closed", required: false, multiple:true, submitOnChange: true + if (settings."shadesflip${mode}") { + input "shadeson${mode}", "bool", title: "...open them", required: false, submitOnChange: true + if (settings."shadeson${mode}") { + input "shadesonlevel${mode}", "number", title: "...partial open (%)", description: "optional (1-99)", range: "1..99", required: false + } + } + } + + section("Choose Valve(s)") { + paragraph "Notify if any of these selected valves are Open/Closed when the hub changes to $mode mode" + input "valves${mode}", "capability.valve", title: "Check for valves left Open", required: false, multiple:true, submitOnChange: true + if (settings."valves${mode}") { + input "valvesoff${mode}", "bool", title: "...close them", required: false + } + input "valvesflip${mode}", "capability.valve", title: "Check for valves left Closed", required: false, multiple:true, submitOnChange: true + if (settings."valvesflip${mode}") { + input "valveson${mode}", "bool", title: "...open them", required: false + } + } + + section("Mode Specific Notification Options") { + paragraph "Enabling mode specific notifications will override over any general notifications defined on the first page" + input "modeOverrideNotifications${mode}", "bool", title: "Enable $mode mode specific notifications", required: false, submitOnChange: true + if (settings."modeOverrideNotifications${mode}") { + input("recipients${mode}", "contact", title: "Send notifications to (optional)", multiple: true, required: false) { + input "notify${mode}", "bool", title: "Send push notifications", defaultValue: true, required: false + paragraph "You can enter multiple phone numbers to send an SMS to by separating them with a '*'. E.g. 5551234567*4447654321" + input "sms${mode}", "phone", title: "Send SMS notification to (optional):", required: false + } + input "audioDevices${mode}", "capability.audioNotification", title: "Speak notifications on", required: false, multiple: true, image: "https://www.rboyapps.com/images/Horn.png" + } + } + } +} + +def installed() +{ + log.debug "Installed" + + subscribeToEvents() +} + +def updated() +{ + log.debug "Updated" + + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace settings + + state.queuedActions = [] // initialize it + + log.trace "Scheduling heartbeat for every 1 minute" + schedule("* */1 * * * ?", heartBeat) // Schedule the heartbeat + + subscribe(location, "mode", modeChangeHandler) + + // Check for new versions of the code + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Integer randomDayOfWeek = random.nextInt(7-1) + 1 // 1 to 7 + schedule("* 0 " + randomHour + " ? * " + randomDayOfWeek, checkForCodeUpdate) // Check for code updates once a week at a random day and time between 10am and 6pm +} + +def heartBeat() { + log.trace "Heartbeat called, pending actions: $state.queuedActions" + def unprocessedActions = [] + def actions = [] + + synchronized(state) { + if (state.queuedActions == null) { // Initialize it if it doesn't exist + log.debug "Initializing queued Actions" + state.queuedActions = [] // initialize it + } + + actions = state.queuedActions.clone() // make a copy instead of working on original + //log.warn "BEFORE CLEAR:$state.queuedActions" + state.queuedActions.clear() // Clear it + //log.warn "AFTER CLEAR:$state.queuedActions" + //log.warn "EXECUTE:$actions" + } + + for (action in actions) { + //log.trace "Processing:$action" + def now = now() + if (now >= action.time) { + log.trace "Checking state of doors/windows" + modeChangeHandler() + } else { + unprocessedActions.add(action) + log.trace "Waiting ${(((action.time - now) as Float)/1000).round()} seconds to process deferred action ${action}" + } + } + + if (unprocessedActions) { // If anything is pending + log.trace "Adding unprocessed actions back to queue: $unprocessedActions" + // Synchronize these lists otherwise we have a race condition + synchronized(state) { + state.queuedActions = state.queuedActions + unprocessedActions // Add back any pending actions (since we are adding an array of maps, use + and not << or .add()) + //log.warn "END:$state.queuedActions" + } + } + + log.trace "Heartbeat actions finished, pending actions: $state.queuedActions" +} + +def modeChangeHandler(evt) { + log.debug "Mode change notification, ${evt ? "name: ${evt.name}, value: ${evt.value}" : "delayed checking"}" + + // Check if we need a delayed actions + if (evt && delayAction) { + log.trace "Delaying checking by 1 minute" + + // Synchronize these lists otherwise we have a race condition + synchronized(state) { + if (state.queuedActions == null) { // Initialize it if it doesn't exist + log.debug "Initializing queued Actions" + state.queuedActions = [] // initialize it + } + + //state.queuedActions.clear() // DEBUG CLEAR + //log.warn "QUEUED ACTIONS: $state.queuedActions" // DEBUG + + state.queuedActions = [[time:(now() + (1 * 60 * 1000) as Long)]] // Delay checking by 1 minute (don't add map to the array only keep 1 map in the array, check for latest mode change) + + //state.queuedActions.clear() // DEBUG CLEAR + log.trace "Queued actions: $state.queuedActions" // DEBUG + } + + return + } + + def mode = location.mode // This should the new mode + def msgs = [] + + // Check for open doors/windows left open + if (settings."doors${mode}") { + def message = "" + for (door in settings."doors${mode}") { + if (door.currentValue("contact") == "open") { + message += ", $door" + } else { + log.debug "$door is currently Closed" + } + } + + if(message) { + message = "These doors/windows were Open when hub was changed to $mode mode" + message + log.info message + msgs << message + } + } else { + log.trace "No doors/windows found to check for open when hub changed to $mode mode" + } + + // Check for open doors/windows left closed + if (settings."doorsflip${mode}") { + def message = "" + for (door in settings."doorsflip${mode}") { + if (door.currentValue("contact") == "closed") { + message += ", $door" + } else { + log.debug "$door is currently Open" + } + } + + if(message) { + message = "These doors/windows were Closed when hub was changed to $mode mode" + message + log.info message + msgs << message + } + } else { + log.trace "No doors/windows found to check for closed when hub changed to $mode mode" + } + + // Check for switches left on + if (settings."switches${mode}") { + def message = "" + for (switchs in settings."switches${mode}") { + if (switchs.currentValue("switch") == "on") { + message += ", $switchs" + if (settings."switchesoff${mode}") { + switchs.off() + } + } else { + log.debug "$switchs is currently Off" + } + } + + if(message) { + if (settings."switchesoff${mode}") { + message = "These switches were On when hub was changed to $mode mode, turning them Off" + message + } else { + message = "These switches were On when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No switches found to check for on when hub changed to $mode mode" + } + + // Check for switches left off + if (settings."switchesflip${mode}") { + def message = "" + for (switchs in settings."switchesflip${mode}") { + if (switchs.currentValue("switch") == "off") { + message += ", $switchs" + if (settings."switcheson${mode}") { + switchs.on() + } + } else { + log.debug "$switchs is currently On" + } + } + + if(message) { + if (settings."switcheson${mode}") { + message = "These switches were Off when hub was changed to $mode mode, turning them On" + message + } else { + message = "These switches were Off when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No switches found to check for off when hub changed to $mode mode" + } + + // Check for locks left unlocked + if (settings."locks${mode}") { + def message = "" + for (lock in settings."locks${mode}") { + if (lock.currentValue("lock") == "unlocked") { + message += ", $lock" + if (settings."locksoff${mode}") { + lock.lock() + } + } else { + log.debug "$lock is currently Locked" + } + } + + if(message) { + if (settings."locksoff${mode}") { + message = "These locks were Unlocked when hub was changed to $mode mode, Locking them" + message + } else { + message = "These locks were Unlocked when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No locks found to check for unlocked when hub changed to $mode mode" + } + + // Check for locks left locked + if (settings."locksflip${mode}") { + def message = "" + for (lock in settings."locksflip${mode}") { + if (lock.currentValue("lock") == "locked") { + message += ", $lock" + if (settings."locksopen${mode}") { + lock.unlock() + } + } else { + log.debug "$lock is currently Unlocked" + } + } + + if(message) { + if (settings."locksopen${mode}") { + message = "These locks were Locked when hub was changed to $mode mode, Unlocking them" + message + } else { + message = "These locks were Locked when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No locks found to check for locked when hub changed to $mode mode" + } + + // Check for garage doors left open + if (settings."garagedoors${mode}") { + def message = "" + for (garagedoor in settings."garagedoors${mode}") { + if (garagedoor.currentValue("door") != "closed") { + message += ", $garagedoor" + if (settings."garagedoorsoff${mode}") { + garagedoor.close() + } + } else { + log.debug "$garagedoor is currently Closed" + } + } + + if(message) { + if (settings."garagedoorsoff${mode}") { + message = "These doors were Open when hub was changed to $mode mode, Closing them" + message + } else { + message = "These doors were Open when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No garage doors found to check for open when hub changed to $mode mode" + } + + // Check for garage doors left closed + if (settings."garagedoorsflip${mode}") { + def message = "" + for (garagedoor in settings."garagedoorsflip${mode}") { + if (garagedoor.currentValue("door") != "open") { + message += ", $garagedoor" + if (settings."garagedoorsopen${mode}") { + garagedoor.open() + } + } else { + log.debug "$garagedoor is currently Open" + } + } + + if(message) { + if (settings."garagedoorsopen${mode}") { + message = "These doors were Closed when hub was changed to $mode mode, Opening them" + message + } else { + message = "These doors were Closed when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No garage doors found to check for closed when hub changed to $mode mode" + } + + // Check for shades left open + if (settings."shades${mode}") { + def message = "" + for (shade in settings."shades${mode}") { + if (shade.currentValue("windowShade") != "closed") { + message += ", $shade" + if (settings."shadesoff${mode}") { + if (settings."shadesofflevel${mode}") { + try { // Not all DTH's implement this + shade.setLevel(settings."shadesofflevel${mode}") + } catch (e) { + log.error "$shade does not support partial controls: $e" + shade.close() // Just do a regular close + } + } else { + shade.close() + } + } + } else { + log.debug "$shade is currently Closed" + } + } + + if(message) { + if (settings."shadesoff${mode}") { + message = "These shades were Open when hub was changed to $mode mode, Closing them" + message + } else { + message = "These shades were Open when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No window shades found to check for open when hub changed to $mode mode" + } + + // Check for window shades left closed + if (settings."shadesflip${mode}") { + def message = "" + for (shade in settings."shadesflip${mode}") { + if (shade.currentValue("windowShade") != "open") { + message += ", $shade" + if (settings."shadeson${mode}") { + if (settings."shadesonlevel${mode}") { + try { // Not all DTH's implement this + shade.setLevel(settings."shadesonlevel${mode}") + } catch (e) { + log.error "$shade does not support partial controls: $e" + shade.open() // Just do a regular open + } + } else { + shade.open() + } + } + } else { + log.debug "$shade is currently Open" + } + } + + if(message) { + if (settings."shadeson${mode}") { + message = "These shades were Closed when hub was changed to $mode mode, Opening them" + message + } else { + message = "These shades were Closed when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No window shades found to check for closed when hub changed to $mode mode" + } + + // Check for valves left open + if (settings."valves${mode}") { + def message = "" + for (valve in settings."valves${mode}") { + if (valve.currentValue("valve") == "open" || valve.currentValue("contact") == "open") { // ST doesn't use valve due to firmware bug - https://community.smartthings.com/t/documentation-error-for-valve-or-platform-bug/88037/19 + message += ", $valve" + if (settings."valvesoff${mode}") { + valve.close() + } + } else { + log.debug "$valve is currently Closed" + } + } + + if(message) { + if (settings."valvesoff${mode}") { + message = "These valves were Open when hub was changed to $mode mode, Closing them" + message + } else { + message = "These valves were Open when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No valves found to check for open when hub changed to $mode mode" + } + + // Check for valves left closed + if (settings."valvesflip${mode}") { + def message = "" + for (valve in settings."valvesflip${mode}") { + if (valve.currentValue("valve") == "closed" || valve.currentValue("contact") == "closed") { // ST doesn't use valve firmware bug - https://community.smartthings.com/t/documentation-error-for-valve-or-platform-bug/88037/19 + message += ", $valve" + if (settings."valveson${mode}") { + valve.open() + } + } else { + log.debug "$valve is currently Open" + } + } + + if(message) { + if (settings."valveson${mode}") { + message = "These valves were Closed when hub was changed to $mode mode, Opening them" + message + } else { + message = "These valves were Closed when hub was changed to $mode mode" + message + } + log.info message + msgs << message + } + } else { + log.trace "No valves found to check for closed when hub changed to $mode mode" + } + + // Last this to do is send messages since these can time out + log.trace "Sending messages" + + for (message in msgs) { + if (settings."modeOverrideNotifications${mode}") { + log.trace "Using mode specific notifications" + sendMessages(mode, message) + } else { + log.trace "Using general notifications" + sendMessages(null, message) + } + } +} + +private sendText(number, message) { + if (number) { + def phones = number.replaceAll("[;,#]", "*").split("\\*") // Some users accidentally use ;,# instead of * and ST can't handle *,#+ in the number except for + at the beginning + for (phone in phones) { + try { + sendSms(phone, message) + } catch (Exception e) { + sendPush "Invalid phone number $phone" + } + } + } +} + +private sendMessages(mode, message) { + if (mode) { + if (location.contactBookEnabled) { + sendNotificationToContacts(message, settings."recipients${mode}") + } else { + if (settings."notify${mode}") { + sendPush message + } + + if (settings."sms${mode}") { + sendText(settings."sms${mode}", message) + } + } + + settings."audioDevices${mode}"?.each { audioDevice -> // Play audio notifications + if (audioDevice.hasCommand("playText")) { // Check if it supports TTS + if (audioVolume) { // Only set volume if defined as it also resumes playback + audioDevice.playTextAndResume(message, audioVolume) + } else { + audioDevice.playText(message) + } + } else { + if (audioVolume) { // Only set volume if defined as it also resumes playback + audioDevice.playTrackAndResume(textToSpeech(message)?.uri, audioVolume) // No translations at this time + } else { + audioDevice.playTrack(textToSpeech(message)?.uri) // No translations at this time + } + } + } + } else { + if (location.contactBookEnabled) { + sendNotificationToContacts(message, recipients) + } else { + if (notify) { + sendPush message + } + + if (sms) { + sendText(sms, message) + } + } + + settings."audioDevices"?.each { audioDevice -> // Play audio notifications + if (audioDevice.hasCommand("playText")) { // Check if it supports TTS + if (audioVolume) { // Only set volume if defined as it also resumes playback + audioDevice.playTextAndResume(message, audioVolume) + } else { + audioDevice.playText(message) + } + } else { + if (audioVolume) { // Only set volume if defined as it also resumes playback + audioDevice.playTrackAndResume(textToSpeech(message)?.uri, audioVolume) // No translations at this time + } else { + audioDevice.playTrack(textToSpeech(message)?.uri) // No translations at this time + } + } + } + } +} + +private loginCheck() { + log.trace "Login check" + + authUpdate("check") { resp -> + if (resp?.status == 401) { // Invalid username + state.loginError = "Invalid username" // No response from website - we should not be here + state.loginSuccess = false + } else if ((resp?.status == 200) && resp?.data) { + def ret = resp.data + if (ret?.Authenticated) { + state.loginError = "" + state.loginSuccess = true + } else { + state.loginError = ret?.Error + state.loginSuccess = false + } + } else { + state.loginError = "Unable to authenticate license, please try again later" // No response from website - we should not be here + state.loginSuccess = false + } + } +} + +private authUpdate(String action, Closure closure = null) { + if (!username) { + return + } + + def params = [ + uri: "https://auth.rboyapps.com/v1/license", + headers: [ + Authorization: "Basic ${"${username?.trim()?.toLowerCase()}:${username?.trim()?.toLowerCase()}".getBytes().encodeBase64()}", + ], + body: [ + AppId: app.id, + Timestamp: new Date(now()).format("yyyy-MM-dd'T'HH:mm:ssXXX", location.timeZone ?: TimeZone.getDefault()), // ISO_8601 + State: action, + Username: username?.trim()?.toLowerCase(), + LocationId: location.id, + LocationName: location.name, + AccountId: app.accountId, + AppName: "Mode Change Actions", + AppInstallName: app.label, + AppVersion: clientVersion(), + ] + ] + + log.trace "Calling AuthUpdate\n${params}" + + try { + httpPostJson(params) { resp -> + /*resp?.headers.each { + log.trace "${it.name} : ${it.value}" + } + log.trace "response contentType: ${resp?.contentType}"*/ + log.debug "response data: ${resp?.data}" + if (closure) { + closure(resp) + } + } + } catch (e) { + //log.error "Auth response:\n${e.response?.data}\n\n${e.response?.allHeaders}\n\n${e.response?.status}\n\n${e.response?.statusLine}\n\n$e" + if ("${e}"?.contains("HttpResponseException")) { // If it's a HTTP error with non 200 status + log.warn "Auth status: ${e?.response?.status}, response: ${e?.response?.statusLine}" + if (closure) { + closure(e?.response) + } + } else { // Some other error + log.error "Auth error: $e" + if (closure) { + closure(null) + } + } + } +} + +def checkForCodeUpdate(evt) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "Open Door/Window/Switch/Lock Notification and Action on Mode Change" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def caps = [] + for (mode in location.modes) { + caps.add(settings."doors${mode}") + caps.add(settings."doorsflip${mode}") + caps.add(settings."garagedoors${mode}") + caps.add(settings."garagedoorsflip${mode}") + caps.add(settings."locks${mode}") + caps.add(settings."locksflip${mode}") + caps.add(settings."switches${mode}") + caps.add(settings."switchesflip${mode}") + caps.add(settings."shades${mode}") + caps.add(settings."shadesflip${mode}") + caps.add(settings."valves${mode}") + caps.add(settings."valvesflip${mode}") + } + caps?.each { + def devices = it?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device handler for ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} + + +// THIS IS THE END OF THE FILE \ No newline at end of file diff --git a/smartapps/rboy/routines-backup.src/routines-backup.groovy b/smartapps/rboy/routines-backup.src/routines-backup.groovy new file mode 100644 index 00000000000..8b2593d2973 --- /dev/null +++ b/smartapps/rboy/routines-backup.src/routines-backup.groovy @@ -0,0 +1,846 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { + return "01.02.01" +} + +/** +* Backup for Routines to verify that they did their job and if not complete their actions +* +* Copyright RBoy Apps, modification, reuse or redistribution of code is not allowed without permission +* +* 2018-12-11 - (v01.02.01) Fix for saved (dynamic) settings not showing due to change in ST app behavior +* 2018-11-22 - (v01.02.00) Added support for announcing messages over a TTS device +* 2018-9-21 - (v01.01.00) Added support to check and change target mode +* 2018-7-27 - (v01.00.00) Initial release +* +*/ +definition( + name: "Routines Backup", + namespace: "rboy", + author: "RBoy Apps", + description: "Run a verification check to ensure that Routines did they job and if not complete the actions", + category: "Safety & Security", + iconUrl: "http://smartthings.rboyapps.com/images/Routines.png", + iconX2Url: "http://smartthings.rboyapps.com/images/Routines.png", + iconX3Url: "http://smartthings.rboyapps.com/images/Routines.png") + +preferences { + page(name: "mainPage") + page(name: "modeDoorMonitorPage") +} + +private getRoutines() { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + return phrases +} + +private getCheckingDescription(mode) { + def description = "" + + def devices = [ + settings."doors${mode}", + settings."doorsflip${mode}", + settings."garagedoors${mode}", + settings."garagedoorsflip${mode}", + settings."locks${mode}", + settings."locksflip${mode}", + settings."switches${mode}", + settings."switchesflip${mode}", + settings."shades${mode}", + settings."shadesflip${mode}", + settings."valves${mode}", + settings."valvesflip${mode}", + settings."thermostats${mode}", + ] + + for (device in devices) { + description += device ? (description ? "\n " : "") + device?.join("\n ") : "" + } + + if (!description) { // Nothing selected + description = "No devices selected" + } else { + description = "Checking:\n " + description // Add the header + } + + return description +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "Routines Backup v${clientVersion()}", install: true, uninstall: true) { + section() { + paragraph "Click on each Routine below to configure verification checks, notifications and actions" + } + + if (!routines) { + section { + paragraph title: "No Routines found on your SmartThing hub, nothing to configure", required: true, "" + } + } else { + routines.each { mode -> + section { + // Unlock actions for each mode + def hrefParams = [ + mode: mode as String, + name: mode as String, + passed: true + ] + log.trace "Routine $mode" + href(name: "modeDoorMonitor", params: hrefParams, title: "${mode}", page: "modeDoorMonitorPage", description: getCheckingDescription(mode), required: false, image: "http://smartthings.rboyapps.com/images/Routines.png") + } + } + } + + section("General Notification Options") { + input("recipients", "contact", title: "Send notifications to (optional)", multiple: true, required: false) { + input name: "notify", title: "Send push notifications", type: "bool", defaultValue: true, required: false + paragraph "You can enter multiple phone numbers to send an SMS to by separating them with a '*'. E.g. 5551234567*4447654321" + input name: "sms", title: "Send SMS notification to (optional):", type: "phone", required: false + } + input name: "audioDevices", title: "Play notifications on these devices", type: "capability.audioNotification", required: false, multiple: true, image: "http://www.rboyapps.com/images/Horn.png" + } + + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "disableUpdateNotifications", title: "Don't check for new versions of the app", type: "bool", required: false + } + } +} + +def modeDoorMonitorPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def mode = "" + def name = "" + // Get mode from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.mode) { + mode = params.mode + name = params.name + log.trace "Passed from main page, using params lookup for mode $mode, name $name" + } else if (atomicState.params) { + mode = atomicState.params.mode ?: "" + name = atomicState.params.name ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for mode $mode, name $name" + } else { + log.error "Invalid params, no mode found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Mode Door Monitor, mode:$mode, name $name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"modeDoorMonitorPage", title: "Configure notifications and actions after running routine " + (name ?: ""), uninstall: false, install: false) { + section("Choose Door(s) and Window(s)") { + paragraph "Notify if any of these doors or windows are left open/closed" + input "doors${mode}", "capability.contactSensor", title: "Check for Open doors/windows", required: false, multiple:true + input "doorsflip${mode}", "capability.contactSensor", title: "Check for Closed doors/windows", required: false, multiple:true + } + + section("Choose Garage Door(s)") { + paragraph "Notify if any of these selected garage doors are left open/closed" + input "garagedoors${mode}", "capability.garageDoorControl", title: "Check for Open garage doors", required: false, multiple:true, submitOnChange: true + if (settings."garagedoors${mode}") { + input "garagedoorsoff${mode}", "bool", title: "Close them?", required: false + } + input "garagedoorsflip${mode}", "capability.garageDoorControl", title: "Check for Closed garage doors", required: false, multiple:true, submitOnChange: true + if (settings."garagedoorsflip${mode}") { + input "garagedoorsopen${mode}", "bool", title: "Open them?", required: false + } + } + + section("Choose Lock(s)") { + paragraph "Notify if any of these selected locks are Unlocked/Locked" + input "locks${mode}", "capability.lock", title: "Check for Unlocked locks", required: false, multiple:true, submitOnChange: true + if (settings."locks${mode}") { + input "locksoff${mode}", "bool", title: "Lock them?", required: false + } + input "locksflip${mode}", "capability.lock", title: "Check for Locked locks", required: false, multiple:true, submitOnChange: true + if (settings."locksflip${mode}") { + input "locksopen${mode}", "bool", title: "Unlock them?", required: false + } + } + + section("Choose Switch(s)") { + paragraph "Notify if any of these selected switches are On/Off" + input "switches${mode}", "capability.switch", title: "Check for switches left On", required: false, multiple:true, submitOnChange: true + if (settings."switches${mode}") { + input "switchesoff${mode}", "bool", title: "Turn them off?", required: false + } + input "switchesflip${mode}", "capability.switch", title: "Check for switches left Off", required: false, multiple:true, submitOnChange: true + if (settings."switchesflip${mode}") { + input "switcheson${mode}", "bool", title: "Turn them on?", required: false + } + } + + section("Choose Windows Shade/Blind(s)") { + paragraph "Notify if any of these selected window shades/blinds are Open/Closed" + input "shades${mode}", "capability.windowShade", title: "Check for shades left Open", required: false, multiple:true, submitOnChange: true + if (settings."shades${mode}") { + input "shadesoff${mode}", "bool", title: "Close them?", required: false + } + input "shadesflip${mode}", "capability.windowShade", title: "Check for shades left Closed", required: false, multiple:true, submitOnChange: true + if (settings."shadesflip${mode}") { + input "shadeson${mode}", "bool", title: "Open them?", required: false + } + } + + section("Choose Valve(s)") { + paragraph "Notify if any of these selected valves are Open/Closed" + input "valves${mode}", "capability.valve", title: "Check for valves left Open", required: false, multiple:true, submitOnChange: true + if (settings."valves${mode}") { + input "valvesoff${mode}", "bool", title: "Close them?", required: false + } + input "valvesflip${mode}", "capability.valve", title: "Check for valves left Closed", required: false, multiple:true, submitOnChange: true + if (settings."valvesflip${mode}") { + input "valveson${mode}", "bool", title: "Open them?", required: false + } + } + + section("Choose Thermostat(s)") { + paragraph "Notify the thermostat heat/cool setpoints do not match" + input "thermostats${mode}", "capability.thermostat", title: "Check Thermostats setpoint", required: false, multiple:true, submitOnChange: true + if (settings."thermostats${mode}") { + input "heatingSetpoint${mode}", "decimal", title: "...Heating", required: true + input "coolingSetpoint${mode}", "decimal", title: "...Cooling", required: true + input "thermostatsSet${mode}", "bool", title: "Update setpoints?", required: false + } + } + + section("Check Mode") { + paragraph "Notify the hub Mode has not changed" + input "targetMode${mode}", "mode", title: "New mode", description: "Select the target hub mode", required: false, multiple: false, submitOnChange: true + if (settings."targetMode${mode}") { + input "targetModeSet${mode}", "bool", title: "Change mode?", required: false + } + } + + section("Run Routine") { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + input "homePhrase${mode}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: settings."homePhrase${mode}" + } + + section("Routine Verification and Notifications Options") { + paragraph "Use this option to delay before checking/taking actions for any devices after the routine runs\nThis can allow for an exit delay or for apps to complete their tasks" + input "delayAction${mode}", "number", title: "Delay by (seconds)", required: false, range: "1..*" + + paragraph "Enabling routine specific notifications will override over any general notifications defined on the first page" + input "modeOverrideNotifications${mode}", "bool", title: "Enable $name routine specific notifications", required: false, submitOnChange: true + if (settings."modeOverrideNotifications${mode}") { + input("recipients${mode}", "contact", title: "Send notifications to (optional)", multiple: true, required: false) { + input "notify${mode}", "bool", title: "Send push notifications", defaultValue: true, required: false + paragraph "You can enter multiple phone numbers to send an SMS to by separating them with a '*'. E.g. 5551234567*4447654321" + input "sms${mode}", "phone", title: "Send SMS notification to (optional):", required: false + } + input "audioDevices${mode}", "capability.audioNotification", title: "Play notifications on these devices", required: false, multiple: true, image: "http://www.rboyapps.com/images/Horn.png" + } + } + } +} + +def installed() { + log.debug "Installed" + + subscribeToEvents() +} + +def updated() { + log.debug "Updated" + + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace settings + + state.queuedActions = [] // initialize it + + subscribe(location, "routineExecuted" , modeChangeHandler) + + // Check for new versions of the code + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Integer randomDayOfWeek = random.nextInt(7-1) + 1 // 1 to 7 + schedule("0 0 " + randomHour + " ? * " + randomDayOfWeek, checkForCodeUpdate) // Check for code updates once a week at a random day and time between 10am and 6pm +} + +def modeChangeHandler(evt = null) { + if (evt && (evt.name != "routineExecuted")) { // This is not a routine notification + log.warn "Invalid event notification received, event name: ${evt.name}" + return + } + + unschedule(modeChangeHandler) // If there are any pending from previous runs cancel them + log.debug "Routine run notification, ${evt ? "name: ${evt.displayName}, description: ${evt.descriptionText}, SmartAppId: ${evt.value}" : "delayed checking/actions"}" + + def mode = evt.displayName // This should the routine name that was run + + // Check if we need a delayed actions for this state + if (evt && settings."delayAction${mode}") { + log.trace "Delaying checking/actions by ${settings."delayAction${mode}"} seconds" + runIn(settings."delayAction${mode}", modeChangeHandler) // It automatically overwrites old schedules so we only get the latest + return + } + + def msgs = [] // Messages to send + + // Check for open doors/windows left open + if (settings."doors${mode}") { + def message = "" + for (door in settings."doors${mode}") { + if (door.currentValue("contact") == "open") { + message += ", $door" + } else { + log.debug "$door is currently Closed" + } + } + + if(message) { + message = "These doors were Open after routine $mode completed" + message + log.info message + msgs << message + } + } else { + log.trace "No doors/windows found to check for open after routine $mode completed" + } + + // Check for open doors/windows left closed + if (settings."doorsflip${mode}") { + def message = "" + for (door in settings."doorsflip${mode}") { + if (door.currentValue("contact") == "closed") { + message += ", $door" + } else { + log.debug "$door is currently Open" + } + } + + if(message) { + message = "These doors were Closed after routine $mode completed" + message + log.info message + msgs << message + } + } else { + log.trace "No doors/windows found to check for closed after routine $mode completed" + } + + // Check for switches left on + if (settings."switches${mode}") { + def message = "" + for (switchs in settings."switches${mode}") { + if (switchs.currentValue("switch") == "on") { + message += ", $switchs" + if (settings."switchesoff${mode}") { + switchs.off() + } + } else { + log.debug "$switchs is currently Off" + } + } + + if(message) { + if (settings."switchesoff${mode}") { + message = "These switches were On after routine $mode completed, turning them Off" + message + } else { + message = "These switches were On after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No switches found to check for on after routine $mode completed" + } + + // Check for switches left off + if (settings."switchesflip${mode}") { + def message = "" + for (switchs in settings."switchesflip${mode}") { + if (switchs.currentValue("switch") == "off") { + message += ", $switchs" + if (settings."switcheson${mode}") { + switchs.on() + } + } else { + log.debug "$switchs is currently On" + } + } + + if(message) { + if (settings."switcheson${mode}") { + message = "These switches were Off after routine $mode completed, turning them On" + message + } else { + message = "These switches were Off after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No switches found to check for off after routine $mode completed" + } + + // Check for locks left unlocked + if (settings."locks${mode}") { + def message = "" + for (lock in settings."locks${mode}") { + if (lock.currentValue("lock") == "unlocked") { + message += ", $lock" + if (settings."locksoff${mode}") { + lock.lock() + } + } else { + log.debug "$lock is currently Locked" + } + } + + if(message) { + if (settings."locksoff${mode}") { + message = "These locks were Unlocked after routine $mode completed, Locking them" + message + } else { + message = "These locks were Unlocked after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No locks found to check for unlocked after routine $mode completed" + } + + // Check for locks left locked + if (settings."locksflip${mode}") { + def message = "" + for (lock in settings."locksflip${mode}") { + if (lock.currentValue("lock") == "locked") { + message += ", $lock" + if (settings."locksopen${mode}") { + lock.unlock() + } + } else { + log.debug "$lock is currently Unlocked" + } + } + + if(message) { + if (settings."locksopen${mode}") { + message = "These locks were Locked after routine $mode completed, Unlocking them" + message + } else { + message = "These locks were Locked after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No locks found to check for locked after routine $mode completed" + } + + // Check for garage doors left open + if (settings."garagedoors${mode}") { + def message = "" + for (garagedoor in settings."garagedoors${mode}") { + if (garagedoor.currentValue("door") != "closed") { + message += ", $garagedoor" + if (settings."garagedoorsoff${mode}") { + garagedoor.close() + } + } else { + log.debug "$garagedoor is currently Closed" + } + } + + if(message) { + if (settings."garagedoorsoff${mode}") { + message = "These doors were Open after routine $mode completed, Closing them" + message + } else { + message = "These doors were Open after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No garage doors found to check for open after routine $mode completed" + } + + // Check for garage doors left closed + if (settings."garagedoorsflip${mode}") { + def message = "" + for (garagedoor in settings."garagedoorsflip${mode}") { + if (garagedoor.currentValue("door") != "open") { + message += ", $garagedoor" + if (settings."garagedoorsopen${mode}") { + garagedoor.open() + } + } else { + log.debug "$garagedoor is currently Open" + } + } + + if(message) { + if (settings."garagedoorsopen${mode}") { + message = "These doors were Closed after routine $mode completed, Opening them" + message + } else { + message = "These doors were Closed after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No garage doors found to check for closed after routine $mode completed" + } + + // Check for shades left open + if (settings."shades${mode}") { + def message = "" + for (shade in settings."shades${mode}") { + if (shade.currentValue("windowShade") != "closed") { + message += ", $shade" + if (settings."shadesoff${mode}") { + shade.close() + } + } else { + log.debug "$shade is currently Closed" + } + } + + if(message) { + if (settings."shadesoff${mode}") { + message = "These shades were Open after routine $mode completed, Closing them" + message + } else { + message = "These shades were Open after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No window shades found to check for open after routine $mode completed" + } + + // Check for window shades left closed + if (settings."shadesflip${mode}") { + def message = "" + for (shade in settings."shadesflip${mode}") { + if (shade.currentValue("windowShade") != "open") { + message += ", $shade" + if (settings."shadeson${mode}") { + shade.open() + } + } else { + log.debug "$shade is currently Open" + } + } + + if(message) { + if (settings."shadeson${mode}") { + message = "These shades were Closed after routine $mode completed, Opening them" + message + } else { + message = "These shades were Closed after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No window shades found to check for closed after routine $mode completed" + } + + // Check for valves left open + if (settings."valves${mode}") { + def message = "" + for (valve in settings."valves${mode}") { + if (valve.currentValue("valve") == "open" || valve.currentValue("contact") == "open") { // ST doesn't use valve due to firmware bug - https://community.smartthings.com/t/documentation-error-for-valve-or-platform-bug/88037/19 + message += ", $valve" + if (settings."valvesoff${mode}") { + valve.close() + } + } else { + log.debug "$valve is currently Closed" + } + } + + if(message) { + if (settings."valvesoff${mode}") { + message = "These valves were Open after routine $mode completed, Closing them" + message + } else { + message = "These valves were Open after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No valves found to check for open after routine $mode completed" + } + + // Check for valves left closed + if (settings."valvesflip${mode}") { + def message = "" + for (valve in settings."valvesflip${mode}") { + if (valve.currentValue("valve") == "closed" || valve.currentValue("contact") == "closed") { // ST doesn't use valve firmware bug - https://community.smartthings.com/t/documentation-error-for-valve-or-platform-bug/88037/19 + message += ", $valve" + if (settings."valveson${mode}") { + valve.open() + } + } else { + log.debug "$valve is currently Open" + } + } + + if(message) { + if (settings."valveson${mode}") { + message = "These valves were Closed after routine $mode completed, Opening them" + message + } else { + message = "These valves were Closed after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No valves found to check for closed after routine $mode completed" + } + + // Check Thermostat setpoints + if (settings."thermostats${mode}") { + // Heating setpoint + def message = "" + for (device in settings."thermostats${mode}") { + if (device.currentValue("heatingSetpoint") != settings."heatingSetpoint${mode}") { + message += ", $device" + if (settings."thermostatsSet${mode}") { + device.setHeatingSetpoint(settings."heatingSetpoint${mode}") + } + } else { + log.debug "$device heating setpoint is set to ${settings."heatingSetpoint${mode}"}" + } + } + + if(message) { + if (settings."thermostatsSet${mode}") { + message = "These thermostats heating setpoint were not set to ${settings."heatingSetpoint${mode}"} after routine $mode completed, updating them" + message + } else { + message = "These thermostats heating setpoint were not set to ${settings."heatingSetpoint${mode}"} after routine $mode completed" + message + } + log.info message + msgs << message + } + + // Cooling setpoint + message = "" + for (device in settings."thermostats${mode}") { + if (device.currentValue("coolingSetpoint") != settings."coolingSetpoint${mode}") { + message += ", $device" + if (settings."thermostatsSet${mode}") { + device.setCoolingSetpoint(settings."coolingSetpoint${mode}") + } + } else { + log.debug "$device cooling setpoint is set to ${settings."coolingSetpoint${mode}"}" + } + } + + if(message) { + if (settings."thermostatsSet${mode}") { + message = "These thermostats cooling setpoint were not set to ${settings."coolingSetpoint${mode}"} after routine $mode completed, updating them" + message + } else { + message = "These thermostats cooling setpoint were not set to ${settings."coolingSetpoint${mode}"} after routine $mode completed" + message + } + log.info message + msgs << message + } + } else { + log.trace "No thermostats found to check setpoints after routine $mode completed" + } + + // Check if Mode has changed + if (settings."targetMode${mode}") { + def message = "" + if (!settings."targetMode${mode}".contains(location.mode)) { + message += "Hub was not in Mode ${settings."targetMode${mode}"} after routine $mode completed" + if (settings."targetModeSet${mode}") { + if (location.modes?.contains(settings."targetMode${mode}")) { + setLocationMode(settings."targetMode${mode}") + message += ", changing mode to ${settings."targetMode${mode}"}" + } else { + message += ", cannot change Mode as new mode is invalid" + } + } + } else { + log.debug "Hub Mode is set to ${location.mode}" + } + + if(message) { + log.info message + msgs << message + } + } else { + log.trace "No target Mode to check after routine $mode completed" + } + + + // Run routine if required, do this last as it can timeout + if (settings."homePhrase${mode}") { + def message = "Running routine ${settings."homePhrase${mode}"} after routine $mode completed" + location.helloHome.execute(settings."homePhrase${mode}") + + if(message) { + log.info message + msgs << message + } + } else { + log.trace "No routines to run after routine $mode completed" + } + + // Last this to do is send messages since these can time out + log.trace "Sending messages" + + for (message in msgs) { + if (settings."modeOverrideNotifications${mode}") { + log.trace "Using mode specific notifications" + sendMessages(mode, message) + } else { + log.trace "Using general notifications" + sendMessages(null, message) + } + } +} + +private sendText(number, message) { + if (number) { + def phones = number.split("\\*") + for (phone in phones) { + sendSms(phone, message) + } + } +} + +private sendMessages(mode, message) { + if (mode) { + if (location.contactBookEnabled) { + sendNotificationToContacts(message, settings."recipients${mode}") + } else { + if (settings."notify${mode}") { + sendPush message + } + + if (settings."sms${mode}") { + sendText(settings."sms${mode}", message) + } + } + if (settings."audioDevices${mode}") { + settings."audioDevices${mode}"*.playTextAndResume(message) + } + } else { + if (location.contactBookEnabled) { + sendNotificationToContacts(message, recipients) + } else { + if (notify) { + sendPush message + } + + if (sms) { + sendText(sms, message) + } + } + if (settings."audioDevices") { + settings."audioDevices"*.playTextAndResume(message) + } + } +} + +def checkForCodeUpdate(evt) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "Routines Backup" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (!disableUpdateNotifications) { + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def caps = [] + routines.each { mode, name -> + caps.add(settings."doors${mode}") + caps.add(settings."doorsflip${mode}") + caps.add(settings."garagedoors${mode}") + caps.add(settings."garagedoorsflip${mode}") + caps.add(settings."locks${mode}") + caps.add(settings."locksflip${mode}") + caps.add(settings."switches${mode}") + caps.add(settings."switchesflip${mode}") + caps.add(settings."shades${mode}") + caps.add(settings."shadesflip${mode}") + caps.add(settings."valves${mode}") + caps.add(settings."valvesflip${mode}") + caps.add(settings."thermostats${mode}") + } + caps?.each { + def devices = it?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (!disableUpdateNotifications) { + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} + + +// THIS IS THE END OF THE FILE \ No newline at end of file diff --git a/smartapps/rboy/smartweather-station-tile-updater.src/smartweather-station-tile-updater.groovy b/smartapps/rboy/smartweather-station-tile-updater.src/smartweather-station-tile-updater.groovy new file mode 100644 index 00000000000..a6cd3bf97eb --- /dev/null +++ b/smartapps/rboy/smartweather-station-tile-updater.src/smartweather-station-tile-updater.groovy @@ -0,0 +1,193 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { + return "01.07.03" +} + +/** +* Weather Station Controller +* +* Copyright RBoy Apps +* 2017-10-13 - (v01.07.03) Patch for Android 2.7.0 app broken causing error +* 2017-10-09 - (v01.07.02) Patch for platform update +* 2017-10-03 - Delayed check to avoid error +* 2017-5-26 - Added automatic update notifications +* 2016-3-3 - Set to use runEvery5Minutes and hopefully more reliable +* 2016-2-12 - Changed scheduling API's (hopefully more resilient), added an option for users to specify update interval +* 2016-1-20 - Kick start timers on sunrise and sunset also +* 2015-10-4 - Kick start timers on each mode change to prevent them from dying +* 2015-7-12 - Simplified app, udpates every 5 minutes now (hopefully more reliable) +* 2015-7-17 - Improved reliability when mode changes +* 2015-6-6 - Bugfix for timers not scheduling, keep only one timer +* Added support to update multiple devices +* Added support for frequency of updates +* +*/ + +definition( + name: "SmartWeather Station Tile Updater", + namespace: "rboy", + author: "RBoy Apps", + description: "Updates SmartWeather Station Tile devices every 5 minutes. This contains a bug fix for the updates stops when user select custom modes and it updates every 5 minutes", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-MindYourHome.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-MindYourHome@2x.png" +) + +preferences { + // dynamic pages broken in Android 2.7.0 for type device.smartweatherStationTile, revert to static page for now + //page(name: "mainPage") + section ("Weather Devices") { + input name: "weatherDevices", type: "device.smartweatherStationTile", title: "Select Weather Device(s)", description: "Select the Weather Tiles to update", required: true, multiple: true + //input name: "updateInterval", type: "number", title: "Enter update frequency (minutes)", description: "How often do you want to update the weather information", range: "1..*", required: true, defaultValue: 5 + } + + section() { + input name: "disableUpdateNotifications", title: "Don't check for new versions of the app", type: "bool", required: false + } + + section("Name & Operating Modes (optional)") { + } +} + +/*def mainPage() { + log.trace "$settings" + + dynamicPage(name: "mainPage", title: "SmartWeather Station Tile Updater v${clientVersion()}", install: true, uninstall: true) { + section ("Weather Devices") { + input name: "weatherDevices", type: "device.smartweatherStationTile", title: "Select Weather Device(s)", description: "Select the Weather Tiles to update", required: true, multiple: true + //input name: "updateInterval", type: "number", title: "Enter update frequency (minutes)", description: "How often do you want to update the weather information", range: "1..*", required: true, defaultValue: 5 + } + + section("Operating Modes (optional)") { + mode title: "Enable only when in this mode(s)", required: false, multiple: true + } + + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "disableUpdateNotifications", title: "Don't check for new versions of the app", type: "bool", required: false + } + } +}*/ + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + // Check for new versions of the code + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Integer randomDayOfWeek = random.nextInt(7-1) + 1 // 1 to 7 + schedule("0 0 " + randomHour + " ? * " + randomDayOfWeek, checkForCodeUpdate) // Check for code updates once a week at a random day and time between 10am and 6pm + + subscribe(location, modeChangeHandler) + subscribe(location, "sunset", modeChangeHandler) + subscribe(location, "sunrise", modeChangeHandler) + runEvery5Minutes(scheduledEvent) + runIn(1, scheduledEvent) +} + +def modeChangeHandler(evt) { + log.debug "Reinitializing refresh timers on mode change notification, new mode $evt.value" + runEvery5Minutes(scheduledEvent) + runIn(1, scheduledEvent) +} + +def scheduledEvent() { + //log.trace "Refresh weather, update frequency $updateInterval minutes" + log.trace "Refresh weather, update frequency 5 minutes" + //runIn(updateInterval*60, scheduledEvent) + weatherDevices*.refresh() +} + +def checkForCodeUpdate(evt) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "SmartWeather Station Tile Updater" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (!disableUpdateNotifications) { + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def caps = [] + caps?.each { + def devices = it?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (!disableUpdateNotifications) { + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} \ No newline at end of file diff --git a/smartapps/rboy/user-unlock-lock-door-notifications-and-actions.src/user-unlock-lock-door-notifications-and-actions.groovy b/smartapps/rboy/user-unlock-lock-door-notifications-and-actions.src/user-unlock-lock-door-notifications-and-actions.groovy new file mode 100644 index 00000000000..6047a877346 --- /dev/null +++ b/smartapps/rboy/user-unlock-lock-door-notifications-and-actions.src/user-unlock-lock-door-notifications-and-actions.groovy @@ -0,0 +1,2000 @@ +/* + * ----------------------- + * ------ SMART APP ------ + * ----------------------- + * + * STOP: Do NOT PUBLISH the code to GitHub, it is a VIOLATION of the license terms. + * You are NOT allowed to share, distribute, reuse or publicly host (e.g. GITHUB) the code. Refer to the license details on our website. + * + */ + +/* **DISCLAIMER** +* THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* Without limitation of the foregoing, Contributors/Regents expressly does not warrant that: +* 1. the software will meet your requirements or expectations; +* 2. the software or the software content will be free of bugs, errors, viruses or other defects; +* 3. any results, output, or data provided through or generated by the software will be accurate, up-to-date, complete or reliable; +* 4. the software will be compatible with third party software; +* 5. any errors in the software will be corrected. +* The user assumes all responsibility for selecting the software and for the results obtained from the use of the software. The user shall bear the entire risk as to the quality and the performance of the software. +*/ + +def clientVersion() { + return "04.05.02" +} + +/** +* User Door Unlock/Lock Notifications and Actions. Mirror of Multi User Lock Code Mgmt without the code programming, reuse existing lock programming +* +* Copyright RBoy Apps, redistribution or reuse of code is not allowed without permission +* +* Change Log: +* 2018-12-13 - (v04.05.02) Improve reinitialization after new version detected (requires app to be opened and saved to initiate changes) +* 2018-12-04 - (v04.05.01) Improve UI layout, separate page for notifications +* 2018-11-26 - (v04.05.00) Sync up actions with baseline 07.08.00 +* 2018-09-11 - (v04.04.02) Sync up actions with baseline 07.07.06 +* 2018-08-20 - (v04.04.01) Improve text for automatic relock +* 2018-08-01 - (v04.04.00) Added support for Arming/disarming ADT and resume playing the audio after notifications +* 2018-06-27 - (v04.03.00) Added support for custom user notifications settings, limiting number of notifications and keypads +* 2018-05-31 - (v04.02.04) Correct RFID case to capitals +* 2018-05-11 - (v04.02.03) Update for platform not saving sms settings for individual users, fixed arming to Arm Stay +* 2018-02-21 - (v04.02.01) Added support to arm SHM to Home when locking via keypad +* 2018-02-14 - (v04.02.00) Improved user configuration experience +* 2018-02-05 - (v04.01.00) Added support for locking/unlocking locks and opening/closing garage doors for lock/unlock actions. Added support to change modes for lock actions. Door sensor is now optional for relocking with timeout +* 2018-02-01 - (v04.00.04) Fix for ST breaking selection of Chime devices with mobile app 2.4.13 +* 2018-01-21 - (v04.00.03) Added support for older device handlers to avoid a failure if users forgot to update the device handler +* 2018-01-17 - (v04.00.02) Added support for maxCodes, fix for ST Mobile app changes +* 2017-12-15 - (v04.00.01) Fix for manual unlock notifications not being sent +* 2017-12-04 - (v04.00.00) Added support for stock handler using lock codes, added support for manual lock and unlock actions +* 2017-11-14 - (v04.00.00) Added support for new ST stock DTH, improved code deletion confirmations +* 2017-11-08 - (v03.07.00) Added support for delayed lock actions and added support to arm SHM on lock and added icons and simplified the UI +* 2017-10-18 - (v03.06.00) Added check for disabling hardware autolock to use smartapp autorelock and autounlock features, fixed bug with notify modes, simplified UI +* 2017-05-31 - (v03.05.00) Improved deadbolt automatic unlocking for Schlage locks, added support for playing back on audio systems, added support for bluetooth +* 2017-05-29 - (v03.04.03) Bugfix for unlocking and locking without codes throwing an error +* 2017-05-26 - (v03.04.02) Due to ST phone changes, now separate multiple SMS numbers with a * +* 2017-04-19 - (v03.04.01) Patch for reporting Master Codes +* 2017-04-11 - (v03.04.00) Added ability to select Chimes for when doors are opened and closed, improved user interface/text, added user presence based notifications +* 2017-04-11 - (v03.03.01) Fixed grammar for messages +* 2017-01-25 - (v3.3.0) Added ability to run lock/unlock actions on when the specified users aren't present or not in specific modes, also less verbose messages unless detailed notifications are enabled while running lock and unlock actions +* 2017-01-12 - (v3.2.2) Added ability to report and run actions on unknown users (some locks don't report use code when unlocking via keypad) and master codes +* 2016-11-14 - Improved code update checks and now use ; instead of + for separating multiple SMS numbers +* 2016-10-30 - Fixed an issue with notifications not working and added code update notifications +* 2016-09-26 - Fix for broken ST phrases returning null data +* 2016-09-17 - Fixed issue with cannot change notify settings, highlight error messages, improved invalid date checks, code clean up +* 2016-09-16 - Fix for actions page not showing up when there are no routines defined on the hub +* 2016-09-16 - Patch for broken HREF pages in ST app 2.2.0 +* 2016-09-13 - Bug fix when mode changes and no sensor is defined for door +* 2016-09-07 - Auto locks and open door notifications will engage if requried when mode changes +* 2016-09-02 - Added ability to specify modes of operations for auto door lock +* 2016-08-30 - Fixed bug with disable all push notifications, it should not disable text notifications and should only work when there is no contact address book +* 2016-08-17 - Added workaround for ST contact address book bug +* 2016-08-06 - Added support to auto relock door if the door hasn't been opened after specified timeout +* 2016-08-05 - Fix for autoRelock and openDoor notifications errors when using large timeout values +* 2016-07-25 - Added support for working with RFID cards +* 2016-07-22 - Added support for contact address book for customers who have this feature enabled +* 2016-07-19 - Updated code to use harmonized universal DH with type event instead of outsideLockEvent +* 2016-07-17 - Improvement to the unlocking and relocking logic +* 2016-07-17 - Workaround for platform calling installed and updated when installing the SmartApp +* 2016-07-17 - Put in a check to not enable automatic unlock if autolock in the door is enabled +* 2016-07-14 - Improved notifications +* 2016-07-13 - Added support for tamper events and when using user codes to lock the door from the keypad +* 2016-07-10 - Added support for running routine when the door is locked using external keypad lock button +* 2016-07-05 - Added support for notifications if door is left open, added support for delayed relock for multiple door and various minor UI improvements +* 2016-07-05 - Added client version on main page +* 2016-04-08 - Added support for notification modes for unlocking (and improved UI) +* 2016-04-08 - Bugfix for jammed and manual lock notifications not coming +* 2016-02-20 - Added slot notification for unknown users +* 2016-02-07 - Revamped from scratch +* +*/ +definition( + name: "User Unlock/Lock Door Notifications and Actions", + namespace: "rboy", + author: "RBoy Apps", + description: "Execute actions when users unlock/lock doors", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Solution/doors-locks-active.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Solution/doors-locks-active@2x.png" +) + +preferences { + page(name: "setupApp") + page(name: "usersPage") + page(name: "notificationsPage") + page(name: "unlockLockActionsPage") + page(name: "unlockKeypadActionsPage") + page(name: "unlockManualActionsPage") + page(name: "armKeypadActionsPage") + page(name: "lockKeypadActionsPage") + page(name: "lockManualActionsPage") + page(name: "openCloseDoorPage") + page(name: "userConfigPage") +} + +def setupApp() { + log.trace "$settings" + + dynamicPage(name: "setupApp", title: "User Door Unlock/Lock Notifications and Actions v${clientVersion()}", install: true, uninstall: true) { + if (state.clientVersion) { // If the app has already been installed + section("Select Lock(s)") { + input "locks","capability.lock", title: "Lock(s)", multiple: true, submitOnChange: true, image: "http://www.rboyapps.com/images/HandleLock.png" + } + + section("How many users do you want to monitor?") { + def maxCodes = 0 + for (lock in locks) { + Integer lockMax = lock.hasAttribute("maxCodes") ? lock.currentValue("maxCodes") : 0 + log.trace "$lock has max users: $lockMax" + maxCodes = maxCodes ? (lockMax ? Math.max(lockMax, maxCodes) as Integer : maxCodes) : (lockMax ?: 0) // Take the highest amongst all selected locks + } + input name: "maxUserNames", title: "Number of users${maxCodes ? " (1 to ${maxCodes})" : ""}", type: "number", required: true, multiple: false, image: "http://www.rboyapps.com/images/Users.png", range: "1..${maxCodes ?: 999}" + href(name: "users", title: "Manage users", page: "usersPage", description: "User names and custom actions", required: false, image: "http://www.rboyapps.com/images/User.png") + } + + section("General Settings") { + // Unlock actions for all users (global) + def hrefParams = [ + user: null, + passed: true + ] + href(name: "unlockLockActions", params: hrefParams, title: "Lock/unlock actions", page: "unlockLockActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/LockUnlock.png") + href(name: "openCloseDoor", title: "Door open/close actions", page: "openCloseDoorPage", description: "", required: false, image: "http://www.rboyapps.com/images/DoorOpenClose.png") + href(name: "notifications", params: hrefParams, title: "Notifications", page: "notificationsPage", description: "", required: false, image: "http://www.rboyapps.com/images/NotificationsD.png") + } + + section() { + label title: "Assign a name for this SmartApp (optional)", required: false + input name: "updateNotifications", title: "Check for new versions of the app", type: "bool", defaultValue: true, required: false + } + + section("Advanced Options (optional)", hideable: true, hidden: true) { + paragraph "Enable this to get additional detailed notifications about lock/unlock/open/close actions being executed" + input name: "detailedNotifications", title: "Get detailed notifications", type: "bool", defaultValue: "false", required: false + } + } else { + section() { + paragraph "Click on 'Save' to install the app\n\nThen you can open it from the 'Automations' tab to finish configuring it" + label title: "Assign a name for this SmartApp (optional)", required: false + } + } + + remove("Uninstall") + } +} + +def notificationsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user $user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Notifications Page, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"notificationsPage", title: (user ? "Setup custom notifications for ${name ?: "user ${user}"}" : "Setup notification options"), uninstall: false, install: false) { + section { + input "audioDevices${user}", "capability.audioNotification", title: "Play notifications on these devices", required: false, multiple: true, image: "http://www.rboyapps.com/images/Horn.png" + input("recipients${user}", "contact", title: "Send notifications to", multiple: true, required: false, image: "http://www.rboyapps.com/images/Notifications.png") { + paragraph "You can enter multiple phone numbers by separating them with a '*'\nE.g. 5551234567*+448747654321" + input "sms${user}", "phone", title: "Send SMS notification to", required: false, image: "http://www.rboyapps.com/images/Notifications.png" + input "disableAllNotify${user}", "bool", title: "Disable all push notifications${user ? " for " + (name ?: "user ${user}") : ""}", defaultValue: false, required: false + } + } + } +} + +def openCloseDoorPage() { + dynamicPage(name:"openCloseDoorPage", title: "Select door open/close sensor for each door and configure the automatic unlock, relock and notifications of the door", uninstall: false, install: false) { + section { + for (lock in locks) { + def priorRelockDoor = settings."relockDoor${lock}" + def priorRelockImmediate = settings."relockImmediate${lock}" + def priorRelockAfter = settings."relockAfter${lock}" + def priorRetractDeadbolt = settings."retractDeadbolt${lock}" + def priorNotifyOpen = settings."openNotify${lock}" + def priorNotifyOpenTimeout = settings."openNotifyTimeout${lock}" + def priorOpenNotifyModes = settings."openNotifyModes${lock}" + def priorRelockDoorModes = settings."relockDoorModes${lock}" + def priorNotifyBeep = settings."openNotifyBeep${lock}" + def priorSensor = settings."sensor${lock}" + def reqDoorSensor = priorRelockImmediate || priorRetractDeadbolt || priorNotifyOpen || priorNotifyBeep + + paragraph title: "Configure ${lock}", required: true, "" + if (priorRelockDoor || priorRetractDeadbolt || priorNotifyOpen || priorNotifyBeep) { + input "sensor${lock}", "capability.contactSensor", title: "Door open/close sensor${reqDoorSensor ? "" : " (optional)"}", required: ( reqDoorSensor ? true : false), submitOnChange: true // required for deadbolt, immediate relock or notifications + } + + // Sanity check do not offer AutoLock is hardware autoLock is engaged + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { + paragraph title: "Disable AutoLock on physical lock to use SmartApp AutoReLock and AutoUnlock features", required: true, "" + } else { + input "relockDoor${lock}", "bool", title: "Relock door automatically", defaultValue: priorRelockDoor, required: false, submitOnChange: true + if (priorRelockDoor) { + input "relockImmediate${lock}", "bool", title: "Relock immediately after closing", defaultValue: priorRelockImmediate, required: false, submitOnChange: true + if (!priorRelockImmediate) { + input "relockAfter${lock}", "number", title: "Relock after ${priorSensor ? "closing" : "unlocking"} (minutes)", defaultValue: priorRelockAfter, required: true + } + input "relockDoorModes${lock}", "mode", title: "...only when in this mode(s) (optional)", defaultValue: priorRelockDoorModes, required: false, multiple: true + } + if (priorRetractDeadbolt) { + paragraph "NOTE: Make sure the AutoLock feature on the lock is disabled to avoid an infinite locking/unlocking loop.", required: false, submitOnChange: true + } + input "retractDeadbolt${lock}", "bool", title: "Unlock door if locked while open", defaultValue: priorRetractDeadbolt, description: "This retracts the deadbolt if it extends while the door is still open", required: false, submitOnChange: true + } + + input "openNotifyBeep${lock}", "capability.tone", title: "Ring chime when door is opened", multiple: true, required: false, submitOnChange: true + input "openNotify${lock}", "bool", title: "Notify if door has been left open", defaultValue: priorNotifyOpen, required: false, submitOnChange: true + if (priorNotifyOpen) { + input "openNotifyTimeout${lock}", "number", title: "...for (minutes)", defaultValue: priorNotifyOpenTimeout, required: true, range: "1..*" + } + if (priorNotifyOpen || priorNotifyBeep) { + input "openNotifyModes${lock}", "mode", title: "...only when in this mode(s) (optional)", defaultValue: priorOpenNotifyModes, required: false, multiple: true + } + + paragraph "\r\n" + } + } + } +} + +def unlockLockActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user $user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Lock/Unlock Action Page, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"unlockLockActionsPage", title: (user ? "Setup custom actions/notifications for ${name ?: "user ${user}"}" : "Setup lock/unlock actions for each door"), uninstall: false, install: false) { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + def showActions = true + if (!phrases) { + log.warn "No Routines found!!!" + } + section { + if (user) { // User specific override options + paragraph "Enabling custom user actions and notifications will override over the general actions defined on the first page" + input "userOverrideUnlockActions${user}", "bool", title: "Define custom actions for ${name ?: "user ${user}"}", required: true, submitOnChange: true + if (!settings."userOverrideUnlockActions${user}") { // Check if user has enabled specific override actions then show menu + showActions = false + } + } + if (showActions && locks?.size() > 1) { + input "individualDoorActions${user}", "bool", title: "Separate actions for each door", required: true, submitOnChange: true + } + } + if (showActions) { // Do we need to show actions? + if (settings."individualDoorActions${user}") { + for (lock in locks) { + section ("$lock", hideable: false) { + def hrefParams = [ + user: user, + lock: lock as String, + passed: true + ] + href(name: "unlockKeypadActions${lock}", params: hrefParams, title: "Keypad Unlock Actions", page: "unlockKeypadActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/KeypadUnlocked.png") + href(name: "lockKeypadActions${lock}", params: hrefParams, title: "Keypad Lock Actions", page: "lockKeypadActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/KeypadLocked.png") + if (!user) { + href(name: "unlockManualActions${lock}", params: hrefParams, title: "Manual Unlock Actions", page: "unlockManualActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/ManualUnlocked.png") + href(name: "lockManualActions${lock}", params: hrefParams, title: "Manual Lock Actions", page: "lockManualActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/ManualLocked.png") + } + } + } + } else { + section("", hideable: false) { + def hrefParams = [ + user: user, + lock: "", + passed: true + ] + href(name: "unlockKeypadActions", params: hrefParams, title: "Keypad Unlock Actions", page: "unlockKeypadActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/KeypadUnlocked.png") + href(name: "lockKeypadActions", params: hrefParams, title: "Keypad Lock Actions", page: "lockKeypadActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/KeypadLocked.png") + if (!user) { + href(name: "unlockManualActions", params: hrefParams, title: "Manual Unlock Actions", page: "unlockManualActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/ManualUnlocked.png") + href(name: "lockManualActions", params: hrefParams, title: "Manual Lock Actions", page: "lockManualActionsPage", description: "", required: false, image: "http://www.rboyapps.com/images/ManualLocked.png") + } + } + } + } + section { + if (user && settings."userNotify${user}") { // User specific override notification options + def showCustomNotifications = true + input "userOverrideNotifications${user}", "bool", title: "Define custom notifications for ${name ?: "user ${user}"}", required: true, submitOnChange: true + if (!settings."userOverrideNotifications${user}") { // Check if user has enabled specific override actions then show menu + showCustomNotifications = false + } + if (showCustomNotifications) { + def hrefParams = [ + user: user, + passed: true + ] + href(name: "notifications", params: hrefParams, title: "Notifications", page: "notificationsPage", description: "", required: false, image: "http://www.rboyapps.com/images/NotificationsD.png") + } + } + } + } +} + +def unlockKeypadActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for user $user, lock $lock" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user, lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Keypad Unlock Action Page, user:$user, name:$name, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"unlockKeypadActionsPage", title: "Setup keypad unlock actions for doors" + (user ? " for user $name." : ""), uninstall: false, install: false) { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + if (!phrases) { // This should not happen, it should be taken care of in parent page + log.warn "No Routines found!!!" + } + + section ("Door Keypad Unlock Actions${lock ? " for $lock" : ""}") { + def priorHomePhrase = settings."homePhrase${lock}${user}" + def priorHomeMode = settings."homeMode${lock}${user}" + def isLockKeypad = locks?.find{ it.name == lock }?.hasAttribute("armMode") // Check if the current lock (lock specific option) is a keypad + def isAnyLockKeypad = locks?.any { keypad -> keypad.hasAttribute("armMode") } // Check if any lock (for global options) is a keypad + def areAllLockKeypad = locks?.every { keypad -> keypad.hasAttribute("armMode") } // Check every lock (for global options) is a keypad + + paragraph "Run these actions when a user successfully unlocks the door using a code" + if (lock ? isLockKeypad : isAnyLockKeypad) { // Show only if we have a supported keypad (for selected lock or for general settings) + input "keypadArmDisarm${lock}${user}", "bool", title: "Control SHM/ADT using keypad", required: false, submitOnChange: true + } + if (lock ? (isLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true) : (areAllLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true)) { // Hide only if we have have a supported keypad for selected lock and using keypad to control SHM + input "homeDisarm${lock}${user}", "bool", title: "Disarm Smart Home Monitor", required: false + input "adtDisarm${lock}${user}", "bool", title: "Disarm ADT", required: false, submitOnChange: true + } + if (((lock ? (isLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true) : (areAllLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true)) && settings."adtDisarm${lock}${user}") || + ((lock ? isLockKeypad : isAnyLockKeypad) && settings."keypadArmDisarm${lock}${user}")) { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel(s)", multiple: true, required: (settings."adtDisarm${lock}${user}" ? true : false) // Required if we select ADT + } + input "homePhrase${lock}${user}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorHomePhrase + input "homeMode${lock}${user}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "turnOnSwitchesAfterSunset${lock}${user}", "capability.switch", title: "Turn on light(s) after dark", required: false, multiple: true + input "turnOnSwitches${lock}${user}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "turnOffSwitches${lock}${user}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "unlockLocks${lock}${user}","capability.lock", title: "Unlock lock(s)", required: false, multiple: true + input "openGarage${lock}${user}","capability.garageDoorControl", title: "Open garage door(s)", required: false, multiple: true + + paragraph title: "Do NOT run the above unlock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleUnlockActions${lock}${user}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeUnlockActions${lock}${user}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + } + } +} + +def unlockManualActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for lock $lock" + } else if (atomicState.params) { + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Manual Unlock Action Page, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"unlockManualActionsPage", title: "Setup manual unlock actions for doors", uninstall: false, install: false) { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + if (!phrases) { // This should not happen, it should be taken care of in parent page + log.warn "No Routines found!!!" + } + + section ("Door Manual Unlock Actions${lock ? " for $lock" : ""}") { + def priorHomePhrase = settings."homePhraseManual${lock}" + def priorHomeMode = settings."homeModeManual${lock}" + def priorManualNotify = settings."manualNotify${lock}" + + paragraph "Run these actions when a user unlocks the door manually" + input "homeDisarmManual${lock}", "bool", title: "Disarm Smart Home Monitor", required: false + input "adtDisarmManual${lock}", "bool", title: "Disarm ADT", required: false, submitOnChange: true + if (settings."adtDisarmManual${lock}") { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel(s)", multiple: true, required: true // Required if we select ADT + } + input "homePhraseManual${lock}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorHomePhrase + input "homeModeManual${lock}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "turnOnSwitchesAfterSunsetManual${lock}", "capability.switch", title: "Turn on light(s) after dark", required: false, multiple: true + input "turnOnSwitchesManual${lock}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "turnOffSwitchesManual${lock}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "unlockLocksManual${lock}","capability.lock", title: "Unlock lock(s)", required: false, multiple: true + input "openGarageManual${lock}","capability.garageDoorControl", title: "Open garage door(s)", required: false, multiple: true + + paragraph title: "Do NOT run the above unlock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleUnlockActionsManual${lock}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeUnlockActionsManual${lock}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + + paragraph "Unlock Notification Options" + input "manualNotify${lock}", "bool", title: "Notify on manual unlock", required: false, submitOnChange: true + if (priorManualNotify) { + input "manualNotifyModes${lock}", "mode", title: "...only when in this mode(s) (optional)", required: false, multiple: true + } + } + } +} + +def armKeypadActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + log.trace "Passed from main page, using params lookup ${params}" + } else if (atomicState.params) { + params = atomicState.params + log.trace "Passed from submitOnChange, atomicState lookup ${atomicState.params}" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def user = params?.user ?: "" + def lock = params?.lock ?: "" + def arm = params?.arm ?: "" + + def name = user ? settings."userNames${user}" : "" + + log.trace "Arm Keypad Action Page, user:$user, name:$name, lock $lock, arm $arm, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"armKeypadActionsPage", title: "Setup Arm ${arm?.capitalize()} button actions for ${lock ?: "keypad"}" + (user ? " for user $name." : ""), uninstall: false, install: false) { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + if (!phrases) { // This should not happen, it should be taken care of in parent page + log.warn "No Routines found!!!" + } + + section { + input "keypadArmActions${lock}${user}${arm}", "bool", title: "Enable custom actions", required: false, submitOnChange: true + if (settings."keypadArmActions${lock}${user}${arm}") { + def priorLockPhrase = settings."externalLockPhrase${lock}${user}${arm}" + def priorHomeMode = settings."externalLockMode${lock}${user}${arm}" + + input "externalLockPhrase${lock}${user}${arm}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorLockPhrase + input "externalLockMode${lock}${user}${arm}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "externalLockTurnOnSwitches${lock}${user}${arm}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "externalLockTurnOffSwitches${lock}${user}${arm}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "lockLocks${lock}${user}${arm}","capability.lock", title: "Lock lock(s)", required: false, multiple: true + input "closeGarage${lock}${user}${arm}","capability.garageDoorControl", title: "Close garage door(s)", required: false, multiple: true + } + } + } +} + +def lockKeypadActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + user = params.user ?: "" + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for user $user, lock $lock" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user $user, lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + + log.trace "Keypad Lock Action Page, user:$user, name:$name, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"lockKeypadActionsPage", title: "Setup keypad lock actions for doors" + (user ? " for user $name." : ""), uninstall: false, install: false) { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + if (!phrases) { // This should not happen, it should be taken care of in parent page + log.warn "No Routines found!!!" + } + + section ("Door Keypad Lock Actions${lock ? " for $lock" : ""}") { + def priorLockPhrase = settings."externalLockPhrase${lock}${user}" + def priorHomeMode = settings."externalLockMode${lock}${user}" + def isLockKeypad = locks?.find{ it.name == lock }?.hasAttribute("armMode") // Check if the current lock (lock specific option) is a keypad + def isAnyLockKeypad = locks?.any { keypad -> keypad.hasAttribute("armMode") } // Check if any lock (for global options) is a keypad + def areAllLockKeypad = locks?.every { keypad -> keypad.hasAttribute("armMode") } // Check every lock (for global options) is a keypad + + paragraph "Some locks can be locked from the keypad outside${user ? " with user codes" : ""}. If your lock has his feature then you can assign actions to execute when it is locked ${user ? "with a user code" : "from the keypad"}" + if (lock ? isLockKeypad : isAnyLockKeypad) { // Show only if we have a supported keypad (for selected lock or for general settings) + input "keypadArmDisarm${lock}${user}", "bool", title: "Control SHM/ADT using keypad", required: false, submitOnChange: true + } + if (lock ? (isLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true) : (areAllLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true)) { // Hide only if we have have a supported keypad for selected lock and using keypad to control SHM + input "homeArm${lock}${user}", "bool", title: "Arm Smart Home Monitor to Away", required: false, submitOnChange: true + input "adtArm${lock}${user}", "bool", title: "Arm ADT to Away", required: false, submitOnChange: true + if (settings."homeArm${lock}${user}" || settings."adtArm${lock}${user}") { + input "homeArmStay${lock}${user}", "bool", title: "...arm to Stay instead of Away", required: false + } + } + if (((lock ? (isLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true) : (areAllLockKeypad ? !settings."keypadArmDisarm${lock}${user}" : true)) && settings."adtArm${lock}${user}") || + ((lock ? isLockKeypad : isAnyLockKeypad) && settings."keypadArmDisarm${lock}${user}")) { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel(s)", multiple: true, required: (settings."adtArm${lock}${user}" ? true : false) // Required if we select ADT + } + if (lock ? isLockKeypad : isAnyLockKeypad) { // Show only if we have a supported keypad (for selected lock or for general settings) + def hrefParams = [ + user: user, + lock: lock as String, + passed: true + ] + href(name: "armAwayKeypadActions${lock}", params: hrefParams + [arm: "away"], title: "Away/On button actions", page: "armKeypadActionsPage", description: "", required: false, image: "") + href(name: "armStayKeypadActions${lock}", params: hrefParams + [arm: "stay"], title: "Stay/Partial button actions", page: "armKeypadActionsPage", description: "", required: false, image: "") + href(name: "armNightKeypadActions${lock}", params: hrefParams + [arm: "night"], title: "Night button actions", page: "armKeypadActionsPage", description: "", required: false, image: "") + } + input "externalLockPhrase${lock}${user}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorLockPhrase + input "externalLockMode${lock}${user}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "externalLockTurnOnSwitches${lock}${user}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "externalLockTurnOffSwitches${lock}${user}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "lockLocks${lock}${user}","capability.lock", title: "Lock lock(s)", required: false, multiple: true + input "closeGarage${lock}${user}","capability.garageDoorControl", title: "Close garage door(s)", required: false, multiple: true + + input "delayLockActionsTime${lock}${user}", "number", title: "Delay running actions (minutes)", required: false, range: "0..*" + + paragraph title: "Do NOT run the above lock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleLockActions${lock}${user}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeLockActions${lock}${user}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + + if (!user) { // Users will use the user notify option + paragraph "Lock Notification Options" + input "externalLockNotify${lock}", "bool", title: "Notify on keypad lock", required: false, submitOnChange: true + if (settings."externalLockNotify${lock}") { + input "externalLockNotifyModes${lock}", "mode", title: "Only when in this mode(s) (optional)", required: false, multiple: true + } + input "jamNotify${lock}", "bool", title: "Notify on Lock Jam/Stuck", required: false + } + } + } +} + +def lockManualActionsPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def lock = "" + // Get details from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.passed) { + lock = params.lock ?: "" + log.trace "Passed from main page, using params lookup for lock $lock" + } else if (atomicState.params) { + lock = atomicState.params.lock ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for lock $lock" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + log.trace "Manual Lock Action Page, lock $lock, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"lockManualActionsPage", title: "Setup manual lock actions for doors", uninstall: false, install: false) { + def phrases = location.helloHome?.getPhrases() + phrases = phrases ? phrases*.label?.sort() - null : [] // Check for null ghost routines + if (!phrases) { // This should not happen, it should be taken care of in parent page + log.warn "No Routines found!!!" + } + + section ("Door Manual Lock Actions${lock ? " for $lock" : ""}") { + def priorLockPhrase = settings."externalLockPhraseManual${lock}" + def priorHomeMode = settings."externalLockModeManual${lock}" + + input "homeArmManual${lock}", "bool", title: "Arm Smart Home Monitor to Stay", required: false + input "adtArmManual${lock}", "bool", title: "Arm ADT to Stay", required: false, submitOnChange: true + if (settings."adtArmManual${lock}") { // If we have a seleted an ADT option + input "adtDevices", "capability.battery", title: "Select ADT panel(s)", multiple: true, required: true // Required if we select ADT + } + input "externalLockPhraseManual${lock}", "enum", title: "Run routine", required: false, options: phrases, defaultValue: priorLockPhrase + input "externalLockModeManual${lock}", "mode", title: "Change mode to", required: false, multiple: false, defaultValue: priorHomeMode + input "externalLockTurnOnSwitchesManual${lock}", "capability.switch", title: "Turn on switch(s)", required: false, multiple: true + input "externalLockTurnOffSwitchesManual${lock}", "capability.switch", title: "Turn off switch(s)", required: false, multiple: true + input "lockLocksManual${lock}","capability.lock", title: "Lock lock(s)", required: false, multiple: true + input "closeGarageManual${lock}","capability.garageDoorControl", title: "Close garage door(s)", required: false, multiple: true + + input "delayLockActionsTimeManual${lock}", "number", title: "Delay running actions (minutes)", required: false, range: "0..*" + + paragraph title: "Do NOT run the above lock actions for door${lock ? " $lock" : ""} under any of the following conditions", required: true, "" + input "runXPeopleLockActionsManual${lock}", "capability.presenceSensor", title: "...when any these people are present", required: false, multiple: true + input "runXModeLockActionsManual${lock}", "mode", title: "...when in any of these mode(s)", required: false, multiple: true + + paragraph "Lock Notification Options" + input "lockNotify${lock}", "bool", title: "Notify on manual/auto lock", required: false, submitOnChange: true + if (settings."lockNotify${lock}") { + input "lockNotifyModes${lock}", "mode", title: "...only when in this mode(s) (optional)", required: false, multiple: true + } + } + } +} + +def usersPage() { + dynamicPage(name:"usersPage", title: "User Names, Actions and Notification Setup", uninstall: false, install: false) { + + if (!maxUserNames) { + section("Invalid number of users") { + paragraph title: "First configure the number of users on the previous page", required: true, "" + } + } + + section() { + for (int i = 1; i <= maxUserNames; i++) { + def priorName = settings."userNames${i}" + def priorNotify = settings."userNotify${i}" + def priorNotifyModes = settings."userNotifyModes${i}" + //log.trace "Initial $i Name: $priorName, Notify: $priorNotify, NotifyModes: $priorNotifyModes" + + // Params for user + def hrefParams = [ + user: i as String, + passed: true + ] + href(name: "userConfig${i}", params: hrefParams, title: "Slot ${i} - ${priorName ?: "< blank >"}", page: "userConfigPage", description: "", required: false, image: ("http://www.rboyapps.com/images/User.png")) + } + } + } +} + +def userConfigPage(params) { + // params is broken, after doing a submitOnChange on this page, params is lost. So as a work around when this page is called with params save it to state and if the page is called with no params we know it's the bug and use the last state instead + if (params.passed) { + atomicState.params = params // We got something, so save it otherwise it's a page refresh for submitOnChange + } + + def user = "" + // Get user from the passed in params when the page is loading, else get from the last saved to work around not having params on pages + if (params.user) { + user = params.user ?: "" + log.trace "Passed from main page, using params lookup for user:$user" + } else if (atomicState.params) { + user = atomicState.params.user ?: "" + log.trace "Passed from submitOnChange, atomicState lookup for user:$user" + } else { + log.error "Invalid params, no user found. Params: $params, saved params: $atomicState.params" + } + + def name = user ? settings."userNames${user}" : "" + def i = user as Integer + + log.trace "User Codes Page, user:$user, name:$name, passed params: $params, saved params:$atomicState.params" + + dynamicPage(name:"userConfigPage", title: "User Slot #${i}", uninstall: false, install: false) { + section() { + def priorName = settings."userNames${i}" + def priorNotify = settings."userNotify${i}" + def priorNotifyModes = settings."userNotifyModes${i}" + //log.trace "Initial $i Name: $priorName, Notify: $priorNotify, NotifyModes: $priorNotifyModes" + + // User and code details/types + input "userNames${i}", "text", description: "Tap to set", title: "Name", multiple: false, required: false, submitOnChange: false, image: "http://www.rboyapps.com/images/User.png" + input "userNotify${i}", "bool", title: "Notify on use", defaultValue: true, required: false, submitOnChange: true, image: "http://www.rboyapps.com/images/Notifications.png" + if (priorNotify != false) { + input "userNotifyUseCount${i}", "number", title: "...limit to only this many times", description: "no limit", required: false, range: "1..*" + input "userNotifyModes${i}", "mode", title: "...only when in this mode(s)", description: "notify only when in any of these modes", required: false, multiple: true + input "userXNotifyPresence${i}", "capability.presenceSensor", title: "...and none of these people are present", description: "when all these people are not present", required: false, multiple: true + } + + // Unlock actions for each user + def hrefParams = [ + user: i as String, + passed: true + ] + href(name: "unlockLockActions", params: hrefParams, title: "Custom actions/notifications", page: "unlockLockActionsPage", description: (settings."userOverrideUnlockActions${user}" || (settings."userOverrideNotifications${user}" && settings."userNotify${user}")) ? "Configured" : "", required: false, image: "http://www.rboyapps.com/images/LockUnlock.png") + } + } +} + +def installed() +{ + log.debug "Install Settings: $settings" + appTouch() +} + +def updated() +{ + log.debug "Update Settings: $settings" + appTouch() +} + +def appTouch() { + state.clientVersion = clientVersion() // Update our local stored client version to detect code upgrades + + unschedule() // clear all pending updates + unsubscribe() + + // Check for new versions of the code + def random = new Random() + Integer randomHour = random.nextInt(18-10) + 10 + Integer randomDayOfWeek = random.nextInt(7-1) + 1 // 1 to 7 + schedule("0 0 " + randomHour + " ? * " + randomDayOfWeek, checkForCodeUpdate) // Check for code updates once a week at a random day and time between 10am and 6pm + + // subscribe to events to kick start timers and presence/mode events to update code states + subscribe(location, "mode", changeHandler) + subscribe(app, changeHandler) // Capture user intent to reinitialize timers + + subscribe(locks, "lock", lockHandler) // Subscribe to lock events to take action as defined as user + subscribe(locks, "tamper", lockHandler) // Subscribe to tamper events + subscribe(location, "alarmSystemStatus" , shmChangeHandler) // Subscribe to SHM state handler + if (adtDevices) { + subscribe(adtDevices, "securitySystemStatus" , adtChangeHandler) // Subscribe to ADT state handler + } + + locks.each { lock -> // check each lock individually + if (settings."sensor${lock}") { + log.trace "Subscribing to sensor ${settings."sensor${lock}"} for ${lock}" + subscribe(settings."sensor${lock}", "contact", sensorHandler) + } + if (lock.hasAttribute('invalidCode')) { + log.trace "Found attribute 'invalidCode' on lock $lock, enabled support for invalid code detection" + subscribe(lock, "invalidCode", lockHandler) + } + } + + state.codeUseCount = [:] // Number of times codes were used + atomicState.reLocks = [:] // List of lock to relock after a timed delay + atomicState.notifyOpenDoors = [:] // List of locks to check for open notifications + atomicState.immediateLocks = [] // List of lock to lock immediately after a short delay + atomicState.unLocks = [] // List of lock to unlock after a short delay + for (lock in locks) { + state.codeUseCount[lock.id] = [:] // Number of times a code usage was used for this lock + } + + schedule("* */10 * * * ?", heartBeatMonitor) // run the heartbeat every 10 minutes +} + +// Handle changes, reinitialize the code check timers after a change, this is to workaround the issue of a buggy ST platform where the timers die randomly for some users +def changeHandler(evt) { + log.trace "Reinitializing code check timer on event notification, name: ${evt?.name}, value: ${evt?.value}, device: ${evt?.device}" + + // Check if the user has upgraded the SmartApp and reinitailize if required + if (state.clientVersion != clientVersion()) { + def msg = "NOTE: ${app.label} detected a code upgrade. Updating configuration, please open the app and click on Save to re-validate your settings" + log.warn msg + startTimer(1, appTouch) // Reinitialize the app offline to avoid a loop as appTouch calls codeCheck + sendNotifications(msg) // Do this in the end as it may timeout + return + } + + if (evt?.name == "mode") { // Mode change notification + for (lock in locks) { // Check all locks + def sensor = settings."sensor${lock}" // Find the lock for this sensor, match by ID and not objects + if (sensor) { + log.trace "Checking for any pending door sensor activites that need to be done for lock $lock with sensor $sensor in mode ${evt.value}" + def sensorEvt = [name: sensor.name, displayName: sensor.displayName, value: sensor.latestValue("contact"), device: sensor] + sensorHandler(sensorEvt) + } + } + } +} + +// Handle changes to ADT states +def adtChangeHandler(evt) { + log.trace "ADT state change notification, name: ${evt.name}, value: ${evt.value}" + + def msg = "" + def keypads = locks?.findAll{ lock -> lock.hasAttribute("armMode")} // Get all keypads and sync state with ADT + //def mode = settings."adtDevices"?.currentState("securitySystemStatus")?.value // This should the new ADT state + def user = "" // We don't check for individual user custom actions for keypads since synchronization needs to happen at the keypad level + def directControl = (settings."individualDoorActions${user}" ? keypads : [ "" ]).any { lock -> settings."keypadArmDisarm${lock}${user}" } + def mode = evt.value // Since it from a device lets take the value directly + if (keypads && directControl) { + switch (mode) { + case "armedAway": + msg = "Detected ADT mode change, setting $keypads to Armed Away" + keypads*.setArmedAway() + break + + case "armedStay": + msg = "Detected ADT mode change, setting $keypads to Armed Stay" + keypads*.setArmedStay() + break + + case "disarmed": + msg = "Detected ADT mode change, setting $keypads to Disarmed" + keypads*.setDisarmed() + break + + default: + log.error "Unknown ADT mode $mode" + break + } + } else { + log.trace "No keypads found under direct SHM/ADT control" + } + + if (keypads && msg) { + log.info msg + } +} + +// Handle changes to SHM states +def shmChangeHandler(evt) { + log.trace "SHM state change notification, name: ${evt.name}, value: ${evt.value}" + + def msg = "" + def keypads = locks?.findAll{ lock -> lock.hasAttribute("armMode")} // Get all keypads and sync state with SHM + def user = "" // We don't check for individual user custom actions for keypads since synchronization needs to happen at the keypad level + def directControl = (settings."individualDoorActions${user}" ? keypads : [ "" ]).any { lock -> settings."keypadArmDisarm${lock}${user}" } + //def mode = location.currentState("alarmSystemStatus")?.value // This should the new SHM state + def mode = evt.value // This is the changed value + if (keypads && directControl) { + switch (mode) { + case "away": + msg = "Detected SHM mode change, setting $keypads to Armed Away" + keypads*.setArmedAway() + break + + case "stay": + msg = "Detected SHM mode change, setting $keypads to Armed Stay" + keypads*.setArmedStay() + break + + case "off": + msg = "Detected SHM mode change, setting $keypads to Disarmed" + keypads*.setDisarmed() + break + + default: + log.error "Unknown SHM mode $mode" + break + } + } else { + log.trace "No keypads found under direct SHM/ADT control" + } + + if (keypads && msg) { + log.info msg + } +} + +def sensorHandler(evt) { + log.trace "Event name $evt.name, value $evt.value, device $evt.displayName" + + def sensor = evt.device + + def lock = locks.find { settings."sensor${it}"?.id == sensor.id } // Find the lock for this sensor, match by ID and not objects + log.trace "Sensor ${sensor} belongs to Lock ${lock}" + + if (evt.value == "closed") { // Door was closed + if (lock && settings."relockDoor${lock}" && (settings."relockDoorModes${lock}" ? settings."relockDoorModes${lock}".find{it == location.mode} : true)) { // Are we asked to reLock this door + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { + log.warn "Disable AutoLock on physical lock to use SmartApp AutoReLock and AutoUnlock features" + } else { + if (settings."relockImmediate${lock}") { + log.debug "Relocking ${lock} immediately in 3 seconds" + def immediatelocks = atomicState.immediateLocks ?: [] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + if (!immediatelocks.contains(lock.id)) { // Don't re add the same lock again + //log.trace "Adding ${lock.id} to the list of immediate locks" + immediatelocks.add(lock.id) // Atomic to ensure we get upto date info here + atomicState.immediateLocks = immediatelocks // Set it back, we can't work direct on atomicState + } + immediateLockDoor() // Lock it right away + } else if (settings."relockAfter${lock}") { + log.debug "Scheduling ${lock} to lock in ${settings."relockAfter${lock}"} minutes" + def reLocks = atomicState.reLocks ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Adding ${lock.id} to the list of relocks" + reLocks[lock.id] = now() // Atomic to ensure we get upto date info here, Update and Add work the same way here so we don't need to check before adding/updating + atomicState.reLocks = reLocks // Set it back, we can't work direct on atomicState + reLockDoor() // Call relock door it'll take of delaying the lock as required + } else { + log.error "Invalid configuration, no relock timeout defined" + } + } + } + } else { // Door was opened + // Chime bell + if (settings."openNotifyBeep${lock}") { + if (!settings."openNotifyModes${lock}" || (settings."openNotifyModes${lock}"?.find{it == location.mode})) { + log.debug "Door ${sensor} was opened, chiming bell ${settings."openNotifyBeep${lock}"}" + settings."openNotifyBeep${lock}".beep() // Beep + } else { + log.trace "${lock} chiming not set for Mode ${location.mode}" + } + } + + // Notify user + if (settings."openNotify${lock}") { + if (!settings."openNotifyModes${lock}" || (settings."openNotifyModes${lock}"?.find{it == location.mode})) { + log.debug "Scheduling ${lock} to notify user of open door in ${settings."openNotifyTimeout${lock}"} minutes" + //log.trace "Updating ${lock.id} timestamp in the list of notifyOpenDoors" + def notifyOpenDoors = atomicState.notifyOpenDoors ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors[lock.id] = now() // Atomic to ensure we get upto date info here, Update and Add work the same way here so we don't need to check before adding/updating + atomicState.notifyOpenDoors = notifyOpenDoors // Set it back, we can't work direct on atomicState + notifyOpenDoor() // Notify, it'll take of delaying it if it's too soon + } else { + log.trace "${lock} open notification not set for Mode ${location.mode}" + } + } + } +} + +// Check for any pending door unlocks +def unLockDoor() { + def unLocksIDs = atomicState.unLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Pending door unlocks ${unLocksIDs}" + + unLocksIDs?.each { lockid -> + def lock = locks.find { it.id == lockid } // find the lock + log.info "UnLocking the door ${lock} immediately" + lock.unlock() // unlock it + def unlocks = atomicState.unLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Removing ${lockid} from the list of pending unlocks" + unlocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.unLocks = unlocks // set it back to atomicState + //log.trace "Checking for any pending door unlocks in 3 seconds" + startTimer(3, unLockDoor) // Next immediate door lock in 3 seconds (give it some time for the mesh network) + return // We're done here + } +} + +// Check for any pending immediate door locks +def immediateLockDoor() { + def immediateLocksIDs = atomicState.immediateLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Pending immediate door locks ${immediateLocksIDs}" + + immediateLocksIDs?.each { lockid -> + def lock = locks.find { it.id == lockid } // find the lock + log.info "Locking the door ${lock} immediately" + lock.lock() // lock it + def immediatelocks = atomicState.immediateLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Removing ${lockid} from the list of pending immediate locks" + immediatelocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.immediateLocks = immediatelocks // set it back to atomicState + //log.trace "Checking for any pending immediate door locks in 3 seconds" + startTimer(3, immediateLockDoor) // Next immediate door lock in 3 seconds (give it some time for the mesh network) + return // We're done here + } +} + +// Check for any pending delayed door relocks +def reLockDoor() { + def reLocksIDs = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Checking door sensor state and relocking ${reLocksIDs}" + + Long shortestPendingTime = 0 // in seconds + + reLocksIDs?.each { lockid, timestamp -> + def lock = locks.find { it.id == lockid } // find the lock + def lockSensor = settings."sensor${lock}" // Get the sensor for the lock + Long timeLeft = (((60 * 1000 * settings."relockAfter${lock}") + timestamp) - now())/1000 // timestamp and now() is in ms + if (timeLeft <= 1) { // If we are within 1 second then go ahead since the timer isn't always 100% accurate + if (settings."relockDoorModes${lock}" ? settings."relockDoorModes${lock}".find{it == location.mode} : true) { // Check if the mode is still active + if (!lockSensor) { // If we don't have a sensor then just lock on schedule + log.info "No sensor found on ${lock} when closed, locking the door" + lock.lock() // lock it + //log.trace "Removing ${lockid} from the list of pending relocks" + def reLocks = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + reLocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.reLocks = reLocks // set it back to atomicState + //log.trace "Checking for any pending relocks in 3 seconds" + startTimer(3, reLockDoor) // Next pending relock in 3 seconds (give it some time for the mesh network) + return // We're done here + } else if (lockSensor.latestValue("contact") == "closed") { + log.info "Sensor ${lockSensor} is reporting door ${lock} is closed, locking the door" + lock.lock() // lock it + //log.trace "Removing ${lockid} from the list of pending relocks" + def reLocks = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + reLocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.reLocks = reLocks // set it back to atomicState + //log.trace "Checking for any pending relocks in 3 seconds" + startTimer(3, reLockDoor) // Next pending relock in 3 seconds (give it some time for the mesh network) + return // We're done here + } else { + log.debug "Sensor ${lockSensor} is reporting door ${lock} is not closed, will check again in 60 seconds" + startTimer(60, reLockDoor) // Check back again in some time + } + } else { + log.trace "Relock mode conditions not met, not executing relock" + def reLocks = atomicState.reLocks // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + reLocks.remove(lockid) // We are done with this lock, remove it from the list + atomicState.reLocks = reLocks // set it back to atomicState + } + } else { + log.trace "${lock} has not reached the time limit of ${settings."relockAfter${lock}"} minutes yet, ${timeLeft/60} minutes to go" + if (!shortestPendingTime || (timeLeft < shortestPendingTime)) { + log.trace "Settings shortest pending time to ${timeLeft} seconds" + shortestPendingTime = timeLeft + } + } + } + + if (shortestPendingTime) { + startTimer((shortestPendingTime < 1 ? 1 : shortestPendingTime), reLockDoor) // Check back again after shortest pending timeout + } +} + +// Notify if the doors are left open +def notifyOpenDoor() { + def notifyOpenDoorsIds = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + log.trace "Checking Locks ${notifyOpenDoorsIds} door sensor state" + + Long shortestPendingTime = 0 // in seconds + + notifyOpenDoorsIds?.each { lockid, timestamp -> + def lock = locks.find { it.id == lockid } // find the lock + def lockSensor = settings."sensor${lock}" // Get the sensor for the lock + + if (!settings."openNotify${lock}" || (settings."openNotifyModes${lock}" && !(settings."openNotifyModes${lock}"?.find{it == location.mode}))) { // Check if the settings have changed + log.trace "No need to monitor open sensor ${lockSensor} for door ${lock} as settings/modes have changed" + //log.trace "Removing ${lockid} from the list of pending notifications" + def notifyOpenDoors = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors.remove(lock.id) // We are done with this lock, remove it from the list + atomicState.notifyOpenDoors = notifyOpenDoors // set it back to atomicState + return // move on + } + + Long timeLeft = (((60 * 1000 * settings."openNotifyTimeout${lock}") + timestamp) - now())/1000 // timestamp and now() is in ms + if (timeLeft <= 1) { // If we are within 1 second then go ahead since the timer isn't always 100% accurate + if (lockSensor.latestValue("contact") == "closed") { + log.trace "Sensor ${lockSensor} is reporting door ${lock} is closed, no notification required" + //log.trace "Removing ${lockid} from the list of pending notifications" + def notifyOpenDoors = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors.remove(lock.id) // We are done with this lock, remove it from the list + atomicState.notifyOpenDoors = notifyOpenDoors // set it back to atomicState + } else { + log.info "Sensor ${lockSensor} is reporting door ${lock} is open, notifying user and checking again after ${settings."openNotifyTimeout${lock}"} minutes" + def msg = "$lockSensor has been open for ${settings."openNotifyTimeout${lock}"} minutes" + + //log.trace "Updating ${lock.id} timestamp in the list of notifyOpenDoors" + def notifyOpenDoors = atomicState.notifyOpenDoors // We need to deference the atomicState object each time, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + notifyOpenDoors[lock.id] = now() // Atomic to ensure we get upto date info here + atomicState.notifyOpenDoors = notifyOpenDoors // set it back to atomicState + + startTimer(60, notifyOpenDoor) // Check back again after short timeout so we don't overwrite a short wait with a long wait + sendNotifications(msg) // Do it in the end to avoid a timeout + } + } else { + log.trace "${lock} has not reached the time limit of ${settings."openNotifyTimeout${lock}"} minutes yet, ${timeLeft/60} minutes to go" + if (!shortestPendingTime || (timeLeft < shortestPendingTime)) { + log.trace "Settings shortest pending time to ${timeLeft} seconds" + shortestPendingTime = timeLeft + } + } + } + + if (shortestPendingTime) { + startTimer((shortestPendingTime < 1 ? 1 : shortestPendingTime), notifyOpenDoor) // Check back again after shortest pending timeout + } +} + +// Lock event handler +def lockHandler(evt) { + def data = null + def lock = evt.device + + log.trace "Lock event name $evt.name, value $evt.value, device $evt.displayName, description $evt.descriptionText, data $evt.data" + + def evtMap = [name:evt.name, value:evt.value, displayName:evt.displayName, descriptionText:evt.descriptionText, data:evt.data, lockId: evt.device.id] + + if (evt.name == "lock") { // LOCK UNLOCK EVENTS + if (evt.value == "unlocked") { // UNLOCKED + unschedule(processLockActions) // If there was a pending delayed actions and user operated the lock then cancel it + processUnlockEvent(evtMap) + } else if (evt.value == "locked") { // LOCKED MANUALLY OR VIA KEYPAD OR ELECTRONICALLY + unschedule(processLockActions) // If there was a pending delayed actions and user operated the lock then cancel it + processLockEvent(evtMap) + } else if (evt.value == "unknown") { // JAMMED CODE EVENT + log.debug "Lock $evt.displayName Jammed!" + if ((!settings."individualDoorActions" && jamNotify) || + (settings."individualDoorActions" && settings."jamNotify${lock}")) { + def msg = "$evt.displayName lock is Jammed!" + sendNotifications(msg) + } + } + } else if (evt.name == "invalidCode") { // INVALID LOCK CODE EVENT + log.debug "Lock $evt.displayName, invalid user code: ${evt.value}" + def msg = "Too many invalid user codes detected on lock $evt.displayName" + sendNotifications(msg) + } else if (evt.name == "tamper" && evt.value == "detected") { // Tampering of the lock + log.debug "Lock $evt.displayName tamper detected with description $evt.descriptionText" + def msg = "Tampering detected on lock $evt.displayName. ${evt.descriptionText ?: ""}" + sendNotifications(msg) + } +} + +def processUnlockEvent(evt) { + def data = null + def lock = locks.find { it.id == evt.lockId } + + log.trace "Processing $lock unlock event: $evt" + + // Check if we have delayed relock is enabled, if so then start the timer now just incase the user never opens the door (reLockDoor will take care of sensor if present, immediate relock should never happen without a sensor) + if (settings."relockDoor${lock}" && settings."relockAfter${lock}" && (settings."relockDoorModes${lock}" ? settings."relockDoorModes${lock}".find{it == location.mode} : true)) { // Are we asked to reLock this door + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { + log.warn "Disable AutoLock on physical lock to use SmartApp AutoReLock and AutoUnlock features" + } else { + log.debug "Scheduling ${lock} to lock in ${settings."relockAfter${lock}"} minutes" + def reLocks = atomicState.reLocks ?: [:] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + //log.trace "Adding ${lock.id} to the list of relocks" + reLocks[lock.id] = now() // Atomic to ensure we get upto date info here, Update and Add work the same way here so we don't need to check before adding/updating + atomicState.reLocks = reLocks // Set it back, we can't work direct on atomicState + reLockDoor() // Call relock door it'll take of delaying the lock as required + } + } else { + log.trace "Relock conditions not met, not scheduling relock" + } + + if (evt.data) { // Was it unlocked using a code + data = parseJson(evt.data) + } + def lockMode = data?.type ?: (data?.method ?: (evt.descriptionText?.contains("manually") ? "manually" : "electronically")) + // Fix for proper grammar + switch (lockMode) { + case "manual": + lockMode = "manually" + break + + case "rfid": + lockMode = "via RFID" + break + + case "bluetooth": + lockMode = "via bluetooth" + break + + case "keypad": + lockMode = "via keypad" + break + + case "remote": + case "command": + lockMode = "remotely" + break + + case "auto": + lockMode = "via internal autolock" + break + + default: + break + } + + if ((data?.usedCode == null) && !(["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) })) { // No extended data, must be a manual/auto/keyed unlock, NOTE: some locks don't send keypad user codes + log.trace "$evt.displayName was unlocked manually. Source type: $lockMode" + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions") { + lockStr = lock as String + } else { + lockStr = "" + } + + // First disarm SHM since it goes off due to other events + if (settings."runXPeopleUnlockActionsManual${lockStr}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleUnlockActionsManual${lockStr}"?.find{it.currentPresence == "present"}} is present, not running unlock actions for door $lock" + } else if (settings."runXModeUnlockActionsManual${lockStr}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running unlock actions for door $lock" + } else { + def msg = "$evt.displayName was unlocked $lockMode" + + if (settings."homeDisarmManual${lockStr}") { + log.info "Disarming Smart Home Monitor" + sendLocationEvent(name: "alarmSystemStatus", value: "off") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming Smart Home Monitor" : "" + } + + try { + if (settings."adtDisarmManual${lockStr}" && settings."adtDevices") { + log.info "Disarming ADT" + settings."adtDevices"?.disarm() // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming ADT" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error disarming ADT\n$e" + msg += ", error disarming ADT" + } + + if (settings."homeModeManual${lockStr}") { + log.info "Changing mode to ${settings."homeModeManual${lockStr}"}" + if (location.modes?.find{it.name == settings."homeModeManual${lockStr}"}) { + setLocationMode(settings."homeModeManual${lockStr}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."homeModeManual${lockStr}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."homeModeManual${lockStr}"}" : "" + } + + if (settings."homePhraseManual${lockStr}") { + log.info "Running unlock Phrase ${settings."homePhraseManual${lockStr}"}" + location.helloHome.execute(settings."homePhraseManual${lockStr}") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", running routine ${settings."homePhraseManual${lockStr}"}" : "" + } + + if (settings."turnOnSwitchesAfterSunsetManual${lockStr}") { + def cdt = new Date(now()) + def sunsetSunrise = getSunriseAndSunset(sunsetOffset: "-00:30") // Turn on 30 minutes before sunset (dark) + log.trace "Current DT: $cdt, Sunset $sunsetSunrise.sunset, Sunrise $sunsetSunrise.sunrise" + if ((cdt >= sunsetSunrise.sunset) || (cdt <= sunsetSunrise.sunrise)) { + log.info "$evt.displayName was unlocked successfully, turning on lights ${settings."turnOnSwitchesAfterSunsetManual${lockStr}"} since it's after sunset but before sunrise" + settings."turnOnSwitchesAfterSunsetManual${lockStr}"?.on() + msg += detailedNotifications ? ", turning on lights ${settings."turnOnSwitchesAfterSunsetManual${lockStr}"}" : "" + } + } + + if (settings."turnOnSwitchesManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, turning on switches ${settings."turnOnSwitchesManual${lockStr}"}" + settings."turnOnSwitchesManual${lockStr}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."turnOnSwitchesManual${lockStr}"}" : "" + } + + if (settings."turnOffSwitchesManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, turning off switches ${settings."turnOffSwitchesManual${lockStr}"}" + settings."turnOffSwitchesManual${lockStr}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."turnOffSwitchesManual${lockStr}"}" : "" + } + + if (settings."unlockLocksManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, unlocking ${settings."unlockLocksManual${lockStr}"}" + settings."unlockLocksManual${lockStr}"?.unlock() + msg += detailedNotifications ? ", unlocking ${settings."unlockLocksManual${lockStr}"}" : "" + } + + if (settings."openGarageManual${lockStr}") { + log.info "$evt.displayName was unlocked successfully, opening ${settings."openGarageManual${lockStr}"}" + settings."openGarageManual${lockStr}"?.open() + msg += detailedNotifications ? ", opening ${settings."openGarageManual${lockStr}"}" : "" + } + + if (settings."manualNotify${lockStr}" && (settings."manualNotifyModes${lockStr}" ? settings."manualNotifyModes${lockStr}".find{it == location.mode} : true)) { + sendNotifications(msg) + } + } + } else { // KEYPAD / RFID UNLOCK + Integer i = data.usedCode as Integer + def name = settings."userNames${i}" + def notify = settings."userNotify${i}" + def notifyCount = settings."userNotifyUseCount${i}" + def notifyModes = settings."userNotifyModes${i}" + def notifyXPresence = settings."userXNotifyPresence${i}" + + log.trace "Lock $evt.displayName unlocked by $name, notify $notify, notify count: $notifyCount, notify modes $notifyModes, notify NOT present $notifyXPresence, Source type: $lockMode" + + def msg = "" + + if (i == 0) { + name = "Master Code" // Special case locks like Yale have a master code which isn't programmable and is code 0 + notify = true // always inform about master users + } + + if (!name) { // will handle usedCode null errors + notify = true // always inform about unknown users + msg = "$evt.displayName was unlocked by Unknown User from slot $i $lockMode" + } else { + msg = "$evt.displayName was unlocked by $name $lockMode" + } + + // Check if we have user override unlock actions defined + def user = "" + if (settings."userOverrideUnlockActions${i as String}") { + log.trace "Found per user unlock actions" + user = i as String + } + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions${user}") { + lockStr = lock as String + } else { + lockStr = "" + } + + // First disarm SHM since it goes off due to other events + if (settings."runXPeopleUnlockActions${lockStr}${user}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleUnlockActions${lockStr}${user}"?.find{it.currentPresence == "present"}} is present, not running unlock actions for door $lock" + } else if (settings."runXModeUnlockActions${lockStr}${user}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running unlock actions for door $lock" + } else { + // If we have a specific mode passed by the keypad lets use that otherwise use configured options + if (settings."keypadArmDisarm${lockStr}${user}" && (data instanceof org.codehaus.groovy.grails.web.json.JSONObject ? !data?.isNull("armMode") : (data?.armMode != null))) { // NOTE: Bug with ST, runIn passes a JSONObject instead of a map - https://community.smartthings.com/t/runin-json-vs-map/104442 + switch (data.armMode) { // Set Keypad lock state + case "disarmed": + log.info "Disarming Smart Home Monitor" + sendLocationEvent(name: "alarmSystemStatus", value: "off") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming Smart Home Monitor" : "" + try { + if (settings."adtDevices") { + log.info "Disarming ADT" + settings."adtDevices"?.disarm() // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming ADT" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error disarming ADT\n$e" + msg += ", error disarming ADT" + } + break + + default: + log.warn "Invalid SHM mode detected: ${data.armMode}" + msg += ", invalid Smart Home Monitor mode ${data.armMode}" + break + } + } else { + if (settings."homeDisarm${lockStr}${user}") { + log.info "Disarming Smart Home Monitor" + sendLocationEvent(name: "alarmSystemStatus", value: "off") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming Smart Home Monitor" : "" + } + + try { + if (settings."adtDisarm${lockStr}${user}" && settings."adtDevices") { + log.info "Disarming ADT" + settings."adtDevices"?.disarm() // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", disarming ADT" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error disarming ADT\n$e" + msg += ", error disarming ADT" + } + } + + if (settings."homeMode${lockStr}${user}") { + log.info "Changing mode to ${settings."homeMode${lockStr}${user}"}" + if (location.modes?.find{it.name == settings."homeMode${lockStr}${user}"}) { + setLocationMode(settings."homeMode${lockStr}${user}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."homeMode${lockStr}${user}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."homeMode${lockStr}${user}"}" : "" + } + + if (settings."homePhrase${lockStr}${user}") { + log.info "Running unlock Phrase ${settings."homePhrase${lockStr}${user}"}" + location.helloHome.execute(settings."homePhrase${lockStr}${user}") // First do this to avoid false alerts from a slow platform + msg += detailedNotifications ? ", running routine ${settings."homePhrase${lockStr}${user}"}" : "" + } + + if (settings."turnOnSwitchesAfterSunset${lockStr}${user}") { + def cdt = new Date(now()) + def sunsetSunrise = getSunriseAndSunset(sunsetOffset: "-00:30") // Turn on 30 minutes before sunset (dark) + log.trace "Current DT: $cdt, Sunset $sunsetSunrise.sunset, Sunrise $sunsetSunrise.sunrise" + if ((cdt >= sunsetSunrise.sunset) || (cdt <= sunsetSunrise.sunrise)) { + log.info "$evt.displayName was unlocked successfully, turning on lights ${settings."turnOnSwitchesAfterSunset${lockStr}${user}"} since it's after sunset but before sunrise" + settings."turnOnSwitchesAfterSunset${lockStr}${user}"?.on() + msg += detailedNotifications ? ", turning on lights ${settings."turnOnSwitchesAfterSunset${lockStr}${user}"}" : "" + } + } + + if (settings."turnOnSwitches${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, turning on switches ${settings."turnOnSwitches${lockStr}${user}"}" + settings."turnOnSwitches${lockStr}${user}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."turnOnSwitches${lockStr}${user}"}" : "" + } + + if (settings."turnOffSwitches${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, turning off switches ${settings."turnOffSwitches${lockStr}${user}"}" + settings."turnOffSwitches${lockStr}${user}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."turnOffSwitches${lockStr}${user}"}" : "" + } + + if (settings."unlockLocks${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, unlocking ${settings."unlockLocks${lockStr}${user}"}" + settings."unlockLocks${lockStr}${user}"?.unlock() + msg += detailedNotifications ? ", unlocking ${settings."unlockLocks${lockStr}${user}"}" : "" + } + + if (settings."openGarage${lockStr}${user}") { + log.info "$evt.displayName was unlocked successfully, opening ${settings."openGarage${lockStr}${user}"}" + settings."openGarage${lockStr}${user}"?.open() + msg += detailedNotifications ? ", opening ${settings."openGarage${lockStr}${user}"}" : "" + } + } + + // Send notifications + if (i) { // If we have a known user, increment the usage count + state.codeUseCount[lock.id][i as String] = (state.codeUseCount[lock.id][i as String] ?: 0) + 1 + } + if (notify && ( + (notifyModes ? notifyModes?.find{it == location.mode} : true) && + (notifyXPresence ? notifyXPresence.every{it.currentPresence != "present"} : true) + ) && ( + !i || (notifyCount ? (state.codeUseCount[lock.id][i as String] <= notifyCount) : true) + )) { + sendNotifications(msg, (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "") + } + } +} + +def processLockEvent(evt) { + def data = null + def lock = locks.find { it.id == evt.lockId } + + log.trace "Processing $lock lock event: $evt" + + def msgs = [] // Message to send + def lockStr = "" // Individual lock actions + def user = "" // User slot used + def i = 0 // Slot used + + if (evt.data) { // Was it locked using a user code + data = parseJson(evt.data) + } + def lockMode = data?.type ?: (data?.method ?: (evt.descriptionText?.contains("manually") ? "manually" : "electronically")) + // Fix for proper grammar and additional lock types mapping + switch (lockMode) { + case "manual": + lockMode = "manually" + break + + case "rfid": + lockMode = "via RFID" + break + + case "bluetooth": + lockMode = "via bluetooth" + break + + case "keypad": + lockMode = "via keypad" + break + + case "remote": + case "command": + lockMode = "remotely" + break + + case "auto": + lockMode = "via internal autolock" + break + + default: + break + } + + evt.lockMode = lockMode // Save the lockMode calculated + evt.data = data // Update the data to be passed + user = (data?.usedCode as String) ?: "" // get the user if present + i = (data?.usedCode as Integer) ?: 0 // get the user if present + log.trace "$lock locked by user $user $lockMode" + + // Check if we have user override unlock actions defined + if (user && !settings."userOverrideUnlockActions${user}") { + log.trace "Did not find per user lock actions, falling back to global lock actions" + user = "" + } + + // Check if we have individual actions for each lock + if (settings."individualDoorActions${user}") { + lockStr = lock as String + } else { + lockStr = "" + } + + if ((["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) }) || (data?.usedCode != null)) { // LOCKED VIA KEYPAD/RFID + def name = user ? (i == 0 ? "Master Code" : (settings."userNames${i}" ?: "Unknown user")) : "" // Should have a name for the user otherwise it's unknown, 0 is Master Code + + // Check if we have a delayed action and process accordingly + if (settings."delayLockActionsTime${lockStr}${user}") { + def msg = "$evt.displayName was locked ${name ? "by " + name + " " : ""}$lockMode, running actions in ${settings."delayLockActionsTime${lockStr}${user}"} minutes" // Default message to send + log.debug msg + msgs << msg + evt.sendNotifications = true // Since it's delayed we request notifications be sent + startTimer(settings."delayLockActionsTime${lockStr}${user}" * 60, processLockActions, evt) + } else { + msgs += processLockActions(evt) // Take the message back to send out + } + } else { // MANUAL LOCK + // Check if we have a delayed action and process accordingly + if (settings."delayLockActionsTimeManual${lockStr}") { + def msg = "$evt.displayName was locked $lockMode, running actions in ${settings."delayLockActionsTimeManual${lockStr}"} minutes" // Default message to send + log.debug msg + msgs << msg + evt.sendNotifications = true // Since it's delayed we request notifications be sent + startTimer(settings."delayLockActionsTimeManual${lockStr}" * 60, processLockActions, evt) + } else { + msgs += processLockActions(evt) // Take the message back to send out + } + } + + // Check if we need to retract a deadbolt lock it was locked while the door was still open + if (settings."retractDeadbolt${lock}") { + def sensor = settings."sensor${lock}" + if (sensor.latestValue("contact") == "open") { + if (lock.hasAttribute('autolock') && (lock.latestValue("autolock") == "enabled")) { // Do not unlock if autolock features on the lock are enabled, avoid infinite loop + def msg = "Disable AutoLock on $lock lock to avoid an infinite locking/unlocking loop while using the 'Unlock on door open' feature" + log.warn msg + msgs << msg + } else { + log.debug "$lock was locked while the door was still open, unlocking it in 10 seconds" + def unlocks = atomicState.unLocks ?: [] // We need to deference the atomicState object each time and it may contain a null if it's empty so we need to allocate a new object, https://community.smartthings.com/t/atomicstate-not-working/27827/6?u=rboy + if (!unlocks.contains(lock.id)) { // Don't re add the same lock again + //log.trace "Adding ${lock.id} to the list of unlocks" + unlocks.add(lock.id) // Atomic to ensure we get upto date info here + atomicState.unLocks = unlocks // Set it back, we can't work direct on atomicState + } + startTimer(10, unLockDoor) // Schedule the unlock in 10 seconds since the door may have just locked and avoid Z-Wave conflict and some locks like Schlage deadbolt have timing limitations which cause a busy conflict if done too soon + } + } else { + log.trace "$lock was locked while the door was closed, we're good" + } + } + + // Last thing to do because it can timeout + for (msg in msgs) { + sendNotifications(msg, (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "") + } +} + +def processLockActions(evt) { + def data = evt.data + def lock = locks.find { it.id == evt.lockId } + def msgs = [] // Message to send + def lockMode = evt.lockMode + def user = "" + def arm = "" // Security keypad arm mode (optional) + def i = 0 + + log.trace "Processing $lock lock actions: $evt" + + if ((["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) }) || (data?.usedCode != null)) { // LOCKED VIA KEYPAD/RFID + def name, notify, notifyCount, notifyModes, notifyXPresence, extLockNotify, extLockNotifyModes, userOverrideActions + + if ((data instanceof org.codehaus.groovy.grails.web.json.JSONObject ? !data?.isNull("usedCode") : (data?.usedCode != null)) && (data?.usedCode >= 0)) { // NOTE: Bug with ST, runIn passes a JSONObject instead of a map - https://community.smartthings.com/t/runin-json-vs-map/104442 + i = data.usedCode as Integer + + if (i == 0) { + name = "Master Code" // Special case locks like Yale have a master code which isn't programmable and is code 0 + notify = true // always inform about master users + } else { + user = i as String + name = settings."userNames${i}" ?: "Unknown user" // Should have a name for the user otherwise it's unknown + notify = settings."userNotify${i}" + notifyCount = settings."userNotifyUseCount${i}" + notifyModes = settings."userNotifyModes${i}" + notifyXPresence = settings."userXNotifyPresence${i}" + userOverrideActions = settings."userOverrideUnlockActions${i}" + + // Check if we have user override lock actions defined + if (!userOverrideActions) { + log.trace "No user $name specific lock action found, falling back to global actions" + user = "" // We don't have a user specific action defined, fall back to global actions + } + } + } else { + log.trace "No usercode found in extended data for external user lock" + } + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions${user}") { + lockStr = lock as String + } else { + lockStr = "" + } + + extLockNotify = settings."externalLockNotify${lockStr}" + extLockNotifyModes = settings."externalLockNotifyModes${lockStr}" + + log.trace "Lock $evt.displayName locked by $name, user notify $notify, notify count: $notifyCount, user notify modes $notifyModes, notify NOT present $notifyXPresence, external notify $extLockNotify, external notify modes $extLockNotifyModes, user override action $userOverrideActions, Source type: $lockMode" + + def msg = evt.sendNotifications ? "Completing lock actions for $evt.displayName" : "$evt.displayName was locked ${name ? "by " + name + " " : ""}$lockMode" // Default message to send + + if (settings."runXPeopleLockActions${lockStr}${user}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleLockActions${lockStr}${user}"?.find{it.currentPresence == "present"}} is present, not running lock actions for door $lock" + } else if (settings."runXModeLockActions${lockStr}${user}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running lock actions for door $lock" + } else { + // If we have a specific mode passed by the keypad lets use that otherwise use configured options + if (data instanceof org.codehaus.groovy.grails.web.json.JSONObject ? !data?.isNull("armMode") : (data?.armMode != null)) { // NOTE: Bug with ST, runIn passes a JSONObject instead of a map - https://community.smartthings.com/t/runin-json-vs-map/104442 + switch (data.armMode) { // Check for custom keypad arm actions + case "armedStay": + if (settings."keypadArmActions${lockStr}${user}${"stay"}") { + log.debug "Running custom actions for keypad stay/partial button" + arm = "stay" + } + break + + case "armedNight": + if (settings."keypadArmActions${lockStr}${user}${"night"}") { + log.debug "Running custom actions for keypad night button" + arm = "night" + } + break + + case "armedAway": + if (settings."keypadArmActions${lockStr}${user}${"away"}") { + log.debug "Running custom actions for keypad away/on button" + arm = "away" + } + break + + default: + log.warn "Invalid keypad Arm mode detected: ${data.armMode}" + msg += ", invalid keypad Arm mode ${data.armMode}" + break + } + } + + if (settings."keypadArmDisarm${lockStr}${user}" && (data instanceof org.codehaus.groovy.grails.web.json.JSONObject ? !data?.isNull("armMode") : (data?.armMode != null))) { // NOTE: Bug with ST, runIn passes a JSONObject instead of a map - https://community.smartthings.com/t/runin-json-vs-map/104442 + switch (data.armMode) { // Set Keypad lock state + case "armedStay": + case "armedNight": + log.info "Arming Smart Home Monitor to Stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Stay" : "" + try { + if (settings."adtDevices") { + log.info "Arming ADT to Stay" + settings."adtDevices"?.armStay('armedStay') + msg += detailedNotifications ? ", Arming ADT to Stay" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT to Stay\n$e" + msg += ", error arming ADT to Stay" + } + break + + case "armedAway": + log.info "Arming Smart Home Monitor to Away" + sendLocationEvent(name: "alarmSystemStatus", value: "away") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Away" : "" + try { + if (settings."adtDevices") { + log.info "Arming ADT to Away" + settings."adtDevices"?.armAway('armedAway') + msg += detailedNotifications ? ", Arming ADT to Away" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT to Away\n$e" + msg += ", error arming ADT to Away" + } + break + + default: + log.warn "Invalid SHM mode detected: ${data.armMode}" + msg += ", invalid Smart Home Monitor mode ${data.armMode}" + break + } + } else { + if (settings."homeArm${lockStr}${user}") { + if (settings."homeArmStay${lockStr}${user}") { + log.info "Arming Smart Home Monitor to Stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Stay" : "" + } else { + log.info "Arming Smart Home Monitor to Away" + sendLocationEvent(name: "alarmSystemStatus", value: "away") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Away" : "" + } + } + + try { + if (settings."adtArm${lockStr}${user}" && settings."adtDevices") { + if (settings."homeArmStay${lockStr}${user}") { + log.info "Arming ADT to Stay" + settings."adtDevices"?.armStay('armedStay') + msg += detailedNotifications ? ", Arming ADT to Stay" : "" + } else { + log.info "Arming ADT to Away" + settings."adtDevices"?.armAway('armedAway') + msg += detailedNotifications ? ", Arming ADT to Away" : "" + } + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT\n$e" + msg += ", error arming ADT" + } + } + + if (settings."externalLockMode${lockStr}${user}${arm}") { + log.info "Changing mode to ${settings."externalLockMode${lockStr}${user}${arm}"}" + if (location.modes?.find{it.name == settings."externalLockMode${lockStr}${user}${arm}"}) { + setLocationMode(settings."externalLockMode${lockStr}${user}${arm}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."externalLockMode${lockStr}${user}${arm}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."externalLockMode${lockStr}${user}${arm}"}" : "" + } + + if (settings."externalLockPhrase${lockStr}${user}${arm}") { + log.info "Running $lock specific locked Phrase ${settings."externalLockPhrase${lockStr}${user}${arm}"} for ${name ?: "external lock"}" + location.helloHome.execute(settings."externalLockPhrase${lockStr}${user}${arm}") + msg += detailedNotifications ? ", running ${settings."externalLockPhrase${lockStr}${user}${arm}"}" : "" + } else { + log.trace "No individual routine configured to run when locked $lockMode for $lock" + } + + if (settings."externalLockTurnOnSwitches${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, turning on switches ${settings."externalLockTurnOnSwitches${lockStr}${user}${arm}"}" + settings."externalLockTurnOnSwitches${lockStr}${user}${arm}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."externalLockTurnOnSwitches${lockStr}${user}${arm}"}" : "" + } + + if (settings."externalLockTurnOffSwitches${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, turning off switches ${settings."externalLockTurnOffSwitches${lockStr}${user}${arm}"}" + settings."externalLockTurnOffSwitches${lockStr}${user}${arm}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."externalLockTurnOffSwitches${lockStr}${user}${arm}"}" : "" + } + + if (settings."lockLocks${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, locking ${settings."lockLocks${lockStr}${user}${arm}"}" + settings."lockLocks${lockStr}${user}${arm}"?.lock() + msg += detailedNotifications ? ", locking ${settings."lockLocks${lockStr}${user}${arm}"}" : "" + } + + if (settings."closeGarage${lockStr}${user}${arm}") { + log.info "$evt.displayName was locked successfully, closing garage ${settings."closeGarage${lockStr}${user}${arm}"}" + settings."closeGarage${lockStr}${user}${arm}"?.close() + msg += detailedNotifications ? ", closing garage ${settings."closeGarage${lockStr}${user}${arm}"}" : "" + } + } + + // Send a notification if required (message would be updated) + if (i) { // If we have a known user, increment the usage count + state.codeUseCount[lock.id][i as String] = (state.codeUseCount[lock.id][i as String] ?: 0) + 1 + } + if ((notify && ( + (notifyModes ? notifyModes?.find{it == location.mode} : true) && + (notifyXPresence ? notifyXPresence.every{it.currentPresence != "present"} : true) + ) && ( + !i || (notifyCount ? (state.codeUseCount[lock.id][i as String] <= notifyCount) : true) + )) || + (!i && extLockNotify && (extLockNotifyModes ? extLockNotifyModes.find{it == location.mode} : true))) { + msgs << msg + } + } else { // MANUAL LOCK + log.trace "Lock $evt.displayName locked manually, Source type: $lockMode" + + // Check if we have individual actions for each lock + def lockStr = "" + if (settings."individualDoorActions") { + lockStr = lock as String + } else { + lockStr = "" + } + + def msg = evt.sendNotifications ? "Completing lock actions for $evt.displayName" : "$evt.displayName was locked $lockMode" // Default message to send + + if (settings."runXPeopleLockActionsManual${lockStr}"?.find{it.currentPresence == "present"}) { + log.trace "${settings."runXPeopleLockActionsManual${lockStr}"?.find{it.currentPresence == "present"}} is present, not running lock actions for door $lock" + } else if (settings."runXModeLockActionsManual${lockStr}"?.find{it == location.mode}) { + log.trace "Current mode is ${location.mode}, not running lock actions for door $lock" + } else { + if (settings."homeArmManual${lockStr}") { + log.info "Arming Smart Home Monitor to Stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + msg += detailedNotifications ? ", Arming Smart Home Monitor to Stay" : "" + } + + try { + if (settings."adtArmManual${lockStr}" && settings."adtDevices") { + log.info "Arming ADT to Stay" + settings."adtDevices"?.armStay('armedStay') + msg += detailedNotifications ? ", Arming ADT to Stay" : "" + } + } catch (e) { // This is still not official so lets be cautious about it + log.error "Error arming ADT to Stay\n$e" + msg += ", error arming ADT to Stay" + } + + if (settings."externalLockModeManual${lockStr}") { + log.info "Changing mode to ${settings."externalLockModeManual${lockStr}"}" + if (location.modes?.find{it.name == settings."externalLockModeManual${lockStr}"}) { + setLocationMode(settings."externalLockModeManual${lockStr}") // First do this to avoid false alerts from a slow platform + } else { + log.warn "Tried to change to undefined mode '${settings."externalLockModeManual${lockStr}"}'" + } + msg += detailedNotifications ? ", changing mode to ${settings."externalLockModeManual${lockStr}"}" : "" + } + + if (settings."externalLockPhraseManual${lockStr}") { + log.info "Running $lock specific locked Phrase ${settings."externalLockPhraseManual${lockStr}"} for ${name ?: "external lock"}" + location.helloHome.execute(settings."externalLockPhraseManual${lockStr}") + msg += detailedNotifications ? ", running ${settings."externalLockPhraseManual${lockStr}"}" : "" + } else { + log.trace "No individual routine configured to run when locked $lockMode for $lock" + } + + if (settings."externalLockTurnOnSwitchesManual${lockStr}") { + log.info "$evt.displayName was locked successfully, turning on switches ${settings."externalLockTurnOnSwitchesManual${lockStr}"}" + settings."externalLockTurnOnSwitchesManual${lockStr}"?.on() + msg += detailedNotifications ? ", turning on switches ${settings."externalLockTurnOnSwitchesManual${lockStr}"}" : "" + } + + if (settings."externalLockTurnOffSwitchesManual${lockStr}") { + log.info "$evt.displayName was locked successfully, turning off switches ${settings."externalLockTurnOffSwitchesManual${lockStr}"}" + settings."externalLockTurnOffSwitchesManual${lockStr}"?.off() + msg += detailedNotifications ? ", turning off switches ${settings."externalLockTurnOffSwitchesManual${lockStr}"}" : "" + } + + if (settings."lockLocksManual${lockStr}") { + log.info "$evt.displayName was locked successfully, locking ${settings."lockLocksManual${lockStr}"}" + settings."lockLocksManual${lockStr}"?.lock() + msg += detailedNotifications ? ", locking ${settings."lockLocksManual${lockStr}"}" : "" + } + + if (settings."closeGarageManual${lockStr}") { + log.info "$evt.displayName was locked successfully, closing garage ${settings."closeGarageManual${lockStr}"}" + settings."closeGarageManual${lockStr}"?.close() + msg += detailedNotifications ? ", closing garage ${settings."closeGarageManual${lockStr}"}" : "" + } + } + + // Send notitications for manual and electronic locking only, keypad is handled above with lock actions + if (settings."lockNotify${lockStr}" && (!(["keypad", "rfid"].any { lockMode?.toLowerCase().contains(it) })) && (settings."lockNotifyModes${lockStr}" ? settings."lockNotifyModes${lockStr}".find{it == location.mode} : true)) { + msgs << msg + } + } + + // Check if we are asked to send the notifications or return them back + if (evt.sendNotifications) { + // Last thing to do because it can timeout + for (msg1 in msgs) { + sendNotifications(msg1, (settings."userOverrideNotifications${i}" && settings."userNotify${i}") ? i as String : "") + } + } else { + return msgs + } +} + +// Heartbeat system to ensure that the MonitorTask doesn't die when it's supposed to be running +def heartBeatMonitor() { + log.trace "Heartbeat monitor called" + + state.lastHeartBeat = now() // Save the last time we were executed + + // Check if the user has upgraded the SmartApp and reinitailize if required + if (state.clientVersion != clientVersion()) { + def msg = "NOTE: ${app.label} detected a code upgrade. Updating configuration, please open the app and click on Save to re-validate your settings" + log.warn msg + startTimer(1, appTouch) // Reinitialize the app offline to avoid a loop as appTouch calls codeCheck + sendNotifications(msg) // Do this in the end as it may timeout + return + } +} + +def startTimer(seconds, function, dataMap = null) { + log.trace "Scheduled to run $function in $seconds seconds${dataMap ? " with data $dataMap" : ""}" + + //def runTime = new Date(now() + ((Long)seconds * 1000)) // for runOnce + //runOnce(runTime, function, [overwrite: true]) // runIn isn't reliable, runOnce is more reliable but isn't as accurate + if (dataMap) { + runIn(seconds, function, [overwrite: true, data: dataMap]) // runOnce is having issues with v2 hubs, hopefully runIn is more stable + } else { + runIn(seconds, function, [overwrite: true]) // runOnce is having issues with v2 hubs, hopefully runIn is more stable + } +} + +private void sendText(number, message) { + if (number) { + def phones = number.split("\\*") + for (phone in phones) { + sendSms(phone, message) + } + } +} + +private void sendNotifications(message, user = "") { + if (!message) { + return + } + + if (location.contactBookEnabled) { + sendNotificationToContacts(message, settings."recipients${user}") + } else { + if (!settings."disableAllNotify${user}") { + sendPush message + } else { + sendNotificationEvent(message) + } + if (settings."sms${user}") { + sendText(settings."sms${user}", message) + } + } + if (settings."audioDevices${user}") { + settings."audioDevices${user}"*.playTextAndResume(message) + } +} + +def checkForCodeUpdate(evt) { + log.trace "Getting latest version data from the RBoy Apps server" + + def appName = "User Unlock Lock Door Notifications and Actions" + def serverUrl = "http://smartthings.rboyapps.com" + def serverPath = "/CodeVersions.json" + + try { + httpGet([ + uri: serverUrl, + path: serverPath + ]) { ret -> + log.trace "Received response from RBoy Apps Server, headers=${ret.headers.'Content-Type'}, status=$ret.status" + //ret.headers.each { + // log.trace "${it.name} : ${it.value}" + //} + + if (ret.data) { + log.trace "Response>" + ret.data + + // Check for app version updates + def appVersion = ret.data?."$appName" + if (appVersion > clientVersion()) { + def msg = "New version of app ${app.label} available: $appVersion, current version: ${clientVersion()}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new app version found, latest version: $appVersion" + } + + // Check device handler version updates + def devices = locks?.findAll { it.hasAttribute("codeVersion") } + for (device in devices) { + if (device) { + def deviceName = device?.currentValue("dhName") + def deviceVersion = ret.data?."$deviceName" + if (deviceVersion && (deviceVersion > device?.currentValue("codeVersion"))) { + def msg = "New version of device ${device?.displayName} available: $deviceVersion, current version: ${device?.currentValue("codeVersion")}.\nPlease visit $serverUrl to get the latest version." + log.info msg + if (updateNotifications != false) { // The default true may not be registered + sendPush(msg) + } + } else { + log.trace "No new device version found for $deviceName, latest version: $deviceVersion, current version: ${device?.currentValue("codeVersion")}" + } + } + } + } else { + log.error "No response to query" + } + } + } catch (e) { + log.error "Exception while querying latest app version: $e" + } +} + +// THIS IS THE END OF THE FILE \ No newline at end of file diff --git a/smartapps/shackrat/hubconnect-remote-client.src/hubconnect-remote-client.groovy b/smartapps/shackrat/hubconnect-remote-client.src/hubconnect-remote-client.groovy new file mode 100644 index 00000000000..720fd75faba --- /dev/null +++ b/smartapps/shackrat/hubconnect-remote-client.src/hubconnect-remote-client.groovy @@ -0,0 +1,2397 @@ +/** + * HubConnect Remote Client for SmartThings + * + * Copyright 2019-2020 Steve White, Retail Media Concepts LLC. + * + * HubConnect for Hubitat is a software package created and licensed by Retail Media Concepts LLC. + * HubConnect, along with associated elements, including but not limited to online and/or electronic documentation are + * protected by international laws and treaties governing intellectual property rights. + * + * This software has been licensed to you. All rights are reserved. You may use and/or modify the software. + * You may not sublicense or distribute this software or any modifications to third parties in any way. + * + * By downloading, installing, and/or executing this software you hereby agree to the terms and conditions set forth in the HubConnect license agreement. + * + * + * Hubitat is the trademark and intellectual property of Hubitat, Inc. Retail Media Concepts LLC has no formal or informal affiliations or relationships with Hubitat. + * + * 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 Agreement + * for the specific language governing permissions and limitations under the License. + * + */ +Map getAppVersion() {[platform: "SmartThings", major: 2, minor: 0, build: 9800]} + +import groovy.transform.Field +import groovy.json.JsonOutput +include 'asynchttp_v1' + +definition( + name: "HubConnect Remote Client", + namespace: "shackrat", + author: "Steve White", + description: "Synchronizes devices and events across hubs..", + category: "My Apps", + iconUrl: "https://hubconnect.to/kbimages/hubconnect-logo.png", + iconX2Url: "https://hubconnect.to/kbimages/hubconnect-logo.png", + iconX3Url: "https://hubconnect.to/kbimages/hubconnect-logo.png" +) + + +// Preferences pages +preferences +{ + page(name: "mainPage") + page(name: "connectPage") + page(name: "devicePage") + page(name: "sensorsPage") + page(name: "customDevicePage") + page(name: "shmConfigPage") + page(name: "dynamicDevicePage") + page(name: "upgradePage") + page(name: "discoverPage") + page(name: "connectFailPage") + page(name: "connectWizard_DiscoverPage") + page(name: "connectWizard_KeyPage") + page(name: "uninstallPage") + page(name: "resetPage") +} + + +// Map containing driver and attribute definitions for each device class +@Field static Map NATIVE_DEVICES = +[ + "arlocamera": [driver: "Arlo Camera", displayName: "Arlo Pro Cameras", platform: "SmartThings", selector: "arloProCameras", capability: "device.ArloProCamera", prefGroup: "smartthings", type: "attr", attr: ["switch", "motion", "sound", "rssi", "battery"]], + "arloqcamera": [driver: "Arlo Camera", displayName: "Arlo Q Cameras", platform: "SmartThings", selector: "arloQCameras", capability: "device.ArloQCamera", prefGroup: "smartthings", type: "attr", attr: ["switch", "motion", "sound", "rssi", "battery"]], + "arlogocamera": [driver: "Arlo Camera", displayName: "Arlo Go Cameras", platform: "SmartThings", selector: "arloGoCameras", capability: "device.ArloGoCamera", prefGroup: "smartthings", type: "attr", attr: ["switch", "motion", "sound", "rssi", "battery"]], + "arrival": [driver: "Arrival Sensor", selector: "smartThingsArrival", capability: "presenceSensor", prefGroup: "presence", type: "attr", attr: ["presence", "battery", "tone"]], + "audioVolume": [driver: "AVR", selector: "audioVolume", capability: "audioVolume", prefGroup: "audio", type: "attr", attr: ["switch", "mediaInputSource", "mute", "volume"]], + "bulb": [driver: "Bulb", selector: "genericBulbs", capability: "changeLevel", prefGroup: "lighting", type: "attr", attr: ["switch", "level"]], + "button": [driver: "Button", selector: "genericButtons", capability: "button", prefGroup: "other", type: "attr", attr: ["numberOfButtons", "pushed", "held", "doubleTapped", "button", "temperature", "battery"]], + "contact": [driver: "Contact Sensor", selector: "genericContacts", capability: "contactSensor", prefGroup: "sensors", type: "attr", attr: ["contact", "temperature", "battery"]], + "dimmer": [driver: "Dimmer", selector: "genericDimmers", capability: "switchLevel", prefGroup: "lighting", type: "attr", attr: ["switch", "level"]], + "domemotion": [driver: "DomeMotion Sensor", selector: "domeMotions", capability: "motionSensor", prefGroup: "sensors", type: "attr", attr: ["motion", "temperature", "illuminance", "battery"]], + "energy": [driver: "Energy Meter", selector: "energyMeters", capability: "energyMeter", prefGroup: "power", type: "attr", attr: ["energy"]], + "energyplug": [driver: "DomeAeon Plug", selector: "energyPlugs", capability: "energyMeter", prefGroup: "lighting", type: "attr", attr: ["switch", "power", "voltage", "current", "energy", "acceleration"]], + "fancontrol": [driver: "Fan Controller", selector: "fanControl", capability: "fanControl", prefGroup: "fans", type: "attr", attr: ["speed"]], + "fanspeed": [driver: "FanSpeed Controller", selector: "fanSpeedControl", capability: "fanControl", prefGroup: "fans", type: "attr", attr: ["speed"]], + "garagedoor": [driver: "Garage Door", selector: "garageDoors", capability: "garageDoorControl", prefGroup: "other", type: "attr", attr: ["door", "contact"]], + "irissmartplug": [driver: "Iris SmartPlug", selector: "smartPlugs", capability: "switch", prefGroup: "shackrat", type: "synth", attr: ["switch", "power", "voltage", "ACFrequency"]], + "irisv3motion": [driver: "Iris IL071 Motion Sensor", selector: "irisV3Motions", capability: "motionSensor", prefGroup: "shackrat", type: "synth", attr: ["motion", "temperature", "humidity", "battery"]], + "keypad": [driver: "Keypad", selector: "genericKeypads", capability: "securityKeypad", prefGroup: "safety", type: "attr", attr: ["motion", "temperature", "battery", "tamper", "alarm", "lastCodeName"]], + "lock": [driver: "Lock", selector: "genericLocks", capability: "lock", prefGroup: "safety", type: "attr", attr: ["lock", "lockCodes", "lastCodeName", "codeChanged", "codeLength", "maxCodes", "battery"]], + "mobileApp": [driver: "Mobile App", selector: "mobileApp", capability: "notification", prefGroup: "presence", type: "attr", attr: ["presence", "notificationText"]], + "moisture": [driver: "Moisture Sensor", selector: "genericMoistures", capability: "waterSensor", prefGroup: "safety", type: "attr", attr: ["water", "temperature", "battery"]], + "motion": [driver: "Motion Sensor", selector: "genericMotions", capability: "motionSensor", prefGroup: "sensors", type: "attr", attr: ["motion", "temperature", "battery"]], + "motionswitch": [driver: "Motion Switch", selector: "genericMotionSwitches", capability: "switch", prefGroup: "lighting", type: "attr", attr: ["switch", "motion"]], + "multipurpose": [driver: "Multipurpose Sensor", selector: "genericMultipurposes", capability: "accelerationSensor", prefGroup: "sensors", type: "attr", attr: ["contact", "temperature", "battery", "acceleration", "threeAxis"]], + "netatmowxbase": [driver: "Netatmo Community Basestation", selector: "netatmoWxBasetations", capability: "relativeHumidityMeasurement", prefGroup: "netatmowx", type: "synth", attr: ["temperature", "humidity", "pressure", "carbonDioxide", "soundPressureLevel", "sound", "min_temp", "max_temp", "temp_trend", "pressure_trend"]], + "netatmowxmodule": [driver: "Netatmo Community Additional Module", selector: "netatmoWxModule", capability: "relativeHumidityMeasurement", prefGroup: "netatmowx", type: "synth", attr: ["temperature", "humidity", "carbonDioxide", "min_temp", "max_temp", "temp_trend", "battery"]], + "netatmowxoutdoor": [driver: "Netatmo Community Outdoor Module", selector: "netatmoWxOutdoor", capability: "relativeHumidityMeasurement", prefGroup: "netatmowx", type: "synth", attr: ["temperature", "humidity", "min_temp", "max_temp", "temp_trend", "battery"]], + "netatmowxrain": [driver: "Netatmo Community Rain", selector: "netatmoWxRain", capability: "sensor", prefGroup: "netatmowx", type: "synth", attr: ["rain", "rainSumHour", "rainSumDay", "units", "battery"]], + "netatmowxwind": [driver: "Netatmo Community Wind", selector: "netatmoWxWind", capability: "sensor", prefGroup: "netatmowx", type: "synth", attr: ["WindStrength", "WindAngle", "GustStrength", "GustAngle", "max_wind_str", "date_max_wind_str", "units", "battery"]], + "omnipurpose": [driver: "Omnipurpose Sensor", selector: "genericOmnipurposes", capability: "relativeHumidityMeasurement", prefGroup: "sensors", type: "attr", attr: ["motion", "temperature", "humidity", "illuminance", "ultravioletIndex", "tamper", "battery"]], + "pocketsocket": [driver: "Pocket Socket", selector: "pocketSockets", capability: "switch", prefGroup: "lighting", type: "attr", attr: ["switch", "power"]], + "power": [driver: "Power Meter", selector: "powerMeters", capability: "powerMeter", prefGroup: "power", type: "attr", attr: ["power"]], + "presence": [driver: "Presence Sensor", selector: "genericPresences", capability: "presenceSensor", prefGroup: "presence", type: "attr", attr: ["presence", "battery"]], + "ringdoorbell": [driver: "Ring Doorbell", selector: "ringDoorbellPros", capability: "device.RingDoorbellPro", prefGroup: "smartthings", type: "attr", attr: ["numberOfButtons", "pushed", "motion"]], + "rgbbulb": [driver: "RGB Bulb", selector: "genericRGBs", capability: "colorControl", prefGroup: "lighting", type: "attr", attr: ["switch", "level", "hue", "saturation", "RGB", "color", "colorMode", "colorTemperature"]], + "rgbwbulb": [driver: "RGBW Bulb", selector: "genericRGBW", capability: "colorMode", prefGroup: "lighting", type: "attr", attr: ["switch", "level", "hue", "saturation", "RGB(w)", "color", "colorMode", "colorTemperature"]], + "shock": [driver: "Shock Sensor", selector: "genericShocks", capability: "shockSensor", prefGroup: "sensors", type: "attr", attr: ["shock", "battery"]], + "siren": [driver: "Siren", selector: "genericSirens", capability: "alarm", prefGroup: "safety", type: "attr", attr: ["switch", "alarm", "battery"]], + "smartsmoke": [driver: "Smart SmokeCO", selector: "smartSmokeCO", capability: "device.HaloSmokeAlarm", prefGroup: "safety", type: "attr", attr: ["smoke", "carbonMonoxide", "battery", "temperature", "humidity", "switch", "level", "hue", "saturation", "pressure"]], + "smoke": [driver: "SmokeCO", selector: "genericSmokeCO", capability: "smokeDetector", prefGroup: "safety", type: "attr", attr: ["smoke", "carbonMonoxide", "battery"]], + "speaker": [driver: "Speaker", selector: "genericSpeakers", capability: "musicPlayer", prefGroup: "audio", type: "attr", attr: ["level", "mute", "volume", "status", "trackData", "trackDescription"]], + "speechSynthesis": [driver: "SpeechSynthesis", selector: "speechSynth", capability: "speechSynthesis", prefGroup: "other", type: "attr", attr: ["mute", "version", "volume"]], + "switch": [driver: "Switch", selector: "genericSwitches", capability: "switch", prefGroup: "lighting", type: "attr", attr: ["switch"]], + "thermostat": [driver: "Thermostat", selector: "genericThermostats", capability: "thermostat", prefGroup: "climate", type: "attr", attr: ["coolingSetpoint", "heatingSetpoint", "schedule", "supportedThermostatFanModes", "supportedThermostatModes", "temperature", "thermostatFanMode", "thermostatMode", "thermostatOperatingState", "thermostatSetpoint"]], + "windowshade": [driver: "Window Shade", selector: "windowShades", capability: "windowShade", prefGroup: "other", type: "attr", attr: ["switch", "position", "windowShade"]], + "valve": [driver: "Valve", selector: "genericValves", capability: "valve", prefGroup: "other", type: "attr", attr: ["valve"]], + "v_acceleration": [driver: "Virtual Acceleration Sensor", selector: "virtualAcceleration", capability: "accelerationSensor", prefGroup: "virtual", type: "synth", attr: ["acceleration"]], + "v_audiovolume": [driver: "Virtual audioVolume", selector: "virtualAudioVolume", capability: "audioVolume", prefGroup: "virtual", type: "synth", attr: ["volume", "mute"]], + "v_co_detector": [driver: "Virtual CO Detector", selector: "virtualCO", capability: "carbonMonoxideDetector", prefGroup: "virtual", type: "synth", attr: ["carbonMonoxide"]], + "v_contact": [driver: "Virtual Contact Sensor", selector: "virtualContact", capability: "contactSensor", prefGroup: "virtual", type: "synth", attr: ["contact"]], + "v_humidity": [driver: "Virtual Virtual Humidity Sensor", selector: "virtualHumidity", capability: "relativeHumidityMeasurement", prefGroup: "virtual", type: "synth", attr: ["humidity"]], + "v_illuminance": [driver: "Virtual Illuminance Sensor", selector: "virtualIlluminance", capability: "illuminanceMeasurement", prefGroup: "virtual", type: "synth", attr: ["illuminance"]], + "v_moisture": [driver: "Virtual Moisture Sensor", selector: "virtualMoisture", capability: "waterSensor", prefGroup: "virtual", type: "synth", attr: ["water"]], + "v_motion": [driver: "Virtual Motion Sensor", selector: "virtualMotion", capability: "motionSensor", prefGroup: "virtual", type: "synth", attr: ["motion"]], + "v_multi": [driver: "Virtual Multi Sensor", selector: "virtualMulti", capability: "accelerationSensor", prefGroup: "virtual", type: "synth", attr: ["acceleration", "contact", "temperature"]], + "v_omni": [driver: "Virtual Omni Sensor", selector: "virtualOmni", capability: "carbonDioxideMeasurement", prefGroup: "virtual", type: "synth", attr: ["acceleration", "carbonDioxide", "carbonMonoxide", "contact", "energy", "humidity", "illuminance", "power", "presence", "smoke", "temperature", "water"]], + "v_presence": [driver: "Virtual Presence Sensor", selector: "virtualPresence", capability: "presenceSensor", prefGroup: "virtual", type: "synth", attr: ["presence"]], + "v_smoke_detector": [driver: "Virtual Smoke Detector", selector: "virtualSmoke", capability: "smokeDetector", prefGroup: "virtual", type: "synth", attr: ["smoke"]], + "v_temperature": [driver: "Virtual Temperature Sensor", selector: "virtualTemperature", capability: "temperatureMeasurement", prefGroup: "virtual", type: "synth", attr: ["temperature"]], + "vc_globalvar": [driver: "RM Global Variable Connector", platform: "Hubitat", selector: "virtualGlobVar", capability: "*", prefGroup: "virtual", synthetic: true, attr: ["sensor"]], + "zwaverepeater": [driver: "Iris Z-Wave Repeater", selector: "zwaveRepeaters", capability: "device.IrisZ-WaveRepeater", prefGroup: "shackrat", type: "synth", attr: ["status", "lastRefresh", "deviceMSR", "deviceVersion", "deviceZWaveLibType", "deviceZWaveVersion", "lastMsgRcvd"]], + + // HubConnect Apps + "automatic": [driver: "Automatic Vehicle", selector: "automaticVehicles", capability: "presenceSensor", prefGroup: "hcautomatic", type: "hcapp", attr: ["presence"]], + "NAMain": [driver: "Netatmo Basestation", selector: "hcnetatmowxBasetations", capability: "relativeHumidityMeasurement", prefGroup: "hcnetatmowx", type: "hcapp", attr: ["temperature", "humidity", "pressure", "carbonDioxide", "soundPressureLevel", "sound", "lowTemperature", "highTemperature", "temperatureTrend", "pressureTrend"]], + "NAModule1": [driver: "Netatmo Outdoor Module", selector: "hcnetatmowxOutdoor", capability: "relativeHumidityMeasurement", prefGroup: "hcnetatmowx", type: "hcapp", attr: ["temperature", "humidity", "lowTemperature", "highTemperature", "temperatureTrend", "battery"]], + "NAModule2": [driver: "Netatmo Wind", selector: "hcnetatmowxWind", capability: "sensor", prefGroup: "hcnetatmowx", type: "hcapp", attr: ["WindStrength", "WindAngle", "GustStrength", "GustAngle", "max_wind_str", "date_max_wind_str", "units", "battery"]], + "NAModule3": [driver: "Netatmo Rain", selector: "hcnetatmowxRain", capability: "sensor", prefGroup: "hcnetatmowx", type: "hcapp", attr: ["rain", "rainSumHour", "rainSumDay", "units", "battery"]], + "NAModule4": [driver: "Netatmo Additional Module", selector: "hcnetatmowxModule", capability: "relativeHumidityMeasurement", prefGroup: "hcnetatmowx", type: "hcapp", attr: ["temperature", "humidity", "carbonDioxide", "lowTemperature", "highTemperature", "temperatureTrend", "battery"]] +] + +// Map containing device group definitions +// NOTE: The parent: property is not supported because the SmartThings UI Cannot deal with with the extra layer of nested page calls. +@Field static Map DEVICE_GROUPS = +[ + "audio": [title: "Audio Devices", description: "Speakers, AV Receivers"], + "climate": [title: "Climate Devices", description: "Thermostats & Weather Stations"], + "fans": [title: "Fans", description: "Celing Fans & Devices"], + "lighting": [title: "Lighting Devices", description: "Bulbs, Dimmers, RGB/RGBW Lights, and Switches"], + "hcautomatic": [title: "HubConnect Automatic Client", description: "HubConnect Automatic Driving Tracker Integration"], + "hcnetatmowx": [title: "HubConnect Netatmo Client", description: "HubConnect Netatmo Weather Station Client"], + "netatmowx": [title: "Netatmo Weather Station (Community)", description: "Netatmo Weather Station Community Integration"], + "other": [title: "Other Devices", description: "Presence, Button, Valves, Garage Doors, SpeechSynthesis, Window Shades"], + "power": [title: "Power & Energy Meters", description: "Power Meters & Energy Meters"], + "presence": [title: "Presence Sensors & Apps", description: "Arrival Sensors, Presence Sensors, and Mobile Apps"], + "safety": [title: "Safety & Security", description: "Locks, Keypads, Smoke & Carbon Monoxide, Leak, Sirens"], + "sensors": [title: "Sensors", description: "Contact, Mobile App, Motion, Multipurpose, Omnipurpose, Presence, Shock, GV Connector"], + "shackrat": [title: "Shackrat's Drivers", description: "Iris V3 Motion, Iris Smart Plug, Z-Wave Repeaters"], + "virtual": [title: "Virtual Devices", description: "Hubitat Virtual Devices"] +] + + +// Mapping to receive events - Note: ST can only support 4 path parts! +mappings +{ + // Client mappings + path("/event/:deviceId/:deviceCommand/:commandParams") + { + action: [GET: "remoteDeviceCommand"] + } + path("/modes/get") + { + action: [GET: "getAllModes"] + } + path("/modes/set/:name") + { + action: [GET: "remoteModeChange"] + } + path("/hsm/get") + { + action: [GET: "getAllHSMStates"] + } + path("/hsm/set/:name") + { + action: [GET: "remoteHSMChange"] + } + + // System endpoints + path("/system/setCommStatus/:status") + { + action: [GET: "systemSetCommStatus"] + } + path("/system/setConnectString/:connectKey") + { + action: [GET: "systemSetConnectKey"] + } + path("/system/drivers/save") + { + action: [POST: "systemSaveCustomDrivers"] + } + path("/system/versions/get") + { + action: [GET: "systemGetVersions"] + } + path("/system/initialize") + { + action: [GET: "systemRemoteInitialize"] + } + path("/system/update") + { + action: [GET: "systemRemoteUpdate"] + } + path("/system/tsreport/get") + { + action: [GET: "systemGetTSReport"] + } + path("/system/disconnect") + { + action: [GET: "systemRemoteDisconnect"] + } + + // Server mappings + path("/devices/save") + { + action: [POST: "devicesSaveAll"] + } + path("/device/:deviceId/event/:event") + { + action: [GET: "deviceSendEvent"] + } + path("/device/:deviceId/sync/:type") + { + action: [GET: "getDeviceSync"] + } +} + + +/* + getDeviceSync + + Purpose: Retrieves the current attribute and device name/label. + + URL Format: GET /device/:deviceId/sync/:type + + API: https://hubconnect.to/knowledgebase/7/getDeviceSync.html +*/ +def getDeviceSync() {getDeviceSync(params)} +def getDeviceSync(Map params) +{ + if (enableDebug) log.info "Received device update request from server: [${params.deviceId}, type ${params.type}]" + + def device = getDevice(params) + if (device) + { + def currentAttributes = getAttributeMap(device, params.type) + String label = device.label ?: device.name + jsonResponse([status: "success", name: "${device.name}", label: "${label}", currentValues: currentAttributes]) + } +} + + +/* + getDevice + + Purpose: Helper function to retreive a device from all groups of devices. +*/ +def getDevice(Map params) +{ + def foundDevice = null + + NATIVE_DEVICES.each + { + groupname, device -> + if (foundDevice != null) return + foundDevice = settings."${device.selector}"?.find{it.id == params.deviceId} + } + + // Custom devices drivers + if (foundDevice == null) + { + state.customDrivers?.each + { + groupname, device -> + if (foundDevice != null) return + foundDevice = settings."custom_${groupname}".find{it.id == params.deviceId} + } + } + foundDevice +} + + +/* + remoteDeviceCommand + + Purpose: Executes a command on a device located on this hub. + + Parameters: params - (Map) + + URL Format: GET /event/:deviceId/:deviceCommand/:commandParams + + API: https://hubconnect.to/knowledgebase/6/remoteDeviceCommand.html +*/ +def remoteDeviceCommand() {remoteDeviceCommand(params)} +def remoteDeviceCommand(Map params) +{ + List commandParams = params.commandParams != "null" ? parseJson(URLDecoder.decode(params.commandParams)) : [] + + // Get the device + def device = getDevice(params) + if (device == null) + { + log.error "Could not locate a device with an id of ${params.deviceId}" + return jsonResponse([status: "error"]) + } + + // DeleteSync: Uninstalling? + if (params.deviceCommand == "uninstalled") + { + def driverDef = NATIVE_DEVICES.findResults{groupname, driver -> settings?."${driver.selector}"?.findResults{ if (it.id == "${device.id}") return driver.selector }?.join() }?.join() ?: + state.customDrivers.each.findResults{groupname, driver -> settings?."custom_${groupname}"?.findResults{ if (it.id == "${device.id}") return driver.selector }?.join() }?.join() + + if (driverDef) + { + // "de-select" the device - this probably doesn't work on SmartThings + def newSetting = settings?."${driverDef}"?.findResults{if (it.id != "${device.id}") return it.id} + app.updateSetting("${driverDef}", [type: "capability", value: newSetting]) + if (enableDebug) log.info "Received device delete requet from client: [\"${device.label ?: device.name}]\".. Sharing of this device has been disabled on this hub." + } + + // Update subscriptions + subscribeLocalEvents() + + return jsonResponse([status: "success"]) + } + + if (enableDebug) log.info "Received command from server: [\"${device.label ?: device.name}\": ${params.deviceCommand}]" + + String deviceCommand = params.deviceCommand + + // Fix for ST to HE lock code fetch + if (deviceCommand == "getCodes" && !device.hasCommand(deviceCommand)) deviceCommand = "reloadAllCodes" + + // Fix for broken button ST drivers + if (deviceCommand == "push" && !device.hasCommand("push")) + { + deviceCommand = "push${commandParams[0]}" + commandParams = [] + } + + // Make sure the physical device supports the command + if (!device.hasCommand(deviceCommand)) + { + log.error "The device [${device.label ?: device.name}] does not support the command ${params.deviceCommand}." + return jsonResponse([status: "error"]) + } + + // Execute the command + device."${deviceCommand}"(*commandParams) + + jsonResponse([status: "success"]) +} + + +/* + remoteModeChange + + Purpose: Executes a mode change on this hub. + + URL Format: GET /modes/set/:name + + API: https://hubconnect.to/knowledgebase/9/remoteModeChange.html +*/ +def remoteModeChange() {remoteModeChange(params)} +def remoteModeChange(Map params) +{ + String modeName = params?.name ? URLDecoder.decode(params?.name) : "" + if (enableDebug) log.debug "Received mode event from server: ${modeName}" + + // Send mode status event to the remote hub device to update even if it's not defined on this hub + if (hubDevice != null) hubDevice.sendEvent([name: "modeStatus", value: modeName]) + + if (location.modes?.find{it.name == modeName}) + { + setLocationMode(modeName) + jsonResponse([status: "complete"]) + } + else + { + jsonResponse([status: "error"]) + } +} + + +/* + remoteHSMChange + + Purpose: Executes a SHM setArm command on this hub. + + URL Format: GET /hsm/set/:name + + API: https://hubconnect.to/knowledgebase/11/remoteHSMChange.html +*/ +def remoteHSMChange() {remoteHSMChange(params)} +def remoteHSMChange(Map params) +{ + String hsmState = params?.name ? URLDecoder.decode(params?.name) : "" + + // Send HSM status event to the remote hub device to update even if it's not configured on this hub + if (hubDevice != null) hubDevice.sendEvent([name: "hsmStatus", value: hsmState]) + + Map hsmToSHM = + [ + armAway: settings?.armAway, + armHome: settings?.armHome, + armNight: settings?.armNight, + disarm: "off" + ] + + if (hsmToSHM.find{it.key == hsmState}) + { + String shmState = hsmToSHM?."${hsmState}" + if (shmState != null) + { + if (enableDebug) log.debug "Received HSM/SHM event from server: ${hsmState}, setting SHM state to ${shmState}" + sendLocationEvent(name: "alarmSystemStatus", value: shmState) + jsonResponse([status: "complete"]) + } + else + { + if (enableDebug) log.debug "HSM/SHM event error ${hsmState} SHM has not been configured for this alarm state." + jsonResponse([status: "error"]) + } + } + else + { + log.error "Received HSM event from server: ${hsmState} does not exist!" + jsonResponse([status: "error"]) + } +} + + +/* + subscribeLocalEvents + + Purpose: Subscribes to all device events for all attribute returned by getSupportedAttributes() + + Notes: Thank god this isn't SmartThings, or this would time out after about 10 subscriptions! + +*/ +private void subscribeLocalEvents() +{ + log.info "Subscribing to events.." + unsubscribe() + + NATIVE_DEVICES.each + { + groupname, device -> + def selectedDevices = settings."${device.selector}" + if (selectedDevices?.size()) getSupportedAttributes(groupname).each + { + switch (groupname) + { + case "button": + subscribe(selectedDevices, it, buttonEventTranslator) + break + case "lock": + case "lockCodes": + subscribe(selectedDevices, it, lockEventTranslator) + break + default: + subscribe(selectedDevices, it, realtimeEventHandler) + break + } + } + } + + // Special handling for Smart Plugs & Power Meters - Kinda Kludgy + if (!sp_EnablePower && smartPlugs?.size()) unsubscribe(smartPlugs, "power", realtimeEventHandler) + if (!sp_EnableVolts && smartPlugs?.size()) unsubscribe(smartPlugs, "voltage", realtimeEventHandler) + if (!pm_EnableVolts && powerMeters?.size()) unsubscribe(powerMeters, "voltage", realtimeEventHandler) + + // Custom defined drivers + state.customDrivers?.each + { + groupname, driver -> + if (settings."custom_${groupname}"?.size()) getSupportedAttributes(groupname).each { subscribe(settings."custom_${groupname}", it, realtimeEventHandler) } + } +} + + +/* + buttonEventTranslator + + Purpose: Translates SmartThings button events into Hubitat button events. + + Platform: SmartThings Only +*/ +def buttonEventTranslator(evt) +{ + def data = parseJson(evt.data) + return realtimeEventHandler([name: evt.value, value: data.buttonNumber, unit: "", isStateChange: true, data: "", deviceId: evt.deviceId, device: evt.device]) +} + + +/* + lockEventTranslator + + Purpose: Translates SmartThings lock & lock code events into Hubitat lock events. + + Platform: SmartThings Only +*/ +def lockEventTranslator(evt) +{ + Map codeEvent = + [ + name: evt.name, + value: null, + displayName: evt.displayName ?: (evt.device?.label ?: evt.device?.name), // SmartThings will always populate displayName; Hubitat, not so much + data: [], + unit: null, + deviceId: evt.deviceId, + device: evt.device + ] + + // If this is a lock/unlock event, then we need to send a "lastCodeName" event BEFORE we send the unlock event so that the value is populated + // for any subscribers to the unlock event + if ((evt.name == "lock") && (evt.value == "unlocked")) + { + Map parsed = evt.data ? parseJson(evt.data) : [:] + String method = parsed?.method ?: "unknown" + codeEvent.name = "lastCodeName" + + // If not unlocked by the keypad, or no codeName is provided, then HE's "lastCodeName" attribute is invalid, so we have to clear it + if ((method != "keypad") || (!parsed?.codeName)) + { + codeEvent.value = null + // Send the "lastCodeName: null" codeEvent + realtimeEventHandler(codeEvent) + } + + // SmartThings' or the RBoy lock DTHs will provide the name of the unlocking user in [evt.data.codeName] + else + { + codeEvent.value = parsed.codeName + // Send the "lastCodeName: name" codeEvent + realtimeEventHandler(codeEvent) + } + + // Now we send the original unlock evt (unmodified) + return realtimeEventHandler(evt) + } + + // Translate SmartThings lockCodes[id:codeName] to Hubitat lockCodes[id[name:codeName,code:""] format + else if (evt.name == "lockCodes") + { + Map codes = (Map) [:] + Map codeMap = parseJson(evt.value) // Should be a String containing JSON like this "["1":"somebbody", "2":"somebody else", ...]" + + codeMap.each + { id, codeName -> + // Send a blank code for each id, because SmartThings doesn't expose the actual codes as an attribute + codes << [(id as String):["name": (codeName as String), "code": ""]] + } + // Note that lockCodes value is actually a String, not raw JSON + codeEvent.value = (codes.size() ? JsonOutput.toJson(codes) : evt.value) + + // Send the modified lockCodes codeEvent instead of the original evt + return realtimeEventHandler(codeEvent) + } + + // Not a lock event that we need to translate, so just send the original evt + else return realtimeEventHandler(evt) +} + + +/* + realtimeEventHandler + + Purpose: Event handler for all local device events. + + URL Format: /device/localDeviceId/event/name/value/unit + + Notes: Handles everything from this hub! +*/ +void realtimeEventHandler(evt) +{ + if (state.commDisabled) return + + Map event = + [ + name: evt.name, + value: evt.value, + unit: evt.unit, + displayName: evt.displayName ?: (evt.device.label ?: evt.device.name), + data: evt.data + ] + + String data = URLEncoder.encode(JsonOutput.toJson(event), "UTF-8") + + if (enableDebug) log.debug "Sending event to server: ${evt.device?.label ?: evt.device?.name} [${evt.name}: ${evt.value} ${evt.unit}]" + sendGetCommand("/device/${evt.deviceId}/event/${data}") +} + + +/* + getAttributeMap + + Purpose: Returns a map of current attribute values for (device) with the device class (deviceType). + + Notes: Calls getSupportedAttributes() to obtain list of attributes. +*/ +List getAttributeMap(Object device, String deviceClass) +{ + def deviceAttributes = getSupportedAttributes(deviceClass) + List currentAttributes = [] + deviceAttributes.each + { + if (device.supportedAttributes.find{attr -> attr.toString() == it}) // Filter only attributes the device supports + { + def value = device.currentValue("${it}") + + // Lock code translation + if (it == "lockCodes") + { + Map codes = (Map) [:] + Map codeMap = parseJson(value) // Should be a String containing JSON like this "["1":"somebbody", "2":"somebody else", ...]" + + codeMap.each + { id, codeName -> + // Send a blank code for each id, because SmartThings doesn't expose the actual codes as an attribute + codes << [(id as String):["name": (codeName as String), "code": ""]] + } + // Note that lockCodes value is actually a String, not raw JSON + value = (codes.size() ? JsonOutput.toJson(codes) : value) + } + + currentAttributes << [name: (String) "${it}", value: value, unit: it == "temperature" ? "°"+getTemperatureScale() : it == "power" ? "W" : it == "voltage" ? "V" : ""] + } + } + return currentAttributes +} + + +/* + getSupportedAttributes + + Purpose: Returns a list of supported attribute values for the device class (deviceType). + + Notes: Called from getAttributeMap(). +*/ +private getSupportedAttributes(String deviceClass) +{ + if (NATIVE_DEVICES.find{it.key == deviceClass}) return NATIVE_DEVICES[deviceClass].attr + if (state.customDrivers.find{it.key == deviceClass}) return state.customDrivers[deviceClass].attr + return null +} + + +/* + realtimeModeChangeHandler + + URL Format: GET /modes/set/modeName + + Purpose: Event handler for mode change events on the controller hub (this one). +*/ +void realtimeModeChangeHandler(evt) +{ + if (state.commDisabled || !pushModes) return + + String newMode = evt.value + if (enableDebug) log.debug "Sending mode change event to server: ${newMode}" + sendGetCommand("/modes/set/${URLEncoder.encode(newMode)}") +} + + +/* + realtimeHSMChangeHandler + + URL Format: GET /hsm/set/hsmStateName + + Purpose: Event handler for HSM state change events on the controller hub (this one). +*/ +void realtimeHSMChangeHandler(evt) +{ + if (state.commDisabled || !pushHSM) return + String newState = evt.value + + Map hsmToSHM = + [ + armAway: settings?.armAway, + armHome: settings?.armHome, + armNight: settings?.armNight, + disarm: "off" + ] + + String hsmState = hsmToSHM.find{it.value == newState}?.key + if (hsmState) + { + if (enableDebug) log.debug "Sending SHM to HSM state change event to server: ${newState} to ${hsmState}" + sendGetCommand("/hsm/set/${URLEncoder.encode(hsmState)}") + } + else if (enableDebug) log.debug "Error sending SHM to HSM state change to server: ${hsmState} is not mapped to ${newState}." +} + + +/* + saveDevicesToServer + + Purpose: Sends all of the devices selected (& current attribute values) from this hub to the controller hub. + + URL Format: POST /devices/save + + Notes: Makes a single POST request for each group of devices. +*/ +void saveDevicesToServer() +{ + if (devicesChanged == false) return + + // Fetch all devices and attributes for each device group and send them to the master. + List idList = [] + List devices = [] + NATIVE_DEVICES.each + { + groupname, device -> + + devices = [] + settings."${device.selector}".each + { + devices << [id: it.id, label: it.label ?: it.name, attr: getAttributeMap(it, groupname)] + idList << it.id + } + if (devices != []) + { + if (enableDebug) log.info "Sending devices to server: ${groupname} - ${devices}" + sendPostCommand("/devices/save", [deviceclass: groupname, devices: devices]) + } + } + + // Custom defined device drivers + state.customDrivers.each + { + groupname, driver -> + + devices = [] + settings?."custom_${groupname}"?.each + { + devices << [id: it.id, label: it.label ?: it.name, attr: getAttributeMap(it, groupname)] + idList << it.id + } + if (devices != []) + { + if (enableDebug) log.info "Sending custom devices to remote: ${groupname} - ${devices}" + sendPostCommand("/devices/save", [deviceclass: groupname, devices: devices]) + } + } + if (cleanupDevices) sendPostCommand("/devices/save", [cleanupDevices: idList]) + state.saveDevices = false +} + + +/* + sendDeviceEvent + + Purpose: Send an event to a client device. + + URL format: GET /event/:deviceId/:deviceCommand/:commandParams + + Notes: CALLED FROM CHILD DEVICE +*/ +void sendDeviceEvent(String deviceId, String deviceCommand, List commandParams=[]) +{ + if (state.commDisabled) return + String[] dniParts = deviceId.split(":") + + String paramsEncoded = commandParams ? URLEncoder.encode(new groovy.json.JsonBuilder(commandParams).toString()) : null + sendGetCommand("/event/${dniParts[1]}/${deviceCommand}/${paramsEncoded}") +} + + +/* + deviceSendEvent + + Purpose: Receives and forwards events received from a physical device located on a remote hub. + + URL Format: GET /device/:deviceId/event/:event + + API: https://hubconnect.to/knowledgebase/22/deviceSendEvent.html +*/ +def deviceSendEvent() {deviceSendEvent(params)} +def deviceSendEvent(Map params) +{ + String eventraw = params.event ? URLDecoder.decode(params.event) : null + if (eventraw == null) return + + Map event = parseJson(new String(eventraw)) + String data = event?.data ?: "" + String unit = event?.unit ?: "" + + event.displayName = event.displayName.replace("!@!", "/").replace("!#!", "’") + + def childDevice = getChildDevice("${serverIP}:${params.deviceId}") + if (childDevice) + { + if (enableDebug) log.debug "Received event from Server/${childDevice.label}: [${event.name}, ${event.value} ${unit}, isStateChange: ${event.isStateChange}]" + childDevice.sendEvent([name: event.name, value: event.value, unit: unit, descriptionText: "${childDevice.displayName} ${event.name} is ${event.value} ${unit}", isStateChange: event.isStateChange, data: data]) + return jsonResponse([status: "complete"]) + } + else if (enableDebug) log.warn "Ignoring Received event from Server: Device Not Found!" + + return jsonResponse([status: "error"]) +} + + +/* + devicesSaveAll + + Purpose: Creates virtual shadow devices and connects them the remote hub. + + URL Format: POST /devices/save + + API: https://hubconnect.to/knowledgebase/21/devicesSaveAll.html +*/ +def devicesSaveAll() {devicesSaveAll(request?.JSON)} +def devicesSaveAll(Map params) +{ + // Device cleanup? + if (params?.cleanupDevices != null) + { + childDevices.each + { + child -> + if (child.deviceNetworkId != state.hubDeviceDNI && params?.cleanupDevices.find{"${serverIP}:${it}" == child.deviceNetworkId} == null) + { + if (enableDebug) log.info "Deleting device ${child.label} as it is no longer shared with this hub." + deleteChildDevice(child.deviceNetworkId) + } + } + } + + // Find the device class + else if (!params?.deviceclass || !params?.devices) + { + return jsonResponse([status: "error"]) + } + + if (NATIVE_DEVICES.find {it.key == params.deviceclass}) + { + // Create the devices + params.devices.each { createLinkedChildDevice(it, "HubConnect ${NATIVE_DEVICES[params.deviceclass].driver}") } + } + else if (state.customDrivers.find {it.key == params.deviceclass}) + { + // Get the custom device type and create the devices + params.devices.each { createLinkedChildDevice(it, "${state.customDrivers[params.deviceclass].driver}") } + } + + jsonResponse([status: "complete"]) +} + + +/* + createLinkedChildDevice + + Purpose: Helper function to create child devices. + + Notes: Called from saveDevices() +*/ +private createLinkedChildDevice(Map dev, String driverType) +{ + def childDevice = getChildDevice("${serverIP}:${dev.id}") + if (childDevice) + { + // Device exists + if (enableDebug) log.trace "${driverType} ${dev.label} exists... Skipping creation.." + return + } + else + { + if (enableDebug) log.trace "Creating Device ${driverType} - ${dev.label}... ${serverIP}:${dev.id}..." + try + { + childDevice = addChildDevice("shackrat", driverType, "${serverIP}:${dev.id}", null, [name: dev.label, label: dev.label]) + } + catch (errorException) + { + log.error "... Uunable to create device ${dev.label}: ${errorException}." + childDevice = null + } + } + + // Set the value of the primary attributes + if (childDevice) + { + dev.attr.each + { + attribute -> + childDevice.sendEvent([name: attribute.name, value: attribute.value, unit: attribute.unit]) + } + } +} + + +/* + syncDevice + + Purpose: Sync device details with the physcial device by requeting an update of all attribute values from the remote hub. + + Notes: CALLED FROM CHILD DEVICE +*/ +void syncDevice(String deviceNetworkId, String deviceType) +{ + String[] dniParts = deviceNetworkId.split(":") + Object childDevice = getChildDevice(deviceNetworkId) + if (childDevice) + { + if (enableDebug) log.debug "Requesting device sync from server: ${childDevice.label}" + + def data = httpGetWithReturn("/device/${dniParts[1]}/sync/${deviceType}") + if (data?.status == "success") + { + childDevice.setLabel(data.label) + + data?.currentValues.each + { + attr -> + childDevice.sendEvent([name: attr.name, value: attr.value, unit: attr.unit, descriptionText: "Sync: ${childDevice.displayName} ${attr.name} is ${attr.value} ${attr.unit}", isStateChange: true]) + } + } + } +} + + +/* + httpGetWithReturn + + Purpose: Helper function to format GET requests with the proper oAuth token. + + Notes: Command is absolute and must begin with '/' + Returns JSON Map if successful. +*/ +def httpGetWithReturn(String command) +{ + Map requestParams = + [ + uri: state.clientURI + command, + requestContentType: "application/json", + headers: + [ + Authorization: "Bearer ${state.clientToken}" + ] + ] + + // Using HubAction? + if (state.connectionType == "hubaction") + { + // This is called asynchronously in the child device; if we try to send HubAction here, atomicState is never updated. + // Send the command and wait for the result + if (hubDevice) + { + hubDevice.httpGetWithReturn(requestParams) + while (atomicState?.httpGetRequestResponse == null) pause(500) + + Map result = atomicState.httpGetRequestResponse + atomicState.httpGetRequestResponse = null + return result + } + else return [status: "error", message: "Remote Hub Device Missing"] + } + + try + { + httpGet(requestParams) + { + response -> + if (response?.status == 200) + { + return response.data + } + else + { + log.error "httpGet() request failed with error ${response?.status}" + return [status: "error", message: "httpGet() request failed with status code ${response?.status}"] + } + } + } + catch (Exception e) + { + log.error "httpGet() failed with error ${e.message}" + return [status: "error", message: e.message] + } +} + + +/* + httpGetWithReturnResponse + + Purpose: Helper function called by Hub Device to send results back to the Remote Client app. + + Notes: This request is asynchronous, a second request should not be called before the first completes. +*/ +void httpGetWithReturnResponse(Map responseData) +{ + atomicState.httpGetRequestResponse = responseData +} + + +/* + sendGetCommand + + Purpose: Helper function to format GET requests with the proper oAuth token. + + Notes: Executes async http request and does not return data. +*/ +void sendGetCommand(String command) +{ + if (state.clientURI == null) return + Map requestParams = + [ + uri: state.clientURI + command, + requestContentType: "application/json", + headers: + [ + Authorization: "Bearer ${state.clientToken}" + ] + ] + + // Using HubAction? + if (state.connectionType == "hubaction") + { + hubDevice?.sendGetCommand(requestParams) + return + } + + try + { + asynchttp_v1.get((enableDebug ? "asyncHTTPHandler" : null), requestParams) + } + catch (Exception e) + { + log.error "asynchttpGet() failed with error ${e.message}" + } +} + + +/* + asyncHTTPHandler + + Purpose: Helper function to handle returned data from asyncHttpGet. + + Notes: Does not return data, only logs errors when debugging is enabled. +*/ +void asyncHTTPHandler(response, data) +{ + if (response?.status != 200) + { + log.error "httpGet() request failed with error ${response?.status}" + } +} + + +/* + httpPostWithReturn + + Purpose: Helper function to format POST requests with the proper oAuth token. + + Notes: Command is absolute and must begin with '/' + Returns JSON Map if successful. +*/ +def httpPostWithReturn(String command, data) +{ + Map requestParams = + [ + uri: state.clientURI + command, + requestContentType: "application/json", + headers: + [ + Authorization: "Bearer ${state.clientToken}" + ], + body: data + ] + + // Using HubAction? + if (state.connectionType == "hubaction") + { + // This is called asynchronously in the child device; if we try to send HubAction here, atomicState is never updated. + // Send the command and wait for the result + hubDevice.sendPostCommand(requestParams) + while (atomicState?.sendPostCommandResponse == null) pause(250) + + Map result = atomicState.sendPostCommandResponse + atomicState.sendPostCommandResponse = null + return result + } + + try + { + httpPostJson(requestParams) + { + response -> + if (response?.status == 200) + { + return response.data + } + else + { + log.error "httpPost() request failed with error ${response?.status}" + } + } + } + catch (Exception e) + { + log.error "httpPostJson() failed with error ${e.message}" + return [status: "error", message: e.message] + } +} + + +/* + httpPostWithReturnResponse + + Purpose: Helper function called by Hub Device to send results back to the Remote Client app. + + Notes: This request is asynchronous, a second request should not be called before the first completes. +*/ +void httpPostWithReturnResponse(Map responseData) +{ + atomicState.httpGetRequestResponse = responseData +} + + +/* + sendPostCommand + + Purpose: Helper function to format POST requests with the proper oAuth token. + + Notes: Executes async http request and does not return data. +*/ +def sendPostCommand(String command, data) +{ + Map requestParams = + [ + uri: state.clientURI + command, + requestContentType: "application/json", + headers: + [ + Authorization: "Bearer ${state.clientToken}" + ], + body: data + ] + + // Using HubAction? + if (state.connectionType == "hubaction") + { + // This is called asynchronously in the child device; if we try to send HubAction here, atomicState is never updated. + // Send the command and wait for the result + hubDevice.sendPostCommand(requestParams) + return + } + + try + { + asynchttp_v1.post((enableDebug ? "asyncHTTPHandler" : null), requestParams) + } + catch (Exception e) + { + log.error "asynchttpPost() failed with error ${e.message}" + } +} + + +/* + appHealth + + Purpose: Checks in with the controller hub every 1 minute. + + URL Format: /ping + + Notes: Hubs are considered in a warning state after missing 2 pings (2 minutes). + Hubs are considered offline after missing 5 pings (5 minutes). + When a hub is offline, the virtual hub device presence state will be set to "not present". +*/ +void appHealth() +{ + sendGetCommand("/ping") +} + + +/* + systemSetCommStatus + + Purpose: Enable or disable bi-directional communications between hubs. + + URL Format: GET /system/setCommStatus/:status + + API: https://hubconnect.to/knowledgebase/13/systemSetCommStatus.html +*/ +def systemSetCommStatus() {systemSetCommStatus(params)} +def systemSetCommStatus(Map params) +{ + log.info "Received setCommStatus command from server: disabled ${params.status}]" + state.commDisabled = params.status == "false" ? false : true + + getHubDevice()?.(state.commDisabled ? "off" : "on")() + jsonResponse([status: "success", switch: params.status == "false" ? "on" : "off"]) +} + + +/* + setCommStatus + + Purpose: Event handler which disables events communications between hubs. + + Notes: This is useful to stop the remote hub from listening to the server web socket. + Called by Remote Hub Device +*/ +void setCommStatus(Boolean status) +{ + log.info "Received setCommStatus command from virtual hub device: disabled ${status}]" + log.info "Master bi-directional communciation status can only be set from the server hub." + state.commDisabled = status +} + + +/* + getAllModes + + Purpose: Returns a list of all configured modes on this hub. + + URL Format: GET /modes/get + + API: https://hubconnect.to/knowledgebase/8/getAllModes.html +*/ +def getAllModes() +{ + jsonResponse(modes: location.modes, active: location.mode) +} + + +/* + getAllHSMStates + + Purpose: Returns a list of all configured HSM States and the active state on this hub. + + URL Format: GET /hsm/get + + API: https://hubconnect.to/knowledgebase/10/getAllHSMStates.html +*/ +def getAllHSMStates() +{ + jsonResponse(hsmSetArm: ["armHome", "armNight", "off"], hsmStatus: location.currentState("alarmSystemStatus")?.value) +} + + +/* + systemSaveCustomDrivers + + Purpose: Saves the custom driver definitions from the server hub. + + URL Format: GET /system/drivers/save + + API: https://hubconnect.to/knowledgebase/16/systemSaveCustomDrivers.html +*/ +def systemSaveCustomDrivers() {systemSaveCustomDrivers(request?.JSON)} +def systemSaveCustomDrivers(Map params) +{ + if (params?.find{it.key == "customdrivers"}) + { + // Clean up from deleted drivers + state.customDrivers.each + { + key, driver -> + if (params?.customdrivers?.findAll{it.key == key}.size() == 0) + { + if (enableDebug) log.debug "Unsubscribing from events and removing device selector for ${key}" + unsubscribe(settings."custom_${key}") + settings.remove("custom_${key}") + } + } + state.customDrivers = params?.customdrivers + state.customDriverDBVersion = params?.customdriverdbversion + jsonResponse([status: "success"]) + } + else + { + jsonResponse([status: "error"]) + } +} + + +/* + installed + + Purpose: Standard install function. + + Notes: Doesn't do much. +*/ +void installed() +{ + log.info "${app.name} Installed" + + state.saveDevices = false + state.installedVersion = appVersion + + if (!state?.customDrivers) + { + state.customDrivers = (Map) [:] + state.customDriverDBVersion = 0 + } + + initialize() +} + + +/* + updated + + Purpose: Standard update function. + + Notes: Still doesn't do much. +*/ +void updated() +{ + log.info "${app.name} Updated" + + if (!state?.customDrivers) + { + state.customDrivers = (Map) [:] + state.customDriverDBVersion = 0 + } + + // Clean up ghost hub devices + childDevices.findAll{it.typeName == "HubConnect Remote Hub" && it.deviceNetworkId != state.hubDeviceDNI}.each{deleteChildDevice(it.deviceNetworkId)} + + initialize() + + state.installedVersion = appVersion +} + + +/* + systemRemoteUpdate + + Purpose: Processes the software update following the installation of new code. + + URL Format: GET /system/update + + API: https://hubconnect.to/knowledgebase/19/systemRemoteUpdate.html +*/ +def systemRemoteUpdate() +{ + updated() + jsonResponse([status: "success"]) +} + + +/* + initialize + + Purpose: Initialize the server instance. + + Notes: Parses the oAuth link into the token and base URL. A real token exchange would obviate the need for this. +*/ +void initialize() +{ + log.info "${app.name} Initialized" + unschedule() + unsubscribe() + + state.commDisabled = false + resetHubDiscovery() + + // Build a lookup table & update device IPs if necessary + if (updateDeviceIPs) + { + List parts = [] + childDevices.each + { + parts = it.deviceNetworkId.split(":") + if (parts?.size() > 1) + { + it.deviceNetworkId = "${serverIP}:${parts[1]}" + } + } + } + app.updateSetting("updateDeviceIPs", [type: "bool", value: false]) + + String[] connURI = state?.clientURI?.split(":") + String serverPort = connURI?.size() > 2 ? connURI[2] : "80" + + // Only create HubDevice for hubaction (local) connections. + if (state.connectionType == "hubaction") + { + def hubDevice = getHubDevice() + if (hubDevice) + { + hubDevice.setConnectionType(state.connectionType, serverIP, serverPort, null, null, null) + } + else if (state?.clientToken && state.hubDeviceDNI != null) + { + hubDevice = createHubChildDevice() + hubDevice?.setConnectionType(state.connectionType, serverIP, serverPort, null, null, null) + hubDevice?.updateClientToken(state.clientToken) + } + } + + if (isConnected) + { + saveDevicesToServer() + subscribeLocalEvents() + if (pushModes) subscribe(location, "mode", realtimeModeChangeHandler) + if (pushHSM) subscribe(location, "alarmSystemStatus", realtimeHSMChangeHandler) + runEvery1Minute("appHealth") + } + state.saveDevices = false + app.updateLabel("${ thisClientName ? thisClientName.replaceAll(/[^0-9a-zA-Z&_ ]/, "") + "${ isConnected ? ' [Online]' : ' [OFFLINE]' }" : 'HubConnect Remote Client' }") +} + + +/* + systemRemoteInitialize + + Purpose: Reinitializes the remote client & remote hub device on this hub. + + URL Format: GET /system/initialize + + API: https://hubconnect.to/knowledgebase/18/systemRemoteInitialize.html +*/ +def systemRemoteInitialize() +{ + initialize() + jsonResponse([status: "success"]) +} + + +/* + createHubChildDevice + + Purpose: Create child device for the server hub so up/down status can be managed with rules. + + Notes: Called from initialize() +*/ +private def createHubChildDevice() +{ + String serverHubName = "Server Hub" + def hubDevice = getHubDevice() + if (hubDevice != null) + { + // Hub exists + log.error "Remote hub device exists... Skipping creation.." + hubDevice = null + } + else + { + if (enableDebug) log.trace "Creating remote hub Device ${serverHubName}... ${state.hubDeviceDNI}..." + try + { + hubDevice = addChildDevice("shackrat", "HubConnect Remote Hub for SmartThings", state.hubDeviceDNI, (state.connectionType == "hubaction" ? location.hubs[0].id : null), [name: "HubConnect Hub", label: serverHubName]) + } + catch (errorException) + { + log.error "Unable to create the Remote Hub device: ${errorException}. Support Data: [id: \"${state.hubDeviceDNI}\", name: \"HubConnect Hub\", label: \"${serverHubName}\"]" + hubDevice = null + } + + // Set the value of the primary attributes + if (hubDevice != null) hubDevice.sendEvent([name: "presence", value: "present"]) + } + + hubDevice +} + + +/* + jsonResponse + + Purpose: Helper function to render JSON responses +*/ +def jsonResponse(Map respMap) +{ + render contentType: 'application/json', data: JsonOutput.toJson(respMap) +} + + +/* + getDevicePageStatus + + Purpose: Helper function to set flags for configured devices. +*/ +def getDevicePageStatus() +{ + Map status = (Map) [:] + NATIVE_DEVICES.each + { + groupname, device -> + status["${device.prefGroup}"] = (status["${device.prefGroup}"] ?: 0) + ((Integer) settings?."${device.selector}"?.size() ?: 0) + } + + // Custom defined device drivers + state.customDrivers.each + { + groupname, driver -> + status["custom"] = (status["custom"] ?: 0) + ((Integer) settings?."custom_${groupname}"?.size() ?: 0) + } + + status["all"] = status.collect{it.value}.sum() + status +} + + +/* + deviceCategoryStatus + + Purpose: Helper function to set flags for configured devices on a category page. +*/ +Integer deviceCategoryStatus(String page) +{ + (DEVICE_GROUPS.findResults{groupname, group -> if (page == group.parent) devicePageStatus."${groupname}"}.sum() ?: 0) + ((Integer) devicePageStatus."${page}" ?: 0) +} + + +/* + mainPage + + Purpose: Displays the main (landing) page. + + Notes: Not very exciting. +*/ +def mainPage() +{ + if (isConnected && state.installedVersion != null && state.installedVersion != appVersion) return upgradePage() + app.updateSetting("removeDevices", [type: "bool", value: false]) + + dynamicPage(name: "mainPage", uninstall: (hubDevice == null && !state.connected) ? true : false, install: true) + { + if (state.saveDevices) + { + section() + { + paragraph "Changes to remote devices will be saved on exit. This will happen in the backgroud and may take several minutes to complete.", title: "Devices will be Saved!", required: true + } + } + section(menuHeader("Connect")) + { + href "${(state.connected ? "connectPage" : "connectWizard_KeyPage")}", title: "Connect to Server Hub...", description: "", state: isConnected ? "complete" : null + if (isConnected) href "devicePage", title: "Select devices to synchronize to Server hub...", description: "", state: devicePageStatus.all ? "complete" : null + } + if (isConnected) + { + section(menuHeader("Modes & SHM/HSM")) + { + href "shmConfigPage", title: "Configure SHM to HSM mapping...", description: "", state: (armAway != null || armHome != null || armNight != null) ? "complete" : null + input "pushModes", "bool", title: "Push mode changes to Server Hub?", description: "", defaultValue: false + input "pushHSM", "bool", title: "Send HSM changes to Server Hub?", description: "", defaultValue: false + } + } + section(menuHeader("Admin")) + { + input "enableDebug", "bool", title: "Enable debug output?", required: false, defaultValue: false + href "uninstallPage", title: "${isConnected ? "Disconnect Server Hub & " : ""}Remove this instance...", description: "", state: null + } + section() + { + paragraph title: "HubConnect v${appVersion.major}.${appVersion.minor}.${appVersion.build}", "Remote Client for SmartThings\n${appCopyright}" + } + } +} + + +/* + upgradePage + + Purpose: Displays the splash page to force users to initialize the app after an upgrade. +*/ +def upgradePage() +{ + dynamicPage(name: "upgradePage", uninstall: false, install: true) + { + section("New Version Detected!") + { + paragraph "This HubConnect Remote Client has an upgrade that has been installed... \n\n Please click [Save] to complete the installation." + } + } +} + + +/* + systemSetConnectKey + + Purpose: Sets the connection parameters from the connection key. + + URL Format: GET + + API: https://hubconnect.to/knowledgebase/15/systemSetConnectKey.html +*/ +def systemSetConnectKey() +{ + connectPage() +} + + +/* + startHubDiscovery + + Purpose: Initiates a SSDP search for Hubitat hubs and subscribes to location events to receive results. +*/ +void startHubDiscovery() +{ + atomicState.discoveredHubs = [:] + state.hubDiscoveryRefreshCount = 0 + atomicState.hubDiscoveryStatus = "running" + subscribe(location, "ssdpTerm.urn:Hubitat:device:hub:1", hubDiscoveryEventHandler) + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:Hubitat:device:hub:1", physicalgraph.device.Protocol.LAN)) +} + + +/* + stopHubDiscovery + + Purpose: Stops a SSDP search for Hubiat hubs by unsubscribing to location events. +*/ +void stopHubDiscovery() +{ + atomicState.hubDiscoveryStatus = "complete" + unsubscribe() +} + + +/* + resetHubDiscovery + + Purpose: Resets tracking variables used for hub discovery. +*/ +void resetHubDiscovery() +{ + atomicState.hubDiscoveryStatus = null + atomicState.discoveredHubs = null + state.remove("hubDiscoveryRefreshCount") +} + + +/* + hubDiscoveryEventHandler + + Purpose: Receives hub discovery events. +*/ +def hubDiscoveryEventHandler(Object event) +{ + // Tokenize the lan message, then parse it into a Map. + String[] parsedData = event.description.toString().split(", ") + Map ssdpData = (Map) [:] + parsedData.each + { + String[] parts = it.split(":") + if (parts?.size() < 2) return // Shouldn't happen + ssdpData["${parts[0]}"] = parts[1] + } + + if (ssdpData.ssdpPath == "/api/hubitat.xml") + { + Map discoveredHubs = atomicState.discoveredHubs + discoveredHubs["${ssdpData.networkAddress}"] = [name: ssdpData.ssdpNTS, mac: ssdpData.mac] + atomicState.discoveredHubs = discoveredHubs + } +} + + +/* + convertIPtoHex + + Purpose: Utility function to convert an IP address in "dotted" notaton to hex. +*/ +private String convertIPtoHex(ipAddress) +{ + ipAddress.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join().toUpperCase() +} + + +/* + connectWizard_KeyPage + + Purpose: Displays a connection key entry dialog. +*/ +def connectWizard_KeyPage() +{ + String nextPage = (String) "connectWizard_KeyPage" + String responseText = "" + Map accessData = (Map) [:] + if (settings?.serverKey != null && settings.serverKey != "hidden") + { + try + { + accessData = parseJson(new String(serverKey.decodeBase64())) + } + catch (errorException) + { + log.error "Error reading connection key: ${errorException}." + responseText = "Error: Corrupt or invalid connection key" + state.connected = false + accessData = null + state.hubDeviceDNI = null + } + + if (accessData && accessData?.token && accessData.serverIP && accessData?.type == "smartthings") + { + // Get the MAC for the server hub + if (accessData.connectionType == "hubaction") + { + nextPage = "connectWizard_DiscoverPage" + } + else if (accessData.connectionType == "http") + { + nextPage = "connectPage" + } + } + } + + dynamicPage(name: "connectWizard_KeyPage", uninstall: false, install: false, nextPage: nextPage) + { + section(menuHeader("Server Details")) + { + if (responseText.size()) paragraph responseText, required: true + input "serverKey", "text", title: "Paste the server hub's connection key here:", required: true, defaultValue: null, submitOnChange: true + } + if (nextPage != "connectWizard_KeyPage") + { + section() + { + if (nextPage == "connectWizard_DiscoverPage") paragraph "Please tap [Next] to discover the server hub." + else paragraph "Please tap [Next] to complete the connection to the server hub." + } + } + } +} + + +/* + connectWizard_DiscoverPage + + Purpose: Displays a device discovery dialog. +*/ +def connectWizard_DiscoverPage() +{ + if (atomicState.hubDiscoveryStatus == "running") + { + state.hubDiscoveryRefreshCount = state.hubDiscoveryRefreshCount + 1 + if (state.hubDiscoveryRefreshCount >= 4) stopHubDiscovery() + } + + // Hub MAC address discovery + if ((state?.hubDeviceDNI == null || hubDevice == null) && atomicState?.hubDiscoveryStatus == null) + { + startHubDiscovery() + } + + String nextPage = (String) (atomicState.hubDiscoveryStatus == "complete" ? "connectPage" : "connectWizard_DiscoverPage") + Integer refreshInterval = (Integer) (atomicState.hubDiscoveryStatus == "complete" ? 0 : 2) + log.debug atomicState.hubDiscoveryStatus + dynamicPage(name: "connectWizard_DiscoverPage", uninstall: false, install: false, refreshInterval: refreshInterval, nextPage: nextPage) + { + if (atomicState.hubDiscoveryStatus == "complete") + { + section(menuHeader("Discover Complete")) + { + paragraph "Discovery is finished.", complete: true + paragraph "Please tap [Next] to complete the connection to the server hub." + } + } + else + { + section(menuHeader("Please wait...")) + { + Map found = atomicState.discoveredHubs + paragraph "Please wait while HubConnect searches for hubs... Found: ${found?.size()}" + } + } + } +} + + +/* + macAddressPage + + Purpose: Notifies the user that the hub could not be located. +*/ +def macAddressPage() +{ + dynamicPage(name: "macAddressPage", uninstall: false, install: false, nextPage: "connectPage") + { + section(menuHeader("Hub Not Found!")) + { + paragraph "HubConnect could not located the server hub.", required: true + input "serverMAC", "text", title: "Please enter the MAC address of the server hub (no colons):", required: false, defaultValue: null + } + } +} + + +/* + connectPage + + Purpose: Displays the local & remote oAuth links. + + Notes: Really should create a proper token exchange someday. +*/ +def connectPage() +{ + if (!state?.accessToken) + { + state.accessToken = createAccessToken() + } + + // Remote key update + if (params?.connectKey) + { + app.updateSetting("serverKey", [type: "text", value: params.connectKey]) + } + + String responseText = "" + if (settings?.serverKey != null && settings.serverKey != "hidden") + { + def accessData + try + { + accessData = parseJson(new String(serverKey.decodeBase64())) + } + catch (errorException) + { + log.error "Error reading connection key: ${errorException}." + responseText = "Error: Corrupt or invalid connection key" + state.connected = false + accessData = null + } + if (accessData && accessData?.token && accessData.serverIP && accessData?.type == "smartthings") + { + // Get the MAC for the server hub + if (accessData.connectionType == "hubaction" && state?.hubDeviceDNI == null) + { + if (atomicState.hubDiscoveryStatus == "complete") + { + String serverIPhex = convertIPtoHex(accessData.serverIP) + Map discoveredHubs = atomicState.discoveredHubs + state.hubDeviceDNI = discoveredHubs?."${serverIPhex}"?.mac + } + + // Prompt for MAC if not found + if (state.hubDeviceDNI == null) + { + if (serverMAC != null) state.hubDeviceDNI = serverMAC + else return macAddressPage() + } + } + + // Set the server hub details + state.clientURI = accessData.uri + state.clientToken = accessData.token + state.clientType = accessData.type + state.connectionType = accessData.connectionType + + app.updateSetting("serverIP", [type: "text", value: accessData.serverIP]) + if (settings?.thisClientName == null) app.updateSetting("thisClientName", [type: "text", value: accessData.name]) + + // If this remote is using HubAction the hub device has to be created now + if (state.connectionType == "hubaction") + { + if (hubDevice == null) + { + def hubDevice = createHubChildDevice() + if (hubDevice != null) + { + String[] connURI = accessData?.uri?.split(":") + String serverPort = connURI?.size() > 2 ? connURI[2] : "80" + + hubDevice.setConnectionType(state.connectionType, accessData.serverIP, serverPort, null, null, null) + } + } + resetHubDiscovery() + } + + hubDevice?.updateClientToken(accessData.token) + + // Send our connect string to the coordinator + String connectKey = new groovy.json.JsonBuilder([uri: (accessData.connectionType == "http" ? apiServerUrl("/api/smartapps/installations/${app.id}") : callBackAddress), name: location.name, type: "remote", token: state.accessToken, mac: location.hubs[0].id ?: "None", customDriverDBVersion: state.customDriverDBVersion]).toString().bytes.encodeBase64() + def response = httpGetWithReturn("/connect/${connectKey}") + if (response.status == "success") + { + state.connected = true + app.updateSetting("serverKey", [type: "text", value: "hidden"]) + } + else + { + state.connected = false + app.updateSetting("serverKey", [type: "text", value: ""]) + app.updateSetting("disconnectHub", [type: "bool", value: false]) + responseText = "Error: ${response?.message}" + } + } + else if (accessData?.type != "smartthings") responseText = "Error: Connection key is not for this platform" + } + + // Reset connection data if handshake failed + if (serverKey == null || disconnectHub || state.connected == false) + { + resetHubConnection() + } + + dynamicPage(name: "connectPage", uninstall: (hubDevice == null && !state.connected) ? true : false, install: false, nextPage: "mainPage") + { + section(menuHeader("Server Details")) + { + input "serverKey", "text", title: "Paste the server hub's connection key here:", required: false, defaultValue: null, submitOnChange: true + if (serverKey) input "serverIP", "text", title: "Local LAN IP Address of the Server Hub:", required: false, defaultValue: accessData?.serverIP, submitOnChange: true + } + if ((serverKey && serverIP) || state.connected ) + { + section(menuHeader("Remote Details")) + { + input "thisClientName", "text", title: "Friendly Name of this Remote Hub (Optional):", required: false, defaultValue: accessData?.serverIP, submitOnChange: false + if (serverIP && state.connected) input "updateDeviceIPs", "bool", title: "Update child devices with new IP address?", defaultValue: false + } + } + section() + { + if (state.connected) + { + if (state?.lastError) + { + paragraph "${state.lastError}", required: true + state.remove("lastError") + } + paragraph "Connected!" + if (state?.installedVersion == null) + { + paragraph "Please click [Done] to complete installation." + } + else input "disconnectHub", "bool", title: "Disconnect Server Hub...", description: "This will erase the connection key.", required: false, submitOnChange: true + } + else + { + paragraph "Not Connected :: ${responseText}", required: true + if (response?.status == null) input "disconnectHub", "bool", title: "Reset Connection to Server Hub...", description: "This will erase the connection key.", required: false, submitOnChange: true + } + } + } +} + + +/* + uninstallPage + + Purpose: Displays options for removing an instance. + + Notes: Really should create a proper token exchange someday. +*/ +def uninstallPage() +{ + dynamicPage(name: "uninstallPage", title: "Uninstall HubConnect Remote", uninstall: true, install: false) + { + section(menuHeader("Warning!")) + { + paragraph "It is strongly recommended to back up your hub before proceeding. This action cannot be undone!\n\nClick the [Remove] button below to disconnect and remove this remote." + } + section(menuHeader("Options")) + { + input "removeDevices", "bool", title: "Remove virtual HubConnect shadow devices on this hub?", required: false, defaultValue: false, submitOnChange: true + } + section(menuHeader("Factory Reset")) + { + href "resetPage", title: "Factory Reset..", description: "Perform a factory reset of this remote.", state: null + } + section() + { + href "mainPage", title: "Cancel and return to the main menu..", description: "", state: null + } + } +} + + +/* + resetPage + + Purpose: Prompts a user to "factory reset" this app. + + Notes: DO NOT USE unless directed by support +*/ +def resetPage() +{ + dynamicPage(name: "resetPage", title: "Factory Reset HubConnect Remote", uninstall: false, install: true) + { + // Factory reset? + if (resetConfirmText == "reset" && resetToFactoryDefaultsSw) + { + resetToFactoryDefaults() + section() + { + paragraph "Reset is complete; please check the logs for details." + href "mainPage", title: "Return to the main menu..", description: "", state: null + } + } + else + { + section(menuHeader("Warning!")) + { + paragraph "Please DO NOT reset unless directed to by support!!" + paragraph "It is strongly recommended to back up your hub before proceeding. This action cannot be undone!" + } + section(menuHeader("Factory Reset")) + { + input "resetConfirmText", "text", title: "Please confirm", description: "Please enter the word \"reset\" (without quotes) to confirm.", required: false, submitOnChange: true + if (resetConfirmText == "reset") input "resetToFactoryDefaultsSw", "bool", title: "RESET TO DEFAULTS", required: false, submitOnChange: true + } + } + section() + { + href "mainPage", title: "Cancel and return to the main menu..", description: "", state: null + } + } +} + + +/* + devicePage + + Purpose: Displays the page where devices are selected to be linked to the controller hub. + + Notes: Really could stand to be better organized. +*/ +def devicePage() +{ + Integer totalNativeDevices = 0 + String requiredDrivers = "" + NATIVE_DEVICES.each + {devicegroup, device -> + if (settings."${device.selector}"?.size()) + { + totalNativeDevices += settings."${device.selector}"?.size() + requiredDrivers += "HubConnect ${device.driver}\n" + } + } + + Integer totalCustomDevices = 0 + state.customDrivers?.each + {devicegroup, device -> + totalCustomDevices += settings."custom_${devicegroup}"?.size() ?: 0 + } + + def quickNavOpts = NATIVE_DEVICES.findResults {devicegroup, driver -> + if (!driver?.platform || driver?.platform == appVersion.platform) ["${devicegroup}": driver.displayName ?: driver.driver] + } + + Integer totalDevices = totalNativeDevices + totalCustomDevices + + // Changes in the quick select list? + if (devicesChanged) state.saveDevices = true + state.quickSelectState = null + + dynamicPage(name: "devicePage", uninstall: false, install: false, nextPage: "mainPage") + { + section(menuHeader("Quick Select")) + { + input "quickSelect", "enum", options: quickNavOpts, title: "Device Types", description: "Select the type of device to connect.", required: false, submitOnChange: true + if (quickSelect) + { + def selector = renderDeviceSelector(NATIVE_DEVICES.find{devicegroup, device -> devicegroup == quickSelect}?.value) + app.updateSetting("quickSelect", [type: "enum", value: ""]) + state.quickSelectState = [name: selector, devices: settings?."${selector}".collect{it.id}] + } + } + + section(menuHeader("Device Categories (${totalDevices} connected)")) + { + DEVICE_GROUPS.each + { + groupname, group -> + if (!group?.parent) href "dynamicDevicePage", title: group.title, description: group.description, state: deviceCategoryStatus(groupname) ? "complete" : null, params: [prefGroup: groupname, title: group.title] + } + href "customDevicePage", title: "Custom Devices", description: "Devices with user-defined drivers.", state: devicePageStatus.custom ? "complete" : null + } + if (state.saveDevices) + { + section() + { + paragraph "Changes to remote devices will be saved on exit." + input "cleanupDevices", "bool", title: "Remove unused devices on the remote hub?", required: false, defaultValue: true + } + } + if (requiredDrivers?.size()) + { + section(menuHeader("Required Drivers")) + { + paragraph "Please make sure the following native drivers are installed on the Server hub before clicking \"Done\": \n${requiredDrivers}" + } + } + } +} + + +/* + dynamicDevicePage + + Purpose: Displays a device selection page. +*/ +def dynamicDevicePage(Map params) +{ + state.saveDevices = true + + dynamicPage(name: "dynamicDevicePage", title: params.title, uninstall: false, install: false, nextPage: "devicePage") + { + if (DEVICE_GROUPS.find{key, val -> val?.parent == params.prefGroup}) + { + section(menuHeader("Device Categories")) + { + DEVICE_GROUPS.each + { + groupname, group -> + if (group?.parent == params.prefGroup) href "dynamicDevicePage", title: group.title, description: group.description, state: devicePageStatus."${groupname}" ? "complete" : null, params: [prefGroup: groupname, title: group.title] + } + } + } + NATIVE_DEVICES.each + { + groupname, device -> + if (device.prefGroup == params.prefGroup) + { + if (device?.platform && device?.platform != appVersion.platform) return + section(menuHeader("Select ${device.driver} Devices (${settings?."${device.selector}"?.size() ?: "0"} connected)")) + { + renderDeviceSelector(device) + + // Customizations + if (groupname == "irissmartplug") + { + input "sp_EnablePower", "bool", title: "Enable power meter reporting?", required: false, defaultValue: true + input "sp_EnableVolts", "bool", title: "Enable voltage reporting?", required: false, defaultValue: true + } + else if (groupname == "power") + { + input "pm_EnableVolts", "bool", title: "Enable voltage reporting?", required: false, defaultValue: true + } + } + } + } + } +} + + +/* + customDevicePage + + Purpose: Displays the page where custom (user-defined) devices are selected to be linked to the controller hub. + + Notes: First attempt at remotely defined device definitions. +*/ +def customDevicePage() +{ + state.saveDevices = true + + dynamicPage(name: "customDevicePage", uninstall: false, install: false) + { + state.customDrivers.each + { + groupname, driver -> + def customSel = settings."custom_${groupname}" + section(menuHeader("Select ${driver.driver} Devices (${customSel?.size() ?: "0"} connected)")) + { + input "custom_${groupname}", "capability.${driver.selector.substring(driver.selector.lastIndexOf("_") + 1)}", title: "${driver.driver} Devices (${driver.attr}):", required: false, multiple: true, defaultValue: null + } + } + } +} + + +/* + shmConfigPage + + Purpose: Configures HSM to SHM Mappings. + + Notes: Not very exciting. +*/ +def shmConfigPage() +{ + List shmStates = ["away", "stay", "off"] + dynamicPage(name: "shmConfigPage", uninstall: true, install: true) + { + section(menuHeader("SHM to HSM Mode Mapping")) + { + input "armAway", "enum", title: "Set HSM to this mode when HSM changes to armAway", options: shmStates, description: "", defaultValue: "away" + input "armHome", "enum", title: "Set HSM to this mode when HSM changes to armHome", options: shmStates, description: "", defaultValue: "off" + input "armNight", "enum", title: "Set HSM to this mode when HSM changes to armNight", options: shmStates, description: "", defaultValue: "stay" + } + } +} + + +/* + systemGetVersions + + Purpose: Returns a list of all versions including this remote client and any active/installed drivers on this hub. + + URL Format: GET /system/versions/get + + API https://hubconnect.to/knowledgebase/17/systemGetVersions.html +*/ +def systemGetVersions() +{ + // Get hub app & drivers + Map remoteDrivers = (Map) [:] + getChildDevices()?.each + { + device -> + if (remoteDrivers[device.typeName] == null) remoteDrivers[device.typeName] = device.getDriverVersion() + } + jsonResponse([apps: [[appName: app.label, appVersion: appVersion]], drivers: remoteDrivers]) +} + + +/* + systemRemoteDisconnect + + Purpose: Accepts a command from the server to disconnect. + + URL Format: GET /system/disconnect + + API: https://hubconnect.to/knowledgebase/25/systemRemoteDisconnect.html +*/ +def systemRemoteDisconnect() +{ + resetHubConnection() + + app.updateSetting("serverKey", [type: "string", value: ""]) + app.updateSetting("disconnectHub", [type: "bool", value: false]) + initialize() + jsonResponse([status: "success"]) +} + + +/* + resetHubConnection + + Purpose: Resets the connection to the server hub. +*/ +def resetHubConnection(Boolean removeHub = false) +{ + log.info "Resetting connection settings and disconnecting from server hub." + + state.remove("clientURI") + state.remove("clientToken") + state.remove("clientType") + state.remove("connectionType") + state.hubDeviceDNI = null + state.connected = false + + hubDevice?.off() + + // Remove all hub devices + getChildDevices().each + { + child -> + if (child.typeName == "HubConnect Remote Hub for SmartThings") + { + if (logDebug) log.info "Deleting Remote Hub Device: ${child.name} (${child.deviceNetworkId})" + deleteChildDevice(child.deviceNetworkId) + } + } + + if (disconnectHub) + { + app.updateSetting("serverIP", [type: "string", value: ""]) + app.updateSetting("serverKey", [type: "string", value: ""]) + app.updateSetting("disconnectHub", [type: "bool", value: false]) + app.updateSetting("serverMAC", [type: "string", value: ""]) + } + + unsubscribe() + unschedule() +} + + +/* + resetToFactoryDefaults + + Purpose: Resets everything to a freshly-installed state. + + Notes: This will disconnect this hub. Do not use ubnless directed to by support. +*/ +void resetToFactoryDefaults() +{ + log.warn "!!! HubConnect resetToFactoryDefaults() Called !!!" + log.warn "** Resetting all settings and app storage to defaults; device selections and child devices will be preserved. **" + log.warn "** This hub will be disconnected... **" + + // Destroy state + log.info ">> Clearing state..." + state.clear() + + // Destroy atomicState + log.info ">> Clearing atomicState..." + atomicState.httpGetRequestResponse = null + atomicState.sendPostCommandResponse = null + atomicState.discoveredHubs = null + atomicState.hubDiscoveryStatus = null + + // Destroy all settings except for device selection + log.info ">> Clearing settings..." + settings.each + { + k, v -> + log.debug "checking: ${k}" + + if (settings?."${k}" != null && k.startsWith("custom_") == false && NATIVE_DEVICES.find{groupname, driver -> k == driver.selector} == null) + { + log.info ">>>> Erasing setting: ${k}" + app.updateSetting(k, "") // ST does not support app.removeSetting() + } + } + + // Remove all Hub Devices + log.info ">> Removing all hub devices..." + childDevices.findAll{it.typeName == "HubConnect Remote Hub"}.each{deleteChildDevice(it.deviceNetworkId)} + + log.warn "!!! HubConnect resetToFactoryDefaults() COMPLETE !!!" +} + + +/* + renderDeviceSelector + + Purpose: Renders the DeviceMatch device selection dropdown. +*/ +private String renderDeviceSelector(Map device) +{ + if (device == null) return + String capability = + (device.type == "attr") ? (device.capability.contains("device.") ? device.capability : "capability.${device.capability}") + : (device.type == "hcapp") ? "device." + device.driver.replace(" ", "") + : (!settings?."syn_${device.selector}" || settings."syn_${device.selector}" == "attribute") ? "capability.${device.capability}" + : (settings."syn_${device.selector}" == "physical") ? "device." + device.driver.replace(" ", "") + : "device.HubConnect" + device.driver.replace(" ", "") + + input "${device.selector}", "${capability}", title: "${device.driver} Device(s) ${device.attr}:", required: false, multiple: true, defaultValue: null + if (device.type=="synth") input "syn_${device.selector}", "enum", title: "DeviceMatch Selection Type? ${settings."${device.selector}"?.size() ? " (Changing may affect the availability of previously selected devices)" : ""}", options: [physical: "Device Driver", synthetic: "HubConnect Driver", attribute: "Primary Attribute"], required: false, defaultValue: (capability.startsWith("device") ? "physical" : "attribute"), submitOnChange: true + device.selector +} + + +/* + systemGetTSReport + + Purpose: Returns a full report on the current app including configuration and current status of this client. + + URL Format: GET /system/tsreport/get + + API: https://hubconnect.to/knowledgebase/20/systemGetTSReport.html +*/ +def systemGetTSReport() +{ + jsonResponse([ + app: [ + appId: app.id, + appVersion: getAppVersion().toString(), + installedVersion: state.installedVersion + ], + prefs: [ + thisClientName: thisClientName, + serverKey: serverKey, + pushModes: pushModes, + pushHSM: pushHSM, + enableDebug: enableDebug, + ], + state: [ + clientURI: state?.clientURI, + connectionType: state.connectionType, + customDrivers: state.customDrivers, + commDisabled: state.commDisabled + ], + devices: [ + incomingDevices: getChildDevices()?.size() - (hubDevice != null ? 1 : 0), + deviceIdList: "N/A" + ], + hub: [ + deviceStatus: "N/A", + connectionType: "http", + eventSocketStatus: "N/A", + hsmStatus: "N/A", + modeStatus: "N/A", + presence: "N/A", + switch: "N/A", + version: "N/A", + subscribedDevices: "N/A", + connectionAttempts: "N/A", + refreshSocket: "N/A", + refreshHour: "N/A", + refreshMinute: "N/A", + hardwareID: location?.hubs[0]?.getType(), + firmwareVersion: location?.hubs[0]?.firmwareVersionString, + localIP: location?.hubs[0]?.getLocalIP() + ] + ]) +} +String menuHeader(String title) {"-= ${title} =-"} +def getHubDevice() { getChildDevice(state.hubDeviceDNI) } +Boolean getIsConnected(){(state?.clientURI?.size() > 0 && state?.clientToken?.size() > 0) ? true : false} +private String getCallBackAddress() { "http://" + location?.hubs[0]?.getLocalIP() + ":" + location?.hubs[0]?.localSrvPortTCP } +def getDevicesChanged() {state.saveDevices || (state?.quickSelectState && state?.quickSelectState.devices != (settings?."${state?.quickSelectState?.name}"?.collect{it.id} ?: []))} +String getAppCopyright(){"© 2019-2020 Steve White, Retail Media Concepts LLC\nhttps://hubconnect.to/knowledgebase/5/HubConnect-License-Agreement.html"} \ No newline at end of file diff --git a/smartapps/toliver182/kodi-manager-callbacks.src/kodi-manager-callbacks.groovy b/smartapps/toliver182/kodi-manager-callbacks.src/kodi-manager-callbacks.groovy new file mode 100644 index 00000000000..6cc3c13df79 --- /dev/null +++ b/smartapps/toliver182/kodi-manager-callbacks.src/kodi-manager-callbacks.groovy @@ -0,0 +1,494 @@ +/** + * KODI Manager + * + * forked from a plex version: https://github.com/iBeech/SmartThings/tree/master/PlexManager + * + * 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. + * + */ +definition( + name: "KODI Manager - Callbacks", + namespace: "toliver182", + author: "toliver182", + description: "Add kodi endpoints", + category: "Safety & Security", + iconUrl: "https://raw.githubusercontent.com/xbmc/xbmc/master/media/icon48x48.png", + iconX2Url: "https://raw.githubusercontent.com/xbmc/xbmc/master/media/icon120x120.png", + iconX3Url: "https://raw.githubusercontent.com/xbmc/xbmc/master/media/icon256x256.png", + oauth: true) + + +preferences { + page(name: "pgSettings") + page(name: "pgURL") + page(name: "pgLights") + } + + //PAGES +/////////////////////////////// +def pgSettings() { + dynamicPage(name: "pgSettings", title: "Settings",uninstall: true, install: true) { + section("Kodi Client"){ + input "clientName", "text", "title": "Client Name", multiple: false, required: true + input "kodiIp", "text", "title": "Kodi IP", multiple: false, required: true + input "kodiPort", "text", "title": "Kodi port", multiple: false, required: true + input "kodiUsername", "text", "title": "Kodi Username", multiple: false, required: false + input "kodiPassword", "password", "title": "Kodi Password", multiple: false, required: false + input "theHub", "hub", title: "On which hub?", multiple: false, required: true + } + section("Configure Lights"){ + href( "pgLights", description: "Configure lights based on Kodi state", title: "") + } + section("View URLs"){ + href( "pgURL", description: "Click here to view URLs", title: "") + } + section("Name") + { label title: "Assign a name", required: false + + } + } +} + +def pgURL(){ + dynamicPage(name: "pgURL", title: "URLs" , uninstall: false, install: true) { + if (!state.accessToken) { + createAccessToken() + } + def url = apiServerUrl("/api/token/${state.accessToken}/smartapps/installations/${app.id}/") + section("Instructions") { + paragraph "This app is designed to work with the xbmc.callbacks2 plugin for Kodi. Please download and install callbacks2 and in its settings assign the following URLs for corresponding events:" + input "playvalue", "text", title:"Web address to copy for play command:", required: false, defaultValue:"${url}play" + input "stopvalue", "text", title:"Web address to copy for stop command:", required: false, defaultValue:"${url}stop" + input "pausevalue", "text", title:"Web address to copy for pause command:", required: false, defaultValue:"${url}pause" + input "resumevalue", "text", title:"Web address to copy for resume command:", required: false, defaultValue:"${url}resume" + input "allvalue", "text", title:"All Commands", required: false, defaultValue:"Play:${url}play###Stop:${url}stop###Pause:${url}pause###Resume:${url}resume" + + + paragraph "If you have more than one Kodi install, you may install an additional copy of this app for unique addresses specific to each room." + } + } +} +def pgLights(){ + dynamicPage(name: "pgLights", title: "Lights", install: false) { + section("Enable Light Control?") { + input "shouldControlLights", "bool", title: "Enable light control?", multiple: false, required: true, submitOnChange: true, defaultValue: false +} +if(shouldControlLights){ + section("Lights to Control") { + input "switches", "capability.switch", required: true, title: "Which Switches?", multiple: true + } + section("Level to set Lights to (101 for last known level):") { + input "playLevel", "number", required: true, title: "On Playback", defaultValue:"0" + input "pauseLevel", "number", required: true, title: "On Pause", defaultValue:"40" + input "resumeLevel", "number", required: true, title: "On Resume", defaultValue:"0" + input "stopLevel", "number", required: true, title: "On Stop", defaultValue:"101" + } + } + } + } +//END PAGES +///////////////////////// + + + + + +def installed() { + + log.debug "Installed with settings: ${settings}" + initialize() + +} + +def initialize() { +checkKodi(); + +//if the ip changes we need to remove the old device id. +getChildDevices().each { childDevice -> +def deviceNetID = childDevice.deviceNetworkId +log.debug "my id: "+ NetworkDeviceId() +log.debug "net id: " + deviceNetID +if(deviceNetID != NetworkDeviceId()){ +log.debug "removing: " + deviceNetID +deleteChildDevice(deviceNetID) +} +} + +} + +def updated() { +unsubscribe(); +/* +getChildDevices().each { childDevice -> +def deviceNetID = childDevice.deviceNetworkId +log.debug "my id: "+ NetworkDeviceId() +log.debug "net id: " + deviceNetID +if(deviceNetID != NetworkDeviceId()){ +log.debug "removing: " + deviceNetID +deleteChildDevice(deviceNetID) +} +}*/ + +initialize() + +} + +//Incoming state changes from kodi + +mappings { + + path("/play") { + action: [ + GET: "stateIsPlay" + ] + } + path("/stop") { + action: [ + GET: "stateIsStop" + ] + } + path("/pause") { + action: [ + GET: "stateIsPause" + ] + } + path("/resume") { + action: [ + GET: "stateIsResume" + ] + } +} +void stateIsPlay() { +if("$settings.shouldControlLights" == "true"){ + RunCommand(playLevel) +} + + //Code to execute when playback started in KODI + log.debug "Play command started" + //Find client + def children = getChildDevices() + def KodiClient = children.find{ d -> d.deviceNetworkId.contains(NetworkDeviceId()) } + //Set State + KodiClient.setPlaybackState("playing") + getPlayingtitle() +} +void stateIsStop() { +if("$settings.shouldControlLights" == "true"){ + RunCommand(stopLevel) +} + //Code to execute when playback stopped in KODI + log.debug "Stop command started" + //Find client + def children = getChildDevices() + def KodiClient = children.find{ d -> d.deviceNetworkId.contains(NetworkDeviceId()) } + //Set State + KodiClient.setPlaybackState("stopped") + } +void stateIsPause() { +if("$settings.shouldControlLights" == "true"){ + RunCommand(pauseLevel) +} + //Code to execute when playback paused in KODI + log.debug "Pause command started" + //Find client + def children = getChildDevices() + def KodiClient = children.find{ d -> d.deviceNetworkId.contains(NetworkDeviceId()) } + //Set State + KodiClient.setPlaybackState("paused") + getPlayingtitle() +} +void stateIsResume() { +if("$settings.shouldControlLights" == "true"){ + RunCommand(resumeLevel) +} + //Code to execute when playback resumed in KODI + log.debug "Resume command started" + //Find client + def children = getChildDevices() + def KodiClient = children.find{ d -> d.deviceNetworkId.contains(NetworkDeviceId()) } + //Set State + KodiClient.setPlaybackState("playing") + getPlayingtitle() +} + + + +def response(evt) { + def msg = parseLanMessage(evt.description); +} + + +//Incoming command handler +def switchChange(evt) { + + // We are only interested in event data which contains + if(evt.value == "on" || evt.value == "off") return; + + //log.debug "Kodi event received: " + evt.value; + + def kodiIP = getKodiAddress(evt.value); + + // Parse out the new switch state from the event data + def command = getKodiCommand(evt.value); + + //log.debug "state: " + state + + switch(command) { + case "next": + log.debug "Sending command 'next' to " + kodiIP + next(kodiIP); + break; + + case "previous": + log.debug "Sending command 'previous' to " + kodiIP + previous(kodiIP); + break; + + case "play": + case "pause": + playpause(kodiIP); + break; + case "stop": + stop(kodiIP); + break; + case "scanNewClients": + getClients(); + + case "setVolume": + def vol = getKodiVolume(evt.value); + log.debug "Vol is: " + vol + setVolume(kodiIP, vol); + break; + } + + return; +} + + + +//Child device setup +def checkKodi() { + + log.debug "Checking to see if the client has been added" + + def children = getChildDevices() ; + def childrenEmpty = children.isEmpty(); + + + def KodiClient = children.find{ d -> d.deviceNetworkId.contains(NetworkDeviceId()) } + + if(!KodiClient){ + log.debug "No Devices found, adding device" + KodiClient = addChildDevice("toliver182", "Kodi Client", NetworkDeviceId() , theHub.id, [label:"$settings.clientName", name:"$settings.clientName"]) + log.debug "Added Device" + } + else + { + log.debug "Device Already Added" + } + subscribe(KodiClient, "switch", switchChange) +} + + + +//Commands to kodi +def playpause(kodiIP) { + log.debug "playpausehere" + def command = "{\"jsonrpc\": \"2.0\", \"method\": \"Player.PlayPause\", \"params\": { \"playerid\": 1 }, \"id\": 1}" + executeRequest("/jsonrpc", "POST",command); +} + +def next(kodiIP) { + log.debug "Executing 'next'" + def command = "{\"jsonrpc\": \"2.0\", \"method\": \"Player.GoTo\", \"params\": { \"playerid\": 1, \"to\": \"next\" }, \"id\": 1}" + executeRequest("/jsonrpc", "POST",command) +} + +def stop(kodiIP){ + def command = "{ \"id\": 1, \"jsonrpc\": \"2.0\", \"method\": \"Player.Stop\", \"params\": { \"playerid\": 1 } }" + executeRequest("/jsonrpc", "POST",command) +} + +def previous(kodiIP) { + log.debug "Executing 'next'" + def command = "{\"jsonrpc\": \"2.0\", \"method\": \"Player.GoTo\", \"params\": { \"playerid\": 1, \"to\": \"previous\" }, \"id\": 1}" + executeRequest("/jsonrpc", "POST",command) +} + +def setVolume(kodiIP, level) { +//TODO + def command = "{\"jsonrpc\": \"2.0\", \"method\": \"Application.SetVolume\", \"params\": { \"volume\": "+ level + "}, \"id\": 1}" + executeRequest("/jsonrpc", "POST",command) +} +def getPlayingtitle(){ +def command = "{\"jsonrpc\": \"2.0\", \"method\": \"Player.GetItem\", \"params\": { \"properties\": [\"title\", \"album\", \"artist\", \"season\", \"episode\", \"duration\", \"showtitle\", \"tvshowid\", \"thumbnail\", \"file\", \"fanart\", \"streamdetails\"], \"playerid\": 1 }, \"id\": \"VideoGetItem\"}" + executeRequest("/jsonrpc", "POST",command); + +} + +//main command handler +def executeRequest(Path, method, command) { + log.debug "Sending command to $settings.kodiIp" + def headers = [:] + + headers.put("HOST", "$settings.kodiIp:$settings.kodiPort") + if("$settings.kodiUsername" !="" ){ + def basicAuth = basicAuthBase64(); + headers.put("Authorization", "Basic " + basicAuth ) + }else{ + log.debug "No Auth needed" + } + headers.put("Content-Type", "application/json") + try { + def actualAction = new physicalgraph.device.HubAction( + method: method, + path: Path, + body: command, + headers: headers) + + sendHubCommand(actualAction) + } + catch (Exception e) { + log.debug "Hit Exception $e on $hubAction" + } +} + + + + +// Helpers +private String convertIPtoHex(ipAddress) { + String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join() + //log.debug "IP address entered is $ipAddress and the converted hex code is $hex" + return hex + +} + +private String convertPortToHex(port) { + String hexport = port.toString().format( '%04x', port.toInteger() ) + //log.debug hexport + return hexport +} + +def String getKodiCommand(deviceNetworkId) { + def parts = deviceNetworkId.tokenize('.'); + return parts[1]; +} +def String getKodiVolume(evt) { + def parts = evt.tokenize('.'); + return parts[2]; +} +private String NetworkDeviceId(){ + def iphex = convertIPtoHex(settings.kodiIp).toUpperCase() + def porthex = convertPortToHex(settings.kodiPort).toUpperCase() + return "$iphex:$porthex" +} + +//Method for encoding username and password in base64 +def basicAuthBase64() { +def s ="$settings.kodiUsername:$settings.kodiPassword" +def encoded = s.bytes.encodeBase64(); +return encoded +} + +def String getKodiAddress(deviceNetworkId) { +def ip = deviceNetworkId.replace("KodiClient:", ""); + def parts = ip.tokenize('.'); + return parts[0] + "." + parts[1] + "." + parts[2] + "." + parts[3]; +} + +//Lighting control + +private void RunCommand(level){ + //Check to see if current mode is in white/black list before we do anything + if((!onlyModes || location.currentMode in onlyModes) && !(location.currentMode in neverModes)){ + //Mode is good, go ahead with commands + if (level == 101){ + log.debug "Restoring Last Known Light Levels" + restoreLast(switches) + }else if (level <= 100){ + log.debug "Setting lights to ${level} %" + SetLight(switches,level) + } + } +} + +private void restoreLast(switchList){ + //This will look for the last external (not from this app) setting applied to each switch and set the switch back to that + //Look at each switch passed + switchList.each{sw -> + def lastState = LastState(sw) //get Last State + if (lastState){ //As long as we found one, set it + SetLight(sw,lastState) + }else{ //Otherwise assume it was off + SetLight(sw, "off") + } + } +} + +private def LastState(device){ + //Get events for this device in the last day + def devEvents = device.eventsSince(new Date() - 1, [max: 1000]) + //Find Last Event Where switch was turned on/off/level, but not changed by this app + //Oddly not all events properly contain the "installedSmartAppId", particularly ones that actual contain useful values + //In order to filter out events created by this app we have to find the set of events for the app control and the actual action + //the first 8 char of the event ID seem to be unique much of the time, but the rest seems to be the same for any grouping of events, so match on that (substring) + //In case the substring fails we will also check for events with similar timestamp (within 8 sec) + def last = devEvents.find { + (it.name == "level" || it.name == "switch") && (devEvents.find{it2 -> it2.installedSmartAppId == app.id && (it2.id.toString().substring(8) == it.id.toString().substring(8) || Math.sqrt((it2.date.getTime() - it.date.getTime())**2) < 6000 )} == null) + } + //If we found one return the stringValue + if (last){ + log.debug "Last External Event - Date: ${last.date} | Event ID: ${last.id} | AppID: ${last.installedSmartAppId} | Description: ${last.descriptionText} | Name: ${last.displayName} (${last.name}) | App: ${last.installedSmartApp} | Value: ${last.stringValue} | Source: ${last.source} | Desc: ${last.description}" + //if event is "on" find last externally set level as it could be in an older event + if(last.stringValue == "on"){ + devEvents = device.eventsSince(new Date() - 7, [max: 1000]) //Last level set command could have been awhile back, look in last 7 days + def lastLevel = devEvents.find { + (it.name == "level") && (devEvents.find{it2 -> it2.installedSmartAppId == app.id && (it2.id.toString().substring(8) == it.id.toString().substring(8) || Math.sqrt((it2.date.getTime() - it.date.getTime())**2) < 6000 )} == null) + } + if(lastLevel){ + return lastLevel.stringValue + } + } + return last.stringValue + }else{ + return null + } +} + +private void SetLight(switches,value){ + //Set value for one or more lights, translates dimmer values to on/off for basic switches + + //Fix any odd values that could be passed + if(value.toString().isInteger()){ + if(value.toInteger() < 0){value = 0} + if(value.toInteger() > 100){value = 100} + }else if(value.toString() != "on" && value.toString() != "off"){ + return //ABORT! Lights do not support commands like "Hamster" + } + switches.each{sw -> + log.debug "${sw.name} | ${value}" + if(value.toString() == "off" || value.toString() == "0"){ //0 and off are the same here, turn the light off + sw.off() + }else if(value.toString() == "on" || value.toString() == "100"){ //As stored light level is not really predictable, on should mean 100% for now + if(sw.hasCommand("setLevel")){ //setlevel for dimmers, on for basic + sw.setLevel(100) + }else{ + sw.on() + } + }else{ //Otherwise we should have a % value here after cleanup above, use ir or just turn a basic switch on + if(sw.hasCommand("setLevel")){//setlevel for dimmers, on for basic + sw.setLevel(value.toInteger()) + }else{ + sw.on() + } + } + } +} \ No newline at end of file diff --git a/smartapps/tonesto7/echo-speaks.src/echo-speaks.groovy b/smartapps/tonesto7/echo-speaks.src/echo-speaks.groovy new file mode 100644 index 00000000000..9e12fa38479 --- /dev/null +++ b/smartapps/tonesto7/echo-speaks.src/echo-speaks.groovy @@ -0,0 +1,2534 @@ +/** + * Echo Speaks SmartApp + * + * Copyright 2018, 2019 Anthony Santilli + * + * 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 + * + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +import groovy.json.* +import java.text.SimpleDateFormat +String appVersion() { return "2.5.0" } +String appModified() { return "2019-05-31" } +String appAuthor() { return "Anthony S." } +Boolean isBeta() { return false } +Boolean isST() { return (getPlatform() == "SmartThings") } +Map minVersions() { return [echoDevice: 250, server: 211] } //These values define the minimum versions of code this app will work with. + +definition( + name : "Echo Speaks", + namespace : "tonesto7", + author : "Anthony Santilli", + description: "Integrate your Amazon Echo devices into your Smart Home environment to create virtual Echo Devices. This allows you to speak text, make announcements, control media playback including volume, and many other Alexa features.", + category : "My Apps", + iconUrl : "https://raw.githubusercontent.com/tonesto7/echo-speaks/master/resources/icons/echo_speaks.1x${state?.updateAvailable ? "_update" : ""}.png", + iconX2Url : "https://raw.githubusercontent.com/tonesto7/echo-speaks/master/resources/icons/echo_speaks.2x${state?.updateAvailable ? "_update" : ""}.png", + iconX3Url : "https://raw.githubusercontent.com/tonesto7/echo-speaks/master/resources/icons/echo_speaks.3x${state?.updateAvailable ? "_update" : ""}.png", + pausable : true, + oauth : true +) + +preferences { + page(name: "startPage") + page(name: "mainPage") + page(name: "settingsPage") + page(name: "devicePrefsPage") + page(name: "newSetupPage") + page(name: "groupsPage") + page(name: "actionsPage") + page(name: "devicePage") + page(name: "deviceListPage") + page(name: "unrecogDevicesPage") + page(name: "changeLogPage") + page(name: "notifPrefPage") + page(name: "servPrefPage") + page(name: "musicSearchTestPage") + page(name: "searchTuneInResultsPage") + page(name: "deviceTestPage") + page(name: "broadcastPage") + page(name: "announcePage") + page(name: "sequencePage") + page(name: "setNotificationTimePage") + page(name: "uninstallPage") +} + +def startPage() { + state?.isParent = true + checkVersionData(true) + state?.childInstallOkFlag = false + if(state?.resumeConfig || (state?.isInstalled && !state?.serviceConfigured)) { + log.debug "resumeConfig: true" + return servPrefPage() + } else if(showChgLogOk()) { + return changeLogPage() + } else { return mainPage() } +} + +def appInfoSect() { + Map codeVer = state?.codeVersions ?: null + def str = ""//"Author: ${appAuthor()}" + // if(isST() && state?.customerName) { str += "User: ${state?.customerName}" } + if(codeVer && (codeVer?.server || codeVer?.echoDevice)) { + str += bulletItem(str, "App: (v${appVersion()})") + str += (codeVer && codeVer?.echoDevice) ? bulletItem(str, "Device: (v${codeVer?.echoDevice})") : "" + str += (codeVer && codeVer?.server) ? bulletItem(str, "Server: (v${codeVer?.server})") : "" + } else { str += "\nApp: v${appVersion()}" } + section() { + href "changeLogPage", title: pTS("${app?.name}", getAppImg("echo_speaks.2x", true)), description: str, image: getAppImg("echo_speaks.2x") + if(!state?.isInstalled) { paragraph "--NEW Install--", state: "complete" } + } +} + +def mainPage() { + def tokenOk = getAccessToken() + Boolean newInstall = (state?.isInstalled != true) + Boolean resumeConf = (state?.resumeConfig == true) + if(state?.refreshDeviceData == true) { getEchoDevices() } + return dynamicPage(name: "mainPage", uninstall: false, install: true) { + appInfoSect() + if(!tokenOk) { + section() { paragraph title: "Uh OH!!!", "Oauth Has NOT BEEN ENABLED. Please Remove this app and try again after it after enabling OAUTH"; }; return; + } + if(newInstall) { + deviceDetectOpts() + } else { + if(!resumeConfig && state?.authValid != true) { + section() { paragraph title: "NOTICE:", "You are not currently logged in to Amazon. Please complete the Authentication Process on the Server Login Page...", required: true, state: null } + } + section(sTS("Alexa Devices:")) { + if(!newInstall) { + List devs = getDeviceList()?.collect { "${it?.value?.name}${it?.value?.online ? " (Online)" : ""}${it?.value?.supported == false ? " \u2639" : ""}" }?.sort() + Map skDevs = state?.skippedDevices?.findAll { (it?.value?.reason != "In Ignore Device Input") } + Map ignDevs = state?.skippedDevices?.findAll { (it?.value?.reason == "In Ignore Device Input") } + if(devs?.size()) { + href "deviceListPage", title: inTS("Installed Devices:"), description: "${devs?.join("\n")}\n\nTap to view details...", state: "complete" + } else { paragraph title: "Discovered Devices:", "No Devices Available", state: "complete" } + if(skDevs?.size()) { + String uDesc = "Unsupported: (${skDevs?.size()})" + uDesc += ignDevs?.size() ? "\nUser Ignored: (${ignDevs?.size()})" : "" + uDesc += settings?.bypassDeviceBlocks ? "\nBlock Bypass: (Active)" : "" + href "unrecogDevicesPage", title: inTS("Unused Devices:"), description: "${uDesc}\n\nTap to view details..." + } + } + def devPrefDesc = devicePrefsDesc() + href "devicePrefsPage", title: inTS("Device Detection\nPreferences", getAppImg("devices", true)), description: "${devPrefDesc ? "${devPrefDesc}\n\n" : ""}Tap to configure...", state: "complete", image: getAppImg("devices") + } + + // def t1 = getGroupsDesc() + // def grpDesc = t1 ? "${t1}\n\nTap to modify" : null + // section(sTS("Manage Groups:")) { + // href "groupsPage", title: inTS("Broadcast Groups", getAppImg("es_groups", true)), description: (grpDesc ?: "Tap to configure"), state: (grpDesc ? "complete" : null), image: getAppImg("es_groups") + // } + + // def t2 = getActionsDesc() + // def actDesc = t2 ? "${t2}\n\nTap to modify" : null + // section(sTS("Manage Actions:")) { + // href "actionsPage", title: inTS("Actions", getAppImg("es_actions", true)), description: (actDesc ?: "Tap to configure"), state: (actDesc ? "complete" : null), image: getAppImg("es_actions") + // } + state?.childInstallOkFlag = true + + section(sTS("Experimental Functions:")) { + href "deviceTestPage", title: inTS("Device Test Page", getAppImg("broadcast", true)), description: "Test Announcements, Broadcasts, and Sequences\n\nTap to proceed...", image: getAppImg("testing") + href "musicSearchTestPage", title: inTS("Music Search Tests", getAppImg("music", true)), description: "Test music queries\n\nTap to proceed...", image: getAppImg("music") + } + section(sTS("Alexa Login Service:")) { + def t0 = getServiceConfDesc() + href "servPrefPage", title: inTS("Login Service\nSettings", getAppImg("settings", true)), description: (t0 ? "${t0}\n\nTap to modify" : "Tap to configure"), state: (t0 ? "complete" : null), image: getAppImg("settings") + } + if(!state?.shownDevSharePage) { showDevSharePrefs() } + section(sTS("Notifications:")) { + def t0 = getAppNotifConfDesc() + href "notifPrefPage", title: inTS("App and Device\nNotifications", getAppImg("devices", true)), description: (t0 ? "${t0}\n\nTap to modify" : "Tap to configure"), state: (t0 ? "complete" : null), image: getAppImg("notification2") + } + } + section(sTS("Documentation & Settings:")) { + href url: documentationLink(), style: "external", required: false, title: inTS("View Documentation", getAppImg("documentation", true)), description: "Tap to proceed", state: "complete", image: getAppImg("documentation") + href "settingsPage", title: inTS("Manage Logging, and Metrics", getAppImg("settings", true)), description: "Tap to modify...", image: getAppImg("settings") + } + if(!newInstall) { + section(sTS("Donations:")) { + href url: textDonateLink(), style: "external", required: false, title: inTS("Donations", getAppImg("donate", true)), description: "Tap to open browser", image: getAppImg("donate") + } + section(sTS("Remove Everything:")) { + href "uninstallPage", title: inTS("Uninstall this App", getAppImg("uninstall", true)), description: "Tap to Remove...", image: getAppImg("uninstall") + } + } else { + showDevSharePrefs() + section(sTS("Important Step:")) { + paragraph title: "Notice:", "Please complete the install and return to the Echo Speaks App to resume deployment and configuration of the server.", required: true, state: null + state?.resumeConfig = true + } + } + } +} + +def groupsPage() { + return dynamicPage(name: "groupsPage", uninstall: false, install: false) { + def groupApp = findChildAppByName( grpChildName() ) + if(groupApp) { /*Nothing to add here yet*/ } + else { + section("") { + paragraph "You haven't created any Broadcast Groups yet!\nTap Create New Group to get Started" + } + } + section("") { + app(name: "groupApp", appName: grpChildName(), namespace: "tonesto7", multiple: true, title: inTS("Create New Group", getAppImg("es_groups", true)), image: getAppImg("es_groups")) + } + } +} + +def actionsPage() { + return dynamicPage(name: "actionsPage", uninstall: false, install: false) { + def actionApp = findChildAppByName( actChildName() ) + if(actionApp) { /*Nothing to add here yet*/ } + else { + section("") { + paragraph "You haven't created any Actions yet!\nTap Create New Action to get Started" + } + } + section("") { + app(name: "actionApp", appName: actChildName(), namespace: "tonesto7", multiple: true, title: inTS("Create New Action", getAppImg("es_actions", true)), image: getAppImg("es_actions")) + } + } +} + +def devicePrefsPage() { + Boolean newInstall = (state?.isInstalled != true) + Boolean resumeConf = (state?.resumeConfig == true) + return dynamicPage(name: "devicePrefsPage", uninstall: false, install: false) { + deviceDetectOpts() + section(sTS("Detection Override:")) { + paragraph "Device not detected? Enabling this will allow you to override the developer block for unrecognized or uncontrollable devices. This is useful for testing the device." + input "bypassDeviceBlocks", "bool", title: inTS("Override Blocks and Create Ignored Devices?"), description: "WARNING: This will create devices for all remaining ignored devices", required: false, defaultValue: false, submitOnChange: true + } + devCleanupSect() + if(!newInstall && !resumeConf) { state?.refreshDeviceData = true } + } +} + +private deviceDetectOpts() { + Boolean newInstall = (state?.isInstalled != true) + Boolean resumeConf = (state?.resumeConfig == true) + section(sTS("Device Detection Preferences")) { + input "autoCreateDevices", "bool", title: inTS("Auto Create New Devices?", getAppImg("devices", true)), description: "", required: false, defaultValue: true, submitOnChange: true, image: getAppImg("devices") + input "createTablets", "bool", title: inTS("Create Devices for Tablets?", getAppImg("amazon_tablet", true)), description: "", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("amazon_tablet") + input "createWHA", "bool", title: inTS("Create Multiroom Devices?", getAppImg("echo_wha", true)), description: "", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("echo_wha") + input "createOtherDevices", "bool", title: inTS("Create Other Alexa Enabled Devices?", getAppImg("devices", true)), description: "FireTV (Cube, Stick), Sonos, etc.", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("devices") + input "autoRenameDevices", "bool", title: inTS("Rename Devices to Match Amazon Echo Name?", getAppImg("name_tag", true)), description: "", required: false, defaultValue: true, submitOnChange: true, image: getAppImg("name_tag") + Map devs = getAllDevices(true) + if(devs?.size()) { + input "echoDeviceFilter", "enum", title: inTS("Don't Use these Devices", getAppImg("exclude", true)), description: "Tap to select", options: (devs ? devs?.sort{it?.value} : []), multiple: true, required: false, submitOnChange: true, image: getAppImg("exclude") + paragraph title:"Notice:", "To prevent unwanted devices from reinstalling after removal make sure to add it to the Don't use input before removing." + } + } +} + +private devCleanupSect() { + if(state?.isInstalled && !state?.resumeConfig) { + section(sTS("Device Cleanup Options:")) { + List remDevs = getRemovableDevs() + if(remDevs?.size()) { paragraph "Removable Devices:\n${remDevs?.sort()?.join("\n")}", required: true, state: null } + paragraph title:"Notice:", "Remember to add device to filter above to prevent recreation. Also the cleanup process will fail if the devices are used in external apps/automations" + input "cleanUpDevices", "bool", title: inTS("Cleanup Unused Devices?"), description: "", required: false, defaultValue: false, submitOnChange: true + if(cleanUpDevices) { removeDevices() } + } + } +} + +private List getRemovableDevs() { + def childDevs = isST() ? app?.getChildDevices(true) : app?.getChildDevices() + Map eDevs = state?.echoDeviceMap ?: [:] + List remDevs = [] + childDevs?.each { cDev-> + def dni = cDev?.deviceNetworkId?.tokenize("|") + if(!eDevs?.containsKey(dni[2])) { remDevs?.push(cDev?.getLabel() as String) } + } + return remDevs ?: [] +} + +private String devicePrefsDesc() { + String str = "" + str += "Auto Create (${(settings?.autoCreateDevices == false) ? "Disabled" : "Enabled"})" + if(settings?.autoCreateDevices) { + str += (settings?.createTablets == true) ? bulletItem(str, "Tablets") : "" + str += (settings?.createWHA == true) ? bulletItem(str, "WHA") : "" + str += (settings?.createOtherDevices == true) ? bulletItem(str, "Other Devices") : "" + } + str += settings?.autoRenameDevices != false ? bulletItem(str, "Auto Rename") : "" + str += settings?.bypassDeviceBlocks == true ? "\nBlock Bypass: (Active)" : "" + def remDevsSz = getRemovableDevs()?.size() ?: 0 + str += remDevsSz > 0 ? "\n\nRemovable Devices: (${remDevsSz})" : "" + return str != "" ? str : null +} + +def settingsPage() { + return dynamicPage(name: "settingsPage", uninstall: false, install: false) { + section(sTS("Logging:")) { + input "appDebug", "bool", title: inTS("Show Debug Logs in the IDE?", getAppImg("debug", true)), description: "Only leave on when required", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("debug") + if(settings?.appDebug) { + input "appTrace", "bool", title: inTS("Show Detailed Trace Logs in the IDE?", getAppImg("debug", true)), description: "Only Enabled when asked by the developer", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("debug") + } + } + showDevSharePrefs() + section(sTS("App Change Details:")) { + href "changeLogPage", title: inTS("View App Revision History", getAppImg("change_log", true)), description: "Tap to view", image: getAppImg("change_log") + } + } +} + +def deviceListPage() { + return dynamicPage(name: "deviceListPage", install: false) { + Boolean onST = isST() + section(sTS("Discovered Devices:")) { + state?.echoDeviceMap?.sort { it?.value?.name }?.each { k,v-> + String str = "Status: (${v?.online ? "Online" : "Offline"})\nStyle: ${v?.style?.name}\nFamily: ${v?.family}\nType: ${v?.type}\nVolume Control: (${v?.volumeSupport?.toString()?.capitalize()})" + str += "\nText-to-Speech: (${v?.ttsSupport?.toString()?.capitalize()})\nMusic Player: (${v?.mediaPlayer?.toString()?.capitalize()})" + str += v?.supported != true ? "\nUnsupported Device: (True)" : "" + str += (v?.mediaPlayer == true && v?.musicProviders) ? "\nMusic Providers: [${v?.musicProviders}]" : "" + if(onST) { + paragraph title: pTS(v?.name, getAppImg(v?.style?.image, true)), str, required: true, state: (v?.online ? "complete" : null), image: getAppImg(v?.style?.image) + } else { href "deviceListPage", title: pTS(v?.name, getAppImg(v?.style?.image, true)), description: str, required: true, state: (v?.online ? "complete" : null), image: getAppImg(v?.style?.image) } + } + } + } +} + +def unrecogDevicesPage() { + return dynamicPage(name: "unrecogDevicesPage", install: false) { + Boolean onST = isST() + Map skDevMap = state?.skippedDevices ?: [:] + Map ignDevs = skDevMap?.findAll { (it?.value?.reason == "In Ignore Device Input") } + Map unDevs = skDevMap?.findAll { (it?.value?.reason != "In Ignore Device Input") } + section(sTS("Unrecognized/Unsupported Devices:")) { + if(unDevs?.size()) { + unDevs?.sort { it?.value?.name }?.each { k,v-> + String str = "Status: (${v?.online ? "Online" : "Offline"})\nStyle: ${v?.desc}\nFamily: ${v?.family}\nType: ${v?.type}\nVolume Control: (${v?.volume?.toString()?.capitalize()})" + str += "\nText-to-Speech: (${v?.tts?.toString()?.capitalize()})\nMusic Player: (${v?.mediaPlayer?.toString()?.capitalize()})\nReason Ignored: (${v?.reason})" + if(onST) { + paragraph title: pTS(v?.name, getAppImg(v?.image, true)), str, required: true, state: (v?.online ? "complete" : null), image: getAppImg(v?.image) + } else { href "unrecogDevicesPage", title: pTS(v?.name, getAppImg(v?.image, true)), description: str, required: true, state: (v?.online ? "complete" : null), image: getAppImg(v?.image) } + } + input "bypassDeviceBlocks", "bool", title: inTS("Override Blocks and Create Ignored Devices?"), description: "WARNING: This will create devices for all remaining ignored devices", required: false, defaultValue: false, submitOnChange: true + } else { + paragraph "No Uncognized Devices" + } + } + if(ignDevs?.size()) { + section(sTS("User Ignored Devices:")) { + ignDevs?.sort { it?.value?.name }?.each { k,v-> + String str = "Status: (${v?.online ? "Online" : "Offline"})\nStyle: ${v?.desc}\nFamily: ${v?.family}\nType: ${v?.type}\nVolume Control: (${v?.volume?.toString()?.capitalize()})" + str += "\nText-to-Speech: (${v?.tts?.toString()?.capitalize()})\nMusic Player: (${v?.mediaPlayer?.toString()?.capitalize()})\nReason Ignored: (${v?.reason})" + if(onST) { + paragraph title: pTS(v?.name, getAppImg(v?.image, true)), str, required: true, state: (v?.online ? "complete" : null), image: getAppImg(v?.image) + } else { href "unrecogDevicesPage", title: pTS(v?.name, getAppImg(v?.image, true)), description: str, required: true, state: (v?.online ? "complete" : null), image: getAppImg(v?.image) } + } + } + } + } +} + +def showDevSharePrefs() { + section(sTS("Share Data with Developer:")) { + paragraph title: "What is this used for?", "These options send non-user identifiable information and error data to diagnose catch trending issues." + input ("optOutMetrics", "bool", title: inTS("Do Not Share Data?", getAppImg("analytics", true)), required: false, defaultValue: false, submitOnChange: true, image: getAppImg("analytics")) + if(settings?.optOutMetrics != true) { + href url: getAppEndpointUrl("renderMetricData"), style: (isST() ? "embedded" : "external"), title: inTS("View the Data shared with Developer", getAppImg("view", true)), description: "Tap to view Data", required: false, image: getAppImg("view") + } + } + if(optOutMetrics != true && state?.isInstalled && state?.serviceConfigured && !state?.resumeConfig) { + section() { input "sendMetricsNow", "bool", title: inTS("Send Metrics Now?", getAppImg("reset", true)), description: "", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("reset") } + if(sendMetricsNow) { sendInstallData() } + } + state?.shownDevSharePage = true +} + +Map getDeviceList(isInputEnum=false, onlyTTS=false) { + Map devMap = [:] + Map availDevs = state?.echoDeviceMap ?: [:] + availDevs?.each { key, val-> + if(onlyTTS && val?.ttsSupport != true) { return } + devMap[key] = val + } + return isInputEnum ? (devMap?.size() ? devMap?.collectEntries { [(it?.key):it?.value?.name] } : devMap) : devMap +} + +Map getAllDevices(isInputEnum=false) { + Map devMap = [:] + Map availDevs = state?.allEchoDevices ?: [:] + availDevs?.each { key, val-> devMap[key] = val } + return isInputEnum ? (devMap?.size() ? devMap?.collectEntries { [(it?.key):it?.value?.name] } : devMap) : devMap +} + +def servPrefPage() { + Boolean newInstall = (state?.isInstalled != true) + Boolean resumeConf = (state?.resumeConfig == true) + return dynamicPage(name: "servPrefPage", install: (newInstall || resumeConf), nextPage: (!(newInstall || resumeConf) ? "mainPage" : ""), uninstall: (state?.serviceConfigured != true)) { + Boolean hasChild = ((isST() ? app?.getChildDevices(true) : getChildDevices())?.size()) + Boolean onHeroku = (isST() || settings?.useHeroku != false) + + if(state?.generatedHerokuName) { section() { paragraph title: "Heroku Name:", "${!isST() ? "Heroku Name:\n" : ""}${state?.generatedHerokuName}", state: "complete" }; } + if(!isST() && settings?.useHeroku == null) settingUpdate("useHeroku", "true", "bool") + if(settings?.amazonDomain == null) settingUpdate("amazonDomain", "amazon.com", "enum") + if(settings?.regionLocale == null) settingUpdate("regionLocale", "en-US", "enum") + + if(!state?.serviceConfigured) { + if(!isST()) { + section(sTS("Server Deployment Option:")) { + input "useHeroku", "bool", title: inTS("Deploy server to Heroku?", getAppImg("heroku", true)), description: "Turn Off to allow local server deployment", required: false, defaultValue: true, submitOnChange: true, image: getAppImg("heroku") + if(settings?.useHeroku == false) { paragraph """

Local Server deployments are only allowed on Hubitat and are something that can be very difficult for me to support. I highly recommend Heroku deployments for most users.

""" } + } + } + section("") { paragraph "Proceed with the server setup by tapping on Begin Server Setup", state: "complete" } + srvcPrefOpts(true) + section(sTS("Deploy the Server:")) { + href (url: getAppEndpointUrl("config"), style: "external", title: inTS("Begin Server Setup", getAppImg("upload", true)), description: "Tap to proceed", required: false, state: "complete", image: getAppImg("upload")) + } + } else { + if(state?.onHeroku) { + section(sTS("Server Management:")) { + href url: "https://${getRandAppName()}.herokuapp.com/config", style: "external", required: false, title: inTS("Amazon Login Page", getAppImg("amazon_orange", true)), description: "Tap to proceed", image: getAppImg("amazon_orange") + href url: "https://dashboard.heroku.com/apps/${getRandAppName()}/settings", style: "external", required: false, title: inTS("Heroku App Settings", getAppImg("heroku", true)), description: "Tap to proceed", image: getAppImg("heroku") + href url: "https://dashboard.heroku.com/apps/${getRandAppName()}/logs", style: "external", required: false, title: inTS("Heroku App Logs", getAppImg("heroku", true)), description: "Tap to proceed", image: getAppImg("heroku") + } + } + if(state?.isLocal) { + section(sTS("Local Server Management:")) { + href url: "${getServerHostURL()}/config", style: "external", required: false, title: inTS("Amazon Login Page", getAppImg("amazon_orange", true)), description: "Tap to proceed", image: getAppImg("amazon_orange") + } + } + if(state?.authValid) { + section(sTS("Cookie Info:")) { + if(state?.lastCookieRefresh) { paragraph "Cookie Date: (${state?.lastCookieRefresh})", state: "complete" } + input "refreshCookie", "bool", title: inTS("Refresh Alexa Cookie?", getAppImg("reset", true)), description: "This will Refresh your Amazon Cookie.", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("reset") + if(refreshCookie) { runCookieRefresh() } + } + } + srvcPrefOpts() + } + section(sTS("Reset Options (Tap to view):"), hideable:true, hidden: true) { + input "resetService", "bool", title: inTS("Reset Service Data?", getAppImg("reset", true)), description: "This will clear all references to the current server and allow you to redeploy a new instance.\nLeave the page and come back after toggling.", + required: false, defaultValue: false, submitOnChange: true, image: getAppImg("reset") + input "resetCookies", "bool", title: inTS("Clear Stored Cookie Data?", getAppImg("reset", true)), description: "This will clear all stored cookie data.", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("reset") + if(settings?.resetService) { clearCloudConfig() } + if(settings?.resetCookies) { clearCookieData() } + } + state?.resumeConfig = false + } +} + +def srvcPrefOpts(pre=false) { + section(sTS("${pre ? "Required " : ""}Amazon Region Settings${state?.serviceConfigured ? " (Tap to view)" : ""}"), hideable: state?.serviceConfigured, hidden: state?.serviceConfigured) { + input "amazonDomain", "enum", title: inTS("Select your Amazon Domain?", getAppImg("amazon_orange", true)), description: "", required: true, defaultValue: "amazon.com", options: amazonDomainOpts(), submitOnChange: true, image: getAppImg("amazon_orange") + input "regionLocale", "enum", title: inTS("Select your Locale?", getAppImg("web", true)), description: "", required: true, defaultValue: "en-US", options: localeOpts(), submitOnChange: true, image: getAppImg("web") + } +} + +def notifPrefPage() { + dynamicPage(name: "notifPrefPage", install: false) { + Integer pollWait = 900 + Integer pollMsgWait = 3600 + Integer updNotifyWait = 7200 + section("") { + paragraph title: "Notice:", "The settings configure here are used by both the App and the Devices.", state: "complete" + } + section(sTS("Push Messages:")) { + input "usePush", "bool", title: inTS("Send Push Notitifications\n(Optional)", getAppImg("notification", true)), required: false, submitOnChange: true, defaultValue: false, image: getAppImg("notification") + } + section(sTS("SMS Text Messaging:")) { + paragraph "To send to multiple numbers separate the number by a comma\nE.g. 8045551122,8046663344" + input "smsNumbers", "text", title: inTS("Send SMS to Text to...\n(Optional)", getAppImg("sms_phone", true)), required: false, submitOnChange: true, image: getAppImg("sms_phone") + } + section(sTS("Pushover Support:")) { + input ("pushoverEnabled", "bool", title: inTS("Use Pushover Integration", getAppImg("pushover", true)), description: "requires Pushover Manager app.", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("pushover")) + if(settings?.pushoverEnabled == true) { + if(state?.isInstalled) { + if(!state?.pushoverManager) { + paragraph "If this is the first time enabling Pushover than leave this page and come back if the devices list is empty" + pushover_init() + } else { + input "pushoverDevices", "enum", title: inTS("Select Pushover Devices"), description: "Tap to select", groupedOptions: getPushoverDevices(), multiple: true, required: false, submitOnChange: true + if(settings?.pushoverDevices) { + def t0 = ["-2":"Lowest", "-1":"Low", "0":"Normal", "1":"High", "2":"Emergency"] + input "pushoverPriority", "enum", title: inTS("Notification Priority (Optional)"), description: "Tap to select", defaultValue: "0", required: false, multiple: false, submitOnChange: true, options: t0 + input "pushoverSound", "enum", title: inTS("Notification Sound (Optional)"), description: "Tap to select", defaultValue: "pushover", required: false, multiple: false, submitOnChange: true, options: getPushoverSounds() + } + } + } else { paragraph "New Install Detected!!!\n\n1. Press Done to Finish the Install.\n2. Goto the Automations Tab at the Bottom\n3. Tap on the SmartApps Tab above\n4. Select ${app?.getLabel()} and Resume configuration", state: "complete" } + } + } + if(settings?.smsNumbers?.toString()?.length()>=10 || settings?.usePush || (settings?.pushoverEnabled && settings?.pushoverDevices)) { + if((settings?.usePush || (settings?.pushoverEnabled && settings?.pushoverDevices)) && !state?.pushTested && state?.pushoverManager) { + if(sendMsg("Info", "Push Notification Test Successful. Notifications Enabled for ${app?.label}", true)) { + state.pushTested = true + } + } + section(sTS("Notification Restrictions:")) { + def t1 = getNotifSchedDesc() + href "setNotificationTimePage", title: inTS("Notification Restrictions", getAppImg("restriction", true)), description: (t1 ?: "Tap to configure"), state: (t1 ? "complete" : null), image: getAppImg("restriction") + } + section(sTS("Missed Poll Alerts:")) { + input (name: "sendMissedPollMsg", type: "bool", title: inTS("Send Missed Checkin Alerts?", getAppImg("late", true)), defaultValue: true, submitOnChange: true, image: getAppImg("late")) + if(settings?.sendMissedPollMsg) { + def misPollNotifyWaitValDesc = settings?.misPollNotifyWaitVal ?: "Default: 45 Minutes" + input (name: "misPollNotifyWaitVal", type: "enum", title: inTS("Time Past the Missed Checkin?", getAppImg("delay_time", true)), required: false, defaultValue: 2700, options: notifValEnum(), submitOnChange: true, image: getAppImg("delay_time")) + if(settings?.misPollNotifyWaitVal) { pollWait = settings?.misPollNotifyWaitVal as Integer } + + def misPollNotifyMsgWaitValDesc = settings?.misPollNotifyMsgWaitVal ?: "Default: 1 Hour" + input (name: "misPollNotifyMsgWaitVal", type: "enum", title: inTS("Send Reminder After?", getAppImg("reminder", true)), required: false, defaultValue: 3600, options: notifValEnum(), submitOnChange: true, image: getAppImg("reminder")) + if(settings?.misPollNotifyMsgWaitVal) { pollMsgWait = settings?.misPollNotifyMsgWaitVal as Integer } + } + } + section(sTS("Cookie Refresh Alert:")) { + input (name: "sendCookieRefreshMsg", type: "bool", title: inTS("Send on Refreshed Cookie?", getAppImg("cookie", true)), defaultValue: false, submitOnChange: true, image: getAppImg("cookie")) + } + section(sTS("Code Update Alerts:")) { + input "sendAppUpdateMsg", "bool", title: inTS("Send for Updates...", getAppImg("update", true)), defaultValue: true, submitOnChange: true, image: getAppImg("update") + if(settings?.sendAppUpdateMsg) { + def updNotifyWaitValDesc = settings?.updNotifyWaitVal ?: "Default: 12 Hours" + input (name: "updNotifyWaitVal", type: "enum", title: inTS("Send Reminders After?", getAppImg("reminder", true)), required: false, defaultValue: 43200, options: notifValEnum(), submitOnChange: true, image: getAppImg("reminder")) + if(settings?.updNotifyWaitVal) { updNotifyWait = settings?.updNotifyWaitVal as Integer } + } + } + } else { state.pushTested = false } + state.misPollNotifyWaitVal = pollWait + state.misPollNotifyMsgWaitVal = pollMsgWait + state.updNotifyWaitVal = updNotifyWait + } +} + +def setNotificationTimePage() { + dynamicPage(name: "setNotificationTimePage", title: "Prevent Notifications\nDuring these Days, Times or Modes", uninstall: false) { + Boolean timeReq = (settings["qStartTime"] || settings["qStopTime"]) ? true : false + section() { + input "qStartInput", "enum", title: inTS("Starting at", getAppImg("start_time", true)), options: ["A specific time", "Sunrise", "Sunset"], defaultValue: null, submitOnChange: true, required: false, image: getAppImg("start_time") + if(settings["qStartInput"] == "A specific time") { + input "qStartTime", "time", title: inTS("Start time", getAppImg("start_time", true)), required: timeReq, image: getAppImg("start_time") + } + input "qStopInput", "enum", title: inTS("Stopping at", getAppImg("stop_time", true)), options: ["A specific time", "Sunrise", "Sunset"], defaultValue: null, submitOnChange: true, required: false, image: getAppImg("stop_time") + if(settings?."qStopInput" == "A specific time") { + input "qStopTime", "time", title: inTS("Stop time", getAppImg("stop_time", true)), required: timeReq, image: getAppImg("stop_time") + } + input "quietDays", "enum", title: inTS("Only on these days of the week", getAppImg("day_calendar", true)), multiple: true, required: false, image: getAppImg("day_calendar"), + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "quietModes", "mode", title: inTS("When these Modes are Active", getAppImg("mode", true)), multiple: true, submitOnChange: true, required: false, image: getAppImg("mode") + } + } +} + +def uninstallPage() { + dynamicPage(name: "uninstallPage", title: "Uninstall", uninstall: true) { + section("") { paragraph "This will uninstall the App and All Child Devices.\n\nPlease make sure that any devices created by this app are removed from any routines/rules/smartapps before tapping Remove." } + if(isST()) { remove("Remove ${app?.label} and Devices!", "WARNING!!!", "Last Chance to Stop!\nThis action is not reversible\n\nThis App and Devices will be removed") } + } +} + +String bulletItem(String inStr, String strVal) { return "${inStr == "" ? "" : "\n"} \u2022 ${strVal}" } +String dashItem(String inStr, String strVal, newLine=false) { return "${(inStr == "" && !newLine) ? "" : "\n"} - ${strVal}" } + +def deviceTestPage() { + return dynamicPage(name: "deviceTestPage", uninstall: false, install: false) { + section("") { + href "broadcastPage", title: inTS("Broadcast Test", getAppImg("broadcast", true)), description: (t1 ?: "Tap to configure"), state: (t1 ? "complete" : null), image: getAppImg("broadcast") + href "announcePage", title: inTS("Announcement Test", getAppImg("broadcast", true)), description: (t1 ?: "Tap to configure"), state: (t1 ? "complete" : null), image: getAppImg("announcement") + href "sequencePage", title: inTS("Sequence Creator Test", getAppImg("broadcast", true)), description: (t1 ?: "Tap to configure"), state: (t1 ? "complete" : null), image: getAppImg("sequence") + } + } +} + +def broadcastPage() { + return dynamicPage(name: "broadcastPage", uninstall: false, install: false) { + section("") { + Map devs = getDeviceList(true, true) + input "broadcastDevices", "enum", title: inTS("Select Devices to Test the Broadcast"), description: "Tap to select", options: (devs ? devs?.sort{it?.value} : []), multiple: true, required: false, submitOnChange: true + input "broadcastVolume", "number", title: inTS("Broadcast at this volume"), description: "Enter number", range: "0..100", defaultValue: 30, required: false, submitOnChange: true + input "broadcastRestVolume", "number", title: inTS("Restore to this volume after"), description: "Enter number", range: "0..100", defaultValue: null, required: false, submitOnChange: true + input "broadcastMessage", "text", title: inTS("Message to broadcast"), defaultValue: "This is a test of the Echo speaks broadcast system!!!", required: true, submitOnChange: true + input "broadcastParallel", "bool", title: inTS("Execute commands in Parallel?"), description: "", required: false, defaultValue: true, submitOnChange: true + } + if(settings?.broadcastDevices) { + section() { + input "broadcastRun", "bool", title: inTS("Perform the Broadcast?"), description: "", required: false, defaultValue: false, submitOnChange: true + if(broadcastRun) { executeBroadcast() } + } + } + } +} + +def announcePage() { + return dynamicPage(name: "announcePage", uninstall: false, install: false) { + section("") { + Map devs = getDeviceList(true, true) + input "announceDevices", "enum", title: inTS("Select Devices to Test the Announcement"), description: "Tap to select", options: (devs ? devs?.sort{it?.value} : []), multiple: true, required: false, submitOnChange: true + input "announceVolume", "number", title: inTS("Announce at this volume"), description: "Enter number", range: "0..100", defaultValue: 30, required: false, submitOnChange: true + input "announceRestVolume", "number", title: inTS("Restore to this volume after"), description: "Enter number", range: "0..100", defaultValue: null, required: false, submitOnChange: true + input "announceMessage", "text", title: inTS("Message to announce"), defaultValue: "This is a test of the Echo speaks broadcast system!!!", required: true, submitOnChange: true + } + if(settings?.announceDevices) { + section() { + input "announceRun", "bool", title: inTS("Perform the Announcement?"), description: "", required: false, defaultValue: false, submitOnChange: true + if(announceRun) { executeAnnouncement() } + } + } + } +} + +Map seqItemsAvail() { + return [ + other: [ + "weather":null, "traffic":null, "flashbriefing":null, "goodmorning":null, "goodnight":null, "cleanup":null, + "singasong":null, "tellstory":null, "funfact":null, "joke":null, "playsearch":null, "calendartoday":null, + "calendartomorrow":null, "calendarnext":null, "stop":null, "stopalldevices":null, + "wait": "value (seconds)", "volume": "value (0-100)", "speak": "message", "announcement": "message", + "announcementall": "message", "pushnotification": "message" + ], + // dnd: [ + // "dnd_duration": "2H30M", "dnd_time": "00:30", "dnd_all_duration": "2H30M", "dnd_all_time": "00:30", + // "dnd_duration":"2H30M", "dnd_time":"00:30" + // ], + speech: [ + "cannedtts_random": ["goodbye", "confirmations", "goodmorning", "compliments", "birthday", "goodnight", "iamhome"] + ], + music: [ + "amazonmusic": "search term", "applemusic": "search term", "iheartradio": "search term", "pandora": "search term", + "spotify": "search term", "tunein": "search term", "cloudplayer": "search term" + ] + ] +} + +def sequencePage() { + return dynamicPage(name: "sequencePage", uninstall: false, install: false) { + section(sTS("Command Legend:"), hideable: true, hidden: true) { + String str1 = "Sequence Options:" + seqItemsAvail()?.other?.sort()?.each { k, v-> + str1 += "${bulletItem(str1, "${k}${v != null ? "::${v}" : ""}")}" + } + String str2 = "Music Options:" + seqItemsAvail()?.music?.sort()?.each { k, v-> + str2 += "${bulletItem(str2, "${k}${v != null ? "::${v}" : ""}")}" + } + String str3 = "Canned TTS Options:" + seqItemsAvail()?.speech?.sort()?.each { k, v-> + def newV = v + if(v instanceof List) { newV = ""; v?.sort()?.each { newV += " ${dashItem(newV, "${it}", true)}"; } } + str3 += "${bulletItem(str3, "${k}${newV != null ? "::${newV}" : ""}")}" + } + paragraph str1, state: "complete" + paragraph str2, state: "complete" + paragraph str3, state: "complete" + paragraph "Enter the command in a format exactly like this:\nvolume::40,, speak::this is so silly,, wait::60,, weather,, cannedtts_random::goodbye,, traffic,, amazonmusic::green day,, volume::30\n\nEach command needs to be separated by a double comma `,,` and the separator between the command and value must be command::value.", state: "complete" + } + section(sTS("Sequence Test Config:")) { + input "sequenceDevice", "device.EchoSpeaksDevice", title: inTS("Select Devices to Test Sequence Command"), description: "Tap to select", multiple: false, required: false, submitOnChange: true + input "sequenceString", "text", title: inTS("Sequence String to Use"), defaultValue: "", required: false, submitOnChange: true + } + if(settings?.sequenceDevice && settings?.sequenceString) { + section() { + input "sequenceRun", "bool", title: inTS("Perform the Sequence?"), description: "", required: false, defaultValue: false, submitOnChange: true + if(sequenceRun) { executeSequence() } + } + } + } +} + +Integer getRecheckDelay(Integer msgLen=null, addRandom=false) { + def random = new Random() + Integer randomInt = random?.nextInt(5) //Was using 7 + if(!msgLen) { return 30 } + def v = (msgLen <= 14 ? 1 : (msgLen / 14)) as Integer + // logger("trace", "getRecheckDelay($msgLen) | delay: $v + $randomInt") + return addRandom ? (v + randomInt) : (v < 5 ? 5 : v) +} + +private executeBroadcast() { + settingUpdate("broadcastRun", "false", "bool") + String testMsg = settings?.broadcastMessage + Map eDevs = state?.echoDeviceMap + List seqItems = [] + def selectedDevs = settings?.broadcastDevices + selectedDevs?.each { dev-> + seqItems?.push([command: "volume", value: settings?.broadcastVolume as Integer, serial: dev, type: eDevs[dev]?.type]) + seqItems?.push([command: "speak", value: testMsg, serial: dev, type: eDevs[dev]?.type]) + } + sendMultiSequenceCommand(seqItems, "broadcastTest", settings?.broadcastParallel) + // schedules volume restore + runIn(getRecheckDelay(testMsg?.length()), "broadcastVolumeRestore") +} + +private broadcastVolumeRestore() { + Map eDevs = state?.echoDeviceMap + def selectedDevs = settings?.broadcastDevices + List seqItems = [] + selectedDevs?.each { dev-> seqItems?.push([command: "volume", value: (settings?.broadcastRestVolume ?: 30), serial: dev, type: eDevs[dev]?.type]) } + sendMultiSequenceCommand(seqItems, "broadcastVolumeRestore", settings?.broadcastParallel) +} + +private announcementVolumeRestore() { + Map eDevs = state?.echoDeviceMap + def selectedDevs = settings?.announceDevices + List seqItems = [] + selectedDevs?.each { dev-> seqItems?.push([command: "volume", value: (settings?.announceRestVolume ?: 30), serial: dev, type: eDevs[dev]?.type]) } + sendMultiSequenceCommand(seqItems, "announcementVolumeRestore", settings?.broadcastParallel) +} + +private executeAnnouncement() { + settingUpdate("announceRun", "false", "bool") + String testMsg = settings?.announceMessage + Map eDevs = state?.echoDeviceMap + List seqItems = [] + def selectedDevs = settings?.announceDevices + selectedDevs?.each { dev-> + seqItems?.push([command: "volume", value: settings?.announceRestVolume as Integer, serial: dev, type: eDevs[dev]?.type]) + } + seqItems?.push([command: "announcementTest", value: testMsg, serial: null, type: null]) + sendMultiSequenceCommand(seqItems, "announcementTest", settings?.broadcastParallel) + runIn(getRecheckDelay(testMsg?.length()), "announcementVolumeRestore") +} + +private executeSequence() { + settingUpdate("sequenceRun", "false", "bool") + String seqStr = settings?.sequenceString + if(settings?.sequenceDevice?.hasCommand("executeSequenceCommand")) { + settings?.sequenceDevice?.executeSequenceCommand(seqStr as String) + } else { + log.warn "sequence test device doesn't support the executeSequenceCommand command..." + } +} + +private executeTuneInSearch() { + Map params = [ + uri: getAmazonUrl(), + path: "/api/tunein/search", + query: [ query: settings?.tuneinSearchQuery, mediaOwnerCustomerId: state?.deviceOwnerCustomerId ], + headers: [cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json" + ] + Map results = makeSyncronousReq(params, "get", "tuneInSearch") ?: [:] + return results +} + +private executeMusicSearchTest() { + settingUpdate("performMusicTest", "false", "bool") + if(settings?.musicTestDevice && settings?.musicTestProvider && settings?.musicTestQuery) { + if(settings?.musicTestDevice?.hasCommand("searchMusic")) { + log.debug "Performing ${settings?.musicTestProvider} Search Test with Query: (${settings?.musicTestQuery}) on Device: (${settings?.musicTestDevice})" + settings?.musicTestDevice?.searchMusic(settings?.musicTestQuery as String, settings?.musicTestProvider as String) + } else { log.error "The Device ${settings?.musicTestDevice} does NOT support the searchMusic() command..." } + } +} + +def musicSearchTestPage() { + return dynamicPage(name: "musicSearchTestPage", uninstall: false, install: false) { + section(sTS("Test a Music Search on Device:")) { + paragraph "Use this to test the search you discovered above directly on a device.", state: "complete" + Map testEnum = ["CLOUDPLAYER": "My Library", "AMAZON_MUSIC": "Amazon Music", "I_HEART_RADIO": "iHeartRadio", "PANDORA": "Pandora", "APPLE_MUSIC": "Apple Music", "TUNEIN": "TuneIn", "SIRIUSXM": "siriusXm", "SPOTIFY": "Spotify"] + input "musicTestProvider", "enum", title: inTS("Select Music Provider to perform test", getAppImg("music", true)), defaultValue: null, required: false, options: testEnum, submitOnChange: true, image: getAppImg("music") + if(musicTestProvider) { + input "musicTestQuery", "text", title: inTS("Music Search term to test on Device", getAppImg("search2", true)), defaultValue: null, required: false, submitOnChange: true, image: getAppImg("search2") + if(settings?.musicTestQuery) { + input "musicTestDevice", "device.EchoSpeaksDevice", title: inTS("Select a Device to Test Music Search", getAppImg("echo_speaks.1x", true)), description: "Tap to select", multiple: false, required: false, submitOnChange: true, image: getAppImg("echo_speaks.1x") + if(musicTestDevice) { + input "performMusicTest", "bool", title: inTS("Perform the Music Search Test?", getAppImg("music", true)), description: "", required: false, defaultValue: false, submitOnChange: true, image: getAppImg("music") + if(performMusicTest) { executeMusicSearchTest() } + } + } + } + } + section(sTS("TuneIn Search Results:")) { + paragraph "Enter a search phrase to query TuneIn to help you find the right search term to use in searchTuneIn() command.", state: "complete" + input "tuneinSearchQuery", "text", title: inTS("Enter search phrase for TuneIn", getAppImg("tunein", true)), defaultValue: null, required: false, submitOnChange: true, image: getAppImg("tunein") + if(settings?.tuneinSearchQuery) { + href "searchTuneInResultsPage", title: inTS("View search results!", getAppImg("search2", true)), description: "Tap to proceed...", image: getAppImg("search2") + } + } + } +} + +def searchTuneInResultsPage() { + return dynamicPage(name: "searchTuneInResultsPage", uninstall: false, install: false) { + def results = executeTuneInSearch() + Boolean onST = isST() + section(sTS("Search Results: (Query: ${settings?.tuneinSearchQuery})")) { + if(results?.browseList && results?.browseList?.size()) { + results?.browseList?.eachWithIndex { item, i-> + if(i < 25) { + if(item?.browseList != null && item?.browseList?.size()) { + item?.browseList?.eachWithIndex { item2, i2-> + String str = "" + str += "ContentType: (${item2?.contentType})" + str += "\nId: (${item2?.id})" + str += "\nDescription: ${item2?.description}" + if(onST) { + paragraph title: pTS(item2?.name?.take(75), (onST ? null : item2?.image)), str, required: true, state: (!item2?.name?.contains("Not Supported") ? "complete" : null), image: item2?.image ?: "" + } else { href "searchTuneInResultsPage", title: pTS(item2?.name?.take(75), (onST ? null : item2?.image)), description: str, required: true, state: (!item2?.name?.contains("Not Supported") ? "complete" : null), image: onST && item2?.image ? item2?.image : null } + } + } else { + String str = "" + str += "ContentType: (${item?.contentType})" + str += "\nId: (${item?.id})" + str += "\nDescription: ${item?.description}" + if(onST) { + paragraph title: pTS(item?.name?.take(75), (onST ? null : item?.image)), str, required: true, state: (!item?.name?.contains("Not Supported") ? "complete" : null), image: item?.image ?: "" + } else { href "searchTuneInResultsPage", title: pTS(item?.name?.take(75), (onST ? null : item?.image)), description: str, required: true, state: (!item?.name?.contains("Not Supported") ? "complete" : null), image: onST && item?.image ? item?.image : null } + } + } + } + } else { paragraph "No Results found..." } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + state?.installData = [initVer: appVersion(), dt: getDtNow().toString(), updatedDt: "Not Set", sentMetrics: false, shownChgLog: true] + state?.isInstalled = true + sendInstallData() + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + if(!state?.isInstalled) { state?.isInstalled = true } + if(!state?.installData) { state?.installData = [initVer: appVersion(), dt: getDtNow().toString(), updatedDt: getDtNow().toString(), sentMetrics: false] } + unschedule() + initialize() +} + +def initialize() { + if(app?.getLabel() != "Echo Speaks") { app?.updateLabel("Echo Speaks") } + if(settings?.optOutMetrics == true && state?.appGuid) { if(removeInstallData()) { state?.appGuid = null } } + subscribe(app, onAppTouch) + if(!state?.resumeConfig) { + runEvery5Minutes("healthCheck") // This task checks for missed polls, app updates, code version changes, and cloud service health + appCleanup() + runEvery1Minute("getOtherData") + runEvery10Minutes("getEchoDevices") //This will reload the device list from Amazon + validateCookie(true) + runIn(15, "reInitDevices") + getOtherData() + getEchoDevices() + } +} + +def uninstalled() { + log.warn "uninstalling app and devices" + unschedule() + if(settings?.optOutMetrics != true) { if(removeInstallData()) { state?.appGuid = null } } + clearCloudConfig() + clearCookieData() + removeDevices(true) +} + +def getGroupApps() { + return getChildApps()?.findAll { it?.name == grpChildName() } +} + +def getActionApps() { + return getChildApps()?.findAll { it?.name == actChildName() } +} + +public getBroadcastGrps() { + Map grps = [:] + def groupApps = getChildApps()?.findAll { it?.name == grpChildName() } + groupApps?.each { grp-> + grps[grp?.getId() as String] = grp?.getBroadcastGroupData(true) + } + return grps +} + +def onAppTouch(evt) { + // log.trace "appTouch..." + updated() +} + +void settingUpdate(name, value, type=null) { + if(name && type) { + app?.updateSetting("$name", [type: "$type", value: value]) + } + else if (name && type == null){ app?.updateSetting(name.toString(), value) } +} + +void settingRemove(String name) { + logger("trace", "settingRemove($name)...") + if(name && settings?.containsKey(name as String)) { isST() ? app?.deleteSetting(name as String) : app?.removeSetting(name as String) } +} + +mappings { + path("/renderMetricData") { action: [GET: "renderMetricData"] } + path("/receiveData") { action: [POST: "processData"] } + path("/config") { action: [GET: "renderConfig"] } + path("/cookie") { action: [GET: "getCookieData", POST: "storeCookieData", DELETE: "clearCookieData"] } +} + +String getCookieVal() { return (state?.cookieData && state?.cookieData?.localCookie) ? state?.cookieData?.localCookie as String : null } +String getCsrfVal() { return (state?.cookieData && state?.cookieData?.csrf) ? state?.cookieData?.csrf as String : null } + +def clearCloudConfig() { + log.trace "clearCloudConfig called..." + settingUpdate("resetService", "false", "bool") + unschedule("cloudServiceHeartbeat") + List remItems = ["generatedHerokuName", "useHeroku", "onHeroku", "nodeServiceInfo", "serverHost", "isLocal"] + remItems?.each { rem-> + state?.remove(rem as String) + } + // (isST() ? app?.getChildDevices(true) : getChildDevices())?.each { dev-> dev?.setAuthState(false) } + state?.serviceConfigured = false + state?.resumeConfig = true +} + +String getEnvParamsStr() { + Map envParams = [:] + envParams["smartThingsUrl"] = "${getAppEndpointUrl("receiveData")}" + envParams["appCallbackUrl"] = "${getAppEndpointUrl("receiveData")}" + envParams["hubPlatform"] = "${getPlatform()}" + envParams["useHeroku"] = (isST() || settings?.useHeroku != false) + envParams["serviceDebug"] = (settings?.serviceDebug == true) ? "true" : "false" + envParams["serviceTrace"] = (settings?.serviceTrace == true) ? "true" : "false" + envParams["amazonDomain"] = settings?.amazonDomain as String ?: "amazon.com" + envParams["regionLocale"] = settings?.regionLocale as String ?: "en-US" + envParams["hostUrl"] = "${getRandAppName()}.herokuapp.com" + String envs = "" + envParams?.each { k, v-> envs += "&env[${k}]=${v}" } + return envs +} + +private checkIfCodeUpdated() { + if(state?.codeVersions && state?.codeVersions?.mainApp != appVersion()) { + checkVersionData(true) + log.info "Code Version Change! Re-Initializing SmartApp in 5 seconds..." + state?.pollBlocked = true + updCodeVerMap("mainApp", appVersion()) + Map iData = atomicState?.installData ?: [:] + iData["updatedDt"] = getDtNow().toString() + iData["shownChgLog"] = false + atomicState?.installData = iData + runIn(5, "postCodeUpdated", [overwrite: false]) + return true + } + state?.pollBlocked = false + return false +} + +private postCodeUpdated() { + updated() + runIn(10, "sendInstallData", [overwrite: false]) +} + +private appCleanup() { + List items = ["availableDevices", "lastMsgDt", "consecutiveCmdCnt", "isRateLimiting", "versionData", "heartbeatScheduled", "serviceAuthenticated", "cookie"] + items?.each { si-> if(state?.containsKey(si as String)) { state?.remove(si)} } + state?.pollBlocked = false + state?.resumeConfig = false + state?.deviceRefreshInProgress = false + // Settings Cleanup + + List setItems = ["tuneinSearchQuery", "performBroadcast", "performMusicTest", "stHub"] + settings?.each { si-> if(si?.key?.startsWith("broadcast") || si?.key?.startsWith("musicTest") || si?.key?.startsWith("announce") || si?.key?.startsWith("sequence")) { setItems?.push(si?.key as String) } } + setItems?.each { sI-> + if(settings?.containsKey(sI as String)) { settingRemove(sI as String) } + } +} + +private resetQueues() { + (isST() ? app?.getChildDevices(true) : getChildDevices())?.each { it?.resetQueue() } +} + +private reInitDevices() { + (isST() ? app?.getChildDevices(true) : getChildDevices())?.each { it?.triggerInitialize() } +} + +private updCodeVerMap(key, val) { + Map cv = atomicState?.codeVersions ?: [:] + cv[key as String] = val + atomicState?.codeVersions = cv +} + +String getRandAppName() { + if(!state?.generatedHerokuName && (!state?.isLocal && !state?.serverHost)) { state?.generatedHerokuName = "${app?.name?.toString().replaceAll(" ", "-")}-${randomString(8)}"?.toLowerCase() } + return state?.generatedHerokuName as String +} + +def processData() { + // log.trace "processData() | Data: ${request.JSON}" + Map data = request?.JSON as Map + if(data) { + if(data?.version) { + state?.onHeroku = (isST() || data?.onHeroku == true || data?.onHeroku == null || (!data?.isLocal && settings?.useHeroku != false)) + state?.isLocal = (!isST() && data?.isLocal == true) + state?.serverHost = (data?.serverUrl ?: null) + log.trace "processData Received | Version: ${data?.version} | onHeroku: ${data?.onHeroku} | serverUrl: ${data?.serverUrl}" + updCodeVerMap("server", data?.version) + } else { log.debug "data: $data" } + } + def json = new groovy.json.JsonOutput().toJson([message: "success", version: appVersion()]) + render contentType: "application/json", data: json, status: 200 +} + +def getCookieData() { + log.trace "getCookieData() Request Received..." + Map resp = state?.cookieData ?: [:] + def json = new groovy.json.JsonOutput().toJson(resp) + incrementCntByKey("getCookieCnt") + render contentType: "application/json", data: json +} + +def storeCookieData() { + log.trace "storeCookieData Request Received..." + if(request?.JSON && request?.JSON?.cookieData) { + log.trace "cookieData Received: ${request?.JSON?.cookieData?.keySet()}" + logger("trace", "cookieData Received: ${request?.JSON?.cookieData?.keySet()}") + Map obj = [:] + request?.JSON?.cookieData?.each { k,v-> + obj[k as String] = v as String + } + state?.cookieData = obj + state?.onHeroku = (isST() || data?.onHeroku == true || data?.onHeroku == null || (!data?.isLocal && settings?.useHeroku != false)) + state?.isLocal = (!isST() && data?.isLocal == true) + state?.serverHost = request?.JSON?.serverUrl ?: null + updCodeVerMap("server", request?.JSON?.version) + } + if(state?.cookieData?.localCookie && state?.cookieData?.csrf) { + log.info "Cookie Data has been Updated... Re-Initializing SmartApp and to restart polling in 10 seconds..." + validateCookie(true) + state?.serviceConfigured = true + state?.lastCookieRefresh = getDtNow() + runIn(10, "initialize", [overwrite: true]) + } +} + +def clearCookieData(src=null) { + logger("trace", "clearCookieData(${src ?: ""})") + settingUpdate("resetCookies", "false", "bool") + state?.remove("cookie") + state?.remove("cookieData") + state?.remove("lastCookieRefresh") + unschedule("getEchoDevices") + unschedule("getOtherData") + log.warn "Cookie Data has been cleared and Device Data Refreshes have been suspended..." + updateChildAuth(false) + // if(getServerHostURL()) { clearServerAuth() } +} + +private updateChildAuth(Boolean isValid) { + (isST() ? app?.getChildDevices(true) : getChildDevices())?.each { it?.setAuthState(isValid) } +} + +private authEvtHandler(Boolean isAuth) { + state?.authValid = (isAuth == true) + if(isAuth == false && !state?.noAuthActive) { + clearCookieData() + noAuthReminder() + sendMsg("${app.name} Amazon Login Issue", "Amazon Cookie Has Expired or is Missing!!! Please login again using the Heroku Web Config page...") + runEvery1Hour("noAuthReminder") + state?.noAuthActive = true + updateChildAuth(isAuth) + } else { + if(state?.noAuthActive) { + unschedule("noAuthReminder") + state?.noAuthActive = false + runIn(10, "initialize", [overwrite: true]) + } + } +} + +Boolean isAuthValid(methodName) { + if(state?.authValid == false) { + log.warn "Echo Speaks Authentication is no longer valid... Please login again and commands will be allowed again!!! | Method: (${methodName})" + return false + } + return true +} + +private validateCookie(frc=false) { + if((!frc && getLastCookieChkSec() <= 1800) || !getCookieVal() || !getCsrfVal()) { return } + try { + def params = [uri: getAmazonUrl(), path: "/api/bootstrap", query: ["version": 0], headers: [cookie: getCookieVal(), csrf: getCsrfVal()], contentType: "application/json"] + execAsyncCmd("get", "cookieValidResp", params, [execDt: now()]) + } catch(ex) { + incrementCntByKey("err_app_cookieValidCnt") + log.error "validateCookie() Exception:", ex + } +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v?.toString(), "utf-8").replaceAll("\\+", "%20")}" }?.sort().join("&") +} + +String getServerHostURL() { + return (state?.isLocal && state?.serverHost) ? (state?.serverHost ? "${state?.serverHost}" : null) : "https://${getRandAppName()}.herokuapp.com" +} + +Integer getLastCookieRefreshSec() { return !state?.lastCookieRefresh ? 100000 : GetTimeDiffSeconds(state?.lastCookieRefresh, "getLastCookieRrshSec").toInteger() } + +def clearServerAuth() { + log.debug "serverUrl: ${getServerHostURL()}" + Map params = [ uri: getServerHostURL(), path: "/clearAuth" ] + def execDt = now() + httpGet(params) { resp-> + log.debug "resp: ${resp.status} | data: ${resp?.data}" + if (resp?.status == 200) { + log.debug "clearServerAuth Completed... | Process Time: (${execDt ? (now()-execDt) : 0}ms)" + } + } +} + +private runCookieRefresh() { + settingUpdate("refreshCookie", "false", "bool") + Map params = [ + uri: getServerHostURL(), + path: "/config", + contentType: "text/html", + requestContentType: "text/html" + ] + execAsyncCmd("get", "wakeUpServerResp", params, [execDt: now()]) +} + +def wakeUpServerResp(response, data) { + log.trace "wakeUpServerResp..." + try { } catch(ex) { log.error "wakeUpServerResp Error: ${response?.getErrorMessage() ?: null}" } + def rData = response?.data ?: null + if (rData) { + // log.debug "rData: $rData" + log.debug "wakeUpServer Completed... | Process Time: (${data?.execDt ? (now()-data?.execDt) : 0}ms)" + Map cookieData = state?.cookieData ?: [:] + if (!cookieData || !cookieData?.loginCookie || !cookieData?.refreshToken) { + log.error("Required Registration data is missing for Cookie Refresh") + return + } + Map params = [ + uri: getServerHostURL(), + path: "/refreshCookie" + ] + execAsyncCmd("get", "cookieRefreshResp", params, [execDt: now()]) + } +} + +def cookieRefreshResp(response, data) { + log.trace "cookieRefreshResp..." + try { } catch(ex) { log.error "cookieRefreshResp Error: ${response?.getErrorMessage() ?: null}" } + Map rData = response?.json ?: [:] + if (rData && rData?.result && rData?.result?.size()) { + log.debug "refreshAlexaCookie Completed | Process Time: (${data?.execDt ? (now()-data?.execDt) : 0}ms)" + if(settings?.sendCookieRefreshMsg == true) { sendMsg("${app.name} Cookie Refresh", "Amazon Cookie was Refreshed Successfully!!!") } + // log.debug "refreshAlexaCookie Response: ${rData?.result}" + } +} + +private apiHealthCheck(frc=false) { + // if(!frc || (getLastApiChkSec() <= 1800)) { return } + try { + Map params = [uri: getAmazonUrl(), path: "/api/ping", query: ["_": ""], headers: [cookie: getCookieVal(), csrf: getCsrfVal()], contentType: "plain/text"] + httpGet(params) { resp-> + log.debug "API Health Check Resp: (${resp?.getData()})" + return (resp?.getData().toString() == "healthy") + } + } catch(ex) { + incrementCntByKey("err_app_apiHealthCnt") + log.error "apiHealthCheck() Exception:", ex + } +} + +def cookieValidResp(response, data) { + // log.trace "cookieValidResp..." + if(response?.status == 401) { + log.error "cookieValidResp Status: (${response.status})" + authEvtHandler(false) + state?.lastCookieChkDt = getDtNow() + return + } + Map aData = response?.json?.authentication ?: [:] + Boolean valid = false + if (aData) { + if(aData?.customerId) { state?.deviceOwnerCustomerId = aData?.customerId } + if(aData?.customerName) { state?.customerName = aData?.customerName } + valid = (resp?.data?.authentication?.authenticated != false) + } + state?.lastCookieChkDt = getDtNow() + def execTime = data?.execDt ? (now()-data?.execDt) : 0 + log.debug "Cookie Validation: (${valid}) | Process Time: (${execTime}ms)" + authEvtHandler(valid) +} + +private respIsValid(statusCode, Boolean hasErr, errMsg=null, String methodName, Boolean falseOnErr=false) { + statusCode = statusCode as Integer + if(statusCode == 401) { + setAuthState(false) + return false + } else { if(statusCode > 401 && statusCode < 500) { log.error "${methodName} Error: ${errMsg ?: null}" } } + if(hasErr && falseOnErr) { return false } + return true +} + +private noAuthReminder() { log.warn "Amazon Cookie Has Expired or is Missing!!! Please login again using the Heroku Web Config page..." } + +private makeSyncronousReq(params, method="get", src, showLogs=false) { + try { + "http${method?.toString()?.toLowerCase()?.capitalize()}"(params) { resp -> + if(resp?.data) { + // log.debug "status: ${resp?.status}" + if(showLogs) { log.debug "makeSyncronousReq(Src: $src) | Status: ${resp?.status}: ${resp?.data}" } + return resp?.data + } + return null + } + } catch (ex) { + if(ex instanceof groovyx.net.http.ResponseParseException) { + log.error "There was an errow while parsing the response: ", ex + } else { log.error "makeSyncronousReq(Method: ${method}, Src: ${src}) exception", ex } + return null + } +} + +public childInitiatedRefresh() { + Integer lastRfsh = getLastChildInitRefreshSec() + if(state?.deviceRefreshInProgress != true && lastRfsh > 120) { + log.debug "A Child Device is requesting a Device List Refresh..." + state?.lastChildInitRefreshDt = getDtNow() + getOtherData() + runIn(3, "getEchoDevices") + } else { + log.warn "childInitiatedRefresh request ignored... Refresh already in progress or it's too soon to refresh again | Last Refresh: (${lastRfsh} seconds)" + } +} + +private getEchoDevices() { + if(!isAuthValid("getEchoDevices")) { return } + def params = [ + uri: getAmazonUrl(), + path: "/api/devices-v2/device", + query: [ cached: true, _: new Date().getTime() ], + headers: [cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json", + ] + state?.deviceRefreshInProgress = true + state?.refreshDeviceData = false + execAsyncCmd("get", "echoDevicesResponse", params, [execDt: now()]) +} + +private getMusicProviders() { + Map params = [ + uri: getAmazonUrl(), + path: "/api/behaviors/entities", + query: [ skillId: "amzn1.ask.1p.music" ], + headers: ["Routines-Version": "1.1.210292", cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json" + ] + Map items = [:] + List musicResp = makeSyncronousReq(params, "get", "getMusicProviders") ?: [:] + if(musicResp?.size()) { + musicResp?.findAll { it?.availability == "AVAILABLE" }?.each { item-> + items[item?.id] = item?.displayName + } + } + // log.debug "items: $items" + return items +} + +private getOtherData() { + getBluetoothDevices() + getDoNotDisturb() +} + +private getBluetoothDevices() { + // log.trace "getBluetoothDevices" + Map params = [ + uri: getAmazonUrl(), + path: "/api/bluetooth", + query: [cached: true, _: new Date().getTime()], + headers: [cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json" + ] + def btResp = makeSyncronousReq(params, "get", "getBluetoothDevices") ?: null + state?.bluetoothData = btResp ?: [:] +} + +def getBluetoothData(serialNumber) { + // log.trace "getBluetoothData: ${serialNumber}" + String curConnName = null + Map btObjs = [:] + Map btData = state?.bluetoothData ?: [:] + Map bluData = btData && btData?.bluetoothStates?.size() ? btData?.bluetoothStates?.find { it?.deviceSerialNumber == serialNumber } : [:] + if(bluData?.size() && bluData?.pairedDeviceList && bluData?.pairedDeviceList?.size()) { + def bData = bluData?.pairedDeviceList?.findAll { (it?.deviceClass != "GADGET") } + bData?.each { + btObjs[it?.address as String] = it + if(it?.connected == true) { curConnName = it?.friendlyName as String } + } + } + return [btObjs: btObjs, pairedNames: btObjs?.collect { it?.value?.friendlyName as String } ?: [], curConnName: curConnName] +} + +private getDoNotDisturb() { + Map params = [ + uri: getAmazonUrl(), + path: "/api/dnd/device-status-list", + query: [_: new Date().getTime()], + headers: [cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json", + ] + def dndResp = makeSyncronousReq(params, "get", "getDoNotDisturb") ?: null + state?.dndData = dndResp ?: [:] +} + +def getDndEnabled(serialNumber) { + // log.trace "getBluetoothData: ${serialNumber}" + Map sData = state?.dndData ?: [:] + def dndData = sData?.doNotDisturbDeviceStatusList?.size() ? sData?.doNotDisturbDeviceStatusList?.find { it?.deviceSerialNumber == serialNumber } : [:] + return (dndData && dndData?.enabled == true) +} + +private getRoutines(autoId=null, limit=2000) { + Map params = [ + uri: getAmazonUrl(), + path: "/api/behaviors/automations${autoId ? "/${autoId}" : ""}", + query: [ limit: limit ], + headers: [cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json" + ] + Map items = [:] + def routineResp = makeSyncronousReq(params, "get", "getRoutinesHandler") ?: [:] + // log.debug "routineResp: $routineResp" + if(routineResp) { + if(autoId) { + return routineResp + } else { + if(routineResp?.size()) { + routineResp?.findAll { it?.status == "ENABLED" }?.each { item-> + items[item?.automationId] = item?.name + } + } + } + } + // log.debug "routine items: $items" + return items +} + +def executeRoutineById(String routineId) { + def execDt = now() + Map routineData = getRoutines(routineId) + if(routineData && routineData?.sequence) { + sendSequenceCommand("ExecuteRoutine", routineData, null) + // log.debug "Executed Alexa Routine | Process Time: (${(now()-execDt)}ms) | RoutineId: ${routineId}" + return true + } else { + log.debug "No Routine Data Returned for ID: (${routineId})" + return false + } +} + +Map isFamilyAllowed(String family) { + Map famMap = getDeviceFamilyMap() + if(family in famMap?.block) { return [ok: false, reason: "Family Blocked"] } + if(family in famMap?.echo) { return [ok: true, reason: "Amazon Echos Allowed"] } + if(family in famMap?.tablet) { + if(settings?.createTablets == true) { return [ok: true, reason: "Tablets Enabled"] } + return [ok: false, reason: "Tablets Not Enabled"] + } + if(family in famMap?.wha) { + if(settings?.createWHA == true) { return [ok: true, reason: "WHA Enabled"] } + return [ok: false, reason: "WHA Devices Not Enabled"] + } + if(settings?.createOtherDevices == true) { + return [ok: true, reason: "Other Devices Enabled"] + } else { return [ok: false, reason: "Other Devices Not Enabled"] } + return [ok: false, reason: "Unknown Reason"] +} + +def echoDevicesResponse(response, data) { + List ignoreTypes = getDeviceTypesMap()?.ignore ?: ["A1DL2DVDQVK3Q", "A21Z3CGI8UIP0F", "A2825NDLA7WDZV", "A2IVLV5VM2W81", "A2TF17PFR55MTB", "A1X7HJX9QL16M5", "A2T0P32DY3F7VB", "A3H674413M2EKB", "AILBSA2LNTOYL"] + List removeKeys = ["appDeviceList", "charging", "macAddress", "deviceTypeFriendlyName", "registrationId", "remainingBatteryLevel", "postalCode", "language"] + if(response?.status == 401) { + authEvtHandler(false) + return + } + try { + // log.debug "json response is: ${response.json}" + state?.deviceRefreshInProgress=false + List eDevData = response?.json?.devices ?: [] + Map echoDevices = [:] + if(eDevData?.size()) { + eDevData?.each { eDevice-> + String serialNumber = eDevice?.serialNumber; + // if (!(eDevice?.deviceType in ignoreTypes) && !eDevice?.accountName?.contains("Alexa App") && !eDevice?.accountName?.startsWith("This Device")) { + if (!(eDevice?.deviceType in ignoreTypes) && !eDevice?.accountName?.startsWith("This Device")) { + removeKeys?.each { rk-> eDevice?.remove(rk as String) } + if (eDevice?.deviceOwnerCustomerId != null) { state?.deviceOwnerCustomerId = eDevice?.deviceOwnerCustomerId } + echoDevices[serialNumber] = eDevice + } + } + } + // log.debug "echoDevices: ${echoDevices}" + receiveEventData([echoDevices: echoDevices, musicProviders: getMusicProviders(), execDt: data?.execDt], "Groovy") + } catch (ex) { + log.error "echoDevicesResponse Exception", ex + } +} + +def receiveEventData(Map evtData, String src) { + try { + if(checkIfCodeUpdated()) { + log.warn "Possible Code Version Change Detected... Device Updates will occur on next cycle." + return + } + // log.debug "musicProviders: ${evtData?.musicProviders}" + logger("trace", "evtData(Keys): ${evtData?.keySet()}", true) + if (evtData?.keySet()?.size()) { + List ignoreTheseDevs = settings?.echoDeviceFilter ?: [] + Boolean onHeroku = (state?.onHeroku == true && state?.isLocal == true) + + //Check for minimum versions before processing + Boolean updRequired = false + List updRequiredItems = [] + ["server":"Echo Speaks Server", "echoDevice":"Echo Speaks Device"]?.each { k,v-> + Map codeVers = state?.codeVersions + if(codeVers && codeVers[k as String] && (versionStr2Int(codeVers[k as String]) < minVersions()[k as String])) { + updRequired = true + updRequiredItems?.push("$v") + } + } + + if (evtData?.echoDevices?.size()) { + def execTime = evtData?.execDt ? (now()-evtData?.execDt) : 0 + Map echoDeviceMap = [:] + Map allEchoDevices = [:] + Map skippedDevices = [:] + List curDevFamily = [] + Integer cnt = 0 + evtData?.echoDevices?.each { echoKey, echoValue-> + logger("debug", "echoDevice | $echoKey | ${echoValue}", true) + logger("debug", "echoDevice | ${echoValue?.accountName}", false) + allEchoDevices[echoKey] = [name: echoValue?.accountName] + // log.debug "name: ${echoValue?.accountName}" + Map familyAllowed = isFamilyAllowed(echoValue?.deviceFamily as String) + Map deviceStyleData = getDeviceStyle(echoValue?.deviceFamily as String, echoValue?.deviceType as String) + // log.debug "deviceStyle: ${deviceStyleData}" + Boolean isBlocked = (deviceStyleData?.blocked || familyAllowed?.reason == "Family Blocked") + Boolean isInIgnoreInput = (echoValue?.serialNumber in settings?.echoDeviceFilter) + Boolean allowTTS = (deviceStyleData?.allowTTS == true) + Boolean isMediaPlayer = (echoValue?.capabilities?.contains("AUDIO_PLAYER") || echoValue?.capabilities?.contains("AMAZON_MUSIC") || echoValue?.capabilities?.contains("TUNE_IN") || echoValue?.capabilities?.contains("PANDORA") || echoValue?.capabilities?.contains("I_HEART_RADIO") || echoValue?.capabilities?.contains("SPOTIFY")) + Boolean volumeSupport = (echoValue?.capabilities.contains("VOLUME_SETTING")) + Boolean unsupportedDevice = ((familyAllowed?.ok == false && familyAllowed?.reason == "Unknown Reason") || isBlocked == true) + Boolean bypassBlock = (settings?.bypassDeviceBlocks == true && !isInIgnoreInput) + + if(!bypassBlock && (familyAllowed?.ok == false || isBlocked == true || (!allowTTS && !isMediaPlayer) || isInIgnoreInput)) { + logger("debug", "familyAllowed(${echoValue?.deviceFamily}): ${familyAllowed?.ok} | Reason: ${familyAllowed?.reason} | isBlocked: ${isBlocked} | deviceType: ${echoValue?.deviceType} | tts: ${allowTTS} | volume: ${volumeSupport} | mediaPlayer: ${isMediaPlayer}") + if(!skippedDevices?.containsKey(echoValue?.serialNumber as String)) { + List reasons = [] + if(deviceStyleData?.blocked) { + reasons?.push("Device Blocked by App Config") + } else if(familyAllowed?.reason == "Family Blocked") { + reasons?.push("Family Blocked by App Config") + } else if (!familyAllowed?.ok) { + reasons?.push(familyAllowed?.reason) + } else if(isInIgnoreInput) { + reasons?.push("In Ignore Device Input") + logger("warn", "skipping ${echoValue?.accountName} because it is in the do not use list...") + } else { + if(!allowTTS) { reasons?.push("No TTS") } + if(!isMediaPlayer) { reasons?.push("No Media Controls") } + } + skippedDevices[echoValue?.serialNumber as String] = [ + name: echoValue?.accountName, desc: deviceStyleData?.name, image: deviceStyleData?.image, family: echoValue?.deviceFamily, + type: echoValue?.deviceType, tts: allowTTS, volume: volumeSupport, mediaPlayer: isMediaPlayer, reason: reasons?.join(", "), + online: echoValue?.online + ] + } + return + } + + echoValue["unsupported"] = (unsupportedDevice == true) + echoValue["authValid"] = (state?.authValid == true) + echoValue["amazonDomain"] = (settings?.amazonDomain ?: "amazon.com") + echoValue["regionLocale"] = (settings?.regionLocale ?: "en-US") + echoValue["cookie"] = [cookie: getCookieVal(), csrf: getCsrfVal()] + echoValue["deviceAccountId"] = echoValue?.deviceAccountId as String ?: null + echoValue["deviceStyle"] = deviceStyleData + // log.debug "deviceStyle: ${echoValue?.deviceStyle}" + + Map permissions = [:] + permissions["TTS"] = allowTTS + permissions["volumeControl"] = volumeSupport + permissions["mediaPlayer"] = isMediaPlayer + permissions["amazonMusic"] = (echoValue?.capabilities.contains("AMAZON_MUSIC")) + permissions["tuneInRadio"] = (echoValue?.capabilities.contains("TUNE_IN")) + permissions["iHeartRadio"] = (echoValue?.capabilities.contains("I_HEART_RADIO")) + permissions["pandoraRadio"] = (echoValue?.capabilities.contains("PANDORA")) + permissions["appleMusic"] = (evtData?.musicProviders.containsKey("APPLE_MUSIC")) + permissions["siriusXm"] = (evtData?.musicProviders?.containsKey("SIRIUSXM")) + // permissions["tidal"] = true + permissions["spotify"] = true //(echoValue?.capabilities.contains("SPOTIFY")) // Temporarily removed restriction check + permissions["isMultiroomDevice"] = (echoValue?.clusterMembers && echoValue?.clusterMembers?.size() > 0) ?: false; + permissions["isMultiroomMember"] = (echoValue?.parentClusters && echoValue?.parentClusters?.size() > 0) ?: false; + permissions["alarms"] = (echoValue?.capabilities.contains("TIMERS_AND_ALARMS")) + permissions["reminders"] = (echoValue?.capabilities.contains("REMINDERS")) + permissions["doNotDisturb"] = (echoValue?.capabilities?.contains("SLEEP")) + permissions["wakeWord"] = (echoValue?.capabilities?.contains("FAR_FIELD_WAKE_WORD")) + permissions["flashBriefing"] = (echoValue?.capabilities?.contains("FLASH_BRIEFING")) + permissions["microphone"] = (echoValue?.capabilities?.contains("MICROPHONE")) + permissions["followUpMode"] = (echoValue?.capabilities?.contains("GOLDFISH")) + permissions["connectedHome"] = (echoValue?.capabilities?.contains("SUPPORTS_CONNECTED_HOME")) + permissions["bluetoothControl"] = (echoValue?.capabilities.contains("PAIR_BT_SOURCE") || echoValue?.capabilities.contains("PAIR_BT_SINK")) + permissions["alexaGuardSupport"] = (echoValue?.capabilities?.contains("TUPLE")) + echoValue["musicProviders"] = evtData?.musicProviders + echoValue["permissionMap"] = permissions + echoValue["hasClusterMembers"] = (echoValue?.clusterMembers && echoValue?.clusterMembers?.size() > 0) ?: false + // log.warn "Device Permisions | Name: ${echoValue?.accountName} | $permissions" + + echoDeviceMap[echoKey] = [ + name: echoValue?.accountName, online: echoValue?.online, family: echoValue?.deviceFamily, serialNumber: echoKey, + style: echoValue?.deviceStyle, type: echoValue?.deviceType, mediaPlayer: isMediaPlayer, + ttsSupport: allowTTS, volumeSupport: volumeSupport, clusterMembers: echoValue?.clusterMembers, + musicProviders: evtData?.musicProviders?.collect{ it?.value }?.sort()?.join(", "), supported: (unsupportedDevice != true) + ] + + String dni = [app?.id, "echoSpeaks", echoKey].join("|") + def childDevice = getChildDevice(dni) + String devLabel = "Echo - ${echoValue?.accountName}${echoValue?.deviceFamily == "WHA" ? " (WHA)" : ""}" + String childHandlerName = "Echo Speaks Device" + if (!childDevice) { + // log.debug "childDevice not found | autoCreateDevices: ${settings?.autoCreateDevices}" + if(settings?.autoCreateDevices != false) { + try{ + log.debug "Creating NEW Echo Speaks Device!!! | Device Label: ($devLabel)${(settings?.bypassDeviceBlocks && unsupportedDevice) ? " | (UNSUPPORTED DEVICE)" : "" }" + childDevice = addChildDevice("tonesto7", childHandlerName, dni, null, [name: childHandlerName, label: devLabel, completedSetup: true]) + } catch(ex) { + log.error "AddDevice Error! ", ex + } + } + } else { + //Check and see if name needs a refresh + if (settings?.autoRenameDevices != false && (childDevice?.name != childHandlerName || childDevice?.label != devLabel)) { + log.debug ("Amazon Device Name Change Detected... Updating Device Name to (${devLabel}) | Old Name: (${childDevice?.label})") + childDevice?.name = childHandlerName as String + childDevice?.setLabel(devLabel as String) + } + // logger("info", "Sending Device Data Update to ${devLabel} | Last Updated (${getLastDevicePollSec()}sec ago)") + childDevice?.updateDeviceStatus(echoValue) + // childDevice?.updateServiceInfo(getServiceHostInfo(), onHeroku) + updCodeVerMap("echoDevice", childDevice?.devVersion()) // Update device versions in codeVersions state Map + } + curDevFamily.push(echoValue?.deviceStyle?.name) + } + log.debug "Device Data Received and Updated for (${echoDeviceMap?.size()}) Alexa Devices | Took: (${execTime}ms) | Last Refreshed: (${(getLastDevicePollSec()/60).toFloat()?.round(1)} minutes)" + state?.lastDevDataUpd = getDtNow() + state?.echoDeviceMap = echoDeviceMap + state?.allEchoDevices = allEchoDevices + state?.skippedDevices = skippedDevices + state?.deviceStyleCnts = curDevFamily?.countBy { it } + } else { + log.warn "No Echo Device Data Sent... This may be the first transmission from the service after it started up!" + } + if(updRequired) { + log.warn "CODE UPDATES REQUIRED: Echo Speaks Integration may not function until the following items are ALL Updated ${updRequiredItems}..." + appUpdateNotify() + } + if(state?.installData?.sentMetrics != true) { runIn(900, "sendInstallData", [overwrite: false]) } + } + } catch(ex) { + log.error "receiveEventData Error:", ex + incrementCntByKey("appErrorCnt") + } +} + +public getDeviceStyle(String family, String type) { + if(!state?.appData || !state?.appData?.deviceSupport) { checkVersionData(true) } + Map typeData = state?.appData?.deviceSupport ?: [:] + if(typeData[type]) { + return typeData[type] + } else { return [name: "Echo Unknown $type", image: "unknown", allowTTS: false] } +} + +public Map getDeviceFamilyMap() { + if(!state?.appData || !state?.appData?.deviceFamilies) { checkVersionData(true) } + return state?.appData?.deviceFamilies ?: [:] +} + +public Map getDeviceTypesMap() { + if(!state?.appData || !state?.appData?.deviceTypes) { checkVersionData(true) } + return state?.appData?.deviceTypes ?: [:] +} + +private getDevicesFromSerialList(serialNumberList) { + //log.trace "getDevicesFromSerialList called with: ${ serialNumberList}" + if (serialNumberList == null) { + log.debug "SerialNumberList is null" + return; + } + def devicesList = serialNumberList.findResults { echoKey -> + String dni = [app?.id, "echoSpeaks", echoKey].join("|") + getChildDevice(dni) + } + //log.debug "Device list: ${ devicesList}" + return devicesList +} + +// This is called by the device handler to send playback data to cluster members +public sendPlaybackStateToClusterMembers(whaKey, response, data) { + //log.trace "sendPlaybackStateToClusterMembers: key: ${ whaKey}" + def echoDeviceMap = state?.echoDeviceMap + def whaMap = echoDeviceMap[whaKey] + def clusterMembers = whaMap?.clusterMembers + + if (clusterMembers) { + def clusterMemberDevices = getDevicesFromSerialList(clusterMembers) + clusterMemberDevices?.each { it?.getPlaybackStateHandler(response, data, true) } + } else { + // The lookup will fail during initial refresh because echoDeviceMap isn't available yet + //log.debug "sendPlaybackStateToClusterMembers: no data found for ${whaKey} (first refresh?)" + } +} + +public getServiceHostInfo() { + return (state?.isLocal && state?.serverHost) ? state?.serverHost : null +} + +private removeDevices(all=false) { + try { + settingUpdate("cleanUpDevices", "false", "bool") + List devList = getDeviceList(true)?.collect { String dni = [app?.id, "echoSpeaks", it?.key].join("|") } + def items = app.getChildDevices()?.findResults { (all || (!all && !devList?.contains(it?.deviceNetworkId as String))) ? it?.deviceNetworkId as String : null } + log.warn "removeDevices(${all ? "all" : ""}) | In Use: (${all ? 0 : devList?.size()}) | Removing: (${items?.size()})" + if(items?.size() > 0) { + Boolean isST = isST() + items?.each { isST ? deleteChildDevice(it as String, true) : deleteChildDevice(it as String) } + } + } catch (ex) { log.error "Device Removal Failed: ", ex } +} + +Map sequenceBuilder(cmd, val) { + def seqJson = null + if (cmd instanceof Map) { + seqJson = cmd?.sequence ?: cmd + } else { seqJson = ["@type": "com.amazon.alexa.behaviors.model.Sequence", "startNode": createSequenceNode(cmd, val)] } + Map seqObj = [behaviorId: (seqJson?.sequenceId ? cmd?.automationId : "PREVIEW"), sequenceJson: new JsonOutput().toJson(seqJson) as String, status: "ENABLED"] + return seqObj +} + +Map multiSequenceBuilder(commands, parallel=false) { + String seqType = parallel ? "ParallelNode" : "SerialNode" + List nodeList = [] + commands?.each { cmdItem-> nodeList?.push(createSequenceNode(cmdItem?.command, cmdItem?.value, [serialNumber: cmdItem?.serial, deviceType:cmdItem?.type])) } + Map seqJson = [ "sequence": [ "@type": "com.amazon.alexa.behaviors.model.Sequence", "startNode": [ "@type": "com.amazon.alexa.behaviors.model.${seqType}", "name": null, "nodesToExecute": nodeList ] ] ] + Map seqObj = sequenceBuilder(seqJson, null) + return seqObj +} + +Map createSequenceNode(command, value, Map deviceData = [:]) { + try { + Map seqNode = [ + "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", + "operationPayload": [ + "deviceType": deviceData?.deviceType, + "deviceSerialNumber": deviceData?.serialNumber, + "locale": (settings?.regionLocale ?: "en-US"), + "customerId": state?.deviceOwnerCustomerId + ] + ] + switch (command) { + case "volume": + seqNode?.type = "Alexa.DeviceControls.Volume" + seqNode?.operationPayload?.value = value; + break + case "speak": + seqNode?.type = "Alexa.Speak" + seqNode?.operationPayload?.textToSpeak = value as String + break + case "announcementTest": + log.debug "test" + seqNode?.type = "AlexaAnnouncement" + seqNode?.operationPayload?.remove('deviceType') + seqNode?.operationPayload?.remove('deviceSerialNumber') + seqNode?.operationPayload?.remove('locale') + seqNode?.operationPayload?.expireAfter = "PT5S" + List valObj = (value?.toString()?.contains("::")) ? value?.split("::") : ["Echo Speaks", value as String] + seqNode?.operationPayload?.content = [[ + locale: (state?.regionLocale ?: "en-US"), + display: [ title: valObj[0], body: valObj[1] as String ], + speak: [ type: "text", value: valObj[1] as String ], + ]] + List announceDevs = [] + if(settings?.announceDevices) { + Map eDevs = state?.echoDeviceMap + settings?.announceDevices?.each { dev-> + announceDevs?.push([deviceTypeId: eDevs[dev]?.type, deviceSerialNumber: dev]) + } + } + seqNode?.operationPayload?.target = [ customerId : state?.deviceOwnerCustomerId, devices: announceDevs ] + break + default: + return + } + // log.debug "seqNode: $seqNode" + return seqNode + } catch (ex) { + log.error "createSequenceNode Exception: $ex" + return [:] + } +} + +private execAsyncCmd(String method, String callbackHandler, Map params, Map otherData = null) { + if(method && callbackHandler && params) { + String m = method?.toString()?.toLowerCase() + if(isST()) { + include 'asynchttp_v1' + asynchttp_v1."${m}"(callbackHandler, params, otherData) + } else { "asynchttp${m?.capitalize()}"("${callbackHandler}", params, otherData) } + } else { log.error "execAsyncCmd Error | Missing a required parameter" } +} + +private sendAmazonCommand(String method, Map params, Map otherData) { + execAsyncCmd(method, "amazonCommandResp", params, otherData) +} + +def amazonCommandResp(response, data) { + Boolean hasErr = (response?.hasError() == true) + String errMsg = (hasErr && response?.getErrorMessage()) ? response?.getErrorMessage() : null + if(!respIsValid(response?.status, hasErr, errMsg, "amazonCommandResp", true)) {return} + try {} catch (ex) { } + def resp = response?.data ? response?.getJson() : null + // logger("warn", "amazonCommandResp | Status: (${response?.status}) | Response: ${resp} | PassThru-Data: ${data}") + if(response?.status == 200) { + log.trace "amazonCommandResp | Status: (${response?.status})${resp != null ? " | Response: ${resp}" : ""} | ${data?.cmdDesc} was Successfully Sent!!!" + } +} + +private sendSequenceCommand(type, command, value) { + // logger("trace", "sendSequenceCommand($type) | command: $command | value: $value") + Map seqObj = sequenceBuilder(command, value) + sendAmazonCommand("post", [ + uri: getAmazonUrl(), + path: "/api/behaviors/preview", + headers: [cookie: getCookieVal(), csrf: getCsrfVal()], + requestContentType: "application/json", + contentType: "application/json", + body: seqObj + ], [cmdDesc: "SequenceCommand (${type})"]) +} + +private sendMultiSequenceCommand(commands, String srcDesc, Boolean parallel=false) { + String seqType = parallel ? "ParallelNode" : "SerialNode" + List nodeList = [] + commands?.each { cmdItem-> nodeList?.push(createSequenceNode(cmdItem?.command, cmdItem?.value, [serialNumber: cmdItem?.serial, deviceType: cmdItem?.type])) } + Map seqJson = [ "sequence": [ "@type": "com.amazon.alexa.behaviors.model.Sequence", "startNode": [ "@type": "com.amazon.alexa.behaviors.model.${seqType}", "name": null, "nodesToExecute": nodeList ] ] ] + sendSequenceCommand("${srcDesc} | MultiSequence: ${parallel ? "Parallel" : "Sequential"}", seqJson, null) +} + +/****************************************** +| Notification Functions +*******************************************/ +String getAmazonDomain() { return settings?.amazonDomain as String } +String getAmazonUrl() {return "https://alexa.${settings?.amazonDomain as String}"} + +Map notifValEnum(allowCust = true) { + Map items = [ + 300:"5 Minutes", 600:"10 Minutes", 900:"15 Minutes", 1200:"20 Minutes", 1500:"25 Minutes", + 1800:"30 Minutes", 2700:"45 Minutes", 3600:"1 Hour", 7200:"2 Hours", 14400:"4 Hours", 21600:"6 Hours", 43200:"12 Hours", 86400:"24 Hours" + ] + if(allowCust) { items[100000] = "Custom" } + return items +} + +private healthCheck() { + // logger("trace", "healthCheck") + checkVersionData() + if(checkIfCodeUpdated()) { + log.warn "Code Version Change Detected... Health Check will occur on next cycle." + return + } + validateCookie() + if(getLastCookieRefreshSec() > 432000) { runCookieRefresh() } + if(!getOk2Notify()) { return } + missPollNotify((settings?.sendMissedPollMsg == true), (state?.misPollNotifyMsgWaitVal ?: 3600)) + appUpdateNotify() + if(state?.isInstalled && getLastMetricUpdSec() > (3600*24)) { runIn(30, "sendInstallData", [overwrite: true]) } +} + +private missPollNotify(Boolean on, Integer wait) { + logger("debug", "missPollNotify() | on: ($on) | wait: ($wait) | getLastDevicePollSec: (${getLastDevicePollSec()}) | misPollNotifyWaitVal: (${state?.misPollNotifyWaitVal}) | getLastMisPollMsgSec: (${getLastMisPollMsgSec()})") + if(!on || !wait || !(getLastDevicePollSec() > (state?.misPollNotifyWaitVal ?: 2700))) { return } + if(!(getLastMisPollMsgSec() > wait.toInteger())) { + state?.missPollRepair = false + return + } else { + if(!state?.missPollRepair) { + state?.missPollRepair = true + initialize() + return + } + state?.missPollRepair = true + String msg = "" + if(state?.authValid) { + msg = "\nThe Echo Speaks app has NOT received any device data from Amazon in the last (${getLastDevicePollSec()}) seconds.\nThere maybe an issue with the scheduling. Please open the app and press Done/Save." + } else { msg = "\nThe Amazon login info has expired!\nPlease open the heroku amazon authentication page and login again to restore normal operation." } + log.warn "${msg.toString().replaceAll("\n", " ")}" + if(sendMsg("${app.name} ${state?.authValid ? "Data Refresh Issue" : "Amazon Login Issue"}", msg)) { + state?.lastMisPollMsgDt = getDtNow() + } + if(state?.authValid) { + (isST() ? app?.getChildDevices(true) : getChildDevices())?.each { cd-> cd?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: true, isStateChange: true) } + } + } +} + +private appUpdateNotify() { + Boolean on = (settings?.sendAppUpdateMsg != false) + Boolean appUpd = isAppUpdateAvail() + Boolean actUpd = false//isActionAppUpdateAvail() + Boolean grpUpd = false//isGroupAppUpdateAvail() + Boolean echoDevUpd = isEchoDevUpdateAvail() + Boolean servUpd = isServerUpdateAvail() + logger("debug", "appUpdateNotify() | on: (${on}) | appUpd: (${appUpd}) | actUpd: (${appUpd}) | grpUpd: (${grpUpd}) | echoDevUpd: (${echoDevUpd}) | servUpd: (${servUpd}) | getLastUpdMsgSec: ${getLastUpdMsgSec()} | state?.updNotifyWaitVal: ${state?.updNotifyWaitVal}") + if(getLastUpdMsgSec() > state?.updNotifyWaitVal.toInteger()) { + if(on && (appUpd || actUpd || grpUpd || echoDevUpd || servUpd)) { + state?.updateAvailable = true + def str = "" + str += !appUpd ? "" : "\nEcho Speaks App: v${state?.appData?.versions?.mainApp?.ver?.toString()}" + str += !actUpd ? "" : "\nEcho Speaks - Actions: v${state?.appData?.versions?.actionApp?.ver?.toString()}" + str += !grpUpd ? "" : "\nEcho Speaks - Groups: v${state?.appData?.versions?.groupApp?.ver?.toString()}" + str += !echoDevUpd ? "" : "\nEcho Speaks Device: v${state?.appData?.versions?.echoDevice?.ver?.toString()}" + str += !servUpd ? "" : "\n${state?.onHeroku ? "Heroku Service" : "Node Service"}: v${state?.appData?.versions?.server?.ver?.toString()}" + sendMsg("Info", "Echo Speaks Update(s) are Available:${str}...\n\nPlease visit the IDE to Update your code...") + state?.lastUpdMsgDt = getDtNow() + return + } + state?.updateAvailable = false + } +} + +Boolean pushStatus() { return (settings?.smsNumbers?.toString()?.length()>=10 || settings?.usePush || settings?.pushoverEnabled) ? ((settings?.usePush || (settings?.pushoverEnabled && settings?.pushoverDevices)) ? "Push Enabled" : "Enabled") : null } +Integer getLastMsgSec() { return !state?.lastMsgDt ? 100000 : GetTimeDiffSeconds(state?.lastMsgDt, "getLastMsgSec").toInteger() } +Integer getLastUpdMsgSec() { return !state?.lastUpdMsgDt ? 100000 : GetTimeDiffSeconds(state?.lastUpdMsgDt, "getLastUpdMsgSec").toInteger() } +Integer getLastMisPollMsgSec() { return !state?.lastMisPollMsgDt ? 100000 : GetTimeDiffSeconds(state?.lastMisPollMsgDt, "getLastMisPollMsgSec").toInteger() } +Integer getLastVerUpdSec() { return !state?.lastVerUpdDt ? 100000 : GetTimeDiffSeconds(state?.lastVerUpdDt, "getLastVerUpdSec").toInteger() } +Integer getLastDevicePollSec() { return !state?.lastDevDataUpd ? 840 : GetTimeDiffSeconds(state?.lastDevDataUpd, "getLastDevicePollSec").toInteger() } +Integer getLastCookieChkSec() { return !state?.lastCookieChkDt ? 3600 : GetTimeDiffSeconds(state?.lastCookieChkDt, "getLastCookieChkSec").toInteger() } +Integer getLastChildInitRefreshSec() { return !state?.lastChildInitRefreshDt ? 3600 : GetTimeDiffSeconds(state?.lastChildInitRefreshDt, "getLastChildInitRefreshSec").toInteger() } +Boolean getOk2Notify() { + Boolean smsOk = (settings?.smsNumbers?.toString()?.length()>=10) + Boolean pushOk = settings?.usePush + Boolean pushOver = (settings?.pushoverEnabled && settings?.pushoverDevices) + Boolean daysOk = quietDaysOk(settings?.quietDays) + Boolean timeOk = quietTimeOk() + Boolean modesOk = quietModesOk(settings?.quietModes) + logger("debug", "getOk2Notify() | smsOk: $smsOk | pushOk: $pushOk | pushOver: $pushOver || daysOk: $daysOk | timeOk: $timeOk | modesOk: $modesOk") + if(!(smsOk || pushOk || pushOver)) { return false } + if(!(daysOk && modesOk && timeOk)) { return false } + return true +} +Boolean quietModesOk(List modes) { return (modes && location?.mode?.toString() in modes) ? false : true } +Boolean quietTimeOk() { + def strtTime = null + def stopTime = null + def now = new Date() + def sun = getSunriseAndSunset() // current based on geofence, previously was: def sun = getSunriseAndSunset(zipCode: zipCode) + if(settings?.qStartTime && settings?.qStopTime) { + if(settings?.qStartInput == "sunset") { strtTime = sun?.sunset } + else if(settings?.qStartInput == "sunrise") { strtTime = sun?.sunrise } + else if(settings?.qStartInput == "A specific time" && settings?.qStartTime) { strtTime = settings?.qStartTime } + + if(settings?.qStopInput == "sunset") { stopTime = sun?.sunset } + else if(settings?.qStopInput == "sunrise") { stopTime = sun?.sunrise } + else if(settings?.qStopInput == "A specific time" && settings?.qStopTime) { stopTime = settings?.qStopTime } + } else { return true } + if(strtTime && stopTime) { + return timeOfDayIsBetween(strtTime, stopTime, new Date(), location.timeZone) ? false : true + } else { return true } +} + +Boolean quietDaysOk(days) { + if(days) { + def dayFmt = new SimpleDateFormat("EEEE") + if(location?.timeZone) { dayFmt?.setTimeZone(location?.timeZone) } + return days?.contains(dayFmt?.format(new Date())) ? false : true + } + return true +} + +// Sends the notifications based on app settings +public sendMsg(String msgTitle, String msg, Boolean showEvt=true, Map pushoverMap=null, sms=null, push=null) { + logger("trace", "sendMsg() | msgTitle: ${msgTitle}, msg: ${msg}, showEvt: ${showEvt}") + String sentstr = "Push" + Boolean sent = false + try { + String newMsg = "${msgTitle}: ${msg}" + String flatMsg = newMsg.toString().replaceAll("\n", " ") + if(!getOk2Notify()) { + log.info "sendMsg: Message Skipped During Quiet Time ($flatMsg)" + if(showEvt) { sendNotificationEvent(newMsg) } + } else { + if(push || settings?.usePush) { + sentstr = "Push Message" + if(showEvt) { + sendPush(newMsg) // sends push and notification feed + } else { + sendPushMessage(newMsg) // sends push + } + sent = true + } + if(settings?.pushoverEnabled && settings?.pushoverDevices) { + sentstr = "Pushover Message" + Map msgObj = [:] + msgObj = pushoverMap ?: [title: msgTitle, message: msg, priority: (settings?.pushoverPriority?:0)] + if(settings?.pushoverSound) { msgObj?.sound = settings?.pushoverSound } + buildPushMessage(settings?.pushoverDevices, msgObj, true) + sent = true + } + String smsPhones = sms ? sms.toString() : (settings?.smsNumbers?.toString() ?: null) + if(smsPhones) { + List phones = smsPhones?.toString()?.split("\\,") + for (phone in phones) { + String t0 = newMsg.take(140) + if(showEvt) { + sendSms(phone?.trim(), t0) // send SMS and notification feed + } else { + sendSmsMessage(phone?.trim(), t0) // send SMS + } + } + sentstr = "Text Message to Phone [${phones}]" + sent = true + } + if(sent) { + state?.lastMsg = flatMsg + state?.lastMsgDt = getDtNow() + logger("debug", "sendMsg: Sent ${sentstr} (${flatMsg})") + } + } + } catch (ex) { + incrementCntByKey("appErrorCnt") + log.error "sendMsg $sentstr Exception:", ex + } + return sent +} + +String getAppImg(String imgName, frc=false) { return (frc || state?.hubPlatform == "SmartThings") ? "https://raw.githubusercontent.com/tonesto7/echo-speaks/${isBeta() ? "beta" : "master"}/resources/icons/${imgName}.png" : "" } +String getPublicImg(String imgName) { return isST() ? "https://raw.githubusercontent.com/tonesto7/SmartThings-tonesto7-public/master/resources/icons/${imgName}.png" : "" } +String sTS(String t, String i = null) { return isST() ? t : """

${i ? """ """ : ""} ${t?.replaceAll("\\n", " ")}

""" } +String inTS(String t, String i = null) { return isST() ? t : """${i ? """ """ : ""} ${t?.replaceAll("\\n", " ")}""" } +String pTS(String t, String i = null) { return isST() ? t : """${i ? """ """ : ""} ${t?.replaceAll("\\n", " ")}""" } + +String actChildName(){ return "Echo Speaks - Actions" } +String grpChildName(){ return "Echo Speaks - Groups" } +String documentationLink() { return "https://tonesto7.github.io/echo-speaks-docs" } +String textDonateLink() { return "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=HWBN4LB9NMHZ4" } +String getAppEndpointUrl(subPath) { return isST() ? "${apiServerUrl("/api/smartapps/installations/${app.id}${subPath ? "/${subPath}" : ""}?access_token=${state.accessToken}")}" : "${getApiServerUrl()}/${getHubUID()}/apps/${app?.id}${subPath ? "/${subPath}" : ""}?access_token=${state?.accessToken}" } +String getLocalEndpointUrl(subPath) { return "${getLocalApiServerUrl()}/apps/${app?.id}${subPath ? "/${subPath}" : ""}?access_token=${state?.accessToken}" } +//PushOver-Manager Input Generation Functions +private getPushoverSounds(){return (Map) state?.pushoverManager?.sounds?:[:]} +private getPushoverDevices(){List opts=[];Map pmd=state?.pushoverManager?:[:];pmd?.apps?.each{k,v->if(v&&v?.devices&&v?.appId){Map dm=[:];v?.devices?.sort{}?.each{i->dm["${i}_${v?.appId}"]=i};addInputGrp(opts,v?.appName,dm);}};return opts;} +private inputOptGrp(List groups,String title){def group=[values:[],order:groups?.size()];group?.title=title?:"";groups<r[v]=v;return r;}} +private addInputGrp(List groups,String title,values){if(values instanceof List){values=listToMap(values)};values.inject(inputOptGrp(groups,title)){r,k,v->return addInputValues(r,k,v)};return groups;} +private addInputGrp(values){addInputGrp([],null,values)} +//PushOver-Manager Location Event Subscription Events, Polling, and Handlers +public pushover_init(){subscribe(location,"pushoverManager",pushover_handler);pushover_poll()} +public pushover_cleanup(){state?.remove("pushoverManager");unsubscribe("pushoverManager");} +public pushover_poll(){sendLocationEvent(name:"pushoverManagerCmd",value:"poll",data:[empty:true],isStateChange:true,descriptionText:"Sending Poll Event to Pushover-Manager")} +public pushover_msg(List devs,Map data){if(devs&&data){sendLocationEvent(name:"pushoverManagerMsg",value:"sendMsg",data:data,isStateChange:true,descriptionText:"Sending Message to Pushover Devices: ${devs}");}} +public pushover_handler(evt){Map pmd=state?.pushoverManager?:[:];switch(evt?.value){case"refresh":def ed = evt?.jsonData;String id = ed?.appId;Map pA = pmd?.apps?.size() ? pmd?.apps : [:];if(id){pA[id]=pA?."${id}"instanceof Map?pA[id]:[:];pA[id]?.devices=ed?.devices?:[];pA[id]?.appName=ed?.appName;pA[id]?.appId=id;pmd?.apps = pA;};pmd?.sounds=ed?.sounds;break;case "reset":pmd=[:];break;};state?.pushoverManager=pmd;} +//Builds Map Message object to send to Pushover Manager +private buildPushMessage(List devices,Map msgData,timeStamp=false){if(!devices||!msgData){return};Map data=[:];data?.appId=app?.getId();data.devices=devices;data?.msgData=msgData;if(timeStamp){data?.msgData?.timeStamp=new Date().getTime()};pushover_msg(devices,data);} + +/****************************************** +| Changelog Logic +******************************************/ +String changeLogData() { return getWebData([uri: "https://raw.githubusercontent.com/tonesto7/echo-speaks/${isBeta() ? "beta" : "master"}/resources/changelog.txt", contentType: "text/plain; charset=UTF-8"], "changelog") } +Boolean showChgLogOk() { return (state?.isInstalled && state?.installData?.shownChgLog != true) } +def changeLogPage() { + def execTime = now() + return dynamicPage(name: "changeLogPage", title: "", nextPage: "mainPage", install: false) { + section() { + paragraph title: "What's New in this Release...", "", state: "complete", image: getAppImg("whats_new") + paragraph changeLogData() + } + Map iData = atomicState?.installData ?: [:] + iData["shownChgLog"] = true + atomicState?.installData = iData + } +} + +/****************************************** +| METRIC Logic +******************************************/ +String getFbMetricsUrl() { return state?.appData?.settings?.database?.metricsUrl ?: "https://echo-speaks-metrics.firebaseio.com" } +Integer getLastMetricUpdSec() { return !state?.lastMetricUpdDt ? 100000 : GetTimeDiffSeconds(state?.lastMetricUpdDt, "getLastMetricUpdSec").toInteger() } +Boolean metricsOk() { (settings?.optOutMetrics != true && state?.appData?.settings?.sendMetrics != false) } +private generateGuid() { if(!state?.appGuid) { state?.appGuid = UUID?.randomUUID().toString() } } +private sendInstallData() { settingUpdate("sendMetricsNow", "false", "bool"); if(metricsOk()) { sendFirebaseData(getFbMetricsUrl(), "/clients/${state?.appGuid}.json", createMetricsDataJson(), "put", "heartbeat"); } } +private removeInstallData() { return removeFirebaseData("/clients/${state?.appGuid}.json") } +private sendFirebaseData(url, path, data, cmdType=null, type=null) { + logger("trace", "sendFirebaseData(${path}, ${data}, $cmdType, $type", true) + return queueFirebaseData(url, path, data, cmdType, type) +} +def queueFirebaseData(url, path, data, cmdType=null, type=null) { + logger("trace", "queueFirebaseData(${path}, ${data}, $cmdType, $type", true) + Boolean result = false + def json = new groovy.json.JsonOutput().prettyPrint(data) + Map params = [uri: url as String, path: path as String, requestContentType: "application/json", contentType: "application/json", body: json.toString()] + String typeDesc = type ? type as String : "Data" + try { + if(!cmdType || cmdType == "put") { + execAsyncCmd(cmdType, "processFirebaseResponse", params, [type: typeDesc]) + result = true + } else if (cmdType == "post") { + execAsyncCmd(cmdType, "processFirebaseResponse", params, [type: typeDesc]) + result = true + } else { log.debug "queueFirebaseData UNKNOWN cmdType: ${cmdType}" } + + } catch(ex) { log.error "queueFirebaseData (type: $typeDesc) Exception:", ex } + return result +} + +def removeFirebaseData(pathVal) { + logger("trace", "removeFirebaseData(${pathVal})", true) + Boolean result = true + try { + httpDelete(uri: getFbMetricsUrl(), path: pathVal as String) { resp -> + logger("debug", "Remove Firebase | resp: ${resp?.status}") + } + } catch (ex) { + if(ex instanceof groovyx.net.http.ResponseParseException) { + logger("error", "removeFirebaseData: Response: ${ex?.message}") + } else { + logger("error", "removeFirebaseData: Response: ${ex?.message}") + result = false + } + } + return result +} + +def processFirebaseResponse(resp, data) { + logger("trace", "processFirebaseResponse(${data?.type})", true) + Boolean result = false + String typeDesc = data?.type as String + try { + if(resp?.status == 200) { + logger("info", "processFirebaseResponse: ${typeDesc} Data Sent SUCCESSFULLY") + if(typeDesc?.toString() == "heartbeat") { state?.lastMetricUpdDt = getDtNow() } + def iData = atomicState?.installData ?: [:] + iData["sentMetrics"] = true + atomicState?.installData = iData + result = true + } else if(resp?.status == 400) { + log.error "processFirebaseResponse: 'Bad Request': ${resp?.status}" + } else { log.warn "processFirebaseResponse: 'Unexpected' Response: ${resp?.status}" } + if (isST() && resp?.hasError()) { log.error "processFirebaseResponse: errorData: ${resp?.errorData} | errorMessage: ${resp?.errorMessage}" } + } catch(ex) { + log.error "processFirebaseResponse (type: $typeDesc) Exception:", ex + } +} + +def renderMetricData() { + try { + def json = new groovy.json.JsonOutput().prettyPrint(createMetricsDataJson()) + render contentType: "application/json", data: json + } catch (ex) { log.error "renderMetricData Exception:", ex } +} + +private Map getSkippedDevsAnon() { + Map res = [:] + Map sDevs = state?.skippedDevices ?: [:] + sDevs?.each { k, v-> if(!res?.containsKey(v?.type)) { res[v?.type] = v } } + return res +} + +private createMetricsDataJson(rendAsMap=false) { + try { + generateGuid() + Map swVer = state?.codeVersions + Map deviceUsageMap = [:] + Map deviceErrorMap = [:] + (isST() ? app?.getChildDevices(true) : getChildDevices())?.each { d-> + Map obj = d?.getDeviceMetrics() + if(obj?.usage?.size()) { obj?.usage?.each { k,v-> deviceUsageMap[k as String] = (deviceUsageMap[k as String] ? deviceUsageMap[k as String] + v : v) } } + if(obj?.errors?.size()) { obj?.errors?.each { k,v-> deviceErrorMap[k as String] = (deviceErrorMap[k as String] ? deviceErrorMap[k as String] + v : v) } } + } + def dataObj = [ + guid: state?.appGuid, + datetime: getDtNow()?.toString(), + installDt: state?.installData?.dt, + updatedDt: state?.installData?.updatedDt, + timeZone: location?.timeZone?.ID?.toString(), + hubPlatform: getPlatform(), + authValid: (state?.authValid == true), + stateUsage: "${stateSizePerc()}%", + amazonDomain: settings?.amazonDomain, + serverPlatform: state?.onHeroku ? "Cloud" : "Local", + versions: [app: appVersion(), server: swVer?.server ?: "N/A", device: swVer?.echoDevice ?: "N/A"], + detections: [skippedDevices: getSkippedDevsAnon()], + counts: [ + deviceStyleCnts: state?.deviceStyleCnts ?: [:], + appHeartbeatCnt: state?.appHeartbeatCnt ?: 0, + getCookieCnt: state?.getCookieCnt ?: 0, + appErrorCnt: state?.appErrorCnt ?: 0, + deviceErrors: deviceErrorMap ?: [:], + deviceUsage: deviceUsageMap ?: [:] + ] + ] + def json = new groovy.json.JsonOutput().toJson(dataObj) + return json + } catch (ex) { + log.error "createMetricsDataJson: Exception:", ex + } +} + +private incrementCntByKey(String key) { + long evtCnt = state?."${key}" ?: 0 + // evtCnt = evtCnt?.toLong()+1 + evtCnt++ + logger("trace", "${key?.toString()?.capitalize()}: $evtCnt", true) + state?."${key}" = evtCnt?.toLong() +} + +/****************************************** +| APP/DEVICE Version Functions +*******************************************/ +Boolean isCodeUpdateAvailable(String newVer, String curVer, String type) { + Boolean result = false + def latestVer + if(newVer && curVer) { + List versions = [newVer, curVer] + if(newVer != curVer) { + latestVer = versions?.max { a, b -> + List verA = a?.tokenize('.') + List verB = b?.tokenize('.') + Integer commonIndices = Math.min(verA?.size(), verB?.size()) + for (int i = 0; i < commonIndices; ++i) { + //log.debug "comparing $numA and $numB" + if(verA[i]?.toInteger() != verB[i]?.toInteger()) { + return verA[i]?.toInteger() <=> verB[i]?.toInteger() + } + } + verA?.size() <=> verB?.size() + } + result = (latestVer == newVer) ? true : false + } + } + // logger("trace", "isCodeUpdateAvailable | type: $type | newVer: $newVer | curVer: $curVer | newestVersion: ${latestVer} | result: $result") + return result +} + +Boolean isAppUpdateAvail() { + if(state?.appData?.versions && state?.codeVersions?.mainApp && isCodeUpdateAvailable(state?.appData?.versions?.mainApp?.ver, state?.codeVersions?.mainApp, "main_app")) { return true } + return false +} + +Boolean isActionAppUpdateAvail() { + if(state?.appData?.versions && state?.codeVersions?.actionApp && isCodeUpdateAvailable(state?.appData?.versions?.actionApp?.ver, state?.codeVersions?.actionApp, "action_app")) { return true } + return false +} + +Boolean isGroupAppUpdateAvail() { + if(state?.appData?.versions && state?.codeVersions?.groupApp && isCodeUpdateAvailable(state?.appData?.versions?.groupApp?.ver, state?.codeVersions?.groupApp, "group_app")) { return true } + return false +} + +Boolean isEchoDevUpdateAvail() { + if(state?.appData?.versions && state?.codeVersions?.echoDevice && isCodeUpdateAvailable(state?.appData?.versions?.echoDevice?.ver, state?.codeVersions?.echoDevice, "dev")) { return true } + return false +} + +Boolean isServerUpdateAvail() { + if(state?.appData?.versions && state?.codeVersions?.server && isCodeUpdateAvailable(state?.appData?.versions?.server?.ver, state?.codeVersions?.server, "server")) { return true } + return false +} + +Integer versionStr2Int(str) { return str ? str.toString()?.replaceAll("\\.", "")?.toInteger() : null } + +private checkVersionData(now = false) { //This reads a JSON file from GitHub with version numbers + if (now || !state?.appData || (getLastVerUpdSec() > (3600*6))) { + if(now && (getLastVerUpdSec() < 300)) { return } + getConfigData() + } +} + +private getConfigData() { + def params = [ + uri: "https://raw.githubusercontent.com/tonesto7/echo-speaks/${isBeta() ? "beta" : "master"}/resources/appData.json", + contentType: "application/json" + ] + def data = getWebData(params, "appData", false) + if(data) { + + state?.appData = data + state?.lastVerUpdDt = getDtNow() + log.info "Successfully Retrieved (v${data?.appDataVer}) of AppData Content from GitHub Repo..." + } +} + +private getWebData(params, desc, text=true) { + try { + // log.trace("getWebData: ${desc} data") + httpGet(params) { resp -> + if(resp?.data) { + if(text) { return resp?.data?.text.toString() } + return resp?.data + } + } + } catch (ex) { + incrementCntByKey("appErrorCnt") + if(ex instanceof groovyx.net.http.HttpResponseException) { + log.warn("${desc} file not found") + } else { log.error "getWebData(params: $params, desc: $desc, text: $text) Exception:", ex } + return "${label} info not found" + } +} + +/****************************************** +| Time and Date Conversion Functions +*******************************************/ +def formatDt(dt, tzChg=true) { + def tf = new SimpleDateFormat("E MMM dd HH:mm:ss z yyyy") + if(tzChg) { if(location.timeZone) { tf.setTimeZone(location?.timeZone) } } + return tf?.format(dt) +} + +String strCapitalize(str) { return str ? str?.toString().capitalize() : null } +String isPluralString(obj) { return (obj?.size() > 1) ? "(s)" : "" } + +def parseDt(pFormat, dt, tzFmt=true) { + def result + def newDt = Date.parse("$pFormat", dt) + result = formatDt(newDt, tzFmt) + //log.debug "parseDt Result: $result" + return result +} + +def getDtNow() { + def now = new Date() + return formatDt(now) +} + +def epochToTime(tm) { + def tf = new SimpleDateFormat("h:mm a") + if(location?.timeZone) { tf?.setTimeZone(location?.timeZone) } + return tf.format(tm) +} + +def time2Str(time) { + if(time) { + def t = timeToday(time, location?.timeZone) + def f = new java.text.SimpleDateFormat("h:mm a") + f?.setTimeZone(location?.timeZone ?: timeZone(time)) + return f?.format(t) + } +} + +def GetTimeDiffSeconds(lastDate, sender=null) { + try { + if(lastDate?.contains("dtNow")) { return 10000 } + def now = new Date() + def lastDt = Date.parse("E MMM dd HH:mm:ss z yyyy", lastDate) + def start = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(lastDt)).getTime() + def stop = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(now)).getTime() + def diff = (int) (long) (stop - start) / 1000 + return diff + } + catch (ex) { + log.error "GetTimeDiffSeconds Exception: (${sender ? "$sender | " : ""}lastDate: $lastDate):", ex + return 10000 + } +} + +/****************************************** +| App Input Description Functions +*******************************************/ +String getAppNotifConfDesc() { + String str = "" + if(pushStatus()) { + def ap = getAppNotifDesc() + def nd = getNotifSchedDesc() + str += (settings?.usePush) ? "${str != "" ? "\n" : ""}Sending via: (Push)" : "" + str += (settings?.pushoverEnabled) ? "${str != "" ? "\n" : ""}Pushover: (Enabled)" : "" + str += (settings?.pushoverEnabled && settings?.pushoverPriority) ? bulletItem(str, "Priority: (${settings?.pushoverPriority})") : "" + str += (settings?.pushoverEnabled && settings?.pushoverSound) ? bulletItem(str, "Sound: (${settings?.pushoverSound})") : "" + str += (settings?.phone) ? "${str != "" ? "\n" : ""}Sending via: (SMS)" : "" + str += (ap) ? "${str != "" ? "\n\n" : ""}Enabled Alerts:\n${ap}" : "" + str += (ap && nd) ? "${str != "" ? "\n" : ""}\nAlert Restrictions:\n${nd}" : "" + } + return str != "" ? str : null +} + +String getNotifSchedDesc() { + def sun = getSunriseAndSunset() + def startInput = settings?.qStartInput + def startTime = settings?.qStartTime + def stopInput = settings?.qStopInput + def stopTime = settings?.qStopTime + def dayInput = settings?.quietDays + def modeInput = settings?.quietModes + def notifDesc = "" + def getNotifTimeStartLbl = ( (startInput == "Sunrise" || startInput == "Sunset") ? ( (startInput == "Sunset") ? epochToTime(sun?.sunset?.time) : epochToTime(sun?.sunrise?.time) ) : (startTime ? time2Str(startTime) : "") ) + def getNotifTimeStopLbl = ( (stopInput == "Sunrise" || stopInput == "Sunset") ? ( (stopInput == "Sunset") ? epochToTime(sun?.sunset?.time) : epochToTime(sun?.sunrise?.time) ) : (stopTime ? time2Str(stopTime) : "") ) + notifDesc += (getNotifTimeStartLbl && getNotifTimeStopLbl) ? " • Silent Time: ${getNotifTimeStartLbl} - ${getNotifTimeStopLbl}" : "" + def days = getInputToStringDesc(dayInput) + def modes = getInputToStringDesc(modeInput) + notifDesc += days ? "${(getNotifTimeStartLbl || getNotifTimeStopLbl) ? "\n" : ""} • Silent Day${isPluralString(dayInput)}: ${days}" : "" + notifDesc += modes ? "${(getNotifTimeStartLbl || getNotifTimeStopLbl || days) ? "\n" : ""} • Silent Mode${isPluralString(modeInput)}: ${modes}" : "" + return (notifDesc != "") ? "${notifDesc}" : null +} + +String getServiceConfDesc() { + String str = "" + str += (state?.generatedHerokuName && state?.onHeroku) ? bulletItem(str, "Heroku: (Configured)") : "" + str += (state?.serviceConfigured && state?.isLocal) ? bulletItem(str, "Local Server: (Configured)") : "" + str += (settings?.amazonDomain) ? bulletItem(str, "Domain: (${settings?.amazonDomain})") : "" + str += (state?.lastCookieRefresh) ? bulletItem(str, "Cookie Date: (${state?.lastCookieRefresh})") : "" + return str != "" ? str : null +} + +String getAppNotifDesc() { + def str = "" + str += settings?.sendMissedPollMsg != false ? bulletItem(str, "Missed Poll Alerts") : "" + str += settings?.sendAppUpdateMsg != false ? bulletItem(str, "Code Updates") : "" + str += settings?.sendCookieRefreshMsg == true ? bulletItem(str, "Cookie Refresh") : "" + return str != "" ? str : null +} + +String getGroupsDesc() { + def grps = getGroupApps() + return grps?.size() ? " • (${grps?.size()}) Groups Configured" : null +} + +String getActionsDesc() { + def acts = getActionApps() + return acts?.size() ? " • (${acts?.size()}) Actions Configured" : null +} + +String getServInfoDesc() { + Map rData = state?.nodeServiceInfo + String str = "" + String dtstr = "" + if(rData?.startupDt) { + def dt = rData?.startupDt + dtstr += dt?.y ? "${dt?.y}yr${dt?.y > 1 ? "s" : ""}, " : "" + dtstr += dt?.mn ? "${dt?.mn}mon${dt?.mn > 1 ? "s" : ""}, " : "" + dtstr += dt?.d ? "${dt?.d}day${dt?.d > 1 ? "s" : ""}, " : "" + dtstr += dt?.h ? "${dt?.h}hr${dt?.h > 1 ? "s" : ""} " : "" + dtstr += dt?.m ? "${dt?.m}min${dt?.m > 1 ? "s" : ""} " : "" + dtstr += dt?.s ? "${dt?.s}sec" : "" + } + if(state?.onHeroku) { + str += " ├ App Name: (${state?.generatedHerokuName})\n" + } + str += " ├ IP: (${rData?.ip})" + str += "\n ├ Port: (${rData?.port})" + str += "\n ├ Version: (v${rData?.version})" + str += "\n ${dtstr != "" ? "├" : "└"} Session Events: (${rData?.sessionEvts})" + str += dtstr != "" ? "\n └ Uptime: ${dtstr.length() > 20 ? "\n └ ${dtstr}" : "${dtstr}"}" : "" + return str != "" ? str : null +} + +String getInputToStringDesc(inpt, addSpace = null) { + Integer cnt = 0 + String str = "" + if(inpt) { + inpt.sort().each { item -> + cnt = cnt+1 + str += item ? (((cnt < 1) || (inpt?.size() > 1)) ? "\n ${item}" : "${addSpace ? " " : ""}${item}") : "" + } + } + //log.debug "str: $str" + return (str != "") ? "${str}" : null +} + +String randomString(Integer len) { + def pool = ["a".."z",0..9].flatten() + Random rand = new Random(new Date().getTime()) + def randChars = (0..len).collect { pool[rand.nextInt(pool.size())] } + log.debug "randomString: ${randChars?.join()}" + return randChars.join() +} + +def getAccessToken() { + try { + if(!state?.accessToken) { state?.accessToken = createAccessToken() } + else { return true } + } + catch (ex) { + // sendPush("Error: OAuth is not Enabled for ${appName()}!. Please click remove and Enable Oauth under the SmartApp App Settings in the IDE") + log.error "getAccessToken Exception", ex + return false + } +} + +def renderConfig() { + String title = "Echo Speaks" + Boolean heroku = (isST() || (settings?.useHeroku == null || settings?.useHeroku != false)) + String oStr = !heroku ? """
+
+

Due to the complexity of node environments I will not be able to support local server setup

+
1. Install the node server
+
2. Start the node server
+
3. Open the servers web config page
+
4. Copy the following URL and use it in the appCallbackUrl field of the Server Web Config Page
+
+
+

${getAppEndpointUrl("receiveData") as String}

+
+
""" : """ +
+
+
1. Copy the following Name and use it when asked by Heroku
+
+

${getRandAppName()?.toString().trim()}

+
+
+
+
2. Tap Button to deploy to Heroku
+ + Deploy + +
+
""" + + String html = """ + + + + + + + + + ${title} + + + + + + + + + + + +
+
+
+ + +

Echo Speaks

+
+
+
+ ${oStr} + +
+
+ + + """ + render contentType: "text/html", data: html +} + +String getObjType(obj) { + if(obj instanceof String) {return "String"} + else if(obj instanceof GString) {return "GString"} + else if(obj instanceof Map) {return "Map"} + else if(obj instanceof List) {return "List"} + else if(obj instanceof ArrayList) {return "ArrayList"} + else if(obj instanceof Integer) {return "Integer"} + else if(obj instanceof BigInteger) {return "BigInteger"} + else if(obj instanceof Long) {return "Long"} + else if(obj instanceof Boolean) {return "Boolean"} + else if(obj instanceof BigDecimal) {return "BigDecimal"} + else if(obj instanceof Float) {return "Float"} + else if(obj instanceof Byte) {return "Byte"} + else { return "unknown"} +} + +private Map amazonDomainOpts() { + return [ + "amazon.com":"Amazon.com", + "amazon.ca":"Amazon.ca", + "amazon.co.uk":"amazon.co.uk", + "amazon.com.au":"amazon.com.au", + "amazon.de":"Amazon.de", + "amazon.it":"Amazon.it" + ] +} +private List localeOpts() { return ["en-US", "en-CA", "de-DE", "en-GB", "it-IT", "en-AU"] } + +private getPlatform() { + def p = "SmartThings" + if(state?.hubPlatform == null) { + try { [dummy: "dummyVal"]?.encodeAsJson(); } catch (e) { p = "Hubitat" } + // p = (location?.hubs[0]?.id?.toString()?.length() > 5) ? "SmartThings" : "Hubitat" + state?.hubPlatform = p + log.debug "hubPlatform: (${state?.hubPlatform})" + } + return state?.hubPlatform +} + +Integer stateSize() { + def j = new groovy.json.JsonOutput().toJson(state) + return j?.toString().length() +} +Integer stateSizePerc() { return (int) ((stateSize() / 100000)*100).toDouble().round(0) } +String debugStatus() { return !settings?.appDebug ? "Off" : "On" } +String deviceDebugStatus() { return !settings?.childDebug ? "Off" : "On" } +Boolean isAppDebug() { return (settings?.appDebug == true) } +Boolean isChildDebug() { return (settings?.childDebug == true) } + +String getAppDebugDesc() { + def str = "" + str += isAppDebug() ? "App Debug: (${debugStatus()})" : "" + str += isChildDebug() && str != "" ? "\n" : "" + str += isChildDebug() ? "Device Debug: (${deviceDebugStatus()})" : "" + return (str != "") ? "${str}" : null +} + +private logger(type, msg, traceOnly=false) { + if (traceOnly && !settings?.appTrace) { return } + if(type && msg && settings?.appDebug) { + log."${type}" "${msg}" + } +} \ No newline at end of file diff --git a/smartapps/tonesto7/st-community-installer.src/st-community-installer.groovy b/smartapps/tonesto7/st-community-installer.src/st-community-installer.groovy new file mode 100644 index 00000000000..e226ce75362 --- /dev/null +++ b/smartapps/tonesto7/st-community-installer.src/st-community-installer.groovy @@ -0,0 +1,162 @@ +/* +* Communtity App Installer +* Copyright 2018 Anthony Santilli, Corey Lista +* +// /**********************************************************************************************************************************************/ +import java.security.MessageDigest + +definition( + name : "ST-Community-Installer", + namespace : "tonesto7", + author : "tonesto7", + description : "The Community SmartApp/Devices Installer", + category : "My Apps", + singleInstance : true, + iconUrl : "${getAppImg("app_logo.png")}", + iconX2Url : "${getAppImg("app_logo.png")}", + iconX3Url : "${getAppImg("app_logo.png")}") +/**********************************************************************************************************************************************/ +private releaseVer() { return "1.0.0213a" } +private appVerDate() { "2-13-2018" } +/**********************************************************************************************************************************************/ +preferences { + page name: "startPage" + page name: "mainPage" +} + +mappings { + path("/installStart") { action: [GET: "installStartHtml"] } +} + +def startPage() { + if(!atomicState?.accessToken) { getAccessToken() } + if(!atomicState?.accessToken) { + return dynamicPage(name: "startPage", title: "Status Page", nextPage: "", install: false, uninstall: true) { + section ("Status Page:") { + def title = "" + def desc = "" + if(!atomicState?.accessToken) { title="OAUTH Error"; desc = "OAuth is not Enabled for ${app?.label} application. Please click remove and review the installation directions again"; } + else { title="Unknown Error"; desc = "Application Status has not received any messages to display"; } + log.warn "Status Message: $desc" + paragraph title: "$title", "$desc", required: true, state: null + } + } + } + else { return mainPage() } +} + +def mainPage() { + dynamicPage (name: "mainPage", title: "", install: true, uninstall: true) { + section("") { image getAppImg("welcome.png") } + section("Login Options:") { + if(!settings?.authAcctType) { + paragraph title: "This helps to determine the login server you are sent to!", "" + } + input "authAcctType", "enum", title: "IDE Login Account Type", multiple: false, required: true, submitOnChange: true, options: ["samsung":"Samsung", "st":"SmartThings"], image: getAppImg("${settings?.authAcctType}_icon.png") + } + def hideBrowDesc = (atomicState?.isInstalled == true && ["embedded", "external"].contains(settings?.browserType)) + section("Browser Type Description:", hideable: hideBrowDesc, hidden: hideBrowDesc) { + def embstr = "It's the most secure as the session is wiped everytime you close the view. So it will require logging in everytime you leave the view and isn't always friendly with Password managers (iOS)" + paragraph title: "Embedded (Recommended)", embstr + def extstr = "Will open the page outside the SmartThings app in your default browser. It will maintain your SmartThings until you logout. It will not force you to login everytime you leave the page and should be compatible with most Password managers. You can bookmark the Page for quick access." + paragraph title: "External", extstr + } + section("Browser Option:") { + input "browserType", "enum", title: "Browser Type", required: true, defaultValue: "embedded", submitOnChange: true, options: ["embedded":"Embedded", "external":"Mobile Browser"], image: "" + } + section("") { + if(settings?.browserType) { + href "", title: "Installer Home", url: getLoginUrl(), style: (settings?.browserType == "external" ? "external" : "embedded"), required: false, description: "Tap Here to load the Installer Web App", image: getAppImg("go_img.png") + } else { + paragraph title: "Browser Type Missing", "Please Select a browser type to proceed", required: true, state: null + } + } + } +} + +def baseUrl(path) { + return "https://community-installer-34dac.firebaseapp.com${path}" +} + +def getLoginUrl() { + def r = URLEncoder.encode(getAppEndpointUrl("installStart")) + def theURL = "https://account.smartthings.com/login?redirect=${r}" + if(settings?.authAcctType == "samsung") { theURL = "https://account.smartthings.com/login/samsungaccount?redirect=${r}" } + return theURL +} + +def installStartHtml() { + def randVerStr = "?=${now()}" + def html = """ + + + + + + + + + +
+ + + """ + render contentType: "text/html", data: html +} + +def installed() { + log.debug "Installed with settings: ${settings}" + atomicState?.isInstalled = true + initialize() +} + +def updated() { + log.trace ("${app?.getLabel()} | Now Running Updated() Method") + if(!atomicState?.isInstalled) { atomicState?.isInstalled = true } + initialize() +} + +def initialize() { + if (!atomicState?.accessToken) { + log.debug "Access token not defined. Attempting to refresh. Ensure OAuth is enabled in the SmartThings IDE." + getAccessToken() + } +} + +def uninstalled() { + revokeAccessToken() + log.warn("${app?.getLabel()} has been Uninstalled...") +} + +def generateLocationHash() { + def s = location?.getId() + MessageDigest digest = MessageDigest.getInstance("MD5") + digest.update(s.bytes); + new BigInteger(1, digest.digest()).toString(16).padLeft(32, '0') +} + +def getAccessToken() { + try { + if(!atomicState?.accessToken) { + log.error "SmartThings Access Token Not Found... Creating a New One!!!" + atomicState?.accessToken = createAccessToken() + } else { return true } + } + catch (ex) { + log.error "Error: OAuth is not Enabled for ${app?.label}!. Please click remove and Enable Oauth under the SmartApp App Settings in the IDE" + return false + } +} + +def gitBranch() { return "master" } +def getAppImg(file) { return "https://cdn.rawgit.com/tonesto7/st-community-installer/${gitBranch()}/images/$file" } +def getAppVideo(file) { return "https://cdn.rawgit.com/tonesto7/st-community-installer/${gitBranch()}/videos/$file" } +def getAppEndpointUrl(subPath) { return "${apiServerUrl("/api/smartapps/installations/${app.id}${subPath ? "/${subPath}" : ""}?access_token=${atomicState.accessToken}")}" } \ No newline at end of file diff --git a/smartapps/turlvo/kuku-harmony.src/kuku-harmony.groovy b/smartapps/turlvo/kuku-harmony.src/kuku-harmony.groovy new file mode 100644 index 00000000000..4d9a552b4a4 --- /dev/null +++ b/smartapps/turlvo/kuku-harmony.src/kuku-harmony.groovy @@ -0,0 +1,821 @@ +/** + * KuKu Harmony - Virtual Switch for Logitech Harmony + * + * Copyright 2017 KuKu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Version history + */ +def version() { return "v1.6.501" } +/* + * 03/28/2017 >>> v1.0.000 - Release first KuKu Harmony supports only on/off command for each device + * 04/13/2017 >>> v1.3.000 - Added Aircon, Fan, Roboking device type + * 04/14/2017 >>> v1.4.000 - Added TV device type + * 04/21/2017 >>> v1.4.100 - changed DTH's default state to 'Off' + * 04/21/2017 >>> v1.4.150 - update on/off state routine and slide + * 04/22/2017 >>> v1.4.170 - changed 'addDevice' page's refreshInterval routine and change all device's power on/off routine + * 04/22/2017 >>> v1.4.181 - changed routine of discovering hub and added checking hub's state + * 05/16/2017 >>> v1.5.000 - support multiple Harmony hubs + * 05/19/2017 >>> v1.5.002 - fixed 'STB' device type crash bug and changed refresh interval + * 05/22/2017 >>> v1.5.102 - added routine of synchronizing device status through plug's power monitoring + * 07/09/2017 >>> v1.5.103 - changed child app to use parent Harmony API server IP address + * 07/29/2017 >>> v1.5.104 - fixed duplicated custom command + * 08/30/2017 >>> v1.6.000 - added Harmony API server's IP changing menu and contact sensor's monitoring at Aircon Type + * 09/03/2017 >>> v1.6.001 - hot fix - not be changed by IP chaning menu + * 09/04/2017 >>> v1.6.002 - hot fix - 'Power Meter' subscription is not called In the case of other devices except the air conditioner + * 09/18/2017 >>> v1.6.500 - added Contact Sensor's monitoring mode and changed version expression + * 09/18/2017 >>> v1.6.501 - added 'Number 0' command at TV Type DTH +*/ + +definition( + name: "KuKu Harmony${parent ? " - Device" : ""}", + namespace: "turlvo", + author: "KuKu", + description: "This is a SmartApp that support to control Harmony's device!", + category: "Convenience", + parent: parent ? "turlvo.KuKu Harmony" : null, + singleInstance: true, + iconUrl: "https://cdn.rawgit.com/turlvo/KuKuHarmony/master/images/icon/KuKu_Harmony_Icon_1x.png", + iconX2Url: "https://cdn.rawgit.com/turlvo/KuKuHarmony/master/images/icon/KuKu_Harmony_Icon_2x.png", + iconX3Url: "https://cdn.rawgit.com/turlvo/KuKuHarmony/master/images/icon/KuKu_Harmony_Icon_3x.png") + +preferences { + page(name: "parentOrChildPage") + + page(name: "mainPage") + page(name: "installPage") + page(name: "mainChildPage") + +} + +// ------------------------------ +// Pages related to Parent +def parentOrChildPage() { + parent ? mainChildPage() : mainPage() +} + +// mainPage +// seperated two danymic page by 'isInstalled' value +def mainPage() { + if (!atomicState?.isInstalled) { + return installPage() + } else { + def interval + discoverHubs(atomicState.harmonyApiServerIP) + if (atomicState.discoverdHubs) { + interval = 15 + } else { + interval = 3 + } + return dynamicPage(name: "mainPage", title: "", uninstall: true, refreshInterval: interval) { + //getHubStatus() + section("Harmony-API Server IP Address :") { + href "installPage", title: "", description: "${atomicState.harmonyApiServerIP}" + } + + section("Harmony Hub List :") { + if (atomicState.discoverdHubs) { + atomicState.discoverdHubs.each { + paragraph "$it" + } + } else { + paragraph "None" + } + } + + section("") { + app( name: "harmonyDevices", title: "Add a device...", appName: "KuKu Harmony", namespace: "turlvo", multiple: true, uninstall: false) + } + + section("KuKu Harmony Version :") { + paragraph "${version()}" + } + } + } +} + +def installPage() { + dynamicPage(name: "installPage", title: "", install: !atomicState.isInstalled) { + section("Enter the Harmony-API Server IP address :") { + input name: "harmonyHubIP", type: "text", required: true, title: "IP address?", submitOnChange: true + } + + if (harmonyHubIP) { + atomicState.harmonyApiServerIP = harmonyHubIP + } + } +} + +def initializeParent() { + atomicState.isInstalled = true + atomicState.harmonyApiServerIP = harmonyHubIP + atomicState.hubStatus = "online" +} + +def getHarmonyApiServerIP() { + return atomicState.harmonyApiServerIP +} + +// ------------------------------ +// Pages realted to Child App +def mainChildPage() { + def interval + if (atomicState.discoverdHubs && atomicState.deviceCommands && atomicState.device) { + interval = 15 + } else { + interval = 3 + } + return dynamicPage(name: "mainChildPage", title: "Add Device", refreshInterval: interval, uninstall: true, install: true) { + log.debug "mainChildPage>> parent's atomicState.harmonyApiServerIP: ${parent.getHarmonyApiServerIP()}" + atomicState.harmonyApiServerIP = parent.getHarmonyApiServerIP() + + log.debug "installHubPage>> $atomicState.discoverdHubs" + if (atomicState.discoverdHubs == null) { + discoverHubs(atomicState.harmonyApiServerIP) + section() { + paragraph "Discovering Harmony Hub. Please wait..." + } + } else { + section("Hub :") { + //def hubs = getHubs(harmonyHubIP) + input name: "selectHub", type: "enum", title: "Select Hub", options: atomicState.discoverdHubs, submitOnChange: true, required: true + log.debug "mainChildPage>> selectHub: $selectHub" + if (selectHub) { + discoverDevices(selectHub) + atomicState.hub = selectHub + } + } + } + + def foundDevices = getHubDevices() + if (atomicState.hub && foundDevices) { + section("Device :") { + def labelOfDevice = getLabelsOfDevices(foundDevices) + input name: "selectedDevice", type: "enum", title: "Select Device", multiple: false, options: labelOfDevice, submitOnChange: true, required: true + if (selectedDevice) { + discoverCommandsOfDevice(selectedDevice) + atomicState.device = selectedDevice + } + } + + if (selectedDevice) { + section("Device Type :") { + def deviceType = ["Default", "Aircon", "TV", "Roboking", "Fan"] + input name: "selectedDeviceType", type: "enum", title: "Select Device Type", multiple: false, options: deviceType, submitOnChange: true, required: true + } + } + + + atomicState.deviceCommands = getCommandsOfDevice() + if (selectedDeviceType && atomicState.deviceCommands) { + atomicState.selectedDeviceType = selectedDeviceType + switch (selectedDeviceType) { + case "Aircon": + addAirconDevice() + break + case "TV": + case "STB": + addTvDeviceTV() + break + case "STB": + break + case "Roboking": + addRobokingDevice() + break + case "Fan": + addFanDevice() + break + default: + log.debug "selectedDeviceType>> default" + addDefaultDevice() + } + } else if (selectedDeviceType && atomicState.deviceCommands == null) { + // log.debug "addDevice()>> selectedDevice: $selectedDevice, commands : $commands" + section("") { + paragraph "Loading selected device's command. This can take a few seconds. Please wait..." + } + } + } else if (atomicState.hub) { + section() { + paragraph "Discovering devices. Please wait..." + } + } + } +} + +// Add device page for Default On/Off device +def addDefaultDevice() { + def labelOfCommand = getLabelsOfCommands(atomicState.deviceCommands) + state.selectedCommands = [:] + + section("Commands :") { + input name: "selectedPowerOn", type: "enum", title: "Power On", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOff", type: "enum", title: "Power Off", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + } + state.selectedCommands["power-on"] = selectedPowerOn + state.selectedCommands["power-off"] = selectedPowerOff + + monitorMenu() +} + +// Add device page for Fan device +def addFanDevice() { + def labelOfCommand = getLabelsOfCommands(atomicState.deviceCommands) + state.selectedCommands = [:] + + section("Commands :") { + // input name: "selectedPower", type: "enum", title: "Power Toggle", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOn", type: "enum", title: "Power On", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOff", type: "enum", title: "Power Off", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedSpeed", type: "enum", title: "Speed", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedSwing", type: "enum", title: "Swing", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedTimer", type: "enum", title: "Timer", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom1", type: "enum", title: "Custom1", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom2", type: "enum", title: "Custom2", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom3", type: "enum", title: "Custom3", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom4", type: "enum", title: "Custom4", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom5", type: "enum", title: "Custom5", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + } + //state.selectedCommands["power"] = selectedPower + state.selectedCommands["power-on"] = selectedPowerOn + state.selectedCommands["power-off"] = selectedPowerOff + state.selectedCommands["speed"] = selectedSpeed + state.selectedCommands["swing"] = selectedSwing + state.selectedCommands["timer"] = selectedTimer + state.selectedCommands["custom1"] = custom1 + state.selectedCommands["custom2"] = custom2 + state.selectedCommands["custom3"] = custom3 + state.selectedCommands["custom4"] = custom4 + state.selectedCommands["custom5"] = custom5 + + monitorMenu() +} + +// Add device page for Aircon +def addAirconDevice() { + def labelOfCommand = getLabelsOfCommands(atomicState.deviceCommands) + state.selectedCommands = [:] + + section("Commands :") { + //input name: "selectedPowerToggle", type: "enum", title: "Power Toggle", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOn", type: "enum", title: "Power On", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOff", type: "enum", title: "Power Off", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedTempUp", type: "enum", title: "Temperature Up", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedMode", type: "enum", title: "Mode", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedJetCool", type: "enum", title: "JetCool", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedTempDown", type: "enum", title: "Temperature Down", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedSpeed", type: "enum", title: "Fan Speed", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom1", type: "enum", title: "Custom1", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom2", type: "enum", title: "Custom2", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom3", type: "enum", title: "Custom3", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom4", type: "enum", title: "Custom4", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom5", type: "enum", title: "Custom5", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + } + + //state.selectedCommands["power"] = selectedPowerToggle + state.selectedCommands["power-on"] = selectedPowerOn + state.selectedCommands["power-off"] = selectedPowerOff + state.selectedCommands["tempup"] = selectedTempUp + state.selectedCommands["mode"] = selectedMode + state.selectedCommands["jetcool"] = selectedJetCool + state.selectedCommands["tempdown"] = selectedTempDown + state.selectedCommands["speed"] = selectedSpeed + state.selectedCommands["custom1"] = custom1 + state.selectedCommands["custom2"] = custom2 + state.selectedCommands["custom3"] = custom3 + state.selectedCommands["custom4"] = custom4 + state.selectedCommands["custom5"] = custom5 + + monitorMenu() +} + +// Add device page for TV +def addTvDeviceTV() { + def labelOfCommand = getLabelsOfCommands(atomicState.deviceCommands) + state.selectedCommands = [:] + + section("Commands :") { + //input name: "selectedPowerToggle", type: "enum", title: "Power Toggle", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOn", type: "enum", title: "Power On", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedPowerOff", type: "enum", title: "Power Off", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedVolumeUp", type: "enum", title: "Volume Up", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedChannelUp", type: "enum", title: "Channel Up", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedMute", type: "enum", title: "Mute", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedVolumeDown", type: "enum", title: "Volume Down", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedChannelDown", type: "enum", title: "Channel Down", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedMenu", type: "enum", title: "Menu", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedHome", type: "enum", title: "Home", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedInput", type: "enum", title: "Input", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedBack", type: "enum", title: "Back", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom1", type: "enum", title: "Custom1", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom2", type: "enum", title: "Custom2", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom3", type: "enum", title: "Custom3", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom4", type: "enum", title: "Custom4", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom5", type: "enum", title: "Custom5", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + } + + //state.selectedCommands["power"] = selectedPowerToggle + state.selectedCommands["power-on"] = selectedPowerOn + state.selectedCommands["power-off"] = selectedPowerOff + state.selectedCommands["volup"] = selectedVolumeUp + state.selectedCommands["chup"] = selectedChannelUp + state.selectedCommands["mute"] = selectedMute + state.selectedCommands["voldown"] = selectedVolumeDown + state.selectedCommands["chdown"] = selectedChannelDown + state.selectedCommands["menu"] = selectedMenu + state.selectedCommands["home"] = selectedHome + state.selectedCommands["input"] = selectedInput + state.selectedCommands["back"] = selectedBack + state.selectedCommands["custom1"] = custom1 + state.selectedCommands["custom2"] = custom2 + state.selectedCommands["custom3"] = custom3 + state.selectedCommands["custom4"] = custom4 + state.selectedCommands["custom5"] = custom5 + + monitorMenu() +} + +// Add device page for Aircon +def addRobokingDevice() { + def labelOfCommand = getLabelsOfCommands(atomicState.deviceCommands) + state.selectedCommands = [:] + + section("Commands :") { + input name: "selectedStart", type: "enum", title: "Start", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedHome", type: "enum", title: "Home", options: labelOfCommand, submitOnChange: true, multiple: false, required: true + input name: "selectedStop", type: "enum", title: "Stop", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedUp", type: "enum", title: "Up", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedDown", type: "enum", title: "Down", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedLeft", type: "enum", title: "Left", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedRight", type: "enum", title: "Right", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedMode", type: "enum", title: "Mode", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "selectedTurbo", type: "enum", title: "Turbo", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom1", type: "enum", title: "Custom1", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom2", type: "enum", title: "Custom2", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom3", type: "enum", title: "Custom3", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom4", type: "enum", title: "Custom4", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + input name: "custom5", type: "enum", title: "Custom5", options: labelOfCommand, submitOnChange: true, multiple: false, required: false + } + + state.selectedCommands["start"] = selectedStart + state.selectedCommands["stop"] = selectedStop + state.selectedCommands["up"] = selectedUp + state.selectedCommands["down"] = selectedDown + state.selectedCommands["left"] = selectedLeft + state.selectedCommands["right"] = selectedRight + state.selectedCommands["home"] = selectedHome + state.selectedCommands["mode"] = selectedMode + state.selectedCommands["turbo"] = selectedTurbo + state.selectedCommands["custom1"] = custom1 + state.selectedCommands["custom2"] = custom2 + state.selectedCommands["custom3"] = custom3 + state.selectedCommands["custom4"] = custom4 + state.selectedCommands["custom5"] = custom5 + + monitorMenu() + +} + +// ------------------------------------ +// Monitoring sub menu +def monitorMenu() { + section("State Monitor :") { + paragraph "It is a function to complement IrDA's biggest drawback. Through sensor's state, synchronize deivce status." + def monitorType = ["Power Meter", "Contact"] + input name: "selectedMonitorType", type: "enum", title: "Select Monitor Type", multiple: false, options: monitorType, submitOnChange: true, required: false + } + + atomicState.selectedMonitorType = selectedMonitorType + if (selectedMonitorType) { + switch (selectedMonitorType) { + case "Power Meter": + powerMonitorMenu() + break + case "Contact": + contactMonitorMenu() + break + } + } +} + +def powerMonitorMenu() { + section("Power Monitor :") { + input name: "powerMonitor", type: "capability.powerMeter", title: "Device", submitOnChange: true, multiple: false, required: false + state.triggerOnFlag = false; + state.triggerOffFlag = false; + if (powerMonitor) { + input name: "triggerOnValue", type: "decimal", title: "On Trigger Watt", submitOnChange: true, multiple: false, required: true + input name: "triggerOffValue", type: "decimal", title: "Off Trigger Watt", submitOnChange: true, multiple: false, required: true + } + } +} + +def contactMonitorMenu() { + section("Contact :") { + input name: "contactMonitor", type: "capability.contactSensor", title: "Device", submitOnChange: true, multiple: false, required: false + if (contactMonitor) { + paragraph "[Normal] : Open(On) / Close(Off)\n[Reverse] : Open(Off) / Close(On)" + input name: "contactMonitorMode", type: "enum", title: "Mode", multiple: false, options: ["Normal", "Reverse"], defaultValue: "Normal", submitOnChange: true, required: true + } + atomicState.contactMonitorMode = contactMonitorMode + } +} + + +// ------------------------------------ +// Monitor Handler +// Subscribe power value and change status +def powerMonitorHandler(evt) { + def device = [] + device = getDeviceByName("$selectedDevice") + def deviceId = device.id + def child = getChildDevice(deviceId) + def event + + log.debug "value is over triggerValue>> flag: $state.triggerOnFlag, value: $evt.value, triggerValue: ${triggerOnValue.floatValue()}" + if (Float.parseFloat(evt.value) >= triggerOnValue.floatValue() && state.triggerOnFlag == false) { + event = [value: "on"] + child.generateEvent(event) + log.debug "value is over send*****" + state.triggerOnFlag = true + } else if (Float.parseFloat(evt.value) < triggerOnValue.floatValue()) { + state.triggerOnFlag = false + } + + log.debug "value is under triggerValue>> flag: $state.triggerOffFlag, value: $evt.value, triggerValue: ${triggerOffValue.floatValue()}" + if (Float.parseFloat(evt.value) <= triggerOffValue.floatValue() && state.triggerOffFlag == false){ + event = [value: "off"] + child.generateEvent(event) + log.debug "value is under send*****" + state.triggerOffFlag = true + } else if (Float.parseFloat(evt.value) > triggerOffValue.floatValue()) { + state.triggerOffFlag = false + } + +} + +// Subscribe contact value and change status +def contactMonitorHandler(evt) { + def device = [] + device = getDeviceByName("$selectedDevice") + def deviceId = device.id + def child = getChildDevice(deviceId) + def event + + def contacted = "off", notContacted = "on" + if (atomicState.contactMonitorMode == "Reverse") { + contacted = "on" + notContacted = "off" + } + log.debug "contactMonitorHandler>> value is : $evt.value" + if (evt.value == "open") { + event = [value: notContacted] + } else { + event = [value: contacted] + } + child.generateEvent(event) +} + +// Install child device +def initializeChild(devicetype) { + //def devices = getDevices() + log.debug "addDeviceDone: $selectedDevice, type: $atomicState.selectedMonitorType" + app.updateLabel("$selectedDevice") + + unsubscribe() + if (atomicState.selectedMonitorType == "Power Meter") { + log.debug "Power: $powerMonitor" + subscribe(powerMonitor, "power", powerMonitorHandler) + } else if (atomicState.selectedMonitorType == "Contact") { + log.debug "Contact: $contactMonitor" + subscribe(contactMonitor, "contact", contactMonitorHandler) + } + def device = [] + device = getDeviceByName("$selectedDevice") + log.debug "addDeviceDone>> device: $device" + + def deviceId = device.id + def existing = getChildDevice(deviceId) + if (!existing) { + def childDevice = addChildDevice("turlvo", "KuKu Harmony_${atomicState.selectedDeviceType}", deviceId, null, ["label": device.label]) + } else { + log.debug "Device already created" + } +} + + +// For child Device +def command(child, command) { + def device = getDeviceByName("$selectedDevice") + + log.debug "childApp parent command(child)>> $selectedDevice, command: $command, changed Command: ${state.selectedCommands[command]}" + def commandSlug = getSlugOfCommandByLabel(atomicState.deviceCommands, state.selectedCommands[command]) + log.debug "childApp parent command(child)>> commandSlug : $commandSlug" + + def result + result = sendCommandToDevice(device.slug, commandSlug) + if (result && result.message != "ok") { + sendCommandToDevice(device.slug, commandSlug) + } +} + +def commandValue(child, command) { + def device = getDeviceByName("$selectedDevice") + + log.debug "childApp parent commandValue(child)>> $selectedDevice, command: $command" + + def result + result = sendCommandToDevice(device.slug, command) + if (result && result.message != "ok") { + sendCommandToDevice(device.slug, command) + } +} + + + +// ------------------------------------ +// ------- Default Common Method ------- +def installed() { + initialize() +} + +def updated() { + //unsubscribe() + initialize() +} + +def initialize() { + log.debug "initialize()" + parent ? initializeChild() : initializeParent() +} + + +def uninstalled() { + parent ? null : removeChildDevices(getChildDevices()) +} + +private removeChildDevices(delete) { + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} + + +// --------------------------- +// ------- Hub Command ------- + +// getSelectedHub +// return : Installed hub name +def getSelectedHub() { + return atomicState.hub +} + +// getLabelsOfDevices +// parameter : +// - devices : List of devices in Harmony Hub {label, slug} +// return : Array of devices's label value +def getLabelsOfDevices(devices) { + def labels = [] + devices.each { + //log.debug "labelOfDevice: $it" + labels.add(it.label) + } + + return labels + +} + +// getLabelsOfCommands +// parameter : +// - cmds : List of some device's commands {label, slug} +// return : Array of commands's label value +def getLabelsOfCommands(cmds) { + def labels = [] + log.debug "getLabelsOfCommands>> cmds" + cmds.each { + //log.debug "getLabelsOfCommands: it.label : $it.label, slug : $it.slug" + labels.add(it.label) + } + + return labels +} + +// getCommandsOfDevice +// return : result of 'discoverCommandsOfDevice(device)' method. It means that recently requested device's commands +def getCommandsOfDevice() { + //log.debug "getCommandsOfDevice>> $atomicState.foundCommandOfDevice" + + return atomicState.foundCommandOfDevice + +} + +// getSlugOfCommandByLabel +// parameter : +// - commands : List of device's command +// - label : name of command +// return : slug value same with label in the list of command +def getSlugOfCommandByLabel(commands, label) { + //def commands = [] + def slug + + commands.each { + if (it.label == label) { + //log.debug "it.label : $it.label, device : $device" + log.debug "getSlugOfCommandByLabel>> $it" + //commands = it.commands + slug = it.slug + } + } + return slug +} + +// getDeviceByName +// parameter : +// - name : device name searching +// return : device matched by name in Harmony Hub's devices +def getDeviceByName(name) { + def device = [] + atomicState.devices.each { + //log.debug "getDeviceByName>> $it.label, $name" + if (it.label == name) { + log.debug "getDeviceByName>> $it" + device = it + } + } + + return device +} + +// getHubDevices +// return : searched list of device in Harmony Hub when installed +def getHubDevices() { + return atomicState.devices +} + + +// -------------------------------- +// ------- HubAction Methos ------- +// sendCommandToDevice +// parameter : +// - device : target device +// - command : sending command +// return : 'sendCommandToDevice_response()' method callback +def sendCommandToDevice(device, command) { + log.debug("sendCommandToDevice >> harmonyApiServerIP : ${parent.getHarmonyApiServerIP()}") + sendHubCommand(setHubAction(parent.getHarmonyApiServerIP(), "/hubs/$atomicState.hub/devices/$device/commands/$command", "sendCommandToDevice_response")) +} + +def sendCommandToDevice_response(resp) { + def result = [] + def body = new groovy.json.JsonSlurper().parseText(parseLanMessage(resp.description).body) + log.debug("sendCommandToDevice_response >> $body") +} + +// getHubStatus +// parameter : +// return : 'getHubStatus_response()' method callback +def getHubStatus() { + log.debug "getHubStatus" + sendHubCommand(getHubAction(atomicState.harmonyApiServerIP, "/hubs/$atomicState.hub/status", "getHubStatus_response")) + if (atomicState.getHubStatusWatchdog == true) { + atomicState.hubStatus = "offline" + } + atomicState.getHubStatusWatchdog = true +} + +def getHubStatus_response(resp) { + def result = [] + atomicState.getHubStatusWatchdog = false + + if (resp.description != null && parseLanMessage(resp.description).body) { + log.debug "getHubStatus_response>> response: $resp.description" + def body = new groovy.json.JsonSlurper().parseText(parseLanMessage(resp.description).body) + + if(body && body.off != null) { + log.debug "getHubStatus_response>> $body.off" + if (body.off == false) { + atomicState.hubStatus = "online" + } + } else { + log.debug "getHubStatus_response>> $body.off" + atomicState.hubStatus = "offline" + } + } else { + log.debug "getHubStatus_response>> Status error" + atomicState.hubStatus = "offline" + } +} + +// discoverCommandsOfDevice +// parameter : +// - name : name of device searching command +// return : 'discoverCommandsOfDevice_response()' method callback +def discoverCommandsOfDevice(name) { + device = getDeviceByName(name) + log.debug "discoverCommandsOfDevice>> name:$name, device:$device" + + sendHubCommand(getHubAction(atomicState.harmonyApiServerIP, "/hubs/$atomicState.hub/devices/${device.slug}/commands", "discoverCommandsOfDevice_response")) + +} + +def discoverCommandsOfDevice_response(resp) { + def result = [] + def body = new groovy.json.JsonSlurper().parseText(parseLanMessage(resp.description).body) + + if(body) { + body.commands.each { + def command = ['label' : it.label, 'slug' : it.slug] + //log.debug "getCommandsOfDevice_response>> command: $command" + result.add(command) + } + } + + atomicState.foundCommandOfDevice = result +} + +// discoverDevices +// parameter : +// - hubname : name of hub searching devices +// return : 'discoverDevices_response()' method callback +def discoverDevices(hubname) { + log.debug "discoverDevices>> $hubname" + sendHubCommand(getHubAction(atomicState.harmonyApiServerIP, "/hubs/$hubname/devices", "discoverDevices_response")) +} + +def discoverDevices_response(resp) { + def result = [] + def body = new groovy.json.JsonSlurper().parseText(parseLanMessage(resp.description).body) + log.debug("discoverHubs_response >> $body.devices") + + if(body) { + body.devices.each { + //log.debug "getHubDevices_response: $it.id, $it.label, $it.slug" + def device = ['id' : it.id, 'label' : it.label, 'slug' : it.slug] + result.add(device) + } + } + atomicState.devices = result + +} + + +// discoverHubs +// parameter : +// - host : ip address searching hubs +// return : 'discoverHubs_response()' method callback +def discoverHubs(host) { + log.debug("discoverHubs") + return sendHubCommand(getHubAction(host, "/hubs", "discoverHubs_response")) +} + +def discoverHubs_response(resp) { + def result = [] + def body = new groovy.json.JsonSlurper().parseText(parseLanMessage(resp.description).body) + log.debug("discoverHubs_response >> $body.hubs") + + if(body && body.hubs != null) { + body.hubs.each { + log.debug "discoverHubs_response: $it" + result.add(it) + } + atomicState.discoverdHubs = result + } else { + atomicState.discoverdHubs = null + } +} + +// ----------------------------- +// -------Hub Action API ------- +// getHubAction +// parameter : +// - host : target address to send 'GET' action +// - url : target url +// - callback : response callback method name +def getHubAction(host, url, callback) { + log.debug "getHubAction>> $host, $url, $callback" + return new physicalgraph.device.HubAction("GET ${url} HTTP/1.1\r\nHOST: ${host}\r\n\r\n", + physicalgraph.device.Protocol.LAN, "${host}", [callback: callback]) +} + +// setHubAction +// parameter : +// - host : target address to send 'POST' action +// - url : target url +// - callback : response callback method name +def setHubAction(host, url, callback) { + log.debug "getHubAction>> $host, $url, $callback" + return new physicalgraph.device.HubAction("POST ${url} HTTP/1.1\r\nHOST: ${host}\r\n\r\n", + physicalgraph.device.Protocol.LAN, "${host}", [callback: callback]) +} \ No newline at end of file