/* ============================================================================= Hubitat Elevation Application Sprinkler Schedule (child application) Sprinkler Valve Timetable Inspiration: Lighting Schedules https://github.com/matt-hammond-001/hubitat-code Inspiration: github example from Hubitat of lightsUsage.groovy This fork: Sprinkler Schedules https://github.com/csteele-PD/Hubitat-public/tree/master/SprinklerSchedule ----------------------------------------------------------------------------- This code is licensed as follows: Portions: Copyright (c) 2022 Hubitat, Inc. All Rights Reserved Bruce Ravenel BSD 3-Clause License Copyright (c) 2023, C Steele Copyright (c) 2020, Matt Hammond All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- * * * * csteele: v1.0.11 corrected updateMyLabel(). * csteele: v1.0.10 initialize state.rainDeviceOutdoor in setOutdoorRain. * csteele: v1.0.9 Skip when no valves in a schedule. * csteele: v1.0.8 Adjustments to recvOutdoorRainHandler(). * csteele: v1.0.7 After carefully fixing schEnable display, it wasn't used in scheduleNext logic. * csteele: v1.0.6 Allow multiple Rain Sensors to be integrated. * csteele: v1.0.5 corrected schEnable, so that enaDis is correct initially. * null safe currentValve?.label/name. * refactor rainHold to be by timetable, * don't offer rain hold if no rain sensor device is selected. * remove rainHold reset at midnight. * csteele: v1.0.4 adjusted valve open/close messages to use device label or name. * minimized Temp and Rain device attributes. * csteele: v1.0.3 Initial Release (end Beta). * csteele: v1.0.2 Add Over Temp and Rain Detection to be used as a Conditional * csteele: v1.0.1 Added month2month and dayGroupMaster from Parent * csteele: v1.0.0 Inspired by Matt Hammond's Lighting Schedule (child) * Converted to capability.valve from switch * */ public static String version() { return "v1.0.11" } definition( name: "Sprinkler Valve Timetable", namespace: "csteele", parent: "csteele:Sprinkler Schedule Manager", author: "C Steele", description: "Controls valves to a timing schedule", importUrl: "https://raw.githubusercontent.com/csteele-PD/Hubitat-public/refs/heads/master/SprinklerSchedule/SprinklerSchedule_child.groovy", documentationLink: "https://www.hubitatcommunity.com/QuikRef/sprinklerScheduleManagerInfo/index.html", iconUrl: "", iconX2Url: "", ) preferences { page(name: "main") } def main(){ init(1) // during first time install of child, along with installCheck(), pre-populate any un-initialized elements dynamicPage(name: "main", title: "", uninstall: true, install: true){ updateMyLabel(1) displayHeader() state.appInstalled = app.getInstallationState() // validate that the Done button has been clicked the first time if (state.appInstalled != 'COMPLETE') return installCheck() section(menuHeader("General")) { label title: "Name for this application", required: false, submitOnChange: true if (app.label.contains('Valve Select" input "valves", "capability.valve", title: "Control which valves?", multiple: true, required: false, submitOnChange: true } section(menuHeader("Timetable Status & Logging")) { if (valves) { currentMonth = new Date().format("M") // Get the current month as a number (1-12) currentMonthPercentage = state.month2month ? state.month2month[currentMonth].toDouble() : 1 // Lookup the percent in month2month or 1 // provide some feedback on which valves are On String str = "
" if (state.month2month) { str += "Adjust valve timing by Month is active. Current month is: $currentMonthPercentage%
" + "Rain hold is $state.rainHold
" } if (state.overTempToday) { str += "Sometime today, the outside temperature exceeded the limit you set of $state.maxOutdoorTemp.
" } str += valves?.collect { dev -> "${dev.label ?: dev.name} is ${dev.currentValue('valve', true) == 'open' ? 'On' : 'Off'}"}?.join(', ') ?: "" str += "
" paragraph str } input "infoEnable", "bool", title: "Enable activity logging", required: false, defaultValue: true, width: 2 input "debugEnable", "bool", title: "Enable debug logging", required: false, defaultValue: false, submitOnChange: true, width: 2 if (debugEnable) { input "debugTimeout", "enum", required: false, defaultValue: "0", title: "Automatic debug Log Disable Timeout?", width: 3, \ options: [ "0":"None", "1800":"30 Minutes", "3600":"60 Minutes", "86400":"1 Day" ] } } if (valves) { section(menuHeader("Schedule")) { paragraph "Select Days into Groups" paragraph displayDayGroups() // display day-of-week groups - Section I paragraph "Select Period Settings by Group" paragraph displayTable() // display groups for scheduling - Section II displayDuration() displayStartTime() paragraph "Select Valves into Day Groups" paragraph displayGrpSched() // display mapping of Valve to DayGroup - Section III selectDayGroup() if (state.rainDeviceOutdoor != [:]) { def rainVars = ["0": "no rainHold"] state.rainDeviceOutdoor.each { key, info -> rainVars[key] = info.name } input "rainEnableDevice", "enum", title: "Choose the Rain Sensor for this Timetable

leave unselected for no Rain Hold

", submitOnChange: true, defaultValue: "0", options: rainVars } paragraph "\n
" } } } } /* ----------------------------------------------------------------------------- Main Page handlers ----------------------------------------------------------------------------- */ String displayDayGroups() { // display day-of-week groups - Section I incM = state.dayGroupMaster?.size() ?: 0 if(state.dayGroupBtn) { // toggle the daily checkmarks dgK = state.dayGroupBtn[0].toInteger() - incM // dayGroupBtn Key dgI = state.dayGroupBtn.substring(1); // dayGroupBtn value (mon-sun) state.dayGroup["$dgK"]["$dgI"] = !state.dayGroup["$dgK"]["$dgI"] // Toggle state state.remove("dayGroupBtn") // only once logDebug "displayDayGroups Item: $dgK.$dgI" } if(state.overTempBtn) { // toggle the overTemp checkmarks dgK = state.overTempBtn[0].toInteger() - incM // overTempBtn Key if (state.dayGroup.containsKey(dgK.toString())) { state.dayGroup[dgK.toString()].ot = !state.dayGroup[dgK.toString()].ot } // Toggle state state.remove("overTempBtn") // only once } masterGroupMerge() // merge or riffle merge if there's a new mMap from Parent. String str = "" str += "
" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" str += "" String X = "" String O = "" String Plus = "+" String Minus = "-" String addDayGroupBtn = buttonLink("addDGBtn", Plus, "#1A77C9", "") strRows = "" rowCount = 1 state.dayGroupMaster.each { k, dg -> str += strRows str += "" for (int r = 1; r < 8; r++) { String dayBoxN = noButtonLink("w$rowCount$r", O, "#49a37d", "") String dayBoxY = noButtonLink("w$rowCount$r", X, "#49a37d", "") str += (dg."$r") ? "" : "" } // no delete button on Master dayGroup rows. str += "" strRows = "" rowCount++ } state.dayGroup.each { k, dg -> str += strRows str += "" for (int r = 1; r < 8; r++) { String dayBoxN = buttonLink("w$rowCount$r", O, "#1A77C9", "") String dayBoxY = buttonLink("w$rowCount$r", X, "#1A77C9", "") str += (dg."$r") ? "" : "" } String remDayGroupBtn = buttonLink("rem$rowCount", "", "#1A77C9", "") str += "" String otBoxN = buttonLink("o$rowCount", O, "#db7321", "") String otBoxY = buttonLink("o$rowCount", X, "#db7321", "") str += (dg."ot") ? "" : "" strRows = "" rowCount++ } str += "" str += "" str += "
Day GroupMonTueWedThuFriSatSunOverTemp
$rowCount$dayBoxY$dayBoxN 
$rowCount$dayBoxY$dayBoxN$remDayGroupBtn$otBoxY$otBoxN
$addDayGroupBtn <- Add new Day Group 
" str } String displayTable() { // display groups for scheduling - Section II if (state.eraseTime) { // if the reset/erase button is clicked def eraseTime = state.eraseTime as Integer // which button (row) was clicked def masterSize = state.dayGroupMaster.size() def offset = (masterSize >= eraseTime) def nIndex = (offset) ? eraseTime.toString() : (eraseTime - masterSize).toString() if (offset) { if (state.dayGroupMaster.containsKey(nIndex)) { state.dayGroupMaster[nIndex].startTime = 0 } if (state.dayGroupMaster.containsKey(nIndex)) { state.dayGroupMaster[nIndex].duraTime = 0 } } else { if (state.dayGroup.containsKey(nIndex)) { state.dayGroup[nIndex].startTime = 0 } if (state.dayGroup.containsKey(nIndex)) { state.dayGroup[nIndex].duraTime = 0 } } state.remove("eraseTime") app.removeSetting("eraseTime") paragraph "" } String str = "" str += "
" + "" + "" + "" + "" + "" + "" state.dayGroupMerge.each { k, dg -> String dayGroupNamed = "Group $k" String sTime = state.dayGroupMerge[k]?.startTime ? buttonLink("t$k", state.dayGroupMerge[k].startTime, "black") : buttonLink("t$k", "Set Time", "green") String dTime = state.dayGroupMerge[k]?.duraTime String duraTime = dTime ? buttonLink("n$k", dTime, "purple") : buttonLink("n$k", "Select", "green") String devLink = "$dev" String reset = buttonLink("x$k", "", "black", "20px") str += "" + "" + "" + "" + "" + "" } str += "
Day GroupStart TimeDurationReset
$dayGroupNamed$sTime$duraTime$reset
" str } String displayGrpSched() { // display mapping of Valve to DayGroup - Section III String str = "" str += "
" + "" + "" + "" + "" valves?.sort{it.displayName.toLowerCase()}.each { dev -> dx = state.valves[dev.id].dayGroupMerge String devLink = "$dev" String myDG = state.valves[dev.id].dayGroup String myDayGroup = myDG ? buttonLink("r$dev.id", myDG, "purple") : buttonLink("r$dev.id", "Select", "green") str += "" + "" + "" "" } str += "
ValveDay Group
$devLink$myDayGroup
" str } /* ----------------------------------------------------------------------------- Display level handlers ----------------------------------------------------------------------------- */ def displayStartTime() { if(state.startTimeBtn) { def startTimeBtn = state.startTimeBtn as Integer def masterSize = state.dayGroupMaster.size() def offset = (masterSize >= startTimeBtn) def nIndex = (offset) ? startTimeBtn.toString() : (startTimeBtn - masterSize).toString() input "StartTime", "time", title: "At This Time", submitOnChange: true, width: 4, defaultValue: state.startTime, newLineAfter: false input "DoneTime$state.startTimeBtn", "button", title: " Done with time ", width: 2, newLineAfter: true if(StartTime) { if (offset) { if (state.dayGroupMaster.containsKey(nIndex)) { state.dayGroupMaster[nIndex].startTime = Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSX", StartTime).format('HH:mm') } } else { if (state.dayGroup.containsKey(nIndex)) { state.dayGroup[nIndex].startTime = Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSX", StartTime).format('HH:mm') } } state.remove("startTimeBtn") app.removeSetting("StartTime") paragraph "" } } } def displayDuration() { if(state.duraTimeBtn) { def duraTimeBtn = state.duraTimeBtn as Integer def masterSize = state.dayGroupMaster.size() def offset = (masterSize >= duraTimeBtn) def nIndex = (offset) ? duraTimeBtn.toString() : (duraTimeBtn - masterSize).toString() input "DuraTime", "decimal", title: "Sprinkler Duration", submitOnChange: true, width: 4, range: "0..60", defaultValue: state.duraTimeBtn, newLineAfter: true if(DuraTime) { if (offset) { if (state.dayGroupMaster.containsKey(nIndex)) { state.dayGroupMaster[nIndex].duraTime = DuraTime } } else { if (state.dayGroup.containsKey(nIndex)) { state.dayGroup[nIndex].duraTime = DuraTime } } state.remove("duraTimeBtn") app.removeSetting("DuraTime") paragraph "" } } } def selectDayGroup() { // map valve to dayGroup if(state.dayGrpBtn) { List vars = state.dayGroupMerge.keySet().collect() input "DayGroup", "enum", title: "Sprinkler Group", submitOnChange: true, width: 4, options: vars, newLineAfter: true, multiple: true if(DayGroup) { state.valves[state.dayGrpBtn].dayGroup = DayGroup state.remove("dayGrpBtn") app.removeSetting("DayGroup") paragraph "" } } } def addDayGroup(evt = null) { def dayGroupTemplate = [ '1': false, '2': false, '3': false, '4': false, '5': false, '6': false, '7': false, "s": "P", "name": "", "ot": false, "ra": false, "duraTime": null, "startTime": null ] // new rows are all empty def dayGroupSize = state.dayGroup.size() // More efficient def newIndex = (dayGroupSize + 1).toString() // Ensures key consistency logDebug "Adding another dayGroup map: $newIndex" state.dayGroup[newIndex] = dayGroupTemplate.clone() // Clone to avoid reference issues } def remDayGroup(evt = null) { // remove a Local dayGroup & dayGroupSettings dayGroupSize = (state.dayGroupMerge ?: [:]).keySet().size() if (dayGroupSize > 1) { // Determine the key to delete keyToDelete = (evt.toInteger() - (state.dayGroupMaster ?: [:]).size()).toString() logDebug "remove another dayGroup map: $dayGroupSize, $keyToDelete, evt:$evt" if (state.dayGroup.containsKey(keyToDelete)) { state.dayGroup.remove(keyToDelete) } // Re-map keys to be sequential def dayGrpReOrder = [:] def counter = 1 state.dayGroup.sort { it.key.toInteger() }.each { k, v -> dayGrpReOrder["${counter}"] = v counter++ } state.dayGroup = dayGrpReOrder } } def masterGroupMerge(masterDayGroupIn = [:]) { // lots of deep copies of hashMaps // three part merge. Part 1: Decide which incoming "Master" is to be used and clone the days-of-week fields. def dayGroupMaster = [:] state.dayGroupMaster.each { k, v -> dayGroupMaster[k] = v.clone() // deep copy } def dayGroupMerge = masterDayGroupIn ? masterDayGroupIn.collectEntries { k, v -> [k, v.clone()] } : dayGroupMaster.collectEntries { k, v -> [k, v.clone()] } ?: [:] // deep copy // Part 2: overwrite setTime, DuraTime, name, ot and ra values from the child for the Master records. if (masterDayGroupIn) { dayGroupMerge.each { k, v -> if (state.dayGroupMaster.containsKey(k)) { def masterEntry = state.dayGroupMaster[k] v.startTime = masterEntry?.startTime ?: null v.duraTime = masterEntry?.duraTime ?: null v.name = masterEntry?.name ?: "" v.ot = masterEntry?.ot ?: false v.ra = masterEntry?.ra ?: false } } dayGroupMerge.each { k, v -> state.dayGroupMaster[k] = v.clone() // deep copy this new master for next pass. } } // Part 3: independent of masterDayGroupIn vs state.dayGroupMaster, all the child dayGroup rows are cloned & keys renumbered. dayGroupSize = (dayGroupMerge ?: [:]).keySet().size() // renumber, starting with the size of dayGroupMerge state.dayGroup.each { k, v -> // merge dayGroup into dayGroupSize++ dayGroupMerge[dayGroupSize] = v.clone() // deep copy } // then clone it for the next pass. state.dayGroupMerge = dayGroupMerge.collectEntries { k, v -> [k, v.clone()] } // deep copy } String buttonLink(String btnName, String linkText, color = "#1A77C9", font = "15px") { "
$linkText
" } String noButtonLink(String btnName, String linkText, color = "#1A77C9", font = "15px") { "
$linkText
" } void appButtonHandler(btn) { // only one button can be pressed, remove their states, since "btn" contains the only valid one. state.remove("duraTimeBtn") state.remove("dayGrpBtn") state.remove("startTimeBtn") state.remove("dayGroupBtn") state.remove("overTempBtn") state.remove("doneTime") state.remove("eraseTime") app.removeSetting("StartTime") app.removeSetting("DuraTime") if ( btn == "btnSchEna") toggleEnaSchBtn() else if ( btn == "addDGBtn") addDayGroup() else if ( btn.startsWith("rem") ) remDayGroup(btn.minus("rem")) else if ( btn.startsWith("n") ) state.duraTimeBtn = btn.minus("n") else if ( btn.startsWith("r") ) state.dayGrpBtn = btn.minus("r") else if ( btn.startsWith("t") ) state.startTimeBtn = btn.minus("t") else if ( btn.startsWith("w") ) state.dayGroupBtn = btn.minus("w") else if ( btn.startsWith("o") ) state.overTempBtn = btn.minus("o") else if ( btn.startsWith("x") ) state.eraseTime = btn.minus("x") } /* ------------------------------------------------------------------------------------------- --- UI code above here, Generic App code below. Background Valve code further below. --- ------------------------------------------------------------------------------------------- */ /* ----------------------------------------------------------------------------- Logging output ----------------------------------------------------------------------------- */ def logDebug(msg) { if (settings.debugEnable) { log.debug msg } } def logWarn(msg) { log.warn msg } def logInfo(msg) { if (settings.infoEnable) { log.info msg } } /* ----------------------------------------------------------------------------- Standard handlers, and mode-change handler ----------------------------------------------------------------------------- */ def initialize() { // unused? logDebug "initialize()" unsubscribe() init() update() } def installed() { logDebug "installed()" parent?.componentInitialize(this.device) } def updated() { logDebug "updated()" unschedule (logsOff) if (debugEnable && debugTimeout.toInteger() >0) runIn(debugTimeout.toInteger(), logsOff) update() } def update() { updateMyLabel(2) scheduleNext() } def uninstalled() { logDebug "uninstalled()" } def set2Month(monthIn) { state.month2month = monthIn logInfo "MonthIn update from Parent." } def set2DayGroup(dayGroupIn) { masterGroupMerge(dayGroupIn) logInfo "DayGroup update from Parent." } def setOutdoorTemp(aTempDevice, dTemp) { state.outdoorTempDevice = aTempDevice.currentStates state.maxOutdoorTemp = dTemp def tempNow = aTempDevice.currentValue("temperature") state.overTempToday = ( tempNow > state.maxOutdoorTemp.toInteger() ) ? true : false logInfo "OutdoorTemp update from Parent, tempNow: $tempNow." unsubscribe(recvOutdoorTempHandler) subscribe(aTempDevice, "temperature", recvOutdoorTempHandler) } def setOutdoorRain(aRainDevice, rainAttr) { unsubscribe(recvOutdoorRainHandler) state.rainDeviceOutdoor = [:] state.rainAttribute = rainAttr aRainDevice.each {ard -> def ms1 = ard.label ?: ard.name // use the Name when Label is blank. state.rainDeviceOutdoor[ard.id.toString()] = [ value: ard.currentValue(rainAttr), name : ms1 ] logInfo "OutdoorRain update from Parent, $ms1: ${ard.currentValue(rainAttr)}" subscribe(ard, rainAttr, recvOutdoorRainHandler) } state.rainHold = rainEnableDevice && rainEnableDevice != "0" && state.rainDeviceOutdoor[rainEnableDevice]?.value.toLowerCase() == "wet" } def recvOutdoorTempHandler(evt) { if (!state.overTempToday) { // if the temp goes over the limit, latch 'true' state til midnight reset state.overTempToday = new BigDecimal(evt.value) > new BigDecimal(state.maxOutdoorTemp) // true : false logDebug "OutdoorTemp update from Device. overTempToday: $state.overTempToday" } } def recvOutdoorRainHandler(evt) { if (rainEnableDevice == evt.deviceId.toString()) { state.rainDeviceOutdoor[evt.id.toString()] = [ value: evt?.value, name : evt?.displayName ] state.rainHold = evt.value.toLowerCase() == "wet" logDebug "OutdoorRain update from Device. rainHold: $state.rainHold" } } def installCheck(){ state.appInstalled = app.getInstallationState() if(state.appInstalled != 'COMPLETE'){ state.paused = false app?.updateSetting("schEnable",[value:"true",type:"bool"]) section{paragraph "Please hit 'Done' to Complete the install."} } else{ logDebug "$app.name is Installed Correctly" } } def init(why) { switch(why) { case 1: if (!app.label) { app.updateLabel(app.name) atomicState.appDisplayName = app.name } if(state.valves == null) state.valves = [:] if(state.paused == null) state.paused = false // the switch visually is inverted from the logic. Default = true aka enabled/not paused. if(state.inCycle == null) state.inCycle = false if(state.overTempToday == null) state.overTempToday = false if(state.rainHold == null) state.rainHold = false if(state.dayGroup == null) state.dayGroup = ['1': ['1':true, '2':true, '3':true, '4':true, '5':true, '6':true, '7':true, "s": "P", "name": "", "ot": false, "ra": false, "duraTime": null, "startTime": null ] ] // initial row if(state.rainDeviceOutdoor == null) state.rainDeviceOutdoor = [:] if(state.month2month == null) state.month2month = [:] if(state.dayGroupMaster == null) state.dayGroupMaster = [:] valves.each { dev -> if(!state.valves["$dev.id"]) { state.valves["$dev.id"] = ['dayGroup':['1']] } } break; } } /* ------------------------------------------------------------------------------------------- --- Generic App code above here, Background Valve on/off code below. -------------------- ------------------------------------------------------------------------------------------- */ /* ----------------------------------------------------------------------------- Whenever there is a Timetable change/update ----------------------------------------------------------------------------- */ def reschedule() { // midnight run to setup first schedule of the day. unschedule(reschedule) schedule('7 7 0 ? * *', reschedule) // reschedule the midnight run to schedule that day's work. state.overTempToday = false // once a day, midnight, reset the over temp indicator runIn(15, scheduleNext) } def scheduleNext() { String myLabel = app.label if (app.label.contains(' value.any { it.value.toString() == "0" } } || state.valves?.isEmpty() if (hasZero) { logWarn "Please set Time and Duration" return } unschedule(reschedule) schedule('7 7 0 ? * *', reschedule) // reschedule the midnight run to schedule that day's work. logInfo "Checking $myLabel Schedule." Calendar calendar = Calendar.getInstance(); def cronDay = calendar.get(Calendar.DAY_OF_WEEK); timings = buildTimings(cronDay) if (!timings) { logWarn "Nothing scheduled for $myLabel Today." return } if (!schEnable) { logWarn "Schedule Paused for $myLabel." return } if (state.rainHold && rainEnableDevice && rainEnableDevice != "0") { logWarn "Rain Hold possible for $myLabel Today." } Date date = new Date() String akaNow = date.format("HH:mm") hasSched = false for (timN in timings) { sk = timN.key // index into dayGroupMerge for Duration (sth, stm) = timN.startTime.split(':') if (akaNow.replace(':', '') > timN.startTime.replace(':', '')) continue hasSched = true break; // quit the for loop on a schedule of first startTime that's in the future. } logDebug "schedule('0 $stm $sth ? * *', schedHandler, [data: ['dKey': $sk]]), hasSched: $hasSched" if (hasSched) { schedule("0 ${stm} ${sth} ? * *", schedHandler, [data: ["dKey":"$sk"]]) logInfo "$myLabel scheduled today." } else { logInfo "Nothing scheduled for $myLabel today." } } /* ----------------------------------------------------------------------------- Schedule Helper/Handler functions ----------------------------------------------------------------------------- */ def schedHandler(data) { unschedule(schedHandler) // don't repeat this day after day. String myLabel = app.label if (app.label.contains(' value[aWeek[cronDayOf]] == true }.keySet() // timings = result.collectEntries { key -> [(key): state.dayGrouptimings[key]] def results = result.collect { key -> [key: key, duraTime: state.dayGroupMerge[key]?.duraTime, startTime: state.dayGroupMerge[key]?.startTime]}.findAll { it.startTime != null }.sort { it.startTime } // Sort by startTime // [[key:2, duraTime:5.0, startTime:06:00]] } void updateMyLabel(num) { String flag = ' (paused)' } else if (state.inCycle) { String beganAt = atomicState.cycleStart ? "started " + fixDateTimeString(atomicState.cycleStart) : 'running' newLabel = myLabel + " (${beganAt})" } else if ((state.inCycle != null) && (state.inCycle == false)) { String endedAt = atomicState.cycleEnd ? "finished " + fixDateTimeString(atomicState.cycleEnd) : 'idle' newLabel = myLabel + " (${endedAt})" } else { newLabel = myLabel } if (myLabel != newLabel) app.updateLabel(newLabel) } String fixDateTimeString(eventDate) { def target = new Date(eventDate) def today = new Date().clearTime() def yesterday = new Date(today.time - 1 * 24 * 60 * 60 * 1000) // Subtract 1 day def tomorrow = new Date(today.time + 1 * 24 * 60 * 60 * 1000) // Add 1 day String myDate = '' boolean showTime = true if (target.clearTime() == today) { myDate = 'today' } else if (target.clearTime() == yesterday) { myDate = 'yesterday' } else if (target.clearTime() == tomorrow) { myDate = 'tomorrow' } else if (target.format('yyyy-MM-dd') == '2035-01-01') { // "Infinity" case myDate = 'a long time from now' showTime = false } else { myDate = "on ${target.format('MM-dd')}" } target = new Date(eventDate) String myTime = showTime ? target.format('h:mma').toLowerCase() : '' return myTime ? "${myDate} at ${myTime}" : myDate } def logsOff() { logWarn "debug logging Disabled..." app?.updateSetting("debugEnable",[value:"false",type:"bool"]) } def sectFormat(type, myText=""){ if(type == "line") return "
" if(type == "title") return "

${myText}

" if(type == "subTitle") return "

${myText}

" } def displayHeader() { section (sectFormat("title", "Sprinkler Valve Timetable")) { paragraph "
Developed by: C Steele, Matt Hammond
Current Version: ${version()} - ${thisCopyright}
" paragraph "\n
" } } String menuHeader(titleText){"
${titleText}
"} def getThisCopyright(){"© 2023 C Steele"}