/** * Advanced Climate Controller * * Author: ShaneAllen */ definition( name: "Advanced Climate Controller", namespace: "ShaneAllen", author: "ShaneAllen", description: "Commercial-grade BMS app with Live Diagnostics, Maintenance Tracking, Service Contacts, ROI Savings, Auto-Swap, Free Cooling Economizer, Failsafes, and Cycle Tracking.", category: "Comfort", iconUrl: "", iconX2Url: "", iconX3Url: "" ) preferences { page(name: "mainPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Advanced Climate Controller", install: true, uninstall: true) { section("Live System Dashboard") { input "btnRefresh", "button", title: "🔄 Refresh Data" paragraph "
What it does: Provides a real-time, top-down view of your entire HVAC system, including active setpoints, dynamic averages, and the current logic state of the BMS engine.
" def statusExplanation = getHumanReadableStatus() paragraph "
" + "System Status: ${statusExplanation}
" if (thermostat) { // Gather Core Metrics def tstatTemp = thermostat.currentValue("temperature") ?: "--" def tstatHum = thermostat.currentValue("humidity") ?: "--" def tstatCool = thermostat.currentValue("coolingSetpoint") ?: "--" def tstatHeat = thermostat.currentValue("heatingSetpoint") ?: "--" def tstatMode = thermostat.currentValue("thermostatMode")?.toUpperCase() ?: "UNKNOWN" def tstatState = thermostat.currentValue("thermostatOperatingState")?.toUpperCase() ?: "IDLE" def tstatFan = thermostat.currentValue("thermostatFanMode")?.toUpperCase() ?: "UNKNOWN" def stateColor = "black" if (tstatState == "COOLING") stateColor = "blue" if (tstatState == "HEATING") stateColor = "#d9534f" if (tstatState.contains("AUX") || tstatState.contains("EMERGENCY")) stateColor = "red" def avgTemp = getAverageTemp() def avgHum = getAverageHumidity() // Gather Diagnostics def currentLocMode = location.mode ?: "Unknown" // Free Cooling Dashboard Updates def fcStatusStr = "Idle / Not Favorable" if (state.freeCoolState == "pending") { def fcRemaining = state.freeCoolTargetTime ? Math.max(0, Math.round((state.freeCoolTargetTime - now()) / 60000)) : 0 fcStatusStr = "Available & Recommended! (Open windows to start - ${fcRemaining} mins remaining)" } else if (state.freeCoolState == "active") { def fanStatus = freeCoolFan ? "ON" : "Auto" fcStatusStr = "Active (AC Paused, Fan: ${fanStatus})" } else if (state.freeCoolState == "lockedOut") { fcStatusStr = "Locked Out (Timeout Reached)" } // System Load Score (Indoor vs Outdoor) def loadStr = "N/A (Outdoor Sensor Missing)" if (outdoorSensor && outdoorSensor.currentValue("temperature") != null) { def outT = outdoorSensor.currentValue("temperature").toBigDecimal() def delta = Math.abs(outT - avgTemp.toBigDecimal()).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) def loadWord = "Low" def loadColor = "green" if (delta > 20.0) { loadWord = "Extreme"; loadColor = "red" } else if (delta > 10.0) { loadWord = "Moderate"; loadColor = "orange" } loadStr = "${delta}°F Delta (${loadWord} Load) (Out: ${outT}°)" } // --- Timer Calculations for Dashboard --- def yoyoRemaining = (state.yoyoCooldownEnds && now() < state.yoyoCooldownEnds) ? Math.max(0, Math.round((state.yoyoCooldownEnds - now()) / 60000)) : 0 def yoyoStr = "" def isAlignmentModeAllowed = !alignmentModes || (alignmentModes as List).contains(location.mode) if (!enableAverageSync) { yoyoStr = "Disabled" } else if (!isAlignmentModeAllowed) { yoyoStr = "Disabled by Mode (${currentLocMode})" } else if (state.alignmentLockout) { yoyoStr = "Aborted (Waiting for local temp to reach ${state.alignmentLockoutTarget}°)" } else if (yoyoRemaining > 0) { yoyoStr = "Paused (${yoyoRemaining} mins remaining)" } else if (enableHysteresis && state.activeHysteresis == "idle") { yoyoStr = "Floating in Deadband (System Idle)" } else if (enableHysteresis && state.activeHysteresis != "idle") { yoyoStr = "Active Recovery (${state.activeHysteresis.capitalize()})" } else { yoyoStr = "Ready" } def bufferStr = "Inactive" if (state.isBuffering && state.cycleStartTime) { def elapsedMins = (now() - state.cycleStartTime) / 60000.0 def remaining = Math.max(0, Math.round((minRunTime ?: 10) - elapsedMins)) bufferStr = "Engaged (${remaining} mins remaining)" } def swapText = "N/A (Disabled)" if (enableAutoSwap && !(state.freeCoolState in ["pending", "active"])) { def safeSwapDB = autoSwapDeadband ?: 1.0 if (enableAverageSync && enableHysteresis) { def drift = hysteresisDrift ?: 1.0 if (safeSwapDB <= drift) safeSwapDB = drift + 0.5 } def distToCool = tstatCool != "--" ? Math.round(( (tstatCool.toBigDecimal() + safeSwapDB) - avgTemp.toBigDecimal() ) * 10) / 10.0 : 0 def distToHeat = tstatHeat != "--" ? Math.round(( avgTemp.toBigDecimal() - (tstatHeat.toBigDecimal() - safeSwapDB) ) * 10) / 10.0 : 0 if (tstatMode == "HEAT") swapText = "↑ ${distToCool}° until Swap to COOL (DB: ${safeSwapDB}°)" else if (tstatMode == "COOL") swapText = "↓ ${distToHeat}° until Swap to HEAT (DB: ${safeSwapDB}°)" else swapText = "Thermostat not in Heat/Cool mode" } def deltaTStr = "N/A (Disabled or Missing Sensors)" if (enableDeltaT && returnSensor && dischargeSensor) { def retT = returnSensor.currentValue("temperature") def disT = dischargeSensor.currentValue("temperature") if (retT != null && disT != null) { def dT = 0.0 if (tstatState == "COOLING") { dT = (retT - disT).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) } else if (tstatState == "HEATING") { dT = (disT - retT).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) } else { if (tstatMode == "COOL" && disT > retT) { dT = (retT - disT).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) } else { dT = Math.abs(retT - disT).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) } } def health = "" if (tstatState == "COOLING" && dT < (minCoolingDeltaT ?: 12.0)) health = " (Warning: Low)" else if (tstatState == "HEATING" && dT < (minHeatingDeltaT ?: 15.0)) health = " (Warning: Low)" else if (tstatState in ["COOLING", "HEATING"]) health = " (Good)" else health = " (System Idle)" deltaTStr = "${dT}°F (Return: ${retT}° | Supply: ${disT}°)${health}" } else { deltaTStr = "Waiting for sensor data..." } } // Calculated Deadband Metric def currentDeadbandStr = "N/A" if (tstatCool != "--" && tstatHeat != "--") { def gap = (tstatCool.toBigDecimal() - tstatHeat.toBigDecimal()).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) if (gap < 3.0) { currentDeadbandStr = "${gap}° (Violation - Conflict Detected)" } else if (gap == 3.0) { currentDeadbandStr = "${gap}° (Minimum Enforced)" } else { currentDeadbandStr = "${gap}° (Healthy Gap)" } } // Gather Maintenance def filterLifeStr = "Disabled" if (enableFilterTracker) { def maxMins = (maxFilterHours ?: 300) * 60 def usedMins = state.filterRunMinutes ?: 0.0 def percentLeft = Math.max(0.0, 100.0 - ((usedMins / maxMins) * 100)).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) filterLifeStr = "${percentLeft}%" if (percentLeft < 10.0) filterLifeStr = "${percentLeft}% (Change Soon)" } def hvacContactStr = "Not Configured" if (hvacCompanyName || hvacCompanyPhone) { hvacContactStr = "${hvacCompanyName ?: 'N/A'} ${hvacCompanyPhone ? '(' + hvacCompanyPhone + ')' : ''}" } // --- 7-Day Compressor Runs Calculation --- def sevenDayRuns = 0 def sevenDayRuntime = 0.0 if (state.runHistory) { state.runHistory.each { date, data -> sevenDayRuns += (data.runs ?: 0) sevenDayRuntime += (data.cool ?: 0.0) + (data.heat ?: 0.0) + (data.aux ?: 0.0) } } def totalRunHours = (sevenDayRuntime / 60.0).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) def compressorRunsStr = "${sevenDayRuns} Cycles (${totalRunHours} Total Hours)" // Unified Dashboard HTML def dashHTML = """
MetricCalculated Average (Rooms)Thermostat SensorTarget Setpoint
Temperature${avgTemp}°${tstatTemp}°Cool: ${tstatCool}° | Heat: ${tstatHeat}°
Humidity${avgHum}%${tstatHum}%Limit: ${maxHumidity ?: '--'}%
HVAC StateMode: ${tstatMode}Status: ${tstatState} (Fan: ${tstatFan})
Internal Diagnostics
Location Mode${currentLocMode}
System Load (HVAC Strain)${loadStr}
Economizer Status${fcStatusStr}
Auto-Swap Distance${swapText}
Calculated Deadband${currentDeadbandStr}
Live Delta-T${deltaTStr}
Dynamic Alignment Status${yoyoStr}
Compressor Protection${bufferStr}
Maintenance & Service
7-Day Compressor Runs${compressorRunsStr}
Filter Life Remaining${filterLifeStr}
Last Filter Change${state.lastFilterDate ?: "Not Recorded"}
Last HVAC Service${state.lastServiceDate ?: "Not Recorded"}
Service Contact${hvacContactStr}
""" paragraph dashHTML if (enableCostTracker && state.runHistory) { paragraph "7-Day Energy Cost & Savings Estimate" paragraph renderCostDashboard() } } else { paragraph "Please select a thermostat below to populate the dashboard." } } section("Zone Breakdown") { paragraph "
What it does: Displays real-time data from all configured room sensors. In Night mode, only rooms with the Good Night Switch active are averaged.
" def zoneHTML = "" def timeoutMs = (occupancyTimeout ?: 60) * 60000 def hasZones = false def maxAgeMs = 24 * 60 * 60 * 1000 def isNight = nightModes ? (nightModes as List).contains(location.mode) : false for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"] && settings["z${i}Temp"]) { hasZones = true def zName = settings["z${i}Name"] ?: "Zone ${i}" def zTempDev = settings["z${i}Temp"] def tempState = zTempDev.currentState("temperature") def tVal = tempState?.value != null ? tempState.value.toBigDecimal() : null def lastUpdate = tempState?.date?.time ?: now() def isError = tVal == null || tVal < 40.0 || tVal > 100.0 || (now() - lastUpdate) > maxAgeMs def zTempStr = tVal != null ? "${tVal}°" : "--" def zHum = settings["z${i}Hum"] ? (settings["z${i}Hum"].currentValue("humidity") ?: "--") : "N/A" def zMotion = settings["z${i}Motion"] def isOccupied = "N/A" def zStatus = "Averaging" if (isError) { zStatus = "Sensor Error (Ignored)" isOccupied = "N/A" } else if (isNight) { def nSwitch = settings["z${i}NightSwitch"] def isNightForced = nSwitch && nSwitch.currentValue("switch") == "on" if (isNightForced) { isOccupied = "Yes (Night Lock)" zStatus = "Averaging (Night Lock)" } else { isOccupied = "N/A (Night Mode)" zStatus = "Ignored (Not Night Room)" } } else if (enableOccupancy && zMotion) { def lastActive = state.zoneLastActive ? state.zoneLastActive[zMotion.id] : null if (lastActive && (now() - lastActive) < timeoutMs) { isOccupied = "Yes" } else { isOccupied = "No" zStatus = "Ignored (Empty)" } } zoneHTML += "" } } zoneHTML += "
Zone NameTempHumidityOccupied?Status
${zName}${zTempStr}${zHum}%${isOccupied}${zStatus}
" if (hasZones) paragraph zoneHTML else paragraph "No zones configured yet." } section("Recent Action History") { paragraph "
What it does: Provides a transparent, rolling log of every command the BMS sends to your thermostat, including mode swaps, failsafe triggers, and setpoint adjustments.
" input "txtEnable", "bool", title: "Enable Description Text Logging", defaultValue: true if (state.actionHistory) { def historyStr = state.actionHistory.join("
") paragraph "${historyStr}" } } section("Last 10 Compressor Cycles") { paragraph "
What it does: Displays the exact duration of your last 10 heating or cooling cycles to help verify that the Minimum Run Time protections are functioning correctly.
" if (state.recentCycles) { def cycleStr = state.recentCycles.join("
") paragraph "${cycleStr}" } else { paragraph "No completed cycles logged yet." } input "resetCycles", "button", title: "Clear Cycle History" } section("App Control & Main HVAC System", hideable: true, hidden: true) { paragraph "
What it does: The core connection to your physical HVAC hardware. Allows you to assign your main thermostat and provides a master kill-switch to quickly bypass all app automation.
" input "appEnableSwitch", "capability.switch", title: "Master Enable/Disable Switch (Optional)", required: false, multiple: false input "thermostat", "capability.thermostat", title: "Select Main Thermostat", required: false, multiple: false if (state.manualHold) input "releaseHold", "button", title: "Release Manual Hold" } section("Safety: Fire & Smoke Isolation", hideable: true, hidden: true) { paragraph "
What it does: Instantly shuts down the HVAC and blower if smoke or carbon monoxide is detected to prevent spreading smoke or feeding oxygen to a fire. This failsafe overrides all other logic.
" input "smokeDetectors", "capability.smokeDetector", title: "Select Smoke Detectors", required: false, multiple: true input "coDetectors", "capability.carbonMonoxideDetector", title: "Select CO Detectors", required: false, multiple: true } section("Health: Sick Mode (Continuous Filtration)", hideable: true, hidden: true) { paragraph "
What it does: When the assigned switch is turned on, the app forces the HVAC fan to run 24/7 to continuously filter the air. Restores to Auto when turned off.
" input "sickModeSwitch", "capability.switch", title: "Select Sick Mode Switch", required: false } section("1. App-Driven Auto Changeover", hideable: true, hidden: true) { paragraph "
What it does: Takes control of deciding whether your house needs Heating or Cooling away from the thermostat. It automatically swaps modes based on your home's Calculated Average Temperature rather than the single wall sensor.
" input "enableAutoSwap", "bool", title: "Enable App-Driven Mode Swapping", defaultValue: false, submitOnChange: true if (enableAutoSwap) { input "autoSwapDeadband", "decimal", title: "Changeover Deadband (°F) - Prevents rapid mode swapping", required: false, defaultValue: 1.0 } } section("2. Zones & Dynamic Occupancy (Global Settings)", hideable: true, hidden: true) { paragraph "
What it does: Connects motion sensors to temperature sensors. If a room has no motion for the set timeout, it is mathematically dropped from the home's average temperature to stop wasting energy on empty rooms.
" input "enableOccupancy", "bool", title: "Enable Dynamic Occupancy Weighting", defaultValue: false, submitOnChange: true if (enableOccupancy) { input "occupancyTimeout", "number", title: "Minutes of no motion before dropping room", required: false, defaultValue: 60 } paragraph "
Click on a zone below to expand its settings.
" } for (int i = 1; i <= 12; i++) { def currentZoneName = settings["z${i}Name"] ?: "Zone ${i}" section("⚙️ ${currentZoneName}", hideable: true, hidden: true) { input "enableZ${i}", "bool", title: "Enable Zone ${i}", submitOnChange: true if (settings["enableZ${i}"]) { input "z${i}Name", "text", title: "Zone Name", required: false, defaultValue: "Zone ${i}" input "z${i}Temp", "capability.temperatureMeasurement", title: "Temp Sensor", required: false input "z${i}Hum", "capability.relativeHumidityMeasurement", title: "Humidity Sensor (Optional)", required: false input "z${i}Motion", "capability.motionSensor", title: "Motion Sensor (Optional)", required: false input "z${i}NightSwitch", "capability.switch", title: "Good Night Virtual Switch (Keeps active in Night Mode)", required: false } } } section("2b. Dynamic Setpoint Alignment & Deadband", hideable: true, hidden: true) { paragraph "
What it does: Automatically shifts the physical thermostat's target to force it to run based on the Average Home Temp.
" input "enableAverageSync", "bool", title: "Enable Dynamic Setpoint Alignment", defaultValue: false, submitOnChange: true if (enableAverageSync) { input "alignmentModes", "mode", title: "Modes to ALLOW Dynamic Alignment (Leave blank for 24/7)", multiple: true, required: false input "maxSyncOffset", "decimal", title: "Maximum Allowed Shift (°F) - Safety limit", required: false, defaultValue: 3.0 input "yoyoCooldownMins", "number", title: "Anti-Yo-Yo Cooldown (Minutes)", required: false, defaultValue: 15 paragraph "Stage 1: Smart Deadband & Hysteresis" paragraph "
Prevents micro-cycling. E.g., if setpoint is 70° and allowed drift is 1.0°, the system ignores the average until it hits 71.0°, then cools until it recovers to 70.5°.
" input "enableHysteresis", "bool", title: "Enable Stage 1 Hysteresis Deadband", defaultValue: true, submitOnChange: true if (enableHysteresis) { input "hysteresisDrift", "decimal", title: "Allowed Drift Before Starting (°F)", required: false, defaultValue: 1.0 input "hysteresisRecovery", "decimal", title: "Stop When Within X° of Setpoint", required: false, defaultValue: 0.5 } } } section("3. The Economizer (Free Cooling Advisor)", hideable: true, hidden: true) { paragraph "
What it does: Suspends the AC and alerts you to open windows if outdoor weather is favorable. Failsafe: If windows are not opened within the timeout period, it aborts Free Cooling and resumes normal AC to prevent the house from getting hot.
" input "enableFreeCooling", "bool", title: "Enable Free Cooling", defaultValue: true, submitOnChange: true if (enableFreeCooling) { input "freeCoolModes", "mode", title: "Modes to ALLOW Free Cooling", multiple: true, required: false input "outdoorSensor", "capability.temperatureMeasurement", title: "Outdoor Temp/Humidity Sensor", required: false input "freeCoolTempDelta", "decimal", title: "Minimum Temp Difference (°F)", required: false, defaultValue: 3.0 input "freeCoolMaxHumidity", "decimal", title: "Maximum Outdoor Humidity Allowed (%)", required: false, defaultValue: 60.0 input "freeCoolTimeout", "number", title: "Minutes to wait for windows to open before aborting", required: false, defaultValue: 15 input "freeCoolFan", "bool", title: "Run HVAC Fan during Free Cooling", defaultValue: false, required: false paragraph "Wind Direction Optimization" input "useWindDirection", "bool", title: "Optimize with Wind Direction", defaultValue: false, submitOnChange: true if (useWindDirection) { input "windWeatherDevice", "capability.sensor", title: "Select Weather/Wind Device", required: false input "optimalWindDirs", "text", title: "Optimal Wind Directions (e.g. N, NW, W)", required: false, defaultValue: "N, S" } input "freeCoolNotify", "capability.notification", title: "Send Push Notification", required: false, multiple: true input "freeCoolSwitch", "capability.switch", title: "Trigger Virtual Switch", required: false, multiple: false } } section("4. Heat Pump: Aux Heat Suppression", hideable: true, hidden: true) { paragraph "
What it does: Tricks thermostats (like the Honeywell T6) into NOT using expensive Aux heat. It does this by 'gliding' the setpoint up 1 degree at a time, keeping the target just out of reach of the thermostat's internal Aux-trigger threshold.
" input "enableAuxSuppression", "bool", title: "Enable Aux Heat Suppression", defaultValue: false, submitOnChange: true if (enableAuxSuppression) { input "maxHeatStep", "decimal", title: "Max Setpoint Step (°F) (Keep below your thermostat's Aux threshold, usually 2°)", required: false, defaultValue: 1.5 } } section("5. Energy Cost & ROI Savings Tracking", hideable: true, hidden: true) { paragraph "
What it does: Tracks exact compressor and Aux heat runtimes to estimate your HVAC utility costs. It also calculates the runtime you avoided while using Free Cooling to prove your Return on Investment (ROI).
" input "enableCostTracker", "bool", title: "Enable 7-Day Energy Tracking", defaultValue: true, submitOnChange: true if (enableCostTracker) { input "costPerKwh", "decimal", title: "Utility Rate (USD per kWh)", required: false, defaultValue: 0.15 input "coolingKw", "decimal", title: "Cooling Power Draw (kW)", required: false, defaultValue: 4.6 input "heatingKw", "decimal", title: "Heat Pump Power Draw (kW)", required: false, defaultValue: 4.6 input "auxHeatingKw", "decimal", title: "Aux/Emergency Heat Power Draw (kW)", required: false, defaultValue: 15.0 input "resetHistory", "button", title: "Clear Tracking History" } } section("6. Predictive Pre-Conditioning (Thermal Battery)", hideable: true, hidden: true) { paragraph "
What it does: Checks tomorrow's weather forecast. If extreme heat is predicted, it sub-cools your house during the early morning when electricity is cheap to build a 'Thermal Battery' to coast through the hot afternoon.
" input "enablePreCondition", "bool", title: "Enable Predictive Pre-Conditioning", defaultValue: false, submitOnChange: true if (enablePreCondition) { input "weatherDevice", "capability.sensor", title: "Select Weather Device", required: false input "heatwaveThreshold", "decimal", title: "Forecast High threshold (°F)", required: false, defaultValue: 90.0 input "preCoolOffset", "decimal", title: "Degrees to DROP setpoint during Pre-Cool", required: false, defaultValue: 3.0 input "preCoolStartTime", "time", title: "Pre-Cool Start Time", required: false input "preCoolEndTime", "time", title: "Pre-Cool End Time", required: false } } section("7. Adaptive Recovery (Smart Start)", hideable: true, hidden: true) { paragraph "
What it does: Eliminates guesswork. You input what time you will get home, and the app calculates exactly when to start the HVAC based on how fast your specific unit heats or cools.
" input "enableAdaptive", "bool", title: "Enable Adaptive Recovery", defaultValue: false, submitOnChange: true if (enableAdaptive) { input "expectedReturnTime", "time", title: "Expected Return Time", required: false input "coolingGlide", "decimal", title: "Degrees Cooled per Hour", required: false, defaultValue: 2.0 input "heatingGlide", "decimal", title: "Degrees Heated per Hour", required: false, defaultValue: 3.0 } } section("8. Open Window / Door Defeat", hideable: true, hidden: true) { paragraph "
What it does: Automatically intercepts and shuts off the HVAC if a monitored door or window is left open past the delay threshold. Restores normal operation once closed.
" input "enableWindowDefeat", "bool", title: "Enable Window/Door Defeat", defaultValue: true, submitOnChange: true if (enableWindowDefeat) { input "contactSensors", "capability.contactSensor", title: "Select Perimeter Contact Sensors", required: false, multiple: true input "contactDelay", "number", title: "Minutes to wait before shutting off HVAC", required: false, defaultValue: 3 } } section("9. Multi-Stage Dehumidification", hideable: true, hidden: true) { paragraph "
What it does: Prioritizes indoor air quality. Stage 1 turns on standalone dehumidifier plugs. Stage 2 slightly overcools the house with the main AC to force the compressor to wring excess moisture out of the air.
" input "enableDehumidification", "bool", title: "Enable Dehumidification Logic", defaultValue: true, submitOnChange: true if (enableDehumidification) { input "maxHumidity", "decimal", title: "Maximum Acceptable Humidity (%)", required: false, defaultValue: 55.0 input "dehumidifierPlugs", "capability.switch", title: "Stage 1: Standalone Dehumidifier Plugs", required: false, multiple: true input "dehumidifierTimeout", "number", title: "Minutes to let plugs run before falling back to AC", required: false, defaultValue: 45 input "acDehumidifyOffset", "decimal", title: "Stage 2: Degrees to drop AC setpoint", required: false, defaultValue: 2.0 } } section("10. Time-of-Use (Peak Shaving)", hideable: true, hidden: true) { paragraph "
What it does: Automatically drifts your target temperatures up or down during expensive utility Time-of-Use (TOU) hours to reduce peak demand charges.
" input "enablePeakShaving", "bool", title: "Enable Peak Shaving", defaultValue: true, submitOnChange: true if (enablePeakShaving) { input "peakStartTime", "time", title: "Peak Rates Start Time", required: false input "peakEndTime", "time", title: "Peak Rates End Time", required: false input "peakCoolingOffset", "decimal", title: "Degrees to RAISE cooling setpoint during Peak", required: false, defaultValue: 3.0 input "peakHeatingOffset", "decimal", title: "Degrees to LOWER heating setpoint during Peak", required: false, defaultValue: 3.0 } } section("11. Smart Filter & Maintenance Tracking", hideable: true, hidden: true) { paragraph "
What it does: Tracks exact blower runtime multiplied by air quality dust conditions to accurately predict filter life, logs physical service dates, and stores your HVAC technician's contact info.
" input "enableFilterTracker", "bool", title: "Enable Maintenance Tracking Logic", defaultValue: true, submitOnChange: true if (enableFilterTracker) { input "filterSize", "text", title: "Filter Size", required: false input "maxFilterHours", "number", title: "Baseline Filter Life (Fan Run Hours)", required: false, defaultValue: 300 input "indoorIAQ", "capability.airQuality", title: "Indoor AQI Sensor", required: false input "outdoorIAQ", "capability.airQuality", title: "Outdoor AQI Sensor", required: false if (state.filterRunMinutes != null) { def maxMins = (maxFilterHours ?: 300) * 60 def usedMins = state.filterRunMinutes ?: 0.0 def percentLeft = Math.max(0.0, 100.0 - ((usedMins / maxMins) * 100)).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) paragraph "Filter Life Remaining: ${percentLeft}%" paragraph "Last Filter Change: ${state.lastFilterDate ?: 'Not Recorded'}" input "resetFilter", "button", title: "Record Filter Change (Resets Life to 100%)" } paragraph "
" paragraph "HVAC System Service Tracking" input "hvacCompanyName", "text", title: "HVAC Company Name", required: false input "hvacCompanyPhone", "text", title: "HVAC Company Phone Number", required: false paragraph "Last HVAC Service: ${state.lastServiceDate ?: 'Not Recorded'}" input "resetService", "button", title: "Record HVAC Service Today" } } section("12. Delta-T Efficiency Monitoring & Run Time Protection", hideable: true, hidden: true) { paragraph "
What it does: 1) Delta-T: Monitors system health by measuring the temperature drop across your HVAC coil. 2) Run Time Protection: Protects oversized compressors from damaging short-cycles by artificially dropping the setpoint to force a minimum, safe runtime.
" input "enableDeltaT", "bool", title: "Enable Delta-T Logic", defaultValue: true, submitOnChange: true if (enableDeltaT) { input "returnSensor", "capability.temperatureMeasurement", title: "Return Air Sensor", required: false input "dischargeSensor", "capability.temperatureMeasurement", title: "Discharge (Supply) Air Sensor", required: false input "deltaTCheckDelay", "number", title: "Minutes before checking Delta-T", required: false, defaultValue: 30 input "minCoolingDeltaT", "decimal", title: "Min Cooling Delta-T (°F)", required: false, defaultValue: 12.0 input "minHeatingDeltaT", "decimal", title: "Min Heating Delta-T (°F)", required: false, defaultValue: 15.0 input "emergencyShutoff", "bool", title: "Emergency Shutoff if Delta-T fails", defaultValue: false } paragraph "Oversized Unit Protection" input "enableMinRuntime", "bool", title: "Enable Min Run Time Protection", defaultValue: true, submitOnChange: true if (enableMinRuntime) { input "minRunTime", "number", title: "Minimum Run Time (minutes)", required: false, defaultValue: 10 input "delayModeChangeForMinRun", "bool", title: "Delay Mode Changes until Min Run Time completes", defaultValue: false input "tempDropThreshold", "decimal", title: "Max Temp Drop per Min", required: false, defaultValue: 0.5 input "setpointBuffer", "decimal", title: "Temporary Setpoint Buffer (°F)", required: false, defaultValue: 2.0 input "shortCycleThreshold", "decimal", title: "Short-Cycle Degree Threshold (°F)", required: false, defaultValue: 1.0 input "enableShortCycleNotify", "bool", title: "Notify on Short-Cycle", defaultValue: false, submitOnChange: true if (enableShortCycleNotify) { input "shortCycleNotifyDevices", "capability.notification", title: "Select Notification Devices", required: false, multiple: true } } } section("13. Routine Setpoint Enforcement", hideable: true, hidden: true) { paragraph "
What it does: Acts as a Self-Healing loop. Periodically wakes up and re-transmits the correct setpoints to the thermostat to ensure no wireless commands were dropped by your Z-Wave/Zigbee mesh network.
" input "enableEnforcement", "bool", title: "Enable Routine Enforcement", defaultValue: false, submitOnChange: true if (enableEnforcement) { input "enforcementInterval", "enum", title: "Check Interval", options: ["15":"Every 15 Minutes", "30":"Every 30 Minutes", "60":"Every 1 Hour"], required: false, defaultValue: "30" } } section("External Money Saving Override", hideable: true, hidden: true) { paragraph "
What it does: Allows external applications or virtual switches to force the system into a high-savings mode. All alignment and compressor protections still function normally around these new targets.
" input "moneySavingSwitch", "capability.switch", title: "Select Money Saving Switch", required: false input "moneySavingCoolingSetpoint", "decimal", title: "Money Saving Cooling Setpoint", required: false, defaultValue: 80 input "moneySavingHeatingSetpoint", "decimal", title: "Money Saving Heating Setpoint", required: false, defaultValue: 60 } section("Base Operating Modes & Ranges", hideable: true, hidden: true) { paragraph "
What it does: The foundation of the BMS. Sets your default targets based on Hubitat Location Modes. Note: 'Good Night' mode strictly locks these temperatures for maximum comfort, bypassing economy features.
" paragraph "Leave 'Allowed Modes (Overall App)' BLANK to allow the app to run 24/7. Otherwise, make sure to select every single mode you want the app to function in." input "allowedModes", "mode", title: "Allowed Modes (Overall App) [Master Override]", multiple: true, required: false paragraph "Home" input "homeCoolingSetpoint", "decimal", title: "Home Cooling Setpoint", required: false, defaultValue: 74 input "homeHeatingSetpoint", "decimal", title: "Home Heating Setpoint", required: false, defaultValue: 68 paragraph "Away" input "awayModes", "mode", title: "Select 'Away' modes", multiple: true, required: false input "awayCoolingSetpoint", "decimal", title: "Away Cooling Setpoint", required: false, defaultValue: 78 input "awayHeatingSetpoint", "decimal", title: "Away Heating Setpoint", required: false, defaultValue: 62 paragraph "Good Night (Strict)" input "nightModes", "mode", title: "Select 'Good Night' modes", multiple: true, required: false input "nightCoolingSetpoint", "decimal", title: "Good Night Cooling Setpoint", required: false, defaultValue: 70 input "nightHeatingSetpoint", "decimal", title: "Good Night Heating Setpoint", required: false, defaultValue: 66 } section("14. Alerts & Routine Notifications", hideable: true, hidden: true) { paragraph "
What it does: Centralized notification hub. The app will quietly monitor system health and only notify you when routine maintenance is required or if critical efficiency drops are detected.
" input "notifyDevices", "capability.notification", title: "Select Notification Devices", required: false, multiple: true input "notifyDeltaT", "bool", title: "Notify on Bad Delta-T (Poor Efficiency/Freezing Coil)", defaultValue: true input "notifyFilter", "bool", title: "Notify when Filter Life is < 10%", defaultValue: true input "notifyMaintenance", "bool", title: "Notify for Summer/Winter Maintenance Reminders", defaultValue: true } section("Disclaimer", hideable: true, hidden: true) { paragraph "
Legal Disclaimer: ShaneAllen is not responsible for any damage or liability with the use of this application. This is a user customer application, use at your own discretion.
" } } } // ============================================================================== // INTERNAL LOGIC ENGINE // ============================================================================== def installed() { logInfo("Installed"); initialize() } def updated() { logInfo("Updated"); unsubscribe(); unschedule(); initialize() } def initialize() { if (!state.actionHistory) state.actionHistory = [] if (!state.recentCycles) state.recentCycles = [] if (state.filterRunMinutes == null) state.filterRunMinutes = 0.0 if (!state.zoneLastActive) state.zoneLastActive = [:] if (!state.runHistory) state.runHistory = [:] if (!state.lastFilterDate) state.lastFilterDate = "Not Recorded" if (!state.lastServiceDate) state.lastServiceDate = "Not Recorded" state.isBuffering = false; state.cycleStartTime = null; state.currentAction = "idle"; state.cycleStartMode = null; state.modeDelayLogged = false state.manualHold = false; state.windowOpenHold = false; state.dehumidifyingStage = 0 state.isPeakHours = false; state.isPreConditioning = false; state.isAdaptiveRecovering = false state.freeCoolState = "idle" state.freeCoolTargetTime = null state.fcStartTime = null state.fireEmergency = false state.expectedCool = null; state.expectedHeat = null state.alignmentLockout = null; state.alignmentLockoutTarget = null state.activeHysteresis = "idle" state.lastCommandTime = null if (thermostat) { subscribe(thermostat, "thermostatOperatingState", hvacStateHandler) subscribe(thermostat, "coolingSetpoint", setpointHandler) subscribe(thermostat, "heatingSetpoint", setpointHandler) subscribe(thermostat, "temperature", sensorHandler) } subscribe(location, "mode", modeChangeHandler) subscribe(location, "systemStart", hubRestartHandler) if (smokeDetectors) subscribe(smokeDetectors, "smoke", smokeCoHandler) if (coDetectors) subscribe(coDetectors, "carbonMonoxide", smokeCoHandler) if (sickModeSwitch) subscribe(sickModeSwitch, "switch", sickModeHandler) if (enableFreeCooling && outdoorSensor) { subscribe(outdoorSensor, "temperature", outdoorSensorHandler) subscribe(outdoorSensor, "humidity", outdoorSensorHandler) } if (enableFreeCooling && useWindDirection && windWeatherDevice) { subscribe(windWeatherDevice, "windDirection", outdoorSensorHandler) } for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"]) { if (settings["z${i}Temp"]) subscribe(settings["z${i}Temp"], "temperature", sensorHandler) if (settings["z${i}Hum"]) subscribe(settings["z${i}Hum"], "humidity", humidityHandler) if (settings["z${i}Motion"]) subscribe(settings["z${i}Motion"], "motion", motionHandler) } } if (enableWindowDefeat && contactSensors) subscribe(contactSensors, "contact", contactHandler) if (appEnableSwitch) subscribe(appEnableSwitch, "switch", enableSwitchHandler) if (moneySavingSwitch) subscribe(moneySavingSwitch, "switch", moneySavingHandler) schedulePeakTimes() schedulePreConditioning() scheduleAdaptiveRecoveryCheck() schedule("0 0 10 * * ?", dailyMaintenanceCheck) if (enableEnforcement) { def interval = enforcementInterval ?: "30" if (interval == "15") runEvery15Minutes(routineSweep) else if (interval == "30") runEvery30Minutes(routineSweep) else if (interval == "60") runEvery1Hour(routineSweep) } logAction("App Initialized. Modular BMS Engine Ready.") // Check initial smoke/co status smokeCoHandler([:]) evaluateSystem() } def routineSweep() { if (state.fireEmergency || state.manualHold || state.windowOpenHold || state.isBuffering) return logAction("Running routine setpoint enforcement sweep.") evaluateSystem() } def hubRestartHandler(evt) { logAction("CRITICAL: Hub reboot detected. Executing BMS Failsafe Recovery.") state.isBuffering = false; state.windowOpenHold = false; state.dehumidifyingStage = 0 state.isPreConditioning = false; state.isAdaptiveRecovering = false; state.freeCoolState = "idle" state.cycleStartTime = null; state.currentAction = "idle"; state.cycleStartMode = null; state.modeDelayLogged = false state.alignmentLockout = null; state.alignmentLockoutTarget = null state.activeHysteresis = "idle" if (state.savedPlugStates) restorePlugs() unschedule() schedulePeakTimes(); schedulePreConditioning(); scheduleAdaptiveRecoveryCheck() schedule("0 0 10 * * ?", dailyMaintenanceCheck) if (enableEnforcement) { def interval = enforcementInterval ?: "30" if (interval == "15") runEvery15Minutes(routineSweep) else if (interval == "30") runEvery30Minutes(routineSweep) else if (interval == "60") runEvery1Hour(routineSweep) } smokeCoHandler([:]) evaluateSystem() } def smokeCoHandler(evt) { def isFire = false if (smokeDetectors && smokeDetectors.any { it.currentValue("smoke") == "detected" }) isFire = true if (coDetectors && coDetectors.any { it.currentValue("carbonMonoxide") == "detected" }) isFire = true if (isFire && !state.fireEmergency) { state.fireEmergency = true logAction("CRITICAL EMERGENCY: Smoke/CO detected! Executing HVAC Fire Isolation.") if (thermostat) { thermostat.off() if (thermostat.hasCommand("setThermostatFanMode")) thermostat.setThermostatFanMode("auto") } if (notifyDevices) notifyDevices.deviceNotification("CRITICAL: Smoke/CO detected. HVAC shut down to prevent smoke spread.") evaluateSystem() } else if (!isFire && state.fireEmergency) { state.fireEmergency = false logAction("Emergency Cleared: Smoke/CO no longer detected. Releasing Fire Isolation.") evaluateSystem() } } def sickModeHandler(evt) { if (state.fireEmergency || !thermostat) return if (evt.value == "on") { logAction("Sick Mode Activated: Forcing HVAC Fan ON for continuous filtration.") if (thermostat.hasCommand("setThermostatFanMode")) thermostat.setThermostatFanMode("on") } else { logAction("Sick Mode Deactivated: Restoring HVAC Fan to Auto.") if (state.freeCoolState == "active" && freeCoolFan) { logAction("Sick Mode Off: Free Cooling is active, keeping fan ON.") } else { if (thermostat.hasCommand("setThermostatFanMode")) thermostat.setThermostatFanMode("auto") } } evaluateSystem() } def moneySavingHandler(evt) { logAction("Money Saving Mode turned ${evt.value.toUpperCase()}.") evaluateSystem() } String getHumanReadableStatus() { def status = "" def sickStr = (sickModeSwitch && sickModeSwitch.currentValue("switch") == "on") ? "
Health: Sick Mode Active (Continuous Fan Filtration)" : "" if (state.fireEmergency) { return "🚨 CRITICAL: FIRE / CO ISOLATION ACTIVE. HVAC SHUT DOWN. 🚨" + sickStr } if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") status = "The application is disabled via the Master Switch." else if (allowedModes && !(allowedModes as List).contains(location.mode)) status = "App Disabled by Mode: The current location mode (${location.mode}) is not selected in your 'Allowed Modes' setting." else if (state.windowOpenHold) status = "HVAC is OFF because a monitored perimeter window or door is open." else if (state.manualHold) status = "Automation Paused because someone manually adjusted the physical thermostat." else if (moneySavingSwitch && moneySavingSwitch.currentValue("switch") == "on") status = "Money Saving Mode Active: Targets shifted to maximize energy savings based on external switch." else if (state.isBuffering) status = "Compressor Protection Engaged: The system is locked ON to satisfy the Minimum Run Time and prevent hardware damage." else if (state.yoyoCooldownEnds && now() < state.yoyoCooldownEnds) status = "Anti-Yo-Yo Cooldown Active: Dynamic Setpoint Alignment is temporarily paused to prevent the system from rapidly turning back on." else { def mode = thermostat?.currentValue("thermostatOperatingState")?.toLowerCase() if (mode?.contains("aux") || mode?.contains("emergency")) status = "WARNING: Auxiliary Heat is Active. The system is currently running the high-power resistance heat strips." else { def isNight = nightModes ? (nightModes as List).contains(location.mode) : false if (isNight) status = "Good Night mode is active. Setpoints strictly locked." else if (state.freeCoolState == "pending") status = "Free Cooling Pending: Favorable weather detected! Waiting for you to open the windows before the timeout aborts the cycle." else if (state.freeCoolState == "active") status = "Free Cooling Active: AC is suspended because outdoor conditions are favorable and windows are open." else if (state.dehumidifyingStage == 1) status = "Running smart-plug dehumidifiers to reduce high indoor humidity." else if (state.dehumidifyingStage == 2) status = "AC is actively overcooling the house to extract excess humidity." else if (state.isAdaptiveRecovering) status = "Starting the HVAC early to ensure the house reaches the target temperature." else if (state.isPeakHours) status = "Saving money by shifting target temperatures during expensive Peak Utility hours." else if (state.isPreConditioning) status = "Pre-cooling the house to build a thermal battery ahead of extreme heat." else if (mode == "cooling" || mode == "heating") status = "Operating normally. Currently ${mode} to satisfy the average room requirements." else status = "System is IDLE. Averaged zone temperatures are currently within the comfort range." } } return status + sickStr } def getAverageTemp() { def total = 0.0; def count = 0 def timeoutMs = (occupancyTimeout ?: 60) * 60000 def isNight = nightModes ? (nightModes as List).contains(location.mode) : false def maxAgeMs = 24 * 60 * 60 * 1000 for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"] && settings["z${i}Temp"]) { def tempDev = settings["z${i}Temp"]; def motionDev = settings["z${i}Motion"] def tempState = tempDev.currentState("temperature") if (tempState != null && tempState.value != null) { def tVal = tempState.value.toBigDecimal() def lastUpdate = tempState.date?.time ?: now() if (tVal >= 40.0 && tVal <= 100.0 && (now() - lastUpdate) <= maxAgeMs) { if (isNight) { def nightSwitch = settings["z${i}NightSwitch"] if (nightSwitch && nightSwitch.currentValue("switch") == "on") { total += tVal; count++ } } else { if (!enableOccupancy || !motionDev || (state.zoneLastActive && state.zoneLastActive[motionDev.id] && (now() - state.zoneLastActive[motionDev.id]) < timeoutMs)) { total += tVal; count++ } } } else { logAction("WARNING: Ignored sensor ${tempDev.displayName} due to stale or out-of-bounds data (${tVal}°).") } } } } if (count == 0 && thermostat?.currentValue("temperature") != null) return thermostat.currentValue("temperature").toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) return count > 0 ? (total / count).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) : 0.0 } def getAverageHumidity() { def total = 0.0; def count = 0 for (int i = 1; i <= 12; i++) { if (settings["enableZ${i}"] && settings["z${i}Hum"]) { def humDev = settings["z${i}Hum"] if (humDev.currentValue("humidity") != null) { total += humDev.currentValue("humidity"); count++ } } } return count > 0 ? (total / count).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) : 0.0 } def motionHandler(evt) { if (evt.value == "active") { if (!state.zoneLastActive) state.zoneLastActive = [:]; state.zoneLastActive[evt.device.id] = now(); evaluateSystem() } } def appButtonHandler(btn) { def todayStr = new Date().format("MM/dd/yyyy", location.timeZone) if (btn == "btnRefresh") { logInfo("Dashboard data manually refreshed by user.") } else if (btn == "resetFilter") { state.filterRunMinutes = 0.0 state.lastFilterDate = todayStr state.filterAlertSent = false logAction("Filter logged as changed. Life reset to 100%.") } else if (btn == "resetService") { state.lastServiceDate = todayStr logAction("HVAC Service recorded for today.") } else if (btn == "releaseHold") { state.manualHold = false logAction("Manual Hold released by user.") evaluateSystem() } else if (btn == "resetHistory") { state.runHistory = [:] logAction("Energy Cost Tracking history cleared.") } else if (btn == "resetCycles") { state.recentCycles = [] logAction("Compressor cycle history cleared.") } } def enableSwitchHandler(evt) { if (evt.value == "off") logAction("App Disabled."); else evaluateSystem() } def modeChangeHandler(evt) { state.manualHold = false; state.isAdaptiveRecovering = false; evaluateSystem() } def setpointHandler(evt) { if (state.fireEmergency || state.windowOpenHold || state.isBuffering) return // 15-second blindspot for incoming setpoint echoes right after the BMS sends a command. if (state.lastCommandTime && (now() - state.lastCommandTime) < 15000) { return } def newVal = evt.value.toBigDecimal() def isManual = false if (evt.name == "coolingSetpoint" && state.expectedCool != null) { if (Math.abs(newVal - state.expectedCool) > 1.0) isManual = true } if (evt.name == "heatingSetpoint" && state.expectedHeat != null) { if (Math.abs(newVal - state.expectedHeat) > 1.0) isManual = true } if (isManual && !state.manualHold) { state.manualHold = true logAction("MANUAL OVERRIDE: Physical thermostat changed to ${newVal}°. Automation suspended until mode change.") evaluateSystem() // Update UI right away } } def outdoorSensorHandler(evt) { evaluateSystem() } def checkFreeCooling(currentCoolTarget, evalMode = location.mode) { if (!enableFreeCooling || !outdoorSensor) return currentCoolTarget if (freeCoolModes && !(freeCoolModes as List).contains(evalMode)) { if (state.freeCoolState != "idle") { state.freeCoolState = "idle" disengageFreeCooling() } return currentCoolTarget } def outTemp = outdoorSensor.currentValue("temperature") def outHum = outdoorSensor.currentValue("humidity") ?: 0.0 def currentAvg = getAverageTemp() def delta = freeCoolTempDelta ?: 3.0 def maxHum = freeCoolMaxHumidity ?: 60.0 def timeoutMins = freeCoolTimeout ?: 15 if (outTemp != null) { def isFavorable = (currentAvg >= currentCoolTarget) && (outTemp <= (currentAvg - delta)) && (outHum <= maxHum) if (isFavorable) { if (state.freeCoolState == "idle") { def anyOpen = contactSensors ? contactSensors.any { it.currentValue("contact") == "open" } : false if (anyOpen) { state.freeCoolState = "active" engageFreeCooling() } else { state.freeCoolState = "pending" state.freeCoolTargetTime = now() + (timeoutMins * 60000) def notifyMsg = "Free Cooling available! Open the windows to save energy. AC suspended for ${timeoutMins} minutes." // Wind Direction Optimization check if (useWindDirection && windWeatherDevice && optimalWindDirs) { def currentWindDir = windWeatherDevice.currentValue("windDirection")?.toString()?.toUpperCase() if (currentWindDir) { def optimalList = optimalWindDirs.split(",").collect { it.trim().toUpperCase() } if (optimalList.contains(currentWindDir)) { notifyMsg = "Free Cooling available! Favorable wind detected from the ${currentWindDir}. Open windows facing this direction for optimal cross-ventilation. AC suspended." } } } logAction("Free Cooling Favorable. Waiting ${timeoutMins} mins for windows to open.") if (freeCoolNotify) freeCoolNotify.deviceNotification(notifyMsg) runIn(timeoutMins * 60, freeCoolTimeoutHandler) } return 85.0 } else if (state.freeCoolState == "pending" || state.freeCoolState == "active") { return 85.0 } } else { if (state.freeCoolState != "idle") { state.freeCoolState = "idle" state.freeCoolTargetTime = null unschedule(freeCoolTimeoutHandler) disengageFreeCooling() } } } return state.freeCoolState in ["pending", "active"] ? 85.0 : currentCoolTarget } def freeCoolTimeoutHandler() { if (state.freeCoolState == "pending") { logAction("Free Cooling Aborted: Windows were not opened in time. Resuming standard AC.") if (freeCoolNotify) freeCoolNotify.deviceNotification("Windows not opened. Free Cooling aborted and standard AC resumed.") state.freeCoolState = "lockedOut" state.freeCoolTargetTime = null evaluateSystem() } } def engageFreeCooling() { logAction("Free Cooling ACTIVE: AC suspended.") state.fcStartTime = now() if (freeCoolSwitch) freeCoolSwitch.on() def isSickMode = sickModeSwitch && sickModeSwitch.currentValue("switch") == "on" if (freeCoolFan && thermostat && !isSickMode) { logAction("Free Cooling: Turning HVAC Fan ON to circulate outside air.") if (thermostat.hasCommand("setThermostatFanMode")) thermostat.setThermostatFanMode("on") } } def disengageFreeCooling() { logAction("Free Cooling disabled or weather reset. Restoring AC.") if (state.fcStartTime) trackFreeCoolingSavings() if (freeCoolSwitch) freeCoolSwitch.off() def isSickMode = sickModeSwitch && sickModeSwitch.currentValue("switch") == "on" if (freeCoolFan && thermostat && thermostat.currentValue("thermostatFanMode") != "auto" && !isSickMode) { logAction("Free Cooling Ended: Restoring HVAC Fan to Auto.") if (thermostat.hasCommand("setThermostatFanMode")) thermostat.setThermostatFanMode("auto") } else if (isSickMode) { logAction("Free Cooling Ended: Sick Mode is active, keeping Fan ON.") } if (freeCoolNotify) freeCoolNotify.deviceNotification("Free Cooling ended. Please close the windows.") } def trackFreeCoolingSavings() { if (!state.fcStartTime) return def fcMins = (now() - state.fcStartTime) / 60000.0 state.fcStartTime = null def estimatedSavedRunMins = fcMins * 0.30 def today = new Date().format("yyyy-MM-dd", location.timeZone) if (!state.runHistory) state.runHistory = [:] if (!state.runHistory[today]) state.runHistory[today] = [cool: 0.0, heat: 0.0, aux: 0.0, fcSavedMins: 0.0, runs: 0] state.runHistory[today].fcSavedMins = (state.runHistory[today].fcSavedMins ?: 0.0) + estimatedSavedRunMins logAction("ROI: Logged ${String.format('%.1f', estimatedSavedRunMins)} minutes of estimated avoided compressor runtime via Free Cooling.") } def evaluateSystem() { if (!thermostat) return if (state.fireEmergency) { if (thermostat.currentValue("thermostatMode") != "off") thermostat.setThermostatMode("off") return } if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") return def evalMode = location.mode def modeHoldMsg = "" if (enableMinRuntime && delayModeChangeForMinRun && state.currentAction in ["cooling", "heating"] && state.cycleStartTime) { def runMins = (now() - state.cycleStartTime) / 60000.0 if (runMins < (minRunTime ?: 10)) { if (state.cycleStartMode && state.cycleStartMode != location.mode) { evalMode = state.cycleStartMode modeHoldMsg = " [Mode Change Delayed: Finishing Compressor Protection]" if (!state.modeDelayLogged) { logAction("Mode changed to ${location.mode}, but Compressor Protection is active. Simulating ${state.cycleStartMode} for the remaining run time.") state.modeDelayLogged = true } } } } if (allowedModes && !(allowedModes as List).contains(evalMode)) return if (state.windowOpenHold || state.manualHold || state.isBuffering) return def isAway = awayModes ? (awayModes as List).contains(evalMode) : false def isNight = nightModes ? (nightModes as List).contains(evalMode) : false def isAlignmentModeAllowed = !alignmentModes || (alignmentModes as List).contains(evalMode) def targetCool = homeCoolingSetpoint ?: 74.0; def targetHeat = homeHeatingSetpoint ?: 68.0 def isMoneySaving = moneySavingSwitch && moneySavingSwitch.currentValue("switch") == "on" if (isMoneySaving) { targetCool = moneySavingCoolingSetpoint ?: 80.0 targetHeat = moneySavingHeatingSetpoint ?: 60.0 } else if (isNight) { targetCool = nightCoolingSetpoint ?: 70.0 targetHeat = nightHeatingSetpoint ?: 66.0 } else if (isAway) { targetCool = awayCoolingSetpoint ?: 78.0 targetHeat = awayHeatingSetpoint ?: 62.0 } // Check if we can release a previous alignment lockout based on local ambient recovery def currentLocalTemp = thermostat.currentValue("temperature")?.toBigDecimal() if (currentLocalTemp != null) { if (state.alignmentLockout == "cooling" && currentLocalTemp >= (state.alignmentLockoutTarget ?: targetCool)) { state.alignmentLockout = null logAction("Local temperature recovered to ${state.alignmentLockoutTarget}°. Dynamic Setpoint Alignment re-enabled.") } else if (state.alignmentLockout == "heating" && currentLocalTemp <= (state.alignmentLockoutTarget ?: targetHeat)) { state.alignmentLockout = null logAction("Local temperature recovered to ${state.alignmentLockoutTarget}°. Dynamic Setpoint Alignment re-enabled.") } } if (!isNight && !isMoneySaving) { if (state.isAdaptiveRecovering) { targetCool = homeCoolingSetpoint ?: 74.0; targetHeat = homeHeatingSetpoint ?: 68.0 } if (enablePeakShaving && state.isPeakHours) { targetCool += (peakCoolingOffset ?: 3.0); targetHeat -= (peakHeatingOffset ?: 3.0) } if (enableDehumidification && state.dehumidifyingStage == 2) { targetCool -= (acDehumidifyOffset ?: 2.0) } if (enablePreCondition && state.isPreConditioning) { targetCool = (homeCoolingSetpoint ?: 74.0) - (preCoolOffset ?: 3.0) } } if (!isNight) { targetCool = checkFreeCooling(targetCool, evalMode) } else { if (state.freeCoolState != "idle") { state.freeCoolState = "idle" state.freeCoolTargetTime = null unschedule(freeCoolTimeoutHandler) disengageFreeCooling() } } def baseCool = targetCool def baseHeat = targetHeat // --- Auto-Swap & Hysteresis Conflict Resolution --- def baseSwapDB = enableAutoSwap ? (autoSwapDeadband ?: 1.0) : 1.0 def safeSwapDB = baseSwapDB if (enableAverageSync && isAlignmentModeAllowed && enableHysteresis) { def drift = hysteresisDrift ?: 1.0 // Ensure the Auto-Swap threshold doesn't overlap or compete with the Hysteresis Drift if (safeSwapDB <= drift) { safeSwapDB = drift + 0.5 } } def isYoYoCooldown = state.yoyoCooldownEnds && now() < state.yoyoCooldownEnds def yoyoMins = yoyoCooldownMins != null ? yoyoCooldownMins : 15 // --- Stage 1: Hysteresis & Deadband Evaluation --- def isHysteresisIdle = false def hysMessage = "" if (enableAverageSync && isAlignmentModeAllowed && enableHysteresis && thermostat.currentValue("temperature") != null) { def currentAvg = getAverageTemp() def drift = hysteresisDrift ?: 1.0 def recovery = hysteresisRecovery ?: 0.5 if (state.activeHysteresis == null || state.activeHysteresis == "idle") { if (currentAvg >= (baseCool + drift)) { state.activeHysteresis = "cooling" logAction("Stage 1 Hysteresis: Temp drifted to ${currentAvg}° (+${drift}° limit). Initiating Cooling Recovery to ${baseCool + recovery}°.") } else if (currentAvg <= (baseHeat - drift)) { state.activeHysteresis = "heating" logAction("Stage 1 Hysteresis: Temp drifted to ${currentAvg}° (-${drift}° limit). Initiating Heating Recovery to ${baseHeat - recovery}°.") } else { isHysteresisIdle = true hysMessage = " [Stage 1: Floating in Deadband]" } } else if (state.activeHysteresis == "cooling") { if (currentAvg <= (baseCool + recovery)) { state.activeHysteresis = "idle" isHysteresisIdle = true logAction("Stage 1 Hysteresis: Cooled to ${currentAvg}° (Within ${recovery}° of target). Satisfied and entering Idle.") hysMessage = " [Stage 1: Recovery Satisfied]" } else { hysMessage = " [Stage 1: Active Cool Recovery]" } } else if (state.activeHysteresis == "heating") { if (currentAvg >= (baseHeat - recovery)) { state.activeHysteresis = "idle" isHysteresisIdle = true logAction("Stage 1 Hysteresis: Heated to ${currentAvg}° (Within ${recovery}° of target). Satisfied and entering Idle.") hysMessage = " [Stage 1: Recovery Satisfied]" } else { hysMessage = " [Stage 1: Active Heat Recovery]" } } } else { state.activeHysteresis = "idle" } // --- Dynamic Setpoint Alignment --- def syncMessage = "" if (enableAverageSync && isAlignmentModeAllowed && thermostat.currentValue("temperature") != null) { if (state.alignmentLockout) { syncMessage = " [Alignment Suspended: Awaiting Temp Recovery]" } else if (isYoYoCooldown) { syncMessage = " [Alignment Paused: ${yoyoMins}-Min Yo-Yo Cooldown]" } else { def tstatTemp = thermostat.currentValue("temperature").toBigDecimal() def currentAvg = getAverageTemp() if (enableHysteresis && isHysteresisIdle) { // Bracket the physical thermostat to forcefully keep it IDLE while the average floats targetCool = (tstatTemp + 1.5).toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) targetHeat = (tstatTemp - 1.5).toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) syncMessage = hysMessage } else { def offset = (currentAvg - tstatTemp) def maxShift = maxSyncOffset ?: 3.0 if (offset > maxShift) offset = maxShift if (offset < -maxShift) offset = -maxShift if (offset != 0.0) { def calcCool = (targetCool - offset).toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) def calcHeat = (targetHeat - offset).toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) // If Hysteresis is disabled, fall back to basic smart snap-back if (!enableHysteresis) { def coolSnapped = false if (calcCool < baseCool && currentAvg <= baseCool) coolSnapped = true def heatSnapped = false if (calcHeat > baseHeat && currentAvg >= baseHeat) heatSnapped = true if (coolSnapped || heatSnapped) { calcCool = baseCool calcHeat = baseHeat syncMessage = " [Alignment Satisfied: System Idle, Snapped to Base]" } else { syncMessage = " [Alignment Active: Shifted by ${String.format('%.1f', -offset)}°]" } } else { syncMessage = hysMessage + " [Shifted by ${String.format('%.1f', -offset)}°]" } targetCool = calcCool targetHeat = calcHeat // --- ANTI-YOYO CLAMP --- if (targetCool <= (baseHeat + safeSwapDB)) { targetCool = baseHeat + safeSwapDB + 1.0 targetHeat = targetCool - 3.0 syncMessage += " [Clamped: Hit Heating Floor]" } else if (targetHeat >= (baseCool - safeSwapDB)) { targetHeat = baseCool - safeSwapDB - 1.0 targetCool = targetHeat + 3.0 syncMessage += " [Clamped: Hit Cooling Ceiling]" } } } } } else if (enableAverageSync && !isAlignmentModeAllowed) { state.alignmentLockout = null // Clear any lockouts when transitioning to a disabled mode } // --- Universal Deadband Enforcer --- def hardwareDeadband = 3.0 if ((targetCool - targetHeat) < hardwareDeadband) { targetHeat = (targetCool - hardwareDeadband).toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) if (!syncMessage.contains("Clamped") && !syncMessage.contains("Satisfied") && !syncMessage.contains("Suspended")) syncMessage += " [Deadband Enforced]" } // ----------------------------------- if (enableMinRuntime && state.currentAction in ["cooling", "heating"] && state.cycleStartTime) { def runMins = (now() - state.cycleStartTime) / 60000.0 if (runMins < (minRunTime ?: 10)) { def localTemp = thermostat.currentValue("temperature")?.toBigDecimal() ?: targetCool def buffer = setpointBuffer ?: 2.0 if (state.currentAction == "cooling") { if (targetCool > thermostat.currentValue("coolingSetpoint").toBigDecimal()) { targetCool = thermostat.currentValue("coolingSetpoint").toBigDecimal() syncMessage += " [Compressor Protection: Lockout Prevented Setpoint Rise]" } // Force target below physical temp to prevent the thermostat from deciding to shut down locally if (targetCool >= localTemp) { targetCool = localTemp - 1.0 def minAllowed = baseCool - buffer if (targetCool < minAllowed) { targetCool = minAllowed // If clamped limit is still above local temp, the physical stat will shut off anyway if (targetCool >= localTemp) { syncMessage += " [CRITICAL: Cannot protect compressor. Min buffer limit reached.]" } if (enableAverageSync && isAlignmentModeAllowed && !state.alignmentLockout) { state.alignmentLockout = "cooling" state.alignmentLockoutTarget = baseCool syncMessage += " [CRITICAL: Max Buffer Hit. Alignment ABORTED until temp recovers]" } else { syncMessage += " [Compressor Protection: Clamped to Max Buffer (-${buffer}°)]" } } else { syncMessage += " [Compressor Protection: Pushed below local temp to maintain run]" } } } else if (state.currentAction == "heating") { if (targetHeat < thermostat.currentValue("heatingSetpoint").toBigDecimal()) { targetHeat = thermostat.currentValue("heatingSetpoint").toBigDecimal() syncMessage += " [Compressor Protection: Lockout Prevented Setpoint Drop]" } // Force target above physical temp to prevent the thermostat from deciding to shut down locally if (targetHeat <= localTemp) { targetHeat = localTemp + 1.0 def maxAllowed = baseHeat + buffer if (targetHeat > maxAllowed) { targetHeat = maxAllowed if (targetHeat <= localTemp) { syncMessage += " [CRITICAL: Cannot protect compressor. Max buffer limit reached.]" } if (enableAverageSync && isAlignmentModeAllowed && !state.alignmentLockout) { state.alignmentLockout = "heating" state.alignmentLockoutTarget = baseHeat syncMessage += " [CRITICAL: Max Buffer Hit. Alignment ABORTED until temp recovers]" } else { syncMessage += " [Compressor Protection: Clamped to Max Buffer (+${buffer}°)]" } } else { syncMessage += " [Compressor Protection: Pushed above local temp to maintain run]" } } } if (enableAutoSwap) { def minCoolFloor = baseHeat + safeSwapDB + 1.5 def maxHeatCeiling = baseCool - safeSwapDB - 1.5 if (state.currentAction == "cooling" && targetCool < minCoolFloor) { targetCool = minCoolFloor.toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) syncMessage += " [Compressor Protection: Protected against Heat Swap]" } else if (state.currentAction == "heating" && targetHeat > maxHeatCeiling) { targetHeat = maxHeatCeiling.toBigDecimal().setScale(0, BigDecimal.ROUND_HALF_UP) syncMessage += " [Compressor Protection: Protected against Cool Swap]" } } } } // --- AUX HEAT SUPPRESSION (GLIDING) --- if (enableAuxSuppression && currentLocalTemp != null) { def stepLimit = maxHeatStep ?: 1.5 if (targetHeat > (currentLocalTemp + stepLimit)) { targetHeat = (currentLocalTemp + stepLimit).toBigDecimal().setScale(1, BigDecimal.ROUND_HALF_UP) syncMessage += " [Aux Suppressed: Heat target glided to ${targetHeat}°]" } } if (thermostat.currentValue("coolingSetpoint") != targetCool || thermostat.currentValue("heatingSetpoint") != targetHeat) { state.expectedCool = targetCool; state.expectedHeat = targetHeat state.lastCommandTime = now() // Track execution time to prevent network echo thermostat.setCoolingSetpoint(targetCool) thermostat.setHeatingSetpoint(targetHeat) logAction("BMS Command -> Pushing Setpoints to Thermostat: COOL ${targetCool}° | HEAT ${targetHeat}°${syncMessage}${modeHoldMsg}") } if (enableAutoSwap && !(state.freeCoolState in ["pending", "active"])) { def currentAvg = getAverageTemp() def tMode = thermostat.currentValue("thermostatMode")?.toLowerCase() if (tMode == "heat" || tMode == "cool" || tMode == "auto") { if (currentAvg >= (targetCool + safeSwapDB) && tMode != "cool") { logAction("BMS Command -> Auto-Swap triggered. Switching thermostat to COOL mode. (Temp: ${currentAvg}°, Target: ${targetCool}°, Safe DB: ${safeSwapDB}°)") thermostat.setThermostatMode("cool") } else if (currentAvg <= (targetHeat - safeSwapDB) && tMode != "heat") { logAction("BMS Command -> Auto-Swap triggered. Switching thermostat to HEAT mode. (Temp: ${currentAvg}°, Target: ${targetHeat}°, Safe DB: ${safeSwapDB}°)") thermostat.setThermostatMode("heat") } } } } // Active Compressor Watchdog - Polls the system every 60 seconds while running to prevent premature local satisfaction def compressorWatchdog() { if (state.currentAction in ["cooling", "heating"] && state.cycleStartTime) { def runMins = (now() - state.cycleStartTime) / 60000.0 if (runMins < (minRunTime ?: 10)) { evaluateSystem() runIn(60, compressorWatchdog) } else { evaluateSystem() // Ensure final evaluation to push pending mode change targets } } } def contactHandler(evt) { def anyOpen = contactSensors ? contactSensors.any { it.currentValue("contact") == "open" } : false if (anyOpen && state.freeCoolState == "pending") { logAction("Windows opened by user. Fully engaging Free Cooling.") unschedule(freeCoolTimeoutHandler) state.freeCoolState = "active" state.freeCoolTargetTime = null engageFreeCooling() evaluateSystem() } else if (!anyOpen && state.freeCoolState == "active") { logAction("Windows closed by user. Ending Free Cooling.") state.freeCoolState = "lockedOut" disengageFreeCooling() evaluateSystem() } if (anyOpen && !state.windowOpenHold) { runIn((contactDelay ?: 3) * 60, executeWindowDefeat) } else if (!anyOpen && state.windowOpenHold) { logAction("Windows closed. Releasing HVAC Safety Defeat.") state.windowOpenHold = false; unschedule(executeWindowDefeat); evaluateSystem() } else if (!anyOpen) unschedule(executeWindowDefeat) } def executeWindowDefeat() { state.windowOpenHold = true; if (state.isBuffering) { state.isBuffering = false; unschedule(releaseBuffer) }; logAction("BMS Command -> Safety Defeat Active. Turning HVAC OFF."); thermostat.off() } def schedulePreConditioning() { if (enablePreCondition && weatherDevice && preCoolStartTime && preCoolEndTime) { schedule(preCoolStartTime, checkWeatherAndPrecool); schedule(preCoolEndTime, endPrecool) } } def checkWeatherAndPrecool() { def f = weatherDevice.currentValue("forecastHigh") ?: weatherDevice.currentValue("temperature"); if (f != null && f.toBigDecimal() >= (heatwaveThreshold ?: 90.0)) { state.isPreConditioning = true; evaluateSystem() } } def endPrecool() { if (state.isPreConditioning) { state.isPreConditioning = false; evaluateSystem() } } def scheduleAdaptiveRecoveryCheck() { if (enableAdaptive && expectedReturnTime) schedule("0 * * * * ?", checkAdaptiveRecovery) } def checkAdaptiveRecovery() { def isAway = awayModes ? (awayModes as List).contains(location.mode) : false if (!enableAdaptive || !expectedReturnTime || !isAway) { state.isAdaptiveRecovering = false; return } def msUntilReturn = timeToday(expectedReturnTime).time - now() if (msUntilReturn > 0 && msUntilReturn < 43200000) { def currentAvg = getAverageTemp() def isCoolingSeason = (currentAvg > (homeCoolingSetpoint ?: 74.0)) def delta = isCoolingSeason ? (currentAvg - (homeCoolingSetpoint ?: 74.0)) : ((homeHeatingSetpoint ?: 68.0) - currentAvg) if (delta <= 0) return def hoursNeeded = delta / (isCoolingSeason ? (coolingGlide ?: 2.0) : (heatingGlide ?: 3.0)) if (msUntilReturn <= (hoursNeeded * 3600000).toLong() && !state.isAdaptiveRecovering) { state.isAdaptiveRecovering = true; evaluateSystem() } } } def hvacStateHandler(evt) { def stateVal = evt.value?.toLowerCase() ?: "" if (stateVal == "cooling" || stateVal == "heating" || stateVal.contains("aux") || stateVal.contains("emergency")) { state.cycleStartTime = now() state.cycleStartMode = location.mode state.modeDelayLogged = false state.startTemp = thermostat.currentValue("temperature") if (stateVal.contains("aux") || stateVal.contains("emergency")) { state.currentAction = "auxHeating" } else { state.currentAction = stateVal } state.isBuffering = false def isNight = nightModes ? (nightModes as List).contains(location.mode) : false if (enableMinRuntime && !isNight && state.currentAction in ["cooling", "heating"]) { runIn(60, compressorWatchdog) def activeSetpoint = (state.currentAction == "cooling") ? thermostat.currentValue("coolingSetpoint") : thermostat.currentValue("heatingSetpoint") def threshold = shortCycleThreshold ?: 1.0 if (activeSetpoint != null && Math.abs(state.startTemp - activeSetpoint) <= threshold) { logAction("BMS Command -> Compressor Protection Engaged! HVAC started within ${threshold}° of setpoint. Forcing minimum run time.") engageBuffer(0) } } if (enableDeltaT && returnSensor && dischargeSensor) runIn((deltaTCheckDelay ?: 30) * 60, checkDeltaT) } else if (stateVal == "idle" || stateVal == "pending cool" || stateVal == "pending heat") { unschedule(checkDeltaT) unschedule(compressorWatchdog) def yoyoMins = yoyoCooldownMins != null ? yoyoCooldownMins : 15 if (state.currentAction == "cooling") { state.yoyoCooldownEnds = now() + (yoyoMins * 60000) logAction("Cooling cycle complete. Starting ${yoyoMins}-Minute Anti-Yo-Yo Cooldown.") } if (state.isBuffering) releaseBuffer() if (state.cycleStartTime) { def runMinutes = (now() - state.cycleStartTime) / 60000.0 if (enableFilterTracker) processFilterWear(runMinutes) if (enableCostTracker && state.currentAction) trackEnergyCost(state.currentAction, runMinutes) if (state.currentAction && state.currentAction != "idle") { trackRecentCycle(state.currentAction, runMinutes) if (enableMinRuntime && state.currentAction in ["cooling", "heating"]) { def targetMin = minRunTime ?: 10 if (runMinutes < targetMin) { logAction("WARNING: Short-cycle detected! Compressor ran for ${String.format('%.1f', runMinutes)} mins (Goal: ${targetMin} mins).") if (enableShortCycleNotify && shortCycleNotifyDevices) { def alertMsg = "HVAC Alert: Short-cycle detected. ${state.currentAction.capitalize()} ran for only ${String.format('%.1f', runMinutes)} minutes." shortCycleNotifyDevices.deviceNotification(alertMsg) } } } } } state.cycleStartTime = null; state.currentAction = "idle"; state.cycleStartMode = null; state.modeDelayLogged = false evaluateSystem() } } def trackEnergyCost(action, runMinutes) { def today = new Date().format("yyyy-MM-dd", location.timeZone) if (!state.runHistory) state.runHistory = [:] if (!state.runHistory[today]) state.runHistory[today] = [cool: 0.0, heat: 0.0, aux: 0.0, fcSavedMins: 0.0, runs: 0] if (action == "cooling") state.runHistory[today].cool += runMinutes if (action == "heating") state.runHistory[today].heat += runMinutes if (action == "auxHeating") state.runHistory[today].aux += runMinutes if (action in ["cooling", "heating", "auxHeating"]) { state.runHistory[today].runs = (state.runHistory[today].runs ?: 0) + 1 } def keys = state.runHistory.keySet().sort().reverse() if (keys.size() > 7) { state.runHistory = state.runHistory.subMap(keys[0..6]) } } def renderCostDashboard() { def liveFCSavingsMins = 0.0 if (state.fcStartTime) liveFCSavingsMins = ((now() - state.fcStartTime) / 60000.0) * 0.30 def html = "" def totalCost = 0.0; def totalSavings = 0.0 def keys = state.runHistory.keySet().sort().reverse() def today = new Date().format("yyyy-MM-dd", location.timeZone) keys.each { date -> def data = state.runHistory[date] def cMins = data.cool ?: 0.0; def hMins = data.heat ?: 0.0; def aMins = data.aux ?: 0.0 def fcSavedMins = (data.fcSavedMins ?: 0.0) + (date == today ? liveFCSavingsMins : 0.0) def cCost = (cMins / 60.0) * (coolingKw ?: 4.6) * (costPerKwh ?: 0.15) def hCost = (hMins / 60.0) * (heatingKw ?: 4.6) * (costPerKwh ?: 0.15) def aCost = (aMins / 60.0) * (auxHeatingKw ?: 15.0) * (costPerKwh ?: 0.15) def fcSavingsCost = (fcSavedMins / 60.0) * (coolingKw ?: 4.6) * (costPerKwh ?: 0.15) def dayCost = cCost + hCost + aCost totalCost += dayCost totalSavings += fcSavingsCost def auxStyle = aMins > 0 ? "color:red; font-weight:bold;" : "" def saveStyle = fcSavingsCost > 0 ? "color:green; font-weight:bold;" : "color:gray;" html += "" } html += "" html += "
DateCoolingHeatingAux HeatEst. CostEst. Savings
${date}${String.format('%.1f', cMins/60.0)}h${String.format('%.1f', hMins/60.0)}h${String.format('%.1f', aMins/60.0)}h$${String.format('%.2f', dayCost)}+$${String.format('%.2f', fcSavingsCost)}
7-Day Totals:$${String.format('%.2f', totalCost)}+$${String.format('%.2f', totalSavings)}
" return html } def sensorHandler(evt) { evaluateSystem() def isNight = nightModes ? (nightModes as List).contains(location.mode) : false if (!enableMinRuntime || isNight || state.currentAction == "idle" || state.isBuffering || !state.cycleStartTime) return if (state.currentAction == "auxHeating") return def runMins = (now() - state.cycleStartTime) / 60000.0 if (runMins < 1.0 || runMins >= (minRunTime ?: 10)) return def dropRate = ((state.currentAction == "cooling" ? state.startTemp - thermostat.currentValue("temperature") : thermostat.currentValue("temperature") - state.startTemp)) / runMins if (dropRate >= (tempDropThreshold ?: 0.5)) engageBuffer(runMins) } def engageBuffer(runMins) { state.isBuffering = true def bufferAmt = setpointBuffer ?: 2.0 def deadband = 3.0 if (state.currentAction == "cooling") { def newCool = (thermostat.currentValue("coolingSetpoint")?.toBigDecimal() ?: 72.0) - bufferAmt def newHeat = (thermostat.currentValue("heatingSetpoint")?.toBigDecimal() ?: 68.0) if ((newCool - newHeat) < deadband) newHeat = newCool - deadband state.expectedCool = newCool; state.expectedHeat = newHeat state.lastCommandTime = now() thermostat.setCoolingSetpoint(newCool) if (thermostat.currentValue("heatingSetpoint") != newHeat) thermostat.setHeatingSetpoint(newHeat) logAction("BMS Command -> Compressor Protection Engaged! Temperature dropping too fast. Target temporarily shifted to ${newCool}° to ensure minimum runtime.") } else { def newHeat = (thermostat.currentValue("heatingSetpoint")?.toBigDecimal() ?: 68.0) + bufferAmt def newCool = (thermostat.currentValue("coolingSetpoint")?.toBigDecimal() ?: 72.0) if ((newCool - newHeat) < deadband) newCool = newHeat + deadband state.expectedHeat = newHeat; state.expectedCool = newCool state.lastCommandTime = now() thermostat.setHeatingSetpoint(newHeat) if (thermostat.currentValue("coolingSetpoint") != newCool) thermostat.setCoolingSetpoint(newCool) logAction("BMS Command -> Compressor Protection Engaged! Temperature rising too fast. Target temporarily shifted to ${newHeat}° to ensure minimum runtime.") } runIn((((minRunTime ?: 10) - runMins) * 60).toInteger(), releaseBuffer) } def releaseBuffer() { state.isBuffering = false def yoyoMins = yoyoCooldownMins != null ? yoyoCooldownMins : 15 if (state.currentAction == "cooling") { state.yoyoCooldownEnds = now() + (yoyoMins * 60000) } logAction("Compressor Protection Buffer Complete. Restoring normal targets and starting Anti-Yo-Yo Cooldown.") evaluateSystem() } def checkDeltaT() { if (state.currentAction == "idle" || state.fireEmergency) return def retT = returnSensor.currentValue("temperature") def disT = dischargeSensor.currentValue("temperature") if (retT == null || disT == null) return def dT = (state.currentAction == "cooling") ? (retT - disT) : (disT - retT) if (dT < (state.currentAction == "cooling" ? (minCoolingDeltaT ?: 12.0) : (minHeatingDeltaT ?: 15.0))) { logAction("HVAC WARNING: Poor efficiency. Delta-T is ${String.format('%.1f', dT)}°F.") if (notifyDeltaT && notifyDevices) { if (!state.lastDeltaTAlert || (now() - state.lastDeltaTAlert) > 86400000) { notifyDevices.deviceNotification("HVAC Alert: Poor efficiency detected. Delta-T is ${String.format('%.1f', dT)}°F. Unit may be freezing up or low on refrigerant.") state.lastDeltaTAlert = now() } } if (emergencyShutoff) thermostat.off() } else { logAction("Delta-T Check Passed: ${String.format('%.1f', dT)}°F.") } runIn((deltaTCheckDelay ?: 30) * 60, checkDeltaT) } def processFilterWear(actualRunMinutes) { def ind = indoorIAQ ? (indoorIAQ.currentValue("airQualityIndex") ?: 0) : 0 def out = outdoorIAQ ? (outdoorIAQ.currentValue("airQualityIndex") ?: 0) : 0 state.filterRunMinutes += (actualRunMinutes * (1.0 + (ind * 0.01) + (out * 0.002))) if (notifyFilter && notifyDevices) { def maxMins = (maxFilterHours ?: 300) * 60 def percentLeft = Math.max(0.0, 100.0 - ((state.filterRunMinutes / maxMins) * 100)) if (percentLeft < 10.0 && !state.filterAlertSent) { notifyDevices.deviceNotification("HVAC Maintenance: Your air filter life is below 10%. Please replace it soon to maintain efficiency.") state.filterAlertSent = true } } } def dailyMaintenanceCheck() { if (!notifyMaintenance || !notifyDevices) return def today = new Date() def month = today.format("MM", location.timeZone).toInteger() def day = today.format("dd", location.timeZone).toInteger() def year = today.format("yyyy", location.timeZone) // May 1st - Summer check if (month == 5 && day == 1 && state.lastMaintenanceAlert != "summer_${year}") { notifyDevices.deviceNotification("HVAC Reminder: Summer is approaching. It's time to schedule your AC maintenance check and clear the condensate drain line.") state.lastMaintenanceAlert = "summer_${year}" } // October 1st - Winter check if (month == 10 && day == 1 && state.lastMaintenanceAlert != "winter_${year}") { notifyDevices.deviceNotification("HVAC Reminder: Winter is approaching. It's time to schedule your Heating maintenance check and test the ignitor/elements.") state.lastMaintenanceAlert = "winter_${year}" } } def humidityHandler(evt) { if (state.windowOpenHold || state.manualHold || state.fireEmergency || !enableDehumidification) return; def avgHum = getAverageHumidity(); if (avgHum > (maxHumidity ?: 55.0) && state.dehumidifyingStage == 0) { state.dehumidifyingStage = 1; if (dehumidifierPlugs) { state.savedPlugStates = dehumidifierPlugs.collectEntries { [it.id, it.currentValue("switch")] }; dehumidifierPlugs.on() }; runIn((dehumidifierTimeout ?: 45) * 60, checkDehumidifierProgress) } else if (avgHum <= ((maxHumidity ?: 55.0) - 2.0) && state.dehumidifyingStage > 0) { state.dehumidifyingStage = 0; unschedule(checkDehumidifierProgress); restorePlugs(); evaluateSystem() } } def checkDehumidifierProgress() { if (getAverageHumidity() > (maxHumidity ?: 55.0)) { state.dehumidifyingStage = 2; restorePlugs(); evaluateSystem() } } def restorePlugs() { if (dehumidifierPlugs && state.savedPlugStates) { dehumidifierPlugs.each { plug -> def orig = state.savedPlugStates[plug.id]; if (orig == "off") plug.off(); else if (orig == "on") plug.on() } }; state.savedPlugStates = null } def schedulePeakTimes() { if (enablePeakShaving && peakStartTime && peakEndTime) { schedule(peakStartTime, startPeak); schedule(peakEndTime, endPeak); def now = new Date(); if (now >= timeToday(peakStartTime) && now <= timeToday(peakEndTime)) state.isPeakHours = true } } def startPeak() { state.isPeakHours = true; evaluateSystem() } def endPeak() { state.isPeakHours = false; evaluateSystem() } def trackRecentCycle(action, runMinutes) { if (!state.recentCycles) state.recentCycles = [] def timestamp = new Date().format("MM/dd hh:mm a", location.timeZone) def formattedTime = String.format("%.1f", runMinutes) def actionName = action.capitalize() if (action == "auxHeating") actionName = "Aux Heat" def cycleLog = "[${timestamp}] ${actionName} ran for ${formattedTime} minutes" state.recentCycles.add(0, cycleLog) if (state.recentCycles.size() > 10) { state.recentCycles = state.recentCycles[0..9] } } 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}" }