Kable Documentation

repository·main·Indexed 22 days ago

https://github.com/juullabs/kable

A Kotlin-first, Coroutines-powered library for interacting with Bluetooth Low Energy (BLE) devices across multiple platforms, including Android, iOS, JavaScript, macOS, and JVM. Kable provides a unified API for scanning peripherals, managing connections, and performing I/O operations on services, characteristics, and descriptors.

Tokens
9.8K
Snippets
43
Records
48
Agent score
77%

What's inside Kable

  1. Overview of Kable

    main
    Kable (Kotlin Asynchronous Bluetooth Low Energy) is a library providing a Coroutines-powered API for interacting with Bluetooth Low Energy (BLE) devices. It is designed to work across multiple platforms including Android, iOS, JavaScript, macOS, and JVM.
  2. How to work with UUIDs in Kable

    main

    Bluetooth Low Energy uses UUIDs to identify services, characteristics, and descriptors. Kable provides several ways to handle them:

    1. Short-form UUIDs: You can create a full 128-bit Uuid by adding a 16-bit or 32-bit integer to Bluetooth.BaseUuid.
    2. Web Bluetooth Names: You can use extension functions to resolve UUIDs from human-readable strings (e.g., Uuid.service("heart_rate")).

    Shorthand Reference Table:

    ShorthandCanonical UUID
    Bluetooth.BaseUuid + 0x180D0000180D-0000-1000-8000-00805F9B34FB
    Bluetooth.BaseUuid + 0x1234567812345678-0000-1000-8000-00805F9B34FB
    Uuid.service("blood_pressure")00001810-0000-1000-8000-00805F9B34FB
    Uuid.characteristic("altitude")00002AB3-0000-1000-8000-00805F9B34FB
    Uuid.descriptor("valid_range")00002906-0000-1000-8000-00805F9B34FB
    val uuid16bit = 0x180D
    val heartRateServiceUuid = Bluetooth.BaseUuid + uuid16bit
    println(heartRateServiceUuid) // Output: 0000180d-0000-1000-8000-00805f9b34fb
    
    val heartRateServiceUuidByName = Uuid.service("heart_rate")
    println(heartRateServiceUuidByName) // Output: 0000180d-0000-1000-8000-00805f9b34fb
  3. How Peripheral connection states work

    main

    A Peripheral transitions through various connection states which can be observed via the state Flow.

    Important Note on Apple/JavaScript: The Disconnecting state is skipped on Apple and JavaScript platforms when the connection closure is initiated by the peripheral itself or if the peripheral goes out of range.

  4. Create and manage a Peripheral

    main

    A Peripheral represents a remote Bluetooth device and provides methods for connection handling and I/O operations. You create a Peripheral from an Advertisement using the Peripheral builder function.

    Lifecycle and Coroutines

    • Scope: A Peripheral provides a CoroutineScope via its scope property. Use this for long-running tasks that should persist across reconnections but be cancelled when the peripheral is disposed.
    • Connection-specific tasks: For tasks that should only run while a single connection is active (and shut down on disconnect), use the CoroutineScope returned from Peripheral.connect() or the scope property of the Connected state.
    • Disposal: You must call peripheral.close() when the peripheral is no longer needed. Once closed, the peripheral cannot be reused (e.g., calling connect will throw an IllegalStateException).
    val peripheral = Peripheral(advertisement) {
        // Configure peripheral
    }
    
    // Long running task tied to peripheral lifecycle
    peripheral.scope.launch {
        // ...
    }
    
    // Cleanup
    peripheral.close()
  5. Access characteristics and descriptors

    main

    BLE devices use a tree structure of Services, Characteristics, and Descriptors. You can access these in two ways:

    1. Lazy Search (characteristicOf / descriptorOf): These functions search the GATT profile for the first match based on the provided UUIDs. When performing I/O, the search also respects the required properties (e.g., searching for a characteristic that supports writeWithoutResponse).
    2. Manual Traversal (Peripheral.services): You can traverse the discovered services tree. This is useful if multiple items share the same UUID. Warning: Objects obtained via Peripheral.services hold strong references to platform types; ensure you remove references when they are no longer needed to avoid leaks.

    Note: read and write operations will throw NotConnectedException if the peripheral is not connected.

    // Lazy access to a descriptor
    val descriptor = descriptorOf(
        service = Bluetooth.BaseUuid + 0x1815,
        characteristic = Bluetooth.BaseUuid + 0x2A56,
        descriptor = Uuid.descriptor("gatt.client_characteristic_configuration"),
    )
    
    // Manual traversal
    val services = peripheral.services.value ?: error("Services not discovered")
    val descriptor = services
        .first { it.serviceUuid == Bluetooth.BaseUuid + 0x1815 }
        .characteristics
        .first { it.characteristicUuid == Bluetooth.BaseUuid + 0x2A56 }
        .descriptors
        .first { it.descriptorUuid == Uuid.descriptor("gatt.client_characteristic_configuration") }
  6. Build and run SensorTag for MacOS

    main

    The MacOS console app automatically scans for nearby [SensorTag] peripherals upon launch. It connects to the first one found and subscribes to the gyroscope, printing values to the console.

    Choose the command based on your Mac's CPU architecture:

    # For Intel Macs
    ./gradlew :shared:runDebugExecutableMacosX64
    
    # For Apple Silicon (e.g., M1, M2)
    ./gradlew :shared:runDebugExecutableMacosArm64
  7. Install Kable via Gradle

    main

    Add Kable to your Kotlin Multiplatform project. For Android, it is recommended to use the kable-default-permissions artifact to simplify permission handling.

    kotlin {
        sourceSets {
            commonMain.dependencies {
                api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}")
                implementation("com.juul.kable:kable-core:${kableVersion}")
            }
    
            androidMain.dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${coroutinesVersion}")
                implementation("com.juul.kable:kable-default-permissions:${kableVersion}") // Optional
            }
        }
    }
  8. Scan for peripherals using Scanner

    main

    The Scanner provides an advertisements Flow that emits Advertisement objects (containing name and RSSI) as they are seen.

    Configuration

    You can configure the Scanner using a DSL to define filters and logging settings.

    Filtering

    Scan results are filtered by providing Filters. Only Advertisements matching at least one filter will be emitted. Supported filters include:

    • Service (Recommended for optimization)
    • Name
    • NamePrefix
    • Address (Android only)
    • ManufacturerData

    Note: Providing Filter.Service is recommended as it is natively supported on all platforms and allows for system-level scan optimizations.

    Lifecycle

    Scanning begins when the advertisements Flow is collected and stops when the collection is terminated.

    // Scan until the first advertisement matching a specific name is found
    val advertisement = Scanner {
        filters {
            match {
                name = Filter.Name.Exact("Example")
            }
        }
    }.advertisements.first()
  9. Request a Peripheral in JavaScript

    main

    In JavaScript environments, instead of processing a stream of advertisements, you can use requestPeripheral(options) to trigger a browser-native device picker.

    Usage Requirements

    • The browser will show a list of peripherals matching your filters.
    • Crucial: If you use name filters, you must also include any services you intend to access in the optionalServices list. Otherwise, the origin will not have permission to access those services after the user selects the device.
    • If the user cancels the dialog, requestPeripheral returns null.
    val options = Options {
        filters {
            match {
                name = Filter.Name.Prefix("Example")
            }
        }
        optionalServices = listOf(
            Uuid.parse("f000aa80-0451-4000-b000-000000000000"),
            Uuid.parse("f000aa81-0451-4000-b000-000000000000"),
        )
    }
    val peripheral = requestPeripheral(options)
  10. Generate and run the SensorTag iOS project

    main

    To use the SensorTag sample on iOS, you must first generate the Xcode project using Gradle. After generation, you must configure signing within Xcode before running the app.

    Use ./gradlew :ios:generateXcodeProject to generate the project, or ./gradlew :ios:openXcode to both generate the project and open it in Xcode immediately.

    ./gradlew :ios:generateXcodeProject
    # OR
    ./gradlew :ios:openXcode