/** * Spruce Scheduler Pre-release V2.53.1 - Updated 11/07/2016, BAB * * * Copyright 2015 Plaid Systems * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * -------v2.53.1------------------- -ln 210: enableManual string modified -ln 496: added code for old ST app zoneNumber number to convert to enum for app update compatibility -ln 854: unschedule if NOT running to clear/correct manual subscription -ln 863: weather scheduled if rain OR seasonal enabled, both off is no weather check scheduled -ln 1083: added sync check to manual start -ln 1538: corrected contact delay minimum fro 5s to 10s -------v2.52--------------------- -Major revision by BAB * */ definition( name: "Spruce Scheduler", namespace: "plaidsystems", author: "Plaid Systems", description: "Setup schedules for Spruce irrigation controller", category: "Green Living", iconUrl: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png", iconX2Url: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png", iconX3Url: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png") preferences { page(name: 'startPage') page(name: 'autoPage') page(name: 'zipcodePage') page(name: 'weatherPage') page(name: 'globalPage') page(name: 'contactPage') page(name: 'delayPage') page(name: 'zonePage') page(name: 'zoneSettingsPage') page(name: 'zoneSetPage') page(name: 'plantSetPage') page(name: 'sprinklerSetPage') page(name: 'optionSetPage') //found at bottom - transition pages page(name: 'zoneSetPage1') page(name: 'zoneSetPage2') page(name: 'zoneSetPage3') page(name: 'zoneSetPage4') page(name: 'zoneSetPage5') page(name: 'zoneSetPage6') page(name: 'zoneSetPage7') page(name: 'zoneSetPage8') page(name: 'zoneSetPage9') page(name: 'zoneSetPage10') page(name: 'zoneSetPage11') page(name: 'zoneSetPage12') page(name: 'zoneSetPage13') page(name: 'zoneSetPage14') page(name: 'zoneSetPage15') page(name: 'zoneSetPage16') } def startPage(){ dynamicPage(name: 'startPage', title: 'Spruce Smart Irrigation setup', install: true, uninstall: true) { section(''){ href(name: 'globalPage', title: 'Schedule settings', required: false, page: 'globalPage', image: 'http://www.plaidsystems.com/smartthings/st_settings.png', description: "Schedule: ${enableString()}\nWatering Time: ${startTimeString()}\nDays:${daysString()}\nNotifications:${notifyString()}" ) } section(''){ href(name: 'weatherPage', title: 'Weather Settings', required: false, page: 'weatherPage', image: 'http://www.plaidsystems.com/smartthings/st_rain_225_r.png', description: "Weather from: ${zipString()}\nRain Delay: ${isRainString()}\nSeasonal Adjust: ${seasonalAdjString()}" ) } section(''){ href(name: 'zonePage', title: 'Zone summary and setup', required: false, page: 'zonePage', image: 'http://www.plaidsystems.com/smartthings/st_zone16_225.png', description: "${getZoneSummary()}" ) } section(''){ href(name: 'delayPage', title: 'Valve delays & Pause controls', required: false, page: 'delayPage', image: 'http://www.plaidsystems.com/smartthings/st_timer.png', description: "Valve Delay: ${pumpDelayString()} s\n${waterStoppersString()}\nSchedule Sync: ${syncString()}" ) } section(''){ href(title: 'Spruce Irrigation Knowledge Base', //page: 'customPage', description: 'Explore our knowledge base for more information on Spruce and Spruce sensors. Contact form is ' + 'also available here.', required: false, style:'embedded', image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png', url: 'http://support.spruceirrigation.com' ) } } } def globalPage() { dynamicPage(name: 'globalPage', title: '') { section('Spruce schedule Settings') { label title: 'Schedule Name:', description: 'Name this schedule', required: false input 'switches', 'capability.switch', title: 'Spruce Irrigation Controller:', description: 'Select a Spruce controller', required: true, multiple: false } section('Program Scheduling'){ input 'enable', 'bool', title: 'Enable watering:', defaultValue: 'true', metadata: [values: ['true', 'false']] 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']] input 'startTime', 'time', title: 'Watering start time', required: true paragraph(image: 'http://www.plaidsystems.com/smartthings/st_calander.png', title: 'Selecting watering days', 'Selecting watering days is optional. Spruce will optimize your watering schedule automatically. ' + 'If your area has water restrictions or you prefer set days, select the days to meet your requirements. ') 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']]) } section('Push Notifications') { input(name: 'notify', type: 'enum', title: 'Select what push notifications to receive.', required: false, multiple: true, metadata: [values: ['Daily', 'Delays', 'Warnings', 'Weather', 'Moisture', 'Events']]) input('recipients', 'contact', title: 'Send push notifications to', required: false, multiple: true) input(name: 'logAll', type: 'bool', title: 'Log all notices to Hello Home?', defaultValue: 'false', options: ['true', 'false']) } } } def weatherPage() { dynamicPage(name: 'weatherPage', title: 'Weather settings') { section('Location to get weather forecast and conditions:') { href(name: 'hrefWithImage', title: "${zipString()}", page: 'zipcodePage', description: 'Set local weather station', required: false, image: 'http://www.plaidsystems.com/smartthings/rain.png' ) input 'isRain', 'bool', title: 'Enable Rain check:', metadata: [values: ['true', 'false']] input 'rainDelay', 'decimal', title: 'inches of rain that will delay watering, default: 0.2', required: false input 'isSeason', 'bool', title: 'Enable Seasonal Weather Adjustment:', metadata: [values: ['true', 'false']] } } } def zipcodePage() { return dynamicPage(name: 'zipcodePage', title: 'Spruce weather station setup') { section(''){ input(name: 'zipcode', type: 'text', title: 'Zipcode or WeatherUnderground station id. Default value is current Zip code', defaultValue: getPWSID(), required: false, submitOnChange: true ) } section(''){ paragraph(image: 'http://www.plaidsystems.com/smartthings/wu.png', title: 'WeatherUnderground Personal Weather Stations (PWS)', required: false, 'To automatically select the PWS nearest to your hub location, select the toggle below and clear the ' + 'location field above') input(name: 'nearestPWS', type: 'bool', title: 'Use nearest PWS', options: ['true', 'false'], defaultValue: false, submitOnChange: true) href(title: 'Or, Search WeatherUnderground.com for your desired PWS', description: 'After page loads, select "Change Station" for a list of weather stations. ' + 'You will need to copy the station code into the location field above', required: false, style:'embedded', url: (location.latitude && location.longitude)? "http://www.wunderground.com/cgi-bin/findweather/hdfForecast?query=${location.latitude}%2C${location.longitude}" : "http://www.wunderground.com/q/${location.zipCode}") } } } private String getPWSID() { String PWSID = location.zipCode if (zipcode) PWSID = zipcode if (nearestPWS && !zipcode) { // find the nearest PWS to the hub's geo location String geoLocation = location.zipCode // use coordinates, if available if (location.latitude && location.longitude) geoLocation = "${location.latitude}%2C${location.longitude}" Map wdata = getWeatherFeature('geolookup', geoLocation) if (wdata && wdata.response && !wdata.response.containsKey('error')) { // if we get good data if (wdata.response.features.containsKey('geolookup') && (wdata.response.features.geolookup.toInteger() == 1) && wdata.location) { PWSID = wdata.location.nearby_weather_stations.pws.station[0].id } else log.debug "bad response" } else log.debug "null or error" } log.debug "Nearest PWS ${PWSID}" return PWSID } private String startTimeString(){ if (!startTime) return 'Please set!' else return hhmm(startTime) } private String enableString(){ if(enable && enableManual) return 'On & Manual Set' else if (enable) return 'On & Manual Off' else if (enableManual) return 'Off & Manual Set' else return 'Off' } private String waterStoppersString(){ String stoppers = 'Contact Sensor' if (settings.contacts) { if (settings.contacts.size() != 1) stoppers += 's' stoppers += ': ' int i = 1 settings.contacts.each { if ( i > 1) stoppers += ', ' stoppers += it.displayName i++ } stoppers = "${stoppers}\nPause: When ${settings.contactStop}\n" } else { stoppers += ': None\n' } stoppers += "Switch" if (settings.toggles) { if (settings.toggles.size() != 1) stoppers += 'es' stoppers += ': ' int i = 1 settings.toggles.each { if ( i > 1) stoppers += ', ' stoppers += it.displayName i++ } stoppers = "${stoppers}\nPause: When switched ${settings.toggleStop}\n" } else { stoppers += ': None\n' } int cd = 10 if (settings.contactDelay && settings.contactDelay > 10) cd = settings.contactDelay.toInteger() stoppers += "Restart Delay: ${cd} secs" return stoppers } private String isRainString(){ if (settings.isRain && !settings.rainDelay) return '0.2' as String if (settings.isRain) return settings.rainDelay as String else return 'Off' } private String seasonalAdjString(){ if(settings.isSeason) return 'On' else return 'Off' } private String syncString(){ if (settings.sync) return "${settings.sync.displayName}" else return 'None' } private String notifyString(){ String notifyStr = '' if(settings.notify) { if (settings.notify.contains('Daily')) notifyStr += ' Daily' //if (settings.notify.contains('Weekly')) notifyStr += ' Weekly' if (settings.notify.contains('Delays')) notifyStr += ' Delays' if (settings.notify.contains('Warnings')) notifyStr += ' Warnings' if (settings.notify.contains('Weather')) notifyStr += ' Weather' if (settings.notify.contains('Moisture')) notifyStr += ' Moisture' if (settings.notify.contains('Events')) notifyStr += ' Events' } if (notifyStr == '') notifyStr = ' None' if (settings.logAll) notifyStr += '\nSending all Notifications to Hello Home log' return notifyStr } private String daysString(){ String daysString = '' if (days){ if(days.contains('Even') || days.contains('Odd')) { if (days.contains('Even')) daysString += ' Even' if (days.contains('Odd')) daysString += ' Odd' } else { if (days.contains('Monday')) daysString += ' M' if (days.contains('Tuesday')) daysString += ' Tu' if (days.contains('Wednesday')) daysString += ' W' if (days.contains('Thursday')) daysString += ' Th' if (days.contains('Friday')) daysString += ' F' if (days.contains('Saturday')) daysString += ' Sa' if (days.contains('Sunday')) daysString += ' Su' } } if(daysString == '') return ' Any' else return daysString } private String hhmm(time, fmt = 'h:mm a'){ def t = timeToday(time, location.timeZone) def f = new java.text.SimpleDateFormat(fmt) f.setTimeZone(location.timeZone ?: timeZone(time)) return f.format(t) } private String pumpDelayString(){ if (!pumpDelay) return '0' else return pumpDelay as String } def delayPage() { dynamicPage(name: 'delayPage', title: 'Additional Options') { section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png', title: 'Pump and Master valve delay', required: false, 'Setting a delay is optional, default is 0. If you have a pump that feeds water directly into your valves, ' + 'set this to 0. To fill a tank or build pressure, you may increase the delay.\n\nStart->Pump On->delay->Valve ' + 'On->Valve Off->delay->...' input name: 'pumpDelay', type: 'number', title: 'Set a delay in seconds?', defaultValue: '0', required: false } section(''){ paragraph(image: 'http://www.plaidsystems.com/smartthings/st_pause.png', title: 'Pause Control Contacts & Switches', required: false, 'Selecting contacts or control switches is optional. When a selected contact sensor is opened or switch is ' + 'toggled, water immediately stops and will not resume until all of the contact sensors are closed and all of ' + 'the switches are reset.\n\nCaution: if all contacts or switches are left in the stop state, the dependent ' + 'schedule(s) will never run.') input(name: 'contacts', title: 'Select water delay contact sensors', type: 'capability.contactSensor', multiple: true, required: false, submitOnChange: true) // if (settings.contact) settings.contact = null // 'contact' has been deprecated if (contacts) input(name: 'contactStop', title: 'Stop watering when sensors are...', type: 'enum', required: (settings.contacts != null), options: ['open', 'closed'], defaultValue: 'open') input(name: 'toggles', title: 'Select water delay switches', type: 'capability.switch', multiple: true, required: false, submitOnChange: true) if (toggles) input(name: 'toggleStop', title: 'Stop watering when switches are...', type: 'enum', required: (settings.toggles != null), options: ['on', 'off'], defaultValue: 'off') input(name: 'contactDelay', type: 'number', title: 'Restart watering how many seconds after all contacts and switches ' + 'are reset? (minimum 10s)', defaultValue: '10', required: false) } section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_spruce_controller_250.png', title: 'Controller Sync', required: false, 'For multiple controllers only. This schedule will wait for the selected controller to finish before ' + 'starting. Do not set with a single controller!' input name: 'sync', type: 'capability.switch', title: 'Select Master Controller', description: 'Only use this setting with multiple controllers', required: false, multiple: false } } } def zonePage() { dynamicPage(name: 'zonePage', title: 'Zone setup', install: false, uninstall: false) { section('') { href(name: 'hrefWithImage', title: 'Zone configuration', page: 'zoneSettingsPage', description: "${zoneString()}", required: false, image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png') } if (zoneActive('1')){ section(''){ href(name: 'z1Page', title: "1: ${getname("1")}", required: false, page: 'zoneSetPage1', image: "${getimage("1")}", description: "${display("1")}" ) } } if (zoneActive('2')){ section(''){ href(name: 'z2Page', title: "2: ${getname("2")}", required: false, page: 'zoneSetPage2', image: "${getimage("2")}", description: "${display("2")}" ) } } if (zoneActive('3')){ section(''){ href(name: 'z3Page', title: "3: ${getname("3")}", required: false, page: 'zoneSetPage3', image: "${getimage("3")}", description: "${display("3")}" ) } } if (zoneActive('4')){ section(''){ href(name: 'z4Page', title: "4: ${getname("4")}", required: false, page: 'zoneSetPage4', image: "${getimage("4")}", description: "${display("4")}" ) } } if (zoneActive('5')){ section(''){ href(name: 'z5Page', title: "5: ${getname("5")}", required: false, page: 'zoneSetPage5', image: "${getimage("5")}", description: "${display("5")}" ) } } if (zoneActive('6')){ section(''){ href(name: 'z6Page', title: "6: ${getname("6")}", required: false, page: 'zoneSetPage6', image: "${getimage("6")}", description: "${display("6")}" ) } } if (zoneActive('7')){ section(''){ href(name: 'z7Page', title: "7: ${getname("7")}", required: false, page: 'zoneSetPage7', image: "${getimage("7")}", description: "${display("7")}" ) } } if (zoneActive('8')){ section(''){ href(name: 'z8Page', title: "8: ${getname("8")}", required: false, page: 'zoneSetPage8', image: "${getimage("8")}", description: "${display("8")}" ) } } if (zoneActive('9')){ section(''){ href(name: 'z9Page', title: "9: ${getname("9")}", required: false, page: 'zoneSetPage9', image: "${getimage("9")}", description: "${display("9")}" ) } } if (zoneActive('10')){ section(''){ href(name: 'z10Page', title: "10: ${getname("10")}", required: false, page: 'zoneSetPage10', image: "${getimage("10")}", description: "${display("10")}" ) } } if (zoneActive('11')){ section(''){ href(name: 'z11Page', title: "11: ${getname("11")}", required: false, page: 'zoneSetPage11', image: "${getimage("11")}", description: "${display("11")}" ) } } if (zoneActive('12')){ section(''){ href(name: 'z12Page', title: "12: ${getname("12")}", required: false, page: 'zoneSetPage12', image: "${getimage("12")}", description: "${display("12")}" ) } } if (zoneActive('13')){ section(''){ href(name: 'z13Page', title: "13: ${getname("13")}", required: false, page: 'zoneSetPage13', image: "${getimage("13")}", description: "${display("13")}" ) } } if (zoneActive('14')){ section(''){ href(name: 'z14Page', title: "14: ${getname("14")}", required: false, page: 'zoneSetPage14', image: "${getimage("14")}", description: "${display("14")}" ) } } if (zoneActive('15')){ section(''){ href(name: 'z15Page', title: "15: ${getname("15")}", required: false, page: 'zoneSetPage15', image: "${getimage("15")}", description: "${display("15")}" ) } } if (zoneActive('16')){ section(''){ href(name: 'z16Page', title: "16: ${getname("16")}", required: false, page: 'zoneSetPage16', image: "${getimage("16")}", description: "${display("16")}" ) } } } } // Verify whether a zone is active /*//Code for fresh install private boolean zoneActive(String zoneStr){ if (!zoneNumber) return false if (zoneNumber.contains(zoneStr)) return true // don't display zones that are not selected return false } */ //code change for ST update file -> change input to zoneNumberEnum private boolean zoneActive(z){ if (!zoneNumberEnum && zoneNumber && zoneNumber >= z.toInteger()) return true else if (!zoneNumberEnum && zoneNumber && zoneNumber != z.toInteger()) return false else if (zoneNumberEnum && zoneNumberEnum.contains(z)) return true return false } private String zoneString() { String numberString = 'Add zones to setup' if (zoneNumber) numberString = "Zones enabled: ${zoneNumber}" if (learn) numberString = "${numberString}\nSensor mode: Adaptive" else numberString = "${numberString}\nSensor mode: Delay" return numberString } def zoneSettingsPage() { dynamicPage(name: 'zoneSettingsPage', title: 'Zone Configuration') { section(''){ //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) 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']] 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' paragraph image: 'http://www.plaidsystems.com/smartthings/st_sensor_200_r.png', title: 'Moisture sensor adapt mode', 'Adaptive mode enabled: Watering times will be adjusted based on the assigned moisture sensor.\n\nAdaptive mode ' + 'disabled (Delay): Zones with moisture sensors will water on any available days when the low moisture setpoint has ' + 'been reached.' input 'learn', 'bool', title: 'Enable Adaptive Moisture Control (with moisture sensors)', metadata: [values: ['true', 'false']] } } } def zoneSetPage() { dynamicPage(name: 'zoneSetPage', title: "Zone ${state.app} Setup") { section(''){ paragraph image: "http://www.plaidsystems.com/smartthings/st_${state.app}.png", title: 'Current Settings', "${display("${state.app}")}" } section(''){ input "name${state.app}", 'text', title: 'Zone name?', required: false, defaultValue: "Zone ${state.app}" } section(''){ href(name: 'tosprinklerSetPage', title: "Sprinkler type: ${setString('zone')}", required: false, page: 'sprinklerSetPage', image: "${getimage("${settings."zone${state.app}"}")}", //description: "Set sprinkler nozzle type or turn zone off") description: 'Sprinkler type descriptions') 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']] } section(''){ href(name: 'toplantSetPage', title: "Landscape Select: ${setString('plant')}", required: false, page: 'plantSetPage', image: "${getimage("${settings["plant${state.app}"]}")}", //description: "Set landscape type") description: 'Landscape type descriptions') input "plant${state.app}", 'enum', title: 'Landscape', multiple: false, required: false, submitOnChange: true, metadata: [values: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']] } section(''){ href(name: 'tooptionSetPage', title: "Options: ${setString('option')}", required: false, page: 'optionSetPage', image: "${getimage("${settings["option${state.app}"]}")}", //description: "Set watering options") description: 'Watering option descriptions') 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']] } section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_sensor_200_r.png', title: 'Moisture sensor settings', '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' input "sensor${state.app}", 'capability.relativeHumidityMeasurement', title: 'Select moisture sensor?', required: false, multiple: false input "sensorSp${state.app}", 'number', title: "Minimum moisture sensor target value, Setpoint: ${getDrySp(state.app)}", required: false } section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png', title: 'Optional: Enter total watering time per week', 'This value will replace the calculated time from other settings' input "minWeek${state.app}", 'number', title: 'Minimum water time per week.\nDefault: 0 = autoadjust', description: 'minutes per week', required: false 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 } } } private String setString(String type) { switch (type) { case 'zone': if (settings."zone${state.app}") return settings."zone${state.app}" else return 'Not Set' break case 'plant': if (settings."plant${state.app}") return settings."plant${state.app}" else return 'Not Set' break case 'option': if (settings."option${state.app}") return settings."option${state.app}" else return 'Not Set' break default: return '????' } } def plantSetPage() { dynamicPage(name: 'plantSetPage', title: "${settings["name${state.app}"]} Landscape Select") { section(''){ paragraph image: 'http://www.plaidsystems.com/img/st_${state.app}.png', title: "${settings["name${state.app}"]}", "Current settings ${display("${state.app}")}" //input "plant${state.app}", "enum", title: "Landscape", multiple: false, required: false, submitOnChange: true, metadata: [values: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']] } section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png', title: 'Lawn', 'Select Lawn for typical grass applications' paragraph image: 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png', title: 'Garden', 'Select Garden for vegetable gardens' paragraph image: 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png', title: 'Flowers', 'Select Flowers for beds with smaller seasonal plants' paragraph image: 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png', title: 'Shrubs', 'Select Shrubs for beds with larger established plants' paragraph image: 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png', title: 'Trees', 'Select Trees for deep rooted areas without other plants' paragraph image: 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png', title: 'Xeriscape', 'Reduces water for native or drought tolorent plants' paragraph image: 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png', title: 'New Plants', 'Increases watering time per week and reduces automatic adjustments to help establish new plants. No weekly seasonal adjustment and moisture setpoint set to 40.' } } } def sprinklerSetPage(){ dynamicPage(name: 'sprinklerSetPage', title: "${settings["name${state.app}"]} Sprinkler Select") { section(''){ paragraph image: "http://www.plaidsystems.com/img/st_${state.app}.png", title: "${settings["name${state.app}"]}", "Current settings ${display("${state.app}")}" //input "zone${state.app}", "enum", title: "Sprinkler Type", multiple: false, required: false, defaultValue: 'Off', metadata: [values: ['Off', 'Spray', 'Rotor', 'Drip', 'Master Valve', 'Pump']] } section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png', title: 'Spray', '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.' paragraph image: 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png', title: 'Rotor', '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.' paragraph image: 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png', title: 'Drip', 'Drip lines or low flow emitters water slowely to minimize evaporation, because they are low flow, they require longer watering periods.' paragraph image: 'http://www.plaidsystems.com/smartthings/st_master_225_r.png', title: 'Master', 'Master valves will open before watering begins. Set the delay between master opening and watering in delay settings.' paragraph image: 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png', title: 'Pump', '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.' } } } def optionSetPage(){ dynamicPage(name: 'optionSetPage', title: "${settings["name${state.app}"]} Options") { section(''){ paragraph image: "http://www.plaidsystems.com/img/st_${state.app}.png", title: "${settings["name${state.app}"]}", "Current settings ${display("${state.app}")}" //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']] } section(''){ paragraph image: 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png', title: 'Slope', 'Slope sets the sprinklers to cycle 3x, each with a short duration to minimize runoff' paragraph image: 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png', title: 'Sand', 'Sandy soil drains quickly and requires more frequent but shorter intervals of water' paragraph image: 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png', title: 'Clay', 'Clay sets the sprinklers to cycle 2x, each with a short duration to maximize absorption' paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png', title: 'No Cycle', 'The sprinklers will run for 1 long duration' paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png', title: 'Cycle 2x', 'Cycle 2x will break the water period up into 2 shorter cycles to help minimize runoff and maximize adsorption' paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png', title: 'Cycle 3x', 'Cycle 3x will break the water period up into 3 shorter cycles to help minimize runoff and maximize adsorption' } } } def setPage(i){ if (i) state.app = i return state.app } private String getaZoneSummary(int zone){ if (!settings."zone${zone}" || (settings."zone${zone}" == 'Off')) return "${zone}: Off" String daysString = '' int tpw = initTPW(zone) int dpw = initDPW(zone) int runTime = calcRunTime(tpw, dpw) if ( !learn && (settings."sensor${zone}")) { daysString = 'if Moisture is low on: ' dpw = daysAvailable() } if (days && (days.contains('Even') || days.contains('Odd'))) { if (dpw == 1) daysString = 'Every 8 days' if (dpw == 2) daysString = 'Every 4 days' if (dpw == 4) daysString = 'Every 2 days' if (days.contains('Even') && days.contains('Odd')) daysString = 'any day' } else { def int[] dpwMap = [0,0,0,0,0,0,0] dpwMap = getDPWDays(dpw) daysString += getRunDays(dpwMap) } return "${zone}: ${runTime} min, ${daysString}" } private String getZoneSummary(){ String summary = '' if (learn) summary = 'Moisture Learning enabled' else summary = 'Moisture Learning disabled' int zone = 1 createDPWMap() while(zone <= 16) { if (nozzle(zone) == 4) summary = "${summary}\n${zone}: ${settings."zone${zone}"}" else if ( (initDPW(zone) != 0) && zoneActive(zone.toString())) summary = "${summary}\n${getaZoneSummary(zone)}" zone++ } if (summary) return summary else return zoneString() //"Setup all 16 zones" } private String display(String i){ //log.trace "display(${i})" String displayString = '' int tpw = initTPW(i.toInteger()) int dpw = initDPW(i.toInteger()) int runTime = calcRunTime(tpw, dpw) if (settings."zone${i}") displayString += settings."zone${i}" + ' : ' if (settings."plant${i}") displayString += settings."plant${i}" + ' : ' if (settings."option${i}") displayString += settings."option${i}" + ' : ' int j = i.toInteger() if (settings."sensor${i}") { displayString += settings."sensor${i}" displayString += "=${getDrySp(j)}% : " } if ((runTime != 0) && (dpw != 0)) displayString = "${displayString}${runTime} minutes, ${dpw} days per week" return displayString } private String getimage(String image){ String imageStr = image if (image.isNumber()) { String zoneStr = settings."zone${image}" if (zoneStr) { if (zoneStr == 'Off') return 'http://www.plaidsystems.com/smartthings/off2.png' if (zoneStr == 'Master Valve') return 'http://www.plaidsystems.com/smartthings/master.png' if (zoneStr == 'Pump') return 'http://www.plaidsystems.com/smartthings/pump.png' if (settings."plant${image}") imageStr = settings."plant${image}" // default assume asking for the plant image } } // OK, lookup the requested image switch (imageStr) { case "null": case null: return 'http://www.plaidsystems.com/smartthings/off2.png' case 'Off': return 'http://www.plaidsystems.com/smartthings/off2.png' case 'Lawn': return 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png' case 'Garden': return 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png' case 'Flowers': return 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png' case 'Shrubs': return 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png' case 'Trees': return 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png' case 'Xeriscape': return 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png' case 'New Plants': return 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png' case 'Spray': return 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png' case 'Rotor': return 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png' case 'Drip': return 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png' case 'Master Valve': return "http://www.plaidsystems.com/smartthings/st_master_225_r.png" case 'Pump': return 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png' case 'Slope': return 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png' case 'Sand': return 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png' case 'Clay': return 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png' case 'No Cycle': return 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png' case 'Cycle 2x': return 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png' case "Cycle 3x": return 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png' default: return 'http://www.plaidsystems.com/smartthings/off2.png' } } private String getname(String i) { if (settings."name${i}") return settings."name${i}" else return "Zone ${i}" } private String zipString() { if (!settings.zipcode) return "${location.zipCode}" //add pws for correct weatherunderground lookup if (!settings.zipcode.isNumber()) return "pws:${settings.zipcode}" else return settings.zipcode } //app install def installed() { state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] state.Rain = [0,0,0,0,0,0,0] state.daycount = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] atomicState.run = false // must be atomic - used to recover from crashes state.pauseTime = null atomicState.startTime = null atomicState.finishTime = null // must be atomic - used to recover from crashes log.debug "Installed with settings: ${settings}" installSchedule() } def updated() { log.debug "Updated with settings: ${settings}" installSchedule() } def installSchedule(){ if (!state.seasonAdj) state.seasonAdj = 100.0 if (!state.weekseasonAdj) state.weekseasonAdj = 0 if (state.daysAvailable != 0) state.daysAvailable = 0 // force daysAvailable to be initialized by daysAvailable() state.daysAvailable = daysAvailable() // every time we save the schedule if (atomicState.run) { attemptRecovery() // clean up if we crashed earlier } else { unsubscribe() //added back in to reset manual subscription resetEverything() } subscribe(app, appTouch) // enable the "play" button for this schedule Random rand = new Random() long randomOffset = 0 // always collect rainfall int randomSeconds = rand.nextInt(59) if (settings.isRain || settings.isSeason) schedule("${randomSeconds} 57 23 1/1 * ? *", getRainToday) // capture today's rainfall just before midnight if (settings.switches && settings.startTime && settings.enable){ randomOffset = rand.nextInt(60000) + 20000 def checktime = timeToday(settings.startTime, location.timeZone).getTime() + randomOffset //log.debug "randomOffset ${randomOffset} checktime ${checktime}" schedule(checktime, preCheck) //check weather & Days writeSettings() note('schedule', "${app.label}: Starts at ${startTimeString()}", 'i') } else { unschedule( preCheck ) note('disable', "${app.label}: Automatic watering disabled or setup is incomplete", 'a') } } // Called to find and repair after crashes - called by installSchedule() and busy() private boolean attemptRecovery() { if (!atomicState.run) { return false // only clean up if we think we are still running } else { // Hmmm...seems we were running before... def csw = settings.switches.currentSwitch def cst = settings.switches.currentStatus switch (csw) { case 'on': // looks like this schedule is running the controller at the moment if (!atomicState.startTime) { // cycleLoop cleared the startTime, but cycleOn() didn't set it log.debug "${app.label}: crashed in cycleLoop(), cycleOn() never started, cst is ${cst} - resetting" resetEverything() // reset and try again...it's probably not us running the controller, though return false } // We have a startTime... if (!atomicState.finishTime) { // started, but we don't think we're done yet..so it's probably us! runIn(15, cycleOn) // goose the cycle, just in case note('active', "${app.label}: schedule is apparently already running", 'i') return true } // hmmm...switch is on and we think we're finished...probably somebody else is running...let busy figure it out resetEverything() return false break case 'off': // switch is off - did we finish? if (atomicState.finishTime) { // off and finished, let's just reset things resetEverything() return false } if (switches.currentStatus != 'pause') { // off and not paused - probably another schedule, let's clean up resetEverything() return false } // off and not finished, and paused, we apparently crashed while paused runIn(15, cycleOn) return true break case 'programOn': // died while manual program running? case 'programWait': // looks like died previously before we got started, let's try to clean things up resetEverything() if (atomicState.finishTime) atomicState.finishTime = null if ((cst == 'active') || atomicState.startTime) { // if we announced we were in preCheck, or made it all the way to cycleOn before it crashed settings.switches.programOff() // only if we think we actually started (cycleOn() started) // probably kills manual cycles too, but we'll let that go for now } if (atomicState.startTime) atomicState.startTime = null note ('schedule', "Looks like ${app.label} crashed recently...cleaning up", c) return false break default: log.debug "attemptRecovery(): atomicState.run == true, and I've nothing left to do" return true } } } // reset everything to the initial (not running) state private def resetEverything() { if (atomicState.run) atomicState.run = false // we're not running the controller any more unsubAllBut() // release manual, switches, sync, contacts & toggles // take care not to unschedule preCheck() or getRainToday() unschedule(cycleOn) unschedule(checkRunMap) unschedule(writeCycles) unschedule(subOff) if (settings.enableManual) subscribe(settings.switches, 'switch.programOn', manualStart) } // unsubscribe from ALL events EXCEPT app.touch private def unsubAllBut() { unsubscribe(settings.switches) unsubWaterStoppers() if (settings.sync) unsubscribe(settings.sync) } // enable the "Play" button in SmartApp list def appTouch(evt) { log.debug "appTouch(): atomicState.run = ${atomicState.run}" runIn(2, preCheck) // run it off a schedule, so we can see how long it takes in the app.state } // true if one of the stoppers is in Stop state private boolean isWaterStopped() { if (settings.contacts && settings.contacts.currentContact.contains(settings.contactStop)) return true if (settings.toggles && settings.toggles.currentSwitch.contains(settings.toggleStop)) return true return false } // watch for water stoppers private def subWaterStop() { if (settings.contacts) { unsubscribe(settings.contacts) subscribe(settings.contacts, "contact.${settings.contactStop}", waterStop) } if (settings.toggles) { unsubscribe(settings.toggles) subscribe(settings.toggles, "switch.${settings.toggleStop}", waterStop) } } // watch for water starters private def subWaterStart() { if (settings.contacts) { unsubscribe(settings.contacts) def cond = (settings.contactStop == 'open') ? 'closed' : 'open' subscribe(settings.contacts, "contact.${cond}", waterStart) } if (settings.toggles) { unsubscribe(settings.toggles) def cond = (settings.toggleStop == 'on') ? 'off' : 'on' subscribe(settings.toggles, "switch.${cond}", waterStart) } } // stop watching water stoppers and starters private def unsubWaterStoppers() { if (settings.contacts) unsubscribe(settings.contacts) if (settings.toggles) unsubscribe(settings.toggles) } // which of the stoppers are in stop mode? private String getWaterStopList() { String deviceList = '' int i = 1 if (settings.contacts) { settings.contacts.each { if (it.currentContact == settings.contactStop) { if (i > 1) deviceList += ', ' deviceList = "${deviceList}${it.displayName} is ${settings.contactStop}" i++ } } } if (settings.toggles) { settings.toggles.each { if (it.currentSwitch == settings.toggleStop) { if (i > 1) deviceList += ', ' deviceList = "${deviceList}${it.displayName} is ${settings.toggleStop}" i++ } } } return deviceList } //write initial zone settings to device at install/update def writeSettings(){ if (!state.tpwMap) state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] if (!state.dpwMap) state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] if (state.setMoisture) state.setMoisture = null // not using any more if (!state.seasonAdj) state.seasonAdj = 100.0 if (!state.weekseasonAdj) state.weekseasonAdj = 0 setSeason() } //get day of week integer int getWeekDay(day) { def weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] def mapDay = [Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7] if(day && weekdays.contains(day)) { return mapDay.get(day).toInteger() } def today = new Date().format('EEEE', location.timeZone) return mapDay.get(today).toInteger() } // Get string of run days from dpwMap private String getRunDays(day1,day2,day3,day4,day5,day6,day7) { String str = '' if(day1) str += 'M' if(day2) str += 'T' if(day3) str += 'W' if(day4) str += 'Th' if(day5) str += 'F' if(day6) str += 'Sa' if(day7) str += 'Su' if(str == '') str = '0 Days/week' return str } //start manual schedule def manualStart(evt){ boolean running = attemptRecovery() // clean up if prior run crashed if (settings.enableManual && !running && (settings.switches.currentStatus != 'pause')){ if (settings.sync && ( (settings.sync.currentSwitch != 'off') || settings.sync.currentStatus == 'pause') ) { note('skipping', "${app.label}: Manual run aborted, ${settings.sync.displayName} appears to be busy", 'a') } else { def runNowMap = [] runNowMap = cycleLoop(0) if (runNowMap) { atomicState.run = true settings.switches.programWait() subscribe(settings.switches, 'switch.off', cycleOff) runIn(60, cycleOn) // start water program // note that manual DOES abide by waterStoppers (if configured) String newString = '' int tt = state.totalTime if (tt) { int hours = tt / 60 // DON'T Math.round this one int mins = tt - (hours * 60) String hourString = '' String s = '' if (hours > 1) s = 's' if (hours > 0) hourString = "${hours} hour${s} & " s = 's' if (mins == 1) s = '' newString = "run time: ${hourString}${mins} minute${s}:\n" } note('active', "${app.label}: Manual run, watering in 1 minute: ${newString}${runNowMap}", 'd') } else note('skipping', "${app.label}: Manual run failed, check configuration", 'a') } } else note('skipping', "${app.label}: Manual run aborted, ${settings.switches.displayName} appears to be busy", 'a') } //true if another schedule is running boolean busy(){ // Check if we are already running, crashed or somebody changed the schedule time while this schedule is running if (atomicState.run){ if (!attemptRecovery()) { // recovery will clean out any prior crashes and correct state of atomicState.run return false // (atomicState.run = false) } else { // don't change the current status, in case the currently running schedule is in off/paused mode note(settings.switches.currentStatus, "${app.label}: Already running, skipping additional start", 'i') return true } } // Not already running... // Moved from cycleOn() - don't even start pre-check until the other controller completes its cycle if (settings.sync) { if ((settings.sync.currentSwitch != 'off') || settings.sync.currentStatus == 'pause') { subscribe(settings.sync, 'switch.off', syncOn) note('delayed', "${app.label}: Waiting for ${settings.sync.displayName} to complete before starting", 'c') return true } } // Check that the controller isn't paused while running some other schedule def csw = settings.switches.currentSwitch def cst = settings.switches.currentStatus if ((csw == 'off') && (cst != 'pause')) { // off && !paused: controller is NOT in use log.debug "switches ${csw}, status ${cst} (1st)" resetEverything() // get back to the start state return false } if (isDay()) { // Yup, we need to run today, so wait for the other schedule to finish log.debug "switches ${csw}, status ${cst} (3rd)" resetEverything() subscribe(settings.switches, 'switch.off', busyOff) note('delayed', "${app.label}: Waiting for currently running schedule to complete before starting", 'c') return true } // Somthing is running, but we don't need to run today anyway - don't need to do busyOff() // (Probably should never get here, because preCheck() should check isDay() before calling busy() log.debug "Another schedule is running, but ${app.label} is not scheduled for today anyway" return true } def busyOff(evt){ def cst = settings.switches.currentStatus if ((settings.switches.currentSwitch == 'off') && (cst != 'pause')) { // double check that prior schedule is done unsubscribe(switches) // we don't want any more button pushes until preCheck runs Random rand = new Random() // just in case there are multiple schedules waiting on the same controller int randomSeconds = rand.nextInt(120) + 15 runIn(randomSeconds, preCheck) // no message so we don't clog the system note('active', "${app.label}: ${settings.switches} finished, starting in ${randomSeconds} seconds", 'i') } } //run check every day def preCheck() { if (!isDay()) { log.debug "preCheck() Skipping: ${app.label} is not scheduled for today" // silent - no note //if (!atomicState.run && enableManual) subscribe(switches, 'switch.programOn', manualStart) // only if we aren't running already return } if (!busy()) { atomicState.run = true // set true before doing anything, atomic in case we crash (busy() set it false if !busy) settings.switches.programWait() // take over the controller so other schedules don't mess with us runIn(45, checkRunMap) // schedule checkRunMap() before doing weather check, gives isWeather 45s to complete // because that seems to be a little more than the max that the ST platform allows unsubAllBut() // unsubscribe to everything except appTouch() subscribe(settings.switches, 'switch.off', cycleOff) // and start setting up for today's cycle def start = now() note('active', "${app.label}: Starting...", 'd') // def end = now() log.debug "preCheck note active ${end - start}ms" if (isWeather()) { // set adjustments and check if we shold skip because of rain resetEverything() // if so, clean up our subscriptions switches.programOff() // and release the controller } else { log.debug 'preCheck(): running checkRunMap in 2 seconds' //COOL! We finished before timing out, and we're supposed to water today runIn(2, checkRunMap) // jack the schedule so it runs sooner! } } } //start water program def cycleOn(){ if (atomicState.run) { // block if manually stopped during precheck which goes to cycleOff if (!isWaterStopped()) { // make sure ALL the contacts and toggles aren't paused // All clear, let's start running! subscribe(settings.switches, 'switch.off', cycleOff) subWaterStop() // subscribe to all the pause contacts and toggles resume() // send the notification AFTER we start the controller (in case note() causes us to run over our execution time limit) String newString = "${app.label}: Starting..." if (!atomicState.startTime) { atomicState.startTime = now() // if we haven't already started if (atomicState.startTime) atomicState.finishTime = null // so recovery in busy() knows we didn't finish if (state.pauseTime) state.pauseTime = null if (state.totalTime) { String finishTime = new Date(now() + (60000 * state.totalTime).toLong()).format('EE @ h:mm a', location.timeZone) newString = "${app.label}: Starting - ETC: ${finishTime}" } } else if (state.pauseTime) { // resuming after a pause def elapsedTime = Math.round((now() - state.pauseTime) / 60000) // convert ms to minutes int tt = state.totalTime + elapsedTime + 1 state.totalTime = tt // keep track of the pauses, and the 1 minute delay above String finishTime = new Date(atomicState.startTime + (60000 * tt).toLong()).format('EE @ h:mm a', location.timeZone) state.pauseTime = null newString = "${app.label}: Resuming - New ETC: ${finishTime}" } note('active', newString, 'd') } else { // Ready to run, but one of the control contacts is still open, so we wait subWaterStart() // one of them is paused, let's wait until the are all clear! note('pause', "${app.label}: Watering paused, ${getWaterStopList()}", 'c') } } } //when switch reports off, watering program is finished def cycleOff(evt){ if (atomicState.run) { def ft = new Date() atomicState.finishTime = ft // this is important to reset the schedule after failures in busy() String finishTime = ft.format('h:mm a', location.timeZone) note('finished', "${app.label}: Finished watering at ${finishTime}", 'd') } else { log.debug "${settings.switches} turned off" // is this a manual off? perhaps we should send a note? } resetEverything() // all done here, back to starting state } //run check each day at scheduled time def checkRunMap(){ //check if isWeather returned true or false before checking if (atomicState.run) { //get & set watering times for today def runNowMap = [] runNowMap = cycleLoop(1) // build the map if (runNowMap) { runIn(60, cycleOn) // start water subscribe(settings.switches, 'switch.off', cycleOff) // allow manual off before cycleOn() starts if (atomicState.startTime) atomicState.startTime = null // these were already cleared in cycleLoop() above if (state.pauseTime) state.pauseTime = null // ditto // leave atomicState.finishTime alone so that recovery in busy() knows we never started if cycleOn() doesn't clear it String newString = '' int tt = state.totalTime if (tt) { int hours = tt / 60 // DON'T Math.round this one int mins = tt - (hours * 60) String hourString = '' String s = '' if (hours > 1) s = 's' if (hours > 0) hourString = "${hours} hour${s} & " s = 's' if (mins == 1) s = '' newString = "run time: ${hourString}${mins} minute${s}:\n" } note('active', "${app.label}: Watering in 1 minute, ${newString}${runNowMap}", 'd') } else { unsubscribe(settings.switches) unsubWaterStoppers() switches.programOff() if (enableManual) subscribe(settings.switches, 'switch.programOn', manualStart) note('skipping', "${app.label}: No watering today", 'd') if (atomicState.run) atomicState.run = false // do this last, so that the above note gets sent to the controller } } else { log.debug 'checkRunMap(): atomicState.run = false' // isWeather cancelled us out before we got started } } //get todays schedule def cycleLoop(int i) { boolean isDebug = false if (isDebug) log.debug "cycleLoop(${i})" int zone = 1 int dpw = 0 int tpw = 0 int cyc = 0 int rtime = 0 def timeMap = [:] def pumpMap = "" def runNowMap = "" String soilString = '' int totalCycles = 0 int totalTime = 0 if (atomicState.startTime) atomicState.startTime = null // haven't started yet while(zone <= 16) { rtime = 0 def setZ = settings."zone${zone}" if ((setZ && (setZ != 'Off')) && (nozzle(zone) != 4) && zoneActive(zone.toString())) { // First check if we run this zone today, use either dpwMap or even/odd date dpw = getDPW(zone) int runToday = 0 // if manual, or every day allowed, or zone uses a sensor, then we assume we can today // - preCheck() has already verified that today isDay() if ((i == 0) || (state.daysAvailable == 7) || (settings."sensor${zone}")) { runToday = 1 } else { dpw = getDPW(zone) // figure out if we need to run (if we don't already know we do) if (settings.days && (settings.days.contains('Even') || settings.days.contains('Odd'))) { def daynum = new Date().format('dd', location.timeZone) int dayint = Integer.parseInt(daynum) if (settings.days.contains('Odd') && (((dayint +1) % Math.round(31 / (dpw * 4))) == 0)) runToday = 1 else if (settings.days.contains('Even') && ((dayint % Math.round(31 / (dpw * 4))) == 0)) runToday = 1 } else { int weekDay = getWeekDay()-1 def dpwMap = getDPWDays(dpw) runToday = dpwMap[weekDay] //1 or 0 if (isDebug) log.debug "Zone: ${zone} dpw: ${dpw} weekDay: ${weekDay} dpwMap: ${dpwMap} runToday: ${runToday}" } } // OK, we're supposed to run (or at least adjust the sensors) if (runToday == 1) { def soil if (i == 0) soil = moisture(0) // manual else soil = moisture(zone) // moisture check soilString = "${soilString}${soil[1]}" // Run this zone if soil moisture needed if ( soil[0] == 1 ) { cyc = cycles(zone) tpw = getTPW(zone) dpw = getDPW(zone) // moisture() may have changed DPW rtime = calcRunTime(tpw, dpw) //daily weather adjust if no sensor if(settings.isSeason && (!settings.learn || !settings."sensor${zone}")) { rtime = Math.round(((rtime / cyc) * (state.seasonAdj / 100.0)) + 0.4) } else { rtime = Math.round((rtime / cyc) + 0.4) // let moisture handle the seasonAdjust for Adaptive (learn) zones } totalCycles += cyc totalTime += (rtime * cyc) runNowMap += "${settings."name${zone}"}: ${cyc} x ${rtime} min\n" if (isDebug) log.debug "Zone ${zone} Map: ${cyc} x ${rtime} min - totalTime: ${totalTime}" } } } if (nozzle(zone) == 4) pumpMap += "${settings."name${zone}"}: ${settings."zone${zone}"} on\n" timeMap."${zone+1}" = "${rtime}" zone++ } if (soilString) { String seasonStr = '' String plus = '' float sa = state.seasonAdj if (settings.isSeason && (sa != 100.0) && (sa != 0.0)) { float sadj = sa - 100.0 if (sadj > 0.0) plus = '+' //display once in cycleLoop() int iadj = Math.round(sadj) if (iadj != 0) seasonStr = "Adjusting ${plus}${iadj}% for weather forecast\n" } note('moisture', "${app.label} Sensor status:\n${seasonStr}${soilString}" /* + seasonStr + soilString */,'m') } if (!runNowMap) { return runNowMap // nothing to run today } //send settings to Spruce Controller switches.settingsMap(timeMap,4002) runIn(30, writeCycles) // meanwhile, calculate our total run time int pDelay = 0 if (settings.pumpDelay && settings.pumpDelay.isNumber()) pDelay = settings.pumpDelay.toInteger() totalTime += Math.round(((pDelay * (totalCycles-1)) / 60.0)) // add in the pump startup and inter-zone delays state.totalTime = totalTime if (state.pauseTime) state.pauseTime = null // and we haven't paused yet // but let cycleOn() reset finishTime return (runNowMap + pumpMap) } //send cycle settings def writeCycles(){ //log.trace "writeCycles()" def cyclesMap = [:] //add pumpdelay @ 1 cyclesMap."1" = pumpDelayString() int zone = 1 int cycle = 0 while(zone <= 17) { if(nozzle(zone) == 4) cycle = 4 else cycle = cycles(zone) //offset by 1, due to pumpdelay @ 1 cyclesMap."${zone+1}" = "${cycle}" zone++ } switches.settingsMap(cyclesMap, 4001) } def resume(){ log.debug 'resume()' settings.switches.zon() } def syncOn(evt){ // double check that the switch is actually finished and not just paused if ((settings.sync.currentSwitch == 'off') && (settings.sync.currentStatus != 'pause')) { resetEverything() // back to our known state Random rand = new Random() // just in case there are multiple schedules waiting on the same controller int randomSeconds = rand.nextInt(120) + 15 runIn(randomSeconds, preCheck) // no message so we don't clog the system note('schedule', "${app.label}: ${settings.sync} finished, starting in ${randomSeconds} seconds", 'c') } // else, it is just pausing...keep waiting for the next "off" } // handle start of pause session def waterStop(evt){ log.debug "waterStop: ${evt.displayName}" unschedule(cycleOn) // in case we got stopped again before cycleOn starts from the restart unsubscribe(settings.switches) subWaterStart() if (!state.pauseTime) { // only need to do this for the first event if multiple contacts state.pauseTime = now() String cond = evt.value switch (cond) { case 'open': cond = 'opened' break case 'on': cond = 'switched on' break case 'off': cond = 'switched off' break //case 'closed': // cond = 'closed' // break case null: cond = '????' break default: break } note('pause', "${app.label}: Watering paused - ${evt.displayName} ${cond}", 'c') // set to Paused } if (settings.switches.currentSwitch != 'off') { runIn(30, subOff) settings.switches.off() // stop the water } else subscribe(settings.switches, 'switch.off', cycleOff) } // This is a hack to work around the delay in response from the controller to the above programOff command... // We frequently see the off notification coming a long time after the command is issued, so we try to catch that so that // we don't prematurely exit the cycle. def subOff() { subscribe(settings.switches, 'switch.off', offPauseCheck) } def offPauseCheck( evt ) { unsubscribe(settings.switches) subscribe(settings.switches, 'switch.off', cycleOff) if (/*(switches.currentSwitch != 'off') && */ (settings.switches.currentStatus != 'pause')) { // eat the first off while paused cycleOff(evt) } } // handle end of pause session def waterStart(evt){ if (!isWaterStopped()){ // only if ALL of the selected contacts are not open def cDelay = 10 if (settings.contactDelay > 10) cDelay = settings.contactDelay runIn(cDelay, cycleOn) unsubscribe(settings.switches) subWaterStop() // allow stopping again while we wait for cycleOn to start log.debug "waterStart(): enabling device is ${evt.device} ${evt.value}" String cond = evt.value switch (cond) { case 'open': cond = 'opened' break case 'on': cond = 'switched on' break case 'off': cond = 'switched off' break //case 'closed': // cond = 'closed' // break case null: cond = '????' break default: break } // let cycleOn() change the status to Active - keep us paused until then note('pause', "${app.label}: ${evt.displayName} ${cond}, watering in ${cDelay} seconds", 'c') } else { log.debug "waterStart(): one down - ${evt.displayName}" } } //Initialize Days per week, based on TPW, perDay and daysAvailable settings int initDPW(int zone){ //log.debug "initDPW(${zone})" if(!state.dpwMap) state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] int tpw = getTPW(zone) // was getTPW -does not update times in scheduler without initTPW int dpw = 0 if(tpw > 0) { float perDay = 20.0 if(settings."perDay${zone}") perDay = settings."perDay${zone}".toFloat() dpw = Math.round(tpw.toFloat() / perDay) if(dpw <= 1) dpw = 1 // 3 days per week not allowed for even or odd day selection if(dpw == 3 && days && (days.contains('Even') || days.contains('Odd')) && !(days.contains('Even') && days.contains('Odd'))) if((tpw.toFloat() / perDay) < 3.0) dpw = 2 else dpw = 4 int daycheck = daysAvailable() // initialize & optimize daysAvailable if (daycheck < dpw) dpw = daycheck } state.dpwMap[zone-1] = dpw return dpw } // Get current days per week value, calls init if not defined int getDPW(int zone) { if (state.dpwMap) return state.dpwMap[zone-1] else return initDPW(zone) } //Initialize Time per Week int initTPW(int zone) { //log.trace "initTPW(${zone})" if (!state.tpwMap) state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] int n = nozzle(zone) def zn = settings."zone${zone}" if (!zn || (zn == 'Off') || (n == 0) || (n == 4) || (plant(zone) == 0) || !zoneActive(zone.toString())) return 0 // apply gain adjustment float gainAdjust = 100.0 if (settings.gain && settings.gain != 0) gainAdjust += settings.gain // apply seasonal adjustment if enabled and not set to new plants float seasonAdjust = 100.0 def wsa = state.weekseasonAdj if (wsa && isSeason && (settings."plant${zone}" != 'New Plants')) seasonAdjust = wsa int tpw = 0 // Use learned, previous tpw if it is available if ( settings."sensor${zone}" ) { seasonAdjust = 100.0 // no weekly seasonAdjust if this zone uses a sensor if(state.tpwMap && settings.learn) tpw = state.tpwMap[zone-1] } // set user-specified minimum time with seasonal adjust int minWeek = 0 def mw = settings."minWeek${zone}" if (mw) minWeek = mw.toInteger() if (minWeek != 0) { tpw = Math.round(minWeek * (seasonAdjust / 100.0)) } else if (!tpw || (tpw == 0)) { // use calculated tpw tpw = Math.round((plant(zone) * nozzle(zone) * (gainAdjust / 100.0) * (seasonAdjust / 100.0))) } state.tpwMap[zone-1] = tpw return tpw } // Get the current time per week, calls init if not defined int getTPW(int zone) { if (state.tpwMap) return state.tpwMap[zone-1] else return initTPW(zone) } // Calculate daily run time based on tpw and dpw int calcRunTime(int tpw, int dpw) { int duration = 0 if ((tpw > 0) && (dpw > 0)) duration = Math.round(tpw.toFloat() / dpw.toFloat()) return duration } // Check the moisture level of a zone returning dry (1) or wet (0) and adjust tpw if overly dry/wet def moisture(int i) { boolean isDebug = false if (isDebug) log.debug "moisture(${i})" def endMsecs = 0 // No Sensor on this zone or manual start skips moisture checking altogether if ((i == 0) || !settings."sensor${i}") { return [1,''] } // Ensure that the sensor has reported within last 48 hours int spHum = getDrySp(i) int hours = 48 def yesterday = new Date(now() - (/* 1000 * 60 * 60 */ 3600000 * hours).toLong()) float latestHum = settings."sensor${i}".latestValue('humidity').toFloat() // state = 29, value = 29.13 def lastHumDate = settings."sensor${i}".latestState('humidity').date if (lastHumDate < yesterday) { note('warning', "${app.label}: Please check sensor ${settings."sensor${i}"}, no humidity reports in the last ${hours} hours", 'a') if (latestHum < spHum) latestHum = spHum - 1.0 // amke sure we water and do seasonal adjustments, but not tpw adjustments else latestHum = spHum + 0.99 // make sure we don't water, do seasonal adjustments, but not tpw adjustments } if (!settings.learn) { // in Delay mode, only looks at target moisture level, doesn't try to adjust tpw // (Temporary) seasonal adjustment WILL be applied in cycleLoop(), as if we didn't have a sensor if (latestHum <= spHum.toFloat()) { //dry soil return [1,"${settings."name${i}"}, Watering: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"] } else { //wet soil return [0,"${settings."name${i}"}, Skipping: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"] } } //in Adaptive mode int tpw = getTPW(i) int dpw = getDPW(i) int cpd = cycles(i) if (isDebug) log.debug "moisture(${i}): tpw: ${tpw}, dpw: ${dpw}, cycles: ${cpd} (before adjustment)" float diffHum = 0.0 if (latestHum > 0.0) diffHum = (spHum - latestHum) / 100.0 else { diffHum = 0.02 // Safety valve in case sensor is reporting 0% humidity (e.g., somebody pulled it out of the ground or flower pot) note('warning', "${app.label}: Please check sensor ${settings."sensor${i}"}, it is currently reading 0%", 'a') } int daysA = state.daysAvailable int minimum = cpd * dpw // minimum of 1 minute per scheduled days per week (note - can be 1*1=1) if (minimum < daysA) minimum = daysA // but at least 1 minute per available day int tpwAdjust = 0 if (diffHum > 0.01) { // only adjust tpw if more than 1% of target SP tpwAdjust = Math.round(((tpw * diffHum) + 0.5) * dpw * cpd) // Compute adjustment as a function of the current tpw float adjFactor = 2.0 / daysA // Limit adjustments to 200% per week - spread over available days if (tpwAdjust > (tpw * adjFactor)) tpwAdjust = Math.round((tpw * adjFactor) + 0.5) // limit fast rise if (tpwAdjust < minimum) tpwAdjust = minimum // but we need to move at least 1 minute per cycle per day to actually increase the watering time } else if (diffHum < -0.01) { if (diffHum < -0.05) diffHum = -0.05 // try not to over-compensate for a heavy rainstorm... tpwAdjust = Math.round(((tpw * diffHum) - 0.5) * dpw * cpd) float adjFactor = -0.6667 / daysA // Limit adjustments to 66% per week if (tpwAdjust < (tpw * adjFactor)) tpwAdjust = Math.round((tpw * adjFactor) - 0.5) // limit slow decay 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 } int seasonAdjust = 0 if (isSeason) { float sa = state.seasonAdj if ((sa != 100.0) && (sa != 0.0)) { float sadj = sa - 100.0 if (sa > 0.0) seasonAdjust = Math.round(((sadj / 100.0) * tpw) + 0.5) else seasonAdjust = Math.round(((sadj / 100.0) * tpw) - 0.5) } } if (isDebug) log.debug "moisture(${i}): diffHum: ${diffHum}, tpwAdjust: ${tpwAdjust} seasonAdjust: ${seasonAdjust}" // Now, adjust the tpw. // With seasonal adjustments enabled, tpw can go up or down independent of the difference in the sensor vs SP int newTPW = tpw + tpwAdjust + seasonAdjust int perDay = 20 def perD = settings."perDay${i}" if (perD) perDay = perD.toInteger() if (perDay == 0) perDay = daysA * cpd // at least 1 minute per cycle per available day if (newTPW < perDay) newTPW = perDay // make sure we have always have enough for 1 day of minimum water int adjusted = 0 if ((tpwAdjust + seasonAdjust) > 0) { // needs more water int maxTPW = daysA * 120 // arbitrary maximum of 2 hours per available watering day per week if (newTPW > maxTPW) newTPW = maxTPW // initDPW() below may spread this across more days 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') if (state.tpwMap[i-1] != newTPW) { // are we changing the tpw? state.tpwMap[i-1] = newTPW dpw = initDPW(i) // need to recalculate days per week since tpw changed - initDPW() stores the value into dpwMap adjusted = newTPW - tpw // so that the adjustment note is accurate } } else if ((tpwAdjust + seasonAdjust) < 0) { // Needs less water // Find the minimum tpw minimum = cpd * daysA // at least 1 minute per cycle per available day int minLimit = 0 def minL = settings."minWeek${i}" if (minL) minLimit = minL.toInteger() // unless otherwise specified in configuration if (minLimit > 0) { if (newTPW < minLimit) newTPW = minLimit // use configured minutes per week as the minimum } else if (newTPW < minimum) { newTPW = minimum // else at least 1 minute per cycle per available day note('warning', "${app.label}: Please check ${settings."sensor${i}"}, ${settings."name${i}"} time per week is very low: ${newTPW} mins/week",'a') } if (state.tpwMap[i-1] != newTPW) { // are we changing the tpw? state.tpwMap[i-1] = newTPW // store the new tpw dpw = initDPW(i) // may need to reclac days per week - initDPW() now stores the value into state.dpwMap - avoid doing that twice adjusted = newTPW - tpw // so that the adjustment note is accurate } } // else no adjustments, or adjustments cancelled each other out. String moistureSum = '' String adjStr = '' String plus = '' if (adjusted > 0) plus = '+' if (adjusted != 0) adjStr = ", ${plus}${adjusted} min" if (Math.abs(adjusted) > 1) adjStr = "${adjStr}s" if (diffHum >= 0.0) { // water only if ground is drier than SP moistureSum = "> ${settings."name${i}"}, Water: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n" return [1, moistureSum] } else { // not watering moistureSum = "> ${settings."name${i}"}, Skip: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n" return [0, moistureSum] } return [0, moistureSum] } //get moisture SP int getDrySp(int i){ if (settings."sensorSp${i}") return settings."sensorSp${i}".toInteger() // configured SP if (settings."plant${i}" == 'New Plants') return 40 // New Plants get special care switch (settings."option${i}") { // else, defaults based off of soil type case 'Sand': return 22 case 'Clay': return 38 default: return 28 } } //notifications to device, pushed if requested def note(String statStr, String msg, String msgType) { // send to debug first (near-zero cost) log.debug "${statStr}: ${msg}" // notify user second (small cost) boolean notifyController = true if(settings.notify || settings.logAll) { String spruceMsg = "Spruce ${msg}" switch(msgType) { case 'd': if (settings.notify && settings.notify.contains('Daily')) { // always log the daily events to the controller sendIt(spruceMsg) } else if (settings.logAll) { sendNotificationEvent(spruceMsg) } break case 'c': if (settings.notify && settings.notify.contains('Delays')) { sendIt(spruceMsg) } else if (settings.logAll) { sendNotificationEvent(spruceMsg) } break case 'i': if (settings.notify && settings.notify.contains('Events')) { sendIt(spruceMsg) //notifyController = false // no need to notify controller unless we don't notify the user } else if (settings.logAll) { sendNotificationEvent(spruceMsg) } break case 'f': notifyController = false // no need to notify the controller, ever if (settings.notify && settings.notify.contains('Weather')) { sendIt(spruceMsg) } else if (settings.logAll) { sendNotificationEvent(spruceMsg) } break case 'a': notifyController = false // no need to notify the controller, ever if (settings.notify && settings.notify.contains('Warnings')) { sendIt(spruceMsg) } else sendNotificationEvent(spruceMsg) // Special case - make sure this goes into the Hello Home log, if not notifying break case 'm': if (settings.notify && settings.notify.contains('Moisture')) { sendIt(spruceMsg) //notifyController = false // no need to notify controller unless we don't notify the user } else if (settings.logAll) { sendNotificationEvent(spruceMsg) } break default: break } } // finally, send to controller DTH, to change the state and to log important stuff in the event log if (notifyController) { // do we really need to send these to the controller? // only send status updates to the controller if WE are running, or nobody else is if (atomicState.run || ((settings.switches.currentSwitch == 'off') && (settings.switches.currentStatus != 'pause'))) { settings.switches.notify(statStr, msg) } else { // we aren't running, so we don't want to change the status of the controller // send the event using the current status of the switch, so we don't change it //log.debug "note - direct sendEvent()" settings.switches.notify(settings.switches.currentStatus, msg) } } } def sendIt(String msg) { if (location.contactBookEnabled && settings.recipients) { sendNotificationToContacts(msg, settings.recipients, [event: true]) } else { sendPush( msg ) } } //days available int daysAvailable(){ // Calculate days available for watering and save in state variable for future use def daysA = state.daysAvailable if (daysA && (daysA > 0)) { // state.daysAvailable has already calculated and stored in state.daysAvailable return daysA } if (!settings.days) { // settings.days = "" --> every day is available state.daysAvailable = 7 return 7 // every day is allowed } int dayCount = 0 // settings.days specified, need to calculate state.davsAvailable (once) if (settings.days.contains('Even') || settings.days.contains('Odd')) { dayCount = 4 if(settings.days.contains('Even') && settings.days.contains('Odd')) dayCount = 7 } else { if (settings.days.contains('Monday')) dayCount += 1 if (settings.days.contains('Tuesday')) dayCount += 1 if (settings.days.contains('Wednesday')) dayCount += 1 if (settings.days.contains('Thursday')) dayCount += 1 if (settings.days.contains('Friday')) dayCount += 1 if (settings.days.contains('Saturday')) dayCount += 1 if (settings.days.contains('Sunday')) dayCount += 1 } state.daysAvailable = dayCount return dayCount } //zone: ['Off', 'Spray', 'rotor', 'Drip', 'Master Valve', 'Pump'] int nozzle(int i){ String getT = settings."zone${i}" if (!getT) return 0 switch(getT) { case 'Spray': return 1 case 'Rotor': return 1.4 case 'Drip': return 2.4 case 'Master Valve': return 4 case 'Pump': return 4 default: return 0 } } //plant: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants'] int plant(int i){ String getP = settings."plant${i}" if(!getP) return 0 switch(getP) { case 'Lawn': return 60 case 'Garden': return 50 case 'Flowers': return 40 case 'Shrubs': return 30 case 'Trees': return 20 case 'Xeriscape': return 30 case 'New Plants': return 80 default: return 0 } } //option: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x'] int cycles(int i){ String getC = settings."option${i}" if(!getC) return 2 switch(getC) { case 'Slope': return 3 case 'Sand': return 1 case 'Clay': return 2 case 'No Cycle': return 1 case 'Cycle 2x': return 2 case 'Cycle 3x': return 3 default: return 2 } } //check if day is allowed boolean isDay() { if (daysAvailable() == 7) return true // every day is allowed def daynow = new Date() String today = daynow.format('EEEE', location.timeZone) if (settings.days.contains(today)) return true def daynum = daynow.format('dd', location.timeZone) int dayint = Integer.parseInt(daynum) if (settings.days.contains('Even') && (dayint % 2 == 0)) return true if (settings.days.contains('Odd') && (dayint % 2 != 0)) return true return false } //set season adjustment & remove season adjustment def setSeason() { boolean isDebug = false if (isDebug) log.debug 'setSeason()' int zone = 1 while(zone <= 16) { if ( !settings.learn || !settings."sensor${zone}" || state.tpwMap[zone-1] == 0) { int tpw = initTPW(zone) // now updates state.tpwMap int dpw = initDPW(zone) // now updates state.dpwMap if (isDebug) { if (!settings.learn && (tpw != 0) && (state.weekseasonAdj != 0)) { log.debug "Zone ${zone}: seasonally adjusted by ${state.weekseasonAdj-100}% to ${tpw}" } } } zone++ } } //capture today's total rainfall - scheduled for just before midnight each day def getRainToday() { def wzipcode = zipString() Map wdata = getWeatherFeature('conditions', wzipcode) if (!wdata) { note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null", 'a') } else { if (!wdata.response || wdata.response.containsKey('error')) { log.debug wdata.response note('warning', "${app.label}: Please check Zipcode/PWS setting, error:\n${wdata.response.error.type}: ${wdata.response.error.description}" , 'a') } else { float TRain = 0.0 if (wdata.current_observation.precip_today_in.isNumber()) { // WU can return "t" for "Trace" - we'll assume that means 0.0 TRain = wdata.current_observation.precip_today_in.toFloat() if (TRain > 25.0) TRain = 25.0 else if (TRain < 0.0) TRain = 0.0 // WU sometimes returns -999 for "estimated" locations log.debug "getRainToday(): ${wdata.current_observation.precip_today_in} / ${TRain}" } int day = getWeekDay() // what day is it today? if (day == 7) day = 0 // adjust: state.Rain order is Su,Mo,Tu,We,Th,Fr,Sa state.Rain[day] = TRain as Float // store today's total rainfall } } } //check weather, set seasonal adjustment factors, skip today if rainy boolean isWeather(){ def startMsecs = 0 def endMsecs = 0 boolean isDebug = false if (isDebug) log.debug 'isWeather()' if (!settings.isRain && !settings.isSeason) return false // no need to do any of this String wzipcode = zipString() if (isDebug) log.debug "isWeather(): ${wzipcode}" // get only the data we need // Moved geolookup to installSchedule() String featureString = 'forecast/conditions' if (settings.isSeason) featureString = "${featureString}/astronomy" if (isDebug) startMsecs= now() Map wdata = getWeatherFeature(featureString, wzipcode) if (isDebug) { endMsecs = now() log.debug "isWeather() getWeatherFeature elapsed time: ${endMsecs - startMsecs}ms" } if (wdata && wdata.response) { if (isDebug) log.debug wdata.response if (wdata.response.containsKey('error')) { if (wdata.response.error.type != 'invalidfeature') { note('warning', "${app.label}: Please check Zipcode/PWS setting, error:\n${wdata.response.error.type}: ${wdata.response.error.description}" , 'a') return false } else { // Will find out which one(s) weren't reported later (probably never happens now that we don't ask for history) log.debug 'Rate limited...one or more WU features unavailable at this time.' } } } else { if (isDebug) log.debug 'wdata is null' note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null" , 'a') return false } String city = wzipcode if (wdata.current_observation) { if (wdata.current_observation.observation_location.city != '') city = wdata.current_observation.observation_location.city else if (wdata.current_observation.observation_location.full != '') city = wdata.current_observation.display_location.full if (wdata.current_observation.estimated.estimated) city = "${city} (est)" } // OK, we have good data, let's start the analysis float qpfTodayIn = 0.0 float qpfTomIn = 0.0 float popToday = 50.0 float popTom = 50.0 float TRain = 0.0 float YRain = 0.0 float weeklyRain = 0.0 if (settings.isRain) { if (isDebug) log.debug 'isWeather(): isRain' // Get forecasted rain for today and tomorrow if (!wdata.forecast) { log.debug 'isWeather(): Unable to get weather forecast.' return false } if (wdata.forecast.simpleforecast.forecastday[0].qpf_allday.in.isNumber()) qpfTodayIn = wdata.forecast.simpleforecast.forecastday[0].qpf_allday.in.toFloat() if (wdata.forecast.simpleforecast.forecastday[0].pop.isNumber()) popToday = wdata.forecast.simpleforecast.forecastday[0].pop.toFloat() if (wdata.forecast.simpleforecast.forecastday[1].qpf_allday.in.isNumber()) qpfTomIn = wdata.forecast.simpleforecast.forecastday[1].qpf_allday.in.toFloat() if (wdata.forecast.simpleforecast.forecastday[1].pop.isNumber()) popTom = wdata.forecast.simpleforecast.forecastday[1].pop.toFloat() if (qpfTodayIn > 25.0) qpfTodayIn = 25.0 else if (qpfTodayIn < 0.0) qpfTodayIn = 0.0 if (qpfTomIn > 25.0) qpfTomIn = 25.0 else if (qpfTomIn < 0.0) qpfTomIn = 0.0 // Get rainfall so far today if (!wdata.current_observation) { log.debug 'isWeather(): Unable to get current weather conditions.' return false } if (wdata.current_observation.precip_today_in.isNumber()) { TRain = wdata.current_observation.precip_today_in.toFloat() if (TRain > 25.0) TRain = 25.0 // Ignore runaway weather else if (TRain < 0.0) TRain = 0.0 // WU can return -999 for estimated locations } if (TRain > (qpfTodayIn * (popToday / 100.0))) { // Not really what PoP means, but use as an adjustment factor of sorts qpfTodayIn = TRain // already have more rain than was forecast for today, so use that instead popToday = 100 // we KNOW this rain happened } // Get yesterday's rainfall int day = getWeekDay() YRain = state.Rain[day - 1] if (isDebug) log.debug "TRain ${TRain} qpfTodayIn ${qpfTodayIn} @ ${popToday}%, YRain ${YRain}" int i = 0 while (i <= 6){ // calculate (un)weighted average (only heavy rainstorms matter) int factor = 0 if ((day - i) > 0) factor = day - i else factor = day + 7 - i float getrain = state.Rain[i] if (factor != 0) weeklyRain += (getrain / factor) i++ } if (isDebug) log.debug "isWeather(): weeklyRain ${weeklyRain}" } if (isDebug) log.debug 'isWeather(): build report' //get highs int highToday = 0 int highTom = 0 if (wdata.forecast.simpleforecast.forecastday[0].high.fahrenheit.isNumber()) highToday = wdata.forecast.simpleforecast.forecastday[0].high.fahrenheit.toInteger() if (wdata.forecast.simpleforecast.forecastday[1].high.fahrenheit.isNumber()) highTom = wdata.forecast.simpleforecast.forecastday[1].high.fahrenheit.toInteger() String weatherString = "${app.label}: ${city} weather:\n TDA: ${highToday}F" if (settings.isRain) weatherString = "${weatherString}, ${qpfTodayIn}in rain (${Math.round(popToday)}% PoP)" weatherString = "${weatherString}\n TMW: ${highTom}F" if (settings.isRain) weatherString = "${weatherString}, ${qpfTomIn}in rain (${Math.round(popTom)}% PoP)\n YDA: ${YRain}in rain" if (settings.isSeason) { if (!settings.isRain) { // we need to verify we have good data first if we didn't do it above if (!wdata.forecast) { log.debug 'Unable to get weather forecast' return false } } // is the temp going up or down for the next few days? float heatAdjust = 100.0 float avgHigh = highToday.toFloat() if (highToday != 0) { // is the temp going up or down for the next few days? int totalHigh = highToday int j = 1 int highs = 1 while (j < 4) { // get forecasted high for next 3 days if (wdata.forecast.simpleforecast.forecastday[j].high.fahrenheit.isNumber()) { totalHigh += wdata.forecast.simpleforecast.forecastday[j].high.fahrenheit.toInteger() highs++ } j++ } if ( highs > 0 ) avgHigh = (totalHigh / highs) heatAdjust = avgHigh / highToday } if (isDebug) log.debug "highToday ${highToday}, avgHigh ${avgHigh}, heatAdjust ${heatAdjust}" //get humidity int humToday = 0 if (wdata.forecast.simpleforecast.forecastday[0].avehumidity.isNumber()) humToday = wdata.forecast.simpleforecast.forecastday[0].avehumidity.toInteger() float humAdjust = 100.0 float avgHum = humToday.toFloat() if (humToday != 0) { int j = 1 int highs = 1 int totalHum = humToday while (j < 4) { // get forcasted humitidty for today and the next 3 days if (wdata.forecast.simpleforecast.forecastday[j].avehumidity.isNumber()) { totalHum += wdata.forecast.simpleforecast.forecastday[j].avehumidity.toInteger() highs++ } j++ } if (highs > 1) avgHum = totalHum / highs humAdjust = 1.5 - ((0.5 * avgHum) / humToday) // basically, half of the delta % between today and today+3 days } if (isDebug) log.debug "humToday ${humToday}, avgHum ${avgHum}, humAdjust ${humAdjust}" //daily adjustment - average of heat and humidity factors //hotter over next 3 days, more water //cooler over next 3 days, less water //drier over next 3 days, more water //wetter over next 3 days, less water // //Note: these should never get to be very large, and work best if allowed to cumulate over time (watering amount will change marginally // as days get warmer/cooler and drier/wetter) def sa = ((heatAdjust + humAdjust) / 2) * 100.0 state.seasonAdj = sa sa = sa - 100.0 String plus = '' if (sa > 0) plus = '+' weatherString = "${weatherString}\n Adjusting ${plus}${Math.round(sa)}% for weather forecast" // Apply seasonal adjustment on Monday each week or at install if ((getWeekDay() == 1) || (state.weekseasonAdj == 0)) { //get daylight if (wdata.sun_phase) { int getsunRH = 0 int getsunRM = 0 int getsunSH = 0 int getsunSM = 0 if (wdata.sun_phase.sunrise.hour.isNumber()) getsunRH = wdata.sun_phase.sunrise.hour.toInteger() if (wdata.sun_phase.sunrise.minute.isNumber()) getsunRM = wdata.sun_phase.sunrise.minute.toInteger() if (wdata.sun_phase.sunset.hour.isNumber()) getsunSH = wdata.sun_phase.sunset.hour.toInteger() if (wdata.sun_phase.sunset.minute.isNumber()) getsunSM = wdata.sun_phase.sunset.minute.toInteger() int daylight = ((getsunSH * 60) + getsunSM)-((getsunRH * 60) + getsunRM) if (daylight >= 850) daylight = 850 //set seasonal adjustment //seasonal q (fudge) factor float qFact = 75.0 // (Daylight / 11.66 hours) * ( Average of ((Avg Temp / 70F) + ((1/2 of Average Humidity) / 65.46))) * calibration quotient // Longer days = more water (day length constant = approx USA day length at fall equinox) // Higher temps = more water // Lower humidity = more water (humidity constant = USA National Average humidity in July) float wa = ((daylight / 700.0) * (((avgHigh / 70.0) + (1.5-((avgHum * 0.5) / 65.46))) / 2.0) * qFact) state.weekseasonAdj = wa //apply seasonal time adjustment plus = '' if (wa != 0) { if (wa > 100.0) plus = '+' String waStr = String.format('%.2f', (wa - 100.0)) weatherString = "${weatherString}\n Seasonal adjustment of ${waStr}% for the week" } setSeason() } else { log.debug 'isWeather(): Unable to get sunrise/set info for today.' } } } note('season', weatherString , 'f') // if only doing seasonal adjustments, we are done if (!settings.isRain) return false float setrainDelay = 0.2 if (settings.rainDelay) setrainDelay = settings.rainDelay.toFloat() // if we have no sensors, rain causes us to skip watering for the day if (!anySensors()) { if (settings.switches.latestValue('rainsensor') == 'rainsensoron'){ note('raintoday', "${app.label}: skipping, rain sensor is on", 'd') return true } float popRain = qpfTodayIn * (popToday / 100.0) if (popRain > setrainDelay){ String rainStr = String.format('%.2f', popRain) note('raintoday', "${app.label}: skipping, ${rainStr}in of rain is probable today", 'd') return true } popRain += qpfTomIn * (popTom / 100.0) if (popRain > setrainDelay){ String rainStr = String.format('%.2f', popRain) note('raintom', "${app.label}: skipping, ${rainStr}in of rain is probable today + tomorrow", 'd') return true } if (weeklyRain > setrainDelay){ String rainStr = String.format('%.2f', weeklyRain) note('rainy', "${app.label}: skipping, ${rainStr}in weighted average rain over the past week", 'd') return true } } else { // we have at least one sensor in the schedule // Ignore rain sensor & historical rain - only skip if more than setrainDelay is expected before midnight tomorrow float popRain = (qpfTodayIn * (popToday / 100.0)) - TRain // ignore rain that has already fallen so far today - sensors should already reflect that if (popRain > setrainDelay){ String rainStr = String.format('%.2f', popRain) note('raintoday', "${app.label}: skipping, at least ${rainStr}in of rain is probable later today", 'd') return true } popRain += qpfTomIn * (popTom / 100.0) if (popRain > setrainDelay){ String rainStr = String.format('%.2f', popRain) note('raintom', "${app.label}: skipping, at least ${rainStr}in of rain is probable later today + tomorrow", 'd') return true } } if (isDebug) log.debug "isWeather() ends" return false } // true if ANY of this schedule's zones are on and using sensors private boolean anySensors() { int zone=1 while (zone <= 16) { def zoneStr = settings."zone${zone}" if (zoneStr && (zoneStr != 'Off') && settings."sensor${zone}") return true zone++ } return false } def getDPWDays(int dpw){ if (dpw && (dpw.isNumber()) && (dpw >= 1) && (dpw <= 7)) { return state."DPWDays${dpw}" } else return [0,0,0,0,0,0,0] } // Create a map of what days each possible DPW value will run on // Example: User sets allowed days to Monday Wed and Fri // Map would look like: DPWDays1:[1,0,0,0,0,0,0] (run on Monday) // DPWDays2:[1,0,0,0,1,0,0] (run on Monday and Friday) // DPWDays3:[1,0,1,0,1,0,0] (run on Monday Wed and Fri) // Everything runs on the first day possible, starting with Monday. def createDPWMap() { state.DPWDays1 = [] state.DPWDays2 = [] state.DPWDays3 = [] state.DPWDays4 = [] state.DPWDays5 = [] state.DPWDays6 = [] state.DPWDays7 = [] //def NDAYS = 7 // day Distance[NDAYS][NDAYS], easier to just define than calculate everytime 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]] def ndaysAvailable = daysAvailable() int i = 0 // def int[] daysAvailable = [0,1,2,3,4,5,6] def int[] daysAvailable = [0,0,0,0,0,0,0] if(settings.days) { if (settings.days.contains('Even') || settings.days.contains('Odd')) { return } if (settings.days.contains('Monday')) { daysAvailable[i] = 0 i++ } if (settings.days.contains('Tuesday')) { daysAvailable[i] = 1 i++ } if (settings.days.contains('Wednesday')) { daysAvailable[i] = 2 i++ } if (settings.days.contains('Thursday')) { daysAvailable[i] = 3 i++ } if (settings.days.contains('Friday')) { daysAvailable[i] = 4 i++ } if (settings.days.contains('Saturday')) { daysAvailable[i] = 5 i++ } if (settings.days.contains('Sunday')) { daysAvailable[i] = 6 i++ } if(i != ndaysAvailable) { log.debug 'ERROR: days and daysAvailable do not match in setup - overriding' log.debug "${i} ${ndaysAvailable}" ndaysAvailable = i // override incorrect setup execution state.daysAvailable = i } } else { // all days are available if settings.days == "" daysAvailable = [0,1,2,3,4,5,6] } //log.debug "Ndays: ${ndaysAvailable} Available Days: ${daysAvailable}" def maxday = -1 def max = -1 def dDays = new int[7] 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]] for(def a=0; a < ndaysAvailable; a++) { // Figure out next day using the dayDistance map, getting the farthest away day (max value) if(a > 0 && ndaysAvailable >= 2 && a != ndaysAvailable-1) { if(a == 1) { for(def c=1; c < ndaysAvailable; c++) { def d = dayDistance[daysAvailable[0]][daysAvailable[c]] if(d > max) { max = d maxday = daysAvailable[c] } } //log.debug "max: ${max} maxday: ${maxday}" dDays[0] = maxday } // Find successive maxes for the following days if(a > 1) { def lmax = max def lmaxday = maxday max = -1 for(int c = 1; c < ndaysAvailable; c++) { def d = dayDistance[daysAvailable[0]][daysAvailable[c]] def t = d > max if (a % 2 == 0) t = d >= max if(d < lmax && d >= max) { if(d == max) { d = dayDistance[lmaxday][daysAvailable[c]] if(d > dayDistance[lmaxday][maxday]) { max = d maxday = daysAvailable[c] } } else { max = d maxday = daysAvailable[c] } } } lmax = 5 while(max == -1) { lmax = lmax -1 for(int c = 1; c < ndaysAvailable; c++) { def d = dayDistance[daysAvailable[0]][daysAvailable[c]] if(d < lmax && d >= max) { if(d == max) { d = dayDistance[lmaxday][daysAvailable[c]] if(d > dayDistance[lmaxday][maxday]) { max = d maxday = daysAvailable[c] } } else { max = d maxday = daysAvailable[c] } } } for (def d=0; d< a-2; d++) { if(maxday == dDays[d]) max = -1 } } //log.debug "max: ${max} maxday: ${maxday}" dDays[a-1] = maxday } } // Set the runDays map using the calculated maxdays for(int b=0; b < 7; b++) { // Runs every day available if(a == ndaysAvailable-1) { runDays[a][b] = 0 for (def c=0; c < ndaysAvailable; c++) { if(b == daysAvailable[c]) runDays[a][b] = 1 } } else { // runs weekly, use first available day if(a == 0) { if(b == daysAvailable[0]) runDays[a][b] = 1 else runDays[a][b] = 0 } else { // Otherwise, start with first available day if(b == daysAvailable[0]) runDays[a][b] = 1 else { runDays[a][b] = 0 for(def c=0; c < a; c++) if(b == dDays[c]) runDays[a][b] = 1 } } } } } //log.debug "DPW: ${runDays}" state.DPWDays1 = runDays[0] state.DPWDays2 = runDays[1] state.DPWDays3 = runDays[2] state.DPWDays4 = runDays[3] state.DPWDays5 = runDays[4] state.DPWDays6 = runDays[5] state.DPWDays7 = runDays[6] } //transition page to populate app state - this is a fix for WP param def zoneSetPage1(){ state.app = 1 zoneSetPage() } def zoneSetPage2(){ state.app = 2 zoneSetPage() } def zoneSetPage3(){ state.app = 3 zoneSetPage() } def zoneSetPage4(){ state.app = 4 zoneSetPage() } def zoneSetPage5(){ state.app = 5 zoneSetPage() } def zoneSetPage6(){ state.app = 6 zoneSetPage() } def zoneSetPage7(){ state.app = 7 zoneSetPage() } def zoneSetPage8(){ state.app = 8 zoneSetPage() } def zoneSetPage9(i){ state.app = 9 zoneSetPage() } def zoneSetPage10(){ state.app = 10 zoneSetPage() } def zoneSetPage11(){ state.app = 11 zoneSetPage() } def zoneSetPage12(){ state.app = 12 zoneSetPage() } def zoneSetPage13(){ state.app = 13 zoneSetPage() } def zoneSetPage14(){ state.app = 14 zoneSetPage() } def zoneSetPage15(){ state.app = 15 zoneSetPage() } def zoneSetPage16(){ state.app = 16 zoneSetPage() }