diff --git a/devicetypes/smartthings/bose-soundtouch.src/bose-soundtouch.groovy b/devicetypes/smartthings/bose-soundtouch.src/bose-soundtouch.groovy new file mode 100644 index 0000000..496797c --- /dev/null +++ b/devicetypes/smartthings/bose-soundtouch.src/bose-soundtouch.groovy @@ -0,0 +1,989 @@ +/** + * 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 "Polling" + + /** + * 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" + } + + /** + * 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 "off", label: '${name}', action: "switch.on", icon: "st.Electronics.electronics16", backgroundColor: "#ffffff" + state "on", label: '${name}', action: "switch.off", 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() { onAction("off") } +def on() { 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() { + onAction("refresh") +} + +/** + * 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": + actions = boseSetPowerState(true) + break + case "off": + boseSetNowPlaying(null, "STANDBY") + actions = 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 +} + +/** + * Called every so often (every 5 minutes actually) to refresh the + * tiles so the user gets the correct information. + */ +def poll() { + return boseRefreshNowPlaying() +} + +/** + * 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})" + queueCallback('nowPlaying', "cb_boseSetPowerState", enable ? "POWERON" : "POWEROFF") + return boseRefreshNowPlaying() +} + +/** + * 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 = [] + log.warn "boseConfirmPowerOn() attempt #" + tries + if (xml.attributes()['source'] == "STANDBY" && tries > 0) { + result << boseRefreshNowPlaying() + queueCallback('nowPlaying', "cb_boseConfirmPowerOn", tries-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") +} + +/** + * 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) +} \ No newline at end of file diff --git a/smartapps/smartthings/bose-soundtouch-connect.src/bose-soundtouch-connect.groovy b/smartapps/smartthings/bose-soundtouch-connect.src/bose-soundtouch-connect.groovy new file mode 100644 index 0000000..bc7cc82 --- /dev/null +++ b/smartapps/smartthings/bose-soundtouch-connect.src/bose-soundtouch-connect.groovy @@ -0,0 +1,606 @@ +/** + * Bose SoundTouch (Connect) + * + * 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. + * + */ + definition( + name: "Bose SoundTouch (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Control your Bose SoundTouch speakers", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + page(name:"deviceDiscovery", title:"Device Setup", content:"deviceDiscovery", refreshTimeout:5) +} + +/** + * Get the urn that we're looking for + * + * @return URN which we are looking for + * + * @todo This + getUSNQualifier should be one and should use regular expressions + */ +def getDeviceType() { + return "urn:schemas-upnp-org:device:MediaRenderer:1" // Bose +} + +/** + * If not null, returns an additional qualifier for ssdUSN + * to avoid spamming the network + * + * @return Additional qualifier OR null if not needed + */ +def getUSNQualifier() { + return "uuid:BO5EBO5E-F00D-F00D-FEED-" +} + +/** + * Get the name of the new device to instantiate in the user's smartapps + * This must be an app owned by the namespace (see #getNameSpace). + * + * @return name + */ +def getDeviceName() { + return "Bose SoundTouch" +} + +/** + * Returns the namespace this app and siblings use + * + * @return namespace + */ +def getNameSpace() { + return "smartthings" +} + +/** + * The deviceDiscovery page used by preferences. Will automatically + * make calls to the underlying discovery mechanisms as well as update + * whenever new devices are discovered AND verified. + * + * @return a dynamicPage() object + */ +def deviceDiscovery() +{ + if(canInstallLabs()) + { + def refreshInterval = 3 // Number of seconds between refresh + int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int + state.deviceRefreshCount = deviceRefreshCount + refreshInterval + + def devices = getSelectableDevice() + def numFound = devices.size() ?: 0 + + // Make sure we get location updates (contains LAN data such as SSDP results, etc) + subscribeNetworkEvents() + + //device discovery request every 15s + if((deviceRefreshCount % 15) == 0) { + discoverDevices() + } + + // Verify request every 3 seconds except on discoveries + if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 15) != 0)) { + verifyDevices() + } + + log.trace "Discovered devices: ${devices}" + + return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { + section("Please wait while we discover your ${getDeviceName()}. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selecteddevice", "enum", required:false, title:"Select ${getDeviceName()} (${numFound} found)", multiple:true, options:devices + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"deviceDiscovery", title:"Upgrade needed!", nextPage:"", install:true, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + } +} + +/** + * Called by SmartThings Cloud when user has selected device(s) and + * pressed "Install". + */ +def installed() { + log.trace "Installed with settings: ${settings}" + initialize() +} + +/** + * Called by SmartThings Cloud when app has been updated + */ +def updated() { + log.trace "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +/** + * Called by SmartThings Cloud when user uninstalls the app + * + * We don't need to manually do anything here because any children + * are automatically removed upon the removal of the parent. + * + * Only time to do anything here is when you need to notify + * the remote end. And even then you're discouraged from removing + * the children manually. + */ +def uninstalled() { +} + +/** + * If user has selected devices, will start monitoring devices + * for changes (new address, port, etc...) + */ +def initialize() { + log.trace "initialize()" + state.subscribe = false + if (selecteddevice) { + addDevice() + refreshDevices() + subscribeNetworkEvents(true) + } +} + +/** + * Adds the child devices based on the user's selection + * + * Uses selecteddevice defined in the deviceDiscovery() page + */ +def addDevice(){ + def devices = getVerifiedDevices() + def devlist + log.trace "Adding childs" + + // If only one device is selected, we don't get a list (when using simulator) + if (!(selecteddevice instanceof List)) { + devlist = [selecteddevice] + } else { + devlist = selecteddevice + } + + log.trace "These are being installed: ${devlist}" + + devlist.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newDevice = devices.find { (it.value.mac) == dni } + def deviceName = newDevice?.value.name + if (!deviceName) + deviceName = getDeviceName() + "[${newDevice?.value.name}]" + d = addChildDevice(getNameSpace(), getDeviceName(), dni, newDevice?.value.hub, [label:"${deviceName}"]) + d.boseSetDeviceID(newDevice.value.deviceID) + log.trace "Created ${d.displayName} with id $dni" + } else { + log.trace "${d.displayName} with id $dni already exists" + } + } +} + +/** + * Resolves a DeviceNetworkId to an address. Primarily used by children + * + * @param dni Device Network id + * @return address or null + */ +def resolveDNI2Address(dni) { + def device = getVerifiedDevices().find { (it.value.mac) == dni } + if (device) { + return convertHexToIP(device.value.networkAddress) + } + return null +} + +/** + * Joins a child to the "Play Everywhere" zone + * + * @param child The speaker joining the zone + * @return A list of maps with POST data + */ +def boseZoneJoin(child) { + log = child.log // So we can debug this function + + def results = [] + def result = [:] + + // Find the master (if any) + def server = getChildDevices().find{ it.boseGetZone() == "server" } + + if (server) { + log.debug "boseJoinZone() We have a server already, so lets add the new speaker" + child.boseSetZone("client") + + result['endpoint'] = "/setZone" + result['host'] = server.getDeviceIP() + ":8090" + result['body'] = "" + getChildDevices().each{ it -> + log.trace "child: " + child + log.trace "zone : " + it.boseGetZone() + if (it.boseGetZone() || it.boseGetDeviceID() == child.boseGetDeviceID()) + result['body'] = result['body'] + "${it.boseGetDeviceID()}" + } + result['body'] = result['body'] + '' + } else { + log.debug "boseJoinZone() No server, add it!" + result['endpoint'] = "/setZone" + result['host'] = child.getDeviceIP() + ":8090" + result['body'] = "" + result['body'] = result['body'] + "${child.boseGetDeviceID()}" + result['body'] = result['body'] + '' + child.boseSetZone("server") + } + results << result + return results +} + +def boseZoneReset() { + getChildDevices().each{ it.boseSetZone(null) } +} + +def boseZoneHasMaster() { + return getChildDevices().find{ it.boseGetZone() == "server" } != null +} + +/** + * Removes a speaker from the play everywhere zone. + * + * @param child Which speaker is leaving + * @return a list of maps with POST data + */ +def boseZoneLeave(child) { + log = child.log // So we can debug this function + + def results = [] + def result = [:] + + // First, tag us as a non-member + child.boseSetZone(null) + + // Find the master (if any) + def server = getChildDevices().find{ it.boseGetZone() == "server" } + + if (server && server.boseGetDeviceID() != child.boseGetDeviceID()) { + log.debug "boseLeaveZone() We have a server, so tell him we're leaving" + result['endpoint'] = "/removeZoneSlave" + result['host'] = server.getDeviceIP() + ":8090" + result['body'] = "" + result['body'] = result['body'] + "${child.boseGetDeviceID()}" + result['body'] = result['body'] + '' + results << result + } else { + log.debug "boseLeaveZone() No server, then...uhm, we probably were it!" + // Dismantle the entire thing, first send this to master + result['endpoint'] = "/removeZoneSlave" + result['host'] = child.getDeviceIP() + ":8090" + result['body'] = "" + getChildDevices().each{ dev -> + if (dev.boseGetZone() || dev.boseGetDeviceID() == child.boseGetDeviceID()) + result['body'] = result['body'] + "${dev.boseGetDeviceID()}" + } + result['body'] = result['body'] + '' + results << result + + // Also issue this to each individual client + getChildDevices().each{ dev -> + if (dev.boseGetZone() && dev.boseGetDeviceID() != child.boseGetDeviceID()) { + log.trace "Additional device: " + dev + result['host'] = dev.getDeviceIP() + ":8090" + results << result + } + } + } + + return results +} + +/** + * Define our XML parsers + * + * @return mapping of root-node <-> parser function + */ +def getParsers() { + [ + "root" : "parseDESC", + "info" : "parseINFO" + ] +} + +/** + * Called when location has changed, contains information from + * network transactions. See deviceDiscovery() for where it is + * registered. + * + * @param evt Holds event information + */ +def onLocation(evt) { + // Convert the event into something we can use + def lanEvent = parseLanMessage(evt.description, true) + lanEvent << ["hub":evt?.hubId] + + // Determine what we need to do... + if (lanEvent?.ssdpTerm?.contains(getDeviceType()) && + (getUSNQualifier() == null || + lanEvent?.ssdpUSN?.contains(getUSNQualifier()) + ) + ) + { + parseSSDP(lanEvent) + } + else if ( + lanEvent.headers && lanEvent.body && + lanEvent.headers."content-type".contains("xml") + ) + { + def parsers = getParsers() + def xmlData = new XmlSlurper().parseText(lanEvent.body) + + // Let each parser take a stab at it + parsers.each { node,func -> + if (xmlData.name() == node) + "$func"(xmlData) + } + } +} + +/** + * Handles SSDP description file. + * + * @param xmlData + */ +private def parseDESC(xmlData) { + log.info "parseDESC()" + + def devicetype = getDeviceType().toLowerCase() + def devicetxml = body.device.deviceType.text().toLowerCase() + + // Make sure it's the type we want + if (devicetxml == devicetype) { + def devices = getDevices() + def device = devices.find {it?.key?.contains(xmlData?.device?.UDN?.text())} + if (device && !device.value?.verified) { + // Unlike regular DESC, we cannot trust this just yet, parseINFO() decides all + device.value << [name:xmlData?.device?.friendlyName?.text(),model:xmlData?.device?.modelName?.text(), serialNumber:xmlData?.device?.serialNum?.text()] + } else { + log.error "parseDESC(): The xml file returned a device that didn't exist" + } + } +} + +/** + * Handle BOSE result. This is an alternative to + * using the SSDP description standard. Some of the speakers do + * not support SSDP description, so we need this as well. + * + * @param xmlData + */ +private def parseINFO(xmlData) { + log.info "parseINFO()" + def devicetype = getDeviceType().toLowerCase() + + def deviceID = xmlData.attributes()['deviceID'] + def device = getDevices().find {it?.key?.contains(deviceID)} + if (device && !device.value?.verified) { + device.value << [name:xmlData?.name?.text(),model:xmlData?.type?.text(), serialNumber:xmlData?.serialNumber?.text(), "deviceID":deviceID, verified: true] + } +} + +/** + * Handles SSDP discovery messages and adds them to the list + * of discovered devices. If it already exists, it will update + * the port and location (in case it was moved). + * + * @param lanEvent + */ +def parseSSDP(lanEvent) { + //SSDP DISCOVERY EVENTS + def USN = lanEvent.ssdpUSN.toString() + def devices = getDevices() + + if (!(devices."${USN}")) { + //device does not exist + log.trace "parseSDDP() Adding Device \"${USN}\" to known list" + devices << ["${USN}":lanEvent] + } else { + // update the values + def d = devices."${USN}" + if (d.networkAddress != lanEvent.networkAddress || d.deviceAddress != lanEvent.deviceAddress) { + log.trace "parseSSDP() Updating device location (ip & port)" + d.networkAddress = lanEvent.networkAddress + d.deviceAddress = lanEvent.deviceAddress + } + } +} + +/** + * Generates a Map object which can be used with a preference page + * to represent a list of devices detected and verified. + * + * @return Map with zero or more devices + */ +Map getSelectableDevice() { + def devices = getVerifiedDevices() + def map = [:] + devices.each { + def value = "${it.value.name}" + def key = it.value.mac + map["${key}"] = value + } + map +} + +/** + * Starts the refresh loop, making sure to keep us up-to-date with changes + * + */ +private refreshDevices() { + discoverDevices() + verifyDevices() + runIn(300, "refreshDevices") +} + +/** + * Starts a subscription for network events + * + * @param force If true, will unsubscribe and subscribe if necessary (Optional, default false) + */ +private subscribeNetworkEvents(force=false) { + if (force) { + unsubscribe() + state.subscribe = false + } + + if(!state.subscribe) { + subscribe(location, null, onLocation, [filterEvents:false]) + state.subscribe = true + } +} + +/** + * Issues a SSDP M-SEARCH over the LAN for a specific type (see getDeviceType()) + */ +private discoverDevices() { + log.trace "discoverDevice() Issuing SSDP request" + sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${getDeviceType()}", physicalgraph.device.Protocol.LAN)) +} + +/** + * Walks through the list of unverified devices and issues a verification + * request for each of them (basically calling verifyDevice() per unverified) + */ +private verifyDevices() { + def devices = getDevices().findAll { it?.value?.verified != true } + + devices.each { + verifyDevice( + it?.value?.mac, + convertHexToIP(it?.value?.networkAddress), + convertHexToInt(it?.value?.deviceAddress), + it?.value?.ssdpPath + ) + } +} + +/** + * Verify the device, in this case, we need to obtain the info block which + * holds information such as the actual mac to use in certain scenarios. + * + * Without this mac (henceforth referred to as deviceID), we can't do multi-speaker + * functions. + * + * @param deviceNetworkId The DNI of the device + * @param ip The address of the device on the network (not the same as DNI) + * @param port The port to use (0 will be treated as invalid and will use 80) + * @param devicessdpPath The URL path (for example, /desc) + * + * @note Result is captured in locationHandler() + */ +private verifyDevice(String deviceNetworkId, String ip, int port, String devicessdpPath) { + if(ip) { + def address = ip + ":8090" + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: "/info", + headers: [ + HOST: address, + ]])) + } else { + log.warn("verifyDevice() IP address was empty") + } +} + +/** + * Returns an array of devices which have been verified + * + * @return array of verified devices + */ +def getVerifiedDevices() { + getDevices().findAll{ it?.value?.verified == true } +} + +/** + * Returns all discovered devices or an empty array if none + * + * @return array of devices + */ +def getDevices() { + state.devices = state.devices ?: [:] +} + +/** + * Converts a hexadecimal string to an integer + * + * @param hex The string with a hexadecimal value + * @return An integer + */ +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +/** + * Converts an IP address represented as 0xAABBCCDD to AAA.BBB.CCC.DDD + * + * @param hex Address represented in hex + * @return String containing normal IPv4 dot notation + */ +private String convertHexToIP(hex) { + if (hex) + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") + else + hex +} + +/** + * Tests if this setup can support SmarthThing Labs items + * + * @return true if it supports it. + */ +private Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +/** + * Tests if the firmwares on all hubs owned by user match or exceed the + * provided version number. + * + * @param desiredFirmware The version that must match or exceed + * @return true if hub has same or newer + */ +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +/** + * Creates a list of firmware version for every hub the user has + * + * @return List of firmwares + */ +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} \ No newline at end of file