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