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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## 2.2.0
* Lower minimum Dart SDK to 3.3 (Flutter 3.19+) to restore compatibility with older stable Flutter releases
* Add `BleCommandQueue` support to `UniversalBlePeripheral` (`queueType`, `timeout`, `clearQueue`, and `onQueueUpdate`)
* iOS/macOS: Handle CoreBluetooth peripheral transmit queue exhaustion and error handling in `updateCharacteristicValue`
* Add Descriptor Read and Write APIs
* Windows: Harden BLE connection lifetime, asynchronous callbacks, and notification subscription handling
* Android: instantly close the GATT client on disconnect
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,26 @@ UniversalBle.clearQueue('customQueueId');
UniversalBle.clearQueue();
```

### Peripheral Command Queue

`UniversalBlePeripheral` supports the same queueing configuration (`queueType`, `timeout`, `clearQueue`, and `onQueueUpdate`) for peripheral commands (e.g. `addService`, `startAdvertising`, `updateCharacteristicValue`):

```dart
// Configure peripheral command queue (defaults to QueueType.global)
UniversalBlePeripheral.queueType = QueueType.perDevice;

// Clear peripheral queue
UniversalBlePeripheral.clearQueue(deviceId);

// Send peripheral updates with a specific queueId
UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: charUuid,
value: data,
deviceId: deviceId,
queueId: 'customQueueId',
);
```

## Timeout

By default, all commands have a global timeout of 10 seconds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,11 @@ class UniversalBlePeripheralPlugin(
characteristic.value = value
val targetDevices = synchronized(bluetoothDevicesMap) {
if (deviceId != null) {
listOf(bluetoothDevicesMap[deviceId] ?: throw Exception("Device not found"))
val dev = bluetoothDevicesMap[deviceId]
?: bluetoothDevicesMap[deviceId.uppercase()]
?: bluetoothDevicesMap[deviceId.lowercase()]
?: throw Exception("Device not found")
listOf(dev)
} else {
Comment thread
fotiDim marked this conversation as resolved.
bluetoothDevicesMap.values.toList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Foundation

enum UniversalBlePeripheralError: Error {
case notFound(String)
case failed(String)
}

var peripheralCharacteristicsList = [CBMutableCharacteristic]()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,22 @@ final class UniversalBlePeripheralPlugin: NSObject, UniversalBlePeripheralChanne
guard let characteristic = characteristicId.findPeripheralCharacteristic() else {
throw UniversalBlePeripheralError.notFound("\(characteristicId) characteristic not found")
}
let centrals: [CBCentral]?
if let deviceId {
guard let central = central(for: deviceId) else {
throw UniversalBlePeripheralError.notFound("\(deviceId) device not found")
}
ensurePeripheralManager().updateValue(
value.toData(),
for: characteristic,
onSubscribedCentrals: [central]
)
centrals = [central]
} else {
ensurePeripheralManager().updateValue(value.toData(), for: characteristic, onSubscribedCentrals: nil)
centrals = nil
}
Comment thread
fotiDim marked this conversation as resolved.
let success = ensurePeripheralManager().updateValue(
value.toData(),
for: characteristic,
onSubscribedCentrals: centrals
)
if !success {
throw UniversalBlePeripheralError.failed("Peripheral transmit queue full")
}
}

Expand Down Expand Up @@ -319,7 +324,7 @@ final class UniversalBlePeripheralPlugin: NSObject, UniversalBlePeripheralChanne

private func central(for id: String) -> CBCentral? {
centralsLock.lock()
let central = centralsById[id]
let central = centralsById[id] ?? centralsById[id.uppercased()] ?? centralsById[id.lowercased()]
centralsLock.unlock()
return central
}
Expand Down
77 changes: 64 additions & 13 deletions lib/src/universal_ble_peripheral.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,49 @@
import 'package:flutter/foundation.dart';
import 'package:universal_ble/src/universal_ble_pigeon/universal_ble_peripheral_pigeon.dart';
import 'package:universal_ble/src/utils/ble_command_queue.dart';
import 'package:universal_ble/src/utils/universal_logger.dart';
import 'package:universal_ble/universal_ble.dart';

class UniversalBlePeripheral {
static UniversalBlePeripheralPlatform? _instance;
static UniversalBlePeripheralPlatform get _platform =>
_instance ??= _defaultPlatform();
static final BleCommandQueue _bleCommandQueue = BleCommandQueue();

static void setInstance(UniversalBlePeripheralPlatform instance) {
_instance?.dispose();
_instance = instance;
}

/// Set global timeout for all peripheral commands.
/// Default timeout is 10 seconds.
/// Set to null to disable.
static set timeout(Duration? duration) {
_bleCommandQueue.timeout = duration;
}

/// Set how peripheral commands will be executed. By default, all commands are executed in a global queue (`QueueType.global`),
/// with each command waiting for the previous one to finish.
///
/// [QueueType.global] will execute commands in a single queue.
/// [QueueType.perDevice] will execute commands of each device in separate queues.
/// [QueueType.none] will execute all commands in parallel.
static set queueType(QueueType queueType) {
_bleCommandQueue.queueType = queueType;
UniversalLogger.logInfo('Peripheral Queue ${queueType.name}');
}

/// Clear all pending queued peripheral commands.
/// If [id] is provided, clears the queue for that specific device or queueId.
static void clearQueue([String? id]) {
_bleCommandQueue.clearQueue(id);
}

/// Callback when the remaining items in a peripheral command queue changes.
static set onQueueUpdate(OnQueueUpdate? onQueueUpdate) {
_bleCommandQueue.onQueueUpdate = onQueueUpdate;
}

/// Advertising state update stream.
static Stream<BlePeripheralAdvertisingStateChanged>
get advertisingStateStream => _platform.advertisingStateStream;
Expand Down Expand Up @@ -59,39 +91,58 @@ class UniversalBlePeripheral {
static Future<void> addService(
BlePeripheralService service, {
Duration? timeout,
}) => _platform.addService(service.toPeripheralService(), timeout: timeout);
}) => _bleCommandQueue.queueCommand(
() => _platform.addService(service.toPeripheralService(), timeout: timeout),
timeout: timeout,
);

static Future<void> removeService(String serviceId) =>
_platform.removeService(BleUuidParser.string(serviceId));
_bleCommandQueue.queueCommand(
() => _platform.removeService(BleUuidParser.string(serviceId)),
);

static Future<void> clearServices() => _platform.clearServices();
static Future<void> clearServices() => _bleCommandQueue.queueCommand(
() => _platform.clearServices(),
);

static Future<List<String>> getServices() => _platform.getServices();
static Future<List<String>> getServices() => _bleCommandQueue.queueCommand(
() => _platform.getServices(),
);

static Future<void> startAdvertising({
required List<String> services,
String? localName,
Duration? timeout,
ManufacturerData? manufacturerData,
PeripheralPlatformConfig? platformConfig,
}) => _platform.startAdvertising(
services: services.map(BleUuidParser.string).toList(),
localName: localName,
}) => _bleCommandQueue.queueCommand(
() => _platform.startAdvertising(
services: services.map(BleUuidParser.string).toList(),
localName: localName,
timeout: timeout,
manufacturerData: manufacturerData,
platformConfig: platformConfig,
),
timeout: timeout,
manufacturerData: manufacturerData,
platformConfig: platformConfig,
);

static Future<void> stopAdvertising() => _platform.stopAdvertising();
static Future<void> stopAdvertising() => _bleCommandQueue.queueCommand(
() => _platform.stopAdvertising(),
);

static Future<void> updateCharacteristicValue({
required String characteristicId,
required Uint8List value,
String? deviceId,
}) => _platform.updateCharacteristicValue(
characteristicId: BleUuidParser.string(characteristicId),
value: value,
String? queueId,
}) => _bleCommandQueue.queueCommand(
() => _platform.updateCharacteristicValue(
characteristicId: BleUuidParser.string(characteristicId),
value: value,
deviceId: deviceId,
),
deviceId: deviceId,
queueId: queueId,
);

/// Returns client device ids currently subscribed to [characteristicId]
Expand Down
158 changes: 158 additions & 0 deletions test/ble_peripheral_command_queue_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_ble/universal_ble.dart';

class MockPeripheralPlatform extends Fake implements UniversalBlePeripheralPlatform {
final List<String> callLog = [];
Completer<void>? notifyCompleter;

@override
void dispose() {}

@override
Future<void> updateCharacteristicValue({
required String characteristicId,
required Uint8List value,
String? deviceId,
}) async {
callLog.add('update:$deviceId:${value.length}');
if (notifyCompleter != null) {
await notifyCompleter!.future;
}
}

@override
Future<void> addService(dynamic service, {Duration? timeout}) async {
callLog.add('addService');
}

@override
Future<void> startAdvertising({
required List<String> services,
String? localName,
Duration? timeout,
dynamic manufacturerData,
dynamic platformConfig,
}) async {
callLog.add('startAdvertising');
}

@override
Future<void> stopAdvertising() async {
callLog.add('stopAdvertising');
}
}

void main() {
group('UniversalBlePeripheral Command Queue', () {
late MockPeripheralPlatform mockPlatform;

setUp(() {
mockPlatform = MockPeripheralPlatform();
UniversalBlePeripheral.setInstance(mockPlatform);
UniversalBlePeripheral.queueType = QueueType.global;
UniversalBlePeripheral.timeout = const Duration(seconds: 5);
});

tearDown(() {
UniversalBlePeripheral.clearQueue();
UniversalBlePeripheral.queueType = QueueType.global;
});

test('serializes updateCharacteristicValue in global queue mode', () async {
UniversalBlePeripheral.queueType = QueueType.global;
final release = Completer<void>();
mockPlatform.notifyCompleter = release;

final first = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([1, 2, 3]),
deviceId: 'device-1',
);
final second = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([4, 5, 6, 7]),
deviceId: 'device-2',
);

await pumpEventQueue();
// First is executing, second is queued
expect(mockPlatform.callLog, equals(['update:device-1:3']));

// Release first
release.complete();
await first;
await second;

expect(mockPlatform.callLog, equals(['update:device-1:3', 'update:device-2:4']));
});

test('isolates updateCharacteristicValue by deviceId in perDevice mode', () async {
UniversalBlePeripheral.queueType = QueueType.perDevice;
final releaseA = Completer<void>();
mockPlatform.notifyCompleter = releaseA;

final firstA = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([1]),
deviceId: 'device-a',
);
final secondA = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([2]),
deviceId: 'device-a',
);

await pumpEventQueue();
expect(mockPlatform.callLog, equals(['update:device-a:1']));

// Set completer to null so device-b completes immediately
mockPlatform.notifyCompleter = null;
final firstB = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([3]),
deviceId: 'device-b',
);

await firstB;
// device-b executed independently while device-a was blocked
expect(mockPlatform.callLog, equals(['update:device-a:1', 'update:device-b:1']));

releaseA.complete();
await firstA;
await secondA;

expect(mockPlatform.callLog, equals([
'update:device-a:1',
'update:device-b:1',
'update:device-a:1',
]));
});

test('clearQueue cancels pending peripheral commands', () async {
UniversalBlePeripheral.queueType = QueueType.global;
final release = Completer<void>();
mockPlatform.notifyCompleter = release;

final first = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([1]),
deviceId: 'device-1',
);
final pending = UniversalBlePeripheral.updateCharacteristicValue(
characteristicId: 'fa5a0003',
value: Uint8List.fromList([2]),
deviceId: 'device-1',
);

await pumpEventQueue();
UniversalBlePeripheral.clearQueue();

await expectLater(pending, throwsA(isA<Exception>()));

release.complete();
await first;
});
});
}
Loading