/**
* 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 += "| Shower | Current Status |
"
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 += "| ${sName} | Not Configured |
"
continue
}
def sStatus = state["showerStatus_${i}"] ?: "Idle"
def statusColor = (sStatus == "Idle") ? "black" : (sStatus == "Grace Period" ? "orange" : "green")
statusText += "| ${sName} | ${sStatus} |
"
}
statusText += "
"
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 += "| Date & Time | Duration | Volume | Est. Cost |
"
logList.each { entry ->
def galStr = entry.gallons ?: "-- gal"
def costStr = entry.cost ?: "--"
logText += "| ${entry.time} | ${entry.duration} | ${galStr} | ${costStr} |
"
}
logText += "
"
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"
}
}