From a5e4570f0162c2d7136a2ecbfa537c3c008d8027 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 16:59:31 -0400 Subject: [PATCH] feat(android): UPnP in-place updates + Sonos topology aggregation --- .../player/output/upnp/DeviceDescription.kt | 5 ++ .../output/upnp/UpnpDiscoveryController.kt | 57 ++++++++++++++++++- .../minstrel/player/output/upnp/UpnpRoute.kt | 1 + .../output/upnp/DeviceDescriptionTest.kt | 31 ++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt index e672deb6..4dd49569 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt @@ -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 = "" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 33427cd0..ff54cc4b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -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>(emptyList()) val routes: StateFlow> = routesInternal.asStateFlow() + private val sonosTopologyInternal = MutableStateFlow>(emptyList()) + val sonosTopology: StateFlow> = 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 + * 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 { + 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, ) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt index 029d300b..63022a1b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpRoute.kt @@ -24,4 +24,5 @@ data class UpnpRoute( val modelName: String, val avTransportControlUrl: HttpUrl, val renderingControlUrl: HttpUrl?, + val zoneGroupTopologyControlUrl: HttpUrl? = null, ) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt index e633415c..8b05d772 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt @@ -81,6 +81,37 @@ class DeviceDescriptionTest { assertNull(DeviceDescription.parse(xml, base)) } + @Test + fun `parses ZoneGroupTopology control URL when present`() { + val xml = """ + + + + uuid:RINCON_XYZ + Office + Sonos, Inc. + Sonos One + + + urn:schemas-upnp-org:service:AVTransport:1 + /MediaRenderer/AVTransport/Control + + + urn:schemas-upnp-org:service:ZoneGroupTopology:1 + /ZoneGroupTopology/Control + + + + + """.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 = """