/** * Advanced Shower Monitor * * Author: ShaneAllen */ definition( name: "Advanced Shower Monitor", namespace: "ShaneAllen", author: "ShaneAllen", description: "Monitors up to 4 showers with Volumetric Tracking, Financial Cost Analytics, and Grace-Period Smoothing.", category: "Green Living", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Shower Monitor Configuration", install: true, uninstall: true) { section("Live System Dashboard") { def statusText = "" statusText += "" def showerCount = settings["numShowers"] ?: 1 for (int i = 1; i <= showerCount; i++) { def sName = settings["showerName_${i}"] ?: "Shower ${i}" def mSensor = settings["motion_${i}"] if (!mSensor) { statusText += "" continue } def sStatus = state["showerStatus_${i}"] ?: "Idle" def statusColor = (sStatus == "Idle") ? "black" : (sStatus == "Grace Period" ? "orange" : "green") statusText += "" } statusText += "
ShowerCurrent Status
${sName}Not Configured
${sName}${sStatus}
" def globalStatus = isSystemPaused() ? "PAUSED (Master Switch Off)" : (!isModeAllowed() ? "PAUSED (Restricted Mode)" : "ACTIVE") statusText += "
Global System Mode: ${globalStatus}
" paragraph statusText } section("Financial Analytics (Last 10 Sessions)") { def showerCount = settings["numShowers"] ?: 1 for (int i = 1; i <= showerCount; i++) { def sName = settings["showerName_${i}"] ?: "Shower ${i}" def logList = state["sessionLog_${i}"] ?: [] if (logList.size() > 0) { def logText = "
${sName}
" logText += "" logList.each { entry -> def galStr = entry.gallons ?: "-- gal" def costStr = entry.cost ?: "--" logText += "" } logText += "
Date & TimeDurationVolumeEst. Cost
${entry.time}${entry.duration}${galStr}${costStr}
" paragraph logText } else { paragraph "${sName}: No shower data recorded yet." } } } section("Application History (Last 20 Events)") { if (state.historyLog && state.historyLog.size() > 0) { def logText = state.historyLog.join("
") paragraph "
${logText}
" } else { paragraph "No history available yet. The log will populate as the app takes action." } } section("Global Settings") { input "numShowers", "number", title: "Number of Showers to Monitor (1-4)", defaultValue: 1, range: "1..4", required: true, submitOnChange: true input "masterEnableSwitch", "capability.switch", title: "Master System Enable Switch", required: false, description: "ON = Application Runs. OFF = Application Paused." input "activeModes", "mode", title: "Active Modes (App only runs in these)", multiple: true, required: false } // --- NEW: Data Management Section --- section("Data Management") { input "clearDataBtn", "button", title: "Clear All Shower & Financial Data", width: 4 paragraph "Clicking the button above will instantly wipe the Financial Analytics and Application History logs. This is useful for clearing out test data." } def count = settings["numShowers"] ?: 1 for (int i = 1; i <= count; i++) { def sName = settings["showerName_${i}"] ?: "Shower ${i}" section("${sName} Setup", hideable: true, hidden: true) { input "showerName_${i}", "text", title: "Custom Name", required: false, defaultValue: "Shower ${i}", submitOnChange: true input "motion_${i}", "capability.motionSensor", title: "Shower Motion Sensor", required: false input "outMotion_${i}", "capability.motionSensor", title: "Out of Shower Motion Sensor (Optional)", required: false, description: "If motion is detected here during the grace period, the shower session ends immediately." input "light_${i}", "capability.switch", title: "Bathroom Light to Flash", required: false input "statusSwitch_${i}", "capability.switch", title: "Virtual Status Switch (Turns ON when shower is active)", required: false, description: "Use this to tell other apps (like Motion Lighting) that a shower is running." input "flowRate_${i}", "decimal", title: "Showerhead Flow Rate (GPM)", defaultValue: 2.5, required: true, description: "Standard US showerheads are 2.5 GPM." input "costPerGallon_${i}", "decimal", title: "Est. Cost per Gallon of Hot Water (\$)", defaultValue: 0.03, required: true input "warn1_${i}", "number", title: "1st Warning (Minutes)", defaultValue: 5, required: true input "warn2_${i}", "number", title: "2nd Warning (Minutes)", defaultValue: 8, required: true input "warn3_${i}", "number", title: "3rd Warning (Minutes)", defaultValue: 10, required: true input "gracePeriod_${i}", "number", title: "Empty Shower Grace Period (Minutes)", defaultValue: 2, required: true, description: "Fixes 'chatty' sensors. This time is subtracted from final duration unless ended early by an 'Out' sensor." input "minDuration_${i}", "number", title: "Minimum Duration to Log (Seconds)", defaultValue: 60, required: true, description: "Ignores 'ghost' triggers like moving towels. Sessions under this time are discarded." input "lockoutPeriod_${i}", "number", title: "Post-Shower Lockout (Minutes)", defaultValue: 2, required: true, description: "Prevents new sessions from starting immediately after one ends (e.g., retrieving a towel)." } } } } // --- NEW: Button Handler --- def appButtonHandler(btn) { if (btn == "clearDataBtn") { state.historyLog = [] def showerCount = settings["numShowers"] ?: 1 for (int i = 1; i <= showerCount; i++) { state["sessionLog_${i}"] = [] } log.info "Advanced Shower Monitor: All tracked session data and history logs have been cleared by the user." } } def installed() { initialize() } def updated() { unsubscribe() unschedule() initialize() } def initialize() { state.historyLog = state.historyLog ?: [] def showerCount = settings["numShowers"] ?: 1 for (int i = 1; i <= showerCount; i++) { if (settings["motion_${i}"]) subscribe(settings["motion_${i}"], "motion", "motionHandler${i}") if (settings["outMotion_${i}"]) { subscribe(settings["outMotion_${i}"], "motion", "outMotionHandler${i}") } state["showerActive_${i}"] = state["showerActive_${i}"] ?: false state["showerStatus_${i}"] = state["showerStatus_${i}"] ?: "Idle" state["sessionLog_${i}"] = state["sessionLog_${i}"] ?: [] } } // --- LOGGING --- def addToHistory(String msg) { if (!state.historyLog) state.historyLog = [] def timestamp = new Date().format("MM/dd HH:mm:ss", location.timeZone) state.historyLog.add(0, "[${timestamp}] ${msg}") if (state.historyLog.size() > 20) state.historyLog = state.historyLog.take(20) log.info "HISTORY: ${msg.replaceAll("\\<.*?\\>", "")}" } // --- DYNAMIC HANDLERS --- def motionHandler1(evt) { handleMotion(1, evt.value) } def outMotionHandler1(evt) { handleOutMotion(1, evt.value) } def motionHandler2(evt) { handleMotion(2, evt.value) } def outMotionHandler2(evt) { handleOutMotion(2, evt.value) } def motionHandler3(evt) { handleMotion(3, evt.value) } def outMotionHandler3(evt) { handleOutMotion(3, evt.value) } def motionHandler4(evt) { handleMotion(4, evt.value) } def outMotionHandler4(evt) { handleOutMotion(4, evt.value) } def warnTierOne1() { triggerFlash(1, 1) } def warnTierTwo1() { triggerFlash(1, 2) } def warnTierThree1() { triggerFlash(1, 3) } def endShower1() { terminateShower(1) } def warnTierOne2() { triggerFlash(2, 1) } def warnTierTwo2() { triggerFlash(2, 2) } def warnTierThree2() { triggerFlash(2, 3) } def endShower2() { terminateShower(2) } def warnTierOne3() { triggerFlash(3, 1) } def warnTierTwo3() { triggerFlash(3, 2) } def warnTierThree3() { triggerFlash(3, 3) } def endShower3() { terminateShower(3) } def warnTierOne4() { triggerFlash(4, 1) } def warnTierTwo4() { triggerFlash(4, 2) } def warnTierThree4() { triggerFlash(4, 3) } def endShower4() { terminateShower(4) } // --- SYSTEM CHECKS --- def isSystemPaused() { if (masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off") return true return false } def isModeAllowed() { if (!activeModes) return true return activeModes.contains(location.mode) } // --- CORE LOGIC --- def handleMotion(showerId, motionState) { def sName = settings["showerName_${showerId}"] ?: "Shower ${showerId}" def grace = settings["gracePeriod_${showerId}"] ?: 2 def lockout = settings["lockoutPeriod_${showerId}"] ?: 2 if (motionState == "active") { unschedule("endShower${showerId}") if (!state["showerActive_${showerId}"]) { if (isSystemPaused() || !isModeAllowed()) return def lastEndTime = state["showerEndTime_${showerId}"] ?: 0 if (new Date().time - lastEndTime < (lockout * 60 * 1000)) { log.debug "${sName}: Motion ignored due to ${lockout}-minute post-shower lockout." return } state["showerActive_${showerId}"] = true settings["statusSwitch_${showerId}"]?.on() state["showerStartTime_${showerId}"] = new Date().time state["showerStatus_${showerId}"] = "Active (Timers Running)" addToHistory("${sName}: Shower started.") runIn((settings["warn1_${showerId}"] ?: 5) * 60, "warnTierOne${showerId}", [overwrite: true]) runIn((settings["warn2_${showerId}"] ?: 8) * 60, "warnTierTwo${showerId}", [overwrite: true]) runIn((settings["warn3_${showerId}"] ?: 10) * 60, "warnTierThree${showerId}", [overwrite: true]) } else { state["showerStatus_${showerId}"] = "Active (Timers Running)" } } else if (state["showerActive_${showerId}"]) { state["showerStatus_${showerId}"] = "Grace Period" state["showerInactiveTime_${showerId}"] = new Date().time addToHistory("${sName}: Motion stopped. Grace period active.") runIn(grace * 60, "endShower${showerId}", [overwrite: true]) } } def handleOutMotion(showerId, motionState) { if (motionState == "active" && state["showerStatus_${showerId}"] == "Grace Period") { def sName = settings["showerName_${showerId}"] ?: "Shower ${showerId}" addToHistory("${sName}: Presence outside shower detected. Terminating session.") unschedule("endShower${showerId}") terminateShower(showerId, true) } } def terminateShower(showerId, earlyTerminate = false) { def sName = settings["showerName_${showerId}"] ?: "Shower ${showerId}" def startTime = state["showerStartTime_${showerId}"] ?: new Date().time def minDuration = settings["minDuration_${showerId}"] ?: 60 def endTime = earlyTerminate ? (state["showerInactiveTime_${showerId}"] ?: new Date().time) : new Date().time def graceSecs = earlyTerminate ? 0 : ((settings["gracePeriod_${showerId}"] ?: 2) * 60) def totalMillis = endTime - startTime - (graceSecs * 1000) if (totalMillis < 0) totalMillis = 0 def totalSecs = (totalMillis / 1000) as Integer def mins = (totalSecs / 60) as Integer def secs = totalSecs % 60 def durationStr = "${mins}m ${secs}s" if (totalSecs < minDuration) { addToHistory("${sName}: Session discarded (Under ${minDuration}s filter).") } else { def gpm = settings["flowRate_${showerId}"]?.toBigDecimal() ?: 2.5 def gallonsUsed = (totalSecs / 60.0) * gpm def gallonsStr = "${gallonsUsed.setScale(1, BigDecimal.ROUND_HALF_UP)} gal" def costFactor = settings["costPerGallon_${showerId}"]?.toBigDecimal() ?: 0.03 def totalCost = gallonsUsed * costFactor def costStr = "\$" + totalCost.setScale(2, BigDecimal.ROUND_HALF_UP) def entry = [time: new Date(startTime).format("MM/dd hh:mm a", location.timeZone), duration: durationStr, gallons: gallonsStr, cost: costStr] def logs = state["sessionLog_${showerId}"] ?: [] logs.add(0, entry) state["sessionLog_${showerId}"] = logs.take(10) addToHistory("${sName}: Session Finished. Duration: ${durationStr} | ${gallonsStr} | ${costStr}") } state["showerActive_${showerId}"] = false settings["statusSwitch_${showerId}"]?.off() state["showerStatus_${showerId}"] = "Idle" state["showerEndTime_${showerId}"] = new Date().time unschedule("warnTierOne${showerId}") unschedule("warnTierTwo${showerId}") unschedule("warnTierThree${showerId}") } def triggerFlash(showerId, flashes) { if (!state["showerActive_${showerId}"] || isSystemPaused() || !isModeAllowed()) return def light = settings["light_${showerId}"] if (!light) return addToHistory("${settings["showerName_${showerId}"]} warning. Flashing ${flashes}x.") for (int i = 0; i < flashes; i++) { runInMillis(i * 2000, "turnLightOff", [data: [showerId: showerId], overwrite: false]) runInMillis((i * 2000) + 1000, "turnLightOn", [data: [showerId: showerId], overwrite: false]) } runInMillis((flashes * 2000) + 1000, "refreshLight", [data: [showerId: showerId], overwrite: false]) } def turnLightOff(data) { settings["light_${data.showerId}"]?.off() } def turnLightOn(data) { settings["light_${data.showerId}"]?.on() } def refreshLight(data) { try { settings["light_${data.showerId}"]?.refresh() } catch (e) { log.debug "Device does not support the refresh command" } }