2 * Copyright 2015 SmartThings
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:
7 * http://www.apache.org/licenses/LICENSE-2.0
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.
16 * Edited by obycode to add Speech Synthesis capability
20 definition (name: "Sonos Player", namespace: "smartthings", author: "SmartThings") {
25 capability "Music Player"
26 capability "Speech Synthesis"
28 attribute "model", "string"
29 attribute "trackUri", "string"
30 attribute "transportUri", "string"
31 attribute "trackNumber", "string"
35 command "getCurrentMedia"
36 command "getCurrentStatus"
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"]
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"
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"
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"
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"
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"
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"
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"
86 controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false) {
87 state "level", action:"tileSetLevel", backgroundColor:"#ffffff"
91 valueTile("currentSong", "device.trackDescription", inactiveLabel: true, height:1, width:3, decoration: "flat") {
92 state "default", label:'${currentValue}', backgroundColor:"#ffffff"
96 standardTile("refresh", "device.status", inactiveLabel: false, decoration: "flat") {
97 state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh", backgroundColor:"#ffffff"
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"
108 standardTile("unsubscribe", "device.status", width: 1, height: 1, decoration: "flat") {
109 state "previous", label:'Unsubscribe', action:"unsubscribe", backgroundColor:"#ffffff"
115 "previousTrack","play","nextTrack",
116 "status","pause","mute",
117 "levelSliderControl",
124 // parse events into attributes
125 def parse(description) {
126 log.trace "parse('$description')"
130 def msg = parseLanMessage(description)
131 log.info "requestId = $msg.requestId"
134 def hdr = msg.header.split('\n')[0]
135 if (hdr.size() > 36) {
136 hdr = hdr[0..35] + "..."
141 if (msg.headers["SID"])
143 sid = msg.headers["SID"]
147 def pos = sid.lastIndexOf("_")
150 log.trace "uuid; $uuid"
154 log.trace "${hdr} ${description.size()} bytes, body = ${msg.body?.size() ?: 0} bytes, sid = ${sid}"
162 log.trace "has XML body"
164 // Process response to getVolume()
165 def node = msg.xml.Body.GetVolumeResponse
167 log.trace "Extracting current volume"
168 sendEvent(name: "level",value: node.CurrentVolume.text())
171 // Process response to getCurrentStatus()
172 node = msg.xml.Body.GetTransportInfoResponse
174 log.trace "Extracting current status"
175 def currentStatus = statusText(node.CurrentTransportState.text())
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'"
184 sendEvent(name: "status", value: "grouped", data: [source: 'xml.Body.GetTransportInfoResponse'])
185 sendEvent(name: "switch", value: "off", displayed: false)
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)
196 // Process group change
197 node = msg.xml.property.ZoneGroupState
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}"
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'
210 log.trace "player: ${myNode?.'@UUID'}, coordinator: ${myCoordinator}"
211 if (myCoordinator && myCoordinator != myNode.'@UUID') {
212 // this player is grouped, find the coordinator
214 def coordinator = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == myCoordinator}
215 def coordinatorDni = dniFromUri(coordinator.'@Location')
216 log.trace "Player has a coordinator: $coordinatorDni"
218 updateDataValue("coordinator", coordinatorDni)
219 updateDataValue("isGroupCoordinator", "")
221 def coordinatorDevice = parent.getChildDevice(coordinatorDni)
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]
229 sendEvent(name: "switch", value: "off", displayed: false)
232 log.warn "Could not find child device for coordinator 'coordinatorDni', devices: ${parent.childDevices*.deviceNetworkId}"
237 updateDataValue("coordinator", "")
238 updateDataValue("isGroupCoordinator", myNode.parent().ZoneGroupMember.size() > 1 ? "true" : "")
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()
245 // Process subscription update
246 node = msg.xml.property.LastChange
248 def xml1 = parseXml(node.text())
251 def currentStatus = statusText(xml1.InstanceID.TransportState.'@val'.text())
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'"
260 sendEvent(name: "status", value: "grouped", data: [source: 'xml.property.LastChange.InstanceID.TransportState'])
261 sendEvent(name: "switch", value: "off", displayed: false)
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)
272 def currentLevel = xml1.InstanceID.Volume.find{it.'@channel' == 'Master'}.'@val'.text()
274 log.trace "Has volume: '$currentLevel'"
275 sendEvent(name: "level", value: currentLevel, description: description)
279 def currentMute = xml1.InstanceID.Mute.find{it.'@channel' == 'Master'}.'@val'.text()
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")
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()
292 if (trackUri.contains("//s3.amazonaws.com/smartapp-")) {
293 log.trace "Skipping event generation for sound file $trackUri"
296 def trackMeta = xml1.InstanceID.CurrentTrackMetaData.'@val'.text()
297 def transportMeta = xml1.InstanceID.AVTransportURIMetaData.'@val'.text()
298 def enqueuedMeta = xml1.InstanceID.EnqueuedTransportURIMetaData.'@val'.text()
300 if (trackMeta || transportMeta) {
301 def isRadioStation = enqueuedUri.startsWith("x-sonosapi-stream:")
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
307 // Use the track metadata for song ID unless it's a radio station
308 def trackXml = (trackMeta && !isRadioStation) || !stationMetaXml ? parseXml(trackMeta) : stationMetaXml
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"
317 currentTrackDescription += " - $currentArtist"
318 descriptionText += " by $currentArtist"
321 // Track Description Event
322 log.info descriptionText
323 sendEvent(name: "trackDescription",
324 value: currentTrackDescription,
325 descriptionText: descriptionText
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.
331 if (stationMetaXml) {
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()
336 def uri = enqueuedUri ?: transportUri
337 def previousState = device.currentState("trackData")?.jsonValue
338 def isDataStateChange = !previousState || (previousState.station != station || previousState.metaData != metaData)
340 if (transportUri?.startsWith("x-rincon-queue:")) {
341 updateDataValue("queueUri", transportUri)
344 def trackDataValue = [
347 artist: currentArtist,
349 trackNumber: trackNumber,
350 status: currentStatus,
354 transportUri: transportUri,
355 enqueuedUri: enqueuedUri,
359 if (trackMeta != metaData) {
360 trackDataValue.trackMetaData = trackMeta
363 results << createEvent(name: "trackData",
364 value: trackDataValue.encodeAsJSON(),
365 descriptionText: currentDescription,
367 isStateChange: isDataStateChange
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)
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)
400 //log.trace "/parse()"
402 catch (Throwable t) {
403 //results << createEvent(name: "parseError", value: "$t")
404 sendEvent(name: "parseError", value: "$t", description: description)
411 def result = [delayAction(5000)]
424 def setModel(String model)
426 log.trace "setModel to $model"
427 sendEvent(name:"model",value:model,isStateChange:true)
431 log.trace "refresh()"
432 def result = subscribe()
433 result << getCurrentStatus()
434 result << getVolume()
438 // For use by apps, sets all levels if sent to a non-coordinator in a group
441 log.trace "setLevel($val)"
450 // For use by tiles, sets all levels if a coordinator, otherwise sets only the local one
451 def tileSetLevel(val)
453 log.trace "tileSetLevel($val)"
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"
470 result << delayAction(delay)
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"])
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)
491 log.trace "/setOtherLevels()"
494 private childLevel(previousMaster, newMaster, previousChild)
496 if (previousMaster) {
498 Math.round(previousChild * (newMaster / previousMaster))
509 def getGroupStatus() {
510 def result = coordinate({device.currentValue("status")}, {it.currentValue("status")})
511 log.trace "getGroupStatus(), result=$result"
517 coordinate({sonosAction("Play")}, {it.play()})
522 coordinate({sonosAction("Stop")}, {it.stop()})
527 coordinate({sonosAction("Pause")}, {it.pause()})
531 log.trace "nextTrack()"
532 coordinate({sonosAction("Next")}, {it.nextTrack()})
535 def previousTrack() {
536 log.trace "previousTrack()"
537 coordinate({sonosAction("Previous")}, {it.previousTrack()})
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)})
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])
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])
561 def setPlayMode(mode)
563 log.trace "setPlayMode($mode)"
564 coordinate({sonosAction("SetPlayMode", [InstanceID: 0, NewPlayMode: mode])}, {it.setPlayMode(mode)})
568 playTextAndResume(text)
571 def playTextAndResume(text, volume=null)
573 log.debug "playTextAndResume($text, $volume)"
575 def sound = textToSpeech(text)
576 playTrackAndResume(sound.uri, (sound.duration as Integer) + 1, volume)
577 }, {it.playTextAndResume(text, volume)})
580 def playTrackAndResume(uri, duration, volume=null) {
581 log.debug "playTrackAndResume($uri, $duration, $volume)"
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
590 log.trace "Stopping and setting level to $volume"
591 result << sonosAction("Stop")
592 result << setLocalLevel(level)
595 log.trace "Setting sound track: ${uri}"
596 result << setTrack(uri)
597 result << sonosAction("Play")
600 def delayTime = ((duration as Integer) * 1000)+3000
604 result << delayAction(delayTime)
605 log.trace "Delaying $delayTime ms before resumption"
607 log.trace "Restoring volume to $currentVolume"
608 result << sonosAction("Stop")
609 result << setLocalLevel(currentVolume)
611 log.trace "Restoring track $currentTrack.uri"
612 result << setTrack(currentTrack)
613 if (currentStatus == "playing") {
614 result << sonosAction("Play")
618 result = result.flatten()
619 log.trace "Returning ${result.size()} commands"
621 }, {it.playTrackAndResume(uri, duration, volume)})
624 def playTextAndRestore(text, volume=null)
626 log.debug "playTextAndResume($text, $volume)"
628 def sound = textToSpeech(text)
629 playTrackAndRestore(sound.uri, (sound.duration as Integer) + 1, volume)
630 }, {it.playTextAndRestore(text, volume)})
633 def playTrackAndRestore(uri, duration, volume=null) {
634 log.debug "playTrackAndRestore($uri, $duration, $volume)"
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
643 log.trace "Stopping and setting level to $volume"
644 result << sonosAction("Stop")
645 result << setLocalLevel(level)
648 log.trace "Setting sound track: ${uri}"
649 result << setTrack(uri)
650 result << sonosAction("Play")
653 def delayTime = ((duration as Integer) * 1000)+3000
657 result << delayAction(delayTime)
658 log.trace "Delaying $delayTime ms before restoration"
660 log.trace "Restoring volume to $currentVolume"
661 result << sonosAction("Stop")
662 result << setLocalLevel(currentVolume)
664 log.trace "Restoring track $currentTrack.uri"
665 result << setTrack(currentTrack)
668 result = result.flatten()
669 log.trace "Returning ${result.size()} commands"
671 }, {it.playTrackAndResume(uri, duration, volume)})
674 def playTextAndTrack(text, trackData, volume=null)
676 log.debug "playTextAndTrack($text, $trackData, $volume)"
678 def sound = textToSpeech(text)
679 playSoundAndTrack(sound.uri, (sound.duration as Integer) + 1, trackData, volume)
680 }, {it.playTextAndResume(text, volume)})
683 def playSoundAndTrack(soundUri, duration, trackData, volume=null) {
684 log.debug "playSoundAndTrack($soundUri, $duration, $trackUri, $volume)"
686 def level = volume as Integer
689 log.trace "Stopping and setting level to $volume"
690 result << sonosAction("Stop")
691 result << setLocalLevel(level)
694 log.trace "Setting sound track: ${soundUri}"
695 result << setTrack(soundUri)
696 result << sonosAction("Play")
698 def delayTime = ((duration as Integer) * 1000)+3000
699 result << delayAction(delayTime)
700 log.trace "Delaying $delayTime ms before resumption"
702 log.trace "Setting track $trackData"
703 result << setTrack(trackData)
704 result << sonosAction("Play")
706 result = result.flatten()
707 log.trace "Returning ${result.size()} commands"
709 }, {it.playTrackAndResume(uri, duration, volume)})
712 def playTrackAtVolume(String uri, volume) {
713 log.trace "playTrack()"
716 result << sonosAction("Stop")
717 result << setLocalLevel(volume as Integer)
718 result << setTrack(uri, metaData)
719 result << sonosAction("Play")
721 }, {it.playTrack(uri, metaData)})
724 def playTrack(String uri, metaData="") {
725 log.trace "playTrack()"
727 def result = setTrack(uri, metaData)
728 result << sonosAction("Play")
730 }, {it.playTrack(uri, metaData)})
733 def playTrack(Map trackData) {
734 log.trace "playTrack(Map)"
736 def result = setTrack(trackData)
737 //result << delayAction(1000)
738 result << sonosAction("Play")
740 }, {it.playTrack(trackData)})
743 def setTrack(Map trackData) {
744 log.trace "setTrack($trackData.uri, ${trackData.metaData?.size()} B)"
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])
759 result = setTrack(data.uri, data.metaData)
762 }, {it.setTrack(trackData)})
765 def setTrack(String uri, metaData="")
767 log.info "setTrack($uri, $trackNumber, ${metaData?.size()} B})"
770 result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData])
772 }, {it.setTrack(uri, metaData)})
775 def resumeTrack(Map trackData = null) {
776 log.trace "resumeTrack()"
778 def result = restoreTrack(trackData)
779 //result << delayAction(500)
780 result << sonosAction("Play")
782 }, {it.resumeTrack(trackData)})
785 def restoreTrack(Map trackData = null) {
786 log.trace "restoreTrack(${trackData?.uri})"
791 data = device.currentState("trackData")?.jsonValue
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])
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])
807 log.warn "Previous track data not found"
810 }, {it.restoreTrack(trackData)})
813 def playText(String msg) {
815 def result = setText(msg)
816 result << sonosAction("Play")
817 }, {it.playText(msg)})
820 def setText(String msg) {
821 log.trace "setText($msg)"
823 def sound = textToSpeech(msg)
825 }, {it.setText(msg)})
831 log.trace "subscribe()"
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")
843 log.trace "unsubscribe()"
845 unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId')),
846 unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId')),
847 unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId')),
849 unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId1')),
850 unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId1')),
851 unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId1')),
853 unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId2')),
854 unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId2')),
855 unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId2'))
857 updateDataValue("subscriptionId", "")
858 updateDataValue("subscriptionId1", "")
859 updateDataValue("subscriptionId2", "")
865 log.trace "getVolume()"
866 sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"])
869 def getCurrentMedia()
871 log.trace "getCurrentMedia()"
872 sonosAction("GetPositionInfo", [InstanceID:0, Channel: "Master"])
875 def getCurrentStatus() //transport info
877 log.trace "getCurrentStatus()"
878 sonosAction("GetTransportInfo", [InstanceID:0])
881 def getSystemString()
883 log.trace "getSystemString()"
884 sonosAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"])
887 private messageFilename(String msg) {
888 msg.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/,'_')
891 private getCallBackAddress()
893 device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP")
896 private sonosAction(String action) {
897 sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID:0, Speed:1])
900 private sonosAction(String action, Map body) {
901 sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", body)
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",
911 headers: [Host:getHostAddress(), CONNECTION: "close"]
914 //log.trace "\n${result.action.encodeAsHTML()}"
915 log.debug "sonosAction: $result.requestId"
919 private subscribeAction(path, callbackPath="") {
920 log.trace "subscribe($path, $callbackPath)"
921 def address = getCallBackAddress()
922 def ip = getHostAddress()
924 def result = new physicalgraph.device.HubAction(
929 CALLBACK: "<http://${address}/notify$callbackPath>",
931 TIMEOUT: "Second-28800"])
933 log.trace "SUBSCRIBE $path"
934 //log.trace "\n${result.action.encodeAsHTML()}"
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",
948 log.trace "UNSUBSCRIBE $path"
949 //log.trace "\n${result.action.encodeAsHTML()}"
953 private delayAction(long time) {
954 new physicalgraph.device.HubAction("delay $time")
957 private Integer convertHexToInt(hex) {
958 Integer.parseInt(hex,16)
961 private String convertHexToIP(hex) {
962 [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".")
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
972 private statusText(s) {
976 case "PAUSED_PLAYBACK":
985 private updateSid(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"
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")
999 else if (sidNumber == "1") {
1000 if (sid != sid0 && sid != sid2) {
1001 updateDataValue("subscriptionId1", sid)
1002 updateDataValue("sidNumber", "2")
1006 if (sid != sid0 && sid != sid0) {
1007 updateDataValue("subscriptionId2", sid)
1008 updateDataValue("sidNumber", "0")
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()
1020 private hex(value, width=2) {
1021 def s = new BigInteger(Math.round(value).toString()).toString(16)
1022 while (s.size() < width) {
1028 private coordinate(closure1, closure2) {
1029 def coordinator = device.getDataValue('coordinator')
1031 closure2(parent.getChildDevice(coordinator))