feat(android): UPnP in-place updates + Sonos topology aggregation
android / Build + lint + test (push) Failing after 1m14s
android / Build + lint + test (push) Failing after 1m14s
This commit is contained in:
+5
@@ -22,10 +22,12 @@ data class DeviceDescription(
|
|||||||
val modelName: String,
|
val modelName: String,
|
||||||
val avTransportControlUrl: HttpUrl,
|
val avTransportControlUrl: HttpUrl,
|
||||||
val renderingControlUrl: HttpUrl?,
|
val renderingControlUrl: HttpUrl?,
|
||||||
|
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
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 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_SERVICE = "service"
|
||||||
private const val TAG_UDN = "UDN"
|
private const val TAG_UDN = "UDN"
|
||||||
@@ -58,6 +60,7 @@ data class DeviceDescription(
|
|||||||
modelName = acc.modelName,
|
modelName = acc.modelName,
|
||||||
avTransportControlUrl = avt,
|
avTransportControlUrl = avt,
|
||||||
renderingControlUrl = acc.rcControlUrl,
|
renderingControlUrl = acc.rcControlUrl,
|
||||||
|
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ data class DeviceDescription(
|
|||||||
when (acc.serviceType) {
|
when (acc.serviceType) {
|
||||||
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
|
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
|
||||||
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
|
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
|
||||||
|
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
|
||||||
}
|
}
|
||||||
acc.inService = false
|
acc.inService = false
|
||||||
}
|
}
|
||||||
@@ -115,6 +119,7 @@ data class DeviceDescription(
|
|||||||
var modelName: String = ""
|
var modelName: String = ""
|
||||||
var avtControlUrl: HttpUrl? = null
|
var avtControlUrl: HttpUrl? = null
|
||||||
var rcControlUrl: HttpUrl? = null
|
var rcControlUrl: HttpUrl? = null
|
||||||
|
var zgtControlUrl: HttpUrl? = null
|
||||||
var inService: Boolean = false
|
var inService: Boolean = false
|
||||||
var serviceType: String = ""
|
var serviceType: String = ""
|
||||||
var serviceControlUrl: String = ""
|
var serviceControlUrl: String = ""
|
||||||
|
|||||||
+55
-2
@@ -2,6 +2,8 @@ package com.fabledsword.minstrel.player.output.upnp
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -44,6 +46,9 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||||
|
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
ssdp.start(appScope)
|
ssdp.start(appScope)
|
||||||
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
|
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
|
||||||
@@ -88,8 +93,55 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun handleDiscovery(locationUrl: String) {
|
private suspend fun handleDiscovery(locationUrl: String) {
|
||||||
val route = fetchRoute(locationUrl) ?: return
|
val route = fetchRoute(locationUrl) ?: return
|
||||||
routesInternal.value =
|
upsertRoute(route)
|
||||||
routesInternal.value.filterNot { it.id == route.id } + 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,
|
modelName = desc.modelName,
|
||||||
avTransportControlUrl = desc.avTransportControlUrl,
|
avTransportControlUrl = desc.avTransportControlUrl,
|
||||||
renderingControlUrl = desc.renderingControlUrl,
|
renderingControlUrl = desc.renderingControlUrl,
|
||||||
|
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ data class UpnpRoute(
|
|||||||
val modelName: String,
|
val modelName: String,
|
||||||
val avTransportControlUrl: HttpUrl,
|
val avTransportControlUrl: HttpUrl,
|
||||||
val renderingControlUrl: HttpUrl?,
|
val renderingControlUrl: HttpUrl?,
|
||||||
|
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+31
@@ -81,6 +81,37 @@ class DeviceDescriptionTest {
|
|||||||
assertNull(DeviceDescription.parse(xml, base))
|
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
|
@Test
|
||||||
fun `handles missing optional fields`() {
|
fun `handles missing optional fields`() {
|
||||||
val xml = """
|
val xml = """
|
||||||
|
|||||||
Reference in New Issue
Block a user