diff --git a/README.md b/README.md index 580d875..48473ef 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/badge/License-MIT-lightgrey.svg)](LICENSE) [![CI](https://github.com/Linqa2/ActorCoreBluetooth/actions/workflows/ci.yml/badge.svg)](https://github.com/Linqa2/ActorCoreBluetooth/actions) -**⚠️ v0.1.1-alpha - APIs may change** +**Status: Active development (API may change)** A modern Swift Bluetooth library providing async/await APIs for CoreBluetooth using MainActor isolation. Built for Swift 6 with strict concurrency compliance and comprehensive logging. @@ -256,19 +256,31 @@ func writeToCharacteristic(peripheral: ConnectedPeripheral, characteristic: Blue let dataToWrite = Data([0x01, 0x02, 0x03, 0x04]) if characteristic.properties.contains(.write) { - // Write with response (reliable) + // Write with response (reliable, waits for acknowledgment) try await peripheral.writeValue(dataToWrite, for: characteristic, timeout: 5.0) print("Write completed with response") } if characteristic.properties.contains(.writeWithoutResponse) { - // Write without response (fast, fire-and-forget) - try peripheral.writeValueWithoutResponse(dataToWrite, for: characteristic) - print("Write sent without response") + // Write without response with flow control (async, handles peripheral readiness) + // This method suspends until the peripheral is ready to accept the write + // Writes are serialized per peripheral - callers wait in order for their turn + try await peripheral.writeValueWithoutResponse(dataToWrite, for: characteristic, timeout: 5.0) + print("Write without response sent") + + // Optional: No timeout (will wait indefinitely until ready or disconnected) + try await peripheral.writeValueWithoutResponse(dataToWrite, for: characteristic) } } ``` +**Write Without Response Details:** +- **Async with Flow Control**: Suspends until peripheral is ready to accept writes +- **Automatic Serialization**: Multiple callers wait in FIFO order +- **Timeout Support**: Optional timeout for waiting on peripheral readiness +- **Cancellation Support**: If the calling Task is cancelled, the write request is automatically removed from the queue +``` + ### Notifications and Real-time Monitoring Set up real-time notifications and monitor characteristic changes: @@ -462,13 +474,29 @@ Add this to your `Package.swift` file: ```swift dependencies: [ - .package(url: "https://github.com/Linqa2/ActorCoreBluetooth.git", exact: "v0.1.1-alpha") + .package(url: "https://github.com/Linqa2/ActorCoreBluetooth.git", branch: "main") ] ``` Or add it through Xcode: 1. File → Add Package Dependencies 2. Enter: `https://github.com/Linqa2/ActorCoreBluetooth.git` +3. Select branch: `main` + +#### Optional: Using a Specific Release Tag + +If you prefer a fixed release tag instead of the latest development version: + +**Package.swift:** +```swift +dependencies: [ + .package(url: "https://github.com/Linqa2/ActorCoreBluetooth.git", exact: "v0.1.1-alpha") +] +``` + +**Xcode:** +1. File → Add Package Dependencies +2. Enter: `https://github.com/Linqa2/ActorCoreBluetooth.git` 3. Select version: `v0.1.1-alpha` ## Requirements diff --git a/Sources/ActorCoreBluetooth/ConnectedPeripheral.swift b/Sources/ActorCoreBluetooth/ConnectedPeripheral.swift index 87c12b7..89e9535 100644 --- a/Sources/ActorCoreBluetooth/ConnectedPeripheral.swift +++ b/Sources/ActorCoreBluetooth/ConnectedPeripheral.swift @@ -10,6 +10,11 @@ import CoreBluetooth @MainActor public final class ConnectedPeripheral { + private struct WriteWithoutResponseWaiter { + let id: UUID + let operation: TimedOperation + } + public let identifier: UUID public let name: String? public private(set) var state: PeripheralState @@ -27,6 +32,9 @@ public final class ConnectedPeripheral { private var notificationStateOperations: [String: TimedOperation] = [:] private var rssiReadOperation: TimedOperation? + // Write without response permit management + private var writeWithoutResponseWaiters: [WriteWithoutResponseWaiter] = [] + // Stream management for peripheral-level events private var serviceDiscoveryStreams: [UUID: AsyncStream<[BluetoothService]>.Continuation] = [:] private var characteristicValueStreams: [UUID: AsyncStream<(BluetoothCharacteristic, Data?)>.Continuation] = [:] @@ -307,8 +315,10 @@ public final class ConnectedPeripheral { } } - /// Write value without response (fire and forget) - public func writeValueWithoutResponse(_ data: Data, for characteristic: BluetoothCharacteristic) throws { + /// Write value without response with flow control + /// Suspends until peripheral is ready to accept the write, then sends exactly one write. + /// Writes are serialized per peripheral - callers wait in order for their turn. + public func writeValueWithoutResponse(_ data: Data, for characteristic: BluetoothCharacteristic, timeout: TimeInterval? = nil) async throws { guard cbPeripheral.state == .connected else { logger?.errorError("Cannot write without response: peripheral not connected", context: [ "peripheralID": identifier.uuidString @@ -327,16 +337,127 @@ public final class ConnectedPeripheral { operation: "Writing (no response)", uuid: characteristic.uuid, peripheralID: identifier, - dataLength: data.count + dataLength: data.count, + context: timeout.map { ["timeout": $0] } ) - // Write without response doesn't need continuation - fire and forget + // Wait for permit to send + try await acquireWriteWithoutResponsePermit(timeout: timeout) + + // At this point we have the permit and peripheral is ready + // Double-check connection state (could have disconnected while waiting) + guard cbPeripheral.state == .connected else { + logger?.errorError("Peripheral disconnected while waiting for permit", context: [ + "peripheralID": identifier.uuidString + ]) + throw BluetoothError.peripheralNotConnected + } + + logger?.internalDebug("Permit acquired, sending write without response", context: [ + "characteristicUUID": characteristic.uuid, + "dataLength": data.count + ]) + cbPeripheral.writeValue(data, for: characteristic.cbCharacteristic.value, type: .withoutResponse) - logger?.characteristicDebug("Write without response completed", context: [ + + logger?.characteristicDebug("Write without response sent", context: [ "characteristicUUID": characteristic.uuid ]) } + /// Acquire permit to send write without response. Suspends until peripheral is ready. + private func acquireWriteWithoutResponsePermit(timeout: TimeInterval?) async throws { + if cbPeripheral.canSendWriteWithoutResponse && writeWithoutResponseWaiters.isEmpty { + logger?.internalDebug("Peripheral ready immediately, no wait needed") + return + } + + logger?.internalDebug("Waiting for permit", context: [ + "queuedWaiters": writeWithoutResponseWaiters.count, + "canSendNow": cbPeripheral.canSendWriteWithoutResponse + ]) + + let waiterID = UUID() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let operation = TimedOperation( + operationName: "Write without response permit \(waiterID)", + logger: logger + ) + operation.setup(continuation) + + if let timeout = timeout { + logger?.internalDebug("Setting write without response permit timeout", context: [ + "timeout": timeout, + "waiterID": waiterID.uuidString + ]) + + operation.setTimeoutTask(timeout: timeout, onTimeout: { [weak self] in + guard let self = self else { return } + + // Remove this waiter from the queue + self.writeWithoutResponseWaiters.removeAll { $0.id == waiterID } + + self.logger?.logTimeout( + operation: "Write without response permit acquisition", + timeout: timeout, + context: ["waiterID": waiterID.uuidString] + ) + }) + } + + let waiter = WriteWithoutResponseWaiter( + id: waiterID, + operation: operation + ) + writeWithoutResponseWaiters.append(waiter) + + logger?.internalDebug("Added to waiters queue", context: [ + "waiterID": waiterID.uuidString, + "queuePosition": writeWithoutResponseWaiters.count + ]) + + // Drain once after enqueuing to prevent missed-signal race + // If peripheralIsReady fired between our initial check and enqueuing, + // we would hang until timeout. This ensures we process any ready state. + drainWriteWithoutResponseWaiters() + } + } onCancel: { + Task { @MainActor [weak self, logger] in + guard let self = self else { return } + + logger?.internalDebug("Task cancelled, removing waiter", context: [ + "waiterID": waiterID.uuidString + ]) + + if let index = self.writeWithoutResponseWaiters.firstIndex(where: { $0.id == waiterID }) { + let waiter = self.writeWithoutResponseWaiters.remove(at: index) + waiter.operation.cancel() + + logger?.internalDebug("Waiter removed and cancelled", context: [ + "waiterID": waiterID.uuidString, + "remainingWaiters": self.writeWithoutResponseWaiters.count + ]) + } + } + } + } + + private func drainWriteWithoutResponseWaiters() { + // Resume waiters one at a time while peripheral can accept writes + while cbPeripheral.canSendWriteWithoutResponse && !writeWithoutResponseWaiters.isEmpty { + let waiter = writeWithoutResponseWaiters.removeFirst() + + logger?.internalDebug("Draining waiter", context: [ + "waiterID": waiter.id.uuidString, + "remainingWaiters": writeWithoutResponseWaiters.count + ]) + + waiter.operation.resumeOnce(with: .success(())) + } + } + /// Set notification state for a characteristic public func setNotificationState(_ enabled: Bool, for characteristic: BluetoothCharacteristic, timeout: TimeInterval? = nil) async throws { guard cbPeripheral.state == .connected else { @@ -770,6 +891,23 @@ public final class ConnectedPeripheral { } } + // Called by delegate proxy when peripheral is ready to send write without response + internal func handlePeripheralReadyToSendWriteWithoutResponse() { + logger?.internalDebug("Peripheral ready to send write without response", context: [ + "waitingCallers": writeWithoutResponseWaiters.count + ]) + + drainWriteWithoutResponseWaiters() + + if writeWithoutResponseWaiters.isEmpty { + logger?.internalDebug("All waiters processed") + } else { + logger?.internalDebug("Peripheral queue full, remaining waiters queued", context: [ + "remainingWaiters": writeWithoutResponseWaiters.count + ]) + } + } + /// Cancel all pending operations - called during disconnection internal func cancelAllPendingOperations() { logger?.peripheralInfo("Cancelling all pending operations due to disconnection", context: [ @@ -802,6 +940,13 @@ public final class ConnectedPeripheral { } characteristicWriteOperations.removeAll() + // Cancel all write without response waiters + for waiter in writeWithoutResponseWaiters { + waiter.operation.cancel() + cancelledCount += 1 + } + writeWithoutResponseWaiters.removeAll() + for (_, operation) in notificationStateOperations { operation.cancel() cancelledCount += 1 @@ -880,4 +1025,9 @@ private final class ConnectedPeripheralDelegateProxy: NSObject, @preconcurrency ]) self.peripheral?.handleRSSIUpdate(rssi: RSSI, error: error) } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + logger?.internalDebug("CBPeripheralDelegate.peripheralIsReady(toSendWriteWithoutResponse:) called") + self.peripheral?.handlePeripheralReadyToSendWriteWithoutResponse() + } }