"
}
paragraph tableHtml
} else {
paragraph "No passing clouds or sudden drops recorded yet."
}
}
section("24-Hour Lux Trend vs. Expected Solar Baseline") {
if (state.luxHistory && state.luxHistory.size() > 2) {
if (chartSource == "SVG/HTML (Local)") {
paragraph generateLocalLineChart()
} else {
def chartUrl = generateChartUrl()
paragraph "
"
}
} else {
paragraph "Collecting data... Graph will appear once enough data points are gathered."
}
}
// --- HIDDEN/COLLAPSIBLE SECTIONS ---
section("Graph Calibration (Solar Baseline)", hideable: true, hidden: true) {
input "useSmartLearning", "bool", title: "Enable Smart Learning Mode", defaultValue: true, submitOnChange: true,
description: "Logs the daily max lux to automatically set your Expected Peak Clear-Sky Brightness. Automatically rejects bad weather days from the dataset."
input "learningDaysReq", "enum", title: "Required Learning Days", options: ["10", "20", "30"], defaultValue: "30", submitOnChange: true,
description: "How many days of valid data must be collected before the algorithm shifts from your manual fallback to dynamic tracking?"
input "peakClearLux", "number", title: "Expected Peak Clear-Sky Brightness (Lux)", defaultValue: 10000,
description: "Manual fallback value. Set this to whatever your sensor typically reads at Solar Noon on a perfectly clear day. This scales the theoretical sun curve on your graph."
}
section("Smart Room Darkness Detection", hideable: true, hidden: true) {
paragraph "Configure indoor rooms to dynamically track natural light. The app will calculate a seasonal 'Darkness Threshold' for each room and output it to a Hub Variable for your lighting automations."
input "numRooms", "number", title: "Number of Smart Rooms to Configure (0-12)", defaultValue: 0, submitOnChange: true, range: "0..12"
if (numRooms && numRooms > 0) {
for (int i = 1; i <= numRooms; i++) {
def rmNum = i
paragraph "Room ${rmNum} Configuration"
input "roomName_${rmNum}", "string", title: "Room Name"
input "roomLux_${rmNum}", "capability.illuminanceMeasurement", title: "Indoor Lux Sensor"
input "roomShades_${rmNum}", "capability.contactSensor", title: "Shade Contact Sensor(s) (Closed = Ignored)", multiple: true
input "roomLights_${rmNum}", "capability.switch", title: "Room Lights (ON = Ignored)", multiple: true
input "roomPeakLux_${rmNum}", "number", title: "Expected Peak Brightness (Lux)",
description: "What does this room's sensor typically read at peak daylight on a clear day?"
input "roomBaseLux_${rmNum}", "number", title: "Base Darkness Threshold (Lux)",
description: "The brightness level at which you consider this room 'dark' (The app will scale this ratio automatically)."
input "roomVar_${rmNum}", "string", title: "Target Hub Variable Name (Number)",
description: "Exact string name of the Hub Variable. The app will write the daily calculated setpoint here."
}
}
}
section("Application History (Last 20 Events)", hideable: true, hidden: true) {
if (state.historyLog && state.historyLog.size() > 0) {
def logText = state.historyLog.join(" ")
paragraph "
${logText}
"
} else {
paragraph "No history available yet. The log will populate as the app takes action."
}
}
section("Sensor & Control Targets", hideable: true, hidden: true) {
input "chartSource", "enum", title: "Chart Source Engine", options: ["QuickChart.io (Cloud)", "SVG/HTML (Local)"], defaultValue: "QuickChart.io (Cloud)",
description: "Local generation runs completely offline but is visually simpler. Cloud generation creates advanced images via the QuickChart API."
paragraph "Outdoor Sensor Array: Provide at least a Primary Sensor."
input "primaryLuxSensor", "capability.illuminanceMeasurement", title: "Primary Outdoor Lux Sensor", required: true
input "auxLuxSensor1", "capability.illuminanceMeasurement", title: "Auxiliary Outdoor Lux Sensor 1", required: false
input "auxLuxSensor2", "capability.illuminanceMeasurement", title: "Auxiliary Outdoor Lux Sensor 2", required: false
input "auxLuxSensor3", "capability.illuminanceMeasurement", title: "Auxiliary Outdoor Lux Sensor 3", required: false
input "averageSensors", "bool", title: "Average all active outdoor sensors? (Smarter detection)", defaultValue: true,
description: "If ON, the app will drop the highest/lowest readings and average the rest for system logic. If OFF, it will only use the Primary Sensor for logic (but will still graph all of them)."
input "sensorInterval", "number", title: "Sensor Update Interval (Minutes)", defaultValue: 15,
description: "How often your sensor reports data. The app dynamically scales its math windows based on this limitation so it doesn't miss sudden drops."
paragraph "Control Targets: Select one or both. The Virtual Switch handles binary logic (ON/OFF). The Virtual Dimmer scales brightness based on storm severity."
input "targetSwitch", "capability.switch", title: "Virtual Switch (ON = Overcast/Dark)"
input "targetDimmer", "capability.switchLevel", title: "Virtual Dimmer (Proportional Brightness)"
input "masterEnableSwitch", "capability.switch", title: "Master System Enable Switch"
input "activeModes", "mode", title: "Active Modes (App only runs in these)", multiple: true
}
section("Audio & Notification Routing", hideable: true, hidden: true) {
paragraph "Configure announcements and notifications when the system detects Overcast (Switch ON) or Clear Skies (Switch OFF)."
input "notifyDevices", "capability.notification", title: "Push Notification Devices", multiple: true, required: false
input "ttsDevices", "capability.speechSynthesis", title: "TTS Audio Devices", multiple: true, required: false
input "soundDevices", "capability.audioNotification", title: "Sound/Chime Devices (e.g., Zooz)", multiple: true, required: false
input "audioVolume", "number", title: "Announcement Volume (%)", defaultValue: 65, range: "1..100"
paragraph "▶ Overcast / Dark (Switch ON)"
input "overcastAnnounceModes", "mode", title: "Restrict Overcast Announcements to these Modes", multiple: true, required: false
input "overcastNotifyMsg", "text", title: "Push Notification Message", required: false, defaultValue: "Overcast conditions detected. Adjusting lighting."
input "overcastTTSMsg", "text", title: "TTS Message", required: false, defaultValue: "Overcast conditions detected."
input "overcastSoundUrl", "text", title: "Sound File URL or Track Number", required: false
input "testOvercastBtn", "button", title: "🔊 Test Overcast Outputs"
paragraph "▶ Clear Sky / Bright (Switch OFF)"
input "clearAnnounceModes", "mode", title: "Restrict Clear Sky Announcements to these Modes", multiple: true, required: false
input "clearNotifyMsg", "text", title: "Push Notification Message", required: false, defaultValue: "Clear skies detected. Restoring lighting."
input "clearTTSMsg", "text", title: "TTS Message", required: false, defaultValue: "Clear skies detected."
input "clearSoundUrl", "text", title: "Sound File URL or Track Number", required: false
input "testClearBtn", "button", title: "🔊 Test Clear Outputs"
}
section("Proportional Dimming Setup", hideable: true, hidden: true) {
paragraph "Maps the virtual dimmer level using a logarithmic curve for natural eye perception."
input "heavyStormLux", "number", title: "Heavy Storm Limit (Lux)", defaultValue: 500,
description: "If lux drops to this level, the dimmer hits Max Brightness."
input "maxDimLevel", "number", title: "Max Brightness Level (%)", defaultValue: 100, range: "1..100"
input "minDimLevel", "number", title: "Min Brightness Level (%)", defaultValue: 20, range: "1..100",
description: "The starting brightness when it just barely crosses the Overcast threshold."
input "nightDimLevel", "number", title: "Nighttime Brightness Level (%)", defaultValue: 100, range: "1..100"
}
section("Hysteresis & Thresholds (The Deadband)", hideable: true, hidden: true) {
input "useSmartThresholds", "bool", title: "Enable Smart Threshold Scaling?", defaultValue: true, submitOnChange: true,
description: "Automatically scales your Overcast and Clear Sky limits proportionally as the Expected Peak Clear-Sky Brightness changes with the seasons."
input "overcastThreshold", "number", title: "Base Overcast Drop Threshold (Lux)", defaultValue: 2000,
description: "If lux drops below this, start the Overcast timer. (Acts as the baseline ratio if Smart Thresholds are enabled)."
input "clearThreshold", "number", title: "Base Clear Sky Recovery Threshold (Lux)", defaultValue: 4000,
description: "If lux rises above this, start the Clear Sky timer. (Acts as the baseline ratio if Smart Thresholds are enabled)."
input "debounceTime", "number", title: "Anti-Yo-Yo Debounce Time (Minutes)", defaultValue: 10,
description: "How long the sky must stay below/above the threshold before flipping the virtual outputs."
input "useDynamicClear", "bool", title: "Enable Automatic Time-of-Year & Time-of-Day Adjustments?", defaultValue: true, submitOnChange: true,
description: "If enabled, the Clear Sky Recovery Threshold dynamically curves based on solar position and season to prevent evening/winter yo-yoing."
}
section("Universal Darkness (Nighttime Logic)", hideable: true, hidden: true) {
input "useAstro", "bool", title: "Apply Nighttime Logic?", defaultValue: true, submitOnChange: true
if (useAstro) {
input "nightAction", "enum", title: "When the sun sets, force the virtual outputs:", options: ["Turn OFF (Clear/Night)", "Turn ON (Dark/Overcast)", "Do Nothing (Leave as is)"], defaultValue: "Turn ON (Dark/Overcast)",
description: "Select 'Turn ON' to ensure your motion lighting automations function properly all night."
input "sunriseOffset", "number", title: "Sunrise Offset (Minutes, +/-)", defaultValue: 0
input "sunsetOffset", "number", title: "Sunset Offset (Minutes, +/-)", defaultValue: 0
}
}
}
}
def installed() {
log.info "Advanced Overcast Detector Installed."
initialize()
}
def updated() {
log.info "Advanced Overcast Detector Updated."
unsubscribe()
unschedule()
initialize()
}
def initialize() {
state.historyLog = state.historyLog ?: []
state.luxHistory = state.luxHistory ?: []
state.cloudHistory = state.cloudHistory ?: []
state.peakLuxHistory = state.peakLuxHistory ?: []
state.dailyMaxLux = state.dailyMaxLux ?: 0
state.activeCloudEvent = null
state.currentCondition = "Evaluating..."
state.pendingOvercast = false
state.pendingClear = false
state.isNight = false
state.lastLuxCheckTime = now()
state.lastLuxValue = null
state.dipReason = null
// Reset peak trackers on boot
state.recentPeakLux = null
state.recentPeakTime = null
// Initialize Smart Room Data Structure
if (!state.roomData) state.roomData = [:]
def configuredRooms = numRooms ?: 0
for (int i = 1; i <= configuredRooms; i++) {
if (!state.roomData["${i}"]) {
state.roomData["${i}"] = [dailyMax: 0, peakHistory: [], currentSetpoint: settings["roomBaseLux_${i}"] ?: 0]
}
def rLux = settings["roomLux_${i}"]
if (rLux) subscribe(rLux, "illuminance", roomLuxHandler)
}
if (primaryLuxSensor) subscribe(primaryLuxSensor, "illuminance", luxHandler)
if (auxLuxSensor1) subscribe(auxLuxSensor1, "illuminance", luxHandler)
if (auxLuxSensor2) subscribe(auxLuxSensor2, "illuminance", luxHandler)
if (auxLuxSensor3) subscribe(auxLuxSensor3, "illuminance", luxHandler)
subscribe(location, "mode", modeHandler)
if (useAstro) {
scheduleAstro()
schedule("0 1 0 * * ?", scheduleAstro)
checkInitialAstroState()
} else {
state.isNight = false
}
runEvery15Minutes(logGraphData)
forceImmediateEvaluation()
}
// --- BUTTON HANDLER ---
def appButtonHandler(btn) {
if (btn == "refreshBtn") {
log.info "Manual Data Refresh Requested."
}
if (btn == "testOvercastBtn") {
announceEvent("test_overcast")
}
if (btn == "testClearBtn") {
announceEvent("test_clear")
}
}
// --- UTILITY: SMART LEARNING HELPER ---
def getExpectedPeakLux() {
def reqDays = (settings.learningDaysReq ?: "30").toInteger()
if (useSmartLearning && state.peakLuxHistory && state.peakLuxHistory.size() >= reqDays) {
return (state.peakLuxHistory.sum() / state.peakLuxHistory.size()).toInteger()
}
return peakClearLux ?: 10000
}
// --- UTILITY: SMART THRESHOLDS ---
def getSmartOvercastThreshold() {
def baseOver = overcastThreshold ?: 2000
if (!useSmartThresholds) return baseOver
def basePeak = peakClearLux ?: 10000
def currentPeak = getExpectedPeakLux()
def ratio = baseOver / basePeak
return (currentPeak * ratio).toInteger()
}
def getSmartClearThreshold() {
def baseClear = clearThreshold ?: 4000
if (!useSmartThresholds) return baseClear
def basePeak = peakClearLux ?: 10000
def currentPeak = getExpectedPeakLux()
def ratio = baseClear / basePeak
return (currentPeak * ratio).toInteger()
}
// --- UTILITY: CLOUD EVENT LOGGER ---
def closeActiveCloudEvent() {
if (!state.activeCloudEvent) return
def endTime = now()
def durationSecs = ((endTime - state.activeCloudEvent.startTime) / 1000).toInteger()
def durationStr = ""
if (durationSecs < 60) {
durationStr = "${durationSecs} sec"
} else {
def mins = (durationSecs / 60).toInteger()
def secs = durationSecs % 60
durationStr = "${mins}m ${secs}s"
}
def maxDrop = state.activeCloudEvent.startLux - state.activeCloudEvent.minLux
def eventTime = new Date(state.activeCloudEvent.startTime).format("MM/dd HH:mm", location.timeZone)
def newEvent = [
time: eventTime,
duration: durationStr,
drop: "${maxDrop} lx",
minLux: "${state.activeCloudEvent.minLux} lx"
]
if (!state.cloudHistory) state.cloudHistory = []
state.cloudHistory.add(0, newEvent)
if (state.cloudHistory.size() > 15) state.cloudHistory = state.cloudHistory.take(15)
state.activeCloudEvent = null
}
// --- UTILITY: HISTORY LOGGER ---
def addToHistory(String msg) {
if (!state.historyLog) state.historyLog = []
def timestamp = new Date().format("MM/dd HH:mm", location.timeZone)
state.historyLog.add(0, "[${timestamp}] ${msg}")
if (state.historyLog.size() > 20) state.historyLog = state.historyLog.take(20)
def cleanMsg = msg.replaceAll("\\<.*?\\>", "")
log.info "HISTORY: [${timestamp}] ${cleanMsg}"
}
// --- SMART ROOM LUX EVALUATION ---
def roomLuxHandler(evt) {
// Only learn the room's natural baseline when the sun is shining
if (state.currentCondition == "Overcast" || state.isNight) return
def devId = evt.device.id
def lux = evt.value.toInteger()
def configuredRooms = numRooms ?: 0
for (int i = 1; i <= configuredRooms; i++) {
def rLux = settings["roomLux_${i}"]
if (rLux && rLux.id == devId) {
// Check for exclusions: Shades drawn or Lights on
def shades = settings["roomShades_${i}"]
def lights = settings["roomLights_${i}"]
def shadesClosed = shades ? shades.any { it.currentValue("contact") == "closed" } : false
def lightsOn = lights ? lights.any { it.currentValue("switch") == "on" } : false
if (!shadesClosed && !lightsOn) {
def rData = state.roomData["${i}"]
if (rData) {
if (lux > (rData.dailyMax ?: 0)) {
rData.dailyMax = lux
}
}
}
break // Found the sensor, no need to loop further
}
}
}
// --- SENSOR AGGREGATION & SOLAR CALCS ---
def getAggregateLux() {
if (!primaryLuxSensor) return 0
if (!averageSensors) return primaryLuxSensor.currentValue("illuminance")?.toInteger() ?: 0
def sensors = [primaryLuxSensor, auxLuxSensor1, auxLuxSensor2, auxLuxSensor3].findAll { it != null }
def values = sensors.collect { it.currentValue("illuminance")?.toInteger() ?: 0 }
if (values.size() == 0) return 0
if (values.size() == 1) return values[0]
if (values.size() > 2) {
values.sort()
values = values[1..-2] // Drop highest and lowest to filter outliers
}
return (values.sum() / values.size()).toInteger()
}
// --- DYNAMIC CLEAR SKY CALCULATOR ---
def getDynamicClearThreshold() {
def baseClear = getSmartClearThreshold()
def baseOvercast = getSmartOvercastThreshold()
if (!useDynamicClear) return baseClear
def sunInfo = getSunriseAndSunset()
if (!sunInfo || !sunInfo.sunrise || !sunInfo.sunset) return baseClear
def nowTime = new Date().time
def sunrise = sunInfo.sunrise.time
def sunset = sunInfo.sunset.time
// Outside daylight hours, return base
if (nowTime < sunrise || nowTime > sunset) return baseClear
// 1. Calculate Time-of-Day Arc (0.0 to 1.0)
def totalDaylightMillis = sunset - sunrise
def currentDaylightMillis = nowTime - sunrise
def dayPercentage = currentDaylightMillis / totalDaylightMillis
def timeMultiplier = Math.sin(dayPercentage * Math.PI)
// 2. Calculate Seasonal Arc (Day of Year)
def cal = Calendar.getInstance()
def dayOfYear = cal.get(Calendar.DAY_OF_YEAR)
// Approx Summer Solstice (172) as Peak, Winter Solstice (355) as Trough
def seasonalOffset = ((dayOfYear - 172) / 365.0) * (Math.PI * 2)
def seasonMultiplier = 0.7 + (0.3 * Math.cos(seasonalOffset))
// 3. Combine Math
def dynamicLimit = (baseClear * timeMultiplier * seasonMultiplier).toInteger()
// Keep a sensible minimum deadband so Clear Sky doesn't dip below the Overcast Threshold
def safeMinimum = baseOvercast + 500
return Math.max(dynamicLimit, safeMinimum)
}
// --- AUDIO & NOTIFICATION ROUTING ---
def announceEvent(eventType) {
def isTest = false
def pfx = eventType
if (eventType.startsWith("test_")) {
isTest = true
pfx = eventType.replace("test_", "")
}
def allowedModes = settings["${pfx}AnnounceModes"]
if (!isTest && allowedModes && !allowedModes.contains(location.mode)) {
log.info "Announcement for ${pfx} suppressed due to Mode Restriction."
return
}
def notifyMsg = settings["${pfx}NotifyMsg"]
if (notifyMsg && settings.notifyDevices) {
def finalMsg = isTest ? "[TEST] " + notifyMsg : notifyMsg
settings.notifyDevices.each { it.deviceNotification(finalMsg) }
addToHistory("Push Sent: ${finalMsg}")
}
def ttsMsg = settings["${pfx}TTSMsg"]
if (ttsMsg && settings.ttsDevices) {
def finalMsg = isTest ? "[TEST] " + ttsMsg : ttsMsg
def vol = settings.audioVolume ?: 65
try {
settings.ttsDevices.each { speaker ->
if (speaker.hasCommand("setVolume")) speaker.setVolume(vol)
if (speaker.hasCommand("speak")) speaker.speak(finalMsg)
else if (speaker.hasCommand("playText")) speaker.playText(finalMsg)
}
addToHistory("TTS Broadcasted: ${finalMsg}")
} catch (e) { log.error "TTS routing failed: ${e}" }
}
def soundUrl = settings["${pfx}SoundUrl"]
if (soundUrl && settings.soundDevices) {
def vol = settings.audioVolume ?: 65
def isNumeric = soundUrl.toString().isNumber()
def trackNum = isNumeric ? soundUrl.toString().toInteger() : null
try {
settings.soundDevices.each { player ->
if (player.hasCommand("setVolume")) player.setVolume(vol)
if (player.hasCommand("playSound") && trackNum != null) {
player.playSound(trackNum)
} else if (player.hasCommand("playTrack")) {
player.playTrack(soundUrl.toString())
} else if (player.hasCommand("chime") && trackNum != null) {
player.chime(trackNum)
} else {
log.error "${player.displayName} does not support standard audio commands."
}
}
addToHistory("Sound File Played: ${soundUrl}")
} catch (e) { log.error "Sound routing failed: ${e}" }
}
}
// --- UTILITY: GRAPHING & SOLAR BASELINE ---
def logGraphData() {
if (!primaryLuxSensor) return
def timestamp = new Date().format("HH:mm", location.timeZone)
def nowTime = now()
def s1 = primaryLuxSensor?.currentValue("illuminance")?.toInteger() ?: 0
def s2 = auxLuxSensor1?.currentValue("illuminance")?.toInteger() ?: 0
def s3 = auxLuxSensor2?.currentValue("illuminance")?.toInteger() ?: 0
def s4 = auxLuxSensor3?.currentValue("illuminance")?.toInteger() ?: 0
def expectedLux = 0
// ALWAYS trace standard theoretical curve based on peak calibration for visual reference
def sunInfo = getSunriseAndSunset()
if (sunInfo && sunInfo.sunrise && sunInfo.sunset) {
def sr = sunInfo.sunrise.time
def ss = sunInfo.sunset.time
if (nowTime >= sr && nowTime <= ss) {
def fraction = (nowTime - sr) / (ss - sr)
def peak = getExpectedPeakLux()
expectedLux = (peak * Math.sin(fraction * Math.PI)).toInteger()
}
}
if (!state.luxHistory) state.luxHistory = []
// Add new data point including expected baseline
state.luxHistory.add([time: timestamp, s1: s1, s2: s2, s3: s3, s4: s4, expected: expectedLux])
if (state.luxHistory.size() > 96) {
state.luxHistory = state.luxHistory.drop(1)
}
}
def generateChartUrl() {
if (!state.luxHistory) return ""
def labels = []
state.luxHistory.each { labels << "'${it.time}'" }
def datasets = []
if (primaryLuxSensor) {
def d = state.luxHistory.collect { it.s1 ?: (it.lux ?: 0) } // Backwards compatible with old logs
datasets << "{label:'Primary Lux',data:[${d.join(',')}],fill:false,borderColor:'rgba(54,162,235,1)',borderWidth:2,pointRadius:0}"
}
if (auxLuxSensor1) {
def d = state.luxHistory.collect { it.s2 ?: 0 }
datasets << "{label:'Aux 1',data:[${d.join(',')}],fill:false,borderColor:'rgba(255,99,132,1)',borderWidth:2,pointRadius:0}"
}
if (auxLuxSensor2) {
def d = state.luxHistory.collect { it.s3 ?: 0 }
datasets << "{label:'Aux 2',data:[${d.join(',')}],fill:false,borderColor:'rgba(75,192,192,1)',borderWidth:2,pointRadius:0}"
}
if (auxLuxSensor3) {
def d = state.luxHistory.collect { it.s4 ?: 0 }
datasets << "{label:'Aux 3',data:[${d.join(',')}],fill:false,borderColor:'rgba(153,102,255,1)',borderWidth:2,pointRadius:0}"
}
def expData = state.luxHistory.collect { it.expected ?: 0 }
datasets << "{label:'Expected Clear Sky',data:[${expData.join(',')}],fill:false,borderColor:'rgba(255,159,64,0.8)',borderWidth:2,borderDash:[5,5],pointRadius:0}"
// Compact JSON string to prevent URL encoding issues
def chartConfig = "{type:'line',data:{labels:[${labels.join(',')}],datasets:[${datasets.join(',')}]},options:{legend:{display:true,position:'bottom'},scales:{xAxes:[{ticks:{autoSkip:true,maxTicksLimit:8}}]}}}"
def encodedConfig = java.net.URLEncoder.encode(chartConfig, "UTF-8")
return "https://quickchart.io/chart?c=${encodedConfig}&w=600&h=300"
}
def generateLocalLineChart() {
if (!state.luxHistory || state.luxHistory.size() < 2) return "Collecting data for local graph..."
def width = 600
def height = 250
def maxLux = state.luxHistory.collect {
[it.s1 ?: (it.lux ?: 0), it.s2 ?: 0, it.s3 ?: 0, it.s4 ?: 0, it.expected ?: 0].max()
}.max() ?: 1000
if (maxLux < 1000) maxLux = 1000
def svg = ""
// Generate Legend
def legend = "
"
if (primaryLuxSensor) legend += "■ Primary"
if (auxLuxSensor1) legend += "■ Aux 1"
if (auxLuxSensor2) legend += "■ Aux 2"
if (auxLuxSensor3) legend += "■ Aux 3"
legend += "■ Expected"
legend += "