diff --git a/.gitignore b/.gitignore index 0e6a3ac..6e14fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,9 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc -.vscode \ No newline at end of file +.vscode + +.docc-build/ +**/.docc-build/ +AsyncCoreBluetooth.doccarchive +swift-docs \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8cb047 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2025-03-07 + +### Added +- New documentation in `Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc` ([2fea786](https://github.com/meech-ward/AsyncCoreBluetooth/commit/2fea7860a82112ca6fa8afa2b130239674761c81)) + +### Changed +- Make sure all characteristic continuations are canceled on peripheral disconnect +- Make sure notifications can't be enabled or disabled if they are already in that state +- Make data for read and notify not optional ([78629a6](https://github.com/meech-ward/AsyncCoreBluetooth/commit/78629a694a84165df5959ff916ea564c86bfdfd4)) +- Make sure a new peripheral actor is created for each new peripheral instance + +### Fixed +- Remove nonisolated lazy property from Peripheral actor + +## [0.1.x] - 2024-XX-XX + +- Initial releases with core functionality diff --git a/README.md b/README.md index 9d915a0..be95664 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,9 @@ struct ContentView: View { Your application should handle all the possible cases of the ble state. It's very common for someone to turn off bluetooth or turn on airplane mode and your application's UI should reflect these states. However, the most common case is `.poweredOn`, so if you're only interested in running code as soon as the device is in that state, you can use the following: ```swift -_ = await centralManager.startStream().first(where: {$0 == .poweredOn}) +for await _ in await centralManager.startStream().first(where: {$0 == .poweredOn}) { + // re run the setup code if it goes off then back on +} ``` Keep in mind that familiarity with swift concurrency is going to make using this library a lot easier. @@ -270,3 +272,7 @@ you can requst a new async stream or break out of these streams as much as you l ``` swift test --no-parallel ``` + +## Building Documentation + +check build.sh \ No newline at end of file diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AccessorySetupKit.md b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AccessorySetupKit.md new file mode 100644 index 0000000..5817f58 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AccessorySetupKit.md @@ -0,0 +1,57 @@ +# Getting Started with Async Core Bluetooth and Accessory Setup Kit + + +@Metadata { + @PageImage(purpose: card, source: "icon", alt: "Async Core Bluetooth") +} + +## Overview + +Async Core Bluetooth + +### Initializing The Central Manager + +Setup the central manager and check the current ble state: + +```swift +import AccessorySetupKit + +// Create a session +var session = ASAccessorySession() + +// Activate session with event handler +session.activate(on: DispatchQueue.main, eventHandler: handleSessionEvent(event:)) + +// Handle event +func handleSessionEvent(event: ASAccessoryEvent) { + switch event.eventType { + case .activated: + print("Session is activated and ready to use") + print(session.accessories) + default: + print("Received event type \(event.eventType)") + } +} +``` + + +```swift +// Create descriptor for pink dice +let pinkDescriptor = ASDiscoveryDescriptor() +pinkDescriptor.bluetoothServiceUUID = pinkUUID +// Create descriptor for blue dice +let blueDescriptor = ASDiscoveryDescriptor() +blueDescriptor.bluetoothServiceUUID = blueUUID + +// Create picker display items +let pinkDisplayItem = ASPickerDisplayItem( + name: "Pink Dice", + productImage: UIImage(named: "pink")!, + descriptor: pinkDescriptor +) +let blueDisplayItem = ASPickerDisplayItem( + name: "Blue Dice", + productImage: UIImage(named: "blue")!, + descriptor: blueDescriptor +) +``` \ No newline at end of file diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AsyncCoreBluetooth.md b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AsyncCoreBluetooth.md index 5747e78..d4c6201 100644 --- a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AsyncCoreBluetooth.md +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/AsyncCoreBluetooth.md @@ -12,7 +12,7 @@ Like Core Bluetooth, but async. ## Overview -AsyncCoreBluetooth provides code. +AsyncCoreBluetooth is a library. ### Featured diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/CentralManager.md b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/CentralManager.md index b46da37..f5b7e02 100644 --- a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/CentralManager.md +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/CentralManager.md @@ -1,11 +1,8 @@ # ``AsyncCoreBluetooth/CentralManager`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} Central Manager is the core class responsible for managing and interacting with CoreBluetooth. This class provides an async interface for interacting with CoreBluetooth. - + diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/Peripheral.md b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/Peripheral.md index 25b30f8..4792c40 100644 --- a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/Peripheral.md +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Extensions/Peripheral.md @@ -1,15 +1,14 @@ # ``AsyncCoreBluetooth/Peripheral`` -@Metadata { - @DocumentationExtension(mergeBehavior: append) -} This class provides an async interface for interacting with CoreBluetooth Peripherals. -## Topics +This is from the md docs + + diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/GettingStarted.md b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/GettingStarted.md index 4428298..f838393 100644 --- a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/GettingStarted.md +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/GettingStarted.md @@ -19,7 +19,7 @@ import AsyncCoreBluetooth let centralManager = CentralManager() -for await bleState in await centralManager.start() { +for await bleState in await centralManager.startStream() { switch bleState { case .unknown: print("Unkown") diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-01.swift b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-01.swift new file mode 100644 index 0000000..849fd45 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-01.swift @@ -0,0 +1,26 @@ +import Foundation +import CoreBluetooth +import AsyncCoreBluetooth + +actor MyAppsBLEManager { + let centralManager = CentralManager() + + func start() async { + for await bleState in await centralManager.startStream() { + switch bleState { + case .unknown: + print("Unkown") + case .resetting: + print("Resetting") + case .unsupported: + print("Unsupported") + case .unauthorized: + print("Unauthorized") + case .poweredOff: + print("Powered Off") + case .poweredOn: + print("Powered On, ready to scan") + } + } + } +} diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-02.swift b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-02.swift new file mode 100644 index 0000000..a5151a6 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-02.swift @@ -0,0 +1,41 @@ +import Foundation +import CoreBluetooth +import AsyncCoreBluetooth + + +actor MyAppsBLEManager { + let centralManager = CentralManager() + var peripheral: Peripheral? + + func start() async { + for await bleState in await centralManager.startStream() { + switch bleState { + case .unknown: + print("Unkown") + case .resetting: + print("Resetting") + case .unsupported: + print("Unsupported") + case .unauthorized: + print("Unauthorized") + case .poweredOff: + print("Powered Off") + case .poweredOn: + print("Powered On, ready to scan") + } + } + } + + func scanForPeripheral() async { + let heartRateServiceUUID = CBUUID(string: "180D") + + do { + let peripherals = try await centralManager.scanForPeripherals(withServices: [heartRateServiceUUID]) + let peripheral = peripherals[heartRateServiceUUID] + print("found peripheral \(peripheral)") + } catch { + // This only happens when ble state is not powered on or you're already scanning + print("error scanning for peripherals \(error)") + } + } +} diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-03.swift b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-03.swift new file mode 100644 index 0000000..07d76b9 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-01-03.swift @@ -0,0 +1,50 @@ +import Foundation +import CoreBluetooth +import AsyncCoreBluetooth + + +actor MyAppsBLEManager { + let centralManager = CentralManager() + var peripheral: Peripheral? + + func start() async { + for await bleState in await centralManager.startStream() { + switch bleState { + case .unknown: + print("Unkown") + case .resetting: + print("Resetting") + case .unsupported: + print("Unsupported") + case .unauthorized: + print("Unauthorized") + case .poweredOff: + print("Powered Off") + case .poweredOn: + print("Powered On, ready to scan") + } + } + } + + func scanForPeripheral() async { + let heartRateServiceUUID = CBUUID(string: "180D") + + do { + let peripherals = try await centralManager.scanForPeripherals(withServices: [heartRateServiceUUID]) + let peripheral = peripherals[heartRateServiceUUID] + print("found peripheral \(peripheral)") + } catch { + // This only happens when ble state is not powered on or you're already scanning + print("error scanning for peripherals \(error)") + } + } + + func connectToPeripheral() async { + guard let peripheral else { + return + } + for await connectionState in await centralManager.connect(self.peripheral) { + print(connectionState) + } + } +} diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-02-01.swift b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-02-01.swift new file mode 100644 index 0000000..bfb3965 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-02-01.swift @@ -0,0 +1,32 @@ +import SwiftUI +import AsyncCoreBluetooth + +struct ContentView: View { + var centralManager = CentralManager() + var body: some View { + NavigationStack { + VStack { + switch centralManager.state.bleState { + case .unknown: + Text("Unkown") + case .resetting: + Text("Resetting") + case .unsupported: + Text("Unsupported") + case .unauthorized: + Text("Unauthorized") + case .poweredOff: + Text("Powered Off") + case .poweredOn: + Text("Powered On, ready to scan") + } + } + .padding() + .navigationTitle("App") + } + .task { + await centralManager.start() + // or startStream if you want the async stream returned from start + } + } +} \ No newline at end of file diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-02-02.swift b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-02-02.swift new file mode 100644 index 0000000..22e9879 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/code-files/01-creating-code-02-02.swift @@ -0,0 +1,29 @@ +import SwiftUI +import AsyncCoreBluetooth + +struct ScanningPeripherals: View { + let heartRateServiceUUID = UUID(string: "180D") + var centralManager: CentralManager + @MainActor @State private var peripherals: Set = [] + + var body: some View { + VStack { + List(Array(peripherals), id: \.identifier) { peripheral in + Section { + ScannedPeripheralRow(centralManager: centralManager, peripheral: peripheral) + } + } + } + .task { + do { + for await peripheral in try await centralManager.scanForPeripherals(withServices: [heartRateServiceUUID]) { + peripherals.insert(peripheral) + // break out of the loop or terminate the continuation to stop the scan + } + } catch { + // This only happens when ble state is not powered on or you're already scanning + print("error scanning for peripherals \(error)") + } + } + } +} \ No newline at end of file diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/creating-central-art/ble-chapter1@2x.png b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/creating-central-art/ble-chapter1@2x.png new file mode 100644 index 0000000..3086f48 Binary files /dev/null and b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/creating-central-art/ble-chapter1@2x.png differ diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/creating-central-art/creating-intro@2x.png b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/creating-central-art/creating-intro@2x.png new file mode 100644 index 0000000..6f0995e Binary files /dev/null and b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/creating-central-art/creating-intro@2x.png differ diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/table-of-contents-art/ble-chapter1@2x.png b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/table-of-contents-art/ble-chapter1@2x.png new file mode 100644 index 0000000..3086f48 Binary files /dev/null and b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/table-of-contents-art/ble-chapter1@2x.png differ diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/table-of-contents-art/ble-intro@2x.png b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/table-of-contents-art/ble-intro@2x.png new file mode 100644 index 0000000..81c3832 Binary files /dev/null and b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Resources/table-of-contents-art/ble-intro@2x.png differ diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/RootLanding.md b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/RootLanding.md new file mode 100644 index 0000000..8b8940b --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/RootLanding.md @@ -0,0 +1,18 @@ +# AsyncCoreBluetooth + +@Metadata { + @TechnologyRoot + @PageImage( + purpose: icon, + source: "icon", + alt: "A technology icon representing the AsyncCoreBluetooth framework." + ) + @PageColor(green) +} + +## Overview + +This is the **root page** for the AsyncCoreBluetooth technology. + +- [Documentation](documentation) +- [Tutorials](tutorials) diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Tutorials/AsyncCoreBluetooth.tutorial b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Tutorials/AsyncCoreBluetooth.tutorial new file mode 100644 index 0000000..c189b43 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Tutorials/AsyncCoreBluetooth.tutorial @@ -0,0 +1,15 @@ +@Tutorials(name: "AsyncCoreBluetooth") { + @Intro(title: "Meet AsyncCoreBluetooth") { + Setup a BLE Central to connect to an external Peripheral. + + @Image(source: ble-intro.png, alt: "BLE") + } + + @Chapter(name: "AsyncCoreBluetooth Essentials") { + @Image(source: ble-chapter1.png, alt: "ble") + + Create a BLE Central to connect to an external Peripheral. + + @TutorialReference(tutorial: "doc:Creating-Central") + } +} diff --git a/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Tutorials/Creating Central.tutorial b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Tutorials/Creating Central.tutorial new file mode 100644 index 0000000..f8864e9 --- /dev/null +++ b/Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc/Tutorials/Creating Central.tutorial @@ -0,0 +1,64 @@ +@Tutorial(time: 30) { + @Intro(title: "Creating Central") { + This tutorial guides you through creating a Central to discover and connect to a Peripheral. + + @Image(source: creating-intro.png, alt: "ble") + } + + + @Comment { + This is very incomplete, just here as a placeholder for later on. + } + + @Section(title: "Create a Central") { + @ContentAndMedia { + Just setup the central. + + @Image(source: ble-chapter1.png, alt: "BLE") + } + + @Steps { + @Step { + Create a new Swift file named `ItDoesntMatter.swift`. + + @Code(name: "ItDoesntMatter.swift", file: 01-creating-code-01-01.swift) + } + + @Step { + Scan for peripheral. + + @Code(name: "ItDoesntMatter.swift", file: 01-creating-code-01-02.swift) + } + + now to connect + + @Step { + Connect to peripheral. + + @Code(name: "ItDoesntMatter.swift", file: 01-creating-code-01-03.swift) + } + } + + } + + @Section(title: "SwiftUI") { + @ContentAndMedia { + Using this with SwiftUI + + @Image(source: ble-chapter1.png, alt: "BLE") + } + + @Steps { + @Step { + Check BLE Status + + @Code(name: "WhateverView.swift", file: 01-creating-code-02-01.swift) + } + @Step { + Scan for peripheral `WhateverView.swift`. + + @Code(name: "WhateverView.swift", file: 01-creating-code-02-02.swift) + } + } + } +} diff --git a/Sources/AsyncCoreBluetooth/CentralManager.swift b/Sources/AsyncCoreBluetooth/CentralManager.swift index bf54234..25e1c1b 100644 --- a/Sources/AsyncCoreBluetooth/CentralManager.swift +++ b/Sources/AsyncCoreBluetooth/CentralManager.swift @@ -91,7 +91,7 @@ public actor CentralManager { forceMock: forceMock) // CentralManagerState - var bleState: CentralManagerBLEState = .unknown { + public var bleState: CentralManagerBLEState = .unknown { willSet { Task { @MainActor in state.bleState = newValue @@ -189,7 +189,7 @@ public actor CentralManager { /// /// Example usage: /// ```swift - /// for await state in centralManager.start() { + /// for await state in centralManager.startStream() { /// print("BLE state changed to: \(state)") /// } /// ``` diff --git a/Sources/AsyncCoreBluetooth/Characteristic.swift b/Sources/AsyncCoreBluetooth/Characteristic.swift index c7f6bd7..b7b5908 100644 --- a/Sources/AsyncCoreBluetooth/Characteristic.swift +++ b/Sources/AsyncCoreBluetooth/Characteristic.swift @@ -26,6 +26,7 @@ public actor Characteristic: Identifiable { } } func setValue(_ value: Data?) { + print("set characteristic value") self.value = value } @@ -56,10 +57,10 @@ public actor Characteristic: Identifiable { } } - var characteristicValueContinuations: [UUID: AsyncStream>.Continuation] = [:] + var characteristicValueContinuations: [UUID: AsyncStream>.Continuation] = [:] func setCharacteristicValueContinuation( - id: UUID, continuation: AsyncStream>.Continuation? + id: UUID, continuation: AsyncStream>.Continuation? ) { characteristicValueContinuations[id] = continuation } @@ -67,14 +68,16 @@ public actor Characteristic: Identifiable { /// Get an async stream representing the characteristic's value. /// This is most useful when the characteristic is notifying. /// The value will be the same as characteristic.value. - public func valueStream() async -> AsyncStream> { + public func valueStream() async -> AsyncStream> { return AsyncStream { continuation in let id = UUID() self.setCharacteristicValueContinuation( id: id, continuation: continuation) - continuation.yield(Result.success(self.value)) + if let value = self.value { + continuation.yield(Result.success(value)) + } continuation.onTermination = { _ in Task { diff --git a/Sources/AsyncCoreBluetooth/Peripheral.swift b/Sources/AsyncCoreBluetooth/Peripheral.swift index 4a176aa..2202624 100644 --- a/Sources/AsyncCoreBluetooth/Peripheral.swift +++ b/Sources/AsyncCoreBluetooth/Peripheral.swift @@ -149,7 +149,7 @@ public actor Peripheral { /// The delegate methods will get called straight from the CBMPeripheral delegate without going through the Peripheral actor. Avoid using this if you can and just use async streams. /// However, if you really need to use the delegate, you can pass it in here. This will not effect the async streams. public var delegate: CBMPeripheralDelegate? - private nonisolated lazy var peripheralDelegate = PeripheralDelegate(peripheral: self) + private var peripheralDelegate: PeripheralDelegate? // MARK: - Peripheral Connection State @@ -190,6 +190,39 @@ public actor Peripheral { func setConnectionState(_ state: PeripheralConnectionState) async { connectionState = state + + if case .disconnected(_) = state { + // cancel all discovery continuations + discoverCharacteristicsContinuations.forEach { + $0.value.forEach { $0.resume(throwing: PeripheralConnectionError.disconnectedWhileWorking) } + } + discoverCharacteristicsContinuations.removeAll() + discoverServicesContinuations.forEach { + $0.resume(throwing: PeripheralConnectionError.disconnectedWhileWorking) + } + discoverServicesContinuations.removeAll() + + readCharacteristicValueContinuations.forEach { + $0.value.forEach { $0.resume(throwing: PeripheralConnectionError.disconnectedWhileWorking) } + } + readCharacteristicValueContinuations.removeAll() + + writeCharacteristicWithResponseContinuations.forEach { + $0.resume(throwing: PeripheralConnectionError.disconnectedWhileWorking) + } + writeCharacteristicWithResponseContinuations.removeAll() + + notifyCharacteristicValueContinuations.forEach { + $0.value.forEach { $0.resume(throwing: PeripheralConnectionError.disconnectedWhileWorking) } + } + notifyCharacteristicValueContinuations.removeAll() + + + // My assumption is that somewhere there is a loop listening for the connection state + // On disconnect, the workflow for discovering and connection should be re triggered + // Canceling the discovery continuations allows discover to block then be re triggered with the above assumptions + // If code is listening for results of read, write, notify, that task is canceled and needs regenerated by the caller + } } // MARK: - Peripheral Creation @@ -201,7 +234,9 @@ public actor Peripheral { await MainActor.run { self.state = .init(cbPeripheral: cbPeripheral) } + let peripheralDelegate = PeripheralDelegate(peripheral: self) cbPeripheral.delegate = peripheralDelegate + self.peripheralDelegate = peripheralDelegate } // MARK: - Peripheral Creation and Caching @@ -236,15 +271,21 @@ public actor Peripheral { /// Discovers the specified services of the peripheral. // internally manage the state continuations var discoverServicesContinuations = Deque< - CheckedContinuation<[CBUUID /*service uuid*/: Service], Error> + CheckedContinuation<[CBUUID /* service uuid */: Service], Error> >() @discardableResult - public func discoverServices(_ serviceUUIDs: [CBUUID]?) async throws -> [CBUUID /*service uuid*/: + public func discoverServices(_ serviceUUIDs: [CBUUID]?) async throws + -> [CBUUID /* service uuid */: Service] { - return try await withCheckedThrowingContinuation { continuation in - discoverServicesContinuations.append(continuation) - cbPeripheral.discoverServices(serviceUUIDs) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + + discoverServicesContinuations.append(continuation) + cbPeripheral.discoverServices(serviceUUIDs) + } + } onCancel: { + // need to figure out how to cancel this nicely } } @@ -258,8 +299,8 @@ public actor Peripheral { // MARK: - Discovering Characteristics var discoverCharacteristicsContinuations: - [CBUUID /*service uuid*/: Deque< - CheckedContinuation<[CBUUID /*characteristic uuid*/: Characteristic], Error> + [CBUUID /* service uuid */: Deque< + CheckedContinuation<[CBUUID /* characteristic uuid */: Characteristic], Error> >] = [:] @discardableResult @@ -276,7 +317,7 @@ public actor Peripheral { } } - var readCharacteristicValueContinuations: [CBUUID: Deque>] = [:] + var readCharacteristicValueContinuations: [CBUUID: Deque>] = [:] var writeCharacteristicWithResponseContinuations: Deque> = Deque>() @@ -285,9 +326,10 @@ public actor Peripheral { } // MARK: - Read, Write, Notify + extension Peripheral { @discardableResult - public func readValue(for characteristic: Characteristic) async throws -> Data? { + public func readValue(for characteristic: Characteristic) async throws -> Data { return try await withCheckedThrowingContinuation { continuation in if readCharacteristicValueContinuations[characteristic.uuid] == nil { readCharacteristicValueContinuations[characteristic.uuid] = [] @@ -317,6 +359,9 @@ extension Peripheral { public func setNotifyValue(_ enabled: Bool, for characteristic: Characteristic) async throws -> Bool { + if await characteristic.isNotifying == enabled { + return true + } return try await withCheckedThrowingContinuation { continuation in if notifyCharacteristicValueContinuations[characteristic.uuid] == nil { notifyCharacteristicValueContinuations[characteristic.uuid] = [] diff --git a/Sources/AsyncCoreBluetooth/PeripheralDelegate.swift b/Sources/AsyncCoreBluetooth/PeripheralDelegate.swift index 520bbff..f440334 100644 --- a/Sources/AsyncCoreBluetooth/PeripheralDelegate.swift +++ b/Sources/AsyncCoreBluetooth/PeripheralDelegate.swift @@ -157,10 +157,20 @@ extension Peripheral { } return } - continuation?.resume(with: Result.success(cbCharacteristic.value)) + + guard let value = cbCharacteristic.value else { + continuation?.resume(throwing: AsyncCoreBluetoothError.unexpectedNilData) + if await characteristic?.isNotifying == true { + await characteristic?.characteristicValueContinuations.values.forEach { + $0.yield(Result.failure(AsyncCoreBluetoothError.unexpectedNilData)) + } + } + return + } + continuation?.resume(with: Result.success(value)) if await characteristic?.isNotifying == true { await characteristic?.characteristicValueContinuations.values.forEach { - $0.yield(Result.success(cbCharacteristic.value)) + $0.yield(Result.success(value)) } } } diff --git a/Sources/AsyncCoreBluetooth/Types.swift b/Sources/AsyncCoreBluetooth/Types.swift index 70c1e0a..1a5d2d5 100644 --- a/Sources/AsyncCoreBluetooth/Types.swift +++ b/Sources/AsyncCoreBluetooth/Types.swift @@ -27,6 +27,7 @@ public typealias Descriptor = CBMDescriptor public enum AsyncCoreBluetoothError: Error { case taskCancelled + case unexpectedNilData } public enum CentralManagerError: Error { @@ -40,6 +41,7 @@ public enum PeripheralConnectionError: String, Error { case alreadyDisconnecting case alreadyDisconnected case failedToConnect + case disconnectedWhileWorking } public enum ServiceError: Error { diff --git a/Tests/AsyncCoreBluetoothTests/Peripheral/DiscoverServicesTests.swift b/Tests/AsyncCoreBluetoothTests/Peripheral/DiscoverServicesTests.swift index 4155568..72cfcde 100644 --- a/Tests/AsyncCoreBluetoothTests/Peripheral/DiscoverServicesTests.swift +++ b/Tests/AsyncCoreBluetoothTests/Peripheral/DiscoverServicesTests.swift @@ -51,8 +51,8 @@ import Testing Issue.record("couldn't get all servies") return } - #expect(await service === peripheralService) - #expect(await service === peripheralStateService) + #expect(service === peripheralService) + #expect(service === peripheralStateService) } @Test("Discover services references the peripheral") diff --git a/Tests/AsyncCoreBluetoothTests/Peripheral/NotifyCharacteristicTests.swift b/Tests/AsyncCoreBluetoothTests/Peripheral/NotifyCharacteristicTests.swift index 9c5fa79..41bf8ae 100644 --- a/Tests/AsyncCoreBluetoothTests/Peripheral/NotifyCharacteristicTests.swift +++ b/Tests/AsyncCoreBluetoothTests/Peripheral/NotifyCharacteristicTests.swift @@ -89,7 +89,7 @@ import Testing var called = false Task { // drop the initial value - for await result in stream.dropFirst() { + for await result in stream { let value = try result.get() #expect(value == "test".data(using: .utf8)) called = true @@ -122,7 +122,7 @@ import Testing var called = false Task { // drop the initial value - for await result in stream.dropFirst() { + for await result in stream { #expect(throws: CharacteristicError.unableToFindCharacteristics.self) { let _ = try result.get() } diff --git a/Tests/AsyncCoreBluetoothTests/Peripheral/ReadCharacteristicTests.swift b/Tests/AsyncCoreBluetoothTests/Peripheral/ReadCharacteristicTests.swift index 5e5940a..7efc302 100644 --- a/Tests/AsyncCoreBluetoothTests/Peripheral/ReadCharacteristicTests.swift +++ b/Tests/AsyncCoreBluetoothTests/Peripheral/ReadCharacteristicTests.swift @@ -54,10 +54,7 @@ import Testing mockPeripheralDelegate.readData = testData.data(using: .utf8) let data = try await peripheral.readValue(for: characteristic) - guard let data else { - Issue.record("couldn't get data") - return - } + let receivedString = String(data: data, encoding: .utf8) #expect(receivedString == testData) } @@ -89,20 +86,17 @@ import Testing mockPeripheralDelegate.readData = "test10".data(using: .utf8) async let data10 = try await peripheral.readValue(for: characteristic) - guard let data = try await data, let data2 = try await data2, let data3 = try await data3, let data4 = try await data4, let data5 = try await data5, let data6 = try await data6, let data7 = try await data7, let data8 = try await data8, let data9 = try await data9, let data10 = try await data10 else { - Issue.record("couldn't get data") - return - } - print(String(data: data, encoding: .utf8) ?? "nil") - print(String(data: data2, encoding: .utf8) ?? "nil") - print(String(data: data3, encoding: .utf8) ?? "nil") - print(String(data: data4, encoding: .utf8) ?? "nil") - print(String(data: data5, encoding: .utf8) ?? "nil") - print(String(data: data6, encoding: .utf8) ?? "nil") - print(String(data: data7, encoding: .utf8) ?? "nil") - print(String(data: data8, encoding: .utf8) ?? "nil") - print(String(data: data9, encoding: .utf8) ?? "nil") - print(String(data: data10, encoding: .utf8) ?? "nil") + + print(String(data: try await data, encoding: .utf8) ?? "nil") + print(String(data: try await data2, encoding: .utf8) ?? "nil") + print(String(data: try await data3, encoding: .utf8) ?? "nil") + print(String(data: try await data4, encoding: .utf8) ?? "nil") + print(String(data: try await data5, encoding: .utf8) ?? "nil") + print(String(data: try await data6, encoding: .utf8) ?? "nil") + print(String(data: try await data7, encoding: .utf8) ?? "nil") + print(String(data: try await data8, encoding: .utf8) ?? "nil") + print(String(data: try await data9, encoding: .utf8) ?? "nil") + print(String(data: try await data10, encoding: .utf8) ?? "nil") // let receivedString = String(data: data, encoding: .utf8) // #expect(receivedString == testData) } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..1c9fc28 --- /dev/null +++ b/build.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -e + +# 1. Clean build artifacts +rm -rf .build +rm -rf ./swift-docs +mkdir ./swift-docs + +# 2. Generate symbol graphs only for 'AsyncCoreBluetooth' target +swift build \ + --target AsyncCoreBluetooth \ + -Xswiftc -emit-symbol-graph \ + -Xswiftc -emit-symbol-graph-dir \ + -Xswiftc .build/symbol-graphs + +# 3. Remove any leftover symbol graphs from dependencies +find .build/symbol-graphs -type f \ + ! -name "*AsyncCoreBluetooth*" -delete + +# 4. Convert DocC, pointing only to our target’s doc catalog and symbol graphs +xcrun docc convert Sources/AsyncCoreBluetooth/AsyncCoreBluetooth.docc \ + --fallback-display-name AsyncCoreBluetooth \ + --fallback-bundle-identifier com.meech-ward.AsyncCoreBluetooth \ + --fallback-bundle-version 1 \ + --additional-symbol-graph-dir .build \ + --additional-symbol-graph-dir .build/symbol-graphs \ + --output-dir AsyncCoreBluetooth.doccarchive + +# 5. Transform for static hosting +xcrun docc process-archive \ + transform-for-static-hosting AsyncCoreBluetooth.doccarchive \ + --hosting-base-path "" \ + --output-path "./swift-docs"