├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── CentralPeripheralDemo ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── CentralView.swift ├── Info.plist ├── PeripheralDemoApp.swift ├── PeripheralView.swift └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── CombineCoreBluetooth.podspec ├── CombineCoreBluetooth.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── CentralPeripheralDemo.xcscheme │ └── CombineCoreBluetooth.xcscheme ├── CombineCoreBluetooth.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── CombineCoreBluetooth │ ├── Central │ ├── Interface+Central.swift │ ├── Live+Central.swift │ └── Mock+Central.swift │ ├── CentralManager │ ├── Interface+CentralManager.swift │ ├── Live+CentralManager.swift │ └── Mock+CentralManager.swift │ ├── Internal │ ├── Exports.swift │ ├── Publisher+Extensions.swift │ └── Unimplemented.swift │ ├── Models │ ├── ATTRequest.swift │ ├── AdvertisementData.swift │ ├── CentralManagerError.swift │ ├── L2CAPChannel.swift │ ├── ManagerCreationOptions.swift │ ├── Peer.swift │ ├── PeripheralDiscovery.swift │ ├── PeripheralError.swift │ └── PeripheralManagerError.swift │ ├── Peripheral │ ├── Interface+Peripheral.swift │ ├── Live+Peripheral.swift │ └── Mock+Peripheral.swift │ └── PeripheralManager │ ├── Interface+PeripheralManager.swift │ ├── Live+PeripheralManager.swift │ └── Mock+PeripheralManager.swift └── Tests └── CombineCoreBluetoothTests ├── CentralManagerTests.swift ├── PeripheralManagerTests.swift └── PeripheralTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | indent_style = space 2 | indent_size = 2 3 | insert_final_newline = true 4 | trim_trailing_whitespace = true 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - "**.swift" 8 | - "**.podspec" 9 | - ".github/workflows/*" 10 | pull_request: 11 | branches: [master] 12 | paths: 13 | - "**.swift" 14 | - "**.podspec" 15 | - ".github/workflows/*" 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | cancel-in-progress: true 20 | 21 | env: 22 | DEVELOPER_DIR: "/Applications/Xcode_16.0.app/Contents/Developer" 23 | 24 | jobs: 25 | build-mac: 26 | runs-on: macos-15 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Update Tools 30 | run: gem update cocoapods 31 | - name: Print Versions 32 | run: | 33 | xcodebuild -version 34 | swift --version 35 | pod --version 36 | - name: Build 37 | run: swift build --build-tests 38 | - name: Test 39 | run: swift test --skip-build 40 | - name: Verify Carthage 41 | run: carthage build --no-skip-current --verbose --use-xcframeworks --platform macOS 42 | - name: Pod lint 43 | run: pod lib lint 44 | 45 | build-ios: 46 | runs-on: macos-15 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Test 50 | run: | 51 | set -o pipefail && xcodebuild \ 52 | -scheme CombineCoreBluetooth \ 53 | -workspace ./CombineCoreBluetooth.xcworkspace/ \ 54 | -destination "platform=iOS Simulator,OS=latest,name=iPhone 16" \ 55 | -sdk iphonesimulator \ 56 | -enableCodeCoverage YES \ 57 | -disableAutomaticPackageResolution \ 58 | test | xcpretty -c 59 | 60 | build-tvos: 61 | runs-on: macos-15 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Test 65 | run: | 66 | set -o pipefail && xcodebuild \ 67 | -scheme CombineCoreBluetooth \ 68 | -workspace ./CombineCoreBluetooth.xcworkspace/ \ 69 | -destination "platform=tvOS Simulator,OS=latest,name=Apple TV" \ 70 | -sdk appletvsimulator \ 71 | -enableCodeCoverage YES \ 72 | -disableAutomaticPackageResolution \ 73 | test | xcpretty -c 74 | 75 | build-watchos: 76 | runs-on: macos-15 77 | steps: 78 | - uses: actions/checkout@v4 79 | - name: Test 80 | run: | 81 | set -o pipefail && xcodebuild \ 82 | -scheme CombineCoreBluetooth \ 83 | -workspace ./CombineCoreBluetooth.xcworkspace/ \ 84 | -destination "platform=watchOS Simulator,OS=latest,name=Apple Watch Ultra 2 (49mm)" \ 85 | -sdk watchsimulator \ 86 | -enableCodeCoverage YES \ 87 | -disableAutomaticPackageResolution \ 88 | test | xcpretty -c 89 | 90 | build-visionos: 91 | runs-on: macos-15 92 | steps: 93 | - uses: actions/checkout@v4 94 | - name: Test 95 | run: | 96 | set -o pipefail && xcodebuild \ 97 | -scheme CombineCoreBluetooth \ 98 | -workspace ./CombineCoreBluetooth.xcworkspace/ \ 99 | -destination "platform=visionOS Simulator,OS=latest,name=Apple Vision Pro" \ 100 | -sdk xrsimulator \ 101 | -enableCodeCoverage YES \ 102 | -disableAutomaticPackageResolution \ 103 | test | xcpretty -c 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata 5 | .swiftpm 6 | /Carthage 7 | .vscode -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [CombineCoreBluetooth] 5 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "2x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "83.5x83.5" 82 | }, 83 | { 84 | "idiom" : "ios-marketing", 85 | "scale" : "1x", 86 | "size" : "1024x1024" 87 | } 88 | ], 89 | "info" : { 90 | "author" : "xcode", 91 | "version" : 1 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/CentralView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CentralDemo 4 | // 5 | // Created by Kevin Lundberg on 3/27/22. 6 | // 7 | 8 | import CombineCoreBluetooth 9 | import SwiftUI 10 | 11 | class CentralDemo: ObservableObject { 12 | let centralManager: CentralManager = .live() 13 | @Published var peripherals: [PeripheralDiscovery] = [] 14 | var scanTask: AnyCancellable? 15 | @Published var peripheralConnectResult: Result? 16 | @Published var scanning: Bool = false 17 | 18 | var connectedPeripheral: Peripheral? { 19 | guard case let .success(value) = peripheralConnectResult else { return nil } 20 | return value 21 | } 22 | 23 | var connectError: Error? { 24 | guard case let .failure(value) = peripheralConnectResult else { return nil } 25 | return value 26 | } 27 | 28 | func searchForPeripherals() { 29 | scanTask = centralManager.scanForPeripherals(withServices: [CBUUID.service]) 30 | .scan([], { list, discovery -> [PeripheralDiscovery] in 31 | guard !list.contains(where: { $0.id == discovery.id }) else { return list } 32 | return list + [discovery] 33 | }) 34 | .receive(on: DispatchQueue.main) 35 | .sink(receiveValue: { [weak self] in 36 | self?.peripherals = $0 37 | }) 38 | scanning = centralManager.isScanning 39 | } 40 | 41 | func stopSearching() { 42 | scanTask = nil 43 | peripherals = [] 44 | scanning = centralManager.isScanning 45 | } 46 | 47 | func connect(_ discovery: PeripheralDiscovery) { 48 | centralManager.connect(discovery.peripheral) 49 | .map(Result.success) 50 | .catch { Just(Result.failure($0)) } 51 | .receive(on: DispatchQueue.main) 52 | .assign(to: &$peripheralConnectResult) 53 | } 54 | } 55 | 56 | class PeripheralDevice: ObservableObject { 57 | let peripheral: Peripheral 58 | init(_ peripheral: Peripheral) { 59 | self.peripheral = peripheral 60 | } 61 | 62 | @Published var writeResponseResult: Result? 63 | @Published var writeNoResponseResult: Result? // never should be set 64 | @Published var writeResponseOrNoResponseResult: Result? 65 | 66 | func write( 67 | to id: CBUUID, 68 | type: CBCharacteristicWriteType, 69 | result: ReferenceWritableKeyPath?>.Publisher> 70 | ) { 71 | peripheral.writeValue( 72 | Data("Hello".utf8), 73 | writeType: type, 74 | forCharacteristic: id, 75 | inService: .service 76 | ) 77 | .receive(on: DispatchQueue.main) 78 | .map { _ in Result.success(Date()) } 79 | .catch { e in Just(Result.failure(e)) } 80 | .assign(to: &self[keyPath: result]) 81 | } 82 | 83 | func writeWithoutResponse(to id: CBUUID) { 84 | writeNoResponseResult = nil 85 | 86 | peripheral.writeValue( 87 | Data("Hello".utf8), 88 | writeType: .withoutResponse, 89 | forCharacteristic: id, 90 | inService: .service 91 | ) 92 | .receive(on: DispatchQueue.main) 93 | .map { _ in Result.success(Date()) } 94 | .catch { e in Just(Result.failure(e)) } 95 | .assign(to: &$writeNoResponseResult) 96 | } 97 | } 98 | 99 | struct CentralView: View { 100 | @StateObject var demo: CentralDemo = .init() 101 | 102 | var body: some View { 103 | if let device = demo.connectedPeripheral { 104 | PeripheralDeviceView(device, demo) 105 | } else { 106 | Form { 107 | Section { 108 | if !demo.scanning { 109 | Button("Search for peripheral") { 110 | demo.searchForPeripherals() 111 | } 112 | } else { 113 | Button("Stop searching") { 114 | demo.stopSearching() 115 | } 116 | } 117 | 118 | if let error = demo.connectError { 119 | Text("Error: \(String(describing: error))") 120 | } 121 | } 122 | 123 | Section("Discovered peripherals") { 124 | ForEach(demo.peripherals) { discovery in 125 | Button(discovery.peripheral.name ?? "") { 126 | demo.connect(discovery) 127 | } 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | struct PeripheralDeviceView: View { 136 | @ObservedObject var device: PeripheralDevice 137 | @ObservedObject var demo: CentralDemo 138 | 139 | init(_ peripheral: Peripheral, _ demo: CentralDemo) { 140 | self.device = .init(peripheral) 141 | self.demo = demo 142 | } 143 | 144 | var body: some View { 145 | Form { 146 | Section("Characteristic sends response") { 147 | Button(action: { 148 | device.write( 149 | to: .writeResponseCharacteristic, 150 | type: .withResponse, 151 | result: \PeripheralDevice.$writeResponseResult 152 | ) 153 | }) { 154 | Text("Write with response") 155 | } 156 | Button(action: { 157 | device.write( 158 | to: .writeResponseCharacteristic, 159 | type: .withoutResponse, 160 | result: \PeripheralDevice.$writeResponseResult 161 | ) 162 | }) { 163 | Text("Write without response") 164 | } 165 | label(for: device.writeResponseResult) 166 | } 167 | 168 | Section("Characteristic doesn't send response") { 169 | Button(action: { 170 | device.write( 171 | to: .writeNoResponseCharacteristic, 172 | type: .withResponse, 173 | result: \PeripheralDevice.$writeNoResponseResult 174 | ) 175 | }) { 176 | Text("Write with response") 177 | } 178 | Button(action: { 179 | device.write( 180 | to: .writeNoResponseCharacteristic, 181 | type: .withoutResponse, 182 | result: \PeripheralDevice.$writeNoResponseResult 183 | ) 184 | }) { 185 | Text("Write without response") 186 | } 187 | label(for: device.writeNoResponseResult) 188 | } 189 | 190 | Section("Characteristic can both send or not send response") { 191 | Button(action: { 192 | device.write( 193 | to: .writeBothResponseAndNoResponseCharacteristic, 194 | type: .withResponse, 195 | result: \PeripheralDevice.$writeResponseOrNoResponseResult 196 | ) 197 | }) { 198 | Text("Write with response") 199 | } 200 | Button(action: { 201 | device.write( 202 | to: .writeBothResponseAndNoResponseCharacteristic, 203 | type: .withoutResponse, 204 | result: \PeripheralDevice.$writeResponseOrNoResponseResult 205 | ) 206 | }) { 207 | Text("Write without response") 208 | } 209 | 210 | label(for: device.writeResponseOrNoResponseResult) 211 | } 212 | } 213 | } 214 | 215 | func label(for result: Result?) -> some View { 216 | Group { 217 | switch result { 218 | case let .success(value)?: 219 | Text("Wrote at \(String(describing: value))") 220 | case let .failure(error)?: 221 | if let error = error as? LocalizedError, let errorDescription = error.errorDescription { 222 | Text("Error: \(errorDescription)") 223 | } else { 224 | Text("Error: \(String(describing: error))") 225 | } 226 | case nil: 227 | EmptyView() 228 | } 229 | } 230 | } 231 | } 232 | 233 | struct ContentView_Previews: PreviewProvider { 234 | static var previews: some View { 235 | CentralView() 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/PeripheralDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PeripheralDemoApp.swift 3 | // PeripheralDemo 4 | // 5 | // Created by Kevin Lundberg on 3/25/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct PeripheralDemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | 19 | struct ContentView: View { 20 | var header: LocalizedStringKey { 21 | #if targetEnvironment(simulator) 22 | return "WARNING: if you run this in the simulator, live bluetooth communication will not work, as CoreBluetooth does not function in the simulator. Run as a mac/mac catalyst app instead." 23 | #else 24 | return "" 25 | #endif 26 | } 27 | 28 | var body: some View { 29 | NavigationView { 30 | Form { 31 | Section(header) { 32 | NavigationLink("Simulate a peripheral") { 33 | PeripheralView() 34 | .navigationTitle("Peripheral") 35 | } 36 | NavigationLink("Simulate a central") { 37 | CentralView() 38 | .navigationTitle("Central") 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/PeripheralView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // PeripheralDemo 4 | // 5 | // Created by Kevin Lundberg on 3/25/22. 6 | // 7 | 8 | import SwiftUI 9 | import CombineCoreBluetooth 10 | 11 | extension CBUUID { 12 | static let service = CBUUID(string: "1337") 13 | static let writeResponseCharacteristic = CBUUID(string: "0001") 14 | static let writeNoResponseCharacteristic = CBUUID(string: "0002") 15 | static let writeBothResponseAndNoResponseCharacteristic = CBUUID(string: "0003") 16 | } 17 | 18 | class PeripheralDemo: ObservableObject { 19 | let peripheralManager = PeripheralManager.live() 20 | @Published var logs: String = "" 21 | @Published var advertising: Bool = false 22 | var cancellables = Set() 23 | 24 | init() { 25 | peripheralManager.didReceiveWriteRequests 26 | .receive(on: DispatchQueue.main) 27 | .sink { [weak self] requests in 28 | guard let self = self else { return } 29 | print(requests.map({ r in 30 | "Write to \(r.characteristic.uuid), value: \(String(bytes: r.value ?? Data(), encoding: .utf8) ?? "")" 31 | }).joined(separator: "\n"), to: &self.logs) 32 | 33 | self.peripheralManager.respond(to: requests[0], withResult: .success) 34 | } 35 | .store(in: &cancellables) 36 | } 37 | 38 | func buildServices() { 39 | let service1 = CBMutableService(type: .service, primary: true) 40 | let writeCharacteristic = CBMutableCharacteristic( 41 | type: .writeResponseCharacteristic, 42 | properties: .write, 43 | value: nil, 44 | permissions: .writeable 45 | ) 46 | let writeNoResponseCharacteristic = CBMutableCharacteristic( 47 | type: .writeNoResponseCharacteristic, 48 | properties: .writeWithoutResponse, 49 | value: nil, 50 | permissions: .writeable 51 | ) 52 | let writeWithOrWithoutResponseCharacteristic = CBMutableCharacteristic( 53 | type: .writeBothResponseAndNoResponseCharacteristic, 54 | properties: [.write, .writeWithoutResponse], 55 | value: nil, 56 | permissions: .writeable 57 | ) 58 | 59 | service1.characteristics = [ 60 | writeCharacteristic, 61 | writeNoResponseCharacteristic, 62 | writeWithOrWithoutResponseCharacteristic, 63 | ] 64 | peripheralManager.removeAllServices() 65 | peripheralManager.add(service1) 66 | } 67 | 68 | func start() { 69 | peripheralManager.startAdvertising(.init([.serviceUUIDs: [CBUUID.service]])) 70 | .receive(on: DispatchQueue.main) 71 | .sink(receiveCompletion: { c in 72 | 73 | }, receiveValue: { [weak self] _ in 74 | self?.advertising = true 75 | self?.buildServices() 76 | }) 77 | .store(in: &cancellables) 78 | } 79 | 80 | func stop() { 81 | peripheralManager.stopAdvertising() 82 | cancellables = [] 83 | advertising = false 84 | } 85 | } 86 | 87 | struct PeripheralView: View { 88 | @StateObject var peripheral: PeripheralDemo = .init() 89 | 90 | var body: some View { 91 | Form { 92 | Section("Device that simulates a peripheral with various kinds of characteristics.") { 93 | 94 | if peripheral.advertising { 95 | Button("Stop advertising") { peripheral.stop() } 96 | } else { 97 | Button("Start advertising") { peripheral.start() } 98 | } 99 | 100 | Text("Logs:") 101 | Text(peripheral.logs) 102 | } 103 | } 104 | .onAppear { 105 | peripheral.start() 106 | } 107 | .onDisappear { 108 | peripheral.stop() 109 | } 110 | } 111 | } 112 | 113 | struct PeripheralView_Previews: PreviewProvider { 114 | static var previews: some View { 115 | PeripheralView() 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /CentralPeripheralDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'CombineCoreBluetooth' 3 | spec.version = '0.8.0' 4 | spec.summary = 'A wrapper API for CoreBluetooth using Combine Publishers.' 5 | spec.homepage = 'https://github.com/StarryInternet/CombineCoreBluetooth' 6 | spec.author = { 'Kevin Lundberg' => 'klundberg@starry.com' } 7 | spec.license = { :type => 'MIT' } 8 | 9 | spec.ios.deployment_target = '13.0' 10 | spec.osx.deployment_target = '11.0' 11 | spec.tvos.deployment_target = '13.0' 12 | spec.watchos.deployment_target = '6.0' 13 | spec.visionos.deployment_target = '1.0' 14 | 15 | spec.swift_version = '5.9' 16 | spec.source = { :git => 'https://github.com/StarryInternet/CombineCoreBluetooth.git', :tag => "#{spec.version}" } 17 | spec.source_files = 'Sources/CombineCoreBluetooth/**/*.swift' 18 | 19 | spec.frameworks = 'Combine', 'CoreBluetooth' 20 | end 21 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 70; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 0014FFB227F0359600D2A122 /* CombineCoreBluetooth in Frameworks */ = {isa = PBXBuildFile; productRef = 0014FFB127F0359600D2A122 /* CombineCoreBluetooth */; }; 11 | 006CA40A2ACB7D2300DDA85D /* ConcurrencyExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 006CA4092ACB7D2300DDA85D /* ConcurrencyExtras */; }; 12 | EB443FDD27C6C1940005CCEA /* CombineCoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB443F8E27C6BCA70005CCEA /* CombineCoreBluetooth.framework */; }; 13 | /* End PBXBuildFile section */ 14 | 15 | /* Begin PBXContainerItemProxy section */ 16 | EB443FDE27C6C1940005CCEA /* PBXContainerItemProxy */ = { 17 | isa = PBXContainerItemProxy; 18 | containerPortal = EB443F8527C6BCA70005CCEA /* Project object */; 19 | proxyType = 1; 20 | remoteGlobalIDString = EB443F8D27C6BCA70005CCEA; 21 | remoteInfo = "CombineCoreBluetooth-iOS"; 22 | }; 23 | /* End PBXContainerItemProxy section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | 003719F827EE588900C2F766 /* CentralPeripheralDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CentralPeripheralDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | EB443F8E27C6BCA70005CCEA /* CombineCoreBluetooth.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CombineCoreBluetooth.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | EB443FD127C6BE440005CCEA /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/CoreBluetooth.framework; sourceTree = DEVELOPER_DIR; }; 29 | EB443FD327C6BE5A0005CCEA /* Combine.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Combine.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/Combine.framework; sourceTree = DEVELOPER_DIR; }; 30 | EB443FD927C6C1940005CCEA /* CombineCoreBluetooth Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "CombineCoreBluetooth Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 31 | EDC190B42CD35B0F00F42E39 /* .editorconfig */ = {isa = PBXFileReference; lastKnownFileType = text; path = .editorconfig; sourceTree = ""; }; 32 | EDC190B52CD35B0F00F42E39 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; 33 | EDC190B62CD35B0F00F42E39 /* .spi.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .spi.yml; sourceTree = ""; }; 34 | /* End PBXFileReference section */ 35 | 36 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 37 | EDC190B32CD35A5800F42E39 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { 38 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 39 | membershipExceptions = ( 40 | Info.plist, 41 | ); 42 | target = 003719F727EE588900C2F766 /* CentralPeripheralDemo */; 43 | }; 44 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 45 | 46 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 47 | EDC190812CD35A5000F42E39 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; 48 | EDC1909C2CD35A5200F42E39 /* .github */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = .github; sourceTree = ""; }; 49 | EDC190A12CD35A5500F42E39 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; 50 | EDC190AC2CD35A5800F42E39 /* CentralPeripheralDemo */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EDC190B32CD35A5800F42E39 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CentralPeripheralDemo; sourceTree = ""; }; 51 | /* End PBXFileSystemSynchronizedRootGroup section */ 52 | 53 | /* Begin PBXFrameworksBuildPhase section */ 54 | 003719F527EE588900C2F766 /* Frameworks */ = { 55 | isa = PBXFrameworksBuildPhase; 56 | buildActionMask = 2147483647; 57 | files = ( 58 | 0014FFB227F0359600D2A122 /* CombineCoreBluetooth in Frameworks */, 59 | ); 60 | runOnlyForDeploymentPostprocessing = 0; 61 | }; 62 | EB443F8B27C6BCA70005CCEA /* Frameworks */ = { 63 | isa = PBXFrameworksBuildPhase; 64 | buildActionMask = 2147483647; 65 | files = ( 66 | ); 67 | runOnlyForDeploymentPostprocessing = 0; 68 | }; 69 | EB443FD627C6C1940005CCEA /* Frameworks */ = { 70 | isa = PBXFrameworksBuildPhase; 71 | buildActionMask = 2147483647; 72 | files = ( 73 | EB443FDD27C6C1940005CCEA /* CombineCoreBluetooth.framework in Frameworks */, 74 | 006CA40A2ACB7D2300DDA85D /* ConcurrencyExtras in Frameworks */, 75 | ); 76 | runOnlyForDeploymentPostprocessing = 0; 77 | }; 78 | /* End PBXFrameworksBuildPhase section */ 79 | 80 | /* Begin PBXGroup section */ 81 | EB443F8427C6BCA70005CCEA = { 82 | isa = PBXGroup; 83 | children = ( 84 | EDC190B42CD35B0F00F42E39 /* .editorconfig */, 85 | EDC190B52CD35B0F00F42E39 /* .gitignore */, 86 | EDC190B62CD35B0F00F42E39 /* .spi.yml */, 87 | EDC1909C2CD35A5200F42E39 /* .github */, 88 | EDC190812CD35A5000F42E39 /* Sources */, 89 | EDC190A12CD35A5500F42E39 /* Tests */, 90 | EDC190AC2CD35A5800F42E39 /* CentralPeripheralDemo */, 91 | EB443F8F27C6BCA70005CCEA /* Products */, 92 | EB443FD027C6BE440005CCEA /* Frameworks */, 93 | ); 94 | indentWidth = 2; 95 | sourceTree = ""; 96 | tabWidth = 2; 97 | }; 98 | EB443F8F27C6BCA70005CCEA /* Products */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | EB443F8E27C6BCA70005CCEA /* CombineCoreBluetooth.framework */, 102 | EB443FD927C6C1940005CCEA /* CombineCoreBluetooth Tests.xctest */, 103 | 003719F827EE588900C2F766 /* CentralPeripheralDemo.app */, 104 | ); 105 | name = Products; 106 | sourceTree = ""; 107 | }; 108 | EB443FD027C6BE440005CCEA /* Frameworks */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | EB443FD327C6BE5A0005CCEA /* Combine.framework */, 112 | EB443FD127C6BE440005CCEA /* CoreBluetooth.framework */, 113 | ); 114 | name = Frameworks; 115 | sourceTree = ""; 116 | }; 117 | /* End PBXGroup section */ 118 | 119 | /* Begin PBXHeadersBuildPhase section */ 120 | EB443F8927C6BCA70005CCEA /* Headers */ = { 121 | isa = PBXHeadersBuildPhase; 122 | buildActionMask = 2147483647; 123 | files = ( 124 | ); 125 | runOnlyForDeploymentPostprocessing = 0; 126 | }; 127 | /* End PBXHeadersBuildPhase section */ 128 | 129 | /* Begin PBXNativeTarget section */ 130 | 003719F727EE588900C2F766 /* CentralPeripheralDemo */ = { 131 | isa = PBXNativeTarget; 132 | buildConfigurationList = 00371A0527EE588C00C2F766 /* Build configuration list for PBXNativeTarget "CentralPeripheralDemo" */; 133 | buildPhases = ( 134 | 003719F427EE588900C2F766 /* Sources */, 135 | 003719F527EE588900C2F766 /* Frameworks */, 136 | 003719F627EE588900C2F766 /* Resources */, 137 | ); 138 | buildRules = ( 139 | ); 140 | dependencies = ( 141 | ); 142 | fileSystemSynchronizedGroups = ( 143 | EDC190AC2CD35A5800F42E39 /* CentralPeripheralDemo */, 144 | ); 145 | name = CentralPeripheralDemo; 146 | packageProductDependencies = ( 147 | 0014FFB127F0359600D2A122 /* CombineCoreBluetooth */, 148 | ); 149 | productName = PeripheralDemo; 150 | productReference = 003719F827EE588900C2F766 /* CentralPeripheralDemo.app */; 151 | productType = "com.apple.product-type.application"; 152 | }; 153 | EB443F8D27C6BCA70005CCEA /* CombineCoreBluetooth */ = { 154 | isa = PBXNativeTarget; 155 | buildConfigurationList = EB443F9527C6BCA70005CCEA /* Build configuration list for PBXNativeTarget "CombineCoreBluetooth" */; 156 | buildPhases = ( 157 | EB443F8927C6BCA70005CCEA /* Headers */, 158 | EB443F8A27C6BCA70005CCEA /* Sources */, 159 | EB443F8B27C6BCA70005CCEA /* Frameworks */, 160 | EB443F8C27C6BCA70005CCEA /* Resources */, 161 | ); 162 | buildRules = ( 163 | ); 164 | dependencies = ( 165 | ); 166 | fileSystemSynchronizedGroups = ( 167 | EDC190812CD35A5000F42E39 /* Sources */, 168 | ); 169 | name = CombineCoreBluetooth; 170 | productName = CombineCoreBluetooth; 171 | productReference = EB443F8E27C6BCA70005CCEA /* CombineCoreBluetooth.framework */; 172 | productType = "com.apple.product-type.framework"; 173 | }; 174 | EB443FD827C6C1940005CCEA /* CombineCoreBluetooth Tests */ = { 175 | isa = PBXNativeTarget; 176 | buildConfigurationList = EB443FE027C6C1940005CCEA /* Build configuration list for PBXNativeTarget "CombineCoreBluetooth Tests" */; 177 | buildPhases = ( 178 | EB443FD527C6C1940005CCEA /* Sources */, 179 | EB443FD627C6C1940005CCEA /* Frameworks */, 180 | EB443FD727C6C1940005CCEA /* Resources */, 181 | ); 182 | buildRules = ( 183 | ); 184 | dependencies = ( 185 | EB443FDF27C6C1940005CCEA /* PBXTargetDependency */, 186 | ); 187 | fileSystemSynchronizedGroups = ( 188 | EDC190A12CD35A5500F42E39 /* Tests */, 189 | ); 190 | name = "CombineCoreBluetooth Tests"; 191 | packageProductDependencies = ( 192 | 006CA4092ACB7D2300DDA85D /* ConcurrencyExtras */, 193 | ); 194 | productName = "CombineCoreBluetooth-iOSTests"; 195 | productReference = EB443FD927C6C1940005CCEA /* CombineCoreBluetooth Tests.xctest */; 196 | productType = "com.apple.product-type.bundle.unit-test"; 197 | }; 198 | /* End PBXNativeTarget section */ 199 | 200 | /* Begin PBXProject section */ 201 | EB443F8527C6BCA70005CCEA /* Project object */ = { 202 | isa = PBXProject; 203 | attributes = { 204 | BuildIndependentTargetsInParallel = 1; 205 | LastSwiftUpdateCheck = 1330; 206 | LastUpgradeCheck = 1600; 207 | TargetAttributes = { 208 | 003719F727EE588900C2F766 = { 209 | CreatedOnToolsVersion = 13.3; 210 | }; 211 | EB443F8D27C6BCA70005CCEA = { 212 | CreatedOnToolsVersion = 13.2.1; 213 | }; 214 | EB443FD827C6C1940005CCEA = { 215 | CreatedOnToolsVersion = 13.2.1; 216 | }; 217 | }; 218 | }; 219 | buildConfigurationList = EB443F8827C6BCA70005CCEA /* Build configuration list for PBXProject "CombineCoreBluetooth" */; 220 | compatibilityVersion = "Xcode 13.0"; 221 | developmentRegion = en; 222 | hasScannedForEncodings = 0; 223 | knownRegions = ( 224 | en, 225 | Base, 226 | ); 227 | mainGroup = EB443F8427C6BCA70005CCEA; 228 | packageReferences = ( 229 | 006CA4082ACB7D2300DDA85D /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */, 230 | ); 231 | productRefGroup = EB443F8F27C6BCA70005CCEA /* Products */; 232 | projectDirPath = ""; 233 | projectRoot = ""; 234 | targets = ( 235 | EB443F8D27C6BCA70005CCEA /* CombineCoreBluetooth */, 236 | EB443FD827C6C1940005CCEA /* CombineCoreBluetooth Tests */, 237 | 003719F727EE588900C2F766 /* CentralPeripheralDemo */, 238 | ); 239 | }; 240 | /* End PBXProject section */ 241 | 242 | /* Begin PBXResourcesBuildPhase section */ 243 | 003719F627EE588900C2F766 /* Resources */ = { 244 | isa = PBXResourcesBuildPhase; 245 | buildActionMask = 2147483647; 246 | files = ( 247 | ); 248 | runOnlyForDeploymentPostprocessing = 0; 249 | }; 250 | EB443F8C27C6BCA70005CCEA /* Resources */ = { 251 | isa = PBXResourcesBuildPhase; 252 | buildActionMask = 2147483647; 253 | files = ( 254 | ); 255 | runOnlyForDeploymentPostprocessing = 0; 256 | }; 257 | EB443FD727C6C1940005CCEA /* Resources */ = { 258 | isa = PBXResourcesBuildPhase; 259 | buildActionMask = 2147483647; 260 | files = ( 261 | ); 262 | runOnlyForDeploymentPostprocessing = 0; 263 | }; 264 | /* End PBXResourcesBuildPhase section */ 265 | 266 | /* Begin PBXSourcesBuildPhase section */ 267 | 003719F427EE588900C2F766 /* Sources */ = { 268 | isa = PBXSourcesBuildPhase; 269 | buildActionMask = 2147483647; 270 | files = ( 271 | ); 272 | runOnlyForDeploymentPostprocessing = 0; 273 | }; 274 | EB443F8A27C6BCA70005CCEA /* Sources */ = { 275 | isa = PBXSourcesBuildPhase; 276 | buildActionMask = 2147483647; 277 | files = ( 278 | ); 279 | runOnlyForDeploymentPostprocessing = 0; 280 | }; 281 | EB443FD527C6C1940005CCEA /* Sources */ = { 282 | isa = PBXSourcesBuildPhase; 283 | buildActionMask = 2147483647; 284 | files = ( 285 | ); 286 | runOnlyForDeploymentPostprocessing = 0; 287 | }; 288 | /* End PBXSourcesBuildPhase section */ 289 | 290 | /* Begin PBXTargetDependency section */ 291 | EB443FDF27C6C1940005CCEA /* PBXTargetDependency */ = { 292 | isa = PBXTargetDependency; 293 | target = EB443F8D27C6BCA70005CCEA /* CombineCoreBluetooth */; 294 | targetProxy = EB443FDE27C6C1940005CCEA /* PBXContainerItemProxy */; 295 | }; 296 | /* End PBXTargetDependency section */ 297 | 298 | /* Begin XCBuildConfiguration section */ 299 | 00371A0327EE588C00C2F766 /* Debug */ = { 300 | isa = XCBuildConfiguration; 301 | buildSettings = { 302 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = NO; 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 305 | CODE_SIGN_STYLE = Automatic; 306 | CURRENT_PROJECT_VERSION = 1; 307 | DEVELOPMENT_ASSET_PATHS = "\"CentralPeripheralDemo/Preview Content\""; 308 | DEVELOPMENT_TEAM = AZKV6YHQQR; 309 | ENABLE_PREVIEWS = YES; 310 | GENERATE_INFOPLIST_FILE = YES; 311 | INFOPLIST_FILE = CentralPeripheralDemo/Info.plist; 312 | INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Uses bluetooth for testing purposes"; 313 | INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Behaves as a bluetooth peripheral for testing purposes"; 314 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 315 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 316 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 317 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 318 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 319 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 320 | LD_RUNPATH_SEARCH_PATHS = ( 321 | "$(inherited)", 322 | "@executable_path/Frameworks", 323 | ); 324 | MACOSX_DEPLOYMENT_TARGET = 12.0; 325 | MARKETING_VERSION = 1.0; 326 | PRODUCT_BUNDLE_IDENTIFIER = com.starry.CentralPeripheralDemo; 327 | PRODUCT_NAME = "$(TARGET_NAME)"; 328 | SDKROOT = iphoneos; 329 | SWIFT_EMIT_LOC_STRINGS = YES; 330 | SWIFT_VERSION = 5.0; 331 | TARGETED_DEVICE_FAMILY = "1,2,6"; 332 | }; 333 | name = Debug; 334 | }; 335 | 00371A0427EE588C00C2F766 /* Release */ = { 336 | isa = XCBuildConfiguration; 337 | buildSettings = { 338 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = NO; 339 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 340 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 341 | CODE_SIGN_STYLE = Automatic; 342 | CURRENT_PROJECT_VERSION = 1; 343 | DEVELOPMENT_ASSET_PATHS = "\"CentralPeripheralDemo/Preview Content\""; 344 | DEVELOPMENT_TEAM = AZKV6YHQQR; 345 | ENABLE_PREVIEWS = YES; 346 | GENERATE_INFOPLIST_FILE = YES; 347 | INFOPLIST_FILE = CentralPeripheralDemo/Info.plist; 348 | INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Uses bluetooth for testing purposes"; 349 | INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Behaves as a bluetooth peripheral for testing purposes"; 350 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 351 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 352 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 353 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 354 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 355 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 356 | LD_RUNPATH_SEARCH_PATHS = ( 357 | "$(inherited)", 358 | "@executable_path/Frameworks", 359 | ); 360 | MACOSX_DEPLOYMENT_TARGET = 12.0; 361 | MARKETING_VERSION = 1.0; 362 | PRODUCT_BUNDLE_IDENTIFIER = com.starry.CentralPeripheralDemo; 363 | PRODUCT_NAME = "$(TARGET_NAME)"; 364 | SDKROOT = iphoneos; 365 | SWIFT_EMIT_LOC_STRINGS = YES; 366 | SWIFT_VERSION = 5.0; 367 | TARGETED_DEVICE_FAMILY = "1,2,6"; 368 | }; 369 | name = Release; 370 | }; 371 | EB443F9327C6BCA70005CCEA /* Debug */ = { 372 | isa = XCBuildConfiguration; 373 | buildSettings = { 374 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; 375 | ALWAYS_SEARCH_USER_PATHS = NO; 376 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 377 | CLANG_ANALYZER_NONNULL = YES; 378 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 379 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 380 | CLANG_CXX_LIBRARY = "libc++"; 381 | CLANG_ENABLE_MODULES = YES; 382 | CLANG_ENABLE_OBJC_ARC = YES; 383 | CLANG_ENABLE_OBJC_WEAK = YES; 384 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 385 | CLANG_WARN_BOOL_CONVERSION = YES; 386 | CLANG_WARN_COMMA = YES; 387 | CLANG_WARN_CONSTANT_CONVERSION = YES; 388 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 389 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 390 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 391 | CLANG_WARN_EMPTY_BODY = YES; 392 | CLANG_WARN_ENUM_CONVERSION = YES; 393 | CLANG_WARN_INFINITE_RECURSION = YES; 394 | CLANG_WARN_INT_CONVERSION = YES; 395 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 396 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 397 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 398 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 399 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 400 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 401 | CLANG_WARN_STRICT_PROTOTYPES = YES; 402 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 403 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 404 | CLANG_WARN_UNREACHABLE_CODE = YES; 405 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 406 | COPY_PHASE_STRIP = NO; 407 | CURRENT_PROJECT_VERSION = 1; 408 | DEAD_CODE_STRIPPING = YES; 409 | DEBUG_INFORMATION_FORMAT = dwarf; 410 | ENABLE_STRICT_OBJC_MSGSEND = YES; 411 | ENABLE_TESTABILITY = YES; 412 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 413 | GCC_C_LANGUAGE_STANDARD = gnu11; 414 | GCC_DYNAMIC_NO_PIC = NO; 415 | GCC_NO_COMMON_BLOCKS = YES; 416 | GCC_OPTIMIZATION_LEVEL = 0; 417 | GCC_PREPROCESSOR_DEFINITIONS = ( 418 | "DEBUG=1", 419 | "$(inherited)", 420 | ); 421 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 422 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 423 | GCC_WARN_UNDECLARED_SELECTOR = YES; 424 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 425 | GCC_WARN_UNUSED_FUNCTION = YES; 426 | GCC_WARN_UNUSED_VARIABLE = YES; 427 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 428 | MACOSX_DEPLOYMENT_TARGET = 10.15; 429 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 430 | MTL_FAST_MATH = YES; 431 | ONLY_ACTIVE_ARCH = YES; 432 | SDKROOT = ""; 433 | SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos appletvsimulator appletvos"; 434 | SUPPORTS_MACCATALYST = YES; 435 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 436 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 437 | SWIFT_STRICT_CONCURRENCY = complete; 438 | SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; 439 | SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; 440 | TVOS_DEPLOYMENT_TARGET = 13.0; 441 | VERSIONING_SYSTEM = "apple-generic"; 442 | VERSION_INFO_PREFIX = ""; 443 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 444 | }; 445 | name = Debug; 446 | }; 447 | EB443F9427C6BCA70005CCEA /* Release */ = { 448 | isa = XCBuildConfiguration; 449 | buildSettings = { 450 | ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; 451 | ALWAYS_SEARCH_USER_PATHS = NO; 452 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 453 | CLANG_ANALYZER_NONNULL = YES; 454 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 455 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 456 | CLANG_CXX_LIBRARY = "libc++"; 457 | CLANG_ENABLE_MODULES = YES; 458 | CLANG_ENABLE_OBJC_ARC = YES; 459 | CLANG_ENABLE_OBJC_WEAK = YES; 460 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 461 | CLANG_WARN_BOOL_CONVERSION = YES; 462 | CLANG_WARN_COMMA = YES; 463 | CLANG_WARN_CONSTANT_CONVERSION = YES; 464 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 465 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 466 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 467 | CLANG_WARN_EMPTY_BODY = YES; 468 | CLANG_WARN_ENUM_CONVERSION = YES; 469 | CLANG_WARN_INFINITE_RECURSION = YES; 470 | CLANG_WARN_INT_CONVERSION = YES; 471 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 472 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 473 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 474 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 475 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 476 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 477 | CLANG_WARN_STRICT_PROTOTYPES = YES; 478 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 479 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 480 | CLANG_WARN_UNREACHABLE_CODE = YES; 481 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 482 | COPY_PHASE_STRIP = NO; 483 | CURRENT_PROJECT_VERSION = 1; 484 | DEAD_CODE_STRIPPING = YES; 485 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 486 | ENABLE_NS_ASSERTIONS = NO; 487 | ENABLE_STRICT_OBJC_MSGSEND = YES; 488 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 489 | GCC_C_LANGUAGE_STANDARD = gnu11; 490 | GCC_NO_COMMON_BLOCKS = YES; 491 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 492 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 493 | GCC_WARN_UNDECLARED_SELECTOR = YES; 494 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 495 | GCC_WARN_UNUSED_FUNCTION = YES; 496 | GCC_WARN_UNUSED_VARIABLE = YES; 497 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 498 | MACOSX_DEPLOYMENT_TARGET = 10.15; 499 | MTL_ENABLE_DEBUG_INFO = NO; 500 | MTL_FAST_MATH = YES; 501 | SDKROOT = ""; 502 | SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos appletvsimulator appletvos"; 503 | SUPPORTS_MACCATALYST = YES; 504 | SWIFT_COMPILATION_MODE = wholemodule; 505 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 506 | SWIFT_STRICT_CONCURRENCY = complete; 507 | SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; 508 | SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; 509 | TVOS_DEPLOYMENT_TARGET = 13.0; 510 | VALIDATE_PRODUCT = YES; 511 | VERSIONING_SYSTEM = "apple-generic"; 512 | VERSION_INFO_PREFIX = ""; 513 | WATCHOS_DEPLOYMENT_TARGET = 6.0; 514 | }; 515 | name = Release; 516 | }; 517 | EB443F9627C6BCA70005CCEA /* Debug */ = { 518 | isa = XCBuildConfiguration; 519 | buildSettings = { 520 | CODE_SIGN_STYLE = Manual; 521 | CURRENT_PROJECT_VERSION = 1; 522 | DEAD_CODE_STRIPPING = YES; 523 | DEFINES_MODULE = YES; 524 | DEVELOPMENT_TEAM = ""; 525 | DYLIB_COMPATIBILITY_VERSION = 1; 526 | DYLIB_CURRENT_VERSION = 1; 527 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 528 | ENABLE_MODULE_VERIFIER = YES; 529 | GENERATE_INFOPLIST_FILE = YES; 530 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 531 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 532 | LD_RUNPATH_SEARCH_PATHS = ( 533 | "$(inherited)", 534 | "@executable_path/Frameworks", 535 | "@loader_path/Frameworks", 536 | ); 537 | MACOSX_DEPLOYMENT_TARGET = 11.0; 538 | MARKETING_VERSION = 0.2.0; 539 | MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; 540 | MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; 541 | PRODUCT_BUNDLE_IDENTIFIER = com.starry.CombineCoreBluetooth; 542 | PRODUCT_NAME = CombineCoreBluetooth; 543 | PROVISIONING_PROFILE_SPECIFIER = ""; 544 | "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; 545 | SKIP_INSTALL = YES; 546 | SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; 547 | SUPPORTS_MACCATALYST = YES; 548 | SWIFT_EMIT_LOC_STRINGS = YES; 549 | SWIFT_VERSION = 5.0; 550 | }; 551 | name = Debug; 552 | }; 553 | EB443F9727C6BCA70005CCEA /* Release */ = { 554 | isa = XCBuildConfiguration; 555 | buildSettings = { 556 | CODE_SIGN_STYLE = Manual; 557 | CURRENT_PROJECT_VERSION = 1; 558 | DEAD_CODE_STRIPPING = YES; 559 | DEFINES_MODULE = YES; 560 | DEVELOPMENT_TEAM = ""; 561 | DYLIB_COMPATIBILITY_VERSION = 1; 562 | DYLIB_CURRENT_VERSION = 1; 563 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 564 | ENABLE_MODULE_VERIFIER = YES; 565 | GENERATE_INFOPLIST_FILE = YES; 566 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 567 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 568 | LD_RUNPATH_SEARCH_PATHS = ( 569 | "$(inherited)", 570 | "@executable_path/Frameworks", 571 | "@loader_path/Frameworks", 572 | ); 573 | MACOSX_DEPLOYMENT_TARGET = 11.0; 574 | MARKETING_VERSION = 0.2.0; 575 | MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; 576 | MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; 577 | PRODUCT_BUNDLE_IDENTIFIER = com.starry.CombineCoreBluetooth; 578 | PRODUCT_NAME = CombineCoreBluetooth; 579 | PROVISIONING_PROFILE_SPECIFIER = ""; 580 | "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; 581 | SKIP_INSTALL = YES; 582 | SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; 583 | SUPPORTS_MACCATALYST = YES; 584 | SWIFT_EMIT_LOC_STRINGS = YES; 585 | SWIFT_VERSION = 5.0; 586 | }; 587 | name = Release; 588 | }; 589 | EB443FE127C6C1940005CCEA /* Debug */ = { 590 | isa = XCBuildConfiguration; 591 | buildSettings = { 592 | CODE_SIGN_STYLE = Automatic; 593 | CURRENT_PROJECT_VERSION = 1; 594 | DEAD_CODE_STRIPPING = YES; 595 | GENERATE_INFOPLIST_FILE = YES; 596 | MACOSX_DEPLOYMENT_TARGET = 11.0; 597 | MARKETING_VERSION = 1.0; 598 | PRODUCT_BUNDLE_IDENTIFIER = "com.starry.CombineCoreBluetooth-iOSTests"; 599 | PRODUCT_NAME = "$(TARGET_NAME)"; 600 | SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; 601 | SUPPORTS_MACCATALYST = YES; 602 | SWIFT_EMIT_LOC_STRINGS = NO; 603 | SWIFT_VERSION = 5.0; 604 | }; 605 | name = Debug; 606 | }; 607 | EB443FE227C6C1940005CCEA /* Release */ = { 608 | isa = XCBuildConfiguration; 609 | buildSettings = { 610 | CODE_SIGN_STYLE = Automatic; 611 | CURRENT_PROJECT_VERSION = 1; 612 | DEAD_CODE_STRIPPING = YES; 613 | GENERATE_INFOPLIST_FILE = YES; 614 | MACOSX_DEPLOYMENT_TARGET = 11.0; 615 | MARKETING_VERSION = 1.0; 616 | PRODUCT_BUNDLE_IDENTIFIER = "com.starry.CombineCoreBluetooth-iOSTests"; 617 | PRODUCT_NAME = "$(TARGET_NAME)"; 618 | SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; 619 | SUPPORTS_MACCATALYST = YES; 620 | SWIFT_EMIT_LOC_STRINGS = NO; 621 | SWIFT_VERSION = 5.0; 622 | }; 623 | name = Release; 624 | }; 625 | /* End XCBuildConfiguration section */ 626 | 627 | /* Begin XCConfigurationList section */ 628 | 00371A0527EE588C00C2F766 /* Build configuration list for PBXNativeTarget "CentralPeripheralDemo" */ = { 629 | isa = XCConfigurationList; 630 | buildConfigurations = ( 631 | 00371A0327EE588C00C2F766 /* Debug */, 632 | 00371A0427EE588C00C2F766 /* Release */, 633 | ); 634 | defaultConfigurationIsVisible = 0; 635 | defaultConfigurationName = Release; 636 | }; 637 | EB443F8827C6BCA70005CCEA /* Build configuration list for PBXProject "CombineCoreBluetooth" */ = { 638 | isa = XCConfigurationList; 639 | buildConfigurations = ( 640 | EB443F9327C6BCA70005CCEA /* Debug */, 641 | EB443F9427C6BCA70005CCEA /* Release */, 642 | ); 643 | defaultConfigurationIsVisible = 0; 644 | defaultConfigurationName = Release; 645 | }; 646 | EB443F9527C6BCA70005CCEA /* Build configuration list for PBXNativeTarget "CombineCoreBluetooth" */ = { 647 | isa = XCConfigurationList; 648 | buildConfigurations = ( 649 | EB443F9627C6BCA70005CCEA /* Debug */, 650 | EB443F9727C6BCA70005CCEA /* Release */, 651 | ); 652 | defaultConfigurationIsVisible = 0; 653 | defaultConfigurationName = Release; 654 | }; 655 | EB443FE027C6C1940005CCEA /* Build configuration list for PBXNativeTarget "CombineCoreBluetooth Tests" */ = { 656 | isa = XCConfigurationList; 657 | buildConfigurations = ( 658 | EB443FE127C6C1940005CCEA /* Debug */, 659 | EB443FE227C6C1940005CCEA /* Release */, 660 | ); 661 | defaultConfigurationIsVisible = 0; 662 | defaultConfigurationName = Release; 663 | }; 664 | /* End XCConfigurationList section */ 665 | 666 | /* Begin XCRemoteSwiftPackageReference section */ 667 | 006CA4082ACB7D2300DDA85D /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */ = { 668 | isa = XCRemoteSwiftPackageReference; 669 | repositoryURL = "https://github.com/pointfreeco/swift-concurrency-extras.git"; 670 | requirement = { 671 | kind = versionRange; 672 | maximumVersion = 2.0.0; 673 | minimumVersion = 0.1.0; 674 | }; 675 | }; 676 | /* End XCRemoteSwiftPackageReference section */ 677 | 678 | /* Begin XCSwiftPackageProductDependency section */ 679 | 0014FFB127F0359600D2A122 /* CombineCoreBluetooth */ = { 680 | isa = XCSwiftPackageProductDependency; 681 | productName = CombineCoreBluetooth; 682 | }; 683 | 006CA4092ACB7D2300DDA85D /* ConcurrencyExtras */ = { 684 | isa = XCSwiftPackageProductDependency; 685 | package = 006CA4082ACB7D2300DDA85D /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */; 686 | productName = ConcurrencyExtras; 687 | }; 688 | /* End XCSwiftPackageProductDependency section */ 689 | }; 690 | rootObject = EB443F8527C6BCA70005CCEA /* Project object */; 691 | } 692 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcodeproj/xcshareddata/xcschemes/CentralPeripheralDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcodeproj/xcshareddata/xcschemes/CombineCoreBluetooth.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CombineCoreBluetooth.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-concurrency-extras", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 7 | "state" : { 8 | "revision" : "ea631ce892687f5432a833312292b80db238186a", 9 | "version" : "1.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Starry, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-concurrency-extras", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 7 | "state" : { 8 | "revision" : "ea631ce892687f5432a833312292b80db238186a", 9 | "version" : "1.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CombineCoreBluetooth", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v11), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | ], 14 | products: [ 15 | .library( 16 | name: "CombineCoreBluetooth", 17 | targets: ["CombineCoreBluetooth"] 18 | ), 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", "0.1.0"..<"2.0.0"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "CombineCoreBluetooth", 26 | dependencies: [ 27 | ], 28 | swiftSettings: [ 29 | ] 30 | ), 31 | .testTarget( 32 | name: "CombineCoreBluetoothTests", 33 | dependencies: [ 34 | "CombineCoreBluetooth", 35 | .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), 36 | ] 37 | ), 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CombineCoreBluetooth 2 | 3 | [![CI](https://github.com/StarryInternet/CombineCoreBluetooth/actions/workflows/ci.yml/badge.svg)](https://github.com/StarryInternet/CombineCoreBluetooth/actions/workflows/ci.yml) 4 | [![GitHub](https://img.shields.io/github/license/StarryInternet/CombineCoreBluetooth)](https://github.com/StarryInternet/CombineCoreBluetooth/blob/master/LICENSE) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStarryInternet%2FCombineCoreBluetooth%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/StarryInternet/CombineCoreBluetooth) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStarryInternet%2FCombineCoreBluetooth%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/StarryInternet/CombineCoreBluetooth) 7 | 8 | CombineCoreBluetooth is a library that bridges Apple's `CoreBluetooth` framework and Apple's `Combine` framework, making it possible to subscribe to perform bluetooth operations while subscribing to a publisher of the results of those operations, instead of relying on implementing delegates and manually filtering for the results you need. 9 | 10 | ## Requirements: 11 | 12 | - iOS 13, tvOS 13, macOS 11, or watchOS 6 13 | - Xcode 15 or higher 14 | - Swift 5.9 or higher 15 | 16 | (If you are using carthage, or if you wish to open the project to play with the demo app, you will need to use Xcode 16) 17 | 18 | ## Installation 19 | 20 | ### Swift Package Manager 21 | 22 | Add this line to your dependencies list in your Package.swift: 23 | 24 | ```swift 25 | .package(name: "CombineCoreBluetooth", url: "https://github.com/StarryInternet/CombineCoreBluetooth.git", from: "0.8.0"), 26 | ``` 27 | 28 | ### Cocoapods 29 | 30 | Add this line to your Podfile: 31 | 32 | ```ruby 33 | pod 'CombineCoreBluetooth' 34 | ``` 35 | 36 | ### Carthage 37 | 38 | Add this line to your Cartfile: 39 | 40 | ``` 41 | github "StarryInternet/CombineCoreBluetooth" 42 | ``` 43 | 44 | ## Usage 45 | 46 | This library is heavily inspired by [pointfree.co's approach](https://www.pointfree.co/collections/dependencies) to designing dependencies, but with some customizations. Many asynchronous operations returns their own `Publisher` or expose their own long-lived publisher you can subscribe to. 47 | 48 | This library doesn't maintain any additional state beyond what's needed to enable this library to provide a combine-centric API. This means that you are responsible for maintaining any state necessary, including holding onto any `Peripheral`s returned by discovering and connected to via the `CentralManager` type. 49 | 50 | To scan for a peripheral, much like in plain CoreBluetooth, you call the `scanForPeripherals(withServices:options:)` method. However, on this library's `CentralManager` type, this returns a publisher of `PeripheralDiscovery` values. If you want to store a peripheral for later use, you could subscribe to the returned publisher by doing something like this: 51 | 52 | ```swift 53 | let serviceID = CBUUID(string: "0123") 54 | 55 | centralManager.scanForPeripherals(withServices: [serviceID]) 56 | .first() 57 | .assign(to: \.peripheralDiscovery, on: self) // property of type PeripheralDiscovery 58 | .store(in: &cancellables) 59 | ``` 60 | 61 | To do something like fetching a value from a characteristic, for instance, you could call the following methods on the `Peripheral` type and subscribe to the resulting `Publisher`: 62 | 63 | ```swift 64 | // use whatever ids your peripheral advertises here 65 | let characteristicID = CBUUID(string: "4567") 66 | 67 | peripheralDiscovery.peripheral 68 | .readValue(forCharacteristic: characteristicID, inService: serviceID) 69 | .sink(receiveCompletion: { completion in 70 | // handle any potential errors here 71 | }, receiveValue: { data in 72 | // handle data from characteristic here, or add more publisher methods to map and transform it. 73 | }) 74 | .store(in: &cancellables) 75 | ``` 76 | 77 | The publisher returned in `readValue` will only send values that match the service and characteristic IDs through to any subscribers, so you don't need to worry about any filtering logic yourself. Note that if the `Peripheral` never receives a value from this characteristic over bluetooth, it will never send a value into the publisher, so you may want to add a timeout if your use case requires it. 78 | 79 | ## Caveats 80 | 81 | All major types from `CoreBluetooth` should be available in this library, wrapped in their own types to provide the `Combine`-centric API. This library has been tested in production for most `CentralManager` related operations. Apps acting as bluetooth peripherals are also supported using the `PeripheralManager` type, but that side hasn't been as rigorously tested. 82 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Central/Interface+Central.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | public struct Central: Sendable { 5 | let rawValue: CBCentral? 6 | 7 | public let identifier: UUID 8 | 9 | public var _maximumUpdateValueLength: @Sendable () -> Int 10 | 11 | public var maximumUpdateValueLength: Int { 12 | _maximumUpdateValueLength() 13 | } 14 | } 15 | 16 | extension Central: Identifiable { 17 | public var id: UUID { 18 | identifier 19 | } 20 | } 21 | 22 | extension Central: Equatable { 23 | public static func == (lhs: Self, rhs: Self) -> Bool { 24 | lhs.identifier == rhs.identifier 25 | } 26 | } 27 | 28 | extension Central: Hashable { 29 | public func hash(into hasher: inout Hasher) { 30 | hasher.combine(identifier) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Central/Live+Central.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | extension Central { 5 | init(cbcentral: CBCentral) { 6 | self.init( 7 | rawValue: cbcentral, 8 | identifier: cbcentral.identifier, 9 | _maximumUpdateValueLength: { cbcentral.maximumUpdateValueLength } 10 | ) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Central/Mock+Central.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Central { 4 | public static func unimplemented( 5 | identifier: UUID, 6 | maximumUpdateValueLength: @escaping @Sendable () -> Int = _Internal._unimplemented("maximumUpdateValueLength") 7 | ) -> Self { 8 | return .init( 9 | rawValue: nil, 10 | identifier: identifier, 11 | _maximumUpdateValueLength: maximumUpdateValueLength 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/CentralManager/Interface+CentralManager.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import Combine 2 | @preconcurrency import CoreBluetooth 3 | import Foundation 4 | 5 | public struct CentralManager: Sendable { 6 | #if os(macOS) && !targetEnvironment(macCatalyst) 7 | public typealias Feature = Never 8 | #else 9 | public typealias Feature = CBCentralManager.Feature 10 | #endif 11 | let delegate: Delegate? 12 | 13 | public var _state: @Sendable () -> CBManagerState 14 | public var _authorization: @Sendable () -> CBManagerAuthorization 15 | public var _isScanning: @Sendable () -> Bool 16 | 17 | public var _supportsFeatures: @Sendable (_ feature: Feature) -> Bool 18 | 19 | public var _retrievePeripheralsWithIdentifiers: @Sendable ([UUID]) -> [Peripheral] 20 | public var _retrieveConnectedPeripheralsWithServices: @Sendable ([CBUUID]) -> [Peripheral] 21 | public var _scanForPeripheralsWithServices: @Sendable (_ serviceUUIDs: [CBUUID]?, _ options: ScanOptions?) -> Void 22 | public var _stopScan: @Sendable () -> Void 23 | 24 | public var _connectToPeripheral: @Sendable (Peripheral, _ options: PeripheralConnectionOptions?) -> Void 25 | public var _cancelPeripheralConnection: @Sendable (_ peripheral: Peripheral) -> Void 26 | public var _registerForConnectionEvents: @Sendable (_ options: [CBConnectionEventMatchingOption : Any]?) -> Void 27 | 28 | public var didUpdateState: AnyPublisher 29 | public var willRestoreState: AnyPublisher<[String: Any], Never> 30 | public var didConnectPeripheral: AnyPublisher 31 | public var didFailToConnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> 32 | public var didDisconnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> 33 | 34 | public var connectionEventDidOccur: AnyPublisher<(CBConnectionEvent, Peripheral), Never> 35 | public var didDiscoverPeripheral: AnyPublisher 36 | 37 | public var didUpdateACNSAuthorizationForPeripheral: AnyPublisher 38 | 39 | public var state: CBManagerState { 40 | _state() 41 | } 42 | 43 | public var authorization: CBManagerAuthorization { 44 | _authorization() 45 | } 46 | 47 | public var isScanning: Bool { 48 | _isScanning() 49 | } 50 | 51 | @available(macOS, unavailable) 52 | public func supports(_ features: Feature) -> Bool { 53 | #if os(macOS) && !targetEnvironment(macCatalyst) 54 | // do nothing 55 | #else 56 | return _supportsFeatures(features) 57 | #endif 58 | } 59 | 60 | public func retrievePeripherals(withIdentifiers identifiers: [UUID]) -> [Peripheral] { 61 | _retrievePeripheralsWithIdentifiers(identifiers) 62 | } 63 | 64 | public func retrieveConnectedPeripherals(withServices serviceUUIDs: [CBUUID]) -> [Peripheral] { 65 | _retrieveConnectedPeripheralsWithServices(serviceUUIDs) 66 | } 67 | 68 | /// Starts scanning for peripherals that are advertising any of the services listed in `serviceUUIDs` 69 | /// 70 | /// To stop scanning for peripherals, cancel the subscription made to the returned publisher. 71 | /// - Parameters: 72 | /// - serviceUUIDs: A list of `CBUUID` objects representing the service(s) to scan for. 73 | /// - options: An optional dictionary specifying options for the scan. 74 | /// - Returns: A publisher that sends values anytime peripherals are discovered that match the given service UUIDs. 75 | public func scanForPeripherals(withServices serviceUUIDs: [CBUUID]?, options: ScanOptions? = nil) -> AnyPublisher { 76 | didDiscoverPeripheral 77 | .handleEvents(receiveSubscription: { _ in 78 | _scanForPeripheralsWithServices(serviceUUIDs, options) 79 | }, receiveCancel: { 80 | _stopScan() 81 | }) 82 | .shareCurrentValue() 83 | .eraseToAnyPublisher() 84 | } 85 | 86 | public func connect(_ peripheral: Peripheral, options: PeripheralConnectionOptions? = nil) -> AnyPublisher { 87 | Publishers.Merge( 88 | didConnectPeripheral 89 | .filter { [id = peripheral.id] p in p.id == id } 90 | .setFailureType(to: Error.self), 91 | didFailToConnectPeripheral 92 | .filter { [id = peripheral.id] p, _ in p.id == id } 93 | .tryMap { _, error in 94 | throw error ?? CentralManagerError.unknownConnectionFailure 95 | } 96 | ) 97 | .prefix(1) 98 | .handleEvents(receiveSubscription: { _ in 99 | _connectToPeripheral(peripheral, options) 100 | }, receiveCancel: { 101 | _cancelPeripheralConnection(peripheral) 102 | }) 103 | .shareCurrentValue() 104 | .eraseToAnyPublisher() 105 | } 106 | 107 | public func cancelPeripheralConnection(_ peripheral: Peripheral) { 108 | _cancelPeripheralConnection(peripheral) 109 | } 110 | 111 | public func registerForConnectionEvents(options: [CBConnectionEventMatchingOption: Any]? = nil) { 112 | _registerForConnectionEvents(options) 113 | } 114 | 115 | /// Monitors connection events to the given peripheral and represents them as a publisher that sends `true` on connect and `false` on disconnect. 116 | /// - Parameter peripheral: The peripheral to monitor for connection events. 117 | /// - Returns: A publisher that sends `true` on connect and `false` on disconnect for the given peripheral. 118 | public func monitorConnection(for peripheral: Peripheral) -> AnyPublisher { 119 | Publishers.Merge( 120 | didConnectPeripheral 121 | .filter { [id = peripheral.id] p in p.id == id } 122 | .map { _ in true }, 123 | didDisconnectPeripheral 124 | .filter { [id = peripheral.id] p, _ in p.id == id } 125 | .map { _ in false } 126 | ) 127 | .eraseToAnyPublisher() 128 | } 129 | 130 | /// Configuration options used when creating a `CentralManager`. 131 | public struct CreationOptions: Sendable { 132 | /// If true, display a warning dialog to the user when the `CentralManager` is instantiated if Bluetooth is powered off 133 | public var showPowerAlert: Bool? 134 | /// A unique identifier for the Central Manager that's being instantiated. This identifier is used by the system to identify a specific CBCentralManager instance for restoration and, therefore, must remain the same for subsequent application executions in order for the manager to be restored. 135 | public var restoreIdentifierKey: String? 136 | 137 | public init(showPowerAlert: Bool? = nil, restoreIdentifierKey: String? = nil) { 138 | self.showPowerAlert = showPowerAlert 139 | self.restoreIdentifierKey = restoreIdentifierKey 140 | } 141 | } 142 | 143 | /// Options used when scanning for peripherals. 144 | public struct ScanOptions: Sendable { 145 | /// Whether or not the scan should filter duplicate peripheral discoveries 146 | public var allowDuplicates: Bool? 147 | /// Causes the scan to also look for peripherals soliciting any of the services contained in the list. 148 | public var solicitedServiceUUIDs: [CBUUID]? 149 | 150 | public init(allowDuplicates: Bool? = nil, solicitedServiceUUIDs: [CBUUID]? = nil) { 151 | self.allowDuplicates = allowDuplicates 152 | self.solicitedServiceUUIDs = solicitedServiceUUIDs 153 | } 154 | } 155 | 156 | /// Options used when connecting to a given `Peripheral` 157 | public struct PeripheralConnectionOptions { 158 | /// If true, indicates that the system should display a connection alert for a given peripheral, if the application is suspended when a successful connection is made. 159 | public var notifyOnConnection: Bool? 160 | /// If true, indicates that the system should display a disconnection alert for a given peripheral, if the application is suspended at the time of the disconnection. 161 | public var notifyOnDisconnection: Bool? 162 | /// if true, indicates that the system should display an alert for all notifications received from a given peripheral, if the application is suspended at the time. 163 | public var notifyOnNotification: Bool? 164 | /// The number of seconds for the system to wait before starting a connection. 165 | public var startDelay: TimeInterval? 166 | 167 | public init(notifyOnConnection: Bool? = nil, notifyOnDisconnection: Bool? = nil, notifyOnNotification: Bool? = nil, startDelay: TimeInterval? = nil) { 168 | self.notifyOnConnection = notifyOnConnection 169 | self.notifyOnDisconnection = notifyOnDisconnection 170 | self.notifyOnNotification = notifyOnNotification 171 | self.startDelay = startDelay 172 | } 173 | } 174 | 175 | @objc(CCBCentralManagerDelegate) 176 | class Delegate: NSObject, @unchecked Sendable { 177 | let didUpdateState: PassthroughSubject = .init() 178 | let willRestoreState: PassthroughSubject<[String: Any], Never> = .init() 179 | let didConnectPeripheral: PassthroughSubject = .init() 180 | let didFailToConnectPeripheral: PassthroughSubject<(Peripheral, Error?), Never> = .init() 181 | let didDisconnectPeripheral: PassthroughSubject<(Peripheral, Error?), Never> = .init() 182 | let connectionEventDidOccur: PassthroughSubject<(CBConnectionEvent, Peripheral), Never> = .init() 183 | let didDiscoverPeripheral: PassthroughSubject = .init() 184 | let didUpdateACNSAuthorizationForPeripheral: PassthroughSubject = .init() 185 | } 186 | 187 | @objc(CCBCentralManagerRestorableDelegate) 188 | final class RestorableDelegate: Delegate, @unchecked Sendable {} 189 | } 190 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/CentralManager/Live+CentralManager.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @preconcurrency import CoreBluetooth 3 | import Foundation 4 | 5 | extension CentralManager { 6 | public static func live(_ options: ManagerCreationOptions? = nil) -> Self { 7 | let delegate: Delegate = options?.restoreIdentifier != nil ? RestorableDelegate() : Delegate() 8 | let centralManager = CBCentralManager( 9 | delegate: delegate, 10 | queue: DispatchQueue(label: "combine-core-bluetooth.central-manager", target: .global()), 11 | options: options?.centralManagerDictionary 12 | ) 13 | 14 | return Self.init( 15 | delegate: delegate, 16 | _state: { centralManager.state }, 17 | _authorization: { 18 | if #available(iOS 13.1, *) { 19 | return CBCentralManager.authorization 20 | } else { 21 | return centralManager.authorization 22 | } 23 | }, 24 | _isScanning: { centralManager.isScanning }, 25 | _supportsFeatures: { 26 | #if os(macOS) && !targetEnvironment(macCatalyst) 27 | // will never be called on native macOS 28 | #else 29 | CBCentralManager.supports($0) 30 | #endif 31 | }, 32 | _retrievePeripheralsWithIdentifiers: { (identifiers) -> [Peripheral] in 33 | centralManager.retrievePeripherals(withIdentifiers: identifiers).map(Peripheral.init(cbperipheral:)) 34 | }, 35 | _retrieveConnectedPeripheralsWithServices: { (serviceIDs) -> [Peripheral] in 36 | centralManager.retrieveConnectedPeripherals(withServices: serviceIDs).map(Peripheral.init(cbperipheral:)) 37 | }, 38 | _scanForPeripheralsWithServices: { services, options in 39 | centralManager.scanForPeripherals(withServices: services, options: options?.dictionary) 40 | }, 41 | _stopScan: { centralManager.stopScan() }, 42 | _connectToPeripheral: { (peripheral, options) in 43 | centralManager.connect(peripheral.rawValue!, options: options?.dictionary) 44 | }, 45 | _cancelPeripheralConnection: { (peripheral) in 46 | centralManager.cancelPeripheralConnection(peripheral.rawValue!) 47 | }, 48 | _registerForConnectionEvents: { 49 | #if os(macOS) && !targetEnvironment(macCatalyst) 50 | fatalError("This method is not callable on native macOS") 51 | #else 52 | centralManager.registerForConnectionEvents(options: $0) 53 | #endif 54 | }, 55 | 56 | didUpdateState: delegate.didUpdateState.eraseToAnyPublisher(), 57 | willRestoreState: delegate.willRestoreState.eraseToAnyPublisher(), 58 | didConnectPeripheral: delegate.didConnectPeripheral.eraseToAnyPublisher(), 59 | didFailToConnectPeripheral: delegate.didFailToConnectPeripheral.eraseToAnyPublisher(), 60 | didDisconnectPeripheral: delegate.didDisconnectPeripheral.eraseToAnyPublisher(), 61 | connectionEventDidOccur: delegate.connectionEventDidOccur.eraseToAnyPublisher(), 62 | didDiscoverPeripheral: delegate.didDiscoverPeripheral.eraseToAnyPublisher(), 63 | didUpdateACNSAuthorizationForPeripheral: delegate.didUpdateACNSAuthorizationForPeripheral.eraseToAnyPublisher() 64 | ) 65 | } 66 | } 67 | 68 | extension CentralManager.ScanOptions { 69 | var dictionary: [String: Any] { 70 | var dict: [String: Any] = [:] 71 | dict[CBCentralManagerScanOptionAllowDuplicatesKey] = allowDuplicates 72 | dict[CBCentralManagerScanOptionSolicitedServiceUUIDsKey] = solicitedServiceUUIDs 73 | return dict 74 | } 75 | } 76 | 77 | extension CentralManager.PeripheralConnectionOptions { 78 | var dictionary: [String: Any] { 79 | var dict: [String: Any] = [:] 80 | dict[CBConnectPeripheralOptionNotifyOnConnectionKey] = notifyOnConnection 81 | dict[CBConnectPeripheralOptionNotifyOnDisconnectionKey] = notifyOnDisconnection 82 | dict[CBConnectPeripheralOptionNotifyOnNotificationKey] = notifyOnNotification 83 | dict[CBConnectPeripheralOptionStartDelayKey] = startDelay 84 | return dict 85 | } 86 | } 87 | 88 | extension CentralManager.Delegate: CBCentralManagerDelegate { 89 | func centralManagerDidUpdateState(_ central: CBCentralManager) { 90 | didUpdateState.send(central.state) 91 | } 92 | 93 | func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 94 | didConnectPeripheral.send(Peripheral(cbperipheral: peripheral)) 95 | } 96 | 97 | func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { 98 | didFailToConnectPeripheral.send((Peripheral(cbperipheral: peripheral), error)) 99 | } 100 | 101 | func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 102 | didDisconnectPeripheral.send((Peripheral(cbperipheral: peripheral), error)) 103 | } 104 | 105 | func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { 106 | didDiscoverPeripheral.send( 107 | PeripheralDiscovery( 108 | peripheral: Peripheral(cbperipheral: peripheral), 109 | advertisementData: AdvertisementData(advertisementData), 110 | rssi: RSSI.doubleValue 111 | ) 112 | ) 113 | } 114 | 115 | #if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst) 116 | func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { 117 | connectionEventDidOccur.send((event, Peripheral(cbperipheral: peripheral))) 118 | } 119 | 120 | func centralManager(_ central: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) { 121 | didUpdateACNSAuthorizationForPeripheral.send(Peripheral(cbperipheral: peripheral)) 122 | } 123 | #endif 124 | } 125 | 126 | extension CentralManager.RestorableDelegate { 127 | func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { 128 | willRestoreState.send(dict) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/CentralManager/Mock+CentralManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import CoreBluetooth 4 | 5 | extension CentralManager { 6 | public static func unimplemented( 7 | state: @escaping @Sendable () -> CBManagerState = _Internal._unimplemented("state"), 8 | authorization: @escaping @Sendable () -> CBManagerAuthorization = _Internal._unimplemented("authorization"), 9 | isScanning: @escaping @Sendable () -> Bool = _Internal._unimplemented("isScanning"), 10 | supportsFeatures: @escaping @Sendable (Feature) -> Bool = _Internal._unimplemented("supportsFeatures"), 11 | retrievePeripheralsWithIdentifiers: @escaping @Sendable ([UUID]) -> [Peripheral] = _Internal._unimplemented("retrievePeripheralsWithIdentifiers"), 12 | retrieveConnectedPeripheralsWithServices: @escaping @Sendable ([CBUUID]) -> [Peripheral] = _Internal._unimplemented("retrieveConnectedPeripheralsWithServices"), 13 | scanForPeripheralsWithServices: @escaping @Sendable ([CBUUID]?, ScanOptions?) -> Void = _Internal._unimplemented("scanForPeripheralsWithServices"), 14 | stopScanForPeripherals: @escaping @Sendable () -> Void = _Internal._unimplemented("stopScanForPeripherals"), 15 | connectToPeripheral: @escaping @Sendable (Peripheral, PeripheralConnectionOptions?) -> Void = _Internal._unimplemented("connectToPeripheral"), 16 | cancelPeripheralConnection: @escaping @Sendable (Peripheral) -> Void = _Internal._unimplemented("cancelPeripheralConnection"), 17 | registerForConnectionEvents: @escaping @Sendable ([CBConnectionEventMatchingOption : Any]?) -> Void = _Internal._unimplemented("registerForConnectionEvents"), 18 | 19 | didUpdateState: AnyPublisher = _Internal._unimplemented("didUpdateState"), 20 | willRestoreState: AnyPublisher<[String: Any], Never> = _Internal._unimplemented("willRestoreState"), 21 | didConnectPeripheral: AnyPublisher = _Internal._unimplemented("didConnectPeripheral"), 22 | didFailToConnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> = _Internal._unimplemented("didFailToConnectToPeripheral"), 23 | didDisconnectPeripheral: AnyPublisher<(Peripheral, Error?), Never> = _Internal._unimplemented("didDisconnectPeripheral"), 24 | 25 | connectionEventDidOccur: AnyPublisher<(CBConnectionEvent, Peripheral), Never> = _Internal._unimplemented("connectionEventDidOccur"), 26 | didDiscoverPeripheral: AnyPublisher = _Internal._unimplemented("didDiscoverPeripheral"), 27 | 28 | didUpdateACNSAuthorizationForPeripheral: AnyPublisher = _Internal._unimplemented("didUpdateACNSAuthorizationForPeripheral") 29 | ) -> Self { 30 | return Self( 31 | delegate: nil, 32 | _state: state, 33 | _authorization: authorization, 34 | _isScanning: isScanning, 35 | _supportsFeatures: supportsFeatures, 36 | _retrievePeripheralsWithIdentifiers: retrievePeripheralsWithIdentifiers, 37 | _retrieveConnectedPeripheralsWithServices: retrieveConnectedPeripheralsWithServices, 38 | _scanForPeripheralsWithServices: scanForPeripheralsWithServices, 39 | _stopScan: stopScanForPeripherals, 40 | _connectToPeripheral: connectToPeripheral, 41 | _cancelPeripheralConnection: cancelPeripheralConnection, 42 | _registerForConnectionEvents: registerForConnectionEvents, 43 | 44 | didUpdateState: didUpdateState, 45 | willRestoreState: willRestoreState, 46 | didConnectPeripheral: didConnectPeripheral, 47 | didFailToConnectPeripheral: didFailToConnectPeripheral, 48 | didDisconnectPeripheral: didDisconnectPeripheral, 49 | connectionEventDidOccur: connectionEventDidOccur, 50 | didDiscoverPeripheral: didDiscoverPeripheral, 51 | didUpdateACNSAuthorizationForPeripheral: didUpdateACNSAuthorizationForPeripheral 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Internal/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import Combine 2 | @_exported import CoreBluetooth 3 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Internal/Publisher+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Publisher { 4 | /// A variation on [share()](https://developer.apple.com/documentation/combine/publisher/3204754-share) 5 | /// that allows for buffering and replaying the most recent value to subscribers that connect after the most recent value is sent. 6 | /// 7 | /// - Returns: A publisher that replays latest value event to future subscribers. 8 | func shareCurrentValue() -> AnyPublisher { 9 | map(Optional.some) 10 | .multicast(subject: CurrentValueSubject(nil)) 11 | .autoconnect() 12 | .compactMap { $0 } 13 | .eraseToAnyPublisher() 14 | } 15 | 16 | /// Applies the predicate to the first item in the Output's tuple; if it matches, take the first element from that and pass it along or throw any errors that are present. 17 | func filterFirstValueOrThrow(where predicate: @escaping (Value) -> Bool) -> Publishers.TryMap, Value> where Output == (Value, Error?) { 18 | filterFirstValueOrThrow(where: { value, error in 19 | predicate(value) 20 | }) 21 | } 22 | 23 | /// Applies the predicate to the first item in the Output's tuple; if it matches, take the first element from that and pass it along or throw any errors that are present. 24 | func filterFirstValueOrThrow(where predicate: @escaping (Value, Error?) -> Bool) -> Publishers.TryMap, Value> where Output == (Value, Error?) { 25 | first(where: predicate) 26 | .selectValueOrThrowError() 27 | } 28 | 29 | func selectValueOrThrowError() -> Publishers.TryMap where Output == (Value, Error?) { 30 | tryMap { value, error in 31 | if let error = error { 32 | throw error 33 | } 34 | return value 35 | } 36 | } 37 | 38 | func ignoreOutput(setOutputType newOutputType: NewOutput.Type) -> Publishers.Map, NewOutput> { 39 | ignoreOutput().map { _ -> NewOutput in } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Internal/Unimplemented.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum _Internal { 4 | public static func _unimplemented( 5 | _ function: StaticString, file: StaticString = #file, line: UInt = #line 6 | ) -> @Sendable () -> Output { 7 | return { 8 | fatalError( 9 | """ 10 | `\(function)` was called but is not implemented. Be sure to provide an implementation for 11 | this endpoint when creating the mock. 12 | """, 13 | file: file, 14 | line: line 15 | ) 16 | } 17 | } 18 | 19 | public static func _unimplemented( 20 | _ function: StaticString, file: StaticString = #file, line: UInt = #line 21 | ) -> @Sendable (Input) -> Output { 22 | return { _ in 23 | fatalError( 24 | """ 25 | `\(function)` was called but is not implemented. Be sure to provide an implementation for 26 | this endpoint when creating the mock. 27 | """, 28 | file: file, 29 | line: line 30 | ) 31 | } 32 | } 33 | 34 | public static func _unimplemented( 35 | _ function: StaticString, file: StaticString = #file, line: UInt = #line 36 | ) -> @Sendable (Input1, Input2) -> Output { 37 | return { _, _ in 38 | fatalError( 39 | """ 40 | `\(function)` was called but is not implemented. Be sure to provide an implementation for 41 | this endpoint when creating the mock. 42 | """, 43 | file: file, 44 | line: line 45 | ) 46 | } 47 | } 48 | 49 | public static func _unimplemented( 50 | _ function: StaticString, file: StaticString = #file, line: UInt = #line 51 | ) -> @Sendable (Input1, Input2, Input3) -> Output { 52 | return { _, _, _ in 53 | fatalError( 54 | """ 55 | `\(function)` was called but is not implemented. Be sure to provide an implementation for 56 | this endpoint when creating the mock. 57 | """, 58 | file: file, 59 | line: line 60 | ) 61 | } 62 | } 63 | 64 | public static func _unimplemented( 65 | _ publisher: StaticString, file: StaticString = #file, line: UInt = #line 66 | ) -> AnyPublisher { 67 | Deferred> { 68 | fatalError( 69 | """ 70 | `\(publisher)` was subscribed to but is not implemented. Be sure to provide an implementation for 71 | this publisher when creating the mock. 72 | """, 73 | file: file, 74 | line: line 75 | ) 76 | }.eraseToAnyPublisher() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/ATTRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | public struct ATTRequest: Sendable { 5 | let rawValue: CBATTRequest? 6 | 7 | public let central: Central 8 | public let characteristic: CBCharacteristic 9 | public let offset: Int 10 | public var value: Data? { 11 | didSet { 12 | rawValue?.value = value 13 | } 14 | } 15 | } 16 | 17 | extension ATTRequest { 18 | init(cbattrequest: CBATTRequest) { 19 | self.init( 20 | rawValue: cbattrequest, 21 | central: .init(cbcentral: cbattrequest.central), 22 | characteristic: cbattrequest.characteristic, 23 | offset: cbattrequest.offset, 24 | value: cbattrequest.value 25 | ) 26 | } 27 | } 28 | 29 | extension ATTRequest { 30 | public init( 31 | central: Central, 32 | characteristic: CBCharacteristic, 33 | offset: Int, 34 | value: Data? 35 | ) { 36 | self.init( 37 | rawValue: nil, 38 | central: central, 39 | characteristic: characteristic, 40 | offset: offset, 41 | value: value 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/AdvertisementData.swift: -------------------------------------------------------------------------------- 1 | import CoreBluetooth 2 | import Foundation 3 | 4 | /// Various kinds of data that are advertised by peripherals and obtained by the ``CentralManager`` during scanning. 5 | public struct AdvertisementData: @unchecked Sendable { 6 | let dictionary: [String: Any] 7 | 8 | /// Initializes the advertisement data with the given dictionary, ideally obtained from `CoreBluetooth` itself. 9 | /// - Parameter dictionary: The advertisement data dictionary that backs all the properties of this type. 10 | public init(_ dictionary: [String: Any] = [:]) { 11 | self.dictionary = dictionary 12 | } 13 | 14 | /// Initializes the advertisement data with the given dictionary, ideally obtained from `CoreBluetooth` itself. 15 | /// - Parameter dictionary: The advertisement data dictionary that backs all the properties of this type. 16 | public init(_ dictionary: [Key: Any]) { 17 | self.dictionary = Dictionary(uniqueKeysWithValues: dictionary.map({ (key, value) in 18 | (key.rawValue, value) 19 | })) 20 | } 21 | 22 | /// The local name of a peripheral 23 | public var localName: String? { 24 | dictionary[Key.localName.rawValue] as? String 25 | } 26 | 27 | /// The transmit power of a peripheral. 28 | public var txPowerLevel: Double? { 29 | (dictionary[Key.txPowerLevel.rawValue] as? NSNumber)?.doubleValue 30 | } 31 | 32 | /// An array of advertised service UUIDs of a peripheral 33 | public var serviceUUIDs: [CBUUID]? { 34 | dictionary[Key.serviceUUIDs.rawValue] as? [CBUUID] 35 | } 36 | 37 | /// A dictionary that contains service-specific advertisement data. 38 | public var serviceData: [CBUUID: Data]? { 39 | dictionary[Key.serviceData.rawValue] as? [CBUUID: Data] 40 | } 41 | 42 | /// Manufacturer data of a peripheral 43 | public var manufacturerData: Data? { 44 | dictionary[Key.manufacturerData.rawValue] as? Data 45 | } 46 | 47 | /// An array of UUIDs found in the overflow area of the advertisement data. 48 | public var overflowServiceUUIDs: [CBUUID]? { 49 | dictionary[Key.overflowServiceUUIDs.rawValue] as? [CBUUID] 50 | } 51 | 52 | /// A Boolean value that indicates whether the advertising event type is connectable. 53 | public var isConnectable: Bool? { 54 | (dictionary[Key.isConnectable.rawValue] as? NSNumber)?.boolValue 55 | } 56 | 57 | // An array of solicited service UUIDs. 58 | public var solicitedServiceUUIDs: [CBUUID]? { 59 | dictionary[Key.solicitedServiceUUIDs.rawValue] as? [CBUUID] 60 | } 61 | 62 | /// Keys that may reference data in the ``AdvertisementData`` obtained by searching for peripherals. 63 | public struct Key: Equatable, Hashable, Sendable { 64 | public let rawValue: String 65 | 66 | private init(_ rawValue: String) { 67 | self.rawValue = rawValue 68 | } 69 | 70 | /// Key referencing the local name of a peripheral. Wrapper around `CBAdvertisementDataLocalNameKey` in `CoreBluetooth` 71 | public static let localName: Key = .init(CBAdvertisementDataLocalNameKey) 72 | /// Key referencing the transmit power of a peripheral. Wrapper around `CBAdvertisementDataTxPowerLevelKey` in `CoreBluetooth` 73 | public static let txPowerLevel: Key = .init(CBAdvertisementDataTxPowerLevelKey) 74 | /// Key referencing an array of advertised service UUIDs. Wrapper around `CBAdvertisementDataServiceUUIDsKey` in `CoreBluetooth` 75 | public static let serviceUUIDs: Key = .init(CBAdvertisementDataServiceUUIDsKey) 76 | /// Key referencing a dictionary that contains service-specific advertisement data. Wrapper around `CBAdvertisementDataServiceDataKey` in `CoreBluetooth` 77 | public static let serviceData: Key = .init(CBAdvertisementDataServiceDataKey) 78 | /// Key referencing the manufacturer data of a peripheral. Wrapper around `CBAdvertisementDataManufacturerDataKey` in `CoreBluetooth` 79 | public static let manufacturerData: Key = .init(CBAdvertisementDataManufacturerDataKey) 80 | /// Key referencing an array of UUIDs found in the overflow area of the advertisement data. Wrapper around `CBAdvertisementDataOverflowServiceUUIDsKey` in `CoreBluetooth` 81 | public static let overflowServiceUUIDs: Key = .init(CBAdvertisementDataOverflowServiceUUIDsKey) 82 | /// Key referencing a boolean value that indicates whether the advertising event type is connectable. Wrapper around `CBAdvertisementDataIsConnectable` in `CoreBluetooth` 83 | public static let isConnectable: Key = .init(CBAdvertisementDataIsConnectable) 84 | /// Key referencing an array of solicited service UUIDs. Wrapper around `CBAdvertisementDataSolicitedServiceUUIDsKey` in `CoreBluetooth` 85 | public static let solicitedServiceUUIDs: Key = .init(CBAdvertisementDataSolicitedServiceUUIDsKey) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/CentralManagerError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum CentralManagerError: Error, Equatable, Sendable { 4 | /// Thrown if there's a failure during connection to a peripheral, but there's no error information 5 | case unknownConnectionFailure 6 | } 7 | 8 | extension CentralManagerError: LocalizedError { 9 | public var errorDescription: String? { 10 | switch self { 11 | case .unknownConnectionFailure: 12 | return "Unknown failure connecting to the peripheral." 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/L2CAPChannel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreBluetooth 3 | 4 | public struct L2CAPChannel { 5 | // Need to keep a reference to this so the system doesn't close the channel 6 | let rawValue: CBL2CAPChannel? 7 | public let peer: Peer 8 | public let inputStream: InputStream 9 | public let outputStream: OutputStream 10 | public let psm: CBL2CAPPSM 11 | } 12 | 13 | extension L2CAPChannel { 14 | init(channel: CBL2CAPChannel) { 15 | let peer: Peer 16 | if let peripheral = channel.peer as? CBPeripheral { 17 | peer = Peripheral(cbperipheral: peripheral) 18 | } else if let central = channel.peer as? CBCentral { 19 | peer = Central(cbcentral: central) 20 | } else { 21 | peer = AnyPeer(channel.peer) 22 | } 23 | 24 | self.init( 25 | rawValue: channel, 26 | peer: peer, 27 | inputStream: channel.inputStream, 28 | outputStream: channel.outputStream, 29 | psm: channel.psm 30 | ) 31 | } 32 | 33 | public init(peer: Peer, inputStream: InputStream, outputStream: OutputStream, psm: CBL2CAPPSM) { 34 | self.init( 35 | rawValue: nil, 36 | peer: peer, 37 | inputStream: inputStream, 38 | outputStream: outputStream, 39 | psm: psm 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/ManagerCreationOptions.swift: -------------------------------------------------------------------------------- 1 | import CoreBluetooth 2 | 3 | /// Configuration options used when creating a `CentralManager` or `PeripheralManager`. 4 | public struct ManagerCreationOptions: Sendable { 5 | /// If true, display a warning dialog to the user when the manager is instantiated if Bluetooth is powered off 6 | public var showPowerAlert: Bool? 7 | /// A unique identifier for the manager that's being instantiated. This identifier is used by the system to identify a specific manager instance for restoration and, therefore, must remain the same for subsequent application executions in order for the manager to be restored. 8 | public var restoreIdentifier: String? 9 | 10 | public init(showPowerAlert: Bool? = nil, restoreIdentifier: String? = nil) { 11 | self.showPowerAlert = showPowerAlert 12 | self.restoreIdentifier = restoreIdentifier 13 | } 14 | 15 | var centralManagerDictionary: [String: Any] { 16 | var dict: [String: Any] = [:] 17 | dict[CBCentralManagerOptionShowPowerAlertKey] = showPowerAlert 18 | dict[CBCentralManagerOptionRestoreIdentifierKey] = restoreIdentifier 19 | return dict 20 | } 21 | 22 | var peripheralManagerDictionary: [String: Any] { 23 | var dict: [String: Any] = [:] 24 | dict[CBPeripheralManagerOptionShowPowerAlertKey] = showPowerAlert 25 | dict[CBPeripheralManagerOptionRestoreIdentifierKey] = restoreIdentifier 26 | return dict 27 | } 28 | 29 | @available(*, deprecated, renamed: "restoreIdentifier") 30 | public var restoreIdentifierKey: String? { 31 | get { restoreIdentifier } 32 | set { restoreIdentifier = newValue } 33 | } 34 | 35 | @available(*, deprecated, renamed: "init(showPowerAlert:restoreIdentifier:)") 36 | public init(showPowerAlert: Bool? = nil, restoreIdentifierKey: String?) { 37 | self.init(showPowerAlert: showPowerAlert, restoreIdentifier: restoreIdentifierKey) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/Peer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | /// Protocol that represents either a `Central` or a `Peripheral` when either could be present. 5 | public protocol Peer: Sendable { 6 | var identifier: UUID { get } 7 | } 8 | 9 | extension Peripheral: Peer {} 10 | extension Central: Peer {} 11 | 12 | /// Wrapper for `CBPeer`s that (by some chance) are neither `Central`s nor `Peripheral`s. 13 | public struct AnyPeer: Peer { 14 | public let identifier: UUID 15 | let rawValue: CBPeer? 16 | 17 | public init(identifier: UUID) { 18 | self.identifier = identifier 19 | self.rawValue = nil 20 | } 21 | 22 | init(_ cbpeer: CBPeer) { 23 | self.identifier = cbpeer.identifier 24 | self.rawValue = cbpeer 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/PeripheralDiscovery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct PeripheralDiscovery: Identifiable, Sendable { 4 | public let peripheral: Peripheral 5 | public let advertisementData: AdvertisementData 6 | public let rssi: Double? 7 | 8 | public init(peripheral: Peripheral, advertisementData: AdvertisementData, rssi: Double? = nil) { 9 | self.peripheral = peripheral 10 | self.advertisementData = advertisementData 11 | self.rssi = rssi 12 | } 13 | 14 | public var id: UUID { 15 | peripheral.id 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/PeripheralError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | public enum PeripheralError: Error, Equatable, Sendable { 5 | case serviceNotFound(CBUUID) 6 | case characteristicNotFound(CBUUID) 7 | case descriptorNotFound(CBUUID, onCharacteristic: CBUUID) 8 | } 9 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Models/PeripheralManagerError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | public enum PeripheralManagerError: Error, Equatable, Sendable { 5 | /// Thrown if there's a failure during updating subscribed centrals of a characteristic's new value. 6 | case failedToUpdateCharacteristic(CBUUID) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Peripheral/Interface+Peripheral.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | @preconcurrency import Combine 4 | 5 | /// The `CombineCoreBluetooth` wrapper around `CBPeripheral`. 6 | public struct Peripheral: Sendable { 7 | let rawValue: CBPeripheral? 8 | let delegate: Delegate? 9 | 10 | public var _name: @Sendable () -> String? 11 | public var _identifier: @Sendable () -> UUID 12 | public var _state: @Sendable () -> CBPeripheralState 13 | public var _services: @Sendable () -> [CBService]? 14 | public var _canSendWriteWithoutResponse: @Sendable () -> Bool 15 | 16 | public var _ancsAuthorized: @Sendable () -> Bool 17 | 18 | public var _readRSSI: @Sendable () -> Void 19 | public var _discoverServices: @Sendable (_ serviceUUIDs: [CBUUID]?) -> Void 20 | public var _discoverIncludedServices: @Sendable (_ includedServiceUUIDs: [CBUUID]?, _ service: CBService) -> Void 21 | public var _discoverCharacteristics: @Sendable (_ characteristicUUIDs: [CBUUID]?, _ service: CBService) -> Void 22 | public var _readValueForCharacteristic: @Sendable (_ characteristic: CBCharacteristic) -> Void 23 | public var _maximumWriteValueLength: @Sendable (_ type: CBCharacteristicWriteType) -> Int 24 | public var _writeValueForCharacteristic: @Sendable (_ data: Data, _ characteristic: CBCharacteristic, _ type: CBCharacteristicWriteType) -> Void 25 | public var _setNotifyValue: @Sendable (_ enabled: Bool, _ characteristic: CBCharacteristic) -> Void 26 | public var _discoverDescriptors: @Sendable (_ characteristic: CBCharacteristic) -> Void 27 | public var _readValueForDescriptor: @Sendable (_ descriptor: CBDescriptor) -> Void 28 | public var _writeValueForDescriptor: @Sendable (_ data: Data, _ descriptor: CBDescriptor) -> Void 29 | public var _openL2CAPChannel: @Sendable (_ PSM: CBL2CAPPSM) -> Void 30 | 31 | public var didReadRSSI: AnyPublisher, Never> 32 | public var didDiscoverServices: AnyPublisher<([CBService], Error?), Never> 33 | public var didDiscoverIncludedServices: AnyPublisher<(CBService, Error?), Never> 34 | public var didDiscoverCharacteristics: AnyPublisher<(CBService, Error?), Never> 35 | public var didUpdateValueForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> 36 | public var didWriteValueForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> 37 | public var didUpdateNotificationState: AnyPublisher<(CBCharacteristic, Error?), Never> 38 | public var didDiscoverDescriptorsForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> 39 | public var didUpdateValueForDescriptor: AnyPublisher<(CBDescriptor, Error?), Never> 40 | public var didWriteValueForDescriptor: AnyPublisher<(CBDescriptor, Error?), Never> 41 | public var didOpenChannel: AnyPublisher<(L2CAPChannel?, Error?), Never> 42 | 43 | public var isReadyToSendWriteWithoutResponse: AnyPublisher 44 | public var nameUpdates: AnyPublisher 45 | public var invalidatedServiceUpdates: AnyPublisher<[CBService], Never> 46 | 47 | // MARK: - Implementations 48 | 49 | public var name: String? { 50 | _name() 51 | } 52 | 53 | public var identifier: UUID { 54 | _identifier() 55 | } 56 | 57 | public var state: CBPeripheralState { 58 | _state() 59 | } 60 | 61 | public var services: [CBService]? { 62 | _services() 63 | } 64 | 65 | public var canSendWriteWithoutResponse: Bool { 66 | _canSendWriteWithoutResponse() 67 | } 68 | 69 | @available(macOS, unavailable) 70 | public var ancsAuthorized: Bool { 71 | _ancsAuthorized() 72 | } 73 | 74 | public func readRSSI() -> AnyPublisher { 75 | didReadRSSI 76 | .tryMap { result in 77 | try result.get() 78 | } 79 | .first() 80 | .handleEvents(receiveSubscription: { [_readRSSI] _ in 81 | _readRSSI() 82 | }) 83 | .shareCurrentValue() 84 | } 85 | 86 | public func discoverServices(_ serviceUUIDs: [CBUUID]?) -> AnyPublisher<[CBService], Error> { 87 | didDiscoverServices 88 | .filterFirstValueOrThrow(where: { services in 89 | // nil identifiers means we want to discover anything we can 90 | guard let identifiers = serviceUUIDs else { return true } 91 | // Only progress if the peripheral contains all the services we are looking for. 92 | let neededUUIDs = Set(identifiers) 93 | let foundUUIDs = Set(services.map(\.uuid)) 94 | let allFound = foundUUIDs.isSuperset(of: neededUUIDs) 95 | return allFound 96 | }) // 97 | .handleEvents(receiveSubscription: { [_discoverServices] _ in 98 | _discoverServices(serviceUUIDs) 99 | }) 100 | .shareCurrentValue() 101 | } 102 | 103 | public func discoverIncludedServices(_ serviceUUIDS: [CBUUID]?, for service: CBService) -> AnyPublisher<[CBService]?, Error> { 104 | didDiscoverIncludedServices 105 | .filterFirstValueOrThrow(where: { discoveredService in 106 | // ignore characteristics from services we're not interested in. 107 | guard discoveredService.uuid == service.uuid else { return false } 108 | // nil identifiers means we want to discover anything we can 109 | guard let identifiers = serviceUUIDS else { return true } 110 | // Only progress if the discovered service contains all the included services we are looking for. 111 | let neededUUIDs = Set(identifiers) 112 | let foundUUIDs = Set((discoveredService.includedServices ?? []).map(\.uuid)) 113 | let allFound = foundUUIDs.isSuperset(of: neededUUIDs) 114 | return allFound 115 | }) 116 | .map(\.includedServices) 117 | .handleEvents(receiveSubscription: { [_discoverIncludedServices] _ in 118 | _discoverIncludedServices(serviceUUIDS, service) 119 | }) 120 | .shareCurrentValue() 121 | } 122 | 123 | public func discoverCharacteristics(_ characteristicUUIDs: [CBUUID]?, for service: CBService) -> AnyPublisher<[CBCharacteristic], Error> { 124 | didDiscoverCharacteristics 125 | .filterFirstValueOrThrow(where: { discoveredService in 126 | // ignore characteristics from services we're not interested in. 127 | guard discoveredService.uuid == service.uuid else { return false } 128 | // nil identifiers means we want to discover anything we can 129 | guard let identifiers = characteristicUUIDs else { return true } 130 | // Only progress if the discovered service contains all the characteristics we are looking for. 131 | let neededUUIDs = Set(identifiers) 132 | let foundUUIDs = Set((discoveredService.characteristics ?? []).map(\.uuid)) 133 | let allFound = foundUUIDs.isSuperset(of: neededUUIDs) 134 | return allFound 135 | }) 136 | .map({ $0.characteristics ?? [] }) 137 | .handleEvents(receiveSubscription: { [_discoverCharacteristics] _ in 138 | _discoverCharacteristics(characteristicUUIDs, service) 139 | }) 140 | .shareCurrentValue() 141 | } 142 | 143 | public func readValue(for characteristic: CBCharacteristic) -> AnyPublisher { 144 | didUpdateValueForCharacteristic 145 | .filterFirstValueOrThrow(where: { 146 | $0.uuid == characteristic.uuid 147 | }) 148 | .map(\.value) 149 | .handleEvents(receiveSubscription: { [_readValueForCharacteristic] _ in 150 | _readValueForCharacteristic(characteristic) 151 | }) 152 | .shareCurrentValue() 153 | } 154 | 155 | public func maximumWriteValueLength(for writeType: CBCharacteristicWriteType) -> Int { 156 | _maximumWriteValueLength(writeType) 157 | } 158 | 159 | public func writeValue(_ value: Data, for characteristic: CBCharacteristic, type writeType: CBCharacteristicWriteType) -> AnyPublisher { 160 | if writeType == .withoutResponse { 161 | return writeValueWithoutResponse(value, for: characteristic) 162 | } else { 163 | return writeValueWithResponse(value, for: characteristic) 164 | } 165 | } 166 | 167 | private func writeValueWithoutResponse(_ value: Data, for characteristic: CBCharacteristic) -> AnyPublisher { 168 | if characteristic.properties.contains(.writeWithoutResponse) { 169 | // Return an empty publisher here, since we never expect to receive a response. 170 | return Empty() 171 | .handleEvents(receiveSubscription: { [_writeValueForCharacteristic] _ in 172 | _writeValueForCharacteristic(value, characteristic, .withoutResponse) 173 | }) 174 | .eraseToAnyPublisher() 175 | } else { 176 | // a response-less write against a characteristic that doesn't support it is silently ignored 177 | // by core bluetooth and never sends to the peripheral, so surface that case with an error here instead. 178 | return Fail( 179 | error: NSError( 180 | domain: CBATTErrorDomain, 181 | code: CBATTError.writeNotPermitted.rawValue, 182 | userInfo: [ 183 | NSLocalizedDescriptionKey: "Writing without response is not permitted." 184 | ] 185 | ) 186 | ) 187 | .eraseToAnyPublisher() 188 | } 189 | } 190 | 191 | private func writeValueWithResponse(_ value: Data, for characteristic: CBCharacteristic) -> AnyPublisher { 192 | didWriteValueForCharacteristic 193 | .filterFirstValueOrThrow(where: { [uuid = characteristic.uuid] in 194 | $0.uuid == uuid 195 | }) 196 | .map { _ in } 197 | .handleEvents(receiveSubscription: { [_writeValueForCharacteristic] _ in 198 | _writeValueForCharacteristic(value, characteristic, .withResponse) 199 | }) 200 | .shareCurrentValue() 201 | } 202 | 203 | public func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) -> AnyPublisher { 204 | didUpdateNotificationState 205 | .filterFirstValueOrThrow(where: { [uuid = characteristic.uuid] in 206 | $0.uuid == uuid 207 | }) 208 | .map { _ in } 209 | .handleEvents(receiveSubscription: { [_setNotifyValue] _ in 210 | _setNotifyValue(enabled, characteristic) 211 | }) 212 | .shareCurrentValue() 213 | } 214 | 215 | public func discoverDescriptors(for characteristic: CBCharacteristic) -> AnyPublisher<[CBDescriptor]?, Error> { 216 | didDiscoverDescriptorsForCharacteristic 217 | .filterFirstValueOrThrow(where: { [uuid = characteristic.uuid] in 218 | $0.uuid == uuid 219 | }) 220 | .map(\.descriptors) 221 | .handleEvents(receiveSubscription: { [_discoverDescriptors] _ in 222 | _discoverDescriptors(characteristic) 223 | }) 224 | .shareCurrentValue() 225 | } 226 | 227 | public func readValue(for descriptor: CBDescriptor) -> AnyPublisher { 228 | didUpdateValueForDescriptor 229 | .filterFirstValueOrThrow(where: { [uuid = descriptor.uuid] in 230 | $0.uuid == uuid 231 | }) 232 | .map(\.value) 233 | .handleEvents(receiveSubscription: { [_readValueForDescriptor] _ in 234 | _readValueForDescriptor(descriptor) 235 | }) 236 | .shareCurrentValue() 237 | } 238 | 239 | public func writeValue(_ value: Data, for descriptor: CBDescriptor) -> AnyPublisher { 240 | didWriteValueForDescriptor 241 | .filterFirstValueOrThrow(where: { [uuid = descriptor.uuid] in 242 | $0.uuid == uuid 243 | }) 244 | .map { _ in } 245 | .handleEvents(receiveSubscription: { [_writeValueForDescriptor] _ in 246 | _writeValueForDescriptor(value, descriptor) 247 | }) 248 | .shareCurrentValue() 249 | } 250 | 251 | public func openL2CAPChannel(_ psm: CBL2CAPPSM) -> AnyPublisher { 252 | didOpenChannel 253 | .filterFirstValueOrThrow(where: { channel, error in 254 | return channel?.psm == psm || error != nil 255 | }) 256 | // we won't get here unless channel is not nil, so we can safely force-unwrap 257 | .map { $0! } 258 | .handleEvents(receiveSubscription: { [_openL2CAPChannel] _ in 259 | _openL2CAPChannel(psm) 260 | }) 261 | .shareCurrentValue() 262 | } 263 | 264 | // MARK: - Convenience methods 265 | 266 | /// Discovers the service with the given service UUID, then discovers characteristics with the given UUIDs and returns those. 267 | /// - Parameters: 268 | /// - characteristicUUIDs: The UUIDs of the characteristics you want to discover. 269 | /// - serviceUUID: The service to discover that contain the characteristics you need. 270 | /// - Returns: A publisher of the desired characteristics 271 | public func discoverCharacteristics(withUUIDs characteristicUUIDs: [CBUUID], inServiceWithUUID serviceUUID: CBUUID) -> AnyPublisher<[CBCharacteristic], Error> { 272 | discoverServices([serviceUUID]) 273 | // discover all the characteristics we need 274 | .flatMap { (services) -> AnyPublisher<[CBCharacteristic], Error> in 275 | // safe to force unwrap, since `discoverServices` guarantees that if you give it a non-nil array, it will only publish a value 276 | // if the requested services are all present 277 | guard let service = services.first(where: { $0.uuid == serviceUUID }) else { 278 | return Fail(error: PeripheralError.serviceNotFound(serviceUUID)).eraseToAnyPublisher() 279 | } 280 | return discoverCharacteristics(characteristicUUIDs, for: service) 281 | } 282 | .first() 283 | .eraseToAnyPublisher() 284 | } 285 | 286 | /// Discovers the service with the given service UUID, then discovers the characteristic with the given UUID and returns that. 287 | /// - Parameters: 288 | /// - characteristicUUIDs: The UUID of the characteristic you want to discover. 289 | /// - serviceUUID: The service to discover that contain the characteristics you need. 290 | /// - Returns: A publisher of the desired characteristic 291 | public func discoverCharacteristic(withUUID characteristicUUID: CBUUID, inServiceWithUUID serviceUUID: CBUUID) -> AnyPublisher { 292 | discoverCharacteristics(withUUIDs: [characteristicUUID], inServiceWithUUID: serviceUUID) 293 | .tryMap { characteristics in 294 | // assume core bluetooth won't send us a characteristic list without the characteristic we expect 295 | guard let characteristic = characteristics.first(where: { c in c.uuid == characteristicUUID }) else { 296 | throw PeripheralError.characteristicNotFound(characteristicUUID) 297 | } 298 | return characteristic 299 | } 300 | .eraseToAnyPublisher() 301 | } 302 | 303 | /// Reads the value in the characteristic with the given UUID from the service with the given UUID. 304 | /// - Parameters: 305 | /// - characteristicUUID: The UUID of the characteristic to read from. 306 | /// - serviceUUID: The UUID of the service the characteristic is a part of. 307 | /// - Returns: A publisher that sends the value that is read from the desired characteristic. 308 | public func readValue(forCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher { 309 | discoverCharacteristic(withUUID: characteristicUUID, inServiceWithUUID: serviceUUID) 310 | .flatMap { characteristic in 311 | self.readValue(for: characteristic) 312 | } 313 | .first() 314 | .eraseToAnyPublisher() 315 | } 316 | 317 | /// Writes the given data to the characteristic with the given UUID in the service in the given UUID 318 | /// - Parameters: 319 | /// - value: The data to write 320 | /// - writeType: How to write the data 321 | /// - characteristicUUID: the id of the characteristic 322 | /// - serviceUUID: the service the characteristic exists within 323 | /// - Returns: A publisher that finishes immediately with no output if the write type is without response, or with a `Void` value if the the write type is with response, when we are told that the write completed. Completes with an error if the write fails, if the write type is unsupported. 324 | public func writeValue(_ value: Data, writeType: CBCharacteristicWriteType, forCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher { 325 | discoverCharacteristic(withUUID: characteristicUUID, inServiceWithUUID: serviceUUID) 326 | .flatMap { characteristic in 327 | self.writeValue(value, for: characteristic, type: writeType) 328 | } 329 | .eraseToAnyPublisher() 330 | } 331 | 332 | 333 | /// Discovers all descriptors on the given characteristic in the given service. 334 | /// - Parameters: 335 | /// - characteristicUUID: The id of the characteristic whose descriptors we want to discover. 336 | /// - serviceUUID: The id of the service containing the characteristic whose descriptors we want to discover. 337 | /// - Returns: A publisher that outputs the descriptors for the given characteristic and then finishes. May fail with an error if the characteristic or service aren't discovered, or if the service doesn't contain a characteristic with the given ID, or in other cases. 338 | public func discoverDescriptors(onCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher<[CBDescriptor]?, Error> { 339 | discoverCharacteristic(withUUID: characteristicUUID, inServiceWithUUID: serviceUUID) 340 | .flatMap { self.discoverDescriptors(for: $0) } 341 | .first() 342 | .eraseToAnyPublisher() 343 | } 344 | 345 | /// Discovers all descriptors on the given characteristic in the given service, and sends the descriptor with the desired ID into the returned publisher. 346 | /// - Parameters: 347 | /// - id: The id of the desired descriptor 348 | /// - characteristicUUID: The id of the characteristic whose descriptors we want to discover. 349 | /// - serviceUUID: The id of the service containing the characteristic whose descriptors we want to discover. 350 | /// - Returns: A publisher that outputs the desired descriptor on the given characteristic and then finishes. May fail with an error if the characteristic does not contain a descriptor with the given ID, the characteristic or service aren't discovered, or if the service doesn't contain a characteristic with the given ID, or in other cases. 351 | public func discoverDescriptor(id: CBUUID, onCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher { 352 | discoverDescriptors(onCharacteristic: characteristicUUID, inService: serviceUUID) 353 | .tryMap { descriptors in 354 | guard let descriptor = descriptors?.first(where: { d in d.uuid == id }) else { 355 | throw PeripheralError.descriptorNotFound(id, onCharacteristic: characteristicUUID) 356 | } 357 | return descriptor 358 | } 359 | .eraseToAnyPublisher() 360 | } 361 | 362 | /// Reads the value from the desired descriptor. 363 | /// - Parameters: 364 | /// - id: The id of the desired descriptor 365 | /// - characteristicUUID: The id of the characteristic that contains the desired descriptor. 366 | /// - serviceUUID: The id of the service containing the characteristic that contains the desired descriptor. 367 | /// - Returns: A publisher that will output the value of the desired descriptor, or which will fail if it somehow cannot find that descriptor. 368 | public func readValue(forDescriptor id: CBUUID, onCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher { 369 | discoverDescriptor(id: id, onCharacteristic: characteristicUUID, inService: serviceUUID) 370 | .flatMap { descriptor in 371 | self.readValue(for: descriptor) 372 | } 373 | .first() 374 | .eraseToAnyPublisher() 375 | } 376 | 377 | /// Writes the given data to the descriptor specified by the given IDs. 378 | /// - Parameters: 379 | /// - value: The value to write 380 | /// - id: The id of the desired descriptor 381 | /// - characteristicUUID: The id of the characteristic that contains the desired descriptor. 382 | /// - serviceUUID: The id of the service containing the characteristic that contains the desired descriptor. 383 | /// - Returns: A publisher that completes if the write succeeded, or fails if the descriptor/characteristic/service is not found, or if the write fails. 384 | public func writeValue(_ value: Data, forDescriptor id: CBUUID, onCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher { 385 | discoverDescriptors(onCharacteristic: characteristicUUID, inService: serviceUUID) 386 | .tryMap { descriptors in 387 | guard let descriptor = (descriptors ?? []).first(where: { d in d.uuid == id }) else { 388 | throw PeripheralError.descriptorNotFound(id, onCharacteristic: characteristicUUID) 389 | } 390 | return descriptor 391 | } 392 | .flatMap { 393 | self.writeValue(value, for: $0) 394 | } 395 | .first() 396 | .eraseToAnyPublisher() 397 | } 398 | 399 | /// Returns a long-lived publisher that receives all value updates for the given characteristic. Allows for many listeners to be updated for a single read, or for indications/notifications of a characteristic's value changing. 400 | /// 401 | /// This function does not subscribe to notifications or indications on its own. 402 | /// 403 | /// - Parameter characteristic: The characteristic to listen to for updates. 404 | /// - Returns: A publisher that will listen to updates to the given characteristic. Continues indefinitely, unless an error is encountered. 405 | public func listenForUpdates(on characteristic: CBCharacteristic) -> AnyPublisher { 406 | didUpdateValueForCharacteristic 407 | // not limiting to `.first()` here as callers may want long-lived listening for value changes 408 | .filter({ [uuid = characteristic.uuid] (readCharacteristic, error) -> Bool in 409 | readCharacteristic.uuid == uuid 410 | }) 411 | .selectValueOrThrowError() 412 | .map(\.value) 413 | .eraseToAnyPublisher() 414 | } 415 | 416 | /// Returns a long-lived publisher that receives all value updates for the characteristic with the given id in the service with the given id. Allows for many listeners to be updated for a single read, or for indications/notifications of a characteristic. 417 | /// 418 | /// This function does not subscribe to notifications or indications on its own. 419 | /// 420 | /// - Parameters: 421 | /// - characteristicUUID: The ID that identifies the characteristic to listen for. 422 | /// - serviceUUID: The ID that identifies the service that contains the desired characteristic. 423 | /// - Returns: A publisher that will listen to updates to the given characteristic. Continues indefinitely, unless an error is encountered. 424 | public func listenForUpdates(onCharacteristic characteristicUUID: CBUUID, inService serviceUUID: CBUUID) -> AnyPublisher { 425 | discoverCharacteristic(withUUID: characteristicUUID, inServiceWithUUID: serviceUUID) 426 | .first() 427 | .flatMap { self.listenForUpdates(on: $0) } 428 | .eraseToAnyPublisher() 429 | } 430 | 431 | /// Subscribes to an update on the given characteristic, and if subscription succeeds, the returned publisher receives all updates to that characteristic until the subscriptions to this publisher are cancelled. 432 | /// 433 | /// If the characteristic doesn't support notifications or indications, the publisher will fail with an error. 434 | /// - Parameter characteristic: The characteristic to subscribe to 435 | /// - Returns: A publisher that is sent updates to the given characteristic's value. 436 | public func subscribeToUpdates(on characteristic: CBCharacteristic) -> AnyPublisher { 437 | Publishers.Merge( 438 | self.listenForUpdates(on: characteristic), 439 | 440 | didUpdateNotificationState 441 | .filterFirstValueOrThrow(where: { [uuid = characteristic.uuid] in 442 | $0.uuid == uuid 443 | }) 444 | .ignoreOutput(setOutputType: Data?.self) 445 | ) 446 | .handleEvents( 447 | receiveSubscription: { _ in 448 | _setNotifyValue(true, characteristic) 449 | }, 450 | receiveCancel: { 451 | _setNotifyValue(false, characteristic) 452 | } 453 | ) 454 | .shareCurrentValue() 455 | } 456 | } 457 | 458 | // MARK: - 459 | 460 | extension Peripheral { 461 | @objc(CCBPeripheralDelegate) 462 | final class Delegate: NSObject, Sendable { 463 | let nameUpdates: PassthroughSubject = .init() 464 | let didInvalidateServices: PassthroughSubject<[CBService], Never> = .init() 465 | let didReadRSSI: PassthroughSubject, Never> = .init() 466 | let didDiscoverServices: PassthroughSubject<([CBService], Error?), Never> = .init() 467 | let didDiscoverIncludedServices: PassthroughSubject<(CBService, Error?), Never> = .init() 468 | let didDiscoverCharacteristics: PassthroughSubject<(CBService, Error?), Never> = .init() 469 | let didUpdateValueForCharacteristic: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init() 470 | let didWriteValueForCharacteristic: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init() 471 | let didUpdateNotificationState: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init() 472 | let didDiscoverDescriptorsForCharacteristic: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init() 473 | let didUpdateValueForDescriptor: PassthroughSubject<(CBDescriptor, Error?), Never> = .init() 474 | let didWriteValueForDescriptor: PassthroughSubject<(CBDescriptor, Error?), Never> = .init() 475 | let isReadyToSendWriteWithoutResponse: PassthroughSubject = .init() 476 | let didOpenChannel: PassthroughSubject<(L2CAPChannel?, Error?), Never> = .init() 477 | } 478 | } 479 | 480 | extension Peripheral: Identifiable { 481 | public var id: UUID { identifier } 482 | } 483 | 484 | extension Peripheral: Equatable, Hashable { 485 | public static func == (lhs: Self, rhs: Self) -> Bool { 486 | lhs.identifier == rhs.identifier 487 | } 488 | 489 | public func hash(into hasher: inout Hasher) { 490 | hasher.combine(identifier) 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Peripheral/Live+Peripheral.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | extension Peripheral { 5 | public init(cbperipheral: CBPeripheral) { 6 | let delegate = cbperipheral.delegate as? Delegate ?? Delegate() 7 | cbperipheral.delegate = delegate 8 | 9 | self.init( 10 | rawValue: cbperipheral, 11 | delegate: delegate, 12 | _name: { cbperipheral.name }, 13 | _identifier: { cbperipheral.identifier }, 14 | _state: { cbperipheral.state }, 15 | _services: { cbperipheral.services }, 16 | _canSendWriteWithoutResponse: { cbperipheral.canSendWriteWithoutResponse }, 17 | _ancsAuthorized: { 18 | #if os(macOS) && !targetEnvironment(macCatalyst) 19 | fatalError("This method is not callable on macOS") 20 | #else 21 | return cbperipheral.ancsAuthorized 22 | #endif 23 | }, 24 | 25 | _readRSSI: { cbperipheral.readRSSI() }, 26 | _discoverServices: { cbperipheral.discoverServices($0) }, 27 | _discoverIncludedServices: { cbperipheral.discoverIncludedServices($0, for: $1) }, 28 | _discoverCharacteristics: { cbperipheral.discoverCharacteristics($0, for: $1) }, 29 | _readValueForCharacteristic: { cbperipheral.readValue(for: $0) }, 30 | _maximumWriteValueLength: { cbperipheral.maximumWriteValueLength(for: $0) }, 31 | _writeValueForCharacteristic: { cbperipheral.writeValue($0, for: $1, type: $2) }, 32 | _setNotifyValue: { cbperipheral.setNotifyValue($0, for: $1) }, 33 | _discoverDescriptors: { cbperipheral.discoverDescriptors(for: $0) }, 34 | _readValueForDescriptor: { cbperipheral.readValue(for: $0) }, 35 | _writeValueForDescriptor: { cbperipheral.writeValue($0, for: $1) }, 36 | _openL2CAPChannel: { cbperipheral.openL2CAPChannel($0) }, 37 | 38 | didReadRSSI: delegate.didReadRSSI.eraseToAnyPublisher(), 39 | didDiscoverServices: delegate.didDiscoverServices.eraseToAnyPublisher(), 40 | didDiscoverIncludedServices: delegate.didDiscoverIncludedServices.eraseToAnyPublisher(), 41 | didDiscoverCharacteristics: delegate.didDiscoverCharacteristics.eraseToAnyPublisher(), 42 | didUpdateValueForCharacteristic: delegate.didUpdateValueForCharacteristic.eraseToAnyPublisher(), 43 | didWriteValueForCharacteristic: delegate.didWriteValueForCharacteristic.eraseToAnyPublisher(), 44 | didUpdateNotificationState: delegate.didUpdateNotificationState.eraseToAnyPublisher(), 45 | didDiscoverDescriptorsForCharacteristic: delegate.didDiscoverDescriptorsForCharacteristic.eraseToAnyPublisher(), 46 | didUpdateValueForDescriptor: delegate.didUpdateValueForDescriptor.eraseToAnyPublisher(), 47 | didWriteValueForDescriptor: delegate.didWriteValueForDescriptor.eraseToAnyPublisher(), 48 | didOpenChannel: delegate.didOpenChannel.eraseToAnyPublisher(), 49 | 50 | isReadyToSendWriteWithoutResponse: delegate.isReadyToSendWriteWithoutResponse.eraseToAnyPublisher(), 51 | nameUpdates: delegate.nameUpdates.eraseToAnyPublisher(), 52 | invalidatedServiceUpdates: delegate.didInvalidateServices.eraseToAnyPublisher() 53 | ) 54 | } 55 | } 56 | 57 | extension Peripheral.Delegate: CBPeripheralDelegate { 58 | func peripheralDidUpdateName(_ peripheral: CBPeripheral) { 59 | nameUpdates.send(peripheral.name) 60 | } 61 | 62 | func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { 63 | didInvalidateServices.send(invalidatedServices) 64 | } 65 | 66 | func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { 67 | if let error = error { 68 | didReadRSSI.send(.failure(error)) 69 | } else { 70 | didReadRSSI.send(.success(RSSI.doubleValue)) 71 | } 72 | } 73 | 74 | func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 75 | didDiscoverServices.send((peripheral.services ?? [], error)) 76 | } 77 | 78 | func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) { 79 | didDiscoverIncludedServices.send((service, error)) 80 | } 81 | 82 | func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { 83 | didDiscoverCharacteristics.send((service, error)) 84 | } 85 | 86 | func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { 87 | didUpdateValueForCharacteristic.send((characteristic, error)) 88 | } 89 | 90 | func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { 91 | didWriteValueForCharacteristic.send((characteristic, error)) 92 | } 93 | 94 | func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { 95 | didUpdateNotificationState.send((characteristic, error)) 96 | } 97 | 98 | func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) { 99 | didDiscoverDescriptorsForCharacteristic.send((characteristic, error)) 100 | } 101 | 102 | func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) { 103 | didUpdateValueForDescriptor.send((descriptor, error)) 104 | } 105 | 106 | func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?) { 107 | didWriteValueForDescriptor.send((descriptor, error)) 108 | } 109 | 110 | func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { 111 | isReadyToSendWriteWithoutResponse.send() 112 | } 113 | 114 | func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: Error?) { 115 | didOpenChannel.send((channel.map(L2CAPChannel.init(channel:)), error)) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/Peripheral/Mock+Peripheral.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Peripheral { 4 | public static func unimplemented( 5 | name: String? = nil, 6 | identifier: UUID = .init(), 7 | state: @escaping @Sendable () -> CBPeripheralState = _Internal._unimplemented("state"), 8 | services: @escaping @Sendable () -> [CBService]? = _Internal._unimplemented("services"), 9 | canSendWriteWithoutResponse: @escaping @Sendable () -> Bool = _Internal._unimplemented("canSendWriteWithoutResponse"), 10 | ancsAuthorized: @escaping @Sendable () -> Bool = _Internal._unimplemented("ancsAuthorized"), 11 | readRSSI: @escaping @Sendable () -> Void = _Internal._unimplemented("readRSSI"), 12 | discoverServices: @escaping @Sendable ([CBUUID]?) -> Void = _Internal._unimplemented("discoverServices") , 13 | discoverIncludedServices: @escaping @Sendable ([CBUUID]?, CBService) -> Void = _Internal._unimplemented("discoverIncludedServices"), 14 | discoverCharacteristics: @escaping @Sendable ([CBUUID]?, CBService) -> Void = _Internal._unimplemented("discoverCharacteristics"), 15 | readValueForCharacteristic: @escaping @Sendable (CBCharacteristic) -> Void = _Internal._unimplemented("readValueForCharacteristic"), 16 | maximumWriteValueLength: @escaping @Sendable (CBCharacteristicWriteType) -> Int = _Internal._unimplemented("maximumWriteValueLength"), 17 | writeValueForCharacteristic: @escaping @Sendable (Data, CBCharacteristic, CBCharacteristicWriteType) -> Void = _Internal._unimplemented("writeValueForCharacteristic"), 18 | setNotifyValue: @escaping @Sendable (Bool, CBCharacteristic) -> Void = _Internal._unimplemented("setNotifyValue"), 19 | discoverDescriptors: @escaping @Sendable (CBCharacteristic) -> Void = _Internal._unimplemented("discoverDescriptors"), 20 | readValueForDescriptor: @escaping @Sendable (CBDescriptor) -> Void = _Internal._unimplemented("readValueForDescriptor"), 21 | writeValueForDescriptor: @escaping @Sendable (Data, CBDescriptor) -> Void = _Internal._unimplemented("writeValueForDescriptor"), 22 | openL2CAPChannel: @escaping @Sendable (CBL2CAPPSM) -> Void = _Internal._unimplemented("openL2CAPChannel"), 23 | didReadRSSI: AnyPublisher, Never> = _Internal._unimplemented("didReadRSSI"), 24 | didDiscoverServices: AnyPublisher<([CBService], Error?), Never> = _Internal._unimplemented("didDiscoverServices"), 25 | didDiscoverIncludedServices: AnyPublisher<(CBService, Error?), Never> = _Internal._unimplemented("didDiscoverIncludedServices"), 26 | didDiscoverCharacteristics: AnyPublisher<(CBService, Error?), Never> = _Internal._unimplemented("didDiscoverCharacteristics"), 27 | didUpdateValueForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> = _Internal._unimplemented("didUpdateValueForCharacteristic"), 28 | didWriteValueForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> = _Internal._unimplemented("didWriteValueForCharacteristic"), 29 | didUpdateNotificationState: AnyPublisher<(CBCharacteristic, Error?), Never> = _Internal._unimplemented("didUpdateNotificationState"), 30 | didDiscoverDescriptorsForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> = _Internal._unimplemented("didDiscoverDescriptorsForCharacteristic"), 31 | didUpdateValueForDescriptor: AnyPublisher<(CBDescriptor, Error?), Never> = _Internal._unimplemented("didUpdateValueForDescriptor"), 32 | didWriteValueForDescriptor: AnyPublisher<(CBDescriptor, Error?), Never> = _Internal._unimplemented("didWriteValueForDescriptor"), 33 | didOpenChannel: AnyPublisher<(L2CAPChannel?, Error?), Never> = _Internal._unimplemented("didOpenChannel"), 34 | isReadyToSendWriteWithoutResponse: AnyPublisher = _Internal._unimplemented("isReadyToSendWriteWithoutResponse"), 35 | nameUpdates: AnyPublisher = _Internal._unimplemented("nameUpdates"), 36 | invalidatedServiceUpdates: AnyPublisher<[CBService], Never> = _Internal._unimplemented("invalidatedServiceUpdates") 37 | ) -> Peripheral { 38 | Peripheral( 39 | rawValue: nil, 40 | delegate: nil, 41 | _name: { name }, 42 | _identifier: { identifier }, 43 | _state: state, 44 | _services: services, 45 | _canSendWriteWithoutResponse: canSendWriteWithoutResponse, 46 | _ancsAuthorized: ancsAuthorized, 47 | _readRSSI: readRSSI, 48 | _discoverServices: discoverServices, 49 | _discoverIncludedServices: discoverIncludedServices, 50 | _discoverCharacteristics: discoverCharacteristics, 51 | _readValueForCharacteristic: readValueForCharacteristic, 52 | _maximumWriteValueLength: maximumWriteValueLength, 53 | _writeValueForCharacteristic: writeValueForCharacteristic, 54 | _setNotifyValue: setNotifyValue, 55 | _discoverDescriptors: discoverDescriptors, 56 | _readValueForDescriptor: readValueForDescriptor, 57 | _writeValueForDescriptor: writeValueForDescriptor, 58 | _openL2CAPChannel: openL2CAPChannel, 59 | 60 | didReadRSSI: didReadRSSI, 61 | didDiscoverServices: didDiscoverServices, 62 | didDiscoverIncludedServices: didDiscoverIncludedServices, 63 | didDiscoverCharacteristics: didDiscoverCharacteristics, 64 | didUpdateValueForCharacteristic: didUpdateValueForCharacteristic, 65 | didWriteValueForCharacteristic: didWriteValueForCharacteristic, 66 | didUpdateNotificationState: didUpdateNotificationState, 67 | didDiscoverDescriptorsForCharacteristic: didDiscoverDescriptorsForCharacteristic, 68 | didUpdateValueForDescriptor: didUpdateValueForDescriptor, 69 | didWriteValueForDescriptor: didWriteValueForDescriptor, 70 | didOpenChannel: didOpenChannel, 71 | 72 | isReadyToSendWriteWithoutResponse: isReadyToSendWriteWithoutResponse, 73 | nameUpdates: nameUpdates, 74 | invalidatedServiceUpdates: invalidatedServiceUpdates 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/PeripheralManager/Interface+PeripheralManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import Combine 3 | 4 | public struct PeripheralManager: Sendable { 5 | let delegate: Delegate? 6 | 7 | public var _state: @Sendable () -> CBManagerState 8 | public var _authorization: @Sendable () -> CBManagerAuthorization 9 | public var _isAdvertising: @Sendable () -> Bool 10 | public var _startAdvertising: @Sendable (_ advertisementData: AdvertisementData?) -> Void 11 | public var _stopAdvertising: @Sendable () -> Void 12 | public var _setDesiredConnectionLatency: @Sendable (_ latency: CBPeripheralManagerConnectionLatency, _ central: Central) -> Void 13 | public var _add: @Sendable (_ service: CBMutableService) -> Void 14 | public var _remove: @Sendable (_ service: CBMutableService) -> Void 15 | public var _removeAllServices: @Sendable () -> Void 16 | public var _respondToRequest: @Sendable (_ request: ATTRequest, _ result: CBATTError.Code) -> Void 17 | public var _updateValueForCharacteristic: @Sendable (_ value: Data, _ characteristic: CBMutableCharacteristic, _ centrals: [Central]?) -> Bool 18 | public var _publishL2CAPChannel: @Sendable (_ encryptionRequired: Bool) -> Void 19 | public var _unpublishL2CAPChannel: @Sendable (_ PSM: CBL2CAPPSM) -> Void 20 | 21 | public var didUpdateState: AnyPublisher 22 | public var didStartAdvertising: AnyPublisher 23 | public var didAddService: AnyPublisher<(CBService, Error?), Never> 24 | public var centralDidSubscribeToCharacteristic: AnyPublisher<(Central, CBCharacteristic), Never> 25 | public var centralDidUnsubscribeFromCharacteristic: AnyPublisher<(Central, CBCharacteristic), Never> 26 | public var didReceiveReadRequest: AnyPublisher 27 | public var didReceiveWriteRequests: AnyPublisher<[ATTRequest], Never> 28 | public var readyToUpdateSubscribers: AnyPublisher 29 | public var didPublishL2CAPChannel: AnyPublisher<(CBL2CAPPSM, Error?), Never> 30 | public var didUnpublishL2CAPChannel: AnyPublisher<(CBL2CAPPSM, Error?), Never> 31 | public var didOpenL2CAPChannel: AnyPublisher<(L2CAPChannel?, Error?), Never> 32 | 33 | public var state: CBManagerState { 34 | _state() 35 | } 36 | 37 | public var authorization: CBManagerAuthorization { 38 | _authorization() 39 | } 40 | 41 | public var isAdvertising: Bool { 42 | _isAdvertising() 43 | } 44 | 45 | public func startAdvertising(_ advertisementData: AdvertisementData?) -> AnyPublisher { 46 | Publishers.Merge( 47 | didUpdateState, 48 | // the act of checking state here will trigger core bluetooth to turn the radio on so that it eventually becomes `poweredOn` later in `didUpdateState` 49 | [state].publisher 50 | ) 51 | .first(where: { $0 == .poweredOn }) 52 | .flatMap { _ in 53 | // once powered on, we can begin advertising. Do so once this flatmap'd publisher is subscribed to, so we don't start too soon. 54 | didStartAdvertising 55 | .handleEvents(receiveSubscription: { _ in 56 | _startAdvertising(advertisementData) 57 | }) 58 | } 59 | .tryMap { error in 60 | if let error = error { 61 | throw error 62 | } 63 | } 64 | .eraseToAnyPublisher() 65 | } 66 | 67 | public func stopAdvertising() { 68 | _stopAdvertising() 69 | } 70 | 71 | public func setDesiredConnectionLatency(_ latency: CBPeripheralManagerConnectionLatency, for central: Central) { 72 | _setDesiredConnectionLatency(latency, central) 73 | } 74 | 75 | public func add(_ service: CBMutableService) { 76 | _add(service) 77 | } 78 | 79 | public func remove(_ service: CBMutableService) { 80 | _remove(service) 81 | } 82 | 83 | public func removeAllServices() { 84 | _removeAllServices() 85 | } 86 | 87 | public func respond(to request: ATTRequest, withResult result: CBATTError.Code) { 88 | _respondToRequest(request, result) 89 | } 90 | 91 | public func updateValue(_ value: Data, for characteristic: CBMutableCharacteristic, onSubscribedCentrals centrals: [Central]?) -> AnyPublisher { 92 | func update(retries: Int, _ value: Data, _ characteristic: CBMutableCharacteristic, _ centrals: [Central]?) -> AnyPublisher { 93 | let success = _updateValueForCharacteristic(value, characteristic, centrals) 94 | if success { 95 | return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() 96 | } else { 97 | if retries == 0 { 98 | return Fail(error: PeripheralManagerError.failedToUpdateCharacteristic(characteristic.uuid)).eraseToAnyPublisher() 99 | } else { 100 | return readyToUpdateSubscribers.first() 101 | .setFailureType(to: Error.self) 102 | .flatMap { _ in 103 | update(retries: retries-1, value, characteristic, centrals) 104 | } 105 | .eraseToAnyPublisher() 106 | } 107 | } 108 | } 109 | return update(retries: 4, value, characteristic, centrals) 110 | } 111 | 112 | public func publishL2CAPChannel(withEncryption encryptionRequired: Bool) -> AnyPublisher { 113 | didPublishL2CAPChannel 114 | .first() 115 | .selectValueOrThrowError() 116 | .handleEvents(receiveSubscription: { _ in 117 | _publishL2CAPChannel(encryptionRequired) 118 | }) 119 | .shareCurrentValue() 120 | .eraseToAnyPublisher() 121 | } 122 | 123 | public func unpublishL2CAPChannel(_ PSM: CBL2CAPPSM) -> AnyPublisher { 124 | didUnpublishL2CAPChannel 125 | .filterFirstValueOrThrow(where: { $0 == PSM }) 126 | .handleEvents(receiveSubscription: { _ in 127 | _unpublishL2CAPChannel(PSM) 128 | }) 129 | .shareCurrentValue() 130 | .eraseToAnyPublisher() 131 | } 132 | } 133 | 134 | extension PeripheralManager { 135 | @objc(CCBPeripheralManagerDelegate) 136 | class Delegate: NSObject, @unchecked Sendable { 137 | let didUpdateState: PassthroughSubject = .init() 138 | let willRestoreState: PassthroughSubject<[String: Any], Never> = .init() 139 | let didStartAdvertising: PassthroughSubject = .init() 140 | let didAddService: PassthroughSubject<(CBService, Error?), Never> = .init() 141 | let centralDidSubscribeToCharacteristic: PassthroughSubject<(Central, CBCharacteristic), Never> = .init() 142 | let centralDidUnsubscribeFromCharacteristic: PassthroughSubject<(Central, CBCharacteristic), Never> = .init() 143 | let didReceiveReadRequest: PassthroughSubject = .init() 144 | let didReceiveWriteRequests: PassthroughSubject<[ATTRequest], Never> = .init() 145 | let readyToUpdateSubscribers: PassthroughSubject = .init() 146 | let didPublishL2CAPChannel: PassthroughSubject<(CBL2CAPPSM, Error?), Never> = .init() 147 | let didUnpublishL2CAPChannel: PassthroughSubject<(CBL2CAPPSM, Error?), Never> = .init() 148 | let didOpenL2CAPChannel: PassthroughSubject<(L2CAPChannel?, Error?), Never> = .init() 149 | } 150 | 151 | @objc(CCBPeripheralManagerRestorableDelegate) 152 | class RestorableDelegate: Delegate, @unchecked Sendable {} 153 | } 154 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/PeripheralManager/Live+PeripheralManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import CoreBluetooth 3 | 4 | extension PeripheralManager { 5 | public static func live(_ options: ManagerCreationOptions? = nil) -> Self { 6 | let delegate: Delegate = options?.restoreIdentifier != nil ? RestorableDelegate() : Delegate() 7 | #if os(macOS) || os(iOS) 8 | let peripheralManager = CBPeripheralManager( 9 | delegate: delegate, 10 | queue: DispatchQueue(label: "combine-core-bluetooth.peripheral-manager", target: .global()), 11 | options: options?.peripheralManagerDictionary 12 | ) 13 | #else 14 | let peripheralManager = CBPeripheralManager() 15 | peripheralManager.delegate = delegate 16 | #endif 17 | 18 | return Self( 19 | delegate: delegate, 20 | _state: { peripheralManager.state }, 21 | _authorization: { 22 | if #available(iOS 13.1, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { 23 | return CBPeripheralManager.authorization 24 | } else { 25 | return peripheralManager.authorization 26 | } 27 | }, 28 | _isAdvertising: { peripheralManager.isAdvertising }, 29 | _startAdvertising: { advertisementData in 30 | peripheralManager.startAdvertising(advertisementData?.dictionary) 31 | }, 32 | _stopAdvertising: { peripheralManager.stopAdvertising() }, 33 | _setDesiredConnectionLatency: { (latency, central) in 34 | peripheralManager.setDesiredConnectionLatency(latency, for: central.rawValue!) 35 | }, 36 | _add: { peripheralManager.add($0) }, 37 | _remove: { peripheralManager.remove($0) }, 38 | _removeAllServices: { peripheralManager.removeAllServices() }, 39 | _respondToRequest: { (request, result) in 40 | peripheralManager.respond(to: request.rawValue!, withResult: result) 41 | }, 42 | _updateValueForCharacteristic: { (data, characteristic, centrals) -> Bool in 43 | peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: centrals?.compactMap(\.rawValue)) 44 | }, 45 | _publishL2CAPChannel: { peripheralManager.publishL2CAPChannel(withEncryption: $0) }, 46 | _unpublishL2CAPChannel: { peripheralManager.unpublishL2CAPChannel($0) }, 47 | 48 | didUpdateState: delegate.didUpdateState.eraseToAnyPublisher(), 49 | didStartAdvertising: delegate.didStartAdvertising.eraseToAnyPublisher(), 50 | didAddService: delegate.didAddService.eraseToAnyPublisher(), 51 | centralDidSubscribeToCharacteristic: delegate.centralDidSubscribeToCharacteristic.eraseToAnyPublisher(), 52 | centralDidUnsubscribeFromCharacteristic: delegate.centralDidUnsubscribeFromCharacteristic.eraseToAnyPublisher(), 53 | didReceiveReadRequest: delegate.didReceiveReadRequest.eraseToAnyPublisher(), 54 | didReceiveWriteRequests: delegate.didReceiveWriteRequests.eraseToAnyPublisher(), 55 | readyToUpdateSubscribers: delegate.readyToUpdateSubscribers.eraseToAnyPublisher(), 56 | didPublishL2CAPChannel: delegate.didPublishL2CAPChannel.eraseToAnyPublisher(), 57 | didUnpublishL2CAPChannel: delegate.didUnpublishL2CAPChannel.eraseToAnyPublisher(), 58 | didOpenL2CAPChannel: delegate.didOpenL2CAPChannel.eraseToAnyPublisher() 59 | ) 60 | } 61 | } 62 | 63 | extension PeripheralManager.Delegate: CBPeripheralManagerDelegate { 64 | func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { 65 | didUpdateState.send(peripheral.state) 66 | } 67 | 68 | func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) { 69 | didStartAdvertising.send(error) 70 | } 71 | 72 | func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { 73 | didAddService.send((service, error)) 74 | } 75 | 76 | func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { 77 | centralDidSubscribeToCharacteristic.send((.init(cbcentral: central), characteristic)) 78 | } 79 | 80 | func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { 81 | centralDidUnsubscribeFromCharacteristic.send((.init(cbcentral: central), characteristic)) 82 | } 83 | 84 | func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { 85 | didReceiveReadRequest.send(.init(cbattrequest: request)) 86 | } 87 | 88 | func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { 89 | didReceiveWriteRequests.send(requests.map(ATTRequest.init(cbattrequest:))) 90 | } 91 | 92 | func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { 93 | readyToUpdateSubscribers.send() 94 | } 95 | 96 | func peripheralManager(_ peripheral: CBPeripheralManager, didPublishL2CAPChannel PSM: CBL2CAPPSM, error: Error?) { 97 | didPublishL2CAPChannel.send((PSM, error)) 98 | } 99 | 100 | func peripheralManager(_ peripheral: CBPeripheralManager, didUnpublishL2CAPChannel PSM: CBL2CAPPSM, error: Error?) { 101 | didUnpublishL2CAPChannel.send((PSM, error)) 102 | } 103 | 104 | func peripheralManager(_ peripheral: CBPeripheralManager, didOpen channel: CBL2CAPChannel?, error: Error?) { 105 | didOpenL2CAPChannel.send((channel.map(L2CAPChannel.init(channel:)), error)) 106 | } 107 | } 108 | 109 | extension PeripheralManager.RestorableDelegate { 110 | func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { 111 | willRestoreState.send(dict) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/CombineCoreBluetooth/PeripheralManager/Mock+PeripheralManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeripheralManager { 4 | public static func unimplemented( 5 | state: @escaping @Sendable () -> CBManagerState = _Internal._unimplemented("state"), 6 | authorization: @escaping @Sendable () -> CBManagerAuthorization = _Internal._unimplemented("authorization"), 7 | isAdvertising: @escaping @Sendable () -> Bool = _Internal._unimplemented("isAdvertising"), 8 | startAdvertising: @escaping @Sendable (AdvertisementData?) -> Void = _Internal._unimplemented("startAdvertising"), 9 | stopAdvertising: @escaping @Sendable () -> Void = _Internal._unimplemented("stopAdvertising"), 10 | setDesiredConnectionLatency: @escaping @Sendable (CBPeripheralManagerConnectionLatency, Central) -> Void = _Internal._unimplemented("setDesiredConnectionLatency"), 11 | add: @escaping @Sendable (CBMutableService) -> Void = _Internal._unimplemented("add"), 12 | remove: @escaping @Sendable (CBMutableService) -> Void = _Internal._unimplemented("remove"), 13 | removeAllServices: @escaping @Sendable () -> Void = _Internal._unimplemented("removeAllServices"), 14 | respondToRequest: @escaping @Sendable (ATTRequest, CBATTError.Code) -> Void = _Internal._unimplemented("respondToRequest"), 15 | updateValueForCharacteristic: @escaping @Sendable (Data, CBMutableCharacteristic, [Central]?) -> Bool = _Internal._unimplemented("updateValueForCharacteristic"), 16 | publishL2CAPChannel: @escaping @Sendable (Bool) -> Void = _Internal._unimplemented("publishL2CAPChannel"), 17 | unpublishL2CAPChannel: @escaping @Sendable (CBL2CAPPSM) -> Void = _Internal._unimplemented("unpublishL2CAPChannel"), 18 | 19 | didUpdateState: AnyPublisher = _Internal._unimplemented("didUpdateState"), 20 | didStartAdvertising: AnyPublisher = _Internal._unimplemented("didStartAdvertising"), 21 | didAddService: AnyPublisher<(CBService, Error?), Never> = _Internal._unimplemented("didAddService"), 22 | centralDidSubscribeToCharacteristic: AnyPublisher<(Central, CBCharacteristic), Never> = _Internal._unimplemented("centralDidSubscribeToCharacteristic"), 23 | centralDidUnsubscribeToCharacteristic: AnyPublisher<(Central, CBCharacteristic), Never> = _Internal._unimplemented("centralDidUnsubscribeToCharacteristic"), 24 | didReceiveReadRequest: AnyPublisher = _Internal._unimplemented("didReceiveReadRequest"), 25 | didReceiveWriteRequests: AnyPublisher<[ATTRequest], Never> = _Internal._unimplemented("didReceiveWriteRequests"), 26 | readyToUpdateSubscribers: AnyPublisher = _Internal._unimplemented("readyToUpdateSubscribers"), 27 | didPublishL2CAPChannel: AnyPublisher<(CBL2CAPPSM, Error?), Never> = _Internal._unimplemented("didPublishL2CAPChannel"), 28 | didUnpublishL2CAPChannel: AnyPublisher<(CBL2CAPPSM, Error?), Never> = _Internal._unimplemented("didUnpublishL2CAPChannel"), 29 | didOpenL2CAPChannel: AnyPublisher<(L2CAPChannel?, Error?), Never> = _Internal._unimplemented("didOpenL2CAPChannel") 30 | ) -> Self { 31 | self.init( 32 | delegate: nil, 33 | _state: state, 34 | _authorization: authorization, 35 | _isAdvertising: isAdvertising, 36 | _startAdvertising: startAdvertising, 37 | _stopAdvertising: stopAdvertising, 38 | _setDesiredConnectionLatency: setDesiredConnectionLatency, 39 | _add: add, 40 | _remove: remove, 41 | _removeAllServices: removeAllServices, 42 | _respondToRequest: respondToRequest, 43 | _updateValueForCharacteristic: updateValueForCharacteristic, 44 | _publishL2CAPChannel: publishL2CAPChannel, 45 | _unpublishL2CAPChannel: unpublishL2CAPChannel, 46 | didUpdateState: didUpdateState, 47 | didStartAdvertising: didStartAdvertising, 48 | didAddService: didAddService, 49 | centralDidSubscribeToCharacteristic: centralDidSubscribeToCharacteristic, 50 | centralDidUnsubscribeFromCharacteristic: centralDidUnsubscribeToCharacteristic, 51 | didReceiveReadRequest: didReceiveReadRequest, 52 | didReceiveWriteRequests: didReceiveWriteRequests, 53 | readyToUpdateSubscribers: readyToUpdateSubscribers, 54 | didPublishL2CAPChannel: didPublishL2CAPChannel, 55 | didUnpublishL2CAPChannel: didUnpublishL2CAPChannel, 56 | didOpenL2CAPChannel: didOpenL2CAPChannel 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/CombineCoreBluetoothTests/CentralManagerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CombineCoreBluetooth 3 | import ConcurrencyExtras 4 | 5 | final class CentralManagerTests: XCTestCase { 6 | var cancellables: Set! 7 | 8 | override func setUpWithError() throws { 9 | cancellables = [] 10 | } 11 | 12 | override func tearDownWithError() throws { 13 | cancellables = nil 14 | } 15 | 16 | func testMonitorConnectionSendsExpectedValues() { 17 | let didConnectPeripheral = PassthroughSubject() 18 | let didDisconnectPeripheral = PassthroughSubject<(Peripheral, Error?), Never>() 19 | let monitoredPeripheral = Peripheral.unimplemented(name: "monitored") 20 | let unmonitoredPeripheral = Peripheral.unimplemented(name: "unmonitored") 21 | 22 | let centralManager = CentralManager.unimplemented( 23 | didConnectPeripheral: didConnectPeripheral.eraseToAnyPublisher(), 24 | didDisconnectPeripheral: didDisconnectPeripheral.eraseToAnyPublisher() 25 | ) 26 | 27 | var values: [Bool] = [] 28 | centralManager.monitorConnection(for: monitoredPeripheral).sink { 29 | values.append($0) 30 | }.store(in: &cancellables) 31 | 32 | didConnectPeripheral.send(monitoredPeripheral) 33 | didDisconnectPeripheral.send((monitoredPeripheral, nil)) 34 | didConnectPeripheral.send(monitoredPeripheral) 35 | 36 | XCTAssertEqual(values, [true, false, true]) 37 | 38 | didConnectPeripheral.send(unmonitoredPeripheral) 39 | didDisconnectPeripheral.send((unmonitoredPeripheral, nil)) 40 | didConnectPeripheral.send(unmonitoredPeripheral) 41 | 42 | XCTAssertEqual(values, [true, false, true], "monitorConnection returned values for a peripheral it wasn't monitoring") 43 | } 44 | 45 | func testMonitorConnectionDisconnectErrorsDontAffectResults() { 46 | let didConnectPeripheral = PassthroughSubject() 47 | let didDisconnectPeripheral = PassthroughSubject<(Peripheral, Error?), Never>() 48 | let monitoredPeripheral = Peripheral.unimplemented(name: "monitored") 49 | 50 | let centralManager = CentralManager.unimplemented( 51 | didConnectPeripheral: didConnectPeripheral.eraseToAnyPublisher(), 52 | didDisconnectPeripheral: didDisconnectPeripheral.eraseToAnyPublisher() 53 | ) 54 | 55 | var values: [Bool] = [] 56 | centralManager.monitorConnection(for: monitoredPeripheral).sink { 57 | values.append($0) 58 | }.store(in: &cancellables) 59 | 60 | struct SomeError: Error {} 61 | 62 | didDisconnectPeripheral.send((monitoredPeripheral, SomeError())) 63 | 64 | XCTAssertEqual(values, [false], "Errors from disconnects should not affect disconnect values sent to subscribers") 65 | } 66 | 67 | func testScanForPeripheralsScansOnlyOnSubscription() { 68 | let scanCount = LockIsolated(0) 69 | let stopCount = LockIsolated(0) 70 | let peripheralDiscovery = PassthroughSubject() 71 | let centralManager = CentralManager.unimplemented( 72 | scanForPeripheralsWithServices: { _, _ in 73 | scanCount.withValue { $0 += 1 } 74 | }, 75 | stopScanForPeripherals: { 76 | stopCount.withValue { $0 += 1} 77 | }, 78 | didDiscoverPeripheral: peripheralDiscovery.eraseToAnyPublisher() 79 | ) 80 | 81 | let p = centralManager.scanForPeripherals(withServices: nil) 82 | scanCount.withValue { XCTAssertEqual($0, 0) } 83 | let _ = p.sink(receiveValue: { _ in }) 84 | scanCount.withValue { XCTAssertEqual($0, 1) } 85 | } 86 | 87 | func testScanForPeripheralsStopsOnCancellation() { 88 | let scanCount = LockIsolated(0) 89 | let stopCount = LockIsolated(0) 90 | let peripheralDiscovery = PassthroughSubject() 91 | let centralManager = CentralManager.unimplemented( 92 | scanForPeripheralsWithServices: { _, _ in 93 | scanCount.withValue { $0 += 1 } 94 | }, 95 | stopScanForPeripherals: { 96 | stopCount.withValue { $0 += 1} 97 | }, 98 | didDiscoverPeripheral: peripheralDiscovery.eraseToAnyPublisher() 99 | ) 100 | 101 | let p = centralManager.scanForPeripherals(withServices: nil) 102 | scanCount.withValue { XCTAssertEqual($0, 0) } 103 | let cancellable = p.sink(receiveValue: { _ in }) 104 | scanCount.withValue { XCTAssertEqual($0, 1) } 105 | stopCount.withValue { XCTAssertEqual($0, 0) } 106 | 107 | cancellable.cancel() 108 | scanCount.withValue { XCTAssertEqual($0, 1) } 109 | stopCount.withValue { XCTAssertEqual($0, 1) } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/CombineCoreBluetoothTests/PeripheralManagerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CombineCoreBluetooth 3 | import ConcurrencyExtras 4 | 5 | #if os(macOS) || os(iOS) 6 | final class PeripheralManagerTests: XCTestCase { 7 | var cancellables: Set! 8 | 9 | override func setUpWithError() throws { 10 | // Put setup code here. This method is called before the invocation of each test method in the class. 11 | cancellables = [] 12 | } 13 | 14 | override func tearDownWithError() throws { 15 | // Put teardown code here. This method is called after the invocation of each test method in the class. 16 | cancellables = nil 17 | } 18 | 19 | func testUpdateValueSuccessCase() throws { 20 | let characteristic = CBMutableCharacteristic(type: .init(string: "0001"), properties: [.notify], value: nil, permissions: [.readable]) 21 | 22 | let central = Central.unimplemented(identifier: .init(), maximumUpdateValueLength: { 512 }) 23 | let peripheralManager = PeripheralManager.unimplemented( 24 | updateValueForCharacteristic: { _, _, _ in true }, 25 | readyToUpdateSubscribers: Just(()).eraseToAnyPublisher() 26 | ) 27 | 28 | peripheralManager.updateValue(Data(), for: characteristic, onSubscribedCentrals: [central]) 29 | .sink { c in 30 | if case let .failure(error) = c { 31 | XCTFail("Unexpected error: \(error)") 32 | } 33 | } receiveValue: { _ in 34 | 35 | }.store(in: &cancellables) 36 | } 37 | 38 | func testUpdateValueErrorCase() throws { 39 | let characteristic = CBMutableCharacteristic(type: .init(string: "0001"), properties: [.notify], value: nil, permissions: [.readable]) 40 | let central = Central.unimplemented(identifier: .init(), maximumUpdateValueLength: { 512 }) 41 | let readyToUpdateSubject = PassthroughSubject() 42 | let peripheralManager = PeripheralManager.unimplemented( 43 | updateValueForCharacteristic: { _, _, _ in false }, 44 | readyToUpdateSubscribers: readyToUpdateSubject.eraseToAnyPublisher() 45 | ) 46 | 47 | var complete = false 48 | peripheralManager.updateValue(Data(), for: characteristic, onSubscribedCentrals: [central]) 49 | .sink { c in 50 | complete = true 51 | if case .finished = c { 52 | XCTFail("Expected an error to be thrown if updating fails after the builtin retry count") 53 | } 54 | } receiveValue: { _ in 55 | 56 | }.store(in: &cancellables) 57 | 58 | for _ in 1...4 { 59 | readyToUpdateSubject.send(()) 60 | } 61 | XCTAssert(complete) 62 | } 63 | 64 | 65 | func testUpdateValueSucceedsAfter4Retries() throws { 66 | let characteristic = CBMutableCharacteristic(type: .init(string: "0001"), properties: [.notify], value: nil, permissions: [.readable]) 67 | let central = Central.unimplemented(identifier: .init(), maximumUpdateValueLength: { 512 }) 68 | let readyToUpdateSubject = PassthroughSubject() 69 | let shouldSucceed = LockIsolated(false) 70 | let peripheralManager = PeripheralManager.unimplemented( 71 | updateValueForCharacteristic: { _, _, _ in 72 | shouldSucceed.withValue { $0 } 73 | }, 74 | readyToUpdateSubscribers: readyToUpdateSubject.eraseToAnyPublisher() 75 | ) 76 | 77 | var complete = false 78 | peripheralManager.updateValue(Data(), for: characteristic, onSubscribedCentrals: [central]) 79 | .sink { c in 80 | complete = true 81 | if case let .failure(error) = c { 82 | XCTFail("Unexpected error: \(error)") 83 | } 84 | } receiveValue: { _ in 85 | 86 | } 87 | .store(in: &cancellables) 88 | 89 | for _ in 1...3 { 90 | readyToUpdateSubject.send(()) 91 | } 92 | shouldSucceed.setValue(true) 93 | readyToUpdateSubject.send(()) 94 | XCTAssert(complete) 95 | } 96 | } 97 | #endif 98 | -------------------------------------------------------------------------------- /Tests/CombineCoreBluetoothTests/PeripheralTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PeripheralTests.swift 3 | // 4 | // 5 | // Created by Kevin Lundberg on 4/26/22. 6 | // 7 | 8 | @testable import CombineCoreBluetooth 9 | @preconcurrency import Combine 10 | import XCTest 11 | import ConcurrencyExtras 12 | 13 | #if os(macOS) || os(iOS) 14 | class PeripheralTests: XCTestCase { 15 | 16 | override func setUpWithError() throws { 17 | // Put setup code here. This method is called before the invocation of each test method in the class. 18 | } 19 | 20 | override func tearDownWithError() throws { 21 | // Put teardown code here. This method is called after the invocation of each test method in the class. 22 | } 23 | 24 | // MARK: - Services 25 | 26 | func testDiscoverServicesFindsExpectedServices() throws { 27 | let expectedServices = [ 28 | CBMutableService(type: CBUUID(string: "0001"), primary: true) 29 | ] 30 | let otherServices = [ 31 | CBMutableService(type: CBUUID(string: "0002"), primary: true) 32 | ] 33 | 34 | let p = Peripheral.failing( 35 | discoverServices: { _ in }, 36 | didDiscoverServices: [ 37 | (otherServices, nil), 38 | (expectedServices, nil) 39 | ].publisher.neverComplete() 40 | ) 41 | 42 | let outputs = try p.discoverServices([CBUUID(string: "0001")]).waitForCompletion() 43 | XCTAssertEqual(outputs, [expectedServices]) 44 | } 45 | 46 | func testDiscoverAMissingServiceDoesntComplete() throws { 47 | let expectedServiceIDs = [ 48 | CBUUID(string: "0001"), 49 | CBUUID(string: "0002"), 50 | ] 51 | 52 | let actualServices = [ 53 | CBMutableService(type: CBUUID(string: "0001"), primary: true), 54 | CBMutableService(type: CBUUID(string: "0003"), primary: true) 55 | ] 56 | 57 | let p = Peripheral.failing( 58 | discoverServices: { ids in }, 59 | didDiscoverServices: Just((actualServices, nil)).append(Empty(completeImmediately: false)).eraseToAnyPublisher() 60 | ) 61 | 62 | let outputs = try p.discoverServices(expectedServiceIDs).waitForCompletion(inverted: true) 63 | // data should never be obtained if not all services are discovered. 64 | XCTAssertEqual(outputs, nil) 65 | } 66 | 67 | func testDiscoverServicesOnlyYieldsFirstMatchingDiscoveredServiceEntry() throws { 68 | let expectedServices = [ 69 | CBMutableService(type: CBUUID(string: "0123"), primary: true) 70 | ] 71 | 72 | let p = Peripheral.failing( 73 | discoverServices: { _ in }, 74 | didDiscoverServices: [ 75 | (expectedServices, nil), 76 | (expectedServices, nil) 77 | ].publisher.neverComplete() 78 | ) 79 | 80 | let outputs = try p.discoverServices([CBUUID(string: "0123")]).waitForCompletion() 81 | XCTAssertEqual(outputs, [expectedServices]) 82 | } 83 | 84 | func testDiscoverServicesWithNilIdsAlwaysSucceeds() throws { 85 | let services = [CBMutableService(type: CBUUID(string: "0001"), primary: true)] 86 | let otherServices = [CBMutableService(type: CBUUID(string: "0002"), primary: false)] 87 | 88 | let p = Peripheral.failing( 89 | discoverServices: { _ in }, 90 | didDiscoverServices: [ 91 | (services, nil), 92 | ].publisher.neverComplete() 93 | ) 94 | 95 | let outputs = try p.discoverServices(nil).waitForCompletion() 96 | XCTAssertEqual(outputs, [services]) 97 | 98 | let p2 = Peripheral.failing( 99 | discoverServices: { _ in }, 100 | didDiscoverServices: [ 101 | (otherServices, nil) 102 | ].publisher.neverComplete() 103 | ) 104 | 105 | let outputs2 = try p2.discoverServices(nil).waitForCompletion() 106 | XCTAssertEqual(outputs2, [otherServices]) 107 | } 108 | 109 | // MARK: - Included services 110 | 111 | func testDiscoverIncludedServicesFindsExpectedServices() throws { 112 | let service = CBMutableService(type: CBUUID(string: "0001"), primary: true) 113 | service.includedServices = [ 114 | CBMutableService(type: CBUUID(string: "0011"), primary: false) 115 | ] 116 | let otherService = CBMutableService(type: CBUUID(string: "0002"), primary: false) 117 | 118 | let p = Peripheral.failing( 119 | discoverIncludedServices: { _,_ in }, 120 | didDiscoverIncludedServices: [ 121 | (otherService, nil), 122 | (service, nil) 123 | ].publisher.neverComplete() 124 | ) 125 | 126 | let outputs = try p.discoverIncludedServices([CBUUID(string: "0011")], for: service).waitForCompletion() 127 | XCTAssertEqual(outputs, [service.includedServices]) 128 | } 129 | 130 | func testDiscoverMissingIncludedServicesDoesntComplete() throws { 131 | let service = CBMutableService(type: CBUUID(string: "0001"), primary: true) 132 | 133 | let p = Peripheral.failing( 134 | discoverIncludedServices: { _,_ in }, 135 | didDiscoverIncludedServices: Just((service, nil)).neverComplete() 136 | ) 137 | 138 | let outputs = try p.discoverIncludedServices([CBUUID(string: "0011")], for: service).waitForCompletion(inverted: true) 139 | // data should never be obtained if not all services are discovered. 140 | XCTAssertEqual(outputs, nil) 141 | } 142 | 143 | func testDiscoverIncludedServicesOnlyYieldsFirstMatchingDiscoveredServiceEntry() throws { 144 | let service = CBMutableService(type: CBUUID(string: "0001"), primary: true) 145 | service.includedServices = [CBMutableService(type: CBUUID(string: "0011"), primary: false)] 146 | 147 | let p = Peripheral.failing( 148 | discoverIncludedServices: { _,_ in }, 149 | didDiscoverIncludedServices: [ 150 | (service, nil), 151 | (service, nil) 152 | ].publisher.neverComplete() 153 | ) 154 | 155 | let outputs = try p.discoverIncludedServices([CBUUID(string: "0011")], for: service).waitForCompletion() 156 | 157 | XCTAssertEqual(outputs, [service.includedServices]) 158 | } 159 | 160 | func testDiscoverIncludedServicesWithNilIdsAlwaysSucceeds() throws { 161 | let service = CBMutableService(type: CBUUID(string: "0001"), primary: true) 162 | service.includedServices = [CBMutableService(type: CBUUID(string: "0011"), primary: false)] 163 | 164 | let p = Peripheral.failing( 165 | discoverIncludedServices: { _,_ in }, 166 | didDiscoverIncludedServices: [ 167 | (service, nil), 168 | ].publisher.neverComplete() 169 | ) 170 | 171 | let outputs = try p.discoverIncludedServices(nil, for: service).waitForCompletion() 172 | XCTAssertEqual(outputs, [service.includedServices]) 173 | 174 | let p2 = Peripheral.failing( 175 | discoverIncludedServices: { _,_ in }, 176 | didDiscoverIncludedServices: [ 177 | (service.includedServices!.first!, nil) 178 | ].publisher.neverComplete() 179 | ) 180 | 181 | let outputs2 = try p2.discoverIncludedServices(nil, for: service.includedServices!.first!).waitForCompletion() 182 | XCTAssertEqual(outputs2, [nil]) 183 | } 184 | 185 | // MARK: - Characteristics 186 | 187 | func testSubscribeToCharacteristicWorks() { 188 | let c = CBMutableCharacteristic(type: .init(string: "0001"), properties: [.read, .notify], value: nil, permissions: [.readEncryptionRequired]) 189 | 190 | let subject = PassthroughSubject<(Data?, Error?), Never>() 191 | let notifyValues: LockIsolated<[Bool]> = LockIsolated([]) 192 | 193 | let p = Peripheral.failing( 194 | setNotifyValue: { notifyValue, characteristic in notifyValues.withValue { $0.append(notifyValue) } }, 195 | didUpdateValueForCharacteristic: subject 196 | .map { 197 | c.value = $0 198 | return (c, $1) 199 | } 200 | .eraseToAnyPublisher(), 201 | didUpdateNotificationState: Just((c, nil)).eraseToAnyPublisher() 202 | ) 203 | 204 | var values: [Data?] = [] 205 | let cancellable = p.subscribeToUpdates(on: c).sink { cancellable in 206 | if case let .failure(error) = cancellable { 207 | XCTFail("\(error)") 208 | } 209 | } receiveValue: { 210 | values.append($0) 211 | } 212 | XCTAssertEqual(notifyValues.withValue { $0 }, [true]) 213 | 214 | subject.send((Data("a".utf8), nil)) 215 | subject.send((Data("b".utf8), nil)) 216 | cancellable.cancel() 217 | // test that cancellation unsubscribes to the characteristic 218 | XCTAssertEqual(notifyValues.withValue { $0 }, [true, false]) 219 | 220 | XCTAssertEqual(values, [Data("a".utf8), Data("b".utf8)]) 221 | } 222 | 223 | func testSubscribeToCharacteristicFailsWhenSetNotifyValueFails() { 224 | let c = CBMutableCharacteristic(type: .init(string: "0001"), properties: [.read, .notify], value: nil, permissions: [.readEncryptionRequired]) 225 | 226 | let notifyValues: LockIsolated<[Bool]> = LockIsolated([]) 227 | 228 | struct SomeError: Error {} 229 | 230 | let p = Peripheral.failing( 231 | setNotifyValue: { notifyValue, characteristic in notifyValues.withValue { $0.append(notifyValue) } }, 232 | didUpdateValueForCharacteristic: Empty().eraseToAnyPublisher(), 233 | didUpdateNotificationState: Just((c, SomeError())).eraseToAnyPublisher() 234 | ) 235 | 236 | var values: [Data?] = [] 237 | let cancellable = p.subscribeToUpdates(on: c).sink { cancellable in 238 | if case .finished = cancellable { 239 | XCTFail("Expected a failure") 240 | } 241 | } receiveValue: { 242 | values.append($0) 243 | } 244 | XCTAssertEqual(notifyValues.withValue { $0 }, [true]) 245 | XCTAssertEqual(values, []) 246 | cancellable.cancel() 247 | } 248 | } 249 | 250 | // MARK: - 251 | 252 | extension Publisher { 253 | func waitForCompletion(inverted: Bool = false) throws -> [Output]? { 254 | let exp = XCTestExpectation(description: "foo") 255 | exp.isInverted = inverted 256 | var error: Failure? 257 | var value: [Output]? 258 | let sub = collect().sink { completion in 259 | if case let .failure(e) = completion { 260 | error = e 261 | } 262 | exp.fulfill() 263 | } receiveValue: { output in 264 | value = output 265 | } 266 | 267 | _ = XCTWaiter.wait(for: [exp], timeout: 1) 268 | sub.cancel() 269 | 270 | if let error = error { 271 | throw error 272 | } else { 273 | return value 274 | } 275 | } 276 | 277 | func neverComplete() -> AnyPublisher { 278 | append(Empty(completeImmediately: false)) 279 | .eraseToAnyPublisher() 280 | } 281 | } 282 | 283 | extension Publisher where Output: Sendable { 284 | func firstValue() async throws -> Output { 285 | try await withCheckedThrowingContinuation { c in 286 | var cancellation: AnyCancellable? 287 | cancellation = self.first().sink( 288 | receiveCompletion: { completion in 289 | if case let .failure(error) = completion { 290 | c.resume(throwing: error) 291 | cancellation = nil 292 | } else { 293 | if cancellation != nil { 294 | c.resume(throwing: CancellationError()) 295 | } 296 | cancellation = nil 297 | } 298 | }, receiveValue: { value in 299 | c.resume(returning: value) 300 | cancellation = nil 301 | } 302 | ) 303 | } 304 | } 305 | } 306 | 307 | extension Peripheral { 308 | 309 | static func fail(_ name: String, file: StaticString = #file, line: UInt = #line) -> Void { 310 | XCTFail("\(name) called when no implementation is provided", file: file, line: line) 311 | } 312 | 313 | @_disfavoredOverload 314 | static func fail(_ name: String, file: StaticString = #file, line: UInt = #line) -> @Sendable () -> Void { 315 | { fail(name, file: file, line: line) } 316 | } 317 | 318 | static func fail(_ name: String, file: StaticString = #file, line: UInt = #line) -> @Sendable (A) -> Void { 319 | { _ in fail(name, file: file, line: line) } 320 | } 321 | 322 | static func fail(_ name: String, file: StaticString = #file, line: UInt = #line) -> @Sendable (A,B) -> Void { 323 | { _, _ in fail(name, file: file, line: line) } 324 | } 325 | 326 | static func fail(_ name: String, file: StaticString = #file, line: UInt = #line) -> @Sendable (A,B,C) -> Void { 327 | { _, _, _ in fail(name, file: file, line: line) } 328 | } 329 | 330 | static func fail(_ name: String, file: StaticString = #file, line: UInt = #line) -> AnyPublisher { 331 | Empty() 332 | .handleEvents( 333 | receiveSubscription: { _ in XCTFail("\(name) subscribed to when no implementation is provided", file: file, line: line) } 334 | ) 335 | .eraseToAnyPublisher() 336 | } 337 | 338 | static func failing( 339 | name: String? = nil, 340 | identifier: UUID = .init(), 341 | state: @escaping @Sendable () -> CBPeripheralState = { fail("name"); return .disconnected }, 342 | services: @escaping @Sendable () -> [CBService]? = { fail("services"); return nil }, 343 | canSendWriteWithoutResponse: @escaping @Sendable () -> Bool = { fail("canSendWriteWithoutResponse"); return false }, 344 | ancsAuthorized: @escaping @Sendable () -> Bool = { fail("ancsAuthorized"); return false }, 345 | readRSSI: @escaping @Sendable () -> Void = fail("readRSSI"), 346 | discoverServices: @escaping @Sendable ([CBUUID]?) -> Void = fail("discoverServices"), 347 | discoverIncludedServices: @escaping @Sendable ([CBUUID]?, CBService) -> Void = fail("discoverIncludedServices"), 348 | discoverCharacteristics: @escaping @Sendable ([CBUUID]?, CBService) -> Void = fail("discoverCharacteristics"), 349 | readValueForCharacteristic: @escaping @Sendable (CBCharacteristic) -> Void = fail("readValueForCharacteristic"), 350 | maximumWriteValueLength: @escaping @Sendable (CBCharacteristicWriteType) -> Int = { _ in fail("maximumWriteValueLength"); return 0 }, 351 | writeValueForCharacteristic: @escaping @Sendable (Data, CBCharacteristic, CBCharacteristicWriteType) -> Void = fail("writeValueForCharacteristic"), 352 | setNotifyValue: @escaping @Sendable (Bool, CBCharacteristic) -> Void = fail("setNotifyValue"), 353 | discoverDescriptors: @escaping @Sendable (CBCharacteristic) -> Void = fail("discoverDescriptors"), 354 | readValueForDescriptor: @escaping @Sendable (CBDescriptor) -> Void = fail("readValueForDescriptor"), 355 | writeValueForDescriptor: @escaping @Sendable (Data, CBDescriptor) -> Void = fail("writeValueForDescriptor"), 356 | openL2CAPChannel: @escaping @Sendable (CBL2CAPPSM) -> Void = fail("openL2CAPChannel"), 357 | didReadRSSI: AnyPublisher, Never> = fail("didReadRSSI"), 358 | didDiscoverServices: AnyPublisher<([CBService], Error?), Never> = fail("didDiscoverServices"), 359 | didDiscoverIncludedServices: AnyPublisher<(CBService, Error?), Never> = fail("didDiscoverIncludedServices"), 360 | didDiscoverCharacteristics: AnyPublisher<(CBService, Error?), Never> = fail("didDiscoverCharacteristics"), 361 | didUpdateValueForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> = fail("didUpdateValueForCharacteristic"), 362 | didWriteValueForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> = fail("didWriteValueForCharacteristic"), 363 | didUpdateNotificationState: AnyPublisher<(CBCharacteristic, Error?), Never> = fail("didUpdateNotificationState"), 364 | didDiscoverDescriptorsForCharacteristic: AnyPublisher<(CBCharacteristic, Error?), Never> = fail("didDiscoverDescriptorsForCharacteristic"), 365 | didUpdateValueForDescriptor: AnyPublisher<(CBDescriptor, Error?), Never> = fail("didUpdateValueForDescriptor"), 366 | didWriteValueForDescriptor: AnyPublisher<(CBDescriptor, Error?), Never> = fail("didWriteValueForDescriptor"), 367 | didOpenChannel: AnyPublisher<(L2CAPChannel?, Error?), Never> = fail("didOpenChannel"), 368 | isReadyToSendWriteWithoutResponse: AnyPublisher = fail("isReadyToSendWriteWithoutResponse"), 369 | nameUpdates: AnyPublisher = fail("nameUpdates"), 370 | invalidatedServiceUpdates: AnyPublisher<[CBService], Never> = fail("invalidatedServiceUpdates") 371 | ) -> Peripheral { 372 | Peripheral( 373 | rawValue: nil, 374 | delegate: nil, 375 | _name: { name }, 376 | _identifier: { identifier }, 377 | _state: state, 378 | _services: services, 379 | _canSendWriteWithoutResponse: canSendWriteWithoutResponse, 380 | _ancsAuthorized: ancsAuthorized, 381 | _readRSSI: readRSSI, 382 | _discoverServices: discoverServices, 383 | _discoverIncludedServices: discoverIncludedServices, 384 | _discoverCharacteristics: discoverCharacteristics, 385 | _readValueForCharacteristic: readValueForCharacteristic, 386 | _maximumWriteValueLength: maximumWriteValueLength, 387 | _writeValueForCharacteristic: writeValueForCharacteristic, 388 | _setNotifyValue: setNotifyValue, 389 | _discoverDescriptors: discoverDescriptors, 390 | _readValueForDescriptor: readValueForDescriptor, 391 | _writeValueForDescriptor: writeValueForDescriptor, 392 | _openL2CAPChannel: openL2CAPChannel, 393 | 394 | didReadRSSI: didReadRSSI, 395 | didDiscoverServices: didDiscoverServices, 396 | didDiscoverIncludedServices: didDiscoverIncludedServices, 397 | didDiscoverCharacteristics: didDiscoverCharacteristics, 398 | didUpdateValueForCharacteristic: didUpdateValueForCharacteristic, 399 | didWriteValueForCharacteristic: didWriteValueForCharacteristic, 400 | didUpdateNotificationState: didUpdateNotificationState, 401 | didDiscoverDescriptorsForCharacteristic: didDiscoverDescriptorsForCharacteristic, 402 | didUpdateValueForDescriptor: didUpdateValueForDescriptor, 403 | didWriteValueForDescriptor: didWriteValueForDescriptor, 404 | didOpenChannel: didOpenChannel, 405 | 406 | isReadyToSendWriteWithoutResponse: isReadyToSendWriteWithoutResponse, 407 | nameUpdates: nameUpdates, 408 | invalidatedServiceUpdates: invalidatedServiceUpdates 409 | ) 410 | } 411 | } 412 | #endif 413 | --------------------------------------------------------------------------------