
SSDP/UPnP discovery client sending stepped multicast retransmits, passively tracking NOTIFYs, maintaining device registry, lazily fetching and caching device description XMLs parsed into services/icons.
A Kotlin Multiplatform SSDP / UPnP discovery client for iOS, macOS, Android, and the JVM:
ssdp:alive,
ssdp:byebye, ssdp:update), for devices that announce themselves.byebye, CACHE-CONTROL: max-age expiry, or a network change)LOCATION), parsed into a friendly-name / manufacturer / model / services / icons tree.
Descriptions are remembered per network, so returning to a network restores
them without refetching.Published to Maven Central:
| Module | Coordinate | Description |
|---|---|---|
:ssdp |
com.happycodelucky.ssdp:ssdp |
Client library |
:ssdp‑testing |
com.happycodelucky.ssdp:ssdp‑testing |
FakeSsdpClient + withFakeSsdpClient { } for tests |
Not published — repo tools and samples (excluded from the publish/check gate):
| Module | What it is |
|---|---|
:ssdp‑bridge |
Host-side daemon that relays SSDP to an Android emulator over TCP (see Emulator bridge). Run with mise run app:bridge. |
:cli |
Command-line discovery harness — scans the LAN and prints devices + descriptions. Run with mise run cli
|
:androidApp |
The Android Compose sample scanner (see apps/) |
# gradle/libs.versions.toml
[libraries]
ssdp = { module = "com.happycodelucky.ssdp:ssdp", version = "0.9.0" }
ssdp-testing = { module = "com.happycodelucky.ssdp:ssdp-testing", version = "0.9.0" }// build.gradle.kts
commonMain.dependencies { implementation(libs.ssdp) }
commonTest.dependencies { implementation(libs.ssdp.testing) }ssdp brings KotlinResult
(com.happycodelucky.kotlinresult:kotlinresult) with it: description()
returns its Result. If you link ssdp into your own Apple framework (a KMP
app or library with SKIE), export KotlinResult into that framework, or its link
fails with "cannot find type 'KotlinResult' in scope". See
KotlinResult's setup notes.
SPM consumers of SsdpKit need nothing; it already exports it.
Add this repository as a package dependency, pinned to a release tag, and
depend on the SsdpKit product (Ssdp before 0.7.0). The XCFramework ships as
a GitHub Release asset (see .github/PUBLISHING.md).
.package(url: "https://github.com/happycodelucky/ssdp-kmp.git", from: "0.9.0")// One factory for all platforms. On Android the library captures the application
// Context at startup, so no argument is needed (emulators: see "Android emulators").
val client: SsdpClient = SsdpClient()
// Search every SSDP target; stop broadcasting after 6s (passive listening and
// the discovered devices persist). Omit `timeout` to broadcast until you close
// the returned session.
val search: SearchSession = client.search(setOf(SearchTarget.All), timeout = 6.seconds)
// The always-current device set, keyed by USN.
client.devices.collect { byUsn ->
byUsn.values.forEach { device -> println("${device.usn} @ ${device.location}") }
}
// Or react to deltas.
client.changes.collect { change ->
when (change) {
is DeviceChange.Found -> add(change.device)
is DeviceChange.Updated -> update(change.device)
is DeviceChange.Removed -> remove(change.device) // reason: Byebye / Expired / NetworkChanged
}
}
// Fetch a device's description on demand (cached; concurrent calls share one
// fetch). A KotlinResult `Result` — the standard kotlin.Result API — failing with
// a sealed DescriptionException.
import com.happycodelucky.kotlinresult.Result // not kotlin.Result
client.description(device)
.onSuccess { show(it.device.friendlyName) }
.onFailure { e ->
when (e as? DescriptionException) {
is DescriptionException.NotFound -> showUnknown()
is DescriptionException.FetchFailed -> retryLater(e.statusCode)
is DescriptionException.ParseFailed -> log(e.message)
null -> throw e
}
}
search.close() // optional here: the 6s timeout ends it anyway
client.close()Search for several specific targets in one session — fanned out over the shared socket, merged into the one registry:
client.search(
setOf(
SearchTarget.DeviceType("schemas-upnp-org", "MediaServer", 1),
SearchTarget.DeviceType("schemas-upnp-org", "MediaRenderer", 1),
),
)search() adds to the searches already running rather than replacing them. It
returns a SearchSession that contributes its targets until it ends: when you
close() it, its timeout elapses, stopSearch() runs, or the client closes. The
client M-SEARCHes for the union of every active session's targets, so independent
parts of an app can share one client (one NOTIFY listener, one socket pair) without
cancelling each other:
val roku = SearchTarget.Custom("roku:ecp")
val renderers = SearchTarget.DeviceType("schemas-upnp-org", "MediaRenderer", 1)
val tvScan = client.search(setOf(roku, renderers))
val rokuScan = client.search(setOf(roku), timeout = 10.seconds)
tvScan.close() // MediaRenderer stops; roku:ecp keeps its cadence for rokuScanmaxWaitSeconds among them.search(emptySet()) returns an inactive session and leaves the others alone.
stopSearch() ends every session.timeout keeps searching until you close it, so keep the
handle (use { } works: it's AutoCloseable).The same flows bridge to AsyncSequence and the sealed types to exhaustive Swift
enums via SKIE:
import SsdpKit
let client = try SsdpClient(bindInterface: nil)
let search = try await client.search(targets: [SearchTargetAll.shared], maxWaitSeconds: 1, timeout: nil)
defer { search.close() } // a SearchSession; ends only this search
for await byUsn in client.devices {
render(Array(byUsn.values))
}
// `description(device:)` returns a `KotlinResult<DeviceDescription>`; `get()` throws
// the Kotlin DescriptionException itself (or use `result(as:)` for a Swift.Result).
do {
let description: DeviceDescription = try await client.description(device: device).get()
show(description.device.friendlyName)
} catch let e as DescriptionException {
switch onEnum(of: e) {
case .notFound: showUnknown()
case .fetchFailed(let f): retryLater(f.statusCode)
case .parseFailed(let p): log(p.message)
}
}
onEnum(of:)is how you switch a Kotlin sealed type in Swift — it's SKIE's intended API, not a workaround. SKIE already generates an exhaustive Swift enum for every sealed type (DeviceChange,SearchTarget,Notification,DescriptionException); a Kotlin sealed value arrives as a class instance, andonEnum(of:)maps it to that enum so theswitchis exhaustive with nodefault. There's no SKIE setting that removes the call.
Prefer a registered callback over a for await loop? Implement SsdpDeviceListener
and register it — a thin fan-out over the same changes stream (the flows stay
available and unchanged):
final class Listener: SsdpDeviceListener {
func onFound(device: DiscoveredDevice) { add(device) }
func onUpdated(device: DiscoveredDevice) { update(device) }
func onRemoved(device: DiscoveredDevice, reason: DeviceChangeRemovedReason) { remove(device) }
}
let listener = Listener()
client.addListener(listener)
// … later
client.removeListener(listener) // all listeners are also dropped on close()val listener = object : SsdpDeviceListener {
override fun onFound(device: DiscoveredDevice) { add(device) }
override fun onUpdated(device: DiscoveredDevice) { update(device) }
override fun onRemoved(device: DiscoveredDevice, reason: DeviceChange.Removed.Reason) { remove(device) }
}
client.addListener(listener)joining 239.255.255.250 requires the com.apple.developer.networking.multicast entitlement (Apple grants it via a request form). Plain-http:// description fetches to LAN IPs need ATS NSAllowsLocalNetworking and an NSLocalNetworkUsageDescription.
A sandboxed app needs both com.apple.security.network.client (outbound) and com.apple.security.network.server (the sandbox treats bind() as a server op: the client binds port 1900 for NOTIFY and an ephemeral port for M-SEARCH replies). No multicast entitlement needed.
The library manifest contributes INTERNET / ACCESS_WIFI_STATE / CHANGE_WIFI_MULTICAST_STATE. Use SsdpClient() — the library captures the application Context at startup (an androidx.startup SsdpInitializer) and holds a WifiManager.MulticastLock for you, so no Context argument is needed. Without the lock Android drops inbound multicast. If your app disables androidx.startup's InitializationProvider, use SsdpClient(context): a plain SsdpClient() then opens without the lock and logs a warning saying so. Apps on Android 13+ also declare NEARBY_WIFI_DEVICES.
Emulators sit behind a user-mode NAT and never receive inbound UDP multicast, so normal discovery hears nothing there. Run the bridge daemon on your host (mise run app:bridge) and build the client with SsdpClient.bridgeAware() — its useBridge defaults to isSsdpBridgeNeeded(), so on an emulator it tunnels SSDP over TCP to the daemon (which does the real multicast on the host LAN) and on a device it's a normal multicast client. The client is otherwise identical (same registry, retransmit, search/description). The library never silently swaps transport on a plain SsdpClient() — but bridgeAware() opts into the auto-decision, pass useBridge = false/true to override, and every multicast client (SsdpClient(), SsdpClient(context), bridgeAware(useBridge = false)) logs a warning on a likely emulator.
// Android: one line, zero args — bridge on an emulator, multicast on a device.
val client = SsdpClient.bridgeAware()Start the host daemon first (it does the real multicast on your LAN):
mise run app:bridge # listen on 1901
mise run app:bridge -- 1901 # explicit portSsdpClient.bridgeAware(useBridge = isSsdpBridgeNeeded(), host = "10.0.2.2", port = 1901)
is the full signature; useBridge and the host/port all default, so the common
call takes no arguments. The lower-level SsdpClient.bridged(host, port) is the
always-bridge form, for when you've made the call yourself.
The daemon is a dumb pipe: the app keeps owning retransmit and the registry, so the emulator path is byte-identical to a physical device — only the wire hop differs. It connects over TCP, frames each M-SEARCH to the daemon, and the daemon streams every reply/NOTIFY it sees back.
Plain MulticastSocket. Like every platform, the client listens and searches on
each up, multicast-capable IPv4 interface (Wi-Fi, Ethernet, a VM bridge), skipping
loopback and VPN tunnels. Pass bindInterface ("en0" or an IPv4 address) to
limit discovery to one.
:ssdp-testing ships a scriptable fake — no real socket, usable from
commonTest on every target:
withFakeSsdpClient { fake ->
fake.emitFound(sampleDevice)
fake.stubDescription(sampleDevice.usn, Result.success(sampleDescription))
val vm = DeviceListViewModel(fake) // takes an SsdpClient
assertEquals(1, vm.devices.value.size)
}apps/ has four scanners — see apps/README.md:
| App | Run |
|---|---|
| JVM CLI | mise run cli |
SSDP bridge daemon (for Android emulators; ssdp-bridge at the repo root) |
mise run app:bridge |
| Android | mise run open:android |
| iOS | mise run open:ios |
| macOS | mise run open:macos |
The CLI prints discovered devices and their fetched descriptions against your real network; the GUI apps show a scanner → detail flow (devices grouped by UDN, tap to fetch the description).
Toolchain is pinned via mise (mise install). Common
tasks:
mise run check # ktlint + detekt + all unit tests (iOS sim, macOS, Android host, JVM)
mise run test:jvm # JVM-only — the fast inner loop
mise run cli # live LAN discovery harness
mise run build:xcframework # assemble the release SsdpKit.xcframework
mise run open:macos # generate + open the macOS sample in XcodeOr directly: ./gradlew :ssdp:check :ssdp-testing:check.
Every PR that reaches consumers adds a changeset (mise run changeset); merges
keep a rolling Release vX.Y.Z PR open, and merging it publishes — see
.changeset/README.md and
.github/PUBLISHING.md.
CLAUDE.md is the contract: ARM-only native targets, Kotlin-first
libraries, the Swift-interop rules (SKIE, sealed result types, @Throws), and
the four product decisions behind the design. .claude/lessons/LESSONS.md
records bugs hit and decisions made. Versions live only in
gradle/libs.versions.toml.
Released under the MIT License.
A Kotlin Multiplatform SSDP / UPnP discovery client for iOS, macOS, Android, and the JVM:
ssdp:alive,
ssdp:byebye, ssdp:update), for devices that announce themselves.byebye, CACHE-CONTROL: max-age expiry, or a network change)LOCATION), parsed into a friendly-name / manufacturer / model / services / icons tree.
Descriptions are remembered per network, so returning to a network restores
them without refetching.Published to Maven Central:
| Module | Coordinate | Description |
|---|---|---|
:ssdp |
com.happycodelucky.ssdp:ssdp |
Client library |
:ssdp‑testing |
com.happycodelucky.ssdp:ssdp‑testing |
FakeSsdpClient + withFakeSsdpClient { } for tests |
Not published — repo tools and samples (excluded from the publish/check gate):
| Module | What it is |
|---|---|
:ssdp‑bridge |
Host-side daemon that relays SSDP to an Android emulator over TCP (see Emulator bridge). Run with mise run app:bridge. |
:cli |
Command-line discovery harness — scans the LAN and prints devices + descriptions. Run with mise run cli
|
:androidApp |
The Android Compose sample scanner (see apps/) |
# gradle/libs.versions.toml
[libraries]
ssdp = { module = "com.happycodelucky.ssdp:ssdp", version = "0.9.0" }
ssdp-testing = { module = "com.happycodelucky.ssdp:ssdp-testing", version = "0.9.0" }// build.gradle.kts
commonMain.dependencies { implementation(libs.ssdp) }
commonTest.dependencies { implementation(libs.ssdp.testing) }ssdp brings KotlinResult
(com.happycodelucky.kotlinresult:kotlinresult) with it: description()
returns its Result. If you link ssdp into your own Apple framework (a KMP
app or library with SKIE), export KotlinResult into that framework, or its link
fails with "cannot find type 'KotlinResult' in scope". See
KotlinResult's setup notes.
SPM consumers of SsdpKit need nothing; it already exports it.
Add this repository as a package dependency, pinned to a release tag, and
depend on the SsdpKit product (Ssdp before 0.7.0). The XCFramework ships as
a GitHub Release asset (see .github/PUBLISHING.md).
.package(url: "https://github.com/happycodelucky/ssdp-kmp.git", from: "0.9.0")// One factory for all platforms. On Android the library captures the application
// Context at startup, so no argument is needed (emulators: see "Android emulators").
val client: SsdpClient = SsdpClient()
// Search every SSDP target; stop broadcasting after 6s (passive listening and
// the discovered devices persist). Omit `timeout` to broadcast until you close
// the returned session.
val search: SearchSession = client.search(setOf(SearchTarget.All), timeout = 6.seconds)
// The always-current device set, keyed by USN.
client.devices.collect { byUsn ->
byUsn.values.forEach { device -> println("${device.usn} @ ${device.location}") }
}
// Or react to deltas.
client.changes.collect { change ->
when (change) {
is DeviceChange.Found -> add(change.device)
is DeviceChange.Updated -> update(change.device)
is DeviceChange.Removed -> remove(change.device) // reason: Byebye / Expired / NetworkChanged
}
}
// Fetch a device's description on demand (cached; concurrent calls share one
// fetch). A KotlinResult `Result` — the standard kotlin.Result API — failing with
// a sealed DescriptionException.
import com.happycodelucky.kotlinresult.Result // not kotlin.Result
client.description(device)
.onSuccess { show(it.device.friendlyName) }
.onFailure { e ->
when (e as? DescriptionException) {
is DescriptionException.NotFound -> showUnknown()
is DescriptionException.FetchFailed -> retryLater(e.statusCode)
is DescriptionException.ParseFailed -> log(e.message)
null -> throw e
}
}
search.close() // optional here: the 6s timeout ends it anyway
client.close()Search for several specific targets in one session — fanned out over the shared socket, merged into the one registry:
client.search(
setOf(
SearchTarget.DeviceType("schemas-upnp-org", "MediaServer", 1),
SearchTarget.DeviceType("schemas-upnp-org", "MediaRenderer", 1),
),
)search() adds to the searches already running rather than replacing them. It
returns a SearchSession that contributes its targets until it ends: when you
close() it, its timeout elapses, stopSearch() runs, or the client closes. The
client M-SEARCHes for the union of every active session's targets, so independent
parts of an app can share one client (one NOTIFY listener, one socket pair) without
cancelling each other:
val roku = SearchTarget.Custom("roku:ecp")
val renderers = SearchTarget.DeviceType("schemas-upnp-org", "MediaRenderer", 1)
val tvScan = client.search(setOf(roku, renderers))
val rokuScan = client.search(setOf(roku), timeout = 10.seconds)
tvScan.close() // MediaRenderer stops; roku:ecp keeps its cadence for rokuScanmaxWaitSeconds among them.search(emptySet()) returns an inactive session and leaves the others alone.
stopSearch() ends every session.timeout keeps searching until you close it, so keep the
handle (use { } works: it's AutoCloseable).The same flows bridge to AsyncSequence and the sealed types to exhaustive Swift
enums via SKIE:
import SsdpKit
let client = try SsdpClient(bindInterface: nil)
let search = try await client.search(targets: [SearchTargetAll.shared], maxWaitSeconds: 1, timeout: nil)
defer { search.close() } // a SearchSession; ends only this search
for await byUsn in client.devices {
render(Array(byUsn.values))
}
// `description(device:)` returns a `KotlinResult<DeviceDescription>`; `get()` throws
// the Kotlin DescriptionException itself (or use `result(as:)` for a Swift.Result).
do {
let description: DeviceDescription = try await client.description(device: device).get()
show(description.device.friendlyName)
} catch let e as DescriptionException {
switch onEnum(of: e) {
case .notFound: showUnknown()
case .fetchFailed(let f): retryLater(f.statusCode)
case .parseFailed(let p): log(p.message)
}
}
onEnum(of:)is how you switch a Kotlin sealed type in Swift — it's SKIE's intended API, not a workaround. SKIE already generates an exhaustive Swift enum for every sealed type (DeviceChange,SearchTarget,Notification,DescriptionException); a Kotlin sealed value arrives as a class instance, andonEnum(of:)maps it to that enum so theswitchis exhaustive with nodefault. There's no SKIE setting that removes the call.
Prefer a registered callback over a for await loop? Implement SsdpDeviceListener
and register it — a thin fan-out over the same changes stream (the flows stay
available and unchanged):
final class Listener: SsdpDeviceListener {
func onFound(device: DiscoveredDevice) { add(device) }
func onUpdated(device: DiscoveredDevice) { update(device) }
func onRemoved(device: DiscoveredDevice, reason: DeviceChangeRemovedReason) { remove(device) }
}
let listener = Listener()
client.addListener(listener)
// … later
client.removeListener(listener) // all listeners are also dropped on close()val listener = object : SsdpDeviceListener {
override fun onFound(device: DiscoveredDevice) { add(device) }
override fun onUpdated(device: DiscoveredDevice) { update(device) }
override fun onRemoved(device: DiscoveredDevice, reason: DeviceChange.Removed.Reason) { remove(device) }
}
client.addListener(listener)joining 239.255.255.250 requires the com.apple.developer.networking.multicast entitlement (Apple grants it via a request form). Plain-http:// description fetches to LAN IPs need ATS NSAllowsLocalNetworking and an NSLocalNetworkUsageDescription.
A sandboxed app needs both com.apple.security.network.client (outbound) and com.apple.security.network.server (the sandbox treats bind() as a server op: the client binds port 1900 for NOTIFY and an ephemeral port for M-SEARCH replies). No multicast entitlement needed.
The library manifest contributes INTERNET / ACCESS_WIFI_STATE / CHANGE_WIFI_MULTICAST_STATE. Use SsdpClient() — the library captures the application Context at startup (an androidx.startup SsdpInitializer) and holds a WifiManager.MulticastLock for you, so no Context argument is needed. Without the lock Android drops inbound multicast. If your app disables androidx.startup's InitializationProvider, use SsdpClient(context): a plain SsdpClient() then opens without the lock and logs a warning saying so. Apps on Android 13+ also declare NEARBY_WIFI_DEVICES.
Emulators sit behind a user-mode NAT and never receive inbound UDP multicast, so normal discovery hears nothing there. Run the bridge daemon on your host (mise run app:bridge) and build the client with SsdpClient.bridgeAware() — its useBridge defaults to isSsdpBridgeNeeded(), so on an emulator it tunnels SSDP over TCP to the daemon (which does the real multicast on the host LAN) and on a device it's a normal multicast client. The client is otherwise identical (same registry, retransmit, search/description). The library never silently swaps transport on a plain SsdpClient() — but bridgeAware() opts into the auto-decision, pass useBridge = false/true to override, and every multicast client (SsdpClient(), SsdpClient(context), bridgeAware(useBridge = false)) logs a warning on a likely emulator.
// Android: one line, zero args — bridge on an emulator, multicast on a device.
val client = SsdpClient.bridgeAware()Start the host daemon first (it does the real multicast on your LAN):
mise run app:bridge # listen on 1901
mise run app:bridge -- 1901 # explicit portSsdpClient.bridgeAware(useBridge = isSsdpBridgeNeeded(), host = "10.0.2.2", port = 1901)
is the full signature; useBridge and the host/port all default, so the common
call takes no arguments. The lower-level SsdpClient.bridged(host, port) is the
always-bridge form, for when you've made the call yourself.
The daemon is a dumb pipe: the app keeps owning retransmit and the registry, so the emulator path is byte-identical to a physical device — only the wire hop differs. It connects over TCP, frames each M-SEARCH to the daemon, and the daemon streams every reply/NOTIFY it sees back.
Plain MulticastSocket. Like every platform, the client listens and searches on
each up, multicast-capable IPv4 interface (Wi-Fi, Ethernet, a VM bridge), skipping
loopback and VPN tunnels. Pass bindInterface ("en0" or an IPv4 address) to
limit discovery to one.
:ssdp-testing ships a scriptable fake — no real socket, usable from
commonTest on every target:
withFakeSsdpClient { fake ->
fake.emitFound(sampleDevice)
fake.stubDescription(sampleDevice.usn, Result.success(sampleDescription))
val vm = DeviceListViewModel(fake) // takes an SsdpClient
assertEquals(1, vm.devices.value.size)
}apps/ has four scanners — see apps/README.md:
| App | Run |
|---|---|
| JVM CLI | mise run cli |
SSDP bridge daemon (for Android emulators; ssdp-bridge at the repo root) |
mise run app:bridge |
| Android | mise run open:android |
| iOS | mise run open:ios |
| macOS | mise run open:macos |
The CLI prints discovered devices and their fetched descriptions against your real network; the GUI apps show a scanner → detail flow (devices grouped by UDN, tap to fetch the description).
Toolchain is pinned via mise (mise install). Common
tasks:
mise run check # ktlint + detekt + all unit tests (iOS sim, macOS, Android host, JVM)
mise run test:jvm # JVM-only — the fast inner loop
mise run cli # live LAN discovery harness
mise run build:xcframework # assemble the release SsdpKit.xcframework
mise run open:macos # generate + open the macOS sample in XcodeOr directly: ./gradlew :ssdp:check :ssdp-testing:check.
Every PR that reaches consumers adds a changeset (mise run changeset); merges
keep a rolling Release vX.Y.Z PR open, and merging it publishes — see
.changeset/README.md and
.github/PUBLISHING.md.
CLAUDE.md is the contract: ARM-only native targets, Kotlin-first
libraries, the Swift-interop rules (SKIE, sealed result types, @Throws), and
the four product decisions behind the design. .claude/lessons/LESSONS.md
records bugs hit and decisions made. Versions live only in
gradle/libs.versions.toml.
Released under the MIT License.