Platforms

Bluetooth LE (BLE)

ble:// paths to Nordic UART Service devices behind the ble feature (desktop, iOS, Android).

With the ble cargo feature, ble://<device id, MAC or name> talks to devices that expose the Nordic UART Service (NUS): nRF52 boards, ESP32 BLE-UART sketches, Adafruit Bluefruit, micro:bit. HM-10 / CC254x modules are not NUS (they use service FFE0 with one characteristic FFE1) and are not supported.

tauri-plugin-serialplugin = { version = "4", features = ["ble"] }
const found = await SerialPort.scanBlePorts({ timeoutMs: 4000 });
// { "ble://<id>": { type: "BLE", product: "MyBoard", rssi: "-60", … } }
const port = new SerialPort({ path: Object.keys(found)[0], baudRate: 115200 });
await port.open();
  • Writes go to the NUS RX characteristic in chunks of MTU − 3 bytes (at most 512, the largest attribute value); TX notifications (or indications) become read data. When RX allows both write types, writes go without response and every 8th chunk with response, so the device can slow the sender down. A lost link ends in onDisconnect.
  • scanBlePorts() lists devices that advertise the NUS service UUID. open() by name or address scans for any device, so a board that does not advertise the UUID can still be opened by name; the service is checked after connecting.
  • Port ids: ble://<MAC> on Linux, Windows and Android (any case); macOS / iOS hide the MAC and give a per-host UUID (ble://5D2A…), which only that Mac knows. An advertised name works everywhere. On desktop / iOS an id or MAC match always wins and is taken at once; a name is used only when one device has it: after the first match the scan goes on for 1.5 s (within the 10 s find timeout), and if a second device with that name shows up, open() fails with an error listing their ble://<id> paths instead of picking one; open the one you want by id.
  • Timeout (desktop / iOS): connect, service discovery and enabling notifications must finish within 20 s (CoreBluetooth would otherwise try to connect forever); finding the device has its own 10 s. Each write (one MTU chunk) must finish within 5 s, else write() fails with a timeout instead of blocking the port; the link is then dropped (a late completion of that write could otherwise be taken for the next one), so the port ends with onDisconnect and is reopened like after link loss. If Bluetooth is off or the app is not authorized, open() and scanBlePorts() fail with a clear error.
  • macOS / iOS: add NSBluetoothAlwaysUsageDescription to src-tauri/Info.plist (macOS) and src-tauri/Info.ios.plist (iOS). Without it the OS kills the app on the first Bluetooth call. The example app has both.
  • Linux: BlueZ, and libdbus-1-dev to build.
  • Android: see below.
  • Baud rate, parity and modem lines mean nothing over BLE and are only stored.

Android

Android uses its own BluetoothGatt bridge in the plugin's Kotlin code (btleplug is not built there), behind the same ble feature and the same API:

  • scanBlePorts() returns ble://<MAC> paths (e.g. ble://C4:4F:33:12:AB:01); open() also accepts ble://<advertised name>. A name is resolved from earlier scans of this app run, otherwise with a short scan (up to 10 s).
  • open("ble://<MAC>") for a device that is not bonded and was not seen in a scan of this app run first scans for that address (up to 5 s). Most nRF / ESP32 boards use a random static address and Android cannot connect to it until it has seen it.
  • Timeout: one open() has a 20 s budget for everything: the scan, connect (with one retry on the transient GATT errors 133, 62 and 129), service discovery, the MTU request, enabling notifications and pairing. Each step gets at most its own limit (discovery 10 s, MTU 3 s, notifications 5 s) and never more than what is left.
  • On connect the plugin waits 1.6 s before service discovery when the device is bonded (its Service Changed indication comes first), requests MTU 247 (Android 14+ always uses 517) and a high connection priority, enables notifications (or indications) on the NUS TX characteristic and sends writes one at a time, in order. If the cached GATT table has no NUS (a reflashed board), it is refreshed once and discovered again. A Service Changed indication on an open port ends it with onDisconnect. If a chunk fails after earlier chunks went out, write returns the bytes sent so far (so a write_all loop does not resend them); it fails only when nothing went out.
  • Pairing: if the device refuses notifications until the link is encrypted (GATT status 5, 15 or 137), the plugin starts pairing (Android shows its prompt) and waits for it within the open() budget, then retries once. If pairing does not finish in time, open() fails with … requires pairing …; pair the device and open again.
  • Bluetooth switched off while a port is open closes the link and fires onDisconnect ("Bluetooth turned off"). Opening or scanning with Bluetooth off fails with Bluetooth is off.
  • Scan limit: Android lets an app start at most 5 scans per 30 s and then silently returns nothing. The plugin counts its own scans (scanBlePorts(), name and address lookups) and fails fast with BLE scan throttled … retry in N s instead of an empty result. Connecting by a name or MAC seen in an earlier scan needs no new scan.
  • Receive buffer: notifications wait in a per-port queue of up to 1 MiB of unread bytes; bytesToRead() counts it. Past that, new notifications are dropped (with one warning in the log) until the app reads again. The plugin's read loop normally drains it at once.
  • Android 12 and older: the platform passes notification data through a shared characteristic object, so a peripheral that notifies very fast can have bytes lost or repeated. This is an Android bug fixed in Android 13 and cannot be worked around here; add flow control or framing with a checksum in the firmware if that matters.
  • The first scanBlePorts() / open() asks for the runtime permission: Nearby devices (BLUETOOTH_SCAN + BLUETOOTH_CONNECT) on Android 12+, location (ACCESS_FINE_LOCATION) on Android 11 and older. If the user declines, the call fails with Bluetooth permission denied (…); after "don't ask again" only the app settings can grant it. When the app is in the background there is no dialog and the call fails at once with … the app is in the background.
  • Location services (the system Location switch) must be on for BLE scans on Android 11 and older; otherwise the scan fails with Location services are off ….
  • The emulator has no Bluetooth radio: test on a phone.

Manifest

The plugin's library manifest declares no Bluetooth or location permission (only <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />, which keeps the app installable on devices without BLE). An app built without the ble feature gets no new permissions.

With the ble feature, the plugin's build.rs writes this block into the app's src-tauri/gen/android/app/src/main/AndroidManifest.xml when the Tauri CLI builds for Android (between <!-- SERIALPLUGIN BLE. AUTO-GENERATED. DO NOT REMOVE. --> markers; dropping the feature empties the block again):

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

A permission the app already declares itself (outside the block) is left out of the block, so the app's own line is kept exactly as written:

  • Need location on every Android version (GPS, maps)? Declare ACCESS_FINE_LOCATION yourself without maxSdkVersion.
  • Scan for beacons to derive location? Declare BLUETOOTH_SCAN yourself without neverForLocation. The plugin then also asks for ACCESS_FINE_LOCATION on Android 12+ (declare it too) and needs Location services on.
  • Do not want one of them at all? Declare it with tools:node="remove" (xmlns:tools="http://schemas.android.com/tools" on <manifest>).

The block lands in the app manifest, which has the highest merge priority, so its maxSdkVersion="30" also caps the same permission declared by another library (for example a geolocation plugin). If another library needs location on Android 12+, declare ACCESS_FINE_LOCATION in the app manifest yourself as above.

If the permissions are missing (for example the app was built with Gradle directly, not through the Tauri CLI, so build.rs could not find the app manifest), open() and scanBlePorts() fail with Bluetooth permission … is not declared in the app's AndroidManifest.xml.