/**
* 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 = """
| Metric | Calculated Average (Rooms) | Thermostat Sensor | Target Setpoint |
| Temperature | ${avgTemp}° | ${tstatTemp}° | Cool: ${tstatCool}° | Heat: ${tstatHeat}° |
| Humidity | ${avgHum}% | ${tstatHum}% | Limit: ${maxHumidity ?: '--'}% |
| HVAC State | Mode: ${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 = "| Zone Name | Temp | Humidity | Occupied? | Status |
"
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 += "| ${zName} | ${zTempStr} | ${zHum}% | ${isOccupied} | ${zStatus} |
"
}
}
zoneHTML += "
"
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 = "| Date | Cooling | Heating | Aux Heat | Est. Cost | Est. Savings |
"
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 += "| ${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)} |
"
}
html += "| 7-Day Totals: | $${String.format('%.2f', totalCost)} | +$${String.format('%.2f', totalSavings)} |
"
html += "
"
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}" }