/** * Advanced Overcast Detector * * Author: ShaneAllen */ definition( name: "Advanced Overcast Detector", namespace: "ShaneAllen", author: "ShaneAllen", description: "Advanced environmental lux monitor with Proportional Dimming, Universal Darkness enforcement, Astro Countdowns, Cloud History, Audio Announcements, and Solar Baseline Graphing.", category: "Green Living", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Advanced Overcast Detector", install: true, uninstall: true) { // --- EXPOSED SECTIONS --- section("Live System Dashboard") { input name: "refreshBtn", type: "button", title: "🔄 Refresh Data" def statusText = "" statusText += "" def currentLux = primaryLuxSensor ? "${getAggregateLux()} lx" : "-- lx" // Environment Display (Showing Dynamic Calculations) def envDisplay = "
${currentLux}
" // Smart Threshold Display Logic def oLimit = getSmartOvercastThreshold() def oReason = useSmartThresholds ? "Smart Scaled" : "Static" envDisplay += "
Drop Target: ${oLimit} lx (${oReason})
" if (useDynamicClear) { def cLimit = getDynamicClearThreshold() envDisplay += "
Clear Target: ${cLimit} lx (Auto-Curve)
" } else { def cLimit = getSmartClearThreshold() def cReason = useSmartThresholds ? "Smart Scaled" : "Static" envDisplay += "
Clear Target: ${cLimit} lx (${cReason})
" } // SMART LEARNING DISPLAY def reqDays = (settings.learningDaysReq ?: "30").toInteger() def epcsb = getExpectedPeakLux() def epcsbReason = "" if (useSmartLearning) { def learnedDays = state.peakLuxHistory ? state.peakLuxHistory.size() : 0 if (learnedDays >= reqDays) { epcsbReason = "Learning Active (${reqDays}-day Avg)" } else { epcsbReason = "Collecting Data (${learnedDays}/${reqDays})" } } else { epcsbReason = "Manual User Setting" } def dailyMax = state.dailyMaxLux ?: 0 envDisplay += "
Daily Max Lux: ${dailyMax} lx
" envDisplay += "
EPCSB Target: ${epcsb} lx
(${epcsbReason})
" def sState = state.currentCondition ?: "Awaiting Sync..." def sColor = "orange" if (sState == "Overcast") sColor = "blue" if (sState == "Clear" || sState == "Assumed Clear (Boot)") sColor = "green" if (sState == "Nighttime") sColor = "purple" def pendingMsg = "" if (state.pendingOvercast) pendingMsg = "
Verifying Weather Event..." if (state.pendingClear) pendingMsg = "
Verifying Clear..." if (state.isNight) pendingMsg = "
Nighttime Hard-Lock Active" def vSwitch = targetSwitch ? targetSwitch.currentValue("switch")?.toUpperCase() : "NOT SET" def switchColor = (vSwitch == "ON") ? "green" : "black" def vDim = targetDimmer ? (targetDimmer.currentValue("switch") == "on" ? "${targetDimmer.currentValue('level')}%" : "OFF") : "NOT SET" def dimColor = (targetDimmer && targetDimmer.currentValue("switch") == "on") ? "blue" : "black" def outputsDisplay = "Switch:${vSwitch}
Dimmer:${vDim}" statusText += "" statusText += "
EnvironmentSystem EvaluationOutputs
${envDisplay}${sState}${pendingMsg}${outputsDisplay}
" // SMART ROOM TARGETS DISPLAY if (numRooms && numRooms > 0) { statusText += "
Smart Room Darkness Targets
" statusText += "" statusText += "" for (int i = 1; i <= numRooms; i++) { def rName = settings["roomName_${i}"] ?: "Room ${i}" def rData = state.roomData ? state.roomData["${i}"] : null def dMax = rData?.dailyMax ?: 0 def setpt = rData?.currentSetpoint ?: (settings["roomBaseLux_${i}"] ?: 0) def vName = settings["roomVar_${i}"] ?: "Not Configured" def daysLearned = rData?.peakHistory ? rData.peakHistory.size() : 0 def learningStatus = "" if (useSmartLearning) { learningStatus = (daysLearned >= reqDays) ? "${daysLearned}/${reqDays} (Active)" : "${daysLearned}/${reqDays} (Learning)" } else { learningStatus = "Disabled" } statusText += "" statusText += "" statusText += "" statusText += "" statusText += "" statusText += "" statusText += "" } statusText += "
RoomDaily MaxDays LearnedLearned SetpointTarget Variable
${rName}${dMax} lx${learningStatus}${setpt} lx${vName}
" // INJECT ROOM BAR GRAPH HERE statusText += generateRoomBarGraph() } // System Status Evaluation def sysStatus = "ACTIVE" if (isSystemPaused()) sysStatus = "PAUSED (Master Switch Off)" else if (!isModeAllowed()) sysStatus = "PAUSED (Restricted Mode)" // Astro Countdown Calculation def astroMsg = "Awaiting Astro Data..." if (useAstro) { def sunInfo = getSunriseAndSunset() if (sunInfo && sunInfo.sunset && sunInfo.sunrise) { def now = new Date() def sSetOffset = sunsetOffset ? sunsetOffset.toInteger() : 0 def actualSunset = new Date(sunInfo.sunset.time + (sSetOffset * 60000)) if (now.before(actualSunset) && now.after(sunInfo.sunrise)) { def diffMillis = actualSunset.time - now.time def h = (diffMillis / 3600000).toInteger() def m = ((diffMillis % 3600000) / 60000).toInteger() astroMsg = "Sunset Hard-Lock in: ${h}h ${m}m" } else { astroMsg = "Astro Status: Currently Nighttime" } } } else { astroMsg = "Astro Status: Disabled" } statusText += "
" statusText += "
System: ${sysStatus}
" statusText += "
${astroMsg}
" statusText += "
" paragraph statusText } section("Weather Event & Cloud History") { if (state.cloudHistory && state.cloudHistory.size() > 0) { def tableHtml = "" tableHtml += "" state.cloudHistory.each { event -> tableHtml += "" tableHtml += "" tableHtml += "" tableHtml += "" tableHtml += "" tableHtml += "" } tableHtml += "
Event StartDurationLux DropLowest Point
${event.time}${event.duration}-${event.drop}${event.minLux}
" if (state.activeCloudEvent) { tableHtml += "
☁️ Potential Clouding in progress...
" } 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 = "" // Background Grid & Y-Axis svg += "" svg += "${maxLux} lx" svg += "0 lx" def xStep = width / (state.luxHistory.size() - 1) // Helper Closure def makePolyline = { dataKey, color, strokeDash -> def pts = [] state.luxHistory.eachWithIndex { pt, i -> def x = (i * xStep).toInteger() // Backwards compatibility included for old 'lux' tag def val = pt[dataKey] ?: (dataKey == 's1' ? (pt.lux ?: 0) : 0) def y = height - ((val / maxLux) * height).toInteger() pts << "${x},${y}" } return "" } if (primaryLuxSensor) svg += makePolyline("s1", "rgba(54,162,235,1)", "none") if (auxLuxSensor1) svg += makePolyline("s2", "rgba(255,99,132,1)", "none") if (auxLuxSensor2) svg += makePolyline("s3", "rgba(75,192,192,1)", "none") if (auxLuxSensor3) svg += makePolyline("s4", "rgba(153,102,255,1)", "none") svg += makePolyline("expected", "rgba(255,159,64,0.8)", "5,5") 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 += "
" return "
${svg}${legend}
" } def generateRoomBarGraph() { def configuredRooms = numRooms ?: 0 if (configuredRooms == 0) return "" def html = "
" html += "
24-Hour Max Lux by Room
" def maxGlobal = 100 // baseline to avoid divide by zero errors for (int i = 1; i <= configuredRooms; i++) { def rData = state.roomData["${i}"] if (rData && rData.dailyMax > maxGlobal) maxGlobal = rData.dailyMax } for (int i = 1; i <= configuredRooms; i++) { def rName = settings["roomName_${i}"] ?: "Room ${i}" def rData = state.roomData["${i}"] def dMax = rData?.dailyMax ?: 0 def pct = (dMax / maxGlobal) * 100 html += "
${rName} (${dMax} lx)
" html += "
" html += "
" html += "
" } html += "
" return html } // --- SYSTEM CHECKS --- def isSystemPaused() { if (masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off") return true return false } def isModeAllowed() { if (!activeModes) return true return activeModes.contains(location.mode) } def modeHandler(evt) { if (!isModeAllowed()) { addToHistory("GLOBAL: Hub entered restricted mode (${evt.value}). Pausing detector.") unschedule("triggerOvercast") unschedule("triggerClear") state.pendingOvercast = false state.pendingClear = false } else { addToHistory("GLOBAL: Hub entered allowed mode (${evt.value}). Resuming detector.") evaluateLuxCondition() } } // --- PROPORTIONAL DIMMING ENGINE --- def updateDimmerLevel(currentLux) { if (!targetDimmer || isSystemPaused() || !isModeAllowed() || state.isNight) return def overLimit = getSmartOvercastThreshold() def stormLimit = heavyStormLux ?: 500 def maxLvl = maxDimLevel ?: 100 def minLvl = minDimLevel ?: 20 def targetLevel = minLvl if (currentLux <= stormLimit) { targetLevel = maxLvl } else if (currentLux >= overLimit) { targetLevel = minLvl } else { // Logarithmic interpolation for natural human eye perception def luxRange = overLimit - stormLimit def levelRange = maxLvl - minLvl def luxDrop = overLimit - currentLux def curve = Math.log10(1 + 9 * (luxDrop / luxRange)) def calcLevel = minLvl + (curve * levelRange) targetLevel = Math.round(calcLevel).toInteger() } def currentDimmerLevel = targetDimmer.currentValue("level")?.toInteger() ?: 0 def currentDimmerState = targetDimmer.currentValue("switch") // Mesh Protection if (currentDimmerState != "on" || Math.abs(currentDimmerLevel - targetLevel) > 2) { addToHistory("DIMMER: Dynamic adjustment to ${targetLevel}% (Lux: ${currentLux}).") targetDimmer.setLevel(targetLevel) } } // --- CORE LUX EVALUATION --- def luxHandler(evt) { evaluateLuxCondition() } def forceImmediateEvaluation() { if (!primaryLuxSensor || isSystemPaused() || !isModeAllowed() || (state.isNight && useAstro)) return def lux = getAggregateLux() def overLimit = getSmartOvercastThreshold() def clearLimit = getDynamicClearThreshold() def prevState = state.currentCondition if (lux <= overLimit) { state.currentCondition = "Overcast" if (targetSwitch && targetSwitch.currentValue("switch") != "on") targetSwitch.on() if (targetDimmer) updateDimmerLevel(lux) if (prevState != "Overcast" && prevState != "Evaluating...") announceEvent("overcast") } else if (lux >= clearLimit) { state.currentCondition = "Clear" if (targetSwitch && targetSwitch.currentValue("switch") != "off") targetSwitch.off() if (targetDimmer && targetDimmer.currentValue("switch") != "off") targetDimmer.off() if (prevState != "Clear" && prevState != "Evaluating..." && prevState != "Assumed Clear (Boot)") announceEvent("clear") } else { // Fallback baseline for deadband boot up state.currentCondition = "Assumed Clear (Boot)" addToHistory("SYSTEM BOOT: Booted inside deadband (${lux} lx). Assuming clear state to prevent stuck lights.") if (targetSwitch && targetSwitch.currentValue("switch") != "off") targetSwitch.off() if (targetDimmer && targetDimmer.currentValue("switch") != "off") targetDimmer.off() } } def evaluateLuxCondition() { if (isSystemPaused() || !isModeAllowed()) return if (state.isNight && useAstro) return if (!primaryLuxSensor) return def lux = getAggregateLux() def overLimit = getSmartOvercastThreshold() def clearLimit = getDynamicClearThreshold() def debounceSecs = (debounceTime ?: 10) * 60 def intervalMins = sensorInterval ?: 15 def timeNow = now() def timeDeltaMins = state.lastLuxCheckTime ? (timeNow - state.lastLuxCheckTime) / 60000 : 0 def luxDrop = state.lastLuxValue ? (state.lastLuxValue - lux) : 0 // --- SMART LEARNING: TRACK OUTDOOR DAILY MAX --- if (!state.dailyMaxLux || lux > state.dailyMaxLux) { state.dailyMaxLux = lux } // --- DYNAMIC TIMERS BASED ON SENSOR HARDWARE LIMITS --- def stalePeakMillis = (intervalMins * 3) * 60000 def detectionWindowMins = (intervalMins * 2) // --- PEAK TRACKER --- if (!state.recentPeakLux) { state.recentPeakLux = lux state.recentPeakTime = timeNow } if (!state.activeCloudEvent) { if (lux > state.recentPeakLux || (timeNow - state.recentPeakTime > stalePeakMillis)) { state.recentPeakLux = lux state.recentPeakTime = timeNow } } def dropFromPeak = state.recentPeakLux - lux def peakDropPercentage = state.recentPeakLux > 0 ? (dropFromPeak / state.recentPeakLux) : 0 def timeFromPeakMins = (timeNow - state.recentPeakTime) / 60000 if ((peakDropPercentage >= 0.30 || dropFromPeak > 15000) && timeFromPeakMins <= detectionWindowMins) { if (!state.activeCloudEvent) { state.activeCloudEvent = [startTime: timeNow, startLux: state.recentPeakLux, minLux: lux] state.dipReason = "Potential Clouding" addToHistory("ANALYSIS: Potential Clouding detected. Tracking as active weather event.") } else if (lux < state.activeCloudEvent.minLux) { state.activeCloudEvent.minLux = lux } } else if (luxDrop > 0 && timeDeltaMins > intervalMins && lux <= overLimit && !state.pendingOvercast) { state.dipReason = "Gradual Fade" } if (state.activeCloudEvent) { def recoveryAmount = lux - state.activeCloudEvent.minLux def totalDrop = state.activeCloudEvent.startLux - state.activeCloudEvent.minLux if (lux >= state.activeCloudEvent.startLux || recoveryAmount >= (totalDrop * 0.50)) { closeActiveCloudEvent() addToHistory("ANALYSIS: Potential Clouding event passed and logged to history.") state.recentPeakLux = lux state.recentPeakTime = timeNow } } state.lastLuxValue = lux state.lastLuxCheckTime = timeNow if (state.currentCondition == "Overcast") { updateDimmerLevel(lux) } if (lux <= overLimit && state.currentCondition != "Overcast") { if (state.pendingClear) { unschedule("triggerClear") state.pendingClear = false if (state.dipReason == "Potential Clouding") { addToHistory("Sky darkened back to ${lux} lx rapidly. Passing cloud verified. Canceled Clear.") } else { addToHistory("Sky darkened back to ${lux} lx. Canceled Clear Verification.") } } if (!state.pendingOvercast) { state.pendingOvercast = true runIn(debounceSecs, "triggerOvercast", [overwrite: true]) def causeStr = (state.dipReason == "Potential Clouding") ? "Monitoring for Storm vs Cloud..." : "Monitoring for Overcast..." addToHistory("Lux dropped to ${lux}. Starting ${(debounceSecs/60).toInteger()}m verification. ${causeStr}") } } else if (lux >= clearLimit && state.currentCondition != "Clear" && state.currentCondition != "Assumed Clear (Boot)") { if (state.pendingOvercast) { unschedule("triggerOvercast") state.pendingOvercast = false if (state.dipReason == "Potential Clouding") { addToHistory("Sky brightened to ${lux} lx rapidly. Logged as POTENTIAL CLOUDING. Canceled Overcast Verification.") } else { addToHistory("Sky brightened back to ${lux} lx. Canceled Overcast Verification.") } } if (!state.pendingClear) { state.pendingClear = true runIn(debounceSecs, "triggerClear", [overwrite: true]) addToHistory("Lux rose to ${lux}. Starting ${(debounceSecs/60).toInteger()}m Clear verification timer.") } } else if (lux > overLimit && lux < clearLimit) { if (state.pendingOvercast) { unschedule("triggerOvercast") state.pendingOvercast = false addToHistory("Lux recovered into deadband (${lux} lx). Canceled Overcast verification.") } if (state.pendingClear) { unschedule("triggerClear") state.pendingClear = false addToHistory("Lux dropped into deadband (${lux} lx). Canceled Clear verification.") } } } def triggerOvercast() { if (isSystemPaused() || !isModeAllowed() || (state.isNight && useAstro)) return def prevState = state.currentCondition state.pendingOvercast = false state.currentCondition = "Overcast" if (state.dipReason == "Potential Clouding") { addToHistory("CONFIRMED: Lux remained low. Logged as STORM or HEAVY OVERCAST. Activating targets.") } else { addToHistory("CONFIRMED: Conditions remained Overcast. Activating targets.") } if (targetSwitch && targetSwitch.currentValue("switch") != "on") targetSwitch.on() if (targetDimmer && primaryLuxSensor) updateDimmerLevel(getAggregateLux()) if (prevState != "Overcast") announceEvent("overcast") } def triggerClear() { if (isSystemPaused() || !isModeAllowed() || (state.isNight && useAstro)) return def prevState = state.currentCondition state.pendingClear = false state.currentCondition = "Clear" addToHistory("CONFIRMED: Conditions remained Clear. Deactivating targets.") if (targetSwitch && targetSwitch.currentValue("switch") != "off") targetSwitch.off() if (targetDimmer && targetDimmer.currentValue("switch") != "off") targetDimmer.off() if (prevState != "Clear") announceEvent("clear") } def scheduleAstro() { def sunInfo = getSunriseAndSunset() if (sunInfo && sunInfo.sunrise) { def sRiseOffset = sunriseOffset ? sunriseOffset.toInteger() : 0 def sunriseTime = new Date(sunInfo.sunrise.time + (sRiseOffset * 60000)) if (sunriseTime.after(new Date())) runOnce(sunriseTime, executeSunrise, [overwrite: true]) } if (sunInfo && sunInfo.sunset) { def sSetOffset = sunsetOffset ? sunsetOffset.toInteger() : 0 def sunsetTime = new Date(sunInfo.sunset.time + (sSetOffset * 60000)) if (sunsetTime.after(new Date())) runOnce(sunsetTime, executeSunset, [overwrite: true]) } } def checkInitialAstroState() { def sunInfo = getSunriseAndSunset() if (!sunInfo || !sunInfo.sunset || !sunInfo.sunrise) return def now = new Date() def sRiseOffset = sunriseOffset ? sunriseOffset.toInteger() : 0 def sSetOffset = sunsetOffset ? sunsetOffset.toInteger() : 0 def actualSunrise = new Date(sunInfo.sunrise.time + (sRiseOffset * 60000)) def actualSunset = new Date(sunInfo.sunset.time + (sSetOffset * 60000)) if (now >= actualSunset || now <= actualSunrise) { state.isNight = true state.currentCondition = "Nighttime" addToHistory("ASTRO BOOT: Currently nighttime. Applying Nighttime Logic.") enforceNightAction() } else { state.isNight = false } } def executeSunset() { if (!useAstro) return if (state.activeCloudEvent) closeActiveCloudEvent() def reqDays = (settings.learningDaysReq ?: "30").toInteger() // --- SMART LEARNING: EVALUATE OUTDOOR DAILY MAX --- if (useSmartLearning && state.dailyMaxLux && state.dailyMaxLux > 100) { def baseline = state.peakLuxHistory.size() > 0 ? (state.peakLuxHistory.sum() / state.peakLuxHistory.size()) : (peakClearLux ?: 10000) def lowerBound = baseline * 0.8 if (state.peakLuxHistory.size() < 3 || state.dailyMaxLux >= lowerBound) { state.peakLuxHistory.add(state.dailyMaxLux) // Clean up array if it exceeds user's required days (or if they scaled down the setting) def overflow = state.peakLuxHistory.size() - reqDays if (overflow > 0) state.peakLuxHistory = state.peakLuxHistory.drop(overflow) log.info "SMART LEARNING (OUTDOOR): Daily max of ${state.dailyMaxLux} lx added." } else { log.info "SMART LEARNING (OUTDOOR): Daily max of ${state.dailyMaxLux} lx rejected (20% bad weather rule)." } } state.dailyMaxLux = 0 // --- SMART LEARNING: EVALUATE INDOOR ROOMS & EXPORT HUB VARIABLES --- def configuredRooms = numRooms ?: 0 for (int i = 1; i <= configuredRooms; i++) { def rData = state.roomData["${i}"] // Ensure the room got at least some light before processing to prevent math errors if (useSmartLearning && rData && rData.dailyMax && rData.dailyMax > 10) { def basePeak = settings["roomPeakLux_${i}"] ?: 1000 def baseTarget = settings["roomBaseLux_${i}"] ?: 100 def rBaseline = rData.peakHistory.size() > 0 ? (rData.peakHistory.sum() / rData.peakHistory.size()) : basePeak def rLowerBound = rBaseline * 0.8 // Apply bad weather filter if (rData.peakHistory.size() < 3 || rData.dailyMax >= rLowerBound) { rData.peakHistory.add(rData.dailyMax) // Clean up array if it exceeds user's required days (or if they scaled down the setting) def overflow = rData.peakHistory.size() - reqDays if (overflow > 0) rData.peakHistory = rData.peakHistory.drop(overflow) log.info "SMART LEARNING (ROOM ${i}): Daily max of ${rData.dailyMax} lx added." } else { log.info "SMART LEARNING (ROOM ${i}): Daily max of ${rData.dailyMax} lx rejected (20% rule)." } // Calculate proportional setpoint def currentPeak = rData.peakHistory.size() >= reqDays ? (rData.peakHistory.sum() / rData.peakHistory.size()) : basePeak def ratio = baseTarget / basePeak def newSetpoint = (currentPeak * ratio).toInteger() rData.currentSetpoint = newSetpoint // Write to Hub Variable def varName = settings["roomVar_${i}"] if (varName) { try { setGlobalVar(varName, newSetpoint) log.info "ROOM ${i}: Exported new setpoint (${newSetpoint}) to Hub Variable: ${varName}." } catch (e) { log.error "ROOM ${i}: Failed to set Hub Variable '${varName}'. Ensure it is created and spelled correctly in your hub settings." } } } // Reset room max for tomorrow if (rData) rData.dailyMax = 0 } state.isNight = true state.currentCondition = "Nighttime" state.pendingOvercast = false state.pendingClear = false unschedule("triggerOvercast") unschedule("triggerClear") addToHistory("ASTRO: Sun has set. Suspending lux detection and applying Nighttime Logic.") enforceNightAction() } def enforceNightAction() { if (isModeAllowed() && !isSystemPaused()) { def action = nightAction ?: "Turn ON (Dark/Overcast)" if (action == "Turn OFF (Clear/Night)") { if (targetSwitch && targetSwitch.currentValue("switch") != "off") targetSwitch.off() if (targetDimmer && targetDimmer.currentValue("switch") != "off") targetDimmer.off() addToHistory("ASTRO: Forced Virtual Targets OFF for nighttime.") } else if (action == "Turn ON (Dark/Overcast)") { if (targetSwitch && targetSwitch.currentValue("switch") != "on") targetSwitch.on() if (targetDimmer) { def nLevel = nightDimLevel ?: 100 targetDimmer.setLevel(nLevel) } addToHistory("ASTRO: Forced Virtual Targets ON for nighttime.") } } } def executeSunrise() { if (!useAstro) return state.isNight = false addToHistory("ASTRO: Sun has risen. Resuming Overcast detection.") forceImmediateEvaluation() }