diff --git a/TempTrack/Connection/BluetoothRequestType.swift b/TempTrack/Connection/BluetoothRequestType.swift new file mode 100644 index 0000000..31df579 --- /dev/null +++ b/TempTrack/Connection/BluetoothRequestType.swift @@ -0,0 +1,46 @@ +import Foundation + +enum BluetoothRequestType: UInt8 { + /** + * Request the number of bytes already recorded + * + * Request: + * - No additional bytes expected + * + * Response: + * - `BluetoothResponseType.success` + * - the number of recorded bytes as a `Uint16` (2 bytes) + * - the number of seconds until the next measurement as a `Uint16` (2 bytes) + * - the number of seconds between measurements as a `Uint16` (2 bytes) + * - the number of measurements as a `Uint16` (2 bytes) + * - the maximum number of bytes to request as a `Uint16` (2 bytes) + * - the number of seconds since power on as a `Uint32` (4 bytes) + */ + case getInfo = 0 + + /** + * Request recording data + * + * Request: + * - Bytes 1-2: Memory offset (`UInt16`) + * - Bytes 3-4: Number of bytes (`UInt16`) + * + * Response: + * - `BluetoothResponseType.success`, plus the requested bytes + * - `BluetoothResponseType.responseTooLarge` if too many bytes are requested + */ + case getRecordingData = 1 + + /** + * Request deletion of recordings + * + * Request: + * - Bytes 1-2: Number of bytes to clear (uint16_t) + * + * Response: + * - `BluetoothResponseType.success` + * - `BluetoothResponseType.invalidNumberOfBytesToDelete`, if the number of bytes does not match. + * This may happen when a new temperature recording is performed in between calls + */ + case clearRecordingBuffer = 2 +} diff --git a/TempTrack/Connection/DeviceConnection.swift b/TempTrack/Connection/DeviceConnection.swift new file mode 100644 index 0000000..3526278 --- /dev/null +++ b/TempTrack/Connection/DeviceConnection.swift @@ -0,0 +1,334 @@ +import Foundation +import CoreBluetooth + +final class 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 + guard let requestContinuation = self?.requestContinuation else { return } + log.info("Timed out for request \(request.type)") + requestContinuation.resume(returning: nil) + self?.requestContinuation = nil + } + } + + 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) + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + guard shouldConnectIfPossible else { + return + } + peripheral.delegate = self + manager.connect(peripheral) + manager.stopScan() + state = .connecting(device: peripheral) + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + 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 \(central.state)") + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + log.info("Connected to " + peripheral.name!) + peripheral.discoverServices([DeviceManager.serviceUUID]) + state = .discoveringServices(device: peripheral) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + log.info("Disconnected from " + peripheral.name!) + state = .disconnected + // Attempt to reconnect + if shouldConnectIfPossible { + initiateDeviceConnection() + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect 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 { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + 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) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, 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) + } + } + + 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)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + if let error = error { + log.warning("Failed to get RSSI: \(error)") + return + } + lastRSSI = RSSI.intValue + log.info("RSSI: \(lastRSSI)") + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, 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 + } +} diff --git a/TempTrack/Connection/DeviceDataRequest.swift b/TempTrack/Connection/DeviceDataRequest.swift new file mode 100644 index 0000000..6ce2430 --- /dev/null +++ b/TempTrack/Connection/DeviceDataRequest.swift @@ -0,0 +1,39 @@ +import Foundation + +struct DeviceDataRequest: DeviceRequest { + + typealias Response = Data + + static let type: BluetoothRequestType = .getRecordingData + + let offset: Int + + let count: Int + + var payload: Data { + count.twoByteData + offset.twoByteData + } + + init(offset: Int, count: Int) { + self.offset = offset + self.count = count + } + + func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> Data? { + switch responseType { + case .success: + break + case .responseTooLarge: + log.warning("Exceeded payload size for device data request (offset: \(offset), count: \(count))") + break + default: + log.warning("Invalid response \(responseType) to device data request") + return nil + } + guard responseData.count == count else { + log.warning("Got \(responseData.count) for device data request (offset: \(offset), count: \(count))") + return nil + } + return responseData + } +} diff --git a/TempTrack/Connection/DeviceDataResetRequest.swift b/TempTrack/Connection/DeviceDataResetRequest.swift new file mode 100644 index 0000000..0122a8f --- /dev/null +++ b/TempTrack/Connection/DeviceDataResetRequest.swift @@ -0,0 +1,32 @@ +import Foundation + +struct DeviceDataResetRequest: DeviceRequest { + + typealias Response = Void + + static var type: BluetoothRequestType { .clearRecordingBuffer } + + var payload: Data { + byteCount.twoByteData + } + + let byteCount: Int + + init(byteCount: Int) { + self.byteCount = byteCount + } + + func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> Void? { + switch responseType { + case .success: + return () + case .invalidNumberOfBytesToDelete: + log.error("Device data reset failed: Invalid number of bytes") + return nil + default: + log.warning("Invalid response \(responseType) to device data reset request") + return nil + } + + } +} diff --git a/TempTrack/Connection/DeviceInfoRequest.swift b/TempTrack/Connection/DeviceInfoRequest.swift new file mode 100644 index 0000000..1fa5c9f --- /dev/null +++ b/TempTrack/Connection/DeviceInfoRequest.swift @@ -0,0 +1,23 @@ +import Foundation + +struct DeviceInfoRequest: DeviceRequest { + + typealias Response = DeviceInfo + + static let type: BluetoothRequestType = .getInfo + + let payload = Data() + + func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> DeviceInfo? { + guard responseType == .success else { + log.warning("Invalid response \(responseType) to device info request") + return nil + } + do { + return try .init(info: responseData) + } catch { + log.error("Failed to decode device info: \(error)") + return nil + } + } +} diff --git a/TempTrack/Connection/DeviceRequest.swift b/TempTrack/Connection/DeviceRequest.swift new file mode 100644 index 0000000..0e035e9 --- /dev/null +++ b/TempTrack/Connection/DeviceRequest.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol DeviceRequest { + + associatedtype Response + + static var type: BluetoothRequestType { get } + + var payload: Data { get } + + func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> Response? +} + +extension DeviceRequest { + + var type: BluetoothRequestType { Self.type } +}