From d1a24b581dd125089c0a674caa1a36dc8d7111a6 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Mon, 3 Jul 2023 13:28:51 +0200 Subject: [PATCH] Fix transfer errors, save raw data --- TempTrack.xcodeproj/project.pbxproj | 28 +- .../UserInterfaceState.xcuserstate | Bin 89685 -> 101783 bytes TempTrack/Bluetooth/BluetoothClient.swift | 304 ------------- TempTrack/Bluetooth/BluetoothRequest.swift | 67 --- TempTrack/Bluetooth/DeviceInfo.swift | 79 ++-- TempTrack/Bluetooth/DeviceManager.swift | 258 ----------- .../Bluetooth/DeviceManagerDelegate.swift | 10 - TempTrack/Bluetooth/DeviceState.swift | 82 ---- TempTrack/Bluetooth/DeviceTime.swift | 7 +- TempTrack/Bluetooth/DeviceWakeCause.swift | 4 + TempTrack/Connection/BluetoothDevice.swift | 22 +- TempTrack/Connection/BluetoothScanner.swift | 75 +++- TempTrack/Connection/DeviceConnection.swift | 405 ------------------ TempTrack/Connection/TransferHandler.swift | 96 +++++ TempTrack/ContentView.swift | 40 +- TempTrack/Extensions/UInt16+Extensions.swift | 4 +- TempTrack/Storage/PersistentStorage.swift | 96 +++-- TempTrack/TempTrackApp.swift | 9 +- TempTrack/Temperature/TemperatureSensor.swift | 4 + TempTrack/Views/DeviceInfoView.swift | 9 +- TempTrack/Views/LogView.swift | 27 +- TempTrack/Views/TransferView.swift | 113 ++--- 22 files changed, 369 insertions(+), 1370 deletions(-) delete mode 100644 TempTrack/Bluetooth/BluetoothClient.swift delete mode 100644 TempTrack/Bluetooth/BluetoothRequest.swift delete mode 100644 TempTrack/Bluetooth/DeviceManager.swift delete mode 100644 TempTrack/Bluetooth/DeviceManagerDelegate.swift delete mode 100644 TempTrack/Bluetooth/DeviceState.swift delete mode 100644 TempTrack/Connection/DeviceConnection.swift create mode 100644 TempTrack/Connection/TransferHandler.swift 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 ba712d4b9f3003f97aed0e3011f863ca689859c5..5e3d2c52c69f94428d693dcea92ff2cd28ee587e 100644 GIT binary patch literal 101783 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 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) } }