Seamless Bluetooth Pairing with Apple's AccessorySetupKit
When building iOS apps that connect to Bluetooth accessories, one of the biggest pain points has traditionally been the pairing experience. Users need to navigate to Settings, find their device in a sea of Bluetooth peripherals, and manually pair before they can use your app. Apple’s AccessorySetupKit (ASK), introduced in iOS 18, changes this entirely—offering a streamlined, in-app pairing flow that feels magical to users.
In this post, I’ll walk through how we integrated AccessorySetupKit into the AirTurn Studio app while maintaining backward compatibility with CoreBluetooth for older iOS versions. We’ll cover the architecture decisions, implementation patterns, and key learnings from building a production-ready solution.
The Problem: Bluetooth Pairing Friction
With CoreBluetooth, apps can scan and connect to BLE devices directly—no trip to Settings required. But the experience still has friction:
- It requires users to accept permission for your app to discover Bluetooth devices
- It requires you to build your own UI to present the list of devices to the user, which they may not be familiar with
- The pairing/bonding dialog appears as a system alert without context
- No system-level accessory management (rename, remove)
AccessorySetupKit solves this by:
- Presenting a native system picker within your app
- Filtering devices to show only your accessories
- Handling pairing automatically
- Managing the accessory lifecycle (rename, remove)
- Providing a consistent experience across iOS
Architecture: Abstraction for Dual-Mode Support
The key architectural challenge was supporting both AccessorySetupKit (iOS 18+) and CoreBluetooth (iOS 17 and earlier) without cluttering our codebase with platform checks everywhere.
We solved this with a protocol-based abstraction:
protocol DeviceDiscoveryProtocol: AnyObject {
var discoveredDevicesSubject: CurrentValueSubject<[DiscoveredDevice], Never> { get }
var rssiSubject: PassthroughSubject<(UUID, Double), Never> { get }
func startDeviceDiscovery() -> AnyPublisher<DiscoveredDevice, BluetoothError>
}
This protocol is implemented by two concrete types:
AccessorySetupKitDeviceDiscovery(iOS 18+) — Uses ASK for seamless pairingCoreBluetoothDeviceDiscovery(iOS 17 and earlier) — Traditional scanning
The BLEManager selects the appropriate implementation at initialisation:
@MainActor
class BLEManager: @preconcurrency TransportManagerProtocol, ObservableObject {
private let deviceDiscovery: DeviceDiscoveryProtocol
init(centralManager: CentralManager = CentralManager.live()) {
self.centralManager = centralManager
#if os(iOS)
if #available(iOS 18.0, *) {
self.deviceDiscovery = AccessorySetupKitDeviceDiscovery(
centralManager: centralManager
)
} else {
self.deviceDiscovery = CoreBluetoothDeviceDiscovery(centralManager: centralManager)
}
#else
self.deviceDiscovery = CoreBluetoothDeviceDiscovery(centralManager: centralManager)
#endif
setupObservers()
}
}
This pattern keeps platform-specific code isolated while presenting a unified interface to the rest of the app.
AccessorySetupKit Manager: Centralised Session Management
AccessorySetupKit operates through an ASAccessorySession that needs careful lifecycle management. We centralised this in a shared manager:
@available(iOS 18.0, *)
@MainActor
final class AccessorySetupKitManager {
static let shared = AccessorySetupKitManager()
private let session: ASAccessorySession
private var deviceNames: [UUID: String] = [:]
lazy var askDeviceAddedPublisher: AnyPublisher<ASAccessory, Never> =
askDeviceAddedSubject
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
lazy var askDeviceRenamedPublisher: AnyPublisher<(UUID, String), Never> =
askDeviceRenamedSubject
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
lazy var askDeviceRemovedPublisher: AnyPublisher<UUID, Never> =
askDeviceRemovedSubject
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
init() {
self.session = ASAccessorySession()
self.session.activate(on: DispatchQueue.main, eventHandler: self.handleEvent(_:))
}
private func handleEvent(_ event: ASAccessoryEvent) {
switch event.eventType {
case .activated:
handleActivatedEvent(event)
case .accessoryAdded:
handleAccessoryAddedEvent(event)
case .accessoryRemoved:
handleAccessoryRemovedEvent(event)
case .accessoryChanged:
handleAccessoryChangedEvent(event)
case .pickerDidDismiss:
handlePickerDismissedEvent(event)
default:
logger.debug("AccessorySetupKit received other event: \(event.eventType)")
}
}
}
Key Design Decisions:
- Singleton pattern: ASK session should be app-wide, not per-view
- Main actor isolation: All ASK APIs must run on the main thread
- Event propagation: Expose events through your preferred pattern (we use Combine publishers, but delegates or async sequences work too)
- Name caching: Track accessory names to detect renames
Implementing Discovery with AccessorySetupKit
The AccessorySetupKit discovery implementation shows how to present the system picker and handle results:
@available(iOS 18.0, *)
@MainActor
final class AccessorySetupKitDeviceDiscovery: @preconcurrency DeviceDiscoveryProtocol {
private let askManager = AccessorySetupKitManager.shared
private let centralManager: CentralManager
let discoveredDevicesSubject: CurrentValueSubject<[DiscoveredDevice], Never> = .init([])
func startDeviceDiscovery() -> AnyPublisher<DiscoveredDevice, BluetoothError> {
let subj = PassthroughSubject<DiscoveredDevice, BluetoothError>()
self.askDevicesSubject = subj
askManager.askDeviceAddedPublisher
.sink { [weak self] accessory in
guard let self else { return }
if let device = Self.getDiscoveredDevice(for: accessory) {
subj.send(device)
var currentDevices = self.discoveredDevicesSubject.value
currentDevices.append(device)
self.discoveredDevicesSubject.send(currentDevices)
}
}
.store(in: &cancellables)
let pickerItems: [ASPickerDisplayItem] = DeviceModelType.supportedDevices.compactMap { deviceType in
guard let modelId = deviceType.modelId else { return nil }
let descriptor = ASDiscoveryDescriptor()
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(
AirTurnConstants.companyIdentifier
)
descriptor.bluetoothManufacturerDataBlob = Data([0, UInt8(modelId)])
descriptor.bluetoothManufacturerDataMask = Data([0x00, 0xFF])
return ASPickerDisplayItem(
name: deviceType.displayName,
productImage: deviceType.image,
descriptor: descriptor
)
}
askManager.showPicker(for: pickerItems)
return subj.eraseToAnyPublisher()
}
}
Filtering Your Accessories:
The ASDiscoveryDescriptor offers several ways to identify your devices in the picker:
- Manufacturer data (our approach): Match specific bytes in the manufacturer data blob using a mask
- Device name: Filter by Bluetooth advertised name patterns
- Service UUIDs: Match devices advertising specific GATT services
- Company identifier: Filter by Bluetooth SIG company ID
- SSID (for WiFi accessories): Match network names
- Bluetooth range: Limit to devices within a certain RSSI range
For AirTurn devices, we use manufacturer data because:
- We encode the device model ID in byte 1 of our manufacturer data
AirTurnConstants.companyIdentifieris our Bluetooth SIG company ID- The mask
Data([0x00, 0xFF])means “ignore byte 0, match byte 1 exactly”
Choose the filtering approach that best matches your device’s advertising characteristics. Many apps use device name patterns (e.g., “MyDevice-*”) or service UUIDs if those are consistent across your product line.
Converting ASK Accessories to Domain Models
One challenge is converting ASK’s ASAccessory objects into your app’s domain models:
nonisolated static func getDiscoveredDevice(for accessory: ASAccessory) -> DiscoveredDevice? {
guard let identifier = accessory.bluetoothIdentifier,
let blob = accessory.descriptor.bluetoothManufacturerDataBlob,
blob.count >= 2,
let modelID = DeviceModelType.fromModelId(Int(blob[1])) else {
return nil
}
return DiscoveredDevice(
identifier: identifier,
detectedModelType: modelID,
detectedName: accessory.displayName,
isAdded: true
)
}
The general pattern: extract the peripheral UUID from bluetoothIdentifier, parse whatever descriptor properties you used for filtering to determine device type, and use the system-provided displayName.
Fallback: CoreBluetooth Discovery
For iOS 17 and earlier, we maintain a traditional CoreBluetooth scanning implementation:
final class CoreBluetoothDeviceDiscovery: @unchecked Sendable, DeviceDiscoveryProtocol {
private let centralManager: CentralManager
func startDeviceDiscovery() -> AnyPublisher<DiscoveredDevice, BluetoothError> {
guard !isDiscovering else { return Empty().eraseToBluetooth() }
isDiscovering = true
let connectedDevicesPublisher = getSystemConnectedDevices()
let scanningPublisher = centralManager.scanForPeripherals(withServices: nil)
.compactMap(handleDiscoveredPeripheral(_:))
return Publishers.Merge(connectedDevicesPublisher, scanningPublisher)
.setFailureType(to: BluetoothError.self)
.handleEvents(receiveOutput: { [weak self] device in
guard let self else { return }
self.logger.info("Discovered device: \(device)")
var currentDevices = self.discoveredDevicesSubject.value
currentDevices.append(device)
self.discoveredDevicesSubject.send(currentDevices)
})
.eraseToAnyPublisher()
}
}
This implementation:
- Scans for all peripherals (our service UUIDs are not always consistent across models/modes)
- Checks manufacturer data to identify our devices
- Includes already-connected system devices
- Uses the same
DeviceDiscoveryProtocolinterface
Managing Accessory Lifecycle
AccessorySetupKit provides system-level accessory management. We expose this through our manager:
func renameAccessory(with identifier: UUID) -> AnyPublisher<Void, BluetoothError> {
guard let accessory = accessory(for: identifier) else {
return Fail(error: BluetoothError.deviceNotFound("Accessory not found"))
.eraseToAnyPublisher()
}
return Future<Void, BluetoothError> { [weak self] promise in
guard let self else {
promise(.failure(.connectionFailed("Session unavailable")))
return
}
nonisolated(unsafe) let promise = promise
self.session.renameAccessory(accessory) { error in
if let error {
promise(.failure(.writeOperationFailed("ASK rename failed: \(error.localizedDescription)")))
} else {
promise(.success(()))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
The rename and remove operations trigger system UI and update the accessory registry. Your app receives change events through the session’s event handler.
Integration with Device Manager
The transport manager abstraction lets our device manager work with either ASK or CoreBluetooth transparently:
class DeviceManager<TransportManager: TransportManagerProtocol>: ObservableObject {
private let transportManager: TransportManager
func startDeviceDiscovery() -> AnyPublisher<DiscoveredDevice, BluetoothError> {
transportManager.startDeviceDiscovery()
}
var usesAccessorySetupKit: Bool {
transportManager.usesAccessorySetupKit
}
}
The device manager doesn’t need to know whether it’s using ASK or CoreBluetooth—it just calls the protocol methods.
Concurrency and Thread Safety
AccessorySetupKit requires careful attention to concurrency:
-
Main actor isolation: ASK APIs must run on the main thread. We use
@MainActoron the manager and discovery classes. -
Event handler thread: The session’s event handler is called on the queue you specify in
activate(on:eventHandler:). Ensure your event handling code is thread-safe or runs on the appropriate queue. -
State synchronisation: If you’re managing accessory state across multiple components, ensure proper synchronisation mechanisms are in place.
Key Takeaways
-
Protocol abstraction enables dual-mode support: Define a common interface for discovery, then implement it with ASK and CoreBluetooth.
-
Centralise ASK session management: Use a singleton manager to handle the session lifecycle and propagate events to interested components.
-
Choose the right descriptor properties: ASK offers multiple filtering options—manufacturer data, device name, service UUIDs, Bluetooth range, and more. Pick what works best for your accessories.
-
Main actor isolation is critical: AccessorySetupKit APIs must run on the main thread—use
@MainActorand ensure your event handling respects this requirement. -
Graceful fallback matters: Not everyone will be on iOS 18 today, and it isn’t available on other platforms. A solid CoreBluetooth implementation ensures a good experience for all users.
-
Event-driven architecture: ASK uses an event handler pattern. Design your app architecture to handle these asynchronous events cleanly, whether through Combine, async/await, delegates, or callbacks.
Conclusion
AccessorySetupKit dramatically improves the Bluetooth accessory pairing experience on iOS 18+. By building a protocol-based abstraction layer, we integrated ASK while maintaining backward compatibility with CoreBluetooth. The result is a seamless pairing flow for users on the latest iOS, with a solid fallback for everyone else.
The patterns shown here—protocol abstraction, centralised session management, and careful concurrency handling—are applicable to any app integrating AccessorySetupKit. While some implementation details (like our use of Combine publishers or manufacturer data parsing for AirTurn devices) reflect our specific choices, the overall architecture provides a blueprint for adding ASK to your own Bluetooth accessory apps.