Enhance BLE peripheral support for iOS and macOS; update README and CHANGELOG

Signed-off-by: Tony <tonylu@tony-cloud.com>
This commit is contained in:
Tony
2026-05-08 03:55:00 +08:00
parent 1d7108c9d0
commit e4b14f38aa
4 changed files with 462 additions and 25 deletions
+1
View File
@@ -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
+4 -2
View File
@@ -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.
@@ -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<String>()
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, Error>) -> Void
init(record: PeripheralCharacteristicRecord, value: Data, result: @escaping (Result<Void, Error>) -> 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<Int64, Error>) -> Void] = []
private var requestPermissionStateUpdateHandlers: [(Result<Void, Error>) -> 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<String>()
private var peripheralPendingStartConfig: UniversalBlePeripheralConfig?
private var peripheralPendingStartCompletion: ((Result<Void, Error>) -> 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<String>()
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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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<Int64, Error>) -> 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..<record.value.count)
peripheral.respond(to: request, withResult: .success)
}
public func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
guard let firstRequest = requests.first else {
return
}
for request in requests {
markPeripheralCentralConnected(request.central)
guard let record = peripheralCharacteristicsByObject[ObjectIdentifier(request.characteristic)] else {
peripheral.respond(to: firstRequest, withResult: .attributeNotFound)
return
}
guard record.characteristic.properties.contains(.write) || record.characteristic.properties.contains(.writeWithoutResponse) else {
peripheral.respond(to: firstRequest, withResult: .writeNotPermitted)
return
}
guard let requestValue = request.value else {
peripheral.respond(to: firstRequest, withResult: .invalidAttributeValueLength)
return
}
let offset = request.offset
guard offset <= record.value.count else {
peripheral.respond(to: firstRequest, withResult: .invalidOffset)
return
}
if offset == 0 {
record.value = requestValue
} else {
var updatedValue = record.value
let replaceEnd = min(updatedValue.count, offset + requestValue.count)
updatedValue.replaceSubrange(offset..<replaceEnd, with: requestValue)
if replaceEnd == updatedValue.count && offset + requestValue.count > 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 {
@@ -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 */;