diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt new file mode 100644 index 00000000..46ef494e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopology.kt @@ -0,0 +1,77 @@ +package com.fabledsword.minstrel.player.output.upnp.sonos + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory + +/** + * One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState. + * Stereo pairs and multi-speaker groups all appear as one group with + * multiple members; one member is the coordinator we send SOAP to. + */ +data class SonosZoneGroup( + val coordinatorUdn: String, + val name: String, + val members: List, +) + +data class SonosZoneMember( + val udn: String, + val roomName: String, + val location: String?, + val channelMapSet: String?, +) + +object SonosTopology { + + fun parse(xml: String): List { + return runCatching { parseStrict(xml) }.getOrDefault(emptyList()) + } + + private fun parseStrict(xml: String): List { + val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(xml.reader()) + } + val groups = mutableListOf() + var currentCoordinator: String? = null + var currentMembers: MutableList? = null + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + when (parser.eventType) { + XmlPullParser.START_TAG -> when (parser.name) { + TAG_ZONE_GROUP -> { + currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR) + currentMembers = mutableListOf() + } + TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser)) + } + XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) { + val members = currentMembers ?: emptyList() + val coord = currentCoordinator + if (coord != null && members.isNotEmpty()) { + val name = members.firstOrNull { it.udn == coord }?.roomName + ?: members.first().roomName + groups.add(SonosZoneGroup(coord, name, members)) + } + currentCoordinator = null + currentMembers = null + } + } + parser.next() + } + return groups + } + + private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember( + udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(), + roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(), + location = parser.getAttributeValue(null, ATTR_LOCATION), + channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET), + ) + + private const val TAG_ZONE_GROUP = "ZoneGroup" + private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember" + private const val ATTR_COORDINATOR = "Coordinator" + private const val ATTR_UUID = "UUID" + private const val ATTR_ZONE_NAME = "ZoneName" + private const val ATTR_LOCATION = "Location" + private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt new file mode 100644 index 00000000..684b7389 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/sonos/ZoneGroupTopologyClient.kt @@ -0,0 +1,30 @@ +package com.fabledsword.minstrel.player.output.upnp.sonos + +import com.fabledsword.minstrel.player.output.upnp.SoapClient +import okhttp3.HttpUrl + +/** + * Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a + * standard UPnP service, exposed on port 1400 at + * /ZoneGroupTopology/Control. GetZoneGroupState returns the full + * network topology as one XML doc wrapped inside a SOAP arg. + */ +class ZoneGroupTopologyClient( + private val soap: SoapClient, + private val controlUrl: HttpUrl, +) { + suspend fun getZoneGroupState(): List { + val args = soap.call( + controlUrl = controlUrl, + serviceType = SERVICE_TYPE, + action = "GetZoneGroupState", + args = emptyMap(), + ) + val inner = args["ZoneGroupState"].orEmpty() + return SonosTopology.parse(inner) + } + + private companion object { + const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1" + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt new file mode 100644 index 00000000..2c5511f6 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/sonos/SonosTopologyTest.kt @@ -0,0 +1,74 @@ +package com.fabledsword.minstrel.player.output.upnp.sonos + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class SonosTopologyTest { + + @Test + fun `single zone group with one member`() { + val xml = """ + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + val g = groups.first() + assertEquals("RINCON_A", g.coordinatorUdn) + assertEquals("Kitchen", g.name) + assertEquals(1, g.members.size) + } + + @Test + fun `stereo pair collapses to one group with two members`() { + val xml = """ + + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals(1, groups.size) + val g = groups.first() + assertEquals("RINCON_L", g.coordinatorUdn) + assertEquals("Living Room", g.name) + assertEquals(2, g.members.size) + assertNotNull(g.members[0].channelMapSet) + } + + @Test + fun `multi-speaker group lists coordinator name`() { + val xml = """ + + + + + + + + + """.trimIndent() + val groups = SonosTopology.parse(xml) + assertEquals("Office", groups.first().name) + } + + @Test + fun `malformed xml returns empty list`() { + assertEquals(emptyList(), SonosTopology.parse("