/**
* Advanced Sonos Controls
*/
definition(
name: "Advanced Sonos Controls",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Ultimate commercial-grade Sonos BMS. Features Live Diagnostics, Master Kills-Switches, Event Muting, Hardware Protection, and Dynamic Favorites.",
category: "Audio",
iconUrl: "",
iconX2Url: "",
iconX3Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "Advanced Sonos Controls", install: true, uninstall: true) {
// Null-safe module checks (Defaults to true on first install)
def optControlPanel = settings.enableControlPanel == null ? true : settings.enableControlPanel
def optPowerManagement = settings.enablePowerManagement == null ? true : settings.enablePowerManagement
def optCostTracker = settings.enableCostTracker == null ? true : settings.enableCostTracker
def optFavorites = settings.enableFavorites == null ? true : settings.enableFavorites
def optAutoPurge = settings.enableAutoPurge == null ? true : settings.enableAutoPurge
def appIsDisabled = (settings.appEnableSwitch && settings.appEnableSwitch.currentValue("switch") == "off")
// Declare UI state variables globally for the page so all sections can see them
def hasZones = false
def activeZoneOptions = [:]
// ========================================================
// REPORTING & CONTROL DASHBOARDS
// ========================================================
if (appIsDisabled) {
paragraph "
â ī¸ SYSTEM DISABLED: The Master Application Switch is currently OFF. All background automations and overrides are suspended.
"
}
section("Live System Dashboard") {
paragraph "What it does: Provides a real-time, top-down view of your entire Sonos network. Extracts active track data, smart power states, and override locks.
"
def dashHTML = """
| Zone Name | Main Power | Status | Volume | Now Playing |
"""
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"] && settings["z${i}Speaker"]) {
hasZones = true
def spk = settings["z${i}Speaker"]
def sw = settings["z${i}Switch"]
def gnLock = settings["z${i}GoodNightSwitch"]
def customName = settings["z${i}Name"]
def resolvedName = customName ?: (spk.label ?: "Zone ${i}")
activeZoneOptions["${i}"] = resolvedName
// Zone State & Locks
def isLocked = gnLock && (gnLock.currentValue("switch") == "on")
def isEventMuted = (state.doorbellMutedSpks?.contains(spk.id)) || (state.doorOpenMutedSpks?.contains(spk.id))
def zoneNameStr = resolvedName
if (isLocked) {
zoneNameStr += "
đ GN Override Active"
}
// Power & Play Status Logic
def isPoweredOn = sw ? (sw.currentValue("switch") == "on") : true
def pwrStatus = sw ? (isPoweredOn ? "ON" : "OFF") : "N/A"
def playStatus = spk.currentValue("status")?.toUpperCase() ?: "UNKNOWN"
def isMuted = spk.currentValue("mute") == "muted"
def statusIcon = "âĒ"
def statusText = playStatus
def statusColor = "gray"
if (!isPoweredOn) {
statusIcon = "đâ"
statusText = "POWER CUT"
statusColor = "red"
} else if (isEventMuted) {
statusIcon = "đ"
statusText = "EVENT MUTE"
statusColor = "red"
} else if (isMuted) {
statusIcon = "đ"
statusText = "MUTED"
statusColor = "orange"
} else if (playStatus == "PLAYING") {
statusIcon = "âļī¸"
statusColor = "blue"
} else if (playStatus == "PAUSED") {
statusIcon = "â¸ī¸"
statusColor = "orange"
} else if (playStatus == "STOPPED") {
statusIcon = "âšī¸"
statusColor = "gray"
}
def vol = spk.currentValue("volume") ?: "--"
def trackTitle = spk.currentValue("trackDescription")
if (!trackTitle || trackTitle.trim() == "") trackTitle = "Idle / Streaming"
dashHTML += "| ${zoneNameStr} | ${pwrStatus} | ${statusIcon} ${statusText} | ${vol}% | ${trackTitle} |
"
}
}
dashHTML += "
"
if (hasZones) {
input "refreshDash", "button", title: "đ Refresh Dashboard Data"
paragraph dashHTML
} else {
paragraph "Please configure your Sonos zones below to populate the dashboard."
}
}
if (hasZones && optControlPanel) {
section("Active Control Panel") {
paragraph "What it does: Remotely control your zones, broadcast TTS, or manage Virtual Favorites.
"
paragraph "đ¨ Global Emergency Override
"
input "btnPauseAll", "button", title: "đ PAUSE ALL ZONES INSTANTLY", width: 12
input "activeZoneControl", "enum", title: "Select Zone to Control", options: activeZoneOptions, submitOnChange: true
if (activeZoneControl) {
def targetSpk = settings["z${activeZoneControl}Speaker"]
def targetSw = settings["z${activeZoneControl}Switch"]
def targetName = settings["z${activeZoneControl}Name"] ?: (targetSpk ? targetSpk.label : "Zone ${activeZoneControl}")
if (targetSpk) {
def cpPwr = targetSw ? (targetSw.currentValue("switch") == "on" ? "ON" : "OFF") : "N/A"
def cpState = targetSpk.currentValue("status")?.toUpperCase() ?: "UNKNOWN"
def cpVol = targetSpk.currentValue("volume") ?: "--"
def cpTrack = targetSpk.currentValue("trackDescription") ?: "Idle / Unknown"
def cpHTML = """
| đ¯ Active Target: ${targetName} |
Main Power ${cpPwr} |
Status ${cpState} |
Volume ${cpVol}% |
Now Playing ${cpTrack} |
"""
paragraph cpHTML
}
paragraph "đī¸ Basic Transport
"
input "btnPlay", "button", title: "âļī¸ Play", width: 2
input "btnPause", "button", title: "⸠Pause", width: 2
input "btnPrev", "button", title: "⎠Prev", width: 2
input "btnNext", "button", title: "â Next", width: 2
input "btnVolDown", "button", title: "đ Vol -5%", width: 2
input "btnVolUp", "button", title: "đ Vol +5%", width: 2
paragraph "đī¸ Advanced Controls
"
input "btnShuffle", "button", title: "đ Toggle Shuffle", width: 3
input "btnRepeat", "button", title: "đ Toggle Repeat", width: 3
input "btnNightMode", "button", title: "đ Toggle Night Mode", width: 3
input "btnSpeechEnhance", "button", title: "đŖī¸ Toggle Speech Enhance", width: 3
paragraph "đĸ Intercom Broadcast (TTS)
"
input "ttsMessage", "text", title: "Message to Broadcast", required: false, width: 4
input "ttsVolume", "number", title: "Vol (%)", required: false, defaultValue: 40, width: 2
input "ttsPriority", "bool", title: "Emergency Override", defaultValue: false, width: 3
input "btnTTS", "button", title: "đĸ Send TTS", width: 3
paragraph "âŗ Sleep Timer
"
input "sleepTimerMins", "number", title: "Minutes until Pause", required: false, width: 6
input "sleepTimerFade", "number", title: "Fade-Out Duration (Sec)", required: false, width: 3
input "btnSleep", "button", title: "âŗ Start Timer", width: 3
if (state.sleepTimers && state.sleepTimers[activeZoneControl]) {
paragraph "Sleep Timer currently active for this zone."
input "btnCancelSleep", "button", title: "Cancel Timer"
}
if (optFavorites) {
paragraph "â Favorites Management
"
paragraph "Save the current Track URI and volume to a Virtual Switch, or manage existing ones.
"
input "btnSaveFav", "button", title: "â Save Current Track as Virtual Switch", width: 4
def favOptions = [:]
if (state.savedFavorites) {
state.savedFavorites.each { dni, data -> favOptions[dni] = data.name }
}
if (favOptions) {
input "favToDelete", "enum", title: "Select Favorite to Delete", options: favOptions, required: false, width: 5
input "btnDeleteFav", "button", title: "đī¸ Delete Selected", width: 3
}
}
} else {
paragraph "Note: Select a zone from the dropdown above to reveal the transport controls, TTS broadcast, and Favorites Virtual Switch generator.
"
}
}
}
if (optCostTracker) {
section("Energy Cost & ROI Savings Tracking") {
paragraph "What it does: Tracks the exact runtime and idle time of your smart plugs to estimate utility costs.
"
input "costPerKwh", "decimal", title: "Utility Rate (USD per kWh)", required: false, defaultValue: 0.15
if (state.runHistory) {
paragraph "7-Day Energy Cost & Savings Estimate"
paragraph renderCostDashboard()
}
input "resetHistory", "button", title: "Clear Tracking History"
}
}
section("Recent Action History") {
input "txtEnable", "bool", title: "Enable Description Text Logging", defaultValue: true
if (state.actionHistory) {
def historyStr = state.actionHistory.join("
")
paragraph "${historyStr}"
}
}
// ========================================================
// GLOBAL CONTROLS & MODULE TOGGLES
// ========================================================
section("Global Controls & Module Toggles") {
paragraph "Manage global permissions and enable or disable major app features. Disabling a feature removes its configuration options and halts its background processing.
"
input "appEnableSwitch", "capability.switch", title: "Master App Enable/Disable Switch (Blocks Automations when OFF)", required: false
paragraph "
"
input "enableControlPanel", "bool", title: "Enable Active Control Panel & TTS", defaultValue: true, submitOnChange: true
input "enablePowerManagement", "bool", title: "Enable Smart Power Automation", defaultValue: true, submitOnChange: true
input "enableSelfHealing", "bool", title: "Enable Self-Healing on Hub Reboot", defaultValue: true, submitOnChange: true
input "enableCostTracker", "bool", title: "Enable Energy Cost Tracking", defaultValue: true, submitOnChange: true
input "enableFavorites", "bool", title: "Enable Virtual Switch Favorites", defaultValue: true, submitOnChange: true
if (optFavorites) {
input "enableAutoPurge", "bool", title: "Enable Auto-Purge for Favorites (Housekeeping)", defaultValue: true, submitOnChange: true
}
}
// ========================================================
// SYSTEM CONFIGURATION
// ========================================================
for (int i = 1; i <= 10; i++) {
def secTitle = settings["z${i}Name"] ? "${settings["z${i}Name"]} Configuration" : "Zone ${i} Configuration"
section(secTitle, hideable: true, hidden: true) {
input "enableZ${i}", "bool", title: "Enable Zone", submitOnChange: true
if (settings["enableZ${i}"]) {
input "z${i}Name", "text", title: "Custom Zone Name", required: false, submitOnChange: true
input "z${i}Speaker", "capability.musicPlayer", title: "Select Sonos Speaker", required: true
if (optPowerManagement || optCostTracker) {
input "z${i}Switch", "capability.switch", title: "Select Smart Power Plug", required: false
input "z${i}Type", "enum", title: "Speaker Hardware Type", options: [
"Sonos Era 100", "Sonos Era 300",
"Sonos One / One SL / Play:1", "Sonos Play:3", "Sonos Five / Play:5",
"Sonos Beam (Gen 1/2)", "Sonos Arc / Playbar / Playbase", "Sonos Ray",
"Sonos Sub / Sub Mini", "Sonos Amp / Connect:Amp", "Sonos Port / Connect",
"Sonos Move / Roam (Docked)", "IKEA SYMFONISK"
], required: true, defaultValue: "Sonos One / One SL / Play:1"
}
paragraph "Protection & Overrides"
input "z${i}GoodNightSwitch", "capability.switch", title: "Good Night Override Switch (Aborts all automations when ON)", required: false
input "z${i}MaxVol", "number", title: "Maximum Volume Cap (%) - Hardware Protection", required: false, range: "1..100"
if (optPowerManagement) {
paragraph "Power Automation & Startup Settings"
input "z${i}TurnOnModes", "mode", title: "Modes to Power ON this Zone", multiple: true, required: false
input "z${i}TurnOffModes", "mode", title: "Modes to Power OFF this Zone", multiple: true, required: false
input "z${i}StartVol", "number", title: "Default Target Startup Volume (%)", required: false, range: "1..100"
input "z${i}FadeIn", "number", title: "Fade-In Duration (Seconds) - Ramps volume slowly", required: false
input "z${i}AutoResume", "bool", title: "Auto-Resume Playback on Boot", defaultValue: false
paragraph "Mode-Based Music Routine"
input "z${i}ModeRoutineEnabled", "bool", title: "Enable Mode-Based Routine", defaultValue: false, submitOnChange: true
if (settings["z${i}ModeRoutineEnabled"]) {
input "z${i}RoutineMode", "mode", title: "Select Mode to Trigger Routine", required: false
// FIX: Added Blocker Switch for Routine
input "z${i}RoutineBlocker", "capability.switch", title: "Block routine if this switch is ON (e.g., TV)", required: false
input "z${i}RoutineDelay", "number", title: "Delay before playing (seconds)", defaultValue: 10, required: false
def favList = [:]
if (state.savedFavorites) {
state.savedFavorites.each { dni, data -> favList[dni] = data.name }
}
if (favList) {
input "z${i}RoutineFavorite", "enum", title: "Select Favorite to Play", options: favList, required: false
} else {
paragraph "No Virtual Favorites saved yet. Use the Control Panel to save a favorite first."
}
}
}
paragraph "Follow-Me Audio Automation"
input "z${i}FollowMeEnabled", "bool", title: "Enable Follow-Me for this Zone", defaultValue: false, submitOnChange: true
if (settings["z${i}FollowMeEnabled"]) {
input "z${i}FollowMeMotion", "capability.motionSensor", title: "When Motion is Active Here...", required: false, multiple: true
def followMeOpts = [:]
for (int j = 1; j <= 10; j++) {
if (j != i && settings["enableZ${j}"] && settings["z${j}Speaker"]) {
def optName = settings["z${j}Name"] ?: (settings["z${j}Speaker"].label ?: "Zone ${j}")
followMeOpts["${j}"] = optName
}
}
input "z${i}FollowMeSource", "enum", title: "...Pull Music FROM this Zone", options: followMeOpts, required: false
}
}
}
}
section("Event Overrides & Muting", hideable: true, hidden: true) {
paragraph "What it does: Temporarily mutes playing speakers during real-world events. Zones locked by Good Night switches will be ignored to preserve privacy.
"
input "enableEventOverrides", "bool", title: "Enable Event Muting", defaultValue: false, submitOnChange: true
if (enableEventOverrides) {
input "doorbellButton", "capability.pushableButton", title: "Doorbell Button", required: false
input "doorbellButtonNum", "number", title: "Doorbell Button Number", required: false, defaultValue: 1
input "doorbellMuteTime", "number", title: "Seconds to Mute for Doorbell", required: false, defaultValue: 30
paragraph "
"
input "doorSensors", "capability.contactSensor", title: "Perimeter Doors (Mutes when Open)", required: false, multiple: true
}
}
section("Automated Night-Time Sweeps", hideable: true, hidden: true) {
paragraph "What it does: Sweeps through the house at a specific time and lowers all speaker volumes to a safe level to prevent late-night jump scares.
"
input "enableNightSweep", "bool", title: "Enable Night Sweeps", defaultValue: false, submitOnChange: true
if (enableNightSweep) {
input "nightSweepTime", "time", title: "Time to Execute Sweep", required: false
input "nightSweepVol", "number", title: "Safe Night Volume (%)", required: false, defaultValue: 15, range: "0..100"
}
}
if (optFavorites && optAutoPurge) {
section("Virtual Switch Housekeeping") {
paragraph "What it does: Automatically purges old Favorite Virtual Switches to keep your Hubitat database clean.
"
input "purgeDays", "number", title: "Delete favorites older than (Days)", defaultValue: 30
}
}
}
}
// ==============================================================================
// INTERNAL LOGIC ENGINE
// ==============================================================================
def installed() { logInfo("Installed"); initialize() }
def updated() { logInfo("Updated"); unsubscribe(); unschedule(); initialize() }
def isAppEnabled() {
if (settings.appEnableSwitch && settings.appEnableSwitch.currentValue("switch") == "off") return false
return true
}
def isZoneLocked(zNum) {
def sw = settings["z${zNum}GoodNightSwitch"]
return (sw && sw.currentValue("switch") == "on")
}
def initialize() {
if (!state.actionHistory) state.actionHistory = []
if (!state.runHistory) state.runHistory = [:]
if (!state.savedFavorites) state.savedFavorites = [:]
if (!state.sleepTimers) state.sleepTimers = [:]
if (!state.doorbellMutedSpks) state.doorbellMutedSpks = []
if (!state.doorOpenMutedSpks) state.doorOpenMutedSpks = []
if (!state.ttsQueue) state.ttsQueue = []
// Default last follow me time
if (!state.lastFollowMeTime) state.lastFollowMeTime = 0
state.isSpeaking = false
if (settings.enablePowerManagement != false) {
subscribe(location, "mode", modeChangeHandler)
runEvery1Hour("hourlyStateEnforcement")
}
if (settings.enableSelfHealing != false) {
subscribe(location, "systemStart", systemStartHandler)
}
if (settings.enableFavorites != false) {
getChildDevices().each { child -> subscribe(child, "switch", childSwitchHandler) }
if (settings.enableAutoPurge != false) {
schedule("0 0 3 ? * *", "purgeOldFavorites")
}
}
if (settings.enableCostTracker != false) {
runEvery15Minutes("calculateEnergy")
}
if (settings.enableEventOverrides) {
if (settings.doorbellButton) subscribe(settings.doorbellButton, "pushed", doorbellHandler)
if (settings.doorSensors) subscribe(settings.doorSensors, "contact", doorHandler)
}
if (settings.enableNightSweep && settings.nightSweepTime) {
schedule(settings.nightSweepTime, "executeNightSweep")
}
// Hardware Volume Protection & Follow-Me Subscriptions
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"]) {
if (settings["z${i}Speaker"] && settings["z${i}MaxVol"]) {
subscribe(settings["z${i}Speaker"], "volume", volumeHandler)
}
if (settings["z${i}FollowMeEnabled"] && settings["z${i}FollowMeMotion"]) {
subscribe(settings["z${i}FollowMeMotion"], "motion", followMeMotionHandler)
}
}
}
logAction("App Initialized. Advanced Sonos Engine Ready.")
}
// --- BUTTON & DASHBOARD HANDLERS ---
def appButtonHandler(btn) {
if (btn == "refreshDash") return
if (btn == "resetHistory") {
state.runHistory = [:]
logAction("Energy Cost Tracking history cleared.")
return
}
if (btn == "btnPauseAll") {
logAction("đ¨ EMERGENCY PAUSE ALL TRIGGERED đ¨")
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"] && settings["z${i}Speaker"]) settings["z${i}Speaker"].pause()
}
return
}
if (!isAppEnabled()) {
logAction("Master Switch is OFF. Control Panel actions ignored.")
return
}
if (settings.enableControlPanel == false) return
if (activeZoneControl && btn.startsWith("btn")) {
def zNum = activeZoneControl
def spk = settings["z${zNum}Speaker"]
if (!spk) return
if (btn == "btnPlay") { spk.play(); logAction("Command -> Sent PLAY to ${spk.label}") }
if (btn == "btnPause") { spk.pause(); logAction("Command -> Sent PAUSE to ${spk.label}") }
if (btn == "btnNext") { spk.nextTrack(); logAction("Command -> Sent NEXT TRACK to ${spk.label}") }
if (btn == "btnPrev") { spk.previousTrack(); logAction("Command -> Sent PREV TRACK to ${spk.label}") }
if (btn == "btnVolUp") { def v = (spk.currentValue("volume") ?: 0) + 5; spk.setLevel(v > 100 ? 100 : v); logAction("Command -> Vol UP on ${spk.label}") }
if (btn == "btnVolDown") { def v = (spk.currentValue("volume") ?: 0) - 5; spk.setLevel(v < 0 ? 0 : v); logAction("Command -> Vol DOWN on ${spk.label}") }
if (btn == "btnShuffle") {
if (spk.hasCommand("setShuffle")) { spk.setShuffle(true); logAction("Command -> Shuffle toggled on ${spk.label}") }
else logAction("Warning: ${spk.label} driver does not support setShuffle.")
}
if (btn == "btnRepeat") {
if (spk.hasCommand("setRepeat")) { spk.setRepeat(true); logAction("Command -> Repeat toggled on ${spk.label}") }
else logAction("Warning: ${spk.label} driver does not support setRepeat.")
}
if (btn == "btnNightMode") {
if (spk.hasCommand("setNightMode")) { spk.setNightMode(true); logAction("Command -> Night Mode toggled on ${spk.label}") }
else logAction("Warning: ${spk.label} driver does not support native setNightMode.")
}
if (btn == "btnSpeechEnhance") {
if (spk.hasCommand("setSpeechEnhancement")) { spk.setSpeechEnhancement(true); logAction("Command -> Speech Enhance toggled on ${spk.label}") }
else logAction("Warning: ${spk.label} driver does not support native setSpeechEnhancement.")
}
if (btn == "btnTTS" && ttsMessage) {
def curVol = spk.currentValue("volume") ?: 20
def targetVol = ttsVolume ?: 40
def priority = ttsPriority ?: false
queueTTS(spk.id, ttsMessage, targetVol, curVol, priority)
}
if (btn == "btnSleep" && sleepTimerMins) {
state.sleepTimers[zNum] = true
def fadeDur = sleepTimerFade ?: 0
runIn((sleepTimerMins * 60).toInteger(), "executeSleepTimer", [data: [spkId: spk.id, zNum: zNum, fade: fadeDur], overwrite: false])
logAction("Command -> Sleep Timer started for ${spk.label} (${sleepTimerMins} mins, ${fadeDur}s fade).")
}
if (btn == "btnCancelSleep") {
state.sleepTimers[zNum] = false
logAction("Command -> Sleep Timer cancelled for ${spk.label}.")
}
if (settings.enableFavorites != false) {
if (btn == "btnSaveFav") createFavoriteVirtualSwitch(spk)
if (btn == "btnDeleteFav" && settings.favToDelete) {
def dni = settings.favToDelete
def favName = state.savedFavorites[dni]?.name ?: "Unknown"
try { deleteChildDevice(dni) } catch (e) { }
state.savedFavorites.remove(dni)
app.removeSetting("favToDelete")
logAction("Command -> Deleted Favorite Virtual Switch: [${favName}]")
}
}
}
}
// --- QUEUED TTS ENGINE ---
def queueTTS(spkId, message, vol, origVol, priority) {
if (!state.ttsQueue) state.ttsQueue = []
def payload = [spkId: spkId, msg: message, vol: vol, origVol: origVol, id: now()]
if (priority) {
state.ttsQueue.add(0, payload)
logAction("Emergency TTS Override placed at front of queue.")
processTTSQueue(true)
} else {
state.ttsQueue << payload
if (!state.isSpeaking) {
processTTSQueue(false)
}
}
}
def processTTSQueue(force = false) {
if (!state.ttsQueue || state.ttsQueue.size() == 0) {
state.isSpeaking = false
return
}
if (state.isSpeaking && !force) return
state.isSpeaking = true
def payload = state.ttsQueue.remove(0)
def spk = getSpeakerById(payload.spkId)
if (spk) {
spk.setLevel(payload.vol)
spk.speak(payload.msg)
logAction("TTS Broadcast: '${payload.msg}' on ${spk.label}")
def waitTime = Math.max((payload.msg.length() / 15).toInteger(), 6)
runIn(waitTime, "restoreVolumeAndContinueTTS", [data: [spkId: spk.id, origVol: payload.origVol], overwrite: false])
} else {
processTTSQueue(false)
}
}
def restoreVolumeAndContinueTTS(data) {
def spk = getSpeakerById(data.spkId)
if (spk) spk.setLevel(data.origVol)
state.isSpeaking = false
runIn(1, "processTTSQueue", [overwrite: false])
}
// --- VOLUME FADING ENGINE ---
def startVolumeFadeWrapper(data) {
startVolumeFade(data.spkId, data.targetVol, data.durationSec, data.isFadeOut)
}
def startVolumeFade(spkId, targetVol, durationSec, isFadeOut = false) {
def spk = getSpeakerById(spkId)
if (!spk) return
def curVol = spk.currentValue("volume") ?: 0
if (curVol == targetVol) return
// Check for native hardware fade support first (Fix #2)
def hasNativeFade = false
if (spk.hasCommand("setLevel")) {
def setLevelCmd = spk.getSupportedCommands().find { it.name == "setLevel" }
if (setLevelCmd && setLevelCmd.arguments?.size() > 1) {
hasNativeFade = true
}
}
if (hasNativeFade) {
logAction("Using native hardware volume fade for ${spk.label}")
spk.setLevel(targetVol, durationSec)
if (isFadeOut) {
runIn(durationSec + 1, "finalizeNativeFadeOut", [data: [spkId: spk.id, origVol: curVol], overwrite: false])
}
return
}
// Software fallback logic adjusted to prevent queue flooding (Fix #2)
def stepDelay = 3 // Minimum delay between steps to protect hub scheduler
def steps = Math.max((durationSec / stepDelay).toInteger(), 1)
// Cap steps to a maximum of 5 executions per fade process
if (steps > 5) {
steps = 5
stepDelay = (durationSec / steps).toInteger()
}
def volDiff = targetVol - curVol
def stepAmount = volDiff / steps
state["fade_${spkId}"] = [
current: curVol, target: targetVol, stepAmt: stepAmount,
stepsLeft: steps, isFadeOut: isFadeOut, origVol: curVol
]
runIn(stepDelay, "processVolumeFade", [data: [spkId: spkId, delay: stepDelay], overwrite: false])
}
def finalizeNativeFadeOut(data) {
def spk = getSpeakerById(data.spkId)
if (spk) {
spk.pause()
logAction("Fade-out complete for ${spk.label}. Paused.")
runIn(2, "restoreVolume", [data: [spkId: data.spkId, origVol: data.origVol], overwrite: false])
}
}
def processVolumeFade(data) {
def spkId = data.spkId
def spk = getSpeakerById(spkId)
def fadeData = state["fade_${spkId}"]
if (!spk || !fadeData) return
fadeData.stepsLeft = fadeData.stepsLeft - 1
fadeData.current = fadeData.current + fadeData.stepAmt
def newVol = fadeData.current.toInteger()
if (newVol < 0) newVol = 0
if (newVol > 100) newVol = 100
spk.setLevel(newVol)
if (fadeData.stepsLeft > 0) {
state["fade_${spkId}"] = fadeData
runIn(data.delay, "processVolumeFade", [data: [spkId: spkId, delay: data.delay], overwrite: false])
} else {
spk.setLevel(fadeData.target)
if (fadeData.isFadeOut) {
spk.pause()
logAction("Software Fade-out complete for ${spk.label}. Paused.")
runIn(2, "restoreVolume", [data: [spkId: spkId, vol: fadeData.origVol], overwrite: false])
} else {
logAction("Software Fade-in complete for ${spk.label}.")
}
state.remove("fade_${spkId}")
}
}
// --- FOLLOW ME AUDIO ENGINE ---
def followMeMotionHandler(evt) {
if (!isAppEnabled() || evt.value != "active") return
// Follow-Me Debouncing Logic (Fix #4)
def nowTime = now()
if (state.lastFollowMeTime && (nowTime - state.lastFollowMeTime) < 5000) {
logAction("Follow-Me Audio: Ignored concurrent motion trigger (debounced).")
return
}
state.lastFollowMeTime = nowTime
def motionId = evt.device.id
for (int i = 1; i <= 10; i++) {
def motions = settings["z${i}FollowMeMotion"]
if (motions && motions.find { it.id == motionId }) {
def sourceZoneNum = settings["z${i}FollowMeSource"]
if (!sourceZoneNum || isZoneLocked(i)) continue
def srcSpk = settings["z${sourceZoneNum}Speaker"]
def targetSpk = settings["z${i}Speaker"]
if (srcSpk && targetSpk && srcSpk.currentValue("status") == "playing") {
logAction("Follow-Me Triggered: Moving audio from ${srcSpk.label} to ${targetSpk.label}")
def trackUri = srcSpk.currentValue("trackUri") ?: srcSpk.currentValue("uri")
def curVol = srcSpk.currentValue("volume") ?: 15
if (trackUri) {
targetSpk.setLevel(curVol)
targetSpk.setTrack(trackUri)
runIn(1, "triggerPlayOnFav", [data: [spkId: targetSpk.id], overwrite: false])
srcSpk.pause()
}
}
}
}
}
// --- BMS HARDWARE PROTECTION (VOLUME CLAMPING) ---
def volumeHandler(evt) {
if (!isAppEnabled()) return
def spk = evt.device
def vol = evt.value.toInteger()
for (int i = 1; i <= 10; i++) {
if (settings["z${i}Speaker"]?.id == spk.id) {
def maxVol = settings["z${i}MaxVol"]
if (maxVol && vol > maxVol) {
logAction("Hardware Protection Activated! Reduced ${spk.label} from ${vol}% to ${maxVol}%.")
spk.setLevel(maxVol)
}
break
}
}
}
// --- EVENT OVERRIDES (DOORBELL & DOORS) ---
def doorbellHandler(evt) {
if (!isAppEnabled() || !settings.enableEventOverrides) return
def btnNum = settings.doorbellButtonNum ?: 1
if (evt.value == btnNum.toString()) {
logAction("Doorbell rang! Muting applicable playing speakers.")
def mutedSpks = []
for(int i = 1; i <= 10; i++) {
def spk = settings["z${i}Speaker"]
if (spk && !isZoneLocked(i)) {
if (spk.currentValue("status") == "playing" && spk.currentValue("mute") != "muted") {
spk.mute()
mutedSpks << spk.id
}
}
}
state.doorbellMutedSpks = mutedSpks
runIn(settings.doorbellMuteTime ?: 30, "restoreDoorbellMute", [overwrite: false])
}
}
def restoreDoorbellMute() {
state.doorbellMutedSpks?.each { id -> getSpeakerById(id)?.unmute() }
state.doorbellMutedSpks = []
logAction("Doorbell mute timeout finished. Restoring volumes.")
}
def doorHandler(evt) {
if (!isAppEnabled() || !settings.enableEventOverrides) return
def anyOpen = settings.doorSensors.any { it.currentValue("contact") == "open" }
if (anyOpen) {
if (!state.doorOpenMuted) {
logAction("Monitored door opened! Muting applicable speakers.")
def mutedSpks = []
for(int i = 1; i <= 10; i++) {
def spk = settings["z${i}Speaker"]
if (spk && !isZoneLocked(i)) {
if (spk.currentValue("status") == "playing" && spk.currentValue("mute") != "muted") {
spk.mute()
mutedSpks << spk.id
}
}
}
state.doorOpenMuted = true
state.doorOpenMutedSpks = mutedSpks
}
} else {
if (state.doorOpenMuted) {
logAction("Doors closed. Restoring volumes.")
state.doorOpenMutedSpks?.each { id -> getSpeakerById(id)?.unmute() }
state.doorOpenMuted = false
state.doorOpenMutedSpks = []
}
}
}
// --- NIGHT-TIME SWEEPS ---
def executeNightSweep() {
if (!isAppEnabled() || !settings.enableNightSweep) return
logAction("Executing Automated Night-Time Volume Sweep.")
def delayMult = 0
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"] && settings["z${i}Speaker"]) {
if (isZoneLocked(i)) continue
// If a smart plug is configured but currently OFF, skip the volume command to prevent connection timeouts
def sw = settings["z${i}Switch"]
if (sw && sw.currentValue("switch") == "off") {
logAction("Night Sweep: Skipping ${settings["z${i}Speaker"].label} (Powered Off).")
continue
}
def delay = (delayMult * 4) + 1
runIn(delay, "executeStaggeredSweep", [data: [zNum: i], overwrite: false])
delayMult++
}
}
}
def executeStaggeredSweep(data) {
def i = data.zNum.toInteger()
def spk = settings["z${i}Speaker"]
if (spk) {
def targetVol = settings.nightSweepVol ?: 15
spk.setLevel(targetVol)
logAction("Night Sweep: Normalized ${spk.label} to ${targetVol}%.")
}
}
def restoreVolume(data) {
def spk = getSpeakerById(data.spkId)
if (spk) spk.setLevel(data.vol)
}
def executeSleepTimer(data) {
def zNumSafe = data.zNum.toInteger()
if (!state.sleepTimers[zNumSafe]) return
def spk = getSpeakerById(data.spkId)
if (spk) {
if (data.fade && data.fade > 0) {
logAction("Sleep Timer Executed: Initiating Fade-Out for ${spk.label}.")
startVolumeFade(spk.id, 0, data.fade, true)
} else {
spk.pause()
logAction("Sleep Timer Executed: Paused ${spk.label}.")
}
}
state.sleepTimers[zNumSafe] = false
}
// --- FAVORITES & VIRTUAL SWITCH GENERATOR ---
def createFavoriteVirtualSwitch(speaker) {
if (settings.enableFavorites == false) return
def trackUri = speaker.currentValue("trackUri")
def trackDataStr = speaker.currentValue("trackData")
if (!trackUri && trackDataStr) {
try {
def json = new groovy.json.JsonSlurper().parseText(trackDataStr)
trackUri = json?.trackUri ?: json?.enqueuedUri ?: json?.uri
} catch (e) { }
}
if (!trackUri) trackUri = speaker.currentValue("uri")
if (!trackUri) trackUri = speaker.currentValue("enqueuedUri")
def trackName = speaker.currentValue("trackDescription")
if (!trackName || trackName.trim() == "") trackName = speaker.currentValue("name")
if (!trackName || trackName.trim() == "") {
if (trackDataStr) {
try {
def json = new groovy.json.JsonSlurper().parseText(trackDataStr)
trackName = json?.station ?: json?.title ?: ""
} catch (e) { }
}
}
if (!trackName || trackName.trim() == "") trackName = "Custom Stream (${now().toString().substring(8)})"
def curVol = speaker.currentValue("volume") ?: 20
if (!trackUri) {
logAction("ERROR: Cannot create favorite. No track URI detected on ${speaker.label}.")
return
}
def safeName = trackName.replaceAll("[^a-zA-Z0-9 ]", "").trim()
if (safeName.length() > 30) safeName = safeName.substring(0, 30)
def dni = "SONOS_FAV_${app.id}_${now()}"
def label = "Sonos Fav - ${safeName}"
try {
def child = addChildDevice("hubitat", "Virtual Switch", dni, [label: label, name: label, isComponent: false])
if (!state.savedFavorites) state.savedFavorites = [:]
state.savedFavorites[dni] = [uri: trackUri, speakerId: speaker.id, name: trackName, vol: curVol, timestamp: now()]
subscribe(child, "switch", childSwitchHandler)
logAction("SUCCESS: Created Virtual Switch [${label}] to recall current track at ${curVol}%.")
} catch (e) { logAction("ERROR: Failed to create virtual switch. Check hub logs.") }
}
def childSwitchHandler(evt) {
if (settings.enableFavorites == false) return
if (evt.value == "on") {
def dni = evt.device.deviceNetworkId
def favData = state.savedFavorites[dni]
if (favData) {
def speaker = getSpeakerById(favData.speakerId)
if (speaker) {
logAction("Triggered Favorite via Switch. Setting volume to ${favData.vol}% and playing [${favData.name}] on ${speaker.label}")
if (favData.vol != null) speaker.setLevel(favData.vol)
speaker.setTrack(favData.uri)
runIn(2, "triggerPlayOnFav", [data: [spkId: speaker.id], overwrite: false])
}
}
runIn(3, "turnOffChild", [data: [dni: dni], overwrite: false])
}
}
def triggerPlayOnFav(data) { getSpeakerById(data.spkId)?.play() }
def turnOffChild(data) { getChildDevice(data.dni)?.off() }
def getSpeakerById(id) { for (int i = 1; i <= 10; i++) { if (settings["z${i}Speaker"]?.id == id) return settings["z${i}Speaker"] }; return null }
// --- HOUSEKEEPING ---
def purgeOldFavorites() {
if (settings.enableFavorites == false || settings.enableAutoPurge == false) return
def threshold = now() - ((purgeDays ?: 30) * 86400000L)
def children = getChildDevices()
children.each { child ->
def dni = child.deviceNetworkId
def favData = state.savedFavorites[dni]
if (favData && favData.timestamp && favData.timestamp < threshold) {
deleteChildDevice(dni)
state.savedFavorites.remove(dni)
logAction("Housekeeping: Auto-Purged old favorite switch [${child.label}]")
}
}
}
// --- POWER MANAGEMENT LOGIC & SELF-HEALING ENGINE ---
def hourlyStateEnforcement() {
if (!isAppEnabled() || settings.enablePowerManagement == false) return
def currentMode = location.mode?.toString()
def delayMult = 0
def zonesFixed = false
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"] && settings["z${i}Switch"]) {
if (isZoneLocked(i)) continue
def sw = settings["z${i}Switch"]
def onModes = [settings["z${i}TurnOnModes"]].flatten().findAll { it }
def offModes = [settings["z${i}TurnOffModes"]].flatten().findAll { it }
def currentState = sw.currentValue("switch")
if (onModes && onModes.contains(currentMode) && currentState != "on") {
def delay = delayMult * 6 + 1
runIn(delay, "enforcePowerState", [data: [zNum: i, state: "on"], overwrite: false])
delayMult++
zonesFixed = true
} else if (offModes && offModes.contains(currentMode) && currentState != "off") {
def delay = delayMult * 6 + 1
runIn(delay, "enforcePowerState", [data: [zNum: i, state: "off"], overwrite: false])
delayMult++
zonesFixed = true
}
}
}
if (zonesFixed) {
logAction("Hourly Enforcement: Correcting missing power states...")
}
}
def enforcePowerState(data) {
def sw = settings["z${data.zNum.toInteger()}Switch"]
if (sw) {
if (data.state == "on") {
sw.on()
logAction("Hourly Enforcement: Turned ON Zone ${data.zNum}.")
} else {
sw.off()
logAction("Hourly Enforcement: Turned OFF Zone ${data.zNum}.")
}
}
}
def modeChangeHandler(evt) {
if (!isAppEnabled() || settings.enablePowerManagement == false) return
syncSystemToMode(evt.value, "Mode Change")
}
def systemStartHandler(evt) {
if (!isAppEnabled() || settings.enablePowerManagement == false || settings.enableSelfHealing == false) return
logAction("đ Hub Reboot/Power Outage Detected. Waiting 60s for mesh networks to settle before Self-Healing Sync...")
runIn(60, "executeSelfHealingSync", [overwrite: false])
}
def executeSelfHealingSync() {
logAction("đ Initiating Self-Healing Sync now...")
syncSystemToMode(location.mode?.toString(), "Self-Healing")
}
def syncSystemToMode(currentMode, triggerSource) {
def zonesToTurnOn = []
def zonesToTurnOff = []
def zonesToRunRoutine = []
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"]) {
if (isZoneLocked(i)) {
logAction("${triggerSource} Engine skipping Zone ${i} (Good Night Override Active).")
continue
}
def onModes = [settings["z${i}TurnOnModes"]].flatten().findAll { it }
def offModes = [settings["z${i}TurnOffModes"]].flatten().findAll { it }
if (onModes && onModes.contains(currentMode)) zonesToTurnOn << i
else if (offModes && offModes.contains(currentMode)) zonesToTurnOff << i
if (settings["z${i}ModeRoutineEnabled"] && settings["z${i}RoutineMode"] == currentMode) {
zonesToRunRoutine << i
}
}
}
if (zonesToTurnOn) {
logAction("${triggerSource}: Mode is ${currentMode}. Initiating Startup sequence for active zones.")
powerUpSpecificZones(zonesToTurnOn, zonesToRunRoutine)
}
if (zonesToTurnOff) {
logAction("${triggerSource}: Mode is ${currentMode}. Initiating Failsafe Pause & Shutdown for inactive zones.")
gracefulShutdownSpecificZones(zonesToTurnOff)
}
if (zonesToRunRoutine) {
logAction("${triggerSource}: Mode is ${currentMode}. Initiating Music Routines.")
def rDelayMult = 0
zonesToRunRoutine.each { zNum ->
def delay = (rDelayMult * 6) + 2
runIn(delay, "executeModeRoutine", [data: [zNum: zNum], overwrite: false])
rDelayMult++
}
}
}
def powerUpSpecificZones(zones, routineZones = []) {
def delayMult = 0
zones.each { i ->
if (settings["z${i}Switch"]) {
def delay = delayMult * 6 + 1
runIn(delay, "executeStaggeredPowerOn", [data: [zNum: i, isRoutine: routineZones.contains(i)], overwrite: false])
delayMult++
}
}
if(zones) logAction("Master power switches scheduled to turn ON (Staggered).")
}
def executeStaggeredPowerOn(data) {
def i = data.zNum.toInteger()
def isRoutine = data.isRoutine
def sw = settings["z${i}Switch"]
def spk = settings["z${i}Speaker"]
if (sw) sw.on()
if (settings["z${i}StartVol"] && spk) {
def targetVol = settings["z${i}StartVol"]
def fadeDur = settings["z${i}FadeIn"]
if (fadeDur && fadeDur > 0) {
spk.setLevel(0)
runIn(5, "startVolumeFadeWrapper", [data: [spkId: spk.id, targetVol: targetVol, durationSec: fadeDur, isFadeOut: false], overwrite: false])
} else {
runIn(5, "setStartupVolume", [data: [spkId: spk.id, vol: targetVol], overwrite: false])
}
}
if (settings["z${i}AutoResume"] && spk && !isRoutine) {
runIn(60, "triggerAutoResume", [data: [spkId: spk.id], overwrite: false])
}
}
def executeModeRoutine(data) {
def i = data.zNum.toInteger()
def spk = settings["z${i}Speaker"]
def sw = settings["z${i}Switch"]
def blocker = settings["z${i}RoutineBlocker"]
def delay = settings["z${i}RoutineDelay"] ?: 10
def favDni = settings["z${i}RoutineFavorite"]
// FIX: Check for TV Blocker before executing the routine
if (blocker && blocker.currentValue("switch") == "on") {
logAction("Routine skipped for ${spk?.label ?: 'Zone '+i}: Blocker switch '${blocker.displayName}' is ON.")
return
}
if (sw && sw.currentValue("switch") != "on") sw.on()
if (favDni && state.savedFavorites && state.savedFavorites[favDni]) {
runIn(delay, "playRoutineFavorite", [data: [spkId: spk.id, favDni: favDni], overwrite: false])
logAction("Routine scheduled for ${spk.label} in ${delay} seconds.")
} else {
logAction("Routine skipped for ${spk.label}: No valid favorite selected.")
}
}
def playRoutineFavorite(data) {
def spk = getSpeakerById(data.spkId)
def favData = state.savedFavorites[data.favDni]
if (spk && favData) {
if (favData.vol != null) spk.setLevel(favData.vol)
spk.setTrack(favData.uri)
runIn(2, "triggerPlayOnFav", [data: [spkId: spk.id], overwrite: false])
logAction("Routine Triggered: Playing favorite [${favData.name}] on ${spk.label}")
}
}
def gracefulShutdownSpecificZones(zones) {
def commandsSent = false
zones.each { i ->
if (settings["z${i}Speaker"] && settings["z${i}Switch"]) {
if (settings["z${i}Switch"].currentValue("switch") == "on" && settings["z${i}Speaker"].currentValue("status") == "playing") {
settings["z${i}Speaker"].pause()
commandsSent = true
logAction("Failsafe: Paused ${settings['z${i}Speaker'].label} prior to power cut.")
}
}
}
runIn(commandsSent ? 5 : 1, "executePowerCutSpecificZones", [data: [zonesToCut: zones], overwrite: false])
}
def executePowerCutSpecificZones(data) {
def zones = data.zonesToCut
def delayMult = 0
zones.each { i ->
if (settings["z${i}Switch"]) {
def delay = delayMult * 6 + 1
runIn(delay, "executeStaggeredPowerOff", [data: [zNum: i], overwrite: false])
delayMult++
}
}
logAction("Master power switches successfully scheduled to turn OFF (Staggered).")
}
def executeStaggeredPowerOff(data) {
settings["z${data.zNum.toInteger()}Switch"]?.off()
}
def setStartupVolume(data) {
def spk = getSpeakerById(data.spkId)
if (spk) {
spk.setLevel(data.vol)
logAction("Startup Volume Normalization: Set ${spk.label} to ${data.vol}%")
}
}
def triggerAutoResume(data) {
def spk = getSpeakerById(data.spkId)
if (spk) {
spk.play()
logAction("Auto-Resume: Resumed playback on ${spk.label} after boot up.")
}
}
// --- ENERGY & ROI TRACKING ---
def getPowerProfiles(type) {
switch(type) {
case "Sonos Era 100": return [idle: 2.0, play: 10.0]
case "Sonos Era 300": return [idle: 2.0, play: 22.5]
case "Sonos One / One SL / Play:1": return [idle: 3.8, play: 15.0]
case "Sonos Play:3": return [idle: 4.0, play: 15.0]
case "Sonos Five / Play:5": return [idle: 2.0, play: 20.0]
case "Sonos Beam (Gen 1/2)": return [idle: 6.3, play: 15.0]
case "Sonos Arc / Playbar / Playbase": return [idle: 4.0, play: 20.0]
case "Sonos Ray": return [idle: 3.0, play: 12.0]
case "Sonos Sub / Sub Mini": return [idle: 4.0, play: 15.0]
case "Sonos Amp / Connect:Amp": return [idle: 7.3, play: 30.0]
case "Sonos Port / Connect": return [idle: 3.0, play: 5.0]
case "Sonos Move / Roam (Docked)": return [idle: 2.0, play: 8.0]
case "IKEA SYMFONISK": return [idle: 4.0, play: 15.0]
default: return [idle: 3.5, play: 15.0]
}
}
def calculateEnergy() {
if (settings.enableCostTracker == false) return
def today = new Date().format("yyyy-MM-dd", location.timeZone)
if (!state.runHistory) state.runHistory = [:]
if (!state.runHistory[today]) state.runHistory[today] = [usedWh: 0.0, savedWh: 0.0]
def fractionOfHour = 0.25
for (int i = 1; i <= 10; i++) {
if (settings["enableZ${i}"] && settings["z${i}Speaker"] && settings["z${i}Type"]) {
def sw = settings["z${i}Switch"]
def profile = getPowerProfiles(settings["z${i}Type"])
if (sw ? (sw.currentValue("switch") == "on") : true) {
if (settings["z${i}Speaker"].currentValue("status") == "playing") state.runHistory[today].usedWh += (profile.play * fractionOfHour)
else state.runHistory[today].usedWh += (profile.idle * fractionOfHour)
} else {
state.runHistory[today].savedWh += (profile.idle * fractionOfHour)
}
}
}
// Strict Pruning to prevent State Bloat (Fix #3)
def keys = state.runHistory.keySet().sort().reverse()
if (keys.size() > 7) {
def keysToKeep = keys[0..6]
state.runHistory = state.runHistory.findAll { it.key in keysToKeep }
}
}
def renderCostDashboard() {
def html = "| Date | Est. Power Used (kWh) | Avoided Power (kWh) | Est. Cost | Est. Savings |
"
def totalCost = 0.0; def totalSavings = 0.0
state.runHistory.keySet().sort().reverse().each { date ->
def data = state.runHistory[date]
def usedKwh = (data.usedWh ?: 0.0) / 1000.0; def savedKwh = (data.savedWh ?: 0.0) / 1000.0
def dayCost = usedKwh * (costPerKwh ?: 0.15); def daySave = savedKwh * (costPerKwh ?: 0.15)
totalCost += dayCost; totalSavings += daySave
def saveStyle = daySave > 0 ? "color:green; font-weight:bold;" : "color:gray;"
html += "| ${date} | ${String.format('%.3f', usedKwh)} | ${String.format('%.3f', savedKwh)} | $${String.format('%.2f', dayCost)} | +$${String.format('%.2f', daySave)} |
"
}
html += "| 7-Day Totals: | $${String.format('%.2f', totalCost)} | +$${String.format('%.2f', totalSavings)} |
"
return html
}
def logAction(msg) {
if(txtEnable) log.info "${app.label}: ${msg}"
def h = state.actionHistory ?: []
h.add(0, "[${new Date().format("MM/dd hh:mm a", location.timeZone)}] ${msg}")
if(h.size() > 30) h = h[0..29]
state.actionHistory = h
}
def logInfo(msg) { if(txtEnable) log.info "${app.label}: ${msg}" }