/**
* Advanced Voice Butler
*
* Author: ShaneAllen
*
* Version 2.8.1
*/
definition(
name: "Advanced Voice Butler",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Estate Manager TTS orchestrator with AI Habit Tracking, Secrecy Engine, and Organic Intercepts.",
category: "Convenience",
iconUrl: "",
iconX2Url: ""
)
import groovy.transform.Field
@Field static String PREV_STYLE = "margin-top: 15px; padding: 10px; background-color: #e9ecef; border-left: 4px solid #0b3b60; border-radius: 4px; font-size: 13px; line-height: 1.4;"
@Field static String VERSION = "2.8.1"
preferences {
page(name: "mainPage")
page(name: "pageConcierge")
page(name: "pageEstate")
page(name: "pageGuests")
page(name: "pageSecurity")
page(name: "pageHardware")
page(name: "roomPage")
}
mappings {
path("/google/email") { action: [POST: "handleGoogleEmail"] }
path("/google/calendar") { action: [POST: "handleGoogleCalendar"] }
path("/notes") { action: [GET: "serveNotesPage"] }
path("/notes/add") { action: [POST: "addNoteEndpoint"] }
path("/notes/clear") { action: [POST: "clearNotesEndpoint"] }
path("/directory/announce") { action: [POST: "announceDirectoryEndpoint"] }
path("/agenda/update") { action: [POST: "updateAgendaEndpoint"] }
path("/wifi/announce") { action: [POST: "announceWifiEndpoint"] }
path("/pa/announce") { action: [POST: "instantPAEndpoint"] }
path("/presence/depart") { action: [POST: "manualDepartEndpoint"] }
path("/reply/quick") { action: [POST: "quickReplyEndpoint"] }
path("/guest/timer") { action: [POST: "guestTimerEndpoint"] }
// --- RSVP SYSTEM MAPPINGS ---
path("/event/create") { action: [POST: "createEventEndpoint"] }
path("/event/delete") { action: [POST: "deleteEventEndpoint"] }
path("/rsvp") { action: [GET: "serveGuestRsvpPage"] }
path("/rsvp/submit") { action: [POST: "submitRsvpEndpoint"] }
// --- QUICK LOCK CODE MAPPINGS (NEW) ---
path("/lock/create") { action: [POST: "createQuickCodeEndpoint"] }
path("/lock/delete") { action: [POST: "deleteQuickCodeEndpoint"] }
path("/calendar/add") { action: [POST: "addCalendarEventEndpoint"] }
path("/chat") { action: [POST: "butlerChatEndpoint"] }
path("/meals/update") { action: [POST: "updateMealsEndpoint"] }
path("/scout/grocery") { action: [POST: "scoutGroceryEndpoint"] }
path("/scout/detailing") { action: [POST: "scoutDetailingEndpoint"] }
path("/scout/cinema") { action: [POST: "scoutCinemaEndpoint"] }
// --- PORTAL MAPPINGS ---
path("/room/toggle") { action: [POST: "roomToggleEndpoint"] }
path("/staff/clean") { action: [POST: "staffCleanEndpoint"] }
path("/tv/cmd") { action: [POST: "tvCmdEndpoint"] }
}
def getRoutingOptions() {
return [
"Global Indoor Speaker Only",
"Outdoor Speaker Only",
"Outdoor + Global Indoor",
"Dedicated Feature Speaker",
"Follow-Me (Active Rooms Only)",
"Follow-Me + Outdoor",
"Follow-Me + Fallback (Global ONLY if no motion)",
"Follow-Me + Fallback + Outdoor",
"Follow-Me + Global Simultaneous",
"Follow-Me + Global Simultaneous + Outdoor"
]
}
def getLockUsers() {
def lockUsers = []
if (settings.frontDoorLock) {
try {
def lockCodesStr = settings.frontDoorLock.currentValue("lockCodes")
if (lockCodesStr) {
def parsed = new groovy.json.JsonSlurper().parseText(lockCodesStr)
if (parsed instanceof Map) {
lockUsers = parsed.collect { it.value.name ?: it.value }.findAll { it != null }.sort()
}
}
} catch (Exception e) {}
}
return lockUsers
}
def mainPage() {
if (!state.accessToken) {
try { createAccessToken() } catch (Exception e) { log.error "OAuth is not enabled! Please click 'OAuth' at the top of the app code and enable it." }
}
dynamicPage(name: "mainPage", title: "Voice Butler Command Center", install: true, uninstall: true) {
// --- HABIT DATABASE SCRUB ---
if (state.learnedHabits) {
def keysToRemove = []
state.learnedHabits.each { k, v -> if (k.contains(" and ") || k.contains("&") || k.contains(",")) keysToRemove << k }
keysToRemove.each { state.learnedHabits.remove(it) }
}
section("App Info & Web Portals", hideable: false) {
paragraph "Advanced Voice Butler Version: ${VERSION}"
if (state.accessToken) {
def cloudUrl = getFullApiServerUrl()
def localUrl = getFullLocalApiServerUrl()
paragraph "
"
paragraph "Your Webhook Base URL (Cloud): ${cloudUrl}/google?access_token=${state.accessToken}Your Webhook Base URL (Local): ${localUrl}/google?access_token=${state.accessToken}
"
} else {
paragraph "Please enable OAuth to generate the portal links. "
}
}
section("System State & Controls", hideable: false, hidden: false) {
input "btnRefresh", "button", title: "π Refresh Data Dashboard"
input "btnQuickSave", "button", title: "πΎ Quick Save / Refresh Page"
input "btnForceSync", "button", title: "π Force Sync Calendar & News Data", description: "Instantly poll all external API feeds to update the dashboard below."
def dndModesList = [settings.dndModes].flatten().findAll { it != null }
def isDndMode = dndModesList.contains(location.mode)
def isDndSwitch = settings.dndSwitch?.currentValue("switch") == "on"
def isPartySwitch = settings.partyModeSwitch?.currentValue("switch") == "on"
def isMasterOff = settings.masterSwitch?.currentValue("switch") == "off"
def isGuestMode = settings.guestModeSwitch?.currentValue("switch") == "on"
def systemState = ""
if (isMasterOff) systemState = "MUTED (Master Switch OFF) "
else if (isGuestMode) systemState = "SILENT (Guest Mode ON) "
else systemState = "RUNNING AND ACTIVE "
def dndState = ""
if (isPartySwitch && settings.enablePartyMode) dndState = "HOSTING (Party Mode Active) "
else if (isDndSwitch || isDndMode) dndState = "ACTIVE (Do Not Disturb) "
else dndState = "STANDBY (Accepting Visitors) "
def inetStatus = (!settings.enableInternetCheck || state.internetActive != false) ? "ONLINE " : "OFFLINE (TTS Suppressed) "
def queueStatus = state.ttsQueue?.size() > 0 ? "${state.ttsQueue.size()} Messages Queued " : "Idle "
def statusText = ""
statusText += "System State: ${systemState}Perimeter Status: ${dndState}Internet Connection: ${inetStatus}TTS Queue Engine: ${queueStatus}
"
paragraph statusText
}
section("π External Integrations Sync", hideable: true, hidden: true) {
def statusText = ""
statusText += "Service Status / Next Item Last Checked / Time "
def nowMs = new Date().time
if (state.nextEventEpoch && nowMs > state.nextEventEpoch) { state.nextEventName = null; state.nextEventTimeStr = null }
def nextCalText = (state.nextEventName && state.nextEventTimeStr) ? "${state.nextEventName} " : "Waiting for Sync... / No Events "
if (!settings.enableCalendar) nextCalText = "Disabled "
statusText += "Calendar Engine ${nextCalText} ${state.nextEventTimeStr ?: '--'} "
def nextNoteText = "No Scheduled Notes "
def nextNoteTimeStr = "--"
if (state.butlerNotes) {
def upcomingNotes = state.butlerNotes.findAll { it.when == "Time" && it.timeEpoch && it.timeEpoch > nowMs }.sort { it.timeEpoch }
if (upcomingNotes.size() > 0) {
def nextNote = upcomingNotes[0]
def senderPrefix = nextNote.sender != "Someone" ? "${nextNote.sender} to " : "To "
nextNoteText = "${senderPrefix}${nextNote.target}: \"${nextNote.text.take(20)}${nextNote.text.length() > 20 ? '...' : ''}\" "
nextNoteTimeStr = new Date(nextNote.timeEpoch).format("MMM d 'at' h:mm a", location.timeZone)
}
}
statusText += "Scheduled Notes ${nextNoteText} ${nextNoteTimeStr} "
def mealNewsText = state.mealNewsHeadline ?: "Waiting for Sync... "
def mealNewsTime = state.mealNewsSyncTime ?: "--"
if (!settings.enableMealTime || !settings.mealTimeNewsWeather) mealNewsText = "Disabled "
statusText += "Meal Time News ${mealNewsText} ${mealNewsTime} "
def breakNewsText = state.lastBreakingHeadline ?: "Waiting for Sync... "
def breakNewsTime = state.breakingNewsSyncTime ?: "--"
if (!settings.enableBreakingNews) breakNewsText = "Disabled "
statusText += "Organic Breaking News ${breakNewsText} ${breakNewsTime} "
def numRoomsConfig = settings.numRooms ? settings.numRooms as Integer : 0
if (numRoomsConfig > 0) {
for (int i = 1; i <= numRoomsConfig; i++) {
if (settings["roomNewsEnable_${i}"]) {
def rName = settings["roomName_${i}"] ?: "Room ${i}"
def roomNewsText = state."roomNewsHeadline_${i}" ?: "Waiting for Sync... "
def roomNewsTime = state."roomNewsSyncTime_${i}" ?: "--"
statusText += "Morning News (${rName}) ${roomNewsText} ${roomNewsTime} "
}
}
}
statusText += "
"
paragraph statusText
}
section("π§ AI Habit & Anomaly Engine", hideable: true, hidden: true) {
def statusText = ""
statusText += "User Learned Departure Time Current Status "
if (state.learnedHabits && state.learnedHabits.size() > 0) {
state.learnedHabits.each { uName, habitData ->
def timeStr = habitData.avgDepartureMins ? formatMinsToTime(habitData.avgDepartureMins) : "Learning..."
def arrived = state.hasArrivedToday != null && (state.hasArrivedToday[uName] == true || state.hasArrivedToday[uName] == "true")
def departed = state.hasDepartedToday != null && (state.hasDepartedToday[uName] == true || state.hasDepartedToday[uName] == "true")
def statStr = ""
if (departed || !arrived) statStr = "Departed "
else if (state.anomalyAlertedToday[uName]) statStr = "Anomaly (Running Late) "
else statStr = "Home / Waiting "
statusText += "${applyAlias(uName)} ${timeStr} ${statStr} "
}
} else {
statusText += "Gathering initial habit data... "
}
statusText += "
"
paragraph statusText
}
section("π Butler Incident Log", hideable: true, hidden: true) {
def statusText = ""
statusText += "Event Type Events Tracked Status "
def nmStatus = state.pendingMorningReport ? "Pending Report " : "Cleared/Idle "
statusText += "Porch Motion (Night) ${state.nightMotionCount ?: 0} ${nmStatus} "
def adStatus = state.pendingArrivalReport ? "Pending Report " : "Cleared/Idle "
statusText += "Doorbell Rings (Away) ${state.awayDoorbellCount ?: 0} ${adStatus} "
statusText += "
"
paragraph statusText
}
section("π₯ Live House Roster", hideable: true, hidden: true) {
def statusText = ""
statusText += "User Status Context "
def allNames = getTrackedUsers()
allNames.each { uName ->
def arrived = state.hasArrivedToday != null && (state.hasArrivedToday[uName] == true || state.hasArrivedToday[uName] == "true")
def departed = state.hasDepartedToday != null && (state.hasDepartedToday[uName] == true || state.hasDepartedToday[uName] == "true")
def isHome = arrived && !departed
def arrStatus = isHome ? "Arrived " : "Waiting... "
def contextText = isHome ? "Present" : (departed ? "Departed Today" : (state.resetReasons?."${uName}" ?: state.globalResetReason ?: "Awaiting First Entry"))
statusText += "${applyAlias(uName)} ${arrStatus} ${contextText} "
}
statusText += "
"
paragraph statusText
}
if (state.trashData) {
section("ποΈ Waste Management", hideable: true, hidden: true) {
def statusText = ""
statusText += "Bin Status ${state.trashData.status} "
statusText += "Capacity & Hygiene ${state.trashData.fill}% Full | ${state.trashData.hygiene} "
statusText += "Next Collection ${state.trashData.nextPickup} "
statusText += "
"
paragraph statusText
}
}
section("System Event History", hideable: true, hidden: true) {
paragraph "A running log of the last 30 voice announcements and status changes. "
if (state.historyLog && state.historyLog.size() > 0) {
def histHtml = ""
state.historyLog.each { logEntry -> histHtml += "
${logEntry}
" }
histHtml += "
"
paragraph histHtml
}
}
section("Butler Management Menus", hideable: false, hidden: false) {
href(name: "hrefConcierge", page: "pageConcierge", title: "ποΈ Concierge & Intelligence", description: "Calendar, News, Meal Plans, Health, and AI Features")
href(name: "hrefEstate", page: "pageEstate", title: "π° Estate Management", description: "Appliance Health, Maintenance, and Household Logistics")
href(name: "hrefGuests", page: "pageGuests", title: "π€ Guest Services & Comms", description: "Notes Portal, Quick Replies, Guests, and Wi-Fi")
href(name: "hrefSecurity", page: "pageSecurity", title: "π‘οΈ Perimeter Security", description: "Doorbells, Intruder Deterrent, Package Rescue, Arrivals")
href(name: "hrefHardware", page: "pageHardware", title: "βοΈ Core Setup & Hardware", description: "Speakers, Global Overrides, Branding, Aliases")
}
section("Local Voice Zones (Rooms)", hideable: false, hidden: false) {
input "numRooms", "number", title: "Number of Local Voice Zones (1-5)", required: true, defaultValue: 1, range: "1..5", submitOnChange: true
if (numRooms > 0) {
for (int i = 1; i <= (numRooms as Integer); i++) {
href(name: "roomHref${i}", page: "roomPage", params: [roomNum: i], title: "Configure ${settings["roomName_${i}"] ?: "Room Zone ${i}"}")
}
}
}
}
}
// ==============================================================
// 1. CONCIERGE & INTELLIGENCE SUB-MENU
// ==============================================================
def pageConcierge() {
def lockUsers = getLockUsers()
dynamicPage(name: "pageConcierge", title: "ποΈ Concierge & Intelligence", install: false, uninstall: false) {
section("Health & Wellness Concierge", hideable: true, hidden: true) {
paragraph "Track medical and dental appointments for household members. The Butler can remind them during the morning briefing or dynamically during a user-defined daytime window. "
input "enableHealthMorning", "bool", title: "Announce in Morning Briefing (Per Room)?", defaultValue: true
input "enableHealthWindow", "bool", title: "Announce during Daytime Window (Global)?", defaultValue: false, submitOnChange: true
if (enableHealthWindow) {
input "healthWindowStart", "time", title: "Window Start Time", required: true
input "healthWindowEnd", "time", title: "Window End Time", required: true
input "healthRoutingMode", "enum", title: "Window Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
input "healthVolume", "number", title: "Announcement Volume (0-100)", required: false
input "btnTestHealth", "button", title: "βΆοΈ Test Health Reminder"
}
paragraph " "
input "numHealthProfiles", "number", title: "Number of Health Profiles (0-5)", defaultValue: 0, range: "0..5", submitOnChange: true
if (numHealthProfiles > 0) {
for (int i = 1; i <= (numHealthProfiles as Integer); i++) {
paragraph "Health Profile ${i} "
input "healthUser_${i}", "text", title: "User Name (Matches Lock/Presence)", required: false
input "lastDental_${i}", "text", title: "Last Dental Cleaning (YYYY-MM-DD)", description: "Triggers after 6 months", required: false
input "lastMedical_${i}", "text", title: "Last Annual Physical (YYYY-MM-DD)", description: "Triggers after 1 year", required: false
input "lastVision_${i}", "text", title: "Last Vision/Eye Exam (YYYY-MM-DD)", description: "Triggers after 1 year", required: false
}
}
}
section("Calendar & Appointment Reminders", hideable: true, hidden: true) {
paragraph "Note: For Google Calendar, use the 'Secret address in iCal format' (.ics link) found in your calendar settings. "
input "enableCalendar", "bool", title: "Enable Calendar Reminders?", defaultValue: false, submitOnChange: true
if (enableCalendar) {
input "calendarType", "enum", title: "Calendar Source Type", options: ["Built-In Device (Advanced Calendar App)", "iCal / Google Calendar URL"], defaultValue: "Built-In Device (Advanced Calendar App)", submitOnChange: true
if (calendarType == "Built-In Device (Advanced Calendar App)") {
input "calendarDevice", "capability.sensor", title: "Calendar Device", required: true
input "calEventTitleAttr", "text", title: "Event Title Attribute", defaultValue: "eventTitle", required: true
input "calEventTimeAttr", "text", title: "Event Time Attribute (Epoch)", defaultValue: "eventEpoch", required: true
} else {
input "calendarUrl", "text", title: "iCal or Google Calendar URL (.ics)", required: true
input "calSyncMethod", "enum", title: "Sync Method", options: ["Google Apps Script Webhook (Instant)", "Standard .ics Polling (Delayed)"], defaultValue: "Google Apps Script Webhook (Instant)", submitOnChange: true
if (calSyncMethod == "Standard .ics Polling (Delayed)") {
input "calPollInterval", "enum", title: "Polling Interval", options: ["15 Minutes", "30 Minutes", "1 Hour", "3 Hours"], defaultValue: "1 Hour", submitOnChange: true
} else {
paragraph "Webhook Mode Active: The app is listening for instant pushes from your Google Script. Background polling is disabled.
"
}
input "googleAppScriptUrl", "text", title: "Google Apps Script Webhook URL (For Adding Events)", description: "Required for pushing events to Google Calendar from the Portal", required: false
}
paragraph " "
paragraph "π Travel & Mapping Intelligence "
input "googleMapsApiKey", "text", title: "Google Maps API Key", description: "Enter your key from Google Cloud Console", required: false
input "homeAddress", "text", title: "Home Address", description: "e.g., 123 Main St, Your City, State", required: false
input "leaveNowBuffer", "number", title: "Leave Now Buffer (Minutes)", defaultValue: 5, description: "Warning threshold before you MUST leave."
input "apiCallLimit", "number", title: "Monthly API Call Limit", defaultValue: 500, description: "Failsafe limit to prevent billing charges."
input "btnTestGoogleApi", "button", title: "βΆοΈ Test Google API Connection"
input "enableTravelPush", "bool", title: "Send Event & Gas Addresses to Phones via Push?", description: "Requires 'Silent Mode Notification Devices' to be configured.", defaultValue: false, submitOnChange: true
def apiCount = state.apiCallCount ?: 0
def apiLimit = settings.apiCallLimit ?: 500
paragraph "API Usage This Month: ${apiCount} / ${apiLimit} Calls
"
paragraph " "
input "calWeatherDevice", "capability.temperatureMeasurement", title: "Weather Device (For Rain Warnings)", required: false
input "calAlertModes", "mode", title: "Allowed Modes for Alerts", multiple: true, required: false
input "calAlertIntervals", "enum", title: "Warning Intervals", options: ["3 Hours", "2 Hours", "1 Hour", "30 Minutes", "15 Minutes"], multiple: true, required: false
input "calRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Follow-Me + Fallback (Global ONLY if no motion)", submitOnChange: true
input "calVolume", "number", title: "Announcement Volume (0-100)", required: false
for (int d = 1; d <= 4; d++) { input "calMessage_${d}", "text", title: "Reminder Message ${d}", required: false, defaultValue: getDefaultMessages("Calendar")[d-1] }
input "btnTestCalendar", "button", title: "βΆοΈ Test Calendar Alert Audio"
def calPrev = applyDynamicVars((settings["calMessage_1"] ?: "%interruption%, but you have %event% starting in %time%.").replace("%event%", "a meeting").replace("%time%", "1 Hour"))
paragraph "Live Calendar Preview: ${calPrev}
"
}
}
section("Organic Breaking News Intercept", hideable: true, hidden: true) {
input "enableBreakingNews", "bool", title: "Enable Organic News Intercept?", defaultValue: false, submitOnChange: true
if (enableBreakingNews) {
input "breakingNewsFeed", "text", title: "Breaking News RSS URL", defaultValue: "https://feeds.npr.org/1001/rss.xml"
input "breakingNewsInterval", "enum", title: "Base Check Interval (AI will randomize +/- 30%)", options: ["15 Minutes", "30 Minutes", "1 Hour", "3 Hours"], defaultValue: "1 Hour", submitOnChange: true
input "breakingNewsModes", "mode", title: "Allowed Modes for Breaking News", multiple: true, required: false
input "breakingNewsRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Follow-Me + Fallback (Global ONLY if no motion)", submitOnChange: true
input "breakingNewsVolume", "number", title: "Announcement Volume (0-100)", required: false
for (int d = 1; d <= 4; d++) { input "breakingNewsPrefix_${d}", "text", title: "Prefix ${d}", required: false, defaultValue: getDefaultMessages("BreakingNews")[d-1] }
input "btnTestBreakingNews", "button", title: "βΆοΈ Test Breaking News Audio"
def bnPrev = applyDynamicVars(settings["breakingNewsPrefix_1"] ?: "%interruption%, but a major news event has just occurred.") + " [Live Breaking Headline]."
paragraph "Live Breaking News Preview: ${bnPrev}
"
}
}
section("Office Interceptor (Science & Tech News)", hideable: true, hidden: true) {
input "enableOffice", "bool", title: "Enable Office Interceptor?", defaultValue: false, submitOnChange: true
if (enableOffice) {
paragraph "Assign a virtual switch. When turned ON, the butler will fetch the latest tech news and deliver a briefing to the assigned speaker. "
input "officeSwitch", "capability.switch", title: "Office Trigger Switch", required: true
input "officeSpeaker", "capability.speechSynthesis", title: "Office Speaker", required: true
input "officeVolume", "number", title: "Speaker Volume (0-100)", required: false
input "officeModes", "mode", title: "Allowed Modes", multiple: true, required: false
input "officeFeed", "enum", title: "News Source", options: ["TechCrunch": "TechCrunch", "Engadget": "Engadget", "Wired": "Wired", "Ars Technica": "Ars Technica", "Custom": "Custom URL"], defaultValue: "TechCrunch", submitOnChange: true
input "officeDebounceHours", "number", title: "Minimum Hours Between Briefings (Cooldown)", defaultValue: 4, required: false
if (officeFeed == "Custom") {
input "officeCustomUrl", "text", title: "Custom RSS URL", required: true
}
}
}
section("Meal Time Routine (Dinner Voice Butler)", hideable: true, hidden: true) {
input "enableMealTime", "bool", title: "Enable Meal Time Routine?", defaultValue: false, submitOnChange: true
if (enableMealTime) {
input "mealTimeSwitch", "capability.switch", title: "Meal Time Trigger Switch", required: false
input "mealTimeButton", "capability.pushableButton", title: "Meal Time Trigger Button", required: false, submitOnChange: true
if (mealTimeButton) {
input "mealTimeButtonNumber", "number", title: "Button Number", defaultValue: 1, required: true
input "mealTimeButtonAction", "enum", title: "Button Action", options: ["pushed", "held", "doubleTapped", "released"], defaultValue: "pushed", required: true
paragraph "Note: Button presses bypass Global routing and exclusively announce to 'Follow-Me (Active Rooms Only)'. "
}
input "mealTimeSpeaker", "capability.speechSynthesis", title: "Dedicated Meal Time Speaker", required: false
input "mealTimeVolume", "number", title: "Announcement Volume (0-100)", required: false
input "mealTimeRoutingMode", "enum", title: "Dinner Bell Routing Mode (For Switch/Test)", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "mealTimeDinnerBell", "text", title: "Base Message", defaultValue: "%interruption%, but dinner is now served.", required: false
input "mealTimeAbsentee", "bool", title: "Enable Absentee Roll Call?", defaultValue: false
input "mealTimeOnThisDay", "bool", title: "Enable 'On This Day' Historical Fact?", defaultValue: false
input "mealTimeNewsWeather", "bool", title: "Enable Evening Digest (News & Weather)?", defaultValue: false, submitOnChange: true
if (mealTimeNewsWeather) { input "mealTimeNewsFeed", "text", title: "RSS News Feed URL", defaultValue: "https://feeds.npr.org/1001/rss.xml"; input "mealTimeWeatherDevice", "capability.temperatureMeasurement", title: "Weather / Temperature Device", required: false }
input "enableMealQuestions", "bool", title: "Enable Meal Time Conversation Starters?", defaultValue: false, submitOnChange: true
if (enableMealQuestions) {
input "mealTimeQuestionsFile", "text", title: "Custom Questions File (.txt)", description: "e.g. dinner_questions.txt", required: false
}
input "btnTestMealNews", "button", title: "βΆοΈ Test Evening News Fetch"
input "btnTestMealTime", "button", title: "βΆοΈ Test Meal Time Audio"
def mealPrev = settings.mealTimeDinnerBell ?: "%interruption%, but dinner is now served."
mealPrev = applyDynamicVars(mealPrev)
paragraph "Live Meal Time Preview: ${mealPrev}
"
}
}
section("Weekly Meal Plan Manager", hideable: true, hidden: true) {
paragraph "Manage the household menu here or via the Web Portal. These meals are announced during morning briefings. "
input "meal_Monday", "text", title: "Monday", required: false
input "meal_Tuesday", "text", title: "Tuesday", required: false
input "meal_Wednesday", "text", title: "Wednesday", required: false
input "meal_Thursday", "text", title: "Thursday", required: false
input "meal_Friday", "text", title: "Friday", required: false
input "meal_Saturday", "text", title: "Saturday", required: false
input "meal_Sunday", "text", title: "Sunday", required: false
}
section("Birthdays, Anniversaries & Holidays", hideable: true, hidden: true) {
input "enableHolidays", "bool", title: "Enable Morning Holiday Announcements?", defaultValue: false, submitOnChange: true
if (enableHolidays) { input "holidayMessage", "text", title: "Holiday Message Format", defaultValue: "By the way, don't forget today is %holiday%!" }
paragraph " "
input "enableParentsDay", "bool", title: "Enable Parent's Day Reminders? (Call Mom/Dad)", defaultValue: false, submitOnChange: true
if (enableParentsDay) {
input "mothersDayUser", "enum", title: "Who should be reminded to call Mom on Mother's Day?", options: lockUsers, multiple: true, required: false
input "fathersDayUser", "enum", title: "Who should be reminded to call Dad on Father's Day?", options: lockUsers, multiple: true, required: false
}
paragraph " "
input "enableAnniversary", "bool", title: "Enable House Anniversary Greetings?", defaultValue: false, submitOnChange: true
if (enableAnniversary) {
def months = ["01":"January", "02":"February", "03":"March", "04":"April", "05":"May", "06":"June", "07":"July", "08":"August", "09":"September", "10":"October", "11":"November", "12":"December"]
input "annivMonth", "enum", title: "Anniversary Month", options: months, required: true
input "annivDay", "number", title: "Anniversary Day (1-31)", range: "1..31", required: true
input "annivAllowedUsers", "enum", title: "Limit Anniversary to Specific Users (Arrival/Departure)", options: lockUsers, multiple: true, required: false
input "annivAllowedCustom", "text", title: "Limit Anniversary (Custom Names)", required: false
input "annivMsgArrival", "text", title: "Arrival Append", defaultValue: "Happy Anniversary! Welcome home."
input "annivMsgMorning", "text", title: "Good Morning Append", defaultValue: "Happy Anniversary! I hope you both have a fantastic day."
}
paragraph " "
input "numBirthdays", "number", title: "Number of Birthdays to Track (0-10)", defaultValue: 0, submitOnChange: true
if (numBirthdays > 0) {
def months = ["01":"January", "02":"February", "03":"March", "04":"April", "05":"May", "06":"June", "07":"July", "08":"August", "09":"September", "10":"October", "11":"November", "12":"December"]
for (int i = 1; i <= (numBirthdays as Integer); i++) {
paragraph "Birthday Profile ${i} "
input "bdayName_${i}", "text", title: "Person's Name ${i}", required: false
input "bdayType_${i}", "enum", title: "Profile Type", options: ["Kid (30-Day Countdown)", "Adult (5-Day Gift Warning)"], defaultValue: "Kid (30-Day Countdown)", submitOnChange: true
input "bdayMonth_${i}", "enum", title: "Birth Month ${i}", options: months, required: false
input "bdayDay_${i}", "number", title: "Birth Day ${i} (1-31)", range: "1..31", required: false
input "bdayNotifyUser_${i}", "enum", title: "Who needs to be home for this reminder?", options: lockUsers, multiple: true, required: false
}
input "bdayMsgArrival", "text", title: "Arrival Append", defaultValue: "Happy Birthday %name%!"
input "bdayMsgMorning", "text", title: "Good Morning Append", defaultValue: "Happy Birthday %name%! I hope you have a fantastic day."
}
def holText = getTodayHoliday() ? (settings.holidayMessage ?: "By the way, don't forget today is %holiday%!").replace("%holiday%", getTodayHoliday()) : "None detected today."
def bdayText = settings.bdayCountdownMsg ?: "By the way, you only have %days% days until your birthday!"
paragraph "Live Special Events Preview: Today's Holiday: ${holText} Birthday Countdown Format: ${bdayText.replace('%days%', '12').replace('%name%', 'Test User')}
"
}
section("Local Farmers Market & Butcher Scout", hideable: true, hidden: true) {
input "enableMarketReminder", "bool", title: "Enable Farmers Market Reminders?", defaultValue: false, submitOnChange: true
if (enableMarketReminder) {
paragraph "The Butler will announce when the local market opens during the specified season. "
input "marketName", "text", title: "Name of the Farmers Market", defaultValue: "the Farmers Market", required: true
def months = ["01":"January", "02":"February", "03":"March", "04":"April", "05":"May", "06":"June", "07":"July", "08":"August", "09":"September", "10":"October", "11":"November", "12":"December"]
input "marketSeasonStart", "enum", title: "Season Start Month", options: months, defaultValue: "05", required: true
input "marketSeasonEnd", "enum", title: "Season End Month", options: months, defaultValue: "09", required: true
input "marketDays", "enum", title: "Operating Days", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], multiple: true, required: true
input "marketOpenTime", "time", title: "Opening Time (Announcement Time)", required: true
paragraph " "
input "enableButcherReminder", "bool", title: "Append Local Butcher Recommendations?", defaultValue: false, submitOnChange: true
if (enableButcherReminder) {
input "butcherName", "text", title: "Name of the Butcher", defaultValue: "the local butcher", required: true
}
paragraph " "
input "marketRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "marketVolume", "number", title: "Announcement Volume (0-100)", required: false
input "btnTestMarket", "button", title: "βΆοΈ Test Market Audio"
}
}
section("Cinema & Streaming Scout (Friday Premieres)", hideable: true, hidden: true) {
input "enableCinemaScout", "bool", title: "Enable Friday Cinema Scout?", defaultValue: false, submitOnChange: true
if (enableCinemaScout) {
paragraph "Every Friday evening, the Butler will ask Gemini for the latest theatrical releases and Netflix arrivals to kick off the weekend. "
input "cinemaTime", "time", title: "Announcement Time (e.g., 5:00 PM)", required: true
input "cinemaRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "cinemaVolume", "number", title: "Announcement Volume (0-100)", required: false
paragraph "π§ Generative AI Integration "
input "geminiApiKey", "text", title: "Google Gemini API Key", description: "Get a free key from Google AI Studio", required: true
input "btnTestCinema", "button", title: "βΆοΈ Test AI Cinema Scout"
}
}
section("Grocery Day Scout (Weekly Ad Tracker)", hideable: true, hidden: true) {
input "enableGroceryScout", "bool", title: "Enable Grocery Day Scout?", defaultValue: false, submitOnChange: true
if (enableGroceryScout) {
paragraph "The Butler will actively search live weekly ads for Aldi, Publix, and Walmart, then notify you of the best deals based on your preferences. "
input "groceryDay", "enum", title: "Which Day to Announce?", options: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], required: true, defaultValue: "Wednesday"
input "groceryTime", "time", title: "Announcement Time", required: true
paragraph "π Deal Preferences "
input "groceryPrefs", "enum", title: "What type of deals do you want?", options: ["Meats & Proteins", "Organic Produce", "Fresh Fruits & Veggies", "Pantry Staples", "Snacks & Drinks", "Household Goods", "BOGO Deals", "Everything"], multiple: true, required: true, defaultValue: ["Everything"]
input "groceryRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "groceryVolume", "number", title: "Announcement Volume (0-100)", required: false
input "btnTestGrocery", "button", title: "βΆοΈ Test Grocery Scout"
}
}
section("Vehicle Care & Weather Scout", hideable: true, hidden: true) {
input "enableVehicleCare", "bool", title: "Enable AI Vehicle Care (Car Wash Scout)?", defaultValue: false, submitOnChange: true
if (enableVehicleCare) {
paragraph "Every Monday morning, the Butler asks Gemini to analyze the 7-day forecast and pick the best consecutive clear-weather day to wash the vehicles. It will announce this dynamically during that day's Morning Briefing and again at noon. "
input "vehicleReminderTime", "time", title: "Fallback Announcement Time (e.g., 12:00 PM)", required: true
input "vehicleRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
input "vehicleVolume", "number", title: "Announcement Volume (0-100)", required: false
input "btnTestVehicleScout", "button", title: "βΆοΈ Test Gemini Car Wash Fetch"
}
}
section("Screen Time Manager", hideable: true, hidden: true) {
input "enableScreenTime", "bool", title: "Enable Screen Time Alerts?", defaultValue: false, submitOnChange: true
if (enableScreenTime) {
input "screenTimeSwitch", "capability.switch", title: "Screen Time Virtual Switch", required: true
input "screenTimeSpeaker", "capability.speechSynthesis", title: "Dedicated Screen Time Speaker", required: false
input "screenTimeVolume", "number", title: "Alert Volume (0-100)", required: false
input "screenTimeRoutingMode", "enum", title: "Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
for (int d = 1; d <= 5; d++) { input "screenTimeMsg_${d}", "text", title: "Alert Message ${d}", required: false, defaultValue: getDefaultMessages("ScreenTime")[d-1] }
input "btnTestScreenTime", "button", title: "βΆοΈ Test Screen Time Audio"
}
}
}
}
// ==============================================================
// 2. ESTATE MANAGEMENT SUB-MENU
// ==============================================================
def pageEstate() {
dynamicPage(name: "pageEstate", title: "π° Estate Management", install: false, uninstall: false) {
section("Cross-App Integrations (Energy & Vacuum)", hideable: true, hidden: true) {
paragraph "Settings for handling external alerts sent to the Butler, such as the Advanced Energy Management Controller. "
input "crossAppRestrictedModes", "mode", title: "Restricted Modes (Stash in inbox, do not speak out loud)", multiple: true, required: false
input "crossAppRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "crossAppVolume", "number", title: "Announcement Volume (0-100)", required: false
}
section("Household Maintenance & Task Alerts", hideable: true, hidden: true) {
input "middayMaintenanceDevice", "capability.sensor", title: "House Maintenance Dashboard Device", required: false, submitOnChange: true
if (middayMaintenanceDevice) {
input "taskAppId", "number", title: "Maintenance App ID", description: "e.g., 123", required: false
input "taskAppToken", "text", title: "Maintenance Access Token", required: false
input "taskAppEndpoint", "text", title: "Maintenance Endpoint Path", description: "Usually 'dashboard' or 'ui'", defaultValue: "dashboard", required: false
paragraph " "
input "enableMiddayMaintenance", "bool", title: "Enable Random Midday Reminder (11am-3pm)?", defaultValue: false, submitOnChange: true
if (enableMiddayMaintenance) {
paragraph "The Butler will secretly pick a random time between 11:00 AM and 3:00 PM every day to politely remind you of any overdue tasks. It will remain silent if the house is empty or if guests are present. "
input "middayMaintenanceModes", "mode", title: "Allowed Modes for Reminder", multiple: true, required: false
input "middayRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "middayVolume", "number", title: "Announcement Volume (0-100)", required: false
input "btnTestMidday", "button", title: "βΆοΈ Test Midday Maintenance Reminder"
}
paragraph " "
input "enableRealTimeTasks", "bool", title: "Enable Real-Time 'Newly Due' Task Alerts?", defaultValue: false, submitOnChange: true
if (enableRealTimeTasks) {
paragraph "The Butler will instantly announce the moment a task switches to 'due'. "
input "realTimeTaskRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "realTimeTaskVolume", "number", title: "Announcement Volume (0-100)", required: false
}
}
paragraph " "
paragraph "Device Health & Network Status "
input "deviceHealthAppId", "number", title: "Device Health App ID", required: false
input "deviceHealthToken", "text", title: "Device Health Access Token", required: false
input "deviceHealthEndpoint", "text", title: "Device Health Endpoint Path", description: "Usually 'dashboard' or 'ui'", defaultValue: "dashboard", required: false
input "enableDeviceHealthAlerts", "bool", title: "Enable Critical Device Health Alerts?", defaultValue: false, submitOnChange: true
if (enableDeviceHealthAlerts) {
input "deviceHealthDevice", "capability.sensor", title: "Device Health Child Device", required: true
input "deviceHealthUser", "enum", title: "Target User for Alerts", options: getLockUsers(), required: true, description: "Who should be notified of network issues?"
input "deviceHealthRoutingMode", "enum", title: "Audio Routing Mode (For live alerts)", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
input "deviceHealthVolume", "number", title: "Alert Volume (0-100)", required: false
}
}
section("Contextual Departures & Habit Tracking", hideable: true, hidden: true) {
input "frontDoorContact", "capability.contactSensor", title: "Front Door Contact Sensor", required: false
input "numDepartureUsers", "number", title: "Number of Departure Profiles (0-5)", required: true, defaultValue: 0, range: "0..5", submitOnChange: true
input "enableCoatCheck", "bool", title: "Enable Departure 'Coat Check' (Weather Warnings)?", defaultValue: false, submitOnChange: true
if (enableCoatCheck) {
input "depWeatherDevice", "capability.temperatureMeasurement", title: "Weather Device for Coat Check", required: true
}
if (numDepartureUsers > 0) {
for (int i = 1; i <= (numDepartureUsers as Integer); i++) {
paragraph "Departure Profile ${i} "
input "depUserName_${i}", "text", title: "User Name (replaces %name%)", required: false
input "depType_${i}", "enum", title: "Profile Type", options: ["Work", "School", "General"], defaultValue: "Work", submitOnChange: true
input "depSwitch_${i}", "capability.switch", title: "Context Switch (e.g. Work Day)", required: false
input "depSickSwitch_${i}", "capability.switch", title: "Sick Day Override Switch", required: false
input "depEnableTraffic_${i}", "bool", title: "Enable Commute/Traffic Time?", defaultValue: false, submitOnChange: true
if (settings["depEnableTraffic_${i}"]) {
input "depDestination_${i}", "text", title: "Destination Address (Work/School)", required: true, description: "e.g., 123 Office Park, City, State"
paragraph "Note: Requires Google Maps API Key to be configured in the Calendar section. "
}
input "depModes_${i}", "mode", title: "Allowed House Modes", multiple: true, required: false
input "depTimeStart_${i}", "time", title: "Departure Window Start", required: false
input "depTimeEnd_${i}", "time", title: "Departure Window End", required: false
input "depDelay_${i}", "number", title: "Greeting Delay (Seconds)", defaultValue: 5, required: false
input "depRoutingMode_${i}", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "depVolume_${i}", "number", title: "Departure Volume (0-100)", required: false
input "btnTestDeparture_${i}", "button", title: "βΆοΈ Test Departure Profile ${i} Audio"
def selMsgs = getDefaultMessages(settings["depType_${i}"] ?: "Work")
for (int m = 1; m <= 10; m++) { input "depMessage_${i}_${m}", "text", title: "Message ${m}", required: false, defaultValue: selMsgs[m-1] }
}
def depType = settings["depType_1"] ?: "Work"
def depPrevBase = settings["depMessage_1_1"] ?: getDefaultMessages(depType)[0]
def depPrev = applyDynamicVars(depPrevBase.replace("%name%", settings["depUserName_1"] ?: "Guest"))
paragraph "Live Departure Preview (Routing: ${settings["depRoutingMode_1"] ?: 'Outdoor Speaker Only'}): ${depPrev}
"
}
}
section("Headed Home (On The Way) Announcements", hideable: true, hidden: true) {
input "enableHeadedHome", "bool", title: "Enable Headed Home Announcements?", defaultValue: false, submitOnChange: true
if (enableHeadedHome) {
paragraph "Assign a virtual switch for each user. When turned ON (e.g. via a Google Home or Alexa driving routine), the Butler will announce they are on their way. "
input "numHeadedHome", "number", title: "Number of Headed Home Profiles (0-5)", defaultValue: 0, range: "0..5", submitOnChange: true
if (numHeadedHome > 0) {
for (int i = 1; i <= (numHeadedHome as Integer); i++) {
paragraph "Headed Home Profile ${i} "
input "hhUser_${i}", "text", title: "User Name (replaces %name%)", required: false
input "hhSwitch_${i}", "capability.switch", title: "Trigger Switch", required: false
input "hhRouting_${i}", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
input "hhVolume_${i}", "number", title: "Announcement Volume (0-100)", required: false
input "hhMessage_${i}", "text", title: "Announcement Message", defaultValue: "%interruption%, but %name% is on their way home.", required: false
input "btnTestHeadedHome_${i}", "button", title: "βΆοΈ Test Headed Home Audio ${i}"
}
}
}
}
section("Fallback Presence Engine (Kids/Room Based)", hideable: true, hidden: true) {
paragraph "Use sustained room motion during a specific time window to mark a user as 'Arrived' if they missed the door greeting."
input "enableFallbackPresence", "bool", title: "Enable Fallback Presence?", defaultValue: false, submitOnChange: true
if (enableFallbackPresence) {
input "fallbackUser", "text", title: "User Name (e.g., Leanne / Princess)", required: true
input "fallbackMotionSensors", "capability.motionSensor", title: "Room & Closet Motion Sensors", multiple: true, required: true
input "fallbackSpeaker", "capability.speechSynthesis", title: "Target Bedroom Speaker", multiple: false, required: true
paragraph "Timing & Debounce "
input "fallbackDuration", "number", title: "Required Sustained Motion (Minutes)", defaultValue: 5, required: true
input "fallbackDebounce", "number", title: "Inactive Debounce (Seconds) to catch fast sensors", defaultValue: 60, required: true
paragraph "School Arrival Window "
input "fallbackStartTime", "time", title: "Window Start (e.g., 3:00 PM)", required: true
input "fallbackEndTime", "time", title: "Window End (e.g., 4:30 PM)", required: true
input "fallbackDays", "enum", title: "Active Days", options: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], multiple: true, defaultValue: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
}
}
section("Portal Dashboard Integrations", hideable: true, hidden: true) {
paragraph "Select the Virtual Override Switches from your 'Advanced Room Occupancy' app to control them from the web portal. "
input "portalRoomSwitches", "capability.switch", title: "Room Occupancy Override Switches", multiple: true, required: false
input "portalVacuum", "capability.switch", title: "Cleaning Staff / Vacuum Switch (Triggered by Portal)", required: false
}
}
}
// ==============================================================
// 3. GUEST SERVICES & COMMUNICATIONS SUB-MENU
// ==============================================================
def pageGuests() {
dynamicPage(name: "pageGuests", title: "π€ Guest Services & Comms", install: false, uninstall: false) {
section("π Butler Notes Portal Settings", hideable: true, hidden: true) {
paragraph "The link to the Notes Portal is located on the main dashboard page. "
input "announceNotesArrival", "bool", title: "Announce pending notes on Arrival?", defaultValue: true
input "announceNotesMorning", "bool", title: "Announce pending notes during Morning Briefing?", defaultValue: true
if (state.butlerNotes && state.butlerNotes.size() > 0) {
paragraph "Currently Pending Notes: "
state.butlerNotes.each { note ->
def timeTxt = note.when == "Time" ? " (At specific time)" : " (On ${note.when})"
paragraph "β’ For ${note.target} ${timeTxt}: ${note.text}"
}
input "btnClearNotes", "button", title: "ποΈ Clear All Notes"
} else {
paragraph "No notes currently pending. "
}
}
section("Portal Quick Replies (Outdoor Intercom)", hideable: true, hidden: true) {
paragraph "Create up to 6 pre-set messages that you can trigger instantly from the web portal. These will announce on the Outdoor Speaker and automatically cancel any pending unanswered doorbell follow-ups. "
input "numQuickReplies", "number", title: "Number of Quick Replies (0-6)", defaultValue: 0, range: "0..6", submitOnChange: true
if (numQuickReplies > 0) {
for (int i = 1; i <= (numQuickReplies as Integer); i++) {
paragraph "Quick Reply Button ${i} "
input "quickReplyName_${i}", "text", title: "Button Label (e.g., Leave Package)", required: false
input "quickReplyText_${i}", "text", title: "Spoken Message", required: false, defaultValue: "Please leave the package at the front door. Thank you."
}
}
}
section("Guest / Party Mode (Doorbell Intercept)", hideable: true, hidden: true) {
input "enablePartyMode", "bool", title: "Enable Guest/Party Mode Doorbell?", defaultValue: false, submitOnChange: true
if (enablePartyMode) {
paragraph "Assign a virtual switch. When ON, the butler will override Do Not Disturb and After Hours to welcome expected guests. "
input "partyModeSwitch", "capability.switch", title: "Party Mode Virtual Switch", required: true
input "partyRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "partyVolume", "number", title: "Announcement Volume (0-100)", required: false
input "partyDebounce", "number", title: "Cooldown (Minutes)", defaultValue: 2, required: false
input "partyVacuum", "capability.switch", title: "Vacuum Cleaner to deploy when Event Ends", required: false
for (int d = 1; d <= 3; d++) { input "partyMessage_${d}", "text", title: "Party Greeting ${d}", required: false, defaultValue: getDefaultMessages("PartyMode")[d-1] }
input "btnTestPartyMode", "button", title: "βΆοΈ Test Party Mode Audio"
def pPrev = applyDynamicVars(settings["partyMessage_1"] ?: getDefaultMessages("PartyMode")[0])
paragraph "Live Party Mode Preview: ${pPrev}
"
}
}
section("Guest Wi-Fi Details", hideable: true, hidden: true) {
input "enableWifiPortal", "bool", title: "Enable Wi-Fi Info in Portal?", defaultValue: false, submitOnChange: true
if (enableWifiPortal) {
paragraph "Add your Guest Wi-Fi details here to display a quick-announce button on the web portal. "
input "wifiSSID", "text", title: "Wi-Fi Network Name (SSID)", required: false
input "wifiPassword", "text", title: "Wi-Fi Password", required: false
input "wifiRoutingMode", "enum", title: "Announcement Routing", options: getRoutingOptions(), defaultValue: "Follow-Me + Fallback (Global ONLY if no motion)"
input "wifiVolume", "number", title: "Announcement Volume (0-100)", required: false
}
}
section("Estate Directory & Emergency Contacts", hideable: true, hidden: true) {
input "enableDirectory", "bool", title: "Enable Estate Directory?", defaultValue: false, submitOnChange: true
if (enableDirectory) {
paragraph "Create a digital Rolodex. Pair a virtual switch to a contact. When turned ON via a dashboard or voice assistant, the Butler will announce the details and push them to your phones. "
input "directoryRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Follow-Me + Fallback (Global ONLY if no motion)"
input "directoryVolume", "number", title: "Announcement Volume (0-100)", required: false
input "numContacts", "number", title: "Number of Contacts (1-10)", defaultValue: 1, range: "1..10", submitOnChange: true
if (numContacts > 0) {
for (int i = 1; i <= (numContacts as Integer); i++) {
paragraph "Contact Profile ${i} "
input "contactName_${i}", "text", title: "Service Name (e.g., Plumber, Pediatrician)", required: false
input "contactInfo_${i}", "text", title: "Contact Info / Phone Number", required: false
input "contactSwitch_${i}", "capability.switch", title: "Trigger Switch", required: false
}
}
}
}
}
}
// ==============================================================
// 4. PERIMETER SECURITY SUB-MENU
// ==============================================================
def pageSecurity() {
def lockUsers = getLockUsers()
dynamicPage(name: "pageSecurity", title: "π‘οΈ Perimeter Security", install: false, uninstall: false) {
section("Smart Package Detection (UniFi Protect)", hideable: true, hidden: true) {
input "enableSmartPackage", "bool", title: "Enable Smart Package Detection?", defaultValue: false, submitOnChange: true
if (enableSmartPackage) {
paragraph "Using a UniFi Protect or similar smart camera, the Butler will announce when a package is detected. "
input "packageCameraDevice", "capability.sensor", title: "Smart Camera Device", required: true
input "packageAttribute", "text", title: "Smart Detection Attribute", defaultValue: "smartDetectType", required: true
input "packageRoutingMode", "enum", title: "Indoor Audio Routing", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
input "packageVolume", "number", title: "Indoor Announcement Volume (0-100)", required: false
input "packageOutdoorMessage", "bool", title: "Play a 'Thank You' message on the Outdoor Speaker?", defaultValue: true
input "btnTestPackage", "button", title: "βΆοΈ Test Package Announcement"
}
}
section("Arrival Greetings & Smart Locks", hideable: true, hidden: true) {
input "frontDoorLock", "capability.lock", title: "Front Door Smart Lock", required: false, submitOnChange: true
input "arrivalVolume", "number", title: "Welcome Home Announcement Volume (0-100)", required: false
input "arrivalFoyerSpeaker", "capability.speechSynthesis", title: "Foyer / Entryway Speaker (Plays Full Greeting)", required: false
input "arrivalIndoorSpeaker", "bool", title: "Play Third-Party Arrival Notice to Rest of House?", defaultValue: false, submitOnChange: true
if (arrivalIndoorSpeaker) {
input "indoorArrivalMessage", "text", title: "Indoor Notice Message", defaultValue: "%name% has arrived home.", required: false
input "indoorArrivalVolume", "number", title: "Indoor Notice Volume (0-100)", required: false
input "arrivalNoticeRoutingMode", "enum", title: "Notice Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
}
input "guestUsers", "enum", title: "Guest Users (Omit from Missing/Curfew Warnings)", options: lockUsers, multiple: true, required: false
input "guestCustomUsers", "text", title: "Custom Guest Names (Comma separated)", required: false
paragraph " "
input "enableExtendedAbsence", "bool", title: "Enable Extended Absence (Vacation) Greetings?", defaultValue: false, submitOnChange: true
if (enableExtendedAbsence) {
input "extendedAbsenceHours", "number", title: "Absence Threshold (Hours)", defaultValue: 48, required: false
for (int m = 1; m <= 5; m++) { input "extAbsenceMessage_${m}", "text", title: "Extended Message ${m}", required: false, defaultValue: getDefaultMessages("ExtendedAbsence")[m-1] }
}
input "enableDurationAware", "bool", title: "Enable Duration-Aware Arrival Context (Quick trip vs Long day)?", defaultValue: false
paragraph " "
input "btnTestArrival", "button", title: "βΆοΈ Test Welcome Home Audio"
input "disableGlobalAnnouncements", "bool", title: "Ignore Keys & Manual Unlocks?", defaultValue: false
input "ignoredCodes", "enum", title: "Silent / Ghost Codes", options: lockUsers, multiple: true, required: false
input "ignoredCustomCodes", "text", title: "Custom Silent Codes", required: false
input "arrivalMode", "enum", title: "Arrival Detection Mode", options: ["Automatic (Reads lock memory)", "Manual (Assign names to slots)"], defaultValue: "Automatic (Reads lock memory)", submitOnChange: true
if (arrivalMode == "Automatic (Reads lock memory)") {
input "trackedLockCodes", "enum", title: "Select Codes to Track", options: lockUsers, multiple: true, required: false, submitOnChange: true
input "adminUserAlias", "text", title: "Admin Code Alias", required: false
for (int m = 1; m <= 10; m++) { input "autoGreeting_${m}", "text", title: "Dynamic Welcome Message ${m}", required: false, defaultValue: getDefaultMessages("Arrival")[m-1] }
} else {
for (int d = 1; d <= 10; d++) { input "defaultArrivalMessage_${d}", "text", title: "Default Arrival Message ${d}", required: false, defaultValue: getDefaultMessages("Arrival")[d-1] }
input "numLockUsers", "number", title: "Number of Manual User Slots (1-5)", required: true, defaultValue: 1, range: "1..5", submitOnChange: true
if (numLockUsers > 0) {
for (int i = 1; i <= (numLockUsers as Integer); i++) {
input "lockUserName_${i}", lockUsers.size() > 0 ? "enum" : "text", title: "User ${i} Lock Code Name", options: lockUsers, required: false
for (int m = 1; m <= 10; m++) { input "lockGreeting_${i}_${m}", "text", title: "User ${i} Welcome Message ${m}", required: false, defaultValue: getDefaultMessages("Arrival")[m-1] }
}
}
}
paragraph " "
paragraph "Presence Sensor & Lock Linking Link your presence sensors to your lock code names. If a sensor arrives but the door isn't unlocked within 10 minutes, the Butler will automatically mark them home. Works with both Automatic and Manual modes! "
input "numPresenceMappings", "number", title: "Number of Presence Sensor Links (0-10)", defaultValue: 0, submitOnChange: true
if (numPresenceMappings > 0) {
for (int i = 1; i <= numPresenceMappings; i++) {
input "presenceUserName_${i}", lockUsers.size() > 0 ? "enum" : "text", title: "User ${i} Name (Matches Lock Code)", options: lockUsers, required: false
input "fallbackPresence_${i}", "capability.presenceSensor", title: "User ${i} Presence Sensor", required: false
}
}
def arrPrevBase = arrivalMode == "Automatic (Reads lock memory)" ? (settings["autoGreeting_1"] ?: "Welcome home %name%.") : (settings["defaultArrivalMessage_1"] ?: "Welcome home.")
def arrPrev = applyDynamicVars(arrPrevBase.replace("%name%", "Guest"))
if (settings.enableHouseRoster) arrPrev += " You are the first to arrive. The house is empty."
if (settings.enableMailCheck) arrPrev += " Pardon the reminder, but the mail was delivered earlier today and still needs to be retrieved."
def inPrevStr = settings.arrivalIndoorSpeaker ? "Rest of House Notice (Routing: ${settings.arrivalNoticeRoutingMode ?: 'Global Indoor Speaker Only'}): " + applyDynamicVars((settings.indoorArrivalMessage ?: "%name% has arrived home.").replace("%name%", "Guest")) + " " : ""
paragraph "Live Arrival Preview: ${arrPrev} ${inPrevStr}
"
}
section("Indoor Doorbell / Intercom Routing", hideable: true, hidden: true) {
input "enableIndoorRouting", "bool", title: "Enable Targeted Indoor Routing?", defaultValue: false, submitOnChange: true
if (enableIndoorRouting) {
input "followMeTimeout", "number", title: "Follow-Me Occupancy Window (Minutes)", defaultValue: 5, description: "Keep room active for X minutes after last motion."
input "indoorDoorbellMsg", "text", title: "Announcement Message", defaultValue: "This is %butler%. %interruption%, but there is a visitor at the front door."
input "indoorDoorbellRoutingMode", "enum", title: "Indoor Routing Mode", options: getRoutingOptions(), defaultValue: "Follow-Me + Fallback (Global ONLY if no motion)", submitOnChange: true
input "indoorRouteMuteDND", "bool", title: "Mute During Do Not Disturb?", defaultValue: true
input "indoorRouteRestrictedModes", "mode", title: "Restricted Modes (Do Not Announce)", multiple: true, required: false
input "btnTestIndoorRouting", "button", title: "βΆοΈ Test Indoor Routing"
input "numRoutingRooms", "number", title: "Number of Routing Zones (1-7)", defaultValue: 0, range: "0..7", submitOnChange: true
if (numRoutingRooms > 0) {
for (int i = 1; i <= (numRoutingRooms as Integer); i++) {
def lastAct = state.lastZoneMotion ? state.lastZoneMotion["${i}"] : 0
def sinceStr = lastAct ? "${Math.round((new Date().time - lastAct)/60000)}m ago" : "Never"
paragraph "Routing Zone ${i} (Last Active: ${sinceStr})"
input "routeRoomName_${i}", "text", title: "Zone Name", required: false, defaultValue: "Zone ${i}"
input "routeMotion_${i}", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false
input "routeSpeaker_${i}", "capability.speechSynthesis", title: "Target Speaker", required: false
input "routeVolume_${i}", "number", title: "Announcement Volume (0-100)", required: false
input "routeTVSwitch_${i}", "capability.switch", title: "Entertainment / TV Device (Roku, TV, etc.)", multiple: true, required: false
input "routeGNSwitch_${i}", "capability.switch", title: "Mute Zone when this Switch is ON (Good Night)", required: false
}
}
def inPrev = applyDynamicVars(settings.indoorDoorbellMsg ?: "This is %butler%. %interruption%, but there is a visitor at the front door.")
paragraph "Live Indoor Routing Preview: ${inPrev}
"
}
}
section("Perimeter Guarding (Do Not Disturb)", hideable: true, hidden: true) {
input "dndSwitch", "capability.switch", title: "Do Not Disturb Toggle Switch", required: false
input "dndModes", "mode", title: "Do Not Disturb Modes", multiple: true, required: false
input "dndRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "dndMotionDebounce", "number", title: "Motion Sensor Cooldown (Minutes)", defaultValue: 10, required: false
input "btnTestDND", "button", title: "βΆοΈ Test DND Intercept Audio"
for (int d = 1; d <= 10; d++) { input "dndMessage_${d}", "text", title: "DND Audio Message ${d}", required: false, defaultValue: getDefaultMessages("DND")[d-1] }
def dndPrev = applyDynamicVars(settings["dndMessage_1"] ?: getDefaultMessages("DND")[0])
paragraph "Live DND Intercept Preview (Routing: ${settings.dndRoutingMode ?: 'Outdoor Speaker Only'}): ${dndPrev}
"
}
section("Daytime Doorbell Acknowledgment", hideable: true, hidden: true) {
input "enableDaytimeDoorbell", "bool", title: "Enable Daytime Doorbell Acknowledgment?", defaultValue: false, submitOnChange: true
if (enableDaytimeDoorbell) {
input "daytimeRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "daytimeDoorbellVolume", "number", title: "Announcement Volume (0-100)", required: false
input "daytimeDoorbellDebounce", "number", title: "Cooldown (Minutes)", defaultValue: 2, required: false
for (int d = 1; d <= 20; d++) { input "daytimeMessage_${d}", "text", title: "Daytime Message ${d}", required: false, defaultValue: getDefaultMessages("Daytime")[d-1] }
input "btnTestDaytime", "button", title: "βΆοΈ Test Daytime Audio"
}
paragraph "---"
input "enableDaytimeFollowUp", "bool", title: "Enable Unanswered Door Follow-Up?", defaultValue: false, submitOnChange: true
if (enableDaytimeFollowUp) {
input "daytimeDoorContact", "capability.contactSensor", title: "Front Door Contact Sensor", required: true
input "daytimeFollowUpDelay", "number", title: "Wait Time (Minutes)", defaultValue: 3, required: false
for (int d = 1; d <= 5; d++) { input "daytimeNoAnswer_${d}", "text", title: "No Answer Message ${d}", required: false, defaultValue: getDefaultMessages("NoAnswer")[d-1] }
}
if (enableDaytimeDoorbell) {
def dayPrev = applyDynamicVars(settings["daytimeMessage_1"] ?: getDefaultMessages("Daytime")[0])
if (settings.enableDaytimeFollowUp) dayPrev += "Unanswered Follow-Up Preview: " + applyDynamicVars(settings["daytimeNoAnswer_1"] ?: getDefaultMessages("NoAnswer")[0])
paragraph " Live Daytime Preview (Routing: ${settings.daytimeRoutingMode ?: 'Outdoor Speaker Only'}): ${dayPrev}
"
}
}
section("After Hours Doorbell Intercept", hideable: true, hidden: true) {
input "enableAfterHours", "bool", title: "Enable After Hours Intercept?", defaultValue: false, submitOnChange: true
if (enableAfterHours) {
input "afterHoursTimeStart", "time", title: "After Hours Start Time", required: false
input "afterHoursTimeEnd", "time", title: "After Hours End Time", required: false
input "afterHoursRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "afterHoursVolume", "number", title: "Announcement Volume (0-100)", required: false
input "afterHoursDebounce", "number", title: "Cooldown (Minutes)", defaultValue: 5, required: false
for (int d = 1; d <= 15; d++) { input "afterHoursMessage_${d}", "text", title: "After Hours Message ${d}", required: false, defaultValue: getDefaultMessages("AfterHours")[d-1] }
input "btnTestAfterHours", "button", title: "βΆοΈ Test After Hours Audio"
def ahPrev = applyDynamicVars(settings["afterHoursMessage_1"] ?: getDefaultMessages("AfterHours")[0])
paragraph "Live After Hours Preview (Routing: ${settings.afterHoursRoutingMode ?: 'Outdoor Speaker Only'}): ${ahPrev}
"
}
}
section("Nighttime Intruder Deterrent", hideable: true, hidden: true) {
input "enableIntruder", "bool", title: "Enable Intruder Deterrent?", defaultValue: false, submitOnChange: true
if (enableIntruder) {
input "intruderModes", "mode", title: "Active Modes (e.g., Night)", multiple: true, required: false
input "intruderMotion", "capability.motionSensor", title: "Trigger Motion Sensors", multiple: true, required: false
input "intruderBypassDoors", "capability.contactSensor", title: "Bypass Doors (Dog Let-Out/User Exit)", multiple: true, required: false
input "intruderBypassMinutes", "number", title: "Door Bypass Timeout (Minutes)", defaultValue: 5, required: false
input "intruderRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "intruderDebounce", "number", title: "Deterrent Cooldown (Minutes)", defaultValue: 5, required: false
input "intruderVolume", "number", title: "Deterrent Announcement Volume (0-100)", required: false
input "smartCameraDevice", "capability.sensor", title: "Smart Camera", required: false
input "smartAttribute", "text", title: "Smart Detection Attribute", defaultValue: "smartDetectType"
for (int d = 1; d <= 3; d++) { input "intruderAnimal_${d}", "text", title: "Animal Message ${d}", required: false, defaultValue: ["Shoo! Get out of here!", "Go away!", "Move along animal!"][d-1] }
for (int d = 1; d <= 3; d++) { input "intruderPerson_${d}", "text", title: "Person Message ${d}", required: false, defaultValue: ["Warning. You are trespassing. Security has been notified.", "Perimeter breach detected. Cameras are recording your face.", "Please step away from the house. You are being recorded."][d-1] }
for (int d = 1; d <= 10; d++) { input "intruderMessage_${d}", "text", title: "Intruder Message ${d}", required: false, defaultValue: getDefaultMessages("Intruder")[d-1] }
input "btnTestIntruder", "button", title: "βΆοΈ Test Intruder Audio"
def intPrev = applyDynamicVars(settings["intruderMessage_1"] ?: getDefaultMessages("Intruder")[0])
def smartPrev = settings.smartCameraDevice ? "Smart Person Detection Preview: " + applyDynamicVars(settings["intruderPerson_1"] ?: "Warning. You are trespassing.") + " " : ""
paragraph "Live Intruder Preview (Routing: ${settings.intruderRoutingMode ?: 'Outdoor Speaker Only'}): ${intPrev} ${smartPrev}
"
}
}
section("Master Perimeter & Estate Security", hideable: true, hidden: true) {
paragraph "Designate all critical access points across the property you wish the Butler to guard. This includes primary house doors, secondary gates, and livestock coops. The Butler will automatically verify these are secured during the Good Night routine. "
input "estateDoors", "capability.contactSensor", title: "All Estate Doors, Gates & Coops", multiple: true, required: false
input "estateLocks", "capability.lock", title: "All Estate Smart Locks", multiple: true, required: false
paragraph " "
paragraph "Advanced Weather Logic "
input "stormSwitch", "capability.switch", title: "Severe Weather / Storm Override Switch", required: false
}
section("Severe Weather & Environmental Alerts", hideable: true, hidden: true) {
paragraph "Link virtual switches to weather events. The Butler will professionally announce these alerts to present users. Warnings will override room Good Night settings. "
input "enableWeatherAlerts", "bool", title: "Enable Weather Alerts?", defaultValue: false, submitOnChange: true
if (enableWeatherAlerts) {
input "weatherAlertModes", "mode", title: "Allowed Modes (Applies to Watches/Rain, WARNINGS override this)", multiple: true, required: false
input "weatherRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only"
input "weatherVolume", "number", title: "Alert Volume (0-100)", required: false
paragraph "Critical Warnings (Overrides Good Night) "
input "swTornadoWarn", "capability.switch", title: "Tornado Warning Switch", required: false
input "swTstormWarn", "capability.switch", title: "Thunderstorm Warning Switch", required: false
paragraph "Select up to 3 rooms. If a critical warning triggers while their Good Night switch is ON, the alarm will be forced to play on that specific speaker. "
for (int i = 1; i <= 3; i++) {
input "warnRoomSwitch_${i}", "capability.switch", title: "Room ${i} Good Night Switch", required: false
input "warnRoomSpeaker_${i}", "capability.speechSynthesis", title: "Room ${i} Speaker", required: false
}
paragraph "Standard Watches & Advisories "
input "swTornadoWatch", "capability.switch", title: "Tornado Watch Switch", required: false
input "swTstormWatch", "capability.switch", title: "Thunderstorm Watch Switch", required: false
input "swFloodWarn", "capability.switch", title: "Flood Warning Switch", required: false
input "swFloodWatch", "capability.switch", title: "Flood Watch Switch", required: false
input "swHeatWarn", "capability.switch", title: "Severe Heat Warning Switch", required: false
input "swHeatWatch", "capability.switch", title: "Severe Heat Watch Switch", required: false
paragraph "Precipitation Status "
input "swRain", "capability.switch", title: "Raining Switch", required: false
input "swSprinkle", "capability.switch", title: "Sprinkling Switch", required: false
paragraph " "
input "btnTestWeather", "button", title: "βΆοΈ Test Critical Weather Override"
}
}
section("Quick Exit (Away Mode Farewell)", hideable: true, hidden: true) {
paragraph "If the house enters Away Mode, the next door opened within 5 minutes will trigger a farewell message and a gas price scout. "
input "enableQuickExit", "bool", title: "Enable Quick Exit Farewell?", defaultValue: false, submitOnChange: true
if (enableQuickExit) {
input "quickExitDoors", "capability.contactSensor", title: "Exit Doors", multiple: true, required: true
input "quickExitModes", "mode", title: "Away Modes to Watch For", multiple: true, required: true
input "quickExitRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Outdoor Speaker Only", submitOnChange: true
input "quickExitVolume", "number", title: "Farewell Volume (0-100)", required: false
input "btnTestQuickExit", "button", title: "βΆοΈ Test Quick Exit Audio"
}
}
}
}
// ==============================================================
// 5. CORE SETUP & HARDWARE SUB-MENU
// ==============================================================
def pageHardware() {
def lockUsers = getLockUsers()
dynamicPage(name: "pageHardware", title: "βοΈ Core Setup & Hardware", install: false, uninstall: false) {
section("1. Global System Control & Audio Hardware", hideable: true, hidden: true) {
input "dashboardStatusDevice", "capability.actuator", title: "App Status Tile Device", required: false
paragraph "Primary Triggers "
input "frontDoorbell", "capability.pushableButton", title: "Front Doorbell Button", required: false
input "frontDoorMotion", "capability.motionSensor", title: "Front Porch/Door Motion Sensor(s)", multiple: true, required: false
paragraph " "
input "masterSwitch", "capability.switch", title: "Master Enable/Pause Switch", required: false
input "enableInbox", "bool", title: "Enable 'Silver Platter' Message Inbox (Hold missed alerts)?", defaultValue: false, submitOnChange: true
input "enableChime", "bool", title: "Enable Pre-Speech 'Throat Clear' Chime?", defaultValue: false, submitOnChange: true
if (enableChime) {
input "chimeUrl", "text", title: "Chime Audio File URL (MP3/WAV)", description: "e.g., http://127.0.0.1:8080/local/chime.mp3", required: true
}
input "guestModeSwitch", "capability.switch", title: "Guest Mode Mute Switch", required: false
input "notificationDevice", "capability.notification", title: "Silent Mode Notification Devices", multiple: true, required: false
input "ttsTTL", "number", title: "Message Expiration / Time-To-Live (Minutes)", defaultValue: 5, required: false
paragraph " "
paragraph "Quiet Hours Muting "
input "quietHoursStart", "time", title: "Quiet Hours Start Time", required: false
input "quietHoursEnd", "time", title: "Quiet Hours End Time", required: false
input "quietVolume", "number", title: "Quiet Hours Max Volume (0-100)", required: false
paragraph " "
input "globalIndoorSpeaker", "capability.speechSynthesis", title: "Global Indoor Speaker(s)", multiple: true, required: false
input "globalVolume", "number", title: "Global Speaker Volume (0-100)", required: false
input "globalTVSwitch", "capability.actuator", title: "Global Entertainment / TV Device", required: false
input "mediaPauseList", "capability.actuator", title: "Other Media Players to Pause/Mute", multiple: true, required: false
input "btnTestGlobal", "button", title: "βΆοΈ Test Global Indoor Speaker"
paragraph " "
input "outdoorSpeaker", "capability.speechSynthesis", title: "Outdoor/Porch Speaker", required: false
input "outdoorVolume", "number", title: "Default Outdoor Volume (0-100)", required: false
input "btnTestOutdoor", "button", title: "βΆοΈ Test Outdoor Speaker"
paragraph " "
input "wakeupPadDelay", "number", title: "Speaker Amp Warm-up Delay (Seconds)", defaultValue: 0
input "enableInternetCheck", "bool", title: "Enable Internet Connection Safety?", defaultValue: true
}
section("ποΈ Estate & Butler Branding", hideable: true, hidden: true) {
input "butlerName", "text", title: "Your Butler's Name", required: true, defaultValue: "Alfred"
input "estateName", "text", title: "Estate / Family Display Name", defaultValue: "The Family", required: true
input "butlerVoice", "enum", title: "Butler Voice Profile (SSML / Echo Speaks)", options: ["Default", "Matthew", "Brian", "Amy", "Emma", "Joey", "Justin", "Ivy", "Kendra", "Kimberly", "Salli"], defaultValue: "Default", required: false
paragraph "π Sonos Compatibility: Please keep this set to 'Default'. Sonos does not support dynamic voice switching. To change your Sonos voice, use the global Hubitat settings under Hub Details .
"
paragraph "This name will appear on your web portal and all guest invitation pages. "
input "estateContext", "textarea", title: "Household Context for AI (Optional)", description: "Teach the Butler about your family, pets, or house rules so it can answer chat questions better."
}
section("Global Foyer / Living Room Morning Report", hideable: true, hidden: true) {
paragraph "Used to deliver Global Incident Reports if you walk into the main living space in the morning before triggering a local room routine. "
input "butlerLrMotion", "capability.motionSensor", title: "Living Room Motion Sensor", required: false
input "butlerLrSpeaker", "capability.speechSynthesis", title: "Living Room Speaker", required: false
input "butlerLrVolume", "number", title: "Announcement Volume (0-100)", required: false
input "butlerLrModes", "mode", title: "Allowed Modes for Morning Report", multiple: true, required: false, defaultValue: ["Morning", "Home"]
}
section("Important Email Alerts (Google Webhook)", hideable: true, hidden: true) {
paragraph "Note: Requires the Google Apps Script bridge to be configured and running. "
input "enableEmailAlerts", "bool", title: "Enable Incoming Email Alerts?", defaultValue: false, submitOnChange: true
if (enableEmailAlerts) {
input "enableDeliveryTracking", "bool", title: "Announce Package Deliveries?", defaultValue: true
input "enableOomaVoicemail", "bool", title: "Announce Ooma Voicemails?", defaultValue: true
input "emailAlertModes", "mode", title: "Allowed Modes for Alerts", multiple: true, required: false
input "emailRoutingMode", "enum", title: "Audio Routing Mode", options: getRoutingOptions(), defaultValue: "Global Indoor Speaker Only", submitOnChange: true
input "emailVolume", "number", title: "Announcement Volume (0-100)", required: false
input "emailPrefix", "text", title: "Announcement Prefix", defaultValue: "%interruption%, you have just received an important email from", required: false
def emailPrev = applyDynamicVars((settings.emailPrefix ?: "%interruption%, you have just received an important email from") + " John Doe. The subject is: Project Update.")
def pkgPrev = applyDynamicVars("%interruption%, I am seeing a delivery notification from Amazon. The subject is: Your package is out for delivery. You have a package arriving today.")
def oomaPrev = applyDynamicVars("%interruption%, you have a new Ooma voicemail from John Doe, received at 2:00 PM. The message says: Please call me back when you get this.")
def previewHtml = "Live Email Preview: ${emailPrev} "
if (settings.enableDeliveryTracking) previewHtml += "Live Package Preview: ${pkgPrev} "
if (settings.enableOomaVoicemail) previewHtml += "Live Ooma Preview: ${oomaPrev} "
previewHtml += "
"
paragraph previewHtml
}
}
section("User Aliases (Secret Identities)", hideable: true, hidden: true) {
input "numAliases", "number", title: "Number of Aliases (0-5)", defaultValue: 0, range: "0..5", submitOnChange: true
if (numAliases > 0) {
for (int i = 1; i <= (numAliases as Integer); i++) {
input "aliasReal_${i}", lockUsers.size() > 0 ? "enum" : "text", title: "Real Name ${i}", options: lockUsers, required: false
input "aliasFake_${i}", "text", title: "Alias / Nickname ${i}", required: false
}
}
}
section("Advanced Features & Arrival Resets", hideable: true, hidden: true) {
input "enableDebug", "bool", title: "Enable Debug Logging?", defaultValue: false
input "resetModes", "mode", title: "Reset ALL Arrivals on Mode Change", multiple: true, required: false
input "btnForceReset", "button", title: "π Force Reset All Daily Statuses"
input "awayIgnoreModes", "mode", title: "Ignore 'Away' Triggers during these Modes", description: "Prevent being marked away during Night or Sleep modes.", multiple: true, required: false
}
}
}
def roomPage(params) {
def rNum = params?.roomNum ?: state.currentRoom ?: 1
state.currentRoom = rNum
dynamicPage(name: "roomPage", title: "Room Voice Setup", install: false, uninstall: false, previousPage: "mainPage") {
section("Zone Identification & Occupant", hideable: true, hidden: true) {
input "roomName_${rNum}", "text", title: "Custom Room Name", defaultValue: "Bedroom ${rNum}", submitOnChange: true
input "roomOccupantName_${rNum}", "text", title: "Primary Occupant Name(s)", defaultValue: "Guest", required: false
input "roomSpeaker_${rNum}", "capability.speechSynthesis", title: "Dedicated Room Speaker", required: false
// --- NEW: GLOBAL SPEAKER TOGGLE ---
input "roomGlobalMorning_${rNum}", "bool", title: "Also play Good Morning on Global Indoor Speaker?", defaultValue: false
// ----------------------------------
input "btnTestRoomSpk_${rNum}", "button", title: "βΆοΈ Test Room Speaker Link"
input "roomVolumeGN_${rNum}", "number", title: "Good Night Volume (0-100)", required: false
input "roomVolumeGM_${rNum}", "number", title: "Good Morning Volume (0-100)", required: false
}
section("Logic & Automations Triggers", hideable: true, hidden: true) {
input "roomGoodNightSwitch_${rNum}", "capability.switch", title: "Room Good Night Switch", required: false
input "roomWakeupMode_${rNum}", "enum", title: "Good Morning Trigger Mode", options: ["1. Immediate (When Good Night Switch turns OFF)", "2. Verified (Wait for switch OFF, then wait for Motion)", "3. Motion Driven (Trigger when Motion activates while switch is ON)"], defaultValue: "1. Immediate (When Good Night Switch turns OFF)", submitOnChange: true
if (settings["roomWakeupMode_${rNum}"] != "1. Immediate (When Good Night Switch turns OFF)") {
input "roomMotion_${rNum}", "capability.motionSensor", title: "Wake-Up Motion Sensors", multiple: true, required: false
}
input "delayGreetingGN_${rNum}", "number", title: "Good Night Greeting Delay (Seconds)", defaultValue: 5, required: false
input "delayGreetingGM_${rNum}", "number", title: "Good Morning Greeting Delay (Seconds)", defaultValue: 30, required: false
}
section("Personalized Greetings", hideable: true, hidden: true) {
input "btnTestGN_${rNum}", "button", title: "βΆοΈ Test Good Night Audio"
input "btnTestGM_${rNum}", "button", title: "βΆοΈ Test Good Morning Audio"
input "useCustomRoomMessages_${rNum}", "bool", title: "Write Custom Overrides?", defaultValue: false, submitOnChange: true
if (settings["useCustomRoomMessages_${rNum}"]) {
paragraph "Good Night Messages "
for (int m = 1; m <= 10; m++) { input "gnMessage_${rNum}_${m}", "text", title: "Good Night Message ${m}", required: false, defaultValue: getDefaultMessages("Good Night")[m-1] }
paragraph "Good Morning Messages "
for (int m = 1; m <= 10; m++) { input "gmMessage_${rNum}_${m}", "text", title: "Good Morning Message ${m}", required: false, defaultValue: getDefaultMessages("Good Morning")[m-1] }
} else {
paragraph "Smart Mode Active: The system will automatically inject ${settings["roomOccupantName_${rNum}"] ?: "Guest"} into one of the smart defaults. "
}
}
section("Room Briefing Add-ons (News & Agenda)", hideable: true, hidden: true) {
input "roomTimeDate_${rNum}", "bool", title: "Announce Current Time & Date?", defaultValue: false, submitOnChange: true
input "roomAnnounceInbox_${rNum}", "bool", title: "Announce Inbox Messages (Stashed Appliance Alerts)?", defaultValue: true, submitOnChange: true
input "roomAnnounceMeal_${rNum}", "bool", title: "Announce Today's Meal Plan?", defaultValue: false, submitOnChange: true
input "roomAgendaEnable_${rNum}", "bool", title: "Enable Daily Agenda Reminders?", defaultValue: false, submitOnChange: true
input "roomAnnounceTrash_${rNum}", "bool", title: "Announce Waste Management Status?", defaultValue: false, submitOnChange: true
if (settings["roomAgendaEnable_${rNum}"]) {
input "roomAgendaMonday_${rNum}", "text", title: "Monday", required: false
input "roomAgendaTuesday_${rNum}", "text", title: "Tuesday", required: false
input "roomAgendaWednesday_${rNum}", "text", title: "Wednesday", required: false
input "roomAgendaThursday_${rNum}", "text", title: "Thursday", required: false
input "roomAgendaFriday_${rNum}", "text", title: "Friday", required: false
input "roomAgendaSaturday_${rNum}", "text", title: "Saturday", required: false
input "roomAgendaSunday_${rNum}", "text", title: "Sunday", required: false
}
input "roomNewsEnable_${rNum}", "bool", title: "Enable Top Headlines News Fetcher?", defaultValue: false, submitOnChange: true
if (settings["roomNewsEnable_${rNum}"]) {
input "roomNewsFeed_${rNum}", "text", title: "RSS Feed URL", defaultValue: "https://feeds.npr.org/1001/rss.xml"
input "btnTestRoomNews_${rNum}", "button", title: "βΆοΈ Test Room News Fetch"
}
input "roomAnnounceNightMotion_${rNum}", "bool", title: "Announce Night Porch Motion in Morning Brief?", defaultValue: false, submitOnChange: true
input "roomAnnounceAwayDoorbell_${rNum}", "bool", title: "Announce Away Doorbell Rings in Morning Brief?", defaultValue: false, submitOnChange: true
}
section("Kid-Friendly Features (Junior Concierge)", hideable: true, hidden: true) {
input "roomKidsNightWatch_${rNum}", "bool", title: "Enable Anti-Monster Security Check?", defaultValue: false, submitOnChange: true
input "roomKidsWeekend_${rNum}", "bool", title: "Enable No-School Weekend Reminder?", defaultValue: false, submitOnChange: true
input "roomKidsMode_${rNum}", "bool", title: "Enable Morning Jokes & Facts?", defaultValue: false, submitOnChange: true
if (settings["roomKidsMode_${rNum}"]) {
input "roomJokesFile_${rNum}", "text", title: "Custom Jokes File (Hubitat File Manager)", description: "e.g., jokes.txt (One joke per line)", required: false
}
}
section("Weather, Security & Briefing Integrations", hideable: true, hidden: true) {
input "roomPerimeterCheck_${rNum}", "bool", title: "Run Perimeter Security Check on Good Night?", defaultValue: false, submitOnChange: true
input "roomEnableAnniversary_${rNum}", "bool", title: "Play Anniversary Greeting in this Room?", defaultValue: false
input "roomWeatherDevice_${rNum}", "capability.temperatureMeasurement", title: "Weather / Temperature Device", required: false, submitOnChange: true
input "roomWeatherGM_${rNum}", "bool", title: "Append Forecast to Good Morning", defaultValue: false
input "roomWeatherGN_${rNum}", "bool", title: "Append Forecast to Good Night", defaultValue: false
input "roomMaintenanceDevice_${rNum}", "capability.sensor", title: "House Maintenance Dashboard Device", required: false
input "roomAnnounceMaintenance_${rNum}", "bool", title: "Announce Overdue Maintenance in Morning Brief?", defaultValue: false
def gnPrev = buildRoomGreeting(rNum, "Good Night", [isTest: true])
def gmPrev = buildRoomGreeting(rNum, "Good Morning", [isTest: true])
paragraph "Live Routine Preview (${settings["roomName_${rNum}"] ?: "Room ${rNum}"}): Good Night: ${gnPrev} Good Morning: ${gmPrev}
"
}
}
}
def getDefaultMessages(type) {
// Log what is being requested to help troubleshoot
log.info "Butler: Fetching messages for type: '${type}'"
switch(type) {
case "Good Night": return ["Good night %name%.", "Sweet dreams %name%.", "Rest well.", "Sleep tight %name%.", "Have a peaceful night %name%.", "Rest easy %name%.", "Sleep well %name%."]
case "Good Morning": return ["Good morning %name%.", "Rise and shine %name%.", "Hello %name%.", "Good morning %name%, I hope you slept well."]
case "PartyMode": return ["Welcome, the homeowners have been expecting you. Let me notify them of your arrival. If the door is unlocked, please come right in.", "Greetings! The family is expecting you. I am letting them know you are here. Feel free to come inside if the door is open.", "Welcome to the party. I will announce your arrival. If the door is open, please head on in and make yourself at home."]
case "DND": return ["We cannot come to the door right now. The camera is recording, please leave your message.", "Please leave a package or a message. We are currently unavailable.", "Do not disturb is active. Please try again later.", "We are unable to answer the door right now. Video recording is active.", "No one is available to answer the door. Please leave a message."]
case "Daytime": return ["Please wait a moment, I am notifying the homeowner.", "Someone will be right with you, please hold on.", "Thank you for ringing, please wait while I fetch someone.", "The residents have been notified, please wait.", "Just a moment please, someone is on the way."]
case "AfterHours": return ["It is currently after hours, the homeowners are unavailable.", "The residents are done receiving visitors for the evening.", "It is too late for visitors. Please return tomorrow.", "The household is resting for the night. Please leave a message.", "We are no longer accepting visitors at this hour."]
case "NoAnswer": return ["I am sorry, but the homeowners are currently unavailable to come to the door.", "Apologies, but it seems no one is able to answer the door right now. Please leave a message.", "The homeowners are unable to come to the door at this moment. Have a good day."]
case "Intruder": return ["Unexpected motion detected. Cameras are currently recording.", "Warning. You are trespassing. Security has been notified.", "Perimeter breach detected. Video logging initiated.", "Please step away from the house. You are being recorded.", "Alert. Unauthorized movement detected. Activating security protocols."]
case "Arrival": return ["Welcome home, %name%.", "Glad you're back, %name%.", "Welcome back %name%, the house is ready.", "Good to see you, %name%.", "Hello %name%. I've adjusted the climate for your arrival."]
case "ScreenTime": return ["%interruption%, but the daily screen time allotment has expired. Please power down the device.", "Attention. Screen time is now over. Please find a non-digital activity.", "Sir, the screen time timer has reached zero. Please turn off the television.", "Excuse me, but screen time has concluded.", "The screen time switch has been deactivated. Please turn off the screens."]
case "Calendar": return ["%interruption%, but you have %event% starting in %time%.", "Sir, please note that %event% will begin in %time%.", "A quick reminder that your schedule shows %event% in %time%.", "Excuse me, but %event% is coming up in %time%."]
case "BreakingNews": return ["%interruption%, but a major news event has just occurred.", "Sir, I have an urgent news update.", "Excuse me, but there is breaking news.", "Pardon me, the news desk has just reported an update."]
case "MailArrival": return ["Pardon the interruption, but the mail has just arrived.", "I am pleased to inform you that the mail has been delivered.", "Attention: New mail has been placed in the mailbox."]
case "MailRetrieved": return ["The mail has been collected. Thank you.", "I see the mail has been retrieved.", "The mailbox is now empty, mail collection is complete."]
}
log.warn "Voice Butler: getDefaultMessages called with unknown type: '${type}'"
return ["Message"]
}
def installed() {
log.info "Advanced Voice Butler Installed."
initialize()
}
def updated() {
log.info "Advanced Voice Butler Updated."
unsubscribe()
unschedule()
initialize()
}
def ensureStateMaps() {
if (state.historyLog == null) state.historyLog = []
if (state.lastRoomGreeting == null) state.lastRoomGreeting = [:]
if (state.hasArrivedToday == null) state.hasArrivedToday = [:]
if (state.hasDepartedToday == null) state.hasDepartedToday = [:]
if (state.waitingForMotion == null) state.waitingForMotion = [:]
if (state.roomAlreadyAwake == null) state.roomAlreadyAwake = [:]
if (state.resetReasons == null) state.resetReasons = [:]
if (state.globalResetReason == null) state.globalResetReason = "Awaiting First Entry"
if (state.nightMotionCount == null) state.nightMotionCount = 0
if (state.awayDoorbellCount == null) state.awayDoorbellCount = 0
if (state.lastNightMotionCount == null) state.lastNightMotionCount = 0
if (state.lastAwayDoorbellCount == null) state.lastAwayDoorbellCount = 0
if (state.pendingMorningReport == null) state.pendingMorningReport = false
if (state.pendingArrivalReport == null) state.pendingArrivalReport = false
if (state.lastMode == null) state.lastMode = location.mode
if (state.lastMailDeliveryTime == null) state.lastMailDeliveryTime = 0
if (state.lastBypassDoorOpen == null) state.lastBypassDoorOpen = 0
if (state.lastIntruderAlert == null) state.lastIntruderAlert = 0
if (state.departureGracePeriodEnd == null) state.departureGracePeriodEnd = 0
if (state.lastDepartureTime == null) state.lastDepartureTime = [:]
if (state.internetActive == null) state.internetActive = true
if (state.ttsQueue == null) state.ttsQueue = []
if (state.speakingUntil == null) state.speakingUntil = 0
if (state.currentPriority == null) state.currentPriority = 99
if (state.lastMealTimeEvent == null) state.lastMealTimeEvent = 0
if (state.scheduledCalendarAlerts == null) state.scheduledCalendarAlerts = []
if (state.lastBreakingHeadline == null) state.lastBreakingHeadline = ""
if (state.originalVolumes == null) state.originalVolumes = [:]
if (state.calendarSyncTime == null) state.calendarSyncTime = ""
if (state.breakingNewsSyncTime == null) state.breakingNewsSyncTime = ""
if (state.mealNewsHeadline == null) state.mealNewsHeadline = ""
if (state.mealNewsSyncTime == null) state.mealNewsSyncTime = ""
if (state.learnedHabits == null) state.learnedHabits = [:]
if (state.anomalyAlertedToday == null) state.anomalyAlertedToday = [:]
if (state.spokenFacts == null) state.spokenFacts = [:]
if (state.lastOfficeIntercept == null) state.lastOfficeIntercept = 0
if (state.reminderCounts == null) state.reminderCounts = [:]
if (state.messageInbox == null) state.messageInbox = []
if (state.butlerNotes == null) state.butlerNotes = []
if (state.lastOverdueTasks == null) state.lastOverdueTasks = []
if (state.hostedEvents == null) state.hostedEvents = [:]
if (state.quickLockCodes == null) state.quickLockCodes = [:]
if (state.applianceHealthData == null) state.applianceHealthData = []
if (state.mealPlan == null) state.mealPlan = ["Monday":"", "Tuesday":"", "Wednesday":"", "Thursday":"", "Friday":"", "Saturday":"", "Sunday":""]
if (state.habitHeatMap == null) state.habitHeatMap = [:]
if (state.packageOnPorch == null) state.packageOnPorch = false
if (state.packageArrivalTime == null) state.packageArrivalTime = 0
if (state.lastIssueCount == null) state.lastIssueCount = 0
}
def initialize() {
ensureStateMaps()
// --- NEW: Restore Event Timers on Hub Reboot ---
state.hostedEvents?.each { eId, ev ->
def nowMs = new Date().time
if (ev.dateEpoch > nowMs) runOnce(new Date(ev.dateEpoch), "startHostedEvent", [data: [eventId: eId], overwrite: false])
if (ev.endEpoch && ev.endEpoch > nowMs) runOnce(new Date(ev.endEpoch), "endHostedEvent", [data: [eventId: eId], overwrite: false])
}
schedule("0 0 0 * * ?", "midnightReset")
schedule("0 0/15 * * * ?", "checkAnomalies")
runEvery1Hour("pollPresenceSensors")
// Subscriptions with correct string format
subscribe(location, "voiceButlerStaffSync", "staffSyncHandler")
subscribe(location, "tvManagerSync", "tvSyncHandler")
subscribe(location, "voiceButlerTvAlert", "tvAlertHandler")
if (settings.enableMiddayMaintenance) {
schedule("0 0 10 * * ?", "scheduleRandomMiddayMaintenance")
def cal = Calendar.getInstance(location.timeZone)
if (cal.get(Calendar.HOUR_OF_DAY) >= 10 && cal.get(Calendar.HOUR_OF_DAY) < 15) scheduleRandomMiddayMaintenance()
}
if (settings.enableRealTimeTasks && settings.middayMaintenanceDevice) {
subscribe(settings.middayMaintenanceDevice, "overdueTasks", "realTimeTaskHandler")
}
if (settings.enableDeviceHealthAlerts && settings.deviceHealthDevice) {
subscribe(settings.deviceHealthDevice, "issueCount", "deviceHealthHandler")
}
if (settings.enableHealthWindow && settings.healthWindowStart && settings.healthWindowEnd) {
schedule("0 5 0 * * ?", "scheduleHealthWindow")
scheduleHealthWindow()
}
if (settings.enableMarketReminder && settings.marketOpenTime) {
schedule(settings.marketOpenTime, "executeMarketReminder")
}
if (settings.enableWeatherAlerts) {
if (settings.swTornadoWarn) subscribe(settings.swTornadoWarn, "switch.on", "weatherAlertHandler")
if (settings.swTstormWarn) subscribe(settings.swTstormWarn, "switch.on", "weatherAlertHandler")
if (settings.swTornadoWatch) subscribe(settings.swTornadoWatch, "switch.on", "weatherAlertHandler")
if (settings.swTstormWatch) subscribe(settings.swTstormWatch, "switch.on", "weatherAlertHandler")
if (settings.swFloodWarn) subscribe(settings.swFloodWarn, "switch.on", "weatherAlertHandler")
if (settings.swFloodWatch) subscribe(settings.swFloodWatch, "switch.on", "weatherAlertHandler")
if (settings.swHeatWarn) subscribe(settings.swHeatWarn, "switch.on", "weatherAlertHandler")
if (settings.swHeatWatch) subscribe(settings.swHeatWatch, "switch.on", "weatherAlertHandler")
if (settings.swRain) {
subscribe(settings.swRain, "switch.on", "weatherAlertHandler")
subscribe(settings.swRain, "switch.off", "weatherAlertHandler")
}
if (settings.swSprinkle) {
subscribe(settings.swSprinkle, "switch.on", "weatherAlertHandler")
subscribe(settings.swSprinkle, "switch.off", "weatherAlertHandler")
}
}
if (settings.enableCinemaScout && settings.cinemaTime) {
schedule(settings.cinemaTime, "executeCinemaScout")
}
if (settings.enableQuickExit && settings.quickExitDoors) {
subscribe(settings.quickExitDoors, "contact.open", "quickExitDoorHandler")
}
if (settings.enableInternetCheck) { runEvery5Minutes("checkInternetConnection"); checkInternetConnection() }
if (frontDoorbell) {
subscribe(frontDoorbell, "pushed", "visitorHandler")
subscribe(frontDoorbell, "pushed", "countDoorbellHandler")
}
if (frontDoorMotion) {
subscribe(frontDoorMotion, "motion.active", "visitorHandler")
subscribe(frontDoorMotion, "motion.active", "countMotionHandler")
}
if (settings.enableDaytimeFollowUp && settings.daytimeDoorContact) {
subscribe(settings.daytimeDoorContact, "contact.open", "daytimeDoorHandler")
}
if (enableIntruder) {
if (intruderMotion) subscribe(intruderMotion, "motion.active", "intruderMotionHandler")
if (intruderBypassDoors) subscribe(intruderBypassDoors, "contact.open", "intruderDoorHandler")
if (smartCameraDevice && smartAttribute) { subscribe(smartCameraDevice, smartAttribute, "unifiProtectHandler") }
}
if (settings.enableFallbackPresence && settings.fallbackMotionSensors) {
subscribe(settings.fallbackMotionSensors, "motion", "fallbackMotionHandler")
state.fallbackTrackingActive = false
}
if (settings.enableSmartPackage && settings.packageCameraDevice && settings.packageAttribute) {
subscribe(settings.packageCameraDevice, settings.packageAttribute, "unifiPackageHandler")
}
if (frontDoorLock) subscribe(frontDoorLock, "lock.unlocked", "arrivalHandler")
def numPres = settings.numPresenceMappings ? settings.numPresenceMappings as Integer : 0
for (int i = 1; i <= numPres; i++) {
if (settings["fallbackPresence_${i}"]) {
subscribe(settings["fallbackPresence_${i}"], "presence.present", "presenceFallbackHandler")
}
}
if (settings.enableGroceryScout && settings.groceryTime && settings.groceryDay) {
schedule(settings.groceryTime, "executeGroceryScout")
}
// --- FIXED MAIL SUBSCRIPTIONS ---
if (settings.enableMailCheck && settings.mailSwitch) {
subscribe(settings.mailSwitch, "switch.on", "mailSwitchHandler")
subscribe(settings.mailSwitch, "switch.off", "mailClearedHandler")
}
if (settings.enableHeadedHome) {
def numHH = settings.numHeadedHome ? settings.numHeadedHome as Integer : 0
for (int i = 1; i <= numHH; i++) {
if (settings["hhSwitch_${i}"]) subscribe(settings["hhSwitch_${i}"], "switch.on", "headedHomeHandler")
}
}
if (settings.enableMealTime) {
if (settings.mealTimeSwitch) subscribe(settings.mealTimeSwitch, "switch.on", "mealTimeHandler")
if (settings.mealTimeButton && settings.mealTimeButtonAction) subscribe(settings.mealTimeButton, settings.mealTimeButtonAction, "mealTimeButtonHandler")
}
if (frontDoorContact) subscribe(frontDoorContact, "contact", "departureHandler")
if (settings.enableScreenTime && settings.screenTimeSwitch) { subscribe(settings.screenTimeSwitch, "switch.off", "screenTimeHandler") }
if (settings.enableOffice && settings.officeSwitch) { subscribe(settings.officeSwitch, "switch.on", "officeSwitchHandler") }
if (settings.enableDirectory) {
def numC = settings.numContacts ? settings.numContacts as Integer : 0
for (int i = 1; i <= numC; i++) {
if (settings["contactSwitch_${i}"]) {
subscribe(settings["contactSwitch_${i}"], "switch.on", "directorySwitchHandler")
}
}
}
subscribe(location, "mode", "modeChangeHandler")
subscribe(location, "voiceButlerMsg", "crossAppMessageHandler")
subscribe(location, "voiceButlerStaffSync", "staffSyncHandler")
subscribe(location, "voiceButlerApplianceSync", "applianceSyncHandler")
subscribe(location, "voiceButlerTrashSync", "trashSyncHandler")
if (butlerLrMotion) { subscribe(butlerLrMotion, "motion.active", "butlerLrMotionHandler") }
if (awayCheckTime) { schedule(awayCheckTime, "scheduledAwayCheck") }
def numMappings = settings.numAwayMappings ? settings.numAwayMappings as Integer : 0
for (int i = 1; i <= numMappings; i++) {
if (settings["awayMappingSwitch_${i}"]) { subscribe(settings["awayMappingSwitch_${i}"], "switch.on", "awaySwitchOnHandler") }
if (settings["awayMappingPresence_${i}"]) { subscribe(settings["awayMappingPresence_${i}"], "presence", "awayPresenceHandler") }
}
def numRoomsSet = settings.numRooms ? settings.numRooms as Integer : 0
for (int i = 1; i <= numRoomsSet; i++) {
if (settings["roomGoodNightSwitch_${i}"]) {
subscribe(settings["roomGoodNightSwitch_${i}"], "switch.on", "goodNightOnHandler")
subscribe(settings["roomGoodNightSwitch_${i}"], "switch.off", "goodNightOffHandler")
}
def mode = settings["roomWakeupMode_${i}"] ?: "1. Immediate (When Good Night Switch turns OFF)"
if (mode != "1. Immediate (When Good Night Switch turns OFF)") {
if (settings["roomMotion_${i}"]) subscribe(settings["roomMotion_${i}"], "motion.active", "roomMotionHandler")
}
}
if (settings.enableCalendar) {
if (settings.calendarType == "Built-In Device (Advanced Calendar App)" && settings.calendarDevice && settings.calEventTimeAttr) {
subscribe(settings.calendarDevice, settings.calEventTimeAttr, "calendarTimeHandler")
} else if (settings.calendarUrl) {
if (settings.calSyncMethod == "Standard .ics Polling (Delayed)") {
def cInt = settings.calPollInterval ?: "1 Hour"
if (cInt == "15 Minutes") runEvery15Minutes("pollCalendars")
else if (cInt == "30 Minutes") runEvery30Minutes("pollCalendars")
else if (cInt == "3 Hours") runEvery3Hours("pollCalendars")
else runEvery1Hour("pollCalendars")
pollCalendars()
}
}
}
if (settings.enableBreakingNews && settings.breakingNewsFeed) {
scheduleNextNewsPoll()
}
if (settings.dashboardStatusDevice) {
settings.dashboardStatusDevice.sendEvent(name: "appStatus", value: "Running and Active", descriptionText: "Voice Butler is active", isStateChange: true)
}
}
// --- CENTRAL ROUTING ENGINE ---
def executeRoutedTTS(String msg, String mode, indoorVol, outdoorVol, int priority = 2, boolean fastTrack = false, dedicatedSpeaker = null) {
def played = false
def anyRouted = false
mode = mode ?: "Global Indoor Speaker Only"
def allTargetSpeakers = []
if (mode.contains("Follow-Me")) {
def numRoutes = settings.numRoutingRooms ? settings.numRoutingRooms as Integer : 0
for (int i = 1; i <= numRoutes; i++) {
def mSensors = [settings["routeMotion_${i}"]].flatten().findAll { it != null }
if (mSensors && mSensors.any { it.currentValue("motion") == "active" }) {
// --- NEW: LOCALIZED DO NOT DISTURB ---
def gnSwitch = settings["routeGNSwitch_${i}"]
if (gnSwitch && gnSwitch.currentValue("switch") == "on") {
if (settings.enableDebug) log.debug "ROUTING: Skipped ${settings["routeRoomName_${i}"] ?: "Zone ${i}"} (Good Night Switch is ON)."
continue // Skip to the next room!
}
// -------------------------------------
if (settings["routeSpeaker_${i}"]) {
allTargetSpeakers << [spk: settings["routeSpeaker_${i}"], vol: (settings["routeVolume_${i}"] ?: indoorVol)]
anyRouted = true
}
}
}
}
if (mode == "Dedicated Feature Speaker" && dedicatedSpeaker) allTargetSpeakers << [spk: dedicatedSpeaker, vol: indoorVol]
if (mode == "Global Indoor Speaker Only" || mode == "Outdoor + Global Indoor" || mode.contains("Global Simultaneous") || (mode.contains("Fallback") && !anyRouted)) {
if (globalIndoorSpeaker) [globalIndoorSpeaker].flatten().each { allTargetSpeakers << [spk: it, vol: indoorVol] }
}
if (mode == "Outdoor Speaker Only" || mode == "Outdoor + Global Indoor" || mode.contains("+ Outdoor")) {
if (outdoorSpeaker) allTargetSpeakers << [spk: outdoorSpeaker, vol: outdoorVol]
}
if (allTargetSpeakers.size() > 0) {
allTargetSpeakers.each { item ->
// FIX: Capture if the TTS engine actually accepted the message
def queued = enqueueTTS(item.spk, msg, item.vol, priority, fastTrack)
if (queued) played = true
}
}
return played
}
// --- DASHBOARD SYNC FETCHERS ---
def parseRssResponse(resp) {
def rawText = ""
if (resp.data instanceof String || resp.data instanceof GString) {
rawText = resp.data.toString()
} else {
try { rawText = resp.data.text } catch (e) { rawText = resp.data.toString() }
}
return new XmlSlurper().parseText(rawText)
}
def syncMealNews() {
def feedUrl = settings.mealTimeNewsFeed ?: "https://feeds.npr.org/1001/rss.xml"
try {
httpGet([uri: feedUrl, headers: ["User-Agent": "Mozilla/5.0 (Hubitat; AdvancedVoiceButler)"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() >= 2) {
state.mealNewsHeadline = "${items[0].title.text().trim()} / ${items[1].title.text().trim()}"
state.mealNewsSyncTime = new Date().format("h:mm a", location.timeZone)
}
}
}
} catch (Exception e) { log.warn "Meal News Sync Error: ${e}"; state.mealNewsHeadline = "Fetch Error" }
}
def syncRoomNews(rNum) {
def feedUrl = settings["roomNewsFeed_${rNum}"] ?: "https://feeds.npr.org/1001/rss.xml"
try {
httpGet([uri: feedUrl, headers: ["User-Agent": "Mozilla/5.0 (Hubitat; AdvancedVoiceButler)"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() >= 2) {
state."roomNewsHeadline_${rNum}" = "${items[0].title.text().trim()} / ${items[1].title.text().trim()}"
state."roomNewsSyncTime_${rNum}" = new Date().format("h:mm a", location.timeZone)
}
}
}
} catch (Exception e) { log.warn "Room News Sync Error: ${e}"; state."roomNewsHeadline_${rNum}" = "Fetch Error" }
}
// --- NEW OFFICE INTERCEPTOR ---
def officeSwitchHandler(evt) {
ensureStateMaps()
// --- NEW COOLDOWN LOGIC ---
def lastOffice = state.lastOfficeIntercept ?: 0
def debounceHours = settings.officeDebounceHours != null ? settings.officeDebounceHours.toInteger() : 4
if ((new Date().time - lastOffice) < (debounceHours * 3600000)) {
if (settings.enableDebug) log.debug "OFFICE INTERCEPTOR: Suppressed (Cooldown active: triggered within the last ${debounceHours} hours)."
return
}
state.lastOfficeIntercept = new Date().time
// --------------------------
def allowedModes = [settings.officeModes].flatten().findAll { it != null }
if (allowedModes.size() > 0 && !allowedModes.contains(location.mode)) {
if (settings.enableDebug) log.debug "OFFICE INTERCEPTOR: Suppressed due to mode restriction."
return
}
def feedUrl = ""
if (settings.officeFeed == "Custom") feedUrl = settings.officeCustomUrl
else if (settings.officeFeed == "Engadget") feedUrl = "https://www.engadget.com/rss.xml"
else if (settings.officeFeed == "Wired") feedUrl = "https://www.wired.com/feed/rss"
else if (settings.officeFeed == "Ars Technica") feedUrl = "https://feeds.arstechnica.com/arstechnica/index"
else feedUrl = "https://techcrunch.com/feed/"
def finalMsg = "%interruption%, here is your latest science and technology update. "
try {
httpGet([uri: feedUrl, headers: ["User-Agent": "Mozilla/5.0"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() >= 2) {
def t1 = items[0].title.text().trim().replace("&", "and").replace("\"", "")
def t2 = items[1].title.text().trim().replace("&", "and").replace("\"", "")
finalMsg += "From ${settings.officeFeed}: ${t1}. In other news, ${t2}."
}
}
}
} catch (e) {
log.warn "Office Interceptor News Fetch Error: ${e}"
finalMsg += "I was unable to retrieve the latest headlines."
}
finalMsg = applyDynamicVars(finalMsg)
enqueueTTS(settings.officeSpeaker, finalMsg, settings.officeVolume ?: settings.globalVolume, 2, false)
addToHistory("OFFICE INTERCEPTOR: Science & Tech news delivered. Queued: '${finalMsg}'")
}
// --- ORGANIC BREAKING NEWS INTERCEPT LOGIC ---
def scheduleNextNewsPoll() {
def bInt = settings.breakingNewsInterval ?: "1 Hour"
def baseMins = 60
if (bInt == "15 Minutes") baseMins = 15
else if (bInt == "30 Minutes") baseMins = 30
else if (bInt == "3 Hours") baseMins = 180
def jitter = new Random().nextInt((baseMins / 3).toInteger() * 2) - (baseMins / 3).toInteger()
def nextMins = baseMins + jitter
if (nextMins < 5) nextMins = 5
runIn(nextMins * 60, "pollBreakingNews")
state.breakingNewsSyncTime = "Next check in ~${nextMins}m"
}
def pollBreakingNews() {
scheduleNextNewsPoll()
if (!settings.enableBreakingNews || !settings.breakingNewsFeed) return
try {
// Switched to robust httpGet to properly handle NPR redirects
httpGet([uri: settings.breakingNewsFeed, headers: ["User-Agent": "Mozilla/5.0 (Hubitat; AdvancedVoiceButler)"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() > 0) {
def topHeadline = items[0].title.text().trim().replace("&", "and").replace("\"", "")
// Only broadcast if we have a baseline AND the headline is brand new
if (state.lastBreakingHeadline != "" && state.lastBreakingHeadline != topHeadline) {
if (settings.enableDebug) log.debug "SYSTEM: New breaking news detected: '${topHeadline}'"
executeBreakingNews(topHeadline)
} else if (state.lastBreakingHeadline == "") {
if (settings.enableDebug) log.debug "SYSTEM: Breaking News baseline set. Waiting for next new headline."
}
state.lastBreakingHeadline = topHeadline
}
}
}
} catch (Exception e) {
log.warn "Voice Butler: Breaking News Fetch Error - ${e}"
}
}
def executeBreakingNews(headline, isTest = false) {
ensureStateMaps()
if (!isTest) {
def allowedModes = [settings.breakingNewsModes].flatten().findAll { it != null }
if (allowedModes.size() > 0 && !allowedModes.contains(location.mode)) return
}
def messages = []
for (int d = 1; d <= 4; d++) { if (settings["breakingNewsPrefix_${d}"]) messages << settings["breakingNewsPrefix_${d}"] }
if (!messages) messages = getDefaultMessages("BreakingNews")
def randomMsg = applyDynamicVars(messages[new Random().nextInt(messages.size())]) + " " + headline + "."
executeRoutedTTS(randomMsg, settings.breakingNewsRoutingMode ?: "Follow-Me + Fallback (Global ONLY if no motion)", settings.breakingNewsVolume ?: settings.globalVolume, settings.outdoorVolume, 2)
addToHistory("BREAKING NEWS: Interpolated organic fetch triggered. Queued: '${randomMsg}'")
}
// --- CALENDAR & SECRECY LOGIC ---
def pollCalendars() {
if (settings.calendarType == "Built-In Device (Advanced Calendar App)") return
// FIX: Prevent background polling and the Force Sync button from wiping the
// dashboard if you are actively using the Google Apps Script Webhook!
if (settings.calSyncMethod == "Google Apps Script Webhook (Instant)") return
if (!settings.calendarUrl) return
try {
asynchttpGet("iCalResponseHandler", [uri: settings.calendarUrl, headers: ["User-Agent": "Mozilla/5.0"], timeout: 15])
} catch (Exception e) { log.error "Failed to fetch iCal/GCal URL: ${e}" }
}
def iCalResponseHandler(response, data) {
if (response.hasError() || response.status != 200) {
log.warn "Calendar Fetch failed. Status: ${response.status}"
return
}
try {
// Asynchronous Hubitat responses require .getData() to guarantee safe string extraction
def text = response.getData() ?: ""
// Remove line folding (CRLF followed by space/tab)
text = text.replaceAll(/\r?\n[ \t]/, "")
def nowMs = new Date().time
def nextEventName = ""
def nextEventTime = Long.MAX_VALUE
def events = text.findAll(/(?s)BEGIN:VEVENT.*?END:VEVENT/)
events.each { evtStr ->
def summaryMatch = evtStr =~ /SUMMARY[^:]*:([^\r\n]+)/
def dtstartMatch = evtStr =~ /DTSTART[^:]*:([^\r\n]+)/
def locationMatch = evtStr =~ /LOCATION[^:]*:([^\r\n]+)/
if (summaryMatch && dtstartMatch) {
def eName = summaryMatch[0][1].trim()
def tStr = dtstartMatch[0][1].trim()
def eDate
try {
if (tStr.length() == 8) {
def sdf = new java.text.SimpleDateFormat("yyyyMMdd")
sdf.setTimeZone(location.timeZone)
eDate = sdf.parse(tStr)
def cal = Calendar.getInstance(location.timeZone)
cal.setTime(eDate)
cal.set(Calendar.HOUR_OF_DAY, 8)
eDate = cal.getTime()
} else {
def format = tStr.endsWith("Z") ? "yyyyMMdd'T'HHmmss'Z'" : "yyyyMMdd'T'HHmmss"
def tz = tStr.endsWith("Z") ? TimeZone.getTimeZone("UTC") : location.timeZone
def sdf = new java.text.SimpleDateFormat(format)
sdf.setTimeZone(tz)
eDate = sdf.parse(tStr)
}
if (eDate.time > nowMs && eDate.time < nextEventTime) {
nextEventTime = eDate.time
nextEventName = eName
if (locationMatch) {
def rawLoc = locationMatch[0][1].trim()
state.nextEventLocation = rawLoc.replace("\\,", ",").replace("\\;", ";")
} else {
state.nextEventLocation = null
}
}
} catch(Exception dateEx) {}
}
}
if (nextEventTime != Long.MAX_VALUE) {
calendarTimeHandler([value: nextEventTime.toString()], nextEventName)
} else {
state.nextEventName = "No Upcoming Events"
state.nextEventTimeStr = "--"
state.nextEventLocation = null
state.calendarSyncTime = new Date().format("h:mm a", location.timeZone)
}
} catch (Exception e) { log.error "iCal Parse Error: ${e}" }
}
def calendarTimeHandler(evt, passedTitle = null) {
ensureStateMaps()
def epochStr = evt.value
if (!epochStr || !epochStr.isNumber()) return
def eventEpoch = epochStr.toLong()
def now = new Date().time
unschedule("executeCalendarAlert")
if (eventEpoch > now) {
def title = passedTitle ?: (settings.calendarType == "Built-In Device (Advanced Calendar App)" ? settings.calendarDevice.currentValue(settings.calEventTitleAttr) : "an appointment")
state.nextEventName = title
state.nextEventEpoch = eventEpoch
state.nextEventTimeStr = new Date(eventEpoch).format("MMM d 'at' h:mm a", location.timeZone)
// 1. Schedule standard intervals
def intervals = [settings.calAlertIntervals].flatten().findAll { it != null }
intervals.each { interval ->
def offsetMs = 0
if (interval == "3 Hours") offsetMs = 3 * 3600000
else if (interval == "2 Hours") offsetMs = 2 * 3600000
else if (interval == "1 Hour") offsetMs = 3600000
else if (interval == "30 Minutes") offsetMs = 30 * 60000
else if (interval == "15 Minutes") offsetMs = 15 * 60000
def alertTime = eventEpoch - offsetMs
if (alertTime > now) {
runOnce(new Date(alertTime), "executeCalendarAlert", [data: [title: title, timeStr: interval], overwrite: false])
}
}
// 2. NEW: THE PROACTIVE TRAVEL CHECK
def travelCheckTime = eventEpoch - 3600000
if (travelCheckTime > now) {
runOnce(new Date(travelCheckTime), "executeCalendarAlert", [data: [title: title, timeStr: "Travel Check", isProactive: true], overwrite: false])
}
runOnce(new Date(eventEpoch), "executeCalendarAlert", [data: [title: title, timeStr: "0 Minutes"], overwrite: false])
}
}
def getSmartEventContext(String title) {
if (!title) return [text: "", reason: null]
def tLow = title.toLowerCase()
if (tLow.contains("birthday") || tLow.contains("anniversary") || tLow.contains("surprise") || tLow.contains("gift") || tLow.contains("present") || tLow.contains("mother's day") || tLow.contains("father's day") || tLow.contains("valentine") || tLow.contains("christmas") || tLow.contains("xmas")) {
def targetSpoiled = false
def matchedName = ""
def presentUsers = getPresentUsers()
presentUsers.each { pName ->
if (tLow.contains(pName.toLowerCase()) || tLow.contains(applyAlias(pName).toLowerCase())) {
targetSpoiled = true
matchedName = applyAlias(pName)
}
}
if (targetSpoiled) return [text: "SECRET", reason: "Target (${matchedName}) is currently home."]
if (tLow.contains("gift") || tLow.contains("present") || tLow.contains("surprise") || tLow.contains("christmas") || tLow.contains("xmas")) {
if (presentUsers.size() > 1) return [text: "SECRET", reason: "Multiple people are home during a gift/surprise event."]
}
if (tLow.contains("anniversary")) return [text: "I see an anniversary is approaching. Have you secured a gift and reservations yet?", reason: null]
if (tLow.contains("birthday")) return [text: "A birthday is coming up. I advise preparing a gift if you have not already.", reason: null]
return [text: "As this is a special occasion, please remember to prepare your gifts or arrangements.", reason: null]
}
if (tLow.contains("dinner") || tLow.contains("restaurant") || tLow.contains("reservation") || tLow.contains("supper")) {
return [text: "Since this is a dining event, I recommend verifying that your reservations are confirmed.", reason: null]
} else if (tLow.contains("flight") || tLow.contains("airport") || tLow.contains("travel")) {
return [text: "As you will be traveling, please ensure you have your identification and travel documents ready.", reason: null]
} else if (tLow.contains("doctor") || tLow.contains("dentist") || tLow.contains("appointment")) {
return [text: "Please remember to bring any necessary identification and insurance information to your appointment.", reason: null]
}
return [text: "", reason: null]
}
def executeCalendarAlert(data) {
ensureStateMaps()
def title = data.title
def timeStr = data.timeStr
def isTest = data.isTest ?: false
def location = state.nextEventLocation
log.info "Voice Butler: Executing Calendar Routine for '${title}' (${timeStr})."
// --- TRAVEL TIME LOGIC ---
def travelWarning = ""
if (location && (timeStr == "30 Minutes" || timeStr == "15 Minutes" || data.isProactive)) {
def minsToDrive = getTravelInfo(location)
if (minsToDrive) {
def now = new Date().time
def eventStart = state.nextEventEpoch
def buffer = settings.leaveNowBuffer ?: 5
def minutesUntilEvent = ((eventStart - now) / 60000).toInteger()
def gasData = getCheapestGas()
// If drive time + buffer is greater than or equal to the time left...
if ((minsToDrive + buffer) >= minutesUntilEvent) {
// THE FIX: Professional, polite, but urgent warning.
travelWarning = "Pardon the urgent interruption. If you do not leave now, you will be late for ${title}. Traffic is currently heavy, and it will take approximately ${minsToDrive} minutes to reach ${location}."
if (gasData) travelWarning += gasData.speech
}
// Send the Push Notification ONLY during the 1-hour Proactive check so we don't spam their phone 3 times
if (data.isProactive && settings.enableTravelPush && settings.notificationDevice) {
def pushMsg = "Event: ${title}\nLocation: ${location}"
if (gasData) pushMsg += "\n\nCheapest Gas on the way:\n${gasData.rawName}\n${gasData.rawAddress}"
settings.notificationDevice.each { dev ->
try { dev.deviceNotification(pushMsg) } catch(e) {}
}
addToHistory("PUSH: Travel intel sent to phones for ${title}.")
}
}
}
// 1. Enforce Allowed Modes
def allowedModes = [settings.calAlertModes].flatten().findAll { it != null }
if (!isTest && allowedModes.size() > 0 && !allowedModes.contains(location.mode)) {
log.info "Voice Butler: Calendar Alert for '${title}' suppressed due to restricted House Mode."
return
}
def finalMsg = ""
if (travelWarning) {
finalMsg = applyDynamicVars(travelWarning)
} else {
// Standard Calendar Message Logic
def messages = []
for (int d = 1; d <= 4; d++) { if (settings["calMessage_${d}"]) messages << settings["calMessage_${d}"] }
if (!messages) messages = getDefaultMessages("Calendar")
def rawMsg = messages[new Random().nextInt(messages.size())]
if (timeStr == "0 Minutes") {
rawMsg = rawMsg.replace("%event%", title).replace(" in %time%", " right now").replace("%time%", "right now")
} else {
rawMsg = rawMsg.replace("%event%", title).replace("%time%", timeStr)
}
finalMsg = applyDynamicVars(rawMsg)
}
// --- NEW: CALENDAR RAIN WARNING ---
if (settings.calWeatherDevice) {
def cond = settings.calWeatherDevice.currentValue("weather")?.toString()?.toLowerCase() ?: ""
if (cond.contains("rain") || cond.contains("storm") || cond.contains("shower") || cond.contains("drizzle")) {
finalMsg += " Please be advised, rain is in the forecast, so you may want to bring a jacket or umbrella."
}
}
// ----------------------------------
executeRoutedTTS(finalMsg, settings.calRoutingMode, settings.calVolume ?: settings.globalVolume, settings.outdoorVolume, 2)
def prefix = isTest ? "TEST TRAVEL CONCIERGE: " : "TRAVEL CONCIERGE: "
addToHistory("${prefix}${finalMsg}")
}
// --- AI HABIT & ANOMALY ENGINE ---
def checkAndRegisterFact(String factText, int ttlHours = 4, String contextKey = "global", boolean isTest = false) {
if (!factText) return ""
if (isTest) return factText // <--- Prevents memory poisoning during tests
ensureStateMaps()
def now = new Date().time
// Clean up expired memories
def keysToRemove = []
state.spokenFacts.each { k, v -> if (now > v) keysToRemove << k }
keysToRemove.each { state.spokenFacts.remove(it) }
def factHash = contextKey + "_" + factText.hashCode().toString()
if (state.spokenFacts[factHash] && now < state.spokenFacts[factHash]) {
if (settings.enableDebug) log.debug "MEMORY FILTER: Suppressed repeated fact for context '${contextKey}'"
return ""
} else {
state.spokenFacts[factHash] = now + (ttlHours * 3600000)
return factText
}
}
def updateHabit(String uName, Long epochTime, String habitType = "departure") {
ensureStateMaps()
if (!state.learnedHabits) state.learnedHabits = [:]
if (!state.learnedHabits[uName]) state.learnedHabits[uName] = [avgDepartureMins: 0, count: 0, avgArrivalMins: 0, arrCount: 0, avgSleepMins: 0, sleepCount: 0]
def cal = Calendar.getInstance(location.timeZone)
cal.setTime(new Date(epochTime))
def minsPastMidnight = (cal.get(Calendar.HOUR_OF_DAY) * 60) + cal.get(Calendar.MINUTE)
if (habitType == "departure") {
def currentAvg = state.learnedHabits[uName].avgDepartureMins ?: 0
def currentCount = state.learnedHabits[uName].count ?: 0
state.learnedHabits[uName].avgDepartureMins = (((currentAvg * currentCount) + minsPastMidnight) / (currentCount + 1)).toInteger()
state.learnedHabits[uName].count = currentCount + 1
} else if (habitType == "arrival") {
def currentAvg = state.learnedHabits[uName].avgArrivalMins ?: 0
def currentCount = state.learnedHabits[uName].arrCount ?: 0
state.learnedHabits[uName].avgArrivalMins = (((currentAvg * currentCount) + minsPastMidnight) / (currentCount + 1)).toInteger()
state.learnedHabits[uName].arrCount = currentCount + 1
} else if (habitType == "sleep") {
// Shift midnight crossover times so 11 PM and 1 AM average out correctly
def shiftedMins = minsPastMidnight < 720 ? minsPastMidnight + 1440 : minsPastMidnight
def currentAvg = state.learnedHabits[uName].avgSleepMins ?: 0
def currentCount = state.learnedHabits[uName].sleepCount ?: 0
state.learnedHabits[uName].avgSleepMins = (((currentAvg * currentCount) + shiftedMins) / (currentCount + 1)).toInteger()
state.learnedHabits[uName].sleepCount = currentCount + 1
}
}
def formatMinsToTime(int mins) {
def h = (mins / 60).toInteger()
def m = mins % 60
def ampm = h >= 12 ? "PM" : "AM"
if (h > 12) h -= 12
if (h == 0) h = 12
return String.format("%d:%02d %s", h, m, ampm)
}
def checkAnomalies() {
ensureStateMaps()
def cal = Calendar.getInstance(location.timeZone)
def dow = cal.get(Calendar.DAY_OF_WEEK)
if (dow == Calendar.SATURDAY || dow == Calendar.SUNDAY) return
def nowMins = (cal.get(Calendar.HOUR_OF_DAY) * 60) + cal.get(Calendar.MINUTE)
state.learnedHabits?.each { uName, habitData ->
// THE FIX: Ensure they are actually home AND haven't departed yet!
def isHome = (state.hasArrivedToday[uName] == true || state.hasArrivedToday[uName] == "true") && !(state.hasDepartedToday[uName] == true || state.hasDepartedToday[uName] == "true")
if (habitData.avgDepartureMins && isHome) {
if (!state.anomalyAlertedToday[uName]) {
def expected = habitData.avgDepartureMins
// The 120-minute cap ensures late arrivals don't trigger the warning!
if (nowMins > (expected + 15) && nowMins < (expected + 120)) {
def expectedTimeStr = formatMinsToTime(expected)
def dispName = applyAlias(uName)
def msg = "Pardon me, ${dispName}. I noticed you normally depart around ${expectedTimeStr}, and you are still home. Are we running behind schedule today?"
executeRoutedTTS(msg, "Global Indoor Speaker Only", settings.globalVolume, settings.outdoorVolume, 2)
addToHistory("AI ANOMALY: ${dispName} missed learned departure window (${expectedTimeStr}). Proactive check initiated.")
state.anomalyAlertedToday[uName] = true
}
}
}
}
}
// --- MAIL DELIVERY HANDLER (Triggered by Switch ON) ---
def mailSwitchHandler(evt) {
ensureStateMaps()
state.lastMailDeliveryTime = new Date().time
def msgs = getDefaultMessages("MailArrival")
def randomMsg = msgs[new Random().nextInt(msgs.size())]
log.info "MAIL Handler: Selected message: ${randomMsg}"
// Announce
executeRoutedTTS(applyDynamicVars(randomMsg), settings.emailRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, settings.emailVolume ?: settings.globalVolume, 2)
addToHistory("MAIL: ${randomMsg}")
}
// --- MEAL TIME HANDLERS ---
def mealTimeButtonHandler(evt) {
def btnNum = settings.mealTimeButtonNumber ?: 1
if (evt.value.toString() == btnNum.toString()) {
mealTimeHandler([value: "button_trigger"])
}
}
def mealTimeHandler(evt) {
if (evt.value != "on" && evt.value != "test" && evt.value != "button_trigger") return
ensureStateMaps()
def now = new Date().time
def lastMeal = state.lastMealTimeEvent ?: 0
if ((now - lastMeal) < 60000 && evt.value != "test") return
state.lastMealTimeEvent = now
// Determine the routing mode based on how it was triggered
def rMode = settings.mealTimeRoutingMode ?: "Global Indoor Speaker Only"
if (evt.value == "button_trigger") {
rMode = "Follow-Me (Active Rooms Only)"
addToHistory("MEAL TIME: Triggered via physical button. Constraining to Active Rooms Only.")
}
// --- PART 1: THE DINNER BELL & ROLL CALL (Chime Active) ---
def bellMsg = settings.mealTimeDinnerBell ?: "%interruption%, but dinner is now served."
if (settings.mealTimeAbsentee) {
def missing = []
def allTracked = []
if (settings.arrivalMode == "Automatic (Reads lock memory)" && settings.trackedLockCodes) {
settings.trackedLockCodes.each { c -> allTracked << (c.toLowerCase() == "admin code" && settings.adminUserAlias ? settings.adminUserAlias : c) }
} else if (settings.numLockUsers) {
for (int i = 1; i <= (settings.numLockUsers as Integer); i++) { if (settings["lockUserName_${i}"]) allTracked << settings["lockUserName_${i}"] }
}
allTracked = allTracked.unique()
def guestList = [settings.guestUsers].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.guestCustomUsers) guestList += settings.guestCustomUsers.split(',').collect { it.trim().toLowerCase() }
allTracked.each { u ->
if (!state.hasArrivedToday || state.hasArrivedToday[u] != true) {
if (!guestList.contains(u.toLowerCase()) && !guestList.contains(applyAlias(u).toLowerCase())) {
missing << applyAlias(u)
}
}
}
if (missing.size() > 0) {
def names = (missing.size() == 1) ? missing[0] : (missing.size() == 2) ? "${missing[0]} and ${missing[1]}" : "${missing[0..-2].join(', ')}, and ${missing.last()}"
bellMsg += " Please note that ${names} have not yet returned home."
}
}
// Send Part 1
executeRoutedTTS(applyDynamicVars(bellMsg), rMode, settings.mealTimeVolume ?: settings.globalVolume, settings.outdoorVolume, 2, false, settings.mealTimeSpeaker)
// --- PART 2: THE NEWS DIGEST (Fast Tracked / No Chime) ---
if (settings.mealTimeNewsWeather) {
def newsMsg = ""
def wDevice = settings.mealTimeWeatherDevice
if (wDevice) {
def wText = getWeatherReport(wDevice)
if (wText) newsMsg += wText + " "
}
def feedUrl = settings.mealTimeNewsFeed ?: "https://feeds.npr.org/1001/rss.xml"
try {
httpGet([uri: feedUrl, headers: ["User-Agent": "Mozilla/5.0"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() >= 2) {
def t1 = items[0].title.text().trim().replace("&", "and")
def t2 = items[1].title.text().trim().replace("&", "and")
newsMsg += "In the news this evening: ${t1}. Additionally, ${t2}."
}
}
}
} catch (Exception e) {}
if (newsMsg) {
executeRoutedTTS(applyDynamicVars(newsMsg), rMode, settings.mealTimeVolume ?: settings.globalVolume, settings.outdoorVolume, 3, true, settings.mealTimeSpeaker)
}
}
// --- PART 3: THE CONVERSATION STARTER (Fast Tracked / No Chime) ---
if (settings.enableMealQuestions) {
def questionMsg = getMealTimeQuestion()
executeRoutedTTS(applyDynamicVars(questionMsg), rMode, settings.mealTimeVolume ?: settings.globalVolume, settings.outdoorVolume, 4, true, settings.mealTimeSpeaker)
}
if (evt.value != "button_trigger") addToHistory("MEAL TIME: Routine triggered in 3 parts for clarity.")
}
// --- HEADED HOME HANDLERS ---
def headedHomeHandler(evt) {
ensureStateMaps()
def devId = evt.device.id
def matchIdx = 0
def numHH = settings.numHeadedHome ? settings.numHeadedHome as Integer : 0
for (int i = 1; i <= numHH; i++) {
if (settings["hhSwitch_${i}"]?.id == devId) { matchIdx = i; break }
}
if (matchIdx > 0) {
// Auto-turn off the virtual switch so it functions like a momentary trigger
try { settings["hhSwitch_${matchIdx}"].off() } catch(e) {}
executeHeadedHome(matchIdx)
}
}
def executeHeadedHome(int idx) {
def uName = settings["hhUser_${idx}"] ?: "Someone"
def displayUserName = applyAlias(uName)
def msg = settings["hhMessage_${idx}"] ?: "%interruption%, but %name% is on their way home."
def finalMsg = applyDynamicVars(msg.replace("%name%", displayUserName))
def rMode = settings["hhRouting_${idx}"] ?: "Global Indoor Speaker Only"
def targetVol = settings["hhVolume_${idx}"] != null ? settings["hhVolume_${idx}"] : settings.globalVolume
executeRoutedTTS(finalMsg, rMode, targetVol, settings.outdoorVolume, 2)
addToHistory("HEADED HOME: Announced ${displayUserName} is on their way.")
}
def testMealNews() {
def feedUrl = settings.mealTimeNewsFeed ?: "https://feeds.npr.org/1001/rss.xml"
def finalMsg = ""
try {
httpGet([uri: feedUrl, headers: ["User-Agent": "Mozilla/5.0 (Hubitat; AdvancedVoiceButler)"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() >= 2) {
def title1 = items[0].title.text().trim().replace("&", "and").replace("\"", "")
def title2 = items[1].title.text().trim().replace("&", "and").replace("\"", "")
finalMsg = "This is a test of my connection to the evening news desk. In the news today: ${title1}. In other news, ${title2}."
}
}
}
} catch (Exception e) { finalMsg = "I am currently unable to reach the news desk to retrieve the latest headlines." }
if (finalMsg) executeRoutedTTS(finalMsg, settings.mealTimeRoutingMode ?: "Global Indoor Speaker Only", settings.mealTimeVolume ?: settings.globalVolume, settings.outdoorVolume, 1, false, settings.mealTimeSpeaker)
}
// --- ALIAS & DYNAMIC HELPERS ---
def getTrackedUsers() {
def allNames = []
if (settings.arrivalMode == "Automatic (Reads lock memory)") {
settings.trackedLockCodes?.each { codeName ->
if (codeName.toLowerCase() == "admin code" && settings.adminUserAlias) { allNames << settings.adminUserAlias } else { allNames << codeName }
}
} else if (settings.numLockUsers && settings.numLockUsers > 0) {
for (int i = 1; i <= (settings.numLockUsers as Integer); i++) {
if (settings["lockUserName_${i}"]) allNames << settings["lockUserName_${i}"]
}
}
allNames += (state.hasArrivedToday ?: [:]).keySet().findAll { it != "global" }
return allNames.unique().sort()
}
def getPresentUsers() {
ensureStateMaps()
def present = []
def allNames = []
if (arrivalMode == "Automatic (Reads lock memory)") {
settings.trackedLockCodes?.each { codeName ->
if (codeName.toLowerCase() == "admin code" && settings.adminUserAlias) { allNames << settings.adminUserAlias } else { allNames << codeName }
}
} else if (numLockUsers && numLockUsers > 0) {
for (int i = 1; i <= (numLockUsers as Integer); i++) {
if (settings["lockUserName_${i}"]) allNames << settings["lockUserName_${i}"]
}
}
allNames += (state.hasArrivedToday ?: [:]).keySet().findAll { it != "global" }
allNames = allNames.unique()
allNames.each { uName -> if (state.hasArrivedToday[uName] == true && uName != "global") present << applyAlias(uName) }
return present
}
def applyAlias(String name) {
if (!name) return name
def num = settings.numAliases ? settings.numAliases as Integer : 0
for (int i = 1; i <= num; i++) {
def real = settings["aliasReal_${i}"]
def fake = settings["aliasFake_${i}"]
if (real && fake && real.trim().equalsIgnoreCase(name.trim())) return fake.trim()
}
return name
}
def applyDynamicVars(String msg) {
if (!msg) return ""
def tz = location?.timeZone ?: TimeZone.getDefault()
def now = new Date()
def tStr = now.format("h:mm a", tz)
def dStr = now.format("EEEE, MMMM d", tz)
def hour = now.format("H", tz).toInteger()
def timeOfDay = "evening"
if (hour >= 4 && hour < 12) timeOfDay = "morning"
else if (hour >= 12 && hour < 17) timeOfDay = "afternoon"
def bName = settings.butlerName ?: "the concierge"
def present = getPresentUsers()
def interruptStr = "Pardon the interruption"
if (present.size() == 1) interruptStr = "Pardon the interruption, ${present[0]}"
else if (present.size() > 1) interruptStr = "Pardon the interruption everyone"
// Dynamically build a name string for integrations that pass %name%
def dynamicName = "everyone"
if (present.size() == 1) dynamicName = present[0]
else if (present.size() > 1) dynamicName = present.join(" and ")
return msg.replace("%time%", tStr)
.replace("%date%", dStr)
.replace("%butler%", bName)
.replace("%timeOfDay%", timeOfDay)
.replace("%interruption%", interruptStr)
.replace("%name%", dynamicName)
}
// --- WEATHER HELPER ---
def getWeatherReport(wDevice, String contextKey = "global", boolean isTest = false) {
if (!wDevice) return ""
def wText = ""
try {
def lastUpdateObj = wDevice.currentState("temperature") ?: wDevice.currentState("meteorologistScript")
def lastUpdate = lastUpdateObj?.date?.time ?: 0
def now = new Date().time
if ((now - lastUpdate) < 21600000) {
wText = wDevice.currentValue("meteorologistScript")
if (!wText) {
def temp = wDevice.currentValue("temperature")
def cond = wDevice.currentValue("weather") ?: "clear conditions"
if (temp) wText = "The current temperature is ${temp} degrees and it is ${cond}."
}
}
} catch (Exception e) {}
return wText ? checkAndRegisterFact(wText, 3, contextKey, isTest) : ""
}
// --- TTS ENGINE & PRIORITY QUEUE ---
def enqueueTTS(speakerInput, msg, originalVol, priority, fastTrack = false) {
if (!speakerInput) return false // Return false if no speaker was provided
def isMuted = false
def muteReason = ""
if (settings.masterSwitch && settings.masterSwitch.currentValue("switch") == "off") { isMuted = true; muteReason = "Master Switch OFF" }
else if (settings.guestModeSwitch && settings.guestModeSwitch.currentValue("switch") == "on") { isMuted = true; muteReason = "Guest Mode ON" }
else if (settings.enableInternetCheck && state.internetActive == false) { isMuted = true; muteReason = "Internet Offline" }
if (isMuted) {
if (settings.enableDebug) log.debug "TTS Suppressed (${muteReason}). Skipped Message: '${msg}'"
return false // Return false if the house is muted
}
def speakers = speakerInput instanceof List ? speakerInput : [speakerInput]
def speakerIds = speakers.collect { it?.id }
if (state.ttsQueue.any { it.msg == msg }) return false
// --- THE FIX: Record the speech transcript to the dashboard ---
logSpeech(msg)
// --------------------------------------------------------------
state.ttsQueue.add([
id: java.util.UUID.randomUUID().toString(),
speakerIds: speakerIds,
msg: msg,
vol: originalVol,
priority: priority,
fastTrack: fastTrack,
queuedAt: new Date().time
])
state.ttsQueue = state.ttsQueue.sort { it.priority }
processQueue()
return true // Successfully queued!
}
def processQueue() {
if (!state.ttsQueue || state.ttsQueue.size() == 0) { state.currentPriority = 99; state.originalVolumes = [:]; return }
def now = new Date().time
def isSpeaking = (now < (state.speakingUntil ?: 0))
def nextItem = state.ttsQueue[0]
def ttlMins = settings.ttsTTL != null ? settings.ttsTTL.toInteger() : 5
if ((ttlMins * 60000) > 0 && (now - nextItem.queuedAt) > (ttlMins * 60000)) {
addToHistory("SYSTEM: Dropped stale message (Age > ${ttlMins}m): '${nextItem.msg}'")
state.ttsQueue.remove(0)
runIn(1, "processQueue", [overwrite: true])
return
}
if (isSpeaking) {
if (nextItem.priority >= state.currentPriority) {
runIn(Math.max(1, Math.ceil((state.speakingUntil - now) / 1000.0).toInteger()), "processQueue", [overwrite: true])
return
}
}
state.ttsQueue.remove(0)
state.currentPriority = nextItem.priority
def durationMs = executeTTS(nextItem)
state.speakingUntil = now + durationMs + 1500
def nextQueueCheckDelay = Math.ceil(durationMs / 1000.0).toInteger() + 2
if (state.ttsQueue.size() > 0) runIn(nextQueueCheckDelay, "processQueue", [overwrite: true])
else runIn(nextQueueCheckDelay, "resetQueuePriority", [overwrite: true])
}
def resetQueuePriority() { state.currentPriority = 99 }
def executeTTS(item) {
def msg = item.msg
def vol = item.vol
def fastTrack = item.fastTrack
def speakerIds = item.speakerIds
def speakers = getAllSpeakers().findAll { speakerIds.contains(it.id) }
def mediaToResume = []
def devicesToSilence = []
if (settings.mediaPauseList) devicesToSilence += settings.mediaPauseList
if (settings.globalTVSwitch && speakers.any { s -> settings.globalIndoorSpeaker?.find { it.id == s.id } }) devicesToSilence << settings.globalTVSwitch
def numR = settings.numRooms ? settings.numRooms as Integer : 0
for (int i = 1; i <= numR; i++) { if (settings["roomTVSwitch_${i}"] && speakers.any { s -> settings["roomSpeaker_${i}"]?.id == s.id }) devicesToSilence << settings["roomTVSwitch_${i}"] }
def numRoute = settings.numRoutingRooms ? settings.numRoutingRooms as Integer : 0
for (int i = 1; i <= numRoute; i++) { if (settings["routeTVSwitch_${i}"] && speakers.any { s -> settings["routeSpeaker_${i}"]?.id == s.id }) devicesToSilence << settings["routeTVSwitch_${i}"] }
devicesToSilence = devicesToSilence.flatten().findAll { it != null }.unique { it.id }
devicesToSilence.each { m ->
try {
def isPlaying = m.currentValue("transportStatus") == "playing" || m.currentValue("status") == "playing"
def isMuted = m.currentValue("mute") == "muted"
def isSwitchOn = m.currentValue("switch") == "on"
if (isPlaying && m.hasCommand("pause")) { m.pause(); mediaToResume << [dev: m, cmd: "play"] }
else if (!isMuted && isSwitchOn && m.hasCommand("mute")) { m.mute(); mediaToResume << [dev: m, cmd: "unmute"] }
} catch(Exception e) {}
}
if (mediaToResume.size() > 0) pauseExecution(1500)
def finalVol = vol
if (quietHoursStart && quietHoursEnd && quietVolume != null) {
try { if (timeOfDayIsBetween(toDateTime(quietHoursStart), toDateTime(quietHoursEnd), new Date(), location.timeZone)) finalVol = quietVolume as Integer } catch(Exception e) {}
}
def finalMsg = msg.replace("&", "and")
// --- GLOBAL EMOJI FILTER ---
// Strips out all emojis sent from integrations so the TTS engine doesn't read them aloud
finalMsg = finalMsg.replaceAll(/[\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF]/, "")
finalMsg = finalMsg.replaceAll(/\s+/, " ").trim()
// ---------------------------
// Apply custom Butler Voice if configured and not Default
if (settings.butlerVoice && settings.butlerVoice != "Default") {
finalMsg = "${finalMsg} "
}
def padSecs = settings.wakeupPadDelay != null ? settings.wakeupPadDelay.toInteger() : 0
// --- STAGE 1: VOLUME ADJUSTMENT (Simultaneous) ---
def restoredVolumes = []
def volumeChanged = false
speakers.each { spk ->
try {
def currentVol = spk.currentValue("volume")
if (state.originalVolumes[spk.id] == null) state.originalVolumes[spk.id] = currentVol
else currentVol = state.originalVolumes[spk.id]
def targetVol = finalVol != null ? (finalVol as Integer) : currentVol
if (targetVol != null && currentVol != targetVol) {
spk.setVolume(targetVol as Integer)
restoredVolumes << [id: spk.id, vol: currentVol]
volumeChanged = true
}
} catch (Exception ve) {}
}
// Single Global Pause for volume hardware to catch up
if (padSecs > 0 && !fastTrack) {
pauseExecution(padSecs * 1000)
} else if (volumeChanged) {
if (!fastTrack) pauseExecution(1000) else pauseExecution(50)
}
// --- STAGE 2: PRE-SPEECH CHIME (Simultaneous) ---
def chimePlayed = false
if (settings.enableChime && settings.chimeUrl && !fastTrack) {
speakers.each { spk ->
try {
if (spk.hasCommand("playTrack")) {
spk.playTrack(settings.chimeUrl)
chimePlayed = true
}
} catch(Exception ce) {}
}
}
// Single Global Pause for chime file to play
if (chimePlayed) pauseExecution(1500)
// --- STAGE 3: SPEECH (Simultaneous) ---
speakers.each { spk ->
try {
// We reverted to .speak() because native playTextAndRestore has a strict
// driver timeout that cuts off long, AI-generated announcements.
spk.speak(finalMsg)
} catch (Exception e) { log.warn "TTS Command Error: ${e}" }
}
// --- STAGE 4: RESTORE TASKS ---
if (restoredVolumes.size() > 0) {
// Increased the buffer to accommodate slower, AI-generated speech patterns
def volDelay = Math.max(15, (finalMsg.length() / 5).toInteger() + 10)
// CRITICAL FIX: overwrite: true cancels the volume drop of previous sentences
// if the Butler is asked to speak multiple things back-to-back.
runIn(volDelay, "restoreMultiVolumeTask", [data: [volumes: restoredVolumes], overwrite: true])
}
def speechDuration = Math.max(10, (finalMsg.length() / 5).toInteger()) * 1000
if (chimePlayed) speechDuration += 2500
if (mediaToResume.size() > 0) {
def resumeDelay = Math.ceil(speechDuration / 1000.0).toInteger() + 5
// CRITICAL FIX: overwrite: true
runIn(resumeDelay, "restoreMediaTask", [data: [resumeList: mediaToResume.collect { [id: it.dev.id, cmd: it.cmd] }], overwrite: true])
}
return speechDuration
}
def restoreMediaTask(data) {
def resumeList = data.resumeList ?: []
def allMedia = []
if (settings.mediaPauseList) allMedia += settings.mediaPauseList
if (settings.globalTVSwitch) allMedia << settings.globalTVSwitch
def numR = settings.numRooms ? settings.numRooms as Integer : 0
for(int i=1; i<=numR; i++) { if (settings["roomTVSwitch_${i}"]) allMedia << settings["roomTVSwitch_${i}"] }
def numRoute = settings.numRoutingRooms ? settings.numRoutingRooms as Integer : 0
for(int i=1; i<=numRoute; i++) { if (settings["routeTVSwitch_${i}"]) allMedia << settings["routeTVSwitch_${i}"] }
allMedia = allMedia.flatten().findAll { it != null }.unique { it.id }
resumeList.each { item ->
def dev = allMedia.find { it.id == item.id }
if (dev) { try { dev."${item.cmd}"() } catch(Exception e) {} }
}
}
def restoreVolumeTask(data) {
def id = data.speakerId
def vol = data.oldVol
if (id != null) {
def spk = getAllSpeakers().find { it.id == id }
if (spk && vol != null) { try { spk.setVolume(vol as Integer) } catch(Exception e) {} }
state.originalVolumes.remove(id)
}
}
def restoreMultiVolumeTask(data) {
def vols = data.volumes ?: []
def allSpks = getAllSpeakers()
vols.each { item ->
def spk = allSpks.find { it.id == item.id }
if (spk && item.vol != null) {
try { spk.setVolume(item.vol as Integer) } catch(Exception e) {}
}
state.originalVolumes.remove(item.id)
}
}
def getAllSpeakers() {
def list = []
if (outdoorSpeaker) list << outdoorSpeaker
if (globalIndoorSpeaker) list.addAll(globalIndoorSpeaker)
if (butlerLrSpeaker) list << butlerLrSpeaker
if (arrivalFoyerSpeaker) list << arrivalFoyerSpeaker
if (settings.officeSpeaker) list << settings.officeSpeaker
def numRoomsSet = settings.numRooms ? settings.numRooms as Integer : 0
for (int i = 1; i <= numRoomsSet; i++) { if (settings["roomSpeaker_${i}"]) list << settings["roomSpeaker_${i}"] }
def numRouteSet = settings.numRoutingRooms ? settings.numRoutingRooms as Integer : 0
for (int i = 1; i <= numRouteSet; i++) { if (settings["routeSpeaker_${i}"]) list << settings["routeSpeaker_${i}"] }
if (settings.screenTimeSpeaker) list << settings.screenTimeSpeaker
if (settings.mealTimeSpeaker) list << settings.mealTimeSpeaker
return list.flatten().findAll { it != null }.unique { it.id }
}
def resetDepartureMessages(int i) {
def workMsgs = getDefaultMessages("Work")
def schoolMsgs = getDefaultMessages("School")
def genMsgs = getDefaultMessages("General")
def type = settings["depType_${i}"] ?: "Work"
def selMsgs = type == "School" ? schoolMsgs : (type == "Work" ? workMsgs : genMsgs)
for (int m = 1; m <= 10; m++) { app.updateSetting("depMessage_${i}_${m}", [type: "text", value: selMsgs[m-1]]) }
addToHistory("SYSTEM: Reset departure messages for Profile ${i} to ${type} defaults.")
}
// --- DEPARTURE LOGIC ---
def departureHandler(evt) {
if (evt.value != "open") return
ensureStateMaps()
// --- NEW: PACKAGE RESCUE CLEARING ---
// If the front door opens, assume the package was retrieved
if (state.packageOnPorch) {
state.packageOnPorch = false
unschedule("packageSafetyCheck")
if (settings.enableDebug) log.debug "Voice Butler: Front door opened, clearing package on porch alert."
}
// ------------------------------------
def nowTime = new Date().time
def numDep = settings.numDepartureUsers ? settings.numDepartureUsers as Integer : 0
if (numDep == 0) return
def now = new Date()
def departedIndexes = []
for (int i = 1; i <= numDep; i++) {
def uName = settings["depUserName_${i}"]
def ctxSwitch = settings["depSwitch_${i}"]
def sickSwitch = settings["depSickSwitch_${i}"]
def tStart = settings["depTimeStart_${i}"]
def tEnd = settings["depTimeEnd_${i}"]
def dModes = [settings["depModes_${i}"]].flatten().findAll { it != null }
if (uName && ctxSwitch && tStart && tEnd) {
def splitNames = uName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
// FIX: Ensure it correctly checks if ALL users in a combined profile have already departed
if (splitNames.every { state.hasDepartedToday[it] }) continue
if (sickSwitch && sickSwitch.currentValue("switch") == "on") continue
if (dModes.size() > 0 && !dModes.contains(location.mode)) continue
if (ctxSwitch.currentValue("switch") != "on") continue
try { if (!timeOfDayIsBetween(toDateTime(tStart), toDateTime(tEnd), now, location.timeZone)) continue } catch (Exception e) { continue }
splitNames.each { n ->
state.hasDepartedToday[n] = true
state.lastDepartureTime[n] = nowTime
updateHabit(n, nowTime, "departure")
}
departedIndexes << i
}
}
if (departedIndexes.size() > 0) {
state.departureGracePeriodEnd = nowTime + 300000
departedIndexes.each { idx ->
def uName = settings["depUserName_${idx}"]
def displayUserName = applyAlias(uName)
def messages = []
for (int m = 1; m <= 10; m++) { if (settings["depMessage_${idx}_${m}"]) messages << settings["depMessage_${idx}_${m}"] }
if (!messages) messages = ["Have a good trip %name%."]
def rawMsg = messages[new Random().nextInt(messages.size())].replace("%name%", displayUserName)
def bdayMsg = getBirthdayMessage(uName, "Departure")
if (bdayMsg) { rawMsg = "${rawMsg} ${bdayMsg}" }
def annivMsg = getAnniversaryMessage("Departure", uName)
if (annivMsg) { rawMsg = "${rawMsg} ${annivMsg}" }
def finalMsg = applyDynamicVars(rawMsg)
// --- COAT CHECK ---
if (settings.enableCoatCheck && settings.depWeatherDevice) {
def wDevice = settings.depWeatherDevice
def temp = wDevice.currentValue("temperature")?.toString()?.replaceAll("[^0-9.-]", "")?.toFloat()?.toInteger() ?: 50
def cond = wDevice.currentValue("weather")?.toString()?.toLowerCase() ?: ""
def weatherWarning = ""
if (cond.contains("rain") || cond.contains("storm") || cond.contains("shower")) {
weatherWarning = " Please note, precipitation is currently detected, an umbrella is highly advisable."
} else if (cond.contains("snow") || cond.contains("ice")) {
weatherWarning = " Please note, it is currently snowing. Drive safely."
} else if (temp <= 32) {
weatherWarning = " Please note, it is freezing outside. Stay warm."
} else if (temp >= 90) {
weatherWarning = " Please note, it is quite hot out there today. Stay hydrated."
}
if (weatherWarning) finalMsg += weatherWarning
}
// -----------------------
// --- COMMUTE / TRAFFIC TIME ---
if (settings["depEnableTraffic_${idx}"] && settings["depDestination_${idx}"]) {
def dest = settings["depDestination_${idx}"]
def mins = getTravelInfo(dest)
if (mins != null) {
def destName = settings["depType_${idx}"]?.toLowerCase() ?: "your destination"
if (destName == "general") destName = "your destination"
finalMsg += " Current traffic indicates it will take approximately ${mins} minutes to get to ${destName}."
}
}
// -----------------------------------
def delay = settings["depDelay_${idx}"] != null ? settings["depDelay_${idx}"].toInteger() : 5
def outVol = settings["depVolume_${idx}"] ?: settings.outdoorVolume
def rMode = settings["depRoutingMode_${idx}"] ?: "Outdoor Speaker Only"
runIn(delay, "playDepartureGreeting", [data: [user: uName, message: finalMsg, routing: rMode, outVol: outVol], overwrite: false])
}
}
}
def playDepartureGreeting(data) {
executeRoutedTTS(data.message, data.routing, settings.globalVolume, data.outVol, 3)
addToHistory("DEPARTURE: Contextual departure window matched for [${data.user}]. Queued: '${data.message}'")
}
// --- NIGHTTIME INTRUDER DETERRENT ---
def intruderDoorHandler(evt) {
ensureStateMaps()
state.lastBypassDoorOpen = new Date().time
}
def canTriggerIntruder() {
def now = new Date().time
if (state.departureGracePeriodEnd && now < state.departureGracePeriodEnd) return false
def activeModes = [settings.intruderModes].flatten().findAll { it != null }
if (!activeModes.contains(location.mode)) return false
def isDoorOpen = false
if (settings.intruderBypassDoors) { settings.intruderBypassDoors.each { door -> if (door.currentValue("contact") == "open") isDoorOpen = true } }
if (isDoorOpen) return false
def lastDoor = atomicState.lastBypassDoorOpen ?: state.lastBypassDoorOpen ?: 0
def bpVal = settings.intruderBypassMinutes
def bypassMins = (bpVal != null && bpVal.toString().isInteger()) ? bpVal.toInteger() : 5
if ((now - lastDoor) < (bypassMins * 60000)) return false
def dbVal = settings.intruderDebounce
def dbMins = (dbVal != null && dbVal.toString().isInteger()) ? dbVal.toInteger() : 5
if (dbMins <= 0) dbMins = 1
def debounceMs = dbMins * 60000
def lastAlert = atomicState.lastIntruderAlert ?: state.lastIntruderAlert ?: 0
if ((now - lastAlert) <= debounceMs) return false
return true
}
def intruderMotionHandler(evt) {
ensureStateMaps()
// FIX: Send data directly to the Morning Report Tracker!
countMotionHandler(evt)
if (!canTriggerIntruder()) return
if (settings.smartCameraDevice && settings.smartAttribute) {
runIn(6, "executeGenericIntruder", [data: [deviceName: evt.device.displayName], overwrite: true])
} else {
executeGenericIntruder([deviceName: evt.device.displayName])
}
}
def executeGenericIntruder(data) {
if (!canTriggerIntruder()) return
atomicState.lastIntruderAlert = new Date().time
atomicState.lastOutdoorGreeting = new Date().time
def messages = []
for (int d = 1; d <= 10; d++) { if (settings["intruderMessage_${d}"]) messages << settings["intruderMessage_${d}"] }
if (!messages) messages = getDefaultMessages("Intruder")
def randomMsg = applyDynamicVars(messages[new Random().nextInt(messages.size())])
def targetVol = settings.intruderVolume != null ? settings.intruderVolume : settings.outdoorVolume
def rMode = settings.intruderRoutingMode ?: "Outdoor Speaker Only"
// Added 'true' at the end to fast-track the alert and skip the chime
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, targetVol, 1, true)
addToHistory("INTRUDER DETERRENT: Generic motion detected. Queued: '${randomMsg}'")
}
def unifiProtectHandler(evt) {
ensureStateMaps()
def detectStr = evt.value?.toLowerCase() ?: ""
if (detectStr == "none" || detectStr == "waiting" || detectStr == "null" || detectStr == "") return
// FIX: Send Smart Detection data to the Morning Report Tracker!
countMotionHandler(evt)
if (!canTriggerIntruder()) return
unschedule("executeGenericIntruder")
atomicState.lastIntruderAlert = new Date().time
atomicState.lastOutdoorGreeting = new Date().time
def isPerson = detectStr.contains("person")
def isVehicle = detectStr.contains("vehicle")
def isAnimal = detectStr.contains("animal")
def messages = []
if (isPerson) {
for (int d = 1; d <= 3; d++) { if (settings["intruderPerson_${d}"]) messages << settings["intruderPerson_${d}"] }
if (!messages) messages = ["Warning. You are trespassing. Security has been notified."]
} else if (isVehicle) {
for (int d = 1; d <= 3; d++) { if (settings["intruderVehicle_${d}"]) messages << settings["intruderVehicle_${d}"] }
if (!messages) messages = ["Unauthorized vehicle detected. License plate logged."]
} else if (isAnimal) {
for (int d = 1; d <= 3; d++) { if (settings["intruderAnimal_${d}"]) messages << settings["intruderAnimal_${d}"] }
if (!messages) messages = ["Shoo! Get out of here!"]
} else {
for (int d = 1; d <= 10; d++) { if (settings["intruderMessage_${d}"]) messages << settings["intruderMessage_${d}"] }
if (!messages) messages = getDefaultMessages("Intruder")
}
def randomMsg = applyDynamicVars(messages[new Random().nextInt(messages.size())])
def targetVol = settings.intruderVolume != null ? settings.intruderVolume : settings.outdoorVolume
// Added 'true' at the end to fast-track the alert and skip the chime
executeRoutedTTS(randomMsg, settings.intruderRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, targetVol, 1, true)
}
def unifiPackageHandler(evt) {
ensureStateMaps()
def detectStr = evt.value?.toLowerCase() ?: ""
if (!detectStr.contains("package")) return
// Flag the porch as having a package and start the 2-hour safety timer
state.packageOnPorch = true
state.packageArrivalTime = new Date().time
runIn(7200, "packageSafetyCheck", [overwrite: true])
executePackageAlert()
}
def packageSafetyCheck() {
if (state.packageOnPorch) {
def msg = "%interruption%, my logs indicate a package has been resting on the porch for over two hours. Shall I have someone bring it inside for safekeeping?"
executeRoutedTTS(applyDynamicVars(msg), "Global Indoor Speaker Only", settings.globalVolume, settings.outdoorVolume, 2)
state.packageOnPorch = false // Prevent nagging
}
}
def executePackageAlert(isTest=false) {
ensureStateMaps()
if (!settings.enableSmartPackage && !isTest) return
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0 && !isTest) {
stashMessage("a package was delivered to the house")
return
}
// 5-minute debounce to prevent camera spam
def debounceMs = 5 * 60000
def lastPkg = state.lastPackageAlert ?: 0
if (!isTest && ((new Date().time - lastPkg) < debounceMs)) return
state.lastPackageAlert = new Date().time
// 1. Thank the driver outside
if (settings.packageOutdoorMessage && outdoorSpeaker) {
def outMsg = "Thank you for the delivery. Please leave the package right there."
enqueueTTS(outdoorSpeaker, outMsg, settings.outdoorVolume, 2, true)
}
// 2. Alert the house inside
def inMsg = "%interruption%, but the security camera has just detected a package delivery at the front door."
if (isTest) inMsg = "This is a test of the perimeter camera system. " + inMsg
def targetVol = settings.packageVolume != null ? settings.packageVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(inMsg), settings.packageRoutingMode ?: "Global Indoor Speaker Only", targetVol, 0, 2)
addToHistory("SMART CAMERA: Package delivery detected and announced.")
}
// --- BUTLER EVENT TRACKING & REPORTING ---
def countMotionHandler(evt) {
ensureStateMaps()
def nightModes = [settings.intruderModes].flatten().findAll { it != null }
if (location.mode in nightModes || location.mode == "Night") {
state.nightMotionCount = (state.nightMotionCount ?: 0) + 1
state.lastNightMotionCount = state.nightMotionCount // <-- THE FIX: Sync for Room Greetings
state.pendingMorningReport = true
if (settings.enableDebug) log.info "INCIDENT TRACKER: Logged Night Motion. Count is now ${state.nightMotionCount}"
}
}
def countDoorbellHandler(evt) {
ensureStateMaps()
def awayModes = [settings.quickExitModes].flatten().findAll { it != null }
if (location.mode in awayModes || location.mode == "Away") {
state.awayDoorbellCount = (state.awayDoorbellCount ?: 0) + 1
state.lastAwayDoorbellCount = state.awayDoorbellCount // <-- THE FIX: Sync for Room Greetings
state.pendingArrivalReport = true
}
}
def playButlerReport(data) {
def type = data.type
def msg = ""
if (type == "Arrival") {
msg = "%interruption%, but there were ${data.count} doorbell rings while you were away. Please check the cameras."
} else {
msg = "There were ${data.count} motion events at the front door last night. Please check the cameras."
}
def targetSpeaker = settings.butlerLrSpeaker ?: globalIndoorSpeaker
if (targetSpeaker) enqueueTTS(targetSpeaker, applyDynamicVars(msg), settings.butlerLrVolume ?: globalVolume, 4)
}
// --- FRONT DOOR DND, AFTER HOURS, & DAYTIME LOGIC ---
def daytimeDoorHandler(evt) {
ensureStateMaps()
unschedule("playDaytimeFollowUp")
}
def playDaytimeFollowUp() {
ensureStateMaps()
def messages = []
for (int d = 1; d <= 5; d++) { if (settings["daytimeNoAnswer_${d}"]) messages << settings["daytimeNoAnswer_${d}"] }
if (!messages) messages = getDefaultMessages("NoAnswer")
def randomMsg = applyDynamicVars(messages[new Random().nextInt(messages.size())])
executeRoutedTTS(randomMsg, settings.daytimeRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, settings.daytimeDoorbellVolume != null ? settings.daytimeDoorbellVolume : settings.outdoorVolume, 2, true)
}
def visitorHandler(evt) {
ensureStateMaps()
def now = new Date().time
if (state.departureGracePeriodEnd && now < state.departureGracePeriodEnd) return
def lastIntruder = atomicState.lastIntruderAlert ?: state.lastIntruderAlert ?: 0
if ((now - lastIntruder) < 60000) return
def intruderModeList = [settings.intruderModes].flatten().findAll { it != null }
if (evt.name == "motion" && settings.enableIntruder && intruderModeList.contains(location.mode)) {
def intIds = [settings.intruderMotion].flatten().findAll { it != null }.collect { it?.id }
if (settings.smartCameraDevice) intIds << settings.smartCameraDevice.id
if (intIds.contains(evt.device.id)) return
}
def dndModesList = [settings.dndModes].flatten().findAll { it != null }
def isDndActive = (dndSwitch?.currentValue("switch") == "on") || dndModesList.contains(location.mode)
def isPartyModeActive = settings.enablePartyMode && settings.partyModeSwitch && settings.partyModeSwitch.currentValue("switch") == "on"
def isMotion = evt.name == "motion"
def lastGreet = atomicState.lastOutdoorGreeting ?: state.lastOutdoorGreeting ?: 0
def isDoorbell = !isMotion
def isAfterHours = false
if (enableAfterHours && afterHoursTimeStart && afterHoursTimeEnd) {
try { isAfterHours = timeOfDayIsBetween(toDateTime(afterHoursTimeStart), toDateTime(afterHoursTimeEnd), new Date(), location.timeZone) } catch(Exception e) {}
}
if (isDoorbell && settings.enableIndoorRouting) {
def shouldRoute = true
if (settings.indoorRouteMuteDND && isDndActive && !isPartyModeActive) shouldRoute = false
def restrictedModes = [settings.indoorRouteRestrictedModes].flatten().findAll { it != null }
if (restrictedModes.contains(location.mode)) shouldRoute = false
if (shouldRoute) {
executeRoutedTTS(applyDynamicVars(settings.indoorDoorbellMsg ?: "This is %butler%. %interruption%, but there is a visitor at the front door."), settings.indoorDoorbellRoutingMode ?: "Follow-Me + Fallback (Global ONLY if no motion)", settings.globalVolume, settings.outdoorVolume, 2)
}
}
if (isPartyModeActive && isDoorbell) {
def debounceMs = (settings.partyDebounce != null ? settings.partyDebounce.toInteger() : 2) * 60000
if ((now - lastGreet) > debounceMs) {
def messages = []
for (int d = 1; d <= 3; d++) { if (settings["partyMessage_${d}"]) messages << settings["partyMessage_${d}"] }
if (!messages) messages = getDefaultMessages("PartyMode")
def randomMsg = applyDynamicVars(messages[new Random().nextInt(messages.size())])
// CHANGED to false for Chime
executeRoutedTTS(randomMsg, settings.partyRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, settings.partyVolume != null ? settings.partyVolume : settings.outdoorVolume, 2, false)
atomicState.lastOutdoorGreeting = now
addToHistory("PARTY MODE: Guest doorbell answered. Queued: '${randomMsg}'")
}
} else if (isDndActive) {
def debounceMs = isMotion ? ((settings.dndMotionDebounce != null ? settings.dndMotionDebounce.toInteger() : 10) * 60000) : 30000
if ((now - lastGreet) > debounceMs) {
def messages = []
for (int d = 1; d <= 10; d++) { if (settings["dndMessage_${d}"]) messages << settings["dndMessage_${d}"] }
if (!messages) messages = getDefaultMessages("DND")
// CHANGED to false for Chime
executeRoutedTTS(applyDynamicVars(messages[new Random().nextInt(messages.size())]), settings.dndRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, settings.outdoorVolume, 2, false)
atomicState.lastOutdoorGreeting = now
}
} else if (isAfterHours && isDoorbell) {
def debounceMs = (settings.afterHoursDebounce != null ? settings.afterHoursDebounce.toInteger() : 5) * 60000
if ((now - lastGreet) > debounceMs) {
def messages = []
for (int d = 1; d <= 15; d++) { if (settings["afterHoursMessage_${d}"]) messages << settings["afterHoursMessage_${d}"] }
if (!messages) messages = getDefaultMessages("AfterHours")
// CHANGED to false for Chime
executeRoutedTTS(applyDynamicVars(messages[new Random().nextInt(messages.size())]), settings.afterHoursRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, settings.afterHoursVolume != null ? settings.afterHoursVolume : settings.outdoorVolume, 2, false)
atomicState.lastOutdoorGreeting = now
}
} else if (!isDndActive && !isAfterHours && isDoorbell && enableDaytimeDoorbell) {
def debounceMs = (settings.daytimeDoorbellDebounce != null ? settings.daytimeDoorbellDebounce.toInteger() : 2) * 60000
if ((now - lastGreet) > debounceMs) {
def presentFolks = getPresentUsers()
def greetingToPlay = ""
def willFollowUp = false
// --- ROSTER-AWARE DOORBELL ---
if (presentFolks.size() == 0) {
// House is empty! Don't pretend we are home.
greetingToPlay = "The homeowners are currently away. Please leave a package or a message."
willFollowUp = false // Don't do the 3-minute follow-up if we know no one is here
addToHistory("DOORBELL: House is empty. Intercepted Daytime greeting.")
} else {
// Someone is home, proceed normally
def messages = []
for (int d = 1; d <= 20; d++) { if (settings["daytimeMessage_${d}"]) messages << settings["daytimeMessage_${d}"] }
if (!messages) messages = getDefaultMessages("Daytime")
greetingToPlay = messages[new Random().nextInt(messages.size())]
willFollowUp = settings.enableDaytimeFollowUp
addToHistory("DOORBELL: Daytime greeting triggered.")
}
// ----------------------------------
// CHANGED to false for Chime
executeRoutedTTS(applyDynamicVars(greetingToPlay), settings.daytimeRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, settings.daytimeDoorbellVolume != null ? settings.daytimeDoorbellVolume : settings.outdoorVolume, 2, false)
atomicState.lastOutdoorGreeting = now
if (willFollowUp && settings.daytimeDoorContact) {
runIn((settings.daytimeFollowUpDelay != null ? settings.daytimeFollowUpDelay.toInteger() : 3) * 60, "playDaytimeFollowUp", [overwrite: true])
}
}
}
}
// --- ARRIVAL & RESET LOGIC ---
def presenceFallbackHandler(evt) {
ensureStateMaps()
def deviceId = evt.device.id
def numPres = settings.numPresenceMappings ? settings.numPresenceMappings as Integer : 0
for (int i = 1; i <= numPres; i++) {
if (settings["fallbackPresence_${i}"]?.id == deviceId) {
def uName = settings["presenceUserName_${i}"]
if (uName && !state.hasArrivedToday[uName]) {
// Schedule the 10-minute (600 second) check
runIn(600, "checkMissedArrival", [data: [user: uName, deviceId: deviceId, mapIdx: i], overwrite: false])
addToHistory("SYSTEM: Presence detected for ${uName}. Starting 10-minute fallback timer.")
}
}
}
}
def checkMissedArrival(data) {
ensureStateMaps()
def uName = data.user
def i = data.mapIdx
if (!state.hasArrivedToday[uName]) {
def sensorStillPresent = false
if (settings["fallbackPresence_${i}"]?.id == data.deviceId && settings["fallbackPresence_${i}"].currentValue("presence") == "present") {
sensorStillPresent = true
}
if (sensorStillPresent) {
state.hasArrivedToday[uName] = true
state.resetReasons[uName] = "Presence Sensor Fallback (>10m)"
def msg = "Pardon me, I didn't catch you coming through the door. Welcome home, ${applyAlias(uName)}."
def finalMsg = applyDynamicVars(msg)
def outdoorTargetVol = settings.arrivalVolume != null ? settings.arrivalVolume : settings.outdoorVolume
def indoorTargetVol = settings.indoorArrivalVolume != null ? settings.indoorArrivalVolume : settings.globalVolume
// 1. Play on Foyer Speaker (Standard Arrival Behavior)
if (settings.arrivalFoyerSpeaker) enqueueTTS(settings.arrivalFoyerSpeaker, finalMsg, indoorTargetVol, 3, false)
// 2. Play to Rest of House (If Enabled)
if (settings.arrivalIndoorSpeaker) executeRoutedTTS(finalMsg, settings.arrivalNoticeRoutingMode ?: "Global Indoor Speaker Only", indoorTargetVol, outdoorTargetVol, 3)
// 3. Failsafe: Play on Global Speaker if no other speakers caught it
if (!settings.arrivalFoyerSpeaker && !settings.arrivalIndoorSpeaker && settings.globalIndoorSpeaker) {
enqueueTTS(settings.globalIndoorSpeaker, finalMsg, indoorTargetVol, 3, false)
}
addToHistory("FALLBACK ARRIVAL: Missed door unlock. Auto-arrived ${uName}.")
}
}
}
def pollPresenceSensors() {
ensureStateMaps()
def numPres = settings.numPresenceMappings ? settings.numPresenceMappings as Integer : 0
if (numPres == 0) return
def missedUsers = []
// Sweep all linked presence sensors
for (int i = 1; i <= numPres; i++) {
def sensor = settings["fallbackPresence_${i}"]
def uName = settings["presenceUserName_${i}"]
if (sensor && uName) {
// If the sensor is home, but the Butler hasn't marked them arrived yet
if (sensor.currentValue("presence") == "present" && !state.hasArrivedToday[uName]) {
state.hasArrivedToday[uName] = true
state.resetReasons[uName] = "Hourly Presence Sweep Fallback"
missedUsers << applyAlias(uName)
addToHistory("FALLBACK ARRIVAL: Hourly sweep detected ${uName} was physically present but not marked arrived.")
}
}
}
// If anyone was caught in the sweep, group their names and play the apology
if (missedUsers.size() > 0) {
def namesStr = ""
if (missedUsers.size() == 1) namesStr = missedUsers[0]
else if (missedUsers.size() == 2) namesStr = "${missedUsers[0]} and ${missedUsers[1]}"
else { def last = missedUsers.pop(); namesStr = "${missedUsers.join(', ')}, and ${last}" }
def msg = "Pardon me, I didn't catch you coming through the door earlier. Welcome home, ${namesStr}."
def finalMsg = applyDynamicVars(msg)
def outdoorTargetVol = settings.arrivalVolume != null ? settings.arrivalVolume : settings.outdoorVolume
def indoorTargetVol = settings.indoorArrivalVolume != null ? settings.indoorArrivalVolume : settings.globalVolume
// 1. Play on Foyer Speaker (Standard Arrival Behavior)
if (settings.arrivalFoyerSpeaker) enqueueTTS(settings.arrivalFoyerSpeaker, finalMsg, indoorTargetVol, 3, false)
// 2. Play to Rest of House (If Enabled)
if (settings.arrivalIndoorSpeaker) executeRoutedTTS(finalMsg, settings.arrivalNoticeRoutingMode ?: "Global Indoor Speaker Only", indoorTargetVol, outdoorTargetVol, 3)
// 3. Failsafe: Play on Global Speaker if no other speakers caught it
if (!settings.arrivalFoyerSpeaker && !settings.arrivalIndoorSpeaker && settings.globalIndoorSpeaker) {
enqueueTTS(settings.globalIndoorSpeaker, finalMsg, indoorTargetVol, 3, false)
}
}
}
def arrivalHandler(evt) {
ensureStateMaps()
def desc = evt.descriptionText ?: ""
def actualUserName = "Guest"
def trackingKey = "global"
def isKeypadUnlock = false
try {
if (evt.data) {
def parsedData = new groovy.json.JsonSlurper().parseText(evt.data)
if (parsedData?.codeName) { actualUserName = parsedData.codeName; trackingKey = actualUserName; isKeypadUnlock = true }
}
} catch (Exception e) {}
if (!isKeypadUnlock && desc.toLowerCase().contains("unlocked by")) {
def match = desc =~ /unlocked by (.*)/
if (match) { actualUserName = match[0][1].trim(); trackingKey = actualUserName; isKeypadUnlock = true }
} else if (desc.toLowerCase().contains("code") || desc.toLowerCase().contains("keypad")) isKeypadUnlock = true
def originalCodeName = actualUserName
def lowerIgnored = [settings.ignoredCodes].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.ignoredCustomCodes) lowerIgnored += settings.ignoredCustomCodes.split(',').collect { it.trim().toLowerCase() }
if (lowerIgnored.contains(originalCodeName.toLowerCase()) || originalCodeName.toLowerCase().contains("ghost")) return
def numServ = settings.numServiceCodes ? settings.numServiceCodes as Integer : 0
for (int i = 1; i <= numServ; i++) {
def sName = settings["serviceCodeName_${i}"]
if (sName && (actualUserName.toLowerCase() == sName.toLowerCase() || desc.toLowerCase().contains(sName.toLowerCase()))) {
def outMsg = applyDynamicVars(settings["serviceMsgOutdoor_${i}"])
def inMsg = applyDynamicVars(settings["serviceMsgIndoor_${i}"])
def outdoorTargetVol = settings["arrivalVolume"] != null ? settings["arrivalVolume"] : settings["outdoorVolume"]
def indoorTargetVol = settings["indoorArrivalVolume"] != null ? settings["indoorArrivalVolume"] : settings["globalVolume"]
if (outMsg && outdoorSpeaker) enqueueTTS(outdoorSpeaker, outMsg, outdoorTargetVol, 3, true)
if (outMsg && settings.arrivalFoyerSpeaker) enqueueTTS(settings.arrivalFoyerSpeaker, outMsg, indoorTargetVol, 3, true)
if (settings.arrivalIndoorSpeaker && inMsg) executeRoutedTTS(inMsg, settings.arrivalNoticeRoutingMode ?: "Global Indoor Speaker Only", indoorTargetVol, outdoorTargetVol, 3, true)
return
}
}
if (actualUserName.toLowerCase() == "admin code" && settings.adminUserAlias) { actualUserName = settings.adminUserAlias; trackingKey = actualUserName }
if (arrivalMode == "Automatic (Reads lock memory)") {
if (!(settings.trackedLockCodes ?: []).contains(originalCodeName)) return
}
def matchedUserIdx = null
if (arrivalMode == "Manual (Assign names to slots)" && numLockUsers) {
for (int i = 1; i <= (numLockUsers as Integer); i++) {
def uName = settings["lockUserName_${i}"]
if (uName && (actualUserName.toLowerCase() == uName.toLowerCase() || desc.toLowerCase().contains(uName.toLowerCase()))) {
matchedUserIdx = i; trackingKey = uName; actualUserName = uName; isKeypadUnlock = true; break
}
}
}
if (!isKeypadUnlock) {
if (disableGlobalAnnouncements) return
trackingKey = "global"; actualUserName = "Guest"
} else if (trackingKey == "global" && disableGlobalAnnouncements) return
if (!state.hasArrivedToday[trackingKey]) {
def nowTime = new Date().time
def lastDepUser = state.lastDepartureTime[trackingKey] ?: 0
if (lastDepUser > 0 && (nowTime - lastDepUser < ((settings.quickReturnGrace != null ? settings.quickReturnGrace.toInteger() : 5) * 60000))) {
state.hasArrivedToday[trackingKey] = true
state.hasDepartedToday.remove(trackingKey) // <-- FIX
state.resetReasons[trackingKey] = "Quick Return" // <-- FIX
return
}
def splitNames = trackingKey.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
splitNames.each { n ->
state.hasArrivedToday[n] = true
state.hasDepartedToday.remove(n) // <-- FIX
state.resetReasons[n] = "Unlocked Door" // <-- FIX
state.lastDepartureTime.remove(n)
state.anomalyAlertedToday[n] = false
}
atomicState.lastOutdoorGreeting = new Date().time
def messages = []
def isExtended = false
if (settings.enableExtendedAbsence && lastDepUser > 0) {
if ((nowTime - lastDepUser) >= ((settings.extendedAbsenceHours != null ? settings.extendedAbsenceHours.toInteger() : 48) * 3600000)) isExtended = true
}
if (isExtended) {
for (int m = 1; m <= 5; m++) { if (settings["extAbsenceMessage_${m}"]) messages << settings["extAbsenceMessage_${m}"] }
if (!messages) messages = getDefaultMessages("ExtendedAbsence")
} else {
if (arrivalMode == "Automatic (Reads lock memory)") {
for (int m = 1; m <= 10; m++) { if (settings["autoGreeting_${m}"]) messages << settings["autoGreeting_${m}"] }
} else {
if (matchedUserIdx) {
for (int m = 1; m <= 10; m++) { if (settings["lockGreeting_${matchedUserIdx}_${m}"]) messages << settings["lockGreeting_${matchedUserIdx}_${m}"] }
} else {
for (int m = 1; m <= 10; m++) { if (settings["defaultArrivalMessage_${m}"]) messages << settings["defaultArrivalMessage_${m}"] }
}
}
if (!messages) messages = getDefaultMessages("Arrival")
}
def displayUserName = applyAlias(actualUserName)
def greetingToPlay = messages[new Random().nextInt(messages.size())].replace("%name%", displayUserName)
if (settings.enableDurationAware && lastDepUser > 0) {
def minsAway = (nowTime - lastDepUser) / 60000
if (minsAway < 45) {
greetingToPlay += " A quick trip, I see."
} else if (minsAway >= 480) {
greetingToPlay += " I hope you had a productive day."
}
}
def splitActual = actualUserName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
splitActual.each { n -> updateHabit(n, nowTime, "arrival") }
if (state.learnedHabits[splitActual[0]]?.avgArrivalMins && state.learnedHabits[splitActual[0]].arrCount > 3) {
def expected = state.learnedHabits[splitActual[0]].avgArrivalMins
def cal = Calendar.getInstance(location.timeZone)
def nowMins = (cal.get(Calendar.HOUR_OF_DAY) * 60) + cal.get(Calendar.MINUTE)
if (nowMins < (expected - 90)) greetingToPlay = "You are home quite early today! " + greetingToPlay
else if (nowMins > (expected + 120)) greetingToPlay = "Working late today? " + greetingToPlay
}
def bdayMsg = getBirthdayMessage(actualUserName, "Arrival")
if (bdayMsg) greetingToPlay = "${greetingToPlay} ${bdayMsg}"
def annivMsg = getAnniversaryMessage("Arrival", actualUserName)
if (annivMsg) greetingToPlay = "${greetingToPlay} ${annivMsg}"
if (settings.enableAfterSchool && settings.afterSchoolStart && settings.afterSchoolEnd) {
def dow = Calendar.getInstance(location.timeZone).get(Calendar.DAY_OF_WEEK)
if (dow >= Calendar.MONDAY && dow <= Calendar.FRIDAY) {
try {
if (timeOfDayIsBetween(toDateTime(settings.afterSchoolStart), toDateTime(settings.afterSchoolEnd), new Date(), location.timeZone)) {
def asUsers = [settings.afterSchoolUsers].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.afterSchoolCustom) asUsers += settings.afterSchoolCustom.split(',').collect { it.trim().toLowerCase() }
if (asUsers.isEmpty() || asUsers.contains(actualUserName.toLowerCase()) || asUsers.contains(trackingKey.toLowerCase())) {
if (settings.afterSchoolMsg) greetingToPlay = "${greetingToPlay} ${settings.afterSchoolMsg}"
}
}
} catch(Exception e) {}
}
}
if (settings.enableHouseRoster) {
def allowedRoster = [settings.rosterAllowedUsers].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.rosterAllowedCustom) allowedRoster += settings.rosterAllowedCustom.split(',').collect { it.trim().toLowerCase() }
if (allowedRoster.isEmpty() || allowedRoster.contains(actualUserName.toLowerCase()) || allowedRoster.contains(trackingKey.toLowerCase())) {
def othersHome = state.hasArrivedToday.findAll { k, v -> v == true && k.toLowerCase() != trackingKey.toLowerCase() && k != "global" }.keySet().toList()
if (othersHome.size() == 0) greetingToPlay += " You are the first to arrive. The house is empty."
else if (othersHome.size() == 1) greetingToPlay += " ${othersHome[0]} is already home."
else if (othersHome.size() == 2) greetingToPlay += " ${othersHome[0]} and ${othersHome[1]} are already home."
else { def last = othersHome.pop(); greetingToPlay += " ${othersHome.join(', ')}, and ${last} are already home." }
}
}
greetingToPlay = applyDynamicVars(greetingToPlay)
// --- FIX 3B: GLOBAL INCIDENT WARNINGS (ARRIVAL DOORBELL) ---
def doorbellCount = state.awayDoorbellCount ?: 0
if (state.pendingArrivalReport && doorbellCount > 0) {
greetingToPlay += " Also, there were ${doorbellCount} doorbell rings while you were away. Please check the cameras."
state.pendingArrivalReport = false
// THE FIX: Removed the line that erased the count to 0!
addToHistory("INCIDENT REPORT: Delivered Global Away Doorbell warning on arrival.")
}
// -----------------------------------------------------------
// --- DEVICE HEALTH ARRIVAL ALERT ---
if (settings.enableDeviceHealthAlerts && settings.deviceHealthDevice && settings.deviceHealthUser) {
def targetAlias = applyAlias(settings.deviceHealthUser)
// Check if the person arriving matches the designated IT/Health user
if (displayUserName.equalsIgnoreCase(targetAlias) || actualUserName.equalsIgnoreCase(settings.deviceHealthUser)) {
def countVal = settings.deviceHealthDevice.currentValue("issueCount")
def count = (countVal != null && countVal.toString().isInteger()) ? countVal.toInteger() : 0
if (count > 0) {
def isAre = count == 1 ? "is" : "are"
def devStr = count == 1 ? "device" : "devices"
greetingToPlay += " I should also report that there ${isAre} currently ${count} network ${devStr} in critical status requiring your attention."
}
}
}
// -----------------------------------
if (settings.enableMailCheck && settings.mailSwitch && settings.mailSwitch.currentValue("switch") == "on") {
def allowedMail = [settings.mailAllowedUsers].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.mailAllowedCustom) allowedMail += settings.mailAllowedCustom.split(',').collect { it.trim().toLowerCase() }
if (allowedMail.isEmpty() || allowedMail.contains(actualUserName.toLowerCase()) || allowedMail.contains(trackingKey.toLowerCase())) {
def mailTimeStr = (state.lastMailDeliveryTime && state.lastMailDeliveryTime > 0) ? new Date(state.lastMailDeliveryTime).format("h:mm a", location.timeZone) : "earlier today"
def mCount = state.reminderCounts["mail"] ?: 0
state.reminderCounts["mail"] = mCount + 1
if (mCount == 0) {
greetingToPlay += " Pardon the reminder, but the mail was delivered at ${mailTimeStr} and still needs to be retrieved."
} else if (mCount == 1) {
greetingToPlay += " As a gentle follow-up, the mail from ${mailTimeStr} is still waiting to be retrieved."
} else {
greetingToPlay += " Please note, the mail remains uncollected."
}
}
}
if (settings.announceNotesArrival && state.butlerNotes && state.butlerNotes.size() > 0) {
def pendingArrivalNotes = state.butlerNotes.findAll { (it.when == "Arrival" || it.when == "Pending") && (it.target == "Anyone" || it.target.equalsIgnoreCase(actualUserName) || it.target.equalsIgnoreCase(displayUserName)) }
if (pendingArrivalNotes.size() > 0) {
def readStr = "I have ${pendingArrivalNotes.size()} notes saved for you. "
pendingArrivalNotes.eachWithIndex { note, idx ->
def senderTxt = note.sender != "Someone" ? "from ${note.sender}: " : ""
if (idx == 0) readStr += "First, ${senderTxt}${note.text}. "
else if (idx == pendingArrivalNotes.size() - 1) readStr += "And finally, ${senderTxt}${note.text}."
else readStr += "Next, ${senderTxt}${note.text}. "
}
if (pendingArrivalNotes.size() == 1) {
def senderTxt = pendingArrivalNotes[0].sender != "Someone" ? "from ${pendingArrivalNotes[0].sender}" : ""
readStr = "I have a note saved for you ${senderTxt}: ${pendingArrivalNotes[0].text}."
}
greetingToPlay += " " + readStr
state.butlerNotes.removeAll { pendingArrivalNotes.contains(it) }
addToHistory("NOTES: Delivered arrival/missed notes to ${displayUserName}.")
}
}
if (settings.enableInbox && state.messageInbox && state.messageInbox.size() > 0) {
def inboxMsgs = state.messageInbox.join(", and ")
greetingToPlay += " By the way, while you were out, ${inboxMsgs}."
state.messageInbox = []
addToHistory("INBOX: Delivered stashed messages on arrival.")
}
def outdoorTargetVol = settings["arrivalVolume"] != null ? settings["arrivalVolume"] : settings["outdoorVolume"]
def indoorTargetVol = settings["indoorArrivalVolume"] != null ? settings["indoorArrivalVolume"] : settings["globalVolume"]
if (outdoorSpeaker) enqueueTTS(outdoorSpeaker, greetingToPlay, outdoorTargetVol, 3, true)
if (settings.arrivalFoyerSpeaker) enqueueTTS(settings.arrivalFoyerSpeaker, greetingToPlay, indoorTargetVol, 3, true)
if (settings.arrivalIndoorSpeaker) executeRoutedTTS(applyDynamicVars((settings.indoorArrivalMessage ?: "%name% has arrived home.").replace("%name%", displayUserName)), settings.arrivalNoticeRoutingMode ?: "Global Indoor Speaker Only", indoorTargetVol, outdoorTargetVol, 3, true)
}
}
def modeChangeHandler(evt) {
ensureStateMaps()
def newMode = evt.value
def nowT = new Date().time
if ([settings.resetModes].flatten().findAll { it != null }.contains(newMode)) {
state.hasArrivedToday.each { k, v -> if (v) state.lastDepartureTime[k] = nowT }
state.hasArrivedToday = [:]; state.hasDepartedToday = [:]; state.resetReasons = [:]; state.globalResetReason = "Reset by Mode Change (${newMode})"
}
def awayList = [settings.butlerAwayModes].flatten().findAll { it != null }
def nightList = [settings.butlerNightModes].flatten().findAll { it != null }
// Flag the report if exiting the mode, or Wipe the counts clean if entering the mode
if (state.lastMode in awayList && !(newMode in awayList)) { if (state.awayDoorbellCount > 0) state.pendingArrivalReport = true }
if (newMode in awayList) { state.awayDoorbellCount = 0; state.lastAwayDoorbellCount = 0; state.pendingArrivalReport = false }
if (state.lastMode in nightList && !(newMode in nightList)) { if (state.nightMotionCount > 0) state.pendingMorningReport = true }
if (newMode in nightList) { state.nightMotionCount = 0; state.lastNightMotionCount = 0; state.pendingMorningReport = false }
state.lastMode = newMode
}
def awaySwitchOnHandler(evt) {
ensureStateMaps()
if (settings.awayIgnoreModes?.contains(location.mode)) return
for (int i = 1; i <= (settings.numAwayMappings ? settings.numAwayMappings as Integer : 0); i++) {
if (settings["awayMappingSwitch_${i}"]?.id == evt.device.id) {
def uName = settings["awayMappingUser_${i}"]
if (uName) {
def splitNames = uName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
splitNames.each { n ->
if (state.hasArrivedToday[n]) {
state.lastDepartureTime[n] = new Date().time
state.hasArrivedToday.remove(n)
state.resetReasons[n] = "Away Switch ON"
}
}
}
}
}
}
def awayPresenceHandler(evt) {
if (evt.value != "not present") return
ensureStateMaps()
if (settings.awayIgnoreModes?.contains(location.mode)) return
for (int i = 1; i <= (settings.numAwayMappings ? settings.numAwayMappings as Integer : 0); i++) {
if (settings["awayMappingPresence_${i}"]?.id == evt.device.id) {
def uName = settings["awayMappingUser_${i}"]
if (uName) {
def splitNames = uName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
splitNames.each { n ->
if (state.hasArrivedToday[n]) {
state.lastDepartureTime[n] = new Date().time
state.hasArrivedToday.remove(n)
state.resetReasons[n] = "Presence Sensor Departed"
}
}
}
}
}
}
def scheduledAwayCheck() {
ensureStateMaps()
for (int i = 1; i <= (settings.numAwayMappings ? settings.numAwayMappings as Integer : 0); i++) {
if (settings["awayMappingSwitch_${i}"]?.currentValue("switch") == "on") {
def uName = settings["awayMappingUser_${i}"]
if (uName) {
def splitNames = uName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
splitNames.each { n ->
if (state.hasArrivedToday[n]) {
state.lastDepartureTime[n] = new Date().time
state.hasArrivedToday.remove(n)
state.resetReasons[n] = "Away Switch ON (Scheduled Check)"
}
}
}
}
}
}
// --- CENTRAL GREETING BUILDER ---
def buildRoomGreeting(rNum, type, context = [:]) {
ensureStateMaps()
def rName = settings["roomName_${rNum}"] ?: "Room ${rNum}"
def rawOccName = context.dynamicName ?: (settings["roomOccupantName_${rNum}"] ?: rName)
def isTest = context.isTest ?: false
// --- NEW: DYNAMIC PRESENCE FILTERING ---
// Split the names (e.g., "Shane and Christy" -> ["Shane", "Christy"])
def splitOcc = rawOccName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
def presentOcc = []
if (isTest || rawOccName == rName) {
presentOcc = splitOcc // In a test, or if it's just "Master Bedroom", assume everyone is there
} else {
// Only greet the people who are actually marked as Arrived
splitOcc.each { n ->
if (state.hasArrivedToday[n] == true) presentOcc << n
}
// Failsafe: if no one is marked home but the switch was hit, default to all assigned users
if (presentOcc.size() == 0) presentOcc = splitOcc
}
// Format the final display name with correct grammar
def displayOccName = ""
if (presentOcc.size() == 1) displayOccName = applyAlias(presentOcc[0])
else if (presentOcc.size() == 2) displayOccName = "${applyAlias(presentOcc[0])} and ${applyAlias(presentOcc[1])}"
else {
def last = presentOcc.pop()
displayOccName = "${presentOcc.collect{applyAlias(it)}.join(', ')}, and ${applyAlias(last)}"
presentOcc.push(last) // Put it back just in case
}
// ---------------------------------------
def parts = []
def roomKey = "room_${rNum}" // Unique ID for the memory filter
// Global variable to determine if this is a kid's room
def isKidRoom = settings["roomKidsMode_${rNum}"] || settings["roomKidsNightWatch_${rNum}"]
def rawMsg = ""
if (type == "Good Night") {
if (context.isNewArrival) {
// --- THE APOLOGY FIX ---
rawMsg = "Pardon me, I didn't catch you coming in earlier. Welcome home, %name%. Your space is prepared."
} else if (settings["useCustomRoomMessages_${rNum}"]) {
def msgs = []
for (int m = 1; m <= 10; m++) { if (settings["gnMessage_${rNum}_${m}"]) msgs << settings["gnMessage_${rNum}_${m}"] }
rawMsg = msgs ? msgs[new Random().nextInt(msgs.size())] : getDefaultMessages("Good Night")[0]
} else {
def defaults = getDefaultMessages("Good Night")
rawMsg = defaults[new Random().nextInt(defaults.size())]
}
} else {
if (settings["useCustomRoomMessages_${rNum}"]) {
def msgs = []
for (int m = 1; m <= 10; m++) { if (settings["gmMessage_${rNum}_${m}"]) msgs << settings["gmMessage_${rNum}_${m}"] }
rawMsg = msgs ? msgs[new Random().nextInt(msgs.size())] : getDefaultMessages("Good Morning")[0]
} else {
def defaults = getDefaultMessages("Good Morning")
rawMsg = defaults[new Random().nextInt(defaults.size())]
}
}
def baseString = ""
if (context.apology) baseString += context.apology + " "
if (context.curfew) baseString += context.curfew + " "
baseString += rawMsg.replace("%name%", displayOccName).replace("%room%", rName)
// --- AI SLEEP HABIT CHECK ---
// Use rawOccName so the habit tracks the core users, not just who is home tonight
if (type == "Good Night" && rawOccName != "Guest") {
def splitHabitOcc = rawOccName.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
if (!isTest) splitHabitOcc.each { n -> updateHabit(n, new Date().time, "sleep") }
def primaryOcc = splitHabitOcc[0]
if (state.learnedHabits && state.learnedHabits[primaryOcc]?.avgSleepMins && state.learnedHabits[primaryOcc].sleepCount > 3) {
def expected = state.learnedHabits[primaryOcc].avgSleepMins
def cal2 = Calendar.getInstance(location.timeZone)
def nowMins = (cal2.get(Calendar.HOUR_OF_DAY) * 60) + cal2.get(Calendar.MINUTE)
def shiftedNow = nowMins < 720 ? nowMins + 1440 : nowMins
if (shiftedNow > (expected + 90)) baseString = "It is quite late. " + baseString
else if (shiftedNow < (expected - 90)) baseString = "Turning in early tonight? " + baseString
}
}
// ----------------------------
parts << baseString
// Use rawOccName for birthdays and anniversaries so it doesn't skip them if someone sneaks in
def bdayMsg = getBirthdayMessage(rawOccName, type == "Good Night" ? "Night" : "Morning")
if (bdayMsg) parts << bdayMsg
if (settings["roomEnableAnniversary_${rNum}"]) {
def annivMsg = getAnniversaryMessage(type == "Good Night" ? "Night" : "Morning")
if (annivMsg) parts << annivMsg
}
if (settings.enableHolidays && type != "Good Night") {
def holiday = getTodayHoliday()
if (isTest && !holiday) holiday = "a Holiday"
if (holiday) {
def hMsg = settings.holidayMessage ?: "Also, don't forget today is %holiday%!"
parts << hMsg.replace('%holiday%', holiday)
}
}
def wDevice = settings["roomWeatherDevice_${rNum}"]
def cal = Calendar.getInstance(location.timeZone)
def dow = cal.get(Calendar.DAY_OF_WEEK)
def dowString = new Date().format("EEEE", location.timeZone)
// ==============================================
// GOOD MORNING LOGIC
// ==============================================
if (type == "Good Morning") {
def timeDateEnabled = settings["roomTimeDate_${rNum}"]
def agendaEnabled = settings["roomAgendaEnable_${rNum}"]
def agendaText = settings["roomAgenda${dowString}_${rNum}"]
if (timeDateEnabled && agendaEnabled && agendaText) {
// --- FIX: AGENDA FLOW (Time only, cleaner transition) ---
def timeNow = new Date().format("h:mm a", location.timeZone)
def agendaStr = getBridge("agenda") + "It is currently ${timeNow}, and your agenda for today is: ${agendaText}."
def smartContext = getSmartEventContext(agendaText)
if (smartContext.text && smartContext.text != "SECRET") agendaStr += " " + smartContext.text
parts << agendaStr
} else if (timeDateEnabled) {
parts << "The current time is %time% on %date%."
} else if (agendaEnabled && agendaText) {
def agendaStr = getBridge("agenda") + "Your agenda for today is: ${agendaText}."
def smartContext = getSmartEventContext(agendaText)
if (smartContext.text && smartContext.text != "SECRET") agendaStr += " " + smartContext.text
parts << agendaStr
}
// --- NEW: MEAL PLAN INJECTION ---
if (settings["roomAnnounceMeal_${rNum}"]) {
// Priority: App Setup Input -> State Memory -> Empty
def todayMeal = settings["meal_${dowString}"] ?: (state.mealPlan ? state.mealPlan[dowString] : "")
if (todayMeal && todayMeal.trim() != "") {
parts << getBridge("general") + "on the menu for dinner tonight is ${todayMeal}."
}
}
// --------------------------------
if (settings["roomAnnounceNightMotion_${rNum}"]) {
def count = state.lastNightMotionCount ?: 0
if (count > 0 || isTest) {
def c = isTest ? 3 : count
parts << "Please note, there were ${c} motion events on the porch last night."
}
}
if (settings["roomAnnounceAwayDoorbell_${rNum}"]) {
def count = state.lastAwayDoorbellCount ?: 0
if (count > 0 || isTest) {
def c = isTest ? 1 : count
parts << "Also, the doorbell rang ${c} times while the house was vacant yesterday."
}
}
if (wDevice && settings["roomWeatherGM_${rNum}"]) {
def wText = getWeatherReport(wDevice, roomKey, isTest)
if (wText) {
def weatherBlock = getBridge("weather") + wText
if (settings["roomWardrobe_${rNum}"]) {
def wardText = getWardrobeAdvice(wDevice)
if (wardText) weatherBlock += " " + wardText
}
parts << weatherBlock
}
} else if (settings["roomWardrobe_${rNum}"] && wDevice) {
def wardText = getWardrobeAdvice(wDevice)
if (wardText) parts << getBridge("weather") + wardText
}
// --- NEW: VEHICLE CARE SCOUT INJECTION ---
if (settings.enableVehicleCare && state.carWashDay) {
if (dow == state.carWashDay) {
parts << getBridge("general") + "the weekly forecast indicates today is the ideal day to wash the vehicles and clean them out."
}
}
// -----------------------------------------
if (settings["roomBoredomBuster_${rNum}"] && (dow == Calendar.SATURDAY || dow == Calendar.SUNDAY || isTest)) {
def bText = getBoredomBuster(wDevice)
if (bText) parts << bText
}
if (settings["roomNewsEnable_${rNum}"]) {
def newsText = getRoomNews(rNum, isTest)
if (newsText) {
// --- FIX: NEWS SLASHES ---
def cleanNews = newsText.trim()
cleanNews = cleanNews.replaceAll(/\s*\/\s*/, ". In other news, ")
if (!cleanNews.endsWith(".")) cleanNews += "."
parts << getBridge("news") + cleanNews
}
}
if (settings["roomAnnounceMaintenance_${rNum}"] && settings["roomMaintenanceDevice_${rNum}"]) {
def mText = getMaintenanceReport(settings["roomMaintenanceDevice_${rNum}"], isTest)
if (mText) {
// --- FIX: MAINTENANCE VARIABLE ---
mText = mText.replace("%name%", displayOccName)
parts << mText
}
}
// --- NEW: WASTE MANAGEMENT INJECTION ---
if (settings["roomAnnounceTrash_${rNum}"] && state.trashData) {
parts << getBridge("general") + "waste management indicates the bins are ${state.trashData.fill} percent full, and the next collection is scheduled for ${state.trashData.nextPickup}."
}
// ---------------------------------------
if (settings["roomKidsMode_${rNum}"]) {
parts << getKidsFunFact(rNum)
}
// --- BUTLER NOTES (MORNING) ---
if (settings.announceNotesMorning && state.butlerNotes && state.butlerNotes.size() > 0) {
// We use displayOccName here so it only reads notes for the people currently in the room
def pendingMorningNotes = state.butlerNotes.findAll { (it.when == "Morning" || it.when == "Pending") && (it.target == "Anyone" || it.target.equalsIgnoreCase(displayOccName) || it.target.equalsIgnoreCase(rawOccName)) }
if (pendingMorningNotes.size() > 0) {
def readStr = "I have ${pendingMorningNotes.size()} notes saved for you. "
pendingMorningNotes.eachWithIndex { note, idx ->
def senderTxt = note.sender != "Someone" ? "from ${note.sender}: " : ""
if (idx == 0) readStr += "First, ${senderTxt}${note.text}. "
else if (idx == pendingMorningNotes.size() - 1) readStr += "And finally, ${senderTxt}${note.text}."
else readStr += "Next, ${senderTxt}${note.text}. "
}
if (pendingMorningNotes.size() == 1) {
def senderTxt = pendingMorningNotes[0].sender != "Someone" ? "from ${pendingMorningNotes[0].sender}" : ""
readStr = "I have a note saved for you ${senderTxt}: ${pendingMorningNotes[0].text}."
}
parts << getBridge("general") + readStr
if (!isTest) {
state.butlerNotes.removeAll { pendingMorningNotes.contains(it) }
addToHistory("NOTES: Delivered morning/missed notes to ${displayOccName}.")
}
}
}
// ---------------------------------------
// --- ADULT ONLY: MESSAGE INBOX (MORNING) ---
if (!isKidRoom && settings.enableInbox && settings["roomAnnounceInbox_${rNum}"] != false && state.messageInbox && state.messageInbox.size() > 0) {
def inboxMsgs = state.messageInbox.join(", and ")
parts << getBridge("general") + "while you were asleep, ${inboxMsgs}."
if (!isTest) {
state.messageInbox = []
addToHistory("INBOX: Delivered stashed messages during Morning Briefing.")
}
}
}
// ==============================================
// GOOD NIGHT LOGIC
// ==============================================
if (type == "Good Night") {
if (settings["roomKidsWeekend_${rNum}"]) {
if (dow == Calendar.FRIDAY || dow == Calendar.SATURDAY || isTest) {
parts << "Since tomorrow is the weekend, there is no school! Sleep well and sleep in."
}
}
if (settings["roomPerimeterCheck_${rNum}"]) {
def perimText = getPerimeterReport(roomKey, isTest)
if (perimText) parts << perimText
}
if (wDevice && settings["roomWeatherGN_${rNum}"]) {
def wText = getWeatherReport(wDevice, roomKey, isTest)
if (wText) parts << wText
}
def tomorrowMsg = getTomorrowPreview()
if (tomorrowMsg) parts << tomorrowMsg
if (settings["roomKidsNightWatch_${rNum}"]) {
def monsterList = [
"I have activated the monster shields. You are completely safe.",
"Scanning the room... No monsters detected. Have a great sleep.",
"My perimeter sensors are watching for bad guys so you can sleep peacefully.",
"I will be keeping a close eye on the house tonight to keep you safe from any monsters.",
"Sleep tight! The anti-monster forcefield is fully powered up.",
"Do not worry, %butler% is on guard duty tonight to keep the bad guys away."
]
parts << monsterList[new Random().nextInt(monsterList.size())]
}
// --- FIX 2: ADULT ONLY INBOX ON LATE ARRIVAL (GOOD NIGHT SWITCH) ---
if (context.isNewArrival && !isKidRoom && settings.enableInbox && state.messageInbox && state.messageInbox.size() > 0) {
def inboxMsgs = state.messageInbox.join(", and ")
parts << getBridge("general") + "while you were away, ${inboxMsgs}."
if (!isTest) {
state.messageInbox = []
addToHistory("INBOX: Delivered stashed messages during Late Night Arrival.")
}
}
// -------------------------------------------------------------------
}
def finalMsg = parts.join(" ")
// --- THE GRAMMAR POLISHER ---
// Fix double punctuation and spacing issues caused by modular injection
finalMsg = finalMsg.replaceAll(/\!\./, "!") // Fixes "Church Day!."
finalMsg = finalMsg.replaceAll(/\?\./, "?") // Fixes "?."
finalMsg = finalMsg.replaceAll(/\.\./, ".") // Fixes ".."
finalMsg = finalMsg.replaceAll(/\s+/, " ") // Removes accidental double spaces
finalMsg = finalMsg.trim()
// ----------------------------
return applyDynamicVars(finalMsg)
}
def executeGoodNightSequence(data) {
def rNum = data.roomNum
def rName = settings["roomName_${rNum}"] ?: "Room ${rNum}"
def targetSpeaker = settings["roomSpeaker_${rNum}"]
// THE FIX: Fallback to Global Volume, not a broken variable
def targetVol = settings["roomVolumeGN_${rNum}"] != null ? settings["roomVolumeGN_${rNum}"] : settings.globalVolume
if (!targetSpeaker && globalIndoorSpeaker) { targetSpeaker = globalIndoorSpeaker; targetVol = globalVolume }
if (targetSpeaker) {
def finalMsg = buildRoomGreeting(rNum, "Good Night", data)
enqueueTTS(targetSpeaker, finalMsg, targetVol, 4)
addToHistory("ROOM GREETING: Good Night sequence triggered for ${rName}. Queued: '${finalMsg}'")
}
}
def goodNightOffHandler(evt) {
ensureStateMaps()
def deviceId = evt.device.id
for (int i = 1; i <= (settings.numRooms as Integer ?: 1); i++) {
if (settings["roomGoodNightSwitch_${i}"]?.id == deviceId) {
def mode = settings["roomWakeupMode_${i}"] ?: "1. Immediate (When Good Night Switch turns OFF)"
if (mode == "1. Immediate (When Good Night Switch turns OFF)") {
triggerGoodMorningSequence(i)
}
else if (mode == "2. Verified (Wait for switch OFF, then wait for Motion)") {
state.waitingForMotion["${i}"] = true
}
else if (mode == "3. Motion Driven (Trigger when Motion activates while switch is ON)") {
// Failsafe: If the switch turns off but they never triggered motion, play it now
if (!state.roomAlreadyAwake["${i}"]) {
triggerGoodMorningSequence(i)
}
// Reset the awake flag for tomorrow night
state.roomAlreadyAwake["${i}"] = false
}
return
}
}
}
def triggerGoodMorningSequence(int i) {
def delaySec = settings["delayGreetingGM_${i}"] != null ? settings["delayGreetingGM_${i}"].toInteger() : 30
runIn(delaySec, "executeGoodMorningSequence", [data: [roomNum: i], overwrite: false])
}
def executeGoodMorningSequence(data) {
def rNum = data.roomNum
def rName = settings["roomName_${rNum}"] ?: "Room ${rNum}"
def targetSpeaker = settings["roomSpeaker_${rNum}"]
def targetVol = settings["roomVolumeGM_${rNum}"] != null ? settings["roomVolumeGM_${rNum}"] : settings.globalVolume
def allSpeakers = []
if (targetSpeaker) allSpeakers << targetSpeaker
// --- SIMULTANEOUS GLOBAL ROUTING ---
if (settings["roomGlobalMorning_${rNum}"] && globalIndoorSpeaker) {
allSpeakers.addAll([globalIndoorSpeaker].flatten().findAll { it != null })
}
// Failsafe if no room speaker was set
if (allSpeakers.size() == 0 && globalIndoorSpeaker) {
allSpeakers.addAll([globalIndoorSpeaker].flatten().findAll { it != null })
targetVol = globalVolume
}
// Strictly filter out duplicates in case the Master Bedroom is also in the Global list
allSpeakers = allSpeakers.flatten().findAll { it != null }.unique { it.id }
if (allSpeakers.size() > 0) {
def finalMsg = buildRoomGreeting(rNum, "Good Morning", data)
enqueueTTS(allSpeakers, finalMsg, targetVol, 4)
addToHistory("ROOM GREETING: Good Morning sequence triggered for ${rName}. Queued: '${finalMsg}'")
}
}
def testRoomGreeting(rNum, type, isNewArrival = false) {
ensureStateMaps()
def roomIdx = rNum.toInteger()
def targetSpeaker = settings["roomSpeaker_${roomIdx}"]
def allSpeakers = []
if (targetSpeaker) allSpeakers << targetSpeaker
if (type == "Good Morning" && settings["roomGlobalMorning_${roomIdx}"] && globalIndoorSpeaker) {
allSpeakers.addAll([globalIndoorSpeaker].flatten().findAll { it != null })
}
if (allSpeakers.size() == 0 && globalIndoorSpeaker) {
allSpeakers.addAll([globalIndoorSpeaker].flatten().findAll { it != null })
}
allSpeakers = allSpeakers.flatten().findAll { it != null }.unique { it.id }
def vol = null
if (type == "Good Morning") {
vol = settings["roomVolumeGM_${roomIdx}"] != null ? settings["roomVolumeGM_${roomIdx}"] : settings.globalVolume
} else {
vol = settings["roomVolumeGN_${roomIdx}"] != null ? settings["roomVolumeGN_${roomIdx}"] : settings.globalVolume
}
if (allSpeakers.size() > 0) {
def finalMsg = buildRoomGreeting(roomIdx, type, [isNewArrival: isNewArrival, isTest: true])
enqueueTTS(allSpeakers, finalMsg, vol, 1)
log.info "TESTING ${type.toUpperCase()} (${settings["roomName_${roomIdx}"]}): '${finalMsg}' at Volume: ${vol}"
} else {
log.warn "Cannot test Room Greeting - no speaker assigned for Room ${roomIdx}."
}
}
// --- REMAINING TEST TRIGGERS ---
def testRoomNews(rNum) {
def feedUrl = settings["roomNewsFeed_${rNum}"] ?: "https://feeds.npr.org/1001/rss.xml"
def finalMsg = ""
try {
httpGet([uri: feedUrl, headers: ["User-Agent": "Mozilla/5.0 (Hubitat; AdvancedVoiceButler)"], timeout: 10, textParser: true]) { resp ->
if (resp.status == 200 && resp.data) {
def rss = parseRssResponse(resp)
def items = rss?.channel?.item
if (items && items.size() >= 2) {
def title1 = items[0].title.text().trim().replace("&", "and").replace("\"", "")
def title2 = items[1].title.text().trim().replace("&", "and").replace("\"", "")
finalMsg = "This is a test of my connection to the morning news desk. ${title1}. In other news, ${title2}."
}
}
}
} catch (Exception e) { log.warn "Voice Butler: Room News Fetch Error - ${e}"; finalMsg = "I am currently unable to reach the news desk to retrieve the latest headlines." }
if (finalMsg) {
def targetSpeaker = settings["roomSpeaker_${rNum}"] ?: globalIndoorSpeaker
def targetVol = settings["roomVolumeGM_${rNum}"] != null ? settings["roomVolumeGM_${rNum}"] : settings["roomVolume_${rNum}"]
if (!targetSpeaker && globalIndoorSpeaker) targetVol = globalVolume
if (targetSpeaker) enqueueTTS(targetSpeaker, finalMsg, targetVol, 1)
log.info "TESTING ROOM NEWS (${settings["roomName_${rNum}"]}): '${finalMsg}'"
}
}
def testDepartureGreeting(int idx) {
if (outdoorSpeaker) {
def uName = settings["depUserName_${idx}"] ?: "Guest"
def displayUserName = applyAlias(uName)
def messages = []
for (int m = 1; m <= 10; m++) {
def msg = settings["depMessage_${idx}_${m}"]
if (msg) messages << msg
}
if (!messages) messages = ["Have a good trip %name%."]
def rawMsg = messages[new Random().nextInt(messages.size())]
def finalMsg = rawMsg.replace("%name%", displayUserName)
def bdayMsg = getBirthdayMessage(uName, "Departure")
if (bdayMsg) {
finalMsg = "${finalMsg} ${bdayMsg}"
}
def annivMsg = getAnniversaryMessage("Departure", uName)
if (annivMsg) {
finalMsg = "${finalMsg} ${annivMsg}"
}
finalMsg = applyDynamicVars(finalMsg)
def profileVol = settings["depVolume_${idx}"]
def targetVolume = profileVol != null ? profileVol : (settings["arrivalVolume"] != null ? settings["arrivalVolume"] : settings["outdoorVolume"])
def rMode = settings["depRoutingMode_${idx}"] ?: "Outdoor Speaker Only"
log.info "TESTING DEPARTURE GREETING (Profile ${idx}): '${finalMsg}'"
executeRoutedTTS(finalMsg, rMode, settings.globalVolume, targetVolume, 1)
} else {
log.warn "Cannot test Departure greeting - no outdoor speaker assigned."
}
}
def testPartyModeGreeting() {
if (outdoorSpeaker) {
def messages = []
for (int d = 1; d <= 3; d++) {
def msg = settings["partyMessage_${d}"]
if (msg) messages << msg
}
if (!messages) messages = getDefaultMessages("PartyMode")
def randomMsg = messages[new Random().nextInt(messages.size())]
randomMsg = applyDynamicVars(randomMsg)
def volLog = settings.partyVolume != null ? "${settings.partyVolume}%" : "Hardware Default"
def rMode = settings.partyRoutingMode ?: "Outdoor Speaker Only"
log.info "TESTING PARTY MODE GREETING: '${randomMsg}'"
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, settings.partyVolume != null ? settings.partyVolume : settings.outdoorVolume, 1, true)
} else {
log.warn "Cannot test Party Mode greeting - no outdoor speaker assigned."
}
}
def testDndGreeting() {
if (outdoorSpeaker) {
def messages = []
for (int d = 1; d <= 10; d++) {
def msg = settings["dndMessage_${d}"]
if (msg) messages << msg
}
if (!messages) messages = getDefaultMessages("DND")
def randomMsg = messages[new Random().nextInt(messages.size())]
randomMsg = applyDynamicVars(randomMsg)
def volLog = outdoorVolume != null ? "${outdoorVolume}%" : "Hardware Default"
def rMode = settings.dndRoutingMode ?: "Outdoor Speaker Only"
log.info "TESTING DND GREETING: '${randomMsg}'"
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, settings.outdoorVolume, 1, true)
} else {
log.warn "Cannot test DND greeting - no outdoor speaker assigned."
}
}
def testAfterHoursGreeting() {
if (outdoorSpeaker) {
def messages = []
for (int d = 1; d <= 15; d++) {
def msg = settings["afterHoursMessage_${d}"]
if (msg) messages << msg
}
if (!messages) messages = getDefaultMessages("AfterHours")
def randomMsg = messages[new Random().nextInt(messages.size())]
randomMsg = applyDynamicVars(randomMsg)
def targetVol = settings.afterHoursVolume != null ? settings.afterHoursVolume : settings.outdoorVolume
def rMode = settings.afterHoursRoutingMode ?: "Outdoor Speaker Only"
log.info "TESTING AFTER HOURS GREETING: '${randomMsg}'"
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, targetVol, 1, true)
} else {
log.warn "Cannot test After Hours greeting - no outdoor speaker assigned."
}
}
def testDaytimeGreeting() {
if (outdoorSpeaker) {
def messages = []
for (int d = 1; d <= 20; d++) {
def msg = settings["daytimeMessage_${d}"]
if (msg) messages << msg
}
if (!messages) messages = getDefaultMessages("Daytime")
def randomMsg = messages[new Random().nextInt(messages.size())]
randomMsg = applyDynamicVars(randomMsg)
def targetVol = settings.daytimeDoorbellVolume != null ? settings.daytimeDoorbellVolume : settings.outdoorVolume
def rMode = settings.daytimeRoutingMode ?: "Outdoor Speaker Only"
log.info "TESTING DAYTIME GREETING: '${randomMsg}'"
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, targetVol, 1, true)
} else {
log.warn "Cannot test Daytime greeting - no outdoor speaker assigned."
}
}
def testArrivalGreeting() {
if (outdoorSpeaker) {
def messages = []
if (arrivalMode == "Automatic (Reads lock memory)") {
for (int m = 1; m <= 10; m++) {
def msg = settings["autoGreeting_${m}"]
if (msg) messages << msg
}
} else {
for (int m = 1; m <= 10; m++) {
def msg = settings["defaultArrivalMessage_${m}"]
if (msg) messages << msg
}
}
if (!messages) messages = getDefaultMessages("Arrival")
def rawMsg = messages[new Random().nextInt(messages.size())]
def testName = settings.adminUserAlias ?: "Test User"
def displayUserName = applyAlias(testName)
def greetingToPlay = rawMsg.replace("%name%", displayUserName)
def bdayMsg = getBirthdayMessage(testName, "Arrival")
if (bdayMsg) {
greetingToPlay = "${greetingToPlay} ${bdayMsg}"
}
def annivMsg = getAnniversaryMessage("Arrival", testName)
if (annivMsg) {
greetingToPlay = "${greetingToPlay} ${annivMsg}"
}
greetingToPlay = applyDynamicVars(greetingToPlay)
def outdoorTargetVol = settings["arrivalVolume"] != null ? settings["arrivalVolume"] : settings["outdoorVolume"]
def indoorTargetVol = settings["indoorArrivalVolume"] != null ? settings["indoorArrivalVolume"] : settings["globalVolume"]
def indoorMsg = ""
if (settings.arrivalIndoorSpeaker) {
indoorMsg = (settings.indoorArrivalMessage ?: "%name% has arrived home.").replace("%name%", displayUserName)
indoorMsg = applyDynamicVars(indoorMsg)
}
def played = false
if (outdoorSpeaker) {
log.info "TESTING OUTDOOR ARRIVAL: '${greetingToPlay}'"
enqueueTTS(outdoorSpeaker, greetingToPlay, outdoorTargetVol, 1, true)
played = true
}
if (settings.arrivalFoyerSpeaker) {
log.info "TESTING FOYER ARRIVAL: '${greetingToPlay}'"
enqueueTTS(settings.arrivalFoyerSpeaker, greetingToPlay, indoorTargetVol, 1, true)
played = true
}
if (settings.arrivalIndoorSpeaker) {
def rMode = settings.arrivalNoticeRoutingMode ?: "Global Indoor Speaker Only"
log.info "TESTING INDOOR ARRIVAL NOTICE: '${indoorMsg}'"
if (executeRoutedTTS(indoorMsg, rMode, indoorTargetVol, outdoorTargetVol, 1, true)) {
played = true
}
}
if (!played) {
log.warn "Cannot test Arrival greeting - no speakers are configured to play it."
}
} else {
log.warn "Cannot test Arrival greeting - no outdoor speaker assigned."
}
}
def testIntruderGreeting() {
if (outdoorSpeaker) {
def messages = []
for (int d = 1; d <= 10; d++) {
def msg = settings["intruderMessage_${d}"]
if (msg) messages << msg
}
if (!messages) messages = getDefaultMessages("Intruder")
def randomMsg = messages[new Random().nextInt(messages.size())]
randomMsg = applyDynamicVars(randomMsg)
def targetVol = settings.intruderVolume != null ? settings.intruderVolume : settings.outdoorVolume
def rMode = settings.intruderRoutingMode ?: "Outdoor Speaker Only"
log.info "TESTING INTRUDER GREETING: '${randomMsg}'"
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, targetVol, 1)
} else {
log.warn "Cannot test Intruder greeting - no outdoor speaker assigned."
}
}
// --- CALENDAR & HISTORY HELPERS ---
def getTodayHoliday() {
def cal = Calendar.getInstance(location.timeZone)
cal.setTime(new Date())
int m = cal.get(Calendar.MONTH) + 1
int d = cal.get(Calendar.DAY_OF_MONTH)
int dow = cal.get(Calendar.DAY_OF_WEEK)
int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH)
if (m == 1 && d == 1) return "New Year's Day"
if (m == 2 && d == 2) return "Groundhog Day"
if (m == 2 && d == 14) return "Valentine's Day"
if (m == 3 && d == 17) return "Saint Patrick's Day"
if (m == 4 && d == 1) return "April Fools' Day"
if (m == 4 && d == 22) return "Earth Day"
if (m == 5 && d == 5) return "Cinco de Mayo"
if (m == 6 && d == 14) return "Flag Day"
if (m == 6 && d == 19) return "Juneteenth"
if (m == 7 && d == 4) return "Independence Day"
if (m == 9 && d == 11) return "Patriot Day"
if (m == 10 && d == 31) return "Halloween"
if (m == 11 && d == 11) return "Veterans Day"
if (m == 12 && d == 24) return "Christmas Eve"
if (m == 12 && d == 25) return "Christmas Day"
if (m == 12 && d == 31) return "New Year's Eve"
if (m == 1 && dow == Calendar.MONDAY && wom == 3) return "Martin Luther King Jr. Day"
if (m == 2 && dow == Calendar.MONDAY && wom == 3) return "Presidents' Day"
if (m == 5 && dow == Calendar.SUNDAY && wom == 2) return "Mother's Day"
if (m == 5 && dow == Calendar.MONDAY && (d + 7 > 31)) return "Memorial Day"
if (m == 6 && dow == Calendar.SUNDAY && wom == 3) return "Father's Day"
if (m == 9 && dow == Calendar.MONDAY && wom == 1) return "Labor Day"
if (m == 10 && dow == Calendar.MONDAY && wom == 2) return "Columbus Day"
if (m == 11 && dow == Calendar.TUESDAY && d >= 2 && d <= 8) return "Election Day"
if (m == 11 && dow == Calendar.THURSDAY && wom == 4) return "Thanksgiving"
if (m == 11 && dow == Calendar.FRIDAY && d >= 23 && d <= 29) return "Black Friday"
return null
}
def getAnniversaryMessage(String type, String userName = "") {
if (!settings.enableAnniversary || !settings.annivMonth || !settings.annivDay) return ""
// 1. Enforce Privacy: Only alert the couple
def allowedRoster = [settings.annivAllowedUsers].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.annivAllowedCustom) allowedRoster += settings.annivAllowedCustom.split(',').collect { it.trim().toLowerCase() }
// If the roster is restricted and the current user isn't on it, stay silent.
if (!allowedRoster.isEmpty() && !allowedRoster.contains(userName.toLowerCase()) && !allowedRoster.contains(applyAlias(userName).toLowerCase())) {
return ""
}
def now = new Date()
def currentMonth = now.format("MM", location.timeZone).toInteger()
def currentDay = now.format("dd", location.timeZone).toInteger()
def annivMonthInt = settings.annivMonth.toInteger()
def annivDayInt = settings.annivDay.toInteger()
// 2. Calculate exact days until the anniversary
def annivCal = Calendar.getInstance(location.timeZone)
annivCal.set(Calendar.MONTH, annivMonthInt - 1)
annivCal.set(Calendar.DAY_OF_MONTH, annivDayInt)
annivCal.set(Calendar.HOUR_OF_DAY, 0)
annivCal.set(Calendar.MINUTE, 0)
annivCal.set(Calendar.SECOND, 0)
if (annivCal.time.time < now.time && !(annivMonthInt == currentMonth && annivDayInt == currentDay)) {
annivCal.add(Calendar.YEAR, 1)
}
def daysUntil = Math.round((annivCal.time.time - now.time) / 86400000.0).toInteger()
// 3. Exact Day Greetings
if (daysUntil == 0) {
switch(type) {
case "Arrival": return settings.annivMsgArrival ?: "Happy Anniversary! Welcome home."
case "Morning": return settings.annivMsgMorning ?: "Happy Anniversary! I hope you both have a fantastic day."
case "Departure": return "Have a wonderful anniversary today!"
case "Night": return "Happy Anniversary. Sleep well."
}
}
// 4. The 5-Day Advanced Warning
if (daysUntil > 0 && daysUntil <= 5 && (type == "Morning" || type == "Arrival")) {
def daysTxt = daysUntil == 1 ? "tomorrow" : "in exactly ${daysUntil} days"
return "As a critical reminder, your anniversary is ${daysTxt}. I advise finalizing any reservations or gifts immediately to ensure a seamless evening."
}
return ""
}
def stashMessage(String msg) {
if (!settings.enableInbox) return
ensureStateMaps()
if (!state.messageInbox) state.messageInbox = []
if (!state.messageInbox.contains(msg)) {
state.messageInbox.add(msg)
if (state.messageInbox.size() > 5) state.messageInbox.remove(0)
if (settings.enableDebug) log.debug "INBOX: Stashed message -> '${msg}'"
}
}
def getBirthdayMessage(String arrivingUser, String type) {
if (!settings.numBirthdays) return ""
def numBdays = settings.numBirthdays as Integer
if (numBdays <= 0) return ""
def now = new Date()
def currentMonth = now.format("MM", location.timeZone).toInteger()
def currentDay = now.format("dd", location.timeZone).toInteger()
for (int i = 1; i <= numBdays; i++) {
def bName = settings["bdayName_${i}"] ?: ""
if (!bName) continue
def bType = settings["bdayType_${i}"] ?: "Kid (30-Day Countdown)"
def bMonthInt = settings["bdayMonth_${i}"]?.toInteger()
def bDayInt = settings["bdayDay_${i}"]?.toInteger()
// Safely extract the list of users who should hear this reminder
def notifyUserSetting = settings["bdayNotifyUser_${i}"]
def nUsers = [notifyUserSetting].flatten().findAll { it != null }.collect { it.toLowerCase() }
// Check if the person in the room IS the birthday person
def isBirthdayPerson = arrivingUser.toLowerCase().contains(bName.toLowerCase()) || arrivingUser.toLowerCase().contains(applyAlias(bName).toLowerCase())
// Check if the people in the room are the designated "Notify Users" (e.g., Parents)
def isTargetPresent = nUsers.isEmpty() || nUsers.contains(arrivingUser.toLowerCase()) || nUsers.any { nu -> state.hasArrivedToday?.keySet()?.any { k -> k.toLowerCase() == nu } }
// If the person in the room is neither the birthday person NOR the designated notified user, skip.
if (!isBirthdayPerson && !isTargetPresent) continue
// Calculate EXACT days until birthday to fix the "exactly 5 days" bug
def bdayCal = Calendar.getInstance(location.timeZone)
bdayCal.set(Calendar.MONTH, bMonthInt - 1)
bdayCal.set(Calendar.DAY_OF_MONTH, bDayInt)
bdayCal.set(Calendar.HOUR_OF_DAY, 0)
bdayCal.set(Calendar.MINUTE, 0)
bdayCal.set(Calendar.SECOND, 0)
// If the birthday passed earlier this year, look to next year
if (bdayCal.time.time < now.time && !(bMonthInt == currentMonth && bDayInt == currentDay)) {
bdayCal.add(Calendar.YEAR, 1)
}
def daysUntil = Math.round((bdayCal.time.time - now.time) / 86400000.0).toInteger()
// 1. Exact Birthday Match
if (daysUntil == 0) {
if (isBirthdayPerson) {
if (type == "Arrival") return settings.bdayMsgArrival?.replace("%name%", applyAlias(bName)) ?: "Happy Birthday ${applyAlias(bName)}!"
else if (type == "Morning") return settings.bdayMsgMorning?.replace("%name%", applyAlias(bName)) ?: "Happy Birthday ${applyAlias(bName)}! I hope you have a fantastic day."
else return "Happy Birthday ${applyAlias(bName)}."
} else {
return "By the way, today is ${applyAlias(bName)}'s birthday. Please remember to wish them a Happy Birthday!"
}
}
// 2. Parent / Notify User Warning (5 days or less - Applies to both Kids and Adults now!)
if (!isBirthdayPerson && isTargetPresent && daysUntil > 0 && daysUntil <= 5 && (type == "Morning" || type == "Arrival")) {
def daysTxt = daysUntil == 1 ? "tomorrow" : "in exactly ${daysUntil} days"
return "As a critical reminder, ${applyAlias(bName)}'s birthday is ${daysTxt}. Please verify your preparations and ensure a gift has been secured."
}
// 3. Kid's 30-Day Countdown (Only plays to the Kid)
if (bType.contains("Kid") && isBirthdayPerson && daysUntil > 0 && daysUntil <= 30 && (type == "Morning" || type == "Arrival")) {
def cdMsg = settings.bdayCountdownMsg ?: "By the way, you only have %days% days until your birthday!"
return cdMsg.replace("%days%", daysUntil.toString()).replace("%name%", applyAlias(bName))
}
}
// 4. Mother's Day / Father's Day check
def holiday = getTodayHoliday()
if (settings.enableParentsDay && holiday == "Mother's Day") {
def mUser = settings.mothersDayUser
def mUsers = [mUser].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (mUsers.isEmpty() || mUsers.contains(arrivingUser.toLowerCase())) {
return "Also, today is Mother's Day. Please remember to call your mother."
}
}
if (settings.enableParentsDay && holiday == "Father's Day") {
def fUser = settings.fathersDayUser
def fUsers = [fUser].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (fUsers.isEmpty() || fUsers.contains(arrivingUser.toLowerCase())) {
return "Also, today is Father's Day. Please remember to call your father."
}
}
return ""
}
def midnightReset() {
ensureStateMaps()
def newHasArrived = [:]
def newResetReasons = [:]
// 1. INTELLIGENT CARRY-OVER
state.hasArrivedToday.each { uName, arrived ->
if (arrived == true || arrived == "true") {
newHasArrived[uName] = true
newResetReasons[uName] = "Present"
}
}
// 2. APPLY THE NEW ROSTER
state.hasArrivedToday = newHasArrived
state.hasDepartedToday = [:]
state.resetReasons = newResetReasons
state.globalResetReason = "Awaiting First Entry"
// 3. DAILY TRACKER RESETS
state.anomalyAlertedToday = [:]
state.reminderCounts = [:]
// 4. QUEUE MAINTENANCE
state.ttsQueue = []
state.speakingUntil = 0
state.currentPriority = 99
state.originalVolumes = [:]
// --- EVENT CLEANUP FIX (Deletes events AND lock codes 24 hours after they occur) ---
def nowMs = new Date().time
def eventsToRemove = []
state.hostedEvents?.each { id, ev ->
if (nowMs > (ev.dateEpoch + 86400000)) eventsToRemove << id
}
eventsToRemove.each {
state.hostedEvents.remove(it)
// Tell Lock Manager to revoke the expired code
sendLocationEvent(name: "lockManagerTempSync", value: "delete", data: groovy.json.JsonOutput.toJson([id: it]), isStateChange: true)
}
// --- QUICK CODE CLEANUP (NEW) ---
def codesToRemove = []
state.quickLockCodes?.each { id, cd ->
// 4102444800000L is the "Permanent" marker (Year 2100). Skip if it's permanent.
if (cd.expires != 4102444800000L && nowMs > cd.expires) codesToRemove << id
}
codesToRemove.each { state.quickLockCodes.remove(it) }
// ----------------------------------------------------------------
def residentList = newHasArrived.keySet().join(', ')
addToHistory("SYSTEM: Midnight transition complete. Residents carried over: ${residentList ?: 'None'}")
}
def appButtonHandler(btn) {
ensureStateMaps()
if (btn != "btnRefresh") {
addToHistory("SYSTEM: Action triggered via UI Dashboard - ${btn}")
}
if (btn == "btnForceReset") {
state.hasArrivedToday = [:]
state.hasDepartedToday = [:]
state.resetReasons = [:]
state.globalResetReason = "Reset manually via Dashboard"
state.lastDepartureTime = [:]
state.ttsQueue = []
state.speakingUntil = 0
state.currentPriority = 99
addToHistory("SYSTEM: Manual reset of all Daily statuses triggered. TTS Queue Flushed.")
} else if (btn == "btnForceSync") {
pollCalendars()
pollBreakingNews()
if (settings.enableMealTime && settings.mealTimeNewsWeather) syncMealNews()
def numRoomsSet = settings.numRooms ? settings.numRooms as Integer : 0
for (int i = 1; i <= numRoomsSet; i++) {
if (settings["roomNewsEnable_${i}"]) syncRoomNews(i)
}
} else if (btn == "btnTestGoogleApi") {
testGoogleIntegration()
} else if (btn == "btnTestQuickExit") {
def gasData = getCheapestGas()
def msg = "Farewell. The security perimeter is active. Have a safe trip."
if (gasData && gasData.speech) msg += gasData.speech
executeRoutedTTS(msg, settings.quickExitRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, settings.quickExitVolume ?: settings.globalVolume, 1, true)
if (settings.enableTravelPush && settings.notificationDevice && gasData?.rawAddress) {
settings.notificationDevice.each { dev ->
try { dev.deviceNotification("Test Alert - Gas: ${gasData.rawName} (${gasData.rawAddress})") } catch(e) {}
}
}
} else if (btn == "btnTestGlobal") {
enqueueTTS(globalIndoorSpeaker, applyDynamicVars("This is a test of the global indoor speakers. The time is %time%."), globalVolume, 1)
} else if (btn == "btnTestOutdoor") {
enqueueTTS(outdoorSpeaker, applyDynamicVars("This is a test of the outdoor speaker. The date is %date%."), outdoorVolume, 1)
} else if (btn == "btnTestIndoorRouting") {
executeRoutedTTS(applyDynamicVars(settings.indoorDoorbellMsg ?: "This is %butler%. %interruption%, but there is a visitor at the front door."), settings.indoorDoorbellRoutingMode ?: "Follow-Me + Fallback (Global ONLY if no motion)", settings.globalVolume, settings.outdoorVolume, 1)
} else if (btn == "btnTestMealNews") testMealNews()
else if (btn == "btnTestBreakingNews") executeBreakingNews("I have successfully connected to the global news wire.", true)
else if (btn == "btnTestMealTime") mealTimeHandler([value: "test"])
else if (btn == "btnTestScreenTime") {
def msgs = []
for (int d = 1; d <= 5; d++) { if (settings["screenTimeMsg_${d}"]) msgs << settings["screenTimeMsg_${d}"] }
if (!msgs) msgs = getDefaultMessages("ScreenTime")
executeRoutedTTS(applyDynamicVars(msgs[new Random().nextInt(msgs.size())]), settings.screenTimeRoutingMode ?: "Global Indoor Speaker Only", settings.screenTimeVolume ?: globalVolume, settings.outdoorVolume, 1, false, settings.screenTimeSpeaker)
}
else if (btn == "btnTestCalendar") executeCalendarAlert([title: "a test appointment", timeStr: "1 Hour", isTest: true])
else if (btn.startsWith("btnTestRoomNews_")) testRoomNews(btn.split("_")[1].toInteger())
else if (btn.startsWith("btnTestRoomSpk_")) {
def rNum = btn.split("_")[1].toInteger()
enqueueTTS(settings["roomSpeaker_${rNum}"] ?: globalIndoorSpeaker, applyDynamicVars("Testing the speaker connection for ${settings["roomName_${rNum}"] ?: "Room ${rNum}"}."), settings["roomVolumeGN_${rNum}"] != null ? settings["roomVolumeGN_${rNum}"] : settings["roomVolume_${rNum}"], 1)
} else if (btn.startsWith("btnTestDeparture_")) testDepartureGreeting(btn.split("_")[1].toInteger())
else if (btn.startsWith("btnResetDepMsgs_")) resetDepartureMessages(btn.split("_")[1].toInteger())
else if (btn.startsWith("btnTestGN_")) testRoomGreeting(btn.split("_")[1].toInteger(), "Good Night")
else if (btn.startsWith("btnTestGM_")) testRoomGreeting(btn.split("_")[1].toInteger(), "Good Morning")
else if (btn == "btnTestPartyMode") testPartyModeGreeting()
else if (btn == "btnTestDND") testDndGreeting()
else if (btn == "btnTestAfterHours") testAfterHoursGreeting()
else if (btn == "btnTestDaytime") testDaytimeGreeting()
else if (btn == "btnTestArrival") testArrivalGreeting()
else if (btn == "btnTestIntruder") testIntruderGreeting()
else if (btn == "btnTestMidday") executeMiddayMaintenance(true)
else if (btn == "btnTestMorningReport") playButlerReport([type: "Morning", count: 3])
else if (btn == "btnTestArrivalReport") playButlerReport([type: "Arrival", count: 2])
else if (btn == "btnClearNotes") clearNotesEndpoint()
else if (btn.startsWith("btnTestHeadedHome_")) executeHeadedHome(btn.split("_")[1].toInteger(), true)
else if (btn == "btnTestHealth") executeHealthWindow(true)
else if (btn == "btnTestPackage") executePackageAlert(true)
else if (btn == "btnTestMarket") executeMarketReminder(true)
else if (btn == "btnTestCinema") executeCinemaScout(true)
else if (btn == "btnTestGrocery") executeGroceryScout(true)
else if (btn == "btnTestVehicleScout") executeVehicleScout(true)
else if (btn == "btnTestIntruder") testIntruderGreeting()
else if (btn == "btnTestWeather") testWeatherAlerts()
}
def testGoogleIntegration() {
def testDest = state.nextEventLocation ?: "4538 US-231, Wetumpka, AL 36092"
log.info "TEST: Pinging Google for travel to ${testDest}..."
def mins = getTravelInfo(testDest)
def gasData = getCheapestGas()
def msg = ""
if (mins != null) {
msg = "My connection to the traffic and navigation network is fully operational. It currently takes ${mins} minutes to get to your destination from your home."
if (gasData) { msg += gasData.speech }
// --- SEND THE TEST PUSH NOTIFICATION WITH LINK ---
if (settings.enableTravelPush && settings.notificationDevice) {
def pushMsg = "TEST ALERT - Travel Intel:\nDestination: ${testDest}\nDrive Time: ${mins} minutes"
if (gasData?.rawAddress) {
pushMsg += "\n\nCheapest Gas:\n${gasData.rawName}\n${gasData.rawAddress}\nTap to Navigate: ${gasData.navLink}"
}
settings.notificationDevice.each { dev ->
try { dev.deviceNotification(pushMsg) } catch(e) {}
}
}
} else {
msg = "I am currently unable to access the traffic network. Please have the administrator verify my credentials."
}
def targetSpeaker = globalIndoorSpeaker
if (!targetSpeaker && arrivalFoyerSpeaker) targetSpeaker = arrivalFoyerSpeaker
if (targetSpeaker) enqueueTTS(targetSpeaker, msg, globalVolume, 1)
addToHistory("TEST: Traffic and navigation logic check performed.")
}
def addToHistory(String msg) {
ensureStateMaps()
def timestamp = new Date().format("h:mm a", location.timeZone)
def cleanMsg = msg.replaceAll("\\<.*?\\>", "")
// FIX: Push system events and notifications to the visual dashboard history
if (!state.historyLog) state.historyLog = []
state.historyLog.add(0, "[${timestamp}] βοΈ ${cleanMsg} ")
// Keep it trimmed to the last 30 events
if (state.historyLog.size() > 30) state.historyLog = state.historyLog.take(30)
log.info "SYSTEM: [${timestamp}] ${cleanMsg}"
}
def logSpeech(String msg) {
// This explicitly adds ONLY spoken text to the app's visual dashboard history log
if (!state.historyLog) state.historyLog = []
def timestamp = new Date().format("h:mm a", location.timeZone)
def cleanMsg = msg.replaceAll("\\<.*?\\>", "").trim() // Strips any hidden SSML voice tags
state.historyLog.add(0, "[${timestamp}] π£οΈ \"${cleanMsg}\" ")
if (state.historyLog.size() > 30) state.historyLog = state.historyLog.take(30)
log.info "SPEECH TRANSCRIPT: ${cleanMsg}"
}
def checkInternetConnection() {
try {
// Ping a reliable endpoint to verify outbound TTS traffic will work
httpGet([uri: "https://www.google.com", timeout: 5]) { resp ->
if (resp.status == 200) {
if (state.internetActive == false) {
log.info "Voice Butler: Internet connection restored. TTS resumed."
}
state.internetActive = true
}
}
} catch (Exception e) {
if (state.internetActive != false) {
log.warn "Voice Butler: Internet connection lost. TTS temporarily suppressed."
}
state.internetActive = false
}
}
def screenTimeHandler(evt) {
ensureStateMaps()
def messages = []
// Fetch user-defined messages or fallback to defaults
for (int d = 1; d <= 5; d++) {
if (settings["screenTimeMsg_${d}"]) messages << settings["screenTimeMsg_${d}"]
}
if (!messages) messages = getDefaultMessages("ScreenTime")
def randomMsg = applyDynamicVars(messages[new Random().nextInt(messages.size())])
def rMode = settings.screenTimeRoutingMode ?: "Global Indoor Speaker Only"
def targetVol = settings.screenTimeVolume != null ? settings.screenTimeVolume : settings.globalVolume
executeRoutedTTS(randomMsg, rMode, targetVol, settings.outdoorVolume, 2, false, settings.screenTimeSpeaker)
addToHistory("SCREEN TIME: Timer expired. Queued: '${randomMsg}'")
}
// --- GOOGLE WEBHOOK HANDLERS ---
def handleGoogleEmail() {
// 1. Check if the master feature switch is turned on
if (!settings.enableEmailAlerts) {
if (settings.enableDebug) log.debug "GOOGLE EMAIL: Alert ignored (Feature is disabled in app settings)."
return [status: "disabled"]
}
// 2. Check if the house is in an allowed mode
def allowedModes = [settings.emailAlertModes].flatten().findAll { it != null }
if (allowedModes.size() > 0 && !allowedModes.contains(location.mode)) {
if (settings.enableDebug) log.debug "GOOGLE EMAIL: Alert suppressed due to mode restriction (Current mode: ${location.mode})."
return [status: "suppressed_by_mode"]
}
def body = request.JSON
if (body) {
def sender = body.sender?.replaceAll(/<.*?>/, "")?.trim() ?: "an unknown sender"
def subject = body.subject ?: "No Subject"
def targetVol = settings.emailVolume != null ? settings.emailVolume : settings.globalVolume
def rMode = settings.emailRoutingMode ?: "Global Indoor Speaker Only"
def msg = ""
// --- NEW: Handle Ooma Voicemails ---
if (body.isVoicemail) {
// Respect the dedicated Ooma Voicemail toggle!
if (settings.enableOomaVoicemail == false) {
if (settings.enableDebug) log.debug "OOMA VOICEMAIL: Suppressed (Ooma Voicemail is toggled OFF)."
return [status: "ooma_disabled"]
}
def caller = body.sender ?: "an unknown caller"
def time = body.time ?: "just now"
def transcript = body.msgContent ?: "The caller did not leave a transcription."
if (getPresentUsers().size() == 0) {
stashMessage("you received a voicemail from ${caller}")
return [status: "stashed"]
}
msg = "%interruption%, you have a new Ooma voicemail from ${caller}, received at ${time}. The message says: ${transcript}"
if (settings.enableDebug) log.debug "OOMA VOICEMAIL RECEIVER: Voicemail notification received from ${caller}."
executeRoutedTTS(applyDynamicVars(msg), rMode, settings.globalVolume, targetVol, 2)
addToHistory("VOICEMAIL ALERT: Missed call announced from ${caller} at ${time}.")
return [status: "success"]
}
// 3. Handle Delivery Notifications
if (body.isDelivery) {
// Respect the dedicated Package Tracking toggle!
if (settings.enableDeliveryTracking == false) {
if (settings.enableDebug) log.debug "GOOGLE DELIVERY: Suppressed (Package Tracking is toggled OFF)."
return [status: "delivery_disabled"]
}
if (getPresentUsers().size() == 0) {
stashMessage("a package was delivered to the house")
return [status: "stashed"]
}
msg = "%interruption%, I am seeing a delivery notification from ${sender}. The subject is: ${subject}. You have a package arriving today."
if (settings.enableDebug) log.debug "GOOGLE DELIVERY RECEIVER: Package notification received from ${sender}."
executeRoutedTTS(applyDynamicVars(msg), rMode, settings.globalVolume, targetVol, 2)
addToHistory("DELIVERY ALERT: Package notification announced from ${sender}.")
return [status: "success"]
}
// 4. Handle Standard Important Emails
if (subject) {
def snippet = body.snippet ?: ""
def prefix = settings.emailPrefix ?: "%interruption%, you have just received an important email from"
msg = "${prefix} ${sender}. The subject is: ${subject}. "
if (snippet.length() > 0) {
msg += "The email begins with: ${snippet}"
}
if (settings.enableDebug) log.debug "GOOGLE EMAIL RECEIVER: New email triggered from ${sender}."
executeRoutedTTS(applyDynamicVars(msg), rMode, settings.globalVolume, targetVol, 2)
addToHistory("EMAIL ALERT: Important email announced from ${sender}.")
}
}
return [status: "success"]
}
def handleGoogleCalendar() {
def body = request.JSON
if (body && body.events != null) {
// 1. Hash Check: Stop CPU spikes. Only reschedule if the calendar actually changed.
def newHash = body.events.toString().hashCode()
if (state.calendarHash == newHash) {
return [status: "unchanged"]
}
state.calendarHash = newHash
// 2. Clear old schedules
unschedule("executeCalendarAlert")
state.scheduledCalendarAlerts = []
def now = new Date().time
def nextTitle = "No Upcoming Events"
def nextEpoch = null
// 3. Loop through the array and schedule all future events
body.events.eachWithIndex { evt, idx ->
def eventEpoch = evt.epoch.toLong()
def title = evt.title
// Save the very next event for the Dashboard UI
if (idx == 0) {
nextTitle = title
nextEpoch = eventEpoch
}
if (eventEpoch > now) {
def intervals = [settings.calAlertIntervals].flatten().findAll { it != null }
intervals.each { interval ->
def offsetMs = 0
if (interval == "3 Hours") offsetMs = 3 * 3600000
if (interval == "2 Hours") offsetMs = 2 * 3600000
if (interval == "1 Hour") offsetMs = 1 * 3600000
if (interval == "30 Minutes") offsetMs = 30 * 60000
if (interval == "15 Minutes") offsetMs = 15 * 60000
def alertTime = eventEpoch - offsetMs
if (alertTime > now) {
runOnce(new Date(alertTime), "executeCalendarAlert", [data: [title: title, timeStr: interval], overwrite: false])
}
}
// Schedule Exact Start Time alert (0 Minutes)
runOnce(new Date(eventEpoch), "executeCalendarAlert", [data: [title: title, timeStr: "0 Minutes"], overwrite: false])
}
}
// 4. Update the live dashboard
state.nextEventName = nextTitle
state.nextEventEpoch = nextEpoch
state.nextEventTimeStr = nextEpoch ? new Date(nextEpoch).format("MMM d 'at' h:mm a", location.timeZone) : "--"
state.calendarSyncTime = new Date().format("h:mm a", location.timeZone)
if (settings.enableDebug) log.debug "GOOGLE CALENDAR: Multi-Event sync complete. Loaded ${body.events.size()} events into the schedule."
}
return [status: "success"]
}
def getBridge(String type = "general") {
def bridges = []
if (type == "weather") bridges = ["Turning to the weather.", "Here is your forecast.", "Checking the conditions outside."]
else if (type == "news") bridges = ["Moving on to the news.", "Here are the latest headlines.", "In the news today:"]
else if (type == "agenda") bridges = ["Let's take a look at your schedule.", "Here is your agenda for today.", "Checking your calendar."]
else bridges = ["Also,", "Additionally,", "One other thing,"]
return bridges[new Random().nextInt(bridges.size())] + " "
}
// --- MAIL RETRIEVAL HANDLER (Triggered by Switch OFF) ---
def mailClearedHandler(evt) {
ensureStateMaps()
state.reminderCounts["mail"] = 0
def msgs = getDefaultMessages("MailRetrieved")
def randomMsg = msgs[new Random().nextInt(msgs.size())]
log.info "MAIL Handler: Selected retrieval message: ${randomMsg}"
executeRoutedTTS(applyDynamicVars(randomMsg), settings.emailRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, settings.emailVolume ?: settings.globalVolume, 2)
addToHistory("MAIL: ${randomMsg}")
}
def getMaintenanceReport(mDevice, boolean isTest = false) {
if (!mDevice) return ""
try {
def maxOverdue = mDevice.currentValue("maxOverdueDays") as Integer ?: 0
def tasks = mDevice.currentValue("overdueTasks")?.toString() ?: ""
def cleanTasks = tasks.replace("[", "").replace("]", "").trim()
if (maxOverdue > 0 && cleanTasks != "" && cleanTasks.toLowerCase() != "none" && cleanTasks.toLowerCase() != "null") {
def taskList = cleanTasks.split(",").collect { it.trim() }.findAll { it != "" && it.toLowerCase() != "none" }
if (taskList.size() == 0) return "" // Failsafe
def taskString = ""
def plural = taskList.size() > 1
def isAre = plural ? "are" : "is"
def requireRequires = plural ? "require" : "requires"
if (taskList.size() == 1) taskString = taskList[0]
else if (taskList.size() == 2) taskString = "${taskList[0]} and ${taskList[1]}"
else {
def last = taskList.pop()
taskString = "${taskList.join(', ')}, and ${last}"
}
if (maxOverdue <= 3) {
return "Just a friendly reminder %name%, the ${taskString} ${requireRequires} your attention when you have a moment."
} else if (maxOverdue <= 7) {
return "Pardon the reminder, but the ${taskString} ${isAre} now past due. Please try to get to them soon."
} else if (maxOverdue <= 14) {
return "I must advise you to address the house maintenance, %name%. The ${taskString} ${isAre} significantly overdue."
} else {
return "Critical alert. The ${taskString} have been neglected for over two weeks. Immediate action is strongly recommended to prevent systemic wear and tear."
}
}
} catch (Exception e) { log.warn "Voice Butler: Failed to fetch maintenance report - ${e}" }
return ""
}
def scheduleRandomMiddayMaintenance() {
if (!settings.enableMiddayMaintenance) return
// Pick a random minute between 660 (11:00 AM) and 900 (3:00 PM) -> 240 minute range
int randomMinute = 660 + new Random().nextInt(241)
int h = (randomMinute / 60).toInteger()
int m = randomMinute % 60
def cal = Calendar.getInstance(location.timeZone)
cal.set(Calendar.HOUR_OF_DAY, h)
cal.set(Calendar.MINUTE, m)
cal.set(Calendar.SECOND, 0)
// Only schedule if the randomly selected time hasn't already passed today
if (cal.getTime().time > new Date().time) {
runOnce(cal.getTime(), "executeMiddayMaintenance", [overwrite: true])
if (settings.enableDebug) log.debug "SYSTEM: Scheduled Random Midday Maintenance Reminder for ${cal.getTime().format('h:mm a', location.timeZone)}"
}
}
def executeMiddayMaintenance(isTest = false) {
ensureStateMaps()
if (!isTest) {
// Gate 1: Mode Check
def allowedModes = [settings.middayMaintenanceModes].flatten().findAll { it != null }
if (allowedModes.size() > 0 && !allowedModes.contains(location.mode)) return
def presentFolks = getPresentUsers()
// Gate 2: Empty House Check
if (presentFolks.size() == 0) return
// Gate 3: Privacy/Guest Check
def guestList = [settings.guestUsers].flatten().findAll { it != null }.collect { it.toLowerCase() }
if (settings.guestCustomUsers) guestList += settings.guestCustomUsers.split(',').collect { it.trim().toLowerCase() }
def hasGuest = presentFolks.any { person ->
guestList.contains(person.toLowerCase()) || guestList.contains(applyAlias(person).toLowerCase())
}
if (hasGuest) {
addToHistory("MIDDAY MAINTENANCE: Suppressed. A recognized guest is currently in the house.")
return
}
}
def mDevice = settings.middayMaintenanceDevice
if (!mDevice) return
def mText = getMiddayMaintenanceReport(mDevice)
if (!mText && !isTest) return // Nothing is due, stay silent!
if (isTest && !mText) mText = "%interruption%, but this is a test of the midday reminder system. If you had overdue tasks, I would politely list them here."
def finalMsg = applyDynamicVars(mText)
def targetVol = settings.middayVolume != null ? settings.middayVolume : settings.globalVolume
executeRoutedTTS(finalMsg, settings.middayRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
addToHistory("MIDDAY MAINTENANCE: Random reminder fired. Queued: '${finalMsg}'")
}
def getMiddayMaintenanceReport(mDevice) {
if (!mDevice) return ""
try {
def maxOverdue = mDevice.currentValue("maxOverdueDays") as Integer ?: 0
def tasks = mDevice.currentValue("overdueTasks")?.toString() ?: ""
def cleanTasks = tasks.replace("[", "").replace("]", "").trim()
if (maxOverdue > 0 && cleanTasks != "" && cleanTasks.toLowerCase() != "none" && cleanTasks.toLowerCase() != "null") {
def taskList = cleanTasks.split(",").collect { it.trim() }.findAll { it != "" && it.toLowerCase() != "none" }
if (taskList.size() == 0) return "" // Failsafe
def taskString = ""
def plural = taskList.size() > 1
def isAre = plural ? "are" : "is"
if (taskList.size() == 1) taskString = taskList[0]
else if (taskList.size() == 2) taskString = "${taskList[0]} and ${taskList[1]}"
else {
def last = taskList.pop()
taskString = "${taskList.join(', ')}, and ${last}"
}
// Escalating Grammar
if (maxOverdue <= 7) {
return "%interruption%, but as a friendly midday reminder, the ${taskString} ${isAre} due for maintenance when you have a free moment."
} else if (maxOverdue <= 14) {
return "%interruption%. I wanted to remind you that the ${taskString} ${isAre} significantly overdue for maintenance."
} else {
return "%interruption%. Please be advised that the ${taskString} have been neglected for over two weeks. It is highly recommended to address this today."
}
}
} catch (Exception e) { log.warn "Voice Butler: Failed to fetch midday report - ${e}" }
return ""
}
def serveNotesPage() {
try {
ensureStateMaps()
def trackedNames = getTrackedUsers()
StringBuilder userOptions = new StringBuilder()
trackedNames.each { u -> userOptions.append("${applyAlias(u)} ") }
// --- NEW: DYNAMICALLY BUILD LOCK OPTIONS ---
StringBuilder lockOptionsHtml = new StringBuilder("All Locks ")
def availableLocks = []
if (settings.frontDoorLock) availableLocks << settings.frontDoorLock.displayName
if (settings.estateLocks) availableLocks.addAll(settings.estateLocks.collect { it.displayName })
availableLocks.unique().sort().each { lName ->
lockOptionsHtml.append("${lName} ")
}
// ------------------------------------------
def apiUrl = getFullApiServerUrl()
def estateDisplayName = settings.estateName ?: "The Family"
if (state.butlerNotes && state.butlerNotes.size() > 0 && state.butlerNotes[0] instanceof String) {
state.butlerNotes = []
}
// --- 0.1 DYNAMICALLY BUILD CALENDAR WIDGET ---
StringBuilder calendarHtml = new StringBuilder()
if (state.nextEventName && state.nextEventTimeStr && state.nextEventName != "No Upcoming Events") {
def navButton = ""
if (state.nextEventLocation) {
def encodedLoc = java.net.URLEncoder.encode(state.nextEventLocation, "UTF-8")
def mapUrl = "http://maps.google.com/maps?q=${encodedLoc}"
navButton = "πΊοΈ Navigate to Event "
}
calendarHtml.append("""
π
Next Scheduled Event
${state.nextEventTimeStr}
${state.nextEventName}
${navButton}
""")
}
// --- 0.1c DYNAMICALLY BUILD MEAL PLAN WIDGET ---
StringBuilder mealPlanHtml = new StringBuilder("π½οΈ Weekly Meal Plan ")
mealPlanHtml.append("""
""")
// --- 0.1a DYNAMICALLY BUILD ADD CALENDAR WIDGET ---
StringBuilder calendarAddHtml = new StringBuilder("""
β Add Calendar Event
Push Event to Google Calendar
""")
// --- 0.1d DYNAMICALLY BUILD ON-DEMAND AI SCOUTS WIDGET ---
StringBuilder scoutButtonsHtml = new StringBuilder()
scoutButtonsHtml.append("""
π§ On-Demand AI Scouts
""")
// --- 0.1b DYNAMICALLY BUILD CHAT WIDGET ---
StringBuilder chatHtml = new StringBuilder("""
π¬ Ask the Butler (AI Chat)
βΉοΈ What can I ask the Butler?
Things I CAN do:
Local Concierge: "Find a highly-rated plumber near me" or "Recommend a good Italian restaurant nearby."
Household Status: "Who is currently home?" or "When is my next calendar appointment?"
Meal & Recipe Prep: "I have chicken and rice. Give me a recipe for dinner."
Troubleshooting: "How do I reset a tripped circuit breaker?"
Things I CANNOT do:
I cannot physically control smart devices (e.g., turn off lights, adjust thermostats).
I cannot unlock or lock the doors.
I cannot view live security camera feeds.
I will not answer questions regarding financial, medical, or highly sensitive personal data.
I am at your service. What do you require?
Ask Butler
""")
// --- 0.2 DYNAMICALLY BUILD MAIL STATUS ---
StringBuilder mailHtml = new StringBuilder()
if (settings.enableMailCheck && settings.mailSwitch) {
def isMailWaiting = settings.mailSwitch.currentValue("switch") == "on"
def mailTimeStr = (state.lastMailDeliveryTime && state.lastMailDeliveryTime > 0) ? new Date(state.lastMailDeliveryTime).format("h:mm a", location.timeZone) : "earlier today"
def mailText = isMailWaiting ? "The mail was delivered at ${mailTimeStr} and is waiting in the mailbox." : "The mailbox is currently empty."
mailHtml.append("""
π¬ Mail Status
${mailText}
""")
}
// --- 0. DYNAMICALLY BUILD STAFF SCHEDULE ---
StringBuilder staffHtml = new StringBuilder()
if (state.cleaningStaffData) {
def lastClean = state.cleaningStaffData.lastFullClean ?: new Date().time
def maxIdle = state.cleaningStaffData.maxIdle ?: 3
def daysIdle = Math.floor((new Date().time - lastClean) / 86400000).toInteger()
def daysRemaining = maxIdle - daysIdle
if (daysRemaining < 0) daysRemaining = 0
def statusColor = daysRemaining == 0 ? "#e67e22" : "#27ae60"
def statusText = daysRemaining == 0 ? "Deploying Imminently" : "Standby (${daysRemaining} Days)"
def dayPlural = daysRemaining == 1 ? "day" : "days"
staffHtml.append("π§Ή Cleaning Staff Schedule ")
staffHtml.append("
")
staffHtml.append("
Status ${statusText}
")
staffHtml.append("
Last Full Sweep: ${daysIdle} days ago.Mandate: The Butler will automatically deploy the staff for a deep clean in ${daysRemaining} ${dayPlural}.
")
staffHtml.append("""
""")
staffHtml.append("
")
}
// --- 0.1 DYNAMICALLY BUILD APPLIANCE HEALTH ---
StringBuilder applianceHtml = new StringBuilder()
if (state.applianceHealthData && state.applianceHealthData.size() > 0) {
applianceHtml.append("π Appliance Health & Status ")
applianceHtml.append("
")
applianceHtml.append("Appliance State Health ")
state.applianceHealthData.each { app ->
def hColor = app.health == "GOOD" ? "#2ecc71" : (app.health == "LEARNING" ? "#f1c40f" : (app.health.contains("CREEP") || app.health.contains("COILS") ? "#e67e22" : "#e74c3c"))
def sColor = app.state == "RUNNING" || app.state == "COOLING" ? "#3498db" : "#95a5a6"
applianceHtml.append("${app.name} ${app.state} ${app.health} ")
}
applianceHtml.append("
")
}
// --- 0.1e DYNAMICALLY BUILD TRASH WIDGET ---
StringBuilder trashHtml = new StringBuilder()
if (state.trashData) {
trashHtml.append("""
ποΈ Waste Management
Bin Status ${state.trashData.status}
Capacity & Hygiene ${state.trashData.fill}% Full | ${state.trashData.hygiene}
Next Collection ${state.trashData.nextPickup}
""")
}
// --- 0.5 DYNAMICALLY BUILD RSVP EVENT CARDS ---
StringBuilder rsvpHtml = new StringBuilder("π₯ Party & Event Invitations ")
rsvpHtml.append("""
""")
if (state.hostedEvents && state.hostedEvents.size() > 0) {
state.hostedEvents.each { eId, ev ->
def eventUrl = "${apiUrl}/rsvp?id=${eId}&access_token=${state.accessToken}"
def displayDate = new Date(ev.dateEpoch).format("EEE, MMM d 'at' h:mm a", location.timeZone)
def preWrittenMsg = java.net.URLEncoder.encode("You're invited to ${ev.title}! Please view the details and RSVP here: ${eventUrl}", "UTF-8")
rsvpHtml.append("
")
rsvpHtml.append("
")
rsvpHtml.append("
${ev.title} ${displayDate}
")
rsvpHtml.append("
")
rsvpHtml.append("
")
def locDisplay = ev.location ?: estateDisplayName
rsvpHtml.append("
π ${locDisplay}
")
if (ev.slot && ev.pin) {
def lockTxt = ev.lockName ?: "All Locks"
rsvpHtml.append("
")
rsvpHtml.append("π Smart Lock Access (${lockTxt}) ")
rsvpHtml.append("Slot: ${ev.slot} | PIN: ${ev.pin} ")
rsvpHtml.append("
")
}
rsvpHtml.append("
")
rsvpHtml.append("
Shareable Link: ${eventUrl} ")
rsvpHtml.append("
")
rsvpHtml.append("
")
rsvpHtml.append("
Host Quick Share: ")
rsvpHtml.append("
")
if (ev.rsvps && ev.rsvps.size() > 0) {
rsvpHtml.append("
Guest Responses: ")
ev.rsvps.each { rsvp ->
def rColor = rsvp.status == "Accepted" ? "#27ae60" : "#c0392b"
def partyText = rsvp.guestCount ? "Party of ${rsvp.guestCount}" : "Party of 1"
rsvpHtml.append("
")
rsvpHtml.append("${rsvp.name} (${rsvp.status} — ${partyText}) ")
if (rsvp.restrictions) rsvpHtml.append("β Restrictions: ${rsvp.restrictions} ")
if (rsvp.message) rsvpHtml.append("\"${rsvp.message}\" ")
rsvpHtml.append("
")
}
rsvpHtml.append("
")
} else {
rsvpHtml.append("
No RSVPs received yet. ")
}
rsvpHtml.append("
")
}
}
rsvpHtml.append("
")
// --- 1. DYNAMICALLY BUILD PRESENCE CARDS ---
StringBuilder presenceHtml = new StringBuilder("π₯ Live House Roster ")
if (trackedNames.size() > 0) {
trackedNames.each { u ->
def dispName = applyAlias(u)
def arrived = state.hasArrivedToday != null && (state.hasArrivedToday[u] == true || state.hasArrivedToday[u] == "true")
def departed = state.hasDepartedToday != null && (state.hasDepartedToday[u] == true || state.hasDepartedToday[u] == "true")
def isHome = arrived && !departed
def statusColor = isHome ? "#27ae60" : "#c0392b"
def statusIcon = isHome ? "π Home" : "π Away"
presenceHtml.append("
")
presenceHtml.append("
${dispName} ${statusIcon}
")
if (isHome) {
presenceHtml.append("""
""")
}
presenceHtml.append("
")
}
} else {
presenceHtml.append("
No presence users configured.
")
}
presenceHtml.append("
")
// --- 2. DYNAMICALLY BUILD ROOM OPTIONS ---
StringBuilder roomOptionsHtml = new StringBuilder()
def numR = settings.numRooms ? settings.numRooms as Integer : 0
for (int i = 1; i <= numR; i++) {
def rName = settings["roomName_${i}"] ?: "Room ${i}"
roomOptionsHtml.append("${rName} ")
}
// --- 3. DYNAMICALLY BUILD DIRECTORY ---
def numC = settings.numContacts ? settings.numContacts as Integer : 0
StringBuilder directoryHtml = new StringBuilder()
if (settings.enableDirectory && numC > 0) {
directoryHtml.append("π Estate Directory ")
for (int i = 1; i <= numC; i++) {
def cName = settings["contactName_${i}"]
def cInfo = settings["contactInfo_${i}"]
if (cName && cInfo) {
def smsMsg = java.net.URLEncoder.encode("Contact Info for ${cName}:\n${cInfo}", "UTF-8")
def emailSub = java.net.URLEncoder.encode("${cName} Contact Info", "UTF-8")
def emailMsg = java.net.URLEncoder.encode("Here is the contact information for ${cName}:\n${cInfo}", "UTF-8")
// Tel link extraction
def telNum = cInfo.replaceAll("[^0-9+]", "")
if (!telNum) telNum = cInfo
directoryHtml.append("""
""")
}
}
directoryHtml.append("
")
}
// --- 3.5 DYNAMICALLY BUILD QUICK LOCK CODES ---
StringBuilder lockHtml = new StringBuilder("π Quick Lock Code Manager ")
lockHtml.append("""
Generate Quick Access Code
""")
if (state.quickLockCodes && state.quickLockCodes.size() > 0) {
state.quickLockCodes.each { cId, cd ->
def expStr = cd.duration == "Permanent" ? "Permanent Access" : "Expires: " + new Date(cd.expires).format("EEE, MMM d 'at' h:mm a", location.timeZone)
def lockTxt = cd.lockName ?: "All Locks"
def preWrittenCodeMsg = java.net.URLEncoder.encode("Your access code for the estate is ${cd.pin}. Please enter it on the keypad to unlock the door.", "UTF-8")
lockHtml.append("
")
lockHtml.append("
")
lockHtml.append("
${cd.name} ${expStr}
")
lockHtml.append("
")
lockHtml.append("
")
lockHtml.append("
")
lockHtml.append("${lockTxt} ")
lockHtml.append("Slot: ${cd.slot} | PIN: ${cd.pin} ")
lockHtml.append("
")
lockHtml.append("
")
lockHtml.append("
")
}
}
lockHtml.append("
")
// --- 4. DYNAMICALLY BUILD WI-FI CARD ---
StringBuilder wifiHtml = new StringBuilder()
if (settings.enableWifiPortal && settings.wifiSSID) {
def wifiPwd = settings.wifiPassword ?: "No password required"
def rawMsg = "Here are the guest Wi-Fi details:\nNetwork: ${settings.wifiSSID}\nPassword: ${wifiPwd}"
def smsMsg = java.net.URLEncoder.encode(rawMsg, "UTF-8")
def emailSub = java.net.URLEncoder.encode("Guest Wi-Fi Details", "UTF-8")
def emailMsg = java.net.URLEncoder.encode(rawMsg, "UTF-8")
wifiHtml.append("""
πΆ Guest Wi-Fi Sharing
${settings.wifiSSID} Tap to announce & push password
""")
}
// --- 5. DYNAMICALLY BUILD QUICK REPLIES ---
StringBuilder quickReplyHtml = new StringBuilder()
def numQR = settings.numQuickReplies ? settings.numQuickReplies as Integer : 0
if (numQR > 0) {
quickReplyHtml.append("β‘ Quick Replies (Outdoor) ")
for (int i = 1; i <= numQR; i++) {
def qrName = settings["quickReplyName_${i}"] ?: "Reply ${i}"
quickReplyHtml.append("""
""")
}
quickReplyHtml.append("
")
}
// --- 5.5 DYNAMICALLY BUILD ROOM OCCUPANCY ---
StringBuilder roomsHtml = new StringBuilder()
if (settings.portalRoomSwitches) {
roomsHtml.append("π Room Occupancy ")
settings.portalRoomSwitches.sort { it.displayName }.each { dev ->
def isOcc = dev.currentValue("switch") == "on"
def sColor = isOcc ? "#27ae60" : "#c0392b"
def sText = isOcc ? "Occupied" : "Empty"
def toggleCmd = isOcc ? "off" : "on"
def btnText = isOcc ? "Mark Empty" : "Mark Occupied"
def btnColor = isOcc ? "#c0392b" : "#27ae60"
// Clean the device name of redundant tags
def cleanName = dev.displayName.replaceAll(/(?i)\(Virtual\)/, "").replaceAll(/(?i)Occupied/, "").replaceAll(/(?i)Override/, "").replaceAll(/(?i)Switch/, "").trim()
cleanName = cleanName.replaceAll(/\s+/, " ")
if (!cleanName) cleanName = dev.displayName // Fallback
roomsHtml.append("""
""")
}
roomsHtml.append("
")
}
// --- 0.6 DYNAMICALLY BUILD TV / SCREEN TIME WIDGET ---
StringBuilder tvHtml = new StringBuilder()
if (state.tvManagerData && state.tvManagerData.size() > 0) {
tvHtml.append("πΊ Television & Screen Time ")
state.tvManagerData.each { idx, tv ->
def sColor = tv.isOn ? "#3498db" : "#7f8c8d"
def sText = tv.isOn ? "ON - ${tv.app}" : "OFF"
def timeText = ""
if (tv.maxTv > 0) {
def remain = (tv.maxTv + tv.ext) - tv.watchMins
if (remain < 0) remain = 0
def hrs = (remain / 60).toInteger()
def mins = remain % 60
timeText += "TV Time Left:
${hrs}h ${mins}m "
}
if (tv.isAppLimited && tv.appLimit > 0) {
def remainApp = (tv.appLimit + tv.ext) - tv.currentAppMins
if (remainApp < 0) remainApp = 0
def aHrs = (remainApp / 60).toInteger()
def aMins = remainApp % 60
timeText += "App Time Left:
${aHrs}h ${aMins}m "
}
if (!timeText) timeText = "Watched Today: ${(tv.watchMins / 60).toInteger()}h ${tv.watchMins % 60}m"
def actionBtns = ""
if (tv.isOn) {
actionBtns += """
"""
}
if (tv.maxTv > 0 || tv.appLimit > 0) {
actionBtns += """
"""
}
if (actionBtns == "") {
actionBtns = "
Currently Powered Down
"
}
tvHtml.append("""
${tv.name}
${sText}
${timeText}
${actionBtns}
""")
}
tvHtml.append("
")
} else {
tvHtml.append("""
πΊ Television & Screen Time
Waiting for TV Manager to sync data... (Turn a TV on, or click 'Done' in the Advanced Television Application to force an instant sync).
""")
}
// --- 6. DASHBOARD SHORTCUT ---
StringBuilder dashboardBtnHtml = new StringBuilder()
if ((settings.taskAppId && settings.taskAppToken) || (settings.deviceHealthAppId && settings.deviceHealthToken)) {
dashboardBtnHtml.append("")
// Maintenance Dashboard
if (settings.taskAppId && settings.taskAppToken) {
def dueCount = 0
if (settings.middayMaintenanceDevice) {
def rawVal = settings.middayMaintenanceDevice.currentValue("overdueTasks")
def tasks = rawVal ? rawVal.toString() : ""
def cleanTasks = tasks.replace("[", "").replace("]", "").trim()
if (cleanTasks != "" && cleanTasks.toLowerCase() != "null" && cleanTasks.toLowerCase() != "none") {
dueCount = cleanTasks.split(",").findAll { it.trim() != "" && it.toLowerCase() != "none" }.size()
}
}
def badgeHtml = dueCount > 0 ? "
${dueCount} Due " : ""
def ep = settings.taskAppEndpoint ?: "dashboard"
dashboardBtnHtml.append("""
β
Open Maintenance Dashboard${badgeHtml}
""")
}
// Device Health Dashboard
if (settings.deviceHealthAppId && settings.deviceHealthToken) {
def dhCount = 0
if (settings.deviceHealthDevice) {
def rawDh = settings.deviceHealthDevice.currentValue("issueCount")
if (rawDh != null && rawDh.toString().isInteger()) dhCount = rawDh.toInteger()
}
def dhBadgeHtml = dhCount > 0 ? "
${dhCount} Critical " : ""
def ep = settings.deviceHealthEndpoint ?: "dashboard"
dashboardBtnHtml.append("""
π©Ί Open Device Health Dashboard${dhBadgeHtml}
""")
}
dashboardBtnHtml.append("
")
}
StringBuilder paRoutingHtml = new StringBuilder()
getRoutingOptions().each { r -> paRoutingHtml.append("${r} ") }
// --- MAIN HTML BUILDER ---
StringBuilder html = new StringBuilder()
html.append("""${estateDisplayName} Voice Butler
""")
// Append Header / Custom SVG Icon
html.append("""
${estateDisplayName} Voice Butler
Manage messaging, context, and live broadcasts.
""")
// --- 1. CONCIERGE & INTELLIGENCE ---
html.append("
ποΈ Concierge & Intelligence ")
html.append(calendarHtml.toString())
html.append(mailHtml.toString())
html.append(chatHtml.toString())
html.append(mealPlanHtml.toString())
html.append(calendarAddHtml.toString())
html.append(scoutButtonsHtml.toString())
html.append("
")
// --- 2. ESTATE MANAGEMENT ---
html.append("
π° Estate Management ")
html.append(dashboardBtnHtml.toString())
html.append(presenceHtml.toString()) // INJECTED ROSTER HERE
html.append(roomsHtml.toString())
html.append(tvHtml.toString())
html.append(trashHtml.toString())
html.append(staffHtml.toString())
html.append(applianceHtml.toString())
html.append(directoryHtml.toString())
html.append("
")
// --- 3. GUEST SERVICES ---
html.append("
π€ Guest Services ")
html.append(rsvpHtml.toString())
html.append(lockHtml.toString())
html.append(wifiHtml.toString())
html.append("""
β±οΈ Guest Departure Timer
""")
html.append("
")
// --- 4. COMMUNICATIONS ---
html.append("
π’ Communications ")
html.append(quickReplyHtml.toString())
html.append("""
ποΈ Live Intercom (PA)
π Schedule a Note
From: Select... ${userOptions.toString()}
To: Anyone ${userOptions.toString()}
Delivery Trigger:
When they arrive home
During their morning briefing
At a specific time
Time:
Queue Message
""")
if (state.butlerNotes && state.butlerNotes.size() > 0) {
html.append("
Pending Deliveries ")
state.butlerNotes.each { note ->
def timeTxt = note.when == "Time" ? " (At specific time)" : " (On ${note.when})"
html.append("
From ${note.sender} to ${note.target} ${timeTxt}\"${note.text}\"
")
}
html.append("""
Clear Delivery Queue """)
}
html.append("
")
// --- 5. SYSTEM MEMORY ---
html.append("""
βοΈ System Memory
Update Context Agenda
${roomOptionsHtml.toString()}
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
Update Memory
""")
html.append("
")
return render(contentType: "text/html", data: html.toString(), status: 200)
} catch (Exception e) {
log.error "Notes Portal Crash: ${e}"
return render(contentType: "text/html", data: "Portal Error: ${e}
Please check your Hubitat logs.
", status: 500)
}
}
def getRedirectHtml() {
// FIX: Using ../notes tells the browser to go up one directory level from /wifi/announce back to the main /notes page
return """Executing command... """
}
def addNoteEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def newNote = [id: java.util.UUID.randomUUID().toString(), addedAt: new Date().time]
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
newNote.sender = params.senderName ?: "Someone"
newNote.target = params.targetUser ?: "Anyone"
newNote.when = params.deliveryWhen ?: "Arrival"
newNote.text = params.noteText ?: ""
if (newNote.text.trim().length() > 0) {
if (state.butlerNotes && state.butlerNotes.size() > 0 && state.butlerNotes[0] instanceof String) {
state.butlerNotes = []
}
if (newNote.when == "Time" && params.deliveryTime) {
def timeParts = params.deliveryTime.split(":")
def cal = Calendar.getInstance(location.timeZone)
cal.set(Calendar.HOUR_OF_DAY, timeParts[0].toInteger())
cal.set(Calendar.MINUTE, timeParts[1].toInteger())
cal.set(Calendar.SECOND, 0)
if (cal.time.time < new Date().time) cal.add(Calendar.DAY_OF_YEAR, 1) // Tomorrow
newNote.timeEpoch = cal.time.time
runOnce(new Date(newNote.timeEpoch), "executeTimeNote", [data: [id: newNote.id], overwrite: false])
addToHistory("NOTES: Scheduled note from ${newNote.sender} to ${newNote.target} at ${params.deliveryTime}.")
} else {
addToHistory("NOTES: Added note from ${newNote.sender} to ${newNote.target} on ${newNote.when}.")
}
state.butlerNotes.add(newNote)
}
} catch (Exception e) {
log.warn "Failed to parse or add note: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def clearNotesEndpoint() {
try {
state.butlerNotes = []
addToHistory("NOTES: All notes cleared via web portal.")
} catch(Exception e) {}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def formatNotesForReading() {
return "" // Handled dynamically in the routines now
}
def executeTimeNote(data) {
ensureStateMaps()
def note = state.butlerNotes.find { it.id == data.id }
if (!note) return
def isTargetHome = false
def presentFolks = getPresentUsers()
if (note.target == "Anyone" && presentFolks.size() > 0) isTargetHome = true
else if (presentFolks.any { it.equalsIgnoreCase(note.target) }) isTargetHome = true
if (isTargetHome) {
def targetName = note.target == "Anyone" ? "the household" : applyAlias(note.target)
def senderTxt = note.sender != "Someone" ? "from ${note.sender} " : ""
def msg = "%interruption%. I have a scheduled note ${senderTxt}for ${targetName}. It says: ${note.text}"
executeRoutedTTS(applyDynamicVars(msg), "Follow-Me + Fallback (Global ONLY if no motion)", settings.globalVolume, settings.outdoorVolume, 2)
state.butlerNotes.remove(note)
addToHistory("NOTES: Time-based note from ${note.sender} delivered to ${note.target}.")
} else {
// FIX: Keep the note and flag it as Pending so it isn't lost!
note.when = "Pending"
addToHistory("NOTES: Scheduled note missed (${note.target} not home). Converted to a pending greeting note.")
}
}
def getPerimeterReport(String contextKey = "global", boolean isTest = false) {
def openDoors = []
def unlockedLocks = []
// 1. Scan for open contact sensors
settings.each { k, v ->
if (v instanceof com.hubitat.app.DeviceWrapper) {
if (v.hasCapability("ContactSensor") && v.currentValue("contact") == "open") openDoors << v.displayName
} else if (v instanceof List) {
v.each { dev -> if (dev instanceof com.hubitat.app.DeviceWrapper && dev.hasCapability("ContactSensor") && dev.currentValue("contact") == "open") openDoors << dev.displayName }
}
}
// 2. Scan for unlocked locks
settings.each { k, v ->
if (v instanceof com.hubitat.app.DeviceWrapper) {
if (v.hasCapability("Lock") && v.currentValue("lock") == "unlocked") unlockedLocks << v.displayName
} else if (v instanceof List) {
v.each { dev -> if (dev instanceof com.hubitat.app.DeviceWrapper && dev.hasCapability("Lock") && dev.currentValue("lock") == "unlocked") unlockedLocks << dev.displayName }
}
}
def report = ""
if (openDoors.size() > 0 || unlockedLocks.size() > 0) {
report = "Security note: "
if (openDoors.size() > 0) {
def doorList = openDoors.unique().join(", and ")
report += "The ${doorList} ${openDoors.size() > 1 ? 'are' : 'is'} still open. "
}
if (unlockedLocks.size() > 0) {
def lockList = unlockedLocks.unique().join(", and ")
report += "Also, the ${lockList} ${unlockedLocks.size() > 1 ? 'are' : 'is'} currently unlocked. "
}
report += "I will monitor the perimeter while you rest."
} else {
return ""
}
return checkAndRegisterFact(report, 2, contextKey, isTest)
}
// --- NEWS RETRIEVAL HELPER ---
def getRoomNews(rNum, isTest) {
def headline = state."roomNewsHeadline_${rNum}"
if (isTest && !headline) {
return "This is a test of the morning news briefing. Once your RSS feed syncs, the latest headlines will appear here."
}
return headline ?: ""
}
def getWardrobeAdvice(wDevice) {
if (!wDevice) return ""
def temp = wDevice.currentValue("temperature") ?: 70
def cond = wDevice.currentValue("weather")?.toLowerCase() ?: ""
if (temp < 45) return "It is quite chilly, so a heavy coat is recommended."
if (temp < 65) return "A light jacket or sweater should be perfect for today."
if (cond.contains("rain") || cond.contains("storm")) return "Don't forget an umbrella, as precipitation is expected."
if (temp > 85) return "It's going to be a warm one, I suggest dressing light and staying hydrated."
return "The weather looks pleasant, standard attire should be fine."
}
def getBoredomBuster(wDevice) {
def suggestions = [
"Since it is the weekend, perhaps a visit to the local park would be refreshing.",
"It might be a good day to catch up on that book you've been meaning to read.",
"I suggest checking out a new recipe for lunch today.",
"A perfect day for a long walk or some light gardening."
]
return suggestions[new Random().nextInt(suggestions.size())]
}
def getKidsFunFact(rNum) {
def customFile = settings["roomJokesFile_${rNum}"]
if (customFile) {
// Use the safe helper we just fixed above
def fileContents = readLocalFile(customFile)
if (fileContents) {
def lines = fileContents.split('\n').findAll { it.trim().length() > 0 }
if (lines) return "Here is your morning joke: " + lines[new Random().nextInt(lines.size())]
}
}
def jokes = ["Why don't scientists trust atoms? Because they make up everything!", "What do you call a fake noodle? An impasta!"]
return "Here is a fun fact for your morning: " + jokes[new Random().nextInt(jokes.size())]
}
def getMealTimeQuestion() {
def questions = [
"If you could have any superpower for just one hour, what would it be?",
"What was the most interesting thing that happened to you today?",
"If we could move the house to any country in the world tomorrow, where should we go?",
"What is one thing you are grateful for today?",
"If you had to eat only one food for the rest of your life, what would you choose?"
]
if (settings.mealTimeQuestionsFile) {
try {
def fileContents = readLocalFile(settings.mealTimeQuestionsFile)
if (fileContents) {
def lines = fileContents.split('\n').findAll { it.trim().length() > 0 }
if (lines) questions = lines
}
} catch (e) {
log.warn "Meal Questions File Error: ${e}. Using defaults."
}
}
return "To start our dinner conversation: ${questions[new Random().nextInt(questions.size())]}"
}
def readLocalFile(fileName) {
try {
def params = [ uri: "http://127.0.0.1:8080/local/${fileName}", timeout: 5 ]
def text = ""
httpGet(params) { resp ->
if (resp.status == 200 && resp.data != null) {
// We use a safer way to extract text that Hubitat's sandbox allows
if (resp.data instanceof String) {
text = resp.data
} else {
try {
// This reads the stream/reader without needing to name the class
text = resp.data.text
} catch (e) {
text = resp.data.toString()
}
}
}
}
return text
} catch (e) {
if (settings.enableDebug) log.error "readLocalFile failed for ${fileName}: ${e}"
return null
}
}
def getTravelInfo(destination) {
if (!settings.googleMapsApiKey || !settings.homeAddress) return null
if (!checkAndIncrementApiLimit()) return null
def encodedHome = java.net.URLEncoder.encode(settings.homeAddress, "UTF-8")
def encodedDest = java.net.URLEncoder.encode(destination, "UTF-8")
def url = "https://maps.googleapis.com/maps/api/distancematrix/json?origins=${encodedHome}&destinations=${encodedDest}&departure_time=now&key=${settings.googleMapsApiKey}"
def travelMins = null // <-- Hold the value outside the closure!
try {
httpGet([uri: url, timeout: 10]) { resp ->
if (resp.status == 200 && resp.data?.rows && resp.data.rows[0]?.elements) {
def element = resp.data.rows[0].elements[0]
if (element?.status == "OK") {
def trafficSecs = element.duration_in_traffic?.value ?: element.duration?.value ?: 0
travelMins = (trafficSecs / 60).toInteger() // <-- Assign it here
}
}
}
} catch (e) {
if (settings.enableDebug) log.warn "Travel Info Error: ${e}"
}
return travelMins // <-- Safely return it to the Butler!
}
def getCheapestGas() {
if (!settings.googleMapsApiKey || !settings.homeAddress) return null
if (!checkAndIncrementApiLimit()) return null
def encodedHome = java.net.URLEncoder.encode(settings.homeAddress, "UTF-8")
def url = "https://maps.googleapis.com/maps/api/place/textsearch/json?query=cheapest+gas+near+${encodedHome}&key=${settings.googleMapsApiKey}"
def gasResult = null
try {
httpGet([uri: url, timeout: 10]) { resp ->
if (resp.status == 200 && resp.data?.results && resp.data.results.size() > 0) {
def topStation = resp.data.results[0]
def addressObj = topStation.formatted_address ?: ""
def streetOnly = addressObj.contains(",") ? addressObj.split(',')[0] : addressObj
// NEW: Build a direct, clickable Google Maps link using the Place ID
def placeId = topStation.place_id ?: ""
def encodedName = java.net.URLEncoder.encode(topStation.name, "UTF-8")
def mapLink = "https://www.google.com/maps/search/?api=1&query=${encodedName}&query_place_id=${placeId}"
gasResult = [
speech: " By the way, the best value for fuel on your way appears to be at ${topStation.name} on ${streetOnly}.",
rawName: topStation.name,
rawAddress: addressObj,
navLink: mapLink // <-- Storing the link here!
]
}
}
} catch (e) {
if (settings.enableDebug) log.warn "Gas Scout Error: ${e}"
}
return gasResult
}
def goodNightOnHandler(evt) {
ensureStateMaps()
def dev = evt.getDevice()
def rNum = 0
def numRoomsSet = settings.numRooms ? settings.numRooms as Integer : 0
for (int i = 1; i <= numRoomsSet; i++) {
if (settings["roomGoodNightSwitch_${i}"]?.id == dev.id) { rNum = i; break }
}
if (rNum > 0) {
def isArrival = false
def rName = settings["roomName_${rNum}"] ?: "Room ${rNum}"
def occNameSetting = settings["roomOccupantName_${rNum}"] ?: rName
def splitNames = occNameSetting.split(/(?i)\s+and\s+|\s*&\s*|\s*,\s*/).collect { it.trim() }
// FIX: Removed the broken global presence sensor check.
// This now properly checks the live roster and corrects it.
splitNames.each { n ->
def isHome = (state.hasArrivedToday[n] == true || state.hasArrivedToday[n] == "true") && !(state.hasDepartedToday[n] == true || state.hasDepartedToday[n] == "true")
if (!isHome) {
state.hasArrivedToday[n] = true
state.hasDepartedToday.remove(n)
state.resetReasons[n] = "Good Night Failsafe"
isArrival = true
}
}
def fullMsg = buildRoomGreeting(rNum, "Good Night", [isNewArrival: isArrival, isTest: false])
def targetSpeaker = settings["roomSpeaker_${rNum}"] ?: globalIndoorSpeaker
def vol = settings["roomVolumeGN_${rNum}"] != null ? settings["roomVolumeGN_${rNum}"] : settings.globalVolume
if (targetSpeaker && fullMsg) {
enqueueTTS(targetSpeaker, fullMsg, vol, 1)
addToHistory("ROOM ENGINE: Good Night Routine executed for ${rName}. Queued: '${fullMsg}'")
}
}
}
def roomMotionHandler(evt) {
if (evt.value != "active") return
ensureStateMaps()
def deviceId = evt.device.id
def now = new Date()
def hourStr = now.format("H", location.timeZone)
def hInt = hourStr.toInteger()
for (int i = 1; i <= (settings.numRooms as Integer ?: 1); i++) {
def mSensors = [settings["roomMotion_${i}"]].flatten().findAll { it != null }
if (mSensors.any { it.id == deviceId }) {
// --- ADVANCED HABIT HEATMAP TRACKING ---
if (!state.habitHeatMap["${i}"]) state.habitHeatMap["${i}"] = [:]
def currentCount = state.habitHeatMap["${i}"]["${hourStr}"] ?: 0
state.habitHeatMap["${i}"]["${hourStr}"] = currentCount + 1
// If a room gets heavy traffic in the morning (5 AM - 9 AM), flag it as the Primary Morning Room
if (hInt > 4 && hInt < 10 && currentCount > 20) {
state.primaryMorningRoom = i
}
// ---------------------------------------
// --- EXISTING WAKEUP LOGIC ---
def mode = settings["roomWakeupMode_${i}"] ?: "1. Immediate (When Good Night Switch turns OFF)"
// Mode 2: Waiting for motion AFTER the switch was turned off
if (mode == "2. Verified (Wait for switch OFF, then wait for Motion)") {
if (state.waitingForMotion["${i}"]) {
state.waitingForMotion["${i}"] = false
triggerGoodMorningSequence(i)
}
}
// Mode 3: Waiting for motion BEFORE the switch is turned off
else if (mode == "3. Motion Driven (Trigger when Motion activates while switch is ON)") {
def gnSwitch = settings["roomGoodNightSwitch_${i}"]
if (gnSwitch && gnSwitch.currentValue("switch") == "on") {
if (!state.roomAlreadyAwake["${i}"]) {
state.roomAlreadyAwake["${i}"] = true
triggerGoodMorningSequence(i)
}
}
}
// Break the loop since we found the matching room
break
}
}
}
def checkAndIncrementApiLimit() {
ensureStateMaps()
def currentMonth = new Date().format("MM")
if (state.apiResetMonth != currentMonth) {
state.apiCallCount = 0
state.apiResetMonth = currentMonth
}
def limit = settings.apiCallLimit != null ? settings.apiCallLimit.toInteger() : 500
if (state.apiCallCount >= limit) {
log.warn "Voice Butler: Monthly Google API Limit (${limit}) reached. Disabling Travel/Gas scout until next month."
return false
}
state.apiCallCount = (state.apiCallCount ?: 0) + 1
return true
}
def getTomorrowPreview() {
if (!state.nextEventName || !state.nextEventEpoch) return ""
def now = new Date()
def tomorrow = now + 1
def startOfTomorrow = tomorrow.clone()
startOfTomorrow.set(hourOfDay: 0, minute: 0, second: 0)
def endOfTomorrow = tomorrow.clone()
endOfTomorrow.set(hourOfDay: 23, minute: 59, second: 59)
if (state.nextEventEpoch >= startOfTomorrow.time && state.nextEventEpoch <= endOfTomorrow.time) {
def timeStr = new Date(state.nextEventEpoch).format("h:mm a", location.timeZone)
return "As a heads up for tomorrow, your first event is ${state.nextEventName} at ${timeStr}."
}
return ""
}
def butlerLrMotionHandler(evt) {
ensureStateMaps()
if (evt.value != "active") return
// --- MODE FILTER ---
if (butlerLrModes && !butlerLrModes.contains(location.mode)) return
def now = new Date()
def hour = now.format("H", location.timeZone).toInteger()
def todayDate = now.format("yyyy-MM-dd", location.timeZone)
// THE FIX: Check if we already ran the morning report today
if (state.lastMorningReportDate == todayDate) return
// Only trigger in the morning between 4 AM and 11 AM
if (hour >= 4 && hour < 11) {
def targetSpeaker = settings.butlerLrSpeaker ?: globalIndoorSpeaker
def vol = settings.butlerLrVolume ?: globalVolume
// 1. Deliver the Night Incident Report
if (state.pendingMorningReport && (state.nightMotionCount ?: 0) > 0) {
if (targetSpeaker) {
def msg = "Good morning. Please note, there were ${state.nightMotionCount} motion events at the front door last night. Please check the cameras."
enqueueTTS(targetSpeaker, applyDynamicVars(msg), vol, 4)
state.pendingMorningReport = false
// THE FIX: Removed the line that erased the count to 0!
state.lastMorningReportDate = todayDate // Lock it for the rest of the day
addToHistory("INCIDENT REPORT: Delivered Global Night Motion warning in Living Room.")
}
}
}
}
// --- QUICK EXIT (FAREWELL & GAS SCOUT) ---
def quickExitDoorHandler(evt) {
if (evt.value != "open") return
ensureStateMaps()
def qModes = [settings.quickExitModes].flatten().findAll { it != null }
if (qModes.size() == 0 || !qModes.contains(location.mode)) return
// 1. Verify the mode changed to Away within the last 5 minutes
def modeState = location.currentState("mode")
def modeChangeTime = modeState?.date?.time ?: 0
def now = new Date().time
if ((now - modeChangeTime) > 300000) {
if (settings.enableDebug) log.debug "QUICK EXIT: Ignored. House entered Away mode more than 5 minutes ago."
return
}
// 2. Prevent spamming if the door is opened multiple times
def lastQuickExit = state.lastQuickExitTime ?: 0
if ((now - lastQuickExit) < 120000) return
state.lastQuickExitTime = now
// 3. Fetch Gas & Build Farewell
def gasData = getCheapestGas()
def msg = "Farewell. The security perimeter is active. Have a safe trip."
if (gasData && gasData.speech) msg += gasData.speech
// 4. Play Audio (Fast-tracked so it plays instantly)
executeRoutedTTS(msg, settings.quickExitRoutingMode ?: "Outdoor Speaker Only", settings.globalVolume, settings.quickExitVolume ?: settings.outdoorVolume, 1, true)
// 5. Send Push Notification if enabled
if (settings.enableTravelPush && settings.notificationDevice && gasData?.rawAddress) {
settings.notificationDevice.each { dev ->
try { dev.deviceNotification("Travel Alert - Gas: ${gasData.rawName} (${gasData.rawAddress})") } catch(e) {}
}
}
addToHistory("QUICK EXIT: Farewell and gas scout triggered.")
}
// --- REFACTORED SWITCH HANDLER ---
def directorySwitchHandler(evt) {
ensureStateMaps()
def devId = evt.device.id
def matchIdx = 0
def numC = settings.numContacts ? settings.numContacts as Integer : 0
for (int i = 1; i <= numC; i++) {
if (settings["contactSwitch_${i}"]?.id == devId) {
matchIdx = i; break
}
}
if (matchIdx > 0) {
// Auto-turn off the switch so it acts like a momentary button!
try { settings["contactSwitch_${matchIdx}"].off() } catch(e) {}
executeDirectoryAnnouncement(matchIdx)
}
}
// --- WEB ENDPOINT FOR DIRECTORY ---
def announceDirectoryEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def cId = params.contactId ? params.contactId.toInteger() : 0
if (cId > 0) {
runIn(1, "asyncDirectoryExecution", [data: [id: cId], overwrite: false])
}
} catch (Exception e) {
log.warn "Failed to trigger directory from web portal: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def asyncDirectoryExecution(data) {
executeDirectoryAnnouncement(data.id as Integer)
}
// --- CENTRAL DIRECTORY ANNOUNCEMENT ENGINE ---
def executeDirectoryAnnouncement(int matchIdx) {
if (matchIdx <= 0) return
def cName = settings["contactName_${matchIdx}"] ?: "the requested service"
def cInfo = settings["contactInfo_${matchIdx}"] ?: "No information provided."
// 1. Check if anyone is home
def presentFolks = getPresentUsers()
def isAnyoneHome = presentFolks.size() > 0
// 2. Build the TTS Message
def msg = "%interruption%, the contact information for ${cName} is: ${cInfo}."
if (settings.notificationDevice && isAnyoneHome) {
msg += " I have also sent these details to your mobile device."
}
// 3. Play Audio
def targetVol = settings.directoryVolume != null ? settings.directoryVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(msg), settings.directoryRoutingMode ?: "Follow-Me + Fallback (Global ONLY if no motion)", settings.globalVolume, settings.outdoorVolume, 2)
// 4. Send Targeted Push Notification
if (settings.notificationDevice && isAnyoneHome) {
def pushMsg = "π ESTATE DIRECTORY\nService: ${cName}\nDetails: ${cInfo}"
settings.notificationDevice.each { dev ->
try { dev.deviceNotification(pushMsg) } catch(e) {}
}
}
addToHistory("DIRECTORY: Contact info for ${cName} announced and pushed.")
}
def cleanDeviceName(String name) {
if (!name) return ""
// Strips out technical jargon so "Front Door Lock" becomes "Front Door"
return name.replaceAll(/(?i)\s*(open|close|contact|motion|water|temperature|humidity)\s*sensor$/, "")
.replaceAll(/(?i)\s*sensor$/, "")
.replaceAll(/(?i)\s*lock$/, "")
.trim()
}
// --- WEB ENDPOINT FOR AGENDA UPDATES ---
def updateAgendaEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def rNum = params.roomSelect
def dayStr = params.daySelect
def newAgenda = params.agendaText ?: ""
if (rNum && dayStr) {
app.updateSetting("roomAgenda${dayStr}_${rNum}", [type: "text", value: newAgenda])
if (settings.enableDebug) log.info "PORTAL: Agenda updated for Room ${rNum} on ${dayStr} to: ${newAgenda}"
}
} catch (Exception e) {
log.warn "Failed to update agenda from web portal: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
// --- WI-FI ANNOUNCEMENT ENGINE ---
def announceWifiEndpoint() {
try {
ensureStateMaps()
// Force Hubitat to clear the request buffer so it doesn't hang
def safeBody = request?.body ? request.body.toString() : ""
runIn(1, "executeWifiAnnouncement", [overwrite: false])
} catch (Exception e) {
log.warn "Wi-Fi Portal Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def executeWifiAnnouncement() {
ensureStateMaps()
def ssid = settings.wifiSSID ?: "Unknown Network"
def rawPwd = settings.wifiPassword ?: "Unknown Password"
// Translate special characters into spoken words so the TTS XML parser doesn't crash
def spokenPwd = rawPwd.replace("&", " ampersand ")
.replace("<", " less than ")
.replace(">", " greater than ")
.replace("\"", " quote ")
.replace("'", " apostrophe ")
.replace("%", " percent ")
.replace("\$", " dollar sign ")
.replace("@", " at symbol ")
.replace("!", " exclamation point ")
.replace("?", " question mark ")
.replace("*", " asterisk ")
.replace("#", " hashtag ")
.replace("-", " dash ")
.replace("_", " underscore ")
def presentFolks = []
try { presentFolks = getPresentUsers() } catch (e) {}
def isAnyoneHome = presentFolks.size() > 0
def msg = "%interruption%, the guest Wi-Fi network is ${ssid}, and the password is: ${spokenPwd}."
if (settings.notificationDevice && isAnyoneHome) {
msg += " I have also sent the exact details to your mobile device for easy copying."
}
def targetVol = settings.wifiVolume != null ? settings.wifiVolume : settings.globalVolume
// Execute routing with the safe spoken password
executeRoutedTTS(applyDynamicVars(msg), settings.wifiRoutingMode ?: "Follow-Me + Fallback (Global ONLY if no motion)", settings.globalVolume, settings.outdoorVolume, 2)
// Push the raw, unedited password to the phones
if (settings.notificationDevice && isAnyoneHome) {
def pushMsg = "πΆ GUEST WI-FI\nNetwork: ${ssid}\nPassword: ${rawPwd}"
settings.notificationDevice.each { dev ->
try { dev.deviceNotification(pushMsg) } catch(e) {}
}
}
addToHistory("WIFI: Network details announced and pushed via portal.")
}
// --- LIVE INTERCOM (PA) ENGINE ---
def instantPAEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def msg = params.paText ?: ""
def route = params.paRoute ?: "Global Indoor Speaker Only"
if (msg.trim().length() > 0) {
runIn(1, "asyncPAExecution", [data: [msg: msg, route: route], overwrite: false])
}
} catch (Exception e) {
log.warn "Portal PA Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def asyncPAExecution(data) {
executeRoutedTTS(applyDynamicVars(data.msg), data.route, settings.globalVolume, settings.outdoorVolume, 1, false)
addToHistory("LIVE PA: Broadcast dispatched to ${data.route}. Queued: '${data.msg}'")
}
// --- MANUAL DEPARTURE OVERRIDE ---
def manualDepartEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def target = params.targetUser ?: ""
if (target && state.hasArrivedToday[target]) {
state.hasArrivedToday.remove(target)
state.hasDepartedToday[target] = true
state.lastDepartureTime[target] = new Date().time
state.resetReasons[target] = "Marked Away via Portal"
addToHistory("PORTAL: ${target} was manually marked as departed.")
}
} catch (Exception e) {
log.warn "Portal Depart Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
// --- QUICK REPLY ENGINE ---
def quickReplyEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def rId = params.replyId ? params.replyId.toInteger() : 0
if (rId > 0) {
runIn(1, "asyncQuickReplyExecution", [data: [id: rId], overwrite: false])
}
} catch (Exception e) {
log.warn "Failed to trigger Quick Reply from web portal: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def asyncQuickReplyExecution(data) {
def idx = data.id as Integer
def msg = settings["quickReplyText_${idx}"]
if (msg) {
// --- CANCEL AUTOMATED RESPONSES ---
// This stops the Butler from playing the "No Answer" message since you replied
unschedule("playDaytimeFollowUp")
def finalMsg = applyDynamicVars(msg)
def targetVol = settings.outdoorVolume ?: settings.globalVolume
if (outdoorSpeaker) {
// Priority 1 + FastTrack (true) to skip the chime and answer them instantly
enqueueTTS(outdoorSpeaker, finalMsg, targetVol, 1, true)
addToHistory("QUICK REPLY: Portal button '${settings["quickReplyName_${idx}"]}' triggered. Queued: '${finalMsg}'")
} else {
log.warn "Quick Reply failed: No outdoor speaker is assigned in the app."
}
}
}
def scheduleHealthWindow() {
if (!settings.enableHealthWindow || !settings.healthWindowStart || !settings.healthWindowEnd) return
try {
def startCal = Calendar.getInstance(location.timeZone)
startCal.setTime(toDateTime(settings.healthWindowStart))
def startMins = (startCal.get(Calendar.HOUR_OF_DAY) * 60) + startCal.get(Calendar.MINUTE)
def endCal = Calendar.getInstance(location.timeZone)
endCal.setTime(toDateTime(settings.healthWindowEnd))
def endMins = (endCal.get(Calendar.HOUR_OF_DAY) * 60) + endCal.get(Calendar.MINUTE)
if (endMins <= startMins) endMins += 1440
def diff = endMins - startMins
if (diff > 0) {
def randomMins = startMins + new Random().nextInt(diff)
def runCal = Calendar.getInstance(location.timeZone)
runCal.set(Calendar.HOUR_OF_DAY, (randomMins / 60).toInteger() % 24)
runCal.set(Calendar.MINUTE, randomMins % 60)
runCal.set(Calendar.SECOND, 0)
// Ensure it only schedules if the random time hasn't already passed today
if (runCal.getTime().time > new Date().time) {
runOnce(runCal.getTime(), "executeHealthWindow", [overwrite: true])
if (settings.enableDebug) log.debug "SYSTEM: Scheduled Health Window Reminder for ${runCal.getTime().format('h:mm a', location.timeZone)}"
}
}
} catch (e) { log.warn "Failed to schedule health window: ${e}" }
}
def executeHealthWindow(isTest = false) {
ensureStateMaps()
def msg = buildGlobalHealthReport(!isTest) // Pass false if it's a test to ignore presence checks
if (!msg) {
if (isTest) executeRoutedTTS("There are currently no overdue health appointments for anyone present in the home.", settings.healthRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, settings.healthVolume ?: settings.globalVolume, 2)
return
}
// Memory filter: 168 hours = 7 days. The Butler will only nag you once a week.
def filteredMsg = checkAndRegisterFact(msg, 168, "health_window_global", isTest)
if (filteredMsg) {
def targetVol = settings.healthVolume != null ? settings.healthVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(filteredMsg), settings.healthRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
addToHistory("HEALTH: Executed daytime window reminder.")
} else if (isTest) {
executeRoutedTTS("The health reminder is currently in its 7-day cooldown period. It will be suppressed until next week.", settings.healthRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, settings.healthVolume ?: settings.globalVolume, 2)
}
}
def buildGlobalHealthReport(boolean checkPresence = true) {
if (!settings.numHealthProfiles || settings.numHealthProfiles == 0) return ""
ensureStateMaps()
def now = new Date().time
def sixMonthsMs = 15768000000L
def oneYearMs = 31536000000L
def presentUsers = checkPresence ? getPresentUsers() : []
def userNeeds = [:]
// 1. Gather all overdue appointments for users currently home
for (int i = 1; i <= settings.numHealthProfiles; i++) {
def hUser = settings["healthUser_${i}"]
if (!hUser) continue
def isHome = !checkPresence || presentUsers.any { it.equalsIgnoreCase(hUser) || it.equalsIgnoreCase(applyAlias(hUser)) }
if (!isHome) continue
def dispName = applyAlias(hUser)
def needs = []
if (settings["lastDental_${i}"]) {
try { if (now - new java.text.SimpleDateFormat("yyyy-MM-dd").parse(settings["lastDental_${i}"]).time > sixMonthsMs) needs << "a dental appointment" } catch(e){}
}
if (settings["lastMedical_${i}"]) {
try { if (now - new java.text.SimpleDateFormat("yyyy-MM-dd").parse(settings["lastMedical_${i}"]).time > oneYearMs) needs << "an annual physical" } catch(e){}
}
if (settings["lastVision_${i}"]) {
try { if (now - new java.text.SimpleDateFormat("yyyy-MM-dd").parse(settings["lastVision_${i}"]).time > oneYearMs) needs << "an eye exam" } catch(e){}
}
if (needs.size() > 0) userNeeds[dispName] = needs
}
if (userNeeds.size() == 0) return ""
// 2. Group the data by "Need" instead of by "User" for cleaner grammar
def needsToUsers = [:]
userNeeds.each { name, needs ->
needs.each { n ->
if (!needsToUsers[n]) needsToUsers[n] = []
needsToUsers[n] << name
}
}
// 3. Assemble the sentences dynamically based on how many people need it
def finalSentences = []
needsToUsers.each { need, names ->
def namesStr = ""
if (names.size() == 1) namesStr = names[0]
else if (names.size() == 2) namesStr = "${names[0]} and ${names[1]}"
else namesStr = "${names[0..-2].join(', ')}, and ${names.last()}"
def pronoun = "you"
if (names.size() == 2) pronoun = "both of you"
else if (names.size() >= 3) pronoun = "all ${names.size()} of you"
finalSentences << "${namesStr}, ${pronoun} require ${need}."
}
return "%interruption%, as a wellness reminder: " + finalSentences.join(" ")
}
def getHealthReminders(String userName) {
if (!settings.numHealthProfiles || settings.numHealthProfiles == 0) return ""
def reminders = []
def now = new Date().time
// Time thresholds in milliseconds
def sixMonthsMs = 15768000000L
def oneYearMs = 31536000000L
for (int i = 1; i <= settings.numHealthProfiles; i++) {
def hUser = settings["healthUser_${i}"]
// Check if the profile matches the person currently in the room
if (hUser && (userName.toLowerCase().contains(hUser.toLowerCase()) || userName.toLowerCase().contains(applyAlias(hUser).toLowerCase()))) {
// Check Dental (Overdue after 6 months)
if (settings["lastDental_${i}"]) {
try {
def dDate = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(settings["lastDental_${i}"]).time
if (now - dDate > sixMonthsMs) reminders << "a dental cleaning"
} catch(e) { log.warn "Voice Butler: Invalid Dental Date for ${hUser}" }
}
// Check Medical (Overdue after 1 year)
if (settings["lastMedical_${i}"]) {
try {
def mDate = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(settings["lastMedical_${i}"]).time
if (now - mDate > oneYearMs) reminders << "an annual physical"
} catch(e) {}
}
// Check Vision (Overdue after 1 year)
if (settings["lastVision_${i}"]) {
try {
def vDate = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(settings["lastVision_${i}"]).time
if (now - vDate > oneYearMs) reminders << "an eye exam"
} catch(e) {}
}
}
}
if (reminders.size() > 0) {
def rStr = reminders.size() == 1 ? reminders[0] : (reminders.size() == 2 ? "${reminders[0]} and ${reminders[1]}" : "${reminders[0..-2].join(', ')}, and ${reminders.last()}")
return "As a wellness reminder, it has been a while since your last visit, so I recommend scheduling ${rStr} soon."
}
return ""
}
def realTimeTaskHandler(evt) {
def previousTasks = state.lastOverdueTasks ?: []
// Find tasks that are in the new list but weren't in the old list
def newTasks = currentTasks - previousTasks
// Update the memory for next time
state.lastOverdueTasks = currentTasks
if (newTasks.size() > 0) {
// Suppress if the house is empty
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0) return
// Respect the midday maintenance mode restrictions if set
def allowedModes = [settings.middayMaintenanceModes].flatten().findAll { it != null }
if (allowedModes.size() > 0 && !allowedModes.contains(location.mode)) return
def taskStr = ""
if (newTasks.size() == 1) taskStr = newTasks[0]
else if (newTasks.size() == 2) taskStr = "${newTasks[0]} and ${newTasks[1]}"
else {
def last = newTasks.pop()
taskStr = "${newTasks.join(', ')}, and ${last}"
}
def msgs = [
"%interruption%, but the ${taskStr} has just become due. Please complete it at your earliest convenience.",
"Pardon me, a new maintenance item requires your attention. The ${taskStr} is now due.",
"Excuse me, the ${taskStr} has just been added to the due list. Please address it when you have a moment."
]
def randomMsg = msgs[new Random().nextInt(msgs.size())]
def finalMsg = applyDynamicVars(randomMsg)
def targetVol = settings.realTimeTaskVolume != null ? settings.realTimeTaskVolume : settings.globalVolume
executeRoutedTTS(finalMsg, settings.realTimeTaskRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
addToHistory("TASK ALERT: Announced newly due task: ${taskStr}")
}
}
def executeCinemaScout(isTest = false) {
ensureStateMaps()
if (!settings.enableCinemaScout && !isTest) return
def tz = location.timeZone ?: TimeZone.getDefault()
def dow = Calendar.getInstance(tz).get(Calendar.DAY_OF_WEEK)
if (!isTest && dow != Calendar.FRIDAY) {
if (settings.enableDebug) log.debug "CINEMA SCOUT: Ignored (Today is not Friday)."
return
}
if (!isTest) {
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0) {
addToHistory("CINEMA SCOUT: Suppressed. House is currently empty.")
return
}
}
if (!settings.geminiApiKey) {
log.warn "Voice Butler: Cinema Scout failed. No Gemini API Key provided."
return
}
def todayStr = new Date().format("MMMM d, yyyy", location.timeZone)
def systemPrompt = "Today is ${todayStr}. You are a live data-retrieval assistant for a high-end smart home. I need the weekend entertainment update for THIS EXACT WEEK. Identify the top 2 movies currently premiering or trending in US movie theaters right now, the top 2 new movies or highly anticipated shows just added to Netflix US this week, 1 new or trending Family Movie, and 1 new or trending Kid Friendly Movie. Return ONLY a raw JSON object with absolutely no markdown formatting, no backticks, and no extra text. Format exactly like this: {\"theaters\": \"Movie 1 and Movie 2\", \"netflix\": \"Show A and Movie B\", \"family\": \"Movie C\", \"kids\": \"Movie D\"}"
def requestBody = [
contents: [ [ role: "user", parts: [ [text: systemPrompt] ] ] ],
tools: [ [ googleSearch: [:] ] ],
generationConfig: [ temperature: 0.2 ]
]
def params = [
uri: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${settings.geminiApiKey?.trim()}",
requestContentType: "application/json",
contentType: "application/json",
body: groovy.json.JsonOutput.toJson(requestBody),
timeout: 60
]
if (isTest) log.info "CINEMA SCOUT: Pinging Google Gemini API for the latest movies..."
def maxRetries = 2
def attempt = 0
def success = false
while (attempt < maxRetries && !success) {
attempt++
try {
httpPost(params) { resp ->
if (resp.status == 200 && resp.data) {
success = true
def geminiText = resp.data?.candidates[0]?.content?.parts[0]?.text ?: ""
geminiText = geminiText.replaceAll(/```json\n?/, "").replaceAll(/
```/, "").trim()
def movieData = new groovy.json.JsonSlurper().parseText(geminiText)
def theaterTitle = movieData.theaters ?: "several new releases"
def netflixTitle = movieData.netflix ?: "new streaming content"
def familyTitle = movieData.family ?: "a great family film"
def kidsTitle = movieData.kids ?: "a fun animated movie"
def greeting = getDynamicGreeting()
def msg = "%interruption%, ${greeting}, the weekend has finally arrived. For your entertainment, the top movies in theaters right now are ${theaterTitle}. For staying in, the top streaming updates are: ${netflixTitle}. If you are planning a family movie night, consider ${familyTitle}. And for the little ones, ${kidsTitle} is highly recommended."
if (settings.cinemaModes == null || settings.cinemaModes.contains(location.mode)) {
def targetVol = settings.cinemaVolume != null ? settings.cinemaVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(msg), settings.cinemaRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
} else {
if (isTest || settings.enableDebug) log.info "CINEMA SCOUT: Spoken announcement skipped because house is in mode: ${location.mode}"
}
def prefix = isTest ? "TEST CINEMA SCOUT: " : "CINEMA SCOUT: "
addToHistory("${prefix}Processed AI-curated movie and Netflix releases.")
if (settings.notificationDevice) {
def pushMsg = "π₯ WEEKEND PREMIERES\nTheaters: ${theaterTitle}\nStreaming: ${netflixTitle}\nFamily Night: ${familyTitle}\nKid Friendly: ${kidsTitle}"
settings.notificationDevice.each { dev ->
try { dev.deviceNotification(pushMsg) } catch(err) {}
}
}
if (isTest) log.info "TESTING CINEMA SCOUT SUCCESS: '${applyDynamicVars(msg)}'"
} else {
log.warn "Voice Butler: Gemini API returned unexpected status: ${resp.status}"
}
}
} catch (groovyx.net.http.HttpResponseException e) {
log.warn "Voice Butler: Cinema Scout API HTTP Error (Attempt ${attempt}/${maxRetries}) - ${e.statusCode} ${e.message}"
if (attempt < maxRetries) {
if (isTest) log.info "CINEMA SCOUT: Google server busy (${e.statusCode}). Retrying in 12 seconds..."
pauseExecution(12000)
} else {
if (isTest) log.info "CINEMA SCOUT TEST FAILED: Google API is temporarily unavailable."
}
} catch (java.net.SocketTimeoutException e) {
log.warn "Voice Butler: Cinema Scout Timeout (Attempt ${attempt}/${maxRetries}) - Upstream search grounding took too long."
if (attempt < maxRetries) {
if (isTest) log.info "CINEMA SCOUT: Reading timed out. Retrying in 5 seconds..."
pauseExecution(5000)
}
} catch (Exception e) {
log.warn "Voice Butler: Cinema Scout Hard General Error - ${e}"
break
}
}
}
// --- GROCERY DAY SCOUT (GEMINI INTEGRATION) ---
def executeGroceryScout(isTest = false) {
ensureStateMaps()
if (!settings.enableGroceryScout && !isTest) return
// 1. Day of Week Check
def tz = location.timeZone ?: TimeZone.getDefault()
def calendar = Calendar.getInstance(tz)
def dowInt = calendar.get(Calendar.DAY_OF_WEEK)
def daysMap = ["Sunday":1, "Monday":2, "Tuesday":3, "Wednesday":4, "Thursday":5, "Friday":6, "Saturday":7]
if (!isTest && settings.groceryDay) {
if (dowInt != daysMap[settings.groceryDay]) {
if (settings.enableDebug) log.debug "GROCERY SCOUT: Ignored (Today is not ${settings.groceryDay})."
return
}
}
// 2. Suppress if the house is empty
if (!isTest) {
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0) {
addToHistory("GROCERY SCOUT: Suppressed. House is currently empty.")
return
}
}
if (!settings.geminiApiKey) {
log.warn "Voice Butler: Grocery Scout failed. No Gemini API Key provided."
return
}
// 3. Build Preferences and Prompt (Added Costco)
def prefsStr = settings.groceryPrefs ? settings.groceryPrefs.join(", ") : "general grocery items"
def todayStr = new Date().format("MMMM d, yyyy", location.timeZone)
def systemPrompt = """Today is ${todayStr}. You are a proactive home manager. I need the best grocery deals for this exact week.
Search the live, current weekly circular ads and warehouse savings for Aldi, Publix, Walmart, and Costco in the US.
The user is specifically looking for deals matching these categories: ${prefsStr}.
Find 2 to 4 of the absolute best current deals from EACH of the 4 stores. Be specific about the price if possible (e.g., "\$1.99/lb Chicken Breast").
Return ONLY a raw JSON object with absolutely no markdown formatting, no backticks, and no extra text.
Format exactly like this:
{
"aldi": ["Deal 1", "Deal 2"],
"publix": ["Deal 1", "Deal 2"],
"walmart": ["Deal 1", "Deal 2"],
"costco": ["Deal 1", "Deal 2"]
}"""
def requestBody = [
contents: [ [ role: "user", parts: [ [text: systemPrompt] ] ] ],
tools: [ [ googleSearch: [:] ] ], // Forces live internet browsing
generationConfig: [ temperature: 0.2 ]
]
def params = [
uri: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${settings.geminiApiKey?.trim()}",
requestContentType: "application/json",
contentType: "application/json",
body: groovy.json.JsonOutput.toJson(requestBody),
timeout: 60 // INCREASED TO 60 SECONDS
]
if (isTest) log.info "GROCERY SCOUT: Pinging Gemini for live weekly ads..."
// 4. Ping Gemini and Process (With Developer-Grade Retry Logic)
def maxRetries = 2
def attempt = 0
def success = false
while (attempt < maxRetries && !success) {
attempt++
try {
httpPost(params) { resp ->
if (resp.status == 200 && resp.data) {
success = true // Mark as successful to exit the loop
def geminiText = resp.data?.candidates[0]?.content?.parts[0]?.text ?: ""
geminiText = geminiText.replaceAll(/```json\n?/, "").replaceAll(/```/, "").trim()
def dealsData = new groovy.json.JsonSlurper().parseText(geminiText)
// Safe extraction into Lists (Added Costco)
def aldiDeals = dealsData.aldi ?: []
def publixDeals = dealsData.publix ?: []
def walmartDeals = dealsData.walmart ?: []
def costcoDeals = dealsData.costco ?: []
def aldiCount = aldiDeals.size()
def publixCount = publixDeals.size()
def walmartCount = walmartDeals.size()
def costcoCount = costcoDeals.size()
def totalDeals = aldiCount + publixCount + walmartCount + costcoCount
// 5. Assemble Spoken Summary
def greeting = getDynamicGreeting()
def msg = ""
if (totalDeals > 0) {
msg = "%interruption%, ${greeting}, I have scanned the weekly ads based on your preferences. I found ${aldiCount} deals at Aldi, ${publixCount} at Publix, ${walmartCount} at Walmart, and ${costcoCount} at Costco that you might want to grab during your grocery run. I've sent the complete lists to your phone."
} else {
msg = "%interruption%, ${greeting}, I scanned the weekly ads, but I didn't find any standout deals matching your preferences today."
}
// CRITICAL FIX: Only speak if the house is in an allowed mode
if (settings.groceryModes == null || settings.groceryModes.contains(location.mode)) {
def targetVol = settings.groceryVolume != null ? settings.groceryVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(msg), settings.groceryRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
} else {
if (isTest || settings.enableDebug) log.info "GROCERY SCOUT: Spoken announcement skipped because house is in mode: ${location.mode}"
}
def prefix = isTest ? "TEST GROCERY SCOUT: " : "GROCERY SCOUT: "
addToHistory("${prefix}Processed ${totalDeals} grocery deals.")
// 6. Split Push Notifications (ALWAYS SENDS, One per store for better scannability)
if (settings.notificationDevice && totalDeals > 0) {
def storeData = [
[label: "ALDI", deals: aldiDeals],
[label: "PUBLIX", deals: publixDeals],
[label: "WALMART", deals: walmartDeals],
[label: "COSTCO", deals: costcoDeals]
]
storeData.each { store ->
if (store.deals.size() > 0) {
def storeMsg = "π ${store.label} DEALS\nβ’ " + store.deals.join("\nβ’ ")
settings.notificationDevice.each { dev ->
try {
dev.deviceNotification(storeMsg.trim())
} catch(err) {
log.warn "Voice Butler: Failed to send ${store.label} notification to ${dev.displayName}"
}
}
}
}
}
if (isTest) log.info "TESTING GROCERY SCOUT SUCCESS: '${applyDynamicVars(msg)}'"
} else {
log.warn "Voice Butler: Gemini API returned unexpected status: ${resp.status}"
}
}
} catch (groovyx.net.http.HttpResponseException e) {
log.warn "Voice Butler: Grocery Scout API HTTP Error (Attempt ${attempt}/${maxRetries}) - ${e.statusCode} ${e.message}"
if (attempt < maxRetries) {
if (isTest) log.info "GROCERY SCOUT: Google server busy (${e.statusCode}). Retrying in 12 seconds..."
pauseExecution(12000) // Increased cooldown from 3000 to 12000
} else {
if (isTest) log.info "GROCERY SCOUT TEST FAILED: Google API is temporarily unavailable."
}
} catch (java.net.SocketTimeoutException e) {
log.warn "Voice Butler: Grocery Scout Timeout (Attempt ${attempt}/${maxRetries}) - Upstream search grounding took too long."
if (attempt < maxRetries) {
if (isTest) log.info "GROCERY SCOUT: Reading timed out. Retrying in 5 seconds..."
pauseExecution(5000) // Quick breather and try attempt #2
}
} catch (Exception e) {
log.warn "Voice Butler: Grocery Scout Hard General Error - ${e}"
break // Keep the break here ONLY for fatal syntax/code crashes
}
}
}
def getDynamicGreeting() {
def presentFolks = getPresentUsers() // Uses your existing presence check
if (presentFolks.size() == 0) return "everyone"
if (presentFolks.size() == 1) return presentFolks[0]
// If multiple people, join them with 'and'
return presentFolks.join(" and ")
}
// --- FALLBACK PRESENCE ENGINE (SUSTAINED MOTION & DEBOUNCE) ---
def fallbackMotionHandler(evt) {
if (!settings.enableFallbackPresence) return
def user = settings.fallbackUser ?: "Princess"
// CRITICAL FIX: Checking the actual state map used by your app
if (state.hasArrivedToday && state.hasArrivedToday[user] == true) return
// 2. Verify Day Constraints (Weekdays)
def tz = location.timeZone ?: TimeZone.getDefault()
def calendar = Calendar.getInstance(tz)
def currentDay = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.US)
if (settings.fallbackDays && !settings.fallbackDays.contains(currentDay)) return
// 3. Verify Time Window (After school hours)
if (settings.fallbackStartTime && settings.fallbackEndTime) {
if (!timeOfDayIsBetween(toDateTime(settings.fallbackStartTime), toDateTime(settings.fallbackEndTime), new Date(), tz)) {
return
}
}
// 4. State Machine: Active vs Inactive with Debounce
if (evt.value == "active") {
unschedule("resetFallbackTimer")
if (!state.fallbackTrackingActive) {
state.fallbackTrackingActive = true
if (settings.enableDebug) log.debug "Voice Butler: Fallback presence tracking started for ${user}."
def durMins = settings.fallbackDuration ?: 5
runIn(durMins * 60, "executeFallbackArrival")
}
} else if (evt.value == "inactive") {
def anyActive = settings.fallbackMotionSensors.any { it.currentValue("motion") == "active" }
if (!anyActive && state.fallbackTrackingActive) {
def debounceSecs = settings.fallbackDebounce ?: 60
if (settings.enableDebug) log.debug "Voice Butler: Room quiet. Waiting ${debounceSecs}s (Debounce) before resetting ${user}'s timer."
runIn(debounceSecs, "resetFallbackTimer")
}
}
}
def resetFallbackTimer() {
// The debounce window expired without any new motion. She must not be in there.
if (settings.enableDebug) log.debug "Voice Butler: Fallback debounce expired. Resetting presence tracker."
state.fallbackTrackingActive = false
unschedule("executeFallbackArrival")
}
def executeFallbackArrival() {
state.fallbackTrackingActive = false
def user = settings.fallbackUser ?: "Princess"
// Double check she wasn't marked home by another method in the last 5 minutes
if (state.hasArrivedToday && state.hasArrivedToday[user] == true) return
log.info "Voice Butler: Sustained room motion confirmed. Executing fallback arrival for ${user}."
// CRITICAL FIX: Force the system to mark her as Home using the correct Maps
state.hasArrivedToday[user] = true
state.hasDepartedToday.remove(user)
state.resetReasons[user] = "Sustained Room Motion"
def msg = "Pardon the intrusion, ${user}. I seem to have missed your arrival at the main door. Welcome home from school. I will cue your arrival briefing now."
if (settings.fallbackSpeaker) {
try {
def targetVol = settings.globalVolume ?: 50
if (settings.fallbackSpeaker.hasCommand("playTextAndRestore")) {
settings.fallbackSpeaker.playTextAndRestore(msg, targetVol as Integer)
} else {
settings.fallbackSpeaker.speak(msg)
}
} catch(e) {
log.warn "Voice Butler: Failed to speak fallback arrival on ${user}'s speaker - ${e}"
}
}
addToHistory("FALLBACK PRESENCE: Marked ${user} as Arrived via sustained room motion.")
}
// --- GUEST TIMER WEB ENDPOINT & EXECUTION ---
def guestTimerEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def gName = params.guestName ?: "Guest"
def gDur = params.guestDuration ? params.guestDuration.toInteger() : 0
def gRoute = params.guestRoutingMode ?: "Global Indoor Speaker Only"
if (gDur > 0) {
log.info "Voice Butler: Guest timer started for ${gName} (${gDur} min)"
runIn(gDur * 60, "executeGuestEviction", [data: [name: gName, route: gRoute], overwrite: true])
}
} catch (Exception e) {
log.warn "Portal Guest Timer Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def executeGuestEviction(data) {
def name = data.name ?: "Guest"
def rMode = data.route ?: "Global Indoor Speaker Only"
def phrases = [
"Pardon the interruption, ${name}. The household's quiet hours are approaching, and I must begin the evening's closing procedures. It was a pleasure having you.",
"Excuse me, ${name}. My logs indicate that our scheduled visit has reached its conclusion. I wish you a safe journey home.",
"Forgive me for interrupting, ${name}, but the residence is scheduled for a security transition shortly. I must ask you to make your way to the exit.",
"${name}, it has been a delight. However, the master has requested I begin the home's reset for the night.",
"My apologies, ${name}. Butler services for today are now ending. I've ensured the path to the door is well-lit for your departure.",
"${name}, as per the household schedule, it is time for the residence to return to its private state. Thank you for your visit.",
"Pardon me, ${name}. I am under instruction to prepare the house for the family's private time now. I trust you had a pleasant stay.",
"Excuse the intrusion, ${name}. The allotted time for our gathering has expired. I hope to see you again soon.",
"${name}, the house is currently signaling the end of guest hours. I must kindly assist you in your departure.",
"Forgive my timing, ${name}, but I must begin the lockdown sequence for the evening. I wish you a very good night."
]
def randomMsg = phrases[new Random().nextInt(phrases.size())]
executeRoutedTTS(randomMsg, rMode, settings.globalVolume, settings.globalVolume, 1)
}
// --- AI VEHICLE CARE SCOUT (GEMINI INTEGRATION) ---
def executeVehicleScout(isTest = false) {
ensureStateMaps()
if (!settings.enableVehicleCare && !isTest) return
def tz = location.timeZone ?: TimeZone.getDefault()
def dow = Calendar.getInstance(tz).get(Calendar.DAY_OF_WEEK)
if (!isTest && dow != Calendar.MONDAY) return
if (!settings.geminiApiKey) {
log.warn "Voice Butler: Vehicle Scout failed. No Gemini API Key provided."
return
}
def todayStr = new Date().format("MMMM d, yyyy", location.timeZone)
def systemPrompt = """Today is ${todayStr}. Look at the 7-day weather forecast for ${settings.homeAddress ?: location.zipCode ?: "the local area"}.
Identify the single best day this week (Monday through Sunday) to wash a car. Look for clear skies and the lowest chance of precipitation for consecutive days.
Return ONLY a raw JSON object formatted exactly like this:
{ "dayOfWeekInt": 4, "dayName": "Wednesday", "reason": "it is followed by three days of clear skies" }
Make sure dayOfWeekInt matches standard Calendar values where Sunday=1, Monday=2, etc. Do not use markdown."""
def requestBody = [
contents: [ [ role: "user", parts: [ [text: systemPrompt] ] ] ],
tools: [ [ googleSearch: [:] ] ],
generationConfig: [ temperature: 0.2 ]
]
def params = [
uri: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${settings.geminiApiKey?.trim()}",
requestContentType: "application/json",
contentType: "application/json",
body: groovy.json.JsonOutput.toJson(requestBody),
timeout: 60
]
if (isTest) log.info "VEHICLE SCOUT: Pinging Gemini for weekly forecast..."
def maxRetries = 2
def attempt = 0
def success = false
while (attempt < maxRetries && !success) {
attempt++
try {
httpPost(params) { resp ->
if (resp.status == 200 && resp.data) {
success = true
def geminiText = resp.data?.candidates[0]?.content?.parts[0]?.text ?: ""
geminiText = geminiText.replaceAll(/```json\n?/, "").replaceAll(/```/, "").trim()
def weatherData = new groovy.json.JsonSlurper().parseText(geminiText)
if (weatherData.dayOfWeekInt) {
state.carWashDay = weatherData.dayOfWeekInt.toInteger()
state.carWashReason = weatherData.reason ?: "favorable conditions"
def prefix = isTest ? "TEST VEHICLE SCOUT: " : "VEHICLE SCOUT: "
addToHistory("${prefix}Analyzed the forecast and selected ${weatherData.dayName} as the ideal car wash day.")
if (isTest) {
log.info "${prefix}Success! Selected ${weatherData.dayName} because ${state.carWashReason}."
def testMsg = "Vehicle Scout test complete. I have analyzed the weekly weather forecast and selected ${weatherData.dayName} as the ideal day to wash the vehicles, because ${state.carWashReason}."
def targetVol = settings.vehicleVolume != null ? settings.vehicleVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(testMsg), settings.vehicleRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
}
}
}
}
} catch (groovyx.net.http.HttpResponseException e) {
log.warn "Voice Butler: Vehicle Scout API HTTP Error (Attempt ${attempt}/${maxRetries}) - ${e.statusCode} ${e.message}"
if (attempt < maxRetries) {
if (isTest) log.info "VEHICLE SCOUT: Google server busy (${e.statusCode}). Retrying in 12 seconds..."
pauseExecution(12000)
} else {
if (isTest) log.info "VEHICLE SCOUT TEST FAILED: Google API is temporarily unavailable."
}
} catch (java.net.SocketTimeoutException e) {
log.warn "Voice Butler: Vehicle Scout Timeout (Attempt ${attempt}/${maxRetries}) - Upstream search grounding took too long."
if (attempt < maxRetries) {
if (isTest) log.info "VEHICLE SCOUT: Reading timed out. Retrying in 5 seconds..."
pauseExecution(5000)
}
} catch (Exception e) {
log.warn "Voice Butler: Vehicle Scout Hard General Error - ${e}"
break
}
}
}
def executeVehicleReminder() {
if (!settings.enableVehicleCare || !state.carWashDay) return
def tz = location.timeZone ?: TimeZone.getDefault()
def dow = Calendar.getInstance(tz).get(Calendar.DAY_OF_WEEK)
// If today is the day Gemini picked, run the 12 PM fallback alert
if (dow == state.carWashDay) {
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0) return
def greeting = getDynamicGreeting()
def msg = "%interruption%, ${greeting}, as a friendly reminder: based on the weekly weather forecast, today is the ideal day to wash the vehicles and clean them out."
def targetVol = settings.vehicleVolume != null ? settings.vehicleVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(msg), settings.vehicleRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
addToHistory("VEHICLE CARE: Delivered afternoon reminder to wash cars.")
}
}
// --- CROSS-APP INTEGRATION RECEIVER ---
def crossAppMessageHandler(evt) {
ensureStateMaps()
// Accept telemetry from Vacuum, Energy, OR Trash Managers
if (evt.value == "vacuum" || evt.value == "energy" || evt.value == "trash") {
def liveMsg = evt.descriptionText
def stashMsg = evt.data
if (!liveMsg) return
def presentFolks = getPresentUsers()
def sourceName = evt.value.capitalize()
// SCENARIO 1: The house is completely empty
if (presentFolks.size() == 0) {
if (stashMsg) stashMessage(stashMsg)
addToHistory("CROSS-APP (${sourceName}): House is empty. Stashed action for arrival inbox: '${stashMsg}'")
return
}
// SCENARIO 2: Check DND and Custom Restricted Modes
def dndModesList = [settings.dndModes].flatten().findAll { it != null }
def restrictedModesList = [settings.crossAppRestrictedModes].flatten().findAll { it != null }
if (dndModesList.contains(location.mode) || restrictedModesList.contains(location.mode) || (dndSwitch?.currentValue("switch") == "on")) {
if (stashMsg) stashMessage(stashMsg)
addToHistory("CROSS-APP (${sourceName}): Suppressed live announcement due to restricted mode (${location.mode}). Stashed for inbox.")
return
}
// SCENARIO 3: The house is active and ready for a live announcement
def finalMsg = "%interruption%. Just keeping you informed: " + liveMsg
def targetVol = settings.crossAppVolume != null ? settings.crossAppVolume : settings.globalVolume
def rMode = settings.crossAppRoutingMode ?: "Global Indoor Speaker Only"
// FIX: Corrected volume parameter order and capture the boolean success response
def played = executeRoutedTTS(applyDynamicVars(finalMsg), rMode, targetVol, settings.outdoorVolume, 2)
if (played) {
addToHistory("CROSS-APP (${sourceName}): Routed live dispatch to speakers: '${liveMsg}'")
} else {
addToHistory("CROSS-APP (${sourceName}): Message dropped by routing engine (No speakers active for route '${rMode}', or system is muted).")
}
}
}
// --- STAFF SCHEDULE RECEIVER ---
def staffSyncHandler(evt) {
ensureStateMaps()
try {
if (evt.data) {
state.cleaningStaffData = new groovy.json.JsonSlurper().parseText(evt.data)
if (settings.enableDebug) log.debug "CROSS-APP: Butler successfully received the updated Cleaning Staff schedule."
}
} catch (e) { log.warn "Voice Butler: Error parsing staff sync: ${e}" }
}
// ==========================================
// DIGITAL CONCIERGE RSVP SYSTEM
// ==========================================
def createEventEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
if (params.eventTitle && params.eventDate && params.eventTime) {
if (state.hostedEvents == null) state.hostedEvents = [:]
def eventId = java.util.UUID.randomUUID().toString().substring(0, 8)
def timeParts = params.eventTime.split(":")
def dateParts = params.eventDate.split("-")
def cal = Calendar.getInstance(location.timeZone)
cal.set(dateParts[0].toInteger(), dateParts[1].toInteger() - 1, dateParts[2].toInteger(), timeParts[0].toInteger(), timeParts[1].toInteger(), 0)
def startEpoch = cal.getTime().time
def eDur = params.eventDuration ? params.eventDuration.toInteger() : 4
def endEpoch = startEpoch + (eDur * 3600000)
def eLoc = params.eventLocation ?: settings.estateName ?: "The Estate"
state.hostedEvents[eventId] = [
title: params.eventTitle,
dateStr: params.eventDate,
timeStr: params.eventTime,
location: eLoc,
dateEpoch: startEpoch,
endEpoch: endEpoch, // Added for the wrap-up scheduler
rsvps: [],
slot: params.eventSlot ?: null,
pin: params.eventPin ?: null,
lockName: params.eventLock ?: "All Locks"
]
// Schedule the auto-start and auto-end functions
runOnce(new Date(startEpoch), "startHostedEvent", [data: [eventId: eventId], overwrite: false])
runOnce(new Date(endEpoch), "endHostedEvent", [data: [eventId: eventId], overwrite: false])
// --- NEW: SMART EVENT PREP (1 Hour Warning) ---
def prepEpoch = startEpoch - 3600000
if (prepEpoch > new Date().time) {
runOnce(new Date(prepEpoch), "executeEventPrep", [data: [eventId: eventId], overwrite: false])
}
}
} catch (e) { log.warn "Event Creation Error: ${e}" }
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def deleteEventEndpoint() {
try {
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
if (params.eventId && state.hostedEvents[params.eventId]) {
state.hostedEvents.remove(params.eventId)
addToHistory("RSVP SYSTEM: Event deleted manually via portal.")
// --- MISSING LINE ADDED: Tell Lock Manager to instantly revoke the code ---
sendLocationEvent(name: "lockManagerTempSync", value: "delete", data: groovy.json.JsonOutput.toJson([id: params.eventId]), isStateChange: true)
}
} catch(e) {}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def serveGuestRsvpPage() {
def eId = params?.id
def event = state.hostedEvents ? state.hostedEvents[eId] : null
if (!event) {
return render(contentType: "text/html", data: "This invitation is no longer active. ", status: 404)
}
def apiUrl = getFullApiServerUrl()
def displayDate = new Date(event.dateEpoch).format("EEEE, MMMM d 'at' h:mm a", location.timeZone)
// Extract dynamic estate name or fallback to default
def eName = settings.estateName ?: "The Family"
// Generate a clickable map link using the dynamic estate name
def locString = event.location ?: eName
def mapLink = "https://maps.google.com/?q=${java.net.URLEncoder.encode(locString, 'UTF-8')}"
// Build Calendar Export Data
def startDate = new Date(event.dateEpoch)
def endDate = new Date(event.dateEpoch + (4 * 3600000)) // Default 4 hours
def gStart = startDate.format("yyyyMMdd'T'HHmmss", location.timeZone)
def gEnd = endDate.format("yyyyMMdd'T'HHmmss", location.timeZone)
def gCalLink = "https://calendar.google.com/calendar/render?action=TEMPLATE&text=${java.net.URLEncoder.encode(event.title, 'UTF-8')}&dates=${gStart}/${gEnd}&location=${java.net.URLEncoder.encode(locString, 'UTF-8')}"
def icsData = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nSUMMARY:${event.title}\nDTSTART:${gStart}\nDTEND:${gEnd}\nLOCATION:${locString}\nEND:VEVENT\nEND:VCALENDAR"
def icsUri = "data:text/calendar;charset=utf8," + java.net.URLEncoder.encode(icsData, 'UTF-8')
def html = """
Invitation RSVP
"""
return render(contentType: "text/html", data: html, status: 200)
}
def submitRsvpEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def eId = params.eventId
if (eId && state.hostedEvents[eId]) {
def gName = params.guestName ?: "A guest"
def gStatus = params.attendance ?: "Responded"
def gCount = params.guestCount ?: "1"
def gRestrictions = params.restrictions ?: ""
def gMsg = params.message ?: ""
state.hostedEvents[eId].rsvps << [
name: gName,
status: gStatus,
guestCount: gCount,
restrictions: gRestrictions,
message: gMsg,
timestamp: new Date().time
]
// Inform the Butler of the party size
def msgText = "${gName}, a party of ${gCount}, has ${gStatus.toLowerCase()} the invitation to ${state.hostedEvents[eId].title}."
if (gRestrictions) msgText += " They noted the following restriction: ${gRestrictions}."
if (gMsg) msgText += " They also left a message: ${gMsg}"
def presentFolks = getPresentUsers()
def dndModesList = [settings.dndModes].flatten().findAll { it != null }
def isDnd = dndModesList.contains(location.mode) || (dndSwitch?.currentValue("switch") == "on")
if (presentFolks.size() > 0 && !isDnd) {
def finalMsg = "%interruption%. Incoming RSVP: " + msgText
executeRoutedTTS(applyDynamicVars(finalMsg), "Global Indoor Speaker Only", settings.globalVolume, settings.outdoorVolume, 2)
addToHistory("RSVP SYSTEM: Live announcement for ${gName}'s response.")
} else {
stashMessage("RSVP Received: " + msgText)
addToHistory("RSVP SYSTEM: Stashed response from ${gName} for event '${state.hostedEvents[eId].title}'.")
}
def successHtml = """
β
Thank You
Your response has been delivered to the Butler.
"""
return render(contentType: "text/html", data: successHtml, status: 200)
}
} catch(e) { log.error "RSVP Submit Error: ${e}" }
return render(contentType: "text/html", data: "An error occurred processing your RSVP. ", status: 500)
}
// ==========================================
// QUICK LOCK CODE MANAGER
// ==========================================
def createQuickCodeEndpoint() {
try {
ensureStateMaps()
// Safely extract parameters without shadowing Hubitat's implicit params object
def formParams = params ?: [:]
def bodyText = request?.body ? request.body.toString() : ""
if (bodyText) {
bodyText.split('&').each {
def parts = it.split('=')
formParams[parts[0]] = parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""
}
}
if (formParams.codeName && formParams.codeSlot && formParams.codePin) {
if (state.quickLockCodes == null) state.quickLockCodes = [:]
def codeId = java.util.UUID.randomUUID().toString().substring(0, 8)
def startEpoch = new Date().time
def endEpoch = 4102444800000L // Permanent Default
def durStr = "Permanent"
if (formParams.codeDuration != "Permanent") {
def hrs = formParams.codeDuration.toInteger()
endEpoch = startEpoch + (hrs * 3600000)
durStr = "${hrs} Hours"
}
state.quickLockCodes[codeId] = [
name: formParams.codeName,
slot: formParams.codeSlot,
pin: formParams.codePin,
duration: durStr,
expires: endEpoch,
lockName: formParams.codeLock ?: "All Locks"
]
def payload = [id: codeId, name: formParams.codeName, slot: formParams.codeSlot.toInteger(), pin: formParams.codePin, start: startEpoch, end: endEpoch, lockName: formParams.codeLock]
sendLocationEvent(name: "lockManagerTempSync", value: "add", data: groovy.json.JsonOutput.toJson(payload), isStateChange: true)
addToHistory("LOCK MANAGER: Synced Portal Quick Code for '${formParams.codeName}' (${durStr}).")
}
} catch(e) { log.warn "Quick Code Error: ${e}" }
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def deleteQuickCodeEndpoint() {
try {
def formParams = params ?: [:]
def bodyText = request?.body ? request.body.toString() : ""
if (bodyText) {
bodyText.split('&').each {
def parts = it.split('=')
formParams[parts[0]] = parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""
}
}
if (formParams.codeId && state.quickLockCodes[formParams.codeId]) {
state.quickLockCodes.remove(formParams.codeId)
sendLocationEvent(name: "lockManagerTempSync", value: "delete", data: groovy.json.JsonOutput.toJson([id: formParams.codeId]), isStateChange: true)
addToHistory("LOCK MANAGER: Quick Code deleted manually via portal.")
}
} catch(e) {}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
// --- CALENDAR ADD ENDPOINT ---
def addCalendarEventEndpoint() {
try {
def formParams = params ?: [:]
def bodyText = request?.body ? request.body.toString() : ""
if (bodyText) {
bodyText.split('&').each {
def parts = it.split('=')
formParams[parts[0]] = parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""
}
}
if (formParams.eventTitle && formParams.eventDate && formParams.eventTime && settings.googleAppScriptUrl) {
def payload = [
title: formParams.eventTitle,
date: formParams.eventDate,
time: formParams.eventTime,
duration: formParams.eventDuration ?: "60",
location: formParams.eventLocation ?: ""
]
httpPost([
uri: settings.googleAppScriptUrl,
contentType: "application/json",
body: groovy.json.JsonOutput.toJson(payload)
]) { resp ->
log.info "Calendar Event Pushed: ${resp.status}"
}
addToHistory("CALENDAR: Added event '${formParams.eventTitle}' via Portal.")
} else {
log.warn "Voice Butler: Missing parameters or Google Apps Script URL not configured."
}
} catch(e) {
log.warn "Add Calendar Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
// --- WEEKLY MEAL PLAN ENDPOINT ---
def updateMealsEndpoint() {
try {
ensureStateMaps()
def bodyText = request?.body ? request.body.toString() : ""
def params = bodyText.split('&').collectEntries {
def parts = it.split('=')
[parts[0], parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""]
}
def days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
days.each { day ->
if (params.containsKey("meal_${day}")) {
def val = params["meal_${day}"]
state.mealPlan[day] = val
// This line "pushes" the portal data into the App Setup inputs
app.updateSetting("meal_${day}", [type: "text", value: val])
}
}
addToHistory("PORTAL: Weekly meal plan updated.")
} catch (Exception e) {
log.warn "Failed to update meal plan from web portal: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
// --- GEMINI BUTLER CHAT ENDPOINT ---
def butlerChatEndpoint() {
def formParams = params ?: [:]
def bodyText = request?.body ? request.body.toString() : ""
if (bodyText) {
bodyText.split('&').each {
def parts = it.split('=')
formParams[parts[0]] = parts.size() > 1 ? java.net.URLDecoder.decode(parts[1], "UTF-8") : ""
}
}
def userMessage = formParams.message ?: ""
if (!userMessage || !settings.geminiApiKey) {
return render(contentType: "text/plain", data: "I apologize, but my generative core is offline (Missing API Key).")
}
// Dynamically fetch live home context for the AI
def present = getPresentUsers().join(", ") ?: "No one"
def bName = settings.butlerName ?: "Alfred"
def nextEvt = state.nextEventName && state.nextEventName != "No Upcoming Events" ? "${state.nextEventName} at ${state.nextEventTimeStr}" : "None"
def mailStat = (settings.enableMailCheck && settings.mailSwitch?.currentValue("switch") == "on") ? "Mail is waiting in the box." : "No new mail."
// Inject the new Context and Location Search parameters
def houseContext = settings.estateContext ? "\n- Household Information: ${settings.estateContext}" : ""
def homeLoc = settings.homeAddress ?: location.zipCode ?: "the local area"
// System Instructions to enforce the persona AND GUARDRAILS
def systemPrompt = """You are ${bName}, a highly professional, polite, and concise British estate manager and butler.
Use the following live context about the smart home to answer the user's request.
CURRENT STATUS:
- People currently home: ${present}
- Next Calendar Event: ${nextEvt}
- Mail Status: ${mailStat}
- Home Location: ${homeLoc}${houseContext}
CAPABILITIES & INSTRUCTIONS:
- Address the user respectfully as "Sir", "Madam", or by name if known.
- If asked to find contractors (plumbers, HVAC, etc.), use your search tools to recommend 2-3 highly rated local businesses near ${homeLoc}, including their names and a brief reason why they are recommended.
- You may assist with recipes, local dining recommendations, general knowledge, and household troubleshooting.
STRICT GUARDRAILS (LIMITATIONS):
- DO NOT use markdown formatting like hashtags or bolding. You may use standard bullet points.
- YOU CANNOT physically control smart home devices. If asked to turn off lights, adjust the thermostat, unlock doors, or view cameras, you must politely decline and state that you do not have physical access to the control systems.
- Keep responses concise (3-5 sentences maximum unless providing a list or recipe).
"""
def requestBody = [
system_instruction: [ parts: [ [text: systemPrompt] ] ],
contents: [ [ role: "user", parts: [ [text: userMessage] ] ] ],
tools: [ [ googleSearch: [:] ] ], // Gives the AI internet access
generationConfig: [ temperature: 0.4 ]
]
def responseText = "I apologize, an error occurred while processing your request."
try {
httpPost([
uri: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${settings.geminiApiKey?.trim()}",
requestContentType: "application/json",
contentType: "application/json",
body: groovy.json.JsonOutput.toJson(requestBody),
timeout: 30
]) { resp ->
if (resp.status == 200 && resp.data) {
responseText = resp.data?.candidates[0]?.content?.parts[0]?.text ?: "I am unsure how to respond."
responseText = responseText.replaceAll(/```.*/, "").trim()
}
}
} catch(e) {
log.warn "Butler Chat Error: ${e}"
}
return render(contentType: "text/plain", data: responseText, status: 200)
}
def startHostedEvent(data) {
def ev = state.hostedEvents[data.eventId]
if (ev) {
if (settings.partyModeSwitch) {
try { settings.partyModeSwitch.on() } catch(e){}
}
addToHistory("RSVP SYSTEM: Event '${ev.title}' started. Party Mode automatically engaged.")
}
}
def endHostedEvent(data) {
def ev = state.hostedEvents[data.eventId]
if (ev) {
if (settings.partyModeSwitch) {
try { settings.partyModeSwitch.off() } catch(e){}
}
def wrapUpPhrases = [
"Pardon the interruption. The scheduled time for ${ev.title} has now concluded. The homeowners thank you for attending, and wish you a safe journey home.",
"Excuse me everyone. The event has officially reached its end. Please gather your belongings. Thank you for celebrating with us tonight.",
"Attention guests. The gathering is now wrapping up. The hosts appreciate your presence and wish you a wonderful rest of your evening.",
"Pardon me. The allotted time for tonight's event has expired. We hope you had a fantastic time. Please travel home safely.",
"Friends and guests, the party is now concluding. Thank you for making it a memorable event. Have a safe trip back."
]
def msg = wrapUpPhrases[new Random().nextInt(wrapUpPhrases.size())]
executeRoutedTTS(msg, "Global Indoor Speaker Only", settings.globalVolume, settings.outdoorVolume, 1)
if (settings.partyVacuum) {
try { settings.partyVacuum.on() } catch(e){}
}
addToHistory("RSVP SYSTEM: Event '${ev.title}' ended. Party Mode disabled, wrap-up announced, and vacuum deployed.")
}
}
def applianceSyncHandler(evt) {
try {
state.applianceHealthData = new groovy.json.JsonSlurper().parseText(evt.value)
} catch(e) {
log.warn "Failed to parse appliance sync data: ${e}"
}
}
def executeEventPrep(data) {
ensureStateMaps()
def ev = state.hostedEvents[data.eventId]
// Only proceed if the event exists AND is located at the estate
def eName = settings.estateName ?: "The Family"
if (!ev || (ev.location && ev.location != eName && ev.location != "The Estate")) return
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0) return
def bName = settings.butlerName ?: "Alfred"
def msg = "%interruption%, as a reminder, ${ev.title} will commence in exactly one hour. "
if (settings.geminiApiKey) {
def systemPrompt = "You are ${bName}, a professional estate butler. The homeowners are hosting an event named '${ev.title}' at the house in exactly one hour. Provide 2 brief, sophisticated preparation reminders for them. For example, for a dinner party, remind them to check the wine temperatures and light the candles. Keep your total response under 40 words. Do not use markdown."
def requestBody = [ contents: [ [ role: "user", parts: [ [text: systemPrompt] ] ] ], generationConfig: [ temperature: 0.5 ] ]
try {
httpPost([
uri: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${settings.geminiApiKey?.trim()}",
requestContentType: "application/json", contentType: "application/json", body: groovy.json.JsonOutput.toJson(requestBody), timeout: 30
]) { resp ->
if (resp.status == 200 && resp.data) {
def aiText = resp.data?.candidates[0]?.content?.parts[0]?.text ?: ""
msg += aiText.replaceAll(/```.*/, "").trim()
}
}
} catch(e) { log.warn "Event Prep AI Error: ${e}" }
} else {
msg += "Please ensure all necessary preparations are finalized before your guests arrive."
}
executeRoutedTTS(applyDynamicVars(msg), "Follow-Me + Fallback (Global ONLY if no motion)", settings.globalVolume, settings.outdoorVolume, 2)
addToHistory("EVENT PREP: Delivered 1-hour proactive warning for '${ev.title}'.")
}
def trashSyncHandler(evt) {
try {
state.trashData = new groovy.json.JsonSlurper().parseText(evt.value)
} catch(e) {
log.warn "Failed to parse trash sync data: ${e}"
}
}
def scoutGroceryEndpoint() {
ensureStateMaps()
runIn(1, "triggerGroceryScout", [overwrite: true])
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def scoutDetailingEndpoint() {
ensureStateMaps()
runIn(1, "triggerVehicleScout", [overwrite: true])
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def scoutCinemaEndpoint() {
ensureStateMaps()
runIn(1, "triggerCinemaScout", [overwrite: true])
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def triggerGroceryScout() { executeGroceryScout(true) }
def triggerVehicleScout() { executeVehicleScout(true) }
def triggerCinemaScout() { executeCinemaScout(true) }
// --- PORTAL ENDPOINTS ---
def roomToggleEndpoint() {
try {
ensureStateMaps()
def formParams = [:]
// Safely parse the POST body manually to avoid Hubitat's implicit 'params' shadow crash
def bodyText = request?.body ? request.body.toString() : ""
if (bodyText) {
bodyText.split('&').each {
def parts = it.split('=')
if (parts.size() > 1) {
formParams[parts[0]] = java.net.URLDecoder.decode(parts[1], "UTF-8")
}
}
}
def dId = formParams.deviceId
def cmd = formParams.command
if (dId && cmd) {
// Flatten forces it into a list so .find() doesn't crash if you only have 1 room
def allSwitches = [settings.portalRoomSwitches].flatten().findAll { it != null }
def dev = allSwitches.find { it.id == dId }
if (dev) {
if (cmd == "on") dev.on()
else dev.off()
addToHistory("PORTAL: Room '${dev.displayName}' manually marked as ${cmd == 'on' ? 'Occupied' : 'Empty'}.")
}
}
} catch(Exception e) {
log.warn "Voice Butler Portal - Room Toggle Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def staffCleanEndpoint() {
try {
ensureStateMaps()
def vac = settings.portalVacuum
if (vac) {
vac.on()
}
// Broadcast to other apps (like Advanced Energy or the Staff Scheduler)
sendLocationEvent(name: "voiceButlerStaffCommand", value: "clean", isStateChange: true)
addToHistory("PORTAL: Cleaning staff deployed manually.")
} catch(Exception e) {
log.warn "Voice Butler Portal - Staff Clean Error: ${e}"
}
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def tvSyncHandler(evt) {
try {
state.tvManagerData = new groovy.json.JsonSlurper().parseText(evt.value)
} catch(e) {}
}
def tvAlertHandler(evt) {
if (evt.value == "limit_reached") {
def tvName = evt.data ?: "the television"
def msgs = []
for (int d = 1; d <= 5; d++) { if (settings["screenTimeMsg_${d}"]) msgs << settings["screenTimeMsg_${d}"] }
if (!msgs) msgs = getDefaultMessages("ScreenTime")
def randomMsg = applyDynamicVars(msgs[new Random().nextInt(msgs.size())])
def rMode = settings.screenTimeRoutingMode ?: "Global Indoor Speaker Only"
def targetVol = settings.screenTimeVolume != null ? settings.screenTimeVolume : settings.globalVolume
executeRoutedTTS(randomMsg, rMode, targetVol, settings.outdoorVolume, 2, false, settings.screenTimeSpeaker)
addToHistory("SCREEN TIME: TV limit reached for ${tvName}. Announcement queued.")
}
}
def tvCmdEndpoint() {
try {
ensureStateMaps()
def formParams = [:]
def bodyText = request?.body ? request.body.toString() : ""
if (bodyText) {
bodyText.split('&').each {
def parts = it.split('=')
if (parts.size() > 1) {
formParams[parts[0]] = java.net.URLDecoder.decode(parts[1], "UTF-8")
}
}
}
def idx = formParams.tvIdx
def cmd = formParams.command
if (idx && cmd) {
sendLocationEvent(name: "voiceButlerTvCmd", value: cmd, data: idx, isStateChange: true)
addToHistory("PORTAL: Sent remote ${cmd.toUpperCase()} command to TV ${idx}.")
}
} catch(Exception e) {
log.warn "Voice Butler Portal - TV Cmd Error: ${e}"
}
// FIX: Render the response as HTML so the browser executes the redirect
return render(contentType: "text/html", data: getRedirectHtml(), status: 200)
}
def deviceHealthHandler(evt) {
if (!settings.enableDeviceHealthAlerts || !settings.deviceHealthUser || !settings.deviceHealthDevice) return
ensureStateMaps()
def count = evt.value != null && evt.value.toString().isInteger() ? evt.value.toInteger() : 0
def lastCount = state.lastIssueCount ?: 0
state.lastIssueCount = count
// Only alert if the count has INCREASED and is greater than 0
if (count > 0 && count > lastCount) {
def presentFolks = getPresentUsers()
def targetAlias = applyAlias(settings.deviceHealthUser)
// Check if the Target User is the ONLY person currently home
if (presentFolks.size() == 1 && presentFolks[0].equalsIgnoreCase(targetAlias)) {
def devList = settings.deviceHealthDevice.currentValue("issueList") ?: "several devices"
def isAre = count == 1 ? "is" : "are"
def devStr = count == 1 ? "device" : "devices"
def msg = "%interruption%. I need to alert you that there ${isAre} now ${count} network ${devStr} in critical status, including ${devList}. Please check the health dashboard when you have a moment."
def targetVol = settings.deviceHealthVolume != null ? settings.deviceHealthVolume : settings.globalVolume
executeRoutedTTS(applyDynamicVars(msg), settings.deviceHealthRoutingMode ?: "Global Indoor Speaker Only", settings.globalVolume, targetVol, 2)
addToHistory("DEVICE HEALTH: Live alert dispatched to ${targetAlias} for ${count} critical devices.")
}
}
}
def weatherAlertHandler(evt) {
ensureStateMaps()
def devId = evt.deviceId ? evt.deviceId.toString() : evt.device.id.toString()
def val = evt.value
// Diagnostic log to help you match the IDs
if (settings.enableDebug) {
log.info "WEATHER ALERT: Received event from ${evt.device.displayName} (ID: ${devId})"
}
def isCritical = false
def alertType = ""
// Check if the received ID matches any of your configured sensors
if (settings.swTornadoWarn?.id?.toString() == devId && val == "on") { alertType = "a Tornado Warning"; isCritical = true }
else if (settings.swTstormWarn?.id?.toString() == devId && val == "on") { alertType = "a Severe Thunderstorm Warning"; isCritical = true }
else if (settings.swTornadoWatch?.id?.toString() == devId && val == "on") { alertType = "a Tornado Watch" }
else if (settings.swTstormWatch?.id?.toString() == devId && val == "on") { alertType = "a Severe Thunderstorm Watch" }
else if (settings.swFloodWarn?.id?.toString() == devId && val == "on") { alertType = "a Flood Warning" }
else if (settings.swFloodWatch?.id?.toString() == devId && val == "on") { alertType = "a Flood Watch" }
else if (settings.swHeatWarn?.id?.toString() == devId && val == "on") { alertType = "a Severe Heat Warning" }
else if (settings.swHeatWatch?.id?.toString() == devId && val == "on") { alertType = "a Severe Heat Watch" }
else if (settings.swRain?.id?.toString() == devId) { alertType = val == "on" ? "Rain Started" : "Rain Stopped" }
else if (settings.swSprinkle?.id?.toString() == devId) { alertType = val == "on" ? "Sprinkling Started" : "Sprinkling Stopped" }
if (!alertType) {
log.warn "WEATHER ALERT: No match found. Received ID ${devId} from ${evt.device.displayName}. Check if this device is selected in the app settings."
return
}
// Check allowed modes
if (!isCritical) {
def allowedModes = [settings.weatherAlertModes].flatten().findAll { it != null }
if (allowedModes.size() > 0 && !allowedModes.contains(location.mode)) {
if (settings.enableDebug) log.debug "WEATHER ALERT: Suppressed due to mode restriction."
return
}
}
// Presence check logic
def presentFolks = getPresentUsers()
if (presentFolks.size() == 0 && !isCritical) {
stashMessage("Weather Alert: ${alertType} occurred while you were out.")
return
}
def msg = ""
if (alertType == "Rain Started") msg = "%interruption%. Please be advised that the local sensors indicate it has started to rain outside."
else if (alertType == "Rain Stopped") msg = "%interruption%. The local sensors indicate the rain has now stopped."
else if (alertType == "Sprinkling Started") msg = "%interruption%. The local sensors detect it is currently sprinkling outside."
else if (alertType == "Sprinkling Stopped") msg = "%interruption%. The sprinkling has concluded."
else if (isCritical) {
msg = "Attention %name%. The weather monitoring system has issued ${alertType} for our area. Please take immediate precautions and seek shelter if necessary."
} else {
msg = "%interruption%. The weather monitoring system has issued ${alertType} for our area. Please remain weather aware."
}
def finalMsg = applyDynamicVars(msg)
def targetVol = settings.weatherVolume != null ? settings.weatherVolume.toInteger() : (settings.globalVolume ?: 50)
if (isCritical) {
def targetSpks = []
if (globalIndoorSpeaker) targetSpks.addAll([globalIndoorSpeaker].flatten())
def rMode = settings.weatherRoutingMode ?: "Global Indoor Speaker Only"
if (rMode.contains("Outdoor") && outdoorSpeaker) targetSpks << outdoorSpeaker
def numRoomsSet = settings.numRooms ? settings.numRooms.toInteger() : 0
for (int i = 1; i <= numRoomsSet; i++) {
def gnSwitch = settings["roomGoodNightSwitch_${i}"]
def spk = settings["roomSpeaker_${i}"]
if (gnSwitch && spk && gnSwitch.currentValue("switch") == "on") {
targetSpks << spk
}
}
targetSpks = targetSpks.flatten().findAll { it != null }.unique { it.id }
if (targetSpks.size() > 0) {
enqueueTTS(targetSpks, finalMsg, targetVol, 1, false)
addToHistory("CRITICAL WEATHER: ${alertType} broadcasted.")
} else {
log.warn "WEATHER ALERT: No target speakers identified."
}
} else {
def rMode = settings.weatherRoutingMode ?: "Global Indoor Speaker Only"
executeRoutedTTS(finalMsg, rMode, targetVol, settings.outdoorVolume ?: 50, 1, false)
addToHistory("WEATHER: ${alertType} announced.")
}
}
def testWeatherAlerts() {
ensureStateMaps()
def alertType = "a Severe Thunderstorm Warning"
def msg = "Attention %name%. The weather monitoring system has issued ${alertType} for our area. Please take immediate precautions and seek shelter if necessary."
def finalMsg = applyDynamicVars(msg)
def targetVol = settings.weatherVolume != null ? settings.weatherVolume : settings.globalVolume
// Execute as if it's a critical warning (bypassing normal routing to wake up sleeping rooms)
def targetSpks = []
def rMode = settings.weatherRoutingMode ?: "Global Indoor Speaker Only"
if (rMode.contains("Global") && globalIndoorSpeaker) targetSpks.addAll([globalIndoorSpeaker].flatten())
if (rMode.contains("Outdoor") && outdoorSpeaker) targetSpks << outdoorSpeaker
for (int i = 1; i <= 3; i++) {
def gnSwitch = settings["warnRoomSwitch_${i}"]
def spk = settings["warnRoomSpeaker_${i}"]
if (gnSwitch && spk && gnSwitch.currentValue("switch") == "on") {
targetSpks << spk
}
}
targetSpks = targetSpks.flatten().findAll { it != null }.unique { it.id }
if (targetSpks.size() > 0) enqueueTTS(targetSpks, finalMsg, targetVol, 1, false)
addToHistory("CRITICAL WEATHER: Test ${alertType} broadcasted. Forced overrides applied for sleeping rooms.")
}