Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* **Breaking:** `onValueChange` now receives a single `BleCharacteristicValue` (`deviceId`, `characteristicId`, `value`, `timestamp`) instead of four positional arguments.
* **Breaking:** Rename `BleConnectionParametersUpdated` to `BleConnectionParametersChange`, aligning the payload with the `…Change` event naming. The `onConnectionParametersChange` callback and the model's fields are unchanged.
* **Breaking:** On iOS 18+ `unpair` now removes an accessory authorized through AccessorySetupKit (it previously threw `notSupported` on iOS). Devices not managed by AccessorySetupKit fail with `deviceNotFound`.
* iOS/macOS/Web: `onPairingStateChange` / `pairingStateStream` now fire. These platforms have no native bond API, so `pair()` drives them from its encrypted-characteristic probe: `true` when the probe succeeds, a failure event (`false`) when it throws. Without a `pairingCommand` the probe reads every readable characteristic and now fails if none can be read, rather than resolving successfully. The 0.12.0 changelog claimed this support, but the callback was never actually wired on those platforms until now.
* `requestMtu` now discovers the device's services on demand on iOS/macOS and Linux, instead of returning a misleading MTU (Apple reported the default ATT MTU, 23, as if it were the negotiated value) or failing with an unclear `operationNotSupported` (Linux). It is a no-op when the services were already discovered, so calling `discoverServices` first still works and is not required, and concurrent calls (including one racing a `discoverServices`) share a single discovery. `timeout` bounds each step, so a call that must discover first can take up to roughly twice `timeout`. Android and Windows read the MTU off the connection itself and are unchanged. Add `BleCapabilities.mtuRequiresServiceDiscovery`.
* Add `requestDevice`, a single entry point for the platform's native device picker: the browser's Bluetooth chooser on Web and the iOS 18+ AccessorySetupKit picker on Apple (configured via `PickerPlatformConfig.apple`), returning the selected `BleDevice`. Connect it with the existing `connect(device.deviceId)`. `AppleAccessorySetupOptions` holds a list of `AppleAccessoryPickerItem`s, so the picker can offer several accessories. Platforms without a native picker throw `notSupported`.
* Windows: enable extended BLE advertisement scanning (`AllowExtendedAdvertisements`), with graceful fallback on unsupported OS versions or adapters.
Expand Down
60 changes: 36 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,51 +40,61 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d

## API Support

**Legend:** ✔️ supported, — not supported, ⏺ partial support (see notes), 🚧 work in progress.

### Client Mode (`UniversalBle`)

| | Android | iOS | macOS | Windows | Linux | Web |
| :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: |
| startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| autoConnect | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ |
| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ |
| autoConnect | ✔️ | ✔️ | ✔️ | — | — | — |
| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — |
| discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ |
| unpair | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ |
| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺¹ |
| unpair | ✔️ | ⏺² | — | ✔️ | ✔️ | — |
| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ |
| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ |
| enable/disable Bluetooth | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ |
| onPairingStateChange | ✔️ | ⏺³ | ⏺³ | ✔️ | ✔️ | ⏺³ |
| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — |
| enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — |
| onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ |
| requestConnectionPriority | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ |
| onConnectionParametersChange | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ |
| readRssi | ✔️ | ✔️ | ✔️ | ❌ | 🚧 | ❌ |
| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — |
| requestConnectionPriority | ✔️ | — | — | — | — | — |
| onConnectionParametersChange | ✔️ | — | — | — | — | — |
| readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — |
| requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |

¹ On Web, `pair()` depends on the browser's host OS: ConfirmOnly ("Just Works") pairing can be triggered when the browser runs on macOS or Android, but not on Windows or Linux.

² On iOS, `unpair` only works for accessories authorized through AccessorySetupKit; other devices fail with `deviceNotFound`.

³ On iOS, macOS, and Web, events are driven by `pair()`: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated event and `unpair` does not report, so read the current state with `isPaired(pairingCommand:)`.

### Peripheral Mode (`UniversalBlePeripheral`)

| API | Android | iOS | macOS | Windows | Linux | Web |
| :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: |
| getCapabilities | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| getAvailabilityState\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| getAdvertisingState | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| addService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| removeService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| clearServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| getServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| startAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| stopAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| updateCharacteristicValue\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| getSubscribedClients | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ |
| getCapabilities | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| getAvailabilityState\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| getAdvertisingState | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| addService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| removeService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| clearServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| getServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| startAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| stopAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| updateCharacteristicValue\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| getSubscribedClients | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |
| event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — |

\* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates.

\*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`).

\*\*\* `advertisingStateStream`, `characteristicSubscriptionStream`, `connectionStateStream`, `serviceAddedStream`, `mtuChangedStream`, `subscribedCentralsStream`.

## Getting Started
Expand Down Expand Up @@ -625,6 +635,8 @@ bleDevice.pairingStateStream.listen((bool paired) {
});
```

