/*
* Shade Minder
* Simple App to control outdoor shades based on illumance and wind speed
*
* Licensed Virtual 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, WIyTHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Date Who Description
* ------------- ------------------- ---------------------------------------------------------
* 27Oct2025 thebearmay v0.0.1 - Original code
* 28Oct2025 thebearmay v0.0.2 - Add the Data Management Screen, Reverse Shade option, Daily Avg Scheduling option
* 29Oct2025 thebearmay v0.0.3 - Fix edge case in time averaging
* 30Oct2025 thebearmay v0.0.4 - Add the initialization values, and Tool Tips
* 03Nov2025 thebearmay v0.0.5 - Add sunrise/sunset offset logic
* 15Dec2025 thebearmay v0.0.6 - Add Pause Date Range, additional tool tips, and rain detect option
* 16Dec2025 thebearmay v0.0.7 - Add Pause Reoccurance
*/
static String version() { return '0.0.7' }
import java.text.SimpleDateFormat
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.transform.Field
//include thebearmay.uiInputElements
definition (
name: "Shade Minder",
namespace: "thebearmay",
author: "Jean P. May, Jr.",
description: "Simple App to control outdoor shades based on illumance and wind speed",
category: "Utility",
importUrl: "https://raw.githubusercontent.com/thebearmay/hubitat/main/apps/shadeMinder.groovy",
installOnOpen: true,
oauth: false,
iconUrl: "",
iconX2Url: ""
)
preferences {
page name: "decision"
page name: "configPage"
page name: "dataFileMgmt"
}
mappings {
/* path("/refresh") {
action: [POST: "refresh",
GET: "refresh"]
}
*/
}
def installed() {
// log.trace "installed()"
state?.isInstalled = true
initialize()
}
def updated(){
// log.trace "updated()"
if(!state?.isInstalled) { state?.isInstalled = true }
if(debugEnabled) runIn(1800,logsOff)
}
def initialize(){
if(!state.avgWind)
state.avgWind = 0
if(!maxWind)
app.updateSetting("maxWind",[type:"number",value:75])
if(!state.avgLight)
state.avgLight = 0
if(!minLight)
app.updateSetting("minLight",[type:"number",value:1000])
}
void logsOff(){
app.updateSetting("debugEnabled",[value:"false",type:"bool"])
}
def decision(){
dynamicPage (name: "decision", title: "
${app.getLabel()} v${version()} ", install: true, uninstall: true) {
section (name:'cPageHndl', title:''){
if(!dmFirst)
configPage()
else
dataFileMgmt()
}
}
}
def configPage(){
dynamicPage (name: "configPage", title: "${app.getLabel()} v${version()} ", install: true, uninstall: true) {
section (name:'cPageHndl', title:'Configuration Page'){
String db = getInputElemStr(name:'debugEnabled', type:'bool', width:'15em', radius:'12px', background:'#e6ffff', title:'Debug Enabled ', defaultValue: "${settings['debugEnabled']}")
String rev = getInputElemStr(name:'reverseDir', type:'bool', width:'15em', radius:'12px', background:'#e6ffff', title:'Reverse Shade Direction ', defaultValue: "${settings['reverseDir']}", hoverText:"Reverse the Open/Close commands issued")
String uDf = getInputElemStr(name:'useAvg', type:'bool', width:'15em', radius:'12px', background:'#e6ffff', title:'Use Avg Settings from File ', defaultValue: "${settings['useDataFile']}", hoverText:"Turn on to schedule using average daily values from the data file")
String wind = getInputElemStr(name:'windDev', type:'capability.*', width:'15em', radius:'12px', background:'#e6ffff', title:'Device for Wind Speed ', defaultValue: "${settings['windDev']}")
//windSpeed
String sunLight = getInputElemStr(name:'luxDev', type:'capability.illuminanceMeasurement', width:'15em', radius:'12px', background:'#e6ffff', title:'Device for Light Measurement ', defaultValue: "${settings['luxDev']}")
//illuminance
String rain = getInputElemStr(name:'rainDev', type:'capability.*', width:'15em', radius:'12px', background:'#e6ffff', title:'Device for Rain Rate ', defaultValue: "${settings['windDev']}")
//rainRate
String shades = getInputElemStr(name:'shadeDev', type:'capability.windowShade', width:'15em', radius:'12px', background:'#e6ffff', title:'Shade Devices ', defaultValue: "${settings['shadeDev']}, multiple:true")
String begTime = getInputElemStr(name:'sTime', type:'time', width:'15em', radius:'12px', background:'#e6ffff', title:'Opening Time Override ', defaultValue: "${settings['sTime']}", hoverText:"This value will override the illumination setting")
String endTime = getInputElemStr(name:'eTime', type:'time', width:'15em', radius:'12px', background:'#e6ffff', title:'Closing Time Override ', defaultValue: "${settings['eTime']}", hoverText:"This value will override the illumination setting")
String begDate = getInputElemStr(name:'sPauseDate', type:'date', width:'15em', radius:'12px', background:'#e6ffff', title:'Pause Starting Date ', defaultValue: "${settings['sPauseDate']}", hoverText:"Suspend app execution starting on this date")
String endDate = getInputElemStr(name:'ePauseDate', type:'date', width:'15em', radius:'12px', background:'#e6ffff', title:'Pause Ending Date ', defaultValue: "${settings['ePauseDate']}", hoverText:"End app suspension after this date")
String dateReoccur = getInputElemStr(name:'pauseReoccur', type:'enum', width:'15em', radius:'12px', background:'#e6ffff', title:'Pause Reoccurs ', defaultValue: "${settings['dateReoccur']}", hoverText:"Sets next occurance automatically", options:['Weekly','Monthly','Annually'])
String bSrS = getInputElemStr(name:'begSunRiseSet', type:'enum', width:'15em', radius:'12px', background:'#e6ffff', title:'Use Opening Sunrise/Sunset ', defaultValue: "${settings['begSunRiseSet']}", options:['Sunrise','Sunset'], hoverText:"This value will override the Opening Time above")
String eSrS = getInputElemStr(name:'endSunRiseSet', type:'enum', width:'15em', radius:'12px', background:'#e6ffff', title:'Use Closing Sunrise/Sunset ', defaultValue: "${settings['endSunRiseSet']}", options:['Sunrise','Sunset'], hoverText:"This value will override the Closing Time above")
String bSrsO = getInputElemStr(name:'begOffset', type:'number', width:'15em', radius:'12px', background:'#e6ffff', title:'Offset in Minutes ', defaultValue: "${settings['begOffset']}")
String eSrsO = getInputElemStr(name:'endOffset', type:'number', width:'15em', radius:'12px', background:'#e6ffff', title:'Offset in Minutes ', defaultValue: "${settings['endOffset']}")
String mWind = getInputElemStr(name:'maxWind', type:'number', width:'15em', radius:'12px', background:'#e6ffff', title:'Maximum Wind Speed ', defaultValue: "${settings['maxWind']}", hoverText:"Retract if 10min average or a gust of 120% exceeds this value")
String mLight = getInputElemStr(name:'minLight', type:'number', width:'15em', radius:'12px', background:'#e6ffff', title:'Minimum Lux to Close ', defaultValue: "${settings['minLight']}", hoverText:"Shade will not deploy until this value is met/exceeded")
String mRain = getInputElemStr(name:'minRain', type:'number', width:'15em', radius:'12px', background:'#e6ffff', title:'Maximum Rain Rate per hour ', defaultValue: "${settings['minRain']}", hoverText:"Retract if 10min average exceeds this value")
String aRename = getInputElemStr(name:"nameOverride", type:"text", title: "New Name for Application ", multiple: false, defaultValue: app.getLabel(), width:'15em', radius:'12px', background:'#e6ffff', hoverText:"Change this if you need multiple instances of the app")
String cTable = "${ttStyleStr}${shades} ${wind} ${sunLight} ${rain} "
cTable += "${mLight} ${mWind} ${mRain} "
cTable += "${endTime} ${begTime} "
cTable += "${eSrS} ${eSrsO} "
cTable += "${bSrS} ${bSrsO} "
cTable += "${begDate} ${endDate} ${dateReoccur}
"
//app.removeSetting('sPauseDate')
//app.removeSetting('ePauseDate')
if(ePauseDate && sPauseDate){
tNow = new Date()
dWork = sPauseDate.split('-')
sDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
dWork = ePauseDate.split('-')
eDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
if(sDate <= tNow && eDate > tNow){ //app is paused
paragraph "Application is Paused "
} else if(pauseReoccur && eDate < tNow)
setReoccur()
}
ci = btnIcon(name:'event', size:'14px')
paragraph getInputElemStr(name:"dMgmt", type:'href', title:"${ci} Data Management", destPage:'dataFileMgmt', width:'11em', radius:'15px', background:'#669999', hoverText:"Go to the maintenance page for the data file")
paragraph cTable
paragraph "${db} ${rev} ${uDf} ${aRename}
"
if(nameOverride != app.getLabel()) app.updateLabel(nameOverride)
if(luxDev)
subscribe(luxDev, "illuminance", evtLux)
else
unsubscribe(evtLux)
if(windDev){
subscribe(windDev, "windSpeed", evtWind)
if(windDev.hasAttribute('windGust'))
subscribe (windDev,'windGust', evtWind)
} else
unsubscribe(evtWind)
if(rainDev && rainDev.hasAttribute('rainRate'))
subscribe(rainDev, "rainRate", evtRain)
else
unsubscribe(evtRain)
subscribe(location,"sunrise", evtTime)
subscribe(location,"sunset", evtTime)
if(begSunRiseSet || endSunRiseSet){
setByOffset()
schedule("0 15 4 * * ? *", "setByOffset")
} else {
unschedule("setByOffset")
}
if(useAvg){
setByAvg()
schedule("0 17 4 * * ? *", "setByAvg")
paragraph "Schedule is being run using the Daily Average File "
} else {
unschedule("setByAvg")
}
checkSched()
if(state.sTimeOld != sTime || state.eTimeOld != eTime){
state.sTimeOld = sTime
state.eTimeOld = eTime
paragraph ""
}
}
}
}
def dataFileMgmt(){
dynamicPage (name: "dataFileMgmt", title: "${app.getLabel()} v${version()} ", install: true, uninstall: true) {
section (name:'dFileHndl', title:'Data Management'){
SimpleDateFormat sdfJulD = new SimpleDateFormat("DDD")
String dmF = getInputElemStr(name:'dmFirst', type:'bool', width:'15em', radius:'12px', background:'#e6ffff', title:'Make this the first page ', defaultValue: "${settings['dmFirst']}")
String cfg = getInputElemStr(name:"cfgPg", type:'href', title:"settings_applications Configuration Page", destPage:'configPage', width:'11em', radius:'15px', background:'#669999')
paragraph ""
String fBuffer=''
try {
fBuffer = new String(downloadHubFile("${app.id}.txt"),"UTF-8")
} catch (e) {
fBuffer = '000:1100:1300\n'
uploadHubFile("${app.id}.txt",fBuffer.getBytes('UTF-8'))
}
String begTime = getInputElemStr(name:'s2Time', type:'time', width:'15em', radius:'12px', background:'#e6ffff', title:'Opening Time ', defaultValue: "${settings['s2Time']}", hoverText:"Required, can be before or after Closing Time")
String endTime = getInputElemStr(name:'e2Time', type:'time', width:'15em', radius:'12px', background:'#e6ffff', title:'Closing Time ', defaultValue: "${settings['e2Time']}", hoverText:"Required, can be before or after Opening Time")
String stoDate = getInputElemStr(name:'entryDate', type:'date', width:'15em', radius:'12px', background:'#e6ffff', title:'Day to Log ', defaultValue: "${settings['entryDate']}", hoverText:"Required, only the Julian Day value is stored; multiple entries will be averaged")
String storeD = getInputElemStr(name:'storeData', type:'button', width:'5em', radius:'12px', background:'#669999', title:'Add to File')
String pData = "${ttStyleStr}${stoDate} ${begTime} ${endTime} ${storeD}"
paragraph "$pData"
if(state.updFile) {
if(entryDate && s2Time && e2Time){
state.updFile = false
eDate = new Date(Integer.parseInt(entryDate.substring(0,4))-1900,Integer.parseInt(entryDate.substring(5,7))-1,Integer.parseInt(entryDate.substring(8,10)))
p1 = "${sdfJulD.format(eDate)}:"
tWork = s2Time.toString()
tW2 = tWork.substring(tWork.indexOf("T")+1,tWork.indexOf("T")+6)
tW3 = tW2.split(":")
p2 = "${tW3[0]}${tW3[1]}:"
tWork = e2Time.toString()
tW2 = tWork.substring(tWork.indexOf("T")+1,tWork.indexOf("T")+6)
tW3 = tW2.split(":")
p3 = "${tW3[0]}${tW3[1]}\n"
newEntry = "${p1}${p2}${p3}"
if(debugEnabled)
log.debug newEntry
fBuffer += newEntry
uploadHubFile("${app.id}.txt",fSort(fBuffer).getBytes('UTF-8'))
app.removeSetting("entryDate")
paragraph ""
}
}
paragraph getInputElemStr(name:'useAvg', type:'bool', width:'16em', radius:'12px', background:'#e6ffff', title:'Schedule using daily average ', defaultValue: "${settings['useAvg']}",hoverText:"Turn on to schedule using average daily values from the data file")
if(useAvg){
setByAvg()
schedule("0 17 4 * * ? *", "setByAvg")
} else {
unschedule("setByAvg")
unschedule("forceClose")
unschedule("forceOpen")
}
}
}
}
def evtLux(evt){
holdLevel = state?.preLightLevel
holdTime = state?.preLightUpdate
if(debugEnabled)
log.debug "lux 1 - $holdLevel ${state.preLightLevel} ${state.lightLevel}"
if(state.lightLevel)
state.preLightLevel = state.lightLevel
else
state.preLightLevel = Integer.parseInt(evt.value)
state.preLightUpdate = state?.lightUpdateTime
state.lightLevel = Integer.parseInt(evt.value)
state.lightUpdateTime = new Date().getTime()
if(!holdLevel || holdLevel < 0)
holdLevel = Integer.parseInt(evt.value)
if(!holdTime || holdTime < 0)
holdTime = new Long(0)
if(debugEnabled)
log.debug "lux 2 - $holdLevel ${state.preLightLevel} ${state.lightLevel}"
if(state.lightUpdateTime - holdTime >= (10*60*1000)){// Only calculate a new average and check whether to open/close every 10 minutes
state.avgLight = (holdLevel + state.preLightLevel + state.lightLevel)/3
openCheck()
}
}
def evtWind(evt){
holdLevel = state?.preWindSpeed
holdTime = state?.preWindUpdate
if(state.windSpeed)
state.preWindSpeed = state?.windSpeed
else
state.preWindSpeed = Float.parseFloat(evt.value)
state.preWindUpdate = state?.windUpdateTime
state.windSpeed = Float.parseFloat(evt.value)
state.windUpdateTime = new Date().getTime()
if(!holdLevel || holdLevel < 0)
holdLevel = Float.parseFloat(evt.value)
if(!holdTime || holdTime < 0)
holdTime = new Long(0)
if(Float.parseFloat(evt.value) > (1.2*maxWind)) { // open the shade if wind gust of 120% of max
forceOpen()
} else if(state.windUpdateTime - holdTime >= (10*60*1000)){
state.avgWind = (holdLevel + state.preWindSpeed + state.windSpeed)/3
openCheck()
}
}
def evtRain(evt){
holdLevel = state?.preRainRate
holdTime = state?.preRainUpdate
if(state.rainRate)
state.preRainRate = state?.rainRate
else
state.preRainRate = Float.parseFloat(evt.value)
state.preRainUpdate = state?.rainUpdateTime
state.rainRate = Float.parseFloat(evt.value)
state.rainUpdateTime = new Date().getTime()
if(!holdLevel || holdLevel < 0)
holdLevel = Float.parseFloat(evt.value)
if(!holdTime || holdTime < 0)
holdTime = new Long(0)
if(state.rainUpdateTime - holdTime >= (10*60*1000)){
state.avgRain = (holdLevel + state.preRainRate + state.rainRate)/3
openCheck()
}
}
def evtTime(evt) { //sunrise sunset check
openCheck()
}
void forceOpen(){
if (avgWind && maxWind && avgWind >= maxWind) // don't time force if recorded wind is too high
return
if (avgRain && maxRain && avgRain >= maxRain) // don't time force if recorded rain rate is too high
return
if(ePauseDate && sPauseDate){
tNow = new Date()
dWork = sPauseDate.split('-')
sDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
dWork = ePauseDate.split('-')
eDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
if(sDate <= tNow && eDate > tNow) //app is paused
return
else if(pauseReoccur && eDate < tNow)
setReoccur()
}
shadeDev.each {
if(!reverseDir){
it.open()
if(debugEnabled)
log.debug "Opening $it ${location.sunrise} $tNow ${location.sunset} ${state.avgWind} $maxWind ${state.avgLight} $minLight"
} else {
it.close()
if(debugEnabled)
log.debug "Closing $it ${location.sunrise} $tNow ${location.sunset} ${state.avgWind} $maxWind ${state.avgLight} $minLight"
}
}
if(!reverseDir){
state.lastCommand = 'open'
} else {
state.lastCommand = 'close'
}
}
void forceClose(){
if(ePauseDate && sPauseDate){
tNow = new Date()
dWork = sPauseDate.split('-')
sDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
dWork = ePauseDate.split('-')
eDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
if(sDate <= tNow && eDate > tNow) //app is paused
return
else if(pauseReoccur && eDate < tNow)
setReoccur()
}
shadeDev.each {
if(!reverseDir){
it.close()
if(debugEnabled)
log.debug "Closing $it ${location.sunrise} $tNow ${location.sunset} ${state.avgWind} $maxWind ${state.avgLight} $minLight"
} else {
it.open()
if(debugEnabled)
log.debug "Opening $it ${location.sunrise} $tNow ${location.sunset} ${state.avgWind} $maxWind ${state.avgLight} $minLight"
}
}
if(!reverseDir){
state.lastCommand = 'close'
} else {
state.lastCommand = 'open'
}
}
void openCheck(){
if(eTime || sTime) // open and close are being overridden
return
Date tNow = new Date()
if(ePauseDate && sPauseDate){
dWork = sPauseDate.split('-')
sDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
dWork = ePauseDate.split('-')
eDate = new Date(dWork[0].toInteger()-1900,dWork[1].toInteger()-1,dWork[2].toInteger())
if(sDate <= tNow && eDate > tNow) //app is paused
return
else if(pauseReoccur && eDate < tNow)
setReoccur()
}
if(tNow > location.sunrise && tNow < location.sunset && state.avgWind < maxWind && state.avgLight >= minLight && state.avgRain < minRain ) {
forceOpen()
} else {
forceClose()
}
}
void setReoccur(){
sdWork = sPauseDate.split('-')
sDate = new Date(sdWork[0].toInteger()-1900,sdWork[1].toInteger()-1,sdWork[2].toInteger())
edWork = ePauseDate.split('-')
eDate = new Date(edWork[0].toInteger()-1900,edWork[1].toInteger()-1,edWork[2].toInteger())
long eDateL = eDate.getTime()
long sDateL = sDate.getTime()
Date tNow = new Date()
long tNowL = tNow.getTime()
if(pauseReoccur == 'Weekly') {
while (sDateL < tNowL) {
sDateL += (7*24*60*60*1000)
eDateL += (7*24*60*60*1000)
}
sDate = new Date(sDateL)
eDate = new Date(eDateL)
} else if(pauseReoccur == 'Monthly') {
while(sDate < tNow) {
sDate.setMonth(sDate.getMonth() % 12 + 1)
eDate.setMonth(eDate.getMonth() % 12 + 1)
}
} else if(pauseReoccur == 'Annually') {
while(sDate < tNow) {
sDate.setYear(sDate.getYear() + 1)
eDate.setYear(eDate.getYear() + 1)
}
}
app.updateSetting('sPauseDate',[value:"${formatDatePref(sDate)}", type:'date'])
app.updateSetting('ePauseDate',[value:"${formatDatePref(eDate)}",type:'date'])
}
String formatDatePref(inDate) {
mWork = inDate.getMonth()+1
if(mWork < 10)
mWork = "0${mWork}"
dWork = inDate.getDate()
if(dWork < 10)
dWork = "0${dWork}"
prefStr = "${inDate.getYear()+1900}-${mWork}-${dWork}"
return prefStr
}
String fSort(fBuff){
String sortedBuff = ''
fList = fBuff.split("\n")
fList = fList.sort()
fList.each{
sortedBuff += "$it\n"
}
return sortedBuff
}
List dailyAvg(){
String fBuffer=''
try {
fBuffer = new String(downloadHubFile("${app.id}.txt"),"UTF-8")
} catch (e) {
fBuffer = '000:1100:1300\n'
uploadHubFile("${app.id}.txt",fBuffer.getBytes('UTF-8'))
}
fList = fBuffer.split("\n")
List aList = []
String dayHold = '000'
int i=0
int sHold=0
int eHold=0
List dRec = []
fList.each{
dRec = it.split(':')
if (dRec[0] == dayHold){
sHold += Integer.parseInt(dRec[1])
eHold += Integer.parseInt(dRec[2])
i++
} else {
if(debugEnabled)
log.debug "$dayHold $sHold $eHold $aList"
sHoldS = (sHold/i).intValue().toString()
eHoldS = (eHold/i).intValue().toString()
if(debugEnabled)
log.debug "$dayHold $sHoldS $eHoldS $aList"
while (sHoldS.size() < 4){
sHoldS = "0$sHoldS"
}
while (eHoldS.size() < 4){
eHoldS = "0$eHoldS"
}
aList.add("${dayHold}:${sHoldS}:${eHoldS}")
i=1
sHold=Integer.parseInt(dRec[1])
eHold=Integer.parseInt(dRec[2])
dayHold = dRec[0]
}
}
sHoldS = (sHold/i).intValue().toString()
eHoldS = (eHold/i).intValue().toString()
while (sHoldS.size() < 4){
sHoldS = "0$sHoldS"
}
while (eHoldS.size() < 4){
eHoldS = "0$eHoldS"
}
aList.add("${dayHold}:${sHoldS}:${eHoldS}")
return aList
}
void setByAvg() {
SimpleDateFormat sdfJulD = new SimpleDateFormat("DDD")
List aList = dailyAvg()
String targetInx = sdfJulD.format(new Date())
String saTime
String eaTime
aList.each {
item = it.split(':')
if(item[0] <= targetInx){
saTime = item[1]
eaTime = item[2]
}
}
if(debugEnabled)
log.debug "$aList $saTime $eaTime $targetInx"
app.updateSetting('sTime',[type:"time",value:"${saTime.substring(0,2)}:${saTime.substring(2,4)}"])
app.updateSetting('eTime',[type:"time",value:"${eaTime.substring(0,2)}:${eaTime.substring(2,4)}"])
schedule("0 ${Integer.parseInt(saTime.substring(2,4))} ${Integer.parseInt(saTime.substring(0,2))} * * ? *", "forceClose")
schedule("0 ${Integer.parseInt(eaTime.substring(2,4))} ${Integer.parseInt(eaTime.substring(0,2))} * * ? *", "forceOpen")
}
void setByOffset(){
SimpleDateFormat sdfIn = new SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy")
if(begSunRiseSet){
if(begSunRiseSet == 'Sunrise'){
workTime = sdfIn.parse(location.sunrise.toString())
} else
workTime = sdfIn.parse(location.sunset.toString())
if(begOffset){
begOffset = begOffset * 60000
} else
begOffSet = 0
nTime = new Date(workTime.getTime() + begOffset)
nHours = nTime.getHours().toString()
if(nHours.size()<2)
nHours="0${nHours}"
nMinutes = nTime.getMinutes().toString()
if(nMinutes.size()<2)
nMinutes="0${nMinutes}"
app.updateSetting('sTime',[type:"time",value:"${nHours}:${nMinutes}"])
}
if(endSunRiseSet){
if(endSunRiseSet == 'Sunrise'){
workTime = sdfIn.parse(location.sunrise.toString())
} else
workTime = sdfIn.parse(location.sunset.toString())
if(begOffset){
begOffset = begOffset * 60000
} else
begOffSet = 0
nTime = new Date(workTime.getTime() + begOffset)
nHours = nTime.getHours().toString()
if(nHours.size()<2)
nHours="0${nHours}"
nMinutes = nTime.getMinutes().toString()
if(nMinutes.size()<2)
nMinutes="0${nMinutes}"
app.updateSetting('eTime',[type:"time",value:"${nHours}:${nMinutes}"])
}
checkSched()
}
void checkSched(){
if(sTime) {
tWork = sTime.toString()
tW2 = tWork.substring(tWork.indexOf("T")+1,tWork.indexOf("T")+6)
tW3 = tW2.split(":")
schedule("0 ${Integer.parseInt(tW3[1])} ${Integer.parseInt(tW3[0])} * * ? *", "forceClose")
} else
unschedule("forceClose")
if(eTime) {
tWork = eTime.toString()
tW2 = tWork.substring(tWork.indexOf("T")+1,tWork.indexOf("T")+6)
tW3 = tW2.split(":")
schedule("0 ${Integer.parseInt(tW3[1])} ${Integer.parseInt(tW3[0])} * * ? *", "forceOpen")
} else
unschedule("forceOpen")
}
def appButtonHandler(btn) {
switch(btn) {
case "storeData":
state.updFile = true
break
default:
log.error "Undefined button $btn pushed"
break
}
}
/*
*
* Set of methods for UI elements
*
*
* Licensed Virtual 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, WIyTHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Date Who Description
* ---------- -------------- -------------------------------------------------------------------------
* 11Mar2025 thebearmay Add checkbox uiType, add trackColor and switchColor for type = bool
* 13Mar2025 Added hoverText, code cleanup
* 15Mar2025 Expand btnIcon to handle he- and fa- icons
* 18Mar2025 Add btnDivHide to hide/display div's (uiType='divHide')
* 03Apr2025 Enable a default value for enums
* 04Apr2025 Size option for icons
*/
import groovy.transform.Field
import java.text.SimpleDateFormat
library (
base: "app",
author: "Jean P. May Jr.",
category: "UI",
description: "Set of methods that allow the customization of the INPUT UI Elements",
name: "uiInputElements",
namespace: "thebearmay",
importUrl: "https://raw.githubusercontent.com/thebearmay/hubitat/main/libraries/uiInputElements.groovy",
version: "0.0.7",
documentationLink: ""
)
/************************************************************************
* Note: If using hoverText, you must add $ttStyleStr to at least one *
* element display *
************************************************************************/
String getInputElemStr(HashMap opt){
switch (opt.type){
case "text":
return inputItem(opt)
break
case "number":
return inputItem(opt)
break
case "decimal":
return inputItem(opt)
break
case "date":
return inputItem(opt)
break
case "time":
return inputItem(opt)
break
case "password":
return inputItem(opt)
break
case "color":
return inputItem(opt)
break
case "enum":
return inputEnum(opt)
break
case "mode":
return inputEnum(opt)
break
case "bool":
return inputBool(opt)
break
case "checkbox":
return inputCheckbox(opt)
break
case "button":
return buttonLink(opt)
break
case "icon":
return btnIcon(opt)
break
case "href":
return buttonHref(opt)
break
case "divHide":
return btnDivHide(opt)
break
default:
if(opt.type && (opt.type.contains('capability') || opt.type.contains('device')))
return inputCap(opt)
else
return "Type ${opt.type} is not supported"
break
}
}
String appLocation() { return "http://${location.hub.localIP}/installedapp/configure/${app.id}/" }
/*****************************************************************************
* Returns a string that will create an input element for an app - limited to *
* text, password, number, date and time inputs currently *
* *
* HashMap fields: *
* name - (required) name to store the input as a setting, no spaces or *
* special characters *
* type - (required) input type *
* title - displayed description for the input element *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* multiple - true/ *
* defaultValue - default for the field *
* radius - CSS border radius value (rounded corners) *
* hoverText - Text to display as a tool tip *
*****************************************************************************/
String inputItem(HashMap opt) {
if(!opt.name || !opt.type) return "Error missing name or type"
if(settings[opt.name] != null){
if(opt.type != 'time') {
opt.defaultValue = settings[opt.name]
} else {
SimpleDateFormat sdf = new SimpleDateFormat('HH:mm')
SimpleDateFormat sdfIn = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
opt.defaultValue = sdf.format(sdfIn.parse(settings[opt.name]))
}
}
typeAlt = opt.type
if(opt.type == 'number') {
step = ' step=\"1\" '
} else if (opt.type == 'decimal') {
step = ' step=\"any\" '
typeAlt = 'number'
} else {
step = ' '
}
String computedStyle = ''
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(!opt.multiple) opt.multiple = false
if(opt.hoverText && opt.hoverText != 'null'){
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
}
String retVal = "
"
retVal+=""
return retVal
}
/*****************************************************************************
* Returns a string that will create an input capability element for an app *
* *
* HashMap fields: *
* name - (required) name to store the input as a setting, no spaces or *
* special characters *
* type - (required) capability type, 'capability.' *
* title - displayed description for the input element *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* multiple - true/ *
* radius - CSS border radius value (rounded corners) *
* hoverText - Text to display as a tool tip *
*****************************************************************************/
String inputCap(HashMap opt) {
String computedStyle = ''
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize}"
if(opt.radius)
computedStyle += "border-radius:${opt.radius};"
else
opt.radius = '1px'
if(!opt.multiple) opt.multiple = false
String dList = ''
String idList = ''
int i=0
if(settings["${opt.name}"]){
ArrayList devNameId = []
settings["${opt.name}"].each{
devNameId.add([name:"${it.displayName}", devId:it.deviceId])
}
ArrayList devNameIdSorted = devNameId.sort(){it.name}
devNameIdSorted.each{
if(i>0) {
dList +=' '
idList += ','
}
dList+="${it.name}"
idList+="${it.devId}"
i++
}
} else {
dList = 'Click to set'
}
String capAlt = opt.type.replace('.','')
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
String retVal = "
"
retVal += ""//${computedStyle}
//retVal += "
"
retVal += "${opt.title} "
retVal += "
${dList} "
retVal += "
"
retVal += "
"
retVal += "
"
retVal += ""
retVal += ""
retVal += "Toggle All On/Off "
retVal += " "
retVal += "
"
retVal += "
"
retVal += "
Update
"
return retVal
}
/*****************************************************************************
* Returns a string that will create an input enum or mode element for an app *
* *
* HashMap fields: *
* name - (required) name to store the input as a setting, no spaces or *
* special characters *
* type - (required) capability type, *
* title - displayed description for the input element *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* multiple - true/ *
* options - list of values for the enum (modes will autofill) *
* defaultValue - default for the field *
* radius - CSS border radius value (rounded corners) *
* hoverText - Text to display as a tool tip *
*****************************************************************************/
String inputEnum(HashMap opt){
String computedStyle = ''
if(opt.type == 'mode') opt.options = location.getModes()
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(!opt.multiple) {
opt.multiple = false
mult = ' '
} else {
mult = 'multiple'
}
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
String retVal = "
"
retVal += ""
retVal += "
${opt.title} "
retVal += "
"
ArrayList selOpt = []
if(settings["${opt.name}"]){
if("${settings["${opt.name}"].class}" == 'class java.lang.String')
selOpt.add("${settings["${opt.name}"]}")
else {
settings["${opt.name}"].each{
selOpt.add("$it")
}
}
} else if(opt.defaultValue) selOpt.add("${opt.defaultValue}")
if(mult != 'multiple') retVal+="
Click to set "
opt.options.each{ option ->
if("$option".contains(':')){
optSplit = "$option".replace('[','').replace(']','').split(':')
optVal = optSplit[0]
optDis = optSplit[1]
} else {
optVal = option
optDis = option
}
sel = ' '
selOpt.each{
//log.debug "$it $optVal ${"$it" == "$optVal"}"
if("$it" == "$optVal" )
sel = 'selected'
}
retVal += "
${optDis} "
}
retVal+= "
"
return retVal
}
/*****************************************************************************
* Returns a string that will create an input boolean element for an app *
* *
* HashMap fields: *
* name - (required) name to store the input as a setting, no spaces or *
* special characters *
* type - (required) capability type, *
* title - displayed description for the input element *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* defaultValue - default for the field *
* radius - CSS border radius value (rounded corners) *
* trackColor - CSS color descriptor for the switch track *
* switchColor - CSS color descriptor for the switch knob *
* hoverText - Text to display as a tool tip *
*****************************************************************************/
String inputBool(HashMap opt) {
if(!opt.name || !opt.type) return "Error missing name or type"
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
String computedStyle = ''
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(!opt.multiple) opt.multiple = false
String trackColor = ' '
String switchColor = ' '
if(opt.trackColor) trackColor = "background-color:$opt.trackColor"
if(opt.switchColor) switchColor = "background-color:$opt.switchColor"
if(settings["${opt.name}"]) opt.defaultValue = settings["${opt.name}"]
String retVal = "
"
retVal += ""
retVal+=""
retVal += "
"
retVal += "${opt.title}"
retVal += "
"
retVal += "
"
retVal += "
"
retVal += "
"
return retVal
}
/*****************************************************************************
* Returns a string that will create an input checkbox element for an app *
* *
* HashMap fields: *
* name - (required) name to store the input as a setting, no spaces or *
* special characters *
* type - (required) capability type, *
* title - displayed description for the input element *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* defaultValue - default for the field *
* radius - CSS border radius value (rounded corners) *
* cBoxColor - CSS color descriptor for the checkbox color *
* hoverText - Text to display as a tool tip *
*****************************************************************************/
String inputCheckbox(HashMap opt) {
if(!opt.name || !opt.type) return "Error missing name or type"
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
opt.type = 'bool'
String computedStyle = ''
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(!opt.multiple) opt.multiple = false
if(settings["${opt.name}"])
opt.defaultValue = settings["${opt.name}"]
else
opt.defaultValue = false
if(!opt.cBoxColor) opt.cBoxColor = '#000000'
String cbClass = 'he-checkbox-unchecked'
if(opt.defaultValue)
cbClass = 'he-checkbox-checked'
String retVal = "
"
retVal += ""
retVal+=""
retVal += "
"
retVal += "${opt.title} "
retVal += "
"
retVal += "
"
retVal += "
"
retVal += "
"
return retVal
}
/*****************************************************************************
* Returns a string that will create an button element for an app *
* *
* HashMap fields: *
* name - (required) name to identify the button, no spaces or *
* special characters *
* title - (required) button label *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* radius - CSS border radius descriptor (corner rounding) *
* hoverText - Text to display as a tool tip *
*****************************************************************************/
String buttonLink(HashMap opt) { //modified slightly from jtp10181's code
if(!opt.name || !opt.title )
return "Error missing name or title"
String computedStyle = 'cursor:pointer;text-align:center;box-shadow: 2px 2px 4px #71797E;'
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
return "
"
}
/*****************************************************************************
* Returns a string that will create an button HREF element for an app *
* *
* HashMap fields: *
* name - (required) name to identify the button, no spaces or *
* special characters *
* title - (required) button label *
* destPage - (required unless using destUrl) name of the app page to go to *
* destUrl - (required unless using destPage) URL for the external page *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* radius - CSS border radius descriptor (corner rounding) *
*****************************************************************************/
String buttonHref(HashMap opt) { //modified jtp10181's code
if(!opt.name || !opt.title )
return "Error missing name or title"
if(!opt.destPage && !opt.destUrl)
return "Error missing Destination info"
String computedStyle = 'cursor:pointer;text-align:center;box-shadow: 2px 2px 4px #71797E;'
if(opt.width) computedStyle += "width:${opt.width};min-width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(opt.destPage) {
inx = appLocation().lastIndexOf("/")
dest = appLocation().substring(0,inx)+"/${opt.destPage}"
} else if(opt.destUrl) {
dest=opt.destUrl
}
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
return "
"
}
/*****************************************************************************
* Returns a string that will create an button element to hide/display a div *
* for an app *
* HashMap fields: *
* name - (required) name to identify the button, no spaces or *
* special characters *
* title - (required) button label *
* divName - (require) name of the division to hide or display *
* hidden - if true will hide the div immediately *
* width - CSS descriptor for field width *
* background - CSS color descriptor for the input background color *
* color - CSS color descriptor for text color *
* fontSize - CSS text size descriptor *
* radius - CSS border radius descriptor (corner rounding) *
*****************************************************************************/
String btnDivHide(HashMap opt) {
if(!opt.name || !opt.title || !opt.divName)
return "Error missing name, title or division"
String computedStyle = 'cursor:pointer;box-shadow: 2px 2px 4px #71797E;'
if(!opt.width) opt.width = '100%'
computedStyle += "width:${opt.width};"
if(opt.background) computedStyle += "background-color:${opt.background};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.fontSize) computedStyle += "font-size:${opt.fontSize};"
if(opt.radius) computedStyle += "border-radius:${opt.radius};"
if(opt.destPage) {
inx = appLocation().lastIndexOf("/")
dest = appLocation().substring(0,inx)+"/${opt.destPage}"
} else if(opt.destUrl) {
dest=opt.destUrl
}
String btnElem = " "
String script= ""
if(opt.hidden){
btnElem = " "
script=""
}
opt.title = "${btnElem} ${opt.title}"
if(opt.hoverText && opt.hoverText != 'null')
opt.title ="${opt.title} ${btnIcon([name:'fa-circle-info'])}${opt.hoverText}
"
return "$script
"
}
/*****************************************************************************
* Returns a string that will create an button icon element for an app from *
* the materials-icon font *
* *
* name - (required) name of the icon to create *
*****************************************************************************/
String btnIcon(HashMap opt) { //modified from jtp10181's code
String computedStyle = ' '
if(opt.size) computedStyle += "font-size:${opt.size};"
if(opt.color) computedStyle += "color:${opt.color};"
if(opt.name.startsWith('he'))
return " "
else if(opt.name.startsWith('fa'))
return " "//fa-circle-info
else
return "${opt.name} "
}
@Field static String ttStyleStr = ""
@Field static String tableStyle = ""