feat(android): UPnP in-place updates + Sonos topology aggregation
android / Build + lint + test (push) Failing after 1m14s

This commit is contained in:
2026-06-03 16:59:31 -04:00
parent bfcb9c42a0
commit a5e4570f01
4 changed files with 92 additions and 2 deletions
@@ -22,10 +22,12 @@ data class DeviceDescription(
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
) {
companion object {
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
private const val TAG_SERVICE = "service"
private const val TAG_UDN = "UDN"
@@ -58,6 +60,7 @@ data class DeviceDescription(
modelName = acc.modelName,
avTransportControlUrl = avt,
renderingControlUrl = acc.rcControlUrl,
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
)
}
@@ -91,6 +94,7 @@ data class DeviceDescription(
when (acc.serviceType) {
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
}
acc.inService = false
}
@@ -115,6 +119,7 @@ data class DeviceDescription(
var modelName: String = ""
var avtControlUrl: HttpUrl? = null
var rcControlUrl: HttpUrl? = null
var zgtControlUrl: HttpUrl? = null
var inService: Boolean = false
var serviceType: String = ""
var serviceControlUrl: String = ""
@@ -2,6 +2,8 @@ package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -44,6 +46,9 @@ class UpnpDiscoveryController @Inject constructor(
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
init {
ssdp.start(appScope)
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
@@ -88,8 +93,55 @@ class UpnpDiscoveryController @Inject constructor(
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
routesInternal.value =
routesInternal.value.filterNot { it.id == route.id } + route
upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
refreshSonosTopology(route)
}
}
private fun upsertRoute(route: UpnpRoute) {
val current = routesInternal.value
val idx = current.indexOfFirst { it.id == route.id }
routesInternal.value = if (idx < 0) {
current + route
} else {
current.toMutableList().also { it[idx] = route }
}
}
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: return
val groups = runCatching {
ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState()
}.getOrNull() ?: return
sonosTopologyInternal.value = groups
}
/**
* Returns the coordinator UDN's full route for the group [udn] belongs
* to, or null if topology hasn't loaded / the udn is in no group. UDN
* normalization strips the "uuid:" prefix because DeviceDescription's
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
*/
fun coordinatorRouteFor(udn: String): UpnpRoute? {
val bare = udn.removePrefix("uuid:")
val coord = sonosTopologyInternal.value
.firstOrNull { g -> g.members.any { it.udn.removePrefix("uuid:") == bare } }
?.coordinatorUdn ?: return null
return routesInternal.value.firstOrNull {
it.id.removePrefix("uuid:") == coord.removePrefix("uuid:")
}
}
/**
* UDNs of every NON-coordinator Sonos group member. The picker
* controller suppresses these from the visible list.
*/
fun nonCoordinatorMemberUdns(): Set<String> {
return sonosTopologyInternal.value.flatMap { g ->
g.members.map { it.udn.removePrefix("uuid:") }
.filter { it != g.coordinatorUdn.removePrefix("uuid:") }
}.toSet()
}
/**
@@ -111,6 +163,7 @@ class UpnpDiscoveryController @Inject constructor(
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl,
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
)
}
}
@@ -24,4 +24,5 @@ data class UpnpRoute(
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
)
@@ -81,6 +81,37 @@ class DeviceDescriptionTest {
assertNull(DeviceDescription.parse(xml, base))
}
@Test
fun `parses ZoneGroupTopology control URL when present`() {
val xml = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<UDN>uuid:RINCON_XYZ</UDN>
<friendlyName>Office</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ZoneGroupTopology:1</serviceType>
<controlURL>/ZoneGroupTopology/Control</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals(
"http://192.168.1.50:1400/ZoneGroupTopology/Control",
desc.zoneGroupTopologyControlUrl?.toString(),
)
}
@Test
fun `handles missing optional fields`() {
val xml = """