Skip to main content
NRB Tech NRB Tech
← Back to Blog

Seamless Bluetooth Pairing with Apple's AccessorySetupKit

ios bluetooth swift 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:

  1. It requires users to accept permission for your app to discover Bluetooth devices
  2. It requires you to build your own UI to present the list of devices to the user, which they may not be familiar with
  3. The pairing/bonding dialog appears as a system alert without context
  4. 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:

  1. AccessorySetupKitDeviceDiscovery (iOS 18+) — Uses ASK for seamless pairing
  2. CoreBluetoothDeviceDiscovery (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.companyIdentifier is 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 DeviceDiscoveryProtocol interface

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:

  1. Main actor isolation: ASK APIs must run on the main thread. We use @MainActor on the manager and discovery classes.

  2. 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.

  3. State synchronisation: If you’re managing accessory state across multiple components, ensure proper synchronisation mechanisms are in place.

Key Takeaways

  1. Protocol abstraction enables dual-mode support: Define a common interface for discovery, then implement it with ASK and CoreBluetooth.

  2. Centralise ASK session management: Use a singleton manager to handle the session lifecycle and propagate events to interested components.

  3. 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.

  4. Main actor isolation is critical: AccessorySetupKit APIs must run on the main thread—use @MainActor and ensure your event handling respects this requirement.

  5. 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.

  6. 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.