๐ถ Core Bluetooth
BLE ๊ธฐ๊ธฐ ์ค์บ, ์ฐ๊ฒฐ, ๋ฐ์ดํฐ ํต์ ํ๋ ์์ํฌ
โจ Core Bluetooth๋?
Core Bluetooth๋ Bluetooth Low Energy(BLE) ํ๋กํ ์ฝ์ ์ฌ์ฉํ์ฌ ์ฃผ๋ณ ๊ธฐ๊ธฐ๋ฅผ ๊ฒ์ํ๊ณ ์ฐ๊ฒฐํ๋ฉฐ ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ๋ ํ๋ ์์ํฌ์ ๋๋ค. ์ค๋งํธ์์น, ํผํธ๋์ค ํธ๋์ปค, ์ผ์, IoT ๊ธฐ๊ธฐ์ ํต์ ํ ์ ์์ผ๋ฉฐ, GATT ์๋น์ค์ ํน์ฑ(Characteristic)์ ํตํด ๋ฐ์ดํฐ๋ฅผ ์ฝ๊ณ ์ธ ์ ์์ต๋๋ค. Central-Peripheral ์ํคํ ์ฒ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ํฉ๋๋ค.
๐ 1. CBCentralManager ์ด๊ธฐํ
Central ์ญํ ๋ก BLE ๊ธฐ๊ธฐ๋ฅผ ๊ฒ์ํ๊ณ ์ฐ๊ฒฐํ๋ ๋งค๋์ ๋ฅผ ์์ฑํฉ๋๋ค.
import CoreBluetooth import SwiftUI @Observable class BluetoothManager: NSObject { private var centralManager: CBCentralManager! var discoveredPeripherals: [CBPeripheral] = [] var connectedPeripheral: CBPeripheral? var isBluetoothOn = false var isScanning = false override init() { super.init() // Central Manager ์์ฑ centralManager = CBCentralManager(delegate: self, queue: nil) } // ์ค์บ ์์ func startScanning() { guard isBluetoothOn else { print("โ ๏ธ Bluetooth๊ฐ ๊บผ์ ธ ์์ต๋๋ค") return } discoveredPeripherals.removeAll() isScanning = true // ๋ชจ๋ BLE ๊ธฐ๊ธฐ ์ค์บ (์๋น์ค ํํฐ ์์) centralManager.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) print("๐ BLE ๊ธฐ๊ธฐ ์ค์บ ์์") } // ํน์ ์๋น์ค๋ง ์ค์บ func scanForDevices(withServices serviceUUIDs: [CBUUID]) { guard isBluetoothOn else { return } discoveredPeripherals.removeAll() isScanning = true centralManager.scanForPeripherals(withServices: serviceUUIDs, options: nil) print("๐ ํน์ ์๋น์ค ์ค์บ: \(serviceUUIDs)") } // ์ค์บ ์ค์ง func stopScanning() { centralManager.stopScan() isScanning = false print("โน๏ธ ์ค์บ ์ค์ง") } // Peripheral ์ฐ๊ฒฐ func connect(peripheral: CBPeripheral) { stopScanning() centralManager.connect(peripheral, options: nil) print("๐ ์ฐ๊ฒฐ ์๋: \(peripheral.name ?? "Unknown")") } // ์ฐ๊ฒฐ ํด์ func disconnect() { guard let peripheral = connectedPeripheral else { return } centralManager.cancelPeripheralConnection(peripheral) print("โ ์ฐ๊ฒฐ ํด์ ") } }
๐ก 2. Central Manager ๋ธ๋ฆฌ๊ฒ์ดํธ
Bluetooth ์ํ ๋ณํ์ ๊ธฐ๊ธฐ ๋ฐ๊ฒฌ์ ์ฒ๋ฆฌํฉ๋๋ค.
import CoreBluetooth extension BluetoothManager: CBCentralManagerDelegate { // Bluetooth ์ํ ๋ณํ func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: print("โ Bluetooth ์ผ์ง") isBluetoothOn = true case .poweredOff: print("โ Bluetooth ๊บผ์ง") isBluetoothOn = false case .unauthorized: print("โ ๏ธ Bluetooth ๊ถํ ์์") isBluetoothOn = false case .unsupported: print("โ Bluetooth ๋ฏธ์ง์") isBluetoothOn = false case .resetting: print("๐ Bluetooth ์ฌ์ค์ ์ค") case .unknown: print("โ Bluetooth ์ํ ๋ถ๋ช ") @unknown default: break } } // Peripheral ๋ฐ๊ฒฌ func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { // ์ด๋ฏธ ๋ฐ๊ฒฌ๋ ๊ธฐ๊ธฐ๋ ์ถ๊ฐํ์ง ์์ guard !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) else { return } discoveredPeripherals.append(peripheral) let deviceName = peripheral.name ?? "Unknown Device" let signalStrength = RSSI.intValue print("๐ถ ๋ฐ๊ฒฌ: \(deviceName) (RSSI: \(signalStrength)dBm)") // Advertisement Data ์ถ๋ ฅ if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String { print(" Local Name: \(localName)") } if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { print(" Services: \(serviceUUIDs)") } } // ์ฐ๊ฒฐ ์ฑ๊ณต func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { print("โ ์ฐ๊ฒฐ ์ฑ๊ณต: \(peripheral.name ?? "Unknown")") connectedPeripheral = peripheral peripheral.delegate = self // ์๋น์ค ํ์ peripheral.discoverServices(nil) } // ์ฐ๊ฒฐ ์คํจ func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { print("โ ์ฐ๊ฒฐ ์คํจ: \(error?.localizedDescription ?? "Unknown error")") connectedPeripheral = nil } // ์ฐ๊ฒฐ ํด์ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { print("โ ์ฐ๊ฒฐ ํด์ : \(peripheral.name ?? "Unknown")") connectedPeripheral = nil if let error = error { print(" Error: \(error.localizedDescription)") } } }
๐ ๏ธ 3. Peripheral ๋ธ๋ฆฌ๊ฒ์ดํธ
์๋น์ค์ ํน์ฑ(Characteristic)์ ํ์ํ๊ณ ๋ฐ์ดํฐ๋ฅผ ์ฝ์ต๋๋ค.
import CoreBluetooth extension BluetoothManager: CBPeripheralDelegate { // ์๋น์ค ๋ฐ๊ฒฌ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { print("โ ์๋น์ค ๋ฐ๊ฒฌ ์คํจ: \(error.localizedDescription)") return } guard let services = peripheral.services else { return } print("๐ ๋ฐ๊ฒฌ๋ ์๋น์ค: \(services.count)๊ฐ") for service in services { print(" Service UUID: \(service.uuid)") // ๊ฐ ์๋น์ค์ ํน์ฑ ํ์ peripheral.discoverCharacteristics(nil, for: service) } } // ํน์ฑ ๋ฐ๊ฒฌ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { print("โ ํน์ฑ ๋ฐ๊ฒฌ ์คํจ: \(error.localizedDescription)") return } guard let characteristics = service.characteristics else { return } print("๐ ์๋น์ค \(service.uuid)์ ํน์ฑ: \(characteristics.count)๊ฐ") for characteristic in characteristics { print(" Characteristic UUID: \(characteristic.uuid)") print(" Properties: \(characteristic.properties)") // ์ฝ๊ธฐ ๊ฐ๋ฅํ๋ฉด ์ฝ๊ธฐ if characteristic.properties.contains(.read) { peripheral.readValue(for: characteristic) } // Notify ๊ฐ๋ฅํ๋ฉด ๊ตฌ๋ if characteristic.properties.contains(.notify) { peripheral.setNotifyValue(true, for: characteristic) print(" ๐ฌ Notification ๊ตฌ๋ ") } } } // ํน์ฑ ๊ฐ ์ฝ๊ธฐ ์๋ฃ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { print("โ ๊ฐ ์ฝ๊ธฐ ์คํจ: \(error.localizedDescription)") return } guard let data = characteristic.value else { print("โ ๏ธ ๋ฐ์ดํฐ ์์") return } print("๐ฅ ๋ฐ์ดํฐ ์์ (\(characteristic.uuid)): \(data.hexString)") // String์ผ๋ก ๋ณํ ์๋ if let stringValue = String(data: data, encoding: .utf8) { print(" String: \(stringValue)") } // ํน์ ์๋น์ค๋ณ ํ์ฑ parseCharacteristicValue(characteristic, data: data) } // ํน์ฑ ๊ฐ ์ฐ๊ธฐ ์๋ฃ func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { print("โ ์ฐ๊ธฐ ์คํจ: \(error.localizedDescription)") return } print("โ ์ฐ๊ธฐ ์ฑ๊ณต (\(characteristic.uuid))") } // Notification ์ํ ๋ณ๊ฒฝ func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { if let error = error { print("โ Notification ์ค์ ์คํจ: \(error.localizedDescription)") return } if characteristic.isNotifying { print("โ Notification ํ์ฑํ (\(characteristic.uuid))") } else { print("โ Notification ๋นํ์ฑํ (\(characteristic.uuid))") } } // ํน์ฑ ๊ฐ ํ์ฑ (์๋น์ค๋ณ ์ฒ๋ฆฌ) private func parseCharacteristicValue(_ characteristic: CBCharacteristic, data: Data) { // Heart Rate Service (0x180D) if characteristic.uuid == CBUUID(string: "2A37") { let heartRate = parseHeartRate(data) print(" โค๏ธ Heart Rate: \(heartRate) bpm") } // Battery Level (0x2A19) else if characteristic.uuid == CBUUID(string: "2A19") { if let batteryLevel = data.first { print(" ๐ Battery: \(batteryLevel)%") } } } // Heart Rate ๋ฐ์ดํฐ ํ์ฑ private func parseHeartRate(_ data: Data) -> Int { guard data.count > 1 else { return 0 } let flags = data[0] let is16Bit = (flags & 0x01) == 0x01 if is16Bit { return Int(data[1]) | (Int(data[2]) << 8) } else { return Int(data[1]) } } } // Data Extension extension Data { var hexString: String { map { String(format: "%02X", $0) }.joined(separator: " ") } }
๐ค 4. ๋ฐ์ดํฐ ์ฐ๊ธฐ
Peripheral์ ๋ฐ์ดํฐ๋ฅผ ์ ์กํฉ๋๋ค.
import CoreBluetooth extension BluetoothManager { // ๋ฌธ์์ด ๋ฐ์ดํฐ ์ ์ก func writeString(_ string: String, to characteristic: CBCharacteristic) { guard let data = string.data(using: .utf8) else { print("โ ๋ฌธ์์ด ๋ณํ ์คํจ") return } writeData(data, to: characteristic) } // ๋ฐ์ดํธ ๋ฐ์ดํฐ ์ ์ก func writeData(_ data: Data, to characteristic: CBCharacteristic) { guard let peripheral = connectedPeripheral else { print("โ ์ฐ๊ฒฐ๋ ๊ธฐ๊ธฐ ์์") return } // ์ฐ๊ธฐ ๊ฐ๋ฅ ์ฌ๋ถ ํ์ธ guard characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) else { print("โ ์ฐ๊ธฐ ๋ถ๊ฐ๋ฅํ ํน์ฑ") return } // ์ฐ๊ธฐ ํ์ ๊ฒฐ์ let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.write) ? .withResponse : .withoutResponse peripheral.writeValue(data, for: characteristic, type: writeType) print("๐ค ๋ฐ์ดํฐ ์ ์ก: \(data.hexString)") } // LED ์ ์ด ์์ func toggleLED(characteristic: CBCharacteristic, isOn: Bool) { let command: UInt8 = isOn ? 0x01 : 0x00 let data = Data([command]) writeData(data, to: characteristic) } }
๐ฑ 5. SwiftUI ํตํฉ
BLE ์ค์บ๋ UI๋ฅผ ๊ตฌํํฉ๋๋ค.
import SwiftUI import CoreBluetooth struct BLEScannerView: View { @State private var bluetoothManager = BluetoothManager() var body: some View { NavigationStack { VStack(spacing: 0) { // ์ํ ํ์ statusHeader // ๊ธฐ๊ธฐ ๋ชฉ๋ก if bluetoothManager.discoveredPeripherals.isEmpty { emptyState } else { deviceList } } .navigationTitle("BLE ์ค์บ๋") .toolbar { ToolbarItem(placement: .topBarTrailing) { scanButton } } } } // ์ํ ํค๋ private var statusHeader: some View { VStack(spacing: 8) { HStack { Circle() .fill(bluetoothManager.isBluetoothOn ? Color.green : Color.red) .frame(width: 12, height: 12) Text(bluetoothManager.isBluetoothOn ? "Bluetooth ํ์ฑ" : "Bluetooth ๋นํ์ฑ") .font(.subheadline) .foregroundStyle(.secondary) Spacer() if bluetoothManager.isScanning { ProgressView() .controlSize(.small) Text("์ค์บ ์ค...") .font(.caption) .foregroundStyle(.secondary) } } .padding() if let connected = bluetoothManager.connectedPeripheral { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("์ฐ๊ฒฐ๋จ: \(connected.name ?? "Unknown")") .font(.subheadline) Spacer() Button("์ฐ๊ฒฐ ํด์ ") { bluetoothManager.disconnect() } .font(.caption) .buttonStyle(.bordered) } .padding(.horizontal) .padding(.bottom) } } .background(Color(.systemGroupedBackground)) } // ์ค์บ ๋ฒํผ private var scanButton: some View { Button { if bluetoothManager.isScanning { bluetoothManager.stopScanning() } else { bluetoothManager.startScanning() } } label: { Image(systemName: bluetoothManager.isScanning ? "stop.circle" : "arrow.clockwise") } .disabled(!bluetoothManager.isBluetoothOn) } // ๋น ์ํ private var emptyState: some View { ContentUnavailableView { Label("BLE ๊ธฐ๊ธฐ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค", systemImage: "antenna.radiowaves.left.and.right.slash") } description: { Text("์ค์บ ๋ฒํผ์ ๋๋ฌ ์ฃผ๋ณ BLE ๊ธฐ๊ธฐ๋ฅผ ๊ฒ์ํ์ธ์") } } // ๊ธฐ๊ธฐ ๋ชฉ๋ก private var deviceList: some View { List(bluetoothManager.discoveredPeripherals, id: \.identifier) { peripheral in Button { bluetoothManager.connect(peripheral: peripheral) } label: { HStack { VStack(alignment: .leading, spacing: 4) { Text(peripheral.name ?? "Unknown Device") .font(.headline) Text(peripheral.identifier.uuidString) .font(.caption) .foregroundStyle(.secondary) if peripheral.state == .connected { Text("์ฐ๊ฒฐ๋จ") .font(.caption2) .foregroundStyle(.green) } } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.secondary) } } .disabled(peripheral.state == .connected) } } }
๐ก HIG ๊ฐ์ด๋๋ผ์ธ
- ๊ถํ ์์ฒญ: Info.plist์ NSBluetoothAlwaysUsageDescription ์ถ๊ฐ
- ๋ฐฐํฐ๋ฆฌ ํจ์จ: ๋ถํ์ํ ์ค์บ ์ค์ง, ์ฐ๊ฒฐ ํ ์ฆ์ ์ค์บ ์ค๋จ
- ๋ฐฑ๊ทธ๋ผ์ด๋: Info.plist์ bluetooth-central ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ชจ๋ ์ถ๊ฐ
- ์ฌ์ฉ์ ํผ๋๋ฐฑ: ์ค์บ ์ํ์ ์ฐ๊ฒฐ ์ํ๋ฅผ ๋ช ํํ ํ์
- ์๋ฌ ์ฒ๋ฆฌ: ์ฐ๊ฒฐ ์คํจ, Bluetooth ๊บผ์ง ๋ฑ์ ๋ํ ์ ์ ํ ์๋ด
๐ฏ ์ค์ ํ์ฉ
- ํผํธ๋์ค ์ฑ: ์ฌ๋ฐ์ ์ผ์, ์ค๋งํธ์์น ์ฐ๋
- IoT ์ ์ด: ์ค๋งํธ ํ ๊ธฐ๊ธฐ ์ ์ด
- ์๋ฃ ๊ธฐ๊ธฐ: ํ์๊ณ, ํ๋น๊ณ ๋ฐ์ดํฐ ์์ง
- ์์น ๋น์ฝ: iBeacon ๊ธฐ๋ฐ ๊ทผ์ ๋ง์ผํ
- ๊ฒ์ ์ปจํธ๋กค๋ฌ: BLE ๊ฒ์ํจ๋ ์ฐ๋
๐ ๋ ์์๋ณด๊ธฐ
allowDuplicatesKey๋ฅผ false๋ก ์ค์ ํ๋ฉด ๋ฐฐํฐ๋ฆฌ ์๋ชจ๋ฅผ ์ค์ผ ์ ์์ต๋๋ค. ํน์ ์๋น์ค UUID๋ก ํํฐ๋งํ๋ฉด ๋ ๋น ๋ฅด๊ฒ ์ํ๋ ๊ธฐ๊ธฐ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค. ์ฐ๊ฒฐ ํ์๋ ๋ฐ๋์ ์ค์บ์ ์ค์งํ์ธ์.