/**
* MIT License
* Copyright 2023 Daniel Winks (daniel.winks@gmail.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#include dwinks.UtilitiesAndLoggingLibrary
#include dwinks.SMAPILibrary
definition(
name: 'Sonos Advanced Controller',
version: '0.7.4',
namespace: 'dwinks',
author: 'Daniel Winks',
category: 'Audio',
description: 'Sonos speaker integration with advanced functionality, such as non-interrupting announcements and grouping control',
iconUrl: '',
iconX2Url: '',
installOnOpen: false,
iconX3Url: '',
singleThreaded: true,
importUrl: 'https://raw.githubusercontent.com/DanielWinks/Hubitat-Public/main/Apps/SonosAdvancedApp.groovy'
)
preferences {
page(name: 'mainPage', install: true, uninstall: true)
page(name: 'localPlayerPage')
page(name: 'localPlayerSelectionPage')
page(name: 'groupPage')
}
// =============================================================================
// Fields
// =============================================================================
@Field static Map playerSelectionOptions = new java.util.concurrent.ConcurrentHashMap()
@Field static Map discoveredSonoses = new java.util.concurrent.ConcurrentHashMap()
@Field static Map discoveredSonosSecondaries = new java.util.concurrent.ConcurrentHashMap()
@Field static Map SOURCES = [
"\$": "None",
"x-file-cifs:": "Library",
"x-rincon-mp3radio:": "Radio",
"x-sonosapi-stream:": "Radio",
"x-sonosapi-radio:": "Radio",
"x-sonosapi-hls:": "Radio",
"x-sonos-http:sonos": "Radio",
"aac:": "Radio",
"hls-radio:": "Radio",
"https?:": "File",
"x-rincon-stream:": "LineIn",
"x-sonos-htastream:": "TV",
"x-sonos-vli:.*,airplay:": "Airplay",
"x-sonos-vli:.*,spotify:": "Spotify",
"x-rincon-queue": "Sonos Q"
]
// =============================================================================
// End Fields
// =============================================================================
// =============================================================================
// Getters and Setters
// =============================================================================
@CompileStatic
String getLocalApiPrefix(String ipAddress) {
return "https://${ipAddress}/api/v1"
}
String getLocalUpnpHostForCoordinatorId(String groupCoordinatorId) {
String localUpnpHost = app.getChildDevices().find{ cd -> cd.getDataValue('id') == groupCoordinatorId }.getDataValue('localUpnpHost')
return localUpnpHost
}
// =============================================================================
// End Getters and Setters
// =============================================================================
// =============================================================================
// App Pages
// =============================================================================
Map mainPage() {
state.remove("discoveryRunning")
dynamicPage(title: 'Sonos Advanced Controller') {
section {
label title: 'Sonos Advanced Controller',
required: false
paragraph 'This application provides Advanced Sonos Player control, including announcements and grouping.'
href (
page: 'localPlayerPage',
title: 'Sonos Virtual Player Devices',
description: 'Select to create Sonos player devices using local discovery'
)
href (
page: 'groupPage',
title: 'Sonos Virtual Group Devices',
description: 'Select to create/delete Sonos group devices'
)
}
section('Optional Features (disable to reduce resource usage):', hideable: true) {
input 'favMatching', 'bool', title: 'Enable "Current Favorite" status.', required: false, defaultValue: true
input 'trackDataMetaData', 'bool', title: 'Include metaData and trackMetaData in trackData JSON', required: false, defaultValue: false
}
section('Logging Settings:', hideable: true) {
input 'logEnable', 'bool', title: 'Enable Logging', required: false, defaultValue: true
input 'debugLogEnable', 'bool', title: 'Enable debug logging', required: false, defaultValue: false
input 'traceLogEnable', 'bool', title: 'Enable trace logging', required: false, defaultValue: false
input 'descriptionTextEnable', 'bool', title: 'Enable descriptionText logging', required: false, defaultValue: true
// input 'applySettingsButton', 'button', title: 'Apply Settings'
}
}
}
Map localPlayerPage() {
if(!state.discoveryRunning) {
subscribeToSsdpEvents(location)
ssdpDiscover()
state.discoveryRunning = true
app.updateSetting('playerDevices', [type: 'enum', value: getCreatedPlayerDevices()])
}
dynamicPage(
name: "localPlayerPage",
title: "Discovery Started!",
nextPage: 'localPlayerSelectionPage',
install: false,
uninstall: false
) {
section("Please wait while we discover your Sonos. Click Next when all your devices have been discovered.") {
paragraph (
""
)
paragraph (
"Found Devices (0): " +
""
)
}
}
}
Map localPlayerSelectionPage() {
state.remove("discoveryRunning")
unsubscribe(location, 'ssdpTerm.upnp:rootdevice')
unsubscribe(location, 'sdpTerm.ssdp:all')
LinkedHashMap newlyDiscovered = discoveredSonoses.collectEntries{id, player -> [(id.toString()): player.name]}
LinkedHashMap previouslyCreated = getCurrentPlayerDevices().collectEntries{[(it.getDeviceNetworkId().toString()): it.getDataValue('name')]}
LinkedHashMap selectionOptions = previouslyCreated
Integer newlyFoundCount = 0
newlyDiscovered.each{ k,v ->
if(!selectionOptions.containsKey(k)) {
selectionOptions[k] = v
newlyFoundCount++
}
}
dynamicPage(
name: "localPlayerSelectionPage",
title: "Select Sonos Devices",
nextPage: 'mainPage',
install: false,
uninstall: false
) {
section("Select your device(s) below.") {
input (
name: 'playerDevices',
title: "Select Sonos (${newlyFoundCount} newly found primaries, ${getCurrentPlayerDevices().size()} previously created):",
type: 'enum',
options: selectionOptions,
multiple: true,
submitOnChange: true,
offerAll: false
)
input 'createPlayerDevices', 'button', title: 'Create Players'
href (
page: 'localPlayerPage',
title: 'Continue Search',
description: 'Click to show'
)
List willBeRemoved = getCurrentPlayerDevices().findAll { p -> (!settings.playerDevices.contains(p.getDeviceNetworkId()) )}
if(willBeRemoved.size() > 0 && !skipOrphanRemoval) {
paragraph("The following devices will be removed: ${willBeRemoved.collect{it.getDataValue('name')}.join(', ')}")
}
List willBeCreated = (settings.playerDevices - getCreatedPlayerDevices())
if(willBeCreated.size() > 0) {
String s = willBeCreated.collect{p -> selectionOptions.get(p)}.join(', ')
paragraph("The following devices will be created: ${s}")
}
input 'skipOrphanRemoval', 'bool', title: 'Add devices only/skip removal', required: false, defaultValue: false, submitOnChange: true
}
}
}
Map groupPage() {
if(!state.userGroups) { state.userGroups = [:] }
Map coordinatorSelectionOptions = getCurrentPlayerDevices().collectEntries { player -> [(player.getDataValue('id')): player.getDataValue('name')] }
Boolean coordIsS1 = (newGroupCoordinator && getDeviceFromRincon(newGroupCoordinator)?.getDataValue('swGen') == '1')
logDebug("Selected group coordinator is S1: ${coordIsS1}")
Map playerSelectionOptionsS1 = coordinatorSelectionOptions.findAll { it.key != newGroupCoordinator && getDeviceFromRincon(it.key).getDataValue('swGen') =='1' }
Map playerSelectionOptionsS2 = coordinatorSelectionOptions.findAll { it.key != newGroupCoordinator && getDeviceFromRincon(it.key).getDataValue('swGen') =='2'}
Map playerSelectionOptions = [:]
if(!newGroupCoordinator) {playerSelectionOptions = playerSelectionOptionsS1 + playerSelectionOptionsS2}
else if(newGroupCoordinator && coordIsS1) {playerSelectionOptions = playerSelectionOptionsS1}
else {playerSelectionOptions = playerSelectionOptionsS2}
String edg = app.getSetting('editDeleteGroup')
if(edg) {
if(!app.getSetting('newGroupName')) {
app.updateSetting('newGroupName', edg)
}
if(!app.getSetting('newGroupPlayers')) {
app.updateSetting('newGroupPlayers', [type: 'enum', value: state.userGroups[edg]?.playerIds])
}
if(!app.getSetting('newGroupCoordinator')) {
app.updateSetting('newGroupCoordinator', [type: 'enum', value: state.userGroups[edg]?.groupCoordinatorId])
}
playerSelectionOptions = coordinatorSelectionOptions.findAll { it.key != newGroupCoordinator }
}
if(app.getSetting('newGroupName') == null || app.getSetting('newGroupPlayers') == null || app.getSetting('newGroupCoordinator') == null) {
state.groupPageError = 'You must select a coordinator, at least 1 follower player, and provide a name for the group'
} else {
state.remove('groupPageError')
}
dynamicPage(title: 'Sonos Player Virtual Groups', nextPage: 'mainPage') {
section {
paragraph ('This page allows you to create and delete Sonos group devices.')
}
section ('Select players to add to new group:', hideable: true){
if(state.groupPageError) {
paragraph (state.groupPageError)
}
input(name: 'newGroupName', type: 'text', title: 'Group Name:', required: false, submitOnChange: true)
input(name: 'newGroupCoordinator', type: 'enum', title: 'Select Coordinator (leader):', multiple: false, options: coordinatorSelectionOptions, required: false, submitOnChange: true, offerAll: false)
input(name: 'newGroupPlayers', type: 'enum', title: 'Select Players (followers):', multiple: true, options: playerSelectionOptions, required: false, submitOnChange: true, offerAll: false)
input(name: 'saveGroup', type: 'button', title: 'Save Group?', submitOnChange: true, width: 2, disabled: state.groupPageError != null)
input(name: 'deleteGroup', type: 'button', title: 'Delete Group?', submitOnChange: true, width: 2)
input(name: 'cancelGroupEdit', type: 'button', title: 'Cancel Edit?', submitOnChange: true, width: 2)
}
section ("Select virtual groups to edit/delete (${state.userGroups.size()} active groups found):", hideable: true) {
input (name: 'editDeleteGroup', title: '', type: 'enum', multiple: false, options: state.userGroups.keySet(), submitOnChange: true)
}
if(state.refreshGroupPage) {
section() {
state.remove('refreshGroupPage')
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
paragraph ""
}
}
}
}
// =============================================================================
// End App Pages
// =============================================================================
// =============================================================================
// Button Handlers
// =============================================================================
void appButtonHandler(String buttonName) {
if(buttonName == 'applySettingsButton') { applySettingsButton() }
if(buttonName == 'saveGroup') { saveGroup() }
if(buttonName == 'deleteGroup') { deleteGroup() }
if(buttonName == 'cancelGroupEdit') { cancelGroupEdit() }
if(buttonName == 'createPlayerDevices') { createPlayerDevices() }
}
void applySettingsButton() { configure() }
void saveGroup() {
state.userGroups[app.getSetting('newGroupName')] = [groupCoordinatorId:app.getSetting('newGroupCoordinator'), playerIds:app.getSetting('newGroupPlayers')]
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
app.removeSetting('editDeleteGroup')
createGroupDevices()
}
void deleteGroup() {
state.userGroups.remove(app.getSetting('newGroupName'))
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
state.refreshGroupPage = true
removeOrphans()
}
void cancelGroupEdit() {
app.removeSetting('newGroupName')
app.removeSetting('newGroupPlayers')
app.removeSetting('newGroupCoordinator')
app.removeSetting('editDeleteGroup')
}
// =============================================================================
// End Button Handlers
// =============================================================================
// =============================================================================
// Initialize() and Configure()
// =============================================================================
void initialize() { configure() }
void configure() {
logInfo("${app.name} updated")
unsubscribe()
try { createPlayerDevices() }
catch (Exception e) { logError("createPlayerDevices() Failed: ${e}")}
try { createGroupDevices() }
catch (Exception e) { logError("createGroupDevices() Failed: ${e}")}
state.remove('favs')
unschedule('appGetFavoritesLocal')
}
// =============================================================================
// End Initialize() and Configure()
// =============================================================================
// =============================================================================
// Create Child Devices
// =============================================================================
void createGroupDevices() {
if(!state.userGroups) {return}
logDebug('Creating group devices...')
state.userGroups.each{ it ->
String dni = "${app.id}-SonosGroupDevice-${it.key}"
DeviceWrapper device = getChildDevice(dni)
if (device == null) {
try {
logDebug("Creating group device for ${it.key}")
device = addChildDevice('dwinks', 'Sonos Advanced Group', dni, [name: 'Sonos Group', label: "Sonos Group: ${it.key}"])
} catch (UnknownDeviceTypeException e) {logException('Sonos Advanced Group driver not found', e)}
}
String groupCoordinatorId = it.value.groupCoordinatorId as String
String playerIds = it.value.playerIds.join(',')
ChildDeviceWrapper coordDev = app.getChildDevices().find{ cd -> cd.getDataValue('id') == groupCoordinatorId}
String householdId = coordDev.getDataValue('householdId')
device.updateDataValue('groupCoordinatorId', groupCoordinatorId)
device.updateDataValue('playerIds', playerIds)
device.updateDataValue('householdId', householdId)
}
app.removeSetting('groupDevices')
app.updateSetting('groupDevices', [type: 'enum', value: getCreatedGroupDevices()])
removeOrphans()
}
void createPlayerDevices() {
settings.playerDevices.each{ dni ->
ChildDeviceWrapper cd = app.getChildDevice(dni)
Map playerInfo = discoveredSonoses[dni]
if(cd) {
logDebug("Not creating ${cd.getDataValue('name')}, child already exists.")
} else {
if(playerInfo) {
logInfo("Creating Sonos Advanced Player device for ${playerInfo?.name}")
try {
cd = addChildDevice('dwinks', 'Sonos Advanced Player', dni, [name: 'Sonos Advanced Player', label: "Sonos Advanced - ${playerInfo?.name}"])
} catch (UnknownDeviceTypeException e) {logException('Sonos Advanced Player driver not found', e)}
} else {
logWarn("Attempted to create child device for ${dni} but did not find playerInfo")
}
}
logInfo("Updating player info with latest info from discovery...")
playerInfo.each { key, value -> cd.updateDataValue(key, value as String) }
LinkedHashMap macToRincon = discoveredSonoses.collectEntries{ k,v -> [k, v.id]}
String rincon = macToRincon[dni]
LinkedHashMap secondaries = discoveredSonosSecondaries.findAll{k,v -> v.primaryDeviceId == rincon}
if(secondaries){
List secondaryDeviceIps = secondaries.collect{it.value.deviceIp}
List secondaryIds = secondaries.collect{it.value.id}
if(secondaryDeviceIps && secondaryIds) {
cd.updateDataValue('secondaryDeviceIps', secondaryDeviceIps.join(','))
cd.updateDataValue('secondaryIds', secondaryIds.join(','))
}
}
cd.secondaryConfiguration()
}
if(!skipOrphanRemoval) {removeOrphans()}
}
void removeOrphans() {
getCurrentGroupDevices().each{ child ->
String dni = child.getDeviceNetworkId()
if(dni in settings.groupDevices) { return }
else {
logInfo("Removing group device not found in selected devices list: ${child}")
app.deleteChildDevice(dni)
}
}
if(!skipOrphanRemoval) {
getCurrentPlayerDevices().each{ child ->
String dni = child.getDeviceNetworkId()
if(dni in settings.playerDevices) { return }
else {
logInfo("Removing player device not found in selected devices list: ${child}")
app.deleteChildDevice(dni)
}
}
}
}
// =============================================================================
// End Create Child Devices
// =============================================================================
// =============================================================================
// Local Discovery
// =============================================================================
void ssdpDiscover() {
logDebug("Starting SSDP Discovery...")
discoveredSonoses = new java.util.concurrent.ConcurrentHashMap()
discoveredSonosSecondaries = new java.util.concurrent.ConcurrentHashMap()
discoveryQueue = new ConcurrentLinkedQueue()
sendHubCommand(new hubitat.device.HubAction("lan discovery upnp:rootdevice", hubitat.device.Protocol.LAN))
sendHubCommand(new hubitat.device.HubAction("lan discovery ssdp:all", hubitat.device.Protocol.LAN))
}
@CompileStatic
void subscribeToSsdpEvents(Location location) {
logDebug("Subscribing to SSDP Discovery events...")
subscribe(location, 'ssdpTerm.upnp:rootdevice', 'ssdpEventHandler')
subscribe(location, 'sdpTerm.ssdp:all', 'ssdpEventHandler')
}
void ssdpEventHandler(Event event) {
LinkedHashMap parsedEvent = parseLanMessage(event?.description)
processParsedSsdpEvent(parsedEvent)
}
@CompileStatic
void processParsedSsdpEvent(LinkedHashMap event) {
String ipAddress = convertHexToIP(event?.networkAddress)
String ipPort = convertHexToInt(event?.deviceAddress)
LinkedHashMap playerInfo = getPlayerInfoLocalSync("${ipAddress}:1443")
if(playerInfo) {
logTrace("Discovered playerInfo for ${ipAddress}")
} else {
logTrace("Did not receive playerInfo for ${ipAddress}")
return
}
GPathResult deviceDescription = getDeviceDescriptionLocalSync("${ipAddress}")
if(deviceDescription) {
logTrace("Discovered device description for ${ipAddress}")
} else {
logTrace("Did not receive device description for ${ipAddress}")
return
}
LinkedHashMap playerInfoDevice = playerInfo?.device as LinkedHashMap
String modelName = deviceDescription['device']['modelName']
String ssdpPath = event?.ssdpPath
String mac = (deviceDescription['device']['MACAddress']).toString().replace(':','')
String playerName = playerInfoDevice?.name
String playerDni = mac
String swGen = playerInfoDevice?.swGen
String websocketUrl = playerInfoDevice?.websocketUrl
String householdId = playerInfo?.householdId
String playerId = playerInfo?.playerId
String groupId = playerInfo?.groupId
List deviceCapabilities = playerInfoDevice?.capabilities as List
if(mac && playerInfoDevice?.name) {
logTrace("Received SSDP event response for MAC: ${mac}, device name: ${playerInfoDevice?.name}")
}
LinkedHashMap discoveredSonos = [
name: playerName,
id: playerId,
swGen: swGen,
capabilities: deviceCapabilities,
modelName: modelName,
householdId: householdId,
websocketUrl: websocketUrl,
deviceIp: "${ipAddress}",
localApiUrl: "https://${ipAddress}:1443/api/v1/",
localUpnpUrl: "http://${ipAddress}:1400",
localUpnpHost: "${ipAddress}:1400"
]
if(playerInfoDevice?.primaryDeviceId && deviceCapabilities.contains('AUDIO_CLIP')) {
LinkedHashMap discoveredSonosSecondary = [
primaryDeviceId: playerInfoDevice?.primaryDeviceId,
id: playerId,
swGen: swGen,
capabilities: deviceCapabilities,
modelName: modelName,
householdId: householdId,
websocketUrl: websocketUrl,
deviceIp: "${ipAddress}",
localApiUrl: "https://${ipAddress}:1443/api/v1/",
localUpnpUrl: "http://${ipAddress}:1400",
localUpnpHost: "${ipAddress}:1400"
]
discoveredSonosSecondaries[mac] = discoveredSonosSecondary
logTrace("Found secondary for ${playerInfoDevice?.primaryDeviceId}")
}
if(discoveredSonos?.name != null && discoveredSonos?.name != 'null') {
discoveredSonoses[mac] = discoveredSonos
sendFoundSonosEvents()
} else {
logTrace("Device id:${discoveredSonos?.id} responded to SSDP discovery, but did not provide device name. This is expected for right channel speakers on stereo pairs, subwoofers, and other 'non primary' devices.")
}
}
@CompileStatic
String getFoundSonoses() {
String foundDevices = ''
List discoveredSonosesNames = discoveredSonoses.collect{Object k, Object v -> ((LinkedHashMap)v)?.name as String }
List discoveredSonosesSecondaryPrimaryIds = discoveredSonosSecondaries.collect{Object k, Object v -> ((LinkedHashMap)v)?.primaryDeviceId as String }
discoveredSonosesSecondaryPrimaryIds.each{
String primaryDNI = getDNIFromRincon(it)
if(discoveredSonoses.containsKey(primaryDNI)) {
LinkedHashMap primary = (LinkedHashMap)discoveredSonoses[primaryDNI]
discoveredSonosesNames.add("${primary?.name as String} Right Channel".toString())
}
}
discoveredSonosesNames.sort()
discoveredSonosesNames.each{ discoveredSonos -> foundDevices += "\n${discoveredSonos}" }
return foundDevices
}
void sendFoundSonosEvents() {
app.sendEvent(name: 'sonosDiscoveredCount', value: "Found Devices (${discoveredSonoses.size()} primary, ${discoveredSonosSecondaries.size()} secondary): ")
app.sendEvent(name: 'sonosDiscovered', value: getFoundSonoses())
}
// =============================================================================
// End Local Discovery
// =============================================================================
// =============================================================================
// Helper methods
// =============================================================================
@CompileStatic
String getDNIFromRincon(String rincon) {
return rincon.tokenize('_')[1][0..-6]
}
ChildDeviceWrapper getDeviceFromRincon(String rincon) {
List childDevices = app.getCurrentPlayerDevices()
ChildDeviceWrapper dev = childDevices.find{ it.getDataValue('id') == rincon}
return dev
}
List getDevicesFromRincons(LinkedHashSet rincons) {
List children = rincons.collect{ player -> getDeviceFromRincon(player) }
children.removeAll([null])
return children
}
List getDevicesFromRincons(List rincons) {
List children = rincons.collect{ player -> getDeviceFromRincon(player) }
children.removeAll([null])
return children
}
List getCreatedPlayerDevices() {
List childDevices = app.getCurrentPlayerDevices()
List pds = []
childDevices.each() {cd -> pds.add("${cd.getDeviceNetworkId()}")}
return pds
}
List getCreatedGroupDevices() {
List childDevices = app.getCurrentGroupDevices()
List pds = []
childDevices.each() {cd -> pds.add("${cd.getDeviceNetworkId()}")}
return pds
}
List getUserGroupsDNIsFromUserGroups() {
List dnis = state.userGroups.collect{ k,v -> "${app.id}-SonosGroupDevice-${k}" }
return dnis
}
List getCurrentPlayerDevices() {
List currentPlayers = []
app.getChildDevices().each{child -> if(child.getDataValue('id')) { currentPlayers.add(child)}}
return currentPlayers
}
// @CompileStatic
// void registerAllPlayersInRinconMap(DeviceWrapper cd) {
// cd.addAllPlayersToRinconMap(getCurrentPlayerDevices())
// }
List getCurrentGroupDevices() {
List currentGroupDevs = []
app.getChildDevices().each{child -> if(child.getDataValue('id') == null) { currentGroupDevs.add(child)}}
return currentGroupDevs
}
List getAllPlayersForGroupDevice(DeviceWrapper device) {
List playerIds = [device.getDataValue('groupCoordinatorId')]
playerIds.addAll(device.getDataValue('playerIds').split(','))
return playerIds
}
LinkedHashMap getPlayerInfoLocalSync(String ipAddress) {
ipAddress = ipAddress.contains(':') ? ipAddress : "${ipAddress}:1443"
LinkedHashMap params = [
uri: "${getLocalApiPrefix(ipAddress)}/players/local/info",
headers: ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000'],
requestContentType: 'application/json',
contentType: 'application/json',
ignoreSSLIssues: true
]
try {
httpGet(params) { resp ->
if (resp && resp.data && resp.success) { return resp.data }
}
} catch(Exception e){
logInfo("Could not connect to: ${ipAddress}. If this is a Sonos player, please report an issue. Note that RIGHT channel speakers on a stereo pair, subwoofers, or rear channel speakers this is expected. Only LEFT channel in stereo pairs (or Arc/Beam in a center + rear setup) will respond.")
}
}
GPathResult getDeviceDescriptionLocalSync(String ipAddress) {
if(!ipAddress.contains(':')) { ipAddress = "${ipAddress}:1400"}
Map params = [
uri: "http://${ipAddress}/xml/device_description.xml",,
requestContentType: 'application/xml',
contentType: 'application/xml'
]
try {
httpGet(params) { resp ->
if (resp && resp.data && resp.success) { return resp.data }
else { logError(resp.data) }
}
} catch(Exception e){
logInfo("Could not connect to: ${ipAddress}. If this is a Sonos player, please report an issue. Note that RIGHT channel speakers on a stereo pair, subwoofers, or rear channel speakers this is expected. Only LEFT channel in stereo pairs (or Arc/Beam in a center + rear setup) will respond.")
}
}
void getPlayerInfoLocalCallback(AsyncResponse response, Map data) {
if(!responseIsValid(response, 'getPlayerInfoLocalCallback')) { return }
Map respJson = response.getJson()
String playerName = respJson?.device?.name
String playerDni = (respJson?.device?.serialNumber.replace('-','').tokenize(':'))[0]
String swGen = respJson?.device?.swGen
String websocketUrl = respJson?.device?.websocketUrl
String householdId = respJson?.householdId
String playerId = respJson?.playerId
String groupId = respJson?.groupId
}
void getZoneGroupAttributesAsync(DeviceWrapper device, String callbackMethod = 'getZoneGroupAttributesAsyncCallback', Map data = null) {
String ip = device.getDataValue('localUpnpHost')
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
asynchttpPost(callbackMethod, params, data)
}
void getZoneGroupAttributesAsyncCallback(AsyncResponse response, Map data) {
if(!responseIsValid(response, 'getZoneGroupAttributesAsyncCallback')) { return }
}
List getCurrentGroupedDevices(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
List groupedRincons
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupedRincons = (xml['Body']['GetZoneGroupAttributesResponse']['CurrentZonePlayerUUIDsInGroup'].text()).toString().tokenize(',')
}
else { logError(resp.data) }
}
List groupedDevices = getDevicesFromRincons(groupedRincons)
return groupedDevices
}
List getGroupedPlayerDevicesFromGetZoneGroupAttributes(GPathResult xml, String rincon) {
List groupedDevices = []
List groupIds = []
List groupedRincons = xml['Body']['GetZoneGroupAttributesResponse']['CurrentZonePlayerUUIDsInGroup'].text().tokenize(',')
if(groupedRincons.size() == 0) {
logDebug("No grouped rincons found!")
return
}
groupedRincons.each{groupIds.add("${it}".tokenize('_')[1][0..-6])}
groupIds.each{groupedDevices.add(getChildDevice(it))}
return groupedDevices
}
String getGroupForPlayerDeviceLocal(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
String groupId
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupId = xml['Body']['GetZoneGroupAttributesResponse']['CurrentZoneGroupID'].text().toString()
}
else { logError(resp.data) }
}
return groupId
}
DeviceWrapper getGroupCoordinatorForPlayerDeviceLocal(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
String groupId
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupId = xml['Body']['GetZoneGroupAttributesResponse']['CurrentZoneGroupID'].text().toString()
}
else { logError(resp.data) }
}
return getDeviceFromRincon(groupId.tokenize(':')[0])
}
String getHouseholdForPlayerDeviceLocal(DeviceWrapper device) {
String ip = device.getDataValue('localUpnpHost')
String groupId
Map params = getSoapActionParams(ip, ZoneGroupTopology, 'GetZoneGroupAttributes')
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
GPathResult xml = resp.data
groupId = xml['Body']['GetZoneGroupAttributesResponse']['CurrentMuseHouseholdId'].text().toString()
}
else { logError(resp.data) }
}
return groupId
}
Boolean hasLeftAndRightChannelsSync(DeviceWrapper device) {
String householdId = device.getDataValue('householdId')
String localApiUrl = device.getDataValue('localApiUrl')
String endpoint = "households/${householdId}/groups"
String uri = "${localApiUrl}${endpoint}"
Map params = [uri: uri]
Map json = sendLocalJsonQuerySync([params:params])
Map playerInfo = json?.players.find{it?.id == device.getDataValue('id')}
Boolean hasLeftChannel = playerInfo?.zoneInfo?.members.find{it?.channelMap.contains('LF')}
Boolean hasRightChannel = playerInfo?.zoneInfo?.members.find{it?.channelMap.contains('RF')}
return hasLeftChannel && hasRightChannel
}
String unEscapeOnce(String text) {
return text.replace('<','<').replace('>','>').replace('"','"')
}
String unEscapeMetaData(String text) {
return text.replace('<','<').replace('>','>').replace('"','"')
}
// =============================================================================
// Helper methods
// =============================================================================
// =============================================================================
// Component Methods for Child Event Processing
// =============================================================================
@CompileStatic
void updateGroupDevices(String coordinatorId, List playersInGroup) {
logTrace('updateGroupDevices')
// Update group device with current on/off state
List groupsForCoord = getCurrentGroupDevices().findAll{it.getDataValue('groupCoordinatorId') == coordinatorId }
groupsForCoord.each{gd ->
List playerIds = gd.getDataValue('playerIds').tokenize(',')
HashSet list1 = new HashSet(playerIds)
HashSet list2 = new HashSet(playersInGroup)
list1.add(coordinatorId)
list2.add(coordinatorId)
Boolean allPlayersAreGrouped = list1.equals(list2)
if(allPlayersAreGrouped) { gd.sendEvent(name: 'switch', value: 'on') }
else { gd.sendEvent(name: 'switch', value: 'off') }
}
}
// =============================================================================
// Component Methods for Child Event Processing
// =============================================================================
// =============================================================================
// HTTP Helpers
// =============================================================================
void sendLocalCommandAsync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
String callbackMethod = args.callbackMethod ?: 'localControlCallback'
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalCommandAsync: ${params}")
asynchttpPost(callbackMethod, params, args.data)
}
Map sendLocalJsonQuerySync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalQuerySync: ${params}")
httpGet(params) { resp ->
if (resp && resp.data && resp.success) { return resp.data }
else { logError(resp.data) }
}
}
void sendLocalQueryAsync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
String callbackMethod = args.callbackMethod ?: 'localControlCallback'
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalQueryAsync: ${params}")
asynchttpGet(callbackMethod, params, args.data)
}
void sendLocalJsonAsync(Map args) {
if(args?.endpoint == null && args?.params?.uri == null) { return }
String callbackMethod = args.callbackMethod ?: 'localControlCallback'
Map params = args.params ?: [:]
params.uri = args.params.uri ?: "${getLocalApiPrefix(args.ipAddress)}${args.endpoint}"
params.contentType = args.params.contentType ?: 'application/json'
params.requestContentType = args.params.requestContentType ?: 'application/json'
params.ignoreSSLIssues = args.params.ignoreSSLIssues ?: true
params.body = JsonOutput.toJson(args.data)
if(params.headers == null) {
params.headers = ['X-Sonos-Api-Key': '123e4567-e89b-12d3-a456-426655440000']
} else if(params.headers != null && params.headers['X-Sonos-Api-Key'] == null) {
params.headers['X-Sonos-Api-Key'] = '123e4567-e89b-12d3-a456-426655440000'
}
logTrace("sendLocalJsonAsync: ${params}")
asynchttpPost(callbackMethod, params)
}
void localControlCallback(AsyncResponse response, Map data) {
if (response?.status != 200 || response.hasError()) {
logError("Request returned HTTP status ${response.status}")
logError("Request error message: ${response.getErrorMessage()}")
try{logError("Request ErrorData: ${response.getErrorData()}")} catch(Exception e){}
try{logErrorJson("Request ErrorJson: ${response.getErrorJson()}")} catch(Exception e){}
try{logErrorXml("Request ErrorXml: ${response.getErrorXml()}")} catch(Exception e){}
}
if(response?.status == 200 && response && response.hasError() == false) {
logTrace("localControlCallback: ${response.getData()}")
}
}
Boolean responseIsValid(AsyncResponse response, String requestName = null) {
if(response?.status == 499) {
try{
Map errData = response.getErrorData()
if(errData?.groupStatus == 'GROUP_STATUS_MOVED') {
ChildDeviceWrapper child = getDeviceFromRincon(errData?.playerId)
if(child) {
child.subscribeToZgtEvents()
logDebug('Resubscribed to ZGT to handle "GROUP_STATUS_MOVED" errors')
}
}
} catch(Exception e){}
} else if (response?.status != 200 || response.hasError()) {
logError("Request returned HTTP status ${response.status}")
logError("Request error message: ${response.getErrorMessage()}")
try{logError("Request ErrorData: ${response.getErrorData()}")} catch(Exception e){}
try{logErrorJson("Request ErrorJson: ${response.getErrorJson()}")} catch(Exception e){}
try{logErrorXml("Request ErrorXml: ${response.getErrorXml()}")} catch(Exception e){}
}
if (response.hasError()) { return false } else { return true }
}
// =============================================================================
// HTTP Helpers
// =============================================================================