Update Switches.groovy
[smartapps.git] / official / spruce-scheduler.groovy
1 /**
2  *  Spruce Scheduler Pre-release V2.53.1 - Updated 11/07/2016, BAB
3  *
4  *      
5  *  Copyright 2015 Plaid Systems
6  *
7  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
8  *  in compliance with the License. You may obtain a copy of the License at:
9  *
10  *      http://www.apache.org/licenses/LICENSE-2.0
11  *
12  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
13  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
14  *  for the specific language governing permissions and limitations under the License.
15  *
16
17 -------v2.53.1-------------------
18 -ln 210: enableManual string modified
19 -ln 496: added code for old ST app zoneNumber number to convert to enum for app update compatibility
20 -ln 854: unschedule if NOT running to clear/correct manual subscription
21 -ln 863: weather scheduled if rain OR seasonal enabled, both off is no weather check scheduled
22 -ln 1083: added sync check to manual start
23 -ln 1538: corrected contact delay minimum fro 5s to 10s
24
25 -------v2.52---------------------
26  -Major revision by BAB
27  *
28  */
29  
30 definition(
31     name: "Spruce Scheduler",
32     namespace: "plaidsystems",
33     author: "Plaid Systems",
34     description: "Setup schedules for Spruce irrigation controller",
35     category: "Green Living",
36     iconUrl: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png",
37     iconX2Url: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png",
38     iconX3Url: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png")    
39  
40 preferences {
41     page(name: 'startPage')
42     page(name: 'autoPage')
43     page(name: 'zipcodePage')
44     page(name: 'weatherPage')
45     page(name: 'globalPage')
46     page(name: 'contactPage')
47     page(name: 'delayPage')
48     page(name: 'zonePage')    
49
50         page(name: 'zoneSettingsPage')
51     page(name: 'zoneSetPage')
52     page(name: 'plantSetPage')
53     page(name: 'sprinklerSetPage')
54     page(name: 'optionSetPage')
55     
56     //found at bottom - transition pages
57     page(name: 'zoneSetPage1')
58     page(name: 'zoneSetPage2')
59     page(name: 'zoneSetPage3')
60     page(name: 'zoneSetPage4')
61     page(name: 'zoneSetPage5')
62     page(name: 'zoneSetPage6')
63     page(name: 'zoneSetPage7')
64     page(name: 'zoneSetPage8')
65     page(name: 'zoneSetPage9')
66     page(name: 'zoneSetPage10')
67     page(name: 'zoneSetPage11')
68     page(name: 'zoneSetPage12')
69     page(name: 'zoneSetPage13')
70     page(name: 'zoneSetPage14')
71     page(name: 'zoneSetPage15')
72     page(name: 'zoneSetPage16') 
73 }
74  
75 def startPage(){
76     dynamicPage(name: 'startPage', title: 'Spruce Smart Irrigation setup', install: true, uninstall: true)
77     {                      
78         section(''){
79             href(name: 'globalPage', title: 'Schedule settings', required: false, page: 'globalPage',
80                 image: 'http://www.plaidsystems.com/smartthings/st_settings.png',                
81                 description: "Schedule: ${enableString()}\nWatering Time: ${startTimeString()}\nDays:${daysString()}\nNotifications:${notifyString()}"
82             )
83         }
84              
85         section(''){            
86             href(name: 'weatherPage', title: 'Weather Settings', required: false, page: 'weatherPage',
87                 image: 'http://www.plaidsystems.com/smartthings/st_rain_225_r.png',
88                 description: "Weather from: ${zipString()}\nRain Delay: ${isRainString()}\nSeasonal Adjust: ${seasonalAdjString()}"
89             )
90         }
91              
92         section(''){            
93             href(name: 'zonePage', title: 'Zone summary and setup', required: false, page: 'zonePage',
94                 image: 'http://www.plaidsystems.com/smartthings/st_zone16_225.png',
95                 description: "${getZoneSummary()}"
96             )
97         }
98              
99         section(''){            
100             href(name: 'delayPage', title: 'Valve delays & Pause controls', required: false, page: 'delayPage',
101                 image: 'http://www.plaidsystems.com/smartthings/st_timer.png',
102                 description: "Valve Delay: ${pumpDelayString()} s\n${waterStoppersString()}\nSchedule Sync: ${syncString()}"
103             )
104         }
105             
106         section(''){
107             href(title: 'Spruce Irrigation Knowledge Base', //page: 'customPage',
108                 description: 'Explore our knowledge base for more information on Spruce and Spruce sensors.  Contact form is ' +
109                                          'also available here.',
110                 required: false, style:'embedded',             
111                 image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png',
112                 url: 'http://support.spruceirrigation.com'
113             )
114         }
115     }
116 }
117  
118 def globalPage() {
119     dynamicPage(name: 'globalPage', title: '') {
120         section('Spruce schedule Settings') {
121                 label title: 'Schedule Name:', description: 'Name this schedule', required: false                
122                 input 'switches', 'capability.switch', title: 'Spruce Irrigation Controller:', description: 'Select a Spruce controller', required: true, multiple: false
123                 }        
124
125         section('Program Scheduling'){
126             input 'enable', 'bool', title: 'Enable watering:', defaultValue: 'true', metadata: [values: ['true', 'false']]
127             input 'enableManual', 'bool', title: 'Enable this schedule for manual start, only 1 schedule should be enabled for manual start at a time!', defaultValue: 'true', metadata: [values: ['true', 'false']]
128             input 'startTime', 'time', title: 'Watering start time', required: true            
129             paragraph(image: 'http://www.plaidsystems.com/smartthings/st_calander.png',
130                       title: 'Selecting watering days', 
131                       'Selecting watering days is optional. Spruce will optimize your watering schedule automatically. ' + 
132                       'If your area has water restrictions or you prefer set days, select the days to meet your requirements. ')
133                         input (name: 'days', type: 'enum', title: 'Water only on these days...', required: false, multiple: true, metadata: [values: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Even', 'Odd']])            
134                 }
135
136         section('Push Notifications') {
137                 input(name: 'notify', type: 'enum', title: 'Select what push notifications to receive.', required: false, 
138                         multiple: true, metadata: [values: ['Daily', 'Delays', 'Warnings', 'Weather', 'Moisture', 'Events']])
139                 input('recipients', 'contact', title: 'Send push notifications to', required: false, multiple: true)
140                 input(name: 'logAll', type: 'bool', title: 'Log all notices to Hello Home?', defaultValue: 'false', options: ['true', 'false'])
141         } 
142     }
143 }
144
145 def weatherPage() {
146     dynamicPage(name: 'weatherPage', title: 'Weather settings') {
147        section('Location to get weather forecast and conditions:') {
148             href(name: 'hrefWithImage', title: "${zipString()}", page: 'zipcodePage',
149                 description: 'Set local weather station',
150                 required: false,             
151                 image: 'http://www.plaidsystems.com/smartthings/rain.png'
152                 )             
153             input 'isRain', 'bool', title: 'Enable Rain check:', metadata: [values: ['true', 'false']] 
154             input 'rainDelay', 'decimal', title: 'inches of rain that will delay watering, default: 0.2', required: false
155             input 'isSeason', 'bool', title: 'Enable Seasonal Weather Adjustment:', metadata: [values: ['true', 'false']]
156         }                
157     }    
158 }
159  
160 def zipcodePage() {
161     return dynamicPage(name: 'zipcodePage', title: 'Spruce weather station setup') {
162         section(''){
163                 input(name: 'zipcode', type: 'text', title: 'Zipcode or WeatherUnderground station id. Default value is current Zip code', 
164                         defaultValue: getPWSID(), required: false, submitOnChange: true )
165         }
166          
167         section(''){
168                 paragraph(image: 'http://www.plaidsystems.com/smartthings/wu.png', title: 'WeatherUnderground Personal Weather Stations (PWS)',
169                                         required: false,
170                                         'To automatically select the PWS nearest to your hub location, select the toggle below and clear the ' + 
171                                         'location field above')
172                 input(name: 'nearestPWS', type: 'bool', title: 'Use nearest PWS', options: ['true', 'false'], 
173                                                 defaultValue: false, submitOnChange: true)
174                 href(title: 'Or, Search WeatherUnderground.com for your desired PWS',
175                                         description: 'After page loads, select "Change Station" for a list of weather stations.  ' +
176                                         'You will need to copy the station code into the location field above',
177                 required: false, style:'embedded',             
178                 url: (location.latitude && location.longitude)? "http://www.wunderground.com/cgi-bin/findweather/hdfForecast?query=${location.latitude}%2C${location.longitude}" :
179                          "http://www.wunderground.com/q/${location.zipCode}")
180         }
181     }
182 }
183
184 private String getPWSID() {
185         String PWSID = location.zipCode
186         if (zipcode) PWSID = zipcode
187         if (nearestPWS && !zipcode) {
188                 // find the nearest PWS to the hub's geo location
189                 String geoLocation = location.zipCode
190                 // use coordinates, if available
191                 if (location.latitude && location.longitude) geoLocation = "${location.latitude}%2C${location.longitude}"  
192         Map wdata = getWeatherFeature('geolookup', geoLocation)
193         if (wdata && wdata.response && !wdata.response.containsKey('error')) {  // if we get good data
194                 if (wdata.response.features.containsKey('geolookup') && (wdata.response.features.geolookup.toInteger() == 1) && wdata.location) {
195                         PWSID = wdata.location.nearby_weather_stations.pws.station[0].id
196                 }
197                 else log.debug "bad response"
198         }
199         else log.debug "null or error"
200         }
201         log.debug "Nearest PWS ${PWSID}"
202         return PWSID
203 }
204  
205 private String startTimeString(){  
206     if (!startTime) return 'Please set!' else return hhmm(startTime)    
207 }
208
209 private String enableString(){  
210     if(enable && enableManual) return 'On & Manual Set'
211     else if (enable) return 'On & Manual Off' 
212     else if (enableManual) return 'Off & Manual Set'
213     else return 'Off'
214 }
215
216 private String waterStoppersString(){
217         String stoppers = 'Contact Sensor'
218         if (settings.contacts) {
219                 if (settings.contacts.size() != 1) stoppers += 's'
220                 stoppers += ': '
221                 int i = 1
222                 settings.contacts.each {
223                         if ( i > 1) stoppers += ', '
224                         stoppers += it.displayName
225                         i++
226                 }
227                 stoppers = "${stoppers}\nPause: When ${settings.contactStop}\n"
228         } 
229         else {
230                 stoppers += ': None\n'
231         }
232         stoppers += "Switch"
233         if (settings.toggles) {
234                 if (settings.toggles.size() != 1) stoppers += 'es'
235                 stoppers += ': '
236                 int i = 1
237                 settings.toggles.each {
238                         if ( i > 1) stoppers += ', '
239                         stoppers += it.displayName
240                         i++
241                 }
242                 stoppers = "${stoppers}\nPause: When switched ${settings.toggleStop}\n"
243         } 
244         else {
245                 stoppers += ': None\n'
246         }
247         int cd = 10
248         if (settings.contactDelay && settings.contactDelay > 10) cd = settings.contactDelay.toInteger() 
249         stoppers += "Restart Delay: ${cd} secs"
250         return stoppers
251 }
252
253 private String isRainString(){
254         if (settings.isRain && !settings.rainDelay) return '0.2' as String
255     if (settings.isRain) return settings.rainDelay as String else return 'Off'
256 }    
257     
258 private String seasonalAdjString(){
259         if(settings.isSeason) return 'On' else return 'Off'
260 }
261
262 private String syncString(){
263         if (settings.sync) return "${settings.sync.displayName}" else return 'None'
264 }
265
266 private String notifyString(){
267         String notifyStr = ''
268         if(settings.notify) {
269         if (settings.notify.contains('Daily'))          notifyStr += ' Daily'
270         //if (settings.notify.contains('Weekly'))       notifyStr += ' Weekly'
271         if (settings.notify.contains('Delays'))         notifyStr += ' Delays'
272         if (settings.notify.contains('Warnings'))       notifyStr += ' Warnings'
273         if (settings.notify.contains('Weather'))        notifyStr += ' Weather'
274         if (settings.notify.contains('Moisture'))       notifyStr += ' Moisture'
275         if (settings.notify.contains('Events'))         notifyStr += ' Events'
276         }
277         if (notifyStr == '')    notifyStr = ' None'
278         if (settings.logAll) notifyStr += '\nSending all Notifications to Hello Home log'
279  
280         return notifyStr
281 }
282
283 private String daysString(){
284         String daysString = ''
285     if (days){
286         if(days.contains('Even') || days.contains('Odd')) {
287                 if (days.contains('Even'))              daysString += ' Even'
288                 if (days.contains('Odd'))               daysString += ' Odd'
289         } 
290         else {
291             if (days.contains('Monday'))        daysString += ' M'
292                 if (days.contains('Tuesday'))   daysString += ' Tu'
293                 if (days.contains('Wednesday')) daysString += ' W'
294                 if (days.contains('Thursday'))  daysString += ' Th'
295                 if (days.contains('Friday'))    daysString += ' F'
296                 if (days.contains('Saturday'))  daysString += ' Sa'
297                 if (days.contains('Sunday'))    daysString += ' Su'
298         }
299     }
300     if(daysString == '') return ' Any'
301
302     else return daysString
303 }
304     
305 private String hhmm(time, fmt = 'h:mm a'){
306     def t = timeToday(time, location.timeZone)
307     def f = new java.text.SimpleDateFormat(fmt)
308     f.setTimeZone(location.timeZone ?: timeZone(time))
309     return f.format(t)
310 }
311  
312 private String pumpDelayString(){
313     if (!pumpDelay) return '0' else return pumpDelay as String
314
315
316 }
317  
318 def delayPage() {
319     dynamicPage(name: 'delayPage', title: 'Additional Options') {
320         section(''){
321             paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png',
322                       title: 'Pump and Master valve delay',
323                       required: false,
324                       'Setting a delay is optional, default is 0.  If you have a pump that feeds water directly into your valves, ' +
325                       'set this to 0. To fill a tank or build pressure, you may increase the delay.\n\nStart->Pump On->delay->Valve ' +
326                       'On->Valve Off->delay->...'
327             input name: 'pumpDelay', type: 'number', title: 'Set a delay in seconds?', defaultValue: '0', required: false
328         }
329         
330         section(''){
331             paragraph(image: 'http://www.plaidsystems.com/smartthings/st_pause.png',
332                       title: 'Pause Control Contacts & Switches',
333                       required: false,
334                       'Selecting contacts or control switches is optional. When a selected contact sensor is opened or switch is ' +
335                       'toggled, water immediately stops and will not resume until all of the contact sensors are closed and all of ' +
336                       'the switches are reset.\n\nCaution: if all contacts or switches are left in the stop state, the dependent ' +
337                       'schedule(s) will never run.')
338             input(name: 'contacts', title: 'Select water delay contact sensors', type: 'capability.contactSensor', multiple: true, 
339                 required: false, submitOnChange: true)        
340             // if (settings.contact) settings.contact = null // 'contact' has been deprecated
341                         if (contacts)
342                                 input(name: 'contactStop', title: 'Stop watering when sensors are...', type: 'enum', required: (settings.contacts != null), 
343                                         options: ['open', 'closed'], defaultValue: 'open')
344                         input(name: 'toggles', title: 'Select water delay switches', type: 'capability.switch', multiple: true, required: false, 
345                                 submitOnChange: true)
346                         if (toggles) 
347                                 input(name: 'toggleStop', title: 'Stop watering when switches are...', type: 'enum', 
348                                         required: (settings.toggles != null), options: ['on', 'off'], defaultValue: 'off')
349                         input(name: 'contactDelay', type: 'number', title: 'Restart watering how many seconds after all contacts and switches ' +
350                                         'are reset? (minimum 10s)', defaultValue: '10', required: false)
351         }
352         
353         section(''){
354             paragraph image: 'http://www.plaidsystems.com/smartthings/st_spruce_controller_250.png',
355                                 title: 'Controller Sync',
356                         required: false,
357                         'For multiple controllers only.  This schedule will wait for the selected controller to finish before ' +
358                         'starting. Do not set with a single controller!'
359                                          input name: 'sync', type: 'capability.switch', title: 'Select Master Controller', description: 'Only use this setting with multiple controllers', required: false, multiple: false
360         }
361     }
362 }
363  
364 def zonePage() {    
365     dynamicPage(name: 'zonePage', title: 'Zone setup', install: false, uninstall: false) {
366                 section('') {
367             href(name: 'hrefWithImage', title: 'Zone configuration', page: 'zoneSettingsPage',
368              description: "${zoneString()}",
369              required: false,             
370              image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png')
371         }
372
373                 if (zoneActive('1')){
374         section(''){
375             href(name: 'z1Page', title: "1: ${getname("1")}", required: false, page: 'zoneSetPage1',
376                 image: "${getimage("1")}",                
377                 description: "${display("1")}" )
378             }
379         }
380         if (zoneActive('2')){
381         section(''){
382             href(name: 'z2Page', title: "2: ${getname("2")}", required: false, page: 'zoneSetPage2',
383                 image: "${getimage("2")}",
384                 description: "${display("2")}" )
385             }
386         }
387         if (zoneActive('3')){
388         section(''){
389             href(name: 'z3Page', title: "3: ${getname("3")}", required: false, page: 'zoneSetPage3',
390                 image: "${getimage("3")}",
391                 description: "${display("3")}" )
392             }
393         }
394         if (zoneActive('4')){
395         section(''){
396             href(name: 'z4Page', title: "4: ${getname("4")}", required: false, page: 'zoneSetPage4',
397                 image: "${getimage("4")}",
398                 description: "${display("4")}" )
399             }
400         }
401         if (zoneActive('5')){
402         section(''){
403             href(name: 'z5Page', title: "5: ${getname("5")}", required: false, page: 'zoneSetPage5',
404                 image: "${getimage("5")}",
405                 description: "${display("5")}" )
406             }
407         }
408         if (zoneActive('6')){
409         section(''){
410             href(name: 'z6Page', title: "6: ${getname("6")}", required: false, page: 'zoneSetPage6',
411                 image: "${getimage("6")}",
412                 description: "${display("6")}" )
413             }
414         }
415         if (zoneActive('7')){    
416         section(''){
417             href(name: 'z7Page', title: "7: ${getname("7")}", required: false, page: 'zoneSetPage7',
418                 image: "${getimage("7")}",
419                 description: "${display("7")}" )
420             }
421         }
422         if (zoneActive('8')){
423         section(''){
424             href(name: 'z8Page', title: "8: ${getname("8")}", required: false, page: 'zoneSetPage8',
425                 image: "${getimage("8")}",
426                 description: "${display("8")}" )
427             }
428         }
429         if (zoneActive('9')){
430         section(''){
431             href(name: 'z9Page', title: "9: ${getname("9")}", required: false, page: 'zoneSetPage9',
432                 image: "${getimage("9")}",
433                 description: "${display("9")}" )
434             }
435         }
436         if (zoneActive('10')){
437         section(''){
438             href(name: 'z10Page', title: "10: ${getname("10")}", required: false, page: 'zoneSetPage10',
439                 image: "${getimage("10")}",
440                 description: "${display("10")}" )
441             }
442         }
443         if (zoneActive('11')){
444         section(''){
445             href(name: 'z11Page', title: "11: ${getname("11")}", required: false, page: 'zoneSetPage11',
446                 image: "${getimage("11")}",
447                 description: "${display("11")}" )
448             }
449         }
450         if (zoneActive('12')){
451         section(''){
452             href(name: 'z12Page', title: "12: ${getname("12")}", required: false, page: 'zoneSetPage12',
453                 image: "${getimage("12")}",
454                 description: "${display("12")}" )
455             }
456         }
457         if (zoneActive('13')){
458         section(''){
459             href(name: 'z13Page', title: "13: ${getname("13")}", required: false, page: 'zoneSetPage13',
460                 image: "${getimage("13")}",
461                 description: "${display("13")}" )
462             }
463         }
464         if (zoneActive('14')){
465         section(''){
466             href(name: 'z14Page', title: "14: ${getname("14")}", required: false, page: 'zoneSetPage14',
467                 image: "${getimage("14")}",
468                 description: "${display("14")}" )
469             }
470         }
471         if (zoneActive('15')){
472         section(''){
473             href(name: 'z15Page', title: "15: ${getname("15")}", required: false, page: 'zoneSetPage15',
474                 image: "${getimage("15")}",
475                 description: "${display("15")}" )
476             }
477         }
478         if (zoneActive('16')){
479         section(''){
480             href(name: 'z16Page', title: "16: ${getname("16")}", required: false, page: 'zoneSetPage16',
481                 image: "${getimage("16")}",
482                 description: "${display("16")}" )
483             }
484         }        
485     }
486 }
487
488 // Verify whether a zone is active
489 /*//Code for fresh install
490 private boolean zoneActive(String zoneStr){
491         if (!zoneNumber) return false
492     if (zoneNumber.contains(zoneStr)) return true       // don't display zones that are not selected
493     return false
494 }
495 */
496 //code change for ST update file -> change input to zoneNumberEnum   
497 private boolean zoneActive(z){  
498     if (!zoneNumberEnum && zoneNumber && zoneNumber >= z.toInteger()) return true        
499     else if (!zoneNumberEnum && zoneNumber && zoneNumber != z.toInteger()) return false
500     else if (zoneNumberEnum && zoneNumberEnum.contains(z)) return true
501     return false
502 }
503
504
505 private String zoneString() {
506         String numberString = 'Add zones to setup'
507     if (zoneNumber) numberString = "Zones enabled: ${zoneNumber}"
508     if (learn) numberString = "${numberString}\nSensor mode: Adaptive"
509     else numberString = "${numberString}\nSensor mode: Delay"
510     return numberString
511 }
512
513 def zoneSettingsPage() {
514         dynamicPage(name: 'zoneSettingsPage', title: 'Zone Configuration') {
515         section(''){
516                 //input (name: "zoneNumber", type: "number", title: "Enter number of zones to configure?",description: "How many valves do you have? 1-16", required: true)//, defaultValue: 16)
517             input 'zoneNumberEnum', 'enum', title: 'Select zones to configure', multiple: true, metadata: [values: ['1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16']]
518             input 'gain', 'number', title: 'Increase or decrease all water times by this %, enter a negative or positive value, Default: 0', required: false, range: '-99..99'
519                         paragraph image: 'http://www.plaidsystems.com/smartthings/st_sensor_200_r.png',
520                         title: 'Moisture sensor adapt mode',                      
521                         'Adaptive mode enabled: Watering times will be adjusted based on the assigned moisture sensor.\n\nAdaptive mode ' +
522                         'disabled (Delay): Zones with moisture sensors will water on any available days when the low moisture setpoint has ' +
523                         'been reached.'
524                 input 'learn', 'bool', title: 'Enable Adaptive Moisture Control (with moisture sensors)', metadata: [values: ['true', 'false']]
525         }
526         }
527 }
528
529 def zoneSetPage() {    
530     dynamicPage(name: 'zoneSetPage', title: "Zone ${state.app} Setup") {
531         section(''){
532             paragraph image: "http://www.plaidsystems.com/smartthings/st_${state.app}.png",             
533             title: 'Current Settings',            
534             "${display("${state.app}")}"        
535         }
536         
537         section(''){
538             input "name${state.app}", 'text', title: 'Zone name?', required: false, defaultValue: "Zone ${state.app}"
539         }
540         
541         section(''){            
542                          href(name: 'tosprinklerSetPage', title: "Sprinkler type: ${setString('zone')}", required: false, page: 'sprinklerSetPage',
543                 image: "${getimage("${settings."zone${state.app}"}")}",         
544                 //description: "Set sprinkler nozzle type or turn zone off")
545                 description: 'Sprinkler type descriptions')         
546              input "zone${state.app}", 'enum', title: 'Sprinkler Type', multiple: false, required: false, defaultValue: 'Off', submitOnChange: true, metadata: [values: ['Off', 'Spray', 'Rotor', 'Drip', 'Master Valve', 'Pump']]
547         }
548         
549         section(''){            
550             href(name: 'toplantSetPage', title: "Landscape Select: ${setString('plant')}", required: false, page: 'plantSetPage',
551                 image: "${getimage("${settings["plant${state.app}"]}")}",
552                 //description: "Set landscape type")
553                 description: 'Landscape type descriptions')
554             input "plant${state.app}", 'enum', title: 'Landscape', multiple: false, required: false, submitOnChange: true, metadata: [values: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']]
555             }  
556          
557         section(''){            
558             href(name: 'tooptionSetPage', title: "Options: ${setString('option')}", required: false, page: 'optionSetPage',
559                 image: "${getimage("${settings["option${state.app}"]}")}",
560                 //description: "Set watering options")
561                 description: 'Watering option descriptions')
562                 input "option${state.app}", 'enum', title: 'Options', multiple: false, required: false, defaultValue: 'Cycle 2x', submitOnChange: true,metadata: [values: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x']]
563         }
564         
565         section(''){
566             paragraph image: 'http://www.plaidsystems.com/smartthings/st_sensor_200_r.png',
567                       title: 'Moisture sensor settings',                      
568                       'Select a soil moisture sensor to monitor and control watering.  The soil moisture target value is set to a default value but can be adjusted to tune watering'
569             input "sensor${state.app}", 'capability.relativeHumidityMeasurement', title: 'Select moisture sensor?', required: false, multiple: false
570             input "sensorSp${state.app}", 'number', title: "Minimum moisture sensor target value, Setpoint: ${getDrySp(state.app)}", required: false
571         }
572         
573         section(''){
574             paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png',
575                       title: 'Optional: Enter total watering time per week', 
576                       'This value will replace the calculated time from other settings'
577                 input "minWeek${state.app}", 'number', title: 'Minimum water time per week.\nDefault: 0 = autoadjust', description: 'minutes per week', required: false
578                 input "perDay${state.app}", 'number', title: 'Guideline value for time per day, this divides minutes per week into watering days. Default: 20', defaultValue: '20', required: false
579         }
580     }
581 }    
582
583 private String setString(String type) {
584         switch (type) {
585                 case 'zone':
586                 if (settings."zone${state.app}") return settings."zone${state.app}" else return 'Not Set'
587                 break
588         case 'plant':
589                 if (settings."plant${state.app}") return settings."plant${state.app}" else return 'Not Set'
590                 break
591                 case 'option':
592                 if (settings."option${state.app}") return settings."option${state.app}" else return 'Not Set'
593                 break
594         default:
595                 return '????'
596         }
597 }
598
599 def plantSetPage() { 
600     dynamicPage(name: 'plantSetPage', title: "${settings["name${state.app}"]} Landscape Select") {
601         section(''){
602             paragraph image: 'http://www.plaidsystems.com/img/st_${state.app}.png',             
603                 title: "${settings["name${state.app}"]}",
604                 "Current settings ${display("${state.app}")}"
605             //input "plant${state.app}", "enum", title: "Landscape", multiple: false, required: false, submitOnChange: true, metadata: [values: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']]
606         }        
607         section(''){
608             paragraph image: 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png',             
609             title: 'Lawn',            
610             'Select Lawn for typical grass applications'
611
612             paragraph image: 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png',             
613             title: 'Garden',            
614             'Select Garden for vegetable gardens'
615             
616             paragraph image: 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png',             
617             title: 'Flowers',            
618             'Select Flowers for beds with smaller seasonal plants'
619              
620             paragraph image: 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png',             
621             title: 'Shrubs',            
622             'Select Shrubs for beds with larger established plants'
623            
624             paragraph image: 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png',             
625             title: 'Trees',            
626             'Select Trees for deep rooted areas without other plants'
627            
628             paragraph image: 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png',             
629             title: 'Xeriscape',            
630             'Reduces water for native or drought tolorent plants'
631             
632             paragraph image: 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png',             
633             title: 'New Plants',            
634             'Increases watering time per week and reduces automatic adjustments to help establish new plants. No weekly seasonal adjustment and moisture setpoint set to 40.'
635         }
636     }
637 }
638  
639 def sprinklerSetPage(){
640     dynamicPage(name: 'sprinklerSetPage', title: "${settings["name${state.app}"]} Sprinkler Select") {
641         section(''){
642             paragraph image: "http://www.plaidsystems.com/img/st_${state.app}.png",             
643             title: "${settings["name${state.app}"]}",
644             "Current settings ${display("${state.app}")}"
645             //input "zone${state.app}", "enum", title: "Sprinkler Type", multiple: false, required: false, defaultValue: 'Off', metadata: [values: ['Off', 'Spray', 'Rotor', 'Drip', 'Master Valve', 'Pump']]
646             }
647         section(''){
648             paragraph image: 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png',             
649             title: 'Spray',            
650             'Spray sprinkler heads spray a fan of water over the lawn. The water is applied evenly and can be turned on for a shorter duration of time.'
651              
652             paragraph image: 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png',             
653             title: 'Rotor',            
654             'Rotor sprinkler heads rotate, spraying a stream over the lawn.  Because they move back and forth across the lawn, they require a longer water period.'
655              
656             paragraph image: 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png',             
657             title: 'Drip',            
658             'Drip lines or low flow emitters water slowely to minimize evaporation, because they are low flow, they require longer watering periods.'
659              
660             paragraph image: 'http://www.plaidsystems.com/smartthings/st_master_225_r.png',             
661             title: 'Master',            
662             'Master valves will open before watering begins.  Set the delay between master opening and watering in delay settings.'
663              
664             paragraph image: 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png',             
665             title: 'Pump',            
666             'Attach a pump relay to this zone and the pump will turn on before watering begins.  Set the delay between pump start and watering in delay settings.'
667         }
668     }
669 }
670  
671 def optionSetPage(){
672     dynamicPage(name: 'optionSetPage', title: "${settings["name${state.app}"]} Options") {
673         section(''){
674             paragraph image: "http://www.plaidsystems.com/img/st_${state.app}.png",             
675             title: "${settings["name${state.app}"]}",
676             "Current settings ${display("${state.app}")}"
677             //input "option${state.app}", "enum", title: "Options", multiple: false, required: false, defaultValue: 'Cycle 2x', metadata: [values: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x']]    
678         }
679         section(''){
680             paragraph image: 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png',             
681             title: 'Slope',            
682             'Slope sets the sprinklers to cycle 3x, each with a short duration to minimize runoff'
683              
684             paragraph image: 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png',             
685             title: 'Sand',            
686             'Sandy soil drains quickly and requires more frequent but shorter intervals of water'
687              
688             paragraph image: 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png',             
689             title: 'Clay',            
690             'Clay sets the sprinklers to cycle 2x, each with a short duration to maximize absorption'
691              
692             paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png',             
693             title: 'No Cycle',            
694             'The sprinklers will run for 1 long duration'
695              
696             paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png',             
697             title: 'Cycle 2x',            
698             'Cycle 2x will break the water period up into 2 shorter cycles to help minimize runoff and maximize adsorption'
699              
700             paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png',             
701             title: 'Cycle 3x',            
702             'Cycle 3x will break the water period up into 3 shorter cycles to help minimize runoff and maximize adsorption'
703         }
704     }
705 }
706  
707 def setPage(i){
708     if (i) state.app = i
709     return state.app
710 }
711
712 private String getaZoneSummary(int zone){
713         if (!settings."zone${zone}" || (settings."zone${zone}" == 'Off')) return "${zone}: Off"
714         
715         String daysString = ''
716     int tpw = initTPW(zone)
717         int dpw = initDPW(zone)
718         int runTime = calcRunTime(tpw, dpw)
719         
720         if ( !learn && (settings."sensor${zone}")) {
721                 daysString = 'if Moisture is low on: '
722         dpw = daysAvailable()
723         }  
724         if (days && (days.contains('Even') || days.contains('Odd'))) {
725         if (dpw == 1) daysString = 'Every 8 days'
726         if (dpw == 2) daysString = 'Every 4 days'
727         if (dpw == 4) daysString = 'Every 2 days'
728         if (days.contains('Even') && days.contains('Odd')) daysString = 'any day'
729         } 
730         else {
731         def int[] dpwMap = [0,0,0,0,0,0,0]
732         dpwMap = getDPWDays(dpw)
733         daysString += getRunDays(dpwMap)
734         }  
735         return "${zone}: ${runTime} min, ${daysString}"
736 }
737
738 private String getZoneSummary(){
739         String summary = ''
740     if (learn) summary = 'Moisture Learning enabled' else summary = 'Moisture Learning disabled'
741          
742     int zone = 1
743     createDPWMap()
744     while(zone <= 16) {   
745         if (nozzle(zone) == 4) summary = "${summary}\n${zone}: ${settings."zone${zone}"}"
746         else if ( (initDPW(zone) != 0) && zoneActive(zone.toString())) summary = "${summary}\n${getaZoneSummary(zone)}"
747         zone++
748     }
749     if (summary) return summary else return zoneString()        //"Setup all 16 zones"
750 }
751  
752 private String display(String i){
753         //log.trace "display(${i})"
754     String displayString = ''    
755     int tpw = initTPW(i.toInteger())
756     int dpw = initDPW(i.toInteger())
757     int runTime = calcRunTime(tpw, dpw)
758     if (settings."zone${i}")    displayString += settings."zone${i}" + ' : '
759     if (settings."plant${i}")   displayString += settings."plant${i}" + ' : '
760     if (settings."option${i}")  displayString += settings."option${i}" + ' : '
761     int j = i.toInteger()
762     if (settings."sensor${i}") {
763         displayString += settings."sensor${i}"
764         displayString += "=${getDrySp(j)}% : "
765     }
766     if ((runTime != 0) && (dpw != 0)) displayString = "${displayString}${runTime} minutes, ${dpw} days per week"
767     return displayString
768 }
769
770 private String getimage(String image){
771         String imageStr = image
772         if (image.isNumber()) {
773                 String zoneStr = settings."zone${image}"
774                 if (zoneStr) {
775                 if (zoneStr == 'Off')                   return 'http://www.plaidsystems.com/smartthings/off2.png'   
776                 if (zoneStr == 'Master Valve')  return 'http://www.plaidsystems.com/smartthings/master.png'
777                 if (zoneStr == 'Pump')                  return 'http://www.plaidsystems.com/smartthings/pump.png'
778         
779                 if (settings."plant${image}") imageStr = settings."plant${image}"               // default assume asking for the plant image
780                 }
781         }
782         // OK, lookup the requested image
783     switch (imageStr) {
784         case "null":
785         case null:
786             return 'http://www.plaidsystems.com/smartthings/off2.png'
787         case 'Off':
788             return 'http://www.plaidsystems.com/smartthings/off2.png'
789         case 'Lawn':
790             return 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png'
791         case 'Garden':
792             return 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png'
793         case 'Flowers':
794             return 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png'
795         case 'Shrubs':
796             return 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png'
797         case 'Trees':
798             return 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png'
799         case 'Xeriscape':
800             return 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png'
801         case 'New Plants':
802             return 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png'
803         case 'Spray':
804             return 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png'
805         case 'Rotor':
806             return 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png'
807         case 'Drip':
808             return 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png'
809         case 'Master Valve':
810             return "http://www.plaidsystems.com/smartthings/st_master_225_r.png"
811         case 'Pump':
812             return 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png'
813         case 'Slope':
814             return 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png'
815         case 'Sand':
816             return 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png'
817         case 'Clay':
818             return 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png'
819         case 'No Cycle':
820             return 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png'
821         case 'Cycle 2x':
822             return 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png'
823         case "Cycle 3x":
824             return 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png'
825         default:
826             return 'http://www.plaidsystems.com/smartthings/off2.png'            
827     }
828 }
829  
830 private String getname(String i) { 
831     if (settings."name${i}") return settings."name${i}" else return "Zone ${i}"
832 }
833
834 private String zipString() {
835     if (!settings.zipcode) return "${location.zipCode}"
836     //add pws for correct weatherunderground lookup
837     if (!settings.zipcode.isNumber()) return "pws:${settings.zipcode}"
838     else return settings.zipcode
839 }
840          
841 //app install
842 def installed() {
843     state.dpwMap =                              [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
844     state.tpwMap =                              [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
845     state.Rain =                                [0,0,0,0,0,0,0]    
846     state.daycount =                    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
847         atomicState.run =                       false                           // must be atomic - used to recover from crashes
848         state.pauseTime =                       null
849         atomicState.startTime =         null
850         atomicState.finishTime =        null            // must be atomic - used to recover from crashes
851     
852     log.debug "Installed with settings: ${settings}"
853     installSchedule()
854 }
855  
856 def updated() {    
857     log.debug "Updated with settings: ${settings}"
858     installSchedule()    
859 }
860  
861 def installSchedule(){
862         if (!state.seasonAdj)                   state.seasonAdj = 100.0
863     if (!state.weekseasonAdj)           state.weekseasonAdj = 0
864     if (state.daysAvailable != 0)       state.daysAvailable = 0 // force daysAvailable to be initialized by daysAvailable()
865     state.daysAvailable =                       daysAvailable()                 // every time we save the schedule      
866
867     if (atomicState.run) {
868         attemptRecovery()                                                                       // clean up if we crashed earlier
869     }
870     else {
871         unsubscribe()                                                                           //added back in to reset manual subscription
872         resetEverything()
873     }
874     subscribe(app, appTouch)                                                            // enable the "play" button for this schedule
875     Random rand = new Random()
876     long randomOffset = 0
877     
878     // always collect rainfall    
879     int randomSeconds = rand.nextInt(59)
880     if (settings.isRain || settings.isSeason) schedule("${randomSeconds} 57 23 1/1 * ? *", getRainToday)                // capture today's rainfall just before midnight
881
882     if (settings.switches && settings.startTime && settings.enable){
883
884         randomOffset = rand.nextInt(60000) + 20000
885         def checktime = timeToday(settings.startTime, location.timeZone).getTime() + randomOffset
886         //log.debug "randomOffset ${randomOffset} checktime ${checktime}"
887         schedule(checktime, preCheck)   //check weather & Days
888         writeSettings()
889         note('schedule', "${app.label}: Starts at ${startTimeString()}", 'i')
890     }
891         else {
892                 unschedule( preCheck )
893                 note('disable', "${app.label}: Automatic watering disabled or setup is incomplete", 'a')
894         }
895 }
896
897 // Called to find and repair after crashes - called by installSchedule() and busy()
898 private boolean attemptRecovery() {
899         if (!atomicState.run) {
900                 return false                                                                            // only clean up if we think we are still running
901         }
902         else {                                                                                                  // Hmmm...seems we were running before...
903                 def csw = settings.switches.currentSwitch
904                 def cst = settings.switches.currentStatus
905         switch (csw) {
906                 case 'on':                                                                              // looks like this schedule is running the controller at the moment
907                         if (!atomicState.startTime) {                           // cycleLoop cleared the startTime, but cycleOn() didn't set it
908                                 log.debug "${app.label}: crashed in cycleLoop(), cycleOn() never started, cst is ${cst} - resetting"
909                                 resetEverything()                                               // reset and try again...it's probably not us running the controller, though
910                                 return false
911                         }
912                         // We have a startTime...
913                         if (!atomicState.finishTime) {                          // started, but we don't think we're done yet..so it's probably us!
914                             runIn(15, cycleOn)                                          // goose the cycle, just in case
915                                 note('active', "${app.label}: schedule is apparently already running", 'i')
916                                 return true
917                         }
918                         
919                         // hmmm...switch is on and we think we're finished...probably somebody else is running...let busy figure it out
920                         resetEverything()
921                                 return false
922                         break
923                         
924                 case 'off':                                                                     // switch is off - did we finish?
925                         if (atomicState.finishTime)     {                               // off and finished, let's just reset things
926                                 resetEverything()
927                                 return false
928                         }
929                         
930                         if (switches.currentStatus != 'pause') {        // off and not paused - probably another schedule, let's clean up
931                                         resetEverything()
932                                         return false
933                         }
934                         
935                         // off and not finished, and paused, we apparently crashed while paused
936                         runIn(15, cycleOn)
937                                 return true
938                         break
939
940                 case 'programOn':                                       // died while manual program running?    
941                 case 'programWait':                                     // looks like died previously before we got started, let's try to clean things up
942                                 resetEverything()
943                                 if (atomicState.finishTime) atomicState.finishTime = null
944                                 if ((cst == 'active') || atomicState.startTime) {       // if we announced we were in preCheck, or made it all the way to cycleOn before it crashed
945                                         settings.switches.programOff()                                  // only if we think we actually started (cycleOn() started)
946                                         // probably kills manual cycles too, but we'll let that go for now
947                                 }
948                                 if (atomicState.startTime) atomicState.startTime = null
949                                 note ('schedule', "Looks like ${app.label} crashed recently...cleaning up", c)
950                                 return false
951                                 break
952
953                         default:
954                                 log.debug "attemptRecovery(): atomicState.run == true, and I've nothing left to do"
955                                 return true
956         }
957     } 
958 }
959
960 // reset everything to the initial (not running) state
961 private def resetEverything() {
962         if (atomicState.run) atomicState.run = false            // we're not running the controller any more
963         unsubAllBut()                                                                           // release manual, switches, sync, contacts & toggles 
964         
965         // take care not to unschedule preCheck() or getRainToday()
966         unschedule(cycleOn)
967         unschedule(checkRunMap)
968         unschedule(writeCycles)
969         unschedule(subOff)
970
971         if (settings.enableManual) subscribe(settings.switches, 'switch.programOn', manualStart)
972 }
973
974 // unsubscribe from ALL events EXCEPT app.touch
975 private def unsubAllBut() {
976         unsubscribe(settings.switches)
977         unsubWaterStoppers()
978         if (settings.sync) unsubscribe(settings.sync)
979
980 }
981
982 // enable the "Play" button in SmartApp list
983 def appTouch(evt) {
984
985         log.debug "appTouch(): atomicState.run = ${atomicState.run}"
986
987         runIn(2, preCheck)                                              // run it off a schedule, so we can see how long it takes in the app.state
988 }
989
990 // true if one of the stoppers is in Stop state
991 private boolean isWaterStopped() {
992         if (settings.contacts && settings.contacts.currentContact.contains(settings.contactStop)) return true
993
994         if (settings.toggles && settings.toggles.currentSwitch.contains(settings.toggleStop)) return true
995
996         return false
997 }
998
999 // watch for water stoppers
1000 private def subWaterStop() {
1001         if (settings.contacts) {
1002                 unsubscribe(settings.contacts)
1003                 subscribe(settings.contacts, "contact.${settings.contactStop}", waterStop)
1004         }
1005         if (settings.toggles) {
1006                 unsubscribe(settings.toggles)
1007                 subscribe(settings.toggles, "switch.${settings.toggleStop}", waterStop)
1008         }
1009 }
1010
1011 // watch for water starters
1012 private def subWaterStart() {
1013         if (settings.contacts) {
1014                 unsubscribe(settings.contacts)
1015                 def cond = (settings.contactStop == 'open') ? 'closed' : 'open'
1016                 subscribe(settings.contacts, "contact.${cond}", waterStart)
1017         }
1018         if (settings.toggles) {
1019                 unsubscribe(settings.toggles)
1020                 def cond = (settings.toggleStop == 'on') ? 'off' : 'on'
1021                 subscribe(settings.toggles, "switch.${cond}", waterStart)
1022         }
1023 }
1024
1025 // stop watching water stoppers and starters
1026 private def unsubWaterStoppers() {
1027         if (settings.contacts)  unsubscribe(settings.contacts)
1028         if (settings.toggles)   unsubscribe(settings.toggles)
1029 }
1030
1031 // which of the stoppers are in stop mode?
1032 private String getWaterStopList() {
1033         String deviceList = ''
1034         int i = 1
1035         if (settings.contacts) {
1036                 settings.contacts.each {
1037                         if (it.currentContact == settings.contactStop) {
1038                                 if (i > 1) deviceList += ', '
1039                                 deviceList = "${deviceList}${it.displayName} is ${settings.contactStop}"
1040                                 i++
1041                         }
1042                 }
1043         }
1044         if (settings.toggles) {
1045                 settings.toggles.each {
1046                         if (it.currentSwitch == settings.toggleStop) {
1047                                 if (i > 1) deviceList += ', '
1048                                 deviceList = "${deviceList}${it.displayName} is ${settings.toggleStop}"
1049                                 i++
1050                         }
1051                 }
1052         }
1053         return deviceList
1054 }
1055
1056 //write initial zone settings to device at install/update
1057 def writeSettings(){    
1058     if (!state.tpwMap)                  state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
1059     if (!state.dpwMap)                  state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
1060     if (state.setMoisture)              state.setMoisture = null                                                        // not using any more
1061     if (!state.seasonAdj)               state.seasonAdj = 100.0
1062     if (!state.weekseasonAdj)   state.weekseasonAdj = 0    
1063     setSeason()     
1064 }
1065
1066 //get day of week integer
1067 int getWeekDay(day)
1068 {
1069         def weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
1070         def mapDay = [Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7]  
1071         if(day && weekdays.contains(day)) {
1072         return mapDay.get(day).toInteger()
1073     }
1074         def today = new Date().format('EEEE', location.timeZone)
1075         return mapDay.get(today).toInteger()
1076 }
1077
1078 // Get string of run days from dpwMap
1079 private String getRunDays(day1,day2,day3,day4,day5,day6,day7)
1080 {
1081     String str = ''
1082     if(day1) str += 'M'
1083     if(day2) str += 'T'
1084     if(day3) str += 'W'
1085     if(day4) str += 'Th'
1086     if(day5) str += 'F'
1087     if(day6) str += 'Sa'
1088     if(day7) str += 'Su'
1089     if(str == '') str = '0 Days/week'
1090     return str
1091 }
1092
1093 //start manual schedule
1094 def manualStart(evt){
1095         boolean running = attemptRecovery()             // clean up if prior run crashed
1096         
1097         if (settings.enableManual && !running && (settings.switches.currentStatus != 'pause')){
1098         if (settings.sync && ( (settings.sync.currentSwitch != 'off') || settings.sync.currentStatus == 'pause') ) {            
1099             note('skipping', "${app.label}: Manual run aborted, ${settings.sync.displayName} appears to be busy", 'a')            
1100                 }
1101                 else {
1102             def runNowMap = []
1103             runNowMap = cycleLoop(0)    
1104
1105             if (runNowMap) { 
1106                 atomicState.run = true
1107                 settings.switches.programWait()
1108                 subscribe(settings.switches, 'switch.off', cycleOff)
1109
1110                 runIn(60, cycleOn)                      // start water program
1111
1112                                                 // note that manual DOES abide by waterStoppers (if configured)
1113                 String newString = ''
1114                 int tt = state.totalTime
1115                 if (tt) {
1116                     int hours = tt / 60                 // DON'T Math.round this one
1117                     int mins = tt - (hours * 60)
1118                     String hourString = ''
1119                     String s = ''
1120                     if (hours > 1) s = 's'
1121                     if (hours > 0) hourString = "${hours} hour${s} & "
1122                     s = 's'
1123                     if (mins == 1) s = ''
1124                     newString = "run time: ${hourString}${mins} minute${s}:\n"
1125                 }
1126
1127                 note('active', "${app.label}: Manual run, watering in 1 minute: ${newString}${runNowMap}", 'd')                      
1128             }
1129             else note('skipping', "${app.label}: Manual run failed, check configuration", 'a')
1130                 }
1131     } 
1132     else note('skipping', "${app.label}: Manual run aborted, ${settings.switches.displayName} appears to be busy", 'a')
1133 }
1134
1135 //true if another schedule is running
1136 boolean busy(){
1137         // Check if we are already running, crashed or somebody changed the schedule time while this schedule is running
1138     if (atomicState.run){
1139         if (!attemptRecovery()) {               // recovery will clean out any prior crashes and correct state of atomicState.run
1140                 return false                            // (atomicState.run = false)
1141         }
1142         else {
1143                 // don't change the current status, in case the currently running schedule is in off/paused mode
1144                 note(settings.switches.currentStatus, "${app.label}: Already running, skipping additional start", 'i')
1145                 return true
1146         }
1147     }
1148     // Not already running...
1149     
1150     // Moved from cycleOn() - don't even start pre-check until the other controller completes its cycle
1151     if (settings.sync) {
1152                 if ((settings.sync.currentSwitch != 'off') || settings.sync.currentStatus == 'pause') {
1153             subscribe(settings.sync, 'switch.off', syncOn)
1154
1155             note('delayed', "${app.label}: Waiting for ${settings.sync.displayName} to complete before starting", 'c')
1156             return true
1157         }
1158     }
1159     
1160     // Check that the controller isn't paused while running some other schedule
1161     def csw = settings.switches.currentSwitch
1162     def cst = settings.switches.currentStatus
1163
1164     if ((csw == 'off') && (cst != 'pause')) {                           // off && !paused: controller is NOT in use
1165                 log.debug "switches ${csw}, status ${cst} (1st)"
1166                 resetEverything()                                                                       // get back to the start state
1167         return false
1168     }    
1169     
1170     if (isDay()) {                                                                                      // Yup, we need to run today, so wait for the other schedule to finish
1171         log.debug "switches ${csw}, status ${cst} (3rd)"
1172         resetEverything()
1173         subscribe(settings.switches, 'switch.off', busyOff)
1174                 note('delayed', "${app.label}: Waiting for currently running schedule to complete before starting", 'c')
1175         return true
1176     }
1177     
1178     // Somthing is running, but we don't need to run today anyway - don't need to do busyOff()
1179     // (Probably should never get here, because preCheck() should check isDay() before calling busy()
1180     log.debug "Another schedule is running, but ${app.label} is not scheduled for today anyway"
1181     return true
1182 }
1183
1184 def busyOff(evt){
1185         def cst = settings.switches.currentStatus
1186         if ((settings.switches.currentSwitch == 'off') && (cst != 'pause')) { // double check that prior schedule is done
1187                 unsubscribe(switches)                                                   // we don't want any more button pushes until preCheck runs
1188                 Random rand = new Random()                                              // just in case there are multiple schedules waiting on the same controller
1189                 int randomSeconds = rand.nextInt(120) + 15
1190         runIn(randomSeconds, preCheck)                                  // no message so we don't clog the system
1191         note('active', "${app.label}: ${settings.switches} finished, starting in ${randomSeconds} seconds", 'i')
1192         }
1193 }
1194
1195 //run check every day
1196 def preCheck() {
1197
1198     if (!isDay()) {
1199                 log.debug "preCheck() Skipping: ${app.label} is not scheduled for today"                                        // silent - no note
1200                 //if (!atomicState.run && enableManual) subscribe(switches, 'switch.programOn', manualStart)    // only if we aren't running already
1201                 return
1202         }
1203         
1204         if (!busy()) {
1205                 atomicState.run = true                                          // set true before doing anything, atomic in case we crash (busy() set it false if !busy)
1206                 settings.switches.programWait()                         // take over the controller so other schedules don't mess with us
1207                 runIn(45, checkRunMap)                                          // schedule checkRunMap() before doing weather check, gives isWeather 45s to complete
1208                                                                                                         // because that seems to be a little more than the max that the ST platform allows
1209                 unsubAllBut()                                                           // unsubscribe to everything except appTouch()
1210                 subscribe(settings.switches, 'switch.off', cycleOff)    // and start setting up for today's cycle
1211                 def start = now()
1212                 note('active', "${app.label}: Starting...", 'd')  //
1213                 def end = now()
1214                 log.debug "preCheck note active ${end - start}ms"
1215                 
1216         if (isWeather()) {                                                      // set adjustments and check if we shold skip because of rain
1217                 resetEverything()                                               // if so, clean up our subscriptions
1218                 switches.programOff()                                   // and release the controller
1219                 } 
1220                 else {
1221                         log.debug 'preCheck(): running checkRunMap in 2 seconds'        //COOL! We finished before timing out, and we're supposed to water today
1222                         runIn(2, checkRunMap)   // jack the schedule so it runs sooner!
1223                 }
1224         }
1225 }
1226
1227 //start water program
1228 def cycleOn(){       
1229         if (atomicState.run) {                                                  // block if manually stopped during precheck which goes to cycleOff
1230
1231         if (!isWaterStopped()) {                                        // make sure ALL the contacts and toggles aren't paused
1232             // All clear, let's start running!
1233             subscribe(settings.switches, 'switch.off', cycleOff)
1234             subWaterStop()                                                      // subscribe to all the pause contacts and toggles
1235             resume()
1236             
1237             // send the notification AFTER we start the controller (in case note() causes us to run over our execution time limit)
1238             String newString = "${app.label}: Starting..."
1239             if (!atomicState.startTime) {
1240                 atomicState.startTime = now()                           // if we haven't already started
1241                 if (atomicState.startTime) atomicState.finishTime = null                // so recovery in busy() knows we didn't finish 
1242                 if (state.pauseTime) state.pauseTime = null
1243                 if (state.totalTime) {
1244                         String finishTime = new Date(now() + (60000 * state.totalTime).toLong()).format('EE @ h:mm a', location.timeZone)
1245                         newString = "${app.label}: Starting - ETC: ${finishTime}"
1246                 }
1247             } 
1248             else if (state.pauseTime) {         // resuming after a pause
1249
1250                                 def elapsedTime = Math.round((now() - state.pauseTime) / 60000) // convert ms to minutes
1251                                 int tt = state.totalTime + elapsedTime + 1
1252                                 state.totalTime = tt            // keep track of the pauses, and the 1 minute delay above
1253                         String finishTime = new Date(atomicState.startTime + (60000 * tt).toLong()).format('EE @ h:mm a', location.timeZone) 
1254                         state.pauseTime = null
1255                         newString = "${app.label}: Resuming - New ETC: ${finishTime}"
1256             }
1257             note('active', newString, 'd')
1258         }
1259         else {
1260             // Ready to run, but one of the control contacts is still open, so we wait
1261                         subWaterStart()                                                                         // one of them is paused, let's wait until the are all clear!
1262             note('pause', "${app.label}: Watering paused, ${getWaterStopList()}", 'c')
1263         }
1264     }
1265 }
1266
1267 //when switch reports off, watering program is finished
1268 def cycleOff(evt){
1269
1270     if (atomicState.run) {
1271         def ft = new Date()
1272         atomicState.finishTime = ft                                                                     // this is important to reset the schedule after failures in busy()
1273         String finishTime = ft.format('h:mm a', location.timeZone)
1274         note('finished', "${app.label}: Finished watering at ${finishTime}", 'd')
1275     } 
1276     else {
1277         log.debug "${settings.switches} turned off"             // is this a manual off? perhaps we should send a note?
1278     }
1279         resetEverything()                                                       // all done here, back to starting state
1280 }
1281
1282 //run check each day at scheduled time
1283 def checkRunMap(){
1284     
1285         //check if isWeather returned true or false before checking
1286     if (atomicState.run) {
1287
1288         //get & set watering times for today
1289         def runNowMap = []    
1290         runNowMap = cycleLoop(1)                // build the map
1291
1292         if (runNowMap) { 
1293             runIn(60, cycleOn)                                                                                  // start water
1294             subscribe(settings.switches, 'switch.off', cycleOff)                // allow manual off before cycleOn() starts
1295             if (atomicState.startTime) atomicState.startTime = null             // these were already cleared in cycleLoop() above
1296             if (state.pauseTime) state.pauseTime = null                                 // ditto
1297             // leave atomicState.finishTime alone so that recovery in busy() knows we never started if cycleOn() doesn't clear it
1298             
1299             String newString = ''
1300             int tt = state.totalTime
1301             if (tt) {
1302                 int hours = tt / 60                     // DON'T Math.round this one
1303                 int mins = tt - (hours * 60)
1304                 String hourString = ''
1305                 String s = ''
1306                 if (hours > 1) s = 's'
1307                 if (hours > 0) hourString = "${hours} hour${s} & "
1308                 s = 's'
1309                 if (mins == 1) s = ''
1310                 newString = "run time: ${hourString}${mins} minute${s}:\n"
1311             }
1312             note('active', "${app.label}: Watering in 1 minute, ${newString}${runNowMap}", 'd')
1313         }
1314         else {
1315             unsubscribe(settings.switches)
1316             unsubWaterStoppers()
1317             switches.programOff()
1318             if (enableManual) subscribe(settings.switches, 'switch.programOn', manualStart)
1319             note('skipping', "${app.label}: No watering today", 'd')
1320             if (atomicState.run) atomicState.run = false                // do this last, so that the above note gets sent to the controller
1321         }
1322     } 
1323     else {
1324         log.debug 'checkRunMap(): atomicState.run = false'      // isWeather cancelled us out before we got started
1325     }
1326 }
1327
1328 //get todays schedule
1329 def cycleLoop(int i)
1330 {
1331         boolean isDebug = false
1332         if (isDebug) log.debug "cycleLoop(${i})"
1333         
1334     int zone = 1
1335     int dpw = 0
1336     int tpw = 0
1337     int cyc = 0
1338     int rtime = 0
1339     def timeMap = [:]
1340     def pumpMap = ""
1341     def runNowMap = ""
1342     String soilString = ''
1343     int totalCycles = 0
1344     int totalTime = 0
1345     if (atomicState.startTime) atomicState.startTime = null                                     // haven't started yet
1346
1347     while(zone <= 16)
1348     {
1349         rtime = 0
1350         def setZ = settings."zone${zone}"
1351         if ((setZ && (setZ != 'Off')) && (nozzle(zone) != 4) && zoneActive(zone.toString())) {
1352
1353                         // First check if we run this zone today, use either dpwMap or even/odd date
1354                         dpw = getDPW(zone)          
1355                 int runToday = 0
1356                 // if manual, or every day allowed, or zone uses a sensor, then we assume we can today
1357                 //  - preCheck() has already verified that today isDay()
1358                 if ((i == 0) || (state.daysAvailable == 7) || (settings."sensor${zone}")) {
1359                         runToday = 1    
1360                 }
1361                 else {
1362
1363                         dpw = getDPW(zone)                                                                      // figure out if we need to run (if we don't already know we do)
1364                         if (settings.days && (settings.days.contains('Even') || settings.days.contains('Odd'))) {
1365                         def daynum = new Date().format('dd', location.timeZone)
1366                         int dayint = Integer.parseInt(daynum)
1367                                 if (settings.days.contains('Odd') && (((dayint +1) % Math.round(31 / (dpw * 4))) == 0)) runToday = 1
1368                                 else if (settings.days.contains('Even') && ((dayint % Math.round(31 / (dpw * 4))) == 0)) runToday = 1
1369                         } 
1370                         else {
1371                         int weekDay = getWeekDay()-1
1372                         def dpwMap = getDPWDays(dpw)
1373                         runToday = dpwMap[weekDay]  //1 or 0
1374                         if (isDebug) log.debug "Zone: ${zone} dpw: ${dpw} weekDay: ${weekDay} dpwMap: ${dpwMap} runToday: ${runToday}"
1375
1376                         }
1377                 }
1378                         
1379                         // OK, we're supposed to run (or at least adjust the sensors)
1380                 if (runToday == 1) 
1381                 {
1382                                 def soil
1383                 if (i == 0) soil = moisture(0)  // manual
1384                 else soil = moisture(zone)              // moisture check
1385                         soilString = "${soilString}${soil[1]}"
1386
1387                                 // Run this zone if soil moisture needed 
1388                 if ( soil[0] == 1 )
1389                 {
1390                         cyc = cycles(zone)
1391                         tpw = getTPW(zone)
1392                         dpw = getDPW(zone)                                      // moisture() may have changed DPW
1393
1394                         rtime = calcRunTime(tpw, dpw)                
1395                         //daily weather adjust if no sensor
1396                         if(settings.isSeason && (!settings.learn || !settings."sensor${zone}")) {
1397
1398
1399                                 rtime = Math.round(((rtime / cyc) * (state.seasonAdj / 100.0)) + 0.4)
1400                         } 
1401                         else {
1402                                 rtime = Math.round((rtime / cyc) + 0.4) // let moisture handle the seasonAdjust for Adaptive (learn) zones   
1403                         }
1404                                         totalCycles += cyc
1405                                         totalTime += (rtime * cyc)
1406                         runNowMap += "${settings."name${zone}"}: ${cyc} x ${rtime} min\n"
1407                         if (isDebug) log.debug "Zone ${zone} Map: ${cyc} x ${rtime} min - totalTime: ${totalTime}"
1408                 }
1409                 }
1410                 }
1411         if (nozzle(zone) == 4) pumpMap += "${settings."name${zone}"}: ${settings."zone${zone}"} on\n"
1412         timeMap."${zone+1}" = "${rtime}"
1413         zone++  
1414     }
1415     
1416         if (soilString) {
1417         String seasonStr = ''
1418         String plus = ''
1419         float sa = state.seasonAdj
1420         if (settings.isSeason && (sa != 100.0) && (sa != 0.0)) {
1421                 float sadj = sa - 100.0
1422                 if (sadj > 0.0) plus = '+'                                                                                      //display once in cycleLoop()
1423                 int iadj = Math.round(sadj)
1424                 if (iadj != 0) seasonStr = "Adjusting ${plus}${iadj}% for weather forecast\n"
1425         }
1426         note('moisture', "${app.label} Sensor status:\n${seasonStr}${soilString}" /* + seasonStr + soilString */,'m')
1427     }
1428
1429     if (!runNowMap) {
1430         return runNowMap                        // nothing to run today
1431     }
1432
1433     //send settings to Spruce Controller
1434     switches.settingsMap(timeMap,4002)
1435         runIn(30, writeCycles)
1436         
1437         // meanwhile, calculate our total run time
1438     int pDelay = 0
1439     if (settings.pumpDelay && settings.pumpDelay.isNumber()) pDelay = settings.pumpDelay.toInteger()
1440     totalTime += Math.round(((pDelay * (totalCycles-1)) / 60.0))  // add in the pump startup and inter-zone delays
1441     state.totalTime = totalTime
1442
1443     if (state.pauseTime) state.pauseTime = null                                 // and we haven't paused yet
1444                                                                                                                         // but let cycleOn() reset finishTime
1445     return (runNowMap + pumpMap)   
1446 }
1447
1448 //send cycle settings
1449 def writeCycles(){
1450         //log.trace "writeCycles()"
1451         def cyclesMap = [:]
1452     //add pumpdelay @ 1
1453     cyclesMap."1" = pumpDelayString()
1454     int zone = 1
1455     int cycle = 0       
1456     while(zone <= 17)
1457     {      
1458         if(nozzle(zone) == 4) cycle = 4
1459         else cycle = cycles(zone)
1460         //offset by 1, due to pumpdelay @ 1
1461         cyclesMap."${zone+1}" = "${cycle}"
1462         zone++
1463     }
1464     switches.settingsMap(cyclesMap, 4001)
1465 }
1466
1467 def resume(){
1468         log.debug 'resume()'
1469         settings.switches.zon()    
1470 }
1471
1472 def syncOn(evt){
1473         // double check that the switch is actually finished and not just paused
1474         if ((settings.sync.currentSwitch == 'off') && (settings.sync.currentStatus != 'pause')) {
1475         resetEverything()                                                               // back to our known state
1476         Random rand = new Random()                                              // just in case there are multiple schedules waiting on the same controller
1477                 int randomSeconds = rand.nextInt(120) + 15
1478         runIn(randomSeconds, preCheck)                                  // no message so we don't clog the system
1479         note('schedule', "${app.label}: ${settings.sync} finished, starting in ${randomSeconds} seconds", 'c')
1480         } // else, it is just pausing...keep waiting for the next "off"
1481 }
1482
1483 // handle start of pause session
1484 def waterStop(evt){
1485         log.debug "waterStop: ${evt.displayName}"
1486         
1487         unschedule(cycleOn)                     // in case we got stopped again before cycleOn starts from the restart
1488         unsubscribe(settings.switches)
1489         subWaterStart()
1490                 
1491         if (!state.pauseTime) {                 // only need to do this for the first event if multiple contacts
1492             state.pauseTime = now()
1493             
1494                 String cond = evt.value
1495                 switch (cond) {
1496                         case 'open':
1497                                 cond = 'opened'
1498                                 break
1499                         case 'on':
1500                                 cond = 'switched on'
1501                                 break
1502                         case 'off':
1503                                 cond = 'switched off'
1504                                 break
1505                         //case 'closed':
1506                         //      cond = 'closed'
1507                         //      break
1508                         case null:
1509                                 cond = '????'
1510                                 break
1511                         default:
1512                                 break
1513                 }
1514             note('pause', "${app.label}: Watering paused - ${evt.displayName} ${cond}", 'c') // set to Paused
1515         }
1516         if (settings.switches.currentSwitch != 'off') {
1517                 runIn(30, subOff)
1518                 settings.switches.off()                                                         // stop the water
1519         }
1520         else 
1521                 subscribe(settings.switches, 'switch.off', cycleOff)
1522 }
1523
1524 // This is a hack to work around the delay in response from the controller to the above programOff command...
1525 // We frequently see the off notification coming a long time after the command is issued, so we try to catch that so that
1526 // we don't prematurely exit the cycle.
1527 def subOff() {
1528         subscribe(settings.switches, 'switch.off', offPauseCheck)
1529 }
1530
1531 def offPauseCheck( evt ) {
1532         unsubscribe(settings.switches)
1533         subscribe(settings.switches, 'switch.off', cycleOff)
1534         if (/*(switches.currentSwitch != 'off') && */ (settings.switches.currentStatus != 'pause')) { // eat the first off while paused
1535                 cycleOff(evt)
1536         } 
1537 }
1538
1539 // handle end of pause session     
1540 def waterStart(evt){
1541         if (!isWaterStopped()){                                         // only if ALL of the selected contacts are not open
1542                 def cDelay = 10
1543         if (settings.contactDelay > 10) cDelay = settings.contactDelay
1544         runIn(cDelay, cycleOn)
1545                 
1546                 unsubscribe(settings.switches)
1547                 subWaterStop()                                                  // allow stopping again while we wait for cycleOn to start
1548                 
1549                 log.debug "waterStart(): enabling device is ${evt.device} ${evt.value}"
1550                 
1551                 String cond = evt.value
1552                 switch (cond) {
1553                         case 'open':
1554                                 cond = 'opened'
1555                                 break
1556                         case 'on':
1557                                 cond = 'switched on'
1558                                 break
1559                         case 'off':
1560                                 cond = 'switched off'
1561                                 break
1562                         //case 'closed':
1563                         //      cond = 'closed'
1564                         //      break
1565                         case null:
1566                                 cond = '????'
1567                                 break
1568                         default:
1569                                 break
1570                 }
1571                 // let cycleOn() change the status to Active - keep us paused until then
1572                 
1573         note('pause', "${app.label}: ${evt.displayName} ${cond}, watering in ${cDelay} seconds", 'c')
1574         } 
1575         else {
1576                 log.debug "waterStart(): one down - ${evt.displayName}"
1577         }
1578 }
1579
1580 //Initialize Days per week, based on TPW, perDay and daysAvailable settings
1581 int initDPW(int zone){
1582         //log.debug "initDPW(${zone})"
1583         if(!state.dpwMap) state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
1584         
1585         int tpw = getTPW(zone)          // was getTPW -does not update times in scheduler without initTPW
1586         int dpw = 0
1587         
1588         if(tpw > 0) {
1589         float perDay = 20.0
1590         if(settings."perDay${zone}") perDay = settings."perDay${zone}".toFloat()
1591         
1592         dpw = Math.round(tpw.toFloat() / perDay)
1593         if(dpw <= 1) dpw = 1
1594                 // 3 days per week not allowed for even or odd day selection
1595             if(dpw == 3 && days && (days.contains('Even') || days.contains('Odd')) && !(days.contains('Even') && days.contains('Odd')))
1596                         if((tpw.toFloat() / perDay) < 3.0) dpw = 2 else dpw = 4
1597                 int daycheck = daysAvailable()                                          // initialize & optimize daysAvailable
1598         if (daycheck < dpw) dpw = daycheck
1599     }
1600         state.dpwMap[zone-1] = dpw
1601     return dpw
1602 }
1603
1604 // Get current days per week value, calls init if not defined
1605 int getDPW(int zone) {
1606         if (state.dpwMap) return state.dpwMap[zone-1] else return initDPW(zone)
1607 }
1608
1609 //Initialize Time per Week
1610 int initTPW(int zone) {   
1611     //log.trace "initTPW(${zone})"
1612     if (!state.tpwMap) state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
1613     
1614     int n = nozzle(zone)
1615     def zn = settings."zone${zone}"
1616     if (!zn || (zn == 'Off') || (n == 0) || (n == 4) || (plant(zone) == 0) || !zoneActive(zone.toString())) return 0
1617     
1618     // apply gain adjustment
1619     float gainAdjust = 100.0
1620     if (settings.gain && settings.gain != 0) gainAdjust += settings.gain
1621     
1622     // apply seasonal adjustment if enabled and not set to new plants
1623     float seasonAdjust = 100.0
1624     def wsa = state.weekseasonAdj
1625     if (wsa && isSeason && (settings."plant${zone}" != 'New Plants')) seasonAdjust = wsa    
1626         
1627         int tpw = 0
1628         // Use learned, previous tpw if it is available
1629         if ( settings."sensor${zone}" ) {
1630                 seasonAdjust = 100.0                    // no weekly seasonAdjust if this zone uses a sensor
1631                 if(state.tpwMap && settings.learn) tpw = state.tpwMap[zone-1]
1632         }
1633         
1634         // set user-specified minimum time with seasonal adjust
1635         int minWeek = 0
1636         def mw = settings."minWeek${zone}"
1637         if (mw) minWeek = mw.toInteger()
1638     if (minWeek != 0) {
1639         tpw = Math.round(minWeek * (seasonAdjust / 100.0))
1640     } 
1641     else if (!tpw || (tpw == 0)) { // use calculated tpw
1642         tpw = Math.round((plant(zone) * nozzle(zone) * (gainAdjust / 100.0) * (seasonAdjust / 100.0)))
1643     }
1644         state.tpwMap[zone-1] = tpw
1645     return tpw
1646 }
1647
1648 // Get the current time per week, calls init if not defined
1649 int getTPW(int zone)
1650 {
1651         if (state.tpwMap) return state.tpwMap[zone-1] else return initTPW(zone)
1652 }
1653
1654 // Calculate daily run time based on tpw and dpw
1655 int calcRunTime(int tpw, int dpw)
1656 {           
1657     int duration = 0
1658     if ((tpw > 0) && (dpw > 0)) duration = Math.round(tpw.toFloat() / dpw.toFloat())
1659     return duration
1660 }
1661
1662 // Check the moisture level of a zone returning dry (1) or wet (0) and adjust tpw if overly dry/wet
1663 def moisture(int i)
1664 {
1665         boolean isDebug = false
1666         if (isDebug) log.debug "moisture(${i})"
1667         
1668         def endMsecs = 0
1669         // No Sensor on this zone or manual start skips moisture checking altogether
1670         if ((i == 0) || !settings."sensor${i}") {
1671         return [1,'']
1672     }
1673
1674     // Ensure that the sensor has reported within last 48 hours
1675     int spHum = getDrySp(i)
1676     int hours = 48
1677     def yesterday = new Date(now() - (/* 1000 * 60 * 60 */ 3600000 * hours).toLong())  
1678     float latestHum = settings."sensor${i}".latestValue('humidity').toFloat()   // state = 29, value = 29.13
1679     def lastHumDate = settings."sensor${i}".latestState('humidity').date
1680     if (lastHumDate < yesterday) {
1681         note('warning', "${app.label}: Please check sensor ${settings."sensor${i}"}, no humidity reports in the last ${hours} hours", 'a')
1682
1683         if (latestHum < spHum) 
1684                 latestHum = spHum - 1.0                         // amke sure we water and do seasonal adjustments, but not tpw adjustments
1685         else 
1686                 latestHum = spHum + 0.99                        // make sure we don't water, do seasonal adjustments, but not tpw adjustments
1687     }
1688
1689     if (!settings.learn)
1690     {
1691         // in Delay mode, only looks at target moisture level, doesn't try to adjust tpw
1692         // (Temporary) seasonal adjustment WILL be applied in cycleLoop(), as if we didn't have a sensor
1693                 if (latestHum <= spHum.toFloat()) {
1694            //dry soil
1695                 return [1,"${settings."name${i}"}, Watering: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"]              
1696         } 
1697         else {
1698                 //wet soil
1699                 return [0,"${settings."name${i}"}, Skipping: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"]           
1700         }
1701     }
1702
1703     //in Adaptive mode
1704     int tpw = getTPW(i)
1705     int dpw = getDPW(i)
1706     int cpd = cycles(i)
1707
1708
1709
1710
1711     if (isDebug) log.debug "moisture(${i}): tpw: ${tpw}, dpw: ${dpw}, cycles: ${cpd} (before adjustment)"
1712     
1713     float diffHum = 0.0
1714     if (latestHum > 0.0) diffHum = (spHum - latestHum) / 100.0
1715     else {
1716         diffHum = 0.02 // Safety valve in case sensor is reporting 0% humidity (e.g., somebody pulled it out of the ground or flower pot)
1717         note('warning', "${app.label}: Please check sensor ${settings."sensor${i}"}, it is currently reading 0%", 'a')
1718     }
1719         
1720         int daysA = state.daysAvailable
1721         int minimum = cpd * dpw                                 // minimum of 1 minute per scheduled days per week (note - can be 1*1=1)
1722         if (minimum < daysA) minimum = daysA    // but at least 1 minute per available day
1723         int tpwAdjust = 0
1724         
1725     if (diffHum > 0.01) {                                                               // only adjust tpw if more than 1% of target SP
1726                 tpwAdjust = Math.round(((tpw * diffHum) + 0.5) * dpw * cpd)     // Compute adjustment as a function of the current tpw
1727         float adjFactor = 2.0 / daysA                                   // Limit adjustments to 200% per week - spread over available days
1728                 if (tpwAdjust > (tpw * adjFactor)) tpwAdjust = Math.round((tpw * adjFactor) + 0.5)              // limit fast rise
1729                 if (tpwAdjust < minimum) tpwAdjust = minimum    // but we need to move at least 1 minute per cycle per day to actually increase the watering time
1730     } else if (diffHum < -0.01) {
1731         if (diffHum < -0.05) diffHum = -0.05                    // try not to over-compensate for a heavy rainstorm...
1732         tpwAdjust = Math.round(((tpw * diffHum) - 0.5) * dpw * cpd)
1733         float adjFactor = -0.6667 / daysA                               // Limit adjustments to 66% per week
1734         if (tpwAdjust < (tpw * adjFactor)) tpwAdjust = Math.round((tpw * adjFactor) - 0.5)      // limit slow decay 
1735                 if (tpwAdjust > (-1 * minimum)) tpwAdjust = -1 * minimum // but we need to move at least 1 minute per cycle per day to actually increase the watering time
1736     }
1737     
1738     int seasonAdjust = 0
1739     if (isSeason) {
1740         float sa = state.seasonAdj
1741         if ((sa != 100.0) && (sa != 0.0)) {
1742                 float sadj = sa - 100.0
1743                 if (sa > 0.0)
1744                         seasonAdjust = Math.round(((sadj / 100.0) * tpw) + 0.5)
1745                 else
1746                         seasonAdjust = Math.round(((sadj / 100.0) * tpw) - 0.5)
1747         }
1748     }
1749         if (isDebug) log.debug "moisture(${i}): diffHum: ${diffHum}, tpwAdjust: ${tpwAdjust} seasonAdjust: ${seasonAdjust}"
1750         
1751         // Now, adjust the tpw. 
1752         // With seasonal adjustments enabled, tpw can go up or down independent of the difference in the sensor vs SP
1753         int newTPW = tpw + tpwAdjust + seasonAdjust
1754     
1755     int perDay = 20
1756         def perD = settings."perDay${i}"
1757     if (perD) perDay = perD.toInteger()
1758     if (perDay == 0) perDay = daysA * cpd                               // at least 1 minute per cycle per available day
1759         if (newTPW < perDay) newTPW = perDay                            // make sure we have always have enough for 1 day of minimum water
1760         
1761         int adjusted = 0
1762     if ((tpwAdjust + seasonAdjust) > 0) {                                                       // needs more water
1763                 int maxTPW = daysA * 120        // arbitrary maximum of 2 hours per available watering day per week
1764                 if (newTPW > maxTPW) newTPW = maxTPW    // initDPW() below may spread this across more days             
1765                 if (newTPW > (maxTPW * 0.75)) note('warning', "${app.label}: Please check ${settings["sensor${i}"]}, ${settings."name${i}"} time per week seems high: ${newTPW} mins/week",'a')
1766                 if (state.tpwMap[i-1] != newTPW) {      // are we changing the tpw?
1767                 state.tpwMap[i-1] = newTPW
1768                 dpw = initDPW(i)                                                        // need to recalculate days per week since tpw changed - initDPW() stores the value into dpwMap
1769                         adjusted = newTPW - tpw         // so that the adjustment note is accurate              
1770                 }
1771     }
1772     else if ((tpwAdjust + seasonAdjust) < 0) {                                          // Needs less water
1773         // Find the minimum tpw
1774         minimum = cpd * daysA                                                                           // at least 1 minute per cycle per available day
1775                 int minLimit = 0
1776                 def minL = settings."minWeek${i}"
1777                 if (minL) minLimit = minL.toInteger()                                           // unless otherwise specified in configuration
1778                 if (minLimit > 0) {
1779                         if (newTPW < minLimit) newTPW = minLimit                                // use configured minutes per week as the minimum
1780                 } else if (newTPW < minimum) {
1781                         newTPW = minimum                                                                                // else at least 1 minute per cycle per available day
1782                 note('warning', "${app.label}: Please check ${settings."sensor${i}"}, ${settings."name${i}"} time per week is very low: ${newTPW} mins/week",'a')
1783                 }
1784         if (state.tpwMap[i-1] != newTPW) {      // are we changing the tpw?
1785                 state.tpwMap[i-1] = newTPW              // store the new tpw
1786                 dpw = initDPW(i)                                // may need to reclac days per week - initDPW() now stores the value into state.dpwMap - avoid doing that twice
1787                 adjusted = newTPW - tpw         // so that the adjustment note is accurate
1788         }
1789     }
1790     // else no adjustments, or adjustments cancelled each other out.
1791     
1792     String moistureSum = ''
1793     String adjStr = ''
1794     String plus = ''
1795     if (adjusted > 0) plus = '+'
1796     if (adjusted != 0) adjStr = ", ${plus}${adjusted} min"
1797     if (Math.abs(adjusted) > 1) adjStr = "${adjStr}s"
1798     if (diffHum >= 0.0) {                               // water only if ground is drier than SP
1799         moistureSum = "> ${settings."name${i}"}, Water: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n"
1800         return [1, moistureSum]
1801     } 
1802     else {                                                      // not watering
1803         moistureSum = "> ${settings."name${i}"}, Skip: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n"
1804         return [0, moistureSum]
1805     }
1806     return [0, moistureSum]
1807 }  
1808
1809 //get moisture SP
1810 int getDrySp(int i){
1811     if (settings."sensorSp${i}") return settings."sensorSp${i}".toInteger() // configured SP
1812
1813
1814     if (settings."plant${i}" == 'New Plants') return 40                                         // New Plants get special care
1815
1816
1817     switch (settings."option${i}") {                                                                            // else, defaults based off of soil type
1818         case 'Sand':
1819             return 22
1820         case 'Clay':
1821             return 38  
1822         default:
1823             return 28
1824     }
1825 }
1826
1827 //notifications to device, pushed if requested
1828 def note(String statStr, String msg, String msgType) {
1829
1830         // send to debug first (near-zero cost)
1831         log.debug "${statStr}: ${msg}"
1832
1833         // notify user second (small cost)
1834         boolean notifyController = true
1835     if(settings.notify || settings.logAll) {
1836         String spruceMsg = "Spruce ${msg}"
1837         switch(msgType) {
1838                 case 'd':
1839                         if (settings.notify && settings.notify.contains('Daily')) {             // always log the daily events to the controller
1840                                 sendIt(spruceMsg)
1841                         }
1842                         else if (settings.logAll) {
1843                                 sendNotificationEvent(spruceMsg)
1844                         }
1845                         break
1846                 case 'c':
1847                                 if (settings.notify && settings.notify.contains('Delays')) {
1848                                 sendIt(spruceMsg)
1849                         }
1850                         else if (settings.logAll) {
1851                                 sendNotificationEvent(spruceMsg)
1852                         }
1853                         break
1854                 case 'i':
1855                         if (settings.notify && settings.notify.contains('Events')) {
1856                                 sendIt(spruceMsg)
1857                                 //notifyController = false                                      // no need to notify controller unless we don't notify the user
1858                         }
1859                         else if (settings.logAll) {
1860                                 sendNotificationEvent(spruceMsg)
1861                         }
1862                         break
1863                         case 'f':
1864                                 notifyController = false                                                // no need to notify the controller, ever
1865                                 if (settings.notify && settings.notify.contains('Weather')) {
1866                                 sendIt(spruceMsg)
1867                         }
1868                         else if (settings.logAll) {
1869                                 sendNotificationEvent(spruceMsg)
1870                         }
1871                         break
1872                 case 'a':
1873                         notifyController = false                                                // no need to notify the controller, ever
1874                         if (settings.notify && settings.notify.contains('Warnings')) {
1875                                 sendIt(spruceMsg)
1876                         } else
1877                                 sendNotificationEvent(spruceMsg)                                        // Special case - make sure this goes into the Hello Home log, if not notifying
1878                         break
1879                 case 'm':
1880                         if (settings.notify && settings.notify.contains('Moisture')) {
1881                                 sendIt(spruceMsg)
1882                                 //notifyController = false                                      // no need to notify controller unless we don't notify the user
1883                         }
1884                         else if (settings.logAll) {
1885                                 sendNotificationEvent(spruceMsg)
1886                         }
1887                         break
1888                 default:
1889                         break
1890                 }
1891     }
1892         // finally, send to controller DTH, to change the state and to log important stuff in the event log
1893         if (notifyController) {         // do we really need to send these to the controller?
1894                 // only send status updates to the controller if WE are running, or nobody else is
1895                 if (atomicState.run || ((settings.switches.currentSwitch == 'off') && (settings.switches.currentStatus != 'pause'))) {
1896                 settings.switches.notify(statStr, msg)
1897
1898                 }       
1899                 else { // we aren't running, so we don't want to change the status of the controller
1900                         // send the event using the current status of the switch, so we don't change it 
1901                         //log.debug "note - direct sendEvent()"
1902                         settings.switches.notify(settings.switches.currentStatus, msg)
1903
1904                 }
1905     }
1906 }
1907
1908 def sendIt(String msg) {
1909         if (location.contactBookEnabled && settings.recipients) {
1910                 sendNotificationToContacts(msg, settings.recipients, [event: true]) 
1911     }
1912     else {
1913                 sendPush( msg )
1914     }
1915 }
1916
1917 //days available
1918 int daysAvailable(){
1919
1920         // Calculate days available for watering and save in state variable for future use
1921     def daysA = state.daysAvailable
1922         if (daysA && (daysA > 0)) {                                                     // state.daysAvailable has already calculated and stored in state.daysAvailable
1923                 return daysA
1924         }
1925         
1926         if (!settings.days)     {                                                               // settings.days = "" --> every day is available
1927                 state.daysAvailable = 7 
1928                 return 7                // every day is allowed
1929         }
1930         
1931         int dayCount = 0                                                                        // settings.days specified, need to calculate state.davsAvailable (once)
1932         if (settings.days.contains('Even') || settings.days.contains('Odd')) {
1933         dayCount = 4
1934         if(settings.days.contains('Even') && settings.days.contains('Odd')) dayCount = 7
1935     } 
1936     else {
1937         if (settings.days.contains('Monday'))           dayCount += 1
1938         if (settings.days.contains('Tuesday'))          dayCount += 1
1939         if (settings.days.contains('Wednesday'))        dayCount += 1
1940         if (settings.days.contains('Thursday'))         dayCount += 1
1941         if (settings.days.contains('Friday'))           dayCount += 1
1942         if (settings.days.contains('Saturday'))         dayCount += 1
1943         if (settings.days.contains('Sunday'))           dayCount += 1
1944     }
1945     
1946     state.daysAvailable = dayCount
1947     return dayCount
1948 }    
1949  
1950 //zone: ['Off', 'Spray', 'rotor', 'Drip', 'Master Valve', 'Pump']
1951 int nozzle(int i){
1952     String getT = settings."zone${i}"    
1953     if (!getT) return 0
1954     
1955     switch(getT) {        
1956         case 'Spray':
1957             return 1
1958         case 'Rotor':
1959             return 1.4
1960         case 'Drip':
1961             return 2.4
1962         case 'Master Valve':
1963             return 4
1964         case 'Pump':
1965             return 4
1966         default:
1967             return 0
1968     }
1969 }
1970  
1971 //plant: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']
1972 int plant(int i){
1973     String getP = settings."plant${i}"    
1974     if(!getP) return 0
1975     
1976     switch(getP) {
1977         case 'Lawn':
1978             return 60
1979         case 'Garden':
1980             return 50
1981         case 'Flowers':
1982             return 40
1983         case 'Shrubs':
1984             return 30
1985         case 'Trees':
1986             return 20
1987         case 'Xeriscape':
1988             return 30
1989         case 'New Plants':
1990             return 80
1991         default:
1992             return 0
1993     }
1994 }
1995  
1996 //option: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x']
1997 int cycles(int i){  
1998     String getC = settings."option${i}"   
1999     if(!getC) return 2
2000     
2001     switch(getC) {
2002         case 'Slope':
2003             return 3
2004         case 'Sand':
2005             return 1
2006         case 'Clay':
2007             return 2
2008         case 'No Cycle':
2009             return 1
2010         case 'Cycle 2x':
2011             return 2
2012         case 'Cycle 3x':
2013             return 3   
2014         default:
2015             return 2
2016     }    
2017 }
2018  
2019 //check if day is allowed
2020 boolean isDay() {
2021         
2022         if (daysAvailable() == 7) return true                                           // every day is allowed
2023      
2024     def daynow = new Date()
2025     String today = daynow.format('EEEE', location.timeZone)    
2026     if (settings.days.contains(today)) return true
2027
2028     def daynum = daynow.format('dd', location.timeZone)
2029     int dayint = Integer.parseInt(daynum)    
2030     if (settings.days.contains('Even') && (dayint % 2 == 0)) return true
2031     if (settings.days.contains('Odd') && (dayint % 2 != 0)) return true
2032     return false      
2033 }
2034
2035 //set season adjustment & remove season adjustment
2036 def setSeason() {
2037     boolean isDebug = false
2038     if (isDebug) log.debug 'setSeason()'
2039     
2040     int zone = 1
2041     while(zone <= 16) {                 
2042         if ( !settings.learn || !settings."sensor${zone}" || state.tpwMap[zone-1] == 0) {
2043
2044             int tpw = initTPW(zone)             // now updates state.tpwMap
2045             int dpw = initDPW(zone)             // now updates state.dpwMap
2046             if (isDebug) {
2047                         if (!settings.learn && (tpw != 0) && (state.weekseasonAdj != 0)) {
2048                         log.debug "Zone ${zone}: seasonally adjusted by ${state.weekseasonAdj-100}% to ${tpw}"
2049                         }
2050             }
2051         }
2052         zone++
2053     }       
2054 }
2055
2056 //capture today's total rainfall - scheduled for just before midnight each day
2057 def getRainToday() {
2058         def wzipcode = zipString()   
2059     Map wdata = getWeatherFeature('conditions', wzipcode)
2060     if (!wdata) {
2061
2062         note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null", 'a')
2063     } 
2064     else {
2065                 if (!wdata.response || wdata.response.containsKey('error')) {
2066                         log.debug wdata.response
2067                         note('warning', "${app.label}: Please check Zipcode/PWS setting, error:\n${wdata.response.error.type}: ${wdata.response.error.description}" , 'a')
2068                 } 
2069                 else {
2070                         float TRain = 0.0
2071                         if (wdata.current_observation.precip_today_in.isNumber()) { // WU can return "t" for "Trace" - we'll assume that means 0.0
2072                 TRain = wdata.current_observation.precip_today_in.toFloat()
2073                                 if (TRain > 25.0) TRain = 25.0
2074                                 else if (TRain < 0.0) TRain = 0.0                       // WU sometimes returns -999 for "estimated" locations
2075                 log.debug "getRainToday(): ${wdata.current_observation.precip_today_in} / ${TRain}"
2076             }
2077                 int day = getWeekDay()                                          // what day is it today?
2078             if (day == 7) day = 0                                               // adjust: state.Rain order is Su,Mo,Tu,We,Th,Fr,Sa
2079                 state.Rain[day] = TRain as Float                        // store today's total rainfall
2080                 }
2081     }
2082 }
2083
2084 //check weather, set seasonal adjustment factors, skip today if rainy
2085 boolean isWeather(){
2086         def startMsecs = 0
2087         def endMsecs = 0
2088         boolean isDebug = false
2089         if (isDebug) log.debug 'isWeather()'
2090         
2091         if (!settings.isRain && !settings.isSeason) return false                // no need to do any of this    
2092         
2093     String wzipcode = zipString()   
2094         if (isDebug) log.debug "isWeather(): ${wzipcode}"   
2095
2096         // get only the data we need
2097         // Moved geolookup to installSchedule()
2098         String featureString = 'forecast/conditions'
2099         if (settings.isSeason) featureString = "${featureString}/astronomy"
2100         if (isDebug) startMsecs= now()
2101     Map wdata = getWeatherFeature(featureString, wzipcode)
2102     if (isDebug) {
2103         endMsecs = now()
2104         log.debug "isWeather() getWeatherFeature elapsed time: ${endMsecs - startMsecs}ms"
2105     }
2106     if (wdata && wdata.response) {
2107         if (isDebug) log.debug wdata.response
2108                 if (wdata.response.containsKey('error')) {
2109                 if (wdata.response.error.type != 'invalidfeature') {
2110                         note('warning', "${app.label}: Please check Zipcode/PWS setting, error:\n${wdata.response.error.type}: ${wdata.response.error.description}" , 'a')
2111                         return false
2112             } 
2113             else {
2114                 // Will find out which one(s) weren't reported later (probably never happens now that we don't ask for history)
2115                 log.debug 'Rate limited...one or more WU features unavailable at this time.'
2116             }
2117                 }
2118     } 
2119     else {
2120         if (isDebug) log.debug 'wdata is null'
2121         note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null" , 'a')
2122         return false
2123     }
2124     
2125     String city = wzipcode
2126
2127
2128
2129     if (wdata.current_observation) { 
2130         if (wdata.current_observation.observation_location.city != '') city = wdata.current_observation.observation_location.city 
2131         else if (wdata.current_observation.observation_location.full != '') city = wdata.current_observation.display_location.full
2132
2133         if (wdata.current_observation.estimated.estimated) city = "${city} (est)"
2134     }
2135     
2136     // OK, we have good data, let's start the analysis
2137     float qpfTodayIn = 0.0
2138     float qpfTomIn = 0.0
2139     float popToday = 50.0
2140     float popTom = 50.0
2141     float TRain = 0.0
2142     float YRain = 0.0
2143     float weeklyRain = 0.0
2144     
2145     if (settings.isRain) {
2146         if (isDebug) log.debug 'isWeather(): isRain'
2147         
2148         // Get forecasted rain for today and tomorrow
2149
2150                 if (!wdata.forecast) {
2151                 log.debug 'isWeather(): Unable to get weather forecast.'
2152                 return false
2153         }
2154         if (wdata.forecast.simpleforecast.forecastday[0].qpf_allday.in.isNumber()) qpfTodayIn = wdata.forecast.simpleforecast.forecastday[0].qpf_allday.in.toFloat()
2155                 if (wdata.forecast.simpleforecast.forecastday[0].pop.isNumber()) popToday = wdata.forecast.simpleforecast.forecastday[0].pop.toFloat()
2156         if (wdata.forecast.simpleforecast.forecastday[1].qpf_allday.in.isNumber()) qpfTomIn = wdata.forecast.simpleforecast.forecastday[1].qpf_allday.in.toFloat()
2157                 if (wdata.forecast.simpleforecast.forecastday[1].pop.isNumber()) popTom = wdata.forecast.simpleforecast.forecastday[1].pop.toFloat()
2158                 if (qpfTodayIn > 25.0) qpfTodayIn = 25.0
2159                 else if (qpfTodayIn < 0.0) qpfTodayIn = 0.0
2160                 if (qpfTomIn > 25.0) qpfTomIn = 25.0
2161                 else if (qpfTomIn < 0.0) qpfTomIn = 0.0
2162                 
2163         // Get rainfall so far today
2164
2165                 if (!wdata.current_observation) {
2166                 log.debug 'isWeather(): Unable to get current weather conditions.'
2167                 return false
2168         }
2169                 if (wdata.current_observation.precip_today_in.isNumber()) {
2170                 TRain = wdata.current_observation.precip_today_in.toFloat()
2171                 if (TRain > 25.0) TRain = 25.0                  // Ignore runaway weather
2172                 else if (TRain < 0.0) TRain = 0.0               // WU can return -999 for estimated locations
2173         }
2174         if (TRain > (qpfTodayIn * (popToday / 100.0))) {  // Not really what PoP means, but use as an adjustment factor of sorts
2175                 qpfTodayIn = TRain                                              // already have more rain than was forecast for today, so use that instead
2176                 popToday = 100                                                  // we KNOW this rain happened
2177         }
2178
2179         // Get yesterday's rainfall
2180         int day = getWeekDay()
2181         YRain = state.Rain[day - 1]
2182
2183         if (isDebug) log.debug "TRain ${TRain} qpfTodayIn ${qpfTodayIn} @ ${popToday}%, YRain ${YRain}"
2184
2185         int i = 0
2186                 while (i <= 6){                                                         // calculate (un)weighted average (only heavy rainstorms matter)
2187                 int factor = 0
2188                 if ((day - i) > 0) factor = day - i else factor =  day + 7 - i
2189                 float getrain = state.Rain[i]
2190                 if (factor != 0) weeklyRain += (getrain / factor)
2191                 i++
2192         }
2193
2194         if (isDebug) log.debug "isWeather(): weeklyRain ${weeklyRain}"
2195     }
2196      
2197     if (isDebug) log.debug 'isWeather(): build report'      
2198
2199     //get highs
2200         int highToday = 0
2201         int highTom = 0
2202         if (wdata.forecast.simpleforecast.forecastday[0].high.fahrenheit.isNumber()) highToday = wdata.forecast.simpleforecast.forecastday[0].high.fahrenheit.toInteger()
2203         if (wdata.forecast.simpleforecast.forecastday[1].high.fahrenheit.isNumber()) highTom = wdata.forecast.simpleforecast.forecastday[1].high.fahrenheit.toInteger()
2204         
2205     String weatherString = "${app.label}: ${city} weather:\n TDA: ${highToday}F"
2206     if (settings.isRain) weatherString = "${weatherString}, ${qpfTodayIn}in rain (${Math.round(popToday)}% PoP)"
2207     weatherString = "${weatherString}\n TMW: ${highTom}F"
2208     if (settings.isRain) weatherString = "${weatherString}, ${qpfTomIn}in rain (${Math.round(popTom)}% PoP)\n YDA: ${YRain}in rain"
2209     
2210     if (settings.isSeason)
2211     {   
2212                 if (!settings.isRain) {                                                         // we need to verify we have good data first if we didn't do it above
2213
2214                         if (!wdata.forecast) {
2215                         log.debug 'Unable to get weather forecast'
2216                         return false
2217                 }
2218                 }
2219                 
2220                 // is the temp going up or down for the next few days?
2221                 float heatAdjust = 100.0
2222                 float avgHigh = highToday.toFloat()
2223                 if (highToday != 0) {
2224                         // is the temp going up or down for the next few days?
2225                 int totalHigh = highToday
2226                 int j = 1
2227                 int highs = 1
2228                 while (j < 4) {                                         // get forecasted high for next 3 days
2229                         if (wdata.forecast.simpleforecast.forecastday[j].high.fahrenheit.isNumber()) {
2230                                 totalHigh += wdata.forecast.simpleforecast.forecastday[j].high.fahrenheit.toInteger()
2231                                 highs++
2232                         }
2233                         j++
2234                 }
2235                 if ( highs > 0 ) avgHigh = (totalHigh / highs)
2236                 heatAdjust = avgHigh / highToday
2237                 }
2238         if (isDebug) log.debug "highToday ${highToday}, avgHigh ${avgHigh}, heatAdjust ${heatAdjust}"
2239         
2240                 //get humidity
2241         int humToday = 0
2242         if (wdata.forecast.simpleforecast.forecastday[0].avehumidity.isNumber()) 
2243                 humToday = wdata.forecast.simpleforecast.forecastday[0].avehumidity.toInteger()
2244         
2245         float humAdjust = 100.0
2246         float avgHum = humToday.toFloat()
2247         if (humToday != 0) {
2248                 int j = 1
2249                 int highs = 1
2250                 int totalHum = humToday
2251                 while (j < 4) {                                         // get forcasted humitidty for today and the next 3 days
2252                         if (wdata.forecast.simpleforecast.forecastday[j].avehumidity.isNumber()) {
2253                                 totalHum += wdata.forecast.simpleforecast.forecastday[j].avehumidity.toInteger()
2254                                 highs++
2255                         }
2256                         j++
2257                 }
2258                 if (highs > 1) avgHum = totalHum / highs
2259                 humAdjust = 1.5 - ((0.5 * avgHum) / humToday)   // basically, half of the delta % between today and today+3 days
2260         }
2261         if (isDebug) log.debug "humToday ${humToday}, avgHum ${avgHum}, humAdjust ${humAdjust}"
2262
2263         //daily adjustment - average of heat and humidity factors
2264         //hotter over next 3 days, more water
2265         //cooler over next 3 days, less water
2266         //drier  over next 3 days, more water
2267         //wetter over next 3 days, less water
2268         //
2269         //Note: these should never get to be very large, and work best if allowed to cumulate over time (watering amount will change marginally
2270         //              as days get warmer/cooler and drier/wetter)
2271         def sa = ((heatAdjust + humAdjust) / 2) * 100.0
2272         state.seasonAdj = sa
2273         sa = sa - 100.0 
2274         String plus = ''
2275         if (sa > 0) plus = '+'
2276         weatherString = "${weatherString}\n Adjusting ${plus}${Math.round(sa)}% for weather forecast"
2277         
2278         // Apply seasonal adjustment on Monday each week or at install
2279         if ((getWeekDay() == 1) || (state.weekseasonAdj == 0)) {
2280             //get daylight
2281
2282                         if (wdata.sun_phase) { 
2283                 int getsunRH = 0
2284                 int getsunRM = 0
2285                 int getsunSH = 0
2286                 int getsunSM = 0
2287                 
2288                 if (wdata.sun_phase.sunrise.hour.isNumber())    getsunRH = wdata.sun_phase.sunrise.hour.toInteger()
2289                         if (wdata.sun_phase.sunrise.minute.isNumber())  getsunRM = wdata.sun_phase.sunrise.minute.toInteger()
2290                 if (wdata.sun_phase.sunset.hour.isNumber())     getsunSH = wdata.sun_phase.sunset.hour.toInteger()
2291                 if (wdata.sun_phase.sunset.minute.isNumber())   getsunSM = wdata.sun_phase.sunset.minute.toInteger()
2292
2293                 int daylight = ((getsunSH * 60) + getsunSM)-((getsunRH * 60) + getsunRM)
2294                                 if (daylight >= 850) daylight = 850
2295             
2296                 //set seasonal adjustment
2297                 //seasonal q (fudge) factor
2298                         float qFact = 75.0
2299                 
2300                 // (Daylight / 11.66 hours) * ( Average of ((Avg Temp / 70F) + ((1/2 of Average Humidity) / 65.46))) * calibration quotient
2301                 // Longer days = more water             (day length constant = approx USA day length at fall equinox)
2302                 // Higher temps = more water
2303                 // Lower humidity = more water  (humidity constant = USA National Average humidity in July)
2304                 float wa = ((daylight / 700.0) * (((avgHigh / 70.0) + (1.5-((avgHum * 0.5) / 65.46))) / 2.0) * qFact)
2305                                 state.weekseasonAdj = wa
2306                                 
2307                 //apply seasonal time adjustment
2308                 plus = ''
2309                 if (wa != 0) {
2310                         if (wa > 100.0) plus = '+'
2311                         String waStr = String.format('%.2f', (wa - 100.0))
2312                         weatherString = "${weatherString}\n Seasonal adjustment of ${waStr}% for the week"
2313                 }
2314                 setSeason()
2315             } 
2316             else {
2317                 log.debug 'isWeather(): Unable to get sunrise/set info for today.'
2318             }
2319         }
2320     }
2321     note('season', weatherString , 'f')
2322
2323         // if only doing seasonal adjustments, we are done
2324     if (!settings.isRain) return false  
2325     
2326     float setrainDelay = 0.2
2327     if (settings.rainDelay) setrainDelay = settings.rainDelay.toFloat()
2328
2329         // if we have no sensors, rain causes us to skip watering for the day    
2330     if (!anySensors()) {                                                
2331         if (settings.switches.latestValue('rainsensor') == 'rainsensoron'){
2332                 note('raintoday', "${app.label}: skipping, rain sensor is on", 'd')        
2333                 return true
2334         }
2335         float popRain = qpfTodayIn * (popToday / 100.0)
2336         if (popRain > setrainDelay){
2337                 String rainStr = String.format('%.2f', popRain)
2338                 note('raintoday', "${app.label}: skipping, ${rainStr}in of rain is probable today", 'd')        
2339                 return true
2340         }
2341         popRain += qpfTomIn * (popTom / 100.0)
2342         if (popRain > setrainDelay){
2343                 String rainStr = String.format('%.2f', popRain)
2344                 note('raintom', "${app.label}: skipping, ${rainStr}in of rain is probable today + tomorrow", 'd')
2345                 return true
2346         }
2347             if (weeklyRain > setrainDelay){
2348                 String rainStr = String.format('%.2f', weeklyRain)
2349             note('rainy', "${app.label}: skipping, ${rainStr}in weighted average rain over the past week", 'd')
2350                 return true
2351         }
2352     } 
2353     else { // we have at least one sensor in the schedule
2354         // Ignore rain sensor & historical rain - only skip if more than setrainDelay is expected before midnight tomorrow
2355         float popRain = (qpfTodayIn * (popToday / 100.0)) - TRain       // ignore rain that has already fallen so far today - sensors should already reflect that
2356         if (popRain > setrainDelay){
2357                 String rainStr = String.format('%.2f', popRain)
2358                 note('raintoday', "${app.label}: skipping, at least ${rainStr}in of rain is probable later today", 'd')        
2359                 return true
2360         }
2361         popRain += qpfTomIn * (popTom / 100.0)
2362         if (popRain > setrainDelay){
2363                 String rainStr = String.format('%.2f', popRain)
2364                 note('raintom', "${app.label}: skipping, at least ${rainStr}in of rain is probable later today + tomorrow", 'd')        
2365                 return true
2366         }
2367     }
2368     if (isDebug) log.debug "isWeather() ends"
2369     return false    
2370 }
2371
2372 // true if ANY of this schedule's zones are on and using sensors
2373 private boolean anySensors() {
2374         int zone=1
2375         while (zone <= 16) {
2376                 def zoneStr = settings."zone${zone}"
2377                 if (zoneStr && (zoneStr != 'Off') && settings."sensor${zone}") return true
2378                 zone++
2379         }
2380         return false
2381 }
2382
2383 def getDPWDays(int dpw){
2384         if (dpw && (dpw.isNumber()) && (dpw >= 1) && (dpw <= 7)) {
2385                 return state."DPWDays${dpw}"            
2386         } else
2387                 return [0,0,0,0,0,0,0]
2388 }
2389
2390 // Create a map of what days each possible DPW value will run on
2391 // Example:  User sets allowed days to Monday Wed and Fri
2392 // Map would look like: DPWDays1:[1,0,0,0,0,0,0] (run on Monday)
2393 //                      DPWDays2:[1,0,0,0,1,0,0] (run on Monday and Friday)
2394 //                      DPWDays3:[1,0,1,0,1,0,0] (run on Monday Wed and Fri)
2395 // Everything runs on the first day possible, starting with Monday.
2396 def createDPWMap() {
2397         state.DPWDays1 = []
2398     state.DPWDays2 = []
2399     state.DPWDays3 = []
2400     state.DPWDays4 = []
2401     state.DPWDays5 = []
2402     state.DPWDays6 = []
2403     state.DPWDays7 = []
2404         //def NDAYS = 7
2405     // day Distance[NDAYS][NDAYS], easier to just define than calculate everytime
2406     def int[][] dayDistance = [[0,1,2,3,3,2,1],[1,0,1,2,3,3,2],[2,1,0,1,2,3,3],[3,2,1,0,1,2,3],[3,3,2,1,0,1,2],[2,3,3,2,1,0,1],[1,2,3,3,2,1,0]]
2407         def ndaysAvailable = daysAvailable() 
2408         int i = 0
2409
2410     // def int[] daysAvailable = [0,1,2,3,4,5,6]
2411     def int[] daysAvailable = [0,0,0,0,0,0,0]
2412
2413     if(settings.days) {
2414         if (settings.days.contains('Even') || settings.days.contains('Odd')) {
2415                 return
2416         }
2417                 if (settings.days.contains('Monday')) {
2418                 daysAvailable[i] = 0
2419                 i++
2420         }
2421         if (settings.days.contains('Tuesday')) {
2422                 daysAvailable[i] = 1
2423                 i++
2424         }
2425         if (settings.days.contains('Wednesday')) {
2426                 daysAvailable[i] = 2
2427                 i++
2428         }
2429         if (settings.days.contains('Thursday')) {
2430                 daysAvailable[i] = 3
2431                 i++
2432         }
2433         if (settings.days.contains('Friday')) {
2434                 daysAvailable[i] = 4
2435                 i++
2436         }
2437         if (settings.days.contains('Saturday')) {
2438                 daysAvailable[i] = 5
2439                 i++
2440         }
2441         if (settings.days.contains('Sunday')) {
2442                 daysAvailable[i] = 6
2443                 i++
2444         }
2445         if(i != ndaysAvailable) {
2446                 log.debug 'ERROR: days and daysAvailable do not match in setup - overriding'
2447                 log.debug "${i} ${ndaysAvailable}"
2448                 ndaysAvailable = i                              // override incorrect setup execution
2449                 state.daysAvailable = i
2450         }
2451     }
2452     else {                                      // all days are available if settings.days == ""
2453         daysAvailable = [0,1,2,3,4,5,6]
2454     }
2455     //log.debug "Ndays: ${ndaysAvailable} Available Days: ${daysAvailable}"
2456     def maxday = -1
2457     def max = -1
2458     def dDays = new int[7]
2459     def int[][] runDays = [[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]]
2460
2461     for(def a=0; a < ndaysAvailable; a++) {
2462         // Figure out next day using the dayDistance map, getting the farthest away day (max value)
2463         if(a > 0 && ndaysAvailable >= 2 && a != ndaysAvailable-1) {
2464                 if(a == 1) {
2465                                 for(def c=1; c < ndaysAvailable; c++) {
2466                                 def d = dayDistance[daysAvailable[0]][daysAvailable[c]]
2467                                         if(d > max) {
2468                                         max = d
2469                                         maxday = daysAvailable[c]
2470                                 }
2471                         }
2472                         //log.debug "max: ${max}  maxday: ${maxday}"
2473                         dDays[0] = maxday
2474                 }
2475  
2476                 // Find successive maxes for the following days
2477                 if(a > 1) {
2478                         def lmax = max
2479                         def lmaxday = maxday
2480                         max = -1
2481                         for(int c = 1; c < ndaysAvailable; c++) {
2482                                 def d = dayDistance[daysAvailable[0]][daysAvailable[c]]
2483                         def t = d > max
2484                         if (a % 2 == 0) t = d >= max
2485                                 if(d < lmax && d >= max) {
2486                                 if(d == max) {
2487                                         d = dayDistance[lmaxday][daysAvailable[c]]
2488                                         if(d > dayDistance[lmaxday][maxday]) {
2489                                                 max = d
2490                                                 maxday = daysAvailable[c]
2491                                         }
2492                                 } 
2493                                 else {
2494                                         max = d
2495                                         maxday = daysAvailable[c]
2496                                 }
2497                                 }
2498                         }
2499                         lmax = 5
2500                         while(max == -1) {
2501                         lmax = lmax -1
2502                                 for(int c = 1; c < ndaysAvailable; c++) {
2503                                         def d = dayDistance[daysAvailable[0]][daysAvailable[c]]
2504                                         if(d < lmax && d >= max) {
2505                                         if(d == max) {
2506                                                 d = dayDistance[lmaxday][daysAvailable[c]]
2507                                                 if(d > dayDistance[lmaxday][maxday]) {
2508                                                         max = d
2509                                                         maxday = daysAvailable[c]
2510                                                 }
2511                                         } 
2512                                         else {
2513                                                 max = d
2514                                                 maxday = daysAvailable[c]
2515                                         }
2516                                         }
2517                                 }
2518                         for (def d=0; d< a-2; d++) {
2519                                 if(maxday == dDays[d]) max = -1
2520                                 }
2521                         }
2522                         //log.debug "max: ${max} maxday: ${maxday}"
2523                         dDays[a-1] = maxday
2524                 }
2525         }
2526       
2527         // Set the runDays map using the calculated maxdays
2528         for(int b=0; b < 7; b++) {
2529                 // Runs every day available
2530                 if(a == ndaysAvailable-1) {
2531                         runDays[a][b] = 0
2532                         for (def c=0; c < ndaysAvailable; c++) {
2533                                 if(b == daysAvailable[c]) runDays[a][b] = 1
2534                         }
2535                 } 
2536                 else {
2537                         // runs weekly, use first available day
2538                         if(a == 0) {
2539                                 if(b == daysAvailable[0])
2540                                         runDays[a][b] = 1
2541                                 else
2542                                         runDays[a][b] = 0
2543                         }
2544                         else {
2545                                 // Otherwise, start with first available day
2546                                 if(b == daysAvailable[0])
2547                                         runDays[a][b] = 1
2548                                 else {
2549                                         runDays[a][b] = 0
2550                                         for(def c=0; c < a; c++)
2551                                         if(b == dDays[c])
2552                                                 runDays[a][b] = 1
2553                                 }
2554                         }
2555                 }
2556         }
2557     }
2558   
2559         //log.debug "DPW: ${runDays}"
2560     state.DPWDays1 = runDays[0]
2561     state.DPWDays2 = runDays[1]
2562     state.DPWDays3 = runDays[2]
2563     state.DPWDays4 = runDays[3]
2564     state.DPWDays5 = runDays[4]
2565     state.DPWDays6 = runDays[5]
2566     state.DPWDays7 = runDays[6]
2567 }
2568
2569 //transition page to populate app state - this is a fix for WP param
2570 def zoneSetPage1(){
2571         state.app = 1
2572     zoneSetPage()
2573     }
2574 def zoneSetPage2(){
2575         state.app = 2
2576     zoneSetPage()
2577     }
2578 def zoneSetPage3(){
2579         state.app = 3
2580     zoneSetPage()
2581     }
2582 def zoneSetPage4(){
2583         state.app = 4
2584     zoneSetPage()
2585     }
2586 def zoneSetPage5(){
2587         state.app = 5
2588     zoneSetPage()
2589     }
2590 def zoneSetPage6(){
2591         state.app = 6
2592     zoneSetPage()
2593     }
2594 def zoneSetPage7(){
2595         state.app = 7
2596     zoneSetPage()
2597     }
2598 def zoneSetPage8(){
2599         state.app = 8
2600     zoneSetPage()
2601     }
2602 def zoneSetPage9(i){
2603         state.app = 9
2604     zoneSetPage()
2605     }
2606 def zoneSetPage10(){
2607         state.app = 10
2608     zoneSetPage()
2609     }
2610 def zoneSetPage11(){
2611         state.app = 11
2612     zoneSetPage()
2613     }
2614 def zoneSetPage12(){
2615         state.app = 12
2616     zoneSetPage()
2617     }
2618 def zoneSetPage13(){
2619         state.app = 13
2620     zoneSetPage()
2621     }
2622 def zoneSetPage14(){
2623         state.app = 14
2624     zoneSetPage()
2625     }
2626 def zoneSetPage15(){
2627         state.app = 15
2628     zoneSetPage()
2629     }
2630 def zoneSetPage16(){
2631         state.app = 16
2632     zoneSetPage()
2633     }
2634