import Foundation import CoreBluetooth /* actor DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObject { static let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001") static let characteristicUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000002") private var manager: CBCentralManager! = nil private(set) var lastRSSI: Int = 0 // TODO: Provide function to update @Published var state: DeviceState = .disconnected var isConnected: Bool { // Automatically updates with device state if case .configured = state { return true } return false } override init() { super.init() self.manager = CBCentralManager(delegate: self, queue: nil) } /** Allow the client to scan for devices and connect to the first device found with the correct characteristic */ @discardableResult func initiateDeviceConnection() -> Bool { switch state { case .bluetoothDisabled: log.info("Can't connect, bluetooth disabled") return false case .disconnected: break default: return true } guard !manager.isScanning else { state = .scanning return true } shouldConnectIfPossible = true state = .scanning manager.scanForPeripherals(withServices: [DeviceManager.serviceUUID]) return true } @discardableResult func updateRSSIForConnectedDevice() -> Bool { guard let device = state.device else { return false } device.readRSSI() return true } /** Indicate that a connection should be attempted when found. This does not necessarily indicate that the phone is scanning for devices. */ @Published var shouldConnectIfPossible = true { didSet { guard oldValue != shouldConnectIfPossible else { return } updateConnectionOnChange() } } private func updateConnectionOnChange() { if shouldConnectIfPossible { ensureConnection() } else { disconnectIfNeeded() } } private func ensureConnection() { switch state { case .disconnected: initiateDeviceConnection() default: return } } private func disconnectIfNeeded() { switch state { case .bluetoothDisabled, .disconnected: return default: disconnect() } } func disconnect() { shouldConnectIfPossible = false switch state { case .bluetoothDisabled, .disconnected: return case .scanning: manager.stopScan() state = .disconnected return case .connecting(let device), .discoveringCharacteristic(let device), .discoveringServices(device: let device), .configured(let device, _): manager.cancelPeripheralConnection(device) manager.stopScan() state = .disconnected return } } private var requestContinuation: CheckedContinuation? func getInfo() async -> DeviceInfo? { await get(DeviceInfoRequest()) } func getDeviceData(offset: Int, count: Int) async -> Data? { await get(DeviceDataRequest(offset: offset, count: count)) } func deleteDeviceData(byteCount: Int) async -> Bool { await get(DeviceDataResetRequest(byteCount: byteCount)) != nil } private func get(_ request: Request) async -> Request.Response? where Request: DeviceRequest { guard requestContinuation == nil else { // Prevent parallel requests return nil } guard case .configured(let device, let characteristic) = state else { return nil } let requestData = Data([request.type.rawValue]) + request.payload let responseData: Data? = await withCheckedContinuation { continuation in requestContinuation = continuation device.writeValue(requestData, for: characteristic, type: .withResponse) device.readValue(for: characteristic) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in Task { await self?.checkTimeoutForCurrentRequest(request.type) } } } guard let responseData else { return nil } guard let responseCode = responseData.first else { log.error("Request \(request.type) got response of zero bytes") return nil } guard let responseType = BluetoothResponseType(rawValue: responseCode) else { log.error("Request \(request.type) got unknown response code \(responseCode)") return nil } switch responseType { case .success, .responseTooLarge, .invalidNumberOfBytesToDelete: break case .invalidCommand: log.error("Request \(request.type) failed: Invalid command") return nil case .unknownCommand: log.error("Request \(request.type) failed: Unknown command") return nil case .responseInProgress: log.info("Request \(request.type) failed: Device is busy") return nil } return request.makeResponse(from: responseData.dropFirst(), responseType: responseType) } private func checkTimeoutForCurrentRequest(_ type: BluetoothRequestType) { guard let requestContinuation else { return } log.info("Timed out for request \(type)") requestContinuation.resume(returning: nil) self.requestContinuation = nil } nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { Task { await didDiscover(peripheral: peripheral) } } private func didDiscover(peripheral: CBPeripheral) { guard shouldConnectIfPossible else { return } peripheral.delegate = self manager.connect(peripheral) manager.stopScan() state = .connecting(device: peripheral) } nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { Task { await didUpdate(state: central.state) } } private func didUpdate(state newState: CBManagerState) { switch newState { case .poweredOff: state = .bluetoothDisabled case .poweredOn: state = .disconnected initiateDeviceConnection() case .unsupported: state = .bluetoothDisabled log.info("Bluetooth is not supported") case .unknown: state = .bluetoothDisabled log.info("Bluetooth state is unknown") case .resetting: state = .bluetoothDisabled log.info("Bluetooth is resetting") case .unauthorized: state = .bluetoothDisabled log.info("Bluetooth is not authorized") @unknown default: state = .bluetoothDisabled log.warning("Unknown state \(newState)") } } nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { Task { await didConnect(to: peripheral) } } private func didConnect(to peripheral: CBPeripheral) { log.info("Connected to " + peripheral.name!) peripheral.discoverServices([DeviceManager.serviceUUID]) state = .discoveringServices(device: peripheral) } nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { Task { await didDisconnect(from: peripheral, error: error) } } private func didDisconnect(from peripheral: CBPeripheral, error: Error?) { log.info("Disconnected from " + peripheral.name!) state = .disconnected // Attempt to reconnect if shouldConnectIfPossible { initiateDeviceConnection() } } nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { Task { await didFailToConnect(to: peripheral, error: error) } } private func didFailToConnect(to peripheral: CBPeripheral, error: Error?) { log.warning("Failed to connect device '\(peripheral.name ?? "NO_NAME")'") if let error = error { log.warning(error.localizedDescription) } state = manager.isScanning ? .scanning : .disconnected // Attempt to reconnect if shouldConnectIfPossible { initiateDeviceConnection() } } } extension DeviceConnection: CBPeripheralDelegate { nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { Task { await didDiscoverServices(for: peripheral) } } private func didDiscoverServices(for peripheral: CBPeripheral) { guard let services = peripheral.services, !services.isEmpty else { log.error("No services found for device '\(peripheral.name ?? "NO_NAME")'") manager.cancelPeripheralConnection(peripheral) return } guard let service = services.first(where: { $0.uuid.uuidString == DeviceManager.serviceUUID.uuidString }) else { log.error("Required service not found for '\(peripheral.name ?? "NO_NAME")': \(services.map { $0.uuid.uuidString})") manager.cancelPeripheralConnection(peripheral) return } peripheral.discoverCharacteristics([DeviceManager.characteristicUUID], for: service) state = .discoveringCharacteristic(device: peripheral) } nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { Task { await didDiscoverCharacteristics(for: service, of: peripheral, error: error) } } private func didDiscoverCharacteristics(for service: CBService, of peripheral: CBPeripheral, error: Error?) { if let error = error { log.error("Failed to discover characteristics: \(error)") manager.cancelPeripheralConnection(peripheral) return } guard let characteristics = service.characteristics, !characteristics.isEmpty else { log.error("No characteristics found for device") manager.cancelPeripheralConnection(peripheral) return } for characteristic in characteristics { guard characteristic.uuid == DeviceManager.characteristicUUID else { log.warning("Unused characteristic \(characteristic.uuid.uuidString)") continue } state = .configured(device: peripheral, characteristic: characteristic) peripheral.setNotifyValue(true, for: characteristic) } } nonisolated func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { log.error("Peripheral failed to write value for \(characteristic.uuid.uuidString): \(error)") } } nonisolated func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { if let error = error { log.warning("Failed to get RSSI: \(error)") return } Task { await update(rssi: RSSI.intValue) } log.info("RSSI: \(RSSI.intValue)") } private func update(rssi: Int) { lastRSSI = rssi } nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { Task { await didUpdateValue(for: characteristic, of: peripheral, error: error) } } private func didUpdateValue(for characteristic: CBCharacteristic, of peripheral: CBPeripheral, error: Error?) { if let error = error { log.error("Failed to read value update: \(error)") continueRequest(with: nil) return } guard case .configured(device: _, characteristic: let storedCharacteristic) = state else { log.warning("Received data while not properly configured") continueRequest(with: nil) return } guard characteristic.uuid == storedCharacteristic.uuid else { log.warning("Read unknown characteristic \(characteristic.uuid.uuidString)") continueRequest(with: nil) return } guard let data = characteristic.value else { log.warning("No data") continueRequest(with: nil) return } continueRequest(with: data) } private func continueRequest(with response: Data?) { guard let requestContinuation else { log.error("No continuation to handle request data (\(response?.count ?? 0) bytes)") return } requestContinuation.resume(returning: response) self.requestContinuation = nil } } */