Open source / Flutter / Bluetooth

Universal BLE 3.0: safer queues, consistent IDs.

Universal BLE 3.0 is a breaking release: commands queue per device where the platform needs it, device IDs use one case everywhere, and connection errors carry the platform’s native code. It also adds native device pickers, deduplicated scans, and agent skills for the upgrade.

Universal BLE’s Bluetooth symbol inside a blue ring on a layered isometric tile.

Universal BLE 3.0 is a breaking release of the Flutter Bluetooth plugin we use in BT Cam and Cinema Slate. It takes three platform differences out of app code: how many commands a Bluetooth stack tolerates at once, which case a device ID uses, and how a failed connection reports its error.

Install it from pub.dev, or read the changelog first. Six changes affect existing code, and the migration list at the end covers them.

Queues follow the platform

QueueType.auto is now the default for the central and peripheral APIs. Android, Web, and Linux queue commands per device, because their Bluetooth stacks reject or misbehave on overlapping operations. Every other platform runs them in parallel. An exhaustive switch over QueueType needs the new auto case, which the compiler will flag.

Device IDs arrive in one case

Scan results, connection and value callbacks, pairing events, and peripheral streams now emit lower-case device IDs on every platform. Android reported upper-case MAC addresses while Windows reported lower case, so an ID stored on one platform did not match the same device on another. Web keeps its IDs verbatim, because they are opaque browser tokens rather than addresses.

Peripheral mode emits central IDs in lower case as well. UniversalBle.canonicalDeviceId() returns the comparable form for the current platform, for map keys and for IDs saved from 2.x.

Errors travel with the event

onConnectionChange now receives one BleConnectionChange instead of three positional arguments. It carries the device ID, the connection state, a human-readable error, a unified errorCode, and the raw nativeErrorCode from CoreBluetooth or Android’s GATT stack. Branch on errorCode. The numeric ranges overlap across platforms, so keep nativeErrorCode for logs.

UniversalBle.onConnectionChange = (BleConnectionChange change) {
  if (change.isConnected) return;
  if (change.errorCode == UniversalBleErrorCode.connectionTimeout) {
    reconnectLater(change.deviceId);
  }
  log('${change.deviceId} dropped: ${change.errorCode} / ${change.nativeErrorCode}');
};

connectionChangeStream and BleDevice.connectionChangeStream deliver the same updates, and connectionStream is unchanged. onValueChange takes one BleCharacteristicValue with the value and, where the platform reports one, a timestamp.

MTU without a guessed answer

requestMtu now discovers services on demand on iOS, macOS, and Linux. Apple used to report the default ATT MTU of 23 as though it had been negotiated, and Linux failed with operationNotSupported; both now return the negotiated value. BleCapabilities.mtuRequiresServiceDiscovery tells you when discovery comes first, and a call that has to discover can take up to roughly twice its timeout.

Pickers, scans, and data helpers

requestDevice opens the platform’s own chooser. On Web that is the browser’s Bluetooth picker, which needs a user gesture and honors the scan filter. On iOS 18 and later it is the AccessorySetupKit picker, configured with AppleAccessorySetupOptions, where one call can offer several accessories; the app needs matching Info.plist declarations, or Apple terminates it. On the same versions, unpair removes an AccessorySetupKit accessory instead of failing. Other platforms throw notSupported.

final device = await UniversalBle.requestDevice();
await UniversalBle.connect(device.deviceId);

skipDuplicates reports each device once per scan session, including when its advertisement payload changes, until the scan restarts; it is off by default. iOS, macOS, Android, and Linux filter duplicates natively, and the Dart layer deduplicates everywhere. ScanFilter.connectableOnly and BleDevice.connectable handle connectable-only filtering. Windows now reports non-connectable devices, enables extended advertising, and delivers scan responses, so peripherals that advertise their service UUID only in the scan response are no longer invisible.

Uint8List and Future<Uint8List> gain conversions for integers at 8, 16, and 32 bits, float32 and float64, plus toUtf8, toHexString, byte offsets, and a byteData view. In peripheral mode, subscribedCentrals and subscribedCentralsStream report which centrals subscribed to a characteristic.

Fixes behind the API

Several of the fixes below started as issue reports from users.

Android reuses an MTU already negotiated on the connection and reports every failed bond rather than only the first. discoverServices takes AndroidDiscoverServicesOptions with clearGattCache: true through platformConfig, for peripherals whose GATT layout changed without a Service Changed indication, which is common during firmware work.

iOS and macOS handle write-without-response backpressure and complete concurrent reads, descriptor operations, notification changes, and RSSI reads one callback at a time. onPairingStateChange and pairingStateStream now fire on iOS, macOS, and Web. The 0.12.0 changelog promised them, but they were never wired; pair() drives them, not a system event.

The GATT client cache is kept per Flutter engine instead of per process, so a connection from a new engine cannot reuse a client from a destroyed one and report connected with no services. Windows disposes disconnected GATT objects on a background thread, retries transient discovery failures, and no longer crashes when the app closes while Bluetooth is starting.

Migrating from 2.x

Six changes break existing code:

  • QueueType.auto is the default, and an exhaustive switch over QueueType needs the new case.
  • Device IDs arrive in lower case. Lower-case stored IDs and map keys.
  • onConnectionChange takes one BleConnectionChange.
  • onValueChange takes one BleCharacteristicValue.
  • BleConnectionParametersUpdated is renamed to BleConnectionParametersChange.
  • On iOS 18 and later, unpair removes AccessorySetupKit accessories instead of failing. Devices not managed by AccessorySetupKit now fail with deviceNotFound.

The package also ships agent skills, installed with dart run skills@ get -p universal_ble: universal-ble-setup for adding Bluetooth to an app, universal-ble-migrate-2-to-3 for this upgrade, and universal-ble-migrate-from-flutter-blue-plus for projects coming from flutter_blue_plus.

Point releases since 3.0 add service-data scan filters and align ATT error reporting across platforms, so upgrade to the latest 3.x. Building a product that talks to hardware? Explore our connected-device expertise or discuss your project.

All news & insights
Opens in a new tab