kmp-ble
0.9.0indexedScan, connect, read/write and observe GATT; advertise and host GATT servers; perform Nordic Secure DFU; type-safe profile parsing, composable codecs, reconnection and bonding support.
Scan, connect, read/write and observe GATT; advertise and host GATT servers; perform Nordic Secure DFU; type-safe profile parsing, composable codecs, reconnection and bonding support.
Kotlin Multiplatform BLE library for Android and iOS.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.atruedev:kmp-ble:0.9.0")
// Optional modules
implementation("com.atruedev:kmp-ble-profiles:0.9.0")
implementation("com.atruedev:kmp-ble-dfu:0.9.0")
implementation("com.atruedev:kmp-ble-codec:0.9.0")
implementation("com.atruedev:kmp-ble-codec-serialization:0.9.0")
}
}
}
Initialize in your Application.onCreate() (Android only):
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
KmpBle.init(this)
}
}
In Xcode: File > Add Package Dependencies and enter:
https://github.com/gary-quinn/kmp-ble
Select the version and add KmpBle to your target.
import KmpBle
Standard service UUIDs are provided as constants in ServiceUuid. For custom/proprietary
services, use the UUID from your device's documentation or GATT profile.
See: Bluetooth SIG Service UUIDs
// Common code - works on both Android and iOS
val scanner = Scanner {
timeout = 30.seconds
emission = EmissionPolicy.FirstThenChanges(rssiThreshold = 10)
filters {
match { serviceUuid(ServiceUuid.HEART_RATE) }
}
}
scanner.scanEvents.collect { event ->
when (event) {
is ScanEvent.Found -> {
val ad = event.advertisement
println("Found: ${ad.name} (${ad.identifier}) rssi=${ad.rssi}")
}
ScanEvent.Failed -> println()
}
}
scanner.close()
val adapter = BluetoothAdapter()
adapter.state.collect { state ->
when (state) {
BluetoothAdapterState.On -> println("Bluetooth ready")
BluetoothAdapterState.Off -> println("Bluetooth off")
BluetoothAdapterState.Unauthorized -> println("Permission denied")
else -> {}
}
}
val peripheral = advertisement.toPeripheral()
peripheral.connect()
val hrChar = peripheral.findCharacteristic(
serviceUuid = uuidFrom("180d"),
characteristicUuid = uuidFrom("2a37"),
)!!
// Read
val value = peripheral.read(hrChar)
// Write (explicit WriteType required)
peripheral.write(hrChar, byteArrayOf(0x01), WriteType.WithResponse)
// Observe notifications
peripheral.observeValues(hrChar).collect { data ->
println("Heart rate: ${data[]}")
}
peripheral.disconnect()
peripheral.close()
Type-safe GATT profile parsing via Peripheral extension functions:
// Heart Rate
peripheral.heartRateMeasurements().collect { measurement ->
println("BPM: ${measurement.heartRate}")
println("RR intervals: ${measurement.rrIntervals}")
println()
}
location = peripheral.readBodySensorLocation()
level = peripheral.readBatteryLevel()
peripheral.batteryLevelNotifications().collect { println() }
info = peripheral.readDeviceInformation()
println()
Supported profiles: Heart Rate, Battery, Device Information, Blood Pressure, Glucose, Cycling Speed and Cadence.
Typed read/write with composable decoders:
CBOR adapters via kotlinx-serialization. Bridge @Serializable types to the
BleCodec surface so they slot into framed L2CAP streams or characteristic
read/write paths without writing a hand-rolled decoder:
@Serializable
data class Reading(val timestampMs: Long, val celsius: Double)
val codec = cborCodec<Reading>()
// Framed L2CAP stream of typed values
l2cap.writeFramed(Reading(1_700_000_000_000, 21.5), codec)
l2cap.framedIncoming(codec).collect { reading -> render(reading) }
Firmware updates supporting Nordic Secure DFU, MCUboot SMP (Zephyr/Mynewt), and Espressif ESP OTA:
// Auto-detect protocol from peripheral's GATT services
val controller = DfuController.create(peripheral)
// Or specify explicitly
val controller = DfuController(peripheral, McuBootDfuProtocol())
controller.performDfu(firmware).collect { progress ->
when (progress) {
is DfuProgress.Transferring -> println("${(progress.fraction * 100).toInt()}%")
DfuProgress.Completed -> println()
DfuProgress.Failed -> println()
-> {}
}
}
controller.abort()
Each protocol has its own firmware parser:
// Nordic Secure DFU (.zip)
val nordic = FirmwarePackage.Nordic.fromZipBytes(zipData)
// MCUboot SMP (.bin)
val mcuboot = FirmwarePackage.McuBoot.fromBinBytes(binData)
// Espressif ESP OTA (.bin)
val esp = FirmwarePackage.EspOta.fromBinBytes(binData)
ESP OTA supports custom service UUIDs for vendor flexibility:
val controller = DfuController(peripheral, EspOtaDfuProtocol())
controller.performDfu(firmware, DfuOptions(
transport = DfuTransportConfig.EspOta(
serviceUuid = uuidFrom("your-custom-service-uuid"),
)
))
val server = GattServer {
service(uuidFrom()) {
characteristic(uuidFrom()) {
properties { read = ; notify = }
permissions { read = }
onRead { device -> BleData(byteArrayOf(, )) }
}
}
}
server.()
advertiser = Advertiser()
advertiser.startAdvertising(AdvertiseConfig(
serviceUuids = listOf(uuidFrom()),
connectable = ,
))
server.notify(uuidFrom(), device = , BleData(byteArrayOf(, )))
extAdvertiser = ExtendedAdvertiser()
extAdvertiser.startAdvertisingSet(ExtendedAdvertiseConfig(
serviceUuids = listOf(uuidFrom()),
primaryPhy = Phy.Le1M,
secondaryPhy = Phy.Le2M,
))
// Proactive bonding
peripheral.connect(ConnectionOptions(bondingPreference = BondingPreference.Required))
// Observe bond state
peripheral.bondState.collect { state ->
println("Bond: $state") // NotBonded, Bonding, Bonded, Unknown
}
// Remove bond (Android only)
@OptIn(ExperimentalBleApi::class)
val result = peripheral.removeBond()
peripheral.connect(ConnectionOptions(
reconnectionStrategy = ReconnectionStrategy.ExponentialBackoff(
initialDelay = 1.seconds,
maxDelay = 30.seconds,
maxAttempts = 10,
)
))
// Pre-configured connection options for common use cases
peripheral.connect(ConnectionRecipe.MEDICAL) // strict bonding, no auto-connect
peripheral.connect(ConnectionRecipe.FITNESS) // reconnection, if-required bonding
peripheral.connect(ConnectionRecipe.IOT) // auto-connect, no bonding
peripheral.connect(ConnectionRecipe.CONSUMER) // balanced defaults
when (val result = checkBlePermissions()) {
is PermissionResult.Granted -> { /* ready to scan */ }
is PermissionResult.Denied -> { /* request permissions */ }
is PermissionResult.PermanentlyDenied -> { /* open settings */ }
}
BleLogConfig.logger = PrintBleLogger() // stdout/logcat
// or
BleLogConfig.logger = BleLogger { event -> Timber.d("BLE: $event") }
sample/)Production-grade BLE utility app (Android + iOS) with tab-based navigation, composed operation classes, and polished UX:
sample-quickstart/)Minimal ~150-line single-screen app: scan, tap, connect, read first characteristic, display value. No ViewModel, no navigation - the "Getting Started in 5 Minutes" reference.
See ARCHITECTURE.md for design details and STREAMS.md for an end-to-end recipe on typed L2CAP streams (codec + framing + accept loop).
After cloning, enable the repo's pre-commit hooks once:
git config core.hooksPath .githooks
The hook blocks Unicode typography characters (em/en-dash, smart quotes,
ellipsis, NBSP) in staged content. See AGENTS.md for the full list
and the typo-ok bypass.
Apache 2.0 - Copyright (C) 2026 Gary Quinn
| Module | Artifact | Description |
|---|
| kmp-ble | com.atruedev:kmp-ble | Core BLE - scanning, connecting, GATT read/write/observe, server, advertising |
| kmp-ble-profiles | com.atruedev:kmp-ble-profiles | Type-safe GATT profile parsing (Heart Rate, Battery, Device Info, Blood Pressure, Glucose, CSC) |
| kmp-ble-dfu | com.atruedev:kmp-ble-dfu | Firmware updates - Nordic Secure DFU, MCUboot SMP, Espressif ESP OTA - with auto-detection and progress tracking |
| kmp-ble-codec | com.atruedev:kmp-ble-codec | Format-agnostic typed read/write via composable BleEncoder/BleDecoder |
| kmp-ble-codec-serialization | com.atruedev:kmp-ble-codec-serialization | kotlinx-serialization adapters (CBOR) bridging @Serializable types to BleCodec |
val TemperatureDecoder = BleDecoder<Float> { data ->
// IEEE 11073 FLOAT parsing
val mantissa = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
val exponent = data[3].toInt()
mantissa * 10f.pow(exponent)
}
// Typed read
val temp: Float = peripheral.read(characteristic, TemperatureDecoder)
// Typed observe
peripheral.observeValues(characteristic, TemperatureDecoder).collect { celsius ->
println("Temperature: $celsius°C")
}
// Decoder composition
val FormattedTemp = TemperatureDecoder.map { "%.1f°C".format(it) }
val scanner = FakeScanner {
advertisement {
name("HeartSensor")
rssi(-55)
serviceUuids("180d")
}
}
val peripheral = FakePeripheral {
service("180d") {
characteristic("2a37") {
properties(notify = true, read = true, write = true)
onRead { byteArrayOf(0x00, 72) }
onWrite { data, writeType -> println("Wrote ${data.size} bytes") }
onObserve {
flow {
emit(byteArrayOf(0x00, 72))
delay(1000)
emit(byteArrayOf(0x00, 80))
}
}
// Simulate slow BLE responses
respondAfter(500.milliseconds)
}
characteristic("2a39") {
properties(write = true)
// Simulate GATT errors
failWith(GattError("write", GattStatus.WriteNotPermitted))
}
}
}
limitedParallelism(1) serialization, no locksBleData wraps NSData on iOS, ByteArray on AndroidCharacteristic and Descriptor use reference equality, matching native API behaviorAuthError, GattOperationError, ConnectionErrorSurfaced from shared tags and platforms — no rankings paid for.