Kotlin Multiplatform UWB (Ultra-Wideband) library for Android and iOS.
Part of the kmp library family alongside kmp-ble. Use kmp-uwb for centimetre-accurate spatial awareness — device ranging, angle-of-arrival, and peer tracking — with the same shared-code-first philosophy.
| Capability | Description |
|---|---|
| Ranging Sessions | Start peer-to-peer TWR (Two-Way Ranging) sessions with real-time distance and angle measurements |
| Angle of Arrival | Azimuth and elevation from the device's UWB antenna, when hardware supports it |
| Adapter State | Observe hardware availability and query device capabilities |
| Session Lifecycle | 10-state state machine with exhaustive transitions — no ambiguous states |
| Composable Errors | Sealed interface hierarchy — errors can implement multiple facets (SessionError + HardwareError) |
| Test Without Hardware | FakeRangingSession and FakeUwbAdapter for full UWB simulation in unit tests |
| FiRa Compliant | Static, Dynamic, and Provisioned STS security modes. Controller/Controlee roles |
| Module | Artifact | Description |
|---|---|---|
| kmp-uwb | com.atruedev:kmp-uwb |
Core UWB library — adapter, sessions, ranging, state machine |
| kmp-uwb-connector | com.atruedev:kmp-uwb-connector |
BLE-based out-of-band parameter exchange using kmp-ble |
| kmp-uwb-testing | com.atruedev:kmp-uwb-testing |
Test doubles — FakeRangingSession, FakeUwbAdapter, FakePreparedSession |
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.atruedev:kmp-uwb:0.2.1")
}
}
}The library auto-initializes on Android via AndroidX Startup — no manual init() call needed. If your app disables auto-initialization, call KmpUwb.init(context) manually.
In Xcode: File > Add Package Dependencies and enter:
https://github.com/gary-quinn/kmp-uwb
Select the version and add KmpUwb to your target.
import KmpUwbNote: UWB requires a U1 or U2 chip. On iOS, the
NearbyInteractionframework is only available on iPhone 11+ and Apple Watch Series 6+.
val adapter = UwbAdapter()
adapter.state.collect { state ->
when (state) {
UwbAdapterState.ON -> println("UWB ready")
UwbAdapterState.OFF -> println("UWB disabled")
UwbAdapterState.UNSUPPORTED -> println("No UWB hardware")
}
}val capabilities = adapter.capabilities()
println("Roles: ${capabilities.supportedRoles}")
println("AoA: ${capabilities.angleOfArrivalSupported}")
println("Channels: ${capabilities.supportedChannels}")
println("Background: ${capabilities.backgroundRangingSupported}")val config = rangingConfig {
role = RangingRole.CONTROLLER
channel = 9
stsMode = StsMode.DYNAMIC
angleOfArrival = true
rangingInterval = 200.milliseconds
}val adapter = UwbAdapter()
val prepared = adapter.prepareSession(config)
// Exchange params with peer over BLE, NFC, or WiFi
val localBytes = prepared.localParams.toByteArray()
// ... send localBytes to peer, receive remoteBytes ...
val remoteParams = SessionParams(remoteBytes)
val session = prepared.startRanging(remoteParams)
// Observe state
session.state.collect { state ->
when (state) {
is RangingState.Active.Ranging -> println("Ranging active")
is RangingState.Active.Suspended -> println("Session suspended by system")
is RangingState.Active.PeerLost -> println("Peer out of range")
is RangingState.Stopped.ByError -> println("Error: ${state.error}")
else -> {}
}
}session.rangingResults.collect { result ->
when (result) {
is RangingResult.Position -> {
println("Distance: ${result.measurement.distance}") // e.g. 1.23 m
println("Azimuth: ${result.measurement.azimuth}") // horizontal angle
println("Elevation: ${result.measurement.elevation}") // vertical angle
}
is RangingResult.PeerLost -> println("Lost: ${result.peer}")
}
}
// Clean up
session.close()val fakeSession = FakeRangingSession(
config = rangingConfig { role = RangingRole.CONTROLLER },
)
val peer = Peer(address = PeerAddress(byteArrayOf(0x01, 0x02)))
// Inject a measurement
fakeSession.emitResult(
RangingResult.Position(
peer = peer,
measurement = RangingMeasurement(
distance = Distance.meters(2.5),
azimuth = Angle.degrees(15.0),
elevation = Angle.degrees(-5.0),
),
)
)
// Simulate error
fakeSession.simulateError(SessionLost(message = "connection dropped"))Control how ranging measurements are buffered by passing a BackpressureStrategy to startRanging():
val session = prepared.startRanging(remoteParams, BackpressureStrategy.KeepLatest)| Strategy | Behavior | Use case |
|---|---|---|
KeepLatest (default) |
Drop oldest when buffer full | Real-time UI |
Unbounded |
No dropping, unbounded buffer | Analytics, replay (bound session lifetime or use take(n)) |
KeepOldest |
Drop newest when buffer full | Strict arrival order processing |
The connector module automates BLE-based out-of-band (OOB) parameter exchange — the handshake every UWB session requires before ranging can start.
commonMain.dependencies {
implementation("com.atruedev:kmp-uwb-connector:0.2.1")
}val scanner = Scanner { filters { match { serviceUuid(UwbOobService.SERVICE_UUID) } } }
val connector = BleConnector.controller(scanner)
try {
val session = adapter.startWithConnector(config, connector)
session.rangingResults.collect { result -> /* ... */ }
} catch (e: ConnectorException) {
when (e.error) {
is ExchangeTimedOut -> println("No peer found")
is TransportFailure -> println("BLE failed: ${e.message}")
is InvalidRemoteParams -> println("Bad params: ${e.message}")
}
}val connector = BleConnector.controlee()
val session = adapter.startWithConnector(config, connector)A complete ranging session requires both BLE (for discovery and parameter exchange) and UWB (for ranging). The typical flow:
Controller Controlee
│ │
│ 1. BLE scan for UwbOobService UUID │
│ ────────────────────────────────────> │ ← BLE advertise
│ │
│ 2. BLE connect │
│ ────────────────────────────────────> │
│ │
│ 3. Write local UWB params │
│ ────────────────────────────────────> │ ← GATT write
│ │
│ 4. Read remote UWB params │
│ <──────────────────────────────────── │ ← GATT indicate
│ │
│ 5. UWB ranging session starts │
│ <═══════════════════════════════════> │ ← UWB TWR
startWithConnector orchestrates steps 1-5 automatically. For custom OOB transports (NFC, WiFi Direct), implement the PeerConnector interface:
val session = adapter.startWithConnector(config) { localParams ->
myTransport.send(localParams.toByteArray())
SessionParams(myTransport.receive())
}kmp-uwb and kmp-ble are independent libraries with no compile-time dependency. They share the same design philosophy:
| kmp-ble | kmp-uwb | |
|---|---|---|
| Technology | Bluetooth Low Energy | Ultra-Wideband |
| Range | ~100 m | ~10 m (centimetre-accurate) |
| Use cases | Data transfer, device control, firmware updates | Spatial awareness, precision ranging, indoor positioning |
| State model | 14-state connection FSM | 10-state ranging FSM |
| Error model | Composable sealed interfaces | Composable sealed interfaces |
| Testing | FakePeripheral, FakeScanner | FakeRangingSession, FakeUwbAdapter |
A typical spatial app might use kmp-ble for device discovery and data exchange, then kmp-uwb for precise positioning — but neither requires the other.
- State machine: 10 states with sealed interface hierarchy — exhaustive
whenbranches - Per-session concurrency:
limitedParallelism(1)serialization, no locks - Configurable backpressure:
BackpressureStrategy(KeepLatest, Unbounded, KeepOldest) for ranging results - Value classes:
DistanceandAngleare zero-allocation wrappers with unit conversion - Composable errors: Sealed interfaces —
SessionError,RangingError,HardwareError,SecurityError - Defensive copies:
PeerAddresscopies on construction and access — no aliasing bugs
See ARCHITECTURE.md for full design documentation.
- Kotlin 2.3.0+
- Android minSdk 33
- iOS 15+ (U1/U2 chip required for UWB)
- kotlinx-coroutines 1.10+
Apache 2.0 — Copyright (C) 2026 Gary Quinn