import Foundation import CoreBluetooth actor BluetoothDevice: NSObject, ObservableObject { private let peripheral: CBPeripheral! private let characteristic: CBCharacteristic! @MainActor @Published var lastDeviceInfo: DeviceInfo? @Published private(set) var lastRSSI: Int = 0 init(peripheral: CBPeripheral, characteristic: CBCharacteristic) { self.peripheral = peripheral self.characteristic = characteristic super.init() peripheral.delegate = self } override init() { self.peripheral = nil self.characteristic = nil super.init() } private var requestContinuation: (id: Int, call: CheckedContinuation)? func updateInfo() async { guard let info = await getInfo() else { return } Task { @MainActor in lastDeviceInfo = info } } 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 } let requestData = Data([request.type.rawValue]) + request.payload let responseData: Data? = await withCheckedContinuation { continuation in let id = Int.random(in: .min...Int.max) requestContinuation = (id, continuation) peripheral.writeValue(requestData, for: characteristic, type: .withResponse) peripheral.readValue(for: characteristic) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in Task { await self?.checkTimeoutForCurrentRequest(request.type, id: id) } } } 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, id: Int) { guard let requestContinuation else { return } guard requestContinuation.id == id else { return } log.info("Timed out for request \(type)") requestContinuation.call.resume(returning: nil) self.requestContinuation = nil } } extension BluetoothDevice: CBPeripheralDelegate { nonisolated func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { } 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 characteristic.uuid == self.characteristic.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.call.resume(returning: response) self.requestContinuation = nil } }