import Foundation import SwiftUI import CoreBluetooth final class BluetoothScanner: NSObject, CBCentralManagerDelegate, ObservableObject { enum ConnectionState { case noDeviceFound case connecting case discoveringService case discoveringCharacteristic } private let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001") private let characteristicUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000002") private var manager: CBCentralManager! = nil @Published var bluetoothIsAvailable = false @Published var connectionState: ConnectionState @Published var configuredDevice: BluetoothDevice? @Published var lastDeviceInfo: DeviceInfo? private var connectingDevice: CBPeripheral? @Published var isScanningForDevices: Bool = false { didSet { if isScanningForDevices { startScanning() } else { stopScanning() } } } private func startScanning() { guard !manager.isScanning else { return } manager.scanForPeripherals(withServices: [serviceUUID]) log.info("Scanner: Started scanning for devices") } private func stopScanning() { guard manager.isScanning else { return } manager.stopScan() log.info("Scanner: Stopped scanning for devices") } var isConnectingOrConnected: Bool { configuredDevice != nil || connectingDevice != nil } func disconnect() { if let configuredDevice { manager.cancelPeripheralConnection(configuredDevice.peripheral) } if let connectingDevice { manager.cancelPeripheralConnection(connectingDevice) } } override init() { connectionState = .noDeviceFound super.init() self.manager = CBCentralManager(delegate: self, queue: nil) self.isScanningForDevices = manager.isScanning } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { guard connectionState == .noDeviceFound && configuredDevice == nil && connectingDevice == nil else { log.info("Scanner: Discovered additional device '\(peripheral.name ?? "No Name")'") return } log.info("Scanner: Connecting to discovered device '\(peripheral.name ?? "No Name")'") connectingDevice = peripheral manager.connect(peripheral) } func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOff: break case .poweredOn: bluetoothIsAvailable = true return case .unsupported: log.info("Bluetooth state: Not supported") case .unknown: log.info("Bluetooth state: Unknown") case .resetting: log.info("Bluetooth state: Resetting") case .unauthorized: log.info("Bluetooth state: Not authorized") @unknown default: log.warning("Bluetooth state: Unknown (\(central.state))") } bluetoothIsAvailable = false // TODO: Disconnect devices? } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { log.info("Scanner: Connected to '\(peripheral.name ?? "No Name")'") connectionState = .discoveringService peripheral.delegate = self peripheral.discoverServices([serviceUUID]) connectingDevice = peripheral configuredDevice = nil isScanningForDevices = false } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { log.info("Scanner: Disconnected from '\(peripheral.name ?? "No Name")'") connectionState = .noDeviceFound configuredDevice = nil connectingDevice = nil // TODO: Check if peripheral matches the connected device(s) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { log.warning("Scanner: Failed to connect to device '\(peripheral.name ?? "No Name")' (\(error?.localizedDescription ?? "No error"))") isScanningForDevices = true connectionState = .noDeviceFound connectingDevice = nil } } extension BluetoothScanner: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let services = peripheral.services, !services.isEmpty else { log.error("Connected device '\(peripheral.name ?? "No Name")': No services found") manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil isScanningForDevices = true return } guard let service = services.first(where: { $0.uuid.uuidString == serviceUUID.uuidString }) else { log.error("Connected device '\(peripheral.name ?? "No Name")': Required service not found: \(services.map { $0.uuid.uuidString})") manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil isScanningForDevices = true return } peripheral.delegate = self peripheral.discoverCharacteristics([characteristicUUID], for: service) connectionState = .discoveringCharacteristic connectingDevice = peripheral } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { log.error("Failed to discover characteristics: \(error)") manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil isScanningForDevices = true return } guard let characteristics = service.characteristics, !characteristics.isEmpty else { log.error("Connected device '\(peripheral.name ?? "No Name")': No characteristics found") manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil isScanningForDevices = true return } var desiredCharacteristic: CBCharacteristic? = nil for characteristic in characteristics { guard characteristic.uuid == characteristicUUID else { log.warning("Connected device '\(peripheral.name ?? "No Name")': Unused characteristic \(characteristic.uuid.uuidString)") continue } desiredCharacteristic = characteristic } connectionState = .noDeviceFound connectingDevice = nil guard let desiredCharacteristic else { log.error("Connected device '\(peripheral.name ?? "No Name")': Characteristic not found") manager.cancelPeripheralConnection(peripheral) isScanningForDevices = true return } configuredDevice = .init(peripheral: peripheral, characteristic: desiredCharacteristic) Task { await configuredDevice?.set(delegate: self) } } } extension BluetoothScanner: BluetoothDeviceDelegate { func bluetoothDevice(didUpdate info: DeviceInfo?) { DispatchQueue.main.async { self.lastDeviceInfo = info } } }