/** * Advanced Mini Split Controls */ definition( name: "Advanced Mini Split Controls", namespace: "ShaneAllen", author: "ShaneAllen", description: "Commercial-Grade BMS. Features: Master App Switch, Mode Restrictions, RF State Enforcement, Peak Shedding, Delta T Alerting, Ambient Lockout, and Cost Tracking.", category: "Green Living", iconUrl: "", iconX2Url: "", iconX3Url: "" ) preferences { page(name: "mainPage") page(name: "roomConfigPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Advanced Mini Split Controls (BMS)", install: true, uninstall: true) { section("Live BMS, Health & Cost Dashboard") { input "refreshDashboardBtn", "button", title: "🔄 Refresh Live Data" def hasZones = false def rate = kwhCost ?: 0.13 def totalAppDailyCost = 0.0 def totalAppWeeklyCost = 0.0 def statusText = "" statusText += "" def outTemp = outdoorTempSensor ? outdoorTempSensor.currentValue("temperature") : null def isPeak = checkPeakShedding() def currentModeStr = location.mode def isRestricted = restrictedModes && (restrictedModes as List).contains(currentModeStr) for (int i = 1; i <= 2; i++) { if (settings["enableZ${i}"]) { hasZones = true def zName = settings["z${i}Name"] ?: "Room ${i}" def currentMode = state."currentMode_z${i}" ?: "OFF" def tempDev = settings["z${i}Temp"] def humDev = settings["z${i}Humidity"] def datDev = settings["z${i}DATSensor"] def secDehumDev = settings["z${i}SecDehum"] def auxHeatDev = settings["z${i}AuxHeater"] def acWatts = settings["z${i}Watts"] ?: 1000.0 def secWatts = settings["z${i}SecDehumWatts"] ?: 400.0 // Calculate Active Setpoint & Ranges def activeSetpoint = "--" def pOff = isPeak ? (settings["peakOffset"] ?: 3) : 0 if (currentMode.contains("COOL") && currentMode != "TURBO COOL" && currentMode != "DRY MODE") { def base = 74 if (currentMode == "OCC COOL") base = settings["z${i}OccCool"] ?: 74 if (currentMode == "NIGHT COOL") base = settings["z${i}NightCool"] ?: 70 if (currentMode == "UNOCC COOL") base = settings["z${i}UnoccCool"] ?: 78 activeSetpoint = "${base + pOff}°" } else if (currentMode.contains("HEAT") && currentMode != "TURBO HEAT" && currentMode != "AUX HEAT") { def base = 68 if (currentMode == "OCC HEAT") base = settings["z${i}OccHeat"] ?: 68 if (currentMode == "NIGHT HEAT") base = settings["z${i}NightHeat"] ?: 65 if (currentMode == "UNOCC HEAT") base = settings["z${i}UnoccHeat"] ?: 62 activeSetpoint = "${base - pOff}°" } else { switch(currentMode) { case "TURBO COOL": activeSetpoint = "${settings["z${i}TurboHigh"] ?: 82}°"; break case "TURBO HEAT": activeSetpoint = "${settings["z${i}TurboLow"] ?: 58}°"; break case "DRY MODE": case "MILDEW PREVENTION": activeSetpoint = "Dry / Dehum"; break case "AUX HEAT": activeSetpoint = "Aux Limit"; break case "FAILSAFE OFF": activeSetpoint = "Failsafe (Doors Open)"; break default: // Unit is OFF or in Standby. Show the active float bracket it's enforcing. def isOcc = (settings["z${i}OccSwitch"]?.currentValue("switch") == "on") || state."pendingPreStage_z${i}" def isNight = (settings["z${i}NightSwitch"]?.currentValue("switch") == "on") if (isNight) { activeSetpoint = "Float: ${(settings["z${i}NightHeat"] ?: 65) - pOff}° - ${(settings["z${i}NightCool"] ?: 70) + pOff}°" } else if (isOcc) { activeSetpoint = "Float: ${(settings["z${i}OccHeat"] ?: 68) - pOff}° - ${(settings["z${i}OccCool"] ?: 74) + pOff}°" } else { activeSetpoint = "Float: ${(settings["z${i}UnoccHeat"] ?: 62) - pOff}° - ${(settings["z${i}UnoccCool"] ?: 78) + pOff}°" } break } } // Stats & Cost Math def dailyStats = state."dailyStats_z${i}" ?: [:] def todayStr = getTodayDateString() def tStats = dailyStats[todayStr] ?: [runtime: 0, secRuntime: 0] def lastUpd = state."lastStatUpdate_z${i}" ?: now() def liveDelta = ((now() - lastUpd) / 1000).toLong() if (liveDelta > 600) liveDelta = 0 def isRunning = (currentMode != "OFF" && currentMode != "FAILSAFE OFF" && currentMode != "BMS COMPRESSOR LOCKOUT" && currentMode != "CHANGEOVER DELAY") def isSecRunning = (secDehumDev && secDehumDev.currentValue("switch") == "on") def liveTodayRuntime = tStats.runtime + (isRunning ? liveDelta : 0) def liveTodaySecRuntime = tStats.secRuntime + (isSecRunning ? liveDelta : 0) def weeklyRuntime = isRunning ? liveDelta : 0 def weeklySecRuntime = isSecRunning ? liveDelta : 0 dailyStats.each { k, v -> weeklyRuntime += v.runtime weeklySecRuntime += (v.secRuntime ?: 0) } def dailyCost = ((liveTodayRuntime * acWatts) + (liveTodaySecRuntime * secWatts)) / 3600.0 / 1000.0 * rate def weeklyCost = ((weeklyRuntime * acWatts) + (weeklySecRuntime * secWatts)) / 3600.0 / 1000.0 * rate totalAppDailyCost += dailyCost totalAppWeeklyCost += weeklyCost // Maintenance Tracker def totalRuntimeSecs = (state."lifetimeRuntimeSecs_z${i}" ?: 0) + (isRunning ? liveDelta : 0) def runtimeHrs = Math.floor(totalRuntimeSecs / 3600).toInteger() def filterColor = runtimeHrs > 250 ? "red" : "green" def maintDisplay = "Filter: ${runtimeHrs}/250 hrs" if(runtimeHrs > 250) maintDisplay += "
⚠️ CLEAN FILTER" // Delta T & DAT Logic def deltaTDisplay = "--" if (datDev) { def currentDat = datDev.currentValue("temperature") def datStr = currentDat != null ? "${currentDat}°" : "--°" def deltaT = state."deltaT_z${i}" ?: 0.0 def deltaColor = (isRunning && !currentMode.contains("DRY") && !currentMode.contains("FAN") && !currentMode.contains("AUX") && Math.abs(deltaT) < 12.0 && (now() - (state."modeStartTime_z${i}" ?: 0) > 900000)) ? "red" : "green" deltaTDisplay = "DAT: ${datStr} | ΔT: ${String.format('%.1f', deltaT)}°" } // Mode & Status Displays def modeColor = "gray" if (currentMode.contains("COOL")) modeColor = "#007BFF" if (currentMode.contains("HEAT")) modeColor = "#FF4500" if (currentMode.contains("AUX HEAT")) modeColor = "#FF8C00" if (currentMode.contains("TURBO")) modeColor = "purple" if (currentMode.contains("DRY") || currentMode.contains("MILDEW")) modeColor = "teal" if (currentMode.contains("LOCKOUT") || currentMode.contains("DELAY") || currentMode == "FAILSAFE OFF") modeColor = "red" def modeDisplay = "${currentMode}" if (isRestricted) { modeDisplay += "
PAUSED (Restricted Mode)" } else if (currentMode == "FAILSAFE OFF") { modeDisplay += "
Window/Door Open!" } else if (currentMode == "BMS COMPRESSOR LOCKOUT") { def left = Math.ceil(((state."compressorLockoutTime_z${i}" ?: now()) - now()) / 60000).toInteger() modeDisplay += "
Rest Timer: ${left}m left" } else if (currentMode == "CHANGEOVER DELAY") { modeDisplay += "
Changeover Guard Active" } else if (currentMode == "MILDEW PREVENTION") { def elapsedMs = now() - (state."modeStartTime_z${i}" ?: now()) def leftMins = Math.ceil((1800000 - elapsedMs) / 60000).toInteger() if (leftMins < 0) leftMins = 0 modeDisplay += "
Drying Coils: ${leftMins}m left" } else if (isRunning) { def activeMins = Math.floor((now() - (state."modeStartTime_z${i}" ?: now())) / 60000).toInteger() modeDisplay += "
Active for ${activeMins}m" } else { modeDisplay += "
Standby" } if (secDehumDev) { def secOnTime = state."secDehumOnTime_z${i}" ?: now() def secMins = isSecRunning ? Math.floor((now() - secOnTime) / 60000).toInteger() : 0 def secState = isSecRunning ? "ON (${secMins}m)" : "OFF" modeDisplay += "
Stage 1 Dehum: ${secState}" } if (state."pendingPreStage_z${i}") { modeDisplay += "
⏰ Pre-Staging Active" } def guardDisplay = maintDisplay if (auxHeatDev && outTemp != null && outTemp <= (settings["z${i}LockoutTemp"] ?: 15)) { guardDisplay += "
Outdoor Lockout Active" } statusText += "" statusText += "" statusText += "" statusText += "" statusText += "" statusText += "" statusText += "" } } statusText += "
ZoneCurrent StateClimate & HealthBMS Guards & MaintEst. Cost
${zName}${modeDisplay}🎯 Target: ${activeSetpoint}
🌡️ ${tempDev?.currentValue("temperature")}° | 💧 ${humDev?.currentValue("humidity")}%
${deltaTDisplay}
${guardDisplay}Today: ${String.format('$%.2f', dailyCost)}
7-Day: ${String.format('$%.2f', weeklyCost)}
" if (hasZones) { def globalStatus = (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") ? "PAUSED (Master Switch Off)" : "ACTIVE" statusText += "
" statusText += "
Global System Mode: ${globalStatus}
" statusText += "
" def peakHtml = isPeak ? "⚡ Peak Load Shedding Active" : "" statusText += "
" statusText += "
Outdoor Temp: ${outTemp != null ? outTemp + '°F' : 'No Sensor'} ${peakHtml}
" statusText += "
" statusText += "
System Est. Cost Today: ${String.format('$%.2f', totalAppDailyCost)}
" statusText += "
" paragraph statusText } else { paragraph "No zones configured yet." } } section("Zone Management") { for (int i = 1; i <= 2; i++) { def zName = settings["z${i}Name"] ?: "Zone ${i}" href(name: "roomPage${i}", page: "roomConfigPage", params: [roomId: i], title: "▶ Configure ${zName}", description: settings["enableZ${i}"] ? "Active" : "Disabled") } } section("Global BMS Configuration") { input "kwhCost", "decimal", title: 'Electricity Cost ($ per kWh)', defaultValue: 0.13 input "appEnableSwitch", "capability.switch", title: "Master Enable/Disable Switch (Optional)", required: false, multiple: false paragraph "Global Mode Overrides" input "restrictedModes", "mode", title: "Restricted Modes (Pause all HVAC automation)", multiple: true, required: false input "forceOffModes", "mode", title: "Force OFF Modes (Immediately turns off all units when entering these modes)", multiple: true, required: false paragraph "Outdoor & Demand Response (Peak Load Shedding)" input "outdoorTempSensor", "capability.temperatureMeasurement", title: "Global Outdoor Temperature Sensor", required: false, submitOnChange: true input "peakStartTime", "time", title: "Peak Energy Start Time", required: false input "peakEndTime", "time", title: "Peak Energy End Time", required: false input "peakOffset", "number", title: "Peak Demand Setpoint Offset (°F)", defaultValue: 3, description: "During peak hours, Cooling targets are raised and Heating targets are lowered by this amount to save energy." paragraph "Active Alerts & Notifications" input "notificationDevice", "capability.notification", title: "Send Health & Maintenance Alerts to:", multiple: true, required: false paragraph "Data Management & Overrides" input "forceSync", "bool", title: "Manually Force Hardware Sync", defaultValue: false, submitOnChange: true if (settings["forceSync"]) { logAction("MANUAL OVERRIDE: Forcing hardware sync...") evaluateAllRooms() app.updateSetting("forceSync", false) } input "resetMaint", "bool", title: "Reset Filter/Maintenance Timers", defaultValue: false, submitOnChange: true if (settings["resetMaint"]) { for (int i = 1; i <= 2; i++) { state."lifetimeRuntimeSecs_z${i}" = 0 state."filterNotified_z${i}" = false } logAction("Maintenance timers manually reset.") app.updateSetting("resetMaint", false) } input "resetCost", "bool", title: "Reset All Running Cost Data", defaultValue: false, submitOnChange: true if (settings["resetCost"]) { for (int i = 1; i <= 2; i++) { state."dailyStats_z${i}" = [:] state."lastStatUpdate_z${i}" = now() } logAction("Cost data reset.") app.updateSetting("resetCost", false) } } section("Action History & Debugging") { input "txtEnable", "bool", title: "Enable Description Text Logging", defaultValue: true input "debugEnable", "bool", title: "Enable Debug Logging", defaultValue: false if (state.actionHistory) paragraph "${state.actionHistory.join("
")}
" } } } def roomConfigPage(params) { if (params?.roomId) state.editingRoom = params.roomId def i = state.editingRoom ?: 1 def zName = settings["z${i}Name"] ?: "Zone ${i}" dynamicPage(name: "roomConfigPage", title: "Configuration: ${zName}", install: false, uninstall: false) { section("▶ Basic Setup") { input "enableZ${i}", "bool", title: "Enable this Zone", submitOnChange: true if (settings["enableZ${i}"]) { input "z${i}Name", "text", title: "Zone Name", defaultValue: "Zone ${i}", submitOnChange: true paragraph "Climate Sensors" input "z${i}Temp", "capability.temperatureMeasurement", title: "Main Room Temperature Sensor", required: true input "z${i}DATSensor", "capability.temperatureMeasurement", title: "Discharge Air Temp (DAT) Sensor (For Delta T)", required: false input "z${i}Humidity", "capability.relativeHumidityMeasurement", title: "Humidity Sensor", required: false, submitOnChange: true paragraph "Occupancy & Schedules" input "z${i}OccSwitch", "capability.switch", title: "Room Occupied Switch", required: true input "z${i}NightSwitch", "capability.switch", title: "Good Night Switch", required: false input "z${i}PreStageEnable", "bool", title: "Enable Predictive Pre-Staging", submitOnChange: true, description: "Will force 'Occupied' setpoints before you actually enter the room." if (settings["z${i}PreStageEnable"]) { input "z${i}SchedDays", "enum", title: "↳ Scheduled Days", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], multiple: true, required: true input "z${i}SchedTime", "time", title: "↳ Pre-Stage Start Time", required: true input "z${i}SchedDuration", "number", title: "↳ Pre-Stage Duration (Minutes)", defaultValue: 60, required: true } paragraph "Temperature Swing / Hysteresis" input "z${i}Deadband", "decimal", title: "Deadband / Swing (°F)", defaultValue: 2.0 paragraph "Ceiling Compensation Offset" input "z${i}CoolOffset", "number", title: "Cooling Offset (Subtract from Target)", defaultValue: 3, submitOnChange: true input "z${i}HeatOffset", "number", title: "Heating Offset (Add to Target)", defaultValue: 4, submitOnChange: true paragraph "BMS Protection Guards" input "z${i}CompressorRest", "number", title: "Compressor Rest Delay (Minutes)", defaultValue: 3, required: true input "z${i}ChangeoverDelay", "number", title: "Auto-Changeover Guard (Minutes)", defaultValue: 60, required: true input "z${i}EnforceState", "number", title: "RF State Enforcement Interval (Minutes)", defaultValue: 30, description: "Re-transmits the active RF code periodically to fix dropped packets or remote overrides. (0 to disable)." if (outdoorTempSensor) { paragraph "Outdoor Ambient Lockout" input "z${i}LockoutTemp", "number", title: "Compressor Lockout Temp (°F)", defaultValue: 15, required: true input "z${i}AuxHeater", "capability.switch", title: "Auxiliary Heater Switch (Optional)", required: false } paragraph "Extreme Limits (Turbo Mode)" input "z${i}TurboHigh", "number", title: "Turbo High Limit (°F)", defaultValue: 82, submitOnChange: true input "z${i}TurboLow", "number", title: "Turbo Low Limit (°F)", defaultValue: 58, submitOnChange: true paragraph "Preferred Setpoints (Occupied)" input "z${i}OccCool", "number", title: "Occupied Cooling Target (°F)", defaultValue: 74, submitOnChange: true input "z${i}OccHeat", "number", title: "Occupied Heating Target (°F)", defaultValue: 68, submitOnChange: true paragraph "Preferred Setpoints (Good Night)" input "z${i}NightCool", "number", title: "Night Cooling Target (°F)", defaultValue: 70, submitOnChange: true input "z${i}NightHeat", "number", title: "Night Heating Target (°F)", defaultValue: 65, submitOnChange: true paragraph "Preferred Setpoints (Unoccupied)" input "z${i}UnoccCool", "number", title: "Unoccupied Cooling Target (°F)", defaultValue: 78, submitOnChange: true input "z${i}UnoccHeat", "number", title: "Unoccupied Heating Target (°F)", defaultValue: 62, submitOnChange: true if (settings["z${i}Humidity"]) { paragraph "Two-Stage Humidity Control" input "z${i}HumidityTarget", "number", title: "High Humidity Limit (%)", defaultValue: 60 input "z${i}SecDehum", "capability.switch", title: "Stage 1 Dehumidifier (Passive Monitor)", required: false, submitOnChange: true if (settings["z${i}SecDehum"]) { input "z${i}AcDehumDelay", "number", title: "Stage 2 Delay (Minutes)", defaultValue: 60 input "z${i}SecDehumWatts", "decimal", title: "Stage 1 Dehumidifier Wattage", defaultValue: 400.0 } } input "z${i}MildewPrevent", "bool", title: "Enable Mildew Prevention Intercept", defaultValue: true input "z${i}Contacts", "capability.contactSensor", title: "Window/Door Failsafe Contacts", multiple: true, required: false input "z${i}ContactTimeout", "number", title: "Failsafe Timeout (Minutes)", defaultValue: 5 def cOff = settings["z${i}CoolOffset"] ?: 3 def hOff = settings["z${i}HeatOffset"] ?: 4 paragraph "Broadlink Integration (by tomw)" input "z${i}Broadlink", "capability.actuator", title: "Broadlink Device", required: true, submitOnChange: true if (settings["z${i}Broadlink"]) { input "z${i}CodeTurboCool", "text", title: "Turbo Cool Code", description: "Rec: COOL at ${(settings["z${i}TurboHigh"] ?: 82) - cOff}°" input "testBtn_${i}_CodeTurboCool", "button", title: "▶ Test Turbo Cool Code" input "z${i}CodeTurboHeat", "text", title: "Turbo Heat Code", description: "Rec: HEAT at ${(settings["z${i}TurboLow"] ?: 58) + hOff}°" input "testBtn_${i}_CodeTurboHeat", "button", title: "▶ Test Turbo Heat Code" input "z${i}CodeOccCool", "text", title: "Occupied Cool Code", description: "Rec: COOL at ${(settings["z${i}OccCool"] ?: 74) - cOff}°" input "testBtn_${i}_CodeOccCool", "button", title: "▶ Test Occupied Cool Code" input "z${i}CodeOccHeat", "text", title: "Occupied Heat Code", description: "Rec: HEAT at ${(settings["z${i}OccHeat"] ?: 68) + hOff}°" input "testBtn_${i}_CodeOccHeat", "button", title: "▶ Test Occupied Heat Code" input "z${i}CodeNightCool", "text", title: "Night Cool Code", description: "Rec: COOL at ${(settings["z${i}NightCool"] ?: 70) - cOff}°" input "testBtn_${i}_CodeNightCool", "button", title: "▶ Test Night Cool Code" input "z${i}CodeNightHeat", "text", title: "Night Heat Code", description: "Rec: HEAT at ${(settings["z${i}NightHeat"] ?: 65) + hOff}°" input "testBtn_${i}_CodeNightHeat", "button", title: "▶ Test Night Heat Code" input "z${i}CodeUnoccCool", "text", title: "Unoccupied Cool Code", description: "Rec: COOL at ${(settings["z${i}UnoccCool"] ?: 78) - cOff}°" input "testBtn_${i}_CodeUnoccCool", "button", title: "▶ Test Unoccupied Cool Code" input "z${i}CodeUnoccHeat", "text", title: "Unoccupied Heat Code", description: "Rec: HEAT at ${(settings["z${i}UnoccHeat"] ?: 62) + hOff}°" input "testBtn_${i}_CodeUnoccHeat", "button", title: "▶ Test Unoccupied Heat Code" input "z${i}CodeDry", "text", title: "Mildew / Dry Code", description: "Rec: DRY MODE" input "testBtn_${i}_CodeDry", "button", title: "▶ Test Mildew / Dry Code" input "z${i}CodeOff", "text", title: "System OFF Code" input "testBtn_${i}_CodeOff", "button", title: "▶ Test OFF Code" } input "z${i}Watts", "decimal", title: "Estimated Active Wattage of Mini Split (Watts)", defaultValue: 1000.0 } } } } // ============================================================================== // INTERNAL BMS LOGIC ENGINE // ============================================================================== def installed() { initialize() } def updated() { unsubscribe(); unschedule(); initialize() } def initialize() { if (!state.actionHistory) state.actionHistory = [] for (int i = 1; i <= 2; i++) { if (!state."currentMode_z${i}") state."currentMode_z${i}" = "OFF" if (!state."dailyStats_z${i}") state."dailyStats_z${i}" = [:] state."lastStatUpdate_z${i}" = now() state."deltaT_z${i}" = 0.0 state."secDehumOnTime_z${i}" = null state."pendingPreStage_z${i}" = false state."filterNotified_z${i}" = false state."deltaTNotified_z${i}" = false // BMS Trackers state."lastOffTime_z${i}" = now() state."lastCoolTime_z${i}" = 0 state."lastHeatTime_z${i}" = 0 state."lastRfSentTime_z${i}" = 0 } subscribe(location, "mode", modeChangeHandler) if (appEnableSwitch) subscribe(appEnableSwitch, "switch", masterSwitchHandler) for (int i = 1; i <= 2; i++) { if (settings["enableZ${i}"]) { if (settings["z${i}Temp"]) subscribe(settings["z${i}Temp"], "temperature", sensorHandler) if (settings["z${i}DATSensor"]) subscribe(settings["z${i}DATSensor"], "temperature", sensorHandler) if (settings["z${i}Humidity"]) subscribe(settings["z${i}Humidity"], "humidity", sensorHandler) if (settings["z${i}OccSwitch"]) subscribe(settings["z${i}OccSwitch"], "switch", sensorHandler) if (settings["z${i}NightSwitch"]) subscribe(settings["z${i}NightSwitch"], "switch", sensorHandler) if (settings["z${i}Contacts"]) subscribe(settings["z${i}Contacts"], "contact", contactHandler) if (settings["z${i}SecDehum"]) subscribe(settings["z${i}SecDehum"], "switch", secDehumHandler) if (settings["z${i}PreStageEnable"] && settings["z${i}SchedTime"]) { schedule(settings["z${i}SchedTime"], "triggerPreStageZ${i}") } } } if (outdoorTempSensor) subscribe(outdoorTempSensor, "temperature", sensorHandler) runEvery5Minutes("evaluateAllRooms") logAction("Advanced Mini Split Controls (BMS Edition) Initialized.") evaluateAllRooms() } def isMasterEnabled() { if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") return false return true } def masterSwitchHandler(evt) { logAction("Master Enable Switch turned ${evt.value}.") if (evt.value == "on") { evaluateAllRooms() } else { unschedule() } } def modeChangeHandler(evt) { if (!isMasterEnabled()) return logAction("Location mode changed to: ${evt.value}") def isForceOff = forceOffModes && (forceOffModes as List).contains(evt.value) if (isForceOff) { logAction("GLOBAL FORCE OFF TRIGGERED: Mode changed to ${evt.value}.") for (int i = 1; i <= 2; i++) { if (settings["enableZ${i}"]) { state."currentMode_z${i}" = "OFF" state."lastOffTime_z${i}" = now() executeRFCommand(i, "OFF", true) } } } evaluateAllRooms() } def sensorHandler(evt) { runIn(2, "evaluateAllRooms") } def contactHandler(evt) { runIn(2, "evaluateAllRooms") } def secDehumHandler(evt) { for (int i = 1; i <= 2; i++) { if (settings["enableZ${i}"] && settings["z${i}SecDehum"]?.id == evt.device.id) { if (evt.value == "on") state."secDehumOnTime_z${i}" = now() else state."secDehumOnTime_z${i}" = null } } runIn(2, "evaluateAllRooms") } // Pre-Stage Scheduling def triggerPreStageZ1() { executePreStage(1) } def triggerPreStageZ2() { executePreStage(2) } def executePreStage(roomId) { if (!isMasterEnabled()) return def currentModeStr = location.mode if (restrictedModes && (restrictedModes as List).contains(currentModeStr)) return if (forceOffModes && (forceOffModes as List).contains(currentModeStr)) return def activeDays = settings["z${roomId}SchedDays"] if (activeDays) { def df = new java.text.SimpleDateFormat("EEEE") df.setTimeZone(location.timeZone) if (!activeDays.contains(df.format(new Date()))) return } logAction("${settings["z${roomId}Name"]}: Executing Predictive Pre-Staging. Conditioning room prior to arrival.") state."pendingPreStage_z${roomId}" = true evaluateRoom(roomId) def dur = settings["z${roomId}SchedDuration"] ?: 60 runIn(dur * 60, "cancelPreStageZ${roomId}") } def cancelPreStageZ1() { state."pendingPreStage_z1" = false; evaluateRoom(1) } def cancelPreStageZ2() { state."pendingPreStage_z2" = false; evaluateRoom(2) } def checkPeakShedding() { def isPeak = false if (peakStartTime && peakEndTime) { def start = timeToday(peakStartTime, location.timeZone).time def end = timeToday(peakEndTime, location.timeZone).time def curr = now() if (start <= end) isPeak = (curr >= start && curr <= end) else isPeak = (curr >= start || curr <= end) } return isPeak } def evaluateAllRooms() { if (!isMasterEnabled()) return for (int i = 1; i <= 2; i++) { if (settings["enableZ${i}"]) evaluateRoom(i) } } def evaluateRoom(roomId) { if (!isMasterEnabled()) return def currentModeStr = location.mode def isRestricted = restrictedModes && (restrictedModes as List).contains(currentModeStr) def isForceOff = forceOffModes && (forceOffModes as List).contains(currentModeStr) def tempDev = settings["z${roomId}Temp"] def humDev = settings["z${roomId}Humidity"] def datDev = settings["z${roomId}DATSensor"] def secDehumDev = settings["z${roomId}SecDehum"] def temp = tempDev ? (tempDev.currentValue("temperature") ?: 72.0).toFloat() : 72.0 def humidity = humDev ? (humDev.currentValue("humidity") ?: 50).toInteger() : 50 def outTemp = outdoorTempSensor ? outdoorTempSensor.currentValue("temperature") : null // Ensure accurate Stage 1 tracking if (secDehumDev && secDehumDev.currentValue("switch") == "on" && !state."secDehumOnTime_z${roomId}") { def lastEvt = secDehumDev.events(max: 1)?.find{ it.value == "on" } state."secDehumOnTime_z${roomId}" = lastEvt ? lastEvt.date.time : now() } else if (secDehumDev && secDehumDev.currentValue("switch") == "off") { state."secDehumOnTime_z${roomId}" = null } def cMode = state."currentMode_z${roomId}" ?: "OFF" // Delta T Check & Health Notification if (datDev) { def dat = (datDev.currentValue("temperature") ?: temp).toFloat() if (cMode.contains("COOL") && !cMode.contains("DRY")) state."deltaT_z${roomId}" = temp - dat else if (cMode.contains("HEAT")) state."deltaT_z${roomId}" = dat - temp else state."deltaT_z${roomId}" = 0.0 } def isOcc = (settings["z${roomId}OccSwitch"]?.currentValue("switch") == "on") def isNight = (settings["z${roomId}NightSwitch"]?.currentValue("switch") == "on") if (isOcc && state."pendingPreStage_z${roomId}") { state."pendingPreStage_z${roomId}" = false logDebug("Pre-staging goal met (Occupancy detected). Resuming normal control.") } // Apply Global Overrides to target mode early def targetMode = "OFF" if (isForceOff) { targetMode = "OFF" } else if (isRestricted) { // Paused. Let the room float manually, skip all evaluations. updateRoomStats(roomId, (cMode != "OFF" && cMode != "FAILSAFE OFF" && !cMode.contains("LOCKOUT") && !cMode.contains("DELAY")), (secDehumDev?.currentValue("switch") == "on")) return } else { targetMode = determineTargetMode(roomId, temp, humidity, isOcc, isNight, outTemp) } def currentMode = state."currentMode_z${roomId}" ?: "OFF" // 1. Mildew Intercept Check if (!isForceOff && settings["z${roomId}MildewPrevent"] && currentMode.contains("COOL") && !currentMode.contains("DRY") && currentMode != targetMode && (targetMode == "OFF" || targetMode == "UNOCC COOL" || targetMode == "UNOCC HEAT")) { def dryCode = settings["z${roomId}CodeDry"] def broadlinkDev = settings["z${roomId}Broadlink"] if (dryCode && broadlinkDev) { logAction("${settings["z${roomId}Name"]}: Mildew Prevention Intercept! Running FAN/DRY for 30 minutes before switching down.") state."currentMode_z${roomId}" = "MILDEW PREVENTION" state."modeStartTime_z${roomId}" = now() executeRFCommand(roomId, "MILDEW PREVENTION", true) runIn(1800, "finishMildewCycle", [data: [room: roomId, nextMode: targetMode]]) return } } if (currentMode == "MILDEW PREVENTION") { if (isForceOff || targetMode == "TURBO COOL" || targetMode == "TURBO HEAT" || targetMode == "FAILSAFE OFF" || targetMode == "OCC COOL" || targetMode == "OCC HEAT") { logAction("${settings["z${roomId}Name"]}: Priority climate demand detected. Canceling Mildew cycle.") unschedule("finishMildewCycle") } else return } // 2. BMS GUARDS: Rest Timer & Changeover (Skip guards if Force Off) if (!isForceOff && targetMode != "OFF" && targetMode != "FAILSAFE OFF" && targetMode != "AUX HEAT") { // A. Anti-Short Cycle def restMins = settings["z${roomId}CompressorRest"] ?: 3 def lastOff = state."lastOffTime_z${roomId}" ?: 0 if (currentMode == "OFF" || currentMode == "FAILSAFE OFF") { def elapsedMins = (now() - lastOff) / 60000 if (elapsedMins < restMins) { if (currentMode != "BMS COMPRESSOR LOCKOUT") { logAction("${settings["z${roomId}Name"]}: BMS Guard - Compressor Rest Delay active. Delaying ON command for ${restMins - Math.floor(elapsedMins).toInteger()} minutes.") state."currentMode_z${roomId}" = "BMS COMPRESSOR LOCKOUT" state."compressorLockoutTime_z${roomId}" = lastOff + (restMins * 60000) } runIn(60, "evaluateAllRooms") return } } // B. Changeover Delay def changeDelay = settings["z${roomId}ChangeoverDelay"] ?: 60 def lastC = state."lastCoolTime_z${roomId}" ?: 0 def lastH = state."lastHeatTime_z${roomId}" ?: 0 def wantsCool = targetMode.contains("COOL") || targetMode.contains("DRY") def wantsHeat = targetMode.contains("HEAT") if (wantsCool && lastH > 0 && ((now() - lastH) / 60000) < changeDelay) { if (currentMode != "CHANGEOVER DELAY") { logAction("${settings["z${roomId}Name"]}: BMS Guard - Auto-Changeover Delay active. Preventing switch from Heat to Cool.") state."currentMode_z${roomId}" = "CHANGEOVER DELAY" executeRFCommand(roomId, "OFF", true) } runIn(60, "evaluateAllRooms") return } if (wantsHeat && lastC > 0 && ((now() - lastC) / 60000) < changeDelay) { if (currentMode != "CHANGEOVER DELAY") { logAction("${settings["z${roomId}Name"]}: BMS Guard - Auto-Changeover Delay active. Preventing switch from Cool to Heat.") state."currentMode_z${roomId}" = "CHANGEOVER DELAY" executeRFCommand(roomId, "OFF", true) } runIn(60, "evaluateAllRooms") return } } updateRoomStats(roomId, (currentMode != "OFF" && currentMode != "FAILSAFE OFF" && !currentMode.contains("LOCKOUT") && !currentMode.contains("DELAY")), (secDehumDev?.currentValue("switch") == "on")) if (currentMode != targetMode) { def zName = settings["z${roomId}Name"] ?: "Room ${roomId}" if (!isForceOff) logAction("${zName}: Mode changing from ${currentMode} to ${targetMode}.") if (targetMode == "OFF" || targetMode == "FAILSAFE OFF") state."lastOffTime_z${roomId}" = now() if (targetMode.contains("COOL") || targetMode.contains("DRY")) state."lastCoolTime_z${roomId}" = now() if (targetMode.contains("HEAT")) state."lastHeatTime_z${roomId}" = now() state."currentMode_z${roomId}" = targetMode state."modeStartTime_z${roomId}" = now() executeRFCommand(roomId, targetMode, true) // Force true on change } else { if (targetMode.contains("COOL") || targetMode.contains("DRY")) state."lastCoolTime_z${roomId}" = now() if (targetMode.contains("HEAT")) state."lastHeatTime_z${roomId}" = now() // 3. BMS State Enforcement (Blind Sync) def enforceMins = settings["z${roomId}EnforceState"] != null ? settings["z${roomId}EnforceState"] : 30 if (enforceMins > 0 && targetMode != "BMS COMPRESSOR LOCKOUT" && targetMode != "CHANGEOVER DELAY") { def lastSent = state."lastRfSentTime_z${roomId}" ?: 0 if (((now() - lastSent) / 60000) >= enforceMins) { logAction("${settings["z${roomId}Name"]}: BMS Guard - State Enforcement active. Re-transmitting ${targetMode} code.") executeRFCommand(roomId, targetMode, true) } } } } def finishMildewCycle(data) { def roomId = data.room def nextMode = data.nextMode logAction("${settings["z${roomId}Name"]}: Mildew Prevention complete. Resuming schedule (${nextMode}).") state."currentMode_z${roomId}" = nextMode state."modeStartTime_z${roomId}" = now() state."lastOffTime_z${roomId}" = now() executeRFCommand(roomId, nextMode, true) } def determineTargetMode(roomId, temp, humidity, isOcc, isNight, outTemp) { def contacts = settings["z${roomId}Contacts"] if (contacts && contacts.any { it.currentValue("contact") == "open" }) { def anyOpen = contacts.find { it.currentValue("contact") == "open" } def openTime = anyOpen.events(max: 1)[0]?.date?.time ?: now() def timeoutMs = (settings["z${roomId}ContactTimeout"] ?: 5) * 60000 if ((now() - openTime) >= timeoutMs) return "FAILSAFE OFF" } def turboH = settings["z${roomId}TurboHigh"] ?: 82 def turboL = settings["z${roomId}TurboLow"] ?: 58 def occC = settings["z${roomId}OccCool"] ?: 74 def occH = settings["z${roomId}OccHeat"] ?: 68 def nightC = settings["z${roomId}NightCool"] ?: 70 def nightH = settings["z${roomId}NightHeat"] ?: 65 def unoccC = settings["z${roomId}UnoccCool"] ?: 78 def unoccH = settings["z${roomId}UnoccHeat"] ?: 62 def deadband = settings["z${roomId}Deadband"] ?: 2.0 def humTarget = settings["z${roomId}HumidityTarget"] ?: 60 // Apply Peak Load Shedding Demand Offsets if (checkPeakShedding()) { def pOff = settings["peakOffset"] ?: 3 occC += pOff; unoccC += pOff; nightC += pOff; occH -= pOff; unoccH -= pOff; nightH -= pOff; } def lockoutTemp = settings["z${roomId}LockoutTemp"] ?: 15 def hasAuxHeater = settings["z${roomId}AuxHeater"] != null def currentMode = state."currentMode_z${roomId}" ?: "OFF" if (state."pendingPreStage_z${roomId}") isOcc = true // 1. Extreme Limits if (temp >= turboH) return "TURBO COOL" if (currentMode == "TURBO COOL" && temp > (turboH - deadband)) return "TURBO COOL" if (temp <= turboL) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "TURBO HEAT" } if (currentMode == "TURBO HEAT" || currentMode == "AUX HEAT") { if (temp < (turboL + deadband)) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "TURBO HEAT" } } // 2. Humidity Override def activeCoolTarget = isNight ? nightC : (isOcc ? occC : unoccC) def secDehumDev = settings["z${roomId}SecDehum"] def acDelayMins = settings["z${roomId}AcDehumDelay"] ?: 60 def allowAcDry = false if (humidity > humTarget && temp >= activeCoolTarget) { if (secDehumDev && secDehumDev.currentValue("switch") == "on") { def onTime = state."secDehumOnTime_z${roomId}" ?: now() if ((now() - onTime) >= (acDelayMins * 60000)) allowAcDry = true } else if (!secDehumDev) { allowAcDry = true } } else if (currentMode == "DRY MODE" && humidity > (humTarget - 5) && temp >= (activeCoolTarget - deadband)) { allowAcDry = true } if (allowAcDry) return "DRY MODE" // 3. Good Night Priority (Continuous Fan/Operation) if (isNight) { def prefersHeat = (currentMode.contains("HEAT") || currentMode == "AUX HEAT") if (currentMode == "OFF" || currentMode == "FAILSAFE OFF" || currentMode == "STANDBY") { // If turning on from OFF, guess the best mode based on which setpoint is closer if (Math.abs(temp - nightH) < Math.abs(temp - nightC)) prefersHeat = true } if (prefersHeat) { // Keep in Heat mode unless it gets way too hot if (temp >= (nightC + deadband)) return "NIGHT COOL" if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "NIGHT HEAT" } else { // Keep in Cool mode unless it gets way too cold if (temp <= (nightH - deadband)) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "NIGHT HEAT" } return "NIGHT COOL" } } // 4. Standard Occupied if (isOcc) { if (temp >= (occC + deadband)) return "OCC COOL" if (currentMode == "OCC COOL" && temp > (occC - deadband)) return "OCC COOL" if (temp <= (occH - deadband)) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "OCC HEAT" } if (currentMode == "OCC HEAT" || currentMode == "AUX HEAT") { if (temp < (occH + deadband)) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "OCC HEAT" } } return "OFF" } // 5. Unoccupied Float if (!isOcc && !isNight) { if (temp >= (unoccC + deadband)) return "UNOCC COOL" if (currentMode == "UNOCC COOL" && temp > (unoccC - deadband)) return "UNOCC COOL" if (temp <= (unoccH - deadband)) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "UNOCC HEAT" } if (currentMode == "UNOCC HEAT" || currentMode == "AUX HEAT") { if (temp < (unoccH + deadband)) { if (outTemp != null && outTemp <= lockoutTemp && hasAuxHeater) return "AUX HEAT" return "UNOCC HEAT" } } return "OFF" } return "OFF" } def executeRFCommand(roomId, targetMode, forceAction = false) { def broadlinkDev = settings["z${roomId}Broadlink"] def auxHeater = settings["z${roomId}AuxHeater"] if (targetMode == "AUX HEAT") { if (auxHeater) safeOn(auxHeater) if (broadlinkDev && forceAction) { state."lastRfSentTime_z${roomId}" = now() broadlinkDev.sendSavedCode(settings["z${roomId}CodeOff"]) } return } else { if (auxHeater) safeOff(auxHeater) } if (!broadlinkDev) return def targetCode = null switch(targetMode) { case "TURBO COOL": targetCode = settings["z${roomId}CodeTurboCool"]; break case "TURBO HEAT": targetCode = settings["z${roomId}CodeTurboHeat"]; break case "OCC COOL": targetCode = settings["z${roomId}CodeOccCool"]; break case "OCC HEAT": targetCode = settings["z${roomId}CodeOccHeat"]; break case "NIGHT COOL": targetCode = settings["z${roomId}CodeNightCool"] ?: settings["z${roomId}CodeOccCool"]; break case "NIGHT HEAT": targetCode = settings["z${roomId}CodeNightHeat"] ?: settings["z${roomId}CodeOccHeat"]; break case "UNOCC COOL": targetCode = settings["z${roomId}CodeUnoccCool"]; break case "UNOCC HEAT": targetCode = settings["z${roomId}CodeUnoccHeat"]; break case "DRY MODE": case "MILDEW PREVENTION": targetCode = settings["z${roomId}CodeDry"]; break case "FAILSAFE OFF": case "OFF": targetCode = settings["z${roomId}CodeOff"]; break } if (forceAction) { state."lastRfSentTime_z${roomId}" = now() if (targetCode) { try { broadlinkDev.sendSavedCode(targetCode) logDebug("Sent RF code '${targetCode}' to ${broadlinkDev.displayName} for ${targetMode}") } catch (e) { log.error "RF Error Room ${roomId}: ${e.message}" } } } } // === BUTTON HANDLER === def appButtonHandler(btn) { if (btn == "refreshDashboardBtn") { // Automatically reloads the page, refreshing the dash stats return } if (btn.startsWith("testBtn_")) { def parts = btn.split("_") if (parts.size() == 3) { def roomId = parts[1] def codeSuffix = parts[2] def codeSettingName = "z${roomId}${codeSuffix}" def targetCode = settings[codeSettingName] def broadlinkDev = settings["z${roomId}Broadlink"] if (broadlinkDev && targetCode) { logInfo("TEST COMMAND: Sending ${codeSuffix} to ${broadlinkDev.displayName} (Room ${roomId})") try { broadlinkDev.sendSavedCode(targetCode) } catch (e) { log.error "Test RF Error Room ${roomId}: ${e.message}" } } else { log.warn("TEST COMMAND FAILED: Missing Broadlink device or saved code for ${codeSuffix} in Room ${roomId}.") } } } } // === HARDWARE SAFE WRAPPERS === def safeOn(dev) { if (dev && dev.currentValue("switch") != "on") { try { dev.on() } catch (e) {} } } def safeOff(dev) { if (dev && dev.currentValue("switch") != "off") { try { dev.off() } catch (e) {} } } // === STATS, NOTIFICATIONS & TRACKING === def getTodayDateString() { def df = new java.text.SimpleDateFormat("yyyy-MM-dd") df.setTimeZone(location.timeZone) return df.format(new Date()) } def updateRoomStats(roomId, isRunning, isSecRunning) { def lastUpdate = state."lastStatUpdate_z${roomId}" ?: now() def nowMs = now() def deltaSecs = ((nowMs - lastUpdate) / 1000).toLong() if (deltaSecs > 300 || deltaSecs < 0) deltaSecs = 0 def today = getTodayDateString() def stats = state."dailyStats_z${roomId}" ?: [:] def daily = stats[today] ?: [runtime: 0, secRuntime: 0] if (isRunning) daily.runtime += deltaSecs if (isSecRunning) daily.secRuntime += deltaSecs stats[today] = daily def keysToRemove = [] def cal = Calendar.getInstance(location.timeZone) cal.add(Calendar.DAY_OF_YEAR, -7) def sevenDaysAgoStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(cal.getTime()) stats.each { k, v -> if (k < sevenDaysAgoStr) keysToRemove << k } keysToRemove.each { stats.remove(it) } state."dailyStats_z${roomId}" = stats state."lastStatUpdate_z${roomId}" = nowMs // Process Notifications if (isRunning) state."lifetimeRuntimeSecs_z${roomId}" = (state."lifetimeRuntimeSecs_z${roomId}" ?: 0) + deltaSecs def runtimeHrs = Math.floor((state."lifetimeRuntimeSecs_z${roomId}" ?: 0) / 3600).toInteger() def currentMode = state."currentMode_z${roomId}" ?: "OFF" def deltaT = state."deltaT_z${roomId}" ?: 0.0 if (notificationDevice) { if (runtimeHrs >= 250 && !state."filterNotified_z${roomId}") { notificationDevice.deviceNotification("BMS Alert: ${settings["z${roomId}Name"]} Filter has reached 250 hours. Please clean it.") state."filterNotified_z${roomId}" = true } if (isRunning && !currentMode.contains("DRY") && !currentMode.contains("FAN") && !currentMode.contains("AUX")) { if (Math.abs(deltaT) < 12.0 && (nowMs - (state."modeStartTime_z${roomId}" ?: 0) > 900000)) { if (!state."deltaTNotified_z${roomId}") { notificationDevice.deviceNotification("BMS Critical: ${settings["z${roomId}Name"]} Delta T is severely low (${String.format('%.1f', deltaT)}°). Check for frozen coils, dirty filter, or low refrigerant.") state."deltaTNotified_z${roomId}" = true } } } else { state."deltaTNotified_z${roomId}" = false // Reset flag if it turns off or corrects itself } } } def logAction(msg) { if(txtEnable) log.info "${app.label}: ${msg}" def h = state.actionHistory ?: [] h.add(0, "[${new Date().format("MM/dd hh:mm a", location.timeZone)}] ${msg}") if(h.size() > 30) h = h[0..29] state.actionHistory = h } def logInfo(msg) { if(txtEnable) log.info "${app.label}: ${msg}" } def logDebug(msg) { if (debugEnable) log.debug "${app.label}: ${msg}" } def disableDebugLog() { app.updateSetting("debugEnable", [value: "false", type: "bool"]) }