From e4b14f38aad019040cd0705fcbdb9cea853ce2cc Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 8 May 2026 03:55:00 +0800 Subject: [PATCH] Enhance BLE peripheral support for iOS and macOS; update README and CHANGELOG Signed-off-by: Tony --- CHANGELOG.md | 1 + README.md | 6 +- .../universal_ble/UniversalBlePlugin.swift | 470 +++++++++++++++++- .../macos/Runner.xcodeproj/project.pbxproj | 10 - 4 files changed, 462 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d850d77..d686296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 1.3.0 +* Add iOS and macOS BLE peripheral/GATT-server support through CoreBluetooth `CBPeripheralManager` * Add Linux BLE peripheral/GATT-server support through BlueZ GATT application and LE advertisement registration * Add `requestConnectionPriority` to allow tuning BLE connection intervals on Android * Add SPM support on Apple diff --git a/README.md b/README.md index 7e9b1c3..cfb2e67 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE | requestConnectionPriority | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | | readRssi | ✔️ | ✔️ | ✔️ | ❌ | 🚧 | ❌ | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| peripheral/GATT server | ✔️ | 🚧 | 🚧 | 🚧 | ✔️ | ❌ | +| peripheral/GATT server | ✔️ | ✔️ | ✔️ | 🚧 | ✔️ | ❌ | ## Getting Started @@ -342,7 +342,9 @@ await characteristic.unsubscribe(); ## Peripheral Mode -`UniversalBlePeripheral` exposes a local GATT-server API for apps that need to advertise services and accept central/client writes. Android and Linux are implemented; Apple and Windows currently report `notSupported` until their platform server implementations are added. Web browsers do not expose a standard GATT-server API. +`UniversalBlePeripheral` exposes a local GATT-server API for apps that need to advertise services and accept central/client writes. Android, iOS, macOS, and Linux are implemented; Windows currently reports `notSupported` until its platform server implementation is added. Web browsers do not expose a standard GATT-server API. + +On iOS and macOS, peripheral mode uses CoreBluetooth `CBPeripheralManager`. CoreBluetooth manages CCCD subscription state internally, and central connection events are reported when a central reads, writes, subscribes, or unsubscribes from a local characteristic. On Linux, peripheral mode uses BlueZ `GattManager1` and `LEAdvertisingManager1`. The Bluetooth adapter must be powered, support the peripheral role, and the app process must be allowed to register GATT applications and LE advertisements on the system bus. diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift index 7005b68..2c8d4d4 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift @@ -28,10 +28,38 @@ private var discoveredPeripherals = [String: CBPeripheral]() // since iOS and MacOS don't do that for system devices private var advertisementNameCache = [String: String]() -private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentralManagerDelegate, CBPeripheralDelegate { +private final class PeripheralCharacteristicRecord { + let serviceUuid: String + let characteristicUuid: String + let characteristic: CBMutableCharacteristic + var value: Data + var subscribedCentralIds = Set() + + init(serviceUuid: String, characteristicUuid: String, characteristic: CBMutableCharacteristic, value: Data) { + self.serviceUuid = serviceUuid + self.characteristicUuid = characteristicUuid + self.characteristic = characteristic + self.value = value + } +} + +private final class PeripheralNotifyFuture { + let record: PeripheralCharacteristicRecord + let value: Data + let result: (Result) -> Void + + init(record: PeripheralCharacteristicRecord, value: Data, result: @escaping (Result) -> Void) { + self.record = record + self.value = value + self.result = result + } +} + +private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentralManagerDelegate, CBPeripheralDelegate, CBPeripheralManagerDelegate { var callbackChannel: UniversalBleCallbackChannel private var universalBleFilterUtil = UniversalBleFilterUtil() private lazy var manager: CBCentralManager = .init(delegate: self, queue: nil) + private lazy var peripheralManager: CBPeripheralManager = .init(delegate: self, queue: nil) private var availabilityStateUpdateHandlers: [(Result) -> Void] = [] private var requestPermissionStateUpdateHandlers: [(Result) -> Void] = [] private var activeServiceDiscoveries: [String: UniversalBleAsyncServiceDiscovery] = [:] @@ -43,6 +71,15 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral private var rssiReadFutures = [RssiReadFuture]() private var isManageScanning = false private var autoConnectDevices = Set() + private var peripheralPendingStartConfig: UniversalBlePeripheralConfig? + private var peripheralPendingStartCompletion: ((Result) -> Void)? + private var peripheralPendingServiceAddCount = 0 + private var peripheralAdvertisedName: String? + private var peripheralAdvertisedServices = [CBUUID]() + private var peripheralCharacteristics = [String: PeripheralCharacteristicRecord]() + private var peripheralCharacteristicsByObject = [ObjectIdentifier: PeripheralCharacteristicRecord]() + private var peripheralConnectedCentrals = Set() + private var peripheralNotifyFutures = [PeripheralNotifyFuture]() init(callbackChannel: UniversalBleCallbackChannel) { self.callbackChannel = callbackChannel @@ -384,34 +421,290 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral } func isPeripheralSupported() throws -> Bool { - false + return peripheralManager.state == .poweredOn && CBCentralManager.authorization == .allowedAlways } - func startPeripheral(config _: UniversalBlePeripheralConfig, completion: @escaping (Result) -> Void) { - completion(.failure(createFlutterError(code: .notSupported, message: "BLE peripheral mode is not implemented on Apple platforms yet"))) + func startPeripheral(config: UniversalBlePeripheralConfig, completion: @escaping (Result) -> Void) { + if peripheralPendingStartCompletion != nil { + completion(.failure(createFlutterError(code: .operationInProgress, message: "Peripheral start already in progress"))) + return + } + + peripheralPendingStartConfig = config + peripheralPendingStartCompletion = completion + _ = peripheralManager + continuePendingPeripheralStartIfPossible() } func stopPeripheral(completion: @escaping (Result) -> Void) { + peripheralPendingStartCompletion?(.failure(createFlutterError(code: .operationCancelled, message: "Peripheral start cancelled"))) + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = nil + stopPeripheralInternal() completion(.success(())) } func updatePeripheralCharacteristicValue( - service _: String, - characteristic _: String, - value _: FlutterStandardTypedData, + service: String, + characteristic: String, + value: FlutterStandardTypedData, completion: @escaping (Result) -> Void ) { - completion(.failure(createFlutterError(code: .notSupported, message: "BLE peripheral mode is not implemented on Apple platforms yet"))) + guard let record = peripheralCharacteristics[peripheralCharacteristicKey(service: service, characteristic: characteristic)] else { + completion(.failure(createFlutterError(code: .characteristicNotFound, message: "Unknown peripheral characteristic \(characteristic)"))) + return + } + record.value = value.data + completion(.success(())) } func notifyPeripheralCharacteristic( - service _: String, - characteristic _: String, - value _: FlutterStandardTypedData, - indicate _: Bool, + service: String, + characteristic: String, + value: FlutterStandardTypedData, + indicate: Bool, completion: @escaping (Result) -> Void ) { - completion(.failure(createFlutterError(code: .notSupported, message: "BLE peripheral mode is not implemented on Apple platforms yet"))) + guard let record = peripheralCharacteristics[peripheralCharacteristicKey(service: service, characteristic: characteristic)] else { + completion(.failure(createFlutterError(code: .characteristicNotFound, message: "Unknown peripheral characteristic \(characteristic)"))) + return + } + + if indicate && !record.characteristic.properties.contains(.indicate) { + completion(.failure(createFlutterError(code: .characteristicDoesNotSupportIndicate, message: "Characteristic does not support indicate"))) + return + } + if !indicate && !record.characteristic.properties.contains(.notify) { + completion(.failure(createFlutterError(code: .characteristicDoesNotSupportNotify, message: "Characteristic does not support notify"))) + return + } + + record.value = value.data + let future = PeripheralNotifyFuture(record: record, value: value.data, result: completion) + if !sendPeripheralNotify(future) { + peripheralNotifyFutures.append(future) + } + } + + private func continuePendingPeripheralStartIfPossible() { + guard let config = peripheralPendingStartConfig, let completion = peripheralPendingStartCompletion else { + return + } + + switch peripheralManager.state { + case .unknown, .resetting: + return + case .unauthorized: + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = nil + completion(.failure(createFlutterError(code: .bluetoothUnauthorized, message: "Not authorized to access Bluetooth"))) + return + case .unsupported: + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = nil + completion(.failure(createFlutterError(code: .notSupported, message: "Bluetooth peripheral mode is not supported"))) + return + case .poweredOff: + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = nil + completion(.failure(createFlutterError(code: .bluetoothNotEnabled, message: "Bluetooth not enabled"))) + return + case .poweredOn: + break + @unknown default: + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = nil + completion(.failure(createFlutterError(code: .unknownError, message: "Unknown Bluetooth peripheral manager state"))) + return + } + + stopPeripheralInternal(failPendingNotifications: true) + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = completion + peripheralAdvertisedName = config.advertisedName + peripheralAdvertisedServices = config.services.map { CBUUID(string: $0.uuid) } + peripheralPendingServiceAddCount = config.services.count + + config.services.forEach { serviceConfig in + let service = CBMutableService(type: CBUUID(string: serviceConfig.uuid), primary: true) + service.characteristics = serviceConfig.characteristics.map { characteristicConfig in + let properties = peripheralProperties(from: characteristicConfig.properties) + let permissions = peripheralPermissions(from: characteristicConfig.permissions) + let descriptors = characteristicConfig.descriptors.compactMap { descriptorConfig -> CBMutableDescriptor? in + guard !isClientCharacteristicConfigDescriptor(descriptorConfig.uuid) else { + return nil + } + return CBMutableDescriptor( + type: CBUUID(string: descriptorConfig.uuid), + value: descriptorConfig.initialValue?.data ?? Data() + ) + } + let characteristic = CBMutableCharacteristic( + type: CBUUID(string: characteristicConfig.uuid), + properties: properties, + value: nil, + permissions: permissions + ) + characteristic.descriptors = descriptors + let record = PeripheralCharacteristicRecord( + serviceUuid: serviceConfig.uuid, + characteristicUuid: characteristicConfig.uuid, + characteristic: characteristic, + value: characteristicConfig.initialValue?.data ?? Data() + ) + peripheralCharacteristics[peripheralCharacteristicKey(service: serviceConfig.uuid, characteristic: characteristicConfig.uuid)] = record + peripheralCharacteristicsByObject[ObjectIdentifier(characteristic)] = record + return characteristic + } + peripheralManager.add(service) + } + + if peripheralPendingServiceAddCount == 0 { + startPeripheralAdvertising() + } + } + + private func startPeripheralAdvertising() { + var advertisementData: [String: Any] = [:] + if let name = peripheralAdvertisedName, !name.isEmpty { + advertisementData[CBAdvertisementDataLocalNameKey] = name + } + if !peripheralAdvertisedServices.isEmpty { + advertisementData[CBAdvertisementDataServiceUUIDsKey] = peripheralAdvertisedServices + } + peripheralManager.startAdvertising(advertisementData) + } + + private func stopPeripheralInternal(failPendingNotifications: Bool = false) { + peripheralManager.stopAdvertising() + peripheralManager.removeAllServices() + peripheralPendingServiceAddCount = 0 + peripheralAdvertisedName = nil + peripheralAdvertisedServices = [] + if failPendingNotifications { + peripheralNotifyFutures.forEach { future in + future.result(.failure(createFlutterError(code: .operationCancelled, message: "Peripheral notify cancelled"))) + } + } + peripheralNotifyFutures.removeAll() + peripheralCharacteristics.removeAll() + peripheralCharacteristicsByObject.removeAll() + peripheralConnectedCentrals.forEach { deviceId in + callbackPeripheralConnection(deviceId: deviceId, connected: false) + } + peripheralConnectedCentrals.removeAll() + } + + private func failPendingPeripheralStart(_ error: Error) { + let completion = peripheralPendingStartCompletion + peripheralPendingStartConfig = nil + peripheralPendingStartCompletion = nil + stopPeripheralInternal(failPendingNotifications: true) + completion?(.failure(error)) + } + + private func peripheralCharacteristicKey(service: String, characteristic: String) -> String { + return "\(service.lowercased())|\(characteristic.lowercased())" + } + + private func peripheralProperties(from properties: [Int64]) -> CBCharacteristicProperties { + var flags: CBCharacteristicProperties = [] + properties.forEach { property in + switch CharacteristicProperty(rawValue: property) { + case .broadcast: + flags.insert(.broadcast) + case .read: + flags.insert(.read) + case .writeWithoutResponse: + flags.insert(.writeWithoutResponse) + case .write: + flags.insert(.write) + case .notify: + flags.insert(.notify) + case .indicate: + flags.insert(.indicate) + case .authenticatedSignedWrites: + flags.insert(.authenticatedSignedWrites) + case .extendedProperties: + flags.insert(.extendedProperties) + case nil: + break + } + } + return flags + } + + private func peripheralPermissions(from permissions: [Int64]) -> CBAttributePermissions { + var flags: CBAttributePermissions = [] + permissions.forEach { permission in + switch permission { + case 0: + flags.insert(.readable) + case 1: + flags.insert(.writeable) + default: + break + } + } + return flags + } + + private func isClientCharacteristicConfigDescriptor(_ uuid: String) -> Bool { + let lowercased = uuid.lowercased() + return lowercased == "2902" || lowercased == "00002902-0000-1000-8000-00805f9b34fb" + } + + private func sendPeripheralNotify(_ future: PeripheralNotifyFuture) -> Bool { + let accepted = peripheralManager.updateValue( + future.value, + for: future.record.characteristic, + onSubscribedCentrals: nil + ) + if accepted { + future.result(.success(())) + } + return accepted + } + + private func callbackPeripheralConnection(deviceId: String, connected: Bool) { + callbackChannel.onPeripheralConnectionChanged(deviceId: deviceId, connected: connected) { _ in } + } + + private func callbackPeripheralSubscription(record: PeripheralCharacteristicRecord, deviceId: String, subscribed: Bool) { + callbackChannel.onPeripheralSubscriptionChanged( + deviceId: deviceId, + service: record.serviceUuid, + characteristic: record.characteristicUuid, + subscribed: subscribed + ) { _ in } + } + + private func callbackPeripheralWrite(record: PeripheralCharacteristicRecord, deviceId: String, value: Data) { + callbackChannel.onPeripheralWrite( + event: UniversalBlePeripheralWriteEvent( + deviceId: deviceId, + service: record.serviceUuid, + characteristic: record.characteristicUuid, + value: FlutterStandardTypedData(bytes: value) + ) + ) { _ in } + } + + private func markPeripheralCentralConnected(_ central: CBCentral) { + let deviceId = central.identifier.uuidString + if peripheralConnectedCentrals.insert(deviceId).inserted { + callbackPeripheralConnection(deviceId: deviceId, connected: true) + } + } + + private func markPeripheralCentralDisconnectedIfIdle(_ central: CBCentral) { + let deviceId = central.identifier.uuidString + let hasSubscriptions = peripheralCharacteristics.values.contains { record in + record.subscribedCentralIds.contains(deviceId) + } + if !hasSubscriptions && peripheralConnectedCentrals.remove(deviceId) != nil { + callbackPeripheralConnection(deviceId: deviceId, connected: false) + } } func readRssi(deviceId: String, completion: @escaping (Result) -> Void) { @@ -682,6 +975,157 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral return false } } + + public func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + if peripheral.state == .poweredOn { + continuePendingPeripheralStartIfPossible() + return + } + + if peripheral.state == .unknown || peripheral.state == .resetting { + return + } + + if peripheralPendingStartCompletion != nil { + continuePendingPeripheralStartIfPossible() + } else if !peripheralCharacteristics.isEmpty || !peripheralConnectedCentrals.isEmpty { + stopPeripheralInternal(failPendingNotifications: true) + } + } + + public func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + guard peripheralPendingStartCompletion != nil else { + return + } + + if let error { + failPendingPeripheralStart(createFlutterError(code: .failed, message: "Failed to add GATT service \(service.uuid.uuidStr)", details: error.localizedDescription)) + return + } + + peripheralPendingServiceAddCount -= 1 + if peripheralPendingServiceAddCount <= 0 { + startPeripheralAdvertising() + } + } + + public func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { + guard let completion = peripheralPendingStartCompletion else { + return + } + + peripheralPendingStartCompletion = nil + if let error { + stopPeripheralInternal(failPendingNotifications: true) + completion(.failure(createFlutterError(code: .failed, message: "Failed to start BLE advertising", details: error.localizedDescription))) + } else { + completion(.success(())) + } + } + + public func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + markPeripheralCentralConnected(request.central) + + guard let record = peripheralCharacteristicsByObject[ObjectIdentifier(request.characteristic)] else { + peripheral.respond(to: request, withResult: .attributeNotFound) + return + } + + guard record.characteristic.properties.contains(.read) else { + peripheral.respond(to: request, withResult: .readNotPermitted) + return + } + + let offset = request.offset + guard offset <= record.value.count else { + peripheral.respond(to: request, withResult: .invalidOffset) + return + } + + request.value = record.value.subdata(in: offset.. replaceEnd { + updatedValue.append(requestValue.suffix(offset + requestValue.count - replaceEnd)) + } + record.value = updatedValue + } + + callbackPeripheralWrite(record: record, deviceId: request.central.identifier.uuidString, value: requestValue) + } + + peripheral.respond(to: firstRequest, withResult: .success) + } + + public func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard let record = peripheralCharacteristicsByObject[ObjectIdentifier(characteristic)] else { + return + } + let deviceId = central.identifier.uuidString + markPeripheralCentralConnected(central) + record.subscribedCentralIds.insert(deviceId) + callbackPeripheralSubscription(record: record, deviceId: deviceId, subscribed: true) + } + + public func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + guard let record = peripheralCharacteristicsByObject[ObjectIdentifier(characteristic)] else { + return + } + let deviceId = central.identifier.uuidString + record.subscribedCentralIds.remove(deviceId) + callbackPeripheralSubscription(record: record, deviceId: deviceId, subscribed: false) + markPeripheralCentralDisconnectedIfIdle(central) + } + + public func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + var remainingFutures = [PeripheralNotifyFuture]() + var blocked = false + for future in peripheralNotifyFutures { + if blocked { + remainingFutures.append(future) + } else if !sendPeripheralNotify(future) { + remainingFutures.append(future) + blocked = true + } + } + peripheralNotifyFutures = remainingFutures + } } extension CBPeripheral { diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index c4a4bab..fa409f7 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -222,7 +222,6 @@ name = Runner; packageProductDependencies = ( 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - D1E2F3A4B5C60718293A4B5C /* universal-ble */, ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* universal_ble_example.app */; @@ -269,7 +268,6 @@ mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, - A1B2C3D4E5F60718293A4B5C /* XCLocalSwiftPackageReference "universal_ble" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; @@ -711,10 +709,6 @@ isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; - A1B2C3D4E5F60718293A4B5C /* XCLocalSwiftPackageReference "universal_ble" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/.packages/universal_ble; - }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -722,10 +716,6 @@ isa = XCSwiftPackageProductDependency; productName = FlutterGeneratedPluginSwiftPackage; }; - D1E2F3A4B5C60718293A4B5C /* universal-ble */ = { - isa = XCSwiftPackageProductDependency; - productName = "universal-ble"; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */;