Update influxdb-logger.groovy
[smartapps.git] / third-party / neato-connect.groovy
1 /**
2  *  Neato (Connect)
3  *
4  *  Copyright 2016 Alex Lee Yuk Cheung
5  *
6  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
7  *  in compliance with the License. You may obtain a copy of the License at:
8  *
9  *  http://www.apache.org/licenses/LICENSE-2.0
10  *
11  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
12  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
13  *  for the specific language governing permissions and limitations under the License.
14  *
15  *  VERSION HISTORY
16  *      28-06-2018:     1.2.4b - Bug fix. Stop nullPointerException on reschedule method.
17  *      18-04-2018:     1.2.4 - Show restriction summary text in app when contact sensor restrictions are configured.
18  *      19-01-2018:     1.2.3 - Allow contact sensors to trigger clean if conditions are met.
19  *      17-01-2018:     1.2.2 - Allow contact sensors to restrict Botvac start.
20  *      06-01-2018:     1.2.1e - Fix null pointer exception on new installations.
21  *      05-01-2018:     1.2.1d - Another attempt to remove null reference when Botvac is removed.
22  *      05-01-2018:     1.2.1c - Attempt to remove null reference when Botvac is removed.
23  *      14-10-2017:     1.2.1b - Fix to setting Smart Home Monitor.
24  *      20-09-2017:     1.2.1 BETA - Allow option for a SmartSchedule 'day' be measured from midnight rather than last cleaning time.
25  *      06-07-2017: 1.2h - Bug fix. Fix to smart schedule event handler typo preventing SHM mode changing. Fix to allow delayed start for multiple botvacs.
26  *      30-05-2017: 1.2g - Bug fix. Null botvac ID generated when no trigger smart schedule is set.
27  *      23-03-2017: 1.2f - Bug fix. Neato Botvac null pointer when start delay is set.
28  *      16-03-2017: 1.2e - Bug fix. Enforce single instance of app.
29  *  16-03-2017: 1.2d - Bug fix. Schedule not reset automatically when clean starts in some scenarios.
30  *                                       - Bug fix. Switch triggers not working.
31  *  06-03-2017: 1.2c - Bug fix. Schedule ignored when SS notifications are turned off for mode and switch triggers.
32  *  02-03-2017: 1.2b - Critical error fix that stopped cleaning completely.
33  *  23-02-2017: 1.2 - Add delay option for clean when using Mode as trigger. Add option to disable notification before scheduled clean.
34  *  27-01-2017: 1.2 BETA Release 2 - Fix to scheduler.
35  *  25-01-2017: 1.2 BETA Release 1b - Minor fix to SmartSchedule menus.
36  *  24-01-2017: 1.2 BETA Release 1 - Individual SmartSchedule for each Botvac. (Loses SmartSchedule from earlier versions).
37  *
38  *  17-01-2017: 1.1.7b - Clean up display and formatting for multiple Botvacs.
39  *  12-01-2017: 1.1.7 - Add authentication scope for Maps. Added reauthentication option.
40  *
41  *  26-11-2016: 1.1.6 - Enforce SHM mode if SHM is changed during a clean.
42  *
43  *  01-11-2016: 1.1.5 - Improved handling of lost credentials to Neato. Better time zone handling.
44  *
45  *      24-10-2016: 1.1.4b - Bug fix. Override switch handler fix to prevent false negatives. 
46  *      23-10-2016: 1.1.4 - Improve error notification from device status.
47  *
48  *      21-10-2016: 1.1.3b - Force poll on settings update.
49  *      20-10-2016: 1.1.3 - Allow device handler to display smart scheduling information.
50  *
51  *      20-10-2016: 1.1.2b - Bug fix. SmartSchedule does not operate if force clean option is disabled.
52  *      19-10-2016:     1.1.2 - Option to specify "no trigger" in SmartSchedule. Notification when Force clean is due in 24 hours.
53                                                 Separate Smart schedule time markers from force clean time markers.
54  *
55  *      19-10-2016:     1.1.1b - Unschedule auto dock if cleaning is resumed.
56  *      18-10-2016: 1.1.1 - Allow smart schedule to also be triggered on presence and switch events. Add option to specify how override switches work (all or any).
57  *
58  *      18-10-2016: 1.1d - Bug fix. Custom state validation errors and error saving page message when upgrading from 1.0 to 1.1.
59  *      18-10-2016: 1.1c - Bug fix. Smart schedule was not updating last clean time properly when Botvac was activated.
60  *      17-10-2016: 1.1b - Set last clean value to new devices for smart schedule.
61  *      17-10-2016: 1.1 - SmartSchedule functionality and minor fixes 
62  *
63  *      15-10-2016: 1.0c - Fix to auto SHM mode not triggering
64  *      14-10-2016: 1.0b - Minor fix to preference list
65  *      14-10-2016: 1.0 - Initial Version
66  */
67 definition(
68     name: "Neato (Connect)",
69     namespace: "alyc100",
70     author: "Alex Lee Yuk Cheung",
71     description: "Integration to Neato Robotics Connected Series robot vacuums",
72     category: "",
73     iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png",
74     iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png",
75     iconX3Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png",
76     oauth: true,
77     singleInstance: true)
78
79 {
80         appSetting "clientId"
81         appSetting "clientSecret"
82 }
83
84
85 preferences {
86         page(name: "auth", title: "Neato", nextPage:"", content:"authPage", uninstall: true, install:true)
87     page(name: "selectDevicePAGE")
88     page(name: "preferencesPAGE")
89     page(name: "notificationsPAGE")
90     page(name: "smartSchedulePAGE")
91     page(name: "timeIntervalPAGE")
92 }
93
94 mappings {
95     path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
96     path("/oauth/callback") {action: [GET: "callback"]}
97 }
98
99 def authPage() {
100     log.debug "authPage()"
101
102         if(!atomicState.accessToken) { //this is to access token for 3rd party to make a call to connect app
103                 atomicState.accessToken = createAccessToken()
104         }
105
106         def description
107         def uninstallAllowed = false
108         def oauthTokenProvided = false
109
110         if(atomicState.authToken) {
111                 description = "You are connected."
112                 uninstallAllowed = true
113                 oauthTokenProvided = true
114         } else {
115                 description = "Click to enter Neato Credentials"
116         }
117
118         def redirectUrl = buildRedirectUrl
119         log.debug "RedirectUrl = ${redirectUrl}"
120         // get rid of next button until the user is actually auth'd
121         if (!oauthTokenProvided) {
122                 return dynamicPage(name: "auth", title: "Login", nextPage: "", uninstall:uninstallAllowed) {
123                 section { headerSECTION() }
124                         section() {
125                                 paragraph "Tap below to log in to the Neato service and authorize SmartThings access."
126                                 href url:redirectUrl, style:"embedded", required:true, title:"Neato", description:description
127                         }
128                 }
129     } else {
130                 updateDevices()
131         //Disable push option if contact book is enabled
132                 if (location.contactBookEnabled) {
133                 settings.sendPush = false
134         }
135         
136         dynamicPage(name: "auth", uninstall: false, install: false) {
137                 section { headerSECTION() }
138             
139                         section ("Choose your Neato Botvacs:") {
140                                 href("selectDevicePAGE", title: null, description: devicesSelected() ? "Devices:" + getDevicesSelectedString() : "Tap to select your Neato Botvacs", state: devicesSelected())
141                 }
142             if (devicesSelected() == "complete") {
143                         section ("SmartSchedule Configuration:") {
144                                         if (selectedBotvacs.size() > 0) {
145                                 selectedBotvacs.each() {
146                             //Migrate settings from v1.1 and earlier to v1.1.1
147                                                 if (settings["smartScheduleEnabled#$it"] && settings["ssScheduleTrigger#$it"] == null) {
148                                                         settings["ssScheduleTrigger#$it"] = "mode"
149                                                 }
150                             def ssEnabled = smartScheduleSelected(it)
151                             href("smartSchedulePAGE", params: ["botvacId": it], title: "SmartSchedule for ${state.botvacDevices[it]}", description: settings["smartScheduleEnabled#$it"] ? "${getSmartScheduleString(it)}" : "Tap to configure SmartSchedule for ${state.botvacDevices[it]}", state: ssEnabled, required: false, submitOnChange: false)
152                                         }
153                         }
154                 }
155                 section ("Preferences:") {
156                                         href("preferencesPAGE", title: null, description: preferencesSelected() ? getPreferencesString() : "Tap to configure preferences", state: preferencesSelected())
157                         }
158                         section ("Notifications:") {
159                                         href("notificationsPAGE", title: null, description: notificationsSelected() ? getNotificationsString() : "Tap to configure notifications", state: notificationsSelected())
160                         }
161                
162                         def botvacList = ""
163                 section("Botvac Status:") {
164                         getChildDevices().each { childDevice -> 
165                                                 try {
166                             paragraph image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_botvac_image.png", "${childDevice.displayName} is ${childDevice.currentStatus}. Battery is ${childDevice.currentBattery}%"
167                                                 }
168                                         catch (e) {
169                                                 log.trace "Error checking status."
170                                         log.trace e
171                                         }
172                                         }
173                 }
174                 }
175             section() {
176                                 paragraph "Tap below to reauthenticate to the Neato service and reauthorize SmartThings access."
177                                 href url:redirectUrl, style:"embedded", required:false, title:"Neato", description:description
178                         }
179         }
180         }
181 }
182
183 def selectDevicePAGE() {
184         updateDevices()
185         dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) {
186         section { headerSECTION() }
187         section() {
188                         paragraph "Tap below to see the list of Neato Botvacs available in your Neato account and select the ones you want to connect to SmartThings."
189                 input "selectedBotvacs", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_botvac_image.png", required:false, title:"Select Neato Devices \n(${state.botvacDevices.size() ?: 0} found)", multiple:true, options:state.botvacDevices
190         }
191     }
192 }
193
194 def smartSchedulePAGE(params) {
195         log.debug "PARAMS: $params"
196     if (params.containsKey("botvacId")) state.configBotvacId = params?.botvacId
197         def botvacId = state.configBotvacId
198         return dynamicPage(name: "smartSchedulePAGE", title: "SmartSchedule for ${state.botvacDevices[botvacId]}", install: false, uninstall: false) { 
199         section() {
200                 paragraph "Configure a dymanic schedule for your Botvac so that it can clean on a regular interval but based on mode, presence sensor or switch triggers."
201                 input "smartScheduleEnabled#$botvacId", "bool", title: "Enable SmartSchedule?", required: false, defaultValue: false, submitOnChange: true
202         }
203             if (settings["smartScheduleEnabled#$botvacId"]) {
204                 section() {
205                         input ("ssEnableWarning#$botvacId", "bool", title: "Enable schedule notification before cleaning", required: false, defaultValue: true)
206                 }
207                 section("Configure your cleaning interval and schedule triggers:") {
208                                 //SmartSchedule configuration options.
209                         //Configure regular cleaning interval in days
210                         input ("ssCleaningInterval#$botvacId", "number", title: "Set your ideal cleaning interval in days", required: true, defaultValue: 3)
211                     
212                     //Define when day should be mesaured
213                     paragraph "[BETA] If enabled then a day is calculated from midnight before the last clean."
214                     input ("ssIntervalFromMidnight#$botvacId", "bool", title: "Measure day interval from midnight before last clean?", required: false, defaultValue: false)
215                     
216                     //Define smart schedule trigger
217                     input("ssScheduleTrigger#$botvacId", "enum", title: "How do you want to trigger the schedule?",  multiple: false, required: true, submitOnChange: true, options: ["mode": "Away Modes", "switch": "Switches", "presence": "Presence", "none": "No Triggers"])
218         
219                     //Define your away modes
220                     if (settings["ssScheduleTrigger#$botvacId"] == "mode") { 
221                         input ("ssAwayModes#$botvacId", "mode", title:"Specify your away modes:", multiple: true, required: true)
222                     }
223                     if (settings["ssScheduleTrigger#$botvacId"] == "switch") { 
224                         input ("ssSwitchTrigger#$botvacId", "capability.switch", title:"Which switches?", multiple: true, required: true) 
225                         input ("ssSwitchTriggerCondition#$botvacId", "enum", title:"Trigger schedule when:", multiple: false, required: true, options: ["any": "Any switch turns on", "all": "All switches are on"], defaultValue: "any") 
226                     }
227                     if (settings["ssScheduleTrigger#$botvacId"] == "presence") { 
228                         input ("ssPeopleAway#$botvacId", "capability.presenceSensor", title:"Which presence sensors?", multiple: true, required: true) 
229                         input ("ssPeopleAwayCondition#$botvacId", "enum", title:"Trigger schedule when:", multiple: false, required: true, options: ["any": "Someone leaves", "all": "Everyone is away"], defaultValue: "all") 
230                     }
231                     
232                     if (settings["ssScheduleTrigger#$botvacId"] != "none") { 
233                         input ("ssStartDelay#$botvacId", "number", title:"Set start delay time (minutes):", required: true, defaultValue: 0) 
234                     }
235                 }
236                 section("SmartSchedule restrictions:") {
237                                         //Define time of day
238                         paragraph "Set SmartSchedule restrictions so that your Botvacs don't start unless below conditions are met."
239                     def greyedOutTime = greyedOutTime(settings["starting#$botvacId"], settings["ending#$botvacId"])
240                     def timeLabel = getTimeLabel(settings["starting#$botvacId"], settings["ending#$botvacId"])
241                         href ("timeIntervalPAGE", params: ["botvacId": botvacId], title: "Operate Botvac only during a certain time", description: timeLabel, state: greyedOutTime, refreshAfterSelection:true)
242                         //Define allowed days of operation
243                         input ("days#$botvacId", "enum", title: "Operate Botvac only on certain days of the week", multiple: true, required: false,
244                                         options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"])
245                     //Define contact sensors
246                     input ("ssRestrictContactSensors#$botvacId", "capability.contactSensor", title:"Set SmartSchedule restriction contact sensors", multiple: true, required: false, submitOnChange: true)
247                     if (settings["ssRestrictContactSensors#$botvacId"]) {
248                         input ("ssRestrictContactSensorsCondition#$botvacId", "enum", title:"Start Botvac only when:", multiple: false, required: true, options: ["allclosed": "All selected contacts are closed", "anyclosed": "Any selected contacts are closed", "allopen": "All selected contacts are open", "anyopen": "Any selected contacts are open"], defaultValue: "allclosed")
249                     }
250                         
251                 }
252                 section("SmartSchedule overrides:") {
253                 //Define override switches to restart SmartSchedule countdown
254                 paragraph "Routine override switches/buttons will cancel the next scheduled clean and reset the interval countdown when switched on."
255                         input ("ssOverrideSwitch#$botvacId", "capability.switch", title:"Set SmartSchedule override switches", multiple: true, required: false, submitOnChange: true)
256                      if (settings["ssOverrideSwitch#$botvacId"]) {
257                         input ("ssOverrideSwitchCondition#$botvacId", "enum", title:"Override schedule when:", multiple: false, required: true, options: ["any": "Any selected switch turns on", "all": "All selected switches are on"], defaultValue: "any") 
258                     }
259                 }
260                 section("Notifications:") {
261                 paragraph "Turn on SmartSchedule notifications. You can configure specific recipients via Notification settings section."
262                         input "ssNotification", "bool", title: "Enable SmartSchedule notifications?", required: false, defaultValue: true
263                 }  
264             }
265         }
266     
267 }
268
269 def timeIntervalPAGE(params) {
270         def botvacId = params.botvacId
271         return dynamicPage(name: "timeIntervalPAGE", title: "Only during a certain time", refreshAfterSelection:true) {
272                 section {
273                         input "starting#$botvacId", "time", title: "Starting", required: false
274                         input "ending#$botvacId", "time", title: "Ending", required: false
275                 }
276         }
277 }
278
279 def notificationsPAGE() {
280         return dynamicPage(name: "notificationsPAGE", title: "Notifications", install: false, uninstall: false) {   
281                 section(){
282                 input("recipients", "contact", title: "Send notifications to", required: false, submitOnChange: true) {
283                                 input "sendPush", "bool", title: "Send notifications via Push?", required: false, defaultValue: false, submitOnChange: true
284             }
285             input "sendSMS", "phone", title: "Send notifications via SMS?", required: false, defaultValue: null, submitOnChange: true
286             if ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) {
287                                 input "sendBotvacOn", "bool", title: "Notify when Botvacs are on?", required: false, defaultValue: false
288                                 input "sendBotvacOff", "bool", title: "Notify when Botvacs are off?", required: false, defaultValue: false
289                                 input "sendBotvacError", "bool", title: "Notify on Botvacs have an error?", required: false, defaultValue: true
290                                 input "sendBotvacBin", "bool", title: "Notify when Botvacs have a full bin?", required: false, defaultValue: true
291                 def smartScheduleEnabled = false
292                 if (selectedBotvacs.size() > 0) {
293                         selectedBotvacs.each() {
294                         if (settings["smartScheduleEnabled#$it"]) smartScheduleEnabled = true
295                         }
296                 }
297                 if (smartScheduleEnabled) {
298                         input "ssNotification", "bool", title: "Enable SmartSchedule notifications?", required: false, defaultValue: true
299                 }
300             }
301                 }
302     }
303 }
304
305 def preferencesPAGE() {
306         return dynamicPage(name: "preferencesPAGE", title: "Preferences", install: false, uninstall: false) {   
307                 
308         section("Force Clean"){
309                 paragraph "If Botvac has been inactive for a number of days specified, then force a clean."
310                 input "forceClean", "bool", title: "Force clean after elapsed time?", required: false, defaultValue: false, submitOnChange: true
311             if (forceClean != false) {
312                         input ("forceCleanDelay", "number", title: "Number of days before force clean (in days)", required: false, defaultValue: 7)
313             }
314         }
315         section("Auto Dock") {
316                 paragraph "When Botvac is paused, automatically send to base after a specified number of seconds."
317                         input "autoDock", "bool", title: "Auto dock Botvac after pause?", required: false, defaultValue: true, submitOnChange: true
318             if (autoDock != false) {
319                 input ("autoDockDelay", "number", title: "Auto dock delay after pause (in seconds)", required: false, defaultValue: 60)
320             }
321                 }
322                 section("Auto Smart Home Monitor..."){
323                 paragraph "If Smart Home Monitor is set to Arm(Away), auto Set Smart Home Monitor to Arm(Stay) when cleaning and reset when done. If Smart Home Monitor is Disarmed during cleaning, then this will not reactivate SHM."
324                         input "autoSHM", "bool", title: "Auto set Smart Home Monitor?", required: false, defaultValue: false, submitOnChange: true
325                         
326                 }
327     }
328 }
329
330 def headerSECTION() {
331         return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png",
332                   "${textVersion()}")
333
334
335 def oauthInitUrl() {
336         log.debug "oauthInitUrl with callback: ${callbackUrl}"
337
338         atomicState.oauthInitState = UUID.randomUUID().toString()
339
340         def oauthParams = [
341                         response_type: "code",
342                         scope: "public_profile control_robots maps",
343                         client_id: clientId(),
344                         state: atomicState.oauthInitState,
345                         redirect_uri: callbackUrl
346         ]
347
348         redirect(location: "${apiEndpoint}/oauth2/authorize?${toQueryString(oauthParams)}")
349 }
350
351 // The toQueryString implementation simply gathers everything in the passed in map and converts them to a string joined with the "&" character.
352 String toQueryString(Map m) {
353         return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
354 }
355
356 def callback() {
357         log.debug "callback()>> params: $params, params.code ${params.code}"
358
359         def code = params.code
360         def oauthState = params.state
361
362         if (oauthState == atomicState.oauthInitState) {
363                 def tokenParams = [
364                         grant_type: "authorization_code",
365                         code      : code,
366                         client_id : clientId(),
367             client_secret: clientSecret(),
368                         redirect_uri: callbackUrl
369                 ]
370
371                 def tokenUrl = "https://beehive.neatocloud.com/oauth2/token?${toQueryString(tokenParams)}"
372
373                 httpPost(uri: tokenUrl) { resp ->
374                         atomicState.refreshToken = resp.data.refresh_token
375                         atomicState.authToken = resp.data.access_token
376                 }
377
378                 if (atomicState.authToken) {
379                         success()
380                 } else {
381                         fail()
382                 }
383
384         } else {
385                 log.error "callback() failed oauthState != atomicState.oauthInitState"
386         }
387
388 }
389
390 // Example success method
391 def success() {
392         def message = """
393         <p>Your Neato Account is now connected to SmartThings!</p>
394         <p>Click 'Done' to finish setup.</p>
395     """
396         displayMessageAsHtml(message)
397 }
398
399 def fail() {
400         def message = """
401         <p>The connection could not be established!</p>
402         <p>Click 'Done' to return to the menu.</p>
403     """
404         displayMessageAsHtml(message)
405 }
406
407 def displayMessageAsHtml(message) {
408     def redirectHtml = ""
409         if (redirectUrl) { redirectHtml = """<meta http-equiv="refresh" content="3; url=${redirectUrl}" />""" }
410
411         def html = """
412                 <!DOCTYPE html>
413                 <html>
414                 <head>
415                 <meta name="viewport" content="width=640">
416                 <title>SmartThings & Neato connection</title>
417                 <style type="text/css">
418                                 @font-face {
419                                                 font-family: 'Swiss 721 W01 Thin';
420                                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
421                                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
422                                                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
423                                                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
424                                                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
425                                                 font-weight: normal;
426                                                 font-style: normal;
427                                 }
428                                 @font-face {
429                                                 font-family: 'Swiss 721 W01 Light';
430                                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
431                                                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
432                                                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
433                                                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
434                                                                 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
435                                                 font-weight: normal;
436                                                 font-style: normal;
437                                 }
438                                 .container {
439                                                 width: 90%;
440                                                 padding: 4%;
441                                                 /*background: #eee;*/
442                                                 text-align: center;
443                                 }
444                                 img {
445                                                 vertical-align: middle;
446                                 }
447                                 p {
448                                                 font-size: 2.2em;
449                                                 font-family: 'Swiss 721 W01 Thin';
450                                                 text-align: center;
451                                                 color: #666666;
452                                                 padding: 0 40px;
453                                                 margin-bottom: 0;
454                                 }
455                                 span {
456                                                 font-family: 'Swiss 721 W01 Light';
457                                 }
458                 </style>
459                 </head>
460                 <body>
461                                 <div class="container">
462                                                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
463                                                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
464                                                 <img src="https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png" alt="neato icon" width="205" />
465                                                 ${message}
466                                 </div>
467                 </body>
468                 </html>
469                 """
470         render contentType: 'text/html', data: html
471 }
472
473 private refreshAuthToken() {
474         log.debug "refreshing auth token"
475
476         if(!atomicState.refreshToken) {
477                 log.warn "Can not refresh OAuth token since there is no refreshToken stored"
478         } else {
479                 def refreshParams = [
480                         method: 'POST',
481                         uri   : "https://beehive.neatocloud.com",
482                         path  : "/oauth2/token",
483                         query : [grant_type: 'refresh_token', refresh_token: "${atomicState.refreshToken}"],
484                 ]
485
486                 def notificationMessage = "Neato is disconnected from SmartThings, because the access credential changed or was lost. Please go to the Neato (Connect) SmartApp and re-enter your account login credentials."
487                 //changed to httpPost
488                 try {
489                         def jsonMap
490                         httpPost(refreshParams) { resp ->
491                                 if(resp.status == 200) {
492                                         log.debug "Token refreshed...calling saved RestAction now!"
493                                         saveTokenAndResumeAction(resp.data)
494                             }
495             }
496                 } catch (groovyx.net.http.HttpResponseException e) {
497                         log.error "refreshAuthToken() >> Error: e.statusCode ${e.statusCode}"
498                         def reAttemptPeriod = 300 // in sec
499                         if (e.statusCode != 401) { // this issue might comes from exceed 20sec app execution, connectivity issue etc.
500                                 runIn(reAttemptPeriod, "refreshAuthToken")
501                         } else if (e.statusCode == 401) { // unauthorized
502                 if (!atomicState.reAttempt) atomicState.reAttempt = 0
503                                 atomicState.reAttempt = atomicState.reAttempt + 1
504                                 log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}"
505                                 if (atomicState.reAttempt <= 3) {
506                                         runIn(reAttemptPeriod, "refreshAuthToken")
507                                 } else {
508                                         messageHandler(notificationMessage, true)
509                     atomicState.authToken = null
510                                         atomicState.reAttempt = 0
511                     
512                                 }
513                         }
514                 }
515         }
516 }
517
518 private void saveTokenAndResumeAction(json) {
519     log.debug "saveTokenAndResumeAction: token response json: $json"
520     if (json) {
521         atomicState.refreshToken = json?.refresh_token
522         atomicState.authToken = json?.access_token
523         if (atomicState.action) {
524             log.debug "got refresh token, executing next action: ${atomicState.action}"
525             "${atomicState.action}"()
526         }
527     } else {
528         log.warn "did not get response body from refresh token response"
529     }
530     atomicState.action = ""
531 }
532
533 def installed() {
534         log.debug "Installed with settings: ${settings}"
535         initialize()
536 }
537
538 def updated() {
539         log.debug "Updated with settings: ${settings}"
540         unsubscribe()
541         initialize()
542 }
543
544 def initialize() {
545         unschedule()
546     //Initialise variables
547     if (state.lastClean == null) {
548         state.lastClean = [:]
549     }
550     if (state.smartSchedule == null) {
551         state.smartSchedule = [:]
552     }
553     if (state.forceCleanNotificationSent == null) {
554         state.forceCleanNotificationSent = [:]
555     }
556     if (state.botvacOnTimeMarker == null) {
557         state.botvacOnTimeMarker = [:]
558     }
559     state.remove("taskStartTimes")
560     if (selectedBotvacs) addBotvacs()
561     
562     getChildDevices().each { childDevice ->     
563         def botvacId = childDevice.deviceNetworkId
564         //subscribe to events for smartSchedule
565         if (settings["smartScheduleEnabled#$botvacId"]) {
566                 //store last mode selected
567                 if ((!state.lastTriggerMode) || (state.lastTriggerMode instanceof String)) state.lastTriggerMode = [:]
568         
569                 if (settings["ssScheduleTrigger#$botvacId"] == "mode") { subscribe(location, "mode", smartScheduleHandler, [filterEvents: false]) }
570                 else if (settings["ssScheduleTrigger#$botvacId"] == "switch") { subscribe(settings["ssSwitchTrigger#$botvacId"], "switch.on", smartScheduleHandler, [filterEvents: false]) }
571                 else if (settings["ssScheduleTrigger#$botvacId"] == "presence") { subscribe(settings["ssPeopleAway#$botvacId"], "presence", smartScheduleHandler, [filterEvents: false]) }
572             
573                 subscribe(settings["ssOverrideSwitch#$botvacId"], "switch.on", smartScheduleHandler, [filterEvents: false])
574             subscribe(settings["ssRestrictContactSensors#$botvacId"], "contact", smartScheduleHandler, [filterEvents: false])
575         }
576     
577                 if (state.botvacOnTimeMarker[botvacId] == null) state.botvacOnTimeMarker[botvacId] = now()
578         //subscribe to events for notifications if activated
579         if (settings["smartScheduleEnabled#$botvacId"] || preferencesSelected() == "complete" || notificationsSelected() == "complete") {
580                 subscribe(childDevice, "status.cleaning", eventHandler, [filterEvents: false])
581         }
582         if (preferencesSelected() == "complete" || notificationsSelected() == "complete") {
583                 subscribe(childDevice, "status.ready", eventHandler, [filterEvents: false])
584             subscribe(childDevice, "status.error", eventHandler, [filterEvents: false])
585             subscribe(childDevice, "status.paused", eventHandler, [filterEvents: false])
586             subscribe(childDevice, "bin.full", eventHandler, [filterEvents: false])
587         }
588         //initialise force clean flags
589         if (settings.forceClean) {
590                 if (state.forceCleanNotificationSent[botvacId] == null) state.forceCleanNotificationSent[botvacId] = false
591         }
592         //subscribe to events for smartSchedule
593         if (settings["smartScheduleEnabled#$botvacId"]) {
594                 //Initialize flags for Smart Schedule
595             if (state.smartSchedule[botvacId] == null) state.smartSchedule[botvacId] = false
596                 if (state.lastClean[botvacId] == null) {
597                 if (settings["ssIntervalFromMidnight#$botvacId"]) {
598                         state.lastClean[botvacId] = (new Date()).clearTime().getTime()
599                 } else {
600                         state.lastClean[botvacId] = now()
601                 }
602             }
603             //Trigger has changed so reset all smart schedule flags
604             if ((state.lastTriggerMode.containsKey(botvacId)) && (state.lastTriggerMode[botvacId] != settings["ssScheduleTrigger#$botvacId"])) {
605                 log.debug "Smart schedule trigger mode has changed. Resetting smart schedule flag."
606                 state.smartSchedule[botvacId] = false
607                 state.lastTriggerMode[botvacId] = settings["ssScheduleTrigger#$botvacId"]
608             }
609         }
610         childDevice.poll()
611     }
612     def nextTimeInSeconds = getNextTimeInSeconds()
613     if (nextTimeInSeconds >= 0) {
614         runIn(nextTimeInSeconds, timeHandler)
615     }
616     else {
617         log.warn "No time has been scheduled. Check that you have Botvacs added under the Neato (Connect) app."
618     }
619     runEvery5Minutes('pollOn') // Asynchronously refresh devices so we don't block
620     
621 }
622
623 def uninstalled() {
624         log.info("Uninstalling, removing child devices...")
625         unschedule()
626         removeChildDevices(getChildDevices())
627 }
628
629 def updateDevices() {
630         log.debug "Executing 'updateDevices'"
631         if (!state.devices) {
632                 state.devices = [:]
633         }
634         def devices = devicesList()
635     state.botvacDevices = [:]
636     def selectors = []
637         devices.each { device -> 
638         if (device.serial != null) {
639                 selectors.add("${device.serial}|${device.secret_key}")
640             def value
641                 value = "Neato Botvac - " + device.name
642                         def key = device.serial + "|" + device.secret_key
643                         state.botvacDevices["${key}"] = value
644         }
645         }    
646     log.debug "selectors: $selectors"
647     //Remove devices if does not exist on the Neato platform
648     getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each {
649                 log.info("Deleting ${it.deviceNetworkId}")
650         try {
651                         deleteChildDevice(it.deviceNetworkId)
652         } catch (physicalgraph.exception.NotFoundException e) {
653                 log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.")
654         } catch (physicalgraph.exception.ConflictException ce) {
655                 log.info("Device ${it.deviceNetworkId} in use. Please manually delete.")
656         }
657         } 
658     if (selectedBotvacs) {
659         selectedBotvacs.retainAll(selectors as Object[])
660     }
661 }
662
663 def addBotvacs() {
664         log.debug "Executing 'addBotvacs'"
665         updateDevices()
666
667         selectedBotvacs.each { device ->
668         
669         def childDevice = getChildDevice("${device}")
670         
671         if (!childDevice) { 
672                 log.info("Adding Neato Botvac device ${device}: ${state.botvacDevices[device]}")
673             
674                 def data = [
675                 name: state.botvacDevices[device],
676                                 label: state.botvacDevices[device],
677                         ]
678             childDevice = addChildDevice(app.namespace, "Neato Botvac Connected Series", "$device", null, data)
679             childDevice.refresh()
680            
681                         log.debug "Created ${state.botvacDevices[device]} with id: ${device}"
682                 } else {
683                         log.debug "found ${state.botvacDevices[device]} with id ${device} already exists"
684                 }
685         }
686 }
687
688 private removeChildDevices(devices) {
689         devices.each {
690                 deleteChildDevice(it.deviceNetworkId) // 'it' is default
691         }
692 }
693
694 def devicesList() {
695         logErrors([]) {
696                 def resp = beehiveGET("/users/me/robots")
697         def notificationMessage = "Neato is disconnected from SmartThings, because the access credential changed or was lost. Please go to the Neato (Connect) SmartApp and re-enter your account login credentials."
698                 if (resp.status == 200) {
699                         return resp.data
700                 } else if (resp.status == 401) {
701                 atomicState.action = "updateDevices"
702                 if (!atomicState.reAttempt) atomicState.reAttempt = 0
703                 atomicState.reAttempt = atomicState.reAttempt + 1
704                         log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}"
705                         if (atomicState.reAttempt <= 3) {
706                                 runIn(reAttemptPeriod, "refreshAuthToken")
707                         } else {
708                                 messageHandler(notificationMessage, true)
709                 atomicState.authToken = null
710                                 atomicState.reAttempt = 0
711                         }
712         }
713         else {
714                 log.error("Non-200 from device list call. ${resp.status} ${resp.data}")
715             runIn(reAttemptPeriod, "refreshAuthToken")
716                         return []
717                 }
718         }
719 }
720
721 def devicesSelected() {
722         return (selectedBotvacs) ? "complete" : null
723 }
724
725 def getDevicesSelectedString() {
726         updateDevices()
727         def listString = ""
728         selectedBotvacs.each { childDevice -> 
729         if (null != state.botvacDevices) {
730                 listString += "\n• " + state.botvacDevices[childDevice]
731         }
732     }
733     return listString
734 }
735
736 def smartScheduleSelected(botvacId) {
737         return settings["smartScheduleEnabled#$botvacId"] ? "complete" : null
738 }
739
740 def getSmartScheduleString(botvacId) {
741         def listString = ""
742     if (settings["smartScheduleEnabled#$botvacId"]) {
743         listString += "SmartSchedule set for every ${settings["ssCleaningInterval#$botvacId"]} days "
744         if (settings["ssScheduleTrigger#$botvacId"] == "mode") {listString += "when mode is ${settings["ssAwayModes#$botvacId"]}."}
745         else if (settings["ssScheduleTrigger#$botvacId"] == "switch") {
746                 if (settings["ssSwitchTriggerCondition#$botvacId"] == "any") {
747                 listString += "when any of ${settings["ssSwitchTrigger#$botvacId"]} turns on."
748             } else {
749                 listString += "when ${settings["ssSwitchTrigger#$botvacId"]} are all on."
750             }
751         }
752         
753         else if (settings["ssScheduleTrigger#$botvacId"] == "presence") {
754                 if (settings["ssPeopleAwayCondition#$botvacId"] == "any") {
755                 listString += "when one of ${settings["ssPeopleAway#$botvacId"]} leaves."
756             } else {
757                 listString += "when ${settings["ssPeopleAway#$botvacId"]} are all away."
758             }
759         }  
760      
761         listString += "\n\nThe following restrictions apply:\n"
762         if (settings["starting#$botvacId"]) listString += "• ${getTimeLabel(settings["starting#$botvacId"], settings["ending#$botvacId"])}\n" 
763         if (settings["days#$botvacId"]) listString += "• Only on ${settings["days#$botvacId"]}.\n"
764         if (settings["ssRestrictContactSensors#$botvacId"]) {
765                 if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allclosed") {
766                 listString += "• When ${settings["ssRestrictContactSensors#$botvacId"]} are all closed.\n"
767             } else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "anyclosed") { 
768                 listString += "• When any one of ${settings["ssRestrictContactSensors#$botvacId"]} are closed.\n"
769             } else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allopen") { 
770                 listString += "• When ${settings["ssRestrictContactSensors#$botvacId"]} are all open.\n"
771             } else {
772                 listString += "• When any one of ${settings["ssRestrictContactSensors#$botvacId"]} are closed.\n"
773             }
774         }
775         if (settings["ssOverrideSwitch#$botvacId"]) {
776                 if (settings["ssOverrideSwitchCondition#$botvacId"] == "any") {
777                 listString += "• Override schedule if any of ${settings["ssOverrideSwitch#$botvacId"]} turns on.\n"
778             } else {
779                         listString += "• Override schedule if ${settings["ssOverrideSwitch#$botvacId"]} are all on.\n"
780             }
781         }
782     }
783     return listString
784 }
785
786 def preferencesSelected() {
787         return (settings.forceClean || settings.autoDock || settings.autoSHM) ? "complete" : null
788 }
789
790 def getPreferencesString() {
791         def listString = ""
792         if (settings.forceClean) listString += "• Force clean after ${settings.forceCleanDelay} days\n"
793         if (settings.autoDock) listString += "• Auto Dock after ${settings.autoDockDelay} seconds\n"
794     if (settings.autoSHM) listString += "• Automatically set Smart Home Monitor\n"
795         
796         if (listString != "") listString = listString.substring(0, listString.length() - 1)
797     return listString
798 }
799
800 def notificationsSelected() {
801     return ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) && (settings.sendBotvacOn || settings.sendBotvacOff || settings.sendBotvacError || settings.sendBotvacBin || settings.ssNotification) ? "complete" : null
802 }
803
804 def getNotificationsString() {
805         def listString = ""
806     if (location.contactBookEnabled && settings.recipients) { 
807         listString += "Send the following notifications to " + settings.recipients
808     }
809     else if (settings.sendPush) {
810         listString += "Send the following notifications"
811     }
812     
813     if (!settings.recipients && !settings.sendPush && settings.sendSMS != null) {
814         listString += "Send the following SMS to ${settings.sendSMS}"
815     }
816     else if (settings.sendSMS != null) {
817         listString += " and SMS to ${settings.sendSMS}"
818     }
819     
820     if ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) {
821         listString += ":\n"
822                 if (settings.sendBotvacOn) listString += "• Botvac On\n"
823                 if (settings.sendBotvacOff) listString += "• Botvac Off\n"
824                 if (settings.sendBotvacError) listString += "• Botvac Error\n"
825                 if (settings.sendBotvacBin) listString += "• Bin Full\n"
826         if (settings.ssNotification) listString += "• SmartSchedule\n"
827     }
828     if (listString != "") listString = listString.substring(0, listString.length() - 1)
829     return listString
830 }
831
832 //Beehive API Access
833 def beehiveGET(path, body = [:]) {
834         try {
835         log.debug("Beginning API GET: ${beehiveURL(path)}, ${beehiveRequestHeaders()}")
836
837         httpGet(uri: beehiveURL(path), contentType: 'application/json', headers: beehiveRequestHeaders()) {response ->
838                         logResponse(response)
839                         return response
840                 }
841         } catch (groovyx.net.http.HttpResponseException e) {
842                 logResponse(e.response)
843                 return e.response
844         }
845 }
846
847 Map beehiveRequestHeaders() {
848         return [
849         'Accept': 'application/vnd.neato.nucleo.v1',
850         'Content-Type': 'application/*+json',
851         'X-Agent': '0.11.3-142',
852         'Authorization': "Bearer ${atomicState.authToken}"
853     ]
854 }
855
856 def logResponse(response) {
857         log.info("Status: ${response.status}")
858         log.info("Body: ${response.data}")
859 }
860
861 def logErrors(options = [errorReturn: null, logObject: log], Closure c) {
862         try {
863                 return c()
864         } catch (groovyx.net.http.HttpResponseException e) {
865                 log.error("got error: ${e}, body: ${e.getResponse().getData()}")
866                 return options.errorReturn
867         } catch (java.net.SocketTimeoutException e) {
868                 log.warn "Connection timed out, not much we can do here"
869                 return options.errorReturn
870         }
871 }
872
873 // Implement event handlers
874 def eventHandler(evt) {
875         log.debug "Executing 'eventHandler' for ${evt.displayName}"
876         def msg
877     if (evt.value == "paused") {
878     log.trace "Setting auto dock for ${evt.displayName}"
879         //If configured, set to dock automatically after one minute.
880         if (settings.autoDock) {
881                 runIn(settings.autoDockDelay, scheduleAutoDock)
882         }
883     }
884         else if (evt.value == "error") {
885         unschedule(pollOn)
886         unschedule(scheduleAutoDock)
887         runEvery5Minutes('pollOn')
888                 sendEvent(linkText:app.label, name:"${evt.displayName}", value:"error",descriptionText:"${evt.displayName} has an error", eventType:"SOLUTION_EVENT", displayed: true)
889                 log.trace "${evt.displayName} has an error"
890                 msg = "${evt.displayName} has an error: " + evt.device.latestState('statusMsg').stringValue.minus('HAS A PROBLEM - ')
891                 if (settings.sendBotvacError) {
892                 messageHandler(msg, false)
893                 }
894      }
895          else if (evt.value == "cleaning") {
896         unschedule(pollOn)
897         unschedule(scheduleAutoDock)
898         //Increase poll interval during cleaning
899         schedule("0 0/1 * * * ?", pollOn)
900         //Record last cleaning time for device
901         log.debug "$evt.device.deviceNetworkId has started cleaning"
902         if (settings["ssIntervalFromMidnight#$evt.device.deviceNetworkId"]) {
903                 state.lastClean[evt.device.deviceNetworkId] = (new Date()).clearTime().getTime()
904         } else {
905                 state.lastClean[evt.device.deviceNetworkId] = now()
906         }
907         state.botvacOnTimeMarker[evt.device.deviceNetworkId] = now()
908         log.debug "$evt.device.deviceNetworkId has started cleaning"
909         if (settings.forceClean) { state.forceCleanNotificationSent[evt.device.deviceNetworkId] = false }
910         //Remove SmartSchedule flag
911         state.smartSchedule[evt.device.deviceNetworkId] = false
912                 sendEvent(linkText:app.label, name:"${evt.displayName}", value:"on",descriptionText:"${evt.displayName} is on", eventType:"SOLUTION_EVENT", displayed: true)
913                 msg = "${evt.displayName} is on"
914                 if (settings.sendBotvacOn) {
915                         messageHandler(msg, false)
916                 }
917         setSHMToStay()
918      }
919          else if (evt.value == "full") {
920         unschedule(pollOn)
921         runEvery5Minutes('pollOn')
922                 sendEvent(linkText:app.label, name:"${evt.displayName}", value:"bin full",descriptionText:"${evt.displayName} bin is full", eventType:"SOLUTION_EVENT", displayed: true)
923                 log.trace "${evt.displayName} bin is full"
924                 msg = "${evt.displayName} bin is full"
925                 if (settings.sendBotvacBin) {
926                         messageHandler(msg, false)
927                 }
928          }
929      else if (evt.value == "ready") {
930         unschedule(pollOn)
931         unschedule(scheduleAutoDock)
932         runEvery5Minutes('pollOn')
933                 sendEvent(linkText:app.label, name:"${evt.displayName}", value:"off",descriptionText:"${evt.displayName} is off", eventType:"SOLUTION_EVENT", displayed: true)
934                 log.trace "${evt.displayName} is off"
935                 msg = "${evt.displayName} is off"
936                 if (settings.sendBotvacOff) {
937                         messageHandler(msg, false)
938                 }
939         }
940 }
941
942 def timeHandler(evt) {
943         smartScheduleHandler(evt)
944 }
945
946 def smartScheduleHandler(evt) {
947         if (evt != null) {
948                 log.debug "Executing 'smartScheduleHandler' for ${evt.displayName}"
949     } else {
950         log.debug "Executing 'smartScheduleHandler' for scheduled event"
951     }
952     //Update scheduler
953     def nextTimeInSeconds = getNextTimeInSeconds()
954     if (nextTimeInSeconds >= 0) {
955         runIn(nextTimeInSeconds, timeHandler)
956     }
957     else {
958         log.warn "No time has been scheduled. Check that you have Botvacs added under the Neato (Connect) app."
959     }
960     getChildDevices().each { childDevice ->
961         def botvacId = childDevice.deviceNetworkId
962         //If switch on for override event
963         if (evt != null && evt.name == "switch") {
964                 def switchInList = false
965                 for (switchName in settings["ssOverrideSwitch#$botvacId"].name) {
966                         if (switchName == evt.device.name) {
967                         switchInList = true
968                         break
969                 }
970                 }
971                 log.debug "Swtich found in override switch list: $switchInList"
972                 if (switchInList) {
973                         def executeOverride = true
974                         //If override switch condition is ALL...
975                         if (settings["ssOverrideSwitchCondition#$botvacId"] == "all") {
976                                 //Check all switches in override switch settings are on
977                         for (switchVal in settings["ssOverrideSwitch#$botvacId"].currentSwitch) {
978                                         if (switchVal == "off") {
979                                         executeOverride = false
980                                         break
981                                         }
982                                 }
983                         }
984         
985                         if (executeOverride) {
986                                 //Reset last clean date to current time
987                         resetSmartScheduleForDevice(botvacId)
988                     childDevice.poll()
989                         }       
990                         if (settings.ssNotification) {
991                                 messageHandler("Neato SmartSchedule has reset schedule for ${childDevice.name} as override switch ${evt.displayName} is on.", false)
992                         }
993                 }
994         }
995         //If mode change event, schedule trigger, contact sensor or presence trigger
996         //Check conditions, time and day have been met and execute clean. If no trigger is specified rely on pollOn method to start clean.
997         if (settings["ssScheduleTrigger#$botvacId"] != "none") {
998                 def delay = 0
999             if (settings["ssStartDelay#$botvacId"]) delay = settings["ssStartDelay#$botvacId"] * 60
1000             if (delay > 0) {
1001                         runIn(delay, startConditionalClean, [data: [botvacId: botvacId], overwrite: false])
1002             } else {
1003                 startConditionalClean([botvacId: botvacId])
1004             }
1005         }
1006                 
1007     }
1008 }
1009
1010 def scheduleAutoDock() {
1011         log.debug "Executing 'scheduleAutoDock'"
1012         getChildDevices().each { childDevice ->
1013                 if (childDevice.latestState('status').stringValue == 'paused') {
1014                         childDevice.dock()
1015                 }
1016         }
1017 }
1018
1019 def pollOn() {
1020         log.debug "Executing 'pollOn'"
1021     
1022     def activeCleaners = false
1023     log.debug "Last clean states: ${state.lastClean}"
1024     log.debug "Smart schedule states: ${state.smartSchedule}"
1025     log.debug "Botvac ON time markers: ${state.botvacOnTimeMarker}"
1026         getChildDevices().each { childDevice ->
1027         def botvacId = childDevice.deviceNetworkId
1028         state.pollState = now()
1029                 childDevice.poll()
1030         if (childDevice.currentSwitch == "off") {
1031                 //Update smart schedule state. Create notification when clean is due.
1032                 if (settings["smartScheduleEnabled#$botvacId"] && state.lastClean != null && state.lastClean[botvacId] != null) { 
1033                         def t = now() - state.lastClean[botvacId]
1034                 log.debug "$childDevice.displayName schedule marker at " + state.lastClean[botvacId] + ". ${t/86400000} days has elapsed since. ${settings["ssCleaningInterval#$botvacId"] - (t/86400000)} days to scheduled clean."
1035             
1036                 //Set SmartSchedule flag if SmartSchedule has not been set already, interval has elapsed and trigger conditions are not met
1037                 if ((settings["ssScheduleTrigger#$botvacId"] == "none") && ((settings["ssCleaningInterval#$botvacId"] - (t/86400000)) < 1) && (!state.smartSchedule[botvacId]) && (settings["ssEnableWarning#$botvacId"])) {
1038                         //hour calculation for notification of next clean
1039                         state.smartSchedule[botvacId] = true
1040                         if (settings.ssNotification) {
1041                                 messageHandler("Neato SmartSchedule has scheduled ${childDevice.displayName} for a clean in 24 hours (date and time restrictions permitting). Please clear obstacles and leave internal doors open ready for the clean.", false)
1042                         }
1043                 } else if ((!getTriggerConditionsOk(botvacId)) && (t > (settings["ssCleaningInterval#$botvacId"] * 86400000)) && (!state.smartSchedule[botvacId]) && (settings["ssEnableWarning#$botvacId"])) {
1044                         state.smartSchedule[botvacId] = true
1045                         if (settings.ssNotification) {
1046                                 def reason = "you're next away"
1047                         if (settings["ssScheduleTrigger#$botvacId"] == "switch") { reason = "your selected switches turn on" }
1048                         else if (settings["ssScheduleTrigger#$botvacId"] == "presence") { reason = "your selected presence sensors leave"}
1049                                 messageHandler("Neato SmartSchedule has scheduled ${childDevice.displayName} for a clean when " + reason + " (date and time restrictions permitting). Please clear obstacles and leave internal doors open ready for the clean.", false)
1050                         }
1051                 }
1052                 //If no trigger has been set for smart schedule, execute clean when interval time has elapsed
1053                         if ((settings["ssScheduleTrigger#$botvacId"] == "none") && (state.smartSchedule[botvacId] || (!settings["ssEnableWarning#$botvacId"])) && (t > (settings["ssCleaningInterval#$botvacId"] * 86400000))) {
1054                                 startConditionalClean([botvacId: botvacId])
1055                         }
1056             }
1057             //Update force clean state and create notification when clean is due.
1058             if (settings.forceClean && state.botvacOnTimeMarker != null && state.botvacOnTimeMarker[botvacId] != null) {
1059                 def t = now() - state.botvacOnTimeMarker[botvacId]
1060                 log.debug "$childDevice.displayName ON time marker at " + state.botvacOnTimeMarker[botvacId] + ". ${t/86400000} days has elapsed since. ${settings.forceCleanDelay - (t/86400000)} days to force clean."
1061                 
1062                 //Create 24 hour warning for force clean.
1063                 if ((state.forceCleanNotificationSent != null) && (!state.forceCleanNotificationSent[botvacId]) && ((settings.forceCleanDelay - (t/86400000)) < 1)) {
1064                         //Send notification when force clean is due
1065                         log.debug "Force clean due within 24 hours"
1066                                         messageHandler(childDevice.displayName + " has not cleaned for " + (settings.forceCleanDelay - 1) + " days. Forcing a clean in 24 hours. Please clear obstacles and leave internal doors open ready for the clean.", true)
1067                         state.forceCleanNotificationSent[botvacId] = true
1068                 }
1069             
1070                 //Execute force clean (no conditions need checking)
1071                                 if (t > (settings.forceCleanDelay * 86400000)) {
1072                         log.debug "Force clean activated as ${t/86400000} days has elapsed"
1073                                         messageHandler(childDevice.displayName + " has not cleaned for " + settings.forceCleanDelay + " days. Forcing a clean.", true)
1074                     resetSmartScheduleForDevice(botvacId)
1075                         childDevice.on()
1076                         }
1077                 }
1078         } 
1079         if (childDevice.currentStatus == "cleaning") {
1080                 //Search for active cleaners
1081                 activeCleaners = true
1082         }
1083         }
1084     
1085     //Set SHM mode depending on whether there are active cleaners.
1086     if (activeCleaners) {
1087         setSHMToStay()
1088     } else {
1089         setSHMToAway()
1090         }
1091     
1092     //If SHM is disarmed because of external event, then disable auto SHM mode
1093     if (location.currentState("alarmSystemStatus")?.value == "off") {
1094         state.autoSHMchange = "n"
1095     }
1096 }
1097
1098 //Access methods for device type
1099 def isSmartScheduleEnabled(botvacId) {
1100         return settings["smartScheduleEnabled#$botvacId"]
1101 }
1102
1103 def timeToSmartScheduleClean(botvacId) {
1104         log.debug "Executing 'timeToSmartScheduleClean' with device $botvacId"
1105         def result = -1
1106     if (settings["smartScheduleEnabled#$botvacId"] && state.lastClean != null && state.lastClean[botvacId] != null) {
1107         result = (state.lastClean[botvacId] + (settings["ssCleaningInterval#$botvacId"] * 86400000)) - now()
1108     }
1109     log.debug "Time to smart schedule clean: $result milliseconds"
1110     result
1111 }
1112
1113 def timeToForceClean(botvacId) {
1114         log.debug "Executing 'timeToForceClean' with device $botvacId"
1115         def result = -1
1116     if (settings.forceClean && state.botvacOnTimeMarker != null && state.botvacOnTimeMarker[botvacId] != null) {
1117         result = (state.botvacOnTimeMarker[botvacId] + (settings.forceCleanDelay * 86400000)) - now()
1118     }
1119     log.debug "Time to force clean: $result milliseconds"
1120     result
1121 }
1122
1123 def autoDockDelayValue() {
1124         log.debug "Executing 'autoDockDelayValue'"
1125         def result = -1
1126         if (settings.autoDock) {
1127                 result = settings.autoDockDelay
1128     }
1129     log.debug "Auto dock delay: $result seconds"
1130     result
1131 }
1132
1133 def resetSmartScheduleForDevice(botvacId) {
1134         log.debug "Executing 'resetSmartScheduleForDevice' with device $botvacId"
1135         if (settings["smartScheduleEnabled#$botvacId"] && state.lastClean != null && state.smartSchedule != null) {
1136                 //Reset last clean date to current time
1137                 state.lastClean[botvacId] = now()
1138         if (settings["ssIntervalFromMidnight#$botvacId"]) {
1139                 state.lastClean[botvacId] = (new Date()).clearTime().getTime()
1140         } else {
1141             state.lastClean[botvacId] = now()
1142         }
1143                 //Remove existing SmartSchedule flag
1144         state.smartSchedule[botvacId] = false
1145     }
1146     /**
1147     //DEBUG PURPOSES ONLY. FAKE TIME ON OVERRIDE SWITCH AND INCREASE POLL
1148     //state.lastClean[deviceNetworkId] = Date.parseToStringDate("Thu Oct 13 01:23:45 UTC 2016").getTime()
1149     state.lastClean[botvacId] = 1476868627993
1150     state.botvacOnTimeMarker[botvacId] = 1476889942741
1151     unschedule(pollOn)
1152     schedule("0 0/1 * * * ?", pollOn)
1153     log.debug "Fake data loaded.... " + (now() - state.lastClean[botvacId])/86400000 
1154     **/
1155     
1156 }
1157
1158 //Helper methods
1159 def setSHMToStay() {
1160         if (settings.autoSHM) {
1161                 if (location.currentState("alarmSystemStatus")?.value == "away") {
1162                         sendEvent(linkText:app.label, name:"Smart Home Monitor", value:"stay",descriptionText:"Smart Home Monitor was set to stay", eventType:"SOLUTION_EVENT", displayed: true)
1163                         log.trace "Smart Home Monitor is set to stay"
1164                         sendLocationEvent(name: "alarmSystemStatus", value: "stay")
1165                         state.autoSHMchange = "y"
1166                 messageHandler("Smart Home Monitor is set to stay as a Neato Botvac is cleaning", true)
1167         }
1168     }
1169 }
1170
1171 def setSHMToAway() {
1172         if (settings.autoSHM) {
1173                 if (location.currentState("alarmSystemStatus")?.value == "stay" && state.autoSHMchange == "y") {
1174                         sendEvent(linkText:app.label, name:"Smart Home Monitor", value:"away",descriptionText:"Smart Home Monitor was set back to away", eventType:"SOLUTION_EVENT", displayed: true)
1175                         log.trace "Smart Home Monitor is set back to away"
1176                         sendLocationEvent(name: "alarmSystemStatus", value: "away")
1177                         state.autoSHMchange = "n"
1178             messageHandler("Smart Home Monitor is set to away as all Neato Botvacs are off", true)
1179                 }
1180         }
1181 }
1182
1183 def startConditionalClean(data) {
1184         def botvacId = data.botvacId
1185         log.debug "Executing 'startConditionalClean for $botvacId'"
1186         if (getAllOk(botvacId)) {
1187         def botvacDevice = getChildDevice(botvacId)
1188         //If smartSchedule flag has been set, start clean.
1189          if ((state.smartSchedule[botvacId]) || (!settings["ssEnableWarning#$botvacId"])) {
1190                 if (settings.ssNotification) {
1191                 messageHandler("Neato SmartSchedule has started ${botvacDevice.displayName} cleaning.", false)
1192             }
1193             resetSmartScheduleForDevice(botvacId)
1194             botvacDevice.on()
1195          }      
1196      }
1197 }
1198
1199 def adjustTimeforTimeZone(originalTime) {
1200         if (getTimeZone()) {
1201                 def adjustedTime = timeToday(originalTime, location.timeZone)
1202         def timeNow = now() + (2*1000) 
1203         if (adjustedTime.time < timeNow) { 
1204                         adjustedTime = adjustedTime + 1
1205         }
1206         return adjustedTime
1207     }
1208     return originalTime
1209 }
1210
1211 def getNextTimeInSeconds() {
1212         def nextTime = null
1213     getChildDevices().each { childDevice ->
1214         def time
1215         def botvacId = childDevice.deviceNetworkId
1216         if (settings["starting#$botvacId"]) {
1217                 time = adjustTimeforTimeZone(settings["starting#$botvacId"])
1218         } else {
1219                 time = timeToday("00:01", location.timeZone)
1220         }
1221         def t = timeTodayAfter(new Date(), time.format("HH:mm", getTimeZone()), getTimeZone())
1222                 if (nextTime) {
1223                 nextTime = (nextTime > t.getTime()) ? t.getTime() : nextTime
1224         } else {
1225                 nextTime = t.getTime()
1226         }
1227         }
1228     if (nextTime) {
1229         def seconds = Math.ceil((nextTime - now()) / 1000)
1230                 log.debug "Scheduling ST job to run in ${seconds}s, at ${nextTime}"
1231                 return seconds as Integer
1232     }
1233     else return -1
1234 }
1235
1236 def messageHandler(msg, forceFlag) {
1237         log.debug "Executing 'messageHandler for $msg. Forcing is $forceFlag'"
1238         if (settings.sendSMS != null && !forceFlag) {
1239                 sendSms(settings.sendSMS, msg) 
1240         }
1241     if (location.contactBookEnabled && settings.recipients) {
1242         sendNotificationToContacts(msg, settings.recipients)
1243     } else if (settings.sendPush || forceFlag) {
1244                 sendPush(msg)
1245         }
1246 }
1247
1248 private getAllOk(botvacId) {
1249         getTriggerConditionsOk(botvacId) && getDaysOk(botvacId) && getTimeOk(botvacId) && getScheduleOk(botvacId) && getContactSensorsOk(botvacId)
1250 }
1251
1252 private getScheduleOk(botvacId) {
1253         def t = (now() - state.lastClean[botvacId]) + 2
1254     def result = t > (settings["ssCleaningInterval#$botvacId"] * 86400000)
1255     log.trace "scheduleOk for $botvacId = $result"
1256     result
1257 }
1258
1259 private getTriggerConditionsOk(botvacId) {
1260         //Calculate, depending on smart schedule trigger mode, whether conditions currently match
1261     def result = true
1262     
1263     if (settings["ssScheduleTrigger#$botvacId"] == "mode") {
1264         result = location.mode in settings["ssAwayModes#$botvacId"] 
1265     } else if (settings["ssScheduleTrigger#$botvacId"] == "switch") {
1266         if (settings["ssSwitchTriggerCondition#$botvacId"] == "any") {
1267                 result = "on" in settings["ssSwitchTrigger#$botvacId"].currentSwitch
1268         } else {
1269                 for (switchVal in settings["ssSwitchTrigger#$botvacId"].currentSwitch) {
1270                         if (switchVal == "off") {
1271                         result = false
1272                         break
1273                         }
1274                 }
1275         }
1276     } else if (settings["ssScheduleTrigger#$botvacId"] == "presence") {
1277         if (settings["ssPeopleAwayCondition#$botvacId"] == "any") {
1278                 result = "not present" in settings["ssPeopleAway#$botvacId"].currentPresence
1279         } else {
1280                 for (person in settings["ssPeopleAway#$botvacId"]) {
1281                         if (person.currentPresence == "present") {
1282                         result = false
1283                         break
1284                         }
1285                 }
1286         }
1287     } 
1288     
1289     log.trace "triggerConditionsOk for $botvacId = $result"
1290     result
1291 }
1292
1293 private getDaysOk(botvacId) {
1294         def result = true
1295         if (settings["days#$botvacId"]) {
1296                 def df = new java.text.SimpleDateFormat("EEEE")
1297                 if (getTimeZone()) { df.setTimeZone(location.timeZone) }
1298                 def day = df.format(new Date())
1299                 result = settings["days#$botvacId"].contains(day)
1300         }
1301         log.trace "daysOk for $botvacId = $result"
1302         result
1303 }
1304
1305 private getTimeOk(botvacId) {
1306         def result = true
1307         if (settings["starting#$botvacId"] && settings["ending#$botvacId"]) {
1308                 def currTime = now()
1309                 def start = timeToday(settings["starting#$botvacId"], location.timeZone).time
1310                 def stop = timeToday(settings["ending#$botvacId"], location.timeZone).time
1311                 result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start
1312         }
1313         log.trace "timeOk for $botvacId = $result"
1314         result
1315 }
1316
1317 private getContactSensorsOk(botvacId) {
1318         def result = true
1319         def currContacts = settings["ssRestrictContactSensors#$botvacId"]?.currentContact
1320     if (currContacts) {
1321         if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allclosed") { 
1322                 if (currContacts.contains("open")) { result = false }
1323         }
1324         else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "anyclosed") { 
1325                 result = currContacts.findAll {contactVal -> contactVal == "closed" ? true : false}
1326         }
1327         else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allopen") { 
1328                 if (currContacts.contains("closed")) { result = false }
1329         }
1330         else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "anyopen") { 
1331                 result = currContacts.findAll {contactVal -> contactVal == "open" ? true : false}
1332         }
1333     }
1334         log.trace "contactSesnorsOk for $botvacId = $result"
1335         result
1336 }
1337
1338 private hhmm(time, fmt = "h:mm a z") {
1339         def t = timeToday(time, location.timeZone)
1340         def f = new java.text.SimpleDateFormat(fmt)
1341     if (getTimeZone()) { f.setTimeZone(location.timeZone ?: timeZone(time)) }
1342         f.format(t)
1343 }
1344
1345 def getTimeLabel(starting, ending){
1346         def timeLabel = "Tap to set"
1347
1348     if(starting && ending){
1349         timeLabel = "Between" + " " + hhmm(starting) + " "  + "and" + " " +  hhmm(ending)
1350     }
1351     else if (starting) {
1352                 timeLabel = "Start at" + " " + hhmm(starting)
1353     }
1354     else if(ending){
1355     timeLabel = "End at" + hhmm(ending)
1356     }
1357         timeLabel
1358 }
1359
1360 def greyedOutTime(starting, ending){
1361         def result = ""
1362     if (starting || ending) {
1363         result = "complete"
1364     }
1365     result
1366 }
1367
1368 def getTimeZone() {
1369         def tz = null
1370         if(location?.timeZone) { tz = location?.timeZone }
1371         if(!tz) { log.warn "No time zone has been retrieved from SmartThings. Please try to open your ST location and press Save." }
1372         return tz
1373 }
1374
1375 def getChildName()           { return "Neato BotVac" }
1376 def getServerUrl()           { return "https://graph.api.smartthings.com" }
1377 def getShardUrl()            { return getApiServerUrl() }
1378 def getCallbackUrl()         { return "https://graph.api.smartthings.com/oauth/callback" }
1379 def getBuildRedirectUrl()    { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
1380 def getApiEndpoint()         { return "https://apps.neatorobotics.com" }
1381 def getSmartThingsClientId() { return appSettings.clientId }
1382 def beehiveURL(path = '/')       { return "https://beehive.neatocloud.com${path}" }
1383 private def textVersion() {
1384     def text = "Neato (Connect)\nVersion: 1.2.4b\nDate: 28062018(1000)"
1385 }
1386
1387 private def textCopyright() {
1388     def text = "Copyright © 2018 Alex Lee Yuk Cheung"
1389 }
1390
1391 def clientId() {
1392         if(!appSettings.clientId) {
1393                 return "3ba64237d07f43e2e6ecff97de60916b73c4b06df71e9ad35ec02d7b3b513881"
1394         } else {
1395                 return appSettings.clientId
1396         }
1397 }
1398
1399 def clientSecret() {
1400         if(!appSettings.clientSecret) {
1401                 return "e7fd560dab04efdd38488f918a2a8b0c097157d765e19003360fc458f5119bde"
1402         } else {
1403                 return appSettings.clientSecret
1404         }
1405 }