Skip to content

gary-quinn/kmp-uwb

Repository files navigation

kmp-uwb

CI Publish Maven Central License Kotlin

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.

Features

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

Modules

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

Setup

Android / KMP (Gradle)

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.

iOS (Swift Package Manager)

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 KmpUwb

Note: UWB requires a U1 or U2 chip. On iOS, the NearbyInteraction framework is only available on iPhone 11+ and Apple Watch Series 6+.

Usage

Check UWB availability

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")
    }
}

Query capabilities

val capabilities = adapter.capabilities()
println("Roles: ${capabilities.supportedRoles}")
println("AoA: ${capabilities.angleOfArrivalSupported}")
println("Channels: ${capabilities.supportedChannels}")
println("Background: ${capabilities.backgroundRangingSupported}")

Configure a session

val config = rangingConfig {
    role = RangingRole.CONTROLLER
    channel = 9
    stsMode = StsMode.DYNAMIC
    angleOfArrival = true
    rangingInterval = 200.milliseconds
}

Start ranging

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 -> {}
    }
}

Collect measurements

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()

Test without hardware

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"))

Configure backpressure

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

kmp-uwb-connector

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")
}

Controller side

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}")
    }
}

Controlee side

val connector = BleConnector.controlee()
val session = adapter.startWithConnector(config, connector)

BLE + UWB integration flow

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())
}

Relationship with kmp-ble

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.

Architecture

  • State machine: 10 states with sealed interface hierarchy — exhaustive when branches
  • Per-session concurrency: limitedParallelism(1) serialization, no locks
  • Configurable backpressure: BackpressureStrategy (KeepLatest, Unbounded, KeepOldest) for ranging results
  • Value classes: Distance and Angle are zero-allocation wrappers with unit conversion
  • Composable errors: Sealed interfaces — SessionError, RangingError, HardwareError, SecurityError
  • Defensive copies: PeerAddress copies on construction and access — no aliasing bugs

See ARCHITECTURE.md for full design documentation.

Requirements

  • Kotlin 2.3.0+
  • Android minSdk 33
  • iOS 15+ (U1/U2 chip required for UWB)
  • kotlinx-coroutines 1.10+

License

Apache 2.0 — Copyright (C) 2026 Gary Quinn

About

Kotlin Multiplatform UWB (Ultra-Wideband) library for Android and iOS — centimetre-accurate ranging, angle-of-arrival, peer discovery via BLE

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors