From 972dda2fc97b2dda15aeb5ce83baf13961153f53 Mon Sep 17 00:00:00 2001 From: Adrian Caramaliu Date: Tue, 27 Sep 2016 14:49:53 -0500 Subject: [PATCH] MSA-1497: General rule engine application to allow complex conditional execution of device commands --- smartapps/ady624/core.src/core.groovy | 10969 ++++++++++++++++++++++++ 1 file changed, 10969 insertions(+) create mode 100644 smartapps/ady624/core.src/core.groovy diff --git a/smartapps/ady624/core.src/core.groovy b/smartapps/ady624/core.src/core.groovy new file mode 100644 index 0000000..9ab1423 --- /dev/null +++ b/smartapps/ady624/core.src/core.groovy @@ -0,0 +1,10969 @@ +/** + * 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.156.20160927" } +/* + * 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 + * 9/18/2016 >>> v0.2.150.20160918 - Beta M2 - Fixed a problem with condition state changes due to a prior fix in the evaluation display for the dashboard (v0.2.14f) + * 9/16/2016 >>> v0.2.14f.20160916 - Beta M2 - Fixed some minor issues with condition evaluation display in the dashboard. Introducing "not evaluated": blue means evaluated as true, red means evaluated as false, gray means not evaluated at all + * 9/16/2016 >>> v0.2.14e.20160916 - Beta M2 - Fixed a problem with "time is any time of the day" where a events would be scheduled in error + * 9/16/2016 >>> v0.2.14d.20160916 - Beta M2 - Added optional 'ingredients' value1, value2, and value3 to IFTTT Maker request + * 9/08/2016 >>> v0.2.14c.20160908 - Beta M2 - Added a few more system variables, $locationMode and $shmStatus + * 9/02/2016 >>> v0.2.14b.20160902 - Beta M2 - Fixed a problem with execution time measurements + * 9/02/2016 >>> v0.2.14a.20160902 - Beta M2 - Fixed a problem with decimal points on dashboard taps + * 9/02/2016 >>> v0.2.149.20160902 - Beta M2 - Improved exit point speed (removed unnecessary piston refreshes) + * 9/02/2016 >>> v0.2.148.20160902 - Beta M2 - Added instructions for removing dashboard taps. Thank you @dseg for the Tap idea. + * 9/02/2016 >>> v0.2.147.20160902 - Beta M2 - Minor fix with adding taps and API + * 9/02/2016 >>> v0.2.146.20160902 - Beta M2 - Introducing the dashboard taps - tap one to run its associated pistons + * 8/21/2016 >>> v0.2.144.20160821 - Beta M2 - Fixed a bug in accepting an action restriction with a negative offset for the range end + * 8/21/2016 >>> v0.2.143.20160821 - Beta M2 - Minor bug fixes + * 8/20/2016 >>> v0.2.142.20160820 - Beta M2 - Made setVariable use long numbers to avoid range overflows + * 8/20/2016 >>> v0.2.141.20160820 - Beta M2 - Fixed a problem with SWITCH-CASE which would, upon the end of a matching case, send the flow to the second following case's start, executing two cases + * 8/17/2016 >>> v0.2.140.20160817 - Beta M2 - Fixed a problem with triggers and threeAxis orientation + * 8/17/2016 >>> v0.2.140.20160817 - Beta M2 - Fixed a problem with triggers and threeAxis orientation + * 8/17/2016 >>> v0.2.13f.20160817 - Beta M2 - Fixed a problem with caching old orientation values - triggers for orientation did not work correctly + * 8/16/2016 >>> v0.2.13e.20160816 - Beta M2 - Minor fixes for the dashboard, progress on the experimental dashboard + * 8/15/2016 >>> v0.2.13d.20160815 - Beta M2 - Fixed a bug affecting variables (introduced with v0.2.13c), made some dashboard improvements (speed) + * 8/14/2016 >>> v0.2.13c.20160814 - Beta M2 - Minor fix regarding setting a number variable - allowing decimals during the calculus + * 8/14/2016 >>> v0.2.13b.20160814 - Beta M2 - Forced capability Sensor to show (no default attribute in documentation) + * 8/12/2016 >>> v0.2.13a.20160812 - Beta M2 - Initial release of Beta M2 + */ + +/******************************************************************************/ +/*** 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: "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: "pageConditionVsTrigger") + 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() { + def res = dev() + 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 + } + } + + 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 + } + + 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", nextPage:"pageGeneralSettings") { + 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 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 + //dev() + 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") + //input "enabled", "bool", 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", defaultValue: true + 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 + switch (state.config.app.mode) { + case "Latching": + paragraph "A latching Piston - also known as a bi-stable Piston - uses one set of conditions to achieve a 'true' state and a second set of conditions to revert back to its 'false' state" + break + case "Else-If": + paragraph "An Else-If Piston executes a set of actions if an initial condition set evaluates to true, otherwise executes a second set of actions if a second condition set evaluates to true" + break + } + } + + 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 = (int) 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: "$pid..${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 { + 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: "$pid..${pid+1}", defaultValue: pid + } + } + } + } catch(e) { + debug "ERROR: Error while executing pageCondition: ", null, "error", e + } +} + +def pageConditionVsTrigger() { + state.run = "config" + dynamicPage(name: "pageConditionVsTrigger", title: "Conditions versus Trigers", uninstall: false, install: false) { + section() { + paragraph "All Pistons are event-driven. This means that an action is taken whenever something happens while the Piston is watching over. To do so, the Piston subscribes to events from all the devices you use while building your 'If...' and - in case of latching Pistons - your 'But if...' statements as well. Since a Piston subscribes to multiple device events, it is evaluated every time such an event occurs. Depending on your conditions, a device event may not necessarily make any change to the evaluated state of the Piston (think OR), but the Piston is evaluated either way, making it possible to execute actions even if the Piston's status didn't change. More about this under the 'Then...' or 'Else...' sections of the Piston." + paragraph "Events tell Pistons something has changed. Depending on the logic you are trying to implement, sometimes you need to check that the state of a device is within a certain range, and sometimes you need to react to a device state reaching a certain value, list or range.\n\nLet's start with an example. Say you have a temperature sensor and you want to monitor its temperature. You want to be alerted if the temperature is over 100°F. Now, assume the temperature starts at 99°F and increases steadily at a rate of one degree Fahrenheit per minute.", title: "State vs. State Change" + paragraph "If you use a condition, the Piston will be evaluated every one minute, as the temperature changes. The first evaluation will result in a false condition as the temperature reaches 100°F. Remember, our condition is for the temperature to be OVER 100°F. The next minute, your temperature is reported at 101°F which will cause the Piston to evaluate true this time. Your 'Then...' actions will now have a chance at execution. The next minute, as the temperature reaches 102°F, the Piston will again evaluate true and proceed to executing your 'Then...' actions. This will happen for as long as the temperature remains over 100°F and will possibly execute your actions every time a new temperature is read that matches that condition. You could use this to pass the information along to another service (think IFTTT) or display it on some sort of screen. But not for turning on a thermostat - you don't neet to turn the thermostat on every one minute, it's very likely already on from your last execution.", title: "Using a Condition" + paragraph "If you use a trigger, the Piston will now be on the lookout for a certain state change that 'triggers' our evaluation to become true. You will no longer look for a temperature over 100°F, but instead you will be looking for when the temperature exceeds 100°F. This means your actions will only be executed when the temperature actually transitioned from below or equal to 100°F to over 100°F. This means your actions will only execute once and for the Piston to fire your actions again, the temperature would have to first drop at or below 100°F and then raise again to exceed your set threshold of 100°F. Now, this you could use to control a thermostat, right?", title: "Using a Trigger" + } + } +} + +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 = [] + for (vcmd in virtualCommands().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 == "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: "$pid..${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(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.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 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.caramaliu.com/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 iftttKey() { + if (parent) return parent.iftttKey() + def modules = atomicState.modules + return (modules && modules["IFTTT"] && modules["IFTTT"].connected ? modules["IFTTT"].key : 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 +} + +//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 "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 + while (settings.findAll { 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 = (int) 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.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 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() + 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", "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 { + 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", "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": + //these two 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 "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 + setVariable("\$currentEventDeviceIndex", cast(evt.jsonData ? evt.jsonData[capability.data] : 0, "number"), true) + def subDeviceId = "#${evt.jsonData ? evt.jsonData[capability.data] : "0"}".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", "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 events = device.eventsSince(new Date(now() - minutes * 60000)); + 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 + //return momentary || (oldValue != currentValue) +} + +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_repeatAction(action, task, time) { + def result = cmd_wait(action, task, time) + result.schedule = true + 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 + 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 { + + def safetyNet = false + + //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 + //since we may timeout here, install the safety net + if (!safetyNet) { + safetyNet = true + debug "Installing ST safety net", null, "trace" + runIn(90, recoveryHandler) + } + //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) + 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) + state.nextScheduledTime = null + } + + //we're done with the scheduling, let's do some real work, if we have any + if (immediateTasks) { + if (!safetyNet) { + //setup a safety net ST schedule to resume the process if we fail + safetyNet = true + debug "Installing ST safety net", null, "trace" + runIn(90, recoveryHandler) + } + + 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 + + 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(param[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) + 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) + 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_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/" + iftttKey(), + 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/" + iftttKey()){ 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 = 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_repeatAction(device, action, task, suffix = "") { + state.rerunSchedule = true + def params = (task && task.data && task.data.p && task.data.p.size()) ? task.data.p : [] + if (!action || (params.size() != 2)) { + return false + } + scheduleAction(action) + return true +} + +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 + 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 + 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 : 60); 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 +private _traverseConditions(parent, conditionId) { + if (parent.id == conditionId) { + return parent + } + for (condition in parent.children) { + def result = _traverseConditions(condition, conditionId) + 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 getConditionTriggerCount(app.conditions) + (settings.mode in ["Latching", "And-If", "Or-If"] ? getConditionTriggerCount(app.otherConditions) : 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 getConditionConditionCount(app.conditions) + (!(settings.mode in ["Basic", "Simple", "Follow-Up"]) ? getConditionConditionCount(app.otherConditions) : 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", "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 "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", "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 : 4 + 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 + } + /* + for (attribute in attributes()) { + if ((attribute.name == name) || (name2 && (attribute.name == name2))) { + return attribute + } + } + */ + //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) { + for(def command in virtualCommands()) { + if (command.name == name) { + return command + } + } + return null +} + +private getCommandByDisplay(display) { + for(def command in commands()) { + if (command.display == display) { + return command + } + } + return null +} + +private getVirtualCommandByDisplay(display) { + for(def command in virtualCommands()) { + 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", "ifttt", "attribute", "attributes", "contact", "contacts", "variable", "variables", "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: "dimmers", ], + [ 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: "switch", display: "Outlet", attribute: "switch", commands: ["on", "off"], multiple: true, devices: "outlets", ], + [ 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: "stepSensor", display: "Step Sensor", attribute: "steps", multiple: true, devices: "step sensors", ], + [ 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", commands: null, /* wish we could control 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 + 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: "Control [devices]", 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: "Control [devices]", 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: "Control [devices]", display: "Set color", parameters: ["?*Color:color","?*RGB:text","Hue:hue","Saturation:saturation","Lightness:level"], attribute: "color", value: "*|color", ], + [ name: "setLevel", category: "Convenience", group: "Control [devices]", display: "Set level", parameters: ["Level:level"], description: "Set level to {0}%", attribute: "level", value: "*|number", ], + [ name: "setHue", category: "Convenience", group: "Control [devices]", display: "Set hue", parameters: ["Hue:hue"], description: "Set hue to {0}°", attribute: "hue", value: "*|number", ], + [ name: "setSaturation", category: "Convenience", group: "Control [devices]", display: "Set saturation", parameters: ["Saturation:saturation"], description: "Set saturation to {0}%", attribute: "saturation", value: "*|number", ], + [ name: "setColorTemperature", category: "Convenience", group: "Control [devices]", display: "Set color temperature", parameters: ["Color Temperature:colorTemperature"], description: "Set color temperature to {0}°K", attribute: "colorTemperature", value: "*|number", ], + [ name: "open", category: "Convenience", group: "Control [devices]", display: "Open", attribute: "door", value: "open", ], + [ name: "close", category: "Convenience", group: "Control [devices]", display: "Close", attribute: "door", value: "close", ], + [ name: "windowShade.open", category: "Convenience", group: "Control [devices]", display: "Open fully", ], + [ name: "windowShade.close", category: "Convenience", group: "Control [devices]", display: "Close fully", ], + [ name: "windowShade.presetPosition", category: "Convenience", group: "Control [devices]", display: "Move to preset position", ], + [ name: "lock", category: "Safety and Security", group: "Control [devices]", display: "Lock", attribute: "lock", value: "locked", ], + [ name: "unlock", category: "Safety and Security", group: "Control [devices]", display: "Unlock", attribute: "lock", value: "unlocked", ], + [ name: "take", category: "Safety and Security", group: "Control [devices]", display: "Take a picture", ], + [ name: "alarm.off", category: "Safety and Security", group: "Control [devices]", display: "Stop", attribute: "alarm", value: "off", ], + [ name: "alarm.strobe", category: "Safety and Security", group: "Control [devices]", display: "Strobe", attribute: "alarm", value: "strobe", ], + [ name: "alarm.siren", category: "Safety and Security", group: "Control [devices]", display: "Siren", attribute: "alarm", value: "siren", ], + [ name: "alarm.both", category: "Safety and Security", group: "Control [devices]", display: "Strobe and Siren", attribute: "alarm", value: "both", ], + [ name: "thermostat.off", category: "Comfort", group: "Control [devices]", display: "Set to Off", attribute: "thermostatMode", value: "off", ], + [ name: "thermostat.heat", category: "Comfort", group: "Control [devices]", display: "Set to Heat", attribute: "thermostatMode", value: "heat", ], + [ name: "thermostat.cool", category: "Comfort", group: "Control [devices]", display: "Set to Cool", attribute: "thermostatMode", value: "cool", ], + [ name: "thermostat.auto", category: "Comfort", group: "Control [devices]", display: "Set to Auto", attribute: "thermostatMode", value: "auto", ], + [ name: "thermostat.emergencyHeat", category: "Comfort", group: "Control [devices]", display: "Set to Emergency Heat", attribute: "thermostatMode", value: "emergencyHeat", ], + [ name: "thermostat.quickSetHeat", category: "Comfort", group: "Control [devices]", display: "Quick set heating point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set quick heating point at {0}$tempUnit", ], + [ name: "thermostat.quickSetCool", category: "Comfort", group: "Control [devices]", display: "Quick set cooling point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set quick cooling point at {0}$tempUnit", ], + [ name: "thermostat.setHeatingSetpoint", category: "Comfort", group: "Control [devices]", display: "Set heating point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set heating point at {0}$tempUnit", attribute: "thermostatHeatingSetpoint", value: "*|decimal", ], + [ name: "thermostat.setCoolingSetpoint", category: "Comfort", group: "Control [devices]", display: "Set cooling point", parameters: ["Desired temperature:thermostatSetpoint"], description: "Set cooling point at {0}$tempUnit", attribute: "thermostatCoolingSetpoint", value: "*|decimal", ], + [ name: "thermostat.setThermostatMode", category: "Comfort", group: "Control [devices]", display: "Set thermostat mode", parameters: ["Mode:thermostatMode"], description: "Set thermostat mode to {0}", attribute: "thermostatMode", value: "*|string", ], + [ name: "fanOn", category: "Comfort", group: "Control [devices]", display: "Set fan to On", ], + [ name: "fanCirculate", category: "Comfort", group: "Control [devices]", display: "Set fan to Circulate", ], + [ name: "fanAuto", category: "Comfort", group: "Control [devices]", display: "Set fan to Auto", ], + [ name: "setThermostatFanMode", category: "Comfort", group: "Control [devices]", display: "Set fan mode", parameters: ["Fan mode:thermostatFanMode"], description: "Set fan mode to {0}", ], + [ name: "play", category: "Entertainment", group: "Control [devices]", display: "Play", ], + [ name: "pause", category: "Entertainment", group: "Control [devices]", display: "Pause", ], + [ name: "stop", category: "Entertainment", group: "Control [devices]", display: "Stop", ], + [ name: "nextTrack", category: "Entertainment", group: "Control [devices]", display: "Next track", ], + [ name: "previousTrack", category: "Entertainment", group: "Control [devices]", display: "Previous track", ], + [ name: "mute", category: "Entertainment", group: "Control [devices]", display: "Mute", ], + [ name: "unmute", category: "Entertainment", group: "Control [devices]", display: "Unmute", ], + [ name: "musicPlayer.setLevel", category: "Entertainment", group: "Control [devices]", display: "Set volume", parameters: ["Level:level"], description: "Set volume to {0}%", ], + [ name: "playText", category: "Entertainment", group: "Control [devices]", 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: "Control [devices]", 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: "Control [devices]", 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: "Control [devices]", display: "Resume track", ], + [ name: "restoreTrack", category: "Entertainment", group: "Control [devices]", display: "Restore track", ], + [ name: "speak", category: "Entertainment", group: "Control [devices]", display: "Speak", parameters: ["Message:string"], description: "Speak \"{0}\"", ], + [ name: "startActivity", category: "Entertainment", group: "Control [devices]", display: "Start activity", parameters: ["Activity:string"], description: "Start activity\"{0}\"", ], + [ name: "getCurrentActivity", category: "Entertainment", group: "Control [devices]", display: "Get current activity", ], + [ name: "getAllActivities", category: "Entertainment", group: "Control [devices]", display: "Get all activities", ], + [ name: "push", category: "Other", group: "Control [devices]", display: "Push", ], + [ name: "beep", category: "Other", group: "Control [devices]", display: "Beep", ], + [ name: "timedSession.setTimeRemaining", category: "Other", group: "Control [devices]", display: "Set remaining time", parameters: ["Remaining time [s]:number"], description: "Set remaining time to {0}s", ], + [ name: "timedSession.start", category: "Other", group: "Control [devices]", display: "Start timed session", ], + [ name: "timedSession.stop", category: "Other", group: "Control [devices]", display: "Stop timed session", ], + [ name: "timedSession.pause", category: "Other", group: "Control [devices]", display: "Pause timed session", ], + [ name: "timedSession.cancel", category: "Other", group: "Control [devices]", display: "Cancel timed session", ], + [ name: "setConsumableStatus", category: "Other", group: "Control [devices]", 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..10]"], 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}", ], + + ] +} + +private virtualCommands() { + return [ + [ 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: "repeatAction", display: "Repeat whole action", parameters: ["Interval:number[1..1440]","Unit:enum[seconds,minutes,hours]"], immediate: true, location: true, description: "Repeat whole action every {0} {1}", aggregated: true], + [ 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, ], + ] + (location.contactBookEnabled ? [ + [ name: "sendNotificationToContacts",requires: [], display: "Send notification to contacts", parameters: ["Message:text","Contacts:contacts","Save notification:bool"], location: true, description: "Send notification '{0}' to {1}", aggregated: true, ], + ] : []) + (iftttKey() ? [ + [ name: "iftttMaker",requires: [], 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, ], + ] : []) +} + +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: "2000..7000", 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", unit: null, options: null, subscribe: "threeAxis", ], + [ name: "axisY", type: "number", range: "-1024..1024", unit: null, options: null, subscribe: "threeAxis", ], + [ name: "axisZ", type: "number", range: "-1024..1024", unit: null, options: null, 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: "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: "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 colors() { + return [ + [ name: "Random", rgb: "#000000", h: 0, s: 0, l: 0, ], + [ name: "Soft White", rgb: "#B6DA7C", h: 83, s: 44, l: 67, ], + [ name: "Warm White", rgb: "#DAF17E", h: 72, s: 20, l: 72, ], + [ name: "Daylight White", rgb: "#CEF4FD", h: 191, s: 9, l: 90, ], + [ name: "Cool White", rgb: "#F3F6F7", h: 187, s: 19, l: 96, ], + [ name: "White", rgb: "#FFFFFF", h: 0, s: 0, l: 100, ], + [ name: "Alice Blue", rgb: "#F0F8FF", h: 208, s: 100, l: 97, ], + [ name: "Antique White", rgb: "#FAEBD7", h: 34, s: 78, l: 91, ], + [ name: "Aqua", rgb: "#00FFFF", h: 180, s: 100, l: 50, ], + [ name: "Aquamarine", rgb: "#7FFFD4", h: 160, s: 100, l: 75, ], + [ name: "Azure", rgb: "#F0FFFF", h: 180, s: 100, l: 97, ], + [ name: "Beige", rgb: "#F5F5DC", h: 60, s: 56, l: 91, ], + [ name: "Bisque", rgb: "#FFE4C4", h: 33, s: 100, l: 88, ], + [ name: "Blanched Almond", rgb: "#FFEBCD", h: 36, s: 100, l: 90, ], + [ name: "Blue", rgb: "#0000FF", h: 240, s: 100, l: 50, ], + [ name: "Blue Violet", rgb: "#8A2BE2", h: 271, s: 76, l: 53, ], + [ name: "Brown", rgb: "#A52A2A", h: 0, s: 59, l: 41, ], + [ name: "Burly Wood", rgb: "#DEB887", h: 34, s: 57, l: 70, ], + [ name: "Cadet Blue", rgb: "#5F9EA0", h: 182, s: 25, l: 50, ], + [ name: "Chartreuse", rgb: "#7FFF00", h: 90, s: 100, l: 50, ], + [ name: "Chocolate", rgb: "#D2691E", h: 25, s: 75, l: 47, ], + [ name: "Coral", rgb: "#FF7F50", h: 16, s: 100, l: 66, ], + [ name: "Corn Flower Blue", rgb: "#6495ED", h: 219, s: 79, l: 66, ], + [ name: "Corn Silk", rgb: "#FFF8DC", h: 48, s: 100, l: 93, ], + [ name: "Crimson", rgb: "#DC143C", h: 348, s: 83, l: 58, ], + [ name: "Cyan", rgb: "#00FFFF", h: 180, s: 100, l: 50, ], + [ name: "Dark Blue", rgb: "#00008B", h: 240, s: 100, l: 27, ], + [ name: "Dark Cyan", rgb: "#008B8B", h: 180, s: 100, l: 27, ], + [ name: "Dark Golden Rod", rgb: "#B8860B", h: 43, s: 89, l: 38, ], + [ name: "Dark Gray", rgb: "#A9A9A9", h: 0, s: 0, l: 66, ], + [ name: "Dark Green", rgb: "#006400", h: 120, s: 100, l: 20, ], + [ name: "Dark Khaki", rgb: "#BDB76B", h: 56, s: 38, l: 58, ], + [ name: "Dark Magenta", rgb: "#8B008B", h: 300, s: 100, l: 27, ], + [ name: "Dark Olive Green", rgb: "#556B2F", h: 82, s: 39, l: 30, ], + [ name: "Dark Orange", rgb: "#FF8C00", h: 33, s: 100, l: 50, ], + [ name: "Dark Orchid", rgb: "#9932CC", h: 280, s: 61, l: 50, ], + [ name: "Dark Red", rgb: "#8B0000", h: 0, s: 100, l: 27, ], + [ name: "Dark Salmon", rgb: "#E9967A", h: 15, s: 72, l: 70, ], + [ name: "Dark Sea Green", rgb: "#8FBC8F", h: 120, s: 25, l: 65, ], + [ name: "Dark Slate Blue", rgb: "#483D8B", h: 248, s: 39, l: 39, ], + [ name: "Dark Slate Gray", rgb: "#2F4F4F", h: 180, s: 25, l: 25, ], + [ name: "Dark Turquoise", rgb: "#00CED1", h: 181, s: 100, l: 41, ], + [ name: "Dark Violet", rgb: "#9400D3", h: 282, s: 100, l: 41, ], + [ name: "Deep Pink", rgb: "#FF1493", h: 328, s: 100, l: 54, ], + [ name: "Deep Sky Blue", rgb: "#00BFFF", h: 195, s: 100, l: 50, ], + [ name: "Dim Gray", rgb: "#696969", h: 0, s: 0, l: 41, ], + [ name: "Dodger Blue", rgb: "#1E90FF", h: 210, s: 100, l: 56, ], + [ name: "Fire Brick", rgb: "#B22222", h: 0, s: 68, l: 42, ], + [ name: "Floral White", rgb: "#FFFAF0", h: 40, s: 100, l: 97, ], + [ name: "Forest Green", rgb: "#228B22", h: 120, s: 61, l: 34, ], + [ name: "Fuchsia", rgb: "#FF00FF", h: 300, s: 100, l: 50, ], + [ name: "Gainsboro", rgb: "#DCDCDC", h: 0, s: 0, l: 86, ], + [ name: "Ghost White", rgb: "#F8F8FF", h: 240, s: 100, l: 99, ], + [ name: "Gold", rgb: "#FFD700", h: 51, s: 100, l: 50, ], + [ name: "Golden Rod", rgb: "#DAA520", h: 43, s: 74, l: 49, ], + [ name: "Gray", rgb: "#808080", h: 0, s: 0, l: 50, ], + [ name: "Green", rgb: "#008000", h: 120, s: 100, l: 25, ], + [ name: "Green Yellow", rgb: "#ADFF2F", h: 84, s: 100, l: 59, ], + [ name: "Honeydew", rgb: "#F0FFF0", h: 120, s: 100, l: 97, ], + [ name: "Hot Pink", rgb: "#FF69B4", h: 330, s: 100, l: 71, ], + [ name: "Indian Red", rgb: "#CD5C5C", h: 0, s: 53, l: 58, ], + [ name: "Indigo", rgb: "#4B0082", h: 275, s: 100, l: 25, ], + [ name: "Ivory", rgb: "#FFFFF0", h: 60, s: 100, l: 97, ], + [ name: "Khaki", rgb: "#F0E68C", h: 54, s: 77, l: 75, ], + [ name: "Lavender", rgb: "#E6E6FA", h: 240, s: 67, l: 94, ], + [ name: "Lavender Blush", rgb: "#FFF0F5", h: 340, s: 100, l: 97, ], + [ name: "Lawn Green", rgb: "#7CFC00", h: 90, s: 100, l: 49, ], + [ name: "Lemon Chiffon", rgb: "#FFFACD", h: 54, s: 100, l: 90, ], + [ name: "Light Blue", rgb: "#ADD8E6", h: 195, s: 53, l: 79, ], + [ name: "Light Coral", rgb: "#F08080", h: 0, s: 79, l: 72, ], + [ name: "Light Cyan", rgb: "#E0FFFF", h: 180, s: 100, l: 94, ], + [ name: "Light Golden Rod Yellow", rgb: "#FAFAD2", h: 60, s: 80, l: 90, ], + [ name: "Light Gray", rgb: "#D3D3D3", h: 0, s: 0, l: 83, ], + [ name: "Light Green", rgb: "#90EE90", h: 120, s: 73, l: 75, ], + [ name: "Light Pink", rgb: "#FFB6C1", h: 351, s: 100, l: 86, ], + [ name: "Light Salmon", rgb: "#FFA07A", h: 17, s: 100, l: 74, ], + [ name: "Light Sea Green", rgb: "#20B2AA", h: 177, s: 70, l: 41, ], + [ name: "Light Sky Blue", rgb: "#87CEFA", h: 203, s: 92, l: 75, ], + [ name: "Light Slate Gray", rgb: "#778899", h: 210, s: 14, l: 53, ], + [ name: "Light Steel Blue", rgb: "#B0C4DE", h: 214, s: 41, l: 78, ], + [ name: "Light Yellow", rgb: "#FFFFE0", h: 60, s: 100, l: 94, ], + [ name: "Lime", rgb: "#00FF00", h: 120, s: 100, l: 50, ], + [ name: "Lime Green", rgb: "#32CD32", h: 120, s: 61, l: 50, ], + [ name: "Linen", rgb: "#FAF0E6", h: 30, s: 67, l: 94, ], + [ name: "Maroon", rgb: "#800000", h: 0, s: 100, l: 25, ], + [ name: "Medium Aquamarine", rgb: "#66CDAA", h: 160, s: 51, l: 60, ], + [ name: "Medium Blue", rgb: "#0000CD", h: 240, s: 100, l: 40, ], + [ name: "Medium Orchid", rgb: "#BA55D3", h: 288, s: 59, l: 58, ], + [ name: "Medium Purple", rgb: "#9370DB", h: 260, s: 60, l: 65, ], + [ name: "Medium Sea Green", rgb: "#3CB371", h: 147, s: 50, l: 47, ], + [ name: "Medium Slate Blue", rgb: "#7B68EE", h: 249, s: 80, l: 67, ], + [ name: "Medium Spring Green", rgb: "#00FA9A", h: 157, s: 100, l: 49, ], + [ name: "Medium Turquoise", rgb: "#48D1CC", h: 178, s: 60, l: 55, ], + [ name: "Medium Violet Red", rgb: "#C71585", h: 322, s: 81, l: 43, ], + [ name: "Midnight Blue", rgb: "#191970", h: 240, s: 64, l: 27, ], + [ name: "Mint Cream", rgb: "#F5FFFA", h: 150, s: 100, l: 98, ], + [ name: "Misty Rose", rgb: "#FFE4E1", h: 6, s: 100, l: 94, ], + [ name: "Moccasin", rgb: "#FFE4B5", h: 38, s: 100, l: 85, ], + [ name: "Navajo White", rgb: "#FFDEAD", h: 36, s: 100, l: 84, ], + [ name: "Navy", rgb: "#000080", h: 240, s: 100, l: 25, ], + [ name: "Old Lace", rgb: "#FDF5E6", h: 39, s: 85, l: 95, ], + [ name: "Olive", rgb: "#808000", h: 60, s: 100, l: 25, ], + [ name: "Olive Drab", rgb: "#6B8E23", h: 80, s: 60, l: 35, ], + [ name: "Orange", rgb: "#FFA500", h: 39, s: 100, l: 50, ], + [ name: "Orange Red", rgb: "#FF4500", h: 16, s: 100, l: 50, ], + [ name: "Orchid", rgb: "#DA70D6", h: 302, s: 59, l: 65, ], + [ name: "Pale Golden Rod", rgb: "#EEE8AA", h: 55, s: 67, l: 80, ], + [ name: "Pale Green", rgb: "#98FB98", h: 120, s: 93, l: 79, ], + [ name: "Pale Turquoise", rgb: "#AFEEEE", h: 180, s: 65, l: 81, ], + [ name: "Pale Violet Red", rgb: "#DB7093", h: 340, s: 60, l: 65, ], + [ name: "Papaya Whip", rgb: "#FFEFD5", h: 37, s: 100, l: 92, ], + [ name: "Peach Puff", rgb: "#FFDAB9", h: 28, s: 100, l: 86, ], + [ name: "Peru", rgb: "#CD853F", h: 30, s: 59, l: 53, ], + [ name: "Pink", rgb: "#FFC0CB", h: 350, s: 100, l: 88, ], + [ name: "Plum", rgb: "#DDA0DD", h: 300, s: 47, l: 75, ], + [ name: "Powder Blue", rgb: "#B0E0E6", h: 187, s: 52, l: 80, ], + [ name: "Purple", rgb: "#800080", h: 300, s: 100, l: 25, ], + [ name: "Red", rgb: "#FF0000", h: 0, s: 100, l: 50, ], + [ name: "Rosy Brown", rgb: "#BC8F8F", h: 0, s: 25, l: 65, ], + [ name: "Royal Blue", rgb: "#4169E1", h: 225, s: 73, l: 57, ], + [ name: "Saddle Brown", rgb: "#8B4513", h: 25, s: 76, l: 31, ], + [ name: "Salmon", rgb: "#FA8072", h: 6, s: 93, l: 71, ], + [ name: "Sandy Brown", rgb: "#F4A460", h: 28, s: 87, l: 67, ], + [ name: "Sea Green", rgb: "#2E8B57", h: 146, s: 50, l: 36, ], + [ name: "Sea Shell", rgb: "#FFF5EE", h: 25, s: 100, l: 97, ], + [ name: "Sienna", rgb: "#A0522D", h: 19, s: 56, l: 40, ], + [ name: "Silver", rgb: "#C0C0C0", h: 0, s: 0, l: 75, ], + [ name: "Sky Blue", rgb: "#87CEEB", h: 197, s: 71, l: 73, ], + [ name: "Slate Blue", rgb: "#6A5ACD", h: 248, s: 53, l: 58, ], + [ name: "Slate Gray", rgb: "#708090", h: 210, s: 13, l: 50, ], + [ name: "Snow", rgb: "#FFFAFA", h: 0, s: 100, l: 99, ], + [ name: "Spring Green", rgb: "#00FF7F", h: 150, s: 100, l: 50, ], + [ name: "Steel Blue", rgb: "#4682B4", h: 207, s: 44, l: 49, ], + [ name: "Tan", rgb: "#D2B48C", h: 34, s: 44, l: 69, ], + [ name: "Teal", rgb: "#008080", h: 180, s: 100, l: 25, ], + [ name: "Thistle", rgb: "#D8BFD8", h: 300, s: 24, l: 80, ], + [ name: "Tomato", rgb: "#FF6347", h: 9, s: 100, l: 64, ], + [ name: "Turquoise", rgb: "#40E0D0", h: 174, s: 72, l: 56, ], + [ name: "Violet", rgb: "#EE82EE", h: 300, s: 76, l: 72, ], + [ name: "Wheat", rgb: "#F5DEB3", h: 39, s: 77, l: 83, ], + [ name: "White Smoke", rgb: "#F5F5F5", h: 0, s: 0, l: 96, ], + [ name: "Yellow", rgb: "#FFFF00", h: 60, s: 100, l: 50, ], + [ name: "Yellow Green", rgb: "#9ACD32", h: 80, s: 61, l: 50, ], + ] +} + +private colorOptions() { + return colors()*.name +} + +private getColorByName(name, ownerId = null, taskId = null) { + if (name == "Random") { + //randomize the color + def valName = "$ownerId-$taskId" + def randomIndex = 6 + Math.round(Math.random() * (colors().size() - 7)) as Integer + def result = getRandomValue(valName) ?: colors()[randomIndex] + setRandomValue(valName, result) + return result + } + for (color in colors()) { + if (color.name == name) { + return color + } + } + return [ name: "White", rgb: "#FFFFFF", h: 0, s: 100, l: 100, ] +} + +/******************************************************************************/ +/*** DEVELOPMENT AREA ***/ +/*** Write code here and then move it to its proper location ***/ +/******************************************************************************/ + +private dev() { +} \ No newline at end of file