/** * Advanced Energy Management Controller */ definition( name: "Advanced Energy Management Controller", namespace: "ShaneAllen", author: "ShaneAllen", description: "An eye-opening energy dashboard that tracks cost, detects power spikes, and warns of creeping averages indicating hardware failure.", category: "Green Living", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") page(name: "clearDataPage") page(name: "maintenancePage") page(name: "doResetPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Energy Management Core", install: true, uninstall: true) { section("Dashboard Actions") { href(name: "refreshPage", title: "🔄 Refresh Data", page: "mainPage") href(name: "maintenancePage", title: "🛠️ Hardware Maintenance Reset", description: "Clear health warnings after servicing an appliance.", page: "maintenancePage") href(name: "clearDataPage", title: "🗑️ Clear All Data", description: "Reset all history, baselines, and counters.", page: "clearDataPage") } section("Live Financial & Health Dashboard") { def kwhRate = settings["costPerKwh"]?.toString()?.toDouble() ?: 0.13 def statusText = "Appliance Status & Analytics
" statusText += "" def appliances = ["refrigerator": "Refrigerator", "chestFreezer": "Chest Freezer", "hotWaterHeater": "Hot Water Heater", "washerDryer": "Washer/Dryer", "dishwasher": "Dishwasher", "microwave": "Microwave"] appliances.each { key, name -> def sw = settings["${key}Switch"] def pMeter = settings["${key}Power"] def eMeter = settings["${key}Energy"] if (pMeter && eMeter) { def currentPower = pMeter.currentValue('power')?.toString()?.toDouble() ?: 0.0 def currentEnergy = eMeter.currentValue('energy')?.toString()?.toDouble() ?: 0.0 // Switch State & Context def switchState = sw ? (sw.currentValue("switch") == "on" ? "ON" : "OFF") : "N/A" def contextMsg = state["${key}_context"] ?: "Normal" def stateDisplay = "${switchState}
${contextMsg}" // Cost Math def baselineEnergy = state["${key}_startEnergy"] ?: currentEnergy def usedKwh = currentEnergy - baselineEnergy if (usedKwh < 0) usedKwh = 0 def estCost = usedKwh * kwhRate def costStr = String.format("\$%.2f", estCost) // Health Check & Compressor Status def health = "GOOD" def struggle = state["${key}_struggleCount"] ?: 0 if (state["${key}_creepWarning"]) health = "CREEPING WATTS" if (struggle >= 3 && struggle < 6) health = "CLEAN COILS" if (struggle >= 6) health = "STRUGGLING" if (state["${key}_spikeWarning"]) health = "SPIKE DETECTED" if (state["${key}_tempWarningActive"]) health = "HIGH TEMP ALERT" if (state["${key}_tempCreepWarning"]) health = "TEMP CREEPING" def powerColor = currentPower > 10 ? "blue" : "black" statusText += "" } } statusText += "
ApplianceState (Reason)Current Power7-Day CostHealth Status
${name}${stateDisplay}${currentPower} W${costStr}${health}
" // Active Cycles & Usage (7-Day) statusText += "Active Cycles & Usage Stats
" statusText += "" def cycleAppliances = ["hotWaterHeater": "Hot Water Heater", "washerDryer": "Washer/Dryer", "dishwasher": "Dishwasher", "microwave": "Microwave"] cycleAppliances.each { key, name -> def isRunning = state["${key}_isRunning"] ? "RUNNING" : "IDLE" if (state["${key}_startPending"]) isRunning = "STARTING..." if (state["${key}_stopPending"]) isRunning = "PAUSED" def lastRunStr = "N/A" if (state["${key}_isRunning"] && state["${key}_cycleStartTime"]) { def currentRunMs = now() - state["${key}_cycleStartTime"] def currentRunMins = Math.round(currentRunMs / 60000.0) lastRunStr = "Running (${currentRunMins} min)" } else if (state["${key}_lastRunLengthMins"]) { lastRunStr = "${Math.round(state["${key}_lastRunLengthMins"])} min" } def runCount = state["${key}_7DayRunCount"] ?: 0 statusText += "" } statusText += "
ApplianceStateLast Run7-Day Runs
${name}${isRunning}${lastRunStr}${runCount}
" // Compressor Cycle Stats (7-Day & Today) statusText += "Compressor Cycle Stats
" statusText += "" def compressorAppliances = ["refrigerator": "Refrigerator", "chestFreezer": "Chest Freezer"] compressorAppliances.each { key, name -> def isRunning = state["${key}_isRunning"] ? "COOLING" : "IDLE" if (state["${key}_startPending"]) isRunning = "STARTING..." if (state["${key}_stopPending"]) isRunning = "COOLING" def runCount7D = state["${key}_7DayRunCount"] ?: 0 def runCountToday = state["${key}_todayRunCount"] ?: 0 def totalMins = state["${key}_7DayTotalCycleMins"] ?: 0.0 def avgCycleStr = runCount7D > 0 ? "${Math.round(totalMins / runCount7D)} min" : "N/A" def rTempAvg = state["${key}_dailyAvgRoomTemp"] def oTempMax = state["${key}_dailyMaxOutsideTemp"] def roomStr = (rTempAvg && rTempAvg != 0.0) ? "${Math.round(rTempAvg?.toString()?.toDouble())}°" : "--" def outStr = (oTempMax && oTempMax != -100.0) ? "${Math.round(oTempMax?.toString()?.toDouble())}°" : "--" statusText += "" } statusText += "
ApplianceStateAvg CycleRuns (Today/7D)Temps (Room Avg / Out Max)
${name}${isRunning}${avgCycleStr}${runCountToday} / ${runCount7D}${roomStr} / ${outStr}
" // ROI Table for Scheduled Savings & Maintenance statusText += "Financial Savings (ROI)
" statusText += "" def totalSavings = 0.0 appliances.each { key, name -> def schSavings = state["${key}_roiSavings"] ?: 0.0 def maintSavings = state["${key}_maintRoiSavings"] ?: 0.0 def appTotal = schSavings + maintSavings totalSavings += appTotal statusText += "" } statusText += "" statusText += "
ApplianceSch. SavingsMaint. SavingsTotal (7D)
${name}\$${String.format("%.2f", schSavings)}\$${String.format("%.2f", maintSavings)}\$${String.format("%.2f", appTotal)}
Total Saved\$${String.format("%.2f", totalSavings)}
" paragraph statusText } section("Global Core Settings") { input "costPerKwh", "decimal", title: "Electricity Cost (\$ per kWh)", required: true, defaultValue: 0.13, description: "Your default rate is \$0.13." input "enableMaintenanceRoi", "bool", title: "Track ROI for Maintenance (Coil Cleaning, etc.)?", defaultValue: true, required: false, description: "Calculates savings when an appliance runs more efficiently after a health reset." } section("Appliance Scheduling & Shutdown") { input "scheduleStart", "time", title: "Time to turn appliances OFF", required: false input "scheduleEnd", "time", title: "Time to turn appliances ON", required: false input "safeShutdownThreshold", "number", title: "Safe Shutdown Power Threshold (Watts)", defaultValue: 15, required: true, description: "Appliance stays on if drawing more than this." paragraph "Note: Refrigerator and Chest Freezer are permanently excluded from time-based schedule shutdowns." } section("Manual Override Button") { input "overrideButton", "capability.pushableButton", title: "Override Button(s)", required: false, multiple: true input "legacyOverrideButton", "capability.button", title: "Legacy Button Devices (Use this if your button didn't show in the list above)", required: false, multiple: true input "buttonNumber", "text", title: "Button Number(s) (Comma separated, e.g., 1, 2)", defaultValue: "1", required: false, description: "Leave blank to allow all buttons on the device." input "buttonAction", "enum", title: "Button Action(s)", options: ["pushed", "held", "doubleTapped", "released", "tapped", "multiTapped"], defaultValue: "pushed", required: false, multiple: true input "overrideModes", "mode", title: "Only allow override in specific modes?", required: false, multiple: true } section("Refrigerator", hideable: true, hidden: true) { input "refrigeratorSwitch", "capability.switch", title: "Appliance Switch", required: false input "refrigeratorPower", "capability.powerMeter", title: "Power Meter (Watts)", required: false input "refrigeratorEnergy", "capability.energyMeter", title: "Energy Meter (kWh)", required: false input "refrigeratorTemp", "capability.temperatureMeasurement", title: "Internal Temperature Sensor", required: false input "refrigeratorRoomTemp", "capability.temperatureMeasurement", title: "Room Temperature Sensor", required: false input "refrigeratorOutsideTemp", "capability.temperatureMeasurement", title: "Outside Air Temperature Sensor", required: false input "refrigeratorTempThreshold", "number", title: "Max Internal Temperature Alert Threshold (°F/°C)", defaultValue: 42, required: false input "refrigeratorSpike", "number", title: "Spike Warning Threshold (Watts)", defaultValue: 800, required: false input "refrigeratorRunWatts", "number", title: "Compressor Running Threshold (Watts)", defaultValue: 80, required: false, description: "Watts required to consider the compressor 'running'." input "refrigeratorStartDelay", "number", title: "Cycle Start Delay (Minutes)", defaultValue: 1, required: false, description: "Wait this long above the running threshold before logging a cycle." input "refrigeratorDebounce", "number", title: "Cycle Pause/Debounce Time (Minutes)", defaultValue: 5, required: false, description: "Wait this long after power drops before declaring cooling cycle complete." input "refrigeratorMaintenanceHours", "number", title: "Maintenance Alert Interval (Total Run Hours)", defaultValue: 2000, required: false paragraph "Active Protection: If this switch is ever turned OFF manually or by another app, it will be instantly forced back ON. (Mode-based shutdown has been disabled for safety)." paragraph "Alert Gatekeeping (Time & Modes)" input "refrigeratorAlertModes", "mode", title: "Only send alerts during these modes? (Leave blank for all)", required: false, multiple: true input "refrigeratorAlertStartTime", "time", title: "Only send alerts after this time?", required: false input "refrigeratorAlertEndTime", "time", title: "Only send alerts before this time?", required: false paragraph "Granular Device Alerts" input "refrigeratorPushNotification", "capability.notification", title: "Push Notification Device", required: false, multiple: true input "refrigeratorPushEvents", "enum", title: "Send these events to Push:", options: ["temp": "Temperature Alerts", "spike": "Power Spikes", "health": "Health & Maintenance", "protection": "Protection Force-ON"], multiple: true, required: false input "refrigeratorTtsDevice", "capability.speechSynthesis", title: "Sonos / TTS Device", required: false, multiple: true input "refrigeratorTtsEvents", "enum", title: "Send these events to TTS:", options: ["temp": "Temperature Alerts", "spike": "Power Spikes", "health": "Health & Maintenance", "protection": "Protection Force-ON"], multiple: true, required: false input "refrigeratorAudioDevice", "capability.audioNotification", title: "Zooz / Audio Siren Device", required: false, multiple: true input "refrigeratorAudioEvents", "enum", title: "Play track for these events:", options: ["temp": "Temperature Alerts", "spike": "Power Spikes", "health": "Health & Maintenance", "protection": "Protection Force-ON"], multiple: true, required: false input "refrigeratorAudioTrack", "number", title: "Audio Track Number (For Sirens/Chimes)", defaultValue: 1, required: false } section("Chest Freezer", hideable: true, hidden: true) { input "chestFreezerSwitch", "capability.switch", title: "Appliance Switch", required: false input "chestFreezerPower", "capability.powerMeter", title: "Power Meter (Watts)", required: false input "chestFreezerEnergy", "capability.energyMeter", title: "Energy Meter (kWh)", required: false input "chestFreezerTemp", "capability.temperatureMeasurement", title: "Internal Temperature Sensor", required: false input "chestFreezerRoomTemp", "capability.temperatureMeasurement", title: "Room Temperature Sensor", required: false input "chestFreezerOutsideTemp", "capability.temperatureMeasurement", title: "Outside Air Temperature Sensor", required: false input "chestFreezerTempThreshold", "number", title: "Max Internal Temperature Alert Threshold (°F/°C)", defaultValue: 15, required: false input "chestFreezerSpike", "number", title: "Spike Warning Threshold (Watts)", defaultValue: 800, required: false input "chestFreezerRunWatts", "number", title: "Compressor Running Threshold (Watts)", defaultValue: 80, required: false, description: "Watts required to consider the compressor 'running'." input "chestFreezerStartDelay", "number", title: "Cycle Start Delay (Minutes)", defaultValue: 1, required: false, description: "Wait this long above the running threshold before logging a cycle." input "chestFreezerDebounce", "number", title: "Cycle Pause/Debounce Time (Minutes)", defaultValue: 5, required: false, description: "Wait this long after power drops before declaring cooling cycle complete." input "chestFreezerMaintenanceHours", "number", title: "Maintenance Alert Interval (Total Run Hours)", defaultValue: 2000, required: false paragraph "Active Protection: If this switch is ever turned OFF manually or by another app, it will be instantly forced back ON. (Mode-based shutdown has been disabled for safety)." paragraph "Alert Gatekeeping (Time & Modes)" input "chestFreezerAlertModes", "mode", title: "Only send alerts during these modes? (Leave blank for all)", required: false, multiple: true input "chestFreezerAlertStartTime", "time", title: "Only send alerts after this time?", required: false input "chestFreezerAlertEndTime", "time", title: "Only send alerts before this time?", required: false paragraph "Granular Device Alerts" input "chestFreezerPushNotification", "capability.notification", title: "Push Notification Device", required: false, multiple: true input "chestFreezerPushEvents", "enum", title: "Send these events to Push:", options: ["temp": "Temperature Alerts", "spike": "Power Spikes", "health": "Health & Maintenance", "protection": "Protection Force-ON"], multiple: true, required: false input "chestFreezerTtsDevice", "capability.speechSynthesis", title: "Sonos / TTS Device", required: false, multiple: true input "chestFreezerTtsEvents", "enum", title: "Send these events to TTS:", options: ["temp": "Temperature Alerts", "spike": "Power Spikes", "health": "Health & Maintenance", "protection": "Protection Force-ON"], multiple: true, required: false input "chestFreezerAudioDevice", "capability.audioNotification", title: "Zooz / Audio Siren Device", required: false, multiple: true input "chestFreezerAudioEvents", "enum", title: "Play track for these events:", options: ["temp": "Temperature Alerts", "spike": "Power Spikes", "health": "Health & Maintenance", "protection": "Protection Force-ON"], multiple: true, required: false input "chestFreezerAudioTrack", "number", title: "Audio Track Number (For Sirens/Chimes)", defaultValue: 1, required: false } section("Hot Water Heater", hideable: true, hidden: true) { input "hotWaterHeaterSwitch", "capability.switch", title: "Appliance Switch", required: false input "hotWaterHeaterPower", "capability.powerMeter", title: "Power Meter (Watts)", required: false input "hotWaterHeaterEnergy", "capability.energyMeter", title: "Energy Meter (kWh)", required: false input "hotWaterHeaterSpike", "number", title: "Spike Warning Threshold (Watts)", defaultValue: 6000, required: false input "hotWaterHeaterRunWatts", "number", title: "Heating Threshold (Watts)", defaultValue: 1000, required: false input "hotWaterHeaterStartDelay", "number", title: "Cycle Start Delay (Minutes)", defaultValue: 1, required: false, description: "Wait this long above the running threshold before logging a cycle." input "hotWaterHeaterDebounce", "number", title: "Heating Pause/Debounce Time (Minutes)", defaultValue: 5, required: false, description: "Wait this long after power drops before declaring cycle complete." paragraph "Mode-Based Power Control" input "hotWaterHeaterTurnOffModes", "mode", title: "Turn OFF switch when entering these modes:", required: false, multiple: true input "hotWaterHeaterTurnOnModes", "mode", title: "Turn ON switch when entering these modes:", required: false, multiple: true paragraph "⚠️ Cool Down / Dry Out Protection\nHigh-heat or water-based appliances need time after running to dissipate heat and run internal moisture-reduction fans. Cutting power immediately after a cycle can cause mold growth or overheat internal components. Adjust this duration carefully." input "hotWaterHeaterEnableCoolDown", "bool", title: "Enable Post-Cycle Cool Down/Dry Out?", defaultValue: true, required: false input "hotWaterHeaterCoolDownMins", "number", title: "Cool Down Duration (Minutes)", defaultValue: 120, required: false paragraph "Auto-Off Settings" input "hotWaterHeaterAutoOff", "bool", title: "Turn OFF switch automatically when heating cycle finishes?", defaultValue: false, required: false input "hotWaterHeaterAutoOffModes", "mode", title: "Only Auto-Off during these modes? (Leave blank for all)", required: false, multiple: true paragraph "Alert Gatekeeping (Time & Modes)" input "hotWaterHeaterAlertModes", "mode", title: "Only send alerts during these modes? (Leave blank for all)", required: false, multiple: true input "hotWaterHeaterAlertStartTime", "time", title: "Only send alerts after this time?", required: false input "hotWaterHeaterAlertEndTime", "time", title: "Only send alerts before this time?", required: false paragraph "Granular Device Alerts" input "hotWaterHeaterPushNotification", "capability.notification", title: "Push Notification Device", required: false, multiple: true input "hotWaterHeaterPushEvents", "enum", title: "Send these events to Push:", options: ["cycle": "Heating Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "hotWaterHeaterTtsDevice", "capability.speechSynthesis", title: "Sonos / TTS Device", required: false, multiple: true input "hotWaterHeaterTtsEvents", "enum", title: "Send these events to TTS:", options: ["cycle": "Heating Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "hotWaterHeaterAudioDevice", "capability.audioNotification", title: "Zooz / Audio Siren Device", required: false, multiple: true input "hotWaterHeaterAudioEvents", "enum", title: "Play track for these events:", options: ["cycle": "Heating Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "hotWaterHeaterAudioTrack", "number", title: "Audio Track Number (For Sirens/Chimes)", defaultValue: 1, required: false } section("Washer / Dryer Combo", hideable: true, hidden: true) { input "washerDryerSwitch", "capability.switch", title: "Appliance Switch", required: false input "washerDryerPower", "capability.powerMeter", title: "Power Meter (Watts)", required: false input "washerDryerEnergy", "capability.energyMeter", title: "Energy Meter (kWh)", required: false input "washerDryerSpike", "number", title: "Spike Warning Threshold (Watts)", defaultValue: 3000, required: false input "washerDryerRunWatts", "number", title: "Cycle Running Threshold (Watts)", defaultValue: 20, required: false input "washerDryerStartDelay", "number", title: "Cycle Start Delay (Minutes)", defaultValue: 1, required: false, description: "Wait this long above the running threshold before logging a cycle." input "washerDryerDebounce", "number", title: "Cycle Pause/Debounce Time (Minutes)", defaultValue: 15, required: false, description: "Wait this long after power drops before declaring cycle complete." paragraph "Mode-Based Power Control" input "washerDryerTurnOffModes", "mode", title: "Turn OFF switch when entering these modes:", required: false, multiple: true input "washerDryerTurnOnModes", "mode", title: "Turn ON switch when entering these modes:", required: false, multiple: true paragraph "⚠️ Cool Down / Dry Out Protection\nHigh-heat or water-based appliances need time after running to dissipate heat and run internal moisture-reduction fans. Cutting power immediately after a cycle can cause mold growth or overheat internal components. Adjust this duration carefully." input "washerDryerEnableCoolDown", "bool", title: "Enable Post-Cycle Cool Down/Dry Out?", defaultValue: true, required: false input "washerDryerCoolDownMins", "number", title: "Cool Down Duration (Minutes)", defaultValue: 120, required: false paragraph "Auto-Off Settings" input "washerDryerAutoOff", "bool", title: "Turn OFF switch automatically when cycle finishes?", defaultValue: false, required: false input "washerDryerAutoOffModes", "mode", title: "Only Auto-Off during these modes? (Leave blank for all)", required: false, multiple: true paragraph "Alert Gatekeeping (Time & Modes)" input "washerDryerAlertModes", "mode", title: "Only send alerts during these modes? (Leave blank for all)", required: false, multiple: true input "washerDryerAlertStartTime", "time", title: "Only send alerts after this time?", required: false input "washerDryerAlertEndTime", "time", title: "Only send alerts before this time?", required: false paragraph "Granular Device Alerts" input "washerDryerPushNotification", "capability.notification", title: "Push Notification Device", required: false, multiple: true input "washerDryerPushEvents", "enum", title: "Send these events to Push:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "washerDryerTtsDevice", "capability.speechSynthesis", title: "Sonos / TTS Device", required: false, multiple: true input "washerDryerTtsEvents", "enum", title: "Send these events to TTS:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "washerDryerAudioDevice", "capability.audioNotification", title: "Zooz / Audio Siren Device", required: false, multiple: true input "washerDryerAudioEvents", "enum", title: "Play track for these events:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "washerDryerAudioTrack", "number", title: "Audio Track Number (For Sirens/Chimes)", defaultValue: 1, required: false } section("Dishwasher", hideable: true, hidden: true) { input "dishwasherSwitch", "capability.switch", title: "Appliance Switch", required: false input "dishwasherPower", "capability.powerMeter", title: "Power Meter (Watts)", required: false input "dishwasherEnergy", "capability.energyMeter", title: "Energy Meter (kWh)", required: false input "dishwasherSpike", "number", title: "Spike Warning Threshold (Watts)", defaultValue: 1800, required: false input "dishwasherRunWatts", "number", title: "Cycle Running Threshold (Watts)", defaultValue: 15, required: false input "dishwasherStartDelay", "number", title: "Cycle Start Delay (Minutes)", defaultValue: 1, required: false, description: "Wait this long above the running threshold before logging a cycle." input "dishwasherDebounce", "number", title: "Cycle Pause/Debounce Time (Minutes)", defaultValue: 15, required: false, description: "Wait this long after power drops before declaring cycle complete." paragraph "Mode-Based Power Control" input "dishwasherTurnOffModes", "mode", title: "Turn OFF switch when entering these modes:", required: false, multiple: true input "dishwasherTurnOnModes", "mode", title: "Turn ON switch when entering these modes:", required: false, multiple: true paragraph "⚠️ Cool Down / Dry Out Protection\nHigh-heat or water-based appliances need time after running to dissipate heat and run internal moisture-reduction fans. Cutting power immediately after a cycle can cause mold growth or overheat internal components. Adjust this duration carefully." input "dishwasherEnableCoolDown", "bool", title: "Enable Post-Cycle Cool Down/Dry Out?", defaultValue: true, required: false input "dishwasherCoolDownMins", "number", title: "Cool Down Duration (Minutes)", defaultValue: 120, required: false paragraph "Auto-Off Settings" input "dishwasherAutoOff", "bool", title: "Turn OFF switch automatically when cycle finishes?", defaultValue: false, required: false input "dishwasherAutoOffModes", "mode", title: "Only Auto-Off during these modes? (Leave blank for all)", required: false, multiple: true paragraph "Alert Gatekeeping (Time & Modes)" input "dishwasherAlertModes", "mode", title: "Only send alerts during these modes? (Leave blank for all)", required: false, multiple: true input "dishwasherAlertStartTime", "time", title: "Only send alerts after this time?", required: false input "dishwasherAlertEndTime", "time", title: "Only send alerts before this time?", required: false paragraph "Granular Device Alerts" input "dishwasherPushNotification", "capability.notification", title: "Push Notification Device", required: false, multiple: true input "dishwasherPushEvents", "enum", title: "Send these events to Push:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "dishwasherTtsDevice", "capability.speechSynthesis", title: "Sonos / TTS Device", required: false, multiple: true input "dishwasherTtsEvents", "enum", title: "Send these events to TTS:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "dishwasherAudioDevice", "capability.audioNotification", title: "Zooz / Audio Siren Device", required: false, multiple: true input "dishwasherAudioEvents", "enum", title: "Play track for these events:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "dishwasherAudioTrack", "number", title: "Audio Track Number (For Sirens/Chimes)", defaultValue: 1, required: false } section("Microwave", hideable: true, hidden: true) { input "microwaveSwitch", "capability.switch", title: "Appliance Switch", required: false input "microwavePower", "capability.powerMeter", title: "Power Meter (Watts)", required: false input "microwaveEnergy", "capability.energyMeter", title: "Energy Meter (kWh)", required: false input "microwaveSpike", "number", title: "Spike Warning Threshold (Watts)", defaultValue: 2000, required: false input "microwaveRunWatts", "number", title: "Cycle Running Threshold (Watts)", defaultValue: 15, required: false input "microwaveStartDelay", "number", title: "Cycle Start Delay (Minutes)", defaultValue: 1, required: false, description: "Wait this long above the running threshold before logging a cycle." input "microwaveDebounce", "number", title: "Cycle Pause/Debounce Time (Minutes)", defaultValue: 1, required: false, description: "Wait this long after power drops before declaring cycle complete." paragraph "Mode-Based Power Control" input "microwaveTurnOffModes", "mode", title: "Turn OFF switch when entering these modes:", required: false, multiple: true input "microwaveTurnOnModes", "mode", title: "Turn ON switch when entering these modes:", required: false, multiple: true paragraph "⚠️ Cool Down / Dry Out Protection\nHigh-heat or water-based appliances need time after running to dissipate heat and run internal moisture-reduction fans. Cutting power immediately after a cycle can cause mold growth or overheat internal components. Adjust this duration carefully." input "microwaveEnableCoolDown", "bool", title: "Enable Post-Cycle Cool Down/Dry Out?", defaultValue: true, required: false input "microwaveCoolDownMins", "number", title: "Cool Down Duration (Minutes)", defaultValue: 120, required: false paragraph "Auto-Off Settings" input "microwaveAutoOff", "bool", title: "Turn OFF switch automatically when cycle finishes?", defaultValue: false, required: false input "microwaveAutoOffModes", "mode", title: "Only Auto-Off during these modes? (Leave blank for all)", required: false, multiple: true paragraph "Alert Gatekeeping (Time & Modes)" input "microwaveAlertModes", "mode", title: "Only send alerts during these modes? (Leave blank for all)", required: false, multiple: true input "microwaveAlertStartTime", "time", title: "Only send alerts after this time?", required: false input "microwaveAlertEndTime", "time", title: "Only send alerts before this time?", required: false paragraph "Granular Device Alerts" input "microwavePushNotification", "capability.notification", title: "Push Notification Device", required: false, multiple: true input "microwavePushEvents", "enum", title: "Send these events to Push:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "microwaveTtsDevice", "capability.speechSynthesis", title: "Sonos / TTS Device", required: false, multiple: true input "microwaveTtsEvents", "enum", title: "Send these events to TTS:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "microwaveAudioDevice", "capability.audioNotification", title: "Zooz / Audio Siren Device", required: false, multiple: true input "microwaveAudioEvents", "enum", title: "Play track for these events:", options: ["cycle": "Cycle Complete", "spike": "Power Spikes", "health": "Health & Maintenance"], multiple: true, required: false input "microwaveAudioTrack", "number", title: "Audio Track Number (For Sirens/Chimes)", defaultValue: 1, required: false } } } def maintenancePage() { dynamicPage(name: "maintenancePage", title: "Hardware Maintenance", install: false, uninstall: false) { def appliances = ["refrigerator": "Refrigerator", "chestFreezer": "Chest Freezer", "hotWaterHeater": "Hot Water Heater", "washerDryer": "Washer/Dryer", "dishwasher": "Dishwasher", "microwave": "Microwave"] section("Reset Appliance Health") { paragraph "Select an appliance to immediately reset its health warnings (e.g., 'Clean Coils', 'Creeping Watts', 'High Temp'). This will recalculate baselines but will keep your 7-Day run counts and financial savings intact." appliances.each { key, name -> href(name: "reset_${key}", title: "🛠️ Reset ${name}", description: "Clear all health & maintenance warnings for this appliance.", page: "doResetPage", params: [applianceKey: key, applianceName: name]) } } } } def doResetPage(params) { if (params?.applianceKey) { def key = params.applianceKey def name = params.applianceName // Clear Warning States state.remove("${key}_struggleCount") state.remove("${key}_creepWarning") state.remove("${key}_spikeWarning") state.remove("${key}_spikePending") state.remove("${key}_tempWarningActive") state.remove("${key}_tempCreepWarning") state.remove("${key}_totalRunHours") // Capture inefficient state before reset for ROI tracking if (state["${key}_baselineAvg"] && state["${key}_avgPower"]) { def currentAvg = state["${key}_avgPower"].toString().toDouble() def baseline = state["${key}_baselineAvg"].toString().toDouble() if (currentAvg > baseline) { state["${key}_inefficientPowerMark"] = currentAvg } } // Recalibrate Baselines state.remove("${key}_baselineAvg") state.remove("${key}_idlePowerAvg") state.remove("${key}_baselineCycleMins") state.remove("${key}_baselineTemp") state["${key}_context"] = "Maintenance Reset" log.info "Maintenance reset performed for ${name}." dynamicPage(name: "doResetPage", title: "${name} Reset Complete", nextPage: "mainPage") { section() { paragraph "✅ The health and maintenance warnings for your ${name} have been successfully cleared.\n\nThe system has refreshed the baselines and will begin tracking its normal operations anew." } } } else { dynamicPage(name: "doResetPage", title: "Error", nextPage: "mainPage") { section() { paragraph "Appliance not found. Please try again." } } } } def clearDataPage() { clearAllData() dynamicPage(name: "clearDataPage", title: "Data Successfully Cleared", nextPage: "mainPage") { section() { paragraph "All stored baselines, historical run times, health statuses, and ROI savings have been permanently deleted.\n\nThe system has been re-initialized and is pulling fresh starting points for your hardware." } } } def clearAllData() { def appliances = ["refrigerator", "chestFreezer", "hotWaterHeater", "washerDryer", "dishwasher", "microwave"] appliances.each { key -> state.remove("${key}_startEnergy") state.remove("${key}_avgPower") state.remove("${key}_idlePowerAvg") state.remove("${key}_roiSavings") state.remove("${key}_maintRoiSavings") // Cleared new maintenance ROI state.remove("${key}_inefficientPowerMark") // Cleared inefficient mark state.remove("${key}_totalRunHours") state.remove("${key}_struggleCount") state.remove("${key}_7DayRunCount") state.remove("${key}_todayRunCount") state.remove("${key}_7DayTotalCycleMins") state.remove("${key}_lastRunLengthMins") state.remove("${key}_stopPending") state.remove("${key}_startPending") state.remove("${key}_tentativeStartTime") state.remove("${key}_baselineAvg") state.remove("${key}_baselineCycleMins") state.remove("${key}_creepWarning") state.remove("${key}_spikeWarning") state.remove("${key}_spikePending") state.remove("${key}_isRunning") state.remove("${key}_cycleStartTime") state.remove("${key}_cycleEndTime") state.remove("${key}_offTimeStart") state.remove("${key}_context") state.remove("${key}_avgTemp") state.remove("${key}_baselineTemp") state.remove("${key}_tempWarningActive") state.remove("${key}_tempCreepWarning") state.remove("${key}_dailyAvgRoomTemp") state.remove("${key}_dailyMaxOutsideTemp") } log.info "All Energy Management Controller data has been reset by the user." initialize() } def installed() { initialize() } def updated() { unsubscribe(); unschedule(); initialize() } def initialize() { def appliances = ["refrigerator", "chestFreezer", "hotWaterHeater", "washerDryer", "dishwasher", "microwave"] appliances.each { key -> def pMeter = settings["${key}Power"] def eMeter = settings["${key}Energy"] def sw = settings["${key}Switch"] if (!state["${key}_startEnergy"] && eMeter) { state["${key}_startEnergy"] = eMeter.currentValue("energy")?.toString()?.toDouble() ?: 0.0 } if (!state["${key}_avgPower"]) state["${key}_avgPower"] = 0.0 if (!state["${key}_idlePowerAvg"]) state["${key}_idlePowerAvg"] = 0.0 if (!state["${key}_roiSavings"]) state["${key}_roiSavings"] = 0.0 if (!state["${key}_maintRoiSavings"]) state["${key}_maintRoiSavings"] = 0.0 if (!state["${key}_totalRunHours"]) state["${key}_totalRunHours"] = 0.0 if (!state["${key}_struggleCount"]) state["${key}_struggleCount"] = 0 if (!state["${key}_7DayRunCount"]) state["${key}_7DayRunCount"] = 0 if (!state["${key}_todayRunCount"]) state["${key}_todayRunCount"] = 0 if (!state["${key}_7DayTotalCycleMins"]) state["${key}_7DayTotalCycleMins"] = 0.0 if (!state["${key}_lastRunLengthMins"]) state["${key}_lastRunLengthMins"] = 0.0 if (!state["${key}_context"]) state["${key}_context"] = "Normal" if (!state["${key}_cycleEndTime"]) state["${key}_cycleEndTime"] = 0 // Force-fetch current temperatures on startup instead of waiting for events if (state["${key}_dailyAvgRoomTemp"] == null || state["${key}_dailyAvgRoomTemp"] == 0.0) { def rSens = settings["${key}RoomTemp"] state["${key}_dailyAvgRoomTemp"] = rSens ? (rSens.currentValue("temperature")?.toString()?.toDouble() ?: 0.0) : 0.0 } if (state["${key}_dailyMaxOutsideTemp"] == null || state["${key}_dailyMaxOutsideTemp"] == -100.0) { def oSens = settings["${key}OutsideTemp"] state["${key}_dailyMaxOutsideTemp"] = oSens ? (oSens.currentValue("temperature")?.toString()?.toDouble() ?: -100.0) : -100.0 } state["${key}_stopPending"] = false state["${key}_startPending"] = false state["${key}_spikePending"] = false if (pMeter) subscribe(pMeter, "power", powerHandler) if (sw) subscribe(sw, "switch", universalSwitchHandler) } // Always-On Protection for Critical Hardware if (settings["refrigeratorSwitch"]) { subscribe(settings["refrigeratorSwitch"], "switch", alwaysOnProtectionHandler) } if (settings["chestFreezerSwitch"]) { subscribe(settings["chestFreezerSwitch"], "switch", alwaysOnProtectionHandler) } // Internal Temperature Subscriptions if (settings["refrigeratorTemp"]) { subscribe(settings["refrigeratorTemp"], "temperature", tempHandler) } if (settings["chestFreezerTemp"]) { subscribe(settings["chestFreezerTemp"], "temperature", tempHandler) } // External/Environmental Temperature Subscriptions if (settings["refrigeratorRoomTemp"]) subscribe(settings["refrigeratorRoomTemp"], "temperature", roomTempHandler) if (settings["refrigeratorOutsideTemp"]) subscribe(settings["refrigeratorOutsideTemp"], "temperature", outsideTempHandler) if (settings["chestFreezerRoomTemp"]) subscribe(settings["chestFreezerRoomTemp"], "temperature", roomTempHandler) if (settings["chestFreezerOutsideTemp"]) subscribe(settings["chestFreezerOutsideTemp"], "temperature", outsideTempHandler) // Scheduling if (scheduleStart) schedule(scheduleStart, triggerTurnOff) if (scheduleEnd) schedule(scheduleEnd, triggerTurnOn) // Per-Appliance Mode Tracking subscribe(location, "mode", modeChangeHandler) // Multi-Button / Multi-Action Override def allOverrideButtons = [] if (overrideButton) allOverrideButtons += overrideButton if (legacyOverrideButton) allOverrideButtons += legacyOverrideButton if (allOverrideButtons) { def actions = buttonAction ?: ["pushed"] if (!(actions instanceof List)) actions = [actions] actions.each { action -> subscribe(allOverrideButtons, action, buttonHandler) // Support for legacy ST event formats subscribe(allOverrideButtons, "button.${action}", buttonHandler) } } schedule("0 0 0 * * ?", resetDailyCounters) schedule("0 5 0 ? * SUN", resetWeeklyCounters) schedule("0 0 2 * * ?", dailyHealthCheck) } def roomTempHandler(evt) { def deviceId = evt.device.id def currentTemp = evt.value.toString().toDouble() def targets = ["refrigerator", "chestFreezer"] targets.each { key -> if (settings["${key}RoomTemp"]?.id == deviceId) { def avg = state["${key}_dailyAvgRoomTemp"]?.toString()?.toDouble() ?: 0.0 if (avg == 0.0) { state["${key}_dailyAvgRoomTemp"] = currentTemp } else { state["${key}_dailyAvgRoomTemp"] = (avg * 0.95) + (currentTemp * 0.05) } } } } def outsideTempHandler(evt) { def deviceId = evt.device.id def currentTemp = evt.value.toString().toDouble() def targets = ["refrigerator", "chestFreezer"] targets.each { key -> if (settings["${key}OutsideTemp"]?.id == deviceId) { def currentMax = state["${key}_dailyMaxOutsideTemp"]?.toString()?.toDouble() ?: -100.0 if (currentTemp > currentMax) { state["${key}_dailyMaxOutsideTemp"] = currentTemp } } } } def resetDailyCounters() { def appliances = ["refrigerator", "chestFreezer", "hotWaterHeater", "washerDryer", "dishwasher", "microwave"] appliances.each { key -> state["${key}_todayRunCount"] = 0 // Reset max outside temp to current value (if sensor exists) or default -100 def outSensor = settings["${key}OutsideTemp"] state["${key}_dailyMaxOutsideTemp"] = outSensor ? (outSensor.currentValue("temperature")?.toString()?.toDouble() ?: -100.0) : -100.0 // Let room temp continue rolling average, or snap it to current to start fresh for the day def roomSensor = settings["${key}RoomTemp"] if (roomSensor) { state["${key}_dailyAvgRoomTemp"] = roomSensor.currentValue("temperature")?.toString()?.toDouble() ?: 0.0 } } } def universalSwitchHandler(evt) { def deviceId = evt.device.id def evtValue = evt.value def isPhysical = evt.isPhysical() def appliances = ["refrigerator", "chestFreezer", "hotWaterHeater", "washerDryer", "dishwasher", "microwave"] appliances.each { key -> if (settings["${key}Switch"]?.id == deviceId) { if (state["${key}_systemActionPending"]) { state["${key}_systemActionPending"] = false } else { state["${key}_context"] = isPhysical ? "Physical Switch" : "External App / Manual" } } } } def tempHandler(evt) { def deviceId = evt.device.id def currentTemp = evt.value.toString().toDouble() def targets = ["refrigerator": "Refrigerator", "chestFreezer": "Chest Freezer"] targets.each { key, name -> if (settings["${key}Temp"]?.id == deviceId) { // 1. Cross-Threshold Warning def threshold = settings["${key}TempThreshold"]?.toString()?.toDouble() if (threshold != null && currentTemp >= threshold) { if (!state["${key}_tempWarningActive"]) { sendAlert("🚨 ${name} TEMPERATURE ALERT: Current temp is ${currentTemp}°, which exceeds the safe threshold of ${threshold}°!", key, "temp") state["${key}_tempWarningActive"] = true } } else if (threshold != null && currentTemp < (threshold - 1.0)) { state["${key}_tempWarningActive"] = false } // 2. Gradual Creeping Warning def avgTemp = state["${key}_avgTemp"]?.toString()?.toDouble() ?: currentTemp def baselineTemp = state["${key}_baselineTemp"]?.toString()?.toDouble() ?: avgTemp // Update slow moving average state["${key}_avgTemp"] = (avgTemp * 0.95) + (currentTemp * 0.05) if (currentTemp > (baselineTemp + 5.0)) { if (!state["${key}_tempCreepWarning"]) { sendAlert("⚠️ ${name} TEMPERATURE CREEP: Baseline is ${Math.round(baselineTemp)}°, but average is creeping up (currently ${currentTemp}°). Check door seal or condenser coils.", key, "temp") state["${key}_tempCreepWarning"] = true } } else if (currentTemp <= (baselineTemp + 2.0)) { state["${key}_tempCreepWarning"] = false } } } } def alwaysOnProtectionHandler(evt) { if (evt.value == "off") { def deviceId = evt.device.id if (settings["refrigeratorSwitch"]?.id == deviceId) { log.warn "Protection Triggered: Refrigerator turned off. Forcing ON." state["refrigerator_systemActionPending"] = true state["refrigerator_context"] = "Protection Force-ON" settings["refrigeratorSwitch"]?.on() sendAlert("🚨 CRITICAL PROTECTION: Your Refrigerator switch was turned OFF! The system has automatically forced it back ON to prevent food spoilage.", "refrigerator", "protection") } else if (settings["chestFreezerSwitch"]?.id == deviceId) { log.warn "Protection Triggered: Chest Freezer turned off. Forcing ON." state["chestFreezer_systemActionPending"] = true state["chestFreezer_context"] = "Protection Force-ON" settings["chestFreezerSwitch"]?.on() sendAlert("🚨 CRITICAL PROTECTION: Your Chest Freezer switch was turned OFF! The system has automatically forced it back ON to prevent food spoilage.", "chestFreezer", "protection") } } } def modeChangeHandler(evt) { def newMode = evt.value // Refrigerator and Chest Freezer explicitly excluded from mode changes here def appliances = ["hotWaterHeater", "washerDryer", "dishwasher", "microwave"] def kwhRate = settings["costPerKwh"]?.toString()?.toDouble() ?: 0.13 def actionCount = 0 // Track how many actual commands we send to stagger them appliances.each { key -> def offModes = settings["${key}TurnOffModes"] def onModes = settings["${key}TurnOnModes"] def sw = settings["${key}Switch"] if (sw) { // Process Turn ON logic first if (onModes && onModes.contains(newMode)) { if (sw.currentValue("switch") != "on") { if (actionCount > 0) pauseExecution(1000) // Prevent mesh flooding actionCount++ state["${key}_systemActionPending"] = true state["${key}_context"] = "Mode Restore" sw.on() if (state["${key}_offTimeStart"]) { def offTimeMs = now() - state["${key}_offTimeStart"] def offTimeHours = offTimeMs / 3600000.0 def idleAvgPower = state["${key}_idlePowerAvg"]?.toString()?.toDouble() ?: 0.0 def savingsThisCycle = (idleAvgPower / 1000.0) * offTimeHours * kwhRate state["${key}_roiSavings"] = (state["${key}_roiSavings"] ?: 0.0) + savingsThisCycle state["${key}_offTimeStart"] = null } log.info "${key} turned ON due to mode changing to ${newMode}" } } // Then process Turn OFF logic else if (offModes && offModes.contains(newMode)) { if (actionCount > 0) pauseExecution(1000) // Prevent mesh flooding actionCount++ executeApplianceShutdown(key) } } } } // Dedicated retry wrappers for delayed mode shutdown def retryModeShutdown_hotWaterHeater() { executeApplianceShutdown("hotWaterHeater") } def retryModeShutdown_washerDryer() { executeApplianceShutdown("washerDryer") } def retryModeShutdown_dishwasher() { executeApplianceShutdown("dishwasher") } def retryModeShutdown_microwave() { executeApplianceShutdown("microwave") } // Dedicated cool-down completion wrappers def endCoolDownShutdown_hotWaterHeater() { finalizeShutdown("hotWaterHeater") } def endCoolDownShutdown_washerDryer() { finalizeShutdown("washerDryer") } def endCoolDownShutdown_dishwasher() { finalizeShutdown("dishwasher") } def endCoolDownShutdown_microwave() { finalizeShutdown("microwave") } def executeApplianceShutdown(key) { def sw = settings["${key}Switch"] def pMeter = settings["${key}Power"] def safeThreshold = settings["safeShutdownThreshold"]?.toString()?.toDouble() ?: 15.0 def allowCoolDown = settings["${key}EnableCoolDown"] != false // Default to true def coolDownMins = settings["${key}CoolDownMins"]?.toString()?.toInteger() ?: 120 // Verify we are still in a mode that dictates shutdown before proceeding if (sw && settings["${key}TurnOffModes"]?.contains(location.mode)) { def currentPower = pMeter ? (pMeter.currentValue('power')?.toString()?.toDouble() ?: 0.0) : 0.0 // CONDITION 1: Appliance is actively running right now if (currentPower > safeThreshold || state["${key}_isRunning"]) { log.info "${key} is actively running (${currentPower}W). Delaying Mode Shutdown." state["${key}_context"] = "Delayed Shutdown (Running)" runIn(900, "retryModeShutdown_${key}") // Check again in 15 minutes } else { // CONDITION 2: Appliance is off, but recently finished a cycle (and Cool Down is enabled) def lastRunEndMs = state["${key}_cycleEndTime"] ?: 0 def timeSinceLastRunMs = now() - lastRunEndMs def thirtyMinsMs = 30 * 60000 if (allowCoolDown && timeSinceLastRunMs < thirtyMinsMs) { // Calculate how much of the cool down is remaining based on when the cycle ended def coolDownWindowMs = coolDownMins * 60000 def remainingCoolDownMs = coolDownWindowMs - timeSinceLastRunMs if (remainingCoolDownMs > 0) { def remainingCoolDownSecs = Math.round(remainingCoolDownMs / 1000) def remainingCoolDownMinsCalc = Math.round(remainingCoolDownSecs / 60) log.info "${key} recently ran. Applying Cool Down period. Postponing shutdown for ${remainingCoolDownMinsCalc} minutes." state["${key}_context"] = "Cooling Down (${remainingCoolDownMinsCalc}m remaining)" runIn(remainingCoolDownSecs, "endCoolDownShutdown_${key}") } else { finalizeShutdown(key) } } // CONDITION 3: Appliance is completely safe to kill now else { finalizeShutdown(key) } } } } def finalizeShutdown(key) { def sw = settings["${key}Switch"] // Final check that we are still in an "off" mode before killing power if (sw && settings["${key}TurnOffModes"]?.contains(location.mode)) { if (sw.currentValue("switch") != "off") { state["${key}_systemActionPending"] = true state["${key}_context"] = "Mode Shutdown" sw.off() state["${key}_offTimeStart"] = now() log.info "${key} turned OFF via Mode Shutdown." } } } def buttonHandler(evt) { if (overrideModes && !overrideModes.contains(location.mode)) return def btnNumberStr = "1" if (evt.name == "button") { btnNumberStr = evt.jsonData?.buttonNumber?.toString() ?: "1" } else { btnNumberStr = evt.value?.toString() ?: "1" } // If the user specified button numbers, strictly enforce them. If left completely blank, allow any button. if (settings.buttonNumber) { def allowedNumbers = settings.buttonNumber.toString().split(",").collect { it.trim() } if (!allowedNumbers.contains(btnNumberStr)) return } log.info "Override activated by button ${btnNumberStr} (${evt.name})! Turning all appliances ON for 2 hours." def appliances = ["hotWaterHeater", "washerDryer", "dishwasher", "microwave"] appliances.each { key -> state["${key}_context"] = "Manual Override" } triggerTurnOn() runIn(7200, endOverrideAndCheckSchedule) } def endOverrideAndCheckSchedule() { def currTime = now() def start = timeToday(scheduleStart).time def end = timeToday(scheduleEnd).time if (currTime >= start && currTime < end) { triggerTurnOff() } } def triggerTurnOff() { def appliances = ["hotWaterHeater", "washerDryer", "dishwasher", "microwave"] def safeThreshold = settings["safeShutdownThreshold"]?.toString()?.toDouble() ?: 15.0 def needsRetry = false def actionCount = 0 appliances.each { key -> def sw = settings["${key}Switch"] def pMeter = settings["${key}Power"] if (sw) { def currentPower = pMeter ? (pMeter.currentValue('power')?.toString()?.toDouble() ?: 0.0) : 0.0 if (currentPower > safeThreshold) { log.info "${key} is currently running (${currentPower}W). Delaying schedule shutdown." needsRetry = true } else { if (sw.currentValue("switch") != "off") { if (actionCount > 0) pauseExecution(1000) // Prevent mesh flooding actionCount++ state["${key}_systemActionPending"] = true state["${key}_context"] = "Scheduled Shutdown" sw.off() state["${key}_offTimeStart"] = now() } } } } if (needsRetry) { runIn(900, "triggerTurnOff") // Fixed missing quotes around method name } } def triggerTurnOn() { def appliances = ["hotWaterHeater", "washerDryer", "dishwasher", "microwave"] def kwhRate = settings["costPerKwh"]?.toString()?.toDouble() ?: 0.13 def actionCount = 0 appliances.each { key -> def sw = settings["${key}Switch"] if (sw) { // Check state before firing to avoid redundant ON commands if (sw.currentValue("switch") != "on") { if (actionCount > 0) pauseExecution(1000) // Prevent mesh flooding actionCount++ state["${key}_systemActionPending"] = true if (state["${key}_context"] != "Manual Override") { state["${key}_context"] = "Scheduled Restore" } sw.on() if (state["${key}_offTimeStart"]) { def offTimeMs = now() - state["${key}_offTimeStart"] def offTimeHours = offTimeMs / 3600000.0 def idleAvgPower = state["${key}_idlePowerAvg"]?.toString()?.toDouble() ?: 0.0 def savingsThisCycle = (idleAvgPower / 1000.0) * offTimeHours * kwhRate state["${key}_roiSavings"] = (state["${key}_roiSavings"] ?: 0.0) + savingsThisCycle state["${key}_offTimeStart"] = null } } } } } def powerHandler(evt) { def meterId = evt.device.id def currentPower = evt.value.toString().toDouble() def appliances = ["refrigerator": "Refrigerator", "chestFreezer": "Chest Freezer", "hotWaterHeater": "Hot Water Heater", "washerDryer": "Washer/Dryer", "dishwasher": "Dishwasher", "microwave": "Microwave"] appliances.each { key, name -> if (settings["${key}Power"]?.id == meterId) { // 1. Spike Detection (60 Second Delay) def spikeThreshold = settings["${key}Spike"]?.toString()?.toDouble() ?: 5000 if (currentPower > spikeThreshold) { if (!state["${key}_spikePending"]) { state["${key}_spikePending"] = true runIn(60, "confirmSpike_${key}") } } else if (currentPower < (spikeThreshold * 0.8)) { state["${key}_spikePending"] = false state["${key}_spikeWarning"] = false unschedule("confirmSpike_${key}") } // 2. State Tracking with Start Delay & Debounced Cycle Completion def runThreshold = settings["${key}RunWatts"]?.toString()?.toDouble() ?: 15.0 def isCurrentlyRunning = (currentPower > runThreshold) def wasRunning = state["${key}_isRunning"] ?: false // --- Learn Idle Power --- // If the appliance is below the run threshold but actually drawing some power (ignoring 0.0W when physically off) if (!isCurrentlyRunning && currentPower > 0.0) { def currentIdle = state["${key}_idlePowerAvg"]?.toString()?.toDouble() ?: currentPower if (currentIdle == 0.0) { state["${key}_idlePowerAvg"] = currentPower } else { state["${key}_idlePowerAvg"] = (currentIdle * 0.95) + (currentPower * 0.05) } } // ----------------------------- if (isCurrentlyRunning) { // Cancel any pending stop/completion countdowns because we surged back over threshold if (state["${key}_stopPending"]) { state["${key}_stopPending"] = false unschedule("checkCycleComplete_${key}") } // If we are not fully "running" and haven't started a delayed confirmation yet if (!wasRunning && !state["${key}_startPending"]) { state["${key}_startPending"] = true state["${key}_tentativeStartTime"] = now() def startDelayMins = settings["${key}StartDelay"]?.toString()?.toInteger() ?: 1 def startDelaySecs = startDelayMins * 60 if (startDelaySecs > 0) { runIn(startDelaySecs, "confirmCycleStart_${key}") } else { startCycle(key) // Immediate start if user set delay to 0 } } } else { // Dropped below threshold // If we were just waiting to start, it was a false spike (e.g., fridge door opened) if (state["${key}_startPending"]) { state["${key}_startPending"] = false unschedule("confirmCycleStart_${key}") state.remove("${key}_tentativeStartTime") } // If we were officially running, start the debounce/pause countdown for completion if (wasRunning && !state["${key}_stopPending"]) { state["${key}_stopPending"] = true def debounceMins = settings["${key}Debounce"]?.toString()?.toInteger() ?: 15 def debounceSecs = debounceMins * 60 runIn(debounceSecs, "checkCycleComplete_${key}") } } // 3. Power Averaging if (currentPower > 10) { def currentAvg = state["${key}_avgPower"]?.toString()?.toDouble() ?: currentPower state["${key}_avgPower"] = (currentAvg * 0.95) + (currentPower * 0.05) } } } } // Dedicated cycle start confirmation methods for each appliance def confirmCycleStart_refrigerator() { startCycle("refrigerator") } def confirmCycleStart_chestFreezer() { startCycle("chestFreezer") } def confirmCycleStart_hotWaterHeater() { startCycle("hotWaterHeater") } def confirmCycleStart_washerDryer() { startCycle("washerDryer") } def confirmCycleStart_dishwasher() { startCycle("dishwasher") } def confirmCycleStart_microwave() { startCycle("microwave") } def startCycle(key) { if (state["${key}_startPending"]) { state["${key}_startPending"] = false state["${key}_isRunning"] = true // Log the start time accurately from when it first crossed the threshold state["${key}_cycleStartTime"] = state["${key}_tentativeStartTime"] ?: now() } } // Dedicated spike confirmation methods for each appliance def confirmSpike_refrigerator() { executeSpikeAlert("refrigerator", "Refrigerator") } def confirmSpike_chestFreezer() { executeSpikeAlert("chestFreezer", "Chest Freezer") } def confirmSpike_hotWaterHeater() { executeSpikeAlert("hotWaterHeater", "Hot Water Heater") } def confirmSpike_washerDryer() { executeSpikeAlert("washerDryer", "Washer/Dryer") } def confirmSpike_dishwasher() { executeSpikeAlert("dishwasher", "Dishwasher") } def confirmSpike_microwave() { executeSpikeAlert("microwave", "Microwave") } def executeSpikeAlert(key, name) { if (state["${key}_spikePending"]) { state["${key}_spikePending"] = false if (!state["${key}_spikeWarning"]) { state["${key}_spikeWarning"] = true sendAlert("⚡ ${name} power spike has persisted for over 60 seconds! Check for failing components.", key, "spike") } } } // Separate methods to ensure safe execution in the SmartThings/Hubitat architecture without runIn overwrites def checkCycleComplete_refrigerator() { finishCycle("refrigerator", "Refrigerator") } def checkCycleComplete_chestFreezer() { finishCycle("chestFreezer", "Chest Freezer") } def checkCycleComplete_hotWaterHeater() { finishCycle("hotWaterHeater", "Hot Water Heater") } def checkCycleComplete_washerDryer() { finishCycle("washerDryer", "Washer/Dryer") } def checkCycleComplete_dishwasher() { finishCycle("dishwasher", "Dishwasher") } def checkCycleComplete_microwave() { finishCycle("microwave", "Microwave") } def finishCycle(key, name) { if (state["${key}_stopPending"] && state["${key}_isRunning"]) { state["${key}_isRunning"] = false state["${key}_stopPending"] = false // Track exact moment cycle finished for cool down logic state["${key}_cycleEndTime"] = now() if (state["${key}_cycleStartTime"]) { def debounceMins = settings["${key}Debounce"]?.toString()?.toInteger() ?: 15 def debounceMs = debounceMins * 60000 // Subtract the dynamic debounce window from the total run time for accuracy def cycleDurationMs = now() - state["${key}_cycleStartTime"] - debounceMs if (cycleDurationMs < 0) cycleDurationMs = 0 def cycleDurationHours = cycleDurationMs / 3600000.0 def cycleDurationMins = cycleDurationMs / 60000.0 // Log final cycle stats state["${key}_lastRunLengthMins"] = cycleDurationMins state["${key}_7DayRunCount"] = (state["${key}_7DayRunCount"] ?: 0) + 1 state["${key}_todayRunCount"] = (state["${key}_todayRunCount"] ?: 0) + 1 state["${key}_7DayTotalCycleMins"] = (state["${key}_7DayTotalCycleMins"] ?: 0.0) + cycleDurationMins state["${key}_totalRunHours"] = (state["${key}_totalRunHours"] ?: 0.0) + cycleDurationHours if (cycleDurationMins > 1) { sendAlert("✅ Your ${name} cycle is complete! (Ran for ${Math.round(cycleDurationMins)} mins)", key, "cycle") } if (key == "refrigerator" || key == "chestFreezer") { trackCompressorHealth(key, name, cycleDurationMins) } // Handle Auto-Off Feature if (settings["${key}AutoOff"]) { def allowedModes = settings["${key}AutoOffModes"] // Proceed if no modes are restricted OR if the current mode matches an allowed mode if (!allowedModes || allowedModes.contains(location.mode)) { // Trigger mode shutdown check, which will respect the Cool Down toggle! executeApplianceShutdown(key) } else { log.info "${name} cycle finished, but Auto-Off was skipped due to mode restrictions." } } } } } def trackCompressorHealth(key, name, cycleDurationMins) { if (cycleDurationMins < 5) return def baselineDuration = state["${key}_baselineCycleMins"]?.toString()?.toDouble() ?: cycleDurationMins if (baselineDuration == 0.0 || baselineDuration == cycleDurationMins) { state["${key}_baselineCycleMins"] = cycleDurationMins } else { if (cycleDurationMins > (baselineDuration * 1.30)) { state["${key}_struggleCount"] = (state["${key}_struggleCount"] ?: 0) + 1 if (state["${key}_struggleCount"] == 3) { sendAlert("🧹 ${name} Maintenance: Compressor is running 30% longer than normal. Please clean the condenser coils and check airflow to prevent failure.", key, "health") } else if (state["${key}_struggleCount"] >= 7) { sendAlert("⚠️ ${name} CRITICAL Warning: Unit is severely struggling to cool. Compressor cycles are continuously extended. Hardware failure may be imminent.", key, "health") state["${key}_struggleCount"] = 0 } } else { state["${key}_baselineCycleMins"] = (baselineDuration * 0.98) + (cycleDurationMins * 0.02) if (state["${key}_struggleCount"] > 0) { state["${key}_struggleCount"] = state["${key}_struggleCount"] - 1 } } } } def dailyHealthCheck() { def appliances = ["refrigerator": "Refrigerator", "chestFreezer": "Chest Freezer", "hotWaterHeater": "Hot Water Heater", "washerDryer": "Washer/Dryer", "dishwasher": "Dishwasher", "microwave": "Microwave"] def kwhRate = settings["costPerKwh"]?.toString()?.toDouble() ?: 0.13 def allowMaintRoi = settings["enableMaintenanceRoi"] != false // Defaults to true appliances.each { key, name -> def avgPower = state["${key}_avgPower"]?.toString()?.toDouble() ?: 0.0 // --- NEW ROI CALCULATION FOR MAINTENANCE SAVINGS --- if (allowMaintRoi && state["${key}_inefficientPowerMark"]) { def badWatts = state["${key}_inefficientPowerMark"].toString().toDouble() if (avgPower < badWatts && avgPower > 0) { def savedKw = (badWatts - avgPower) / 1000.0 // Estimate daily run hours (use 7-day average if available, otherwise assume 24h standby) def totalWeeklyMins = state["${key}_7DayTotalCycleMins"]?.toString()?.toDouble() ?: 0.0 def dailyRunHours = totalWeeklyMins > 0 ? (totalWeeklyMins / 7.0 / 60.0) : 24.0 def dailySavings = savedKw * dailyRunHours * kwhRate state["${key}_maintRoiSavings"] = (state["${key}_maintRoiSavings"] ?: 0.0) + dailySavings } else if (avgPower >= badWatts) { // If it creeps back up to the bad mark, stop calculating savings until the next reset state.remove("${key}_inefficientPowerMark") } } // Existing creep warning logic def baselineAvg = state["${key}_baselineAvg"]?.toString()?.toDouble() ?: avgPower if (baselineAvg == 0.0) { state["${key}_baselineAvg"] = avgPower } else { if (avgPower > (baselineAvg * 1.20)) { state["${key}_creepWarning"] = true sendAlert("⚠️ ${name} average power is creeping up (${Math.round(avgPower)}W vs baseline ${Math.round(baselineAvg)}W). A motor/vent check is recommended.", key, "health") } else { state["${key}_creepWarning"] = false state["${key}_baselineAvg"] = (baselineAvg * 0.98) + (avgPower * 0.02) } } // Temperature Daily Recalibration for Compressor units if (key == "refrigerator" || key == "chestFreezer") { def totalHours = state["${key}_totalRunHours"]?.toString()?.toDouble() ?: 0.0 def maintenanceInterval = settings["${key}MaintenanceHours"]?.toString()?.toDouble() ?: 2000.0 if (totalHours > maintenanceInterval) { sendAlert("🔧 Routine Maintenance: Your ${name} has reached ${Math.round(totalHours)} run hours. Consider scheduling a preventative maintenance check.", key, "health") settings["${key}MaintenanceHours"] = maintenanceInterval + 2000.0 } // Adjust temp baseline daily if (state["${key}_avgTemp"]) { def currentAvgTemp = state["${key}_avgTemp"].toString().toDouble() def tempBaseline = state["${key}_baselineTemp"]?.toString()?.toDouble() ?: currentAvgTemp state["${key}_baselineTemp"] = (tempBaseline * 0.90) + (currentAvgTemp * 0.10) } } } } def resetWeeklyCounters() { def appliances = ["refrigerator", "chestFreezer", "hotWaterHeater", "washerDryer", "dishwasher", "microwave"] appliances.each { key -> def eMeter = settings["${key}Energy"] if (eMeter) { state["${key}_startEnergy"] = eMeter.currentValue("energy")?.toString()?.toDouble() ?: 0.0 } state["${key}_roiSavings"] = 0.0 state["${key}_maintRoiSavings"] = 0.0 state["${key}_7DayRunCount"] = 0 state["${key}_7DayTotalCycleMins"] = 0.0 } } def sendAlert(msg, key, alertType) { // 1. Time & Mode Gatekeeping (Per Appliance) def aModes = settings["${key}AlertModes"] if (aModes && !aModes.contains(location.mode)) return def aStart = settings["${key}AlertStartTime"] def aEnd = settings["${key}AlertEndTime"] if (aStart && aEnd) { def currTime = now() def start = timeToday(aStart).time def end = timeToday(aEnd).time if (start <= end) { if (currTime < start || currTime > end) return } else { // Handles wrap-around midnight if (currTime < start && currTime > end) return } } // 2. Fetch specific notification targets and allowed events for this appliance def pushDev = settings["${key}PushNotification"] def pushEvents = settings["${key}PushEvents"] ?: [] def ttsDev = settings["${key}TtsDevice"] def ttsEvents = settings["${key}TtsEvents"] ?: [] def audioDev = settings["${key}AudioDevice"] def audioEvents = settings["${key}AudioEvents"] ?: [] def audioTrack = settings["${key}AudioTrack"]?.toString() ?: "1" // 3. Execute Allowed Notifications if (pushDev && pushEvents.contains(alertType)) { pushDev.deviceNotification(msg) } if (ttsDev && ttsEvents.contains(alertType)) { ttsDev.speak(msg) } // FIX APPLIED: Routing audio through the safe Zooz Helper if (audioDev && audioEvents.contains(alertType)) { playZoozSound(audioDev, audioTrack) } } // FIX APPLIED: New safe play function handling playSound, playTrack, and chime with mesh protection def playZoozSound(devices, sound) { if (!devices || sound == null) return def soundInt = sound.toInteger() // Handle single device or list of devices def devList = devices instanceof List ? devices : [devices] devList.eachWithIndex { dev, index -> // Add a 1000ms (1 second) delay before subsequent devices to prevent Z-Wave mesh flooding if (index > 0) { pauseExecution(1000) } try { if (dev.hasCommand("playSound")) { dev.playSound(soundInt) } else if (dev.hasCommand("playTrack")) { dev.playTrack(sound.toString()) } else if (dev.hasCommand("chime")) { dev.chime(soundInt) } else { log.warn "Advanced Energy Management Controller: Device ${dev.displayName} does not support standard sound commands (playSound, playTrack, or chime)." } } catch (e) { log.error "Failed to play audio on ${dev.displayName}: ${e}" } } }