Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
160 changes: 155 additions & 5 deletions Sources/ActorCoreBluetooth/ConnectedPeripheral.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import CoreBluetooth

@MainActor
public final class ConnectedPeripheral {
private struct WriteWithoutResponseWaiter {
let id: UUID
let operation: TimedOperation<Void>
}

public let identifier: UUID
public let name: String?
public private(set) var state: PeripheralState
Expand All @@ -27,6 +32,9 @@ public final class ConnectedPeripheral {
private var notificationStateOperations: [String: TimedOperation<Void>] = [:]
private var rssiReadOperation: TimedOperation<Int>?

// 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] = [:]
Expand Down Expand Up @@ -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
Expand All @@ -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<Void, Error>) in
let operation = TimedOperation<Void>(
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 {
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}