2 * Bose SoundTouch (Connect)
4 * Copyright 2015 SmartThings
6 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
7 * in compliance with the License. You may obtain a copy of the License at:
9 * http://www.apache.org/licenses/LICENSE-2.0
11 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
12 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
13 * for the specific language governing permissions and limitations under the License.
17 name: "Bose SoundTouch (Connect)",
18 namespace: "smartthings",
19 author: "SmartThings",
20 description: "Control your Bose SoundTouch speakers",
21 category: "SmartThings Labs",
22 iconUrl: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon.png",
23 iconX2Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon@2x.png",
24 iconX3Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon@2x-1.png",
29 page(name:"deviceDiscovery", title:"Device Setup", content:"deviceDiscovery", refreshTimeout:5)
33 * Get the urn that we're looking for
35 * @return URN which we are looking for
37 * @todo This + getUSNQualifier should be one and should use regular expressions
40 return "urn:schemas-upnp-org:device:MediaRenderer:1" // Bose
44 * If not null, returns an additional qualifier for ssdUSN
45 * to avoid spamming the network
47 * @return Additional qualifier OR null if not needed
49 def getUSNQualifier() {
50 return "uuid:BO5EBO5E-F00D-F00D-FEED-"
54 * Get the name of the new device to instantiate in the user's smartapps
55 * This must be an app owned by the namespace (see #getNameSpace).
60 return "Bose SoundTouch"
64 * Returns the namespace this app and siblings use
73 * The deviceDiscovery page used by preferences. Will automatically
74 * make calls to the underlying discovery mechanisms as well as update
75 * whenever new devices are discovered AND verified.
77 * @return a dynamicPage() object
83 def refreshInterval = 3 // Number of seconds between refresh
84 int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int
85 state.deviceRefreshCount = deviceRefreshCount + refreshInterval
87 def devices = getSelectableDevice()
88 def numFound = devices.size() ?: 0
90 // Make sure we get location updates (contains LAN data such as SSDP results, etc)
91 subscribeNetworkEvents()
93 //device discovery request every 15s
94 if((deviceRefreshCount % 15) == 0) {
98 // Verify request every 3 seconds except on discoveries
99 if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 15) != 0)) {
103 log.trace "Discovered devices: ${devices}"
105 return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) {
106 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.") {
107 input "selecteddevice", "enum", required:false, title:"Select ${getDeviceName()} (${numFound} found)", multiple:true, options:devices, submitOnChange: true
113 def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date.
115 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"."""
117 return dynamicPage(name:"deviceDiscovery", title:"Upgrade needed!", nextPage:"", install:true, uninstall: true) {
119 paragraph "$upgradeNeeded"
126 * Called by SmartThings Cloud when user has selected device(s) and
130 log.trace "Installed with settings: ${settings}"
135 * Called by SmartThings Cloud when app has been updated
138 log.trace "Updated with settings: ${settings}"
144 * Called by SmartThings Cloud when user uninstalls the app
146 * We don't need to manually do anything here because any children
147 * are automatically removed upon the removal of the parent.
149 * Only time to do anything here is when you need to notify
150 * the remote end. And even then you're discouraged from removing
151 * the children manually.
157 * If user has selected devices, will start monitoring devices
158 * for changes (new address, port, etc...)
161 log.trace "initialize()"
162 state.subscribe = false
163 if (selecteddevice) {
166 subscribeNetworkEvents(true)
171 * Adds the child devices based on the user's selection
173 * Uses selecteddevice defined in the deviceDiscovery() page
176 def devices = getVerifiedDevices()
178 log.trace "Adding childs"
180 // If only one device is selected, we don't get a list (when using simulator)
181 if (!(selecteddevice instanceof List)) {
182 devlist = [selecteddevice]
184 devlist = selecteddevice
187 log.trace "These are being installed: ${devlist}"
189 devlist.each { dni ->
190 def d = getChildDevice(dni)
192 def newDevice = devices.find { (it.value.mac) == dni }
193 def deviceName = newDevice?.value.name
195 deviceName = getDeviceName() + "[${newDevice?.value.name}]"
196 d = addChildDevice(getNameSpace(), getDeviceName(), dni, newDevice?.value.hub, [label:"${deviceName}"])
197 d.boseSetDeviceID(newDevice.value.deviceID)
198 log.trace "Created ${d.displayName} with id $dni"
199 // sync DTH with device, done here as it currently don't work from the DTH's installed() method
202 log.trace "${d.displayName} with id $dni already exists"
208 * Resolves a DeviceNetworkId to an address. Primarily used by children
210 * @param dni Device Network id
211 * @return address or null
213 def resolveDNI2Address(dni) {
214 def device = getVerifiedDevices().find { (it.value.mac) == dni }
216 return convertHexToIP(device.value.networkAddress)
222 * Joins a child to the "Play Everywhere" zone
224 * @param child The speaker joining the zone
225 * @return A list of maps with POST data
227 def boseZoneJoin(child) {
228 log = child.log // So we can debug this function
233 // Find the master (if any)
234 def server = getChildDevices().find{ it.boseGetZone() == "server" }
237 log.debug "boseJoinZone() We have a server already, so lets add the new speaker"
238 child.boseSetZone("client")
240 result['endpoint'] = "/setZone"
241 result['host'] = server.getDeviceIP() + ":8090"
242 result['body'] = "<zone master=\"${server.boseGetDeviceID()}\" senderIPAddress=\"${server.getDeviceIP()}\">"
243 getChildDevices().each{ it ->
244 log.trace "child: " + child
245 log.trace "zone : " + it.boseGetZone()
246 if (it.boseGetZone() || it.boseGetDeviceID() == child.boseGetDeviceID())
247 result['body'] = result['body'] + "<member ipaddress=\"${it.getDeviceIP()}\">${it.boseGetDeviceID()}</member>"
249 result['body'] = result['body'] + '</zone>'
251 log.debug "boseJoinZone() No server, add it!"
252 result['endpoint'] = "/setZone"
253 result['host'] = child.getDeviceIP() + ":8090"
254 result['body'] = "<zone master=\"${child.boseGetDeviceID()}\" senderIPAddress=\"${child.getDeviceIP()}\">"
255 result['body'] = result['body'] + "<member ipaddress=\"${child.getDeviceIP()}\">${child.boseGetDeviceID()}</member>"
256 result['body'] = result['body'] + '</zone>'
257 child.boseSetZone("server")
263 def boseZoneReset() {
264 getChildDevices().each{ it.boseSetZone(null) }
267 def boseZoneHasMaster() {
268 return getChildDevices().find{ it.boseGetZone() == "server" } != null
272 * Removes a speaker from the play everywhere zone.
274 * @param child Which speaker is leaving
275 * @return a list of maps with POST data
277 def boseZoneLeave(child) {
278 log = child.log // So we can debug this function
283 // First, tag us as a non-member
284 child.boseSetZone(null)
286 // Find the master (if any)
287 def server = getChildDevices().find{ it.boseGetZone() == "server" }
289 if (server && server.boseGetDeviceID() != child.boseGetDeviceID()) {
290 log.debug "boseLeaveZone() We have a server, so tell him we're leaving"
291 result['endpoint'] = "/removeZoneSlave"
292 result['host'] = server.getDeviceIP() + ":8090"
293 result['body'] = "<zone master=\"${server.boseGetDeviceID()}\" senderIPAddress=\"${server.getDeviceIP()}\">"
294 result['body'] = result['body'] + "<member ipaddress=\"${child.getDeviceIP()}\">${child.boseGetDeviceID()}</member>"
295 result['body'] = result['body'] + '</zone>'
298 log.debug "boseLeaveZone() No server, then...uhm, we probably were it!"
299 // Dismantle the entire thing, first send this to master
300 result['endpoint'] = "/removeZoneSlave"
301 result['host'] = child.getDeviceIP() + ":8090"
302 result['body'] = "<zone master=\"${child.boseGetDeviceID()}\" senderIPAddress=\"${child.getDeviceIP()}\">"
303 getChildDevices().each{ dev ->
304 if (dev.boseGetZone() || dev.boseGetDeviceID() == child.boseGetDeviceID())
305 result['body'] = result['body'] + "<member ipaddress=\"${dev.getDeviceIP()}\">${dev.boseGetDeviceID()}</member>"
307 result['body'] = result['body'] + '</zone>'
310 // Also issue this to each individual client
311 getChildDevices().each{ dev ->
312 if (dev.boseGetZone() && dev.boseGetDeviceID() != child.boseGetDeviceID()) {
313 log.trace "Additional device: " + dev
314 result['host'] = dev.getDeviceIP() + ":8090"
324 * Define our XML parsers
326 * @return mapping of root-node <-> parser function
330 "root" : "parseDESC",
336 * Called when location has changed, contains information from
337 * network transactions. See deviceDiscovery() for where it is
340 * @param evt Holds event information
342 def onLocation(evt) {
343 // Convert the event into something we can use
344 def lanEvent = parseLanMessage(evt.description, true)
345 lanEvent << ["hub":evt?.hubId]
347 // Determine what we need to do...
348 if (lanEvent?.ssdpTerm?.contains(getDeviceType()) &&
349 (getUSNQualifier() == null ||
350 lanEvent?.ssdpUSN?.contains(getUSNQualifier())
357 lanEvent.headers && lanEvent.body &&
358 lanEvent.headers."content-type"?.contains("xml")
361 def parsers = getParsers()
362 def xmlData = new XmlSlurper().parseText(lanEvent.body)
364 // Let each parser take a stab at it
365 parsers.each { node,func ->
366 if (xmlData.name() == node)
373 * Handles SSDP description file.
377 private def parseDESC(xmlData) {
378 log.info "parseDESC()"
380 def devicetype = getDeviceType().toLowerCase()
381 def devicetxml = body.device.deviceType.text().toLowerCase()
383 // Make sure it's the type we want
384 if (devicetxml == devicetype) {
385 def devices = getDevices()
386 def device = devices.find {it?.key?.contains(xmlData?.device?.UDN?.text())}
387 if (device && !device.value?.verified) {
388 // Unlike regular DESC, we cannot trust this just yet, parseINFO() decides all
389 device.value << [name:xmlData?.device?.friendlyName?.text(),model:xmlData?.device?.modelName?.text(), serialNumber:xmlData?.device?.serialNum?.text()]
391 log.error "parseDESC(): The xml file returned a device that didn't exist"
397 * Handle BOSE <info></info> result. This is an alternative to
398 * using the SSDP description standard. Some of the speakers do
399 * not support SSDP description, so we need this as well.
403 private def parseINFO(xmlData) {
404 log.info "parseINFO()"
405 def devicetype = getDeviceType().toLowerCase()
407 def deviceID = xmlData.attributes()['deviceID']
408 def device = getDevices().find {it?.key?.contains(deviceID)}
409 if (device && !device.value?.verified) {
410 device.value << [name:xmlData?.name?.text(),model:xmlData?.type?.text(), serialNumber:xmlData?.serialNumber?.text(), "deviceID":deviceID, verified: true]
415 * Handles SSDP discovery messages and adds them to the list
416 * of discovered devices. If it already exists, it will update
417 * the port and location (in case it was moved).
421 def parseSSDP(lanEvent) {
422 //SSDP DISCOVERY EVENTS
423 def USN = lanEvent.ssdpUSN.toString()
424 def devices = getDevices()
426 if (!(devices."${USN}")) {
427 //device does not exist
428 log.trace "parseSDDP() Adding Device \"${USN}\" to known list"
429 devices << ["${USN}":lanEvent]
432 def d = devices."${USN}"
433 if (d.networkAddress != lanEvent.networkAddress || d.deviceAddress != lanEvent.deviceAddress) {
434 log.trace "parseSSDP() Updating device location (ip & port)"
435 d.networkAddress = lanEvent.networkAddress
436 d.deviceAddress = lanEvent.deviceAddress
442 * Generates a Map object which can be used with a preference page
443 * to represent a list of devices detected and verified.
445 * @return Map with zero or more devices
447 Map getSelectableDevice() {
448 def devices = getVerifiedDevices()
451 def value = "${it.value.name}"
452 def key = it.value.mac
453 map["${key}"] = value
459 * Starts the refresh loop, making sure to keep us up-to-date with changes
462 private refreshDevices() {
465 runIn(300, "refreshDevices")
469 * Starts a subscription for network events
471 * @param force If true, will unsubscribe and subscribe if necessary (Optional, default false)
473 private subscribeNetworkEvents(force=false) {
476 state.subscribe = false
479 if(!state.subscribe) {
480 subscribe(location, null, onLocation, [filterEvents:false])
481 state.subscribe = true
486 * Issues a SSDP M-SEARCH over the LAN for a specific type (see getDeviceType())
488 private discoverDevices() {
489 log.trace "discoverDevice() Issuing SSDP request"
490 sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${getDeviceType()}", physicalgraph.device.Protocol.LAN))
494 * Walks through the list of unverified devices and issues a verification
495 * request for each of them (basically calling verifyDevice() per unverified)
497 private verifyDevices() {
498 def devices = getDevices().findAll { it?.value?.verified != true }
503 convertHexToIP(it?.value?.networkAddress),
504 convertHexToInt(it?.value?.deviceAddress),
511 * Verify the device, in this case, we need to obtain the info block which
512 * holds information such as the actual mac to use in certain scenarios.
514 * Without this mac (henceforth referred to as deviceID), we can't do multi-speaker
517 * @param deviceNetworkId The DNI of the device
518 * @param ip The address of the device on the network (not the same as DNI)
519 * @param port The port to use (0 will be treated as invalid and will use 80)
520 * @param devicessdpPath The URL path (for example, /desc)
522 * @note Result is captured in locationHandler()
524 private verifyDevice(String deviceNetworkId, String ip, int port, String devicessdpPath) {
526 def address = ip + ":8090"
527 sendHubCommand(new physicalgraph.device.HubAction([
534 log.warn("verifyDevice() IP address was empty")
539 * Returns an array of devices which have been verified
541 * @return array of verified devices
543 def getVerifiedDevices() {
544 getDevices().findAll{ it?.value?.verified == true }
548 * Returns all discovered devices or an empty array if none
550 * @return array of devices
553 state.devices = state.devices ?: [:]
557 * Converts a hexadecimal string to an integer
559 * @param hex The string with a hexadecimal value
562 private Integer convertHexToInt(hex) {
563 Integer.parseInt(hex,16)
567 * Converts an IP address represented as 0xAABBCCDD to AAA.BBB.CCC.DDD
569 * @param hex Address represented in hex
570 * @return String containing normal IPv4 dot notation
572 private String convertHexToIP(hex) {
574 [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".")
580 * Tests if this setup can support SmarthThing Labs items
582 * @return true if it supports it.
584 private Boolean canInstallLabs()
586 return hasAllHubsOver("000.011.00603")
590 * Tests if the firmwares on all hubs owned by user match or exceed the
591 * provided version number.
593 * @param desiredFirmware The version that must match or exceed
594 * @return true if hub has same or newer
596 private Boolean hasAllHubsOver(String desiredFirmware)
598 return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware }
602 * Creates a list of firmware version for every hub the user has
604 * @return List of firmwares
606 private List getRealHubFirmwareVersions()
608 return location.hubs*.firmwareVersionString.findAll { it }