/** * Advanced Smart Blind Controller * * Author: ShaneAllen */ definition( name: "Advanced Smart Blind Controller", namespace: "ShaneAllen", author: "ShaneAllen", description: "Predictive thermal engine with Financial ROI tracking, Virtual Aggregate Sensor, and Telemetry Dashboards.", category: "Convenience", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") page(name: "roomPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Main Configuration", install: true, uninstall: true) { section("Live System Dashboard") { input "btnRefresh", "button", title: "🔄 Refresh Data" if (numRooms > 0) { def statusText = "" statusText += "" for (int i = 1; i <= (numRooms as Integer); i++) { def rName = settings["roomName_${i}"] ?: "Room ${i}" def dir = settings["direction_${i}"] ?: "Unset" def blind = settings["blind_${i}"] def rNameDisplay = "${rName}
Facing: ${dir}" if (!blind) { statusText += "" continue } def tSensor = settings["tempSensor_${i}"] def lSensor = settings["luxSensor_${i}"] def rTemp = tSensor ? "${tSensor.currentValue('temperature')}°" : "--°" def rLux = lSensor ? "${lSensor.currentValue('illuminance')} lx" : "-- lx" def envDisplay = "${rTemp}
${rLux}" def vState = state.verifiedState?."${i}"?.toUpperCase() ?: "UNKNOWN" def stateColor = (vState == "OPEN") ? "green" : (vState == "CLOSED" ? "blue" : "black") def tState = state.targetState?."${i}"?.toUpperCase() ?: "UNKNOWN" def tReason = state.targetReason?."${i}" ?: "Awaiting Initial Sync..." def targetDisplay = "${tState}
${tReason}" def locks = [] if (state.manualHold?."${i}") locks << "Manual Hold" if (state.windLock?."${i}") locks << "Storm Shield" if (state.fortressLocked?."${i}") locks << "Fortress Lock" if (settings["goodNightSwitch_${i}"]?.currentValue("switch") == "on") locks << "Nap Lock" def lockStr = locks ? locks.join("
") : "Clear" statusText += "" } statusText += "
RoomEnvironmentVerified StateTarget & ReasonActive Locks
${rNameDisplay}-Not Configured--
${rNameDisplay}${envDisplay}${vState}${targetDisplay}${lockStr}
" def globalStatus = (masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off") ? "PAUSED" : "ACTIVE" def outTemp = outdoorTempSensor ? "${outdoorTempSensor.currentValue('temperature')}°" : "--°" def outLux = outdoorLuxSensor ? "${outdoorLuxSensor.currentValue('illuminance')} lx" : "-- lx" def avgTemp = getAverageIndoorTemp() def hvac = mainThermostat ? mainThermostat.currentValue("thermostatOperatingState")?.capitalize() : "--" statusText += "
" statusText += "
System: ${globalStatus}
" statusText += "
Outdoor: ${outTemp} | ${outLux}
" statusText += "
House Avg: ${avgTemp}°
" statusText += "
HVAC: ${hvac}
" statusText += "
" def lifetimeSavings = "\$" + (state.lifetimeSavings ?: 0.00).setScale(2, BigDecimal.ROUND_HALF_UP) def todaySavings = "\$" + (state.todaySavings ?: 0.00).setScale(2, BigDecimal.ROUND_HALF_UP) statusText += "
" statusText += "
Estimated ROI: Today: ${todaySavings} | Total: ${lifetimeSavings}
" statusText += "
" paragraph statusText } else { paragraph "Configure rooms below to see live system status." } } section("Application History (Last 20 Events)") { 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("Global Settings & Modes") { input "masterEnableSwitch", "capability.switch", title: "Master System Enable Switch", required: false, description: "The Global Pause. ON = Application Runs. OFF = Application Paused." input "numRooms", "number", title: "Number of Rooms to Configure (1-12)", required: true, defaultValue: 1, range: "1..12", submitOnChange: true input "retryTimeoutMinutes", "number", title: "Max Sync Retry Duration (Minutes)", defaultValue: 15, required: true, description: "Maximum time to keep retrying commands before giving up." input "aggregateSensor", "capability.contactSensor", title: "Virtual Contact Sensor (All Blinds Status)", required: false, description: "Select a Virtual Contact Sensor. Turns 'closed' if ALL blinds are closed. Turns 'open' if ANY blind is open." input "masterBlind", "capability.windowShade", title: "Master Bond Device (For 'Open All' / 'Close All')", required: false input "activeModes", "mode", title: "Master Active Modes (App only runs in these)", multiple: true, required: false input "openOnModes", "mode", title: "Modes that trigger Global Open", multiple: true, required: false input "closeOnModes", "mode", title: "Modes that trigger Global Close", multiple: true, required: false input "btnReleaseAllHolds", "button", title: "Release All Manual Holds Now (And Sync House)" input "btnForceSync", "button", title: "Force System Re-evaluation & Sync Now" input "autoReleaseHoldModes", "mode", title: "Modes that Auto-Release Manual Holds", multiple: true, required: false input "vacationModes", "mode", title: "Vacation Modes (Triggers random open/close presence)", multiple: true, required: false } section("Time & Solar Settings") { input "useSunriseSunset", "bool", title: "Enable Sunrise/Sunset automations?", defaultValue: false, submitOnChange: true if (useSunriseSunset) { input "sunriseOffset", "number", title: "Sunrise Offset (Minutes, +/-)", defaultValue: 0 input "sunriseModes", "mode", title: "Modes allowed for Auto-Sunrise Open", multiple: true, required: false input "sunsetOffset", "number", title: "Sunset Offset (Minutes, +/-)", defaultValue: 0 input "maxCloseTime", "time", title: "Maximum Evening Close Time", required: false input "sunsetModes", "mode", title: "Modes allowed for Auto-Sunset/Time Close", multiple: true, required: false input "sunsetDeadband", "number", title: "Sunset Deadband / Motor Saver (Minutes)", defaultValue: 30 input "darkArrivalLockout", "bool", title: "Enable Dark Arrival Lockout?", defaultValue: true input "circadianWake", "bool", title: "Enable Circadian Gradual Wakeup?", defaultValue: false } } section("Exterior Weather & Master Solar Override") { input "windSensor", "capability.sensor", title: "Weather Station / Wind Sensor", required: false input "windThreshold", "number", title: "Storm Shield Wind Threshold (mph)", defaultValue: 15 input "outdoorLuxSensor", "capability.illuminanceMeasurement", title: "Master Outdoor Lux Sensor", required: false input "highSolarRadiationThreshold", "number", title: "High Solar Radiation Threshold (Lux)", defaultValue: 10000 input "luxHysteresis", "number", title: "Solar Radiation Hysteresis (Deadband Lux)", defaultValue: 500, description: "Lux must drop this far below the threshold before blinds reopen." input "outdoorTempSensor", "capability.temperatureMeasurement", title: "Outdoor Temperature Sensor", required: false input "outdoorHighTempThreshold", "number", title: "Outdoor High Temp Lockout (°)", defaultValue: 92 } section("Environmental Controls & Predictive ROI") { input "mainThermostat", "capability.thermostat", title: "Main Thermostat (Syncs blinds with AC/Heat states)", required: false input "elecRate", "decimal", title: "Electricity Rate (per kWh)", defaultValue: 0.14, required: true input "hvacEfficiency", "decimal", title: "Est. kWh Saved per Hour of Defense", defaultValue: 0.25, required: true, description: "Average kWh reduction of your HVAC when blinds are blocking sun. Standard is 0.20 to 0.40." input "environmentalDebounce", "number", title: "Environmental Anti-Yo-Yo Hold Time (Minutes)", defaultValue: 15, description: "Forces blinds to hold position to prevent constant up/down movements on partly cloudy days." input "tempHysteresis", "decimal", title: "Temperature Hysteresis (Deadband °)", defaultValue: 1.0, description: "Temp must change this much past the threshold to revert states." input "activeCoolingDefense", "bool", title: "Active Cooling Defense (Close sun-facing blinds when AC cools)?", defaultValue: true, submitOnChange: true input "enableFortressMode", "bool", title: "Enable Unoccupied Fortress Mode?", defaultValue: false, submitOnChange: true if (enableFortressMode) { input "fortressAutoReopen", "bool", title: "Auto-Reopen on Motion?", defaultValue: false } input "summerEnergyMode", "bool", title: "Summer Mode (Close shades to block heat)?", defaultValue: false, submitOnChange: true if (summerEnergyMode) { input "summerTempThreshold", "number", title: "Summer Indoor Temp Threshold (°)", defaultValue: 75 input "summerOutdoorTempThreshold", "number", title: "Summer Outdoor Temp Trigger (Preemptive °)", defaultValue: 82, required: false input "summerAllowedModes", "mode", title: "Modes allowed for Summer Mode", multiple: true, required: false } input "winterHeatingMode", "bool", title: "Winter Mode (Open shades to harvest free solar heat)?", defaultValue: false, submitOnChange: true if (winterHeatingMode) { input "winterTempThreshold", "number", title: "Winter Indoor Temp Threshold (Open if below this °)", defaultValue: 68 input "winterOutdoorTempThreshold", "number", title: "Winter Outdoor Temp Trigger (Preemptive °)", defaultValue: 45, required: false input "winterMaxOutdoorTemp", "number", title: "Winter Max Outdoor Temp Lockout (°)", defaultValue: 75, required: false, description: "If the outdoor temp is above this, Winter Mode is disabled (prevents heating up the house in summer)." input "winterAllowedModes", "mode", title: "Modes allowed for Winter Mode", multiple: true, required: false } } if (numRooms > 0 && numRooms <= 12) { for (int i = 1; i <= (numRooms as Integer); i++) { def roomNum = i def rName = settings["roomName_${i}"] ?: "Room ${i}" section("${rName}") { href(name: "roomHref${i}", page: "roomPage", params: [roomNum: i], title: "Configure ${rName}") } } } } } def roomPage(params) { def rNum = params?.roomNum ?: state.currentRoom ?: 1 state.currentRoom = rNum def currentName = settings["roomName_${rNum}"] ?: "Room ${rNum}" dynamicPage(name: "roomPage", title: "${currentName} Setup", install: false, uninstall: false, previousPage: "mainPage") { section("Room Identification") { input "roomName_${rNum}", "text", title: "Custom Room Name", required: false, defaultValue: "Room ${rNum}", submitOnChange: true } section("Control Devices") { input "blind_${rNum}", "capability.windowShade", title: "Blind / Shade Device (Bond)", required: false input "blindSensor_${rNum}", "capability.contactSensor", title: "Blind State Sensor (Manual override detection)", required: false input "direction_${rNum}", "enum", title: "Window Facing Direction", options: ["North", "South", "East", "West"], required: false } section("Physical Buttons / Remotes") { input "roomButton_${rNum}", "capability.pushableButton", title: "Room Button Controller", required: false input "buttonNumber_${rNum}", "number", title: "Button Number", defaultValue: 1, required: false input "buttonModes_${rNum}", "mode", title: "Allowed Modes for Button", multiple: true, required: false input "buttonStartTime_${rNum}", "time", title: "Button Active Start Time", required: false input "buttonEndTime_${rNum}", "time", title: "Button Active End Time", required: false } section("Sensors & Triggers") { input "tempSensor_${rNum}", "capability.temperatureMeasurement", title: "Indoor Temperature Sensor", required: false input "luxSensor_${rNum}", "capability.illuminanceMeasurement", title: "Indoor Lux (Light) Sensor", required: false input "humiditySensor_${rNum}", "capability.relativeHumidityMeasurement", title: "Humidity Sensor (For Privacy Triggers)", required: false input "contactSensor_${rNum}", "capability.contactSensor", title: "Window Open/Close Sensor", required: false if (enableFortressMode) { input "motionSensor_${rNum}", "capability.motionSensor", title: "Motion Sensor (For Unoccupied Fortress)", required: false input "unoccupiedTimeout_${rNum}", "number", title: "Unoccupied Timeout (Minutes)", defaultValue: 60 } } section("Overrides (Hard-Locks)") { input "goodNightSwitch_${rNum}", "capability.switch", title: "Nap Time / Good Night Hard-Lock Switch", required: false input "releaseHoldSwitch_${rNum}", "capability.switch", title: "Switch to Manually Release Control Hold", required: false input "privacyHumidityThreshold_${rNum}", "number", title: "Privacy Humidity Threshold (%)", defaultValue: 65 } } } def installed() { log.info "Smart Blind Controller Installed." initialize() } def updated() { log.info "Smart Blind Controller Updated." unsubscribe() unschedule() initialize() } def initialize() { state.targetState = state.targetState ?: [:] state.targetReason = state.targetReason ?: [:] state.verifiedState = state.verifiedState ?: [:] state.manualHold = state.manualHold ?: [:] state.lastAutoMoveTime = state.lastAutoMoveTime ?: [:] state.windLock = state.windLock ?: [:] state.fortressLocked = state.fortressLocked ?: [:] state.historyLog = state.historyLog ?: [] state.roiMinutes = state.roiMinutes ?: 0 state.commandStartTime = state.commandStartTime ?: [:] if (useSunriseSunset) { scheduleAstro() schedule("0 1 0 * * ?", scheduleAstro) if (maxCloseTime) schedule(maxCloseTime, "executeMaxCloseTime") } unschedule("calculateROIStep") calculateROIStep() schedule("0 0 0 * * ?", "midnightReset") runIn(10, "bootSync", [overwrite: true]) subscribe(location, "mode", modeHandler) if (mainThermostat) subscribe(mainThermostat, "thermostatOperatingState", hvacHandler) if (windSensor) subscribe(windSensor, "windSpeed", weatherHandler) if (outdoorLuxSensor) subscribe(outdoorLuxSensor, "illuminance", weatherHandler) if (outdoorTempSensor) subscribe(outdoorTempSensor, "temperature", weatherHandler) for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["tempSensor_${i}"]) subscribe(settings["tempSensor_${i}"], "temperature", tempHandler) if (settings["luxSensor_${i}"]) subscribe(settings["luxSensor_${i}"], "illuminance", luxHandler) if (settings["humiditySensor_${i}"]) subscribe(settings["humiditySensor_${i}"], "relativeHumidity", humidityHandler) if (settings["motionSensor_${i}"]) subscribe(settings["motionSensor_${i}"], "motion", motionHandler) if (settings["contactSensor_${i}"]) subscribe(settings["contactSensor_${i}"], "contact", windowContactHandler) if (settings["roomButton_${i}"]) { subscribe(settings["roomButton_${i}"], "pushed", buttonPushedHandler) subscribe(settings["roomButton_${i}"], "held", buttonHeldHandler) } if (settings["goodNightSwitch_${i}"]) { subscribe(settings["goodNightSwitch_${i}"], "switch.on", hardLockOnHandler) subscribe(settings["goodNightSwitch_${i}"], "switch.off", hardLockOffHandler) } if (settings["blindSensor_${i}"]) subscribe(settings["blindSensor_${i}"], "contact", blindSensorHandler) if (settings["releaseHoldSwitch_${i}"]) subscribe(settings["releaseHoldSwitch_${i}"], "switch.on", releaseHoldHandler) } } def updateAggregateSensor() { if (!aggregateSensor) return def anyOpen = false def allClosed = true def configuredCount = 0 for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["blind_${i}"]) { configuredCount++ def vState = state.verifiedState?."${i}" ?: state.targetState?."${i}" if (vState == "open") { anyOpen = true allClosed = false } else if (vState != "closed") { allClosed = false } } } if (configuredCount == 0) return def currentState = aggregateSensor.currentValue("contact") if (allClosed && currentState != "closed") { addToHistory("SYSTEM: All blinds are verified closed. Updating Virtual Aggregate Sensor.") if (aggregateSensor.hasCommand("close")) aggregateSensor.close() } else if (anyOpen && currentState != "open") { addToHistory("SYSTEM: One or more blinds are open. Updating Virtual Aggregate Sensor.") if (aggregateSensor.hasCommand("open")) aggregateSensor.open() } } def calculateROIStep() { if (isSystemPaused()) return def rate = settings["elecRate"] != null ? settings["elecRate"].toBigDecimal() : 0.14 def factor = settings["hvacEfficiency"] != null ? settings["hvacEfficiency"].toBigDecimal() : 0.25 def totalDefenseRooms = 0 for (int i = 1; i <= (numRooms as Integer); i++) { def reason = state.targetReason?."${i}" ?: "" if (reason.contains("Summer Mode") || reason.contains("Winter Mode") || reason.contains("Fortress") || reason.contains("High Solar Radiation") || reason.contains("HVAC Active Cooling")) { totalDefenseRooms++ } } if (totalDefenseRooms > 0) { def earned = (totalDefenseRooms * (factor / 12)) * rate state.todaySavings = (state.todaySavings ?: 0.0) + earned state.lifetimeSavings = (state.lifetimeSavings ?: 0.0) + earned } runIn(300, "calculateROIStep") // Run every 5 minutes } def midnightReset() { state.todaySavings = 0.0 state.manualHold = [:] if (!isSystemPaused()) runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: true], overwrite: true]) } def isSystemPaused() { if (masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off") return true return false } def appButtonHandler(btn) { if (btn == "btnRefresh") { log.info "Dashboard data manually refreshed by user." } else if (btn == "btnReleaseAllHolds") { state.manualHold = [:] state.fortressLocked = [:] addToHistory("GLOBAL: 'Release All Holds' button pressed. Wiping locks and auto-syncing house.") if (!isSystemPaused()) runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: false], overwrite: true]) } else if (btn == "btnForceSync") { addToHistory("GLOBAL: 'Force Sync' button pressed. Re-evaluating and syncing all rooms.") if (!isSystemPaused()) runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: true], overwrite: true]) } } def executeSyncStaggered(data) { syncSingleRoom(data.roomNum, data.ignoreDebounce ?: false) } def bootSync() { addToHistory("SYSTEM REBOOT: Hub restarted. Auto-syncing the entire house to recover missed events.") if (!isSystemPaused()) { for (int i = 1; i <= (numRooms as Integer); i++) { def bSensor = settings["blindSensor_${i}"] if (bSensor) state.verifiedState["${i}"] = bSensor.currentValue("contact") else state.verifiedState["${i}"] = state.targetState["${i}"] ?: "unknown" } runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: true], overwrite: true]) runIn(10, "updateAggregateSensor", [overwrite: true]) } } def windowContactHandler(evt) { def deviceId = evt.device.id def isClosed = evt.value == "closed" for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["contactSensor_${i}"]?.id == deviceId) { if (isClosed) { def rName = getRoomName(i) addToHistory("${rName}: Physical window was closed. Re-evaluating room state to recover any blocked actions.") runIn(5, "executeSyncStaggered", [data: [roomNum: i, ignoreDebounce: true], overwrite: false]) } } } } def getAverageIndoorTemp() { def totalTemp = 0.0 def count = 0 for (int i = 1; i <= (numRooms as Integer); i++) { def tSensor = settings["tempSensor_${i}"] if (tSensor) { totalTemp += (tSensor.currentValue("temperature")?.toBigDecimal() ?: 70.0) count++ } } return count > 0 ? (totalTemp / count).setScale(1, BigDecimal.ROUND_HALF_UP) : 70.0 } def addToHistory(String msg) { if (!state.historyLog) state.historyLog = [] def tz = location.timeZone ?: TimeZone.getDefault() def timestamp = new Date().format("MM/dd HH:mm:ss", tz) 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}" } def getRoomName(rNum) { return settings["roomName_${rNum}"] ?: "Room ${rNum}" } def scheduleAstro() { def sunInfo = getSunriseAndSunset() if (sunInfo && sunInfo.sunrise) { def sRiseOffset = sunriseOffset != null ? 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 != null ? sunsetOffset.toInteger() : 0 def sunsetTime = new Date(sunInfo.sunset.time + (sSetOffset * 60000)) if (sunsetTime.after(new Date())) runOnce(sunsetTime, executeSunset, [overwrite: true]) } } def executeMaxCloseTime() { if (isSystemPaused()) return if (activeModes && !activeModes.contains(location.mode)) return if (sunsetModes && !sunsetModes.contains(location.mode)) return def roomsNeedClosing = false for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["blind_${i}"] && state.targetState["${i}"] != "close" && !state.manualHold["${i}"]) { roomsNeedClosing = true break } } if (roomsNeedClosing) { addToHistory("GLOBAL: Maximum Evening Close Time reached. Closing eligible blinds.") operateAllShades("close", false, "Max Evening Close Time") } } def weatherHandler(evt) { if (isSystemPaused()) return def eventName = evt.name if (eventName == "windSpeed") { def currentWind = evt.value?.toBigDecimal() ?: 0.0 def threshold = windThreshold != null ? windThreshold.toBigDecimal() : 15.0 if (currentWind >= threshold) { for (int i = 1; i <= (numRooms as Integer); i++) { def contact = settings["contactSensor_${i}"] if (contact && contact.currentValue("contact") == "open" && !state.windLock["${i}"]) { state.windLock["${i}"] = true def rName = getRoomName(i) addToHistory("STORM SHIELD ACTIVE: High wind (${currentWind} mph). Forced ${rName} blind open.") singleBlindAction(i, "open", true, "Storm Shield (Wind: ${currentWind}mph >= ${threshold}mph)", true) } } } else { for (int i = 1; i <= (numRooms as Integer); i++) { if (state.windLock["${i}"]) { state.windLock["${i}"] = false addToHistory("${getRoomName(i)}: Storm Shield lock lifted. Restoring room state.") runIn(i * 2, "executeSyncStaggered", [data: [roomNum: i, ignoreDebounce: true], overwrite: false]) } } } } if (eventName == "illuminance" || eventName == "temperature") { runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: false], overwrite: true]) } } def hvacHandler(evt) { if (isSystemPaused()) return runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: false], overwrite: true]) } def luxHandler(evt) { if (isSystemPaused()) return runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: false], overwrite: true]) } def tempHandler(evt) { if (isSystemPaused()) return runIn(2, "orchestrateHouseSync", [data: [ignoreDebounce: false], overwrite: true]) } def motionHandler(evt) { if (isSystemPaused()) return if (!enableFortressMode) return def deviceId = evt.device.id def isActive = evt.value == "active" for (int i = 1; i <= (numRooms as Integer); i++) { def mSensor = settings["motionSensor_${i}"] if (mSensor && mSensor.id == deviceId) { def rName = getRoomName(i) if (isActive) { unschedule("executeFortressClose_${i}") if (fortressAutoReopen && state.fortressLocked["${i}"]) { addToHistory("${rName}: Motion detected. Unlocking Unoccupied Fortress mode.") state.fortressLocked["${i}"] = false runIn(2, "executeSyncStaggered", [data: [roomNum: i, ignoreDebounce: false], overwrite: false]) } } else { def timeout = settings["unoccupiedTimeout_${i}"] != null ? settings["unoccupiedTimeout_${i}"].toInteger() : 60 runIn(timeout * 60, "executeFortressClose", [data: [roomNum: i], overwrite: false]) } } } } def executeFortressClose(data) { if (isSystemPaused()) return def rNum = data.roomNum addToHistory("${getRoomName(rNum)}: Unoccupied Fortress triggered. Room empty for timeout period. Closing blind.") state.fortressLocked["${rNum}"] = true singleBlindAction(rNum, "close", false, "Unoccupied Fortress", false) } def isButtonAllowed(roomNum) { def allowedModes = settings["buttonModes_${roomNum}"] if (allowedModes && !allowedModes.contains(location.mode)) { addToHistory("${getRoomName(roomNum)}: Physical Button ignored. Hub is not in an allowed mode.") return false } def startTime = settings["buttonStartTime_${roomNum}"] def endTime = settings["buttonEndTime_${roomNum}"] if (startTime && endTime) { def between = timeOfDayIsBetween(startTime, endTime, new Date(), location.timeZone) if (!between) { addToHistory("${getRoomName(roomNum)}: Physical Button ignored. Outside of allowed active time window.") return false } } return true } def buttonPushedHandler(evt) { def deviceId = evt.device.id def btnVal = evt.value for (int i = 1; i <= (numRooms as Integer); i++) { def btn = settings["roomButton_${i}"] def targetBtn = settings["buttonNumber_${i}"]?.toString() ?: "1" if (btn && btn.id == deviceId && btnVal == targetBtn) { if (!isButtonAllowed(i)) return addToHistory("${getRoomName(i)}: Physical Button PUSHED. Opening blind and engaging Manual Hold.") state.manualHold["${i}"] = true state.fortressLocked["${i}"] = false singleBlindAction(i, "open", true, "Physical Button Hold", true) } } } def buttonHeldHandler(evt) { def deviceId = evt.device.id def btnVal = evt.value for (int i = 1; i <= (numRooms as Integer); i++) { def btn = settings["roomButton_${i}"] def targetBtn = settings["buttonNumber_${i}"]?.toString() ?: "1" if (btn && btn.id == deviceId && btnVal == targetBtn) { if (!isButtonAllowed(i)) return addToHistory("${getRoomName(i)}: Physical Button HELD. Closing blind and engaging Manual Hold.") state.manualHold["${i}"] = true state.fortressLocked["${i}"] = false singleBlindAction(i, "close", true, "Physical Button Hold", true) } } } def modeHandler(evt) { def currentMode = evt.value if (autoReleaseHoldModes?.contains(currentMode)) { addToHistory("GLOBAL: Mode changed to ${currentMode}. Auto-releasing all manual holds.") state.manualHold = [:] state.fortressLocked = [:] } if (isSystemPaused()) return if (vacationModes?.contains(currentMode)) { addToHistory("GLOBAL: Vacation Mode active. Random presence routines engaged.") scheduleRandomPresence() return } else { unschedule("triggerRandomBlind") } if (activeModes && !activeModes.contains(currentMode)) return if (openOnModes?.contains(currentMode)) { if (darkArrivalLockout && isDarkOut()) { addToHistory("GLOBAL: Mode changed to ${currentMode}, but Dark Arrival Lockout blocked OPEN command.") } else if (isExteriorUnsafeToOpen()) { // Engine handles block logic silently } else { addToHistory("GLOBAL: Mode changed to ${currentMode}. Global OPEN routine triggered.") operateAllShades("open", false, "Global Open Mode") } } else if (closeOnModes?.contains(currentMode)) { addToHistory("GLOBAL: Mode changed to ${currentMode}. Global CLOSE routine triggered.") operateAllShades("close", false, "Global Close Mode") } } def executeSunrise() { if (isSystemPaused()) return if (activeModes && !activeModes.contains(location.mode)) return if (sunriseModes && !sunriseModes.contains(location.mode)) return if (isExteriorUnsafeToOpen()) return addToHistory("GLOBAL: Sunrise routine triggered.") if (circadianWake) { state.circadianStep = 10 runCircadianStep() } else { operateAllShades("open", false, "Sunrise Routine") } } def runCircadianStep() { if (isSystemPaused()) return def step = state.circadianStep ?: 10 if (step > 100) return for (int i = 1; i <= (numRooms as Integer); i++) { def blind = settings["blind_${i}"] def gnSwitch = settings["goodNightSwitch_${i}"] if (blind && (!gnSwitch || gnSwitch.currentValue("switch") != "on") && !state.manualHold["${i}"] && !state.windLock["${i}"]) { state.targetState["${i}"] = "open" state.targetReason["${i}"] = "Circadian Wakeup Cycle" if (blind.hasCommand("setPosition")) blind.setPosition(step) else if (step == 100) blind.open() } } state.circadianStep = step + 10 runIn(300, "runCircadianStep", [overwrite: true]) } def executeSunset() { if (isSystemPaused()) return if (activeModes && !activeModes.contains(location.mode)) return if (sunsetModes && !sunsetModes.contains(location.mode)) return addToHistory("GLOBAL: Sunset routine triggered. Closing all eligible blinds.") operateAllShades("close", false, "Sunset Routine") } def humidityHandler(evt) { if (isSystemPaused()) return def currentHum = evt.value?.toInteger() ?: 0 def deviceId = evt.device.id for (int i = 1; i <= (numRooms as Integer); i++) { def hSensor = settings["humiditySensor_${i}"] def threshold = settings["privacyHumidityThreshold_${i}"] != null ? settings["privacyHumidityThreshold_${i}"].toInteger() : 65 if (hSensor && hSensor.id == deviceId) { if (currentHum >= threshold && state.targetState["${i}"] != "close") { addToHistory("${getRoomName(i)}: Privacy Override. Humidity spiked to ${currentHum}%. Closing blind.") singleBlindAction(i, "close", false, "Privacy (High Hum: ${currentHum}% >= ${threshold}%)", false) } else if (currentHum < threshold && state.targetReason["${i}"]?.startsWith("Privacy (High Hum")) { addToHistory("${getRoomName(i)}: Humidity cleared. Restoring room to normal environment state.") runIn(2, "executeSyncStaggered", [data: [roomNum: i, ignoreDebounce: true], overwrite: false]) } } } } // --- DYNAMIC PREDICTIVE THERMAL ENGINE & ORCHESTRATOR --- def orchestrateHouseSync(data = null) { def ignoreDebounce = data?.ignoreDebounce ?: false if (isSystemPaused()) return def houseTargets = [:] def allNeedToClose = true def eligibleCount = 0 def allReasons = [] def actionRequiredCount = 0 // Tracks if any blind actually needs to physically move // 1. Calculate targets for all rooms without executing for (int i = 1; i <= (numRooms as Integer); i++) { def roomTarget = determineRoomTarget(i) houseTargets[i] = roomTarget if (settings["blind_${i}"]) { if (roomTarget.locked) { allNeedToClose = false // A locked room blocks pure Master commands } else { eligibleCount++ if (roomTarget.action != "close") { allNeedToClose = false } if (roomTarget.reason && !allReasons.contains(roomTarget.reason)) { allReasons << roomTarget.reason } // VERIFICATION: Check if the blind is already in the correct physical state def currentState = settings["blindSensor_${i}"]?.currentValue("contact") ?: state.verifiedState["${i}"] def expectedState = (roomTarget.action == "close") ? "closed" : roomTarget.action if (currentState != expectedState || state.targetState["${i}"] != roomTarget.action) { actionRequiredCount++ // A blind is out of sync and requires action } else if (state.targetReason["${i}"] != roomTarget.reason) { // State matches, but the environment reason changed (e.g., Nighttime to Summer Mode). Silently update reason. state.targetReason["${i}"] = roomTarget.reason } } } } // If no blinds actually need to change state, exit silently to prevent log spam if (actionRequiredCount == 0) { return } // 2. Decide Execution Strategy if (eligibleCount > 0 && allNeedToClose && masterBlind) { def combinedReason = allReasons.join(" / ") addToHistory("ORCHESTRATOR: Entire house evaluated to CLOSE. Intercepting and routing to Master Blind.") operateAllShades("close", false, combinedReason) } else { def delayMultiplier = 0 houseTargets.each { rNum, target -> if (!target.locked && target.action) { def delaySec = delayMultiplier * 2 runIn(delaySec, "executeStaggeredCommand", [data: [roomNum: rNum, action: target.action, reason: target.reason, ignoreDebounce: ignoreDebounce], overwrite: false]) delayMultiplier++ } } } } def determineRoomTarget(roomNum) { def target = [action: null, reason: null, locked: false] def blind = settings["blind_${roomNum}"] if (!blind) { target.locked = true return target } if (state.manualHold["${roomNum}"] || state.windLock["${roomNum}"] || state.fortressLocked["${roomNum}"]) { target.locked = true return target } def gnSwitch = settings["goodNightSwitch_${roomNum}"] if (gnSwitch && gnSwitch.currentValue("switch") == "on") { target.locked = true return target } def currentMode = location.mode def isNight = isDarkOut() || isPastMaxCloseTime() // 1. Time / Mode Hard Overrides if (isNight && (!sunsetModes || sunsetModes.contains(currentMode))) { target.action = "close" target.reason = "Nighttime Secure" return target } if (closeOnModes?.contains(currentMode)) { target.action = "close" target.reason = "Global Close Mode" return target } // 2. Evaluate Base Daytime State def shouldBeOpen = false if (openOnModes?.contains(currentMode) && !isExteriorUnsafeToOpen()) { shouldBeOpen = true } else if (useSunriseSunset && !isNight && (!sunriseModes || sunriseModes.contains(currentMode)) && !isExteriorUnsafeToOpen()) { shouldBeOpen = true } // 3. Evaluate Environment (Overrides Base State) def envTarget = evaluateEnvironmentTarget(roomNum, isNight, currentMode) if (envTarget.action) { target.action = envTarget.action target.reason = envTarget.reason return target } // 4. Fallback to Base State if (shouldBeOpen) { target.action = "open" target.reason = "Normal Daytime Condition" return target } // 5. Maintain Current State if no rules match target.action = state.targetState["${roomNum}"] ?: "close" target.reason = state.targetReason["${roomNum}"] ?: "Maintaining State" return target } def evaluateEnvironmentTarget(roomNum, isNight, currentHubMode) { def target = [action: null, reason: null] def dir = settings["direction_${roomNum}"] def dirName = dir ? dir : "these" def tempSensor = settings["tempSensor_${roomNum}"] def currentTemp = tempSensor ? (tempSensor.currentValue("temperature")?.toBigDecimal() ?: 70.0) : 70.0 def outTemp = outdoorTempSensor ? (outdoorTempSensor.currentValue("temperature")?.toBigDecimal() ?: 70.0) : 70.0 def hvacState = mainThermostat ? (mainThermostat.currentValue("thermostatOperatingState") ?: "idle") : "idle" // --- HYSTERESIS VARIABLES --- def luxHysteresisOffset = settings["luxHysteresis"] != null ? settings["luxHysteresis"].toInteger() : 500 def tempHysteresisOffset = settings["tempHysteresis"] != null ? settings["tempHysteresis"].toBigDecimal() : 1.0 def currentRoomReason = state.targetReason["${roomNum}"] ?: "" // --- LUX EVALUATION WITH HYSTERESIS --- def outLux = outdoorLuxSensor ? (outdoorLuxSensor.currentValue("illuminance")?.toInteger() ?: 0) : 0 def highRadiationLimit = settings["highSolarRadiationThreshold"] != null ? settings["highSolarRadiationThreshold"].toInteger() : 10000 def isHighRadiation = false if (outdoorLuxSensor) { if (currentRoomReason?.startsWith("High Solar")) { // Already blocking sun: require lux to drop BELOW deadband to lift the lock isHighRadiation = (outLux >= (highRadiationLimit - luxHysteresisOffset)) } else { // Not currently blocking: trigger standard limit isHighRadiation = (outLux >= highRadiationLimit) } } def tz = location.timeZone ?: TimeZone.getDefault() def hour = new Date().format("HH", tz).toInteger() def isMorning = (hour < 12) def isSunFacing = false if (dir == "South") isSunFacing = true if (dir == "East" && isMorning) isSunFacing = true if (dir == "West" && !isMorning) isSunFacing = true // --- WINTER MODE EVALUATION WITH HYSTERESIS --- def maxWinterOut = settings["winterMaxOutdoorTemp"] != null ? settings["winterMaxOutdoorTemp"].toBigDecimal() : 75.0 def isActuallyWinter = outdoorTempSensor ? (outTemp <= maxWinterOut) : true def winterThresh = settings["winterTempThreshold"] != null ? settings["winterTempThreshold"].toBigDecimal() : 68.0 def indoorWinterTrigger = currentTemp <= winterThresh def winterOutThresh = settings["winterOutdoorTempThreshold"] != null ? settings["winterOutdoorTempThreshold"].toBigDecimal() : null def outdoorWinterTrigger = false if (winterOutThresh != null) { if (currentRoomReason?.startsWith("Winter Mode")) { // Already heating: temp must rise ABOVE deadband to stop outdoorWinterTrigger = (outTemp <= (winterOutThresh + tempHysteresisOffset)) } else { outdoorWinterTrigger = (outTemp <= winterOutThresh) } } if (winterHeatingMode && !isNight && isActuallyWinter) { if (!winterAllowedModes || winterAllowedModes.contains(currentHubMode)) { if (indoorWinterTrigger || outdoorWinterTrigger) { if (isSunFacing && (hvacState == "heating" || hvacState == "idle")) { target.action = "open" def wReason = indoorWinterTrigger ? "In: ${currentTemp}° <= ${winterThresh}°" : "Out: ${outTemp}° <= ${winterOutThresh}°" target.reason = "Winter Mode: Opening ${dirName} facing blinds to harvest active solar heat [${wReason}]" return target } } } } if (!isNight && isHighRadiation && isSunFacing) { target.action = "close" target.reason = "High Solar Radiation: Closing ${dirName} blinds because the sun is currently on this side [Lux: ${outLux} >= ${highRadiationLimit}]" return target } def coolingDefense = settings["activeCoolingDefense"] != null ? settings["activeCoolingDefense"] : true if (coolingDefense && hvacState == "cooling" && !isNight && isSunFacing) { target.action = "close" target.reason = "HVAC Active Cooling: Closing ${dirName} blinds to block direct sun [State: ${hvacState.capitalize()}]" return target } // --- SUMMER MODE EVALUATION WITH HYSTERESIS --- def summerThresh = settings["summerTempThreshold"] != null ? settings["summerTempThreshold"].toBigDecimal() : 75.0 def indoorSummerTrigger = currentTemp >= summerThresh def summerOutThresh = settings["summerOutdoorTempThreshold"] != null ? settings["summerOutdoorTempThreshold"].toBigDecimal() : null def outdoorSummerTrigger = false if (summerOutThresh != null) { if (currentRoomReason?.startsWith("Summer Mode")) { // Already cooling: temp must drop BELOW deadband to stop outdoorSummerTrigger = (outTemp >= (summerOutThresh - tempHysteresisOffset)) } else { outdoorSummerTrigger = (outTemp >= summerOutThresh) } } if (summerEnergyMode && !isNight) { if (!summerAllowedModes || summerAllowedModes.contains(currentHubMode)) { if (indoorSummerTrigger || outdoorSummerTrigger) { def avgTemp = getAverageIndoorTemp() def houseIsHot = avgTemp >= summerThresh def sReason = indoorSummerTrigger ? "In: ${currentTemp}° >= ${summerThresh}°" : "Out: ${outTemp}° >= ${summerOutThresh}°" if (hvacState != "heating") { if (houseIsHot) { target.action = "close" target.reason = "Summer Mode: House is hot, closing ${dirName} blinds to defend against heat [AvgIn: ${avgTemp}° >= ${summerThresh}°]" return target } else if (isSunFacing) { target.action = "close" target.reason = "Summer Mode: Closing ${dirName} blinds because direct sun is heating this side [${sReason}]" return target } } } } } return target } def syncSingleRoom(roomNum, ignoreDebounce = false) { def target = determineRoomTarget(roomNum) if (!target.locked && target.action) { singleBlindAction(roomNum, target.action, false, target.reason, ignoreDebounce) } } // --- HARD-LOCK OVERRIDE HANDLERS --- def hardLockOnHandler(evt) { def deviceId = evt.device.id for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["goodNightSwitch_${i}"]?.id == deviceId) { addToHistory("${getRoomName(i)}: NAP TIME / HARD-LOCK ENGAGED. Room forced closed.") singleBlindAction(i, "close", true, "Nap Time/Hard Lock", true) } } } def hardLockOffHandler(evt) { def deviceId = evt.device.id for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["goodNightSwitch_${i}"]?.id == deviceId) { addToHistory("${getRoomName(i)}: Hard-Lock released. Syncing room state.") syncSingleRoom(i, true) } } } // --- UTILITY & VERIFICATION --- def isDarkOut() { if (!useSunriseSunset) return false def sunInfo = getSunriseAndSunset() if (!sunInfo || !sunInfo.sunset || !sunInfo.sunrise) return false def now = new Date() return (now >= sunInfo.sunset || now <= sunInfo.sunrise) } def isPastMaxCloseTime() { if (!maxCloseTime) return false def tz = location.timeZone ?: TimeZone.getDefault() def maxTime = timeToday(maxCloseTime, tz) def now = new Date() return (now >= maxTime) } def isExteriorUnsafeToOpen() { def outTemp = outdoorTempSensor ? (outdoorTempSensor.currentValue("temperature")?.toBigDecimal() ?: 0.0) : 0.0 def outLux = outdoorLuxSensor ? (outdoorLuxSensor.currentValue("illuminance")?.toInteger() ?: 0) : 0 def limitTemp = settings["outdoorHighTempThreshold"] != null ? settings["outdoorHighTempThreshold"].toBigDecimal() : 92.0 def limitLux = settings["highSolarRadiationThreshold"] != null ? settings["highSolarRadiationThreshold"].toInteger() : 10000 if (outdoorTempSensor && (outTemp >= limitTemp)) { return true } if (outdoorLuxSensor && (outLux >= limitLux)) { return true } return false } def isWithinSunsetDeadband() { if (!useSunriseSunset || !sunsetDeadband) return false def sunInfo = getSunriseAndSunset() if (!sunInfo || !sunInfo.sunset) return false def now = new Date() def deadbandStart = new Date(sunInfo.sunset.time - (sunsetDeadband.toInteger() * 60000)) return (now >= deadbandStart && now < sunInfo.sunset) } def singleBlindAction(roomNum, action, bypassLock = false, reason = "Automated Sync", ignoreDebounce = false) { def rName = getRoomName(roomNum) def blind = settings["blind_${roomNum}"] if (!blind) { log.warn "${rName}: ABORTED. No Blind Device is selected in the app settings!" return } if (state.windLock["${roomNum}"] && action == "close") { log.warn "${rName}: ABORTED CLOSE. Storm Shield wind lock is active." return } if (!bypassLock && settings["goodNightSwitch_${roomNum}"]?.currentValue("switch") == "on") { return } if (action == "open" && !bypassLock && isWithinSunsetDeadband()) { return } if (action == "close" && settings["contactSensor_${roomNum}"]?.currentValue("contact") == "open") { if (state.targetState["${roomNum}"] != action) addToHistory("${rName}: Aborted CLOSE command. Physical window is OPEN.") return } def currentState = settings["blindSensor_${roomNum}"]?.currentValue("contact") ?: state.verifiedState["${roomNum}"] def mappedActionState = (action == "close") ? "closed" : action if (currentState == mappedActionState && state.targetState["${roomNum}"] == action) { if (state.targetReason["${roomNum}"] != reason) { state.targetReason["${roomNum}"] = reason runIn(2, "updateAggregateSensor", [overwrite: true]) } return } state.targetReason["${roomNum}"] = reason // --- ANTI-YO-YO DEBOUNCE LOGIC --- if (!bypassLock && !ignoreDebounce) { def now = new Date().time def lastMove = state.lastAutoMoveTime["${roomNum}"] ?: 0 def debounceMillis = (environmentalDebounce != null ? environmentalDebounce.toInteger() : 15) * 60000 if ((now - lastMove) < debounceMillis) { def timeLeft = ((debounceMillis - (now - lastMove)) / 1000).toInteger() if (state.targetState["${roomNum}"] != action) { addToHistory("${rName}: ${action.toUpperCase()} delayed. Anti-Yo-Yo cooldown active (${(timeLeft/60).toInteger()}m left).") } runIn(timeLeft + 2, "executeSyncStaggered", [data: [roomNum: roomNum, ignoreDebounce: false], overwrite: true]) return } } if (state.targetState["${roomNum}"] != action) { state.commandStartTime["${roomNum}"] = new Date().time } state.targetState["${roomNum}"] = action state.lastAutoMoveTime["${roomNum}"] = new Date().time addToHistory("${rName}: Executing ${action.toUpperCase()} command. Reason: ${reason}") if (action == "open") blind.open() else blind.close() state.retryCount = 0 runIn(30, "verifyAndRetry", [overwrite: true]) } def blindSensorHandler(evt) { def deviceId = evt.device.id runIn(8, "evaluateSensorEvent", [data: [deviceId: deviceId], overwrite: false]) } def evaluateSensorEvent(data) { def deviceId = data.deviceId for (int i = 1; i <= (numRooms as Integer); i++) { def sensor = settings["blindSensor_${i}"] if (sensor && sensor.id == deviceId) { def actualState = sensor.currentValue("contact") def verified = state.verifiedState["${i}"] def target = state.targetState["${i}"] def rName = getRoomName(i) def expectedState = (target == "close") ? "closed" : target def now = new Date().time def lastMove = state.lastAutoMoveTime["${i}"] ?: 0 if ((now - lastMove) < 90000) { if (actualState == expectedState) { state.verifiedState["${i}"] = actualState runIn(2, "updateAggregateSensor", [overwrite: true]) } return } if (actualState == expectedState) { if (state.verifiedState["${i}"] != actualState) { state.verifiedState["${i}"] = actualState runIn(2, "updateAggregateSensor", [overwrite: true]) } return } if (verified == "closed" && actualState == "open") { addToHistory("${rName}: Blind was manually opened. Activating Manual Hold.") state.manualHold["${i}"] = true state.fortressLocked["${i}"] = false state.targetState["${i}"] = "open" state.targetReason["${i}"] = "Manual Physical Override" state.verifiedState["${i}"] = actualState runIn(2, "updateAggregateSensor", [overwrite: true]) } else if (verified == "open" && actualState == "closed") { addToHistory("${rName}: Blind was manually closed. Activating Manual Hold.") state.manualHold["${i}"] = true state.fortressLocked["${i}"] = false state.targetState["${i}"] = "close" state.targetReason["${i}"] = "Manual Physical Override" state.verifiedState["${i}"] = actualState runIn(2, "updateAggregateSensor", [overwrite: true]) } } } } def releaseHoldHandler(evt) { def deviceId = evt.device.id for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["releaseHoldSwitch_${i}"]?.id == deviceId) { state.manualHold["${i}"] = false addToHistory("${getRoomName(i)}: Manual Hold released by user switch. Syncing room state.") syncSingleRoom(i, true) } } } def scheduleRandomPresence() { def randomMinutes = new Random().nextInt(45) + 15 runIn(randomMinutes * 60, "triggerRandomBlind", [overwrite: false]) } def triggerRandomBlind() { if (isSystemPaused()) return if (!vacationModes?.contains(location.mode)) return def rNum = new Random().nextInt(numRooms as Integer) + 1 def action = new Random().nextBoolean() ? "open" : "close" if (settings["goodNightSwitch_${rNum}"]?.currentValue("switch") == "on" || state.windLock["${rNum}"]) { scheduleRandomPresence() return } addToHistory("VACATION PRESENCE: Randomly executing ${action.toUpperCase()} on ${getRoomName(rNum)}.") singleBlindAction(rNum, action, false, "Vacation Mode", true) scheduleRandomPresence() } // --- LUXURY WAVE EXECUTION --- def executeStaggeredCommand(data) { singleBlindAction(data.roomNum, data.action, false, data.reason ?: "Automated Sync", data.ignoreDebounce ?: false) } def operateAllShades(action, force = false, reason = "Global Command") { if (action == "open" && !force && isWithinSunsetDeadband()) { addToHistory("GLOBAL: Aborted global OPEN command (within sunset deadband).") return } def allEligible = true def roomsToCommand = [] for (int i = 1; i <= (numRooms as Integer); i++) { def blind = settings["blind_${i}"] def windowContact = settings["contactSensor_${i}"] def gnSwitch = settings["goodNightSwitch_${i}"] if (!blind) continue if (!force && state.manualHold["${i}"]) { allEligible = false; continue } if (state.windLock["${i}"]) { allEligible = false; continue } if (gnSwitch && gnSwitch.currentValue("switch") == "on") { allEligible = false; continue } if (action == "close" && windowContact && windowContact.currentValue("contact") == "open") { allEligible = false; continue } if (state.targetState["${i}"] != action) { state.commandStartTime["${i}"] = new Date().time } state.targetState["${i}"] = action state.targetReason["${i}"] = reason roomsToCommand << i } if (roomsToCommand.size() == 0) return if (allEligible && masterBlind) { addToHistory("GLOBAL: Master Blind device triggered. Executing ${action.toUpperCase()} on all rooms.") if (action == "open") masterBlind.open() else masterBlind.close() state.masterRetryCount = 0 runIn(60, "verifyMasterAndRetry", [data: [action: action, reason: reason], overwrite: true]) } else { def delayMultiplier = 0 roomsToCommand.each { rNum -> def delaySec = delayMultiplier * 2 runIn(delaySec, "executeStaggeredCommand", [data: [roomNum: rNum, action: action, reason: reason, ignoreDebounce: true], overwrite: false]) delayMultiplier++ } state.retryCount = 0 runIn((roomsToCommand.size() * 2) + 60, "verifyAndRetry", [overwrite: true]) } } // --- MASTER VERIFY & RETRY LOOP --- def verifyMasterAndRetry(data) { if (isSystemPaused()) return def action = data.action def reason = data.reason def needsMasterRetry = false for (int i = 1; i <= (numRooms as Integer); i++) { def target = state.targetState["${i}"] if (!target || state.manualHold["${i}"]) continue def blindSensor = settings["blindSensor_${i}"] if (blindSensor) { def currentState = blindSensor.currentValue("contact") def expectedState = (target == "close") ? "closed" : target if (currentState != expectedState) { needsMasterRetry = true break } } } if (needsMasterRetry) { state.masterRetryCount = (state.masterRetryCount ?: 0) + 1 if (state.masterRetryCount <= 2) { addToHistory("GLOBAL: Some blinds failed to sync. Retrying Master ${action.toUpperCase()} command (${state.masterRetryCount + 1}/3).") if (action == "open") masterBlind.open() else masterBlind.close() runIn(60, "verifyMasterAndRetry", [data: data, overwrite: true]) } else { addToHistory("GLOBAL: Master Blind failed after 3 attempts. Falling back to individual shade sync.") def delayMultiplier = 0 for (int i = 1; i <= (numRooms as Integer); i++) { def target = state.targetState["${i}"] if (!target || state.manualHold["${i}"]) continue def delaySec = delayMultiplier * 2 runIn(delaySec, "executeStaggeredCommand", [data: [roomNum: i, action: target, reason: reason + " (Master Fallback)", ignoreDebounce: true], overwrite: false]) delayMultiplier++ } runIn((delayMultiplier * 2) + 60, "verifyAndRetry", [overwrite: true]) } } else { addToHistory("GLOBAL: Master Blind sync verified successfully.") runIn(2, "updateAggregateSensor", [overwrite: true]) } } // --- VERIFY & PERSISTENT RETRY LOOP --- def verifyAndRetry() { if (isSystemPaused()) return def needsRetry = false def timeoutMinutes = settings["retryTimeoutMinutes"] != null ? settings["retryTimeoutMinutes"].toInteger() : 15 def timeoutMillis = timeoutMinutes * 60000 def now = new Date().time def delayMultiplier = 0 for (int i = 1; i <= (numRooms as Integer); i++) { def target = state.targetState["${i}"] if (!target || state.manualHold["${i}"]) continue def blindSensor = settings["blindSensor_${i}"] if (blindSensor) { def currentState = blindSensor.currentValue("contact") def expectedState = (target == "close") ? "closed" : target if (currentState != expectedState) { // 1. Check if we've exceeded the global timeout setting def startTime = state.commandStartTime["${i}"] ?: now if ((now - startTime) >= timeoutMillis) { if (state.targetReason["${i}"] != "TIMEOUT FAILED") { def rName = getRoomName(i) addToHistory("TIMEOUT ERROR: ${rName} failed to reach ${target.toUpperCase()} after ${timeoutMinutes} minutes. Abandoning retries.") state.targetReason["${i}"] = "TIMEOUT FAILED" } continue // Skip retrying this specific blind } needsRetry = true // 2. Only send the command if it's been at least 60s since the last RF blast def lastMove = state.lastAutoMoveTime["${i}"] ?: 0 if ((now - lastMove) >= 60000) { def delaySec = delayMultiplier * 3 def tReason = state.targetReason["${i}"] ?: "Persistent Retry Sync" // THIS IS WHERE THE BI-DIRECTIONAL WIGGLE HAPPENS if (target == "open") { runIn(delaySec, "executeWiggleOpen", [data: [roomNum: i, reason: tReason], overwrite: false]) } else if (target == "close") { runIn(delaySec, "executeWiggleClose", [data: [roomNum: i, reason: tReason], overwrite: false]) } delayMultiplier++ } } else { state.verifiedState["${i}"] = currentState } } else { // DASHBOARD FIX: If no physical sensor is installed, assume the RF command worked to keep the UI clean state.verifiedState["${i}"] = target } } if (needsRetry) { runIn(60, "verifyAndRetry", [overwrite: true]) } else { runIn(2, "updateAggregateSensor", [overwrite: true]) } } // --- WIGGLE RECOVERY MANEUVERS --- def executeWiggleOpen(data) { def rNum = data.roomNum def blind = settings["blind_${rNum}"] if (!blind) return addToHistory("${getRoomName(rNum)}: Wiggle maneuver engaged. Forcing CLOSE, then re-issuing OPEN. Reason: ${data.reason}") if (blind.hasCommand("close")) blind.close() state.lastAutoMoveTime["${rNum}"] = new Date().time runIn(5, "finalizeWiggleOpen", [data: [roomNum: rNum], overwrite: false]) } def finalizeWiggleOpen(data) { def blind = settings["blind_${data.roomNum}"] if (blind && blind.hasCommand("open")) blind.open() } def executeWiggleClose(data) { def rNum = data.roomNum def blind = settings["blind_${rNum}"] if (!blind) return addToHistory("${getRoomName(rNum)}: Wiggle maneuver engaged. Forcing OPEN, then re-issuing CLOSE. Reason: ${data.reason}") if (blind.hasCommand("open")) blind.open() state.lastAutoMoveTime["${rNum}"] = new Date().time runIn(5, "finalizeWiggleClose", [data: [roomNum: rNum], overwrite: false]) } def finalizeWiggleClose(data) { def blind = settings["blind_${data.roomNum}"] if (blind && blind.hasCommand("close")) blind.close() }