Update gentle-wake-up.groovy
[smartapps.git] / official / bose-soundtouch-connect.groovy
1 /**
2  *  Bose SoundTouch (Connect)
3  *
4  *  Copyright 2015 SmartThings
5  *
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:
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
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.
14  *
15  */
16  definition(
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",
25     singleInstance: true
26 )
27
28 preferences {
29     page(name:"deviceDiscovery", title:"Device Setup", content:"deviceDiscovery", refreshTimeout:5)
30 }
31
32 /**
33  * Get the urn that we're looking for
34  *
35  * @return URN which we are looking for
36  *
37  * @todo This + getUSNQualifier should be one and should use regular expressions
38  */
39 def getDeviceType() {
40     return "urn:schemas-upnp-org:device:MediaRenderer:1" // Bose
41 }
42
43 /**
44  * If not null, returns an additional qualifier for ssdUSN
45  * to avoid spamming the network
46  *
47  * @return Additional qualifier OR null if not needed
48  */
49 def getUSNQualifier() {
50     return "uuid:BO5EBO5E-F00D-F00D-FEED-"
51 }
52
53 /**
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).
56  *
57  * @return name
58  */
59 def getDeviceName() {
60     return "Bose SoundTouch"
61 }
62
63 /**
64  * Returns the namespace this app and siblings use
65  *
66  * @return namespace
67  */
68 def getNameSpace() {
69     return "smartthings"
70 }
71
72 /**
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.
76  *
77  * @return a dynamicPage() object
78  */
79 def deviceDiscovery()
80 {
81     if(canInstallLabs())
82     {
83         def refreshInterval = 3 // Number of seconds between refresh
84         int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int
85         state.deviceRefreshCount = deviceRefreshCount + refreshInterval
86
87         def devices = getSelectableDevice()
88         def numFound = devices.size() ?: 0
89
90         // Make sure we get location updates (contains LAN data such as SSDP results, etc)
91         subscribeNetworkEvents()
92
93         //device discovery request every 15s
94         if((deviceRefreshCount % 15) == 0) {
95             discoverDevices()
96         }
97
98         // Verify request every 3 seconds except on discoveries
99         if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 15) != 0)) {
100             verifyDevices()
101         }
102
103         log.trace "Discovered devices: ${devices}"
104
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
108             }
109         }
110     }
111     else
112     {
113         def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date.
114
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"."""
116
117         return dynamicPage(name:"deviceDiscovery", title:"Upgrade needed!", nextPage:"", install:true, uninstall: true) {
118             section("Upgrade") {
119                 paragraph "$upgradeNeeded"
120             }
121         }
122     }
123 }
124
125 /**
126  * Called by SmartThings Cloud when user has selected device(s) and
127  * pressed "Install".
128  */
129 def installed() {
130     log.trace "Installed with settings: ${settings}"
131     initialize()
132 }
133
134 /**
135  * Called by SmartThings Cloud when app has been updated
136  */
137 def updated() {
138     log.trace "Updated with settings: ${settings}"
139     unsubscribe()
140     initialize()
141 }
142
143 /**
144  * Called by SmartThings Cloud when user uninstalls the app
145  *
146  * We don't need to manually do anything here because any children
147  * are automatically removed upon the removal of the parent.
148  *
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.
152  */
153 def uninstalled() {
154 }
155
156 /**
157  * If user has selected devices, will start monitoring devices
158  * for changes (new address, port, etc...)
159  */
160 def initialize() {
161     log.trace "initialize()"
162     state.subscribe = false
163     if (selecteddevice) {
164         addDevice()
165         refreshDevices()
166         subscribeNetworkEvents(true)
167     }
168 }
169
170 /**
171  * Adds the child devices based on the user's selection
172  *
173  * Uses selecteddevice defined in the deviceDiscovery() page
174  */
175 def addDevice(){
176     def devices = getVerifiedDevices()
177     def devlist
178     log.trace "Adding childs"
179
180     // If only one device is selected, we don't get a list (when using simulator)
181     if (!(selecteddevice instanceof List)) {
182         devlist = [selecteddevice]
183     } else {
184         devlist = selecteddevice
185     }
186
187     log.trace "These are being installed: ${devlist}"
188
189     devlist.each { dni ->
190         def d = getChildDevice(dni)
191         if(!d) {
192             def newDevice = devices.find { (it.value.mac) == dni }
193             def deviceName = newDevice?.value.name
194             if (!deviceName)
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
200             d.refresh()
201         } else {
202             log.trace "${d.displayName} with id $dni already exists"
203         }
204     }
205 }
206
207 /**
208  * Resolves a DeviceNetworkId to an address. Primarily used by children
209  *
210  * @param dni Device Network id
211  * @return address or null
212  */
213 def resolveDNI2Address(dni) {
214     def device = getVerifiedDevices().find { (it.value.mac) == dni }
215     if (device) {
216         return convertHexToIP(device.value.networkAddress)
217     }
218     return null
219 }
220
221 /**
222  * Joins a child to the "Play Everywhere" zone
223  *
224  * @param child The speaker joining the zone
225  * @return A list of maps with POST data
226  */
227 def boseZoneJoin(child) {
228     log = child.log // So we can debug this function
229
230     def results = []
231     def result = [:]
232
233     // Find the master (if any)
234     def server = getChildDevices().find{ it.boseGetZone() == "server" }
235
236     if (server) {
237         log.debug "boseJoinZone() We have a server already, so lets add the new speaker"
238         child.boseSetZone("client")
239
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>"
248         }
249         result['body'] = result['body'] + '</zone>'
250     } else {
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")
258     }
259     results << result
260     return results
261 }
262
263 def boseZoneReset() {
264     getChildDevices().each{ it.boseSetZone(null) }
265 }
266
267 def boseZoneHasMaster() {
268     return getChildDevices().find{ it.boseGetZone() == "server" } != null
269 }
270
271 /**
272  * Removes a speaker from the play everywhere zone.
273  *
274  * @param child Which speaker is leaving
275  * @return a list of maps with POST data
276  */
277 def boseZoneLeave(child) {
278     log = child.log // So we can debug this function
279
280     def results = []
281     def result = [:]
282
283     // First, tag us as a non-member
284     child.boseSetZone(null)
285
286     // Find the master (if any)
287     def server = getChildDevices().find{ it.boseGetZone() == "server" }
288
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>'
296         results << result
297     } else {
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>"
306         }
307         result['body'] = result['body'] + '</zone>'
308         results << result
309
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"
315                 results << result
316             }
317         }
318     }
319
320     return results
321 }
322
323 /**
324  * Define our XML parsers
325  *
326  * @return mapping of root-node <-> parser function
327  */
328 def getParsers() {
329     [
330         "root" : "parseDESC",
331         "info" : "parseINFO"
332     ]
333 }
334
335 /**
336  * Called when location has changed, contains information from
337  * network transactions. See deviceDiscovery() for where it is
338  * registered.
339  *
340  * @param evt Holds event information
341  */
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]
346
347     // Determine what we need to do...
348     if (lanEvent?.ssdpTerm?.contains(getDeviceType()) &&
349         (getUSNQualifier() == null ||
350          lanEvent?.ssdpUSN?.contains(getUSNQualifier())
351         )
352        )
353     {
354         parseSSDP(lanEvent)
355     }
356     else if (
357         lanEvent.headers && lanEvent.body &&
358         lanEvent.headers."content-type"?.contains("xml")
359         )
360     {
361         def parsers = getParsers()
362         def xmlData = new XmlSlurper().parseText(lanEvent.body)
363
364         // Let each parser take a stab at it
365         parsers.each { node,func ->
366             if (xmlData.name() == node)
367                 "$func"(xmlData)
368         }
369     }
370 }
371
372 /**
373  * Handles SSDP description file.
374  *
375  * @param xmlData
376  */
377 private def parseDESC(xmlData) {
378     log.info "parseDESC()"
379
380     def devicetype = getDeviceType().toLowerCase()
381     def devicetxml = body.device.deviceType.text().toLowerCase()
382
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()]
390         } else {
391             log.error "parseDESC(): The xml file returned a device that didn't exist"
392         }
393     }
394 }
395
396 /**
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.
400  *
401  * @param xmlData
402  */
403 private def parseINFO(xmlData) {
404     log.info "parseINFO()"
405     def devicetype = getDeviceType().toLowerCase()
406
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]
411     }
412 }
413
414 /**
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).
418  *
419  * @param lanEvent
420  */
421 def parseSSDP(lanEvent) {
422     //SSDP DISCOVERY EVENTS
423     def USN = lanEvent.ssdpUSN.toString()
424     def devices = getDevices()
425
426     if (!(devices."${USN}")) {
427         //device does not exist
428         log.trace "parseSDDP() Adding Device \"${USN}\" to known list"
429         devices << ["${USN}":lanEvent]
430     } else {
431         // update the values
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
437         }
438     }
439 }
440
441 /**
442  * Generates a Map object which can be used with a preference page
443  * to represent a list of devices detected and verified.
444  *
445  * @return Map with zero or more devices
446  */
447 Map getSelectableDevice() {
448     def devices = getVerifiedDevices()
449     def map = [:]
450     devices.each {
451         def value = "${it.value.name}"
452         def key = it.value.mac
453         map["${key}"] = value
454     }
455     map
456 }
457
458 /**
459  * Starts the refresh loop, making sure to keep us up-to-date with changes
460  *
461  */
462 private refreshDevices() {
463     discoverDevices()
464     verifyDevices()
465     runIn(300, "refreshDevices")
466 }
467
468 /**
469  * Starts a subscription for network events
470  *
471  * @param force If true, will unsubscribe and subscribe if necessary (Optional, default false)
472  */
473 private subscribeNetworkEvents(force=false) {
474     if (force) {
475         unsubscribe()
476         state.subscribe = false
477     }
478
479     if(!state.subscribe) {
480         subscribe(location, null, onLocation, [filterEvents:false])
481         state.subscribe = true
482     }
483 }
484
485 /**
486  * Issues a SSDP M-SEARCH over the LAN for a specific type (see getDeviceType())
487  */
488 private discoverDevices() {
489     log.trace "discoverDevice() Issuing SSDP request"
490     sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${getDeviceType()}", physicalgraph.device.Protocol.LAN))
491 }
492
493 /**
494  * Walks through the list of unverified devices and issues a verification
495  * request for each of them (basically calling verifyDevice() per unverified)
496  */
497 private verifyDevices() {
498     def devices = getDevices().findAll { it?.value?.verified != true }
499
500     devices.each {
501         verifyDevice(
502             it?.value?.mac,
503             convertHexToIP(it?.value?.networkAddress),
504             convertHexToInt(it?.value?.deviceAddress),
505             it?.value?.ssdpPath
506         )
507     }
508 }
509
510 /**
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.
513  *
514  * Without this mac (henceforth referred to as deviceID), we can't do multi-speaker
515  * functions.
516  *
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)
521  *
522  * @note Result is captured in locationHandler()
523  */
524 private verifyDevice(String deviceNetworkId, String ip, int port, String devicessdpPath) {
525     if(ip) {
526         def address = ip + ":8090"
527         sendHubCommand(new physicalgraph.device.HubAction([
528             method: "GET",
529             path: "/info",
530             headers: [
531                 HOST: address,
532             ]]))
533     } else {
534         log.warn("verifyDevice() IP address was empty")
535     }
536 }
537
538 /**
539  * Returns an array of devices which have been verified
540  *
541  * @return array of verified devices
542  */
543 def getVerifiedDevices() {
544     getDevices().findAll{ it?.value?.verified == true }
545 }
546
547 /**
548  * Returns all discovered devices or an empty array if none
549  *
550  * @return array of devices
551  */
552 def getDevices() {
553     state.devices = state.devices ?: [:]
554 }
555
556 /**
557  * Converts a hexadecimal string to an integer
558  *
559  * @param hex The string with a hexadecimal value
560  * @return An integer
561  */
562 private Integer convertHexToInt(hex) {
563     Integer.parseInt(hex,16)
564 }
565
566 /**
567  * Converts an IP address represented as 0xAABBCCDD to AAA.BBB.CCC.DDD
568  *
569  * @param hex Address represented in hex
570  * @return String containing normal IPv4 dot notation
571  */
572 private String convertHexToIP(hex) {
573     if (hex)
574         [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".")
575     else
576         hex
577 }
578
579 /**
580  * Tests if this setup can support SmarthThing Labs items
581  *
582  * @return true if it supports it.
583  */
584 private Boolean canInstallLabs()
585 {
586     return hasAllHubsOver("000.011.00603")
587 }
588
589 /**
590  * Tests if the firmwares on all hubs owned by user match or exceed the
591  * provided version number.
592  *
593  * @param desiredFirmware The version that must match or exceed
594  * @return true if hub has same or newer
595  */
596 private Boolean hasAllHubsOver(String desiredFirmware)
597 {
598     return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware }
599 }
600
601 /**
602  * Creates a list of firmware version for every hub the user has
603  *
604  * @return List of firmwares
605  */
606 private List getRealHubFirmwareVersions()
607 {
608     return location.hubs*.firmwareVersionString.findAll { it }
609 }