feat(android): Sonos ZoneGroupTopology client + parser
android / Build + lint + test (push) Failing after 1m27s
android / Build + lint + test (push) Failing after 1m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+77
@@ -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<SonosZoneMember>,
|
||||
)
|
||||
|
||||
data class SonosZoneMember(
|
||||
val udn: String,
|
||||
val roomName: String,
|
||||
val location: String?,
|
||||
val channelMapSet: String?,
|
||||
)
|
||||
|
||||
object SonosTopology {
|
||||
|
||||
fun parse(xml: String): List<SonosZoneGroup> {
|
||||
return runCatching { parseStrict(xml) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun parseStrict(xml: String): List<SonosZoneGroup> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setInput(xml.reader())
|
||||
}
|
||||
val groups = mutableListOf<SonosZoneGroup>()
|
||||
var currentCoordinator: String? = null
|
||||
var currentMembers: MutableList<SonosZoneMember>? = 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"
|
||||
}
|
||||
+30
@@ -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<SonosZoneGroup> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
+74
@@ -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 = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".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 = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_L" ID="RINCON_L:2">
|
||||
<ZoneGroupMember UUID="RINCON_L" ZoneName="Living Room"
|
||||
Location="http://192.168.1.11:1400/xml/device_description.xml"
|
||||
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
|
||||
<ZoneGroupMember UUID="RINCON_R" ZoneName="Living Room"
|
||||
Location="http://192.168.1.12:1400/xml/device_description.xml"
|
||||
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".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 = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_X" ID="RINCON_X:3">
|
||||
<ZoneGroupMember UUID="RINCON_X" ZoneName="Office"/>
|
||||
<ZoneGroupMember UUID="RINCON_Y" ZoneName="Bedroom"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals("Office", groups.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed xml returns empty list`() {
|
||||
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user