/**
* Advanced Mail Monitor
*
* Author: ShaneAllen
*/
definition(
name: "Advanced Mail Monitor",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Advanced mailbox state tracking with historical averages, audio announcements, nag reminders, and live telemetry dashboard.",
category: "Convenience",
iconUrl: "",
iconX2Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "Main Configuration", install: true, uninstall: true) {
section("Live System Dashboard") {
if (mailSensors && mailSwitch) {
def statusText = "
"
statusText += "| Metric | Today | Historical Average |
"
def tDelivery = state.todayDeliveryTime ?: "--:-- --"
def tRetrieval = state.todayRetrievalTime ?: "--:-- --"
def tWalk = state.lastRetrievalWalkTime ? formatSeconds(state.lastRetrievalWalkTime) : "--"
def avgDel = state.avgDeliveryTime ? minutesToTimeStr(state.avgDeliveryTime) : "--:-- --"
def avgRet = state.avgRetrievalTime ? minutesToTimeStr(state.avgRetrievalTime) : "--:-- --"
statusText += "| Mail Delivery | ${tDelivery} | ${avgDel} |
"
statusText += "| Mail Retrieval | ${tRetrieval} | ${avgRet} |
"
statusText += "| Last Retrieval Trip | ${tWalk} | -- |
"
statusText += "
"
def batteryHtml = ""
batteryHtml += "| Mailbox Sensor | Current State | Battery Health |
"
mailSensors.each { sensor ->
def contactState = sensor.currentValue("contact")?.toUpperCase() ?: "UNKNOWN"
def contactColor = (contactState == "OPEN") ? "red" : "green"
def batt = sensor.currentValue("battery") ?: "N/A"
def battColor = "green"
if (batt != "N/A") {
if (batt.toInteger() <= 15) battColor = "red"
else if (batt.toInteger() <= 50) battColor = "orange"
batt = "${batt}%"
}
batteryHtml += "| ${sensor.displayName} | ${contactState} | ${batt} |
"
}
batteryHtml += "
"
statusText += batteryHtml
if (tempSensor || outsideTempSensor) {
def currentTemp
if (tempSensor) {
currentTemp = tempSensor.currentValue("temperature")
} else {
def outTemp = outsideTempSensor.currentValue("temperature")
currentTemp = outTemp ? (outTemp.toDouble() + (tempOffset ?: 20)) : null
}
if (currentTemp) {
statusText += "Mailbox Internal Temperature: ${currentTemp}° ${outsideTempSensor && !tempSensor ? '(Estimated)' : ''}
"
}
}
def switchState = mailSwitch.currentValue("switch")?.toUpperCase() ?: "UNKNOWN"
def switchColor = (switchState == "ON") ? "red" : "green"
def indicatorText = (switchState == "ON") ? "MAIL WAITING" : "EMPTY / WAITING FOR DELIVERY"
statusText += ""
statusText += "
Indicator Switch: ${switchState}
"
statusText += "
Current State: ${indicatorText}
"
statusText += "
"
paragraph statusText
input "btnClearMail", "button", title: "Manually Clear Mail Status & Lights"
} else {
paragraph "Configure devices below to see live system status."
}
}
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 events occur."
}
}
section("Device Configuration") {
input "mailSensors", "capability.contactSensor", title: "Mailbox Door Sensor(s)", multiple: true, required: true
input "tempSensor", "capability.temperatureMeasurement", title: "Internal Mailbox Temperature Sensor (Preferred)", required: false
input "outsideTempSensor", "capability.temperatureMeasurement", title: "OR Outside Air Temperature Sensor (Fallback)", required: false
input "tempOffset", "number", title: "Estimated Mailbox Heat Offset (° added to outside temp)", defaultValue: 20, required: false
input "tempThreshold", "number", title: "Temperature Alert Threshold (°)", defaultValue: 90, required: false
input "mailSwitch", "capability.switch", title: "Virtual Mail Indicator Switch", required: true
input "deliveryLockout", "number", title: "State Change Lockout (Minutes)", defaultValue: 2, required: true
}
section("Delivery Time Restrictions") {
input "enableDeliveryWindow", "bool", title: "Restrict Delivery Detection to a Time Window?", defaultValue: false, submitOnChange: true
if (enableDeliveryWindow) {
paragraph "Only allow 'Mail Delivered' events between these times. This prevents false deliveries when you drop off outgoing mail in the morning or evening."
input "deliveryStartTime", "time", title: "Delivery Window Start Time", required: true
input "deliveryEndTime", "time", title: "Delivery Window End Time", required: true
}
}
section("Home Activity Tracking (Doors & Arrivals)") {
paragraph "These sensors are used to calculate your 'Retrieval Trip Time' and are optional. They can also be used to prevent false mail deliveries."
input "exteriorDoors", "capability.contactSensor", title: "Exterior Doors (Front, Garage, etc.)", multiple: true, required: false
input "arrivalSensors", "capability.presenceSensor", title: "Arrival Sensors (Mobile Phones)", multiple: true, required: false
input "enableSecondaryCheck", "bool", title: "Enable False Retrieval Protection? (Requires sensors above)", defaultValue: false, submitOnChange: true
if (enableSecondaryCheck) {
input "activityTimeWindow", "number", title: "Activity Time Window (Minutes)", defaultValue: 10, required: true
}
}
section("Visual Indicators (Colored Lights)") {
input "indicatorLight", "capability.colorControl", title: "Standard RGB Lights", required: false, multiple: true
input "inovelliSwitches", "capability.pushableButton", title: "Inovelli Red Series Switches", required: false, multiple: true, submitOnChange: true
if (inovelliSwitches) {
input "inovelliTarget", "enum", title: "Inovelli Target LEDs", required: true, defaultValue: "All", options: [
"All":"All LEDs", "7":"LED 7 (Top)", "6":"LED 6", "5":"LED 5", "4":"LED 4 (Middle)", "3":"LED 3", "2":"LED 2", "1":"LED 1 (Bottom)"
]
}
input "deliveryColor", "enum", title: "Color when Mail is Delivered", required: false, defaultValue: "Green", options: ["Red", "Green", "Blue", "Yellow", "Orange", "Purple", "Pink", "White"]
input "lightLevel", "number", title: "Indicator Light Level (%)", defaultValue: 100, required: false, range: "1..100"
input "retrievalLightAction", "enum", title: "Action when Mail is Retrieved", required: false, defaultValue: "Turn Off", options: ["Turn Off", "Leave On"]
}
section("Integration & External Overrides") {
paragraph "Freeze other applications during delivery
If you are using shared lights for your mail notification (such as a living room lamp managed by a motion lighting app), those other apps might try to turn the light off while the mail notification is active.
Select a Virtual Switch here. This app will turn it ON during a delivery. You can use that switch in your other apps to 'freeze' or 'disable' them until the mail is retrieved."
input "overrideSwitch", "capability.switch", title: "State Override Switch (Freezes external apps)", required: false
paragraph "Yield to Higher Priority Applications
If another app (like the School Bus sequence) is actively using the lights, select its active switch here. Mail deliveries will be tracked, but the lights will wait to turn on until the priority app is finished."
input "priorityYieldSwitch", "capability.switch", title: "Priority Yield Switch (e.g., School Active Switch)", required: false
}
section("Audio Announcements & Notifications") {
paragraph "Smart Speakers (TTS)"
input "ttsSpeakers", "capability.speechSynthesis", title: "Smart Speakers", multiple: true, required: false
input "ttsDeliveryText", "text", title: "Delivery Announcement Text", defaultValue: "The mail has arrived"
input "ttsRetrievalText", "text", title: "Retrieval Announcement Text", defaultValue: "The mail has been retrieved"
paragraph "Zooz Siren & Chime"
input "zoozChimes", "capability.chime", title: "Zooz Chime Devices", multiple: true, required: false, submitOnChange: true
if (zoozChimes) {
input "zoozSoundDelivery", "number", title: "Sound File #: Mail Arrived", required: false
input "zoozSoundMore", "number", title: "Sound File #: More Mail Arrived", required: false
input "zoozSoundRetrieval", "number", title: "Sound File #: Mail Retrieved", required: false
}
paragraph "Push Notifications"
input "pushDevices", "capability.notification", title: "Push Notification Devices", multiple: true, required: false
input "sendPushDelivery", "bool", title: "Push on Delivery?", defaultValue: false
input "sendPushRetrieval", "bool", title: "Push on Retrieval?", defaultValue: false
}
section("Global Audio Room Mapping") {
paragraph "1-to-1 Motion Filtering: Map your speakers to motion sensors here. When the system attempts to play audio on a speaker or chime, it will automatically intercept the command and check if that specific device's room has recent motion. (Devices not mapped here will play unconditionally).
"
input "audioMotionTimeout", "number", title: "Audio Motion Timeout (Minutes)", defaultValue: 5, description: "Time to wait after motion stops before muting announcements (prevents muting if someone is sitting still)."
input "alwaysOnRoom", "enum", title: "Select ONE room to ALWAYS announce (Ignores motion)", options: ["1": "Room 1", "2": "Room 2", "3": "Room 3", "4": "Room 4", "5": "Room 5", "6": "Room 6", "7": "Room 7"], required: false
input "room1Speaker", "capability.actuator", title: "Room 1 Speaker/Chime(s)", required: false, multiple: true
input "room1Motion", "capability.motionSensor", title: "Room 1 Motion Sensor(s)", required: false, multiple: true
input "room2Speaker", "capability.actuator", title: "Room 2 Speaker/Chime(s)", required: false, multiple: true
input "room2Motion", "capability.motionSensor", title: "Room 2 Motion Sensor(s)", required: false, multiple: true
input "room3Speaker", "capability.actuator", title: "Room 3 Speaker/Chime(s)", required: false, multiple: true
input "room3Motion", "capability.motionSensor", title: "Room 3 Motion Sensor(s)", required: false, multiple: true
input "room4Speaker", "capability.actuator", title: "Room 4 Speaker/Chime(s)", required: false, multiple: true
input "room4Motion", "capability.motionSensor", title: "Room 4 Motion Sensor(s)", required: false, multiple: true
input "room5Speaker", "capability.actuator", title: "Room 5 Speaker/Chime(s)", required: false, multiple: true
input "room5Motion", "capability.motionSensor", title: "Room 5 Motion Sensor(s)", required: false, multiple: true
input "room6Speaker", "capability.actuator", title: "Room 6 Speaker/Chime(s)", required: false, multiple: true
input "room6Motion", "capability.motionSensor", title: "Room 6 Motion Sensor(s)", required: false, multiple: true
input "room7Speaker", "capability.actuator", title: "Room 7 Speaker/Chime(s)", required: false, multiple: true
input "room7Motion", "capability.motionSensor", title: "Room 7 Motion Sensor(s)", required: false, multiple: true
}
section("Security & Nags") {
input "autoResetMidnight", "bool", title: "Auto-clear 'Mail Waiting' status at midnight?", defaultValue: true, description: "If left on overnight, prevents the next delivery from being logged as a retrieval."
input "securityStartTime", "time", title: "Security Start", required: false
input "securityEndTime", "time", title: "Security End", required: false
// Single-time Daily Nag
input "enableNag", "bool", title: "Enable Single Daily Reminder?", defaultValue: false, submitOnChange: true
if (enableNag) {
input "nagTime", "time", title: "Time to check", required: true
input "nagMessage", "text", title: "Nag Message", defaultValue: "Don't forget the mail!", required: true
}
paragraph "
"
// Hourly Mode-Restricted Nag
input "enableHourlyNag", "bool", title: "Enable Hourly Mode-Restricted Reminder?", defaultValue: false, submitOnChange: true
if (enableHourlyNag) {
paragraph "If the mail is active and the hub is in one of the selected modes, this will run every hour with its own dedicated sound/TTS instead of repeating the 'Mail Arrived' alerts."
input "hourlyNagModes", "mode", title: "Only run in these Modes:", multiple: true, required: true
input "ttsHourlyText", "text", title: "Hourly Nag TTS Announcement Text", defaultValue: "The mail is still waiting to be retrieved", required: false
if (zoozChimes) {
input "zoozSoundHourly", "number", title: "Sound File #: Hourly Nag Reminder", required: false
}
}
}
section("System Settings") {
input "activeModes", "mode", title: "Active Modes", multiple: true, required: false
input "btnForceReset", "button", title: "Reset Historical Averages & Logs"
}
}
}
def installed() { initialize() }
def updated() { unsubscribe(); unschedule(); initialize() }
def initialize() {
state.historyLog = state.historyLog ?: []
subscribe(mailSensors, "contact.open", "sensorOpenHandler")
if (tempSensor) {
subscribe(tempSensor, "temperature", "tempHandler")
} else if (outsideTempSensor) {
subscribe(outsideTempSensor, "temperature", "tempHandler")
}
if (exteriorDoors) subscribe(exteriorDoors, "contact.open", "homeActivityHandler")
if (arrivalSensors) subscribe(arrivalSensors, "presence.present", "homeActivityHandler")
if (priorityYieldSwitch) subscribe(priorityYieldSwitch, "switch", "priorityYieldHandler")
if (inovelliSwitches) subscribe(inovelliSwitches, "switch.off", "inovelliMailSwitchOffHandler")
// Subscribe to specific room motion sensors for Audio Logic
if (settings.room1Motion) subscribe(settings.room1Motion, "motion.active", "room1MotionHandler")
if (settings.room2Motion) subscribe(settings.room2Motion, "motion.active", "room2MotionHandler")
if (settings.room3Motion) subscribe(settings.room3Motion, "motion.active", "room3MotionHandler")
if (settings.room4Motion) subscribe(settings.room4Motion, "motion.active", "room4MotionHandler")
if (settings.room5Motion) subscribe(settings.room5Motion, "motion.active", "room5MotionHandler")
if (settings.room6Motion) subscribe(settings.room6Motion, "motion.active", "room6MotionHandler")
if (settings.room7Motion) subscribe(settings.room7Motion, "motion.active", "room7MotionHandler")
schedule("0 0 0 * * ?", "midnightReset")
if (enableNag && nagTime) schedule(nagTime, "nagHandler")
if (enableHourlyNag) runEvery1Hour("hourlyNagHandler")
}
// ------------------------------------------------------------------------------
// AUDIO & 1-TO-1 MOTION HELPER ENGINE
// ------------------------------------------------------------------------------
def room1MotionHandler(evt) { state.lastMotionRoom1 = now() }
def room2MotionHandler(evt) { state.lastMotionRoom2 = now() }
def room3MotionHandler(evt) { state.lastMotionRoom3 = now() }
def room4MotionHandler(evt) { state.lastMotionRoom4 = now() }
def room5MotionHandler(evt) { state.lastMotionRoom5 = now() }
def room6MotionHandler(evt) { state.lastMotionRoom6 = now() }
def room7MotionHandler(evt) { state.lastMotionRoom7 = now() }
def isSpeakerMotionActive(speaker) {
boolean isMapped = false
boolean hasMotion = false
for (int i = 1; i <= 7; i++) {
def mappedSpeaker = settings["room${i}Speaker"]
if (mappedSpeaker) {
def mappedList = mappedSpeaker instanceof List ? mappedSpeaker : [mappedSpeaker]
if (mappedList.any { it.id == speaker.id }) {
isMapped = true
// 1. Check Always On Room
if (settings.alwaysOnRoom && settings.alwaysOnRoom.toString() == i.toString()) {
hasMotion = true
}
// 2. Evaluate Standard Motion
if (!hasMotion) {
def motion = settings["room${i}Motion"]
if (!motion) {
hasMotion = true // Mapped, but no sensor to restrict it
} else {
def mList = motion instanceof List ? motion : [motion]
if (mList.any { it?.currentValue("motion") == "active" }) {
state."lastMotionRoom${i}" = now()
hasMotion = true
} else {
def lastTime = state."lastMotionRoom${i}"
if (lastTime) {
long timeoutMillis = (settings.audioMotionTimeout ?: 5) * 60 * 1000
if ((now() - lastTime) <= timeoutMillis) {
hasMotion = true
}
}
}
}
}
}
}
}
if (!isMapped) return true // Unmapped speakers play unconditionally
return hasMotion
}
def playTTS(speakers, msg) {
if (!speakers || !msg) return
def devList = speakers instanceof List ? speakers : [speakers]
devList.each { dev ->
if (isSpeakerMotionActive(dev)) {
try {
dev.speak(msg)
log.info "Played TTS on ${dev.displayName}: ${msg}"
} catch (e) { log.error "Failed to play TTS: ${e}" }
} else {
log.info "Skipping TTS on ${dev.displayName}: No recent motion."
}
}
}
def playZoozChime(soundNum) {
if (!settings.zoozChimes || soundNum == null) return
def isNumeric = soundNum.toString().isNumber()
def trackNum = isNumeric ? soundNum.toString().toInteger() : null
int playCount = 0
settings.zoozChimes.each { chime ->
if (isSpeakerMotionActive(chime)) {
if (playCount > 0) pauseExecution(1000)
try {
if (chime.hasCommand("playSound") && trackNum != null) {
chime.playSound(trackNum)
} else if (chime.hasCommand("playTrack")) {
chime.playTrack(soundNum.toString())
} else if (chime.hasCommand("chime") && trackNum != null) {
chime.chime(trackNum)
} else {
log.error "${chime.displayName} does not support standard audio/siren commands (playSound, playTrack, or chime)."
}
playCount++
} catch (e) {
log.error "${chime.displayName} failed to play sound: ${e.message ?: e}"
}
} else {
log.info "Skipping Zooz Chime on ${chime.displayName}: No recent motion."
}
}
}
// ------------------------------------------------------------------------------
// APP HANDLERS
// ------------------------------------------------------------------------------
def inovelliMailSwitchOffHandler(evt) {
if (mailSwitch && mailSwitch.currentValue("switch") == "on") {
log.info "Switch '${evt.device.displayName}' turned off, but Mail is still waiting. Re-applying Mail LED indicator."
setLightColor([evt.device], deliveryColor, lightLevel ?: 100, inovelliTarget ?: "All")
}
}
def priorityYieldHandler(evt) {
if (evt.value == "off") {
if (mailSwitch.currentValue("switch") == "on") {
log.info "Priority sequence ended. Mail is waiting. Activating indicators."
addToHistory("RESUMING: Priority ended. Turning on mail lights.")
if (indicatorLight) captureLightState(indicatorLight)
if (overrideSwitch && overrideSwitch.currentValue("switch") != "on") {
overrideSwitch.on()
}
if (indicatorLight) setLightColor(indicatorLight, deliveryColor, lightLevel ?: 100, "All")
if (inovelliSwitches) setLightColor(inovelliSwitches, deliveryColor, lightLevel ?: 100, inovelliTarget ?: "All")
}
}
}
def appButtonHandler(btn) {
if (btn == "btnClearMail") {
log.info "Manually clearing mail status..."
if (mailSwitch) mailSwitch.off()
if (indicatorLight) {
if (retrievalLightAction == "Turn Off") restoreLightState(indicatorLight)
}
if (inovelliSwitches) {
inovelliSwitches.each { device ->
def target = inovelliTarget ?: "All"
if (target == "All") {
if (device.hasCommand("ledEffectAll")) device.ledEffectAll(255, 0, 0, 0)
} else {
if (device.hasCommand("ledEffectOne")) device.ledEffectOne(target, 255, 0, 0, 0)
}
}
}
if (overrideSwitch) overrideSwitch.off()
state.lastValidStateChange = 0
addToHistory("MANUAL CLEAR: System reset via app dashboard.")
} else if (btn == "btnForceReset") {
log.info "Resetting historical data..."
state.historyLog = []
state.avgDeliveryTime = null
state.avgRetrievalTime = null
state.deliveryCount = 0
state.retrievalCount = 0
state.todayDeliveryTime = null
state.todayRetrievalTime = null
state.lastRetrievalWalkTime = null
addToHistory("SYSTEM WIPE: Historical data reset.")
}
}
def homeActivityHandler(evt) { state.lastHomeActivity = new Date().time }
def sensorOpenHandler(evt) {
try {
def now = new Date().time
// 1. BULLETPROOF DUAL-SENSOR LOCK
// atomicState writes directly to the DB to prevent milliseconds-apart parallel execution.
def lastEvt = atomicState.lastSensorEvent ?: 0
if ((now - lastEvt) < 5000) return
atomicState.lastSensorEvent = now
def switchState = mailSwitch?.currentValue("switch") ?: "off"
def tz = location.timeZone ?: TimeZone.getDefault()
// 2. HARD LOCKOUT CHECK (For bouncing doors)
def lastStateChange = state.lastValidStateChange ?: 0
def lockoutMillis = (deliveryLockout != null ? deliveryLockout.toInteger() : 2) * 60000
if ((now - lastStateChange) < lockoutMillis) {
addToHistory("DIAGNOSTIC: Ignored. Opened during ${deliveryLockout}m lockout.")
return
}
// 3. DELIVERY WINDOW RESTRICTION CHECK (Re-written for raw time math)
if (switchState != "on" && enableDeliveryWindow && deliveryStartTime && deliveryEndTime) {
try {
def startTime = timeToday(deliveryStartTime, tz).time
def endTime = timeToday(deliveryEndTime, tz).time
def isWithinWindow = false
if (endTime < startTime) {
// Window crosses midnight
isWithinWindow = (now >= startTime || now <= endTime)
} else {
// Standard window
isWithinWindow = (now >= startTime && now <= endTime)
}
if (!isWithinWindow) {
addToHistory("IGNORED: Opened outside of delivery window.")
return
}
} catch (timeErr) {
log.error "Time parse error: ${timeErr}"
addToHistory("ERROR: Time check failed. Processing anyway to prevent missed mail.")
}
}
// --- PAST ALL GATES: PROCESS THE EVENT ---
def currentTimeStr = new Date().format("h:mm a", tz)
def currentMinutes = getMinutesSinceMidnight(new Date(), tz)
if (switchState == "on") {
// --- MAIL RETRIEVAL LOGIC ---
if (enableSecondaryCheck && (exteriorDoors || arrivalSensors)) {
def lastActivity = state.lastHomeActivity ?: 0
def window = (activityTimeWindow ?: 10) * 60000
if ((now - lastActivity) > window) {
state.lastValidStateChange = now
if (sendPushDelivery) sendMessage("📫 More mail was delivered!")
if (settings.zoozChimes && settings.zoozSoundMore != null) {
playZoozChime(settings.zoozSoundMore)
}
addToHistory("SECONDARY DELIVERY: No home activity detected.")
return
}
}
def tripTimeStr = ""
if ((exteriorDoors || arrivalSensors) && state.lastHomeActivity) {
def timeDiff = now - state.lastHomeActivity
if (timeDiff <= 900000) {
def totalSecs = Math.round(timeDiff / 1000).toInteger()
state.lastRetrievalWalkTime = totalSecs
tripTimeStr = " (Trip Time: ${formatSeconds(totalSecs)})"
}
}
mailSwitch.off()
state.lastValidStateChange = now
state.todayRetrievalTime = currentTimeStr
updateAverage("retrieval", currentMinutes)
addToHistory("RETRIEVAL DETECTED.${tripTimeStr}")
if (retrievalLightAction == "Turn Off") {
if (indicatorLight) restoreLightState(indicatorLight)
if (inovelliSwitches) {
inovelliSwitches.each { device ->
def target = inovelliTarget ?: "All"
if (target == "All") {
if (device.hasCommand("ledEffectAll")) device.ledEffectAll(255, 0, 0, 0)
} else {
if (device.hasCommand("ledEffectOne")) device.ledEffectOne(target, 255, 0, 0, 0)
}
}
}
}
if (overrideSwitch) overrideSwitch.off()
if (sendPushRetrieval) sendMessage("📬 Mail retrieved!")
if (ttsSpeakers && ttsRetrievalText) playTTS(ttsSpeakers, ttsRetrievalText)
if (settings.zoozChimes && settings.zoozSoundRetrieval != null) {
playZoozChime(settings.zoozSoundRetrieval)
}
} else {
// --- MAIL DELIVERY LOGIC ---
mailSwitch.on()
state.lastValidStateChange = now
state.todayDeliveryTime = currentTimeStr
updateAverage("delivery", currentMinutes)
addToHistory("DELIVERY DETECTED.")
if (sendPushDelivery) sendMessage("📫 Mail delivered!")
if (ttsSpeakers && ttsDeliveryText) playTTS(ttsSpeakers, ttsDeliveryText)
if (settings.zoozChimes && settings.zoozSoundDelivery != null) {
playZoozChime(settings.zoozSoundDelivery)
}
if (priorityYieldSwitch && priorityYieldSwitch.currentValue("switch") == "on") {
addToHistory("YIELD: Priority sequence active. Delaying lights.")
return
}
if (indicatorLight) captureLightState(indicatorLight)
if (overrideSwitch && overrideSwitch.currentValue("switch") != "on") {
overrideSwitch.on()
}
if (indicatorLight) setLightColor(indicatorLight, deliveryColor, lightLevel ?: 100, "All")
if (inovelliSwitches) setLightColor(inovelliSwitches, deliveryColor, lightLevel ?: 100, inovelliTarget ?: "All")
}
} catch (Exception e) {
log.error "Mail Monitor CRITICAL ERROR: ${e.message}"
try { addToHistory("CRITICAL ERROR: ${e.message}") } catch(e2) {}
}
}
// === STATE CAPTURE ENGINE ---
def captureLightState(devices) {
if (!state.savedLightStates) state.savedLightStates = [:]
devices.each { dev ->
state.savedLightStates[dev.id] = [
switch: dev.currentValue("switch"),
hue: dev.currentValue("hue"),
saturation: dev.currentValue("saturation"),
level: dev.currentValue("level"),
colorTemperature: dev.currentValue("colorTemperature")
]
log.info "Captured previous state for ${dev.displayName}: ${state.savedLightStates[dev.id]}"
}
}
def restoreLightState(devices) {
if (!state.savedLightStates) return
devices.each { dev ->
def saved = state.savedLightStates[dev.id]
if (saved) {
if (saved.switch == "on") {
if (saved.colorTemperature) {
dev.setColorTemperature(saved.colorTemperature, saved.level)
} else if (saved.hue != null && saved.saturation != null) {
dev.setColor([hue: saved.hue, saturation: saved.saturation, level: saved.level])
} else {
dev.on()
if (saved.level) dev.setLevel(saved.level)
}
log.info "Restored ${dev.displayName} to ON state."
} else {
dev.off()
log.info "Restored ${dev.displayName} to OFF state."
}
} else {
dev.off()
}
}
state.savedLightStates = [:]
}
def setLightColor(devices, colorName, level, target = "All") {
def inovelliHue = 0
def standardHue = 0
def standardSat = 100
switch(colorName) {
case "White": inovelliHue = 255; standardSat = 0; break
case "Red": inovelliHue = 0; standardHue = 0; break
case "Green": inovelliHue = 85; standardHue = 33; break
case "Blue": inovelliHue = 170; standardHue = 66; break
case "Yellow": inovelliHue = 42; standardHue = 16; break
case "Orange": inovelliHue = 14; standardHue = 10; break
case "Purple": inovelliHue = 191; standardHue = 75; break
case "Pink": inovelliHue = 234; standardHue = 83; break
}
devices.each { device ->
if (device.hasCommand("ledEffectAll") || device.hasCommand("ledEffectOne")) {
if (target == "All") {
if (device.hasCommand("ledEffectAll")) device.ledEffectAll(1, inovelliHue, level as Integer, 255)
} else {
if (device.hasCommand("ledEffectOne")) device.ledEffectOne(target, 1, inovelliHue, level as Integer, 255)
}
} else {
device.on()
device.setColor([hue: standardHue, saturation: standardSat, level: level as Integer])
}
}
}
def tempHandler(evt) {
def currentTemp = evt.numericValue ?: evt.value.toDouble()
if (evt.device.id == outsideTempSensor?.id) currentTemp += (tempOffset ?: 20)
if (mailSwitch.currentValue("switch") == "on" && currentTemp >= (tempThreshold ?: 90)) {
def today = new Date().format("yyyy-MM-dd", location.timeZone ?: TimeZone.getDefault())
if (state.lastTempAlertDate != today) {
sendMessage("🌡️ Warning: Box is estimated to be ${currentTemp}°. Get mail soon!")
state.lastTempAlertDate = today
}
}
}
def nagHandler() {
if (enableNag && mailSwitch?.currentValue("switch") == "on") {
log.info "Nag scheduled task running: Mail has not been retrieved."
def msg = nagMessage ?: "Don't forget the mail!"
sendMessage(msg)
if (ttsSpeakers) playTTS(ttsSpeakers, msg)
addToHistory("NAG ALERT: Reminder sent.")
}
}
def hourlyNagHandler() {
if (enableHourlyNag && mailSwitch?.currentValue("switch") == "on") {
def currentMode = location.mode
def allowedModes = hourlyNagModes ?: []
if (allowedModes.contains(currentMode)) {
log.info "Hourly Mode-Restricted Nag executing in mode: ${currentMode}"
if (ttsSpeakers && ttsHourlyText) {
playTTS(ttsSpeakers, ttsHourlyText)
}
if (settings.zoozChimes && settings.zoozSoundHourly != null) {
playZoozChime(settings.zoozSoundHourly)
}
addToHistory("HOURLY NAG ALERT: Announcement triggered.")
} else {
log.info "Skipping hourly nag because current mode '${currentMode}' is not selected."
}
}
}
def sendMessage(msg) { settings.pushDevices ? settings.pushDevices*.deviceNotification(msg) : sendPush(msg) }
def updateAverage(type, currentMinutes) {
def count = state."${type}Count" ?: 0
def currentAvg = state."avg${type.capitalize()}Time" ?: currentMinutes
state."avg${type.capitalize()}Time" = ((currentAvg * count) + currentMinutes) / (count + 1)
state."${type}Count" = count + 1
}
def getMinutesSinceMidnight(date, tz) {
return (new Date(date.time).format("H", tz).toInteger() * 60) + new Date(date.time).format("m", tz).toInteger()
}
def minutesToTimeStr(minutesNum) {
if (!minutesNum) return "--:-- --"
int totalMins = Math.round(minutesNum.toDouble()).toInteger()
int h = (totalMins / 60).toInteger()
int m = totalMins % 60
def ampm = h >= 12 ? "PM" : "AM"
h = h % 12 ?: 12
return "${h}:${m < 10 ? '0'+m : m} ${ampm}"
}
def formatSeconds(totalSecs) {
if (!totalSecs) return "--"
int m = (totalSecs / 60).toInteger()
int s = totalSecs % 60
if (m > 0) return "${m}m ${s}s"
return "${s}s"
}
def addToHistory(msg) {
def timestamp = new Date().format("MM/dd HH:mm:ss", location.timeZone ?: TimeZone.getDefault())
state.historyLog.add(0, "[${timestamp}] ${msg}")
if (state.historyLog.size() > 20) state.historyLog = state.historyLog.take(20)
}
def midnightReset() {
state.todayDeliveryTime = state.todayRetrievalTime = state.lastTempAlertDate = null
state.lastRetrievalWalkTime = null
if (mailSwitch.currentValue("switch") == "on") {
if (autoResetMidnight != false) {
addToHistory("SYSTEM RESET: Mail left overnight. Auto-clearing status.")
mailSwitch.off()
if (indicatorLight && retrievalLightAction == "Turn Off") restoreLightState(indicatorLight)
if (inovelliSwitches) {
inovelliSwitches.each { device ->
def target = inovelliTarget ?: "All"
if (target == "All") {
if (device.hasCommand("ledEffectAll")) device.ledEffectAll(255, 0, 0, 0)
} else {
if (device.hasCommand("ledEffectOne")) device.ledEffectOne(target, 255, 0, 0, 0)
}
}
}
if (overrideSwitch) overrideSwitch.off()
state.lastValidStateChange = new Date().time
} else {
addToHistory("SYSTEM RESET: Mail left overnight. Status retained.")
}
}
}