On `Apple` and `Web`, see note ³ in [API Support](#api-support).

#### Unpair

```dart
Expand Down
98 changes: 70 additions & 28 deletions lib/src/universal_ble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -740,11 +740,15 @@ class UniversalBle {
///
/// On `Apple` and `Web`, it only works on devices with encrypted characteristics.
/// It is advised to pass a pairingCommand with an encrypted read or write characteristic.
/// When not passing a pairingCommand, you should afterwards use [isPaired] with a pairingCommand
/// to verify the pairing state.
/// When not passing a pairingCommand, it reads every readable characteristic and fails if none
/// can be read; afterwards use [isPaired] with a pairingCommand to verify the pairing state.
///
/// On `Web/Windows` and `Web/Linux`, it does not work for devices that use `ConfirmOnly` pairing.
/// Can throw `PairingException`, `ConnectionException` or `PlatformException`.
///
/// On `Apple` and `Web` the outcome is also reported through [pairingStateStream] /
/// [onPairingStateChange]: `true` when the encrypted-characteristic probe succeeds,
/// `false` when it fails.
static Future<void> pair(
String deviceId, {
BleCommand? pairingCommand,
Expand All @@ -766,6 +770,7 @@ class UniversalBle {
await _connectAndExecuteBleCommand(
deviceId,
pairingCommand,
updateCallbackValue: true,
timeout: timeout,
queueId: queueId,
);
Expand Down Expand Up @@ -998,6 +1003,19 @@ class UniversalBle {
);
}

/// Connects (if needed), discovers services and runs a pairing probe: either
/// [bleCommand] on an encrypted characteristic, or a read of every readable
/// characteristic when none is given.
///
/// [updateCallbackValue] reports the probe outcome as a pairing state change:
/// [UniversalBlePlatform.updatePairingState] on success,
/// [UniversalBlePlatform.updatePairingFailure] on failure. [pair] enables it on
/// the platforms without a system pairing API (Apple/Web), where this probe is
/// the only signal that the OS paired the device. [isPaired] runs the same probe
/// as a query and leaves it `false`, so checking the state does not emit.
///
/// Only the probe itself is reported; a failure to connect or discover services
/// happens before it and propagates without emitting a pairing state.
static Future<void> _connectAndExecuteBleCommand(
String deviceId,
BleCommand? bleCommand, {
Expand All @@ -1023,56 +1041,80 @@ class UniversalBle {
);
UniversalLogger.logInfo("Discovered services: ${services.length}");

if (bleCommand == null) {
// Just attempt pairing
await _attemptPairingReadingAll(
deviceId,
services,
timeout: timeout,
queueId: queueId,
);
return;
try {
if (bleCommand == null) {
// Just attempt pairing
final probeSucceeded = await _attemptPairingReadingAll(
deviceId,
services,
timeout: timeout,
queueId: queueId,
);
if (!probeSucceeded) {
throw PairingException("No readable characteristic could be read");
}
} else {
await _executeBleCommand(
deviceId,
services,
bleCommand,
timeout: timeout,
queueId: queueId,
);
}
} catch (_) {
// A failed bond is an event, not a state: report it before surfacing the
// error, matching the native platforms' onPairStateChange(isFailure: true).
if (updateCallbackValue) _platform.updatePairingFailure(deviceId);
rethrow;
}

await _executeBleCommand(
deviceId,
services,
bleCommand,
timeout: timeout,
queueId: queueId,
);
// The probe completed, so the OS paired the device. Apple/Web have no native
// bond-state API, which is why this is the only pairing-state signal there.
if (updateCallbackValue) _platform.updatePairingState(deviceId, true);
}

// Fire and forget, and do not rely on result
static Future<void> _attemptPairingReadingAll(
/// Reads every readable characteristic to trigger pairing, since Apple/Web pair
/// the device when an encrypted characteristic is accessed.
///
/// Returns `true` when at least one read succeeded and `false` when reads were
/// attempted but all failed, so a probe can be reported as a pairing outcome.
/// Individual read failures are ignored, so one unreachable characteristic does
/// not stop the others from being tried. Throws [PairingException] when the
/// device exposes no readable characteristic to probe.
static Future<bool> _attemptPairingReadingAll(
String deviceId,
List<BleService> services, {
Duration? timeout,
String? queueId,
}) async {
bool containsReadCharacteristics = false;
try {
// If BleCommand not given, fallback to reading all characteristics
for (BleService service in services) {
for (BleCharacteristic characteristic in service.characteristics) {
if (characteristic.properties.contains(CharacteristicProperty.read)) {
containsReadCharacteristics = true;
bool readSucceeded = false;

// If BleCommand not given, fallback to reading all characteristics
for (BleService service in services) {
for (BleCharacteristic characteristic in service.characteristics) {
if (characteristic.properties.contains(CharacteristicProperty.read)) {
containsReadCharacteristics = true;
try {
await read(
deviceId,
service.uuid,
characteristic.uuid,
timeout: timeout ?? const Duration(seconds: 30),
queueId: queueId,
);
}
readSucceeded = true;
} catch (_) {}
}
}
} catch (_) {}
}

if (!containsReadCharacteristics) {
throw PairingException("No readable characteristic found");
}

return readSucceeded;
}

static Future<void> _executeBleCommand(
Expand Down
Loading
Loading