/** * Sonos Favorites Support for Hubitat * Schwark Satyavolu * */ def version() {"1.0.3"} import hubitat.helper.InterfaceUtils def appVersion() { return version() } def appName() { return "Sonos Favorites Support" } definition( name: "${appName()}", namespace: "schwark", author: "Schwark Satyavolu", description: "This adds support for Sonos Favorites", category: "Convenience", iconUrl: "https://play-lh.googleusercontent.com/ixBnWaJs0NdWI1w4rpAgiWlavHQZ2cMpatPoh3dwbj6ywnYIZ0g6me16prz-ABr7GA", iconX2Url: "https://play-lh.googleusercontent.com/ixBnWaJs0NdWI1w4rpAgiWlavHQZ2cMpatPoh3dwbj6ywnYIZ0g6me16prz-ABr7GA", singleInstance: true, importUrl: "https://raw.githubusercontent.com/schwark/hubitat-sonos-favorites/main/sonos-favorites.groovy" ) preferences { page(name: "mainPage") page(name: "configPage") } def getFormat(type, myText=""){ if(type == "section") return "
${myText}
" if(type == "hlight") return "
${myText}
" if(type == "header") return "
${myText}
" if(type == "redhead") return "
${myText}
" if(type == "line") return "\n
" if(type == "centerBold") return "
${myText}
" } def mainPage(){ dynamicPage(name:"mainPage",install:true, uninstall:true){ section { input "debugMode", "bool", title: "Enable debugging", defaultValue: true } section(getFormat("header", "Step 1: Choose your Sonos Speakers")) { input "sonoses", "capability.musicPlayer", title: "Speaker", submitOnChange: true, multiple:true, required: true } if(sonoses){ section(getFormat("header", "Step 2: Configure/Edit Your Presets")){ href "configPage", title: "Presets" } } } } def configPage(){ refresh() dynamicPage(name: "configPage", title: "Configure/Edit Presets:") { section(""){input("numPresets", "number", title: getFormat("section", "How many presets?:"), submitOnChange: true, range: "1..25")} if(numPresets){ for(i in 1..numPresets){ section(getFormat("header", "Preset ${i}")){ input("speaker${i}", "enum", title: getFormat("section", "Speaker:"), options: state.speakers) input("preset${i}", "enum", title: getFormat("section", "Preset:"), options: state.presets, submitOnChange: true) input("volume${i}", "number", title: getFormat("section", "Volume:"), range: "0..100", submitOnChange: true) } } } } } def installed() { initialize() } def updated() { initialize() updateDevices() } def initialize() { unschedule() } def uninstalled() { def children = getAllChildDevices() log.info("uninstalled: children = ${children}") children.each { deleteChildDevice(it.deviceNetworkId) } } def updateDevices() { for(i in 1..numPresets) { def label = state.speakers[settings."speaker${i}"]?.replaceAll(/(?i) (sonos|speaker)/,'')+" Fave ${i}" log.info("${label} Sonos favorite switch being updated/created") createChildDevice(label, i) } } def getUDN(sonos) { return sonos.getDataValue('subscriptionId')?.replaceAll(/(uuid:|_[^_]+$)/,'') } def isRadio(uri) { return uri ==~ /(x\-sonosapi\-stream|x\-sonosapi\-radio|pndrradio|x\-sonosapi\-hls|hls\-radio|m3u8|x\-sonosprog\-http)/ } def updateSpeakers() { state.speakers = [:] sonoses?.each() { state.speakers[it.getDeviceNetworkId()] = it.getLabel() } } def updatePresets() { state.presets = state.presets ?: [:] state.favorites?.each() { k, v -> state.presets[k] = v.title } } def refresh() { updateSpeakers() getFavorites() } def getFavorites() { sonosRequest(sonoses[0], 'Browse', [ObjectID: 'FV:2'], 'favorites') } def getCommand(cmd) { def commands = [ ContentDirectory : [ class: 'ContentDirectory', urn : 'urn:schemas-upnp-org:service:ContentDirectory:1', control : '/MediaServer/ContentDirectory/Control', events : '/MediaServer/ContentDirectory/Event', commands : [ Browse : [params : [ObjectID : "", BrowseFlag : "BrowseDirectChildren", Filter : "*", StartingIndex : 0, RequestedCount : 100, SortCriteria:""]] ] ], RenderingControl : [ class: 'RenderingControl', urn : 'urn:schemas-upnp-org:service:RenderingControl:1', control : '/MediaRenderer/RenderingControl/Control', events : '/MediaRenderer/RenderingControl/Event', commands : [ GetVolume : [params : [InstanceID : 0, Channel : "Master"]], GetMute : [params : [InstanceID : 0, Channel : "Master"]], SetVolume : [params : [InstanceID : 0, Channel : "Master", DesiredVolume : 50]], SetMute : [params : [InstanceID : 0, Channel : "Master", DesiredMute : true]], ] ], GroupRenderingControl : [ class: 'GroupRenderingControl', urn : 'urn:schemas-upnp-org:service:GroupRenderingControl:1', control : '/MediaRenderer/GroupRenderingControl/Control', events : '/MediaRenderer/GroupRenderingControl/Event', commands : [ GetGroupVolume : [params : [InstanceID : 0]], GetGroupMute : [params : [InstanceID : 0]], SetGroupVolume : [params : [InstanceID : 0, DesiredVolume : 50]], SetGroupMute : [params : [InstanceID : 0, DesiredMute : true]], ] ], ZoneGroupTopology : [ class: 'ZoneGroupTopology', urn : 'urn:schemas-upnp-org:service:ZoneGroupTopology:1', control : '/ZoneGroupTopology/Control', events : '/ZoneGroupTopology/Event', commands : [ GetZoneGroupState : [], GetZoneGroupAttributes : [] ] ], AVTransport : [ class: 'AVTransport', urn : 'urn:schemas-upnp-org:service:AVTransport:1', control : '/MediaRenderer/AVTransport/Control', events : '/MediaRenderer/AVTransport/Event', commands : [ SetAVTransportURI : [params : [InstanceID : 0, CurrentURI : "", CurrentURIMetaData: ""]], RemoveAllTracksFromQueue : [params : [InstanceID : 0]], AddURIToQueue : [params : [InstanceID : 0, EnqueuedURI : "", EnqueuedURIMetaData: "", DesiredFirstTrackNumberEnqueued:0, EnqueueAsNext:false]], GetMediaInfo : [params : [InstanceID : 0]], GetPositionInfo : [params : [InstanceID : 0]], GetTransportInfo : [params : [InstanceID : 0]], // STOPPED / PLAYING / PAUSED_PLAYBACK / TRANSITIONING GetTransportSettings : [params : [InstanceID : 0]], // NORMAL / REPEAT_ALL / REPEAT_ONE / SHUFFLE_NOREPEAT / SHUFFLE / SHUFFLE_REPEAT_ONE SetPlayMode : [params : [InstanceID : 0, NewPlayMode : ""]], //NORMAL / REPEAT_ALL / REPEAT_ONE / SHUFFLE_NOREPEAT / SHUFFLE / SHUFFLE_REPEAT_ONE Play : [params : [InstanceID : 0, Speed : 1]], Pause : [params : [InstanceID : 0]], Stop : [params : [InstanceID : 0]], Next : [params : [InstanceID : 0]], Previous : [params : [InstanceID : 0]], Seek : [params : [InstanceID : 0, Unit : "", Target : ""]], // TRACK_NR / REL_TIME / TIME_DELTA // Position of track in queue (start at 1) or hh:mm:ss for REL_TIME or +/-hh:mm:ss for TIME_DELTA ] ], Queue : [ class: 'Queue', urn : 'urn:schemas-sonos-com:service:Queue:1', control : '/MediaRenderer/Queue/Control', events : '/MediaRenderer/Queue/Event', commands : [ ] ] ] def result = null commands.each { cls, meta -> if(!result) { meta['commands'].each { name, cmdmeta -> if (name == cmd) return result = meta } } } return result } def fixXML(didl) { def fixing = (didl ==~ /]+>/) if(fixing) { didl = didl.replaceAll(/()(.+)(<\/r:resMD>)/) { def child = "${it[1]}" xml = (new XmlSlurper().parseText(child)) as String it[0] + groovy.xml.XmlUtil.escapeXml(xml) + it[2] } } return didl } def parseResponse(cmd, resp, var) { def xml = resp.data.text def envelope = new XmlSlurper().parseText(xml) if('Browse' == cmd) { def didl = new XmlSlurper().parseText(envelope.children()[0].children()[0].children()[0].text()) state[var] = [:] didl.children().each() { def id = (it.@id as String).replace('FV:2/','') state[var][id] = [title: it.title as String, uri: it.res as String, id: it.@id as String, meta: it.resMD as String, desc: it.description as String] } updatePresets() } envelope = null didl = null } def sonosRequest(sonos, cmd, values=null, var=null) { def ip = convertHexToIP(sonos.getDeviceNetworkId()) def meta = getCommand(cmd) def params = meta['commands'][cmd]['params'] def paramXml = '' params.each {k,v -> paramXml = paramXml + sprintf("<%s>%s", k, groovy.xml.XmlUtil.escapeXml((values?.containsKey(k) ? values[k] : params[k]) as String), k) } def req = sprintf(""" %s """, cmd, meta['urn'], paramXml, cmd) def headers = [ Host: ip+':1400', soapaction: meta['urn']+'#'+cmd ] def url = 'http://'+ip+':1400'+meta['control'] debug("${url} -> ${headers} -> ${req}") try { httpPost([uri: url, headers: headers, body: req, contentType: 'text/xml; charset="utf-8"', textParser: true], { parseResponse(cmd, it, var) } ) } catch (groovyx.net.http.HttpResponseException e) { logError('sonosRequest', "${e.statusCode}: ${e.response.data}") } } private createChildDevice(label, id) { def deviceId = makeChildDeviceId(id) def createdDevice = getChildDevice(deviceId) def name = "Sonos Favorite" if(!createdDevice) { try { def component = 'Generic Component Switch' // create the child device addChildDevice("hubitat", component, deviceId, [label : "${label}", isComponent: false, name: "${name}"]) createdDevice = getChildDevice(deviceId) def created = createdDevice ? "created" : "failed creation" log.info("Sonos Favorite Switch: id: ${deviceId} label: ${label} ${created}") } catch (e) { logError("Failed to add child device with error: ${e}", "createChildDevice()") } } else { debug("Child device type: ${type} id: ${deviceId} already exists", "createChildDevice()") if(label && label != createdDevice.getLabel()) { createdDevice.setLabel(label) createdDevice.sendEvent(name:'label', value: label, isStateChange: true) } if(name && name != createdDevice.getName()) { createdDevice.setName(name) createdDevice.sendEvent(name:'name', value: name, isStateChange: true) } } return createdDevice } def addURItoQueue(sonos, uri, meta="") { debug("adding ${uri} to queue") sonosRequest(sonos, 'AddURIToQueue', [EnqueuedURI : uri, EnqueuedURIMetaData: meta, DesiredFirstTrackNumberEnqueued: 1]) } def setURI(sonos, uri, meta="") { debug("setting current uri to ${uri}") sonosRequest(sonos, 'SetAVTransportURI', [CurrentURI : uri, CurrentURIMetaData: meta]) } def setVolume(sonos, volume) { debug("setting current volume to ${volume}") sonosRequest(sonos, 'SetVolume', [DesiredVolume : volume]) } def play(sonos, uri, meta="") { if(!isRadio(uri)) { addURItoQueue(sonos, uri, meta) def udn = getUDN(sonos) uri = "x-rincon-queue:${udn}#0" meta = "" } setURI(sonos, uri, meta) pauseExecution(1000) sonosRequest(sonos, 'Play') } def stop(sonos) { sonosRequest(sonos, 'Stop') } def getSonosById(id) { debug("finding sonos for ${id}...") def result = null sonoses?.each() { if(it.getDeviceNetworkId() == id) result = it } debug(result) return result } void componentRefresh(cd) { debug("received refresh request from ${cd.displayName}") refresh() } def componentOn(cd) { debug("received on request from DN = ${cd.name}, DNI = ${cd.deviceNetworkId}") def idparts = cd.deviceNetworkId.split("-") def num = idparts[-1] debug("preset num is ${num}") def sonos = getSonosById(settings."speaker${num}") if(sonos) { def volume = state.favorites[settings."volume${num}"] ?: 3 if(volume) { setVolume(sonos, volume) pauseExecution(2000) } def fave = state.favorites[settings."preset${num}"] if(fave) { play(sonos, fave.uri, fave.meta) cd.sendEvent(name: 'switch', value: 'on') } } } def componentOff(cd) { debug("received off request from DN = ${cd.name}, DNI = ${cd.deviceNetworkId}") def idparts = cd.deviceNetworkId.split("-") def num = idparts[-1] def sonos = getSonosById(settings."speaker${num}") if(sonos) { stop(sonos) cd.sendEvent(name: 'switch', value: 'off') } } def makeChildDeviceId(id) { def hubid = 'SONOSFAVORITES' return "${hubid}-${id}" } private debug(logMessage, fromMethod="") { if (debugMode) { def fMethod = "" if (fromMethod) { fMethod = ".${fromMethod}" } log.debug("[Sonos Favorites] DEBUG: ${fMethod}: ${logMessage}") } } private logError(fromMethod, e) { log.error("[Sonos Favorites] ERROR: (${fromMethod}): ${e}") } 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(".") }