/**
* Advanced Room Good Night
*/
definition(
name: "Advanced Room Good Night",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Ultimate Good Night controller with Live Sleep Dashboard, Variable Speed Ceiling Fans, Auto-Sleep Quality Tracking, Power-Failure Recovery, Periodic State Enforcement, Hourly Fan Wiggle, and Command History.",
category: "Convenience",
iconUrl: "",
iconX2Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "Advanced Room Good Night", install: true, uninstall: true) {
section("Live System Dashboard") {
paragraph "
What it does: Provides a real-time, top-down view of your home's sleep status, blocking devices, and individual room environments.
"
input "refreshDataBtn", "button", title: "🔄 Refresh Data"
input "forceEvalBtn", "button", title: "⚡ Force Global Sync Evaluation"
def cssHTML = """
"""
def syncExplanation = "Global Mode Sync is currently Disabled."
if (settings.enableGlobalMode) {
def activeBlockers = []
if (settings.blockingSwitches) {
settings.blockingSwitches.each { bSw ->
if (bSw.currentValue("switch") == "on") {
activeBlockers << bSw.displayName
}
}
}
if (activeBlockers.size() > 0) {
syncExplanation = "⚠️ Night Mode Blocked By: ${activeBlockers.join(", ")}"
} else {
syncExplanation = "✅ No Blocking Devices Active"
}
if (state.nightModeScheduledTime) {
def globalTimeSecs = Math.round((state.nightModeScheduledTime - now()) / 1000.0)
syncExplanation += "
⏳ Global Night Mode Countdown: ${globalTimeSecs > 60 ? '~' + Math.round(globalTimeSecs / 60.0) + ' min(s)' : globalTimeSecs + ' sec(s)'} until Good Night."
} else {
syncExplanation += "
⏳ Global Night Mode Countdown: Not Available"
}
if (state.wakeModeScheduledTime) {
def wakeTimeSecs = Math.round((state.wakeModeScheduledTime - now()) / 1000.0)
syncExplanation += "
⏳ Morning Wake Mode Countdown: ${wakeTimeSecs > 60 ? '~' + Math.round(wakeTimeSecs / 60.0) + ' min(s)' : wakeTimeSecs + ' sec(s)'} until Wake Up."
} else {
syncExplanation += "
⏳ Morning Wake Mode Countdown: Not Available"
}
if (settings.enableLeadRoomOverride && state.overrideScheduledTime) {
def timeLeft = Math.round((state.overrideScheduledTime - now()) / 60000.0)
if (timeLeft > 0) {
syncExplanation += "
⏳ Lead Room(s) Timer Active: ~${timeLeft} min(s) until forced Good Night evaluation."
} else {
syncExplanation += "
⏳ Lead Room(s) Timer: Evaluation Pending..."
}
} else if (settings.enableLeadRoomOverride) {
syncExplanation += "
⏳ Lead Room(s) Timer: Not Available"
}
}
def dashHTML = cssHTML + "Global Sleep Sync:
${syncExplanation}
"
def hasConfiguredRooms = false
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"]) {
hasConfiguredRooms = true
def rName = settings["roomName${i}"] ?: "Room ${i}"
def tSensor = settings["tempSensor${i}"]
def hSensor = settings["humSensor${i}"]
def cTemp = tSensor ? tSensor.currentValue("temperature") : null
def cHum = hSensor ? hSensor.currentValue("humidity") : null
def sleepQuality = calculateSleepSuitability(cTemp, cHum)
def tonightTrack = "Not Generated Yet"
def aType = settings["audioSourceType${i}"] ?: "uri"
if (aType == "uri" && state."nextUri${i}") {
tonightTrack = state."nextUri${i}"
} else if (aType == "switch" && state."nextSwitchId${i}") {
def nId = state."nextSwitchId${i}"
for(int u = 1; u <= 5; u++) {
def s = settings["audioSwitch${i}_${u}"]
if (s?.id == nId) tonightTrack = s.displayName
}
}
def sw = settings["roomSwitch${i}"]
def isAsleep = false
if (settings["enableDualOccupant${i}"]) {
def pSw = settings["partnerSwitch${i}"]
isAsleep = (sw?.currentValue("switch") == "on" && pSw?.currentValue("switch") == "on")
} else {
isAsleep = (sw?.currentValue("switch") == "on")
}
def titleColor = isAsleep ? "#2e154f" : "#007bff"
def expCeiling = "App Released"
def expStdFan = "App Released"
def expLights = "App Released"
def expAudio = "App Released"
def targetSpeedDisp = "N/A"
if (isAsleep) {
def isWindingDown = state."windDownActive${i}"
expLights = isWindingDown ? "Fading Out (Wind Down Active)" : (settings["roomLightsOn${i}"] ? "OFF / Bedtime Plugs ON" : "OFF")
expAudio = isWindingDown ? "Fading Out (Wind Down Active)" : "PLAYING (Unless Timer Ended)"
if (cTemp != null) {
def stdSet = settings["fanSetpoint${i}"]
expStdFan = stdSet ? (cTemp >= stdSet ? "ON" : "OFF") : "Not Configured"
def cSet = settings["ceilingFanSetpoint${i}"]
def delta = settings["fanSpeedDelta${i}"] ?: 1.0
def fType = settings["fanType${i}"] ?: "3_speed"
if (cSet && settings["ceilingFanSwitch${i}"]) {
if (fType == "on_off") {
expCeiling = "Power: ON | Speed: N/A"
targetSpeedDisp = "N/A (On/Off)"
} else {
def tSpeed = calculateTargetSpeed(cTemp, cSet, delta, fType).toUpperCase()
targetSpeedDisp = tSpeed
expCeiling = "Power: ON | Speed: ${tSpeed}"
}
} else {
expCeiling = "Not Configured"
}
} else {
expStdFan = "Awaiting Temp Data"
expCeiling = "Awaiting Temp Data"
}
}
dashHTML += """
${rName} - ${isAsleep ? '🌙 ASLEEP' : '☀️ AWAKE'}
| Live Environment |
| Current Temp | ${cTemp != null ? cTemp + '°F' : '--'} |
| Humidity | ${cHum != null ? cHum + '%' : '--'} |
| Environment | ${sleepQuality} |
| Tonight's Audio | ${tonightTrack} |
| Expected States |
| Calculated Target Speed | ${targetSpeedDisp} |
| Ceiling Fan Hardware | ${expCeiling} |
| Standard Fans | ${expStdFan} |
| Lights/Shades | ${expLights} |
| Audio Track | ${expAudio} |
| Sleep Analytics & History |
| Date | Duration (Consistency Score: ${calculateConsistencyScore(state."sleepHistory${i}")}) |
"""
def histList = state."sleepHistory${i}" ?: []
if (histList.size() > 0) {
histList.each { entry ->
dashHTML += "| ${entry.date} | ${entry.duration} |
"
}
} else {
dashHTML += "| No history recorded yet. |
"
}
dashHTML += "
"
}
}
if (hasConfiguredRooms) {
paragraph dashHTML
} else {
paragraph dashHTML + "Please enable and configure a room below to populate the dashboard."
}
}
section("Command History (Last 20)") {
paragraph "What it does: Provides a transparent, rolling log of every command the system evaluates and sends.
"
def logList = state.eventLog ?: []
if (logList.size() > 0) {
def logHtml = logList.join("
")
paragraph "${logHtml}"
} else {
paragraph "No commands logged yet. Turn a Good Night switch on to begin tracking."
}
input "clearLogBtn", "button", title: "Clear Command History"
}
section("Global Settings & Logs", hideable: true, hidden: true) {
input "enablePeriodicEnforcement", "bool", title: "Enable Periodic State Enforcement
(Checks every 10 mins to ensure lights are off, fans are correct, and mode is synced)", defaultValue: true
input "enableWiggle", "bool", title: "Master Enable: Hourly Fan Wiggle (Self-Healing)
(Global toggle to allow room fans to run their configured Wiggle routines)", defaultValue: true
input "txtLogEnable", "bool", title: "Enable Action Logging", defaultValue: true
}
section("Global Handshake: Mode Synchronization", hideable: true, hidden: true) {
input "enableGlobalMode", "bool", title: "Enable Global Mode Sync", submitOnChange: true
if (enableGlobalMode) {
paragraph "Entering Night Mode"
input "allowedNightModes", "mode", title: "Safety Whitelist: ONLY allow entering Night Mode if house is currently in these Modes", multiple: true, required: false
input "syncRooms", "enum", title: "Require these rooms to be Asleep", options: ["1":"Room 1", "2":"Room 2", "3":"Room 3", "4":"Room 4"], multiple: true, required: true
input "syncMotion", "capability.motionSensor", title: "Require NO motion on these sensors", multiple: true, required: false
input "blockingSwitches", "capability.switch", title: "Blocking Devices: Prevent Night Mode if ANY of these are ON (e.g. TV, Pinball)", multiple: true, required: false
paragraph "Safety Sweep (Optional Security Verification)"
input "enableSafetySweep", "bool", title: "Verify Security before entering Night Mode", submitOnChange: true
if (enableSafetySweep) {
input "sweepContacts", "capability.contactSensor", title: "Require these Doors/Windows to be CLOSED", multiple: true, required: false
input "sweepLocks", "capability.lock", title: "Require these Doors to be LOCKED", multiple: true, required: false
input "sweepSpeaker", "capability.speechSynthesis", title: "TTS Speaker for Warning Broadcasts", required: false
}
input "nightStartTime", "time", title: "Between Start Time", required: true
input "nightEndTime", "time", title: "And End Time", required: true
input "targetNightMode", "mode", title: "Change House Mode to", required: true
input "nightModeDelay", "number", title: "Delay before entering Night Mode (seconds)", defaultValue: 60, required: false
paragraph "Morning Wake-Up Mode"
input "wakeStartTime", "time", title: "Between Start Time", required: true
input "wakeEndTime", "time", title: "And End Time", required: true
input "requireNightMode", "mode", title: "Only if currently in this Mode", required: true
input "targetWakeMode", "mode", title: "Change House Mode to", required: true
input "wakeModeDelay", "number", title: "Delay before entering Wake Mode (seconds)", defaultValue: 60, required: false
paragraph "Lead Room(s) Good Night Override"
input "enableLeadRoomOverride", "bool", title: "Enable Lead Room Override", submitOnChange: true
if (enableLeadRoomOverride) {
input "leadRooms", "enum", title: "Select Lead Rooms (All selected must be asleep to trigger override)", options: ["1":"Room 1", "2":"Room 2", "3":"Room 3", "4":"Room 4"], multiple: true, required: true
input "leadRoomTimeout", "number", title: "Wait time (minutes) for other rooms to go to sleep", defaultValue: 30, required: true
input "leadRoomMotionSensors", "capability.motionSensor", title: "Require NO motion on these sensors to force Good Night", multiple: true, required: true
input "targetOverrideMode", "mode", title: "Change House Mode to (Good Night)", required: true
}
}
}
for (int i = 1; i <= 4; i++) {
def rName = settings["roomName${i}"] ?: "Room ${i}"
section("${rName} Configuration", hideable: true, hidden: true) {
input "enableRoom${i}", "bool", title: "Enable ${rName}", submitOnChange: true
if (settings["enableRoom${i}"]) {
input "roomName${i}", "text", title: "Custom Room Name", defaultValue: "Room ${i}", submitOnChange: true
input "roomSwitch${i}", "capability.switch", title: "${rName} Good Night Virtual Switch", required: true
input "enableDualOccupant${i}", "bool", title: "Enable Multi-Occupant (Partner Sync)", submitOnChange: true
if (settings["enableDualOccupant${i}"]) {
paragraph "Room will only go to sleep when BOTH switches are ON, and will wake up if EITHER is turned OFF."
input "partnerSwitch${i}", "capability.switch", title: "Partner Good Night Switch", required: true
}
paragraph "Good Night Toggle Button"
input "gnButton${i}", "capability.pushableButton", title: "Toggle Button Device", required: false
input "gnButtonNum${i}", "number", title: "Button Number", required: false, defaultValue: 1
input "gnButtonAction${i}", "enum", title: "Button Action", options: ["pushed":"Pushed", "doubleTapped":"Double Tapped", "held":"Held", "released":"Released"], required: false, defaultValue: "pushed"
input "gnButtonModes${i}", "mode", title: "Only Allow in These Modes", multiple: true, required: false
paragraph "1. Climate & Environment"
input "tempSensor${i}", "capability.temperatureMeasurement", title: "Temperature Sensor", required: true
input "humSensor${i}", "capability.relativeHumidityMeasurement", title: "Humidity Sensor (Optional - for sleep rating)", required: false
paragraph "Standard ON/OFF Fans"
input "fanSetpoint${i}", "decimal", title: "Turn ON Standard Fans if Temp reaches (°F)", required: false
input "roomFans${i}", "capability.switch", title: "Standard Fans (Select up to 2)", multiple: true, required: false
paragraph "Dynamic Ceiling Fan"
paragraph "Select your fan capability. The routine will automatically scale the speed commands as the room heats up."
input "fanType${i}", "enum", title: "Ceiling Fan Type", options: ["on_off": "Simple On/Off", "3_speed": "3-Speed", "6_speed": "5/6-Speed"], defaultValue: "3_speed", required: true
input "ceilingFanSwitch${i}", "capability.switch", title: "Ceiling Fan Power Switch", required: false
input "ceilingFanSpeed${i}", "capability.fanControl", title: "Ceiling Fan Speed Control", required: false
input "ceilingFanSetpoint${i}", "decimal", title: "Ceiling Fan Base Setpoint (°F)", required: false
input "fanSpeedDelta${i}", "decimal", title: "Degrees above setpoint to step up speed (Default: 1.0)", required: false, defaultValue: 1.0
input "enableWiggle${i}", "bool", title: "Enable Hourly Wiggle for this specific fan", defaultValue: true
paragraph "2. Lighting & Shades"
input "roomLights${i}", "capability.switch", title: "Lights to Turn OFF", multiple: true, required: false
input "roomLightsOn${i}", "capability.switch", title: "Lights/Plugs to Turn ON (Turns OFF when waking)", multiple: true, required: false
input "pauseLightingEnforcement${i}", "capability.switch", title: "Pause Lighting Enforcement Switch", required: false
input "romanceSwitch${i}", "capability.switch", title: "Romance / Override Switch (Stops 10-min cycle)", required: false
input "shadeContact${i}", "capability.contactSensor", title: "Shade Open/Close Contact Sensor", required: false
input "roomShade${i}", "capability.windowShade", title: "Window Shade to Close", required: false
input "shadeHoldRelease${i}", "capability.switch", title: "Manual Hold Release Switch", required: false
paragraph "Reading Light 1"
input "enableReadingLight1_${i}", "bool", title: "Enable Reading Light 1?", submitOnChange: true
if (settings["enableReadingLight1_${i}"]) {
input "readingLight1_${i}", "capability.switchLevel", title: "Reading Light 1 (Dimmer)", required: false
input "readingButton1_${i}", "capability.pushableButton", title: "Button for Light 1", required: false
input "readingButtonNum1_${i}", "number", title: "Button Number", required: false, defaultValue: 1
input "readingLevel1_${i}", "number", title: "Dim Level (%)", required: false, defaultValue: 30
input "readingTimeout1_${i}", "number", title: "Timeout (Minutes)", required: false, defaultValue: 60
input "readingModes1_${i}", "mode", title: "Only Allow in These Modes", multiple: true, required: false
}
paragraph "Reading Light 2"
input "enableReadingLight2_${i}", "bool", title: "Enable Reading Light 2?", submitOnChange: true
if (settings["enableReadingLight2_${i}"]) {
input "readingLight2_${i}", "capability.switchLevel", title: "Reading Light 2 (Dimmer)", required: false
input "readingButton2_${i}", "capability.pushableButton", title: "Button for Light 2", required: false
input "readingButtonNum2_${i}", "number", title: "Button Number", required: false, defaultValue: 1
input "readingLevel2_${i}", "number", title: "Dim Level (%)", required: false, defaultValue: 30
input "readingTimeout2_${i}", "number", title: "Timeout (Minutes)", required: false, defaultValue: 60
input "readingModes2_${i}", "mode", title: "Only Allow in These Modes", multiple: true, required: false
}
paragraph "3. Sonos Audio Polish"
input "roomSpeakerPower${i}", "capability.switch", title: "Sonos Speaker Power Plug (Optional)", required: false
input "roomSpeaker${i}", "capability.musicPlayer", title: "Sonos Speaker", required: false
input "audioVolume${i}", "number", title: "Fixed Nighttime Volume (1-100)", required: false, defaultValue: 15
input "audioTimer${i}", "number", title: "Sleep Timer: Stop audio after X minutes", required: false
input "audioSourceType${i}", "enum", title: "Audio Source Type", options: ["uri":"Direct Audio URIs", "switch":"Sonos Favorite Virtual Switches"], defaultValue: "uri", submitOnChange: true
if ((settings["audioSourceType${i}"] ?: "uri") == "uri") {
input "audioUri${i}_1", "text", title: "Audio URI 1", required: false
input "audioUri${i}_2", "text", title: "Audio URI 2", required: false
input "audioUri${i}_3", "text", title: "Audio URI 3", required: false
input "audioUri${i}_4", "text", title: "Audio URI 4", required: false
input "audioUri${i}_5", "text", title: "Audio URI 5", required: false
} else {
input "audioSwitch${i}_1", "capability.switch", title: "Favorite Switch 1", required: false
input "audioSwitch${i}_2", "capability.switch", title: "Favorite Switch 2", required: false
input "audioSwitch${i}_3", "capability.switch", title: "Favorite Switch 3", required: false
input "audioSwitch${i}_4", "capability.switch", title: "Favorite Switch 4", required: false
input "audioSwitch${i}_5", "capability.switch", title: "Favorite Switch 5", required: false
}
paragraph "4. Adaptive Wind Down (Fade Out)"
input "enableWindDown${i}", "bool", title: "Enable Smooth Wind Down Transition", submitOnChange: true
if (settings["enableWindDown${i}"]) {
paragraph "Slowly fades audio volume and dims lights to zero over the specified timeframe when going to sleep."
input "windDownDuration${i}", "number", title: "Wind Down Duration (Minutes)", defaultValue: 15, required: true
input "windDownStartVol${i}", "number", title: "Starting Audio Volume (%)", defaultValue: 30, required: true
}
}
}
}
}
}
// ==============================================================================
// INTERNAL LOGIC ENGINE
// ==============================================================================
def appButtonHandler(btn) {
if (btn == "refreshDataBtn") {
logInfo("Manual UI Data Refresh Triggered.")
} else if (btn == "clearLogBtn") {
state.eventLog = []
logInfo("User manually cleared the command history log.")
} else if (btn == "forceEvalBtn") {
logInfo("User manually forced a Global Mode Sync re-evaluation.")
evaluateGlobalMode(null)
}
}
def getSpeedLevels() { return ["off": 0, "low": 1, "medium-low": 2, "medium": 3, "medium-high": 4, "high": 5] }
def getLevelSpeeds() { return [0: "off", 1: "low", 2: "medium-low", 3: "medium", 4: "medium-high", 5: "high"] }
def calculateTargetSpeed(currentTemp, setpoint, delta, fanType) {
def diff = currentTemp - setpoint
if (fanType == "3_speed") {
if (diff >= (delta * 2)) return "high"
if (diff >= delta) return "medium"
return "low"
} else if (fanType == "6_speed") {
if (diff >= (delta * 4)) return "high"
if (diff >= (delta * 3)) return "medium-high"
if (diff >= (delta * 2)) return "medium"
if (diff >= delta) return "medium-low"
return "low"
}
return "low"
}
def getDropSpeed(current, fanType) {
if (fanType == "3_speed") {
if (current == "high") return "medium"
if (current == "medium") return "low"
if (current == "low") return "off"
} else if (fanType == "6_speed") {
if (current == "high") return "medium-high"
if (current == "medium-high") return "medium"
if (current == "medium") return "medium-low"
if (current == "medium-low") return "low"
if (current == "low") return "off"
}
return null
}
def installed() {
logInfo("Installed and initialized.")
initialize()
}
def updated() {
logInfo("Updated. Re-initializing.")
unsubscribe()
unschedule()
initialize()
}
def initialize() {
if (!state.eventLog) state.eventLog = []
subscribe(location, "systemStart", hubRebootHandler)
if (enablePeriodicEnforcement) {
runEvery10Minutes("periodicEnforcementHandler")
}
if (settings.enableWiggle) {
runEvery1Hour("doHourlyWiggle")
}
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"]) {
if (settings["roomSwitch${i}"]) {
subscribe(settings["roomSwitch${i}"], "switch", roomSwitchHandler)
}
if (settings["enableDualOccupant${i}"] && settings["partnerSwitch${i}"]) {
subscribe(settings["partnerSwitch${i}"], "switch", roomSwitchHandler)
}
if (settings["tempSensor${i}"]) {
subscribe(settings["tempSensor${i}"], "temperature", tempHandler)
}
if (settings["gnButton${i}"]) {
def action = settings["gnButtonAction${i}"] ?: "pushed"
subscribe(settings["gnButton${i}"], action, goodNightButtonHandler)
}
if (settings["enableReadingLight1_${i}"] && settings["readingButton1_${i}"]) {
subscribe(settings["readingButton1_${i}"], "pushed", readingButtonHandler)
}
if (settings["enableReadingLight2_${i}"] && settings["readingButton2_${i}"]) {
subscribe(settings["readingButton2_${i}"], "pushed", readingButtonHandler)
}
if (!state."sleepHistory${i}") state."sleepHistory${i}" = []
prepNextAudio(i)
}
}
if (enableGlobalMode) {
if (syncMotion) {
subscribe(syncMotion, "motion", globalMotionHandler)
}
if (blockingSwitches) {
subscribe(blockingSwitches, "switch", blockingSwitchHandler)
}
}
}
// --- GOOD NIGHT BUTTON TOGGLE ENGINE ---
def goodNightButtonHandler(evt) {
def btnId = evt.device.id
def btnNum = evt.value
for (int i = 1; i <= 4; i++) {
if (!settings["enableRoom${i}"]) continue
def confBtn = settings["gnButton${i}"]
def confNum = settings["gnButtonNum${i}"]?.toString() ?: "1"
def confAction = settings["gnButtonAction${i}"] ?: "pushed"
def rName = settings["roomName${i}"] ?: "Room ${i}"
if (confBtn && confBtn.id == btnId && evt.name == confAction && btnNum == confNum) {
def rModes = settings["gnButtonModes${i}"]
if (rModes && !(rModes as List).contains(location.mode)) {
logInfo("${rName}: Good Night button pressed, but not in an allowed mode.")
return
}
def sw = settings["roomSwitch${i}"]
if (sw) {
if (sw.currentValue("switch") == "on") {
logInfo("${rName}: Toggle button pressed. Turning Primary Good Night OFF.")
sw.off()
} else {
logInfo("${rName}: Toggle button pressed. Turning Primary Good Night ON.")
sw.on()
}
}
}
}
}
// --- READING LIGHT BUTTON ENGINE ---
def readingButtonHandler(evt) {
def btnId = evt.device.id
def btnNum = evt.value
for (int i = 1; i <= 4; i++) {
if (!settings["enableRoom${i}"]) continue
for (int l = 1; l <= 2; l++) {
if (!settings["enableReadingLight${l}_${i}"]) continue
def confBtn = settings["readingButton${l}_${i}"]
def confNum = settings["readingButtonNum${l}_${i}"]?.toString() ?: "1"
if (confBtn && confBtn.id == btnId && btnNum == confNum) {
toggleReadingMode(i, l)
return
}
}
}
}
def toggleReadingMode(roomNum, lightNum) {
if (!settings["enableReadingLight${lightNum}_${roomNum}"]) return
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
def rLight = settings["readingLight${lightNum}_${roomNum}"]
def rLevel = settings["readingLevel${lightNum}_${roomNum}"] ?: 30
def rTimeout = settings["readingTimeout${lightNum}_${roomNum}"] ?: 60
def rModes = settings["readingModes${lightNum}_${roomNum}"]
if (!rLight) return
if (rModes && !(rModes as List).contains(location.mode)) {
logInfo("${rName}: Reading button pushed, but not in an allowed mode.")
return
}
def isActive = state."readingModeActive_${roomNum}_${lightNum}"
if (isActive) {
logInfo("${rName}: Reading Light ${lightNum} OFF (Toggled manually).")
endReadingMode(roomNum, lightNum)
} else {
logInfo("${rName}: Reading Light ${lightNum} ON. Level: ${rLevel}%, Timer: ${rTimeout}m.")
state."readingModeActive_${roomNum}_${lightNum}" = true
rLight.setLevel(rLevel)
runIn(rTimeout * 60, "readingTimeoutRoom${roomNum}Light${lightNum}")
}
}
def readingTimeoutRoom1Light1() { endReadingMode(1, 1) }
def readingTimeoutRoom1Light2() { endReadingMode(1, 2) }
def readingTimeoutRoom2Light1() { endReadingMode(2, 1) }
def readingTimeoutRoom2Light2() { endReadingMode(2, 2) }
def readingTimeoutRoom3Light1() { endReadingMode(3, 1) }
def readingTimeoutRoom3Light2() { endReadingMode(3, 2) }
def readingTimeoutRoom4Light1() { endReadingMode(4, 1) }
def readingTimeoutRoom4Light2() { endReadingMode(4, 2) }
def endReadingMode(roomNum, lightNum) {
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
def rLight = settings["readingLight${lightNum}_${roomNum}"]
logInfo("${rName}: Reading Mode for Light ${lightNum} ended. Turning off.")
state."readingModeActive_${roomNum}_${lightNum}" = false
unschedule("readingTimeoutRoom${roomNum}Light${lightNum}")
if (rLight) rLight.off()
}
def isReadingLightActive(roomNum, lightDeviceId) {
for (int l = 1; l <= 2; l++) {
if (!settings["enableReadingLight${l}_${roomNum}"]) continue
if (state."readingModeActive_${roomNum}_${l}") {
def rLight = settings["readingLight${l}_${roomNum}"]
if (rLight && rLight.id == lightDeviceId) return true
}
}
return false
}
// --- SLEEP ANALYTICS MATH ---
def calculateConsistencyScore(histList) {
if (!histList) return "Data needed"
// Filter out old legacy history entries that don't have the new startMins data point
def validEntries = histList.findAll { it.startMins != null }
if (validEntries.size() < 3) return "Data needed"
def sum = 0
validEntries.each { sum += it.startMins }
def mean = sum / validEntries.size()
def sumSqDev = 0
validEntries.each { sumSqDev += Math.pow((it.startMins - mean), 2) }
def stdDev = Math.sqrt(sumSqDev / validEntries.size())
if (stdDev < 30) return "A+"
if (stdDev < 45) return "A"
if (stdDev < 60) return "B"
if (stdDev < 90) return "C"
return "D (Irregular)"
}
// --- HOURLY FAN WIGGLE ---
def doHourlyWiggle() {
if (!settings.enableWiggle) return
logInfo("Executing Hourly RF Fan Wiggle to actively enforce target speeds...")
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"] && state."roomAsleepStatus${i}" && settings["enableWiggle${i}"]) {
def cFanSpeed = settings["ceilingFanSpeed${i}"]
def rName = settings["roomName${i}"] ?: "Room ${i}"
def fType = settings["fanType${i}"] ?: "3_speed"
if (cFanSpeed && fType != "on_off") {
def sensor = settings["tempSensor${i}"]
def currentTemp = sensor ? sensor.currentValue("temperature") : null
def cFanSetpoint = settings["ceilingFanSetpoint${i}"]
def delta = settings["fanSpeedDelta${i}"] ?: 1.0
def targetSpeed = "off"
if (cFanSetpoint && currentTemp != null) {
targetSpeed = calculateTargetSpeed(currentTemp, cFanSetpoint, delta, fType)
}
if (targetSpeed != "off") {
def dropSpeed = getDropSpeed(targetSpeed, fType)
if (dropSpeed) {
logInfo("${rName}: Wiggle - Target speed is ${targetSpeed.toUpperCase()}. Dropping to ${dropSpeed.toUpperCase()} temporarily to force resync.")
cFanSpeed.setSpeed(dropSpeed)
}
} else {
logInfo("${rName}: Wiggle - Target speed is OFF. Bumping to LOW to force Bond Bridge reset.")
cFanSpeed.setSpeed("low")
}
}
}
}
runIn(10, "evaluateAllSleepingFans")
}
def evaluateAllSleepingFans() {
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"] && state."roomAsleepStatus${i}") {
evaluateFans(i)
}
}
}
// --- PERIODIC STATE ENFORCEMENT ---
def periodicEnforcementHandler() {
def anyoneAsleep = false
for (int i = 1; i <= 4; i++) {
if (state."roomAsleepStatus${i}") {
anyoneAsleep = true
break
}
}
if (anyoneAsleep) {
if (txtLogEnable) log.debug "PERIODIC ENFORCEMENT: Waking up to verify system state..."
evaluateGlobalMode(null)
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"]) {
def rName = settings["roomName${i}"] ?: "Room ${i}"
if (state."roomAsleepStatus${i}") {
if (state."windDownActive${i}") {
if (txtLogEnable) log.debug "ENFORCEMENT: Skipping ${rName} (Wind Down Active)."
continue
}
def pauseEnforce = settings["pauseLightingEnforcement${i}"]
def romanceSw = settings["romanceSwitch${i}"]
def isPaused = (pauseEnforce?.currentValue("switch") == "on") || (romanceSw?.currentValue("switch") == "on")
if (!isPaused) {
if (txtLogEnable) log.debug "ENFORCEMENT: No overrides active. Proceeding with forced light shutdown for ${rName}."
def lights = settings["roomLights${i}"]
if (lights) {
lights.each { lgt ->
if (lgt.currentValue("switch") == "on") {
if (isReadingLightActive(i, lgt.id)) {
if (txtLogEnable) log.debug "ENFORCEMENT: Skipping [${lgt.displayName}] as Reading Mode is active."
return
}
if (lgt.hasCommand("setLevel")) {
lgt.setLevel(1)
pauseExecution(400)
}
lgt.off()
logInfo("ENFORCEMENT: ${rName} is asleep but light [${lgt.displayName}] was ON. Forced OFF.")
}
}
}
def lightsOn = settings["roomLightsOn${i}"]
if (lightsOn) {
lightsOn.each { lgt ->
if (lgt.currentValue("switch") == "off") {
lgt.on()
logInfo("ENFORCEMENT: ${rName} is asleep but bedtime light/plug [${lgt.displayName}] was OFF. Forced ON.")
}
}
}
} else {
if (txtLogEnable) log.debug "ENFORCEMENT: Lighting checks successfully paused for ${rName} (Sunrise or Romance Active)."
}
def shadeContact = settings["shadeContact${i}"]
def shade = settings["roomShade${i}"]
if (shadeContact && shade && shadeContact.currentValue("contact") == "open") {
shade.close()
logInfo("ENFORCEMENT: ${rName} is asleep but shade contact was OPEN. Forced CLOSE.")
}
evaluateFans(i)
}
}
}
}
}
def hubRebootHandler(evt) {
logInfo("SYSTEM BOOT: Hub reboot or power failure detected. Running nighttime recovery scan...")
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"]) {
if (state."roomAsleepStatus${i}") {
def rName = settings["roomName${i}"] ?: "Room ${i}"
logInfo("RECOVERY: ${rName} is still ASLEEP. Re-applying Good Night environment...")
executeRoomGoodNight(i)
}
}
}
evaluateGlobalMode(null)
}
def calculateSleepSuitability(cTemp, cHum) {
if (cTemp == null) return "Awaiting Sensor Data..."
def tempStatus = ""
def humStatus = ""
def color = "green"
if (cTemp < 60.0) { tempStatus = "Too Cold"; color = "blue" }
else if (cTemp >= 60.0 && cTemp <= 69.0) { tempStatus = "Optimal Temp" }
else { tempStatus = "Too Warm"; color = "red" }
if (cHum != null) {
if (cHum < 30.0) humStatus = " & Dry"
else if (cHum >= 30.0 && cHum <= 50.0) humStatus = " & Ideal Humidity"
else { humStatus = " & Humid"; color = "orange" }
}
def finalStatus = tempStatus + humStatus
if (finalStatus.contains("Optimal Temp") && (humStatus == "" || humStatus.contains("Ideal"))) {
return "Perfect 🌙"
}
return "${finalStatus}"
}
def areRequiredRoomsAsleep() {
if (!settings.syncRooms) return false
def allAsleep = true
def roomsChecked = 0
for (int i = 1; i <= 4; i++) {
if (settings.syncRooms.contains(i.toString())) {
roomsChecked++
if (!state."roomAsleepStatus${i}") {
allAsleep = false
break
}
}
}
return (roomsChecked > 0 && allAsleep)
}
def areAllLeadRoomsAsleep() {
if (!settings.leadRooms) return false
def allAsleep = true
def roomsList = settings.leadRooms as List
roomsList.each { rNum ->
if (!state."roomAsleepStatus${rNum}") {
allAsleep = false
}
}
return allAsleep
}
def globalMotionHandler(evt) {
logInfo("GLOBAL SYNC: Motion state changed to [${evt.value}] on ${evt.device.displayName}. Re-evaluating...")
evaluateGlobalMode(evt)
}
def blockingSwitchHandler(evt) {
logInfo("GLOBAL SYNC: Blocking device state changed to [${evt.value}] on ${evt.device.displayName}. Re-evaluating...")
evaluateGlobalMode(evt)
}
def roomSwitchHandler(evt) {
def roomNum = null
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"]) {
if (settings["roomSwitch${i}"]?.id == evt.device.id || (settings["enableDualOccupant${i}"] && settings["partnerSwitch${i}"]?.id == evt.device.id)) {
roomNum = i
break
}
}
}
if (!roomNum) return
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
def sw1 = settings["roomSwitch${roomNum}"]
def sw2 = settings["partnerSwitch${roomNum}"]
def isNowAsleep = false
if (settings["enableDualOccupant${roomNum}"]) {
isNowAsleep = (sw1?.currentValue("switch") == "on" && sw2?.currentValue("switch") == "on")
} else {
isNowAsleep = (sw1?.currentValue("switch") == "on")
}
def wasAsleep = state."roomAsleepStatus${roomNum}" ?: false
if (isNowAsleep && !wasAsleep) {
state."roomAsleepStatus${roomNum}" = true
state."sleepStartTime${roomNum}" = now()
def dt = new Date()
def minsSinceMidnight = (dt.getHours() * 60) + dt.getMinutes()
if (dt.getHours() < 12) minsSinceMidnight += 1440
state."sleepStartMinsOfDay${roomNum}" = minsSinceMidnight
logInfo("${rName}: Good Night Triggered (Multi-Occupant Sync Met if enabled). Engaging Routine.")
executeRoomGoodNight(roomNum)
if (settings.enableLeadRoomOverride && settings.leadRooms?.contains(roomNum.toString())) {
if (areAllLeadRoomsAsleep()) {
def delaySecs = (settings.leadRoomTimeout ?: 30) * 60
state.overrideScheduledTime = now() + (delaySecs * 1000)
logInfo("GOOD NIGHT OVERRIDE: All selected Lead Rooms are asleep. Waiting ${settings.leadRoomTimeout} minutes for other rooms.")
runIn(delaySecs, "evaluateLeadRoomOverride")
}
}
} else if (!isNowAsleep && wasAsleep) {
state."roomAsleepStatus${roomNum}" = false
def startTime = state."sleepStartTime${roomNum}"
if (startTime) {
def totalMins = Math.round((now() - startTime) / 60000.0).toInteger()
def hours = (totalMins / 60).toInteger()
def mins = totalMins % 60
logInfo("${rName}: Good Night Wake Up Triggered. Sleep Duration Logged: ${hours}h ${mins}m.")
def hist = state."sleepHistory${roomNum}" ?: []
def startMins = state."sleepStartMinsOfDay${roomNum}" ?: 0
def todayDate = new Date().format("MM/dd", location.timeZone)
hist.add(0, [date: todayDate, duration: "${hours}h ${mins}m", startMins: startMins])
if (hist.size() > 7) hist = hist.take(7)
state."sleepHistory${roomNum}" = hist
state.remove("sleepStartTime${roomNum}")
state.remove("sleepStartMinsOfDay${roomNum}")
}
endRoomGoodNight(roomNum)
if (settings.enableLeadRoomOverride && settings.leadRooms?.contains(roomNum.toString())) {
unschedule("evaluateLeadRoomOverride")
state.remove("overrideScheduledTime")
logInfo("GOOD NIGHT OVERRIDE: A Lead Room (${roomNum}) woke up. Canceled evaluation.")
}
}
evaluateGlobalMode(evt)
}
def evaluateLeadRoomOverride() {
state.remove("overrideScheduledTime")
if (!settings.enableLeadRoomOverride || !settings.targetOverrideMode) return
logInfo("GOOD NIGHT OVERRIDE: Timeout reached. Checking other rooms and motion.")
def otherRoomAwake = false
def lRooms = settings.leadRooms as List
for (int i = 1; i <= 4; i++) {
if (!lRooms.contains(i.toString()) && settings["enableRoom${i}"]) {
if (!state."roomAsleepStatus${i}") {
otherRoomAwake = true
break
}
}
}
if (otherRoomAwake) {
def motionActive = false
if (settings.leadRoomMotionSensors) {
settings.leadRoomMotionSensors.each { mSens ->
if (mSens.currentValue("motion") == "active") {
motionActive = true
}
}
}
if (!motionActive) {
logInfo("GOOD NIGHT OVERRIDE: Other rooms are awake but no motion detected. Forcing mode to ${settings.targetOverrideMode}.")
setLocationMode(settings.targetOverrideMode)
} else {
logInfo("GOOD NIGHT OVERRIDE: Other rooms awake and motion detected. Skipping forced mode change, falling back to standard rules.")
}
} else {
logInfo("GOOD NIGHT OVERRIDE: All enabled rooms are actually asleep. Letting standard Night mode logic handle it.")
}
}
def evaluateGlobalMode(evt = null) {
if (!enableGlobalMode) return
def now = new Date()
def currentMode = location.mode
if (nightStartTime && nightEndTime && targetNightMode) {
def nightStart = timeToday(nightStartTime, location.timeZone)
def nightEnd = timeToday(nightEndTime, location.timeZone)
def isNightWindow = false
if (nightStart.time <= nightEnd.time) {
isNightWindow = (now.time >= nightStart.time && now.time <= nightEnd.time)
} else {
isNightWindow = (now.time >= nightStart.time || now.time <= nightEnd.time)
}
if (txtLogEnable) log.debug "EVAL DEBUG: isNightWindow=${isNightWindow} | currentMode=${currentMode} | targetMode=${targetNightMode}"
if (isNightWindow && currentMode != targetNightMode) {
def isAllowedMode = true
if (allowedNightModes) isAllowedMode = (allowedNightModes as List).contains(currentMode)
if (txtLogEnable) log.debug "EVAL DEBUG: isAllowedMode=${isAllowedMode}"
if (isAllowedMode) {
def allAsleep = true
def roomsChecked = 0
for (int i = 1; i <= 4; i++) {
if (syncRooms && syncRooms.contains(i.toString())) {
roomsChecked++
if (!state."roomAsleepStatus${i}") allAsleep = false
}
}
if (roomsChecked == 0) allAsleep = false
if (txtLogEnable) log.debug "EVAL DEBUG: allAsleep=${allAsleep} (Rooms Checked: ${roomsChecked})"
if (allAsleep) {
def nDelay = settings.nightModeDelay != null ? settings.nightModeDelay.toInteger() : 60
if (!state.nightModeScheduledTime) {
state.nightModeScheduledTime = now.time + (nDelay * 1000)
logInfo("GLOBAL SYNC: Required rooms asleep. Scheduling Night Mode evaluation in ${nDelay} seconds.")
runIn(nDelay, "executeNightModeChange")
}
} else {
if (state.nightModeScheduledTime) {
logInfo("GLOBAL SYNC: Timer Canceled. A required room is awake.")
unschedule("executeNightModeChange")
state.remove("nightModeScheduledTime")
}
}
} else {
if (state.nightModeScheduledTime) {
logInfo("GLOBAL SYNC: Timer Canceled. Hub mode shifted out of the Safety Whitelist.")
unschedule("executeNightModeChange")
state.remove("nightModeScheduledTime")
}
}
} else {
if (state.nightModeScheduledTime) {
unschedule("executeNightModeChange")
state.remove("nightModeScheduledTime")
}
}
}
if (wakeStartTime && wakeEndTime && requireNightMode && targetWakeMode) {
def wakeStart = timeToday(wakeStartTime, location.timeZone)
def wakeEnd = timeToday(wakeEndTime, location.timeZone)
def isWakeWindow = false
if (wakeStart.time <= wakeEnd.time) {
isWakeWindow = (now.time >= wakeStart.time && now.time <= wakeEnd.time)
} else {
isWakeWindow = (now.time >= wakeStart.time || now.time <= wakeEnd.time)
}
if (isWakeWindow && currentMode == requireNightMode) {
def allAwake = true
def anyRoomConfigured = false
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"]) {
anyRoomConfigured = true
if (state."roomAsleepStatus${i}") allAwake = false
}
}
if (anyRoomConfigured && allAwake) {
def wDelay = settings.wakeModeDelay != null ? settings.wakeModeDelay.toInteger() : 60
if (!state.wakeModeScheduledTime) {
logInfo("GLOBAL SYNC: All Good Night switches are OFF. Scheduling Wake Mode (${targetWakeMode}) in ${wDelay} seconds.")
state.wakeModeScheduledTime = now.time + (wDelay * 1000)
runIn(wDelay, "executeWakeModeChange")
}
} else {
unschedule("executeWakeModeChange")
state.remove("wakeModeScheduledTime")
}
} else {
unschedule("executeWakeModeChange")
state.remove("wakeModeScheduledTime")
}
}
}
def executeRoomGoodNight(roomNum) {
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
unschedule("turnOffHoldReleaseRoom${roomNum}")
unschedule("fanOffTwoRoom${roomNum}")
unschedule("fanOffThreeRoom${roomNum}")
unschedule("fanPowerOffRoom${roomNum}")
def doWindDown = settings["enableWindDown${roomNum}"]
def lights = settings["roomLights${roomNum}"]
if (doWindDown) {
def durMins = settings["windDownDuration${roomNum}"] ?: 15
logInfo("${rName}: Initiating Adaptive Wind Down for ${durMins} minutes.")
state."windDownActive${roomNum}" = true
state."windDownStep${roomNum}" = 0
state."windDownMaxSteps${roomNum}" = durMins
if (lights) {
lights.each { lgt ->
if (isReadingLightActive(roomNum, lgt.id)) {
logInfo("${rName}: Skipping light [${lgt.displayName}] (Reading Mode active).")
return
}
if (lgt.hasCommand("setLevel")) {
lgt.setLevel(0, durMins * 60)
} else {
lgt.off()
}
}
}
} else {
if (lights) {
lights.each { lgt ->
if (isReadingLightActive(roomNum, lgt.id)) {
logInfo("${rName}: Skipping light [${lgt.displayName}] (Reading Mode active).")
return
}
if (lgt.hasCommand("setLevel")) {
lgt.setLevel(1)
pauseExecution(400)
}
lgt.off()
}
logInfo("${rName}: Lights turned OFF (w/ 1% flashbang protection if applicable).")
}
}
def lightsOn = settings["roomLightsOn${roomNum}"]
if (lightsOn) {
lightsOn.each { lgt ->
if (lgt.currentValue("switch") != "on") lgt.on()
}
logInfo("${rName}: Bedtime Lights/Plugs turned ON.")
}
def shadeContact = settings["shadeContact${roomNum}"]
def shade = settings["roomShade${roomNum}"]
if (shadeContact && shade && shadeContact.currentValue("contact") == "open") {
shade.close()
logInfo("${rName}: Shade contact is open. Closing shade.")
}
if (doWindDown) {
def startVol = settings["windDownStartVol${roomNum}"] ?: 30
def speaker = settings["roomSpeaker${roomNum}"]
if (speaker) {
speaker.setVolume(startVol)
logInfo("${rName}: Setting starting Wind Down volume to ${startVol}%.")
}
runIn(60, "executeWindDownLoopRoom${roomNum}")
executeAudioPlay(roomNum)
} else {
def speakerPower = settings["roomSpeakerPower${roomNum}"]
def audioType = settings["audioSourceType${roomNum}"] ?: "uri"
if (settings["roomSpeaker${roomNum}"] || audioType == "switch") {
if (speakerPower) {
if (speakerPower.hasCommand("refresh")) {
speakerPower.refresh()
logInfo("${rName}: Refreshed speaker power plug state.")
pauseExecution(1000)
}
if (speakerPower.currentValue("switch") == "off") {
logInfo("${rName}: Speaker power plug is OFF. Turning ON and waiting 120s before initiating audio play.")
speakerPower.on()
runIn(120, "playDelayedAudioRoom${roomNum}")
} else {
executeAudioPlay(roomNum)
}
} else {
executeAudioPlay(roomNum)
}
}
}
evaluateFans(roomNum)
}
def executeWindDownLoopRoom1() { windDownStepHandler(1) }
def executeWindDownLoopRoom2() { windDownStepHandler(2) }
def executeWindDownLoopRoom3() { windDownStepHandler(3) }
def executeWindDownLoopRoom4() { windDownStepHandler(4) }
def windDownStepHandler(roomNum) {
if (!state."windDownActive${roomNum}") return
def step = state."windDownStep${roomNum}" + 1
def maxSteps = state."windDownMaxSteps${roomNum}"
def endVol = settings["audioVolume${roomNum}"] ?: 15
def speaker = settings["roomSpeaker${roomNum}"]
if (step >= maxSteps) {
state.remove("windDownActive${roomNum}")
if (speaker) speaker.setVolume(endVol)
def lights = settings["roomLights${roomNum}"]
if (lights) {
lights.each { lgt ->
if (isReadingLightActive(roomNum, lgt.id)) return
if (lgt.currentValue("switch") != "off") lgt.off()
}
}
logInfo("Room ${roomNum}: Wind Down Complete. Reached target sleep levels.")
return
}
state."windDownStep${roomNum}" = step
def startVol = settings["windDownStartVol${roomNum}"] ?: 30
def currentVol = startVol - (((startVol - endVol) / maxSteps) * step).toInteger()
if (speaker) speaker.setVolume(currentVol)
runIn(60, "executeWindDownLoopRoom${roomNum}")
}
def playDelayedAudioRoom1() { executeAudioPlay(1) }
def playDelayedAudioRoom2() { executeAudioPlay(2) }
def playDelayedAudioRoom3() { executeAudioPlay(3) }
def playDelayedAudioRoom4() { executeAudioPlay(4) }
def executeAudioPlay(roomNum) {
def speaker = settings["roomSpeaker${roomNum}"]
def audioType = settings["audioSourceType${roomNum}"] ?: "uri"
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
if (speaker) {
if (!state."windDownActive${roomNum}") {
def setVol = settings["audioVolume${roomNum}"]
if (setVol != null) {
speaker.setVolume(setVol)
logInfo("${rName}: Speaker volume forced to ${setVol}%.")
}
}
if (audioType == "uri") {
def trackToPlay = state."nextUri${roomNum}"
if (trackToPlay) {
speaker.playTrack(trackToPlay)
logInfo("${rName}: Playing tonight's Sonos URI (${trackToPlay}).")
state."lastUri${roomNum}" = trackToPlay
}
} else if (audioType == "switch") {
def switchToTurnOnId = state."nextSwitchId${roomNum}"
if (switchToTurnOnId) {
def targetSw = null
for(int u = 1; u <= 5; u++) {
def sw = settings["audioSwitch${roomNum}_${u}"]
if (sw?.id == switchToTurnOnId) {
targetSw = sw
break
}
}
if (targetSw) {
targetSw.on()
logInfo("${rName}: Triggered Sonos Favorite Virtual Switch (${targetSw.displayName}).")
state."lastSwitchId${roomNum}" = switchToTurnOnId
if (settings["audioVolume${roomNum}"] != null && !state."windDownActive${roomNum}") {
runIn(30, "applyDelayedVolumeRoom${roomNum}")
}
}
}
}
def sTimer = settings["audioTimer${roomNum}"]
if (sTimer) {
logInfo("${rName}: Sleep timer set for ${sTimer} minutes.")
runIn(sTimer * 60, "stopAudioRoom${roomNum}")
}
}
}
def stopAudioRoom1() { executeAudioStop(1) }
def stopAudioRoom2() { executeAudioStop(2) }
def stopAudioRoom3() { executeAudioStop(3) }
def stopAudioRoom4() { executeAudioStop(4) }
def executeAudioStop(roomNum) {
def speaker = settings["roomSpeaker${roomNum}"]
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
if (speaker && speaker.hasCommand("stop")) {
speaker.stop()
logInfo("${rName}: Sleep timer reached. Audio stopped.")
}
}
def applyDelayedVolumeRoom1() { executeDelayedVolume(1) }
def applyDelayedVolumeRoom2() { executeDelayedVolume(2) }
def applyDelayedVolumeRoom3() { executeDelayedVolume(3) }
def applyDelayedVolumeRoom4() { executeDelayedVolume(4) }
def executeDelayedVolume(roomNum) {
def speaker = settings["roomSpeaker${roomNum}"]
def setVol = settings["audioVolume${roomNum}"]
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
if (speaker && setVol != null) {
speaker.setVolume(setVol)
logInfo("${rName}: Applied delayed volume enforcement (${setVol}%) 30 seconds after Sonos Favorite start.")
}
}
def endRoomGoodNight(roomNum) {
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
logInfo("${rName}: Executing Wake-Up routine (shutting down fans and audio).")
state.remove("windDownActive${roomNum}")
unschedule("executeWindDownLoopRoom${roomNum}")
unschedule("playDelayedAudioRoom${roomNum}")
unschedule("stopAudioRoom${roomNum}")
unschedule("applyDelayedFanSpeedRoom${roomNum}")
unschedule("applyDelayedVolumeRoom${roomNum}")
state.remove("pendingFanSpeed${roomNum}")
def stdFans = settings["roomFans${roomNum}"]
if (stdFans) stdFans.off()
def lightsOn = settings["roomLightsOn${roomNum}"]
if (lightsOn) {
lightsOn.each { lgt ->
if (lgt.currentValue("switch") != "off") lgt.off()
}
logInfo("${rName}: Bedtime Lights/Plugs turned OFF (Wake-up).")
}
def cFanSwitch = settings["ceilingFanSwitch${roomNum}"]
def cFanSpeed = settings["ceilingFanSpeed${roomNum}"]
def fType = settings["fanType${roomNum}"] ?: "3_speed"
if (cFanSpeed && cFanSpeed.hasCommand("setSpeed") && fType != "on_off") {
cFanSpeed.setSpeed("low")
logInfo("${rName}: Applying Low-Off Wiggle fix. Initiating 3x redundant shutdown sequence before cutting power.")
runIn(2, "fanOffTwoRoom${roomNum}")
} else if (cFanSwitch) {
cFanSwitch.off()
}
def speaker = settings["roomSpeaker${roomNum}"]
if (speaker && speaker.hasCommand("stop")) speaker.stop()
def holdRelease = settings["shadeHoldRelease${roomNum}"]
if (holdRelease) {
holdRelease.on()
logInfo("${rName}: Sent hold release signal to Advanced Shade Controller. Auto-reset scheduled in 30s.")
runIn(30, "turnOffHoldReleaseRoom${roomNum}")
}
prepNextAudio(roomNum)
}
def turnOffHoldReleaseRoom1() { executeHoldReleaseOff(1) }
def turnOffHoldReleaseRoom2() { executeHoldReleaseOff(2) }
def turnOffHoldReleaseRoom3() { executeHoldReleaseOff(3) }
def turnOffHoldReleaseRoom4() { executeHoldReleaseOff(4) }
def executeHoldReleaseOff(roomNum) {
def holdRelease = settings["shadeHoldRelease${roomNum}"]
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
if (holdRelease && holdRelease.currentValue("switch") != "off") {
holdRelease.off()
logInfo("${rName}: Hold release signal automatically reset to OFF.")
}
}
def fanOffTwoRoom1() { executeFanOffTwo(1) }
def fanOffTwoRoom2() { executeFanOffTwo(2) }
def fanOffTwoRoom3() { executeFanOffTwo(3) }
def fanOffTwoRoom4() { executeFanOffTwo(4) }
def executeFanOffTwo(roomNum) {
def cFanSpeed = settings["ceilingFanSpeed${roomNum}"]
def fType = settings["fanType${roomNum}"] ?: "3_speed"
if (cFanSpeed && cFanSpeed.hasCommand("setSpeed") && fType != "on_off") cFanSpeed.setSpeed("off")
runIn(2, "fanOffThreeRoom${roomNum}")
}
def fanOffThreeRoom1() { executeFanOffThree(1) }
def fanOffThreeRoom2() { executeFanOffThree(2) }
def fanOffThreeRoom3() { executeFanOffThree(3) }
def fanOffThreeRoom4() { executeFanOffThree(4) }
def executeFanOffThree(roomNum) {
def cFanSpeed = settings["ceilingFanSpeed${roomNum}"]
def fType = settings["fanType${roomNum}"] ?: "3_speed"
if (cFanSpeed && cFanSpeed.hasCommand("setSpeed") && fType != "on_off") cFanSpeed.setSpeed("off")
runIn(2, "fanPowerOffRoom${roomNum}")
}
def fanPowerOffRoom1() { executeFanPowerOff(1) }
def fanPowerOffRoom2() { executeFanPowerOff(2) }
def fanPowerOffRoom3() { executeFanPowerOff(3) }
def fanPowerOffRoom4() { executeFanPowerOff(4) }
def executeFanPowerOff(roomNum) {
def cFanSwitch = settings["ceilingFanSwitch${roomNum}"]
if (cFanSwitch) {
cFanSwitch.off()
logInfo("${settings["roomName${roomNum}"] ?: "Room " + roomNum}: Ceiling fan power safely disconnected.")
}
}
def tempHandler(evt) {
for (int i = 1; i <= 4; i++) {
if (settings["enableRoom${i}"] && settings["tempSensor${i}"]?.id == evt.device.id) {
if (state."roomAsleepStatus${i}") {
evaluateFans(i)
}
}
}
}
def evaluateFans(roomNum) {
def sensor = settings["tempSensor${roomNum}"]
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
def currentTemp = sensor ? sensor.currentValue("temperature") : null
if (currentTemp != null) {
def stdSetpoint = settings["fanSetpoint${roomNum}"]
def stdFans = settings["roomFans${roomNum}"]
if (stdSetpoint && stdFans) {
if (currentTemp >= stdSetpoint) {
stdFans.each { if (it.currentValue("switch") != "on") it.on() }
} else {
stdFans.each { if (it.currentValue("switch") != "off") it.off() }
}
}
}
def cFanSwitch = settings["ceilingFanSwitch${roomNum}"]
def cFanSpeed = settings["ceilingFanSpeed${roomNum}"]
def cFanSetpoint = settings["ceilingFanSetpoint${roomNum}"]
def delta = settings["fanSpeedDelta${roomNum}"] ?: 1.0
def fType = settings["fanType${roomNum}"] ?: "3_speed"
if (cFanSwitch) {
if (cFanSwitch.currentValue("switch") != "on") {
cFanSwitch.on()
logInfo("${rName}: Ceiling fan powered ON.")
if (fType != "on_off" && cFanSpeed && cFanSetpoint && currentTemp != null) {
def targetSpeed = calculateTargetSpeed(currentTemp, cFanSetpoint, delta, fType)
logInfo("${rName}: Waiting 30 seconds before setting speed to ${targetSpeed.toUpperCase()}.")
state."pendingFanSpeed${roomNum}" = targetSpeed
runIn(30, "applyDelayedFanSpeedRoom${roomNum}")
}
} else {
if (fType != "on_off" && cFanSpeed && cFanSetpoint && currentTemp != null && !state."pendingFanSpeed${roomNum}") {
def targetSpeed = calculateTargetSpeed(currentTemp, cFanSetpoint, delta, fType)
if (cFanSpeed.currentValue("speed") != targetSpeed) {
cFanSpeed.setSpeed(targetSpeed)
logInfo("${rName}: Ceiling fan dynamically adjusted to ${targetSpeed.toUpperCase()} (Temp: ${currentTemp}°, Setpoint: ${cFanSetpoint}°).")
}
}
}
}
}
def applyDelayedFanSpeedRoom1() { executeDelayedFanSpeed(1) }
def applyDelayedFanSpeedRoom2() { executeDelayedFanSpeed(2) }
def applyDelayedFanSpeedRoom3() { executeDelayedFanSpeed(3) }
def applyDelayedFanSpeedRoom4() { executeDelayedFanSpeed(4) }
def executeDelayedFanSpeed(roomNum) {
def cFanSpeed = settings["ceilingFanSpeed${roomNum}"]
def targetSpeed = state."pendingFanSpeed${roomNum}"
def rName = settings["roomName${roomNum}"] ?: "Room ${roomNum}"
if (cFanSpeed && targetSpeed) {
cFanSpeed.setSpeed(targetSpeed)
logInfo("${rName}: 30-second hardware warm-up complete. Ceiling fan speed safely set to ${targetSpeed.toUpperCase()}.")
state.remove("pendingFanSpeed${roomNum}")
}
}
def prepNextAudio(roomNum) {
def audioType = settings["audioSourceType${roomNum}"] ?: "uri"
if (audioType == "uri") {
state.remove("nextSwitchId${roomNum}")
def uris = []
for(int u = 1; u <= 5; u++) {
def uri = settings["audioUri${roomNum}_${u}"]
if (uri) uris << uri
}
if (uris.size() > 0) {
if (uris.size() == 1) {
state."nextUri${roomNum}" = uris[0]
} else {
def lastPlayed = state."lastUri${roomNum}"
def availableUris = uris.findAll { it != lastPlayed }
if (availableUris.size() == 0) availableUris = uris
def chosen = availableUris[new Random().nextInt(availableUris.size())]
state."nextUri${roomNum}" = chosen
}
} else {
state.remove("nextUri${roomNum}")
}
} else if (audioType == "switch") {
state.remove("nextUri${roomNum}")
def switches = []
for(int u = 1; u <= 5; u++) {
def sw = settings["audioSwitch${roomNum}_${u}"]
if (sw) switches << sw.id
}
if (switches.size() > 0) {
if (switches.size() == 1) {
state."nextSwitchId${roomNum}" = switches[0]
} else {
def lastPlayed = state."lastSwitchId${roomNum}"
def availableSwitches = switches.findAll { it != lastPlayed }
if (availableSwitches.size() == 0) availableSwitches = switches
def chosen = availableSwitches[new Random().nextInt(availableSwitches.size())]
state."nextSwitchId${roomNum}" = chosen
}
} else {
state.remove("nextSwitchId${roomNum}")
}
}
}
def logInfo(msg) {
if (txtLogEnable) log.info "${app.label}: ${msg}"
def hist = state.eventLog ?: []
def timeStamp = new Date().format("MM/dd hh:mm:ss a", location.timeZone)
hist.add(0, "[${timeStamp}] ${msg}")
if (hist.size() > 20) hist = hist.take(20)
state.eventLog = hist
}
def executeNightModeChange() {
state.remove("nightModeScheduledTime")
if (!targetNightMode) return
def noMotion = true
if (syncMotion) {
syncMotion.each { mSens ->
if (mSens.currentValue("motion") == "active") noMotion = false
}
}
def noBlockingDevices = true
if (blockingSwitches) {
blockingSwitches.each { bSw ->
if (bSw.currentValue("switch") == "on") noBlockingDevices = false
}
}
if (noMotion && noBlockingDevices) {
// --- SAFETY SWEEP HANDSHAKE ---
if (enableSafetySweep) {
def failedDevs = []
if (sweepContacts) {
sweepContacts.each { if (it.currentValue("contact") == "open") failedDevs << it.displayName }
}
if (sweepLocks) {
sweepLocks.each { if (it.currentValue("lock") == "unlocked") failedDevs << it.displayName }
}
if (failedDevs.size() > 0) {
def msg = "Night mode paused. Please check the ${failedDevs.join(', ')}."
logInfo("SAFETY SWEEP FAILED: ${msg}")
if (sweepSpeaker) sweepSpeaker.speak(msg)
return
}
}
// ------------------------------
logInfo("GLOBAL SYNC: Countdown complete. House is quiet and ready. Changing mode to ${targetNightMode}.")
setLocationMode(targetNightMode)
} else {
logInfo("GLOBAL SYNC: Countdown complete, but Motion or Blockers are active. Waiting for them to clear...")
}
}
def executeWakeModeChange() {
state.remove("wakeModeScheduledTime")
if (targetWakeMode) {
logInfo("GLOBAL SYNC: Delay complete. Changing mode to ${targetWakeMode}.")
setLocationMode(targetWakeMode)
}
}