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, iOS, and JVM desktop (Linux via BlueZ, macOS on Apple silicon via CoreBluetooth).
These are the only modules published to Maven Central (see
.github/workflows/publish.yml). Copy-paste
coordinates from here or Setup below - they match Central.
Satellite modules (kmp-ble-profiles, kmp-ble-dfu, kmp-ble-codec,
kmp-ble-codec-serialization) were last published at 0.11.2. From
0.12.0 onward they are source-only in this monorepo.
To use profiles, DFU, or codec modules on current main, build them from this
repo instead of Maven Central.
Option A - monorepo / composite build (consumer in another repo):
// settings.gradle.kts
includeBuild("../kmp-ble") {
dependencySubstitution {
substitute(module("com.atruedev:kmp-ble-profiles"))
.using(project(":kmp-ble-profiles"))
substitute(module("com.atruedev:kmp-ble-dfu"))
.using(project(":kmp-ble-dfu"))
substitute(module("com.atruedev:kmp-ble-codec"))
.using(project(":kmp-ble-codec"))
substitute(module("com.atruedev:kmp-ble-codec-serialization"))
.using(project(":kmp-ble-codec-serialization"))
}
}
Then depend on Maven-style coordinates as usual:
commonMain.dependencies {
implementation("com.atruedev:kmp-ble:0.14.0") // from Central
implementation("com.atruedev:kmp-ble-profiles") // resolved to local project
}
Option B - same Gradle build (you cloned or vendored this repo):
// settings.gradle.kts
include(":kmp-ble-profiles")
// build.gradle.kts
commonMain.dependencies {
implementation(project(":kmp-ble-profiles"))
}
Pin com.atruedev:kmp-ble-*:0.11.2 on Maven Central only if you need the last
pre-monolith published artifacts without building from source.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.atruedev:kmp-ble:0.14.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
Add the backend for each desktop OS you ship on. The portable API (Scanner { }, toPeripheral(), GattServer { }, Advertiser(), BluetoothAdapter()) picks the backend that matches the host at runtime through ServiceLoader; without a backend it throws UnsupportedOperationException, as before.
kotlin {
sourceSets {
jvmMain.dependencies {
implementation("com.atruedev:kmp-ble-bluez:0.14.0") // Linux, BlueZ 5.x
implementation("com.atruedev:kmp-ble-macos:0.14.0") // macOS 11+, Apple silicon
}
}
}
See Platform Setup: Linux and Platform Setup: macOS (JVM) for permissions, the macOS NSBluetoothAlwaysUsageDescription requirement, and per-OS limits.
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: ")
}
peripheral.disconnect()
peripheral.close()
Requires the kmp-ble-profiles module - build from source (see
Build from source); last Maven artifact
was 0.11.2.
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.
Requires the kmp-ble-codec module - build from source (see
Build from source); last Maven artifact
was 0.11.2.
Typed read/write with composable decoders:
Requires the kmp-ble-codec-serialization module - build from source (see
Build from source); last Maven artifact
was 0.11.2.
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) }
Requires the kmp-ble-dfu module - build from source (see
Build from source); last Maven artifact
was 0.11.2.
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 single-screen Compose Multiplatform app (~200 lines of UI): scan, tap a device, connect, read or observe the first useful characteristic (prefers Heart Rate 0x180D/0x2A37), disconnect. No ViewModel or navigation - the runnable companion to GETTING_STARTED.md. For a code-only walkthrough, see BleQuickstart.kt.
# Android
./gradlew :sample-quickstart-android:installDebug
# iOS - build the framework, then wire MainViewController() from your Xcode target
./gradlew :sample-quickstart:linkDebugFrameworkIosSimulatorArm64
sample-jvm/)Command-line sample for Linux and macOS: permissions, adapter state, scanning, and a connect-dump-read-observe pass over one device. Useful for hardware checks of the JVM backends.
./gradlew :sample-jvm:run --args="scan 10"
./gradlew :sample-jvm:run --args="connect <identifier> 15"
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 | Description |
|---|
| kmp-ble | Core BLE - scanning, connecting, GATT read/write/observe, server, advertising |
| kmp-ble-profiles | Type-safe GATT profile parsing (Heart Rate, Battery, Device Info, Blood Pressure, Glucose, CSC) |
| kmp-ble-dfu | Firmware updates - Nordic Secure DFU, MCUboot SMP, Espressif ESP OTA - with auto-detection and progress tracking |
| kmp-ble-codec | Format-agnostic typed read/write via composable BleEncoder/BleDecoder |
| kmp-ble-codec-serialization | kotlinx-serialization adapters (CBOR) bridging @Serializable types to BleCodec |
| kmp-ble-bluez | JVM backend for Linux (BlueZ over D-Bus) - picked up automatically on Linux |
| kmp-ble-macos | JVM backend for macOS on Apple silicon (CoreBluetooth over JNI) - picked up automatically on macOS arm64 |
| Artifact | Gradle coordinate | Notes |
|---|
| Core | com.atruedev:kmp-ble:0.13.3 | Required for all apps |
| Android quirks | com.atruedev:kmp-ble-quirks:0.13.3 | Transitive on Android via kmp-ble; rarely declared directly |
| Linux JVM backend | com.atruedev:kmp-ble-bluez | New in the next release; add to jvmMain for Linux desktop / edge apps |
| macOS JVM backend | com.atruedev:kmp-ble-macos | New in the next release; add to jvmMain for macOS arm64 desktop apps |
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.