diff --git a/TempTrack.xcodeproj/project.pbxproj b/TempTrack.xcodeproj/project.pbxproj index 6af3437..71900f1 100644 --- a/TempTrack.xcodeproj/project.pbxproj +++ b/TempTrack.xcodeproj/project.pbxproj @@ -23,18 +23,13 @@ 88CDE0512A2508E900114294 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0502A2508E900114294 /* ContentView.swift */; }; 88CDE0532A2508EA00114294 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0522A2508EA00114294 /* Assets.xcassets */; }; 88CDE0562A2508EA00114294 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0552A2508EA00114294 /* Preview Assets.xcassets */; }; - 88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE05C2A250F3C00114294 /* DeviceManager.swift */; }; - 88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE05E2A250F5200114294 /* DeviceState.swift */; }; - 88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0602A25108100114294 /* BluetoothClient.swift */; }; 88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */; }; 88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 88CDE0652A25D08F00114294 /* SFSafeSymbols */; }; 88CDE0682A2698B400114294 /* PersistentStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0672A2698B400114294 /* PersistentStorage.swift */; }; 88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE06C2A28A92000114294 /* DeviceInfo.swift */; }; 88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE06F2A28AEA300114294 /* TemperatureMeasurement.swift */; }; - 88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */; }; 88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0752A28AF0900114294 /* TemperatureValue.swift */; }; 88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */; }; - 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */; }; 88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */; }; E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */; }; E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */; }; @@ -45,7 +40,6 @@ E2A554012A3A6403005204C3 /* DeviceTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A554002A3A6403005204C3 /* DeviceTime.swift */; }; E2A554052A4ADA93005204C3 /* TransferView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A554042A4ADA93005204C3 /* TransferView.swift */; }; E2A554072A4ADB9C005204C3 /* TemperatureDataTransferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A554062A4ADB9C005204C3 /* TemperatureDataTransferDelegate.swift */; }; - E2A554092A4ADCC9005204C3 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A554082A4ADCC9005204C3 /* DeviceConnection.swift */; }; E2A5540C2A4ADFC6005204C3 /* DeviceInfoRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A5540B2A4ADFC6005204C3 /* DeviceInfoRequest.swift */; }; E2A5540E2A4C9C4C005204C3 /* BluetoothRequestType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A5540D2A4C9C4C005204C3 /* BluetoothRequestType.swift */; }; E2A554102A4C9C68005204C3 /* DeviceRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A5540F2A4C9C68005204C3 /* DeviceRequest.swift */; }; @@ -54,6 +48,7 @@ E2E69B602A4CD48F00C6035E /* IconAndTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E69B5F2A4CD48F00C6035E /* IconAndTextView.swift */; }; E2E69B622A4D7C3100C6035E /* BluetoothScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E69B612A4D7C3100C6035E /* BluetoothScanner.swift */; }; E2E69B662A4DA48B00C6035E /* BluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E69B652A4DA48B00C6035E /* BluetoothDevice.swift */; }; + E2E69B682A529FA800C6035E /* TransferHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E69B672A529FA800C6035E /* TransferHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -73,17 +68,12 @@ 88CDE0502A2508E900114294 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 88CDE0522A2508EA00114294 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 88CDE0552A2508EA00114294 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 88CDE05C2A250F3C00114294 /* DeviceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceManager.swift; sourceTree = ""; }; - 88CDE05E2A250F5200114294 /* DeviceState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceState.swift; sourceTree = ""; }; - 88CDE0602A25108100114294 /* BluetoothClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothClient.swift; sourceTree = ""; }; 88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDataTransfer.swift; sourceTree = ""; }; 88CDE0672A2698B400114294 /* PersistentStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentStorage.swift; sourceTree = ""; }; 88CDE06C2A28A92000114294 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 88CDE06F2A28AEA300114294 /* TemperatureMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMeasurement.swift; sourceTree = ""; }; - 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagerDelegate.swift; sourceTree = ""; }; 88CDE0752A28AF0900114294 /* TemperatureValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureValue.swift; sourceTree = ""; }; 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSensor.swift; sourceTree = ""; }; - 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequest.swift; sourceTree = ""; }; 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoView.swift; sourceTree = ""; }; E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureHistoryChart.swift; sourceTree = ""; }; @@ -94,7 +84,6 @@ E2A554002A3A6403005204C3 /* DeviceTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTime.swift; sourceTree = ""; }; E2A554042A4ADA93005204C3 /* TransferView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferView.swift; sourceTree = ""; }; E2A554062A4ADB9C005204C3 /* TemperatureDataTransferDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDataTransferDelegate.swift; sourceTree = ""; }; - E2A554082A4ADCC9005204C3 /* DeviceConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnection.swift; sourceTree = ""; }; E2A5540B2A4ADFC6005204C3 /* DeviceInfoRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoRequest.swift; sourceTree = ""; }; E2A5540D2A4C9C4C005204C3 /* BluetoothRequestType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequestType.swift; sourceTree = ""; }; E2A5540F2A4C9C68005204C3 /* DeviceRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRequest.swift; sourceTree = ""; }; @@ -103,6 +92,7 @@ E2E69B5F2A4CD48F00C6035E /* IconAndTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconAndTextView.swift; sourceTree = ""; }; E2E69B612A4D7C3100C6035E /* BluetoothScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothScanner.swift; sourceTree = ""; }; E2E69B652A4DA48B00C6035E /* BluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDevice.swift; sourceTree = ""; }; + E2E69B672A529FA800C6035E /* TransferHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferHandler.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -185,12 +175,7 @@ 88CDE0792A28AF3E00114294 /* Bluetooth */ = { isa = PBXGroup; children = ( - 88CDE0602A25108100114294 /* BluetoothClient.swift */, - 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */, 88404DE02A31CA6B00D30244 /* BluetoothResponseType.swift */, - 88CDE05C2A250F3C00114294 /* DeviceManager.swift */, - 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */, - 88CDE05E2A250F5200114294 /* DeviceState.swift */, 88CDE06C2A28A92000114294 /* DeviceInfo.swift */, E2A554002A3A6403005204C3 /* DeviceTime.swift */, 88404DEA2A37BE3000D30244 /* DeviceWakeCause.swift */, @@ -230,7 +215,6 @@ E2A5540A2A4ADD1D005204C3 /* Connection */ = { isa = PBXGroup; children = ( - E2A554082A4ADCC9005204C3 /* DeviceConnection.swift */, E2A5540D2A4C9C4C005204C3 /* BluetoothRequestType.swift */, E2A5540F2A4C9C68005204C3 /* DeviceRequest.swift */, E2A5540B2A4ADFC6005204C3 /* DeviceInfoRequest.swift */, @@ -238,6 +222,7 @@ E2A554152A4C9D2E005204C3 /* DeviceDataResetRequest.swift */, E2E69B612A4D7C3100C6035E /* BluetoothScanner.swift */, E2E69B652A4DA48B00C6035E /* BluetoothDevice.swift */, + E2E69B672A529FA800C6035E /* TransferHandler.swift */, ); path = Connection; sourceTree = ""; @@ -323,13 +308,10 @@ 88404DDB2A2F4DCA00D30244 /* MeasurementDailyCount.swift in Sources */, E2A554142A4C9C96005204C3 /* DeviceDataRequest.swift in Sources */, 88CDE0512A2508E900114294 /* ContentView.swift in Sources */, - 88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */, 88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */, - E2A554092A4ADCC9005204C3 /* DeviceConnection.swift in Sources */, 88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */, E2A554162A4C9D2E005204C3 /* DeviceDataResetRequest.swift in Sources */, 88404DE12A31CA6B00D30244 /* BluetoothResponseType.swift in Sources */, - 88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */, 88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */, E2E69B602A4CD48F00C6035E /* IconAndTextView.swift in Sources */, E2A554052A4ADA93005204C3 /* TransferView.swift in Sources */, @@ -344,7 +326,6 @@ E2E69B622A4D7C3100C6035E /* BluetoothScanner.swift in Sources */, 88404DE32A31F20E00D30244 /* Int+Extensions.swift in Sources */, E2A5540C2A4ADFC6005204C3 /* DeviceInfoRequest.swift in Sources */, - 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */, E2E69B662A4DA48B00C6035E /* BluetoothDevice.swift in Sources */, E2A553FD2A39C86B005204C3 /* LogEntry.swift in Sources */, E2A553F92A399F58005204C3 /* Log.swift in Sources */, @@ -353,9 +334,8 @@ 88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */, E2A554102A4C9C68005204C3 /* DeviceRequest.swift in Sources */, E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */, - 88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */, E2A554072A4ADB9C005204C3 /* TemperatureDataTransferDelegate.swift in Sources */, - 88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */, + E2E69B682A529FA800C6035E /* TransferHandler.swift in Sources */, 88404DEB2A37BE3000D30244 /* DeviceWakeCause.swift in Sources */, 88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */, E2A553FB2A39C82D005204C3 /* LogView.swift in Sources */, diff --git a/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index ba712d4..5e3d2c5 100644 Binary files a/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/TempTrack/Bluetooth/BluetoothClient.swift b/TempTrack/Bluetooth/BluetoothClient.swift deleted file mode 100644 index ab76111..0000000 --- a/TempTrack/Bluetooth/BluetoothClient.swift +++ /dev/null @@ -1,304 +0,0 @@ -import Foundation -import SwiftUI -/* -final class BluetoothClient: ObservableObject { - - private let updateInterval = 3.0 - - private let minimumOffsetToUpdateDeviceClock = 5.0 - - private let connection = DeviceManager() - - private let storage: PersistentStorage - - var hasInfo: Bool { - deviceInfo != nil - } - - var isConnected: Bool { - if case .configured = deviceState { - return true - } - return false - } - - private var isUpdatingFlag = false - - @Published - var shouldConnect: Bool { - didSet { - isUpdatingFlag = true - connection.shouldConnectIfPossible = shouldConnect - log.info("Should connect: \(shouldConnect)") - isUpdatingFlag = false - } - } - - init(storage: PersistentStorage, shouldConnect: Bool = false, deviceInfo: DeviceInfo? = nil) { - self.storage = storage - self.deviceInfo = deviceInfo - self.shouldConnect = shouldConnect - connection.shouldConnectIfPossible = shouldConnect - connection.delegate = self - } - - func connect() -> Bool { - connection.connect() - } - - @Published - private(set) var deviceState: DeviceState = .disconnected { - didSet { - log.info("State: \(deviceState)") - if case .configured = deviceState { - startRegularUpdates() - } else { - endRegularUpdates() - } - } - } - - @Published - private(set) var deviceInfo: DeviceInfo? { - didSet { - // collectRecordedData() - if let deviceInfo, let runningTransfer { - runningTransfer.update(info: deviceInfo) - let next = runningTransfer.nextRequest() - addRequest(next) - } - } - } - - private var openRequests: [BluetoothRequest] = [] - - private var runningRequest: BluetoothRequest? - - private var runningTransfer: TemperatureDataTransfer? - - // MARK: Regular updates - - func updateDeviceInfo() { - guard case .configured = deviceState else { - return - } - addRequest(.getInfo) - - } - - private var dataUpdateTimer: Timer? - - private func startRegularUpdates() { - guard dataUpdateTimer == nil else { - return - } - log.info("Starting updates") - dataUpdateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] timer in - guard let self = self else { - timer.invalidate() - return - } - self.updateDeviceInfo() - } - - dataUpdateTimer?.fire() - } - - private func endRegularUpdates() { - guard let dataUpdateTimer else { - return - } - dataUpdateTimer.invalidate() - runningRequest = nil - self.dataUpdateTimer = nil - log.info("Ending updates") - } - - // MARK: Requests - - func clearDeviceStorage() { - guard let count = deviceInfo?.numberOfRecordedBytes else { - log.info("Can't clear device data without device info") - return - } - addRequest(.clearRecordingBuffer(byteCount: count)) - } - - private func performNextRequest() { - guard runningRequest == nil else { - return - } - guard !openRequests.isEmpty else { - return - } - let next = openRequests.removeFirst() - - guard connection.send(next.serialized) else { - log.warning("Failed to start request \(next)") - performNextRequest() - return - } - runningRequest = next - } - - func addRequest(_ request: BluetoothRequest) { - defer { - performNextRequest() - } - let type = request.byte - if let runningRequest, runningRequest.byte == type { - log.info("Skipping duplicate request \(request)") - return - } - guard !openRequests.contains(where: { $0.byte == type }) else { - log.info("Skipping duplicate request \(request)") - return - } - openRequests.append(request) - } - - // MARK: Data transfer - - @discardableResult - func collectRecordedData() -> Bool { - guard runningTransfer == nil else { - log.info("Transfer already running") - return false - } - guard !openRequests.contains(where: { if case .getRecordingData = $0 { return true }; return false }) else { - log.info("Transfer already scheduled") - return false - } - guard let info = deviceInfo else { - log.warning("No device info to start transfer") - return false - } - guard info.numberOfStoredMeasurements > 0 else { - return false - } - let transfer = TemperatureDataTransfer(info: info, previous: storage.lastDeviceTime) - runningTransfer = transfer - let next = transfer.nextRequest() - log.info("Starting transfer") - addRequest(next) - return true - } - - private func didReceive(data: Data, offset: Int, count: Int) { - guard let runningTransfer else { - log.warning("No running transfer to process device data") - self.runningRequest = nil - return // TODO: Start new transfer? - } - guard runningTransfer.add(data: data, offset: offset, count: count) else { - self.runningRequest = nil - return // TODO: Start new transfer - } - let next = runningTransfer.nextRequest() - addRequest(next) - } - - private func decode(info: Data) { - guard let newInfo = try? DeviceInfo(info: info) else { - log.error("Failed to decode device info") - return - } - self.deviceInfo = newInfo - } -} - -extension BluetoothClient: DeviceManagerDelegate { - - func deviceManager(shouldConnectToDevice: Bool) { - guard !isUpdatingFlag else { - return - } - self.shouldConnect = shouldConnectToDevice - } - - func deviceManager(didReceive data: Data) { - defer { - performNextRequest() - } - guard let runningRequest else { - log.warning("No request active, but \(data) received") - return - } - self.runningRequest = nil - - guard data.count > 0 else { - log.error("No response data for request \(runningRequest)") - return - } - - guard let type = BluetoothResponseType(rawValue: data[0]) else { - log.error("Unknown response \(data[0]) for request \(runningRequest)") - return - } - switch type { - case .success: - break - case .responseInProgress: - log.info("Device is busy for \(runningRequest)") - // Retry the request - addRequest(runningRequest) - return - case .invalidNumberOfBytesToDelete: - guard case .clearRecordingBuffer = runningRequest else { - log.error("Request \(runningRequest) received non-matching response about number of bytes to delete") - return - } - // If clearing the recording buffer fails due to byte mismatch, - // then requesting new info will resolve the mismatch, and the transfer will be resumed - addRequest(.getInfo) - - case .responseTooLarge: - guard case .getRecordingData = runningRequest else { - log.error("Unexpectedly exceeded payload size for request \(runningRequest)") - return - } - // If requesting bytes fails due to the response size, - // then requesting new info will update the response size, and the transfer will be resumed - addRequest(.getInfo) - default: - log.error("Unknown response \(data[0]) for request \(runningRequest)") - // If clearing the recording buffer fails due to byte mismatch, - // then requesting new info will resolve the mismatch, and the transfer will be resumed - - addRequest(.getInfo) - return - - } - let payload = data.dropFirst() - - switch runningRequest { - case .getInfo: - decode(info: payload) - case .getRecordingData(let offset, let count): - didReceive(data: payload, offset: offset, count: count) - case .clearRecordingBuffer: - didClearDeviceStorage() - } - } - - private func didClearDeviceStorage() { - guard let runningTransfer else { - log.warning("No running transfer after clearing device storage") - return - } - defer { self.runningTransfer = nil } - guard runningTransfer.completeTransfer() else { - return - } - storage.add(runningTransfer.measurements) - storage.lastDeviceTime = runningTransfer.time - } - - func deviceManager(didChangeState state: DeviceState) { - DispatchQueue.main.async { - self.deviceState = state - } - } -} - -*/ diff --git a/TempTrack/Bluetooth/BluetoothRequest.swift b/TempTrack/Bluetooth/BluetoothRequest.swift deleted file mode 100644 index 7a522d5..0000000 --- a/TempTrack/Bluetooth/BluetoothRequest.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -/* -enum BluetoothRequest { - /** - * 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 - - /** - * 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(offset: Int, count: Int) - - /** - * 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(byteCount: Int) - - var serialized: Data { - let firstByte = Data([byte]) - switch self { - case .getInfo: - return firstByte - case .getRecordingData(let offset, let count): - return firstByte + count.twoByteData + offset.twoByteData - case .clearRecordingBuffer(let byteCount): - return firstByte + byteCount.twoByteData - } - } - - var byte: UInt8 { - switch self { - case .getInfo: return 0 - case .getRecordingData: return 1 - case .clearRecordingBuffer: return 2 - } - } -} -*/ diff --git a/TempTrack/Bluetooth/DeviceInfo.swift b/TempTrack/Bluetooth/DeviceInfo.swift index 2cae653..045bab6 100644 --- a/TempTrack/Bluetooth/DeviceInfo.swift +++ b/TempTrack/Bluetooth/DeviceInfo.swift @@ -2,15 +2,15 @@ import Foundation struct DeviceInfo { - /** - The maximum factor by which the device clock can run - */ - private let maximumTimeDilationFactor: Double = 0.01 - + /// The unique ID generated by the device to distinguish between power cycles + let uniqueIdOfPowerCycle: Int /// The number of bytes recorded by the tracker let numberOfRecordedBytes: Int - + + /// The sum of all recorded bytes + let dataChecksum: UInt16 + /// The number of measurements already performed let numberOfStoredMeasurements: Int @@ -46,54 +46,15 @@ struct DeviceInfo { time.nextMeasurement.addingTimeInterval(-Double(numberOfStoredMeasurements * measurementInterval)) } - func estimatedTimeDilation(to previous: DeviceTime?) -> (start: Date, dilation: Double) { - let trivialResult = (start: currentMeasurementStartTime, dilation: 1.0) - guard let previous else { - log.info("No previous device time to compare") - return trivialResult - } - // Check if device was restarted in between - guard time.secondsSincePowerOn >= previous.secondsSincePowerOn else { - log.info("Device restarted (runtime decreased from \(previous.secondsSincePowerOn) to \(time.secondsSincePowerOn))") - return trivialResult - } - let newMeasurementCount = time.totalNumberOfMeasurements - previous.totalNumberOfMeasurements - guard newMeasurementCount >= 0 else { - log.info("Device restarted (measurements decreased from \(previous.totalNumberOfMeasurements) to \(time.totalNumberOfMeasurements))") - return trivialResult - } - guard newMeasurementCount > 0 else { - log.warning("No new measurements to calculate time difference") - return trivialResult - } - - // Check that no measurements are missing - - // Calculate the difference between the expected time for the next measurement and the device time - let deviceTimeDifference = Double(newMeasurementCount * measurementInterval) - let expectedNextMeasurement = previous.nextMeasurement.addingTimeInterval(deviceTimeDifference) - let timeDifference = time.nextMeasurement.timeIntervalSince(expectedNextMeasurement) - - let realTimeDifference = time.nextMeasurement.timeIntervalSince(previous.nextMeasurement) - let timeDilation = realTimeDifference / deviceTimeDifference - - log.info("Device time dilation \(timeDilation) (difference \(timeDifference))") - - guard abs(timeDilation - 1.0) < maximumTimeDilationFactor else { - log.warning("Device time too different from expected value (difference \(timeDifference) s)") - return (currentMeasurementStartTime, 1.0) - } - return (previous.nextMeasurement, timeDilation) - } } extension DeviceInfo { init(info: Data) throws { let date = Date() - var data = info - + + self.uniqueIdOfPowerCycle = try data.decodeFourByteInteger() self.numberOfRecordedBytes = try data.decodeTwoByteInteger() let secondsUntilNextMeasurement = try data.decodeTwoByteInteger() self.measurementInterval = try data.decodeTwoByteInteger() @@ -102,16 +63,24 @@ extension DeviceInfo { self.transferBlockSize = try data.decodeTwoByteInteger() self.storageSize = try data.decodeTwoByteInteger() let secondsSincePowerOn = try data.decodeFourByteInteger() + let startSecondsOfCurrentRecording = try data.decodeFourByteInteger() + self.dataChecksum = try data.decodeUInt16() + self.wakeupReason = .init(rawValue: try data.getByte()) ?? .WAKEUP_UNDEFINED + + self.sensor0 = try data.decodeSensor() + self.sensor1 = try data.decodeSensor() + + guard data.isEmpty else { + log.error("\(data.count) bytes remaining in device info buffer") + throw DeviceInfoError.missingData + } self.time = .init( date: date, secondsSincePowerOn: secondsSincePowerOn, totalNumberOfMeasurements: totalNumberOfMeasurements, - secondsUntilNextMeasurement: secondsUntilNextMeasurement) - let _ = try data.decodeFourByteInteger() - self.sensor0 = try data.decodeSensor() - self.sensor1 = try data.decodeSensor() - self.wakeupReason = .init(rawValue: try data.getByte()) ?? .WAKEUP_UNDEFINED + secondsUntilNextMeasurement: secondsUntilNextMeasurement, + secondsOfFirstMeasurement: startSecondsOfCurrentRecording) } } @@ -119,7 +88,9 @@ extension DeviceInfo { static var mock: DeviceInfo { .init( + uniqueIdOfPowerCycle: .random(in: 0...Int(UInt32.max)), numberOfRecordedBytes: 123, + dataChecksum: .random(in: .min...UInt16.max), numberOfStoredMeasurements: 234, measurementInterval: 60, sensor0: .init(address: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], value: .value(21.0), date: .now.addingTimeInterval(-2)), @@ -130,3 +101,7 @@ extension DeviceInfo { transferBlockSize: 180) } } + +extension DeviceInfo: Codable { + +} diff --git a/TempTrack/Bluetooth/DeviceManager.swift b/TempTrack/Bluetooth/DeviceManager.swift deleted file mode 100644 index 25304be..0000000 --- a/TempTrack/Bluetooth/DeviceManager.swift +++ /dev/null @@ -1,258 +0,0 @@ -import Foundation -import CoreBluetooth - -final class DeviceManager: NSObject, CBCentralManagerDelegate { - - 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 - - weak var delegate: DeviceManagerDelegate? - - var state: DeviceState = .disconnected { - didSet { - delegate?.deviceManager(didChangeState: state) - } - } - - override init() { - super.init() - self.manager = CBCentralManager(delegate: self, queue: nil) - } - - @discardableResult - func connect() -> 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 - } - if !shouldConnectIfPossible { - shouldConnectIfPossible = true - } - state = .scanning - manager.scanForPeripherals(withServices: [DeviceManager.serviceUUID]) - return true - } - - var shouldConnectIfPossible = true { - didSet { - updateConnectionOnChange() - delegate?.deviceManager(shouldConnectToDevice: shouldConnectIfPossible) - } - } - - private func updateConnectionOnChange() { - if shouldConnectIfPossible { - ensureConnection() - } else { - disconnectIfNeeded() - } - } - - private func ensureConnection() { - switch state { - case .disconnected: - connect() - default: - return - } - } - - private func disconnectIfNeeded() { - switch state { - case .bluetoothDisabled, .disconnected: - return - default: - disconnect() - } - } - - func disconnect() { - if shouldConnectIfPossible { - 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 - } - } - - @discardableResult - func send(_ data: Data) -> Bool { - guard case .configured(let device, let characteristic) = state else { - return false - } - device.writeValue(data, for: characteristic, type: .withResponse) - return self.read() - } - - @discardableResult - private func read() -> Bool { - guard case .configured(let device, let characteristic) = state else { - return false - } - device.readValue(for: characteristic) - return true - } - - 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 - connect() - 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 { - connect() - } - } - - 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 { - connect() - } - } -} - -extension DeviceManager: 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)") - return - } - guard case .configured(device: _, characteristic: let storedCharacteristic) = state else { - log.warning("Received data while not properly configured") - return - } - guard characteristic.uuid == storedCharacteristic.uuid else { - log.warning("Read unknown characteristic \(characteristic.uuid.uuidString)") - return - } - guard let data = characteristic.value else { - log.warning("No data") - return - } - delegate?.deviceManager(didReceive: data) - } -} diff --git a/TempTrack/Bluetooth/DeviceManagerDelegate.swift b/TempTrack/Bluetooth/DeviceManagerDelegate.swift deleted file mode 100644 index 4a0c40a..0000000 --- a/TempTrack/Bluetooth/DeviceManagerDelegate.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -protocol DeviceManagerDelegate: AnyObject { - - func deviceManager(shouldConnectToDevice: Bool) - - func deviceManager(didReceive data: Data) - - func deviceManager(didChangeState state: DeviceState) -} diff --git a/TempTrack/Bluetooth/DeviceState.swift b/TempTrack/Bluetooth/DeviceState.swift deleted file mode 100644 index fa16d0d..0000000 --- a/TempTrack/Bluetooth/DeviceState.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -import CoreBluetooth - -enum DeviceState { - - case bluetoothDisabled - - case scanning - - case connecting(device: CBPeripheral) - - case discoveringServices(device: CBPeripheral) - - case discoveringCharacteristic(device: CBPeripheral) - - case configured(device: CBPeripheral, characteristic: CBCharacteristic) - - case disconnected - - var text: String { - switch self { - case .bluetoothDisabled: - return "Bluetooth is disabled" - case .scanning: - return "Scanning..." - case .connecting(let device): - guard let name = device.name else { - return "Connecting..." - } - return "Connecting to \(name)..." - case .discoveringServices: - return "Discovering service..." - case .discoveringCharacteristic: - return "Discovering characteristic..." - case .configured(let device, _): - guard let name = device.name else { - return "Connected" - } - return name - case .disconnected: - return "Not connected" - } - } - - var device: CBPeripheral? { - switch self { - case .bluetoothDisabled, .disconnected, .scanning: - return nil - case .connecting(let device), - .discoveringCharacteristic(let device), - .discoveringServices(device: let device), - .configured(let device, _): - return device - } - } -} - -extension DeviceState: CustomStringConvertible { - - var description: String { - switch self { - case .bluetoothDisabled: - return "Bluetooth disabled" - case .scanning: - return "Searching for device" - case .connecting: - return "Connecting to device" - case .discoveringServices: - return "Discovering services" - case .discoveringCharacteristic: - return "Discovering characteristics" - case .configured: - return "Connected" - case .disconnected: - return "Disconnected" - } - } -} - -extension DeviceState: Equatable { - -} diff --git a/TempTrack/Bluetooth/DeviceTime.swift b/TempTrack/Bluetooth/DeviceTime.swift index febff22..fc4571e 100644 --- a/TempTrack/Bluetooth/DeviceTime.swift +++ b/TempTrack/Bluetooth/DeviceTime.swift @@ -10,6 +10,8 @@ struct DeviceTime { let secondsUntilNextMeasurement: Int + let secondsOfFirstMeasurement: Int + var nextMeasurement: Date { date.adding(seconds: secondsUntilNextMeasurement) } @@ -47,6 +49,7 @@ extension DeviceTime: Codable { self.secondsSincePowerOn = try container.decode(Int.self) self.totalNumberOfMeasurements = try container.decode(Int.self) self.secondsUntilNextMeasurement = try container.decode(Int.self) + self.secondsOfFirstMeasurement = try container.decode(Int.self) } func encode(to encoder: Encoder) throws { @@ -55,6 +58,7 @@ extension DeviceTime: Codable { try container.encode(secondsSincePowerOn) try container.encode(totalNumberOfMeasurements) try container.encode(secondsUntilNextMeasurement) + try container.encode(secondsOfFirstMeasurement) } } @@ -65,6 +69,7 @@ extension DeviceTime { date: .now, secondsSincePowerOn: 125, totalNumberOfMeasurements: 3, - secondsUntilNextMeasurement: 55) + secondsUntilNextMeasurement: 55, + secondsOfFirstMeasurement: 1) } } diff --git a/TempTrack/Bluetooth/DeviceWakeCause.swift b/TempTrack/Bluetooth/DeviceWakeCause.swift index 992206c..a8a22a1 100644 --- a/TempTrack/Bluetooth/DeviceWakeCause.swift +++ b/TempTrack/Bluetooth/DeviceWakeCause.swift @@ -75,3 +75,7 @@ extension DeviceWakeCause { } } } + +extension DeviceWakeCause: Codable { + +} diff --git a/TempTrack/Connection/BluetoothDevice.swift b/TempTrack/Connection/BluetoothDevice.swift index 22d104f..578f732 100644 --- a/TempTrack/Connection/BluetoothDevice.swift +++ b/TempTrack/Connection/BluetoothDevice.swift @@ -1,18 +1,29 @@ import Foundation import CoreBluetooth +protocol BluetoothDeviceDelegate: AnyObject { + + func bluetoothDevice(didUpdate info: DeviceInfo?) +} + actor BluetoothDevice: NSObject, ObservableObject { - private let peripheral: CBPeripheral! + let peripheral: CBPeripheral! private let characteristic: CBCharacteristic! - @MainActor @Published + @Published var lastDeviceInfo: DeviceInfo? @Published private(set) var lastRSSI: Int = 0 + weak var delegate: BluetoothDeviceDelegate? + + func set(delegate: BluetoothDeviceDelegate?) { + self.delegate = delegate + } + init(peripheral: CBPeripheral, characteristic: CBCharacteristic) { self.peripheral = peripheral self.characteristic = characteristic @@ -33,9 +44,10 @@ actor BluetoothDevice: NSObject, ObservableObject { guard let info = await getInfo() else { return } - Task { @MainActor in - lastDeviceInfo = info - } + lastDeviceInfo = info + delegate?.bluetoothDevice(didUpdate: info) + #warning("Don't use global variable") + storage.save(deviceInfo: info) } func getInfo() async -> DeviceInfo? { diff --git a/TempTrack/Connection/BluetoothScanner.swift b/TempTrack/Connection/BluetoothScanner.swift index 64306c9..ac9212d 100644 --- a/TempTrack/Connection/BluetoothScanner.swift +++ b/TempTrack/Connection/BluetoothScanner.swift @@ -26,33 +26,56 @@ final class BluetoothScanner: NSObject, CBCentralManagerDelegate, ObservableObje @Published var configuredDevice: BluetoothDevice? + @Published + var lastDeviceInfo: DeviceInfo? + private var connectingDevice: CBPeripheral? - var isScanningForDevices: Bool { - get { - manager.isScanning - } - set { - if newValue { - guard !manager.isScanning else { - return - } - manager.scanForPeripherals(withServices: [serviceUUID]) - log.info("Scanner: Started scanning for devices") + @Published + var isScanningForDevices: Bool = false { + didSet { + if isScanningForDevices { + startScanning() } else { - guard manager.isScanning else { - return - } - manager.stopScan() - log.info("Scanner: Stopped scanning for devices") + 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) { @@ -95,6 +118,7 @@ final class BluetoothScanner: NSObject, CBCentralManagerDelegate, ObservableObje peripheral.discoverServices([serviceUUID]) connectingDevice = peripheral configuredDevice = nil + isScanningForDevices = false } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { @@ -121,13 +145,15 @@ extension BluetoothScanner: CBPeripheralDelegate { manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil + isScanningForDevices = true return } - guard let service = services.first(where: { $0.uuid.uuidString == DeviceManager.serviceUUID.uuidString }) else { + 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 @@ -142,6 +168,7 @@ extension BluetoothScanner: CBPeripheralDelegate { manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil + isScanningForDevices = true return } @@ -150,6 +177,7 @@ extension BluetoothScanner: CBPeripheralDelegate { manager.cancelPeripheralConnection(peripheral) connectionState = .noDeviceFound connectingDevice = nil + isScanningForDevices = true return } @@ -168,9 +196,22 @@ extension BluetoothScanner: CBPeripheralDelegate { 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 + } } } diff --git a/TempTrack/Connection/DeviceConnection.swift b/TempTrack/Connection/DeviceConnection.swift deleted file mode 100644 index 90bba61..0000000 --- a/TempTrack/Connection/DeviceConnection.swift +++ /dev/null @@ -1,405 +0,0 @@ -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 - } -} -*/ diff --git a/TempTrack/Connection/TransferHandler.swift b/TempTrack/Connection/TransferHandler.swift new file mode 100644 index 0000000..341a7fb --- /dev/null +++ b/TempTrack/Connection/TransferHandler.swift @@ -0,0 +1,96 @@ +import Foundation + +final class TransferHandler: ObservableObject { + + @Published + var bytesTransferred: Double = 0.0 + + @Published + var totalBytes: Double = 0.0 + + @Published + var measurements: [TemperatureMeasurement] = [] + + @Published + var transferIsRunning = false + + func startTransfer(from device: BluetoothDevice, with info: DeviceInfo, storage: PersistentStorage) { + #warning("Update device info during transfer") + discardTransfer() + transferIsRunning = true + let total = info.numberOfRecordedBytes + let chunkSize = info.transferBlockSize + totalBytes = Double(total) + Task { + defer { + DispatchQueue.main.async { + self.transferIsRunning = false + } + } + var data = Data(capacity: total) + while data.count < total { + let remainingBytes = total - data.count + let currentChunkSize = min(remainingBytes, chunkSize) + guard let chunk = await device.getDeviceData(offset: data.count, count: currentChunkSize) else { + log.warning("Failed to finish transfer") + return + } + guard !chunk.isEmpty else { + break + } + data.append(chunk) + let count = Double(data.count) + DispatchQueue.main.async { + self.bytesTransferred = count + } + } + + if data.count != info.numberOfRecordedBytes { + log.warning("Expected \(info.numberOfRecordedBytes) in transfer, got only \(data.count)") + } + let sum = data.reduce(0) { $0 &+ UInt16($1) } + if sum != info.dataChecksum { + log.warning("Checksum does not match") + } + if data.count != 4 * info.numberOfStoredMeasurements { + log.warning("expected \(4 * info.numberOfStoredMeasurements) bytes for \(info.numberOfStoredMeasurements) measurements, got \(data.count)") + } + storage.saveTransferData(data: data, date: info.time.date) + + DispatchQueue.main.async { + self.bytesTransferred = self.totalBytes + } + + let recordingStart = info.currentMeasurementStartTime + while !data.isEmpty { + guard data.count >= 4 else { + log.error("Expected four bytes at index \(total - data.count - 1)") + break + } + let intervalCount = try! data.decodeUInt16() + let temp0 = TemperatureValue(byte: data.removeFirst()) + let temp1 = TemperatureValue(byte: data.removeFirst()) + let date = recordingStart + .addingTimeInterval(TimeInterval(intervalCount) * TimeInterval(info.measurementInterval)) + let measurement = TemperatureMeasurement( + sensor0: temp0, + sensor1: temp1, + date: date) + DispatchQueue.main.async { + self.measurements.append(measurement) + } + } + } + } + + func saveTransfer(in storage: PersistentStorage) { + storage.add(measurements) + discardTransfer() + } + + func discardTransfer() { + self.measurements = [] + self.bytesTransferred = 0 + self.totalBytes = 0 + } +} diff --git a/TempTrack/ContentView.swift b/TempTrack/ContentView.swift index 258334f..ba17acf 100644 --- a/TempTrack/ContentView.swift +++ b/TempTrack/ContentView.swift @@ -13,12 +13,15 @@ struct ContentView: View { private let disconnectedColor = Color(white: 0.8) - @StateObject - var scanner = BluetoothScanner() + @ObservedObject + var scanner: BluetoothScanner @EnvironmentObject var storage: PersistentStorage + @EnvironmentObject + var transfer: TransferHandler + @State var showDeviceInfo = false @@ -33,14 +36,12 @@ struct ContentView: View { @State var deviceInfoUpdateTimer: Timer? - - init() { } var averageTemperature: Double? { - guard let bluetoothDevice = scanner.configuredDevice else { + guard scanner.configuredDevice != nil else { return nil } - guard let info = bluetoothDevice.lastDeviceInfo else { + guard let info = scanner.lastDeviceInfo else { return nil } let t1 = info.sensor1?.optionalValue @@ -123,7 +124,7 @@ struct ContentView: View { } var hasNoDeviceInfo: Bool { - scanner.configuredDevice?.lastDeviceInfo == nil + scanner.lastDeviceInfo == nil } var isDisconnected: Bool { @@ -155,6 +156,7 @@ struct ContentView: View { .cornerRadius(8) if storage.recentMeasurements.isEmpty { Text("No recent measurements") + .foregroundColor(.white) } } } @@ -167,20 +169,26 @@ struct ContentView: View { } Spacer() Button { - self.scanner.isScanningForDevices.toggle() + if scanner.isScanningForDevices { + scanner.isScanningForDevices = false + } else if scanner.isConnectingOrConnected { + scanner.disconnect() + } else { + scanner.isScanningForDevices = true + } } label: { Image(systemSymbol: connectionSymbol) .foregroundColor(.white) } .foregroundColor(.white) Spacer() - if let device = scanner.configuredDevice { + if scanner.lastDeviceInfo != nil { Button { self.showDeviceInfo = true } label: { Image(systemSymbol: .infoCircle) - .foregroundColor(device.lastDeviceInfo == nil ? .gray : .white) - }.disabled(device.lastDeviceInfo == nil) + .foregroundColor(.white) + } Spacer() Button { showDataTransferView = true @@ -195,8 +203,6 @@ struct ContentView: View { Image(systemSymbol: .arrowUpArrowDownCircle) .foregroundColor(.gray) } - - } .padding() .font(.system(size: 30, weight: .light)) @@ -204,7 +210,7 @@ struct ContentView: View { } .padding() .sheet(isPresented: $showDeviceInfo) { - if let info = scanner.configuredDevice?.lastDeviceInfo { + if let info = scanner.lastDeviceInfo { DeviceInfoView(info: info, isPresented: $showDeviceInfo) } else { EmptyView() @@ -217,12 +223,14 @@ struct ContentView: View { .sheet(isPresented: $showLog) { LogView() .environmentObject(log) + .environmentObject(storage) } .sheet(isPresented: $showDataTransferView) { if let client = scanner.configuredDevice { TransferView( - bluetoothClient: client) + bluetoothClient: client, info: $scanner.lastDeviceInfo) .environmentObject(storage) + .environmentObject(transfer) } else { EmptyView() } @@ -263,7 +271,7 @@ struct ContentView: View { struct ContentView_Previews: PreviewProvider { static var previews: some View { let storage = PersistentStorage(lastMeasurements: TemperatureMeasurement.mockData) - ContentView() + ContentView(scanner: .init()) .environmentObject(storage) } } diff --git a/TempTrack/Extensions/UInt16+Extensions.swift b/TempTrack/Extensions/UInt16+Extensions.swift index 457b306..ba61875 100644 --- a/TempTrack/Extensions/UInt16+Extensions.swift +++ b/TempTrack/Extensions/UInt16+Extensions.swift @@ -7,11 +7,11 @@ extension UInt16 { } var low: UInt8 { - UInt8(clamping: self) + UInt8(self & 0xFF) } var high: UInt8 { - UInt8(clamping: self >> 8) + UInt8(self >> 8 & 0xFF) } var integer: Int { diff --git a/TempTrack/Storage/PersistentStorage.swift b/TempTrack/Storage/PersistentStorage.swift index e2eeae4..c71602c 100644 --- a/TempTrack/Storage/PersistentStorage.swift +++ b/TempTrack/Storage/PersistentStorage.swift @@ -15,9 +15,6 @@ final class PersistentStorage: ObservableObject { @AppStorage("newestDate") private var newestMeasurementTime: Int = 0 - @AppStorage("deviceTime") - private var lastDeviceTimeData: Data? - /** The date of the latest measurement. @@ -71,6 +68,8 @@ final class PersistentStorage: ObservableObject { ensureExistenceOfFolder() //recalculateDailyCounts() + updateTransferCount() + updateDeviceInfoCount() } private func ensureExistenceOfFolder() { @@ -121,7 +120,7 @@ final class PersistentStorage: ObservableObject { private func updateLastMeasurements(_ measurements: [TemperatureMeasurement]) { let startDate = Date().addingTimeInterval(-lastValueInterval).seconds recentMeasurements = (measurements + recentMeasurements) - .filter { $0.id > startDate } + .filter { $0.id > startDate }.sorted().reversed() log.info("\(recentMeasurements.count) recent measurements (with \(measurements.count) new entries)") } @@ -278,35 +277,78 @@ final class PersistentStorage: ObservableObject { } } - // MARK: Device time + // MARK: Device info archive - var lastDeviceTime: DeviceTime? { - get { - guard let data = lastDeviceTimeData else { - return nil - } + @Published + var numberOfStoredDeviceInfos: Int = 0 + + private func updateDeviceInfoCount() { + let count = countFiles(in: "info") + DispatchQueue.main.async { + self.numberOfStoredDeviceInfos = count + } + } + + @Published + var numberOfStoredTransfers: Int = 0 + + private func updateTransferCount() { + let count = countFiles(in: "transfers") + DispatchQueue.main.async { + self.numberOfStoredTransfers = count + } + } + + private func countFiles(in folder: String) -> Int { + let folder = PersistentStorage.documentDirectory.appendingPathComponent(folder) + guard fm.fileExists(atPath: folder.path) else { + return 0 + } + do { + return try fm.contentsOfDirectory(atPath: folder.path).count + } catch { + log.error("Failed to count files in '\(folder)': \(error)") + return -1 + } + } + + private func save(data: Data, date: Date, in folderName: String) -> Bool { + let folder = PersistentStorage.documentDirectory.appendingPathComponent(folderName) + if !fm.fileExists(atPath: folder.path) { do { - let result: DeviceTime = try BinaryDecoder.decode(from: data) - return result + try fm.createDirectory(at: folder, withIntermediateDirectories: false) } catch { - log.error("Failed to decode device time: \(error)") - lastDeviceTimeData = nil - return nil + log.error("Failed to create folder '\(folderName)': \(error)") + return false } } - set { - guard let newValue else { - lastDeviceTimeData = nil - return - } - do { - let data = try BinaryEncoder.encode(newValue) - lastDeviceTimeData = data - } catch { - log.error("Failed to encode device time: \(error)") - lastDeviceTimeData = nil - } + let url = folder.appendingPathComponent("\(date.seconds)") + do { + try data.write(to: url) + } catch { + log.error("Failed to write '\(url.lastPathComponent)' in '\(folder)': \(error)") + return false } + return true + } + + @discardableResult + func save(deviceInfo: DeviceInfo) -> Bool { + defer { updateDeviceInfoCount() } + let data: Data + do { + data = try BinaryEncoder.encode(deviceInfo) + } catch { + log.error("Failed to encode device info for storage: \(error)") + return false + } + return save(data: data, date: deviceInfo.time.date, in: "info") + } + + @discardableResult + func saveTransferData(data: Data, date: Date) -> Bool { + defer { updateTransferCount() } + return save(data: data, date: date, in: "transfers") } } diff --git a/TempTrack/TempTrackApp.swift b/TempTrack/TempTrackApp.swift index 8e985e9..8bef630 100644 --- a/TempTrack/TempTrackApp.swift +++ b/TempTrack/TempTrackApp.swift @@ -1,14 +1,19 @@ import SwiftUI -private let storage = PersistentStorage() +let storage = PersistentStorage() + +private let scanner = BluetoothScanner() + +private let transfer = TransferHandler() @main struct TempTrackApp: App { var body: some Scene { WindowGroup { - ContentView() + ContentView(scanner: scanner) .environmentObject(storage) + .environmentObject(transfer) } } } diff --git a/TempTrack/Temperature/TemperatureSensor.swift b/TempTrack/Temperature/TemperatureSensor.swift index d229a85..7a5d0bb 100644 --- a/TempTrack/Temperature/TemperatureSensor.swift +++ b/TempTrack/Temperature/TemperatureSensor.swift @@ -74,3 +74,7 @@ extension Data { return .init(address: address, valueByte: temperatureByte, secondsAgo: time) } } + +extension TemperatureSensor: Codable { + +} diff --git a/TempTrack/Views/DeviceInfoView.swift b/TempTrack/Views/DeviceInfoView.swift index fa60aab..e48183f 100644 --- a/TempTrack/Views/DeviceInfoView.swift +++ b/TempTrack/Views/DeviceInfoView.swift @@ -45,7 +45,7 @@ struct DeviceInfoView: View { } private var nextUpdateText: String { - let secs = info.time.nextMeasurement.secondsToNow + let secs = -info.time.nextMeasurement.secondsToNow guard secs > 1 else { return "Now" } @@ -101,6 +101,9 @@ struct DeviceInfoView: View { IconAndTextView( icon: .power, text: "\(df.string(from: info.time.deviceStartTime)) (\(runTimeString))") + IconAndTextView( + icon: .touchid, + text: "\(info.uniqueIdOfPowerCycle)") IconAndTextView( icon: .autostartstop, text: "Wakeup: \(info.wakeupReason.text)") @@ -124,6 +127,10 @@ struct DeviceInfoView: View { IconAndTextView( icon: .iphoneAndArrowForward, text: "\(info.transferBlockSize) Byte Block Size") + IconAndTextView( + icon: .externaldriveBadgeCheckmark, + text: String(format: "0x%02X 0x%02X", + UInt8(info.dataChecksum >> 8), UInt8( info.dataChecksum & 0xFF))) } sensorView(info.sensor0, id: 0) sensorView(info.sensor1, id: 1) diff --git a/TempTrack/Views/LogView.swift b/TempTrack/Views/LogView.swift index b52ce68..12d2409 100644 --- a/TempTrack/Views/LogView.swift +++ b/TempTrack/Views/LogView.swift @@ -12,16 +12,27 @@ struct LogView: View { @EnvironmentObject var log: Log + @EnvironmentObject + var storage: PersistentStorage + var body: some View { NavigationView { - List(log.logEntries) { entry in - VStack(alignment: .leading) { - HStack { - Text(entry.level.description) - Spacer() - Text(df.string(from: entry.date)) - }.font(.footnote) - Text(entry.message) + List { + Text("\(storage.numberOfStoredDeviceInfos) device infos") + .font(.body) + .foregroundColor(.secondary) + Text("\(storage.numberOfStoredTransfers) transfers") + .font(.body) + .foregroundColor(.secondary) + ForEach(log.logEntries) { entry in + VStack(alignment: .leading) { + HStack { + Text(entry.level.description) + Spacer() + Text(df.string(from: entry.date)) + }.font(.footnote) + Text(entry.message) + } } } .navigationTitle("Log") diff --git a/TempTrack/Views/TransferView.swift b/TempTrack/Views/TransferView.swift index 6f6d3d8..2609579 100644 --- a/TempTrack/Views/TransferView.swift +++ b/TempTrack/Views/TransferView.swift @@ -8,24 +8,17 @@ struct TransferView: View { let bluetoothClient: BluetoothDevice + @Binding + var info: DeviceInfo? + @EnvironmentObject var storage: PersistentStorage - @State - var bytesTransferred: Double = 0.0 - - @State - var totalBytes: Double = 0.0 - - @State - var measurements: [TemperatureMeasurement] = [] - - @State - var transferIsRunning = false - + @EnvironmentObject + var transfer: TransferHandler private var storageIcon: SFSymbol { - guard let info = bluetoothClient.lastDeviceInfo else { + guard let info else { return .externaldrive } if info.storageSize - info.numberOfRecordedBytes < storageWarnBytes { @@ -35,14 +28,14 @@ struct TransferView: View { } private var measurementsText: String { - guard let info = bluetoothClient.lastDeviceInfo else { + guard let info else { return "No measurements" } return "\(info.numberOfStoredMeasurements) measurements (\(info.time.totalNumberOfMeasurements) total)" } private var storageText: String { - guard let info = bluetoothClient.lastDeviceInfo else { + guard let info else { return "No data" } if info.storageSize <= 0 { @@ -52,25 +45,25 @@ struct TransferView: View { } private var transferSizeText: String { - guard let info = bluetoothClient.lastDeviceInfo else { + guard let info else { return "No transfer size" } return "\(info.transferBlockSize) Byte Block Size" } private var transferByteText: String { - let total = Int(totalBytes) + let total = Int(transfer.totalBytes) guard total > 0 else { return "No data" } - return "\(Int(bytesTransferred)) / \(total) Bytes" + return "\(Int(transfer.bytesTransferred)) / \(total) Bytes" } private var transferMeasurementText: String { - guard !measurements.isEmpty else { + guard !transfer.measurements.isEmpty else { return "No measurements" } - return "\(measurements.count) measurements" + return "\(transfer.measurements.count) measurements" } var body: some View { @@ -93,13 +86,13 @@ struct TransferView: View { Button(action: clearStorage) { Text("Remove recorded data") } - .disabled(transferIsRunning) + .disabled(transfer.transferIsRunning) .padding() VStack(alignment: .leading, spacing: 5) { Text("Transfer") .font(.headline) - ProgressView(value: bytesTransferred, total: totalBytes) + ProgressView(value: transfer.bytesTransferred, total: transfer.totalBytes) .progressViewStyle(.linear) .padding(.vertical, 5) IconAndTextView( @@ -113,19 +106,19 @@ struct TransferView: View { Button(action: transferData) { Text("Transfer") } - .disabled(transferIsRunning) + .disabled(transfer.transferIsRunning) .padding() Spacer() Button(action: saveTransfer) { Text("Save") } - .disabled(transferIsRunning || measurements.isEmpty) + .disabled(transfer.transferIsRunning || transfer.measurements.isEmpty) .padding() Spacer() Button(action: discardTransfer) { Text("Discard") } - .disabled(transferIsRunning || measurements.isEmpty) + .disabled(transfer.transferIsRunning || transfer.measurements.isEmpty) .padding() } Spacer() @@ -140,80 +133,22 @@ struct TransferView: View { } func transferData() { - guard let info = bluetoothClient.lastDeviceInfo else { + guard let info else { return } - transferIsRunning = true - let total = info.numberOfRecordedBytes - let chunkSize = info.transferBlockSize - bytesTransferred = 0 - totalBytes = Double(total) - Task { - defer { - DispatchQueue.main.async { - self.transferIsRunning = false - } - } - var data = Data(capacity: total) - while data.count < total { - let remainingBytes = total - data.count - let currentChunkSize = min(remainingBytes, chunkSize) - guard let chunk = await bluetoothClient.getDeviceData(offset: data.count, count: currentChunkSize) else { - log.warning("Failed to finish transfer") - return - } - data.append(chunk) - DispatchQueue.main.async { - self.bytesTransferred = Double(data.count) - } - } - - DispatchQueue.main.async { - self.bytesTransferred = totalBytes - } - - var measurementCount = 0 - let recordingStart = info.currentMeasurementStartTime - while !data.isEmpty { - let byte = data.removeFirst() - guard (byte == 0xFF) else { - log.error("Expected 0xFF at index \(total - data.count - 1)") - break - } - guard data.count >= 2 else { - log.error("Expected two more bytes after index \(total - data.count - 1)") - break - } - let temp0 = TemperatureValue(byte: data.removeFirst()) - let temp1 = TemperatureValue(byte: data.removeFirst()) - let date = recordingStart - .addingTimeInterval(TimeInterval(measurementCount * info.measurementInterval)) - let measurement = TemperatureMeasurement( - sensor0: temp0, - sensor1: temp1, - date: date) - measurementCount += 1 - DispatchQueue.main.async { - self.measurements.append(measurement) - } - } - } + transfer.startTransfer(from: bluetoothClient, with: info, storage: storage) } func discardTransfer() { - self.measurements = [] - self.bytesTransferred = 0 - self.totalBytes = 0 + transfer.discardTransfer() } func saveTransfer() { - // TODO: Save - - discardTransfer() + transfer.saveTransfer(in: storage) } func clearStorage() { - guard let byteCount = bluetoothClient.lastDeviceInfo?.numberOfRecordedBytes else { + guard let byteCount = info?.numberOfRecordedBytes else { return } Task { @@ -229,7 +164,7 @@ struct TransferView: View { struct TransferView_Previews: PreviewProvider { static var previews: some View { let storage = PersistentStorage(lastMeasurements: TemperatureMeasurement.mockData) - TransferView(bluetoothClient: .init()) + TransferView(bluetoothClient: .init(), info: .constant(.mock)) .environmentObject(storage) } }