/** * Advanced Room Occupancy */ definition( name: "Advanced Room Occupancy", namespace: "ShaneAllen", author: "ShaneAllen", description: "Multi-zone occupancy controller with System Boot Recovery, Active Wattage Failsafes, Two-Stage Shutdowns, Collapsible UI, and Partial Occupancy.", category: "Green Living", iconUrl: "", iconX2Url: "", iconX3Url: "" ) preferences { page(name: "mainPage") page(name: "roomConfigPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Advanced Room Occupancy", install: true, uninstall: true) { section("Live Occupancy & ROI Dashboard") { input "refreshDashboardBtn", "button", title: "🔄 Refresh Live Data" paragraph "
What it does: Provides a real-time view of your configured rooms, active triggers, power profiles, and exact financial savings.
" def hasZones = false def rate = kwhCost ?: 0.13 def totalAppSavings = 0.0 def statusText = "" statusText += "" for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { hasZones = true def zName = settings["z${i}Name"] ?: "Room ${i}" def isOccupied = getRoomOccupancyState(i) def currentState = state."currentRoomStates_z${i}" def mDevs = settings["z${i}Motion"] def vDevs = settings["z${i}Vibration"] def pDevs = settings["z${i}Presence"] def devs = settings["z${i}Switches"] def softDevs = settings["z${i}SoftKillDevices"] def pMonitor = settings["z${i}PowerMonitor"] def oSwitch = settings["z${i}OverrideSwitch"] def gnSwitch = settings["z${i}GoodNightSwitch"] def statusAdditions = [] def sensorDetails = [] def maxRemainingMs = 0 def isHardActive = false if (gnSwitch && gnSwitch.currentValue("switch") == "on") { sensorDetails << "🌙 Good Night: ON (Locked)" isHardActive = true } if (pDevs) { pDevs.each { dev -> def val = dev.currentValue("presence") def color = val == "present" ? "blue" : "gray" def fw = val == "present" ? "bold" : "normal" sensorDetails << "👤 ${dev.displayName}: ${val}" if (val == "present") isHardActive = true } } if (mDevs) { mDevs.each { dev -> def val = dev.currentValue("motion") def color = val == "active" ? "blue" : "gray" def fw = val == "active" ? "bold" : "normal" sensorDetails << "🏃 ${dev.displayName}: ${val}" if (val == "active") isHardActive = true } def mTimeout = (settings["z${i}Timeout"] ?: 15) * 60000 def mLast = state."zoneLastActive_z${i}" ?: null def mReqHits = settings["z${i}MotionActivationHits"] ?: 1 def mHits = state."motionHitCount_z${i}" ?: 0 if (mLast && !mDevs.any{it.currentValue("motion") == "active"}) { def mLeft = mTimeout - (now() - mLast) if (mLeft > maxRemainingMs) maxRemainingMs = mLeft } if (settings["z${i}TurnOnTriggers"]?.contains("Motion Hit Count") && mReqHits > 1 && mHits > 0) { def windowMs = (settings["z${i}MotionActivationWindow"] ?: 3) * 60000 def hitStart = state."motionHitStartTime_z${i}" ?: (mLast ?: now()) def windowLeft = (hitStart + windowMs) - now() if (windowLeft > 0) { def secsLeft = Math.ceil(windowLeft / 1000).toInteger() statusAdditions << "Motion Hits: ${mHits}/${mReqHits} (Resets in ${secsLeft}s)" } } if (settings["z${i}TurnOnTriggers"]?.contains("Continuous Motion")) { def activeSince = state."motionActiveSince_z${i}" ?: null if (activeSince) { def reqMins = settings["z${i}MotionContinuousDuration"] ?: 3 def left = (activeSince + (reqMins * 60000)) - now() if (left > 0) { def secsLeft = Math.ceil(left / 1000).toInteger() statusAdditions << "Continuous Motion: ${secsLeft}s left" } else { def activeMins = Math.floor((now() - activeSince) / 60000).toInteger() statusAdditions << "Continuous Motion: Active for ${activeMins}m" } } } } if (vDevs) { vDevs.each { dev -> def val = dev.currentValue("acceleration") def color = val == "active" ? "blue" : "gray" def fw = val == "active" ? "bold" : "normal" sensorDetails << "📳 ${dev.displayName}: ${val}" if (val == "active") isHardActive = true } def vTimeout = (settings["z${i}VibeTimeout"] ?: 5) * 60000 def vLast = state."vibeLastActive_z${i}" ?: null def vReqHits = settings["z${i}VibeActivationHits"] ?: 1 def vHits = state."vibeHitCount_z${i}" ?: 0 if (vLast && !vDevs.any{it.currentValue("acceleration") == "active"}) { def vLeft = vTimeout - (now() - vLast) if (vLeft > maxRemainingMs) maxRemainingMs = vLeft } if (settings["z${i}TurnOnTriggers"]?.contains("Vibration") && vReqHits > 1 && vHits > 0) { def windowMs = (settings["z${i}VibeActivationWindow"] ?: 3) * 60000 def hitStart = state."vibeHitStartTime_z${i}" ?: (vLast ?: now()) def windowLeft = (hitStart + windowMs) - now() if (windowLeft > 0) { def secsLeft = Math.ceil(windowLeft / 1000).toInteger() statusAdditions << "Vibe Hits: ${vHits}/${vReqHits} (Resets in ${secsLeft}s)" } } } if (oSwitch && oSwitch.currentValue("switch") == "on") { def switchMode = state."switchIsManual_z${i}" ? "Manual" : "Auto-Sync" sensorDetails << "🔘 Override Switch: ON (${switchMode})" } if (pMonitor) { def currentDraw = pMonitor.currentValue("power") ?: 0.0 def safeThresh = settings["z${i}ActiveWattageThreshold"] ?: 15.0 if (currentDraw > safeThresh) { statusAdditions << "Power Lock Active (${currentDraw}W)" isHardActive = true } } if (!isOccupied && state."waitForClear_z${i}") { statusAdditions << "Force Off: Waiting for sensors to clear..." } if (!isOccupied && currentState == "empty") { def cooldownMins = settings["z${i}UnoccupiedCooldown"] != null ? settings["z${i}UnoccupiedCooldown"].toInteger() : 5 if (cooldownMins > 0 && state."unoccupiedLockoutTime_z${i}") { def lockedTime = state."unoccupiedLockoutTime_z${i}" def timeLeft = (lockedTime + (cooldownMins * 60000)) - now() if (timeLeft > 0) { def minsLeft = Math.ceil(timeLeft / 60000).toInteger() if (minsLeft < 1) minsLeft = 1 statusAdditions << "⏳ Cooldown: Auto-triggers blocked for ~${minsLeft}m" } } } if (oSwitch && oSwitch.currentValue("switch") == "on" && state."switchIsManual_z${i}") { def oTimeout = settings["z${i}OverrideTimeout"] if (oTimeout && oTimeout > 0 && !isHardActive) { def lastM = state."zoneLastActive_z${i}" ?: 0 def lastV = state."vibeLastActive_z${i}" ?: 0 def maxLast = Math.max(lastM ?: 0, lastV ?: 0) if (maxLast > 0) { def timeLeft = (oTimeout * 60000) - (now() - maxLast) if (timeLeft > 0) { def minsLeft = Math.ceil(timeLeft / 60000).toInteger() statusAdditions << "Override Auto-Off: ~${minsLeft}m" } } } } def restrictionReason = getRoomRestrictionReason(i) if (!isOccupied && settings["z${i}Sweeper"]) { def allHard = (devs ?: []) + (settings["z${i}PartialSwitches"] ?: []) def allSoft = (softDevs ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) def anyOn = false if (allHard?.any { it.currentValue("switch") == "on" }) anyOn = true if (allSoft?.any { it.currentValue("switch") == "on" }) anyOn = true if (anyOn) { def lastAct = state."zoneLastActive_z${i}" ?: now() def timeoutMs = (settings["z${i}SweeperTimeout"] ?: 60) * 60000 def sweepMinsLeft = Math.ceil(((lastAct + timeoutMs) - now()) / 60000).toInteger() if (sweepMinsLeft > 0) { statusAdditions << "🧹 Sweeper: ~${sweepMinsLeft}m until auto-off" } else { statusAdditions << "🧹 Sweeper: Pending shutdown..." } } } if (restrictionReason && settings["z${i}AbsoluteSweeper"]) { def allHard = (devs ?: []) + (settings["z${i}PartialSwitches"] ?: []) def allSoft = (softDevs ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) def anyOn = false if (allHard?.any { it.currentValue("switch") == "on" }) anyOn = true if (allSoft?.any { it.currentValue("switch") == "on" }) anyOn = true if (anyOn) { def pActive = pDevs && pDevs.any { it.currentValue("presence") == "present" } if (!pActive) { def lastM = state."zoneLastActive_z${i}" ?: 0 def lastV = state."vibeLastActive_z${i}" ?: 0 def lastAct = Math.max(lastM ?: 0, lastV ?: 0) if (lastAct == 0) lastAct = now() def timeoutMs = (settings["z${i}AbsoluteSweeperTimeout"] ?: 120) * 60000 def sweepMinsLeft = Math.ceil(((lastAct + timeoutMs) - now()) / 60000).toInteger() if (sweepMinsLeft > 0) { statusAdditions << "🚨 Absolute Sweeper: ~${sweepMinsLeft}m until auto-off" } else { statusAdditions << "🚨 Absolute Sweeper: Pending shutdown..." } } else { statusAdditions << "🚨 Absolute Sweeper: Paused (Presence Detected)" } } } if (isOccupied && !isHardActive && maxRemainingMs > 0) { def mins = Math.floor(maxRemainingMs / 60000).toInteger() def secs = Math.floor((maxRemainingMs % 60000) / 1000).toInteger() def timeStr = mins > 0 ? "${mins}m ${secs}s" : "${secs}s" statusAdditions << "Unoccupied in: ${timeStr}" } if (!isOccupied && state."shutdownDelayActive_z${i}") { statusAdditions << "Safe Shutdown Sequence..." } def dailyStats = state."dailyStats_z${i}" ?: [:] def todayStr = getTodayDateString() def tStats = dailyStats[todayStr] ?: [occTime: 0, unoccTime: 0, count: 0] def lastUpd = state."lastStatUpdate_z${i}" ?: now() def liveDelta = ((now() - lastUpd) / 1000).toLong() if (liveDelta > 600) liveDelta = 0 def liveTodayOcc = tStats.occTime + (isOccupied ? liveDelta : 0) def liveTodayUnocc = tStats.unoccTime + (!isOccupied ? liveDelta : 0) def weeklyOcc = isOccupied ? liveDelta : 0 def weeklyUnocc = !isOccupied ? liveDelta : 0 def weeklyCount = 0 dailyStats.each { k, v -> weeklyOcc += v.occTime weeklyUnocc += v.unoccTime weeklyCount += v.count } def statHtml = "
" statHtml += "
RoomState & TimersSensorsDevices & PowerEst. Savings
" statHtml += "" statHtml += "" statHtml += "" statHtml += "
OccupiedEmptyVisits
Today:${formatDuration(liveTodayOcc)}${formatDuration(liveTodayUnocc)}${tStats.count}x
7-Day:${formatDuration(weeklyOcc)}${formatDuration(weeklyUnocc)}${weeklyCount}x
" def stateColor = restrictionReason ? "orange" : (currentState == "partial" ? "#D2691E" : (isOccupied ? "green" : "black")) def stateLabel = restrictionReason ? "PAUSED (${restrictionReason})" : (currentState == "partial" ? "PARTIAL OCC" : (isOccupied ? "OCCUPIED" : "EMPTY")) def isOccupiedDisplay = "${stateLabel}${statHtml}" def sensorListDisplay = sensorDetails ? sensorDetails.join("
") : "None Monitored" def totalActive = 0.0 def totalStandby = 0.0 def devNames = [] def allHardDevs = (devs ?: []) + (settings["z${i}PartialSwitches"] ?: []) allHardDevs = allHardDevs.unique { it.id } def allSoftDevs = (softDevs ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) allSoftDevs = allSoftDevs.unique { it.id } if (allHardDevs) { allHardDevs.each { dev -> def activeW = settings["z${i}_${dev.id}_active"] ?: 0.0 def standbyW = settings["z${i}_${dev.id}_standby"] ?: 0.0 totalActive += activeW totalStandby += standbyW def devState = dev.currentValue("switch") == "on" ? "ON" : "OFF" devNames << "⚡ ${dev.displayName} (${devState})" } } if (allSoftDevs) { allSoftDevs.each { dev -> def devState = dev.currentValue("switch") == "on" ? "ON" : "OFF" devNames << "💻 ${dev.displayName} (${devState})" } } def devListDisplay = devNames ? devNames.join("
") : "None Configured" def powerDisplay = "Active: ${totalActive}W
Standby: ${totalStandby}W
" def fullDeviceColumn = "${devListDisplay}

${powerDisplay}" def secondsOff = state."lifetimeUnoccSecs_z${i}" ?: 0 secondsOff += (!isOccupied && !restrictionReason ? liveDelta : 0) def savedHours = secondsOff / 3600.0 def wastedKw = (totalActive + totalStandby) / 1000.0 def roomSavings = savedHours * wastedKw * rate totalAppSavings += roomSavings def formattedSavings = String.format('$%.2f', roomSavings) def rowBorder = statusAdditions ? "none" : "2px solid #ccc" statusText += "" statusText += "${zName}" statusText += "${isOccupiedDisplay}" statusText += "${sensorListDisplay}" statusText += "${fullDeviceColumn}" statusText += "${formattedSavings}" statusText += "" if (statusAdditions) { statusText += "" statusText += "" statusText += "
" statusText += "Active Conditions & Timers:
" statusText += statusAdditions.join("  |  ") statusText += "
" statusText += "" } } } statusText += "" if (hasZones) { def globalStatus = (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") ? "PAUSED (Master Switch Off)" : "ACTIVE" statusText += "
" statusText += "
Global System Mode: ${globalStatus}
" statusText += "
" def formattedTotal = String.format('$%.2f', totalAppSavings) statusText += "
" statusText += "
Estimated Financial ROI: ${formattedTotal} (Calculated by eliminated Active & Standby wattage while rooms are empty)
" statusText += "
" paragraph statusText } else { paragraph "No rooms configured yet. Click a room below to begin." } } section("Room Management") { paragraph "Click a room below to configure its sensors, smart plugs, timeouts, and rules." for (int i = 1; i <= 12; i++) { def zName = settings["z${i}Name"] ?: "Room ${i}" def statusTag = settings["enableZ${i}"] ? " (Active)" : "" href(name: "roomPage${i}", page: "roomConfigPage", params: [roomId: i], title: "▶ Configure ${zName}${statusTag}", description: "") } } section("Individual Room Overrides") { paragraph "Manually force individual rooms to instantly switch states." def hasActive = false for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { hasActive = true def zName = settings["z${i}Name"] ?: "Room ${i}" input "forceEmptyBtn_${i}", "button", title: "⏏️ Force EMPTY: ${zName}" input "forceOccBtn_${i}", "button", title: "⚡ Force FULL OCC: ${zName}" input "forcePartBtn_${i}", "button", title: "💡 Force PARTIAL OCC: ${zName}" } } if (!hasActive) paragraph "No active rooms available." } section("Global Configuration & Restrictions") { input "kwhCost", "decimal", title: 'Electricity Cost ($ per kWh)', required: true, defaultValue: 0.13 input "appEnableSwitch", "capability.switch", title: "Master Enable/Disable Switch (Optional)", required: false, multiple: false paragraph "Global Mode Overrides" input "restrictedModes", "mode", title: "Restricted Modes (Pause all occupancy rules)", multiple: true, required: false input "forceOffModes", "mode", title: "Force OFF Modes (Immediately turns off all rooms when entering these modes)", multiple: true, required: false paragraph "Global Manual Controls" input "forceAllOccupiedBtn", "button", title: "Force All Rooms FULL Occupied" input "forceAllEmptyBtn", "button", title: "Force All Rooms Unoccupied" input "clearAllStatesBtn", "button", title: "⚠ EMERGENCY: Clear All States & Reset App" paragraph "Data Management" input "resetSavings", "bool", title: "Reset All Savings & Occupancy Data to Zero", defaultValue: false, submitOnChange: true if (settings["resetSavings"]) { resetAllSavings() app.updateSetting("resetSavings", false) } input "forceSync", "bool", title: "Manually Force Hardware Sync (Pushes ON/OFF commands immediately)", defaultValue: false, submitOnChange: true if (settings["forceSync"]) { if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") { log.warn "Force Sync aborted: Master Switch is OFF." } else { logAction("MANUAL OVERRIDE: Forcing hardware sync...") evaluateRooms(true) } app.updateSetting("forceSync", false) } } section("Action History & Debugging") { input "txtEnable", "bool", title: "Enable Description Text Logging", defaultValue: true input "debugEnable", "bool", title: "Enable Debug Logging (Auto-disables after 30 min)", defaultValue: false, submitOnChange: true if (state.actionHistory) { paragraph "${state.actionHistory.join("
")}
" } } } } def roomConfigPage(params) { if (params?.roomId) state.editingRoom = params.roomId def i = state.editingRoom ?: 1 def zName = settings["z${i}Name"] ?: "Room ${i}" dynamicPage(name: "roomConfigPage", title: "Configuration: ${zName}", install: false, uninstall: false) { section("▶ Setup") { input "enableZ${i}", "bool", title: "Enable this Room", submitOnChange: true if (settings["enableZ${i}"]) { input "z${i}Name", "text", title: "Room Name", required: false, defaultValue: "Room ${i}", submitOnChange: true paragraph "Triggers & Sensors" input "z${i}TurnOnTriggers", "enum", title: "Turn ON Triggers (What makes the room occupied?)", options: ["Virtual Switch", "Motion Hit Count", "Continuous Motion", "Vibration", "Presence Sensor"], multiple: true, required: true, defaultValue: ["Virtual Switch", "Motion Hit Count", "Vibration", "Presence Sensor"], submitOnChange: true input "z${i}TurnOffTriggers", "enum", title: "Turn OFF Triggers (What makes the room empty?)", options: ["Motion/Vibe Timeout", "Virtual Switch OFF"], multiple: true, required: true, defaultValue: ["Motion/Vibe Timeout", "Virtual Switch OFF"], submitOnChange: true input "z${i}UnoccupiedCooldown", "number", title: "Unoccupied Cooldown (Minutes room must stay empty before it can be automatically occupied again)", required: true, defaultValue: 5 input "z${i}GoodNightSwitch", "capability.switch", title: "Room Good Night Switch (Locks room as Occupied & ignores Mode changes)", required: false, submitOnChange: true input "z${i}OverrideSwitch", "capability.switch", title: "Virtual Override Switch", required: false, submitOnChange: true if (settings["z${i}OverrideSwitch"] && settings["z${i}TurnOnTriggers"]?.contains("Virtual Switch")) { input "z${i}OverrideTimeout", "number", title: "↳ Auto-Off Timeout (Minutes of NO movement before turning switch OFF)", required: false, defaultValue: 120 input "z${i}OverrideButton", "capability.pushableButton", title: "↳ Toggle Button (Physical Trigger to switch states)", required: false, submitOnChange: true if (settings["z${i}OverrideButton"]) { input "z${i}OverrideButtonNum", "number", title: "↳ Button Number", required: true, defaultValue: 1 input "z${i}OverrideButtonAction", "enum", title: "↳ FULL Occupancy Button Action", options: ["pushed", "held", "doubleTapped", "released"], required: true, defaultValue: "pushed" input "z${i}PartialOverrideButtonAction", "enum", title: "↳ PARTIAL Occupancy Button Action", options: ["pushed", "held", "doubleTapped", "released"], required: true, defaultValue: "doubleTapped" } } input "z${i}Motion", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false, submitOnChange: true if (settings["z${i}Motion"]) { input "z${i}MotionGracePeriod", "number", title: "↳ Motion Inactive Grace Period (Seconds before officially confirming empty)", required: true, defaultValue: 15 if (settings["z${i}TurnOnTriggers"]?.contains("Motion Hit Count")) { input "z${i}MotionActivationWindow", "number", title: "↳ Hit Count Window (Minutes)", required: true, defaultValue: 3 input "z${i}MotionActivationHits", "number", title: "↳ Required Active Hits", required: true, defaultValue: 1 } if (settings["z${i}TurnOnTriggers"]?.contains("Continuous Motion")) { input "z${i}MotionContinuousDuration", "number", title: "↳ Continuous Motion Duration (Minutes before turning ON)", required: true, defaultValue: 3 } if (settings["z${i}TurnOffTriggers"]?.contains("Motion/Vibe Timeout")) { input "z${i}Timeout", "number", title: "↳ Motion Empty Timeout (Minutes before turning OFF)", required: true, defaultValue: 15 } } input "z${i}Vibration", "capability.accelerationSensor", title: "Vibration Sensors (e.g. Chair/Bed)", multiple: true, required: false, submitOnChange: true if (settings["z${i}Vibration"]) { if (settings["z${i}TurnOnTriggers"]?.contains("Vibration")) { input "z${i}VibeActivationWindow", "number", title: "↳ Activation Window (Minutes to count hits)", required: true, defaultValue: 3 input "z${i}VibeActivationHits", "number", title: "↳ Required Active Hits to trigger Occupied", required: true, defaultValue: 1 } if (settings["z${i}TurnOffTriggers"]?.contains("Motion/Vibe Timeout")) { input "z${i}VibeTimeout", "number", title: "↳ Vibration Empty Timeout (Minutes before turning OFF)", required: true, defaultValue: 5 } } input "z${i}Presence", "capability.presenceSensor", title: "mmWave / Presence Sensors (Instant Occupied)", multiple: true, required: false paragraph "FULL Occupancy Devices" input "z${i}Switches", "capability.switch", title: "Hard Power Relays (Smart Plugs, Wall Switches)", multiple: true, required: false, submitOnChange: true input "z${i}SoftKillDevices", "capability.switch", title: "Network Devices (PCs, TVs, APIs) for Graceful Shutdown", multiple: true, required: false, submitOnChange: true paragraph "PARTIAL Occupancy Devices (Triggered via Double Tap)" input "z${i}PartialSwitches", "capability.switch", title: "Partial Mode Hard Power Relays", multiple: true, required: false, submitOnChange: true input "z${i}PartialSoftKillDevices", "capability.switch", title: "Partial Mode Network Devices", multiple: true, required: false, submitOnChange: true paragraph "Auto-Partial Occupancy (Night Mode)" input "z${i}AutoPartialModes", "mode", title: "Auto-Partial Modes (Sensors trigger Partial instead of Full)", multiple: true, required: false input "z${i}AutoPartialStart", "time", title: "Auto-Partial Start Time (Optional)", required: false input "z${i}AutoPartialEnd", "time", title: "Auto-Partial End Time (Optional)", required: false def allHardDevs = (settings["z${i}Switches"] ?: []) + (settings["z${i}PartialSwitches"] ?: []) allHardDevs = allHardDevs.unique { it.id } if (allHardDevs) { paragraph "Device Power Profiles" allHardDevs.each { dev -> input "z${i}_${dev.id}_active", "decimal", title: "↳ ${dev.displayName} - Active/Idle Watts", required: true, defaultValue: 50.0 input "z${i}_${dev.id}_standby", "decimal", title: "↳ ${dev.displayName} - Standby/Phantom Watts", required: true, defaultValue: 5.0 } } def allSoftDevs = (settings["z${i}SoftKillDevices"] ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) if (allSoftDevs) { paragraph "Two-Stage Graceful Shutdown" input "z${i}HardKillDelay", "number", title: "Delay before cutting Hard Power (Seconds)", defaultValue: 60, required: true, description: "Provides time for operating systems to shut down and screens to run pixel refresh." } paragraph "Power Failsafe Override" input "z${i}PowerMonitor", "capability.powerMeter", title: "Failsafe Power Monitor (Prevents shutdown if device is active)", required: false, submitOnChange: true if (settings["z${i}PowerMonitor"]) { input "z${i}ActiveWattageThreshold", "decimal", title: "↳ Protection Threshold (Watts)", required: true, defaultValue: 15.0, description: "If the monitor reads above this wattage, the room will ignore empty timeouts and stay ON." } paragraph "Manual Mode / Unoccupied Sweeper" input "z${i}Sweeper", "bool", title: "Enable Unoccupied Sweeper (Turns off stranded devices left ON in empty rooms)", defaultValue: false, submitOnChange: true if (settings["z${i}Sweeper"]) { input "z${i}SweeperTimeout", "number", title: "↳ Sweeper Timeout (Minutes of NO motion before turning OFF)", required: true, defaultValue: 60 } input "z${i}AbsoluteSweeper", "bool", title: "Enable Absolute Sweeper (Works even when room rules are PAUSED/Restricted)", defaultValue: false, submitOnChange: true if (settings["z${i}AbsoluteSweeper"]) { input "z${i}AbsoluteSweeperTimeout", "number", title: "↳ Absolute Sweeper Timeout (Minutes of NO motion/vibe/presence before turning OFF)", required: true, defaultValue: 120 } paragraph "Room Scheduling & Mode Restrictions" input "z${i}OperatingModes", "mode", title: "Active Modes (Leave blank for all modes)", multiple: true, required: false input "z${i}ActiveDays", "enum", title: "Active Days (Leave blank for all days)", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], multiple: true, required: false input "z${i}StartTime", "time", title: "Active Start Time (Optional)", required: false input "z${i}EndTime", "time", title: "Active End Time (Optional)", required: false } } } } // ============================================================================== // INTERNAL LOGIC ENGINE // ============================================================================== def installed() { logInfo("Installed"); initialize() } def updated() { logInfo("Updated"); unsubscribe(); unschedule(); initialize(); if (debugEnable) { log.info "Debug logging enabled for 30 minutes." runIn(1800, "disableDebugLog") } } def initialize() { if (!state.actionHistory) state.actionHistory = [] for (int i = 1; i <= 12; i++) { if (!state."currentRoomStates_z${i}") state."currentRoomStates_z${i}" = "unknown" if (!state."dailyStats_z${i}") state."dailyStats_z${i}" = [:] if (state."roomStatsTotalSecs_z${i}" != null && state."lifetimeUnoccSecs_z${i}" == null) { state."lifetimeUnoccSecs_z${i}" = state."roomStatsTotalSecs_z${i}" } else if (!state."lifetimeUnoccSecs_z${i}") { state."lifetimeUnoccSecs_z${i}" = 0 } state."lastStatUpdate_z${i}" = now() state."motionHitCount_z${i}" = 0 state."vibeHitCount_z${i}" = 0 state.remove("occStats_z${i}") state.remove("roomStatsOccupiedSince_z${i}") state.remove("roomStatsUnoccupiedSince_z${i}") state.remove("roomStatsTotalSecs_z${i}") } subscribe(location, "mode", modeChangeHandler) subscribe(location, "systemStart", hubRestartHandler) if (appEnableSwitch) subscribe(appEnableSwitch, "switch", masterSwitchHandler) for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { if (settings["z${i}OverrideSwitch"]) subscribe(settings["z${i}OverrideSwitch"], "switch", sensorHandler) if (settings["z${i}GoodNightSwitch"]) subscribe(settings["z${i}GoodNightSwitch"], "switch", sensorHandler) if (settings["z${i}Motion"]) subscribe(settings["z${i}Motion"], "motion", motionHandler) if (settings["z${i}Vibration"]) subscribe(settings["z${i}Vibration"], "acceleration", vibrationHandler) if (settings["z${i}Presence"]) subscribe(settings["z${i}Presence"], "presence", sensorHandler) if (settings["z${i}OverrideButton"]) { def fullAction = settings["z${i}OverrideButtonAction"] ?: "pushed" def partialAction = settings["z${i}PartialOverrideButtonAction"] ?: "doubleTapped" subscribe(settings["z${i}OverrideButton"], fullAction, buttonHandler) if (fullAction != partialAction) { subscribe(settings["z${i}OverrideButton"], partialAction, buttonHandler) } } if (settings["z${i}Sweeper"] || settings["z${i}AbsoluteSweeper"]) { def allHard = (settings["z${i}Switches"] ?: []) + (settings["z${i}PartialSwitches"] ?: []) allHard = allHard.unique { it.id } if (allHard) subscribe(allHard, "switch", physicalSwitchHandler) def allSoft = (settings["z${i}SoftKillDevices"] ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) allSoft = allSoft.unique { it.id } if (allSoft) subscribe(allSoft, "switch", physicalSwitchHandler) } } } runEvery5Minutes("evaluateRooms") logAction("Advanced Room Occupancy Initialized (Event-Driven Mode Active).") evaluateRooms(false) } def isMasterEnabled() { if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") return false return true } def masterSwitchHandler(evt) { logAction("Master Enable Switch turned ${evt.value}.") if (evt.value == "on") { evaluateRooms(true) } else { unschedule() } } // === CENTRALIZED MANUAL STATE FORCERS === def forceRoomEmpty(i) { def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch) { state."expectedSwitchBehavior_z${i}" = "manual_off" state."expectedSwitchBehaviorTime_z${i}" = now() safeOff(oSwitch) } state."switchIsManual_z${i}" = false state."isPartial_z${i}" = false state."waitForClear_z${i}" = true state."unoccupiedLockoutTime_z${i}" = now() state."motionHitCount_z${i}" = 0 state."vibeHitCount_z${i}" = 0 state."motionHitStartTime_z${i}" = null state."vibeHitStartTime_z${i}" = null state."zoneLastActive_z${i}" = null state."vibeLastActive_z${i}" = null state."motionActiveSince_z${i}" = null // Set state to empty BEFORE initiating shutdown to pass safety checks state."currentRoomStates_z${i}" = "empty" initiateRoomShutdown(i) } def forceRoomState(i, targetState) { def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch) { state."expectedSwitchBehavior_z${i}" = "manual_on" state."expectedSwitchBehaviorTime_z${i}" = now() safeOn(oSwitch) } state."switchIsManual_z${i}" = true state."isPartial_z${i}" = (targetState == "partial") state."zoneLastActive_z${i}" = now() state."unoccupiedLockoutTime_z${i}" = null if (targetState == "partial") { turnRoomPartialDevicesOn(i) turnOffNonPartialDevices(i) } else { turnRoomDevicesOn(i) } state."currentRoomStates_z${i}" = targetState } def appButtonHandler(btn) { if (btn == "refreshDashboardBtn") { logDebug("Dashboard manually refreshed by user.") return } if (btn == "clearAllStatesBtn") { log.warn "EMERGENCY RESET: Purging all application states, trackers, and timers." state.clear() unschedule() initialize() return } if (!isMasterEnabled()) { log.warn "Master Switch is OFF. Manual app overrides are blocked." return } if (btn.startsWith("forceEmptyBtn_")) { def i = btn.split("_")[1].toInteger() def zName = settings["z${i}Name"] ?: "Room ${i}" logAction("MANUAL OVERRIDE: Forcing ${zName} to EMPTY.") forceRoomEmpty(i) evaluateRooms(true) return } if (btn.startsWith("forceOccBtn_")) { def i = btn.split("_")[1].toInteger() def zName = settings["z${i}Name"] ?: "Room ${i}" logAction("MANUAL OVERRIDE: Forcing ${zName} to FULL OCCUPIED.") forceRoomState(i, "occupied") evaluateRooms(true) return } if (btn.startsWith("forcePartBtn_")) { def i = btn.split("_")[1].toInteger() def zName = settings["z${i}Name"] ?: "Room ${i}" logAction("MANUAL OVERRIDE: Forcing ${zName} to PARTIAL OCCUPIED.") forceRoomState(i, "partial") evaluateRooms(true) return } if (btn == "forceAllOccupiedBtn") { logAction("GLOBAL MANUAL OVERRIDE: Forcing ALL configured rooms to FULL OCCUPIED.") for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { forceRoomState(i, "occupied") } } evaluateRooms(true) } else if (btn == "forceAllEmptyBtn") { logAction("GLOBAL MANUAL OVERRIDE: Forcing ALL configured rooms to EMPTY.") for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { forceRoomEmpty(i) } } evaluateRooms(true) } } // === ENHANCED RECOVERY LOGIC === def hubRestartHandler(evt) { if (!isMasterEnabled()) return logAction("SYSTEM REBOOT / POWER OUTAGE DETECTED: Recovering previous room states and forcing hardware sync...") for (int i = 1; i <= 12; i++) { state."shutdownDelayActive_z${i}" = false if (settings["enableZ${i}"]) { def lastKnownState = state."currentRoomStates_z${i}" def zName = settings["z${i}Name"] ?: "Room ${i}" if (lastKnownState == "occupied") { logAction("RECOVERY: ${zName} was FULL OCCUPIED before outage. Restoring ON state.") def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch && state."switchIsManual_z${i}") safeOn(oSwitch) turnRoomDevicesOn(i) } else if (lastKnownState == "partial") { logAction("RECOVERY: ${zName} was PARTIAL OCCUPIED before outage. Restoring PARTIAL ON state.") def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch && state."switchIsManual_z${i}") safeOn(oSwitch) turnRoomPartialDevicesOn(i) } else if (lastKnownState == "empty") { logAction("RECOVERY: ${zName} was EMPTY before outage. Restoring OFF state.") forceRoomEmpty(i) } } } logAction("Pausing live sensor evaluations for 2 minutes to allow Zigbee/Z-Wave meshes to stabilize...") unschedule("evaluateRooms") runIn(120, "resumeNormalOperations") } def resumeNormalOperations() { logAction("Mesh stabilization complete. Resuming standard occupancy evaluations.") runEvery5Minutes("evaluateRooms") evaluateRooms(true) } def modeChangeHandler(evt) { if (!isMasterEnabled()) return logAction("Location mode changed to: ${evt.value}") def isForceOff = forceOffModes && (forceOffModes as List).contains(evt.value) if (isForceOff) { logAction("GLOBAL FORCE OFF TRIGGERED: Mode changed to ${evt.value}.") for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"] && state."currentRoomStates_z${i}" != "empty") { def gnSwitch = settings["z${i}GoodNightSwitch"] if (!(gnSwitch && gnSwitch.currentValue("switch") == "on")) { forceRoomEmpty(i) } } } } evaluateRooms(false) } def buttonHandler(evt) { if (!isMasterEnabled()) return def btnVal = evt.value ? evt.value.toInteger() : 1 for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"] && settings["z${i}OverrideButton"]?.id == evt.device.id) { def targetBtn = (settings["z${i}OverrideButtonNum"] ?: 1).toInteger() def fullAction = settings["z${i}OverrideButtonAction"] ?: "pushed" def partialAction = settings["z${i}PartialOverrideButtonAction"] ?: "doubleTapped" if (btnVal == targetBtn) { def currentState = state."currentRoomStates_z${i}" def zName = settings["z${i}Name"] ?: "Room ${i}" def restriction = getRoomRestrictionReason(i) if (restriction) { logAction("${zName}: Override Button pressed, but ignored. Room is currently PAUSED due to: ${restriction}") return } // If room is ON in any capacity, ANY button action will instantly shut it down if (currentState == "occupied" || currentState == "partial") { logAction("${zName}: Override Button Action (Toggle OFF). Forcing EMPTY.") forceRoomEmpty(i) runIn(1, "evalR${i}") } else { // Room is OFF. Turn ON according to the physical action pressed if (evt.name == fullAction) { logAction("${zName}: Override Button FULL Action. Forcing FULL Occupancy.") forceRoomState(i, "occupied") runIn(1, "evalR${i}") } else if (evt.name == partialAction) { logAction("${zName}: Override Button PARTIAL Action. Forcing PARTIAL Occupancy.") forceRoomState(i, "partial") runIn(1, "evalR${i}") } } } } } } def physicalSwitchHandler(evt) { if (!isMasterEnabled()) return if (evt.value == "on") { for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"] && (settings["z${i}Sweeper"] || settings["z${i}AbsoluteSweeper"])) { def allHard = (settings["z${i}Switches"] ?: []) + (settings["z${i}PartialSwitches"] ?: []) def allSoft = (settings["z${i}SoftKillDevices"] ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) if ((allHard.find { it.id == evt.device.id }) || (allSoft.find { it.id == evt.device.id })) { state."zoneLastActive_z${i}" = now() } } } } } def motionHandler(evt) { if (!isMasterEnabled()) return def isActive = (evt.value == "active") logDebug("Motion event received from ${evt.device.displayName}: ${evt.value}") for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { def mDevs = settings["z${i}Motion"] if (mDevs && mDevs.find { it.id == evt.device.id }) { if (isActive) { unschedule("verifyInactiveR${i}") state."zoneLastActive_z${i}" = now() if (!state."motionActiveSince_z${i}") { state."motionActiveSince_z${i}" = now() def contMins = settings["z${i}MotionContinuousDuration"] ?: 3 runIn(contMins * 60, "evalR${i}") } def count = (state."motionHitCount_z${i}" ?: 0) + 1 state."motionHitCount_z${i}" = count def windowMs = (settings["z${i}MotionActivationWindow"] ?: 3) * 60000 if (count == 1 && windowMs > 0) { state."motionHitStartTime_z${i}" = now() def windowSecs = (windowMs / 1000).toInteger() runIn(windowSecs, "resetMotionZ${i}") } unschedule("evalR${i}") runIn(1, "evalR${i}") } else { state."zoneLastActive_z${i}" = now() if (!mDevs.any { it.currentValue("motion") == "active" }) { def graceSecs = settings["z${i}MotionGracePeriod"] != null ? settings["z${i}MotionGracePeriod"].toInteger() : 15 if (graceSecs > 0) { runIn(graceSecs, "verifyInactiveR${i}") } else { processMotionInactive(i) } } } } } } } def processMotionInactive(roomId) { if (!isMasterEnabled()) return def mDevs = settings["z${roomId}Motion"] if (mDevs && !mDevs.any { it.currentValue("motion") == "active" }) { if (state."motionActiveSince_z${roomId}") { state."motionActiveSince_z${roomId}" = null } state."zoneLastActive_z${roomId}" = now() if (settings["z${roomId}TurnOffTriggers"]?.contains("Motion/Vibe Timeout")) { def timeoutSecs = (settings["z${roomId}Timeout"] ?: 15) * 60 runIn(timeoutSecs, "evalR${roomId}") } else { runIn(1, "evalR${roomId}") } } } def vibrationHandler(evt) { if (!isMasterEnabled()) return def isActive = (evt.value == "active") logDebug("Vibration event received from ${evt.device.displayName}: ${evt.value}") for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { def vDevs = settings["z${i}Vibration"] if (vDevs && vDevs.find { it.id == evt.device.id }) { if (isActive) { state."vibeLastActive_z${i}" = now() def count = (state."vibeHitCount_z${i}" ?: 0) + 1 state."vibeHitCount_z${i}" = count def windowMs = (settings["z${i}VibeActivationWindow"] ?: 3) * 60000 if (count == 1 && windowMs > 0) { state."vibeHitStartTime_z${i}" = now() def windowSecs = (windowMs / 1000).toInteger() runIn(windowSecs, "resetVibeZ${i}") } unschedule("evalR${i}") runIn(1, "evalR${i}") } else { state."vibeLastActive_z${i}" = now() if (!vDevs.any { it.currentValue("acceleration") == "active" }) { if (settings["z${i}TurnOffTriggers"]?.contains("Motion/Vibe Timeout")) { def timeoutSecs = (settings["z${i}VibeTimeout"] ?: 5) * 60 runIn(timeoutSecs, "evalR${i}") } else { runIn(1, "evalR${i}") } } } } } } } def sensorHandler(evt) { if (!isMasterEnabled()) return if (evt.name == "switch") { for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { if (settings["z${i}OverrideSwitch"]?.id == evt.device.id) { def expected = state."expectedSwitchBehavior_z${i}" def expectedTime = state."expectedSwitchBehaviorTime_z${i}" ?: 0 def withinWindow = (now() - expectedTime) < 5000 def isExpectedEcho = false if (withinWindow) { if ((expected == "auto" && evt.value == "off") || (expected == "manual_off" && evt.value == "off")) { isExpectedEcho = true } else if ((expected == "auto" && evt.value == "on") || (expected == "manual_on" && evt.value == "on")) { isExpectedEcho = true } } if (isExpectedEcho) { if (expected == "auto") { state."switchIsManual_z${i}" = false } else if (expected == "manual_on") { state."switchIsManual_z${i}" = true } else if (expected == "manual_off") { state."switchIsManual_z${i}" = false } } else { if (evt.value == "on") { // NEW LOGIC: If the room is already Partial or Occupied (from a button push or sensor), ignore the switch echo entirely. if (state."currentRoomStates_z${i}" == "occupied" || state."currentRoomStates_z${i}" == "partial") { logDebug("${settings["z${i}Name"]}: Ignored Virtual Switch ON event. Room is already active and handling its own state.") } else { logAction("${settings["z${i}Name"]}: Virtual Switch manually turned ON. Forcing FULL Occupancy.") forceRoomState(i, "occupied") } } else { logAction("${settings["z${i}Name"]}: Virtual Switch manually turned OFF externally.") forceRoomEmpty(i) } } } if (settings["z${i}GoodNightSwitch"]?.id == evt.device.id && evt.value == "on") { turnRoomDevicesOn(i) } } } } runIn(1, "evalR_All") } def getRoomOccupancyState(roomId) { def mDevs = settings["z${roomId}Motion"] def vDevs = settings["z${roomId}Vibration"] def pDevs = settings["z${roomId}Presence"] def oSwitch = settings["z${roomId}OverrideSwitch"] def gnSwitch = settings["z${roomId}GoodNightSwitch"] def pMonitor = settings["z${roomId}PowerMonitor"] if (mDevs && !mDevs.any { it.currentValue("motion") == "active" }) { if (state."motionActiveSince_z${roomId}") { state."motionActiveSince_z${roomId}" = null } } if (state."waitForClear_z${roomId}") { def isClear = true if (mDevs && mDevs.any { it.currentValue("motion") == "active" }) isClear = false if (vDevs && vDevs.any { it.currentValue("acceleration") == "active" }) isClear = false if (pDevs && pDevs.any { it.currentValue("presence") == "present" }) isClear = false if (oSwitch && oSwitch.currentValue("switch") == "on") { def expected = state."expectedSwitchBehavior_z${roomId}" def expectedTime = state."expectedSwitchBehaviorTime_z${roomId}" ?: 0 if (!(expected == "manual_off" && (now() - expectedTime) < 5000)) { state."waitForClear_z${roomId}" = null isClear = true } } if (!isClear) { return false } else { state."waitForClear_z${roomId}" = null } } def isOccupied = false def cooldownMins = settings["z${roomId}UnoccupiedCooldown"] != null ? settings["z${roomId}UnoccupiedCooldown"].toInteger() : 5 if (cooldownMins > 0 && state."currentRoomStates_z${roomId}" == "empty") { def lockedTime = state."unoccupiedLockoutTime_z${roomId}" ?: 0 if (lockedTime > 0 && (now() - lockedTime) < (cooldownMins * 60000)) { if (pMonitor) { def currentDraw = pMonitor.currentValue("power") ?: 0.0 def safeThresh = settings["z${roomId}ActiveWattageThreshold"] ?: 15.0 if (currentDraw > safeThresh) return true } if (gnSwitch && gnSwitch.currentValue("switch") == "on") return true if (oSwitch && oSwitch.currentValue("switch") == "on") { def expected = state."expectedSwitchBehavior_z${roomId}" def expectedTime = state."expectedSwitchBehaviorTime_z${roomId}" ?: 0 if (!(expected == "manual_off" && (now() - expectedTime) < 5000)) { return true } } return false } } if (pMonitor) { def currentDraw = pMonitor.currentValue("power") ?: 0.0 def safeThresh = settings["z${roomId}ActiveWattageThreshold"] ?: 15.0 if (currentDraw > safeThresh) { state."zoneLastActive_z${roomId}" = now() return true } } if (gnSwitch && gnSwitch.currentValue("switch") == "on") { return true } def isHardActive = false def maxStuckTime = 6 * 3600000 if (pDevs) { pDevs.each { dev -> if (dev.currentValue("presence") == "present") { try { def lastEvent = dev.events(max: 1) def lastEventTime = (lastEvent && lastEvent.size() > 0) ? lastEvent[0].date.time : now() if ((now() - lastEventTime) > maxStuckTime) { log.warn "⚠️ DEADMAN SWITCH: Presence sensor ${dev.displayName} in Room ${roomId} has been stuck PRESENT for over 6 hours. Ignoring." } else { isHardActive = true } } catch (e) { isHardActive = true } } } } def graceSecs = settings["z${roomId}MotionGracePeriod"] != null ? settings["z${roomId}MotionGracePeriod"].toInteger() : 15 def graceMs = graceSecs * 1000 if (mDevs) { mDevs.each { dev -> if (dev.currentValue("motion") == "active") { try { def lastEvent = dev.events(max: 1) def lastEventTime = (lastEvent && lastEvent.size() > 0) ? lastEvent[0].date.time : now() if ((now() - lastEventTime) > maxStuckTime) { log.warn "⚠️ DEADMAN SWITCH: Motion sensor ${dev.displayName} in Room ${roomId} has been stuck ACTIVE for over 6 hours. Ignoring." } else { isHardActive = true } } catch (e) { isHardActive = true } } } } if (!isHardActive && state."zoneLastActive_z${roomId}") { if ((now() - state."zoneLastActive_z${roomId}") <= graceMs) isHardActive = true } if (vDevs && vDevs.any { it.currentValue("acceleration") == "active" }) isHardActive = true def wasAlreadyOccupied = (state."currentRoomStates_z${roomId}" == "occupied" || state."currentRoomStates_z${roomId}" == "partial") def onTriggers = settings["z${roomId}TurnOnTriggers"] ?: ["Virtual Switch", "Motion Hit Count", "Continuous Motion", "Vibration", "Presence Sensor"] def offTriggers = settings["z${roomId}TurnOffTriggers"] ?: ["Motion/Vibe Timeout", "Virtual Switch OFF"] if (onTriggers.contains("Presence Sensor") && pDevs && pDevs.any { it.currentValue("presence") == "present" }) { isOccupied = true } if (!isOccupied && onTriggers.contains("Virtual Switch") && oSwitch && oSwitch.currentValue("switch") == "on" && state."switchIsManual_z${roomId}") { def oTimeout = settings["z${roomId}OverrideTimeout"] if (oTimeout && oTimeout > 0) { if (!isHardActive) { def maxLast = Math.max(state."zoneLastActive_z${roomId}" ?: 0, state."vibeLastActive_z${roomId}" ?: 0) if (maxLast == 0) maxLast = now() if ((now() - maxLast) > (oTimeout * 60000)) { logAction("${settings["z${roomId}Name"]}: Virtual Override Switch timed out due to inactivity. Turning OFF.") safeOff(oSwitch) } else { isOccupied = true } } else { isOccupied = true } } else { isOccupied = true } } if (!isOccupied && !wasAlreadyOccupied && onTriggers.contains("Continuous Motion") && mDevs) { def activeSince = state."motionActiveSince_z${roomId}" def reqMins = settings["z${roomId}MotionContinuousDuration"] ?: 3 if (activeSince && (now() - activeSince) >= (reqMins * 60000)) { isOccupied = true } } if (!isOccupied && !wasAlreadyOccupied && onTriggers.contains("Motion Hit Count") && mDevs) { def reqHits = (settings["z${roomId}MotionActivationHits"] ?: 1).toInteger() def hitCount = state."motionHitCount_z${roomId}" ?: 0 if (hitCount >= reqHits) { isOccupied = true } } if (!isOccupied && !wasAlreadyOccupied && onTriggers.contains("Vibration") && vDevs) { def reqHits = (settings["z${roomId}VibeActivationHits"] ?: 1).toInteger() def hitCount = state."vibeHitCount_z${roomId}" ?: 0 if (hitCount >= reqHits) { isOccupied = true } } if (wasAlreadyOccupied && !isOccupied) { def holdOccupied = false if (offTriggers.contains("Motion/Vibe Timeout")) { def mTimeoutMs = (settings["z${roomId}Timeout"] ?: 15) * 60000 def vTimeoutMs = (settings["z${roomId}VibeTimeout"] ?: 5) * 60000 def mLastActive = state."zoneLastActive_z${roomId}" ?: 0 def vLastActive = state."vibeLastActive_z${roomId}" ?: 0 if (mDevs && (mDevs.any { it.currentValue("motion") == "active" } || (mLastActive && (now() - mLastActive) < mTimeoutMs))) holdOccupied = true if (vDevs && (vDevs.any { it.currentValue("acceleration") == "active" } || (vLastActive && (now() - vLastActive) < vTimeoutMs))) holdOccupied = true } if (offTriggers.contains("Virtual Switch OFF")) { if (oSwitch && oSwitch.currentValue("switch") == "on" && state."switchIsManual_z${roomId}") { holdOccupied = true } } if (holdOccupied) { isOccupied = true } } return isOccupied } def isAutoPartialActive(roomId) { def isPartialTime = false def pStart = settings["z${roomId}AutoPartialStart"] def pEnd = settings["z${roomId}AutoPartialEnd"] if (pStart && pEnd) { def currTime = now() def start = timeToday(pStart, location.timeZone).time def end = timeToday(pEnd, location.timeZone).time if (start <= end) { isPartialTime = (currTime >= start && currTime <= end) } else { isPartialTime = (currTime >= start || currTime <= end) } } def pModes = settings["z${roomId}AutoPartialModes"] def isPartialMode = pModes && (pModes as List).contains(location.mode) return isPartialTime || isPartialMode } def getRoomRestrictionReason(roomId) { def gnSwitch = settings["z${roomId}GoodNightSwitch"] if (gnSwitch && gnSwitch.currentValue("switch") == "on") { return null } def currentMode = location.mode if (forceOffModes && (forceOffModes as List).contains(currentMode)) { return "Force Off Mode" } if (restrictedModes && (restrictedModes as List).contains(currentMode)) { return "Global Mode" } def roomModes = settings["z${roomId}OperatingModes"] if (roomModes && !(roomModes as List).contains(currentMode)) { return "Room Mode" } def activeDays = settings["z${roomId}ActiveDays"] if (activeDays) { def df = new java.text.SimpleDateFormat("EEEE") df.setTimeZone(location.timeZone) def day = df.format(new Date()) if (!activeDays.contains(day)) return "Day" } def startTime = settings["z${roomId}StartTime"] def endTime = settings["z${roomId}EndTime"] if (startTime && endTime) { def currTime = now() def start = timeToday(startTime, location.timeZone).time def end = timeToday(endTime, location.timeZone).time def isTimeActive = false if (start <= end) { isTimeActive = (currTime >= start && currTime <= end) } else { isTimeActive = (currTime >= start || currTime <= end) } if (!isTimeActive) return "Time" } return null } def evaluateRooms(boolean forceSync = false) { if (!isMasterEnabled()) return for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { def zName = settings["z${i}Name"] ?: "Room ${i}" def restriction = getRoomRestrictionReason(i) if (restriction) { updateRoomStats(i, false) state."currentRoomStates_z${i}" = "restricted" state."isPartial_z${i}" = false if (settings["z${i}AbsoluteSweeper"]) { def allHard = (settings["z${i}Switches"] ?: []) + (settings["z${i}PartialSwitches"] ?: []) def allSoft = (settings["z${i}SoftKillDevices"] ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) def anyOn = false if (allHard?.any { it.currentValue("switch") == "on" }) anyOn = true if (allSoft?.any { it.currentValue("switch") == "on" }) anyOn = true if (anyOn) { def pDevs = settings["z${i}Presence"] def pActive = pDevs && pDevs.any { it.currentValue("presence") == "present" } if (!pActive) { def lastM = state."zoneLastActive_z${i}" ?: 0 def lastV = state."vibeLastActive_z${i}" ?: 0 def lastAct = Math.max(lastM ?: 0, lastV ?: 0) if (lastAct == 0) { state."zoneLastActive_z${i}" = now() } else { def timeoutMs = (settings["z${i}AbsoluteSweeperTimeout"] ?: 120) * 60000 if ((now() - lastAct) >= timeoutMs) { logAction("${zName}: 🚨 ABSOLUTE SWEEPER activated! Devices left ON in paused room with no motion/presence for ${(settings["z${i}AbsoluteSweeperTimeout"] ?: 120)} minutes. Forcing shutdown.") initiateRoomShutdown(i) state."zoneLastActive_z${i}" = now() } } } else { state."zoneLastActive_z${i}" = now() } } } state."motionHitCount_z${i}" = 0 state."vibeHitCount_z${i}" = 0 state."motionHitStartTime_z${i}" = null state."vibeHitStartTime_z${i}" = null state."motionActiveSince_z${i}" = null if (!settings["z${i}AbsoluteSweeper"]) { state."zoneLastActive_z${i}" = null state."vibeLastActive_z${i}" = null } def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch && oSwitch.currentValue("switch") == "on") { state."expectedSwitchBehavior_z${i}" = "auto" state."expectedSwitchBehaviorTime_z${i}" = now() safeOff(oSwitch) } continue } def isOccupied = getRoomOccupancyState(i) def currentState = state."currentRoomStates_z${i}" updateRoomStats(i, (currentState == "occupied" || currentState == "partial")) def targetState = "empty" if (isOccupied) { // If the room is waking up organically (via sensors, not a manual button push) if (currentState == "empty" && !state."switchIsManual_z${i}") { state."isPartial_z${i}" = isAutoPartialActive(i) } targetState = state."isPartial_z${i}" ? "partial" : "occupied" } def stateChanged = (currentState != targetState) if (stateChanged || forceSync) { state."currentRoomStates_z${i}" = targetState if (targetState == "occupied" || targetState == "partial") { if (stateChanged) { def today = getTodayDateString() def stats = state."dailyStats_z${i}" ?: [:] def daily = stats[today] ?: [occTime: 0, unoccTime: 0, count: 0] daily.count += 1 stats[today] = daily state."dailyStats_z${i}" = stats } if (!forceSync) { def modeName = targetState == "partial" ? "PARTIAL OCCUPANCY" : "FULL OCCUPANCY" logAction("${zName} is now in ${modeName}. Powering ON designated devices.") } state."shutdownDelayActive_z${i}" = false def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch && oSwitch.currentValue("switch") != "on") { state."expectedSwitchBehavior_z${i}" = "auto" state."expectedSwitchBehaviorTime_z${i}" = now() runIn(1, "syncVirtualSwitchOn", [data: [room: i]]) } if (targetState == "partial") { turnRoomPartialDevicesOn(i) if (!forceSync) turnOffNonPartialDevices(i) } else { turnRoomDevicesOn(i) } } else { if (!forceSync) logAction("${zName} is now EMPTY. Initiating shutdown sequence.") state."unoccupiedLockoutTime_z${i}" = now() state."isPartial_z${i}" = false if (state."motionActiveSince_z${i}") { state."motionActiveSince_z${i}" = null } def oSwitch = settings["z${i}OverrideSwitch"] if (oSwitch && oSwitch.currentValue("switch") == "on") { state."expectedSwitchBehavior_z${i}" = "auto" state."expectedSwitchBehaviorTime_z${i}" = now() runIn(1, "syncVirtualSwitchOff", [data: [room: i]]) } initiateRoomShutdown(i) } } else if (currentState == "empty" && settings["z${i}Sweeper"]) { def allHard = (settings["z${i}Switches"] ?: []) + (settings["z${i}PartialSwitches"] ?: []) def allSoft = (settings["z${i}SoftKillDevices"] ?: []) + (settings["z${i}PartialSoftKillDevices"] ?: []) def anyOn = false if (allHard?.any { it.currentValue("switch") == "on" }) anyOn = true if (allSoft?.any { it.currentValue("switch") == "on" }) anyOn = true if (anyOn) { def lastAct = state."zoneLastActive_z${i}" ?: now() def timeoutMs = (settings["z${i}SweeperTimeout"] ?: 60) * 60000 if ((now() - lastAct) >= timeoutMs) { logAction("${zName}: 🧹 Sweeper activated! Stranded devices were left ON with no motion for ${(settings["z${i}SweeperTimeout"] ?: 60)} minutes. Forcing shutdown.") if (state."motionActiveSince_z${i}") { state."motionActiveSince_z${i}" = null } initiateRoomShutdown(i) state."zoneLastActive_z${i}" = now() } } } } } } def turnRoomDevicesOn(roomId) { def hardDevs = settings["z${roomId}Switches"] if (hardDevs) hardDevs.each { safeOn(it) } def partHardDevs = settings["z${roomId}PartialSwitches"] if (partHardDevs) partHardDevs.each { safeOn(it) } def allSoft = (settings["z${roomId}SoftKillDevices"] ?: []) + (settings["z${roomId}PartialSoftKillDevices"] ?: []) if (allSoft) runIn(2, "executeSoftBoot", [data: [room: roomId, mode: "full"], overwrite: false]) } def turnRoomPartialDevicesOn(roomId) { def partHardDevs = settings["z${roomId}PartialSwitches"] if (partHardDevs) partHardDevs.each { safeOn(it) } def partSoftDevs = settings["z${roomId}PartialSoftKillDevices"] if (partSoftDevs) runIn(2, "executeSoftBoot", [data: [room: roomId, mode: "partial"], overwrite: false]) } def turnOffNonPartialDevices(roomId) { def fullHard = settings["z${roomId}Switches"] ?: [] def partHard = settings["z${roomId}PartialSwitches"] ?: [] fullHard.each { dev -> if (!partHard.any { it.id == dev.id }) safeOff(dev) } def fullSoft = settings["z${roomId}SoftKillDevices"] ?: [] def partSoft = settings["z${roomId}PartialSoftKillDevices"] ?: [] fullSoft.each { dev -> if (!partSoft.any { it.id == dev.id }) safeOff(dev) } } def executeSoftBoot(data) { if (!isMasterEnabled()) return def roomId = data.room def stateCheck = state."currentRoomStates_z${roomId}" if (stateCheck == "occupied" || stateCheck == "partial") { if (data.mode == "partial") { def softDevs = settings["z${roomId}PartialSoftKillDevices"] softDevs?.each { safeOn(it) } } else { def softDevs = (settings["z${roomId}SoftKillDevices"] ?: []) + (settings["z${roomId}PartialSoftKillDevices"] ?: []) softDevs.unique { it.id }?.each { safeOn(it) } } } } def initiateRoomShutdown(roomId) { if (!isMasterEnabled()) return def softDevs = (settings["z${roomId}SoftKillDevices"] ?: []) + (settings["z${roomId}PartialSoftKillDevices"] ?: []) softDevs = softDevs.unique { it.id } def hardDevs = (settings["z${roomId}Switches"] ?: []) + (settings["z${roomId}PartialSwitches"] ?: []) hardDevs = hardDevs.unique { it.id } def delaySecs = settings["z${roomId}HardKillDelay"] ?: 0 if (softDevs && softDevs.any { it.currentValue("switch") != "off" }) { logAction("Sending Graceful Shutdown commands to sensitive electronics in Room ${roomId}.") softDevs.each { safeOff(it) } if (hardDevs && delaySecs > 0) { logAction("Waiting ${delaySecs} seconds for sensitive devices to shut down before cutting hard power.") state."shutdownDelayActive_z${roomId}" = true runIn(delaySecs, "executeHardKill", [data: [room: roomId], overwrite: false]) return } else if (hardDevs) { executeHardKill([room: roomId]) return } } else { executeHardKill([room: roomId]) return } } def executeHardKill(data) { if (!isMasterEnabled()) return def roomId = data.room if (state."currentRoomStates_z${roomId}" != "empty") { logAction("Hard kill aborted. Room ${roomId} became occupied during the shutdown delay.") state."shutdownDelayActive_z${roomId}" = false return } state."shutdownDelayActive_z${roomId}" = false def hardDevs = (settings["z${roomId}Switches"] ?: []) + (settings["z${roomId}PartialSwitches"] ?: []) hardDevs = hardDevs.unique { it.id } if (hardDevs) { logAction("Cutting hard power to managed relays in Room ${roomId}.") hardDevs.each { safeOff(it) } } } // === ROI SAVINGS TRACKING === def resetAllSavings() { logAction("MANUAL OVERRIDE: Resetting all ROI Savings Data and Occupancy Stats to zero.") for (int i = 1; i <= 12; i++) { state."lifetimeUnoccSecs_z${i}" = 0 state."dailyStats_z${i}" = [:] state."lastStatUpdate_z${i}" = now() } } // === HARDWARE SAFE WRAPPERS === def safeOn(dev) { if (dev && dev.currentValue("switch") != "on") { try { dev.on() } catch (e) { log.error "Failed to turn ON ${dev.displayName}: ${e.message}" } } } def safeOff(dev) { if (dev && dev.currentValue("switch") != "off") { try { dev.off() } catch (e) { log.error "Failed to turn OFF ${dev.displayName}: ${e.message}" } } } // === LOGGING === def logAction(msg) { if(txtEnable) log.info "${app.label}: ${msg}" def h = state.actionHistory ?: [] h.add(0, "[${new Date().format("MM/dd hh:mm a", location.timeZone)}] ${msg}") if(h.size() > 30) h = h[0..29] state.actionHistory = h } def logInfo(msg) { if(txtEnable) log.info "${app.label}: ${msg}" } def disableDebugLog() { log.info "Auto-disabling debug logging." app.updateSetting("debugEnable", [value: "false", type: "bool"]) } def logDebug(msg) { if (debugEnable) log.debug "${app.label}: ${msg}" } // ============================================================================== // OCCUPANCY STATS & TRACKING WRAPPERS // ============================================================================== def getTodayDateString() { def df = new java.text.SimpleDateFormat("yyyy-MM-dd") df.setTimeZone(location.timeZone) return df.format(new Date()) } def formatDuration(long secs) { if (secs < 60) return "${secs}s" def mins = (secs / 60).toInteger() if (mins < 60) return "${mins}m" def hrs = (mins / 60).toInteger() def remMins = mins % 60 return "${hrs}h ${remMins}m" } def updateRoomStats(roomId, isOccupied) { def lastUpdate = state."lastStatUpdate_z${roomId}" ?: now() def nowMs = now() def deltaSecs = ((nowMs - lastUpdate) / 1000).toLong() if (deltaSecs > 300) deltaSecs = 300 if (deltaSecs < 0) deltaSecs = 0 def today = getTodayDateString() def stats = state."dailyStats_z${roomId}" ?: [:] def daily = stats[today] ?: [occTime: 0, unoccTime: 0, count: 0] if (isOccupied) { daily.occTime += deltaSecs } else { daily.unoccTime += deltaSecs state."lifetimeUnoccSecs_z${roomId}" = (state."lifetimeUnoccSecs_z${roomId}" ?: 0) + deltaSecs } stats[today] = daily def keysToRemove = [] def cal = Calendar.getInstance(location.timeZone) cal.add(Calendar.DAY_OF_YEAR, -7) def sevenDaysAgoStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(cal.getTime()) stats.each { k, v -> if (k < sevenDaysAgoStr) keysToRemove << k } keysToRemove.each { stats.remove(it) } state."dailyStats_z${roomId}" = stats state."lastStatUpdate_z${roomId}" = nowMs } // ============================================================================== // SCHEDULING WRAPPERS (Prevents cross-room overwriting in Hubitat memory) // ============================================================================== def evalR_All() { evaluateRooms(false) } def evalR1() { evaluateRooms(false) } def evalR2() { evaluateRooms(false) } def evalR3() { evaluateRooms(false) } def evalR4() { evaluateRooms(false) } def evalR5() { evaluateRooms(false) } def evalR6() { evaluateRooms(false) } def evalR7() { evaluateRooms(false) } def evalR8() { evaluateRooms(false) } def evalR9() { evaluateRooms(false) } def evalR10() { evaluateRooms(false) } def evalR11() { evaluateRooms(false) } def evalR12() { evaluateRooms(false) } def verifyInactiveR1() { processMotionInactive(1) } def verifyInactiveR2() { processMotionInactive(2) } def verifyInactiveR3() { processMotionInactive(3) } def verifyInactiveR4() { processMotionInactive(4) } def verifyInactiveR5() { processMotionInactive(5) } def verifyInactiveR6() { processMotionInactive(6) } def verifyInactiveR7() { processMotionInactive(7) } def verifyInactiveR8() { processMotionInactive(8) } def verifyInactiveR9() { processMotionInactive(9) } def verifyInactiveR10() { processMotionInactive(10) } def verifyInactiveR11() { processMotionInactive(11) } def verifyInactiveR12() { processMotionInactive(12) } def resetMotionZ1() { state."motionHitCount_z1" = 0 } def resetMotionZ2() { state."motionHitCount_z2" = 0 } def resetMotionZ3() { state."motionHitCount_z3" = 0 } def resetMotionZ4() { state."motionHitCount_z4" = 0 } def resetMotionZ5() { state."motionHitCount_z5" = 0 } def resetMotionZ6() { state."motionHitCount_z6" = 0 } def resetMotionZ7() { state."motionHitCount_z7" = 0 } def resetMotionZ8() { state."motionHitCount_z8" = 0 } def resetMotionZ9() { state."motionHitCount_z9" = 0 } def resetMotionZ10() { state."motionHitCount_z10" = 0 } def resetMotionZ11() { state."motionHitCount_z11" = 0 } def resetMotionZ12() { state."motionHitCount_z12" = 0 } def resetVibeZ1() { state."vibeHitCount_z1" = 0 } def resetVibeZ2() { state."vibeHitCount_z2" = 0 } def resetVibeZ3() { state."vibeHitCount_z3" = 0 } def resetVibeZ4() { state."vibeHitCount_z4" = 0 } def resetVibeZ5() { state."vibeHitCount_z5" = 0 } def resetVibeZ6() { state."vibeHitCount_z6" = 0 } def resetVibeZ7() { state."vibeHitCount_z7" = 0 } def resetVibeZ8() { state."vibeHitCount_z8" = 0 } def resetVibeZ9() { state."vibeHitCount_z9" = 0 } def resetVibeZ10() { state."vibeHitCount_z10" = 0 } def resetVibeZ11() { state."vibeHitCount_z11" = 0 } def resetVibeZ12() { state."vibeHitCount_z12" = 0 } def syncVirtualSwitchOn(data) { def dev = settings["z${data.room}OverrideSwitch"] if (dev) safeOn(dev) } def syncVirtualSwitchOff(data) { def dev = settings["z${data.room}OverrideSwitch"] if (dev) safeOff(dev) }