ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Core Buletooth 사용하기
    SwiftUI 2023. 6. 6. 20:52

    Core Bluetooth 프레임워크는 앱이 저전력을 지원하거나 흔히 클래식이라고 부르는 BR/EDR 장비와 통신을 지원하기 위한 프레임워입니다. 중요한 점은 Core Bluetooth 에 클래스는 상속을 받아 재정의를 하면 안된다고 공식 문서에 나와 있습니다. 여기서는 iOS 디바이스로 주변 불루투스 장비를 검색하는 SwiftUI 앱을 구현해 보겠습니다. 우선 BLE에 대해 간단히 살펴 보겠습니다. 

    BLE는 Bluetooth Low Energy 의 약자로 저 전력 모드를 지원하는 블루투스를 의미합니다. 이 후 기존의 블루투스 관련 프로토콜은 클래식 이라는 이름으로 사용되고 있습니다. 이전 클래식 버전이 저 지연과 고 데이터 전송을 위해 발전되어 왔다면 BLE는 얼마나 적은 전력을 소모하느냐는 관점에서 발전해 왔습니다. 

     블루투스 동작 방식 

    Advertise Mode

    블루투스트는 크게 Central 과 Peripheral 장비로 구분 지을 수 있습니다. 여기서 iOS 앱이 Central 역할을 하고 주변 블루투스 장비를 검색하게 됩니다. 보통 비콘이나 블루투스를 지원하는 iOT 장비들이 Peripheral(주변 장치) 역활을 수행하는 데 이들은 Central에 연결되어 자신의 정보를 전달하게 됩니다. Advertise 모드는 주변 장치로 표현되는 BLE 장비들이 전원이 인가되면 주기적으로 신호를 보내게 되는 데 이를 Advertising 이라고 합니다. 

     

    Connection Mode 

    Central 장비는 Advertising 되는 신호를 감지하여 해당 디바이스와 연결을 수행합니다. 이렇게 Advertising 패킷을 수신하고 장치간의 연결 과정을 수행하고 연결이 되면 두 장비 간 통신을 수행할 수 있습니다. 

     

    Central (중앙 장치)

    중앙장치는 주변 장치들이 브로드캐스팅하는 Advertising 패킷을 받아 수신된 패킷에 해당하는 주변 장치와 연결을 시도합니다. 이때 연결 간격과 Hopping 규칙들은 중앙장치에서 정합니다. 

     

    Peripheral(주변 장치)

    주변 장치는 중앙장치와 연결 후 중양 장치의 설정에 따라 동작합니다. 

     

    간단하게 두 장치와의 연결동작을 보면 아래와 같습니다. 

     

    CBCentralManager를 이용한 BluetoothCentral 클래스 

    BluetoothCentral 클래스는 기본적으로 검색한 주변 장치들에 대한 정보를 뷰에 전달하기 위해 ObservableObject 프로토콜을 따르고 CBCenteralManager의 delegate 프로토콜을 구현하기 위해 NSObject를 상속받아서 구현합니다. 

    class BluetoothCenteral : NSObject, ObservableObject {
        let centralManager = CBCentralManager(delegate: nil, 
        				queue: DispatchQueue.global(qos: .userInitiated))
        @Published var peripherals: [PeripheralViewModel] = []

    init(delegate: CBCentralManagerDelegate?, queue: DispatchQueue?) 를 통해 CBCenteralManager를 생성하게 되는데 delegate는 CBCenteralManager 주변 장치를 검색한 결과나 연결한 결과를 delegate를 통해 전달하게 됩니다. 디스패치 큐는 해당 delegate 가 수행되는 큐를 설정하는 데 사용하는 데 여기서는 global(qos: .userInitiaged) 큐를 사용합니다.  delegate는 해당 클래스 초기화 시 자신의 클래스를 할당해 줍니다. peripherals 프로퍼티는 게시 속성을 가지고 있어 주변 장치를 검색할 경우 peripherals 에 장치를 추가할 때 마다 클래스를 Observed 하고 있는 뷰에서 주변 장치가 변경될 때 마다 뷰를 갱신할 수 있도록 해줍니다. 

     override init() {
            super.init()
            centralManager.delegate = self
        }

    기본적으로 BluetoothCentralManager는 스캔을 시작하는 startScan() 함수, 스캔을 종료하는 stopScan() 탐색된 주변 장치와 연결하는 connect(peripheral: CBPheriperal), 연결된 주변 장치와 연결을 종료하는 disconnect(_ peripheral: CBPeripheral) 함수로 구성됩니다. 

    func startScan() {
            centralManager.scanForPeripherals(withServices:nil)
        }

    startScan() 함수는 centeralManager.scanForPeripherals(withServices:) 함수를 호출하는 데 실제로는 검색하고자 하는 서비스를 구성해 해당 서비스에 대해서만 검색하도록 해야하지만, 여기서는 현재 주변에 탐색 가능한 모든 블루투스 주변장치를 검색하기 위해 nil 값을 사용합니다. 

    여기서 Service라는 개념이 등장하는 데 이는 블루투스의 GATT(Generic Attribute Profile)에 대한 개념을 이해해야 합니다. 블루투스 장비는 자신이 가지고 있는 기능에 대한 내용을 GATT를 통해 관리하고 있고 각각은 Profile , Service, Characteristic 으로 표현합니다. 해당 내용에 대한 자세한 내용은 다음에 다루도록 하겠습니다. 여기서는 scan 시 검색하고자 하는 서비스에 대한 스펙을 정하고 해당 스펙을 센트럴 매니저에게 전달하면 해당 서비스에 대한 주변 장치만을 검색한다고 넘어가시면 됩니다. 

     

    func stopScan() {
            centralManager.stopScan()
        }

    stopScan() 함수는 찾고자 하는 디바이스를 발견했다면 stopScan()을 사용하여 스캔을 종료합니다. 

    func connect(peripheral: CBPeripheral) {
            print("connecting: \(peripheral.name!)")
            centralManager.connect(peripheral)
        }

    connect(peripheral: CBPherial)  함수는 검색된 주변장치에 대해 연결을 수행합니다. 

     func disconnect(_ peripheral: CBPeripheral) {
            guard let services = peripheral.services else { return }
            for service in services {
                for characteristic in service.characteristics ?? [] {
                    if characteristic.isNotifying {
                        peripheral.setNotifyValue(false, for: characteristic)
                    }
                }
            }
        }

     

    disconnect(_ peripheral: CBPeripheral) 함수는 해당 주변 장치의 서비스를 탐색해 해당 서비스의 characteristics 중 통지를 받고 있는 chracteristic이 있는 경우 해당 통지 수신을 중지할 수 있도록 setNotifyValue를 false 로 설정해 더 이상 주변 장치로 부터 통지를 받지 않도록 설정합니다. characteristic 에 대한 내용은 GATT를 다룰 때 설명하게 되는 데 여기서는 주변 디바이스의 특징으로 쓰고 읽고 통지 할 수 있는 지의 특성을 나타낸다고 이해하시면 됩니다. 

    BluetoothCenteral 은 CBCenteralManagerDelegate 프로토콜을 구현해 CBCenteralManager 에게서 주변 장치에 대한 이벤트를 받게 됩니다. 

    extension BluetoothCenteral: CBCentralManagerDelegate {
        func centralManagerDidUpdateState(_ central: CBCentralManager) {
        }
        
        func centralManager(_ central: CBCentralManager, 
        					didDiscover peripheral: CBPeripheral, 
    				        advertisementData: [String : Any], 
    				        rssi RSSI: NSNumber) {
            DispatchQueue.main.async {
                guard RSSI.intValue >= -70, 
                let name = peripheral.name, 
                !self.peripherals.contains(where: { $0.peripheral == peripheral }) else {
                    return
                }
                for key in advertisementData.keys {
                    print("key:\(key), value:\(advertisementData[key]!)")
                }
                self.peripherals.append(PeripheralViewModel(peripheral: peripheral) )
            }
        }
        
    
        
        func centralManager(_ central: CBCentralManager, 
        						didConnect peripheral: CBPeripheral) {
            if let viewModel = peripherals.first(where: { $0.peripheral == peripheral }) {
                //peripheral.discoverServices([serviceId])
                peripheral.discoverServices(nil)
            }
        }
        
        func centralManager(_ central: CBCentralManager, 
        					didFailToConnect peripheral: CBPeripheral, error: Error?) {
            print("\(peripheral.name ?? peripheral.identifier.uuidString) connect fail")
        }
        
        
        func centralManager(_ central: CBCentralManager, 
        					didDisconnectPeripheral peripheral: CBPeripheral, 
                            error: Error?) {
            print("\(peripheral.name ?? peripheral.identifier.uuidString) is disconnectd")
        }
    }

    scan 동작 시 CBCenteralManager는 주변 장치에서 브로드캐스드 되는 advertise 패킷을 수신하게 되면 딜리케이터의 centeralManaager(_:didDiscover:advertisementData:rssi) 딜리케이터 함수를 호출해 이를 알리게 됩니다. 해당 함수의 RSSI 의 세기와 이미 검색된 장치인지를 검사하고 해당 장치의 신호 세기가 양호하고 이미 검색된 장치가 아니라면 해당 장치를 peripherals 배열에 추가합니다. 연결하고자 하는 장치에 connect를 호출하게 되면 센트럴 매니저는 해당 연결이 이루어졌을 때 딜리게이터 함수 centeralManager(_:didConnect) 함수를 호출하게 됩니다. 이 때 해당 장치에 관심 있는 서비스 목록을 전달해 peripheral.discoverSerivces 함수를 호출합니다. 여기서는 모든 서비스를 검색하는 데 이는 실제 앱을 구현할 때는 좋은 방법이 아닙니다. 여기서는 CBPeripheral 객체는 PeripheralViewModel로 래핑하고 있습니다. CBPeripheral은 주변 장치의 서비스의 내용을 업데이트 내용을 제공하기 위해 CBPeripheralDelegate를 등록해야 하는데 이를 PeripheralViewModel을 통해서 구현하고 연결 상태나 주변 장치의 서비스의 상태가 변경 될 시 해당 값을 퍼플리시 하기 위해 ObservableObject로 구현합니다. 이 방식은 CBCenteralManager를 구현했던 방식과 동일합니다. 

    class PeripheralViewModel: NSObject, ObservableObject, Identifiable {
        @Published var isConnected: Bool = false
        @Published var services: [ServiceViewModel] = []
        var id: String {
            return peripheral?.identifier.uuidString ?? ""
        }
        var name: String {
            return peripheral?.name ?? ""
        }
        
        let peripheral: CBPeripheral?
        init(peripheral: CBPeripheral?) {
            self.peripheral = peripheral
            super.init()
            self.peripheral?.delegate = self
        }

    CBPeripheral은 주변 장치가 지원하는 서비스와 해당 서비스에 Characteristic 목록에 대한 요청을 할 수 있습니다. 서비스에 대한 정보는 주변장치의 connection 시 살펴본 바와 같이 discoverServices를 호출해 해당 서비스 목록을 받아오고 서비스에 존재하는 characteristic에 대한 정보는 CBPeripheral 클래스의 discoverCharacteristics(_: for service: ) 함수를 통해 이루어 집니다. 마찬가지로 해당 이벤트가 발생하면 CBPeripheralDelegate를 통해서 해당 이벤트를 확인 할 수 있습니다. PeripheralViewModel은 CBPeripheralDeletate를 구현합니다. 

    extension PeripheralViewModel: CBPeripheralDelegate {
      func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
            DispatchQueue.main.async {
                self.isConnected = true
                print("did discover services:\(peripheral.services!.count)")
                peripheral.services?.forEach{ peripheral.discoverCharacteristics(nil, for: $0)}
                self.services = peripheral.services?.compactMap{ServiceViewModel(service: $0)} ?? []
            }
        }
        
        func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
            guard let characteristics = service.characteristics else {
                if let error = error {
                    print("discover characteristics error:\(error)")
                }
                return
            }
            guard let index = services.firstIndex(where: {$0.service == service}) else { return }
            DispatchQueue.main.async { [weak self] in
                self?.services[index].characteristics = characteristics.compactMap{CharacteristicViewModel(characteristic: $0)}
            }
        }
        
        func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
            guard let value = characteristic.value as NSData? else {
                print("update value error")
                return
            }
            
            print("update value: \(characteristic.isNotifying): \(value.description)")
        }
    
    }

    PeripheralViewModel은 주변 장치가 지원하는 서비스 목록을 관리하고 이를 게시합니다.  또한 주변 장치로 부터 업데이트 된 값에 대한 정보를 수신하게 되면 peripheral(_:didupdateValueFor:error) 함수가 호출되어 해당 값을 확인할 수 있습니다. 

     

    여기까지 CoreBluetooth 가 CBCenteralManager를 통해서 주변장치와 어떻게 통신하는 지를 보여 주었습니다. 

    이 블루투스 ViewModel을 이용하여 CenteralModelView를 구현하게 됩니다. CenteralViewModel은 BluetoothCenteral 로 부터 게시된 정보와 기능을 이용해 Scan을 요청하고 Scan한 디바이스의 정보를 화면에 출력하도록 구현됩니다. 

     

    struct CentereralModeView: View {
        @EnvironmentObject var centeral: BluetoothCenteral
        @State var isScaning: Bool = false
        var body: some View {
            NavigationStack {
                VStack {
                    List {
                        ForEach(centeral.peripherals, id: \.id) { viewModel  in
                            NavigationLink(destination: PeripheralView(viewModel: viewModel)) {
                                HStack() {
                                    Image(systemName: "doc.text.magnifyingglass")
                                    VStack(alignment: .leading, spacing: 0) {
                                        Text(viewModel.name)
                                            .font(.subheadline)
                                        Text(viewModel.id)
                                            .font(.caption)
                                    }
                                }
                            }
                        }
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    Button {
                        
                        if !isScaning {
                            centeral.startScan()
                        } else {
                            centeral.stopScan()
                        }
                        isScaning.toggle()
                    }label: {
                        
                            Text(isScaning ? "Stop" : "Scan")
                                .tint(.white)
                                .frame(width: 120, height: 46)
                                .background(isScaning ? .gray : .blue)
                                .cornerRadius(20)
                    }
                    
                }
                .navigationTitle("Bluetooth Peripherals")
            }
        }
    }

    여기서 버튼을 하나 구현하고 해당 버튼을 누르면 스캔 동작을 시작 / 중지 하도록 설정하고 BluetoothCenteral에서 게시되는 peripherals를 리스트 내에 ForEach 문을 통해서 NavigationLink를 생성하기만 하면 리스트 뷰가 만들어 집니다. 이는 새로운 주변 장치가 검색되면 peripherals 값이 변경되는 데 이를 SwiftUI가 확인해 뷰를 자동으로 업데이트 해 줍니다. 딱히 UIKit에서 UITableViewController로 구현했을 때 테이블을 리로드 해줘야 하는 일은 하지 않아도 됩니다. 각각의 리스트에 엔트리는 NavigationLink를 통해 해당 리스트의 셀을 클릭시 PeripheralView로 이동하게 됩니다. 

     

    PeripheralView는 뷰 생성 시 task 한정자를 통해 centeralManager.connect(peripheral:)을 호출해 해당 장치로 연결을 요청합니다. 연결이 완료되면 CBCentralManagerDelegate의 didConnect 함수에서 해당 장치의 서비스 탐색을 요청하게 되고 서비스에 대한 정보를 받으면  CBPeripheralDelegate 의 peripheral(_: didDiscoverService:)를 호출하게 되는 데 여기서 perpheral 서비스 목록을 게시하게 되는데 이 때 PeripheralView는 화면에 해당 장치으 모든 서비스들을 화면에 출력하게 됩니다. 

    struct PeripheralView: View {
        @EnvironmentObject var centralManager: BluetoothCenteral
        @ObservedObject var viewModel: PeripheralViewModel
        @State var time = 0.0
        init(viewModel: PeripheralViewModel) {
            self.viewModel = viewModel
        }
        
        let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
        var body: some View {
            ZStack {
                VStack {
                    Image(systemName: "timelapse", variableValue: time)
                        .imageScale(.large)
                        .foregroundColor(.accentColor)
                        .font(.system(size: 50))
                        .fontWeight(.thin)
                        .onReceive(timer) { _ in
                            if time < 1.0
                            {
                                time += 0.1
                            } else {
                                timer.upstream.connect().cancel()
                            }
                        }
                        .padding()
                    Text("Connecting")
                }
                .padding(30)
                .background(.ultraThinMaterial)
                .cornerRadius(10)
                .overlay(
                    RoundedRectangle(cornerRadius: 10, style: .continuous)
                    .stroke()
                    .foregroundStyle(
                        .linearGradient(colors: [.white.opacity(0.5), .clear, .white.opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing)))
                .shadow(color: .black.opacity(0.3), radius: 10, y: 10)
                .frame(maxWidth: 500)
                .opacity(viewModel.isConnected ? 0 : 1)
                if viewModel.isConnected {
                    Form {
                        Section {
                            Text(viewModel.name)
                        } header: {
                            Text("Peripheral information")
                        }
                        Section {
                            ForEach(viewModel.services, id: \.id) { service in
                                NavigationLink(destination: CharacteristicsView(service: service))
                                               {
                                    VStack (alignment: .leading){
                                        Text(service.id)
                                            .font(.subheadline)
                                        Text("Primary: \(service.isPrimary ? "true" : "false")")
                                            .font(.footnote)
                                    }
                                }
                            }
                        } header: {
                            Text("Services")
                        }
                        
                    }
                }
            }
            .navigationTitle("\(viewModel.name)")
            .navigationBarTitleDisplayMode(.inline)
            .animation(.default, value: viewModel.isConnected)
            .task {
                if let peripheral = self.viewModel.peripheral, peripheral.state == .disconnected {
                    centralManager.connect(peripheral: peripheral)
                }
            }
        }
    }

    여기까지가 CBCenteralManager를 이용해 주변 장치를 검색하고 검색된 장치를 연결해 서비스를 요청하는 것까지의 과정을 설명했습니다. 다음에는 GATT에 대해 살펴보고 검색된 서비스에 있는 모든 Characteristic를 검색해 화면에 출력하는 부분까지를 설명하겠습니다. 

    여기 설명한 해당 코드는 https://github.com/hjpark0724/Bluetooth.git 에 올려져 있습니다. 관심 있는 분들은 클론해서 보세요. 

     

    댓글

Designed by Tistory.