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