A cross-platform BLE (Bluetooth Low Energy) library for Rust, providing both Central and Peripheral roles with platform backends for Apple (CoreBluetooth via objc2), Linux (BlueZ via bluer), and Android (JNI + Kotlin).
blew was extracted from iroh-ble-transport (local clone: ~/git/iroh-ble-transport) — that project is the primary driver for the API shape, L2CAP focus, and cross-platform requirements. When in doubt about a design decision, check what iroh-ble-transport needs. Many of the quirks handled here (e.g., post-connect MTU stability, L2CAP ergonomics) came directly from issues encountered there.
- L2CAP is a first-class transport, not an edge feature.
- Designed to run with as many concurrent L2CAP channels as the device and OS will allow.
- Backend-owned transports with explicit close/shutdown. Shared event-loop / reactor threads (Apple
L2capReactor, Linux BlueZ, Android Kotlin coroutines) instead of per-channel worker threads. When a short-term fix is tempting, bias toward the reactor-shaped architecture anyway.
Uses mise for task management. Run mise tasks for the full list.
mise run build # build all crates
mise run test # run all tests (nextest)
mise run lint # clippy
mise run fmt # format
mise run fmt:check # check formatting
mise run deny # license/vulnerability audit
cargo run --example scan -p blew # scan for 10s
cargo run --example advertise -p blew # advertise GATT service- No comments unless the logic is non-obvious. Don't add doc comments to code you didn't change.
- Don't add features, refactor, or "improve" code beyond what was asked.
- Clippy pedantic is enabled (
pedantic = "warn"in blew). Fix warnings, don't suppress them unless there's a good reason. - Test with nextest:
cargo nextest run --workspace. - Update
CHANGELOG.mdalongside user-visible changes. New entries go under## [Unreleased](creating that heading if missing) in theAdded/Changed/Removed/Fixedbuckets. Breaking changes must also include a before/after snippet in the upgrade guide at the bottom of the file. Pure-internal refactors that don't alter public API or observable behavior can skip the changelog.
| Crate | Role |
|---|---|
objc2 / objc2-core-bluetooth |
Apple backend (CoreBluetooth) |
bluer 0.17 |
Linux backend (BlueZ D-Bus bindings) |
jni 0.22 |
Android backend (JNI bridge) |
tokio 1 |
Async runtime |
crates/blew/src/
├── lib.rs # pub use re-exports; top-level doc example
├── error.rs # BlewError (typed enum), BlewResult<T>
├── types.rs # DeviceId (Display + as_str()), BleDevice
├── testing.rs # In-memory mock backends (feature = "testing")
├── gatt/
│ ├── props.rs # CharacteristicProperties, AttributePermissions (bitflags)
│ └── service.rs # GattService, GattCharacteristic, GattDescriptor
├── central/
│ ├── mod.rs # Central<B> (default B = PlatformCentral)
│ ├── types.rs # CentralEvent, ScanFilter, WriteType
│ └── backend.rs # CentralBackend sealed trait (RPITIT, no async_trait)
├── peripheral/
│ ├── mod.rs # Peripheral<B> (default B = PlatformPeripheral)
│ │ # + state_events() / take_requests() accessors
│ ├── types.rs # PeripheralStateEvent (Clone), PeripheralRequest (!Clone),
│ │ # ReadResponder, WriteResponder, AdvertisingConfig
│ └── backend.rs # PeripheralBackend sealed trait
├── l2cap/
│ ├── mod.rs # L2capChannel (AsyncRead + AsyncWrite) with close hook
│ └── types.rs # Psm(u16) newtype
├── platform/
│ ├── mod.rs # #[cfg] type aliases: PlatformCentral, PlatformPeripheral
│ ├── apple/
│ │ ├── central.rs # AppleCentral — full CoreBluetooth implementation
│ │ ├── peripheral.rs # ApplePeripheral — full CoreBluetooth implementation
│ │ └── l2cap.rs # L2capReactor — single-thread NSRunLoop for all channels
│ ├── linux/
│ │ ├── central.rs # LinuxCentral — full bluer/BlueZ implementation
│ │ ├── peripheral.rs # LinuxPeripheral — full bluer/BlueZ implementation
│ │ └── l2cap.rs # bluer::l2cap::Stream → L2capChannel bridge
│ └── android/
│ ├── mod.rs # Exports + init_jvm re-export
│ ├── jni_globals.rs # OnceLock<JavaVM>, init_jvm(), jvm()
│ ├── central.rs # AndroidCentral — JNI bridge to BleCentralManager.kt
│ ├── peripheral.rs # AndroidPeripheral — JNI bridge to BlePeripheralManager.kt
│ ├── l2cap_state.rs # Global L2CAP channel map + JNI data-path bridges
│ └── jni_hooks.rs # #[unsafe(no_mangle)] extern "C" JNI callbacks
└── util/
├── event_stream.rs # EventStream<T,S> + BroadcastEventStream<T> (lag-swallowing)
└── request_map.rs # RequestMap<V> / KeyedRequestMap<K,V>
crates/blew/android/ # Co-located Kotlin/Gradle module for the Android backend
├── src/main/java/org/jakebot/blew/ # BleCentralManager.kt, BlePeripheralManager.kt,
│ # GattOperationQueue.kt, L2capSocketManager.kt,
│ # BlewPlugin.kt (Tauri entry point)
└── AndroidManifest.xml # Runtime permission declarations (merged into host app)
// Both roles are independent; use only what you need.
let central: Central = Central::new().await?; // explicit type required — see below
let peripheral: Peripheral = Peripheral::new().await?;
let mut events = central.events(); // returns an impl Stream of CentralEvent
use tokio_stream::StreamExt as _;
while let Some(ev) = events.next().await { /* ... */ }
// Peripheral events are split by kind:
let mut state = peripheral.state_events(); // Clone, broadcast fan-out
let mut requests = peripheral.take_requests() // single-consumer; None on 2nd call
.expect("requests already taken");let central: Central is required. Rust's default type-parameter inference does not kick in for method calls; without the explicit annotation the compiler fails with E0283. Same for Peripheral.
Threading model:
- Each manager (
CBCentralManager,CBPeripheralManager) is initialized with a dedicated GCD serial queue viainitWithDelegate_queue(Some(&queue)). - All CB delegate callbacks fire exclusively on that queue.
- Tokio tasks call CB methods directly from the thread pool; CoreBluetooth is documented thread-safe on macOS 10.15+ / iOS 13+.
- Results flow back to Tokio via
tokio::sync::oneshotchannels (set in the delegate callback, awaited in the async method).
Key patterns:
// ObjcSend<T> — asserts Send+Sync for Retained<T> when using a GCD queue.
struct ObjcSend<T: objc2::Message>(Retained<T>);
unsafe impl<T: objc2::Message> Send for ObjcSend<T> {}
unsafe impl<T: objc2::Message> Sync for ObjcSend<T> {}
// retain_send — retain a CB object and wrap it for cross-thread use.
unsafe fn retain_send<T: objc2::Message>(obj: &T) -> ObjcSend<T> {
ObjcSend(Retained::retain(obj as *const T as *mut T).expect("retain"))
}
// Avoid holding Retained<T> (non-Send) across .await points.
// Pattern: do all ObjC work in a synchronous block, capture only the rx end.
let rx = {
let peripheral = ...get ObjcSend<CBPeripheral>...;
let (tx, rx) = oneshot::channel();
// ... ObjC calls ...
rx
}; // peripheral drops here — before .await
rx.await...objc2 0.6 traits:
use objc2::AnyThread— providesalloc()for both user-defined classes (viadefine_class!) and external CB classes when initialized with a custom queue. (AllocAnyThreadis a deprecated alias for the same trait.)use objc2::DefinedClass— providesivars()insidedefine_class!method bodies.- Both imports are required; missing either gives "no method found" errors.
RAII responders: peripheralManager:didReceiveReadRequest: and didReceiveWriteRequests: build a ReadResponder/WriteResponder (backed by an oneshot::Sender), emit a PeripheralRequest on the mpsc::UnboundedSender handed out by take_requests(), then spawn a task (via inner.runtime.spawn()) that awaits the oneshot and calls respondToRequest:withResult:. The spawn uses the captured Handle because GCD callbacks run outside the Tokio runtime context — bare tokio::spawn would panic. All Rust-side synchronization uses parking_lot::Mutex (poison-free, faster than std::sync::Mutex).
L2CAP reactor (platform/apple/l2cap.rs): one dedicated OS thread owns an NSRunLoop and all NSInputStream/NSOutputStream objects. Channels register via ReactorCmd::Register { id, channel_ref, input, output, inbound_tx }, writes go via ReactorCmd::Write, close via ReactorCmd::Close. Bytes flow Reactor→App through mpsc::UnboundedSender<Vec<u8>>; App→Reactor through a tokio::io::duplex + outbound bridge task. No per-channel threads.
L2CAP close invariant: exactly one ReactorCmd::Close per channel. DuplexTransport::trigger_close uses Option::take on the close hook so .close().await and Drop both route to the same single-fire path. Do not add defensive ReactorCmd::Close sends from the bridge tasks — the hook is the only sender.
L2CAP accept channel policy:
- Apple:
mpsc::unbounded_channel(). Blocking the GCD delegate queue onblocking_sendwould stall every subsequent CB callback (disconnects, restore, etc.). - Android:
mpsc::unbounded_channel().try_sendon a bounded channel would silently drop incoming L2CAP connections. - Linux:
mpsc::channel(16)+send().await. Backpressure flows into BlueZ's kernel-side socket accept queue, which is the right place to cap.
All Central event streams and Peripheral state_events() streams use
tokio::sync::broadcast::channel(256) wrapped in util::BroadcastEventStream.
The wrapper silently drops Lagged(n) errors so slow subscribers miss events
but stay connected. Don't introduce new custom fan-out utilities — use
broadcast::Sender directly (it's Clone + Sync; no external Mutex needed).
Buffer depth is 256 across every role/backend; keep it uniform unless there's
a specific reason to diverge. Backend-emitted send() calls should be
let _ = tx.send(event); because broadcast returns Err(SendError) when
there are zero subscribers (normal at startup and for apps that don't need events).
Central::take_restored() and Peripheral::take_restored() are not on the
sealed CentralBackend / PeripheralBackend traits. They live as inherent
methods on Apple-only impl blocks:
#[cfg(target_vendor = "apple")]
impl Peripheral { pub fn take_restored(&self) -> Option<Vec<Uuid>> { … } }Mocks get their own impl Peripheral<MockPeripheral> block in testing.rs.
Non-Apple backends have no stub method. Any code that calls take_restored()
on a shared cross-platform path must #[cfg(target_vendor = "apple")]-gate the
call. restore_identifier on PeripheralConfig / CentralConfig remains
cross-platform (inert on non-Apple) so with_config can be called unconditionally.
Android has no willRestoreState: equivalent. If background scan survival
becomes a requirement, the natural surface is a separate PendingIntent-backed
API (see Android's BLE background guide), not an extension of take_restored.
Static value + Write property = NSInvalidArgumentException → SIGABRT.
If GattCharacteristic.value is non-empty, CoreBluetooth treats the characteristic as static and throws if the characteristic also has the Write property. Use value: vec![] for any characteristic that needs to be writable; the app handles reads via PeripheralRequest::Read.
GattCharacteristic {
properties: CharacteristicProperties::READ | CharacteristicProperties::WRITE,
permissions: AttributePermissions::READ | AttributePermissions::WRITE,
value: vec![], // MUST be empty for writable characteristics
..
}Uses bluer 0.17 (official BlueZ Rust bindings over D-Bus).
Threading model: bluer is async-native (tokio). All calls go through the tokio runtime; no GCD queues or spawn_blocking needed. Session::new().await + session.default_adapter().await in new().
Key patterns:
- Scan:
adapter.discover_devices().await?returnsimpl Stream<Item = AdapterEvent>. Must beBox::pin-ned before iterating since the concrete type may not beUnpin.AdapterEventhas onlyDeviceAdded(Address)andDeviceRemoved(Address)variants. - CharacteristicFlags:
ch.flags().await?returns aCharacteristicFlagsstruct with bool fields (.read,.write,.notify, etc.), not an enum orHashSet. Access fields directly. - Write Command vs Write Request: Remote
Characteristichaswrite()(D-Bus, Write Request/response) andwrite_io()(kernel socket fd, Write Command/no-response). Usewrite_io()forWriteType::WithoutResponseto avoid D-Bus latency;write()forWriteType::WithResponse. - Notifications:
ch.notify_io().await?returnsCharacteristicReader: AsyncRead. Spawn a task reading chunks. - GATT server callbacks:
CharacteristicRead { fun: Box<dyn Fn(CharacteristicReadRequest) -> Pin<Box<dyn Future<Output = ReqResult<Vec<u8>>> + Send>>> }. The future IS the ATT response — the server waits for it to complete. Bridge toReadResponder/WriteResponderviaoneshot::channel. CharacteristicNotifier: Received viaCharacteristicNotifyMethod::Funcallback when a client subscribes. Store asArc<tokio::sync::Mutex<CharacteristicNotifier>>(not std Mutex) sonotifier.notify(value).awaitdoesn't hold a MutexGuard across an await point.notifier.notify(value: Vec<u8>)takes ownership.- L2CAP server:
bluer::l2cap::StreamListener::bind(SocketAddr::any_le())gets dynamic PSM.listener.as_ref().local_addr()?.psmreads it back. - L2CAP client:
device.address_type().await?forbluer::AddressType, thenbluer::l2cap::Stream::connect(SocketAddr::new(addr, addr_type, psm)). Add ~200ms delay after ACL connect before L2CAP CoC setup. - L2CAP bridging:
bluer::l2cap::StreamimplementsAsyncRead + AsyncWritedirectly — just twotokio::io::copytasks into atokio::io::duplex. Much simpler than Apple.
bluer API gotchas:
Service,Characteristic,Applicationdo not deriveDefault— construct them field-by-field.ServiceControlHandle::default()andCharacteristicControlHandle::default()exist and are used for thecontrol_handlefields.CharacteristicWrite.write= Write Request (with response);CharacteristicWrite.write_without_response= Write Command (no response). The doc comment onwritesaying "Write Command" is incorrect — trustset_characteristic_flagswhich maps directly toCharacteristicFlags.write= BlueZ "write" property = Write Request.WriteOp::RequestinCharacteristicWriteRequest.op_typeindicates a Write Request needing a response.
Uses jni 0.22 and ndk-context 0.1. The Android BLE API is Java/Kotlin-only, so the backend bridges Rust ↔ Kotlin via JNI.
Architecture:
- Kotlin singletons (
BleCentralManager,BlePeripheralManager) live incrates/blew/android/(co-located with the Rust JNI hooks so they link-time version together). They wrapBluetoothLeScanner,BluetoothGatt,BluetoothGattServer, andBluetoothLeAdvertiser. - Rust → Kotlin:
jvm().attach_current_thread()thencall_static_methodon Kotlin object singletons. - Kotlin → Rust:
@JvmStatic external fundeclarations in Kotlin, implemented as#[unsafe(no_mangle)] extern "C"injni_hooks.rs.
Classloader gotcha: Rust background threads use the system classloader, which cannot find APK classes. init_jvm() caches GlobalRefs to both Kotlin classes on the main thread (which has the app classloader). All JNI calls use central_class() / peripheral_class() from jni_globals.rs instead of string class names.
Threading model: Android BLE callbacks arrive on Binder threads. JNI hooks push events into tokio mpsc channels and return immediately. Rust async code awaits these channels. JNI AttachGuard is NOT Send — always drop it in a block before any .await point:
// Correct pattern — env drops before await:
{
let mut env = jvm().attach_current_thread()?;
env.call_static_method(...)?;
} // env dropped here
rx.await?; // safe to await nowGlobal state: Module-level OnceLock statics store event channels and pending operation maps. Only one Bluetooth adapter exists on Android so singletons are correct.
AndroidCentral: uses atokio::sync::broadcastchannel (central events areClone; wrapped inBroadcastEventStreamso slow-subscriber lag is swallowed) +KeyedRequestMap<oneshot::Sender>for async request/response coupling. A per-device Kotlin coroutine queue serializes GATT ops (replacing the old adapter-wide semaphore) so a slow peer can't block others.AndroidPeripheral: state events fan out throughtokio::sync::broadcast(PeripheralStateEventisClone). GATT reads/writes are delivered asPeripheralRequestover anmpsc::UnboundedSender, handed out once viatake_requests(). For each request, a tokio task awaits the responder's oneshot then calls KotlinrespondToRead/respondToWritevia JNI. All Rust-side synchronization usesparking_lot::Mutex.
JNI data marshalling: Complex data (GATT services, UUID lists) passed as flat arrays or JSON strings to avoid complex JNI type construction. Service characteristics use parallel arrays (uuids, properties, permissions, values).
L2CAP: Implemented via L2capSocketManager.kt — JNI hooks bridge BluetoothServerSocket/BluetoothSocket to Rust L2capChannel (AsyncRead + AsyncWrite). Global state in l2cap_state.rs.
Auto MTU negotiation: BleCentralManager.kt calls requestMtu(512) automatically after connecting.
Connect reliability invariants (all three backends, Android load-bearing):
CentralConfig::connect_timeout: Option<Duration>boundsconnect(). TheDefaultis 15s. On elapse every backend returnsBlewError::ConnectTimedOutand emitsCentralEvent::DeviceDisconnected { cause: Timeout }.Nonerestores pre-0.3 unbounded behavior.BlewError::Timeoutis now reserved for adapter-readiness waits (wait_ready/wait_powered) and is not used by the connect path. Keep it that way — splitting the two lets callers match precisely.- Overlapping
connect()on the same device is rejected withBlewError::ConnectInFlight(DeviceId)on Apple and Android (built onKeyedRequestMap::try_insert). Linux relies on bluer's own state machine. Do not reintroduce "latest wins" eviction — it silently orphans the first caller's oneshot. - Android
disconnect()awaitsonConnectionStateChange(DISCONNECTED)with a 2s fallback. On fallback it calls KotlinforceClose(addr)which synchronously removes the handle fromgattConnections, invokes the hiddenrefresh(), and callsgatt.close(). This prevents leaking client-IF slots (Android caps at ~7). Do not revert to fire-and-forget disconnect. - Status-133 zombie handling lives in Kotlin: on
onConnectionStateChange(DISCONNECTED, status=133)the backend callsrefresh()beforeclose()to flush the client-side service cache.BleCentralManager.connect()'s stale-cleanup path does the same and thendelay(300)before the nextconnectGatt()— back-to-backconnectGattattempts to the same address can be silently dropped by some vendor stacks.
A thin Tauri 2 integration wrapper. The Kotlin BLE classes now live in crates/blew/android/; this crate just points Tauri's Gradle build at that path and initializes the JNI bridge.
Rust side (src/lib.rs): On Android, stores the JVM reference in blew's OnceLock via blew::platform::android::init_jvm(), then registers the Android plugin. build.rs computes the path to ../blew/android and passes it to tauri_plugin::Builder::android_path().
Kotlin side (lives at crates/blew/android/src/main/java/org/jakebot/blew/):
BlewPlugin.kt—@TauriPlugin, initializes BLE managers, requests runtime permissions (BLUETOOTH_SCAN/CONNECT/ADVERTISE on Android 12+; ACCESS_FINE_LOCATION on pre-12 only).BLUETOOTH_SCANis declared withneverForLocationso scan results are delivered regardless of the OS-level Location Services toggle.BleCentralManager.kt— Singleton wrapping scanner + GATT client.ScanCallback→nativeOnDeviceDiscovered.BluetoothGattCallback→nativeOnConnectionStateChanged,nativeOnServicesDiscovered,nativeOnCharacteristicRead/Write/Changed,nativeOnMtuChanged.BlePeripheralManager.kt— Singleton wrapping GATT server + advertiser.BluetoothGattServerCallback→nativeOnReadRequest,nativeOnWriteRequest,nativeOnSubscriptionChanged,nativeOnConnectionStateChanged,nativeOnAdapterStateChanged.
AndroidManifest.xml: Declares BLE permissions with maxSdkVersion guards. These merge into the app manifest automatically via Gradle.
Usage in a Tauri app:
// Cargo.toml: [target.'cfg(target_os = "android")'.dependencies]
// tauri-plugin-blew = { git = "https://github.com/mcginty/blew.git" }
#[allow(unused_mut)]
let mut builder = tauri::Builder::default();
#[cfg(target_os = "android")]
{ builder = builder.plugin(tauri_plugin_blew::init()); }cargo run --example scan -p blew # scan for 10 s, print discoveries
cargo run --example advertise -p blew # advertise a GATT service, handle reads/writes
cargo run --example l2cap_server -p blew # peripheral: publish L2CAP CoC, echo data
cargo run --example l2cap_client -p blew # central: scan, connect, open L2CAP, send dataAll use #[tokio::main(flavor = "current_thread")] since the blew tokio dependency only enables the rt (not rt-multi-thread) feature.