├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── bluetooth-law-energy-swift │ ├── BluetoothLEManager.swift │ ├── Combine+ │ └── Publisher+.swift │ ├── delegate │ ├── BluetoothDelegate.swift │ └── PeripheralDelegate.swift │ ├── enum │ ├── Errors.swift │ └── ServiceType.swift │ ├── ext │ ├── CBPeripheral+.swift │ └── Task+.swift │ ├── model │ └── BLEState.swift │ ├── protocol │ ├── IBluetoothLEManager.swift │ └── ILogger.swift │ └── service │ ├── AppleLogger.swift │ ├── CacheServices.swift │ ├── ServiceRegistration.swift │ ├── State.swift │ ├── StreamFactory.swift │ └── StreamRegistration.swift ├── Tests └── bluetooth-law-energy-swiftTests │ └── BluetoothLEManagerTests.swift └── img ├── ble_discover.gif ├── ble_flow.png ├── ble_mac.png └── ble_manager.jpeg /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [bluetooth-law-energy-swift] 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Igor Shelopaev 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" : "retry-policy-service", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swiftuiux/retry-policy-service.git", 7 | "state" : { 8 | "revision" : "2a6a1f057fbf77337dfc73db98bd3d538127b3e2", 9 | "version" : "1.0.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 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: "bluetooth-law-energy-swift", 8 | platforms: [.macOS(.v12), .iOS(.v15), .watchOS(.v8), .tvOS(.v15)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "bluetooth-law-energy-swift", 13 | targets: ["bluetooth-law-energy-swift"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | .package(url: "https://github.com/swiftuiux/retry-policy-service.git", from: "1.0.1"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package, defining a module or a test suite. 21 | // Targets can depend on other targets in this package and products from dependencies. 22 | .target( 23 | name: "bluetooth-law-energy-swift", 24 | dependencies: [ 25 | .product(name: "retry-policy-service", package: "retry-policy-service"), 26 | ]), 27 | .testTarget( 28 | name: "bluetooth-law-energy-swiftTests", 29 | dependencies: ["bluetooth-law-energy-swift"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⭐ Star it — so I know it’s worth to keep improving it. 2 | 3 | # BLE Asynchronous Bluetooth Low Energy Kit 4 | 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fbluetooth-law-energy-swift%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swiftuiux/bluetooth-law-energy-swift) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fbluetooth-law-energy-swift%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swiftuiux/bluetooth-law-energy-swift) 6 | 7 | ## [Swiftui example](https://github.com/swiftuiux/ble-bluetooth-law-energy-swiftui-example-ios) 8 | 9 | 10 | ## [Documentation(API)](https://swiftpackageindex.com/swiftuiux/bluetooth-law-energy-swift/main/documentation/bluetooth_law_energy_swift) 11 | 12 | ![macOS 11](https://github.com/swiftuiux/bluetooth-law-energy-swift/blob/main/img/ble_mac.png) 13 | 14 | ## Main Features 15 | 16 | | Feature | Description | 17 | |---------|-------------| 18 | | **Bluetooth Authorization Management** | Manage and request Bluetooth authorization permissions from the user. | 19 | | **Bluetooth Power Management** | Monitor and handle Bluetooth power state changes to ensure functionality. | 20 | | **State Publishing** | Publish the current state of the Bluetooth manager, including authorization and power status. | 21 | | **User Interface Integration** | Integrate seamlessly with user interfaces to provide real-time updates on Bluetooth status and devices. | 22 | | **Peripheral Management** | Manage discovered Bluetooth peripherals, including connection and disconnection. | 23 | | **Multi-platform** | Support for multiple platforms, ensuring compatibility across different devices and operating systems. | 24 | | **Utilizing Modern Concurrency in Swift with Async Stream** | Employ modern concurrency techniques in Swift, such as AsyncStream, for efficient and responsive Bluetooth operations. | 25 | | **Scanning of Available Devices** | Scan for and discover available Bluetooth devices in the vicinity. | 26 | | **Fetching Services for Discovered Devices Asynchronously** | Fetch and manage services for discovered Bluetooth devices using asynchronous methods, ensuring smooth and non-blocking operations. | 27 | 28 | ## Typical Workflow for Discovering Characteristics on a Peripheral 29 | 30 | The following blocks show a workflow for using CBCentralManager. The flowchart provides a visualization of the key steps involved in managing Bluetooth Low Energy devices and gives you a theoretical basis to get started. 31 | 32 | ![macOS 11](https://github.com/swiftuiux/bluetooth-law-energy-swift/blob/main/img/ble_flow.png) 33 | 34 | 35 | ## Bluetooth LE Manager Implementation Specifics 36 | 37 | `BluetoothLEManager` serves as a wrapper around `CBCentralManager`, providing a streamlined interface for managing Bluetooth Low Energy (BLE) operations. This package integrates the authorization and power state monitoring specific to Apple's implementation for BLE devices, which simplifies handling these aspects within your application. 38 | 39 | ### Key Implementation Details 40 | 41 | 1. **Authorization and Power State Management**: 42 | - When a `CBCentralManager` is instantiated, it automatically prompts the user for Bluetooth authorization if it hasn't been granted yet. This prompt is managed by the system, and no additional code is needed to request authorization. 43 | - If Bluetooth is turned off during the lifecycle of `BluetoothLEManager`, the manager will handle this state change and update the relevant properties (`isAuthorized`, `isPowered`, etc.) accordingly. This ensures that your application remains informed about the Bluetooth state throughout its lifecycle. 44 | 45 | 2. **Handling Mid-Lifecycle Bluetooth State Changes**: 46 | - If Bluetooth is turned off or access is denied while `BluetoothLEManager` is active, the manager will process this change and provide the necessary updates to the state properties. This allows your application to respond to changes in Bluetooth availability dynamically. 47 | 48 | 3. **User Authorization Handling**: 49 | - When a `CBCentralManager` is created, it prompts the user for authorization if it hasn't been granted yet. If the user denies this request, `BluetoothLEManager` will detect this and update its `isAuthorized` property accordingly. 50 | - If authorization is denied, the manager can inform the user and suggest that they enable Bluetooth access in the device settings. This ensures that your application can guide users to resolve authorization issues without requiring additional implementation. 51 | 52 | 4. **Dynamic Scanning Based on Subscribers**: 53 | - `BluetoothLEManager` manages the scanning process based on the number of active subscribers waiting the peripheral list. 54 | - Scanning for peripherals starts only when at least one subscriber is connected through the `peripheralsStream` method to get the list of peripherals. This ensures that scanning is active when there is a need for peripheral data. 55 | - When the number of subscribers drops to zero, the manager stops scanning to conserve resources and battery life. This allows efficient use of the device's Bluetooth capabilities. 56 | 5. **Specifics of Authorizing Access to Bluetooth and Checking Availability for macOS**: 57 | - Detailed guidance on these aspects can be found [here](https://github.com/swiftuiux/bluetooth-law-energy-swiftui-example). 58 | 59 | ## Public API 60 | 61 | | Name | Type | Description | Type/Return Type | 62 | |---------------------|----------|-------------------------------------------------------------------------------------------------------------------------|-------------------------------------| 63 | | `bleState` | Property | A subject that publishes the BLE state changes. | `CurrentValueSubject` | 64 | | `peripheralsStream` | Property | Provides an asynchronous stream of discovered Bluetooth peripherals. | `AsyncStream<[CBPeripheral]>` | 65 | | `discoverServices` | Method | Fetches services for a given peripheral, with optional caching and optional disconnection. | `async throws -> [CBService]` | 66 | | `connect` | Method | Connects to a specific peripheral. 🟡 Always use the same BluetoothLEManager instance to manage connections and disconnections for a peripheral to avoid errors and ensure correct behavior. | `async throws -> Void` | 67 | | `disconnect` | Method | Disconnects from a specific peripheral. | `async throws -> Void` | 68 | 69 | Apple’s documentation specifies that all Core Bluetooth interactions should be performed on the main thread to maintain thread safety and proper synchronization of Bluetooth events. This includes interactions with CBCentralManager, such as connecting and disconnecting peripherals. 70 | 71 | ### BLEState 72 | 73 | The `BLEState` struct provides information about the current state of Bluetooth on the device. This struct includes three key properties that indicate whether Bluetooth is authorized, powered on, and actively scanning for peripherals. 74 | 75 | #### Properties 76 | 77 | | Name | Type | Description | 78 | |----------------|--------|-----------------------------------------------------------------------------| 79 | | `isAuthorized` | Bool | Indicates if Bluetooth is authorized (`true` if authorized, `false` otherwise). | 80 | | `isPowered` | Bool | Indicates if Bluetooth is powered on (`true` if powered, `false` otherwise). | 81 | | `isScanning` | Bool | Indicates if Bluetooth is currently scanning for peripherals (`true` if scanning, `false` otherwise). | 82 | 83 | 84 | ### Description of `IBluetoothLEManager` Protocol 85 | 86 | The `IBluetoothLEManager` protocol defines the essential functionalities for managing Bluetooth Low Energy (BLE) operations. It includes properties and methods for monitoring the state of Bluetooth, discovering peripherals, and fetching services for a specific peripheral. This protocol is intended to be implemented by classes or structures that handle Bluetooth communication on macOS 12.0+, iOS 15.0+, tvOS 15.0+, and watchOS 8.0+. 87 | 88 | 89 | | iOS | macOS | 90 | |------|-------| 91 | | ![iOS 15](https://github.com/swiftuiux/bluetooth-law-energy-swift/blob/main/img/ble_manager.jpeg) | ![macOS 12](https://github.com/swiftuiux/bluetooth-law-energy-swift/blob/main/img/ble_discover.gif) | 92 | 93 | ## License 94 | 95 | This project is licensed under the MIT License. 96 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/BluetoothLEManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BluetoothLEManager.swift 3 | // 4 | // 5 | // Created by Igor on 12.07.24. 6 | // 7 | 8 | import Combine 9 | import CoreBluetooth 10 | import retry_policy_service 11 | 12 | /// Manages Bluetooth Low Energy (BLE) interactions using Combine and CoreBluetooth. 13 | @available(macOS 12, iOS 15, tvOS 15.0, watchOS 8.0, *) 14 | public final class BluetoothLEManager: NSObject, ObservableObject, IBluetoothLEManager { 15 | 16 | /// Publishes BLE state changes to the main actor. 17 | @MainActor 18 | public let bleState: CurrentValueSubject = .init(.init()) 19 | 20 | /// Internal state flags. 21 | private var isAuthorized = false 22 | private var isPowered = false 23 | private var isScanning = false 24 | 25 | /// Type aliases for publishers. 26 | private typealias StatePublisher = AnyPublisher 27 | private typealias PeripheralPublisher = AnyPublisher<[CBPeripheral], Never> 28 | 29 | /// Publishers for state and peripheral updates. 30 | private var getStatePublisher: StatePublisher { delegateHandler.statePublisher } 31 | private var getPeripheralPublisher: PeripheralPublisher { delegateHandler.peripheralPublisher } 32 | 33 | /// CoreBluetooth central manager. 34 | private let centralManager: CBCentralManager 35 | /// The queue on which the central delivers callbacks; we also dispatch central calls onto this queue. 36 | private let bleQueue: DispatchQueue 37 | 38 | @MainActor 39 | private let cachedServices = CacheServices() 40 | 41 | private typealias Delegate = BluetoothDelegate 42 | private let stream : StreamFactory 43 | private let delegateHandler: Delegate 44 | private var cancellables: Set = [] 45 | private let retry = RetryService(strategy: .exponential(retry: 5, multiplier: 2, duration: .seconds(3), timeout: .seconds(15))) 46 | private let logger: ILogger 47 | 48 | /// Initializes the BluetoothLEManager with a logger. 49 | /// Always uses the main queue for CoreBluetooth to ensure thread safety and consistent behavior. 50 | public init(logger: ILogger?) { 51 | let logger = logger ?? AppleLogger(subsystem: "BluetoothLEManager", category: "Bluetooth") 52 | self.logger = logger 53 | stream = StreamFactory(logger: logger) 54 | delegateHandler = Delegate(logger: logger) 55 | 56 | // Always main queue — CoreBluetooth interactions are guaranteed to run here. 57 | self.bleQueue = .main 58 | self.centralManager = CBCentralManager(delegate: delegateHandler, queue: bleQueue) 59 | 60 | super.init() 61 | setupSubscriptions() 62 | } 63 | 64 | /// Deinitializes the BluetoothLEManager. 65 | deinit { 66 | onBLE { [centralManager] in 67 | centralManager.stopScan() 68 | centralManager.delegate = nil 69 | } 70 | logger.log("BluetoothManager deinitialized", level: .debug) 71 | } 72 | 73 | // MARK: - Queue helper 74 | 75 | @inline(__always) 76 | private func onBLE(_ block: @escaping () -> Void) { 77 | bleQueue.async(execute: block) // always hop; simpler & safe 78 | } 79 | 80 | // MARK: - API 81 | 82 | /// Provides a stream of discovered peripherals. 83 | @MainActor 84 | public var peripheralsStream: AsyncStream<[CBPeripheral]> { 85 | get async{ 86 | await stream.peripheralsStream() 87 | } 88 | } 89 | 90 | /// Discovers services for a given peripheral, with optional caching and optional disconnection. 91 | /// 92 | /// Note: public API stays on @MainActor; CoreBluetooth calls are dispatched to the central's queue. 93 | @MainActor 94 | public func discoverServices(for peripheral: CBPeripheral, from cache: Bool = true, disconnect: Bool = true) async throws -> [CBService] { 95 | 96 | try Task.checkCancellation() // ensure early exit if cancelled 97 | defer { 98 | if disconnect { 99 | // Ensure cancellation runs on the central's queue. 100 | onBLE { [centralManager] in 101 | centralManager.cancelPeripheralConnection(peripheral) 102 | } 103 | } 104 | } 105 | 106 | // Check the cache before attempting to fetch services. 107 | if cache, let services = cachedServices.fetch(for: peripheral) { 108 | return services 109 | } 110 | 111 | for delay in retry { 112 | do { 113 | return try await attemptFetchServices(for: peripheral, cache: cache) 114 | } catch { } 115 | 116 | try Task.checkCancellation() 117 | try await Task.sleep(nanoseconds: delay) 118 | 119 | if cache, let services = cachedServices.fetch(for: peripheral) { 120 | return services 121 | } 122 | } 123 | 124 | // Final attempt to fetch services if retries fail 125 | return try await attemptFetchServices(for: peripheral, cache: cache) 126 | } 127 | 128 | /// Connects to a specific peripheral. 129 | /// Contract: 130 | /// - Public API is @MainActor for UI-facing consistency. 131 | /// - We *register* an async expectation with `delegateHandler` first, 132 | /// then invoke CoreBluetooth on the central's queue via `onBLE`. 133 | /// - Cancellation is checked early and again right before the central call, 134 | /// so the operation can exit cleanly if the task was cancelled. 135 | @MainActor 136 | public func connect(to peripheral: CBPeripheral) async throws { 137 | guard peripheral.isNotConnected else { throw Errors.connected(peripheral) } 138 | try Task.checkCancellation() 139 | 140 | return try await withCheckedThrowingContinuation { continuation in 141 | Task { 142 | let id = peripheral.getId 143 | let name = peripheral.getName 144 | do { 145 | try await delegateHandler.connect(to: id, name: name, with: continuation) 146 | try Task.checkCancellation() // ensure early exit if cancelled 147 | onBLE { [weak self] in 148 | guard let self else { return } 149 | self.centralManager.connect(peripheral, options: nil) 150 | } 151 | } catch { 152 | continuation.resume(throwing: error) 153 | } 154 | } 155 | } 156 | } 157 | 158 | /// Disconnects from a specific peripheral. 159 | /// Contract: 160 | /// - Public API is @MainActor for UI-facing consistency. 161 | /// - We *register* an async expectation with `delegateHandler` first, 162 | /// then invoke CoreBluetooth on the central's queue via `onBLE`. 163 | /// - Cancellation is checked early and again right before the central call, 164 | /// so the operation can exit cleanly if the task was cancelled. 165 | @MainActor 166 | public func disconnect(from peripheral: CBPeripheral) async throws { 167 | guard peripheral.isConnected else { throw Errors.notConnected(peripheral.getName) } 168 | try Task.checkCancellation() 169 | 170 | return try await withCheckedThrowingContinuation { continuation in 171 | Task { 172 | let id = peripheral.getId 173 | let name = peripheral.getName 174 | do { 175 | try await delegateHandler.disconnect(to: id, name: name, with: continuation) 176 | try Task.checkCancellation() // ensure early exit if cancelled 177 | onBLE { [weak self] in 178 | guard let self else { return } 179 | self.centralManager.cancelPeripheralConnection(peripheral) 180 | } 181 | } catch { 182 | continuation.resume(throwing: error) 183 | } 184 | } 185 | } 186 | } 187 | 188 | // MARK: - Private Methods 189 | 190 | /// Attempts to connect to the given peripheral and fetch its services. 191 | @MainActor 192 | private func attemptFetchServices(for peripheral: CBPeripheral, cache: Bool) async throws -> [CBService] { 193 | try Task.checkCancellation() // ensure early exit if cancelled 194 | try await connect(to: peripheral) 195 | return try await discover(for: peripheral, cache: cache) 196 | } 197 | 198 | /// Discovers services for a connected peripheral. 199 | @MainActor 200 | private func discover(for peripheral: CBPeripheral, cache: Bool) async throws -> [CBService] { 201 | defer { peripheral.delegate = nil } 202 | 203 | try Task.checkCancellation() 204 | 205 | let delegate = PeripheralDelegate(logger: logger) 206 | peripheral.delegate = delegate 207 | try await delegate.discoverServices(for: peripheral) 208 | 209 | let services = peripheral.services ?? [] 210 | 211 | if cache { 212 | cachedServices.add(key: peripheral.getId, services: services) 213 | } 214 | 215 | return services 216 | } 217 | 218 | /// Sets up Combine subscriptions for state and peripheral changes. 219 | private func setupSubscriptions() { 220 | getPeripheralPublisher 221 | .sink { [weak self] peripherals in 222 | guard let self = self else { return } 223 | let stream = self.stream 224 | Task { 225 | await stream.updatePeripherals(peripherals) 226 | } 227 | } 228 | .store(in: &cancellables) 229 | 230 | Publishers.CombineLatest(getStatePublisher, stream.subscriberCountPublisher) 231 | .receiveOnMainAndEraseToAnyPublisher() 232 | .sink { [weak self] state, subscriberCount in 233 | guard let self = self else { return } 234 | let result = self.checkForScan(state, subscriberCount) 235 | let bleState = self.bleState 236 | Task { @MainActor in 237 | bleState.send(result) 238 | } 239 | } 240 | .store(in: &cancellables) 241 | } 242 | 243 | /// Checks if Bluetooth is ready (powered on and authorized). 244 | private var checkIfBluetoothReady: Bool { 245 | isAuthorized = State.isBluetoothAuthorized 246 | isPowered = State.isBluetoothPoweredOn(for: centralManager) 247 | return isPowered && isAuthorized 248 | } 249 | 250 | /// Starts or stops scanning based on the state and subscriber count. 251 | private func checkForScan(_ state: CBManagerState, _ subscriberCount: Int) -> BLEState { 252 | if !checkIfBluetoothReady { 253 | stopScanning() 254 | } else { 255 | if subscriberCount == 0 { 256 | stopScanning() 257 | } else { 258 | startScanning() 259 | } 260 | isScanning = subscriberCount != 0 261 | } 262 | 263 | return .init( 264 | isAuthorized: self.isAuthorized, 265 | isPowered: self.isPowered, 266 | isScanning: self.isScanning 267 | ) 268 | } 269 | 270 | /// Starts scanning for peripherals (dispatched to the central's queue). 271 | private func startScanning() { 272 | onBLE { [centralManager] in 273 | centralManager.scanForPeripherals(withServices: nil, options: nil) 274 | } 275 | } 276 | 277 | /// Stops scanning for peripherals (dispatched to the central's queue). 278 | private func stopScanning() { 279 | onBLE { [centralManager] in 280 | centralManager.stopScan() 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/Combine+/Publisher+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Publisher+.swift 3 | // 4 | // 5 | // Created by Igor on 12.07.24. 6 | // 7 | 8 | import Combine 9 | import CoreBluetooth 10 | 11 | /// Extension to the Publisher type where the Output is CBManagerState and Failure is Never 12 | extension Publisher where Output == CBManagerState, Failure == Never { 13 | 14 | /// Custom operator to drop the first state if it is .poweredOff 15 | /// 16 | /// - Returns: An `AnyPublisher` that drops the first state if it is .poweredOff 17 | func dropFirstIfPoweredOff() -> AnyPublisher { 18 | // Accumulates state with a count 19 | self.scan((0, CBManagerState.unknown)) { acc, newState in 20 | let (count, _) = acc 21 | // Increment count and update state 22 | return (count + 1, newState) 23 | } 24 | // Drop the first state if it is .poweredOff 25 | .drop { (count, state) in 26 | return count == 1 && state == .poweredOff 27 | } 28 | // Extract the actual CBManagerState value from the tuple 29 | .map { $0.1 } 30 | // Erase to AnyPublisher to hide the implementation details 31 | .eraseToAnyPublisher() 32 | } 33 | } 34 | 35 | /// Extension to the Publisher type for receiving on the main queue and erasing to AnyPublisher 36 | extension Publisher { 37 | 38 | /// Custom operator to receive output on the main thread and erase to AnyPublisher 39 | /// 40 | /// - Returns: An `AnyPublisher` that ensures the output is received on the main thread 41 | func receiveOnMainAndEraseToAnyPublisher() -> AnyPublisher { 42 | // Ensure the publisher receives output on the main thread 43 | self.receive(on: DispatchQueue.main) 44 | // Erase to AnyPublisher to hide the implementation details 45 | .eraseToAnyPublisher() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/delegate/BluetoothDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BluetoothDelegateHandler.swift 3 | // 4 | // 5 | // Created by Igor on 12.07.24. 6 | // 7 | 8 | import Combine 9 | import CoreBluetooth 10 | 11 | extension BluetoothLEManager { 12 | 13 | /// BluetoothDelegate is a final class that conforms to CBCentralManagerDelegate for handling Bluetooth interactions. 14 | final class BluetoothDelegate: NSObject, CBCentralManagerDelegate { 15 | 16 | /// A subject to publish Bluetooth state updates. 17 | private let stateSubject = PassthroughSubject() 18 | 19 | /// A subject to publish discovered Bluetooth peripherals. 20 | private let peripheralSubject = CurrentValueSubject<[CBPeripheral], Never>([]) 21 | 22 | /// Service registration for connection-related actions. 23 | private let connection: ServiceRegistration 24 | 25 | /// Service registration for disconnection-related actions. 26 | private let disconnection: ServiceRegistration 27 | 28 | /// Logger instance for logging purposes. 29 | private let logger: ILogger 30 | 31 | /// Initializes the manager with a logger and sets up service registrations for connection and disconnection. 32 | /// 33 | /// - Parameter logger: The logger instance to be used for logging. 34 | public init(logger: ILogger) { 35 | self.logger = logger 36 | connection = .init(type: .connection, logger: logger) 37 | disconnection = .init(type: .disconnection, logger: logger) 38 | } 39 | 40 | // MARK: - API 41 | 42 | /// Connects to a given peripheral. 43 | /// 44 | /// - Parameters: 45 | /// - peripheral: The `CBPeripheral` instance to connect to. 46 | /// - manager: The `CBCentralManager` used to manage the connection. 47 | /// - timeout: The time interval to wait before timing out the connection attempt. 48 | /// - Returns: The connected `CBPeripheral`. 49 | /// - Throws: A `BluetoothLEManager.Errors` error if the connection fails. 50 | public func connect( 51 | to id: UUID, 52 | name : String, 53 | with continuation : CheckedContinuation) async throws{ 54 | try await connection.register(to: id, name: name, with: continuation) 55 | } 56 | 57 | /// Disconnects from a peripheral. 58 | /// - Parameters: 59 | /// - id: The UUID of the peripheral to disconnect from. 60 | /// - name: The name of the peripheral to disconnect from. 61 | /// - continuation: A CheckedContinuation to handle the result of the disconnection operation asynchronously. 62 | /// - Throws: An error if the disconnection fails. 63 | public func disconnect( 64 | to id: UUID, 65 | name: String, 66 | with continuation: CheckedContinuation) async throws { 67 | try await disconnection.register(to: id, name: name, with: continuation) 68 | } 69 | 70 | /// A publisher for Bluetooth state updates, applying custom operators to handle initial powered-off state and receive on the main thread. 71 | public var statePublisher: AnyPublisher { 72 | stateSubject 73 | .dropFirstIfPoweredOff() 74 | } 75 | 76 | /// A publisher for discovered Bluetooth peripherals, ensuring updates are received on the main thread. 77 | public var peripheralPublisher: AnyPublisher<[CBPeripheral], Never> { 78 | peripheralSubject 79 | .eraseToAnyPublisher() 80 | } 81 | 82 | // MARK: - Delegate API methods 83 | 84 | /// Called when the central manager's state is updated. 85 | /// 86 | /// - Parameter central: The central manager whose state has been updated. 87 | public func centralManagerDidUpdateState(_ central: CBCentralManager) { 88 | // Send state updates through the state subject 89 | stateSubject.send(central.state) 90 | } 91 | 92 | /// Called when a peripheral is discovered during a scan. 93 | /// 94 | /// - Parameters: 95 | /// - central: The central manager that discovered the peripheral. 96 | /// - peripheral: The discovered peripheral. 97 | /// - advertisementData: A dictionary containing advertisement data. 98 | /// - RSSI: The signal strength of the peripheral. 99 | public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { 100 | var peripherals = peripheralSubject.value 101 | // Add the peripheral to the list if it hasn't been discovered before 102 | if !peripherals.contains(where: { $0.identifier == peripheral.identifier }) { 103 | peripherals.append(peripheral) 104 | peripheralSubject.send(peripherals) 105 | } 106 | } 107 | 108 | /// Called when a connection to a peripheral is successful. 109 | /// 110 | /// - Parameters: 111 | /// - central: The central manager managing the connection. 112 | /// - peripheral: The peripheral that has successfully connected. 113 | public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 114 | Task{ 115 | await connection.handleResult( 116 | for: peripheral, 117 | result: .success(Void()) 118 | ) 119 | } 120 | } 121 | 122 | /// Called when a connection attempt to a peripheral fails. 123 | /// 124 | /// - Parameters: 125 | /// - central: The central manager managing the connection. 126 | /// - peripheral: The peripheral that failed to connect. 127 | /// - error: The error that occurred during the connection attempt. 128 | public func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { 129 | Task{ 130 | let e = Errors.connection(peripheral, error) 131 | await connection.handleResult( 132 | for: peripheral, 133 | result: .failure(e) 134 | ) 135 | } 136 | } 137 | 138 | /// Called when a peripheral disconnects. 139 | /// 140 | /// - Parameters: 141 | /// - central: The central manager managing the connection. 142 | /// - peripheral: The peripheral that has disconnected. 143 | /// - error: The error that occurred during the disconnection, if any. 144 | public func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 145 | Task{ 146 | guard let error else{ 147 | await disconnection.handleResult( 148 | for: peripheral, 149 | result: .success(Void()) 150 | ) 151 | return 152 | } 153 | 154 | let e = Errors.disconnection(peripheral, error) 155 | await disconnection.handleResult( 156 | for: peripheral, 157 | result: .failure(e) 158 | ) 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/delegate/PeripheralDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PeripheralDelegateHandler.swift 3 | // 4 | // 5 | // Created by Igor on 15.07.24. 6 | // 7 | 8 | import Foundation 9 | import CoreBluetooth 10 | 11 | extension BluetoothLEManager { 12 | 13 | // Class to handle CB Peripheral Delegate 14 | class PeripheralDelegate: NSObject, CBPeripheralDelegate { 15 | 16 | /// Service registration for a specific action. 17 | private let service: ServiceRegistration 18 | 19 | /// Initializes the manager with a logger and sets up the service registration. 20 | /// 21 | /// - Parameter logger: The logger instance to be used for logging. 22 | init(logger: ILogger) { 23 | service = .init(type: .discovering, logger: logger) 24 | } 25 | 26 | /// Called when the peripheral discovers services 27 | /// 28 | /// - Parameters: 29 | /// - peripheral: The `CBPeripheral` instance that discovered services 30 | /// - error: An optional error if the discovery failed 31 | public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 32 | Task{ 33 | if let error = error { 34 | await service.handleResult(for: peripheral, result: .failure(BluetoothLEManager.Errors.discoveringServices(peripheral.getName, error))) 35 | 36 | } else{ 37 | await service.handleResult(for: peripheral, result: .success(Void())) 38 | } 39 | } 40 | } 41 | 42 | /// Initiates the discovery of services on the specified peripheral 43 | /// 44 | /// - Parameter peripheral: The `CBPeripheral` instance on which to discover services 45 | /// - Returns: An array of `CBService` representing the services supported by the peripheral 46 | /// - Throws: An error if service discovery fails 47 | @MainActor 48 | public func discoverServices(for peripheral: CBPeripheral) async throws { 49 | return try await withCheckedThrowingContinuation { continuation in 50 | Task{ 51 | let id = peripheral.getId 52 | let name = peripheral.getName 53 | try await service.register(to: id, name: name, with: continuation) 54 | guard peripheral.isConnected else{ 55 | await service.handleResult(for: peripheral, result: .failure(BluetoothLEManager.Errors.discoveringServices(peripheral.getName, nil))) 56 | return 57 | } 58 | peripheral.discoverServices(nil) 59 | } 60 | } 61 | } 62 | 63 | public func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { 64 | 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/enum/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // 4 | // 5 | // Created by Igor on 15.07.24. 6 | // 7 | 8 | import CoreBluetooth 9 | 10 | public extension BluetoothLEManager { 11 | 12 | /// Errors enum represents possible errors within Bluetooth operations, conforming to Error and LocalizedError protocols. 13 | enum Errors: Error, LocalizedError { 14 | 15 | /// Error encountered while discovering services. 16 | case discoveringServices(String, Error?) 17 | 18 | /// Error encountered while discovering characteristics. 19 | case discoveringCharacteristics(String, Error?) 20 | 21 | /// Error encountered while connecting. 22 | case connection(CBPeripheral, Error?) 23 | 24 | /// Error when peripheral is already connected. 25 | case connected(CBPeripheral) 26 | 27 | /// Error when peripheral is not connected. 28 | case notConnected(String) 29 | 30 | /// Error when peripheral is currently connecting. 31 | case connecting(String) 32 | 33 | /// Error when an operation times out. 34 | case timeout 35 | 36 | /// Error when an operation times out. 37 | case timeoutServices 38 | 39 | case noServices(String) 40 | 41 | /// Error encountered while disconnecting. 42 | case disconnection(CBPeripheral, Error?) 43 | 44 | /// Text description 45 | public var errorDescription: String? { 46 | switch self { 47 | case .noServices(let name): 48 | return NSLocalizedString("No services found for \(name).", comment: "No Services Error") 49 | case .discoveringServices(let message, let error): 50 | if let error = error { 51 | return NSLocalizedString("Error discovering services for \(message): \(error.localizedDescription)", comment: "Discovering Services Error") 52 | } else { 53 | return NSLocalizedString("Error discovering services for \(message)", comment: "Discovering Services Error") 54 | } 55 | case .discoveringCharacteristics(let message, let error): 56 | if let error = error { 57 | return NSLocalizedString("Error discovering characteristics for \(message): \(error.localizedDescription)", comment: "Discovering Characteristics Error") 58 | } else { 59 | return NSLocalizedString("Error discovering characteristics for \(message)", comment: "Discovering Characteristics Error") 60 | } 61 | case .connection(let peripheral, let error): 62 | return NSLocalizedString("Error connecting to \(peripheral.name ?? "unknown peripheral"): \(error?.localizedDescription ?? "unknown error")", comment: "Connection Error") 63 | case .connected(let peripheral): 64 | return NSLocalizedString("The peripheral \(peripheral.name ?? "unknown peripheral") is already connected. Please disconnect and try again.", comment: "Already Connected Error") 65 | case .notConnected(let name): 66 | return NSLocalizedString("The peripheral \(name) is not connected. Please connect first.", comment: "Not Connected Error") 67 | case .connecting(let name): 68 | return NSLocalizedString("The peripheral \(name) is currently connecting. Please wait until the connection is complete.", comment: "Connecting Error") 69 | case .timeout: 70 | return NSLocalizedString("Connecting operation timed out for peripheral.", comment: "Timeout Error") 71 | case .timeoutServices: 72 | return NSLocalizedString("Discovering operation timed out for peripheral.", comment: "Timeout Error") 73 | case .disconnection(let peripheral, let error): 74 | return NSLocalizedString("Error disconnecting from \(peripheral.name ?? "unknown peripheral"): \(error?.localizedDescription ?? "unknown error")", comment: "Disconnection Error") 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/enum/ServiceType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceType.swift 3 | // 4 | // 5 | // Created by Igor on 22.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension BluetoothLEManager { 11 | 12 | /// An enumeration representing the different types of services. 13 | enum ServiceType: String { 14 | /// The service type for connecting to a peripheral. 15 | case connection = "Connecting to" 16 | /// The service type for disconnecting from a peripheral. 17 | case disconnection = "Disconnecting from" 18 | /// The service type for discovering services or characteristics for a peripheral. 19 | case discovering = "Discovering for" 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/ext/CBPeripheral+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CBPeripheral+.swift 3 | // 4 | // 5 | // Created by Igor on 19.07.24. 6 | // 7 | 8 | import CoreBluetooth 9 | import Foundation 10 | 11 | /// Extension of CBPeripheral to include computed properties related to the connection state and identity of Bluetooth peripherals. 12 | @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) 13 | extension CBPeripheral { 14 | 15 | // MARK: - Computed Properties 16 | 17 | /// Checks if the peripheral is connected. 18 | public var isConnected: Bool { 19 | self.state == .connected 20 | } 21 | 22 | /// Checks if the peripheral is not connected. 23 | public var isNotConnected: Bool { 24 | self.state != .connected 25 | } 26 | 27 | /// Retrieves the identifier of the peripheral. 28 | public var getId: UUID { 29 | return self.identifier 30 | } 31 | 32 | /// Retrieves the name of the peripheral. If the name is nil, returns "unknown". 33 | public var getName: String { 34 | name ?? "unknown" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/ext/Task+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+.swift 3 | // 4 | // 5 | // Created by Igor on 18.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Extends Task to provide a sleep function when used in an async context. 12 | @available(iOS 15, *) 13 | @available(macOS 12, *) 14 | @available(watchOS 8, *) 15 | @available(tvOS 15, *) 16 | extension Task where Success == Never, Failure == Never { 17 | 18 | @available(iOS, deprecated: 16.0, message: "Use Task.sleep(for: .seconds(_)) on iOS 16+") 19 | @available(macOS, deprecated: 13.0, message: "Use Task.sleep(for: .seconds(_)) on macOS 13+") 20 | @available(watchOS, deprecated: 9.0, message: "Use Task.sleep(for: .seconds(_)) on watchOS 9+") 21 | @available(tvOS, deprecated: 16.0, message: "Use Task.sleep(for: .seconds(_)) on tvOS 16+") 22 | static func sleep(for seconds: Double) async throws { 23 | // Не зовём новый API, чтобы не пересекаться по имени. 24 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/model/BLEState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BLEState.swift 3 | // 4 | // 5 | // Created by Igor on 19.07.24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents the state of Bluetooth connectivity and permissions. 11 | @available(macOS 12, iOS 15, tvOS 15.0, watchOS 8.0, *) 12 | public struct BLEState { 13 | 14 | /// Indicates if Bluetooth is authorized. 15 | public let isAuthorized: Bool 16 | 17 | /// Indicates if Bluetooth is powered on. 18 | public let isPowered: Bool 19 | 20 | /// Indicates if Bluetooth is currently scanning for peripherals. 21 | public let isScanning: Bool 22 | 23 | /// Initializes a new `BLEState` instance. 24 | /// 25 | /// - Parameters: 26 | /// - isAuthorized: A boolean indicating if Bluetooth is authorized (default is `false`). 27 | /// - isPowered: A boolean indicating if Bluetooth is powered on (default is `false`). 28 | /// - isScanning: A boolean indicating if Bluetooth is scanning (default is `false`). 29 | public init(isAuthorized: Bool = false, isPowered: Bool = false, isScanning: Bool = false) { 30 | self.isAuthorized = isAuthorized 31 | self.isPowered = isPowered 32 | self.isScanning = isScanning 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/protocol/IBluetoothLEManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IBluetoothLEManager.swift 3 | // 4 | // 5 | // Created by Igor on 16.07.24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import CoreBluetooth 11 | 12 | /// A protocol defining the Bluetooth LE manager functionality. 13 | @available(macOS 12, iOS 15, tvOS 15.0, watchOS 8.0, *) 14 | @preconcurrency 15 | public protocol IBluetoothLEManager { 16 | 17 | /// A subject that publishes the BLE state changes. 18 | @MainActor 19 | var bleState: CurrentValueSubject { get } 20 | 21 | /// Provides an asynchronous stream of discovered Bluetooth peripherals. 22 | @MainActor 23 | var peripheralsStream: AsyncStream<[CBPeripheral]> { get async } 24 | 25 | /// Discovers services for a given peripheral, with optional caching and disconnection. 26 | /// - Parameters: 27 | /// - peripheral: The `CBPeripheral` instance to fetch services for. 28 | /// - cache: A Boolean value indicating whether to use cached data. Defaults to `true`. 29 | /// - disconnect: A Boolean value indicating whether to disconnect from the peripheral after fetching services. Defaults to `true`. 30 | /// - Returns: An array of `CBService` instances. 31 | /// - Throws: An error if the services could not be fetched. 32 | @MainActor 33 | func discoverServices(for peripheral: CBPeripheral, from cache: Bool, disconnect: Bool) async throws -> [CBService] 34 | 35 | /// Connects to a specific peripheral. 36 | /// Always use the same CBCentralManager instance to manage connections and disconnections for a peripheral to avoid errors and ensure correct behavior. 37 | /// - Parameter peripheral: The `CBPeripheral` instance to connect to. 38 | /// - Throws: `BluetoothLEManager.Errors` if the connection fails. 39 | @MainActor 40 | func connect(to peripheral: CBPeripheral) async throws 41 | 42 | /// Disconnects from a specific peripheral. 43 | /// Always use the same CBCentralManager instance to manage connections and disconnections for a peripheral to avoid errors and ensure correct behavior. 44 | /// - Parameter peripheral: The `CBPeripheral` instance to disconnect from. 45 | /// - Throws: `BluetoothLEManager.Errors` if the disconnection fails. 46 | @MainActor 47 | func disconnect(from peripheral: CBPeripheral) async throws 48 | } 49 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/protocol/ILogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ILogger.swift 3 | // 4 | // 5 | // Created by Igor on 23.07.24. 6 | // 7 | 8 | import os 9 | 10 | /// Protocol defining a logger with a log method. 11 | public protocol ILogger { 12 | /// Logs a message with a specified log level. 13 | /// 14 | /// - Parameters: 15 | /// - message: The message to log. 16 | /// - level: The log level of type `OSLogType`. 17 | func log(_ message: String, level: OSLogType) 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/service/AppleLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleLogger.swift 3 | // 4 | // 5 | // Created by Igor on 23.07.24. 6 | // 7 | 8 | import os 9 | 10 | /// A logger implementation that uses Apple's os.Logger for logging. 11 | /// This class conforms to the ILogger protocol. 12 | public class AppleLogger: ILogger { 13 | 14 | /// The underlying os.Logger instance used for logging. 15 | private let logger: Logger 16 | 17 | /// Initializes a new instance of AppleLogger with the specified subsystem and category. 18 | /// 19 | /// - Parameters: 20 | /// - subsystem: The subsystem identifier (typically the bundle identifier of your app). 21 | /// - category: The logging category (e.g., a specific module or feature). 22 | public init(subsystem: String, category: String) { 23 | logger = Logger(subsystem: subsystem, category: category) 24 | } 25 | 26 | /// Logs a message at the specified log level. 27 | /// 28 | /// - Parameters: 29 | /// - message: The message to log. 30 | /// - level: The log level (e.g., .default, .info, .debug, .error). Defaults to .default. 31 | public func log(_ message: String, level: OSLogType = .default) { 32 | logger.log(level: level, "\(message, privacy: .public)") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/service/CacheServices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CachedServices.swift 3 | // 4 | // 5 | // Created by Igor on 19.07.24. 6 | // 7 | 8 | import CoreBluetooth 9 | 10 | extension BluetoothLEManager { 11 | 12 | /// CacheServices is an actor designed to safely manage concurrent access to a cache of Bluetooth services. 13 | /// It stores services associated with peripherals identified by UUIDs. 14 | @MainActor 15 | final class CacheServices { 16 | 17 | /// Private dictionary to hold the cached services with peripheral UUID as the key. 18 | 19 | private var data: [UUID: [CBService]] = [:] 20 | 21 | deinit{ 22 | data = [:] 23 | } 24 | 25 | // MARK: - API 26 | 27 | /// Adds or updates a list of `CBService` objects for a given UUID. 28 | /// - Parameters: 29 | /// - key: The UUID of the peripheral whose services are being cached. 30 | /// - services: An array of `CBService` objects to cache. 31 | public func add(key: UUID, services: [CBService]) { 32 | data[key] = services 33 | } 34 | 35 | /// Removes cached services for a specific UUID. 36 | /// - Parameter key: The UUID of the peripheral whose services are to be removed from the cache. 37 | public func remove(key: UUID) { 38 | data.removeValue(forKey: key) 39 | } 40 | 41 | /// Clears all cached services from the dictionary. 42 | public func removeAll() { 43 | data = [:] 44 | } 45 | 46 | /// Fetches the cached services for a specific peripheral. 47 | /// - Parameter peripheral: The `CBPeripheral` whose services are to be fetched. 48 | /// - Returns: An optional array of `CBService`, if services are found for the UUID; otherwise, nil. 49 | public func fetch(for peripheral: CBPeripheral) -> [CBService]? { 50 | let key = peripheral.getId 51 | return data[key] 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/service/ServiceRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceRegistration.swift 3 | // 4 | // 5 | // Created by Igor on 19.07.24. 6 | // 7 | 8 | import CoreBluetooth 9 | 10 | extension BluetoothLEManager { 11 | 12 | /// Actor responsible for managing service registration for Bluetooth LE operations. 13 | actor ServiceRegistration { 14 | 15 | // MARK: - Public Properties 16 | 17 | /// The type of the service. 18 | public let type: ServiceType 19 | 20 | // MARK: - Private Properties 21 | 22 | /// A dictionary to keep track of registered continuations. 23 | private var register: [UUID: CheckedContinuation] = [:] 24 | 25 | /// Initializes the BluetoothLEManager. 26 | private let logger: ILogger 27 | 28 | // MARK: - Initializer 29 | 30 | init(type: ServiceType, logger: ILogger) { 31 | self.type = type 32 | self.logger = logger 33 | } 34 | 35 | deinit{ 36 | register.forEach{ (key, value) in 37 | value.resume(throwing: Errors.timeout) 38 | } 39 | 40 | register = [:] 41 | } 42 | 43 | // MARK: - API 44 | 45 | /// Checks if a continuation for a given UUID is not active. 46 | /// - Parameter id: The UUID to check. 47 | /// - Returns: A Boolean value indicating whether the continuation is not active. 48 | public func isNotActive(_ id: UUID) -> Bool { 49 | return register[id] == nil 50 | } 51 | 52 | /// Adds a continuation for a given UUID. 53 | /// - Parameter continuation: The continuation to add. 54 | /// - Parameter id: The UUID to associate with the continuation. 55 | public func add(_ continuation: CheckedContinuation, for id: UUID) { 56 | register[id] = continuation 57 | } 58 | 59 | /// Handles the result of a peripheral operation. 60 | /// - Parameter peripheral: The peripheral associated with the operation. 61 | /// - Parameter result: The result of the operation. 62 | public func handleResult(for peripheral: CBPeripheral, result: Result) { 63 | let id = peripheral.identifier 64 | 65 | guard let continuation = register[id] else { 66 | return 67 | } 68 | 69 | remove(for: id) 70 | 71 | switch result { 72 | case .success(let value): 73 | continuation.resume(returning: value) 74 | case .failure(let error): 75 | continuation.resume(throwing: error) 76 | } 77 | } 78 | 79 | /// Registers a continuation for a given UUID and name with a timeout. 80 | /// - Parameters: 81 | /// - id: The UUID to register. 82 | /// - name: The name associated with the UUID. 83 | /// - continuation: The continuation to register. 84 | /// - timeout: The timeout duration for the registration. 85 | /// - Throws: An error if the UUID is already active. 86 | public func register( 87 | to id: UUID, 88 | name: String, 89 | with continuation: CheckedContinuation, 90 | timeout: Double = 30.0 91 | ) throws { 92 | 93 | guard isNotActive(id) else { 94 | continuation.resume(throwing: Errors.connecting(name)) 95 | return 96 | } 97 | 98 | add(continuation, for: id) 99 | 100 | logger.log("\(type.rawValue) \(name)", level: .debug) 101 | 102 | timeoutTask(for: id, timeout: timeout) 103 | } 104 | 105 | // MARK: - Private Methods 106 | 107 | /// Removes the continuation for a given UUID. 108 | /// - Parameter id: The UUID to remove. 109 | private func remove(for id: UUID) { 110 | register.removeValue(forKey: id) 111 | } 112 | 113 | /// Schedules a timeout task for a given UUID. 114 | /// - Parameters: 115 | /// - id: The UUID to associate with the timeout task. 116 | /// - timeout: The duration before the task times out. 117 | private func timeoutTask(for id: UUID, timeout: Double) { 118 | Task { 119 | try? await Task.sleep(for: timeout) 120 | 121 | guard let continuation = register[id] else { 122 | return 123 | } 124 | 125 | remove(for: id) 126 | 127 | logger.log("timeout \(type) \(id)", level: .debug) 128 | 129 | continuation.resume(throwing: Errors.timeout) 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/service/State.swift: -------------------------------------------------------------------------------- 1 | // 2 | // State.swift 3 | // 4 | // 5 | // Created by Igor on 12.07.24. 6 | // 7 | 8 | import CoreBluetooth 9 | 10 | extension BluetoothLEManager{ 11 | 12 | /// A private struct that encapsulates Bluetooth-related state checks. 13 | struct State { 14 | 15 | /// A computed property that checks if Bluetooth is authorized. 16 | /// 17 | /// - Returns: A Boolean value indicating whether Bluetooth is authorized. 18 | static var isBluetoothAuthorized: Bool { 19 | return CBCentralManager.authorization == .allowedAlways 20 | } 21 | 22 | /// A function that checks if Bluetooth is powered on for a given central manager. 23 | /// 24 | /// - Parameter manager: The `CBCentralManager` instance to check the state of. 25 | /// - Returns: A Boolean value indicating whether Bluetooth is powered on. 26 | static func isBluetoothPoweredOn(for manager: CBCentralManager) -> Bool { 27 | return manager.state == .poweredOn 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/service/StreamFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamFactory.swift 3 | // 4 | // Manages the creation and handling of streams for Bluetooth peripheral data. 5 | // 6 | // Created by Igor on 12.07.24. 7 | // 8 | 9 | import Combine 10 | import CoreBluetooth 11 | 12 | extension BluetoothLEManager { 13 | 14 | /// `StreamFactory` is responsible for creating and managing streams related to Bluetooth peripherals. 15 | final class StreamFactory { 16 | 17 | /// Publisher to expose the number of subscribers to stream events. 18 | /// Он проксирует события из актёра. 19 | public var subscriberCountPublisher: AnyPublisher { 20 | subscriberCountSubject.eraseToAnyPublisher() 21 | } 22 | 23 | /// Internal relay subject (thread-safe via single writer in this class). 24 | private let subscriberCountSubject = PassthroughSubject() 25 | 26 | /// Internal service (actor) that handles registration/notification. 27 | private let service: StreamRegistration 28 | 29 | /// Cancellables for Combine subscriptions. 30 | private var cancellables = Set() 31 | 32 | /// Logger 33 | private let logger: ILogger 34 | 35 | // MARK: - Initializer 36 | 37 | init(logger: ILogger) { 38 | self.logger = logger 39 | self.service = StreamRegistration(logger: logger) 40 | Task { await setupSubscriptions() } 41 | } 42 | 43 | deinit { 44 | logger.log("Stream factory deinitialized", level: .debug) 45 | } 46 | 47 | // MARK: - API 48 | 49 | /// Provides an asynchronous stream of discovered peripherals. 50 | public func peripheralsStream() async -> AsyncStream<[CBPeripheral]> { 51 | await service.stream 52 | } 53 | 54 | /// Push updated peripherals to all subscribers. 55 | public func updatePeripherals(_ peripherals: [CBPeripheral]) async { 56 | await service.notifySubscribers(peripherals) 57 | } 58 | 59 | // MARK: - Private 60 | 61 | /// Mirror actor's subscriber count to a local subject. 62 | private func setupSubscriptions() async { 63 | let publisher = await service.subscriberCountPublisher 64 | publisher 65 | .sink { [weak self] count in 66 | guard let self else { return } 67 | self.subscriberCountSubject.send(count) 68 | } 69 | .store(in: &cancellables) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/bluetooth-law-energy-swift/service/StreamRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegistrationStream.swift 3 | // 4 | // Manages Bluetooth Low Energy (BLE) peripheral discovery stream. 5 | // 6 | // Created by Igor on 22.07.24. 7 | // 8 | 9 | import Combine 10 | import CoreBluetooth 11 | 12 | extension BluetoothLEManager { 13 | 14 | /// An actor responsible for the registration and streaming of discovered Bluetooth peripherals. 15 | actor StreamRegistration { 16 | 17 | /// A dictionary to keep track of subscribers using their UUIDs. 18 | private var subscribers: [UUID: PeripheralsContinuation] = [:] 19 | 20 | /// A subject to broadcast changes in the subscriber count. 21 | private let subscriberCountSubject = PassthroughSubject() 22 | 23 | /// A list to store the discovered peripherals (actor-isolated). 24 | private var discoveredPeripherals: [CBPeripheral] = [] 25 | 26 | /// A type alias for an asynchronous stream continuation specific to an array of CBPeripheral. 27 | public typealias PeripheralsContinuation = AsyncStream<[CBPeripheral]>.Continuation 28 | 29 | /// A publisher to provide external updates on the subscriber count changes. 30 | public var subscriberCountPublisher: AnyPublisher { 31 | subscriberCountSubject.eraseToAnyPublisher() 32 | } 33 | 34 | /// Current number of subscribers. 35 | public var count: Int { subscribers.count } 36 | 37 | /// Provides an asynchronous stream of arrays of discovered peripherals. 38 | public var stream: AsyncStream<[CBPeripheral]> { 39 | get async { 40 | createPeripheralStream() 41 | } 42 | } 43 | 44 | private let logger: ILogger 45 | 46 | /// Initializes the StreamRegistration with a logger. 47 | init(logger: ILogger) { 48 | self.logger = logger 49 | } 50 | 51 | deinit { 52 | subscribers.forEach { (_, continuation) in 53 | continuation.finish() 54 | } 55 | discoveredPeripherals = [] 56 | 57 | #if DEBUG 58 | print("Peripherals released") 59 | #endif 60 | } 61 | 62 | /// Registers a new subscriber and immediately provides the current list of peripherals. 63 | /// - Parameter continuation: The `PeripheralsContinuation` to handle the discovered peripherals. 64 | public func register(_ continuation: PeripheralsContinuation) -> UUID { 65 | let id = UUID() 66 | subscribers[id] = continuation 67 | let current = discoveredPeripherals 68 | continuation.yield(current) 69 | subscriberCountSubject.send(count) 70 | return id 71 | } 72 | 73 | /// Unregisters a subscriber and updates the subscriber count. 74 | /// - Parameter id: The UUID of the subscriber to unregister. 75 | public func unregister(with id: UUID) { 76 | subscribers.removeValue(forKey: id) 77 | subscriberCountSubject.send(count) 78 | } 79 | 80 | /// Notifies all subscribers with the updated list of discovered peripherals. 81 | /// - Parameter peripherals: The updated list of discovered `CBPeripheral` instances. 82 | public func notifySubscribers(_ peripherals: [CBPeripheral]) { 83 | discoveredPeripherals = peripherals 84 | for continuation in subscribers.values { 85 | continuation.yield(peripherals) 86 | } 87 | } 88 | 89 | /// Creates and returns an `AsyncStream` of peripherals, managing the lifecycle events. 90 | /// - Returns: An `AsyncStream` of `[CBPeripheral]` to provide peripheral data. 91 | private func createPeripheralStream() -> AsyncStream<[CBPeripheral]> { 92 | let (newStream, newContinuation) = AsyncStream<[CBPeripheral]>.makeStream(of: [CBPeripheral].self) 93 | 94 | let id = register(newContinuation) 95 | 96 | newContinuation.onTermination = { [weak self] _ in 97 | guard let self = self else { return } 98 | Task { 99 | await self.unregister(with: id) 100 | } 101 | } 102 | 103 | return newStream 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tests/bluetooth-law-energy-swiftTests/BluetoothLEManagerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CoreBluetooth 3 | @testable import bluetooth_law_energy_swift 4 | 5 | final class BluetoothLEManagerTests: XCTestCase { 6 | 7 | func testBluetoothLEManagerCreation() async throws { 8 | let manager = BluetoothLEManager(logger: nil) 9 | 10 | XCTAssertNotNil(manager) 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /img/ble_discover.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/bluetooth-law-energy-swift/HEAD/img/ble_discover.gif -------------------------------------------------------------------------------- /img/ble_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/bluetooth-law-energy-swift/HEAD/img/ble_flow.png -------------------------------------------------------------------------------- /img/ble_mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/bluetooth-law-energy-swift/HEAD/img/ble_mac.png -------------------------------------------------------------------------------- /img/ble_manager.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/bluetooth-law-energy-swift/HEAD/img/ble_manager.jpeg --------------------------------------------------------------------------------