/**
* Advanced Trash Day Reminder
*
* Author: ShaneAllen
*
* Version 1.5
*/
definition(
name: "Advanced Trash Day Reminder",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Advanced trash day scheduling with predictive AI, omni-axis spatial tracking, Utility Financial Auditing, and Bi-Weekly Recycling logic.",
category: "Convenience",
iconUrl: "",
iconX2Url: "",
iconX3Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "
📊 Advanced Trash Operations Center
", install: true, uninstall: true) {
section() {
paragraph "App Version: 1.5.0
"
}
section("🖥️ REAL-TIME SYSTEM MONITOR
") {
checkSensorHealth()
def dashHTML = getDashHTML()
paragraph dashHTML
}
section("⚙️ SYSTEM CONTROL PANEL
") {
// Row 1: System Actions (12 columns total: 4 + 4 + 4)
input "btnRefreshData", "button", title: "🔄 Refresh Dashboard", width: 4
input "btnCalibrate", "button", title: "📐 Calibrate Baseline", width: 4
input "btnCreateChild", "button", title: "🖥️ Re-link Tile", width: 4
// Row 2: State Overrides (12 columns total: 3 + 3 + 3 + 3)
input "btnSetHouse", "button", title: "🏠 Set: House", width: 3
input "btnSetCurbFull", "button", title: "📦 Set: Curb (Full)", width: 3
input "btnSetCurbEmptied", "button", title: "✅ Set: Curb (Empty)", width: 3
input "btnSetMissed", "button", title: "⚠️ Set: Missed", width: 3
// Row 3: Resets & Modifiers (12 columns total: 4 + 4 + 4)
input "btnResetAI", "button", title: "🧠 Reset AI Memory", width: 4
input "btnResetFinance", "button", title: "💳 Reset Ledger", width: 4
input "btnHoliday", "button", title: state.holidayShift ? "Cancel Holiday Shift" : "Force Holiday Shift", width: 4
// Row 4: Maintenance (12 columns)
input "btnWashBin", "button", title: "🧽 Clear Hygiene Status (Log Wash)", width: 12
}
// --- PREFERENCES DROP MENUS (Native Hubitat Hideable Sections) ---
section("🔊 Voice Butler Telemetry Broadcast Integration", hideable: true, hidden: true) {
paragraph "Broadcast status telemetry streams and operational threshold disruptions directly to your Voice Butler ecosystem."
input "sendToButler", "bool", title: "Enable Voice Butler Streams?", defaultValue: true, submitOnChange: true
}
section("💳 Utility Financial Auditing Ledger", hideable: true, hidden: true) {
paragraph "Apportion quarterly processing invoices to calculate spatial degradation refunds when service providers fail pickup deadlines."
input "quarterlyCost", "decimal", title: "Quarterly Waste Disposal Cost (\$)", description: "e.g., 90.00", required: false, submitOnChange: true
}
section("🧠 Predictive AI Scheduling & Recycling Profiles", hideable: true, hidden: true) {
input "usePredictiveTiming", "bool", title: "Enable AI Telemetry Collection (Predictive Timing)", defaultValue: true, submitOnChange: true
input "enableRecycling", "bool", title: "Enable Bi-Weekly Alternating Recycling Matrix", defaultValue: false, submitOnChange: true
if (enableRecycling) {
input "recycleWeek", "enum", title: "Target Recycling Matrix Routine Day:", options: ["Even Weeks", "Odd Weeks"], required: true
input "ttsRecycleText", "text", title: "Recycling Multi-Broadcast Announcement String", defaultValue: "Reminder, it is time to take the trash and the recycling out to the road."
}
}
section("🚚 Automated Physical Tracking Acceleration Profiles", hideable: true, hidden: true) {
input "binMultiSensor", "capability.threeAxis", title: "Samsung Multipurpose Sensor Hardware Target", required: true, submitOnChange: true
input "drivewaySurface", "enum", title: "Driveway Surface Friction/Jolt Profile", options: ["Smooth Pavement", "Mixed / Patchy", "Fine Gravel", "Chunky / Rough Gravel", "Custom (Manual Overrides)"], defaultValue: "Smooth Pavement", required: true, submitOnChange: true
if (drivewaySurface == "Custom (Manual Overrides)") {
input "transitMinTime", "number", title: "Minimum Transit Duration Bounds (Seconds)", defaultValue: 10, required: true
input "transitTiltThreshold", "number", title: "Transit Tilt Vector Threshold", defaultValue: 150, required: true
input "lidOpenThreshold", "number", title: "Lid Inversion Vector Threshold", defaultValue: 600, required: true
}
input "estimatedMaxOpens", "number", title: "Calculated Envelope Mass Volumetric Limits (Bags to Full)", defaultValue: 8, required: true
}
section("⛈️ Extreme Weather Overrides & Structural Failsafes", hideable: true, hidden: true) {
paragraph "Virtual input sensors block data fluctuations during highly kinetic weather incidents."
input "swTornado", "capability.switch", title: "Tornado Incident Emergency Switch", required: false
input "swThunderstorm", "capability.switch", title: "Severe Thunderstorm Alarm Switch", required: false
input "swRain", "capability.switch", title: "Active Precipitation Sensor Switch", required: false
input "swSprinkle", "capability.switch", title: "Light Precipitation Sensor Switch", required: false
input "enableFallenBin", "bool", title: "Enable Structural Dislocation (Fallen Bin) Notifications", defaultValue: true, submitOnChange: true
if (enableFallenBin) {
input "rainSensor", "capability.waterSensor", title: "Hardware Precipitation Subsystem Input", required: false
input "weatherStation", "capability.sensor", title: "Meteorological Terminal Input (Wind)", required: false
input "windThreshold", "number", title: "Velocity Cutoff Threshold (MPH)", defaultValue: 15, required: false
}
}
section("🏡 Municipal & HOA Compliance Automation", hideable: true, hidden: true) {
input "enableHoaNag", "bool", title: "Enable HOA Return Post-Collection Mandate Nag", defaultValue: false, submitOnChange: true
if (enableHoaNag) {
input "hoaNagTime", "number", title: "Post-Disposal Buffer Delay (Hours before Nag):", defaultValue: 4, required: true
input "ttsHoaText", "text", title: "HOA Post-Disposal Multi-Broadcast Compliance String", defaultValue: "The garbage truck has collected the trash. Please return the bin to the house."
}
}
section("📅 Automated Municipal Holiday Shifting Parameters", hideable: true, hidden: true) {
input "autoHoliday", "bool", title: "Enable Automatic Calendar Interruption Routing", defaultValue: true, submitOnChange: true
if (autoHoliday) {
input "selectedHolidays", "enum", title: "Recognized Interruption Holidays", options: ["New Year's Day", "Memorial Day", "Independence Day", "Labor Day", "Thanksgiving", "Christmas"], multiple: true, required: true, defaultValue: ["New Year's Day", "Memorial Day", "Independence Day", "Labor Day", "Thanksgiving", "Christmas"]
}
}
section("⏰ Core Schedule Mapping & Warning Offsets", hideable: true, hidden: true) {
input "trashDays", "enum", title: "Municipal Disposal Sequence Target Day(s)", options: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], multiple: true, required: true, submitOnChange: true
input "pickupTime", "time", title: "Baseline Routine Static Target Time", required: true, submitOnChange: true
input "reminderOffset", "decimal", title: "Notification Sequence Offset Target (Hours Early)", required: true, defaultValue: 12.0, submitOnChange: true
}
section("🔔 Audio Broadcasters & Operational Restrictions", hideable: true, hidden: true) {
input "trashSwitch", "capability.switch", title: "Task Monitor Virtual Switch Entity", required: false
input "enableNag", "bool", title: "Loop Alert Broadcast Every Hour Until Virtual Switch Reset?", defaultValue: false
paragraph "Operational Restriction Constraints"
input "pushModes", "mode", title: "Allowed Modes: Push Text Stream", multiple: true, required: false
input "audioModes", "mode", title: "Allowed Modes: Voice TTS/Chime Engine", multiple: true, required: false
input "nagModes", "mode", title: "Allowed Modes: Hourly Nags Sequence", multiple: true, required: false
input "trackingModes", "mode", title: "Allowed Modes: Kinetic Tracking Processing", multiple: true, required: false
paragraph "Hardware Audio Interfaces"
input "notifyDevice", "capability.notification", title: "Push System Target Devices", required: false, multiple: true
input "ttsSpeakers", "capability.speechSynthesis", title: "Smart Audio Speakers (Fallback Audio Output)", multiple: true, required: false
input "ttsReminderText", "text", title: "Standard Dispatch Text Template", defaultValue: "Reminder, it is time to take the trash out to the road."
input "ttsEmptiedText", "text", title: "Disposal Cleared Event Text Template", defaultValue: "The garbage truck has emptied your bin."
input "ttsReturnedText", "text", title: "Return Event Home Text Template", defaultValue: "The trash bin has been returned to the house."
paragraph "Zooz Multi-Chime Direct Controls"
input "zoozChimes", "capability.chime", title: "Zooz Physical Chime Audio Arrays", multiple: true, required: false, submitOnChange: true
if (zoozChimes) {
input "zoozSoundReminder", "number", title: "Chime Target Index: Standard Dispatch Reminder", required: false
input "zoozSoundEmptied", "number", title: "Chime Target Index: Disposal Cleared Event", required: false
input "zoozSoundReturned", "number", title: "Chime Target Index: Return Event Home", required: false
}
}
section("📝 Operational Logging & Execution History Stack", hideable: true, hidden: true) {
input "txtEnable", "bool", title: "Enable Application Diagnostic Log Streams", defaultValue: true
if (state.actionHistory) {
def historyStr = state.actionHistory.join("
")
paragraph "${historyStr}
"
}
}
}
}
// ==============================================================================
// INTERNAL LOGIC ENGINE
// ==============================================================================
def installed() { logInfo("Installed"); initialize() }
def updated() { logInfo("Updated"); unsubscribe(); unschedule(); initialize() }
def initialize() {
if (!state.actionHistory) state.actionHistory = []
if (!state.binStatus) state.binStatus = "house"
if (state.holidayShift == null) state.holidayShift = false
if (state.autoHolidayTriggered == null) state.autoHolidayTriggered = false
if (!state.lidOpens) state.lidOpens = 0
if (!state.lastWashed) state.lastWashed = now()
if (!state.maxTempSinceWash) state.maxTempSinceWash = 70
if (state.isSensorDead == null) state.isSensorDead = false
if (!state.lifetimeMissedHours) state.lifetimeMissedHours = 0.0
if (state.queuedReminderTime == null) state.queuedReminderTime = null
if (!state.historyPutOut) state.historyPutOut = []
if (!state.historyEmptied) state.historyEmptied = []
if (!state.historyReturned) state.historyReturned = []
if (!state.historyFullness) state.historyFullness = []
subscribe(location, "mode", modeChangeHandler)
if (trashSwitch) subscribe(trashSwitch, "switch.off", ackHandler)
if (binMultiSensor) {
subscribe(binMultiSensor, "threeAxis", axisSpatialHandler)
subscribe(binMultiSensor, "acceleration.active", binMoveActiveHandler)
subscribe(binMultiSensor, "acceleration.inactive", binMoveInactiveHandler)
subscribe(binMultiSensor, "temperature", tempHandler)
}
schedule("0 5 0 * * ?", updateSchedule)
runEvery1Hour("nagCheck")
updateSchedule()
updateHygiene()
checkSensorHealth()
pushChildUpdate()
logAction("App Initialized.")
}
def appButtonHandler(btn) {
if (btn == "btnRefreshData") {
checkSensorHealth()
logAction("MANUAL: Dashboard data refreshed.")
} else if (btn == "btnSetHouse") {
state.binStatus = "house"
state.lidOpens = 0
unschedule("hoaNagHandler")
logAction("MANUAL OVERRIDE: Bin forced to 'House'.")
} else if (btn == "btnSetCurbFull") {
state.binStatus = "curb_full"
if (trashSwitch && trashSwitch.currentValue("switch") != "off") trashSwitch.off()
state.isNagging = false
unschedule("hoaNagHandler")
logAction("MANUAL OVERRIDE: Bin forced to 'At Curb (Full)'.")
} else if (btn == "btnSetCurbEmptied") {
state.binStatus = "curb_emptied"
state.lidOpens = 0
if (enableHoaNag && hoaNagTime) runIn((hoaNagTime as Integer) * 3600, "hoaNagHandler")
logAction("MANUAL OVERRIDE: Bin forced to 'At Curb (Emptied)'.")
} else if (btn == "btnSetMissed") {
state.binStatus = "curb_missed"
state.missedTime = now()
unschedule("hoaNagHandler")
logAction("MANUAL OVERRIDE: Bin forced to 'Missed Pickup'.")
} else if (btn == "btnResetFinance") {
state.lifetimeMissedHours = 0.0
logAction("FINANCIAL AUDIT: Ledger reset to \$0.00.")
} else if (btn == "btnResetAI") {
state.historyEmptied = []
state.predictiveActive = false
updateSchedule()
logAction("SYSTEM OVERRIDE: Predictive AI History Cleared. Resetting to baseline schedule.")
} else if (btn == "btnHoliday") {
state.holidayShift = !state.holidayShift
logAction("MANUAL: Holiday Shift " + (state.holidayShift ? "Activated" : "Deactivated"))
updateSchedule()
} else if (btn == "btnWashBin") {
state.lastWashed = now()
def tempVal = binMultiSensor?.currentValue("temperature")
state.maxTempSinceWash = tempVal != null ? tempVal : 70
updateHygiene()
logAction("MAINTENANCE: Bin hygiene manually reset to 'Clean'.")
} else if (btn == "btnCalibrate") {
calibrateSpatialBaseline()
} else if (btn == "btnCreateChild") {
def childDev = getChildDevice("trash_dash_${app.id}")
if (!childDev) {
try {
childDev = addChildDevice("hubitat", "Virtual Device", "trash_dash_${app.id}", null, [name: "Trash Dashboard", label: "Trash Dashboard Tile"])
logAction("SYSTEM: Virtual Dashboard Device Created successfully.")
} catch (e) { log.error "Failed to create child device. ${e}" }
} else {
logAction("SYSTEM: Virtual Dashboard Device already exists.")
}
}
pushChildUpdate()
}
// ------------------------------------------------------------------------------
// BUTLER TELEMETRY & NOTIFICATION ENGINE
// ------------------------------------------------------------------------------
def sendAlert(msg, forceButlerStash = false) {
if (notifyDevice && isPushAllowed()) {
notifyDevice*.deviceNotification(msg)
}
if (settings.sendToButler) {
def stashPayload = forceButlerStash ? msg.toLowerCase() : ""
sendLocationEvent(name: "voiceButlerMsg", value: "trash", descriptionText: msg, data: stashPayload, isStateChange: true)
} else {
if (ttsSpeakers) playTTS(ttsSpeakers, msg)
}
}
def syncButlerDashboard() {
if (settings.sendToButler) {
def payload = [
status: getHumanReadableStatus(),
fill: getFillPct(),
hygiene: state.hygieneStatus ?: "Clean ✨",
nextPickup: state.nextPickupStr ?: "Calculating..."
]
sendLocationEvent(name: "voiceButlerTrashSync", value: groovy.json.JsonOutput.toJson(payload), isStateChange: true)
}
}
// ------------------------------------------------------------------------------
// HIGH-END HIGH-CONTRAST GRID DASHBOARD GENERATOR
// ------------------------------------------------------------------------------
def pushChildUpdate() {
def childDev = getChildDevice("trash_dash_${app.id}")
if (childDev) {
def html = getDashHTML()
childDev.sendEvent(name: "htmlTile", value: html, descriptionText: "Updated Dashboard HTML")
}
syncButlerDashboard()
}
String getDashHTML() {
def nPickup = state.nextPickupStr ?: "Recalculating Operational Target..."
def isRecycleWeek = checkRecyclingWeek()
def recycleStr = isRecycleWeek ? "Active Vector (Trash + Recycling)" : "Standard Sequence (Trash Only)"
def schedMode = state.predictiveActive ? "PREDICTIVE AI" : "STATIC SCHED"
def headerStyle = "background: linear-gradient(135deg, #2c3e50, #34495e); color: white;"
def humanStatus = "System Operational Initialization"
if (state.isNagging || (trashSwitch && trashSwitch.currentValue("switch") == "on")) {
headerStyle = "background: linear-gradient(135deg, #c0392b, #e74c3c); color: white; animation: pulse 2s infinite;"
humanStatus = "PENDING DISPATCH: Deploy container to curb side zone immediately."
} else if (state.binStatus == "curb_full") {
headerStyle = "background: linear-gradient(135deg, #d35400, #f39c12); color: white;"
humanStatus = "CONTAINER AT CURB: Awaiting processing fleet intercept."
} else if (state.binStatus == "curb_emptied") {
headerStyle = "background: linear-gradient(135deg, #27ae60, #2ecc71); color: white;"
humanStatus = "DISPOSAL VERIFIED: Container structural matrix cleared. Return to base."
} else if (state.binStatus == "curb_missed") {
headerStyle = "background: linear-gradient(135deg, #7f8c8d, #34495e); color: white;"
humanStatus = "SLA DISRUPTION REPORTED: Fleet missed scheduled intercept window."
} else if (state.binStatus == "house") {
headerStyle = "background: linear-gradient(135deg, #2c3e50, #2980b9); color: white;"
humanStatus = "CONTAINER SECURED AT STATION: Holding resting baseline posture."
}
def battLvl = binMultiSensor ? binMultiSensor.currentValue("battery") : null
def battDisplay = battLvl != null ? (battLvl <= 15 ? "${battLvl}% (LOW)" : "${battLvl}%") : "Offline"
def sensorHealthStr = state.isSensorDead ? "HARDWARE DISCONNECTED" : "ONLINE & TELEMETRIC"
int fillPct = getFillPct()
def fillBarColor = "#2ecc71"
if (fillPct > 85) fillBarColor = "#e74c3c"
else if (fillPct > 50) fillBarColor = "#f1c40f"
def ecoScore = getEcoScoreBadge()
def hygStatus = state.hygieneStatus ?: "Clean ✨"
def refundStr = calculateRefundOwed()
return """
INTERCEPT TARGET TIMELINE
${nPickup}
${recycleStr}
VOLUME & ENVIRONMENTAL AUDIT
Mass Metrics: ${fillPct}%
Envelope Stack: ${ecoScore}
CONTAINER HYGIENE STATE
${hygStatus}
Substrate Biological Threat Metric
FINANCIAL PERFORMANCE LEDGER
\$${refundStr}
Prorated SLA Violation Recovery
HARDWARE SUBSYSTEM TELEMETRY
${sensorHealthStr}
Power Vector Core: ${battDisplay}
"""
}
// ------------------------------------------------------------------------------
// FINANCIAL MATH & RECYCLING
// ------------------------------------------------------------------------------
boolean checkRecyclingWeek() {
if (!settings.enableRecycling || !settings.recycleWeek) return false
def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault())
int weekNum = cal.get(Calendar.WEEK_OF_YEAR)
boolean isEven = (weekNum % 2 == 0)
if (settings.recycleWeek == "Even Weeks" && isEven) return true
if (settings.recycleWeek == "Odd Weeks" && !isEven) return true
return false
}
String calculateRefundOwed() {
if (!settings.quarterlyCost || !state.lifetimeMissedHours) return "0.00"
def qCost = settings.quarterlyCost.toDouble()
def dailyRate = qCost / 91.25
def hourlyRate = dailyRate / 24.0
def refund = (state.lifetimeMissedHours.toDouble() * hourlyRate)
return String.format("%.2f", refund)
}
def hoaNagHandler() {
if (state.binStatus == "curb_emptied") {
logAction("HOA COMPLIANCE: Sending return nag.")
def msg = settings.ttsHoaText ?: "The garbage truck has collected the trash. Please return the bin to the house."
sendAlert(msg, false)
if (zoozChimes && zoozSoundReturned != null) playZoozChime(zoozSoundReturned)
}
}
// ------------------------------------------------------------------------------
// MODE OPERATIONS FILTERING
// ------------------------------------------------------------------------------
boolean isPushAllowed() { return !settings.pushModes || (settings.pushModes as List).contains(location.mode) }
boolean isAudioAllowed() { return !settings.audioModes || (settings.audioModes as List).contains(location.mode) }
boolean isNagAllowed() { return !settings.nagModes || (settings.nagModes as List).contains(location.mode) }
boolean isTrackingAllowed() { return !settings.trackingModes || (settings.trackingModes as List).contains(location.mode) }
int getFillPct() {
int maxOpens = settings.estimatedMaxOpens ?: 8
int currentOpens = state.lidOpens ?: 0
return Math.min(Math.round((currentOpens / maxOpens) * 100), 100)
}
def checkSensorHealth() {
boolean isDead = false
if (binMultiSensor) {
def lastEvt = binMultiSensor.currentState("temperature")?.date
if (lastEvt) {
long hrsSince = (now() - lastEvt.time) / 3600000
if (hrsSince > 48) isDead = true
} else { isDead = true }
}
state.isSensorDead = isDead
}
def tempHandler(evt) {
state.isSensorDead = false
def t = evt.numericValue
if (t != null && t > (state.maxTempSinceWash ?: 0)) state.maxTempSinceWash = t
}
def updateHygiene() {
long daysSince = state.lastWashed ? ((now() - state.lastWashed) / 86400000) as Integer : 0
int maxT = state.maxTempSinceWash ?: 70
String status = "Clean ✨"
if (daysSince > 30 || (maxT > 90 && daysSince > 14)) status = "Bio-Hazard ☣️"
else if (daysSince > 21 || (maxT > 85 && daysSince > 10)) status = "Gross 🤢"
else if (daysSince > 14 || (maxT > 80 && daysSince > 7)) status = "Needs Washing 🧽"
if (status != state.hygieneStatus && (status == "Bio-Hazard ☣️" || status == "Gross 🤢")) {
sendAlert("Maintenance alert. Your exterior waste bin has reached a hygiene level of ${status.replaceAll(/[^a-zA-Z -]/, '')}. Please sanitize it soon.", true)
}
state.hygieneStatus = status
pushChildUpdate()
}
def recordTelemetryTime(String stateKey) {
def tz = location.timeZone ?: TimeZone.getDefault()
def cal = Calendar.getInstance(tz)
cal.setTime(new Date())
int minutes = (cal.get(Calendar.HOUR_OF_DAY) * 60) + cal.get(Calendar.MINUTE)
def list = state[stateKey] ?: []
list.add(0, minutes)
if (list.size() > 10) list = list[0..9]
state[stateKey] = list
}
String getEcoScoreBadge() {
def hist = state.historyFullness ?: []
if (hist.size() == 0) return "Awaiting Processing Matrix Cycles"
int avgFill = Math.round(hist.sum() / hist.size()) as Integer
if (avgFill <= 25) return "Rating A+ [Eco-Warrior]"
if (avgFill <= 50) return "Rating A [Optimal]"
if (avgFill <= 75) return "Rating B [Nominal]"
if (avgFill <= 99) return "Rating C [Elevated]"
return "Rating F [Saturation]"
}
// ------------------------------------------------------------------------------
// OPERATIONAL BALANCING & CRITICAL PHYSICS OVERRIDES
// ------------------------------------------------------------------------------
boolean isWithinAllowedTransitWindow() {
if (state.binStatus == "curb_missed") return true
if (!state.nextPickupMs) return true
def tz = location.timeZone ?: TimeZone.getDefault()
def nowCal = Calendar.getInstance(tz)
nowCal.setTime(new Date())
def pickupCal = Calendar.getInstance(tz)
pickupCal.setTime(new Date(state.nextPickupMs as Long))
nowCal.set(Calendar.HOUR_OF_DAY, 0); nowCal.set(Calendar.MINUTE, 0); nowCal.set(Calendar.SECOND, 0); nowCal.set(Calendar.MILLISECOND, 0)
pickupCal.set(Calendar.HOUR_OF_DAY, 0); pickupCal.set(Calendar.MINUTE, 0); pickupCal.set(Calendar.SECOND, 0); pickupCal.set(Calendar.MILLISECOND, 0)
long diffDays = (nowCal.getTimeInMillis() - pickupCal.getTimeInMillis()) / 86400000
return (diffDays >= -1 && diffDays <= 1)
}
def calibrateSpatialBaseline() {
if (!binMultiSensor) return
def xyz = binMultiSensor.currentValue("threeAxis")
if (xyz) {
def axes = ["x": xyz.x, "y": xyz.y, "z": xyz.z]
def dominantAxis = axes.max { Math.abs(it.value as Integer) }.key
state.activeAxis = dominantAxis
state.baselineValue = axes[dominantAxis]
state.baselineX = xyz.x as Integer
state.baselineY = xyz.y as Integer
state.baselineZ = xyz.z as Integer
state.isSensorDead = false
logAction("SYSTEM: 3D Calibration complete. Locked to global [${dominantAxis.toUpperCase()}] axis for Truck Dumps.")
pushChildUpdate()
}
}
def getSurfaceProfile() {
def profile = [transitTime: 10, transitTilt: 150, lidTilt: 600]
if (settings.drivewaySurface == "Smooth Pavement") {
profile = [transitTime: 8, transitTilt: 120, lidTilt: 600]
} else if (settings.drivewaySurface == "Mixed / Patchy") {
profile = [transitTime: 12, transitTilt: 150, lidTilt: 600]
} else if (settings.drivewaySurface == "Fine Gravel") {
profile = [transitTime: 15, transitTilt: 180, lidTilt: 600]
} else if (settings.drivewaySurface == "Chunky / Rough Gravel") {
profile = [transitTime: 20, transitTilt: 220, lidTilt: 600]
} else if (settings.drivewaySurface == "Custom (Manual Overrides)") {
profile.transitTime = settings.transitMinTime ?: 10
profile.transitTilt = settings.transitTiltThreshold ?: 150
profile.lidTilt = settings.lidOpenThreshold ?: 600
}
return profile
}
def binMoveActiveHandler(evt) {
state.isSensorDead = false
state.motionStartTime = now()
state.maxRelativeDevDuringMotion = 0
state.wasFlippedDuringMotion = false
state.hasTriggeredMechanicalDump = false
if (state.lastKnownXYZ) {
state.restX = state.lastKnownXYZ.x
state.restY = state.lastKnownXYZ.y
state.restZ = state.lastKnownXYZ.z
}
}
def axisSpatialHandler(evt) {
def xyz = binMultiSensor.currentValue("threeAxis")
if (!xyz) return
if (!state.motionStartTime) {
state.lastKnownXYZ = [x: xyz.x as Integer, y: xyz.y as Integer, z: xyz.z as Integer]
return
}
int curX = xyz.x as Integer
int curY = xyz.y as Integer
int curZ = xyz.z as Integer
int rX = state.restX != null ? state.restX : (state.baselineX ?: 0)
int rY = state.restY != null ? state.restY : (state.baselineY ?: 0)
int rZ = state.restZ != null ? state.restZ : (state.baselineZ ?: 0)
int devX = Math.abs(curX - rX)
int devY = Math.abs(curY - rY)
int devZ = Math.abs(curZ - rZ)
int maxRelative = Math.max(devX, Math.max(devY, devZ))
if (maxRelative > (state.maxRelativeDevDuringMotion ?: 0)) {
state.maxRelativeDevDuringMotion = maxRelative
}
if ((state.binStatus == "curb_full" || state.binStatus == "curb_missed") && maxRelative >= 1800) {
if (!state.isCurrentlyDumped && !state.hasTriggeredMechanicalDump) {
state.hasTriggeredMechanicalDump = true
state.isCurrentlyDumped = true
processTruckDump()
}
}
boolean isFlipped = false
if (state.activeAxis && state.baselineValue != null) {
int currentDom = xyz[state.activeAxis] as Integer
int baselineDom = state.baselineValue as Integer
isFlipped = (baselineDom > 0 && currentDom < -500) || (baselineDom < 0 && currentDom > 500)
if (isFlipped) state.wasFlippedDuringMotion = true
}
if (isFlipped && (state.binStatus == "curb_full" || state.binStatus == "curb_missed")) {
if (!state.isCurrentlyDumped && !state.hasTriggeredMechanicalDump) {
state.isCurrentlyDumped = true
processTruckDump()
}
} else if (!isFlipped && !state.hasTriggeredMechanicalDump) {
state.isCurrentlyDumped = false
}
}
def binMoveInactiveHandler(evt) {
if (settings.enableFallenBin) runIn(300, "fallenBinCheck", [overwrite: true])
if (!state.motionStartTime) return
boolean severeSwitch = (swTornado?.currentValue("switch") == "on" || swThunderstorm?.currentValue("switch") == "on")
if (severeSwitch) {
logAction("WEATHER OVERRIDE: Suppressing physical tracking event.")
state.motionStartTime = null
return
}
long durationSec = (now() - state.motionStartTime) / 1000
def profile = getSurfaceProfile()
int maxTilt = state.maxRelativeDevDuringMotion ?: 0
if (state.hasTriggeredMechanicalDump) {
logAction("Motion Event Ended: Dump completed early via mechanical identification threshold.")
state.motionStartTime = null
state.hasTriggeredMechanicalDump = false
return
}
boolean isCurrentlyFlipped = false
boolean startedFlipped = false
def xyz = binMultiSensor.currentValue("threeAxis")
if (state.activeAxis && state.baselineValue != null) {
int baselineDom = state.baselineValue as Integer
if (xyz) {
int currentDom = xyz[state.activeAxis] as Integer
isCurrentlyFlipped = (baselineDom > 0 && currentDom < -500) || (baselineDom < 0 && currentDom > 500)
}
if (state.lastKnownXYZ) {
int startDom = state.lastKnownXYZ[state.activeAxis] as Integer
startedFlipped = (baselineDom > 0 && startDom < -500) || (baselineDom < 0 && startDom > 500)
}
}
boolean lidWasClosedDuringMotion = (startedFlipped && !isCurrentlyFlipped)
state.motionStartTime = null
logAction("Motion Event Ended: Duration: ${durationSec}s | Max Relative Tilt: ${maxTilt} | Flipped: ${isCurrentlyFlipped}")
if (isCurrentlyFlipped) {
if (maxTilt >= profile.lidTilt && isTrackingAllowed()) {
state.lidOpens = (state.lidOpens ?: 0) + 1
logAction("Action Processed: Trash Toss (Lid Flipped Open). Capacity: (${state.lidOpens}).")
if (getFillPct() >= 100) sendAlert("Warning, the exterior trash bin is now at maximum capacity.", true)
pushChildUpdate()
}
return
}
boolean isValidTransit = false
if (durationSec >= profile.transitTime && maxTilt >= profile.transitTilt) {
if (maxTilt < profile.lidTilt) { isValidTransit = true }
else if (!state.wasFlippedDuringMotion) { isValidTransit = true }
else if (lidWasClosedDuringMotion) { isValidTransit = true }
else if (state.binStatus == "curb_emptied" && durationSec >= 20) { isValidTransit = true }
}
if (isValidTransit) {
if (isTrackingAllowed()) {
logAction("Action Processed: Sustained Transit Matrix Tracked.")
processValidTransit()
}
return
}
if (maxTilt >= profile.lidTilt || (durationSec <= 4 && maxTilt >= 450)) {
if (isTrackingAllowed()) {
state.lidOpens = (state.lidOpens ?: 0) + 1
logAction("Action Processed: Trash Toss (Velocity Snap). Capacity: (${state.lidOpens}).")
if (getFillPct() >= 100) sendAlert("Warning, the exterior trash bin is now at maximum capacity.", true)
pushChildUpdate()
}
return
}
logAction("Action Processed: Ignored bump/wind oscillation.")
}
def fallenBinCheck() {
def xyz = binMultiSensor.currentValue("threeAxis")
if (!xyz || !state.baselineValue || !state.activeAxis) return
int curDom = xyz[state.activeAxis] as Integer
boolean severeSwitch = (swTornado?.currentValue("switch") == "on" || swThunderstorm?.currentValue("switch") == "on")
boolean wetSwitch = (swRain?.currentValue("switch") == "on" || swSprinkle?.currentValue("switch") == "on")
boolean isRaining = wetSwitch || (rainSensor && rainSensor.currentValue("water") == "wet")
def wSpeed = weatherStation ? weatherStation.currentValue("windSpeed") : 0
boolean isWindy = wSpeed && wSpeed.toBigDecimal() >= (settings.windThreshold ?: 15)
if (severeSwitch) return
if (Math.abs(curDom) < 400 && !state.isCurrentlyDumped) {
if (state.binStatus == "curb_emptied") return
if (isRaining || isWindy) {
sendAlert("SEVERE WEATHER ALERT: Your outdoor trash bin was knocked over by the weather.", false)
} else {
sendAlert("BIN ALERT: Your outdoor trash bin is sideways or open. The weather is calm, so check for animals or mishaps.", false)
}
}
}
def processTruckDump() {
if (!isWithinAllowedTransitWindow()) return
logAction("AUTOMATION: Kinetic Mechanical Displacement Inversion Confirmed. Matrix Cleared.")
if (state.binStatus == "curb_missed" && state.missedTime) {
long hrsLate = (now() - state.missedTime) / 3600000
state.lastMissedDuration = hrsLate
state.lifetimeMissedHours = (state.lifetimeMissedHours ?: 0.0) + hrsLate
sendAlert("FINANCIAL TRACKER: Your missed trash was finally collected ${hrsLate} hours late. Ledger updated.", true)
state.missedTime = null
}
def histFull = state.historyFullness ?: []
histFull.add(0, getFillPct())
if (histFull.size() > 10) histFull = histFull[0..9]
state.historyFullness = histFull
recordTelemetryTime("historyEmptied")
state.binStatus = "curb_emptied"
state.lastDumpTime = now()
if (enableHoaNag && hoaNagTime) runIn((hoaNagTime as Integer) * 3600, "hoaNagHandler")
updateSchedule()
def msg = ttsEmptiedText ?: "The garbage truck has emptied your bin."
sendAlert(msg, false)
if (zoozChimes && zoozSoundEmptied != null) playZoozChime(zoozSoundEmptied)
pushChildUpdate()
}
def processValidTransit() {
if (state.binStatus == "house") {
if (!isWithinAllowedTransitWindow()) return
logAction("AUTOMATION: Container moved to curb side perimeter.")
recordTelemetryTime("historyPutOut")
if (trashSwitch && trashSwitch.currentValue("switch") != "off") trashSwitch.off()
state.isNagging = false
state.queuedReminderTime = null
unschedule("playQueuedReminder")
state.binStatus = "curb_full"
pushChildUpdate()
}
else if (state.binStatus == "curb_emptied" || state.binStatus == "curb_missed") {
if (state.binStatus == "curb_emptied" && state.lastDumpTime) {
long msSinceDump = now() - state.lastDumpTime
if (msSinceDump < 120000) return
}
logAction("AUTOMATION: Container safely docked at structural anchor station.")
recordTelemetryTime("historyReturned")
state.binStatus = "house"
state.lidOpens = 0
unschedule("hoaNagHandler")
def msg = ttsReturnedText ?: "The trash bin has been returned to the house."
sendAlert(msg, false)
if (zoozChimes && zoozSoundReturned != null) playZoozChime(zoozSoundReturned)
pushChildUpdate()
}
}
def ackHandler(evt) {
if (state.isNagging) {
state.isNagging = false
state.binStatus = "curb_full"
state.queuedReminderTime = null
unschedule("playQueuedReminder")
logAction("System Acknowledged: Switch turned OFF.")
pushChildUpdate()
}
}
String getHumanReadableStatus() {
if (trashSwitch && trashSwitch.currentValue("switch") == "on" || state.isNagging) return "Pending"
if (state.binStatus == "curb_full") return "At Curb"
if (state.binStatus == "curb_emptied") return "Emptied"
if (state.binStatus == "curb_missed") return "Missed"
return "Secured Base"
}
// ------------------------------------------------------------------------------
// PREDICTIVE SCHEDULING ENGINE
// ------------------------------------------------------------------------------
boolean isHolidayShiftRequired(Calendar targetPickup) {
if (!settings.autoHoliday || !settings.selectedHolidays) return false
int year = targetPickup.get(Calendar.YEAR)
def tz = location.timeZone ?: TimeZone.getDefault()
def validHolidays = settings.selectedHolidays as List
def holidayDates = []
if (validHolidays.contains("New Year's Day")) {
def ny = Calendar.getInstance(tz); ny.set(year, Calendar.JANUARY, 1, 0, 0, 0); ny.set(Calendar.MILLISECOND, 0); holidayDates << ny
}
if (validHolidays.contains("Memorial Day")) {
def mem = Calendar.getInstance(tz); mem.set(year, Calendar.MAY, 31, 0, 0, 0); mem.set(Calendar.MILLISECOND, 0)
while(mem.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) { mem.add(Calendar.DAY_OF_MONTH, -1) }; holidayDates << mem
}
if (validHolidays.contains("Independence Day")) {
def ind = Calendar.getInstance(tz); ind.set(year, Calendar.JULY, 4, 0, 0, 0); ind.set(Calendar.MILLISECOND, 0); holidayDates << ind
}
if (validHolidays.contains("Labor Day")) {
def lab = Calendar.getInstance(tz); lab.set(year, Calendar.SEPTEMBER, 1, 0, 0, 0); lab.set(Calendar.MILLISECOND, 0)
while(lab.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) { lab.add(Calendar.DAY_OF_MONTH, 1) }; holidayDates << lab
}
if (validHolidays.contains("Thanksgiving")) {
def tg = Calendar.getInstance(tz); tg.set(year, Calendar.NOVEMBER, 1, 0, 0, 0); tg.set(Calendar.MILLISECOND, 0)
int thursdays = 0
while(thursdays < 4) {
if (tg.get(Calendar.DAY_OF_WEEK) == Calendar.THURSDAY) thursdays++
if (thursdays < 4) tg.add(Calendar.DAY_OF_MONTH, 1)
}; holidayDates << tg
}
if (validHolidays.contains("Christmas")) {
def xmas = Calendar.getInstance(tz); xmas.set(year, Calendar.DECEMBER, 25, 0, 0, 0); xmas.set(Calendar.MILLISECOND, 0); holidayDates << xmas
}
def weekStart = targetPickup.clone()
while(weekStart.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) { weekStart.add(Calendar.DAY_OF_MONTH, -1) }
weekStart.set(Calendar.HOUR_OF_DAY, 0); weekStart.set(Calendar.MINUTE, 0); weekStart.set(Calendar.SECOND, 0)
def targetEnd = targetPickup.clone()
targetEnd.set(Calendar.HOUR_OF_DAY, 23); targetEnd.set(Calendar.MINUTE, 59)
for (cal in holidayDates) {
if (cal.getTimeInMillis() >= weekStart.getTimeInMillis() && cal.getTimeInMillis() <= targetEnd.getTimeInMillis()) return true
}
return false
}
def updateSchedule() {
checkSensorHealth()
if (!trashDays || !pickupTime || reminderOffset == null) return
try {
def tz = location.timeZone ?: TimeZone.getDefault()
def now = new Date()
def cal = Calendar.getInstance(tz)
cal.setTime(now)
int currentDayNum = cal.get(Calendar.DAY_OF_WEEK)
def parsedTime = timeToday(pickupTime, tz)
def pCal = Calendar.getInstance(tz)
pCal.setTime(parsedTime)
int pHour = pCal.get(Calendar.HOUR_OF_DAY)
int pMin = pCal.get(Calendar.MINUTE)
if (settings.usePredictiveTiming && state.historyEmptied && state.historyEmptied.size() >= 2 && !state.isSensorDead) {
def avgMins = Math.round(state.historyEmptied.sum() / state.historyEmptied.size()) as Integer
pHour = avgMins.intdiv(60)
pMin = avgMins % 60
state.predictiveActive = true
} else { state.predictiveActive = false }
def dayMap = ["Sunday":1, "Monday":2, "Tuesday":3, "Wednesday":4, "Thursday":5, "Friday":6, "Saturday":7]
long nextPickupMs = Long.MAX_VALUE
boolean shiftApplied = false
trashDays.each { dayName ->
int targetDay = dayMap[dayName]
int daysToAdd = targetDay - currentDayNum
if (daysToAdd < 0) daysToAdd += 7
def testCal = Calendar.getInstance(tz)
testCal.setTime(now)
testCal.add(Calendar.DAY_OF_YEAR, daysToAdd)
testCal.set(Calendar.HOUR_OF_DAY, pHour); testCal.set(Calendar.MINUTE, pMin); testCal.set(Calendar.SECOND, 0)
if (daysToAdd == 0 && testCal.getTimeInMillis() <= now.time) testCal.add(Calendar.DAY_OF_YEAR, 7)
if (settings.autoHoliday && isHolidayShiftRequired(testCal)) {
testCal.add(Calendar.DAY_OF_YEAR, 1)
shiftApplied = true
} else if (state.holidayShift) { testCal.add(Calendar.DAY_OF_YEAR, 1) }
if (testCal.getTimeInMillis() < nextPickupMs) nextPickupMs = testCal.getTimeInMillis()
}
state.autoHolidayTriggered = shiftApplied
def pickupDate = new Date(nextPickupMs)
def offsetMs = (reminderOffset.toDouble() * 3600000).toLong()
def reminderDate = new Date(nextPickupMs - offsetMs)
state.nextPickupStr = pickupDate.format("EEEE, MMM d 'at' h:mm a", tz)
state.nextReminderStr = reminderDate.format("EEEE, MMM d 'at' h:mm a", tz)
state.nextPickupMs = nextPickupMs
unschedule("triggerReminder")
unschedule("autoResetHandler")
if (reminderDate.time > now.time) runOnce(reminderDate, "triggerReminder", [overwrite: true])
runOnce(new Date(nextPickupMs + 7200000), "autoResetHandler", [overwrite: true])
pushChildUpdate()
} catch (e) { log.error "Schedule Calculation Error: ${e}" }
}
def triggerReminder() {
checkSensorHealth()
if (state.binStatus == "curb_full" && !state.isSensorDead) {
if (trashSwitch && trashSwitch.currentValue("switch") != "off") trashSwitch.off()
state.isNagging = false
pushChildUpdate()
return
}
if (trashSwitch) trashSwitch.on()
state.isNagging = true
if (state.binStatus != "curb_missed") state.binStatus = "house"
String finalMsg = ttsReminderText ?: "Reminder, it is time to take the trash out to the road."
if (checkRecyclingWeek()) finalMsg = settings.ttsRecycleText ?: "Reminder, it is time to take the trash and recycling out."
sendAlert(finalMsg, false)
if (zoozChimes && zoozSoundReminder != null) playZoozChime(zoozSoundReminder)
pushChildUpdate()
}
def nagCheck() {
checkSensorHealth()
if (binMultiSensor && binMultiSensor.currentValue("battery") != null) {
int batt = binMultiSensor.currentValue("battery") as Integer
if (batt <= 15) {
def tz = location.timeZone ?: TimeZone.getDefault()
if (state.lastBatteryWarningDate != new Date().format("yyyy-MM-dd", tz)) {
sendAlert("WARNING: Your outdoor trash bin sensor battery is low (${batt}%).", true)
state.lastBatteryWarningDate = new Date().format("yyyy-MM-dd", tz)
}
}
}
updateHygiene()
if (!enableNag || !state.isNagging) return
if (trashSwitch && trashSwitch.currentValue("switch") == "off") { state.isNagging = false; return }
if (!isNagAllowed()) return
String finalMsg = ttsReminderText ?: "Don't forget the trash!"
if (checkRecyclingWeek()) finalMsg = settings.ttsRecycleText ?: "Don't forget the trash and recycling!"
sendAlert("NAG: " + finalMsg, false)
if (zoozChimes && zoozSoundReminder != null) playZoozChime(zoozSoundReminder)
pushChildUpdate()
}
def autoResetHandler() {
if (trashSwitch) trashSwitch.off()
state.isNagging = false
if (state.binStatus == "curb_full" && !state.isSensorDead) {
state.binStatus = "curb_missed"
state.missedTime = now()
sendAlert("ALERT: The garbage truck missed your scheduled pickup!", true)
} else {
state.binStatus = "house"
state.lidOpens = 0
}
if (state.holidayShift) state.holidayShift = false
updateSchedule()
}
// ------------------------------------------------------------------------------
// INTERIOR MOTION AUDIO ANNOUNCEMENT HANDLERS
// ------------------------------------------------------------------------------
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 modeChangeHandler(evt) {
if (isAudioAllowed() && state.isNagging) {
state.queuedReminderTime = now() + 1800000
runIn(1800, "playQueuedReminder", [overwrite: true])
pushChildUpdate()
} else if (!isAudioAllowed()) {
unschedule("playQueuedReminder")
state.queuedReminderTime = null
pushChildUpdate()
}
}
def playQueuedReminder() {
state.queuedReminderTime = null
if (state.isNagging && state.binStatus != "curb_full" && state.binStatus != "curb_missed") {
String finalMsg = ttsReminderText ?: "Reminder, it is time to take the trash out to the road."
if (checkRecyclingWeek()) finalMsg = settings.ttsRecycleText ?: "Reminder, it is time to take the trash and recycling out."
sendAlert(finalMsg, false)
if (zoozChimes && zoozSoundReminder != null) playZoozChime(zoozSoundReminder)
}
pushChildUpdate()
}
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
if (settings.alwaysOnRoom && settings.alwaysOnRoom.toString() == i.toString()) hasMotion = true
if (!hasMotion) {
def motion = settings["room${i}Motion"]
if (!motion) { hasMotion = true }
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 && (now() - lastTime) <= ((settings.audioMotionTimeout ?: 5) * 60 * 1000)) hasMotion = true
}
}
}
}
}
}
return !isMapped || hasMotion
}
def playTTS(speakers, msg, forcePlay = false) {
if (!speakers || !msg || (!forcePlay && !isAudioAllowed())) return
def msgList = msg.split(",").collect{ it.trim() }
def selectedMsg = msgList[new Random().nextInt(msgList.size())]
def devList = speakers instanceof List ? speakers : [speakers]
devList.each { dev ->
if (forcePlay || isSpeakerMotionActive(dev)) {
try { dev.speak(selectedMsg) } catch (e) { log.error "Failed to play TTS: ${e}" }
}
}
}
def playZoozChime(soundNum, forcePlay = false) {
if (!settings.zoozChimes || soundNum == null || (!forcePlay && !isAudioAllowed())) return
def trackNum = soundNum.toString().isNumber() ? soundNum.toString().toInteger() : null
int playCount = 0
settings.zoozChimes.each { chime ->
if (forcePlay || 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)
playCount++
} catch (e) { log.error "${chime.displayName} chime command exception: ${e}" }
}
}
}
def logAction(msg) {
if(txtEnable) log.info "${app.label}: ${msg}"
def h = state.actionHistory ?: []
h.add(0, "[${new Date().format("MM/dd hh:mm a", location.timeZone)}] ${msg}")
if(h.size() > 30) h = h[0..29]
state.actionHistory = h
}
def logInfo(msg) { if(txtEnable) log.info "${app.label}: ${msg}" }