--- /dev/null
+/**
+ * Copyright 2015 SmartThings
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
+ * for the specific language governing permissions and limitations under the License.
+ *
+ * Sonos Player
+ *
+ * Author: SmartThings
+ * Edited by obycode to add Speech Synthesis capability
+ */
+
+metadata {
+ definition (name: "Sonos Player", namespace: "smartthings", author: "SmartThings") {
+ capability "Actuator"
+ capability "Switch"
+ capability "Refresh"
+ capability "Sensor"
+ capability "Music Player"
+ capability "Speech Synthesis"
+
+ attribute "model", "string"
+ attribute "trackUri", "string"
+ attribute "transportUri", "string"
+ attribute "trackNumber", "string"
+
+ command "subscribe"
+ command "getVolume"
+ command "getCurrentMedia"
+ command "getCurrentStatus"
+ command "seek"
+ command "unsubscribe"
+ command "setLocalLevel", ["number"]
+ command "tileSetLevel", ["number"]
+ command "playTrackAtVolume", ["string","number"]
+ command "playTrackAndResume", ["string","number","number"]
+ command "playTextAndResume", ["string","number"]
+ command "playTrackAndRestore", ["string","number","number"]
+ command "playTextAndRestore", ["string","number"]
+ command "playSoundAndTrack", ["string","number","json_object","number"]
+ command "playTextAndResume", ["string","json_object","number"]
+ }
+
+ // Main
+ standardTile("main", "device.status", width: 1, height: 1, canChangeIcon: true) {
+ state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff"
+ state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#79b821"
+ state "grouped", label:'Grouped', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff"
+ }
+
+ // Row 1
+ standardTile("nextTrack", "device.status", width: 1, height: 1, decoration: "flat") {
+ state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff"
+ }
+ standardTile("play", "device.status", width: 1, height: 1, decoration: "flat") {
+ state "default", label:'', action:"music Player.play", icon:"st.sonos.play-btn", nextState:"playing", backgroundColor:"#ffffff"
+ state "grouped", label:'', action:"music Player.play", icon:"st.sonos.play-btn", backgroundColor:"#ffffff"
+ }
+ standardTile("previousTrack", "device.status", width: 1, height: 1, decoration: "flat") {
+ state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff"
+ }
+
+ // Row 2
+ standardTile("status", "device.status", width: 1, height: 1, decoration: "flat", canChangeIcon: true) {
+ state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#ffffff"
+ state "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff"
+ state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff"
+ state "grouped", label:'Grouped', action:"", icon:"st.Electronics.electronics16", backgroundColor:"#ffffff"
+ }
+ standardTile("pause", "device.status", width: 1, height: 1, decoration: "flat") {
+ state "default", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", nextState:"paused", backgroundColor:"#ffffff"
+ state "grouped", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", backgroundColor:"#ffffff"
+ }
+ standardTile("mute", "device.mute", inactiveLabel: false, decoration: "flat") {
+ state "unmuted", label:"", action:"music Player.mute", icon:"st.custom.sonos.unmuted", backgroundColor:"#ffffff", nextState:"muted"
+ state "muted", label:"", action:"music Player.unmute", icon:"st.custom.sonos.muted", backgroundColor:"#ffffff", nextState:"unmuted"
+ }
+
+ // Row 3
+ controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false) {
+ state "level", action:"tileSetLevel", backgroundColor:"#ffffff"
+ }
+
+ // Row 4
+ valueTile("currentSong", "device.trackDescription", inactiveLabel: true, height:1, width:3, decoration: "flat") {
+ state "default", label:'${currentValue}', backgroundColor:"#ffffff"
+ }
+
+ // Row 5
+ standardTile("refresh", "device.status", inactiveLabel: false, decoration: "flat") {
+ state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh", backgroundColor:"#ffffff"
+ }
+ standardTile("model", "device.model", width: 1, height: 1, decoration: "flat") {
+ state "Sonos PLAY:1", label:'', action:"", icon:"st.sonos.sonos-play1", backgroundColor:"#ffffff"
+ state "Sonos PLAY:2", label:'', action:"", icon:"st.sonos.sonos-play2", backgroundColor:"#ffffff"
+ state "Sonos PLAY:3", label:'', action:"", icon:"st.sonos.sonos-play3", backgroundColor:"#ffffff"
+ state "Sonos PLAY:5", label:'', action:"", icon:"st.sonos.sonos-play5", backgroundColor:"#ffffff"
+ state "Sonos CONNECT:AMP", label:'', action:"", icon:"st.sonos.sonos-connect-amp", backgroundColor:"#ffffff"
+ state "Sonos CONNECT", label:'', action:"", icon:"st.sonos.sonos-connect", backgroundColor:"#ffffff"
+ state "Sonos PLAYBAR", label:'', action:"", icon:"st.sonos.sonos-playbar", backgroundColor:"#ffffff"
+ }
+ standardTile("unsubscribe", "device.status", width: 1, height: 1, decoration: "flat") {
+ state "previous", label:'Unsubscribe', action:"unsubscribe", backgroundColor:"#ffffff"
+ }
+
+ main "main"
+
+ details([
+ "previousTrack","play","nextTrack",
+ "status","pause","mute",
+ "levelSliderControl",
+ "currentSong",
+ "refresh","model"
+ //,"unsubscribe"
+ ])
+}
+
+// parse events into attributes
+def parse(description) {
+ log.trace "parse('$description')"
+ def results = []
+ try {
+
+ def msg = parseLanMessage(description)
+ log.info "requestId = $msg.requestId"
+ if (msg.headers)
+ {
+ def hdr = msg.header.split('\n')[0]
+ if (hdr.size() > 36) {
+ hdr = hdr[0..35] + "..."
+ }
+
+ def uuid = ""
+ def sid = ""
+ if (msg.headers["SID"])
+ {
+ sid = msg.headers["SID"]
+ sid -= "uuid:"
+ sid = sid.trim()
+
+ def pos = sid.lastIndexOf("_")
+ if (pos > 0) {
+ uuid = sid[0..pos-1]
+ log.trace "uuid; $uuid"
+ }
+ }
+
+ log.trace "${hdr} ${description.size()} bytes, body = ${msg.body?.size() ?: 0} bytes, sid = ${sid}"
+
+ if (!msg.body) {
+ if (sid) {
+ updateSid(sid)
+ }
+ }
+ else if (msg.xml) {
+ log.trace "has XML body"
+
+ // Process response to getVolume()
+ def node = msg.xml.Body.GetVolumeResponse
+ if (node.size()) {
+ log.trace "Extracting current volume"
+ sendEvent(name: "level",value: node.CurrentVolume.text())
+ }
+
+ // Process response to getCurrentStatus()
+ node = msg.xml.Body.GetTransportInfoResponse
+ if (node.size()) {
+ log.trace "Extracting current status"
+ def currentStatus = statusText(node.CurrentTransportState.text())
+ if (currentStatus) {
+ if (currentStatus != "TRANSITIONING") {
+ def coordinator = device.getDataValue('coordinator')
+ log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'"
+ updateDataValue('currentStatus', currentStatus)
+ log.trace "Updated saved status to '$currentStatus'"
+
+ if (coordinator) {
+ sendEvent(name: "status", value: "grouped", data: [source: 'xml.Body.GetTransportInfoResponse'])
+ sendEvent(name: "switch", value: "off", displayed: false)
+ }
+ else {
+ log.trace "status = $currentStatus"
+ sendEvent(name: "status", value: currentStatus, data: [source: 'xml.Body.GetTransportInfoResponse'])
+ sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false)
+ }
+ }
+ }
+ }
+
+ // Process group change
+ node = msg.xml.property.ZoneGroupState
+ if (node.size()) {
+ log.trace "Extracting group status"
+ // Important to use parser rather than slurper for this version of Groovy
+ def xml1 = new XmlParser().parseText(node.text())
+ log.trace "Parsed group xml"
+ def myNode = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == uuid}
+ log.trace "myNode: ${myNode}"
+
+ // TODO - myNode is often false and throwing 1000 exceptions/hour, find out why
+ // https://smartthings.atlassian.net/browse/DVCSMP-793
+ def myCoordinator = myNode?.parent()?.'@Coordinator'
+
+ log.trace "player: ${myNode?.'@UUID'}, coordinator: ${myCoordinator}"
+ if (myCoordinator && myCoordinator != myNode.'@UUID') {
+ // this player is grouped, find the coordinator
+
+ def coordinator = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == myCoordinator}
+ def coordinatorDni = dniFromUri(coordinator.'@Location')
+ log.trace "Player has a coordinator: $coordinatorDni"
+
+ updateDataValue("coordinator", coordinatorDni)
+ updateDataValue("isGroupCoordinator", "")
+
+ def coordinatorDevice = parent.getChildDevice(coordinatorDni)
+
+ // TODO - coordinatorDevice is also sometimes coming up null
+ if (coordinatorDevice) {
+ sendEvent(name: "trackDescription", value: "[Grouped with ${coordinatorDevice.displayName}]")
+ sendEvent(name: "status", value: "grouped", data: [
+ coordinator: [displayName: coordinatorDevice.displayName, id: coordinatorDevice.id, deviceNetworkId: coordinatorDevice.deviceNetworkId]
+ ])
+ sendEvent(name: "switch", value: "off", displayed: false)
+ }
+ else {
+ log.warn "Could not find child device for coordinator 'coordinatorDni', devices: ${parent.childDevices*.deviceNetworkId}"
+ }
+ }
+ else if (myNode) {
+ // Not grouped
+ updateDataValue("coordinator", "")
+ updateDataValue("isGroupCoordinator", myNode.parent().ZoneGroupMember.size() > 1 ? "true" : "")
+ }
+ // Return a command to read the current status again to take care of status and group events arriving at
+ // about the same time, but in the reverse order
+ results << getCurrentStatus()
+ }
+
+ // Process subscription update
+ node = msg.xml.property.LastChange
+ if (node.size()) {
+ def xml1 = parseXml(node.text())
+
+ // Play/pause status
+ def currentStatus = statusText(xml1.InstanceID.TransportState.'@val'.text())
+ if (currentStatus) {
+ if (currentStatus != "TRANSITIONING") {
+ def coordinator = device.getDataValue('coordinator')
+ log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'"
+ updateDataValue('currentStatus', currentStatus)
+ log.trace "Updated saved status to '$currentStatus'"
+
+ if (coordinator) {
+ sendEvent(name: "status", value: "grouped", data: [source: 'xml.property.LastChange.InstanceID.TransportState'])
+ sendEvent(name: "switch", value: "off", displayed: false)
+ }
+ else {
+ log.trace "status = $currentStatus"
+ sendEvent(name: "status", value: currentStatus, data: [source: 'xml.property.LastChange.InstanceID.TransportState'])
+ sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false)
+ }
+ }
+ }
+
+ // Volume level
+ def currentLevel = xml1.InstanceID.Volume.find{it.'@channel' == 'Master'}.'@val'.text()
+ if (currentLevel) {
+ log.trace "Has volume: '$currentLevel'"
+ sendEvent(name: "level", value: currentLevel, description: description)
+ }
+
+ // Mute status
+ def currentMute = xml1.InstanceID.Mute.find{it.'@channel' == 'Master'}.'@val'.text()
+ if (currentMute) {
+ def value = currentMute == "1" ? "muted" : "unmuted"
+ log.trace "Has mute: '$currentMute', value: '$value"
+ sendEvent(name: "mute", value: value, descriptionText: "$device.displayName is $value")
+ }
+
+ // Track data
+ def trackUri = xml1.InstanceID.CurrentTrackURI.'@val'.text()
+ def transportUri = xml1.InstanceID.AVTransportURI.'@val'.text()
+ def enqueuedUri = xml1.InstanceID.EnqueuedTransportURI.'@val'.text()
+ def trackNumber = xml1.InstanceID.CurrentTrack.'@val'.text()
+
+ if (trackUri.contains("//s3.amazonaws.com/smartapp-")) {
+ log.trace "Skipping event generation for sound file $trackUri"
+ }
+ else {
+ def trackMeta = xml1.InstanceID.CurrentTrackMetaData.'@val'.text()
+ def transportMeta = xml1.InstanceID.AVTransportURIMetaData.'@val'.text()
+ def enqueuedMeta = xml1.InstanceID.EnqueuedTransportURIMetaData.'@val'.text()
+
+ if (trackMeta || transportMeta) {
+ def isRadioStation = enqueuedUri.startsWith("x-sonosapi-stream:")
+
+ // Use enqueued metadata, if available, otherwise transport metadata, for station ID
+ def metaData = enqueuedMeta ? enqueuedMeta : transportMeta
+ def stationMetaXml = metaData ? parseXml(metaData) : null
+
+ // Use the track metadata for song ID unless it's a radio station
+ def trackXml = (trackMeta && !isRadioStation) || !stationMetaXml ? parseXml(trackMeta) : stationMetaXml
+
+ // Song properties
+ def currentName = trackXml.item.title.text()
+ def currentArtist = trackXml.item.creator.text()
+ def currentAlbum = trackXml.item.album.text()
+ def currentTrackDescription = currentName
+ def descriptionText = "$device.displayName is playing $currentTrackDescription"
+ if (currentArtist) {
+ currentTrackDescription += " - $currentArtist"
+ descriptionText += " by $currentArtist"
+ }
+
+ // Track Description Event
+ log.info descriptionText
+ sendEvent(name: "trackDescription",
+ value: currentTrackDescription,
+ descriptionText: descriptionText
+ )
+
+ // Have seen cases where there is no engueued or transport metadata. Haven't figured out how to resume in that case
+ // so not creating a track data event.
+ //
+ if (stationMetaXml) {
+ // Track Data Event
+ // Use track description for the data event description unless it is a queued song (to support resumption & use in mood music)
+ def station = (transportUri?.startsWith("x-rincon-queue:") || enqueuedUri?.contains("savedqueues")) ? currentName : stationMetaXml.item.title.text()
+
+ def uri = enqueuedUri ?: transportUri
+ def previousState = device.currentState("trackData")?.jsonValue
+ def isDataStateChange = !previousState || (previousState.station != station || previousState.metaData != metaData)
+
+ if (transportUri?.startsWith("x-rincon-queue:")) {
+ updateDataValue("queueUri", transportUri)
+ }
+
+ def trackDataValue = [
+ station: station,
+ name: currentName,
+ artist: currentArtist,
+ album: currentAlbum,
+ trackNumber: trackNumber,
+ status: currentStatus,
+ level: currentLevel,
+ uri: uri,
+ trackUri: trackUri,
+ transportUri: transportUri,
+ enqueuedUri: enqueuedUri,
+ metaData: metaData,
+ ]
+
+ if (trackMeta != metaData) {
+ trackDataValue.trackMetaData = trackMeta
+ }
+
+ results << createEvent(name: "trackData",
+ value: trackDataValue.encodeAsJSON(),
+ descriptionText: currentDescription,
+ displayed: false,
+ isStateChange: isDataStateChange
+ )
+ }
+ }
+ }
+ }
+ if (!results) {
+ def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
+ .replaceAll('(</[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
+ .replaceAll('\n\n','\n').encodeAsHTML() : ""
+ results << createEvent(
+ name: "sonosMessage",
+ value: "${msg.body.encodeAsMD5()}",
+ description: description,
+ descriptionText: "Body is ${msg.body?.size() ?: 0} bytes",
+ data: "<pre>${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}</pre><br/><pre>${bodyHtml}</pre>",
+ isStateChange: false, displayed: false)
+ }
+ }
+ else {
+ log.warn "Body not XML"
+ def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
+ .replaceAll('(</[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n')
+ .replaceAll('\n\n','\n').encodeAsHTML() : ""
+ results << createEvent(
+ name: "unknownMessage",
+ value: "${msg.body.encodeAsMD5()}",
+ description: description,
+ descriptionText: "Body is ${msg.body?.size() ?: 0} bytes",
+ data: "<pre>${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}</pre><br/><pre>${bodyHtml}</pre>",
+ isStateChange: true, displayed: true)
+ }
+ }
+ //log.trace "/parse()"
+ }
+ catch (Throwable t) {
+ //results << createEvent(name: "parseError", value: "$t")
+ sendEvent(name: "parseError", value: "$t", description: description)
+ throw t
+ }
+ results
+}
+
+def installed() {
+ def result = [delayAction(5000)]
+ result << refresh()
+ result.flatten()
+}
+
+def on(){
+ play()
+}
+
+def off(){
+ stop()
+}
+
+def setModel(String model)
+{
+ log.trace "setModel to $model"
+ sendEvent(name:"model",value:model,isStateChange:true)
+}
+
+def refresh() {
+ log.trace "refresh()"
+ def result = subscribe()
+ result << getCurrentStatus()
+ result << getVolume()
+ result.flatten()
+}
+
+// For use by apps, sets all levels if sent to a non-coordinator in a group
+def setLevel(val)
+{
+ log.trace "setLevel($val)"
+ coordinate({
+ setOtherLevels(val)
+ setLocalLevel(val)
+ }, {
+ it.setLevel(val)
+ })
+}
+
+// For use by tiles, sets all levels if a coordinator, otherwise sets only the local one
+def tileSetLevel(val)
+{
+ log.trace "tileSetLevel($val)"
+ coordinate({
+ setOtherLevels(val)
+ setLocalLevel(val)
+ }, {
+ setLocalLevel(val)
+ })
+}
+
+// Always sets only this level
+def setLocalLevel(val, delay=0) {
+ log.trace "setLocalLevel($val)"
+ def v = Math.max(Math.min(Math.round(val), 100), 0)
+ log.trace "volume = $v"
+
+ def result = []
+ if (delay) {
+ result << delayAction(delay)
+ }
+ result << sonosAction("SetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredVolume: v])
+ //result << delayAction(500),
+ result << sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"])
+ result
+}
+
+private setOtherLevels(val, delay=0) {
+ log.trace "setOtherLevels($val)"
+ if (device.getDataValue('isGroupCoordinator')) {
+ log.trace "setting levels of coordinated players"
+ def previousMaster = device.currentState("level")?.integerValue
+ parent.getChildDevices().each {child ->
+ if (child.getDeviceDataByName("coordinator") == device.deviceNetworkId) {
+ def newLevel = childLevel(previousMaster, val, child.currentState("level")?.integerValue)
+ log.trace "Setting level of $child.displayName to $newLevel"
+ child.setLocalLevel(newLevel, delay)
+ }
+ }
+ }
+ log.trace "/setOtherLevels()"
+}
+
+private childLevel(previousMaster, newMaster, previousChild)
+{
+ if (previousMaster) {
+ if (previousChild) {
+ Math.round(previousChild * (newMaster / previousMaster))
+ }
+ else {
+ newMaster
+ }
+ }
+ else {
+ newMaster
+ }
+}
+
+def getGroupStatus() {
+ def result = coordinate({device.currentValue("status")}, {it.currentValue("status")})
+ log.trace "getGroupStatus(), result=$result"
+ result
+}
+
+def play() {
+ log.trace "play()"
+ coordinate({sonosAction("Play")}, {it.play()})
+}
+
+def stop() {
+ log.trace "stop()"
+ coordinate({sonosAction("Stop")}, {it.stop()})
+}
+
+def pause() {
+ log.trace "pause()"
+ coordinate({sonosAction("Pause")}, {it.pause()})
+}
+
+def nextTrack() {
+ log.trace "nextTrack()"
+ coordinate({sonosAction("Next")}, {it.nextTrack()})
+}
+
+def previousTrack() {
+ log.trace "previousTrack()"
+ coordinate({sonosAction("Previous")}, {it.previousTrack()})
+}
+
+def seek(trackNumber) {
+ log.trace "seek($trackNumber)"
+ coordinate({sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: trackNumber])}, {it.seek(trackNumber)})
+}
+
+def mute()
+{
+ log.trace "mute($m)"
+ // TODO - handle like volume?
+ //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1])}, {it.mute()})
+ sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1])
+}
+
+def unmute()
+{
+ log.trace "mute($m)"
+ // TODO - handle like volume?
+ //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0])}, {it.unmute()})
+ sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0])
+}
+
+def setPlayMode(mode)
+{
+ log.trace "setPlayMode($mode)"
+ coordinate({sonosAction("SetPlayMode", [InstanceID: 0, NewPlayMode: mode])}, {it.setPlayMode(mode)})
+}
+
+def speak(text) {
+ playTextAndResume(text)
+}
+
+def playTextAndResume(text, volume=null)
+{
+ log.debug "playTextAndResume($text, $volume)"
+ coordinate({
+ def sound = textToSpeech(text)
+ playTrackAndResume(sound.uri, (sound.duration as Integer) + 1, volume)
+ }, {it.playTextAndResume(text, volume)})
+}
+
+def playTrackAndResume(uri, duration, volume=null) {
+ log.debug "playTrackAndResume($uri, $duration, $volume)"
+ coordinate({
+ def currentTrack = device.currentState("trackData")?.jsonValue
+ def currentVolume = device.currentState("level")?.integerValue
+ def currentStatus = device.currentValue("status")
+ def level = volume as Integer
+
+ def result = []
+ if (level) {
+ log.trace "Stopping and setting level to $volume"
+ result << sonosAction("Stop")
+ result << setLocalLevel(level)
+ }
+
+ log.trace "Setting sound track: ${uri}"
+ result << setTrack(uri)
+ result << sonosAction("Play")
+
+ if (currentTrack) {
+ def delayTime = ((duration as Integer) * 1000)+3000
+ if (level) {
+ delayTime += 1000
+ }
+ result << delayAction(delayTime)
+ log.trace "Delaying $delayTime ms before resumption"
+ if (level) {
+ log.trace "Restoring volume to $currentVolume"
+ result << sonosAction("Stop")
+ result << setLocalLevel(currentVolume)
+ }
+ log.trace "Restoring track $currentTrack.uri"
+ result << setTrack(currentTrack)
+ if (currentStatus == "playing") {
+ result << sonosAction("Play")
+ }
+ }
+
+ result = result.flatten()
+ log.trace "Returning ${result.size()} commands"
+ result
+ }, {it.playTrackAndResume(uri, duration, volume)})
+}
+
+def playTextAndRestore(text, volume=null)
+{
+ log.debug "playTextAndResume($text, $volume)"
+ coordinate({
+ def sound = textToSpeech(text)
+ playTrackAndRestore(sound.uri, (sound.duration as Integer) + 1, volume)
+ }, {it.playTextAndRestore(text, volume)})
+}
+
+def playTrackAndRestore(uri, duration, volume=null) {
+ log.debug "playTrackAndRestore($uri, $duration, $volume)"
+ coordinate({
+ def currentTrack = device.currentState("trackData")?.jsonValue
+ def currentVolume = device.currentState("level")?.integerValue
+ def currentStatus = device.currentValue("status")
+ def level = volume as Integer
+
+ def result = []
+ if (level) {
+ log.trace "Stopping and setting level to $volume"
+ result << sonosAction("Stop")
+ result << setLocalLevel(level)
+ }
+
+ log.trace "Setting sound track: ${uri}"
+ result << setTrack(uri)
+ result << sonosAction("Play")
+
+ if (currentTrack) {
+ def delayTime = ((duration as Integer) * 1000)+3000
+ if (level) {
+ delayTime += 1000
+ }
+ result << delayAction(delayTime)
+ log.trace "Delaying $delayTime ms before restoration"
+ if (level) {
+ log.trace "Restoring volume to $currentVolume"
+ result << sonosAction("Stop")
+ result << setLocalLevel(currentVolume)
+ }
+ log.trace "Restoring track $currentTrack.uri"
+ result << setTrack(currentTrack)
+ }
+
+ result = result.flatten()
+ log.trace "Returning ${result.size()} commands"
+ result
+ }, {it.playTrackAndResume(uri, duration, volume)})
+}
+
+def playTextAndTrack(text, trackData, volume=null)
+{
+ log.debug "playTextAndTrack($text, $trackData, $volume)"
+ coordinate({
+ def sound = textToSpeech(text)
+ playSoundAndTrack(sound.uri, (sound.duration as Integer) + 1, trackData, volume)
+ }, {it.playTextAndResume(text, volume)})
+}
+
+def playSoundAndTrack(soundUri, duration, trackData, volume=null) {
+ log.debug "playSoundAndTrack($soundUri, $duration, $trackUri, $volume)"
+ coordinate({
+ def level = volume as Integer
+ def result = []
+ if (level) {
+ log.trace "Stopping and setting level to $volume"
+ result << sonosAction("Stop")
+ result << setLocalLevel(level)
+ }
+
+ log.trace "Setting sound track: ${soundUri}"
+ result << setTrack(soundUri)
+ result << sonosAction("Play")
+
+ def delayTime = ((duration as Integer) * 1000)+3000
+ result << delayAction(delayTime)
+ log.trace "Delaying $delayTime ms before resumption"
+
+ log.trace "Setting track $trackData"
+ result << setTrack(trackData)
+ result << sonosAction("Play")
+
+ result = result.flatten()
+ log.trace "Returning ${result.size()} commands"
+ result
+ }, {it.playTrackAndResume(uri, duration, volume)})
+}
+
+def playTrackAtVolume(String uri, volume) {
+ log.trace "playTrack()"
+ coordinate({
+ def result = []
+ result << sonosAction("Stop")
+ result << setLocalLevel(volume as Integer)
+ result << setTrack(uri, metaData)
+ result << sonosAction("Play")
+ result.flatten()
+ }, {it.playTrack(uri, metaData)})
+}
+
+def playTrack(String uri, metaData="") {
+ log.trace "playTrack()"
+ coordinate({
+ def result = setTrack(uri, metaData)
+ result << sonosAction("Play")
+ result.flatten()
+ }, {it.playTrack(uri, metaData)})
+}
+
+def playTrack(Map trackData) {
+ log.trace "playTrack(Map)"
+ coordinate({
+ def result = setTrack(trackData)
+ //result << delayAction(1000)
+ result << sonosAction("Play")
+ result.flatten()
+ }, {it.playTrack(trackData)})
+}
+
+def setTrack(Map trackData) {
+ log.trace "setTrack($trackData.uri, ${trackData.metaData?.size()} B)"
+ coordinate({
+ def data = trackData
+ def result = []
+ if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) {
+ // TODO - Clear queue?
+ def uri = device.getDataValue('queueUri')
+ result << sonosAction("RemoveAllTracksFromQueue", [InstanceID: 0])
+ //result << delayAction(500)
+ result << sonosAction("AddURIToQueue", [InstanceID: 0, EnqueuedURI: data.uri, EnqueuedURIMetaData: data.metaData, DesiredFirstTrackNumberEnqueued: 0, EnqueueAsNext: 1])
+ //result << delayAction(500)
+ result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData])
+ //result << delayAction(500)
+ result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber])
+ } else {
+ result = setTrack(data.uri, data.metaData)
+ }
+ result.flatten()
+ }, {it.setTrack(trackData)})
+}
+
+def setTrack(String uri, metaData="")
+{
+ log.info "setTrack($uri, $trackNumber, ${metaData?.size()} B})"
+ coordinate({
+ def result = []
+ result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData])
+ result
+ }, {it.setTrack(uri, metaData)})
+}
+
+def resumeTrack(Map trackData = null) {
+ log.trace "resumeTrack()"
+ coordinate({
+ def result = restoreTrack(trackData)
+ //result << delayAction(500)
+ result << sonosAction("Play")
+ result
+ }, {it.resumeTrack(trackData)})
+}
+
+def restoreTrack(Map trackData = null) {
+ log.trace "restoreTrack(${trackData?.uri})"
+ coordinate({
+ def result = []
+ def data = trackData
+ if (!data) {
+ data = device.currentState("trackData")?.jsonValue
+ }
+ if (data) {
+ if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) {
+ def uri = device.getDataValue('queueUri')
+ log.trace "Restoring queue position $data.trackNumber of $data.uri"
+ result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: data.metaData])
+ //result << delayAction(500)
+ result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber])
+ } else {
+ log.trace "Setting track to $data.uri"
+ //setTrack(data.uri, null, data.metaData)
+ result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: data.uri, CurrentURIMetaData: data.metaData])
+ }
+ }
+ else {
+ log.warn "Previous track data not found"
+ }
+ result
+ }, {it.restoreTrack(trackData)})
+}
+
+def playText(String msg) {
+ coordinate({
+ def result = setText(msg)
+ result << sonosAction("Play")
+ }, {it.playText(msg)})
+}
+
+def setText(String msg) {
+ log.trace "setText($msg)"
+ coordinate({
+ def sound = textToSpeech(msg)
+ setTrack(sound.uri)
+ }, {it.setText(msg)})
+}
+
+// Custom commands
+
+def subscribe() {
+ log.trace "subscribe()"
+ def result = []
+ result << subscribeAction("/MediaRenderer/AVTransport/Event")
+ result << delayAction(10000)
+ result << subscribeAction("/MediaRenderer/RenderingControl/Event")
+ result << delayAction(20000)
+ result << subscribeAction("/ZoneGroupTopology/Event")
+
+ result
+}
+
+def unsubscribe() {
+ log.trace "unsubscribe()"
+ def result = [
+ unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId')),
+ unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId')),
+ unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId')),
+
+ unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId1')),
+ unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId1')),
+ unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId1')),
+
+ unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId2')),
+ unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId2')),
+ unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId2'))
+ ]
+ updateDataValue("subscriptionId", "")
+ updateDataValue("subscriptionId1", "")
+ updateDataValue("subscriptionId2", "")
+ result
+}
+
+def getVolume()
+{
+ log.trace "getVolume()"
+ sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"])
+}
+
+def getCurrentMedia()
+{
+ log.trace "getCurrentMedia()"
+ sonosAction("GetPositionInfo", [InstanceID:0, Channel: "Master"])
+}
+
+def getCurrentStatus() //transport info
+{
+ log.trace "getCurrentStatus()"
+ sonosAction("GetTransportInfo", [InstanceID:0])
+}
+
+def getSystemString()
+{
+ log.trace "getSystemString()"
+ sonosAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"])
+}
+
+private messageFilename(String msg) {
+ msg.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/,'_')
+}
+
+private getCallBackAddress()
+{
+ device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP")
+}
+
+private sonosAction(String action) {
+ sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID:0, Speed:1])
+}
+
+private sonosAction(String action, Map body) {
+ sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", body)
+}
+
+private sonosAction(String action, String service, String path, Map body = [InstanceID:0, Speed:1]) {
+ log.trace "sonosAction($action, $service, $path, $body)"
+ def result = new physicalgraph.device.HubSoapAction(
+ path: path ?: "/MediaRenderer/$service/Control",
+ urn: "urn:schemas-upnp-org:service:$service:1",
+ action: action,
+ body: body,
+ headers: [Host:getHostAddress(), CONNECTION: "close"]
+ )
+
+ //log.trace "\n${result.action.encodeAsHTML()}"
+ log.debug "sonosAction: $result.requestId"
+ result
+}
+
+private subscribeAction(path, callbackPath="") {
+ log.trace "subscribe($path, $callbackPath)"
+ def address = getCallBackAddress()
+ def ip = getHostAddress()
+
+ def result = new physicalgraph.device.HubAction(
+ method: "SUBSCRIBE",
+ path: path,
+ headers: [
+ HOST: ip,
+ CALLBACK: "<http://${address}/notify$callbackPath>",
+ NT: "upnp:event",
+ TIMEOUT: "Second-28800"])
+
+ log.trace "SUBSCRIBE $path"
+ //log.trace "\n${result.action.encodeAsHTML()}"
+ result
+}
+
+private unsubscribeAction(path, sid) {
+ log.trace "unsubscribe($path, $sid)"
+ def ip = getHostAddress()
+ def result = new physicalgraph.device.HubAction(
+ method: "UNSUBSCRIBE",
+ path: path,
+ headers: [
+ HOST: ip,
+ SID: "uuid:${sid}"])
+
+ log.trace "UNSUBSCRIBE $path"
+ //log.trace "\n${result.action.encodeAsHTML()}"
+ result
+}
+
+private delayAction(long time) {
+ new physicalgraph.device.HubAction("delay $time")
+}
+
+private Integer convertHexToInt(hex) {
+ Integer.parseInt(hex,16)
+}
+
+private String convertHexToIP(hex) {
+ [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".")
+}
+
+private getHostAddress() {
+ def parts = device.deviceNetworkId.split(":")
+ def ip = convertHexToIP(parts[0])
+ def port = convertHexToInt(parts[1])
+ return ip + ":" + port
+}
+
+private statusText(s) {
+ switch(s) {
+ case "PLAYING":
+ return "playing"
+ case "PAUSED_PLAYBACK":
+ return "paused"
+ case "STOPPED":
+ return "stopped"
+ default:
+ return s
+ }
+}
+
+private updateSid(sid) {
+ if (sid) {
+ def sid0 = device.getDataValue('subscriptionId')
+ def sid1 = device.getDataValue('subscriptionId1')
+ def sid2 = device.getDataValue('subscriptionId2')
+ def sidNumber = device.getDataValue('sidNumber') ?: "0"
+
+ log.trace "updateSid($sid), sid0=$sid0, sid1=$sid1, sid2=$sid2, sidNumber=$sidNumber"
+ if (sidNumber == "0") {
+ if (sid != sid1 && sid != sid2) {
+ updateDataValue("subscriptionId", sid)
+ updateDataValue("sidNumber", "1")
+ }
+ }
+ else if (sidNumber == "1") {
+ if (sid != sid0 && sid != sid2) {
+ updateDataValue("subscriptionId1", sid)
+ updateDataValue("sidNumber", "2")
+ }
+ }
+ else {
+ if (sid != sid0 && sid != sid0) {
+ updateDataValue("subscriptionId2", sid)
+ updateDataValue("sidNumber", "0")
+ }
+ }
+ }
+}
+
+private dniFromUri(uri) {
+ def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":")
+ def nums = segs[0].split("\\.")
+ (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase()
+}
+
+private hex(value, width=2) {
+ def s = new BigInteger(Math.round(value).toString()).toString(16)
+ while (s.size() < width) {
+ s = "0" + s
+ }
+ s
+}
+
+private coordinate(closure1, closure2) {
+ def coordinator = device.getDataValue('coordinator')
+ if (coordinator) {
+ closure2(parent.getChildDevice(coordinator))
+ }
+ else {
+ closure1()
+ }
+}