/**
* Bose SoundTouch
*
* Copyright 2015 SmartThings
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
*/
// Needed to be able to serialize the XmlSlurper data back to XML
import groovy.xml.XmlUtil
// for the UI
metadata {
definition (name: "Bose SoundTouch", namespace: "smartthings", author: "SmartThings") {
/**
* List our capabilties. Doing so adds predefined command(s) which
* belong to the capability.
*/
capability "Switch"
capability "Refresh"
capability "Music Player"
capability "Health Check"
/**
* Define all commands, ie, if you have a custom action not
* covered by a capability, you NEED to define it here or
* the call will not be made.
*
* To call a capability function, just prefix it with the name
* of the capability, for example, refresh would be "refresh.refresh"
*/
command "preset1"
command "preset2"
command "preset3"
command "preset4"
command "preset5"
command "preset6"
command "aux"
command "everywhereJoin"
command "everywhereLeave"
command "forceOff"
command "forceOn"
}
/**
* Define the various tiles and the states that they can be in.
* The 2nd parameter defines an event which the tile listens to,
* if received, it tries to map it to a state.
*
* You can also use ${currentValue} for the value of the event
* or ${name} for the name of the event. Just make SURE to use
* single quotes, otherwise it will only be interpreted at time of
* launch, instead of every time the event triggers.
*/
valueTile("nowplaying", "device.nowplaying", width: 2, height: 1, decoration:"flat") {
state "nowplaying", label:'${currentValue}', action:"refresh.refresh"
}
standardTile("switch", "device.switch", width: 1, height: 1, canChangeIcon: true) {
state "on", label: '${name}', action: "forceOff", icon: "st.Electronics.electronics16", backgroundColor: "#79b821", nextState:"turningOff"
state "turningOff", label:'TURNING OFF', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff"
state "off", label: '${name}', action: "forceOn", icon: "st.Electronics.electronics16", backgroundColor: "#ffffff", nextState:"turningOn"
state "turningOn", label:'TURNING ON', icon:"st.Electronics.electronics16", backgroundColor:"#79b821"
}
valueTile("1", "device.station1", decoration: "flat", canChangeIcon: false) {
state "station1", label:'${currentValue}', action:"preset1"
}
valueTile("2", "device.station2", decoration: "flat", canChangeIcon: false) {
state "station2", label:'${currentValue}', action:"preset2"
}
valueTile("3", "device.station3", decoration: "flat", canChangeIcon: false) {
state "station3", label:'${currentValue}', action:"preset3"
}
valueTile("4", "device.station4", decoration: "flat", canChangeIcon: false) {
state "station4", label:'${currentValue}', action:"preset4"
}
valueTile("5", "device.station5", decoration: "flat", canChangeIcon: false) {
state "station5", label:'${currentValue}', action:"preset5"
}
valueTile("6", "device.station6", decoration: "flat", canChangeIcon: false) {
state "station6", label:'${currentValue}', action:"preset6"
}
valueTile("aux", "device.switch", decoration: "flat", canChangeIcon: false) {
state "default", label:'Auxillary\nInput', action:"aux"
}
standardTile("refresh", "device.nowplaying", decoration: "flat", canChangeIcon: false) {
state "default", label:'', action:"refresh", icon:"st.secondary.refresh"
}
controlTile("volume", "device.volume", "slider", height:1, width:3, range:"(0..100)") {
state "volume", action:"music Player.setLevel"
}
standardTile("playpause", "device.playpause", decoration: "flat") {
state "pause", label:'', icon:'st.sonos.play-btn', action:'music Player.play'
state "play", label:'', icon:'st.sonos.pause-btn', action:'music Player.pause'
}
standardTile("prev", "device.switch", decoration: "flat", canChangeIcon: false) {
state "default", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn"
}
standardTile("next", "device.switch", decoration: "flat", canChangeIcon: false) {
state "default", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn"
}
valueTile("everywhere", "device.everywhere", width:2, height:1, decoration:"flat") {
state "join", label:"Join\nEverywhere", action:"everywhereJoin"
state "leave", label:"Leave\nEverywhere", action:"everywhereLeave"
// Final state is used if the device is in a state where joining is not possible
state "unavailable", label:"Not Available"
}
// Defines which tile to show in the overview
main "switch"
// Defines which tile(s) to show when user opens the detailed view
details ([
"nowplaying", "refresh", // Row 1 (112)
"prev", "playpause", "next", // Row 2 (123)
"volume", // Row 3 (111)
"1", "2", "3", // Row 4 (123)
"4", "5", "6", // Row 5 (123)
"aux", "everywhere"]) // Row 6 (122)
}
/**************************************************************************
* The following section simply maps the actions as defined in
* the metadata into onAction() calls.
*
* This is preferred since some actions can be dealt with more
* efficiently this way. Also keeps all user interaction code in
* one place.
*
*/
def off() {
if (device.currentState("switch")?.value == "on") {
onAction("off")
}
}
def forceOff() {
onAction("off")
}
def on() {
if (device.currentState("switch")?.value == "off") {
onAction("on")
}
}
def forceOn() {
onAction("on")
}
def volup() { onAction("volup") }
def voldown() { onAction("voldown") }
def preset1() { onAction("1") }
def preset2() { onAction("2") }
def preset3() { onAction("3") }
def preset4() { onAction("4") }
def preset5() { onAction("5") }
def preset6() { onAction("6") }
def aux() { onAction("aux") }
def refresh() { onAction("refresh") }
def setLevel(level) { onAction("volume", level) }
def play() { onAction("play") }
def pause() { onAction("pause") }
def mute() { onAction("mute") }
def unmute() { onAction("unmute") }
def previousTrack() { onAction("previous") }
def nextTrack() { onAction("next") }
def everywhereJoin() { onAction("ejoin") }
def everywhereLeave() { onAction("eleave") }
/**************************************************************************/
/**
* Main point of interaction with things.
* This function is called by SmartThings Cloud with the resulting data from
* any action (see HubAction()).
*
* Conversely, to execute any actions, you need to return them as a single
* item or a list (flattened).
*
* @param data Data provided by the cloud
* @return an action or a list() of actions. Can also return null if no further
* action is desired at this point.
*/
def parse(String event) {
def data = parseLanMessage(event)
def actions = []
// List of permanent root node handlers
def handlers = [
"nowPlaying" : "boseParseNowPlaying",
"volume" : "boseParseVolume",
"presets" : "boseParsePresets",
"zone" : "boseParseEverywhere",
]
// No need to deal with non-XML data
if (!data.headers || !data.headers?."content-type".contains("xml"))
return null
// Move any pending callbacks into ready state
prepareCallbacks()
def xml = new XmlSlurper().parseText(data.body)
// Let each parser take a stab at it
handlers.each { node,func ->
if (xml.name() == node)
actions << "$func"(xml)
}
// If we have callbacks waiting for this...
actions << processCallbacks(xml)
// Be nice and helpful
if (actions.size() == 0) {
log.warn "parse(): Unhandled data = " + lan
return null
}
// Issue new actions
return actions.flatten()
}
/**
* Called when the devicetype is first installed.
*
* @return action(s) to take or null
*/
def installed() {
// Notify health check about this device with timeout interval 12 minutes
sendEvent(name: "checkInterval", value: 12 * 60, data: [protocol: "lan", hubHardwareId: device.hub.hardwareID], displayed: false)
startPoll()
}
/**
* Called by health check if no events been generated in the last 12 minutes
* If device doesn't respond it will be marked offline (not available)
*/
def ping() {
TRACE("ping")
boseSendGetNowPlaying()
}
/**
* Schedule a 2 minute poll of the device to refresh the
* tiles so the user gets the correct information.
*/
def startPoll() {
TRACE("startPoll")
unschedule()
// Schedule 2 minute polling of speaker status (song average length is 3-4 minutes)
def sec = Math.round(Math.floor(Math.random() * 60))
//def cron = "$sec 0/5 * * * ?" // every 5 min
def cron = "$sec 0/2 * * * ?" // every 2 min
log.debug "schedule('$cron', boseSendGetNowPlaying)"
schedule(cron, boseSendGetNowPlaying)
}
/**
* Responsible for dealing with user input and taking the
* appropiate action.
*
* @param user The user interaction
* @param data Additional data (optional)
* @return action(s) to take (or null if none)
*/
def onAction(String user, data=null) {
log.info "onAction(${user})"
// Keep IP address current (since device may have changed)
state.address = parent.resolveDNI2Address(device.deviceNetworkId)
// Process action
def actions = null
switch (user) {
case "on":
boseSetPowerState(true)
break
case "off":
boseSetNowPlaying(null, "STANDBY")
boseSetPowerState(false)
break
case "volume":
actions = boseSetVolume(data)
break
case "aux":
boseSetNowPlaying(null, "AUX")
boseZoneReset()
sendEvent(name:"everywhere", value:"unavailable")
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
actions = boseSetInput(user)
break
case "refresh":
boseSetNowPlaying(null, "REFRESH")
actions = [boseRefreshNowPlaying(), boseGetPresets(), boseGetVolume(), boseGetEverywhereState()]
break
case "play":
actions = [boseSetPlayMode(true), boseRefreshNowPlaying()]
break
case "pause":
actions = [boseSetPlayMode(false), boseRefreshNowPlaying()]
break
case "previous":
actions = [boseChangeTrack(-1), boseRefreshNowPlaying()]
break
case "next":
actions = [boseChangeTrack(1), boseRefreshNowPlaying()]
break
case "mute":
actions = boseSetMute(true)
break
case "unmute":
actions = boseSetMute(false)
break
case "ejoin":
actions = boseZoneJoin()
break
case "eleave":
actions = boseZoneLeave()
break
default:
log.error "Unhandled action: " + user
}
// Make sure we don't have nested lists
if (actions instanceof List)
return actions.flatten()
return actions
}
/**
* Joins this speaker into the everywhere zone
*/
def boseZoneJoin() {
def results = []
def posts = parent.boseZoneJoin(this)
for (post in posts) {
if (post['endpoint'])
results << bosePOST(post['endpoint'], post['body'], post['host'])
}
sendEvent(name:"everywhere", value:"leave")
results << boseRefreshNowPlaying()
return results
}
/**
* Removes this speaker from the everywhere zone
*/
def boseZoneLeave() {
def results = []
def posts = parent.boseZoneLeave(this)
for (post in posts) {
if (post['endpoint'])
results << bosePOST(post['endpoint'], post['body'], post['host'])
}
sendEvent(name:"everywhere", value:"join")
results << boseRefreshNowPlaying()
return results
}
/**
* Removes this speaker and any children WITHOUT
* signaling the speakers themselves. This is needed
* in certain cases where we know the user action will
* cause the zone to collapse (for example, AUX)
*/
def boseZoneReset() {
parent.boseZoneReset()
}
/**
* Handles information and can also
* perform addtional actions if there is a pending command
* stored in the state variable. For example, the power is
* handled this way.
*
* @param xmlData Data to parse
* @return command
*/
def boseParseNowPlaying(xmlData) {
def result = []
// Perform display update, allow it to add additional commands
if (boseSetNowPlaying(xmlData)) {
result << boseRefreshNowPlaying()
}
return result
}
/**
* Parses volume data
*
* @param xmlData Data to parse
* @return command
*/
def boseParseVolume(xmlData) {
def result = []
sendEvent(name:"volume", value:xmlData.actualvolume.text())
sendEvent(name:"mute", value:(Boolean.toBoolean(xmlData.muteenabled.text()) ? "unmuted" : "muted"))
return result
}
/**
* Parses the result of the boseGetEverywhereState() call
*
* @param xmlData
*/
def boseParseEverywhere(xmlData) {
// No good way of detecting the correct state right now
}
/**
* Parses presets and updates the buttons
*
* @param xmlData Data to parse
* @return command
*/
def boseParsePresets(xmlData) {
def result = []
state.preset = [:]
def missing = ["1", "2", "3", "4", "5", "6"]
for (preset in xmlData.preset) {
def id = preset.attributes()['id']
def name = preset.ContentItem.itemName[0].text().replaceAll(~/ +/, "\n")
if (name == "##TRANS_SONGS##")
name = "Local\nPlaylist"
sendEvent(name:"station${id}", value:name)
missing = missing.findAll { it -> it != id }
// Store the presets into the state for recall later
state.preset["$id"] = XmlUtil.serialize(preset.ContentItem)
}
for (id in missing) {
state.preset["$id"] = null
sendEvent(name:"station${id}", value:"Preset $id\n\nNot set")
}
return result
}
/**
* Based on , updates the visual
* representation of the speaker
*
* @param xmlData The nowPlaying info
* @param override Provide the source type manually (optional)
*
* @return true if it would prefer a refresh soon
*/
def boseSetNowPlaying(xmlData, override=null) {
def needrefresh = false
def nowplaying = null
if (xmlData && xmlData.playStatus) {
switch(xmlData.playStatus) {
case "BUFFERING_STATE":
nowplaying = "Please wait\nBuffering..."
needrefresh = true
break
case "PLAY_STATE":
sendEvent(name:"playpause", value:"play")
break
case "PAUSE_STATE":
case "STOP_STATE":
sendEvent(name:"playpause", value:"pause")
break
}
}
// If the previous section didn't handle this, take another stab at it
if (!nowplaying) {
nowplaying = ""
switch (override ? override : xmlData.attributes()['source']) {
case "AUX":
nowplaying = "Auxiliary Input"
break
case "AIRPLAY":
nowplaying = "Air Play"
break
case "STANDBY":
nowplaying = "Standby"
break
case "INTERNET_RADIO":
nowplaying = "${xmlData.stationName.text()}\n\n${xmlData.description.text()}"
break
case "REFRESH":
nowplaying = "Please wait"
break
case "SPOTIFY":
case "DEEZER":
case "PANDORA":
case "IHEART":
if (xmlData.ContentItem.itemName[0])
nowplaying += "[${xmlData.ContentItem.itemName[0].text()}]\n\n"
case "STORED_MUSIC":
nowplaying += "${xmlData.track.text()}"
if (xmlData.artist)
nowplaying += "\nby\n${xmlData.artist.text()}"
if (xmlData.album)
nowplaying += "\n\n(${xmlData.album.text()})"
break
default:
if (xmlData != null)
nowplaying = "${xmlData.ContentItem.itemName[0].text()}"
else
nowplaying = "Unknown"
}
}
// Some last parsing which only deals with actual data from device
if (xmlData) {
if (xmlData.attributes()['source'] == "STANDBY") {
log.trace "nowPlaying reports standby: " + XmlUtil.serialize(xmlData)
sendEvent(name:"switch", value:"off")
} else {
sendEvent(name:"switch", value:"on")
}
boseSetPlayerAttributes(xmlData)
}
// Do not allow a standby device or AUX to be master
if (!parent.boseZoneHasMaster() && (override ? override : xmlData.attributes()['source']) == "STANDBY")
sendEvent(name:"everywhere", value:"unavailable")
else if ((override ? override : xmlData.attributes()['source']) == "AUX")
sendEvent(name:"everywhere", value:"unavailable")
else if (boseGetZone()) {
log.info "We're in the zone: " + boseGetZone()
sendEvent(name:"everywhere", value:"leave")
} else
sendEvent(name:"everywhere", value:"join")
sendEvent(name:"nowplaying", value:nowplaying)
return needrefresh
}
/**
* Updates the attributes exposed by the music Player capability
*
* @param xmlData The NowPlaying XML data
*/
def boseSetPlayerAttributes(xmlData) {
// Refresh attributes
def trackText = ""
def trackDesc = ""
def trackData = [:]
switch (xmlData.attributes()['source']) {
case "STANDBY":
trackData["station"] = trackText = trackDesc = "Standby"
break
case "AUX":
trackData["station"] = trackText = trackDesc = "Auxiliary Input"
break
case "AIRPLAY":
trackData["station"] = trackText = trackDesc = "Air Play"
break
case "SPOTIFY":
case "DEEZER":
case "PANDORA":
case "IHEART":
case "STORED_MUSIC":
trackText = trackDesc = "${xmlData.track.text()}"
trackData["name"] = xmlData.track.text()
if (xmlData.artist) {
trackText += " by ${xmlData.artist.text()}"
trackDesc += " - ${xmlData.artist.text()}"
trackData["artist"] = xmlData.artist.text()
}
if (xmlData.album) {
trackText += " (${xmlData.album.text()})"
trackData["album"] = xmlData.album.text()
}
break
case "INTERNET_RADIO":
trackDesc = xmlData.stationName.text()
trackText = xmlData.stationName.text() + ": " + xmlData.description.text()
trackData["station"] = xmlData.stationName.text()
break
default:
trackText = trackDesc = xmlData.ContentItem.itemName[0].text()
}
sendEvent(name:"trackDescription", value:trackDesc, descriptionText:trackText)
}
/**
* Queries the state of the "play everywhere" mode
*
* @return command
*/
def boseGetEverywhereState() {
return boseGET("/getZone")
}
/**
* Generates a remote key event
*
* @param key The name of the key
*
* @return command
*
* @note It's VITAL that it's done as two requests, or it will ignore the
* the second key info.
*/
def boseKeypress(key) {
def press = "${key}"
def release = "${key}"
return [bosePOST("/key", press), bosePOST("/key", release)]
}
/**
* Pauses or plays current preset
*
* @param play If true, plays, else it pauses (depending on preset, may stop)
*
* @return command
*/
def boseSetPlayMode(boolean play) {
log.trace "Sending " + (play ? "PLAY" : "PAUSE")
return boseKeypress(play ? "PLAY" : "PAUSE")
}
/**
* Sets the volume in a deterministic way.
*
* @param New volume level, ranging from 0 to 100
*
* @return command
*/
def boseSetVolume(int level) {
def result = []
int vol = Math.min(100, Math.max(level, 0))
sendEvent(name:"volume", value:"${vol}")
return [bosePOST("/volume", "${vol}"), boseGetVolume()]
}
/**
* Sets the mute state, unfortunately, for now, we need to query current
* state before taking action (no discrete mute/unmute)
*
* @param mute If true, mutes the system
* @return command
*/
def boseSetMute(boolean mute) {
queueCallback('volume', 'cb_boseSetMute', mute ? 'MUTE' : 'UNMUTE')
return boseGetVolume()
}
/**
* Callback for boseSetMute(), checks current state and changes it
* if it doesn't match the requested state.
*
* @param xml The volume XML data
* @param mute The new state of mute
*
* @return command
*/
def cb_boseSetMute(xml, mute) {
def result = []
if ((xml.muteenabled.text() == 'false' && mute == 'MUTE') ||
(xml.muteenabled.text() == 'true' && mute == 'UNMUTE'))
{
result << boseKeypress("MUTE")
}
log.trace("muteunmute: " + ((mute == "MUTE") ? "unmute" : "mute"))
sendEvent(name:"muteunmute", value:((mute == "MUTE") ? "unmute" : "mute"))
return result
}
/**
* Refreshes the state of the volume
*
* @return command
*/
def boseGetVolume() {
return boseGET("/volume")
}
/**
* Changes the track to either the previous or next
*
* @param direction > 0 = next track, < 0 = previous track, 0 = no action
* @return command
*/
def boseChangeTrack(int direction) {
if (direction < 0) {
return boseKeypress("PREV_TRACK")
} else if (direction > 0) {
return boseKeypress("NEXT_TRACK")
}
return []
}
/**
* Sets the input to preset 1-6 or AUX
*
* @param input The input (one of 1,2,3,4,5,6,aux)
*
* @return command
*
* @note If no presets have been loaded, it will first refresh the presets.
*/
def boseSetInput(input) {
log.info "boseSetInput(${input})"
def result = []
if (!state.preset) {
result << boseGetPresets()
queueCallback('presets', 'cb_boseSetInput', input)
} else {
result << cb_boseSetInput(null, input)
}
return result
}
/**
* Callback used by boseSetInput(), either called directly by
* boseSetInput() if we already have presets, or called after
* retreiving the presets for the first time.
*
* @param xml The presets XML data
* @param input Desired input
*
* @return command
*
* @note Uses KEY commands for AUX, otherwise /select endpoint.
* Reason for this is latency. Since keypresses are done
* in pairs (press + release), you could accidentally change
* the preset if there is a long delay between the two.
*/
def cb_boseSetInput(xml, input) {
def result = []
if (input >= "1" && input <= "6" && state.preset["$input"])
result << bosePOST("/select", state.preset["$input"])
else if (input.toLowerCase() == "aux") {
result << boseKeypress("AUX_INPUT")
}
// Horrible workaround... but we need to delay
// the update by at least a few seconds...
result << boseRefreshNowPlaying(3000)
return result
}
/**
* Sets the power state of the bose unit
*
* @param device The device in-question
* @param enable True to power on, false to power off
*
* @return command
*
* @note Will first query state before acting since there
* is no discreete call.
*/
def boseSetPowerState(boolean enable) {
log.info "boseSetPowerState(${enable})"
// Fix to get faster update of power status back from speaker after sending on/off
// Instead of queuing the command to be sent after the refresh send it directly via sendHubCommand
// Note: This is a temporary hack that should be replaced by a re-design of the
// DTH to use sendHubCommand for all commands
sendHubCommand(bosePOST("/key", "POWER"))
sendHubCommand(bosePOST("/key", "POWER"))
sendHubCommand(boseGET("/now_playing"))
if (enable) {
queueCallback('nowPlaying', "cb_boseConfirmPowerOn", 5)
}
}
/**
* Callback function used by boseSetPowerState(), is used
* to handle the fact that we only have a toggle for power.
*
* @param xml The XML data from nowPlaying
* @param state The requested state
*
* @return command
*/
def cb_boseSetPowerState(xml, state) {
def result = []
if ( (xml.attributes()['source'] == "STANDBY" && state == "POWERON") ||
(xml.attributes()['source'] != "STANDBY" && state == "POWEROFF") )
{
result << boseKeypress("POWER")
if (state == "POWERON") {
result << boseRefreshNowPlaying()
queueCallback('nowPlaying', "cb_boseConfirmPowerOn", 5)
}
}
return result.flatten()
}
/**
* We're sometimes too quick on the draw and get a refreshed nowPlaying
* which shows standby (essentially, the device has yet to completely
* transition to awake state), so we need to poll a few times extra
* to make sure we get it right.
*
* @param xml The XML data from nowPlaying
* @param tries A counter which will decrease, once it reaches zero,
* we give up and assume that whatever we got was correct.
* @return command
*/
def cb_boseConfirmPowerOn(xml, tries) {
def result = []
def attempt = tries as Integer
log.warn "boseConfirmPowerOn() attempt #$attempt"
if (xml.attributes()['source'] == "STANDBY" && attempt > 0) {
result << boseRefreshNowPlaying()
queueCallback('nowPlaying', "cb_boseConfirmPowerOn", attempt-1)
}
return result
}
/**
* Requests an update on currently playing item(s)
*
* @param delay If set to non-zero, delays x ms before issuing
*
* @return command
*/
def boseRefreshNowPlaying(delay=0) {
if (delay > 0) {
return ["delay ${delay}", boseGET("/now_playing")]
}
return boseGET("/now_playing")
}
def boseSendGetNowPlaying() {
sendHubCommand(boseGET("/now_playing"))
}
/**
* Requests the list of presets
*
* @return command
*/
def boseGetPresets() {
return boseGET("/presets")
}
/**
* Utility function, makes GET requests to BOSE device
*
* @param path What endpoint
*
* @return command
*/
def boseGET(String path) {
new physicalgraph.device.HubAction([
method: "GET",
path: path,
headers: [
HOST: state.address + ":8090",
]])
}
/**
* Utility function, makes a POST request to the BOSE device with
* the provided data.
*
* @param path What endpoint
* @param data What data
* @param address Specific ip and port (optional)
*
* @return command
*/
def bosePOST(String path, String data, String address=null) {
new physicalgraph.device.HubAction([
method: "POST",
path: path,
body: data,
headers: [
HOST: address ?: (state.address + ":8090"),
]])
}
/**
* Queues a callback function for when a specific XML root is received
* Will execute on subsequent parse() call(s), never on the current
* parse() call.
*
* @param root The root node that this callback should react to
* @param func Name of the function
* @param param Parameters for function (optional)
*/
def queueCallback(String root, String func, param=null) {
if (!state.pending)
state.pending = [:]
if (!state.pending[root])
state.pending[root] = []
state.pending[root] << ["$func":"$param"]
}
/**
* Transfers the pending callbacks into readiness state
* so they can be executed by processCallbacks()
*
* This is needed to avoid reacting to queueCallbacks() within
* the same loop.
*/
def prepareCallbacks() {
if (!state.pending)
return
if (!state.ready)
state.ready = [:]
state.ready << state.pending
state.pending = [:]
}
/**
* Executes any ready callback for a specific root node
* with associated parameter and then clears that entry.
*
* If a callback returns data, it's added to a list of
* commands which is returned to the caller of this function
*
* Once a callback has been used, it's removed from the list
* of queued callbacks (ie, it executes only once!)
*
* @param xml The XML data to be examined and delegated
* @return list of commands
*/
def processCallbacks(xml) {
def result = []
if (!state.ready)
return result
if (state.ready[xml.name()]) {
state.ready[xml.name()].each { callback ->
callback.each { func, param ->
if (func != "func") {
if (param)
result << "$func"(xml, param)
else
result << "$func"(xml)
}
}
}
state.ready.remove(xml.name())
}
return result.flatten()
}
/**
* State managament for the Play Everywhere zone.
* This is typically called from the parent.
*
* A device is either:
*
* null = Not participating
* server = running the show
* client = under the control of the server
*
* @param newstate (see above for types)
*/
def boseSetZone(String newstate) {
log.debug "boseSetZone($newstate)"
state.zone = newstate
// Refresh our state
if (newstate) {
sendEvent(name:"everywhere", value:"leave")
} else {
sendEvent(name:"everywhere", value:"join")
}
}
/**
* Used by the Everywhere zone, returns the current state
* of zone membership (null, server, client)
* This is typically called from the parent.
*
* @return state
*/
def boseGetZone() {
return state.zone
}
/**
* Sets the DeviceID of this particular device.
*
* Needs to be done this way since DNI is not always
* the same as DeviceID which is used internally by
* BOSE.
*
* @param devID The DeviceID
*/
def boseSetDeviceID(String devID) {
state.deviceID = devID
}
/**
* Retrieves the DeviceID for this device
*
* @return deviceID
*/
def boseGetDeviceID() {
return state.deviceID
}
/**
* Returns the IP of this device
*
* @return IP address
*/
def getDeviceIP() {
return parent.resolveDNI2Address(device.deviceNetworkId)
}
def TRACE(text) {
log.trace "${text}"
}