2 * Spruce Scheduler Pre-release V2.53.1 - Updated 11/07/2016, BAB
5 * Copyright 2015 Plaid Systems
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:
10 * http://www.apache.org/licenses/LICENSE-2.0
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.
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
25 -------v2.52---------------------
26 -Major revision by BAB
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")
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')
50 page(name: 'zoneSettingsPage')
51 page(name: 'zoneSetPage')
52 page(name: 'plantSetPage')
53 page(name: 'sprinklerSetPage')
54 page(name: 'optionSetPage')
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')
76 dynamicPage(name: 'startPage', title: 'Spruce Smart Irrigation setup', install: true, uninstall: true)
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()}"
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()}"
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()}"
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()}"
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'
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
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']])
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'])
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',
151 image: 'http://www.plaidsystems.com/smartthings/rain.png'
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']]
161 return dynamicPage(name: 'zipcodePage', title: 'Spruce weather station setup') {
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 )
168 paragraph(image: 'http://www.plaidsystems.com/smartthings/wu.png', title: 'WeatherUnderground Personal Weather Stations (PWS)',
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}")
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
197 else log.debug "bad response"
199 else log.debug "null or error"
201 log.debug "Nearest PWS ${PWSID}"
205 private String startTimeString(){
206 if (!startTime) return 'Please set!' else return hhmm(startTime)
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'
216 private String waterStoppersString(){
217 String stoppers = 'Contact Sensor'
218 if (settings.contacts) {
219 if (settings.contacts.size() != 1) stoppers += 's'
222 settings.contacts.each {
223 if ( i > 1) stoppers += ', '
224 stoppers += it.displayName
227 stoppers = "${stoppers}\nPause: When ${settings.contactStop}\n"
230 stoppers += ': None\n'
233 if (settings.toggles) {
234 if (settings.toggles.size() != 1) stoppers += 'es'
237 settings.toggles.each {
238 if ( i > 1) stoppers += ', '
239 stoppers += it.displayName
242 stoppers = "${stoppers}\nPause: When switched ${settings.toggleStop}\n"
245 stoppers += ': None\n'
248 if (settings.contactDelay && settings.contactDelay > 10) cd = settings.contactDelay.toInteger()
249 stoppers += "Restart Delay: ${cd} secs"
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'
258 private String seasonalAdjString(){
259 if(settings.isSeason) return 'On' else return 'Off'
262 private String syncString(){
263 if (settings.sync) return "${settings.sync.displayName}" else return 'None'
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'
277 if (notifyStr == '') notifyStr = ' None'
278 if (settings.logAll) notifyStr += '\nSending all Notifications to Hello Home log'
283 private String daysString(){
284 String daysString = ''
286 if(days.contains('Even') || days.contains('Odd')) {
287 if (days.contains('Even')) daysString += ' Even'
288 if (days.contains('Odd')) daysString += ' Odd'
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'
300 if(daysString == '') return ' Any'
302 else return daysString
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))
312 private String pumpDelayString(){
313 if (!pumpDelay) return '0' else return pumpDelay as String
319 dynamicPage(name: 'delayPage', title: 'Additional Options') {
321 paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png',
322 title: 'Pump and Master valve delay',
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
331 paragraph(image: 'http://www.plaidsystems.com/smartthings/st_pause.png',
332 title: 'Pause Control Contacts & Switches',
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
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)
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)
354 paragraph image: 'http://www.plaidsystems.com/smartthings/st_spruce_controller_250.png',
355 title: 'Controller Sync',
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
365 dynamicPage(name: 'zonePage', title: 'Zone setup', install: false, uninstall: false) {
367 href(name: 'hrefWithImage', title: 'Zone configuration', page: 'zoneSettingsPage',
368 description: "${zoneString()}",
370 image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png')
373 if (zoneActive('1')){
375 href(name: 'z1Page', title: "1: ${getname("1")}", required: false, page: 'zoneSetPage1',
376 image: "${getimage("1")}",
377 description: "${display("1")}" )
380 if (zoneActive('2')){
382 href(name: 'z2Page', title: "2: ${getname("2")}", required: false, page: 'zoneSetPage2',
383 image: "${getimage("2")}",
384 description: "${display("2")}" )
387 if (zoneActive('3')){
389 href(name: 'z3Page', title: "3: ${getname("3")}", required: false, page: 'zoneSetPage3',
390 image: "${getimage("3")}",
391 description: "${display("3")}" )
394 if (zoneActive('4')){
396 href(name: 'z4Page', title: "4: ${getname("4")}", required: false, page: 'zoneSetPage4',
397 image: "${getimage("4")}",
398 description: "${display("4")}" )
401 if (zoneActive('5')){
403 href(name: 'z5Page', title: "5: ${getname("5")}", required: false, page: 'zoneSetPage5',
404 image: "${getimage("5")}",
405 description: "${display("5")}" )
408 if (zoneActive('6')){
410 href(name: 'z6Page', title: "6: ${getname("6")}", required: false, page: 'zoneSetPage6',
411 image: "${getimage("6")}",
412 description: "${display("6")}" )
415 if (zoneActive('7')){
417 href(name: 'z7Page', title: "7: ${getname("7")}", required: false, page: 'zoneSetPage7',
418 image: "${getimage("7")}",
419 description: "${display("7")}" )
422 if (zoneActive('8')){
424 href(name: 'z8Page', title: "8: ${getname("8")}", required: false, page: 'zoneSetPage8',
425 image: "${getimage("8")}",
426 description: "${display("8")}" )
429 if (zoneActive('9')){
431 href(name: 'z9Page', title: "9: ${getname("9")}", required: false, page: 'zoneSetPage9',
432 image: "${getimage("9")}",
433 description: "${display("9")}" )
436 if (zoneActive('10')){
438 href(name: 'z10Page', title: "10: ${getname("10")}", required: false, page: 'zoneSetPage10',
439 image: "${getimage("10")}",
440 description: "${display("10")}" )
443 if (zoneActive('11')){
445 href(name: 'z11Page', title: "11: ${getname("11")}", required: false, page: 'zoneSetPage11',
446 image: "${getimage("11")}",
447 description: "${display("11")}" )
450 if (zoneActive('12')){
452 href(name: 'z12Page', title: "12: ${getname("12")}", required: false, page: 'zoneSetPage12',
453 image: "${getimage("12")}",
454 description: "${display("12")}" )
457 if (zoneActive('13')){
459 href(name: 'z13Page', title: "13: ${getname("13")}", required: false, page: 'zoneSetPage13',
460 image: "${getimage("13")}",
461 description: "${display("13")}" )
464 if (zoneActive('14')){
466 href(name: 'z14Page', title: "14: ${getname("14")}", required: false, page: 'zoneSetPage14',
467 image: "${getimage("14")}",
468 description: "${display("14")}" )
471 if (zoneActive('15')){
473 href(name: 'z15Page', title: "15: ${getname("15")}", required: false, page: 'zoneSetPage15',
474 image: "${getimage("15")}",
475 description: "${display("15")}" )
478 if (zoneActive('16')){
480 href(name: 'z16Page', title: "16: ${getname("16")}", required: false, page: 'zoneSetPage16',
481 image: "${getimage("16")}",
482 description: "${display("16")}" )
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
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
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"
513 def zoneSettingsPage() {
514 dynamicPage(name: 'zoneSettingsPage', title: 'Zone Configuration') {
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 ' +
524 input 'learn', 'bool', title: 'Enable Adaptive Moisture Control (with moisture sensors)', metadata: [values: ['true', 'false']]
530 dynamicPage(name: 'zoneSetPage', title: "Zone ${state.app} Setup") {
532 paragraph image: "http://www.plaidsystems.com/smartthings/st_${state.app}.png",
533 title: 'Current Settings',
534 "${display("${state.app}")}"
538 input "name${state.app}", 'text', title: 'Zone name?', required: false, defaultValue: "Zone ${state.app}"
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']]
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']]
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']]
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
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
583 private String setString(String type) {
586 if (settings."zone${state.app}") return settings."zone${state.app}" else return 'Not Set'
589 if (settings."plant${state.app}") return settings."plant${state.app}" else return 'Not Set'
592 if (settings."option${state.app}") return settings."option${state.app}" else return 'Not Set'
600 dynamicPage(name: 'plantSetPage', title: "${settings["name${state.app}"]} Landscape Select") {
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']]
608 paragraph image: 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png',
610 'Select Lawn for typical grass applications'
612 paragraph image: 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png',
614 'Select Garden for vegetable gardens'
616 paragraph image: 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png',
618 'Select Flowers for beds with smaller seasonal plants'
620 paragraph image: 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png',
622 'Select Shrubs for beds with larger established plants'
624 paragraph image: 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png',
626 'Select Trees for deep rooted areas without other plants'
628 paragraph image: 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png',
630 'Reduces water for native or drought tolorent plants'
632 paragraph image: 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png',
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.'
639 def sprinklerSetPage(){
640 dynamicPage(name: 'sprinklerSetPage', title: "${settings["name${state.app}"]} Sprinkler Select") {
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']]
648 paragraph image: 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png',
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.'
652 paragraph image: 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png',
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.'
656 paragraph image: 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png',
658 'Drip lines or low flow emitters water slowely to minimize evaporation, because they are low flow, they require longer watering periods.'
660 paragraph image: 'http://www.plaidsystems.com/smartthings/st_master_225_r.png',
662 'Master valves will open before watering begins. Set the delay between master opening and watering in delay settings.'
664 paragraph image: 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png',
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.'
672 dynamicPage(name: 'optionSetPage', title: "${settings["name${state.app}"]} Options") {
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']]
680 paragraph image: 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png',
682 'Slope sets the sprinklers to cycle 3x, each with a short duration to minimize runoff'
684 paragraph image: 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png',
686 'Sandy soil drains quickly and requires more frequent but shorter intervals of water'
688 paragraph image: 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png',
690 'Clay sets the sprinklers to cycle 2x, each with a short duration to maximize absorption'
692 paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png',
694 'The sprinklers will run for 1 long duration'
696 paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png',
698 'Cycle 2x will break the water period up into 2 shorter cycles to help minimize runoff and maximize adsorption'
700 paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png',
702 'Cycle 3x will break the water period up into 3 shorter cycles to help minimize runoff and maximize adsorption'
712 private String getaZoneSummary(int zone){
713 if (!settings."zone${zone}" || (settings."zone${zone}" == 'Off')) return "${zone}: Off"
715 String daysString = ''
716 int tpw = initTPW(zone)
717 int dpw = initDPW(zone)
718 int runTime = calcRunTime(tpw, dpw)
720 if ( !learn && (settings."sensor${zone}")) {
721 daysString = 'if Moisture is low on: '
722 dpw = daysAvailable()
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'
731 def int[] dpwMap = [0,0,0,0,0,0,0]
732 dpwMap = getDPWDays(dpw)
733 daysString += getRunDays(dpwMap)
735 return "${zone}: ${runTime} min, ${daysString}"
738 private String getZoneSummary(){
740 if (learn) summary = 'Moisture Learning enabled' else summary = 'Moisture Learning disabled'
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)}"
749 if (summary) return summary else return zoneString() //"Setup all 16 zones"
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)}% : "
766 if ((runTime != 0) && (dpw != 0)) displayString = "${displayString}${runTime} minutes, ${dpw} days per week"
770 private String getimage(String image){
771 String imageStr = image
772 if (image.isNumber()) {
773 String zoneStr = settings."zone${image}"
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'
779 if (settings."plant${image}") imageStr = settings."plant${image}" // default assume asking for the plant image
782 // OK, lookup the requested image
786 return 'http://www.plaidsystems.com/smartthings/off2.png'
788 return 'http://www.plaidsystems.com/smartthings/off2.png'
790 return 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png'
792 return 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png'
794 return 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png'
796 return 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png'
798 return 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png'
800 return 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png'
802 return 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png'
804 return 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png'
806 return 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png'
808 return 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png'
810 return "http://www.plaidsystems.com/smartthings/st_master_225_r.png"
812 return 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png'
814 return 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png'
816 return 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png'
818 return 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png'
820 return 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png'
822 return 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png'
824 return 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png'
826 return 'http://www.plaidsystems.com/smartthings/off2.png'
830 private String getname(String i) {
831 if (settings."name${i}") return settings."name${i}" else return "Zone ${i}"
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
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
852 log.debug "Installed with settings: ${settings}"
857 log.debug "Updated with settings: ${settings}"
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
867 if (atomicState.run) {
868 attemptRecovery() // clean up if we crashed earlier
871 unsubscribe() //added back in to reset manual subscription
874 subscribe(app, appTouch) // enable the "play" button for this schedule
875 Random rand = new Random()
876 long randomOffset = 0
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
882 if (settings.switches && settings.startTime && settings.enable){
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
889 note('schedule', "${app.label}: Starts at ${startTimeString()}", 'i')
892 unschedule( preCheck )
893 note('disable', "${app.label}: Automatic watering disabled or setup is incomplete", 'a')
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
902 else { // Hmmm...seems we were running before...
903 def csw = settings.switches.currentSwitch
904 def cst = settings.switches.currentStatus
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
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')
919 // hmmm...switch is on and we think we're finished...probably somebody else is running...let busy figure it out
924 case 'off': // switch is off - did we finish?
925 if (atomicState.finishTime) { // off and finished, let's just reset things
930 if (switches.currentStatus != 'pause') { // off and not paused - probably another schedule, let's clean up
935 // off and not finished, and paused, we apparently crashed while paused
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
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
948 if (atomicState.startTime) atomicState.startTime = null
949 note ('schedule', "Looks like ${app.label} crashed recently...cleaning up", c)
954 log.debug "attemptRecovery(): atomicState.run == true, and I've nothing left to do"
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
965 // take care not to unschedule preCheck() or getRainToday()
967 unschedule(checkRunMap)
968 unschedule(writeCycles)
971 if (settings.enableManual) subscribe(settings.switches, 'switch.programOn', manualStart)
974 // unsubscribe from ALL events EXCEPT app.touch
975 private def unsubAllBut() {
976 unsubscribe(settings.switches)
978 if (settings.sync) unsubscribe(settings.sync)
982 // enable the "Play" button in SmartApp list
985 log.debug "appTouch(): atomicState.run = ${atomicState.run}"
987 runIn(2, preCheck) // run it off a schedule, so we can see how long it takes in the app.state
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
994 if (settings.toggles && settings.toggles.currentSwitch.contains(settings.toggleStop)) return true
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)
1005 if (settings.toggles) {
1006 unsubscribe(settings.toggles)
1007 subscribe(settings.toggles, "switch.${settings.toggleStop}", waterStop)
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)
1018 if (settings.toggles) {
1019 unsubscribe(settings.toggles)
1020 def cond = (settings.toggleStop == 'on') ? 'off' : 'on'
1021 subscribe(settings.toggles, "switch.${cond}", waterStart)
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)
1031 // which of the stoppers are in stop mode?
1032 private String getWaterStopList() {
1033 String deviceList = ''
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}"
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}"
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
1066 //get day of week integer
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()
1074 def today = new Date().format('EEEE', location.timeZone)
1075 return mapDay.get(today).toInteger()
1078 // Get string of run days from dpwMap
1079 private String getRunDays(day1,day2,day3,day4,day5,day6,day7)
1085 if(day4) str += 'Th'
1087 if(day6) str += 'Sa'
1088 if(day7) str += 'Su'
1089 if(str == '') str = '0 Days/week'
1093 //start manual schedule
1094 def manualStart(evt){
1095 boolean running = attemptRecovery() // clean up if prior run crashed
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')
1103 runNowMap = cycleLoop(0)
1106 atomicState.run = true
1107 settings.switches.programWait()
1108 subscribe(settings.switches, 'switch.off', cycleOff)
1110 runIn(60, cycleOn) // start water program
1112 // note that manual DOES abide by waterStoppers (if configured)
1113 String newString = ''
1114 int tt = state.totalTime
1116 int hours = tt / 60 // DON'T Math.round this one
1117 int mins = tt - (hours * 60)
1118 String hourString = ''
1120 if (hours > 1) s = 's'
1121 if (hours > 0) hourString = "${hours} hour${s} & "
1123 if (mins == 1) s = ''
1124 newString = "run time: ${hourString}${mins} minute${s}:\n"
1127 note('active', "${app.label}: Manual run, watering in 1 minute: ${newString}${runNowMap}", 'd')
1129 else note('skipping', "${app.label}: Manual run failed, check configuration", 'a')
1132 else note('skipping', "${app.label}: Manual run aborted, ${settings.switches.displayName} appears to be busy", 'a')
1135 //true if another schedule is running
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)
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')
1148 // Not already running...
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)
1155 note('delayed', "${app.label}: Waiting for ${settings.sync.displayName} to complete before starting", 'c')
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
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
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)"
1173 subscribe(settings.switches, 'switch.off', busyOff)
1174 note('delayed', "${app.label}: Waiting for currently running schedule to complete before starting", 'c')
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"
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')
1195 //run check every day
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
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
1212 note('active', "${app.label}: Starting...", 'd') //
1214 log.debug "preCheck note active ${end - start}ms"
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
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!
1227 //start water program
1229 if (atomicState.run) { // block if manually stopped during precheck which goes to cycleOff
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
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}"
1248 else if (state.pauseTime) { // resuming after a pause
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}"
1257 note('active', newString, 'd')
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')
1267 //when switch reports off, watering program is finished
1270 if (atomicState.run) {
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')
1277 log.debug "${settings.switches} turned off" // is this a manual off? perhaps we should send a note?
1279 resetEverything() // all done here, back to starting state
1282 //run check each day at scheduled time
1285 //check if isWeather returned true or false before checking
1286 if (atomicState.run) {
1288 //get & set watering times for today
1290 runNowMap = cycleLoop(1) // build the map
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
1299 String newString = ''
1300 int tt = state.totalTime
1302 int hours = tt / 60 // DON'T Math.round this one
1303 int mins = tt - (hours * 60)
1304 String hourString = ''
1306 if (hours > 1) s = 's'
1307 if (hours > 0) hourString = "${hours} hour${s} & "
1309 if (mins == 1) s = ''
1310 newString = "run time: ${hourString}${mins} minute${s}:\n"
1312 note('active', "${app.label}: Watering in 1 minute, ${newString}${runNowMap}", 'd')
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
1324 log.debug 'checkRunMap(): atomicState.run = false' // isWeather cancelled us out before we got started
1328 //get todays schedule
1329 def cycleLoop(int i)
1331 boolean isDebug = false
1332 if (isDebug) log.debug "cycleLoop(${i})"
1342 String soilString = ''
1345 if (atomicState.startTime) atomicState.startTime = null // haven't started yet
1350 def setZ = settings."zone${zone}"
1351 if ((setZ && (setZ != 'Off')) && (nozzle(zone) != 4) && zoneActive(zone.toString())) {
1353 // First check if we run this zone today, use either dpwMap or even/odd date
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}")) {
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
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}"
1379 // OK, we're supposed to run (or at least adjust the sensors)
1383 if (i == 0) soil = moisture(0) // manual
1384 else soil = moisture(zone) // moisture check
1385 soilString = "${soilString}${soil[1]}"
1387 // Run this zone if soil moisture needed
1392 dpw = getDPW(zone) // moisture() may have changed DPW
1394 rtime = calcRunTime(tpw, dpw)
1395 //daily weather adjust if no sensor
1396 if(settings.isSeason && (!settings.learn || !settings."sensor${zone}")) {
1399 rtime = Math.round(((rtime / cyc) * (state.seasonAdj / 100.0)) + 0.4)
1402 rtime = Math.round((rtime / cyc) + 0.4) // let moisture handle the seasonAdjust for Adaptive (learn) zones
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}"
1411 if (nozzle(zone) == 4) pumpMap += "${settings."name${zone}"}: ${settings."zone${zone}"} on\n"
1412 timeMap."${zone+1}" = "${rtime}"
1417 String seasonStr = ''
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"
1426 note('moisture', "${app.label} Sensor status:\n${seasonStr}${soilString}" /* + seasonStr + soilString */,'m')
1430 return runNowMap // nothing to run today
1433 //send settings to Spruce Controller
1434 switches.settingsMap(timeMap,4002)
1435 runIn(30, writeCycles)
1437 // meanwhile, calculate our total run time
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
1443 if (state.pauseTime) state.pauseTime = null // and we haven't paused yet
1444 // but let cycleOn() reset finishTime
1445 return (runNowMap + pumpMap)
1448 //send cycle settings
1450 //log.trace "writeCycles()"
1453 cyclesMap."1" = pumpDelayString()
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}"
1464 switches.settingsMap(cyclesMap, 4001)
1468 log.debug 'resume()'
1469 settings.switches.zon()
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"
1483 // handle start of pause session
1485 log.debug "waterStop: ${evt.displayName}"
1487 unschedule(cycleOn) // in case we got stopped again before cycleOn starts from the restart
1488 unsubscribe(settings.switches)
1491 if (!state.pauseTime) { // only need to do this for the first event if multiple contacts
1492 state.pauseTime = now()
1494 String cond = evt.value
1500 cond = 'switched on'
1503 cond = 'switched off'
1514 note('pause', "${app.label}: Watering paused - ${evt.displayName} ${cond}", 'c') // set to Paused
1516 if (settings.switches.currentSwitch != 'off') {
1518 settings.switches.off() // stop the water
1521 subscribe(settings.switches, 'switch.off', cycleOff)
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.
1528 subscribe(settings.switches, 'switch.off', offPauseCheck)
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
1539 // handle end of pause session
1540 def waterStart(evt){
1541 if (!isWaterStopped()){ // only if ALL of the selected contacts are not open
1543 if (settings.contactDelay > 10) cDelay = settings.contactDelay
1544 runIn(cDelay, cycleOn)
1546 unsubscribe(settings.switches)
1547 subWaterStop() // allow stopping again while we wait for cycleOn to start
1549 log.debug "waterStart(): enabling device is ${evt.device} ${evt.value}"
1551 String cond = evt.value
1557 cond = 'switched on'
1560 cond = 'switched off'
1571 // let cycleOn() change the status to Active - keep us paused until then
1573 note('pause', "${app.label}: ${evt.displayName} ${cond}, watering in ${cDelay} seconds", 'c')
1576 log.debug "waterStart(): one down - ${evt.displayName}"
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]
1585 int tpw = getTPW(zone) // was getTPW -does not update times in scheduler without initTPW
1590 if(settings."perDay${zone}") perDay = settings."perDay${zone}".toFloat()
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
1600 state.dpwMap[zone-1] = dpw
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)
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]
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
1618 // apply gain adjustment
1619 float gainAdjust = 100.0
1620 if (settings.gain && settings.gain != 0) gainAdjust += settings.gain
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
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]
1634 // set user-specified minimum time with seasonal adjust
1636 def mw = settings."minWeek${zone}"
1637 if (mw) minWeek = mw.toInteger()
1639 tpw = Math.round(minWeek * (seasonAdjust / 100.0))
1641 else if (!tpw || (tpw == 0)) { // use calculated tpw
1642 tpw = Math.round((plant(zone) * nozzle(zone) * (gainAdjust / 100.0) * (seasonAdjust / 100.0)))
1644 state.tpwMap[zone-1] = tpw
1648 // Get the current time per week, calls init if not defined
1649 int getTPW(int zone)
1651 if (state.tpwMap) return state.tpwMap[zone-1] else return initTPW(zone)
1654 // Calculate daily run time based on tpw and dpw
1655 int calcRunTime(int tpw, int dpw)
1658 if ((tpw > 0) && (dpw > 0)) duration = Math.round(tpw.toFloat() / dpw.toFloat())
1662 // Check the moisture level of a zone returning dry (1) or wet (0) and adjust tpw if overly dry/wet
1665 boolean isDebug = false
1666 if (isDebug) log.debug "moisture(${i})"
1669 // No Sensor on this zone or manual start skips moisture checking altogether
1670 if ((i == 0) || !settings."sensor${i}") {
1674 // Ensure that the sensor has reported within last 48 hours
1675 int spHum = getDrySp(i)
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')
1683 if (latestHum < spHum)
1684 latestHum = spHum - 1.0 // amke sure we water and do seasonal adjustments, but not tpw adjustments
1686 latestHum = spHum + 0.99 // make sure we don't water, do seasonal adjustments, but not tpw adjustments
1689 if (!settings.learn)
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()) {
1695 return [1,"${settings."name${i}"}, Watering: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"]
1699 return [0,"${settings."name${i}"}, Skipping: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"]
1711 if (isDebug) log.debug "moisture(${i}): tpw: ${tpw}, dpw: ${dpw}, cycles: ${cpd} (before adjustment)"
1714 if (latestHum > 0.0) diffHum = (spHum - latestHum) / 100.0
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')
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
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
1738 int seasonAdjust = 0
1740 float sa = state.seasonAdj
1741 if ((sa != 100.0) && (sa != 0.0)) {
1742 float sadj = sa - 100.0
1744 seasonAdjust = Math.round(((sadj / 100.0) * tpw) + 0.5)
1746 seasonAdjust = Math.round(((sadj / 100.0) * tpw) - 0.5)
1749 if (isDebug) log.debug "moisture(${i}): diffHum: ${diffHum}, tpwAdjust: ${tpwAdjust} seasonAdjust: ${seasonAdjust}"
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
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
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
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
1776 def minL = settings."minWeek${i}"
1777 if (minL) minLimit = minL.toInteger() // unless otherwise specified in configuration
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')
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
1790 // else no adjustments, or adjustments cancelled each other out.
1792 String moistureSum = ''
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]
1802 else { // not watering
1803 moistureSum = "> ${settings."name${i}"}, Skip: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n"
1804 return [0, moistureSum]
1806 return [0, moistureSum]
1810 int getDrySp(int i){
1811 if (settings."sensorSp${i}") return settings."sensorSp${i}".toInteger() // configured SP
1814 if (settings."plant${i}" == 'New Plants') return 40 // New Plants get special care
1817 switch (settings."option${i}") { // else, defaults based off of soil type
1827 //notifications to device, pushed if requested
1828 def note(String statStr, String msg, String msgType) {
1830 // send to debug first (near-zero cost)
1831 log.debug "${statStr}: ${msg}"
1833 // notify user second (small cost)
1834 boolean notifyController = true
1835 if(settings.notify || settings.logAll) {
1836 String spruceMsg = "Spruce ${msg}"
1839 if (settings.notify && settings.notify.contains('Daily')) { // always log the daily events to the controller
1842 else if (settings.logAll) {
1843 sendNotificationEvent(spruceMsg)
1847 if (settings.notify && settings.notify.contains('Delays')) {
1850 else if (settings.logAll) {
1851 sendNotificationEvent(spruceMsg)
1855 if (settings.notify && settings.notify.contains('Events')) {
1857 //notifyController = false // no need to notify controller unless we don't notify the user
1859 else if (settings.logAll) {
1860 sendNotificationEvent(spruceMsg)
1864 notifyController = false // no need to notify the controller, ever
1865 if (settings.notify && settings.notify.contains('Weather')) {
1868 else if (settings.logAll) {
1869 sendNotificationEvent(spruceMsg)
1873 notifyController = false // no need to notify the controller, ever
1874 if (settings.notify && settings.notify.contains('Warnings')) {
1877 sendNotificationEvent(spruceMsg) // Special case - make sure this goes into the Hello Home log, if not notifying
1880 if (settings.notify && settings.notify.contains('Moisture')) {
1882 //notifyController = false // no need to notify controller unless we don't notify the user
1884 else if (settings.logAll) {
1885 sendNotificationEvent(spruceMsg)
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)
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)
1908 def sendIt(String msg) {
1909 if (location.contactBookEnabled && settings.recipients) {
1910 sendNotificationToContacts(msg, settings.recipients, [event: true])
1918 int daysAvailable(){
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
1926 if (!settings.days) { // settings.days = "" --> every day is available
1927 state.daysAvailable = 7
1928 return 7 // every day is allowed
1931 int dayCount = 0 // settings.days specified, need to calculate state.davsAvailable (once)
1932 if (settings.days.contains('Even') || settings.days.contains('Odd')) {
1934 if(settings.days.contains('Even') && settings.days.contains('Odd')) dayCount = 7
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
1946 state.daysAvailable = dayCount
1950 //zone: ['Off', 'Spray', 'rotor', 'Drip', 'Master Valve', 'Pump']
1952 String getT = settings."zone${i}"
1962 case 'Master Valve':
1971 //plant: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']
1973 String getP = settings."plant${i}"
1996 //option: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x']
1998 String getC = settings."option${i}"
2019 //check if day is allowed
2022 if (daysAvailable() == 7) return true // every day is allowed
2024 def daynow = new Date()
2025 String today = daynow.format('EEEE', location.timeZone)
2026 if (settings.days.contains(today)) return true
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
2035 //set season adjustment & remove season adjustment
2037 boolean isDebug = false
2038 if (isDebug) log.debug 'setSeason()'
2042 if ( !settings.learn || !settings."sensor${zone}" || state.tpwMap[zone-1] == 0) {
2044 int tpw = initTPW(zone) // now updates state.tpwMap
2045 int dpw = initDPW(zone) // now updates state.dpwMap
2047 if (!settings.learn && (tpw != 0) && (state.weekseasonAdj != 0)) {
2048 log.debug "Zone ${zone}: seasonally adjusted by ${state.weekseasonAdj-100}% to ${tpw}"
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)
2062 note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null", 'a')
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')
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}"
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
2084 //check weather, set seasonal adjustment factors, skip today if rainy
2085 boolean isWeather(){
2088 boolean isDebug = false
2089 if (isDebug) log.debug 'isWeather()'
2091 if (!settings.isRain && !settings.isSeason) return false // no need to do any of this
2093 String wzipcode = zipString()
2094 if (isDebug) log.debug "isWeather(): ${wzipcode}"
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)
2104 log.debug "isWeather() getWeatherFeature elapsed time: ${endMsecs - startMsecs}ms"
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')
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.'
2120 if (isDebug) log.debug 'wdata is null'
2121 note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null" , 'a')
2125 String city = wzipcode
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
2133 if (wdata.current_observation.estimated.estimated) city = "${city} (est)"
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
2143 float weeklyRain = 0.0
2145 if (settings.isRain) {
2146 if (isDebug) log.debug 'isWeather(): isRain'
2148 // Get forecasted rain for today and tomorrow
2150 if (!wdata.forecast) {
2151 log.debug 'isWeather(): Unable to get weather forecast.'
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
2163 // Get rainfall so far today
2165 if (!wdata.current_observation) {
2166 log.debug 'isWeather(): Unable to get current weather conditions.'
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
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
2179 // Get yesterday's rainfall
2180 int day = getWeekDay()
2181 YRain = state.Rain[day - 1]
2183 if (isDebug) log.debug "TRain ${TRain} qpfTodayIn ${qpfTodayIn} @ ${popToday}%, YRain ${YRain}"
2186 while (i <= 6){ // calculate (un)weighted average (only heavy rainstorms matter)
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)
2194 if (isDebug) log.debug "isWeather(): weeklyRain ${weeklyRain}"
2197 if (isDebug) log.debug 'isWeather(): build report'
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()
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"
2210 if (settings.isSeason)
2212 if (!settings.isRain) { // we need to verify we have good data first if we didn't do it above
2214 if (!wdata.forecast) {
2215 log.debug 'Unable to get weather forecast'
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
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()
2235 if ( highs > 0 ) avgHigh = (totalHigh / highs)
2236 heatAdjust = avgHigh / highToday
2238 if (isDebug) log.debug "highToday ${highToday}, avgHigh ${avgHigh}, heatAdjust ${heatAdjust}"
2242 if (wdata.forecast.simpleforecast.forecastday[0].avehumidity.isNumber())
2243 humToday = wdata.forecast.simpleforecast.forecastday[0].avehumidity.toInteger()
2245 float humAdjust = 100.0
2246 float avgHum = humToday.toFloat()
2247 if (humToday != 0) {
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()
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
2261 if (isDebug) log.debug "humToday ${humToday}, avgHum ${avgHum}, humAdjust ${humAdjust}"
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
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
2275 if (sa > 0) plus = '+'
2276 weatherString = "${weatherString}\n Adjusting ${plus}${Math.round(sa)}% for weather forecast"
2278 // Apply seasonal adjustment on Monday each week or at install
2279 if ((getWeekDay() == 1) || (state.weekseasonAdj == 0)) {
2282 if (wdata.sun_phase) {
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()
2293 int daylight = ((getsunSH * 60) + getsunSM)-((getsunRH * 60) + getsunRM)
2294 if (daylight >= 850) daylight = 850
2296 //set seasonal adjustment
2297 //seasonal q (fudge) factor
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
2307 //apply seasonal time adjustment
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"
2317 log.debug 'isWeather(): Unable to get sunrise/set info for today.'
2321 note('season', weatherString , 'f')
2323 // if only doing seasonal adjustments, we are done
2324 if (!settings.isRain) return false
2326 float setrainDelay = 0.2
2327 if (settings.rainDelay) setrainDelay = settings.rainDelay.toFloat()
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')
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')
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')
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')
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')
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')
2368 if (isDebug) log.debug "isWeather() ends"
2372 // true if ANY of this schedule's zones are on and using sensors
2373 private boolean anySensors() {
2375 while (zone <= 16) {
2376 def zoneStr = settings."zone${zone}"
2377 if (zoneStr && (zoneStr != 'Off') && settings."sensor${zone}") return true
2383 def getDPWDays(int dpw){
2384 if (dpw && (dpw.isNumber()) && (dpw >= 1) && (dpw <= 7)) {
2385 return state."DPWDays${dpw}"
2387 return [0,0,0,0,0,0,0]
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() {
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()
2410 // def int[] daysAvailable = [0,1,2,3,4,5,6]
2411 def int[] daysAvailable = [0,0,0,0,0,0,0]
2414 if (settings.days.contains('Even') || settings.days.contains('Odd')) {
2417 if (settings.days.contains('Monday')) {
2418 daysAvailable[i] = 0
2421 if (settings.days.contains('Tuesday')) {
2422 daysAvailable[i] = 1
2425 if (settings.days.contains('Wednesday')) {
2426 daysAvailable[i] = 2
2429 if (settings.days.contains('Thursday')) {
2430 daysAvailable[i] = 3
2433 if (settings.days.contains('Friday')) {
2434 daysAvailable[i] = 4
2437 if (settings.days.contains('Saturday')) {
2438 daysAvailable[i] = 5
2441 if (settings.days.contains('Sunday')) {
2442 daysAvailable[i] = 6
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
2452 else { // all days are available if settings.days == ""
2453 daysAvailable = [0,1,2,3,4,5,6]
2455 //log.debug "Ndays: ${ndaysAvailable} Available Days: ${daysAvailable}"
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]]
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) {
2465 for(def c=1; c < ndaysAvailable; c++) {
2466 def d = dayDistance[daysAvailable[0]][daysAvailable[c]]
2469 maxday = daysAvailable[c]
2472 //log.debug "max: ${max} maxday: ${maxday}"
2476 // Find successive maxes for the following days
2479 def lmaxday = maxday
2481 for(int c = 1; c < ndaysAvailable; c++) {
2482 def d = dayDistance[daysAvailable[0]][daysAvailable[c]]
2484 if (a % 2 == 0) t = d >= max
2485 if(d < lmax && d >= max) {
2487 d = dayDistance[lmaxday][daysAvailable[c]]
2488 if(d > dayDistance[lmaxday][maxday]) {
2490 maxday = daysAvailable[c]
2495 maxday = daysAvailable[c]
2502 for(int c = 1; c < ndaysAvailable; c++) {
2503 def d = dayDistance[daysAvailable[0]][daysAvailable[c]]
2504 if(d < lmax && d >= max) {
2506 d = dayDistance[lmaxday][daysAvailable[c]]
2507 if(d > dayDistance[lmaxday][maxday]) {
2509 maxday = daysAvailable[c]
2514 maxday = daysAvailable[c]
2518 for (def d=0; d< a-2; d++) {
2519 if(maxday == dDays[d]) max = -1
2522 //log.debug "max: ${max} maxday: ${maxday}"
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) {
2532 for (def c=0; c < ndaysAvailable; c++) {
2533 if(b == daysAvailable[c]) runDays[a][b] = 1
2537 // runs weekly, use first available day
2539 if(b == daysAvailable[0])
2545 // Otherwise, start with first available day
2546 if(b == daysAvailable[0])
2550 for(def c=0; c < a; c++)
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]
2569 //transition page to populate app state - this is a fix for WP param
2602 def zoneSetPage9(i){
2606 def zoneSetPage10(){
2610 def zoneSetPage11(){
2614 def zoneSetPage12(){
2618 def zoneSetPage13(){
2622 def zoneSetPage14(){
2626 def zoneSetPage15(){
2630 def zoneSetPage16(){