Add kable-server module (BLE peripheral role / GATT server) - #1224
Add kable-server module (BLE peripheral role / GATT server)#1224twyatt wants to merge 10 commits into
kable-server module (BLE peripheral role / GATT server)#1224Conversation
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
❌ 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) }, |
There was a problem hiding this comment.
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)
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 |
There was a problem hiding this comment.
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.
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) |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit c896b10. Configure here.
| if (job != null) { | ||
| job.cancel() // Cancelling the `advertise` coroutine stops advertising. | ||
| return | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit c896b10. Configure here.
…atchOS) Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>
…ce, offset docs Co-authored-by: Travis Wyatt <travis.i.wyatt@gmail.com>


Summary
Adds a new
kable-servermodule (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:Characteristic properties are inferred from declared behavior (
value/onRead→read,onWrite→write/writeWithoutResponse,onSubscription→notify/indicate). Requests are rejected by throwingGattErrorException(AttError...)from handlers. The entire public surface is@ExperimentalKableApi.BluetoothGattServer+BluetoothLeAdvertiserCBPeripheralManager; advertising limited to local name + service UUIDs; dynamic descriptors unsupported (per Core Bluetooth)API_UNAVAILABLE, which also breaks commonizedappleMainmetadata compilation — so watchOS targets are not configured for this module)Architecture
Platform engines translate callbacks into a common
InboundRequeststream consumed by a commonRequestDispatcher, which owns the tricky (and unit-tested) logic:SubscriptionActioncoroutine per subscribed central, with backpressure-awaresend(Android: serialized viaonNotificationSent; Apple:updateValue+isReadyToUpdateSubscribers).Also included
kable-default-permissionsintentionally untouched).samples/gatt-server: minimal Compose sample app (heart rate server with simulated bpm slider) + nRF Connect verification steps, with aGATT Server / AndroidCI workflow (mirrors sensortag workflows).Testing
:kable-server:testAndroidHostTest(all passing):RequestDispatcher/GattServerImplbehavior against aFakeServerEngine(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 viacheckon CI.AdvertiseSettings/AdvertiseDatamapping.:kable-server:check(incl. ktlint) green locally; CIbuildjob (macos-latest) green — validates Apple target compilation, native test execution, andpublishToMavenLocal.:app:assembleDebugand:app:checkgreen locally and on CI.samples/gatt-server/README.mdhas not been performed on-device.Notes for reviewers
Peripheralinkable-coremeans remote peripheral, so the new module avoids that term: the entry point isGattServerand remote devices areCentrals.kable-serverreuseskable-core's publicUuidhelpers,Characteristic/characteristicOf,WriteType, andlogging { }DSL;kable-coreinternals (Logger,applicationContext) are intentionally not touched — small internal equivalents live inkable-server.apiCheckis a no-op for it (klib validation is not enabled repo-wide).