Update groveStreams.groovy
[smartapps.git] / third-party / Sonos.groovy
1 /**
2  *  Copyright 2015 SmartThings
3  *
4  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5  *  in compliance with the License. You may obtain a copy of the License at:
6  *
7  *      http://www.apache.org/licenses/LICENSE-2.0
8  *
9  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11  *  for the specific language governing permissions and limitations under the License.
12  *
13  *  Sonos Player
14  *
15  *  Author: SmartThings
16  *  Edited by obycode to add Speech Synthesis capability
17  */
18
19 metadata {
20         definition (name: "Sonos Player", namespace: "smartthings", author: "SmartThings") {
21                 capability "Actuator"
22                 capability "Switch"
23                 capability "Refresh"
24                 capability "Sensor"
25                 capability "Music Player"
26     capability "Speech Synthesis"
27
28                 attribute "model", "string"
29                 attribute "trackUri", "string"
30                 attribute "transportUri", "string"
31                 attribute "trackNumber", "string"
32
33                 command "subscribe"
34                 command "getVolume"
35                 command "getCurrentMedia"
36                 command "getCurrentStatus"
37                 command "seek"
38                 command "unsubscribe"
39                 command "setLocalLevel", ["number"]
40                 command "tileSetLevel", ["number"]
41                 command "playTrackAtVolume", ["string","number"]
42                 command "playTrackAndResume", ["string","number","number"]
43                 command "playTextAndResume", ["string","number"]
44                 command "playTrackAndRestore", ["string","number","number"]
45                 command "playTextAndRestore", ["string","number"]
46                 command "playSoundAndTrack", ["string","number","json_object","number"]
47                 command "playTextAndResume", ["string","json_object","number"]
48         }
49
50         // Main
51         standardTile("main", "device.status", width: 1, height: 1, canChangeIcon: true) {
52                 state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff"
53                 state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#79b821"
54                 state "grouped", label:'Grouped', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff"
55         }
56
57         // Row 1
58         standardTile("nextTrack", "device.status", width: 1, height: 1, decoration: "flat") {
59                 state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff"
60         }
61         standardTile("play", "device.status", width: 1, height: 1, decoration: "flat") {
62                 state "default", label:'', action:"music Player.play", icon:"st.sonos.play-btn", nextState:"playing", backgroundColor:"#ffffff"
63                 state "grouped", label:'', action:"music Player.play", icon:"st.sonos.play-btn", backgroundColor:"#ffffff"
64         }
65         standardTile("previousTrack", "device.status", width: 1, height: 1, decoration: "flat") {
66                 state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff"
67         }
68
69         // Row 2
70         standardTile("status", "device.status", width: 1, height: 1, decoration: "flat", canChangeIcon: true) {
71                 state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#ffffff"
72                 state "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff"
73                 state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff"
74                 state "grouped", label:'Grouped', action:"", icon:"st.Electronics.electronics16", backgroundColor:"#ffffff"
75         }
76         standardTile("pause", "device.status", width: 1, height: 1, decoration: "flat") {
77                 state "default", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", nextState:"paused", backgroundColor:"#ffffff"
78                 state "grouped", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", backgroundColor:"#ffffff"
79         }
80         standardTile("mute", "device.mute", inactiveLabel: false, decoration: "flat") {
81                 state "unmuted", label:"", action:"music Player.mute", icon:"st.custom.sonos.unmuted", backgroundColor:"#ffffff", nextState:"muted"
82                 state "muted", label:"", action:"music Player.unmute", icon:"st.custom.sonos.muted", backgroundColor:"#ffffff", nextState:"unmuted"
83         }
84
85         // Row 3
86         controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false) {
87                 state "level", action:"tileSetLevel", backgroundColor:"#ffffff"
88         }
89
90         // Row 4
91         valueTile("currentSong", "device.trackDescription", inactiveLabel: true, height:1, width:3, decoration: "flat") {
92                 state "default", label:'${currentValue}', backgroundColor:"#ffffff"
93         }
94
95         // Row 5
96         standardTile("refresh", "device.status", inactiveLabel: false, decoration: "flat") {
97                 state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh", backgroundColor:"#ffffff"
98         }
99         standardTile("model", "device.model", width: 1, height: 1, decoration: "flat") {
100                 state "Sonos PLAY:1", label:'', action:"", icon:"st.sonos.sonos-play1", backgroundColor:"#ffffff"
101                 state "Sonos PLAY:2", label:'', action:"", icon:"st.sonos.sonos-play2", backgroundColor:"#ffffff"
102                 state "Sonos PLAY:3", label:'', action:"", icon:"st.sonos.sonos-play3", backgroundColor:"#ffffff"
103                 state "Sonos PLAY:5", label:'', action:"", icon:"st.sonos.sonos-play5", backgroundColor:"#ffffff"
104                 state "Sonos CONNECT:AMP", label:'', action:"", icon:"st.sonos.sonos-connect-amp", backgroundColor:"#ffffff"
105                 state "Sonos CONNECT", label:'', action:"", icon:"st.sonos.sonos-connect", backgroundColor:"#ffffff"
106                 state "Sonos PLAYBAR", label:'', action:"", icon:"st.sonos.sonos-playbar", backgroundColor:"#ffffff"
107         }
108         standardTile("unsubscribe", "device.status", width: 1, height: 1, decoration: "flat") {
109                 state "previous", label:'Unsubscribe', action:"unsubscribe", backgroundColor:"#ffffff"
110         }
111
112         main "main"
113
114         details([
115                 "previousTrack","play","nextTrack",
116                 "status","pause","mute",
117                 "levelSliderControl",
118                 "currentSong",
119                 "refresh","model"
120                 //,"unsubscribe"
121         ])
122 }
123
124 // parse events into attributes
125 def parse(description) {
126         log.trace "parse('$description')"
127         def results = []
128         try {
129
130                 def msg = parseLanMessage(description)
131         log.info "requestId = $msg.requestId"
132                 if (msg.headers)
133                 {
134                         def hdr = msg.header.split('\n')[0]
135                         if (hdr.size() > 36) {
136                                 hdr = hdr[0..35] + "..."
137                         }
138
139                         def uuid = ""
140                         def sid = ""
141                         if (msg.headers["SID"])
142                         {
143                                 sid = msg.headers["SID"]
144                                 sid -= "uuid:"
145                                 sid = sid.trim()
146
147                                 def pos = sid.lastIndexOf("_")
148                                 if (pos > 0) {
149                                         uuid = sid[0..pos-1]
150                                         log.trace "uuid; $uuid"
151                                 }
152                         }
153
154                         log.trace "${hdr} ${description.size()} bytes, body = ${msg.body?.size() ?: 0} bytes, sid = ${sid}"
155
156                         if (!msg.body) {
157                                 if (sid) {
158                                         updateSid(sid)
159                                 }
160                         }
161                         else if (msg.xml) {
162                                 log.trace "has XML body"
163
164                                 // Process response to getVolume()
165                                 def node = msg.xml.Body.GetVolumeResponse
166                                 if (node.size()) {
167                                         log.trace "Extracting current volume"
168                                         sendEvent(name: "level",value: node.CurrentVolume.text())
169                                 }
170
171                                 // Process response to getCurrentStatus()
172                                 node = msg.xml.Body.GetTransportInfoResponse
173                                 if (node.size()) {
174                                         log.trace "Extracting current status"
175                                         def currentStatus = statusText(node.CurrentTransportState.text())
176                                         if (currentStatus) {
177                                                 if (currentStatus != "TRANSITIONING") {
178                                                         def coordinator = device.getDataValue('coordinator')
179                                                         log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'"
180                                                         updateDataValue('currentStatus', currentStatus)
181                                                         log.trace "Updated saved status to '$currentStatus'"
182
183                                                         if (coordinator) {
184                                                                 sendEvent(name: "status", value: "grouped", data: [source: 'xml.Body.GetTransportInfoResponse'])
185                                                                 sendEvent(name: "switch", value: "off", displayed: false)
186                                                         }
187                                                         else {
188                                                                 log.trace "status = $currentStatus"
189                                                                 sendEvent(name: "status", value: currentStatus, data: [source: 'xml.Body.GetTransportInfoResponse'])
190                                                                 sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false)
191                                                         }
192                                                 }
193                                         }
194                                 }
195
196                                 // Process group change
197                                 node = msg.xml.property.ZoneGroupState
198                                 if (node.size()) {
199                                         log.trace "Extracting group status"
200                                         // Important to use parser rather than slurper for this version of Groovy
201                                         def xml1 = new XmlParser().parseText(node.text())
202                                         log.trace "Parsed group xml"
203                                         def myNode =  xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == uuid}
204                                         log.trace "myNode: ${myNode}"
205
206                     // TODO - myNode is often false and throwing 1000 exceptions/hour, find out why
207                     // https://smartthings.atlassian.net/browse/DVCSMP-793
208                                         def myCoordinator = myNode?.parent()?.'@Coordinator'
209
210                                         log.trace "player: ${myNode?.'@UUID'}, coordinator: ${myCoordinator}"
211                                         if (myCoordinator && myCoordinator != myNode.'@UUID') {
212                                                 // this player is grouped, find the coordinator
213
214                                                 def coordinator = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == myCoordinator}
215                                                 def coordinatorDni = dniFromUri(coordinator.'@Location')
216                                                 log.trace "Player has a coordinator: $coordinatorDni"
217
218                                                 updateDataValue("coordinator", coordinatorDni)
219                                                 updateDataValue("isGroupCoordinator", "")
220
221                                                 def coordinatorDevice = parent.getChildDevice(coordinatorDni)
222
223                         // TODO - coordinatorDevice is also sometimes coming up null
224                         if (coordinatorDevice) {
225                             sendEvent(name: "trackDescription", value: "[Grouped with ${coordinatorDevice.displayName}]")
226                             sendEvent(name: "status", value: "grouped", data: [
227                                 coordinator: [displayName: coordinatorDevice.displayName, id: coordinatorDevice.id, deviceNetworkId: coordinatorDevice.deviceNetworkId]
228                             ])
229                             sendEvent(name: "switch", value: "off", displayed: false)
230                         }
231                         else {
232                                 log.warn "Could not find child device for coordinator 'coordinatorDni', devices: ${parent.childDevices*.deviceNetworkId}"
233                         }
234                                         }
235                                         else if (myNode) {
236                                                 // Not grouped
237                                                 updateDataValue("coordinator", "")
238                                                 updateDataValue("isGroupCoordinator", myNode.parent().ZoneGroupMember.size() > 1 ? "true" : "")
239                                         }
240                                         // Return a command to read the current status again to take care of status and group events arriving at
241                                         // about the same time, but in the reverse order
242                                         results << getCurrentStatus()
243                                 }
244
245                                 // Process subscription update
246                                 node = msg.xml.property.LastChange
247                                 if (node.size()) {
248                                         def xml1 = parseXml(node.text())
249
250                                         // Play/pause status
251                                         def currentStatus = statusText(xml1.InstanceID.TransportState.'@val'.text())
252                                         if (currentStatus) {
253                                                 if (currentStatus != "TRANSITIONING") {
254                                                         def coordinator = device.getDataValue('coordinator')
255                                                         log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'"
256                                                         updateDataValue('currentStatus', currentStatus)
257                                                         log.trace "Updated saved status to '$currentStatus'"
258
259                                                         if (coordinator) {
260                                                                 sendEvent(name: "status", value: "grouped", data: [source: 'xml.property.LastChange.InstanceID.TransportState'])
261                                                                 sendEvent(name: "switch", value: "off", displayed: false)
262                                                         }
263                                                         else {
264                                                                 log.trace "status = $currentStatus"
265                                                                 sendEvent(name: "status", value: currentStatus, data: [source: 'xml.property.LastChange.InstanceID.TransportState'])
266                                                                 sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false)
267                                                         }
268                                                 }
269                                         }
270
271                                         // Volume level
272                                         def currentLevel = xml1.InstanceID.Volume.find{it.'@channel' == 'Master'}.'@val'.text()
273                                         if (currentLevel) {
274                                                 log.trace "Has volume: '$currentLevel'"
275                                                 sendEvent(name: "level", value: currentLevel, description: description)
276                                         }
277
278                                         // Mute status
279                                         def currentMute = xml1.InstanceID.Mute.find{it.'@channel' == 'Master'}.'@val'.text()
280                                         if (currentMute) {
281                                                 def value = currentMute == "1" ? "muted" : "unmuted"
282                                                 log.trace "Has mute: '$currentMute', value: '$value"
283                                                 sendEvent(name: "mute", value: value, descriptionText: "$device.displayName is $value")
284                                         }
285
286                                         // Track data
287                                         def trackUri = xml1.InstanceID.CurrentTrackURI.'@val'.text()
288                                         def transportUri = xml1.InstanceID.AVTransportURI.'@val'.text()
289                                         def enqueuedUri = xml1.InstanceID.EnqueuedTransportURI.'@val'.text()
290                                         def trackNumber = xml1.InstanceID.CurrentTrack.'@val'.text()
291
292                                         if (trackUri.contains("//s3.amazonaws.com/smartapp-")) {
293                                                 log.trace "Skipping event generation for sound file $trackUri"
294                                         }
295                                         else {
296                                                 def trackMeta = xml1.InstanceID.CurrentTrackMetaData.'@val'.text()
297                                                 def transportMeta = xml1.InstanceID.AVTransportURIMetaData.'@val'.text()
298                                                 def enqueuedMeta = xml1.InstanceID.EnqueuedTransportURIMetaData.'@val'.text()
299
300                                                 if (trackMeta || transportMeta) {
301                                                         def isRadioStation = enqueuedUri.startsWith("x-sonosapi-stream:")
302
303                                                         // Use enqueued metadata, if available, otherwise transport metadata, for station ID
304                                                         def metaData = enqueuedMeta ? enqueuedMeta :  transportMeta
305                                                         def stationMetaXml = metaData ? parseXml(metaData) : null
306
307                                                         // Use the track metadata for song ID unless it's a radio station
308                                                         def trackXml = (trackMeta && !isRadioStation) || !stationMetaXml ? parseXml(trackMeta) : stationMetaXml
309
310                                                         // Song properties
311                                                         def currentName = trackXml.item.title.text()
312                                                         def currentArtist = trackXml.item.creator.text()
313                                                         def currentAlbum  = trackXml.item.album.text()
314                                                         def currentTrackDescription = currentName
315                                                         def descriptionText = "$device.displayName is playing $currentTrackDescription"
316                                                         if (currentArtist) {
317                                                                 currentTrackDescription += " - $currentArtist"
318                                                                 descriptionText += " by $currentArtist"
319                                                         }
320
321                                                         // Track Description Event
322                                                         log.info descriptionText
323                                                         sendEvent(name: "trackDescription",
324                                                                 value: currentTrackDescription,
325                                                                 descriptionText: descriptionText
326                                                         )
327
328                                                         // Have seen cases where there is no engueued or transport metadata. Haven't figured out how to resume in that case
329                                                         // so not creating a track data event.
330                                                         //
331                                                         if (stationMetaXml) {
332                                                                 // Track Data Event
333                                                                 // Use track description for the data event description unless it is a queued song (to support resumption & use in mood music)
334                                                                 def station = (transportUri?.startsWith("x-rincon-queue:") || enqueuedUri?.contains("savedqueues")) ? currentName : stationMetaXml.item.title.text()
335
336                                                                 def uri = enqueuedUri ?: transportUri
337                                                                 def previousState = device.currentState("trackData")?.jsonValue
338                                                                 def isDataStateChange = !previousState || (previousState.station != station || previousState.metaData != metaData)
339
340                                                                 if (transportUri?.startsWith("x-rincon-queue:")) {
341                                                                         updateDataValue("queueUri", transportUri)
342                                                                 }
343
344                                                                 def trackDataValue = [
345                                                                         station: station,
346                                                                         name: currentName,
347                                                                         artist: currentArtist,
348                                                                         album: currentAlbum,
349                                                                         trackNumber: trackNumber,
350                                                                         status: currentStatus,
351                                                                         level: currentLevel,
352                                                                         uri: uri,
353                                                                         trackUri: trackUri,
354                                                                         transportUri: transportUri,
355                                                                         enqueuedUri: enqueuedUri,
356                                                                         metaData: metaData,
357                                                                 ]
358
359                                                                 if (trackMeta != metaData) {
360                                                                         trackDataValue.trackMetaData = trackMeta
361                                                                 }
362
363                                                                 results << createEvent(name: "trackData",
364                                                                         value: trackDataValue.encodeAsJSON(),
365                                                                         descriptionText: currentDescription,
366                                                                         displayed: false,
367                                                                         isStateChange: isDataStateChange
368                                                                 )
369                                                         }
370                                                 }
371                                         }
372                                 }
373                                 if (!results) {
374                                         def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
375                                                 .replaceAll('(</[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
376                                                 .replaceAll('\n\n','\n').encodeAsHTML() : ""
377                                         results << createEvent(
378                                                 name: "sonosMessage",
379                                                 value: "${msg.body.encodeAsMD5()}",
380                                                 description: description,
381                                                 descriptionText: "Body is ${msg.body?.size() ?: 0} bytes",
382                                                 data: "<pre>${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}</pre><br/><pre>${bodyHtml}</pre>",
383                                                 isStateChange: false, displayed: false)
384                                 }
385                         }
386                         else {
387                                 log.warn "Body not XML"
388                                 def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
389                                         .replaceAll('(</[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
390                                         .replaceAll('\n\n','\n').encodeAsHTML() : ""
391                                 results << createEvent(
392                                         name: "unknownMessage",
393                                         value: "${msg.body.encodeAsMD5()}",
394                                         description: description,
395                                         descriptionText: "Body is ${msg.body?.size() ?: 0} bytes",
396                                         data: "<pre>${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}</pre><br/><pre>${bodyHtml}</pre>",
397                                         isStateChange: true, displayed: true)
398                         }
399                 }
400                 //log.trace "/parse()"
401         }
402         catch (Throwable t) {
403                 //results << createEvent(name: "parseError", value: "$t")
404                 sendEvent(name: "parseError", value: "$t", description: description)
405                 throw t
406         }
407         results
408 }
409
410 def installed() {
411         def result = [delayAction(5000)]
412         result << refresh()
413         result.flatten()
414 }
415
416 def on(){
417         play()
418 }
419
420 def off(){
421         stop()
422 }
423
424 def setModel(String model)
425 {
426         log.trace "setModel to $model"
427         sendEvent(name:"model",value:model,isStateChange:true)
428 }
429
430 def refresh() {
431         log.trace "refresh()"
432         def result = subscribe()
433         result << getCurrentStatus()
434         result << getVolume()
435         result.flatten()
436 }
437
438 // For use by apps, sets all levels if sent to a non-coordinator in a group
439 def setLevel(val)
440 {
441         log.trace "setLevel($val)"
442         coordinate({
443                 setOtherLevels(val)
444                 setLocalLevel(val)
445         }, {
446                 it.setLevel(val)
447         })
448 }
449
450 // For use by tiles, sets all levels if a coordinator, otherwise sets only the local one
451 def tileSetLevel(val)
452 {
453         log.trace "tileSetLevel($val)"
454         coordinate({
455                 setOtherLevels(val)
456                 setLocalLevel(val)
457         }, {
458                 setLocalLevel(val)
459         })
460 }
461
462 // Always sets only this level
463 def setLocalLevel(val, delay=0) {
464         log.trace "setLocalLevel($val)"
465         def v = Math.max(Math.min(Math.round(val), 100), 0)
466         log.trace "volume = $v"
467
468         def result = []
469         if (delay) {
470                 result << delayAction(delay)
471         }
472         result << sonosAction("SetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredVolume: v])
473         //result << delayAction(500),
474         result << sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"])
475         result
476 }
477
478 private setOtherLevels(val, delay=0) {
479         log.trace "setOtherLevels($val)"
480         if (device.getDataValue('isGroupCoordinator')) {
481                 log.trace "setting levels of coordinated players"
482                 def previousMaster = device.currentState("level")?.integerValue
483                 parent.getChildDevices().each {child ->
484                         if (child.getDeviceDataByName("coordinator") == device.deviceNetworkId) {
485                                 def newLevel = childLevel(previousMaster, val, child.currentState("level")?.integerValue)
486                                 log.trace "Setting level of $child.displayName to $newLevel"
487                                 child.setLocalLevel(newLevel, delay)
488                         }
489                 }
490         }
491         log.trace "/setOtherLevels()"
492 }
493
494 private childLevel(previousMaster, newMaster, previousChild)
495 {
496         if (previousMaster) {
497                 if (previousChild) {
498                         Math.round(previousChild * (newMaster / previousMaster))
499                 }
500                 else {
501                         newMaster
502                 }
503         }
504         else {
505                 newMaster
506         }
507 }
508
509 def getGroupStatus() {
510         def result = coordinate({device.currentValue("status")}, {it.currentValue("status")})
511         log.trace "getGroupStatus(), result=$result"
512         result
513 }
514
515 def play() {
516         log.trace "play()"
517         coordinate({sonosAction("Play")}, {it.play()})
518 }
519
520 def stop() {
521         log.trace "stop()"
522         coordinate({sonosAction("Stop")}, {it.stop()})
523 }
524
525 def pause() {
526         log.trace "pause()"
527         coordinate({sonosAction("Pause")}, {it.pause()})
528 }
529
530 def nextTrack() {
531         log.trace "nextTrack()"
532         coordinate({sonosAction("Next")}, {it.nextTrack()})
533 }
534
535 def previousTrack() {
536         log.trace "previousTrack()"
537         coordinate({sonosAction("Previous")}, {it.previousTrack()})
538 }
539
540 def seek(trackNumber) {
541         log.trace "seek($trackNumber)"
542         coordinate({sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: trackNumber])}, {it.seek(trackNumber)})
543 }
544
545 def mute()
546 {
547         log.trace "mute($m)"
548         // TODO - handle like volume?
549         //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1])}, {it.mute()})
550         sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1])
551 }
552
553 def unmute()
554 {
555         log.trace "mute($m)"
556         // TODO - handle like volume?
557         //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0])}, {it.unmute()})
558         sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0])
559 }
560
561 def setPlayMode(mode)
562 {
563         log.trace "setPlayMode($mode)"
564         coordinate({sonosAction("SetPlayMode", [InstanceID: 0, NewPlayMode: mode])}, {it.setPlayMode(mode)})
565 }
566
567 def speak(text) {
568   playTextAndResume(text)
569 }
570
571 def playTextAndResume(text, volume=null)
572 {
573         log.debug "playTextAndResume($text, $volume)"
574         coordinate({
575                 def sound = textToSpeech(text)
576                 playTrackAndResume(sound.uri, (sound.duration as Integer) + 1, volume)
577         }, {it.playTextAndResume(text, volume)})
578 }
579
580 def playTrackAndResume(uri, duration, volume=null) {
581         log.debug "playTrackAndResume($uri, $duration, $volume)"
582         coordinate({
583                 def currentTrack = device.currentState("trackData")?.jsonValue
584                 def currentVolume = device.currentState("level")?.integerValue
585                 def currentStatus = device.currentValue("status")
586                 def level = volume as Integer
587
588                 def result = []
589                 if (level) {
590                         log.trace "Stopping and setting level to $volume"
591                         result << sonosAction("Stop")
592                         result << setLocalLevel(level)
593                 }
594
595                 log.trace "Setting sound track: ${uri}"
596                 result << setTrack(uri)
597                 result << sonosAction("Play")
598
599                 if (currentTrack) {
600                         def delayTime = ((duration as Integer) * 1000)+3000
601                         if (level) {
602                                 delayTime += 1000
603                         }
604                         result << delayAction(delayTime)
605                         log.trace "Delaying $delayTime ms before resumption"
606                         if (level) {
607                                 log.trace "Restoring volume to $currentVolume"
608                                 result << sonosAction("Stop")
609                                 result << setLocalLevel(currentVolume)
610                         }
611                         log.trace "Restoring track $currentTrack.uri"
612                         result << setTrack(currentTrack)
613                         if (currentStatus == "playing") {
614                                 result << sonosAction("Play")
615                         }
616                 }
617
618                 result = result.flatten()
619                 log.trace "Returning ${result.size()} commands"
620                 result
621         }, {it.playTrackAndResume(uri, duration, volume)})
622 }
623
624 def playTextAndRestore(text, volume=null)
625 {
626         log.debug "playTextAndResume($text, $volume)"
627         coordinate({
628                 def sound = textToSpeech(text)
629                 playTrackAndRestore(sound.uri, (sound.duration as Integer) + 1, volume)
630         }, {it.playTextAndRestore(text, volume)})
631 }
632
633 def playTrackAndRestore(uri, duration, volume=null) {
634         log.debug "playTrackAndRestore($uri, $duration, $volume)"
635         coordinate({
636                 def currentTrack = device.currentState("trackData")?.jsonValue
637                 def currentVolume = device.currentState("level")?.integerValue
638                 def currentStatus = device.currentValue("status")
639                 def level = volume as Integer
640
641                 def result = []
642                 if (level) {
643                         log.trace "Stopping and setting level to $volume"
644                         result << sonosAction("Stop")
645                         result << setLocalLevel(level)
646                 }
647
648                 log.trace "Setting sound track: ${uri}"
649                 result << setTrack(uri)
650                 result << sonosAction("Play")
651
652                 if (currentTrack) {
653                         def delayTime = ((duration as Integer) * 1000)+3000
654                         if (level) {
655                                 delayTime += 1000
656                         }
657                         result << delayAction(delayTime)
658                         log.trace "Delaying $delayTime ms before restoration"
659                         if (level) {
660                                 log.trace "Restoring volume to $currentVolume"
661                                 result << sonosAction("Stop")
662                                 result << setLocalLevel(currentVolume)
663                         }
664                         log.trace "Restoring track $currentTrack.uri"
665                         result << setTrack(currentTrack)
666                 }
667
668                 result = result.flatten()
669                 log.trace "Returning ${result.size()} commands"
670                 result
671         }, {it.playTrackAndResume(uri, duration, volume)})
672 }
673
674 def playTextAndTrack(text, trackData, volume=null)
675 {
676         log.debug "playTextAndTrack($text, $trackData, $volume)"
677         coordinate({
678                 def sound = textToSpeech(text)
679                 playSoundAndTrack(sound.uri, (sound.duration as Integer) + 1, trackData, volume)
680         }, {it.playTextAndResume(text, volume)})
681 }
682
683 def playSoundAndTrack(soundUri, duration, trackData, volume=null) {
684         log.debug "playSoundAndTrack($soundUri, $duration, $trackUri, $volume)"
685         coordinate({
686                 def level = volume as Integer
687                 def result = []
688                 if (level) {
689                         log.trace "Stopping and setting level to $volume"
690                         result << sonosAction("Stop")
691                         result << setLocalLevel(level)
692                 }
693
694                 log.trace "Setting sound track: ${soundUri}"
695                 result << setTrack(soundUri)
696                 result << sonosAction("Play")
697
698                 def delayTime = ((duration as Integer) * 1000)+3000
699                 result << delayAction(delayTime)
700                 log.trace "Delaying $delayTime ms before resumption"
701
702                 log.trace "Setting track $trackData"
703                 result << setTrack(trackData)
704                 result << sonosAction("Play")
705
706                 result = result.flatten()
707                 log.trace "Returning ${result.size()} commands"
708                 result
709         }, {it.playTrackAndResume(uri, duration, volume)})
710 }
711
712 def playTrackAtVolume(String uri, volume) {
713         log.trace "playTrack()"
714         coordinate({
715                 def result = []
716                 result << sonosAction("Stop")
717                 result << setLocalLevel(volume as Integer)
718                 result << setTrack(uri, metaData)
719                 result << sonosAction("Play")
720                 result.flatten()
721         }, {it.playTrack(uri, metaData)})
722 }
723
724 def playTrack(String uri, metaData="") {
725         log.trace "playTrack()"
726         coordinate({
727                 def result = setTrack(uri, metaData)
728                 result << sonosAction("Play")
729                 result.flatten()
730         }, {it.playTrack(uri, metaData)})
731 }
732
733 def playTrack(Map trackData) {
734         log.trace "playTrack(Map)"
735         coordinate({
736                 def result = setTrack(trackData)
737                 //result << delayAction(1000)
738                 result << sonosAction("Play")
739                 result.flatten()
740         }, {it.playTrack(trackData)})
741 }
742
743 def setTrack(Map trackData) {
744         log.trace "setTrack($trackData.uri, ${trackData.metaData?.size()} B)"
745         coordinate({
746                 def data = trackData
747                 def result = []
748                 if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) {
749                         // TODO - Clear queue?
750                         def uri = device.getDataValue('queueUri')
751                         result << sonosAction("RemoveAllTracksFromQueue", [InstanceID: 0])
752                         //result << delayAction(500)
753                         result << sonosAction("AddURIToQueue", [InstanceID: 0, EnqueuedURI: data.uri, EnqueuedURIMetaData: data.metaData, DesiredFirstTrackNumberEnqueued: 0, EnqueueAsNext: 1])
754                         //result << delayAction(500)
755                         result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData])
756                         //result << delayAction(500)
757                         result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber])
758                 } else {
759                         result = setTrack(data.uri, data.metaData)
760                 }
761                 result.flatten()
762         }, {it.setTrack(trackData)})
763 }
764
765 def setTrack(String uri, metaData="")
766 {
767         log.info "setTrack($uri, $trackNumber, ${metaData?.size()} B})"
768         coordinate({
769                 def result = []
770                 result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData])
771                 result
772         }, {it.setTrack(uri, metaData)})
773 }
774
775 def resumeTrack(Map trackData = null) {
776         log.trace "resumeTrack()"
777         coordinate({
778                 def result = restoreTrack(trackData)
779                 //result << delayAction(500)
780                 result << sonosAction("Play")
781                 result
782         }, {it.resumeTrack(trackData)})
783 }
784
785 def restoreTrack(Map trackData = null) {
786         log.trace "restoreTrack(${trackData?.uri})"
787         coordinate({
788                 def result = []
789                 def data = trackData
790                 if (!data) {
791                         data = device.currentState("trackData")?.jsonValue
792                 }
793                 if (data) {
794                         if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) {
795                                 def uri = device.getDataValue('queueUri')
796                                 log.trace "Restoring queue position $data.trackNumber of $data.uri"
797                                 result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: data.metaData])
798                                 //result << delayAction(500)
799                                 result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber])
800                         } else {
801                                 log.trace "Setting track to $data.uri"
802                                 //setTrack(data.uri, null, data.metaData)
803                                 result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: data.uri, CurrentURIMetaData: data.metaData])
804                         }
805                 }
806                 else {
807                         log.warn "Previous track data not found"
808                 }
809                 result
810         }, {it.restoreTrack(trackData)})
811 }
812
813 def playText(String msg) {
814         coordinate({
815                 def result = setText(msg)
816                 result << sonosAction("Play")
817         }, {it.playText(msg)})
818 }
819
820 def setText(String msg) {
821         log.trace "setText($msg)"
822         coordinate({
823                 def sound = textToSpeech(msg)
824                 setTrack(sound.uri)
825         }, {it.setText(msg)})
826 }
827
828 // Custom commands
829
830 def subscribe() {
831         log.trace "subscribe()"
832         def result = []
833         result << subscribeAction("/MediaRenderer/AVTransport/Event")
834         result << delayAction(10000)
835         result << subscribeAction("/MediaRenderer/RenderingControl/Event")
836         result << delayAction(20000)
837         result << subscribeAction("/ZoneGroupTopology/Event")
838
839         result
840 }
841
842 def unsubscribe() {
843         log.trace "unsubscribe()"
844         def result = [
845                 unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId')),
846                 unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId')),
847                 unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId')),
848
849                 unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId1')),
850                 unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId1')),
851                 unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId1')),
852
853                 unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId2')),
854                 unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId2')),
855                 unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId2'))
856         ]
857         updateDataValue("subscriptionId", "")
858         updateDataValue("subscriptionId1", "")
859         updateDataValue("subscriptionId2", "")
860         result
861 }
862
863 def getVolume()
864 {
865         log.trace "getVolume()"
866         sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"])
867 }
868
869 def getCurrentMedia()
870 {
871         log.trace "getCurrentMedia()"
872         sonosAction("GetPositionInfo", [InstanceID:0, Channel: "Master"])
873 }
874
875 def getCurrentStatus() //transport info
876 {
877         log.trace "getCurrentStatus()"
878         sonosAction("GetTransportInfo", [InstanceID:0])
879 }
880
881 def getSystemString()
882 {
883         log.trace "getSystemString()"
884         sonosAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"])
885 }
886
887 private messageFilename(String msg) {
888         msg.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/,'_')
889 }
890
891 private getCallBackAddress()
892 {
893         device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP")
894 }
895
896 private sonosAction(String action) {
897         sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID:0, Speed:1])
898 }
899
900 private sonosAction(String action, Map body) {
901         sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", body)
902 }
903
904 private sonosAction(String action, String service, String path, Map body = [InstanceID:0, Speed:1]) {
905         log.trace "sonosAction($action, $service, $path, $body)"
906         def result = new physicalgraph.device.HubSoapAction(
907                 path:    path ?: "/MediaRenderer/$service/Control",
908                 urn:     "urn:schemas-upnp-org:service:$service:1",
909                 action:  action,
910                 body:    body,
911                 headers: [Host:getHostAddress(), CONNECTION: "close"]
912         )
913
914         //log.trace "\n${result.action.encodeAsHTML()}"
915         log.debug "sonosAction: $result.requestId"
916         result
917 }
918
919 private subscribeAction(path, callbackPath="") {
920         log.trace "subscribe($path, $callbackPath)"
921         def address = getCallBackAddress()
922         def ip = getHostAddress()
923
924         def result = new physicalgraph.device.HubAction(
925                 method: "SUBSCRIBE",
926                 path: path,
927                 headers: [
928                         HOST: ip,
929                         CALLBACK: "<http://${address}/notify$callbackPath>",
930                         NT: "upnp:event",
931                         TIMEOUT: "Second-28800"])
932
933         log.trace "SUBSCRIBE $path"
934         //log.trace "\n${result.action.encodeAsHTML()}"
935         result
936 }
937
938 private unsubscribeAction(path, sid) {
939         log.trace "unsubscribe($path, $sid)"
940         def ip = getHostAddress()
941         def result = new physicalgraph.device.HubAction(
942                 method: "UNSUBSCRIBE",
943                 path: path,
944                 headers: [
945                         HOST: ip,
946                         SID: "uuid:${sid}"])
947
948         log.trace "UNSUBSCRIBE $path"
949         //log.trace "\n${result.action.encodeAsHTML()}"
950         result
951 }
952
953 private delayAction(long time) {
954         new physicalgraph.device.HubAction("delay $time")
955 }
956
957 private Integer convertHexToInt(hex) {
958         Integer.parseInt(hex,16)
959 }
960
961 private String convertHexToIP(hex) {
962         [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".")
963 }
964
965 private getHostAddress() {
966         def parts = device.deviceNetworkId.split(":")
967         def ip = convertHexToIP(parts[0])
968         def port = convertHexToInt(parts[1])
969         return ip + ":" + port
970 }
971
972 private statusText(s) {
973         switch(s) {
974                 case "PLAYING":
975                         return "playing"
976                 case "PAUSED_PLAYBACK":
977                         return "paused"
978                 case "STOPPED":
979                         return "stopped"
980                 default:
981                         return s
982         }
983 }
984
985 private updateSid(sid) {
986         if (sid) {
987                 def sid0 = device.getDataValue('subscriptionId')
988                 def sid1 = device.getDataValue('subscriptionId1')
989                 def sid2 = device.getDataValue('subscriptionId2')
990                 def sidNumber = device.getDataValue('sidNumber') ?: "0"
991
992                 log.trace "updateSid($sid), sid0=$sid0, sid1=$sid1, sid2=$sid2, sidNumber=$sidNumber"
993                 if (sidNumber == "0") {
994                         if (sid != sid1 && sid != sid2) {
995                                 updateDataValue("subscriptionId", sid)
996                                 updateDataValue("sidNumber", "1")
997                         }
998                 }
999                 else if (sidNumber == "1") {
1000                         if (sid != sid0 && sid != sid2) {
1001                                 updateDataValue("subscriptionId1", sid)
1002                                 updateDataValue("sidNumber", "2")
1003                         }
1004                 }
1005                 else {
1006                         if (sid != sid0 && sid != sid0) {
1007                                 updateDataValue("subscriptionId2", sid)
1008                                 updateDataValue("sidNumber", "0")
1009                         }
1010                 }
1011         }
1012 }
1013
1014 private dniFromUri(uri) {
1015         def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":")
1016         def nums = segs[0].split("\\.")
1017         (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase()
1018 }
1019
1020 private hex(value, width=2) {
1021         def s = new BigInteger(Math.round(value).toString()).toString(16)
1022         while (s.size() < width) {
1023                 s = "0" + s
1024         }
1025         s
1026 }
1027
1028 private coordinate(closure1, closure2) {
1029         def coordinator = device.getDataValue('coordinator')
1030         if (coordinator) {
1031                 closure2(parent.getChildDevice(coordinator))
1032         }
1033         else {
1034                 closure1()
1035         }
1036 }