Skip to content

Add kable-server module (BLE peripheral role / GATT server) - #1224

Draft
twyatt wants to merge 10 commits into
mainfrom
cursor/bc-9dbb48c8-e10b-45d1-8c6c-1f6b63aee9fb-3f25
Draft

Add kable-server module (BLE peripheral role / GATT server)#1224
twyatt wants to merge 10 commits into
mainfrom
cursor/bc-9dbb48c8-e10b-45d1-8c6c-1f6b63aee9fb-3f25

Conversation

@twyatt

@twyatt twyatt commented Jul 18, 2026

Copy link
Copy Markdown
Member

Summary

Adds a new kable-server module (com.juul.kable.server) providing the peripheral (server) side of BLE: hosting a GATT server and advertising it to remote centrals, declared via a coroutines-first DSL:

val server = GattServer {
    service(Uuid.service("heart_rate")) {
        characteristic(Uuid.characteristic("heart_rate_measurement")) {
            onSubscription {                       // Launched per subscribed central,
                while (true) {                     // cancelled on unsubscribe/disconnect.
                    send(measureHeartRate())
                    delay(1.seconds)
                }
            }
        }
        characteristic(Uuid.characteristic("body_sensor_location")) {
            value = byteArrayOf(0x01)              // Static (read-only) value.
        }
    }
    service(customServiceUuid) {
        characteristic(customCharacteristicUuid) {
            onRead { byteArrayOf(/* ... */) }      // "Long read" offsets handled by Kable.
            onWrite { value -> process(value) }    // Prepared writes assembled by Kable.
        }
    }
}

server.start()                                     // Suspends until services are published.
server.advertise {                                 // Active while suspended; cancel to stop.
    name = "Example"
    services = listOf(Uuid.service("heart_rate"))
}
server.notify(characteristicOf(serviceUuid, characteristicUuid), data)

Characteristic properties are inferred from declared behavior (value/onReadread, onWritewrite/writeWithoutResponse, onSubscriptionnotify/indicate). Requests are rejected by throwing GattErrorException(AttError...) from handlers. The entire public surface is @ExperimentalKableApi.

Platform Supported Notes
Android BluetoothGattServer + BluetoothLeAdvertiser
Apple (iOS/macOS) CBPeripheralManager; advertising limited to local name + service UUIDs; dynamic descriptors unsupported (per Core Bluetooth)
Apple (watchOS) Core Bluetooth peripheral role is unavailable on watchOS (peripheral-role initializers are API_UNAVAILABLE, which also breaks commonized appleMain metadata compilation — so watchOS targets are not configured for this module)
JS / wasmJs Web Bluetooth is client-only
JVM btleplug is central-only

Architecture

Platform engines translate callbacks into a common InboundRequest stream consumed by a common RequestDispatcher, which owns the tricky (and unit-tested) logic:

  • Read offset ("long read") slicing — handlers return full values.
  • Prepared ("long"/queued) write assembly — handlers always receive complete values.
  • CCCD management — auto-attached on Android (reads/writes answered internally, translated to subscribe/unsubscribe); handled by the OS on Apple (subscription events used directly). Never declared by users.
  • Subscription lifecycle — one SubscriptionAction coroutine per subscribed central, with backpressure-aware send (Android: serialized via onNotificationSent; Apple: updateValue + isReadyToUpdateSubscribers).

Also included

  • README section (usage, platform support matrix, permissions documentation — kable-default-permissions intentionally untouched).
  • samples/gatt-server: minimal Compose sample app (heart rate server with simulated bpm slider) + nRF Connect verification steps, with a GATT Server / Android CI workflow (mirrors sensortag workflows).

Testing

  • 44 tests run via :kable-server:testAndroidHostTest (all passing):
    • 31 common tests: DSL validation + RequestDispatcher/GattServerImpl behavior against a FakeServerEngine (offsets, prepared writes/abort, CCCD subscribe/unsubscribe + state reads, subscription coroutine lifecycle, disconnect cleanup, notify routing/validation, start/stop/restart/close state machine, advertise structured-concurrency semantics). These also run against the Apple (native) targets via check on CI.
    • 13 Robolectric tests: Android profile mapping (properties/permissions bitfields, auto-CCCD attachment incl. secure CCCD) and AdvertiseSettings/AdvertiseData mapping.
  • :kable-server:check (incl. ktlint) green locally; CI build job (macos-latest) green — validates Apple target compilation, native test execution, and publishToMavenLocal.
  • Sample app: :app:assembleDebug and :app:check green locally and on CI.
  • Hardware (radio) validation via the sample app + nRF Connect steps documented in samples/gatt-server/README.md has not been performed on-device.

Notes for reviewers

  • Peripheral in kable-core means remote peripheral, so the new module avoids that term: the entry point is GattServer and remote devices are Centrals.
  • kable-server reuses kable-core's public Uuid helpers, Characteristic/characteristicOf, WriteType, and logging { } DSL; kable-core internals (Logger, applicationContext) are intentionally not touched — small internal equivalents live in kable-server.
  • Binary-compatibility-validator: no JVM target in this module, so apiCheck is a no-op for it (klib validation is not enabled repo-wide).
Open in Web Open in Cursor 

cursoragent and others added 8 commits July 18, 2026 03:32
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c896b10. Configure here.

attribute = characteristic.attributeKey,
offset = offset,
respond = { value -> sendResponse(device, requestId, GATT_SUCCESS, offset, value) },
fail = { error -> sendResponse(device, requestId, error.code, offset, null) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android long read double offset

High Severity

Long reads with a non-zero offset return the wrong payload on Android. RequestDispatcher already slices attribute data before invoking the response callback, but ServerCallback still passes the original read offset into BluetoothGattServer.sendResponse, so the stack applies the offset again to an already-truncated byte array.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c896b10. Configure here.

return@launch
}
val delivered = serveWrite(request.central, attribute, handler, value, respond = null, fail = request.fail)
if (!delivered) return@launch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prepared write not atomic

Medium Severity

When a committed prepared-write transaction spans multiple attributes, onExecuteWrite invokes each onWrite handler sequentially. If a later attribute fails assembly, permission checks, or the handler, earlier attributes may already have been written while the ATT response reports failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c896b10. Configure here.

logger.debug { "Central ${request.central.identifier} disconnected" }
val identifier = request.central.identifier
connected.remove(identifier)
preparedWrites.remove(identifier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apple prepared write queue leak

Medium Severity

Prepared-write fragments are stored per central until ExecuteWrite or CentralDisconnected. The Apple engine never emits CentralDisconnected, so an interrupted long write can leave stale fragments that merge into a later transaction from the same central identifier.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c896b10. Configure here.

if (job != null) {
job.cancel() // Cancelling the `advertise` coroutine stops advertising.
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advertising toggle race

Low Severity

Stopping advertising only cancels the job and returns; advertiseJob stays non-null until the coroutine’s finally runs. A quick stop-then-start can hit the stop branch again instead of launching a new advertisement.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c896b10. Configure here.

@twyatt twyatt added the minor Changes that should bump the MINOR version number label Jul 18, 2026 — with Cursor
cursoragent and others added 2 commits July 18, 2026 04:21
…atchOS)

Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
…ce, offset docs

Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Changes that should bump the MINOR version number

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants