diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce87c08..96b6551f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ac2c096f..fbea94c3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePeripheralPlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePeripheralPlugin.kt index 2c3a4088..45617bf4 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePeripheralPlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePeripheralPlugin.kt @@ -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 { bluetoothDevicesMap.values.toList() } diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralExtensions.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralExtensions.swift index 0a175125..45c4648f 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralExtensions.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralExtensions.swift @@ -10,6 +10,7 @@ import Foundation enum UniversalBlePeripheralError: Error { case notFound(String) + case failed(String) } var peripheralCharacteristicsList = [CBMutableCharacteristic]() diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralPlugin.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralPlugin.swift index 5291994c..85d858c7 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralPlugin.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePeripheralPlugin.swift @@ -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 + } + let success = ensurePeripheralManager().updateValue( + value.toData(), + for: characteristic, + onSubscribedCentrals: centrals + ) + if !success { + throw UniversalBlePeripheralError.failed("Peripheral transmit queue full") } } @@ -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 } diff --git a/lib/src/universal_ble_peripheral.dart b/lib/src/universal_ble_peripheral.dart index 4bb750a5..685133d1 100644 --- a/lib/src/universal_ble_peripheral.dart +++ b/lib/src/universal_ble_peripheral.dart @@ -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 get advertisingStateStream => _platform.advertisingStateStream; @@ -59,14 +91,23 @@ class UniversalBlePeripheral { static Future addService( BlePeripheralService service, { Duration? timeout, - }) => _platform.addService(service.toPeripheralService(), timeout: timeout); + }) => _bleCommandQueue.queueCommand( + () => _platform.addService(service.toPeripheralService(), timeout: timeout), + timeout: timeout, + ); static Future removeService(String serviceId) => - _platform.removeService(BleUuidParser.string(serviceId)); + _bleCommandQueue.queueCommand( + () => _platform.removeService(BleUuidParser.string(serviceId)), + ); - static Future clearServices() => _platform.clearServices(); + static Future clearServices() => _bleCommandQueue.queueCommand( + () => _platform.clearServices(), + ); - static Future> getServices() => _platform.getServices(); + static Future> getServices() => _bleCommandQueue.queueCommand( + () => _platform.getServices(), + ); static Future startAdvertising({ required List services, @@ -74,24 +115,34 @@ class UniversalBlePeripheral { 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 stopAdvertising() => _platform.stopAdvertising(); + static Future stopAdvertising() => _bleCommandQueue.queueCommand( + () => _platform.stopAdvertising(), + ); static Future 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] diff --git a/test/ble_peripheral_command_queue_test.dart b/test/ble_peripheral_command_queue_test.dart new file mode 100644 index 00000000..392a547b --- /dev/null +++ b/test/ble_peripheral_command_queue_test.dart @@ -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 callLog = []; + Completer? notifyCompleter; + + @override + void dispose() {} + + @override + Future updateCharacteristicValue({ + required String characteristicId, + required Uint8List value, + String? deviceId, + }) async { + callLog.add('update:$deviceId:${value.length}'); + if (notifyCompleter != null) { + await notifyCompleter!.future; + } + } + + @override + Future addService(dynamic service, {Duration? timeout}) async { + callLog.add('addService'); + } + + @override + Future startAdvertising({ + required List services, + String? localName, + Duration? timeout, + dynamic manufacturerData, + dynamic platformConfig, + }) async { + callLog.add('startAdvertising'); + } + + @override + Future 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(); + 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(); + 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(); + 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())); + + release.complete(); + await first; + }); + }); +}