From 396571fd303c15190476bc2b8d75b8960bf2f5d9 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sun, 2 Jul 2023 17:29:39 +0200 Subject: [PATCH] Transfer view, change data flow, actors --- TempTrack.xcodeproj/project.pbxproj | 64 ++++- .../UserInterfaceState.xcuserstate | Bin 60670 -> 89685 bytes TempTrack/Bluetooth/BluetoothClient.swift | 94 ++++--- TempTrack/Bluetooth/BluetoothRequest.swift | 11 +- TempTrack/Bluetooth/DeviceInfo.swift | 108 ++++---- TempTrack/Bluetooth/DeviceManager.swift | 55 +++- .../Bluetooth/DeviceManagerDelegate.swift | 2 + TempTrack/Bluetooth/DeviceState.swift | 18 +- TempTrack/Bluetooth/DeviceTime.swift | 70 +++++ TempTrack/Connection/BluetoothDevice.swift | 150 +++++++++++ TempTrack/Connection/BluetoothScanner.swift | 176 +++++++++++++ TempTrack/Connection/DeviceConnection.swift | 91 ++++++- TempTrack/ContentView.swift | 169 +++++++++--- ...eStorage.swift => PersistentStorage.swift} | 83 ++++-- TempTrack/TempTrackApp.swift | 6 +- .../Temperature/TemperatureDataTransfer.swift | 84 ++++-- .../TemperatureDataTransferDelegate.swift | 1 + TempTrack/Views/DayView.swift | 4 +- TempTrack/Views/DeviceInfoView.swift | 103 +++----- TempTrack/Views/HistoryList.swift | 4 +- TempTrack/Views/IconAndTextView.swift | 24 ++ TempTrack/Views/LogView.swift | 21 +- TempTrack/Views/TemperatureDayOverview.swift | 4 +- TempTrack/Views/TransferView.swift | 245 ++++++++++++++++++ 24 files changed, 1285 insertions(+), 302 deletions(-) create mode 100644 TempTrack/Bluetooth/DeviceTime.swift create mode 100644 TempTrack/Connection/BluetoothDevice.swift create mode 100644 TempTrack/Connection/BluetoothScanner.swift rename TempTrack/Storage/{TemperatureStorage.swift => PersistentStorage.swift} (80%) create mode 100644 TempTrack/Temperature/TemperatureDataTransferDelegate.swift create mode 100644 TempTrack/Views/IconAndTextView.swift create mode 100644 TempTrack/Views/TransferView.swift diff --git a/TempTrack.xcodeproj/project.pbxproj b/TempTrack.xcodeproj/project.pbxproj index 28485dc..6af3437 100644 --- a/TempTrack.xcodeproj/project.pbxproj +++ b/TempTrack.xcodeproj/project.pbxproj @@ -28,7 +28,7 @@ 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 /* TemperatureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0672A2698B400114294 /* TemperatureStorage.swift */; }; + 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 */; }; @@ -42,6 +42,18 @@ E2A553FB2A39C82D005204C3 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A553FA2A39C82D005204C3 /* LogView.swift */; }; E2A553FD2A39C86B005204C3 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A553FC2A39C86B005204C3 /* LogEntry.swift */; }; E2A553FF2A3A1024005204C3 /* DayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A553FE2A3A1024005204C3 /* DayView.swift */; }; + 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 */; }; + E2A554142A4C9C96005204C3 /* DeviceDataRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A554132A4C9C96005204C3 /* DeviceDataRequest.swift */; }; + E2A554162A4C9D2E005204C3 /* DeviceDataResetRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A554152A4C9D2E005204C3 /* DeviceDataResetRequest.swift */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -65,7 +77,7 @@ 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 /* TemperatureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureStorage.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 = ""; }; @@ -79,6 +91,18 @@ E2A553FA2A39C82D005204C3 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = ""; }; E2A553FC2A39C86B005204C3 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = ""; }; E2A553FE2A3A1024005204C3 /* DayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayView.swift; sourceTree = ""; }; + 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 = ""; }; + E2A554132A4C9C96005204C3 /* DeviceDataRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataRequest.swift; sourceTree = ""; }; + E2A554152A4C9D2E005204C3 /* DeviceDataResetRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataResetRequest.swift; sourceTree = ""; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -98,7 +122,7 @@ isa = PBXGroup; children = ( 88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */, - 88CDE0672A2698B400114294 /* TemperatureStorage.swift */, + 88CDE0672A2698B400114294 /* PersistentStorage.swift */, E2A553F82A399F58005204C3 /* Log.swift */, E2A553FC2A39C86B005204C3 /* LogEntry.swift */, ); @@ -124,6 +148,7 @@ 88CDE04D2A2508E900114294 /* TempTrack */ = { isa = PBXGroup; children = ( + E2A5540A2A4ADD1D005204C3 /* Connection */, 88CDE04E2A2508E900114294 /* TempTrackApp.swift */, 88CDE0502A2508E900114294 /* ContentView.swift */, E253A9202A2B39A700EC6B28 /* Extensions */, @@ -152,6 +177,7 @@ 88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */, 88CDE0752A28AF0900114294 /* TemperatureValue.swift */, 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */, + E2A554062A4ADB9C005204C3 /* TemperatureDataTransferDelegate.swift */, ); path = Temperature; sourceTree = ""; @@ -166,6 +192,7 @@ 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */, 88CDE05E2A250F5200114294 /* DeviceState.swift */, 88CDE06C2A28A92000114294 /* DeviceInfo.swift */, + E2A554002A3A6403005204C3 /* DeviceTime.swift */, 88404DEA2A37BE3000D30244 /* DeviceWakeCause.swift */, ); path = Bluetooth; @@ -181,6 +208,8 @@ 88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */, E2A553FA2A39C82D005204C3 /* LogView.swift */, E2A553FE2A3A1024005204C3 /* DayView.swift */, + E2A554042A4ADA93005204C3 /* TransferView.swift */, + E2E69B5F2A4CD48F00C6035E /* IconAndTextView.swift */, ); path = Views; sourceTree = ""; @@ -198,6 +227,21 @@ path = Extensions; sourceTree = ""; }; + E2A5540A2A4ADD1D005204C3 /* Connection */ = { + isa = PBXGroup; + children = ( + E2A554082A4ADCC9005204C3 /* DeviceConnection.swift */, + E2A5540D2A4C9C4C005204C3 /* BluetoothRequestType.swift */, + E2A5540F2A4C9C68005204C3 /* DeviceRequest.swift */, + E2A5540B2A4ADFC6005204C3 /* DeviceInfoRequest.swift */, + E2A554132A4C9C96005204C3 /* DeviceDataRequest.swift */, + E2A554152A4C9D2E005204C3 /* DeviceDataResetRequest.swift */, + E2E69B612A4D7C3100C6035E /* BluetoothScanner.swift */, + E2E69B652A4DA48B00C6035E /* BluetoothDevice.swift */, + ); + path = Connection; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -277,28 +321,40 @@ buildActionMask = 2147483647; files = ( 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 */, 88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */, 88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */, - 88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */, + E2A554012A3A6403005204C3 /* DeviceTime.swift in Sources */, + 88CDE0682A2698B400114294 /* PersistentStorage.swift in Sources */, 88404DE92A31F7D500D30244 /* Data+Extensions.swift in Sources */, 88404DE52A31F23E00D30244 /* UInt16+Extensions.swift in Sources */, 88404DD42A2F0DB100D30244 /* Date+Extensions.swift in Sources */, 88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */, + 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 */, + E2A5540E2A4C9C4C005204C3 /* BluetoothRequestType.swift in Sources */, E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */, 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 */, 88404DEB2A37BE3000D30244 /* DeviceWakeCause.swift in Sources */, 88CDE06D2A28A92000114294 /* DeviceInfo.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 c52ec07517089f0df0e536798d1a58675389c4b6..ba712d4b9f3003f97aed0e3011f863ca689859c5 100644 GIT binary patch literal 89685 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 RtNWkx8pwan|J}|q{tFvUedPcE diff --git a/TempTrack/Bluetooth/BluetoothClient.swift b/TempTrack/Bluetooth/BluetoothClient.swift index 2860dcc..ab76111 100644 --- a/TempTrack/Bluetooth/BluetoothClient.swift +++ b/TempTrack/Bluetooth/BluetoothClient.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI - +/* final class BluetoothClient: ObservableObject { private let updateInterval = 3.0 @@ -9,7 +9,7 @@ final class BluetoothClient: ObservableObject { private let connection = DeviceManager() - private let storage: TemperatureStorage + private let storage: PersistentStorage var hasInfo: Bool { deviceInfo != nil @@ -21,10 +21,24 @@ final class BluetoothClient: ObservableObject { } 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: TemperatureStorage, deviceInfo: DeviceInfo? = nil) { + init(storage: PersistentStorage, shouldConnect: Bool = false, deviceInfo: DeviceInfo? = nil) { self.storage = storage self.deviceInfo = deviceInfo + self.shouldConnect = shouldConnect + connection.shouldConnectIfPossible = shouldConnect connection.delegate = self } @@ -47,7 +61,6 @@ final class BluetoothClient: ObservableObject { @Published private(set) var deviceInfo: DeviceInfo? { didSet { - updateDeviceTimeIfNeeded() // collectRecordedData() if let deviceInfo, let runningTransfer { runningTransfer.update(info: deviceInfo) @@ -102,6 +115,14 @@ final class BluetoothClient: ObservableObject { } // 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 { @@ -136,24 +157,6 @@ final class BluetoothClient: ObservableObject { openRequests.append(request) } - // MARK: Device time - - private func updateDeviceTimeIfNeeded() { - guard let deviceInfo else { - return - } - guard !deviceInfo.hasDeviceStartTimeSet || abs(deviceInfo.clockOffset) > minimumOffsetToUpdateDeviceClock else { - return - } - - guard !openRequests.contains(where: { if case .setDeviceStartTime = $0 { return true }; return false }) else { - return - } - let time = deviceInfo.calculatedDeviceStartTime.seconds - addRequest(.setDeviceStartTime(deviceStartTimeSeconds: time)) - log.info("Setting device start time to \(time) s (correcting offset of \(Int(deviceInfo.clockOffset)) s)") - } - // MARK: Data transfer @discardableResult @@ -173,8 +176,7 @@ final class BluetoothClient: ObservableObject { guard info.numberOfStoredMeasurements > 0 else { return false } - - let transfer = TemperatureDataTransfer(info: info) + let transfer = TemperatureDataTransfer(info: info, previous: storage.lastDeviceTime) runningTransfer = transfer let next = transfer.nextRequest() log.info("Starting transfer") @@ -185,9 +187,13 @@ final class BluetoothClient: ObservableObject { 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? } - runningTransfer.add(data: data, offset: offset, count: count) + guard runningTransfer.add(data: data, offset: offset, count: count) else { + self.runningRequest = nil + return // TODO: Start new transfer + } let next = runningTransfer.nextRequest() addRequest(next) } @@ -202,6 +208,13 @@ final class BluetoothClient: ObservableObject { } extension BluetoothClient: DeviceManagerDelegate { + + func deviceManager(shouldConnectToDevice: Bool) { + guard !isUpdatingFlag else { + return + } + self.shouldConnect = shouldConnectToDevice + } func deviceManager(didReceive data: Data) { defer { @@ -232,20 +245,21 @@ extension BluetoothClient: DeviceManagerDelegate { return case .invalidNumberOfBytesToDelete: guard case .clearRecordingBuffer = runningRequest else { - // 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) + log.error("Request \(runningRequest) received non-matching response about number of bytes to delete") return } - log.error("Request \(runningRequest) received non-matching responde about number of bytes to delete") + // 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 { - // 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) + log.error("Unexpectedly exceeded payload size for request \(runningRequest)") return } - log.error("Unexpectedly exceeded payload size for request \(runningRequest)") + // 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, @@ -264,10 +278,6 @@ extension BluetoothClient: DeviceManagerDelegate { didReceive(data: payload, offset: offset, count: count) case .clearRecordingBuffer: didClearDeviceStorage() - - case .setDeviceStartTime: - log.info("Device time set") - break } } @@ -276,11 +286,12 @@ extension BluetoothClient: DeviceManagerDelegate { log.warning("No running transfer after clearing device storage") return } - runningTransfer.completeTransfer() + defer { self.runningTransfer = nil } + guard runningTransfer.completeTransfer() else { + return + } storage.add(runningTransfer.measurements) - self.runningTransfer = nil - - updateDeviceTimeIfNeeded() + storage.lastDeviceTime = runningTransfer.time } func deviceManager(didChangeState state: DeviceState) { @@ -290,3 +301,4 @@ extension BluetoothClient: DeviceManagerDelegate { } } +*/ diff --git a/TempTrack/Bluetooth/BluetoothRequest.swift b/TempTrack/Bluetooth/BluetoothRequest.swift index bbc65b6..7a522d5 100644 --- a/TempTrack/Bluetooth/BluetoothRequest.swift +++ b/TempTrack/Bluetooth/BluetoothRequest.swift @@ -1,5 +1,5 @@ import Foundation - +/* enum BluetoothRequest { /** * Request the number of bytes already recorded @@ -44,11 +44,6 @@ enum BluetoothRequest { */ case clearRecordingBuffer(byteCount: Int) - /** - - */ - case setDeviceStartTime(deviceStartTimeSeconds: Int) - var serialized: Data { let firstByte = Data([byte]) switch self { @@ -58,8 +53,6 @@ enum BluetoothRequest { return firstByte + count.twoByteData + offset.twoByteData case .clearRecordingBuffer(let byteCount): return firstByte + byteCount.twoByteData - case .setDeviceStartTime(let deviceStartTimeSeconds): - return firstByte + deviceStartTimeSeconds.fourByteData } } @@ -68,7 +61,7 @@ enum BluetoothRequest { case .getInfo: return 0 case .getRecordingData: return 1 case .clearRecordingBuffer: return 2 - case .setDeviceStartTime: return 3 } } } +*/ diff --git a/TempTrack/Bluetooth/DeviceInfo.swift b/TempTrack/Bluetooth/DeviceInfo.swift index 5154233..2cae653 100644 --- a/TempTrack/Bluetooth/DeviceInfo.swift +++ b/TempTrack/Bluetooth/DeviceInfo.swift @@ -1,39 +1,31 @@ import Foundation struct DeviceInfo { - - let receivedDate: Date - + + /** + The maximum factor by which the device clock can run + */ + private let maximumTimeDilationFactor: Double = 0.01 + + /// The number of bytes recorded by the tracker let numberOfRecordedBytes: Int /// The number of measurements already performed let numberOfStoredMeasurements: Int - /// The measurements since device start - let totalNumberOfMeasurements: Int - /// The interval between measurements (in seconds) let measurementInterval: Int - let nextMeasurement: Date - let sensor0: TemperatureSensor? let sensor1: TemperatureSensor? // MARK: Device time - /** - The number of seconds the device has been powered on - */ - let numberOfSecondsRunning: Int - - let deviceStartTime: Date - - let hasDeviceStartTimeSet: Bool - let wakeupReason: DeviceWakeCause + + let time: DeviceTime // MARK: Storage @@ -49,48 +41,77 @@ struct DeviceInfo { var storageFillPercentage: Int { Int((storageFillRatio * 100).rounded()) } - - var clockOffset: TimeInterval { - // Measurements are performed on device start (-1) and also count next measurement (+1) - let nextMeasurementTime = deviceStartTime.adding(seconds: totalNumberOfMeasurements * measurementInterval) - return nextMeasurement.timeIntervalSince(nextMeasurementTime) + + var currentMeasurementStartTime: Date { + time.nextMeasurement.addingTimeInterval(-Double(numberOfStoredMeasurements * measurementInterval)) } - var calculatedDeviceStartTime: Date { - let runtime = totalNumberOfMeasurements * measurementInterval - return nextMeasurement.adding(seconds: -runtime) + 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 - let date = Date().nearestSecond - self.receivedDate = date self.numberOfRecordedBytes = try data.decodeTwoByteInteger() - self.nextMeasurement = date.adding(seconds: try data.decodeTwoByteInteger()) + let secondsUntilNextMeasurement = try data.decodeTwoByteInteger() self.measurementInterval = try data.decodeTwoByteInteger() self.numberOfStoredMeasurements = try data.decodeTwoByteInteger() - self.totalNumberOfMeasurements = try data.decodeFourByteInteger() + let totalNumberOfMeasurements = try data.decodeFourByteInteger() self.transferBlockSize = try data.decodeTwoByteInteger() self.storageSize = try data.decodeTwoByteInteger() let secondsSincePowerOn = try data.decodeFourByteInteger() - self.numberOfSecondsRunning = secondsSincePowerOn - let deviceStartTimeSeconds = try data.decodeFourByteInteger() + + 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 - - if deviceStartTimeSeconds != 0 { - self.hasDeviceStartTimeSet = true - self.deviceStartTime = Date(seconds: deviceStartTimeSeconds) - - } else { - self.hasDeviceStartTimeSet = false - self.deviceStartTime = Date(seconds: date.seconds - secondsSincePowerOn) // Round to nearest second - } } } @@ -98,18 +119,13 @@ extension DeviceInfo { static var mock: DeviceInfo { .init( - receivedDate: Date(), numberOfRecordedBytes: 123, numberOfStoredMeasurements: 234, - totalNumberOfMeasurements: 345, measurementInterval: 60, - nextMeasurement: .now.addingTimeInterval(5), sensor0: .init(address: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], value: .value(21.0), date: .now.addingTimeInterval(-2)), sensor1: .init(address: [0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09], value: .value(19.0), date: .now.addingTimeInterval(-4)), - numberOfSecondsRunning: 20, - deviceStartTime: .now.addingTimeInterval(-20755), - hasDeviceStartTimeSet: true, wakeupReason: .WAKEUP_EXT0, + time: .mock, storageSize: 10000, transferBlockSize: 180) } diff --git a/TempTrack/Bluetooth/DeviceManager.swift b/TempTrack/Bluetooth/DeviceManager.swift index 62d5566..25304be 100644 --- a/TempTrack/Bluetooth/DeviceManager.swift +++ b/TempTrack/Bluetooth/DeviceManager.swift @@ -24,16 +24,13 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate { self.manager = CBCentralManager(delegate: self, queue: nil) } - - private var dataUpdateTimer: Timer? - @discardableResult func connect() -> Bool { switch state { case .bluetoothDisabled: log.info("Can't connect, bluetooth disabled") return false - case .disconnected, .bluetoothEnabled: + case .disconnected: break default: return true @@ -42,18 +39,53 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate { state = .scanning return true } - shouldConnectIfPossible = true + if !shouldConnectIfPossible { + shouldConnectIfPossible = true + } state = .scanning manager.scanForPeripherals(withServices: [DeviceManager.serviceUUID]) return true } - private var shouldConnectIfPossible = 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() { - shouldConnectIfPossible = false + if shouldConnectIfPossible { + shouldConnectIfPossible = false + } switch state { - case .bluetoothDisabled, .bluetoothEnabled: + case .bluetoothDisabled, .disconnected: return case .scanning: manager.stopScan() @@ -67,8 +99,6 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate { manager.stopScan() state = .disconnected return - case .disconnected: - return } } @@ -91,6 +121,9 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate { } 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() @@ -102,7 +135,7 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate { case .poweredOff: state = .bluetoothDisabled case .poweredOn: - state = .bluetoothEnabled + state = .disconnected connect() case .unsupported: state = .bluetoothDisabled diff --git a/TempTrack/Bluetooth/DeviceManagerDelegate.swift b/TempTrack/Bluetooth/DeviceManagerDelegate.swift index dcd5aec..4a0c40a 100644 --- a/TempTrack/Bluetooth/DeviceManagerDelegate.swift +++ b/TempTrack/Bluetooth/DeviceManagerDelegate.swift @@ -1,6 +1,8 @@ import Foundation protocol DeviceManagerDelegate: AnyObject { + + func deviceManager(shouldConnectToDevice: Bool) func deviceManager(didReceive data: Data) diff --git a/TempTrack/Bluetooth/DeviceState.swift b/TempTrack/Bluetooth/DeviceState.swift index 41b9f45..fa16d0d 100644 --- a/TempTrack/Bluetooth/DeviceState.swift +++ b/TempTrack/Bluetooth/DeviceState.swift @@ -4,8 +4,6 @@ import CoreBluetooth enum DeviceState { case bluetoothDisabled - - case bluetoothEnabled case scanning @@ -23,8 +21,6 @@ enum DeviceState { switch self { case .bluetoothDisabled: return "Bluetooth is disabled" - case .bluetoothEnabled: - return "Bluetooth enabled" case .scanning: return "Scanning..." case .connecting(let device): @@ -45,6 +41,18 @@ enum DeviceState { 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 { @@ -53,8 +61,6 @@ extension DeviceState: CustomStringConvertible { switch self { case .bluetoothDisabled: return "Bluetooth disabled" - case .bluetoothEnabled: - return "Bluetooth enabled" case .scanning: return "Searching for device" case .connecting: diff --git a/TempTrack/Bluetooth/DeviceTime.swift b/TempTrack/Bluetooth/DeviceTime.swift new file mode 100644 index 0000000..febff22 --- /dev/null +++ b/TempTrack/Bluetooth/DeviceTime.swift @@ -0,0 +1,70 @@ +import Foundation + +struct DeviceTime { + + let date: Date + + let secondsSincePowerOn: Int + + let totalNumberOfMeasurements: Int + + let secondsUntilNextMeasurement: Int + + var nextMeasurement: Date { + date.adding(seconds: secondsUntilNextMeasurement) + } + + var deviceStartTime: Date { + date.adding(seconds: -secondsSincePowerOn) + } + + var estimatedMeasurementInterval: TimeInterval { + guard totalNumberOfMeasurements > 0 else { + return 60 + } + return Double(secondsSincePowerOn + secondsUntilNextMeasurement) / Double(totalNumberOfMeasurements) + } + + func measurementStartTime(measurementInterval interval: TimeInterval) -> Date { + nextMeasurement.addingTimeInterval(-Double(totalNumberOfMeasurements) * interval) + } + + func measurementOffset(measurementInterval interval: TimeInterval) -> TimeInterval { + measurementStartTime(measurementInterval: interval).timeIntervalSince(deviceStartTime) + } +} + +extension DeviceTime: Equatable { + +} + +extension DeviceTime: Codable { + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let time = try container.decode(Double.self) + self.date = .init(timeIntervalSince1970: time) + self.secondsSincePowerOn = try container.decode(Int.self) + self.totalNumberOfMeasurements = try container.decode(Int.self) + self.secondsUntilNextMeasurement = try container.decode(Int.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(date.timeIntervalSince1970) + try container.encode(secondsSincePowerOn) + try container.encode(totalNumberOfMeasurements) + try container.encode(secondsUntilNextMeasurement) + } +} + +extension DeviceTime { + + static var mock: DeviceTime { + .init( + date: .now, + secondsSincePowerOn: 125, + totalNumberOfMeasurements: 3, + secondsUntilNextMeasurement: 55) + } +} diff --git a/TempTrack/Connection/BluetoothDevice.swift b/TempTrack/Connection/BluetoothDevice.swift new file mode 100644 index 0000000..22d104f --- /dev/null +++ b/TempTrack/Connection/BluetoothDevice.swift @@ -0,0 +1,150 @@ +import Foundation +import CoreBluetooth + +actor BluetoothDevice: NSObject, ObservableObject { + + private let peripheral: CBPeripheral! + + private let characteristic: CBCharacteristic! + + @MainActor @Published + var lastDeviceInfo: DeviceInfo? + + @Published + private(set) var lastRSSI: Int = 0 + + init(peripheral: CBPeripheral, characteristic: CBCharacteristic) { + self.peripheral = peripheral + self.characteristic = characteristic + super.init() + + peripheral.delegate = self + } + + override init() { + self.peripheral = nil + self.characteristic = nil + super.init() + } + + private var requestContinuation: (id: Int, call: CheckedContinuation)? + + func updateInfo() async { + guard let info = await getInfo() else { + return + } + Task { @MainActor in + lastDeviceInfo = info + } + } + + func getInfo() async -> DeviceInfo? { + await get(DeviceInfoRequest()) + } + + func getDeviceData(offset: Int, count: Int) async -> Data? { + await get(DeviceDataRequest(offset: offset, count: count)) + } + + func deleteDeviceData(byteCount: Int) async -> Bool { + await get(DeviceDataResetRequest(byteCount: byteCount)) != nil + } + + private func get(_ request: Request) async -> Request.Response? where Request: DeviceRequest { + guard requestContinuation == nil else { + // Prevent parallel requests + return nil + } + + let requestData = Data([request.type.rawValue]) + request.payload + let responseData: Data? = await withCheckedContinuation { continuation in + let id = Int.random(in: .min...Int.max) + requestContinuation = (id, continuation) + peripheral.writeValue(requestData, for: characteristic, type: .withResponse) + peripheral.readValue(for: characteristic) + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in + Task { + await self?.checkTimeoutForCurrentRequest(request.type, id: id) + } + } + } + + guard let responseData else { + return nil + } + guard let responseCode = responseData.first else { + log.error("Request \(request.type) got response of zero bytes") + return nil + } + guard let responseType = BluetoothResponseType(rawValue: responseCode) else { + log.error("Request \(request.type) got unknown response code \(responseCode)") + return nil + } + switch responseType { + case .success, .responseTooLarge, .invalidNumberOfBytesToDelete: + break + case .invalidCommand: + log.error("Request \(request.type) failed: Invalid command") + return nil + case .unknownCommand: + log.error("Request \(request.type) failed: Unknown command") + return nil + case .responseInProgress: + log.info("Request \(request.type) failed: Device is busy") + return nil + } + return request.makeResponse(from: responseData.dropFirst(), responseType: responseType) + } + + private func checkTimeoutForCurrentRequest(_ type: BluetoothRequestType, id: Int) { + guard let requestContinuation else { return } + guard requestContinuation.id == id else { return } + log.info("Timed out for request \(type)") + requestContinuation.call.resume(returning: nil) + self.requestContinuation = nil + } +} + +extension BluetoothDevice: CBPeripheralDelegate { + + nonisolated + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + + } + + nonisolated + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + Task { + await didUpdateValue(for: characteristic, of: peripheral, error: error) + } + } + + private func didUpdateValue(for characteristic: CBCharacteristic, of peripheral: CBPeripheral, error: Error?) { + if let error = error { + log.error("Failed to read value update: \(error)") + continueRequest(with: nil) + return + } + + guard characteristic.uuid == self.characteristic.uuid else { + log.warning("Read unknown characteristic \(characteristic.uuid.uuidString)") + continueRequest(with: nil) + return + } + guard let data = characteristic.value else { + log.warning("No data") + continueRequest(with: nil) + return + } + continueRequest(with: data) + } + + private func continueRequest(with response: Data?) { + guard let requestContinuation else { + log.error("No continuation to handle request data (\(response?.count ?? 0) bytes)") + return + } + requestContinuation.call.resume(returning: response) + self.requestContinuation = nil + } +} diff --git a/TempTrack/Connection/BluetoothScanner.swift b/TempTrack/Connection/BluetoothScanner.swift new file mode 100644 index 0000000..64306c9 --- /dev/null +++ b/TempTrack/Connection/BluetoothScanner.swift @@ -0,0 +1,176 @@ +import Foundation +import SwiftUI +import CoreBluetooth + +final class BluetoothScanner: NSObject, CBCentralManagerDelegate, ObservableObject { + + enum ConnectionState { + case noDeviceFound + case connecting + case discoveringService + case discoveringCharacteristic + } + + private let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001") + + private let characteristicUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000002") + + private var manager: CBCentralManager! = nil + + @Published + var bluetoothIsAvailable = false + + @Published + var connectionState: ConnectionState + + @Published + var configuredDevice: BluetoothDevice? + + 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") + } else { + guard manager.isScanning else { + return + } + manager.stopScan() + log.info("Scanner: Stopped scanning for devices") + } + } + } + + override init() { + connectionState = .noDeviceFound + super.init() + self.manager = CBCentralManager(delegate: self, queue: nil) + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + guard connectionState == .noDeviceFound && configuredDevice == nil && connectingDevice == nil else { + log.info("Scanner: Discovered additional device '\(peripheral.name ?? "No Name")'") + return + } + log.info("Scanner: Connecting to discovered device '\(peripheral.name ?? "No Name")'") + connectingDevice = peripheral + manager.connect(peripheral) + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOff: + break + case .poweredOn: + bluetoothIsAvailable = true + return + case .unsupported: + log.info("Bluetooth state: Not supported") + case .unknown: + log.info("Bluetooth state: Unknown") + case .resetting: + log.info("Bluetooth state: Resetting") + case .unauthorized: + log.info("Bluetooth state: Not authorized") + @unknown default: + log.warning("Bluetooth state: Unknown (\(central.state))") + } + bluetoothIsAvailable = false + // TODO: Disconnect devices? + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + log.info("Scanner: Connected to '\(peripheral.name ?? "No Name")'") + + connectionState = .discoveringService + peripheral.delegate = self + peripheral.discoverServices([serviceUUID]) + connectingDevice = peripheral + configuredDevice = nil + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + log.info("Scanner: Disconnected from '\(peripheral.name ?? "No Name")'") + connectionState = .noDeviceFound + configuredDevice = nil + connectingDevice = nil + // TODO: Check if peripheral matches the connected device(s) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + log.warning("Scanner: Failed to connect to device '\(peripheral.name ?? "No Name")' (\(error?.localizedDescription ?? "No error"))") + isScanningForDevices = true + connectionState = .noDeviceFound + connectingDevice = nil + } +} + +extension BluetoothScanner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let services = peripheral.services, !services.isEmpty else { + log.error("Connected device '\(peripheral.name ?? "No Name")': No services found") + manager.cancelPeripheralConnection(peripheral) + connectionState = .noDeviceFound + connectingDevice = nil + return + } + guard let service = services.first(where: { $0.uuid.uuidString == DeviceManager.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 + return + } + peripheral.delegate = self + peripheral.discoverCharacteristics([characteristicUUID], for: service) + connectionState = .discoveringCharacteristic + connectingDevice = peripheral + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + log.error("Failed to discover characteristics: \(error)") + manager.cancelPeripheralConnection(peripheral) + connectionState = .noDeviceFound + connectingDevice = nil + return + } + + guard let characteristics = service.characteristics, !characteristics.isEmpty else { + log.error("Connected device '\(peripheral.name ?? "No Name")': No characteristics found") + manager.cancelPeripheralConnection(peripheral) + connectionState = .noDeviceFound + connectingDevice = nil + return + } + + var desiredCharacteristic: CBCharacteristic? = nil + for characteristic in characteristics { + guard characteristic.uuid == characteristicUUID else { + log.warning("Connected device '\(peripheral.name ?? "No Name")': Unused characteristic \(characteristic.uuid.uuidString)") + continue + } + desiredCharacteristic = characteristic + } + + connectionState = .noDeviceFound + connectingDevice = nil + + guard let desiredCharacteristic else { + log.error("Connected device '\(peripheral.name ?? "No Name")': Characteristic not found") + manager.cancelPeripheralConnection(peripheral) + return + } + + configuredDevice = .init(peripheral: peripheral, characteristic: desiredCharacteristic) + } +} diff --git a/TempTrack/Connection/DeviceConnection.swift b/TempTrack/Connection/DeviceConnection.swift index 3526278..90bba61 100644 --- a/TempTrack/Connection/DeviceConnection.swift +++ b/TempTrack/Connection/DeviceConnection.swift @@ -1,7 +1,7 @@ import Foundation import CoreBluetooth - -final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObject { +/* +actor DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObject { static let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001") @@ -149,10 +149,9 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje device.writeValue(requestData, for: characteristic, type: .withResponse) device.readValue(for: characteristic) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in - guard let requestContinuation = self?.requestContinuation else { return } - log.info("Timed out for request \(request.type)") - requestContinuation.resume(returning: nil) - self?.requestContinuation = nil + Task { + await self?.checkTimeoutForCurrentRequest(request.type) + } } } @@ -183,7 +182,21 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje 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 } @@ -193,8 +206,15 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje state = .connecting(device: peripheral) } + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { - switch central.state { + Task { + await didUpdate(state: central.state) + } + } + + private func didUpdate(state newState: CBManagerState) { + switch newState { case .poweredOff: state = .bluetoothDisabled case .poweredOn: @@ -214,17 +234,31 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje log.info("Bluetooth is not authorized") @unknown default: state = .bluetoothDisabled - log.warning("Unknown state \(central.state)") + 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 @@ -233,7 +267,14 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje } } + 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) @@ -248,7 +289,14 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje 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) @@ -263,7 +311,14 @@ extension DeviceConnection: CBPeripheralDelegate { 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) @@ -284,22 +339,37 @@ extension DeviceConnection: CBPeripheralDelegate { } } + 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 } - lastRSSI = RSSI.intValue - log.info("RSSI: \(lastRSSI)") + 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) @@ -332,3 +402,4 @@ extension DeviceConnection: CBPeripheralDelegate { self.requestContinuation = nil } } +*/ diff --git a/TempTrack/ContentView.swift b/TempTrack/ContentView.swift index 96e81de..258334f 100644 --- a/TempTrack/ContentView.swift +++ b/TempTrack/ContentView.swift @@ -3,6 +3,8 @@ import SFSafeSymbols struct ContentView: View { + private let deviceInfoUpdateInterval = 3.0 + private let minTempColor = Color(hue: 0.624, saturation: 0.5, brightness: 1.0) private let minTemperature = -20.0 @@ -11,11 +13,11 @@ struct ContentView: View { private let disconnectedColor = Color(white: 0.8) - @EnvironmentObject - var bluetoothClient: BluetoothClient + @StateObject + var scanner = BluetoothScanner() @EnvironmentObject - var storage: TemperatureStorage + var storage: PersistentStorage @State var showDeviceInfo = false @@ -26,13 +28,23 @@ struct ContentView: View { @State var showLog = false - init() { - - } + @State + var showDataTransferView = false + + @State + var deviceInfoUpdateTimer: Timer? + + init() { } var averageTemperature: Double? { - let t1 = bluetoothClient.deviceInfo?.sensor1?.optionalValue - guard let t0 = bluetoothClient.deviceInfo?.sensor0?.optionalValue else { + guard let bluetoothDevice = scanner.configuredDevice else { + return nil + } + guard let info = bluetoothDevice.lastDeviceInfo else { + return nil + } + let t1 = info.sensor1?.optionalValue + guard let t0 = info.sensor0?.optionalValue else { return t1 } guard let t1 else { @@ -87,15 +99,48 @@ struct ContentView: View { return .init(colors: [lighter, color]) } + var connectionSymbol: SFSymbol { + if scanner.configuredDevice != nil { + return .iphoneCircle + } + if !scanner.bluetoothIsAvailable { + return .antennaRadiowavesLeftAndRightSlash + } + switch scanner.connectionState { + case .noDeviceFound: + if scanner.isScanningForDevices { + return .iphoneRadiowavesLeftAndRightCircle + } + return .iphoneSlashCircle + case .connecting: + return .arrowRightToLineCircle + case .discoveringService: + return .linkCircle + case .discoveringCharacteristic: + return .magnifyingglassCircle + } + + } + + var hasNoDeviceInfo: Bool { + scanner.configuredDevice?.lastDeviceInfo == nil + } + + var isDisconnected: Bool { + scanner.configuredDevice == nil + } + var body: some View { VStack { Spacer() -// Image(systemSymbol: temperatureIcon) -// .font(.system(size: 100, weight: .light)) if hasTemperature { Text(temperatureString) .font(.system(size: 150, weight: .light)) .foregroundColor(.white) + } else { + Image(systemSymbol: temperatureIcon) + .font(.system(size: 100, weight: .thin)) + .foregroundColor(.gray) } Spacer() @@ -103,45 +148,63 @@ struct ContentView: View { Button { self.showHistory = true } label: { - TemperatureHistoryChart(points: $storage.recentMeasurements) - .frame(height: 300) - .background(Color.white.opacity(0.1)) - .cornerRadius(8) + ZStack { + TemperatureHistoryChart(points: $storage.recentMeasurements) + .frame(height: 300) + .background(Color.white.opacity(0.1)) + .cornerRadius(8) + if storage.recentMeasurements.isEmpty { + Text("No recent measurements") + } + } } HStack(alignment: .center) { Button { - self.showLog.toggle() + self.showLog = true } label: { - Image(systemSymbol: .textBubble) - .font(.system(size: 30, weight: .light)) + Image(systemSymbol: .paperclipCircle) .foregroundColor(.white) } Spacer() Button { - self.showDeviceInfo = true + self.scanner.isScanningForDevices.toggle() } label: { - if bluetoothClient.hasInfo { - Image(systemSymbol: .iphone) - .font(.system(size: 30, weight: .light)) - } - Text(bluetoothClient.deviceState.text) + Image(systemSymbol: connectionSymbol) + .foregroundColor(.white) } - .disabled(!bluetoothClient.hasInfo) .foregroundColor(.white) Spacer() - Button { - bluetoothClient.collectRecordedData() - } label: { + if let device = scanner.configuredDevice { + Button { + self.showDeviceInfo = true + } label: { + Image(systemSymbol: .infoCircle) + .foregroundColor(device.lastDeviceInfo == nil ? .gray : .white) + }.disabled(device.lastDeviceInfo == nil) + Spacer() + Button { + showDataTransferView = true + } label: { + Image(systemSymbol: .arrowUpArrowDownCircle) + .foregroundColor(.white) + } + } else { + Image(systemSymbol: .infoCircle) + .foregroundColor(.gray) + Spacer() Image(systemSymbol: .arrowUpArrowDownCircle) - .font(.system(size: 30, weight: .light)) - .foregroundColor(.white) - }.disabled(!bluetoothClient.isConnected) + .foregroundColor(.gray) + } + + + } + .padding() + .font(.system(size: 30, weight: .light)) - }.padding() } .padding() .sheet(isPresented: $showDeviceInfo) { - if let info = bluetoothClient.deviceInfo { + if let info = scanner.configuredDevice?.lastDeviceInfo { DeviceInfoView(info: info, isPresented: $showDeviceInfo) } else { EmptyView() @@ -155,17 +218,53 @@ struct ContentView: View { LogView() .environmentObject(log) } + .sheet(isPresented: $showDataTransferView) { + if let client = scanner.configuredDevice { + TransferView( + bluetoothClient: client) + .environmentObject(storage) + } else { + EmptyView() + } + } .background(backgroundGradient) + .onAppear(perform: startDeviceInfoUpdates) + .onDisappear(perform: endDeviceInfoUpdates) + } + + // MARK: Device info updates + + private func startDeviceInfoUpdates() { + deviceInfoUpdateTimer?.invalidate() + + log.info("Starting device info updates") + deviceInfoUpdateTimer = Timer.scheduledTimer(withTimeInterval: deviceInfoUpdateInterval, repeats: true) { timer in + self.updateDeviceInfo() + } + + deviceInfoUpdateTimer?.fire() + } + + private func updateDeviceInfo() { + guard let bluetoothDevice = scanner.configuredDevice else { + return + } + Task { + await bluetoothDevice.updateInfo() + } + } + + private func endDeviceInfoUpdates() { + deviceInfoUpdateTimer?.invalidate() + deviceInfoUpdateTimer = nil } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - let storage = TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData) - let client = BluetoothClient(storage: storage, deviceInfo: .mock) + let storage = PersistentStorage(lastMeasurements: TemperatureMeasurement.mockData) ContentView() .environmentObject(storage) - .environmentObject(client) } } diff --git a/TempTrack/Storage/TemperatureStorage.swift b/TempTrack/Storage/PersistentStorage.swift similarity index 80% rename from TempTrack/Storage/TemperatureStorage.swift rename to TempTrack/Storage/PersistentStorage.swift index c423070..e2eeae4 100644 --- a/TempTrack/Storage/TemperatureStorage.swift +++ b/TempTrack/Storage/PersistentStorage.swift @@ -3,7 +3,7 @@ import Combine import BinaryCodable import SwiftUI -final class TemperatureStorage: ObservableObject { +final class PersistentStorage: ObservableObject { static var documentDirectory: URL { try! FileManager.default.url( @@ -15,6 +15,9 @@ final class TemperatureStorage: ObservableObject { @AppStorage("newestDate") private var newestMeasurementTime: Int = 0 + @AppStorage("deviceTime") + private var lastDeviceTimeData: Data? + /** The date of the latest measurement. @@ -34,22 +37,26 @@ final class TemperatureStorage: ObservableObject { @Published var dailyMeasurementCounts: [MeasurementDailyCount] = [] - + + /// The formatter for the temperature measurement file names private let fileNameFormatter: DateFormatter - - private let storageFolder: URL - - private let overviewFileUrl: URL + + /// The storage of daily temperature measurements + private let temperatureStorageFolderUrl: URL + + /// The storage of the measurement counts per day + private let dailyCountsFileUrl: URL private let fm: FileManager - + + /// The interval in which the measurements should be kept in `recentMeasurements` private let lastValueInterval: TimeInterval init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) { self.recentMeasurements = lastMeasurements - let documentDirectory = TemperatureStorage.documentDirectory - self.storageFolder = documentDirectory.appendingPathComponent("measurements") - self.overviewFileUrl = documentDirectory.appendingPathComponent("overview.bin") + let documentDirectory = PersistentStorage.documentDirectory + self.temperatureStorageFolderUrl = documentDirectory.appendingPathComponent("measurements") + self.dailyCountsFileUrl = documentDirectory.appendingPathComponent("overview.bin") self.fm = .default self.fileNameFormatter = DateFormatter() self.fileNameFormatter.dateFormat = "yyyyMMdd.bin" @@ -63,15 +70,15 @@ final class TemperatureStorage: ObservableObject { } ensureExistenceOfFolder() - recalculateDailyCounts() + //recalculateDailyCounts() } private func ensureExistenceOfFolder() { - guard !fm.fileExists(atPath: storageFolder.path) else { + guard !fm.fileExists(atPath: temperatureStorageFolderUrl.path) else { return } do { - try fm.createDirectory(at: storageFolder, withIntermediateDirectories: true) + try fm.createDirectory(at: temperatureStorageFolderUrl, withIntermediateDirectories: true) } catch { log.error("Failed to create folder: \(error)") } @@ -86,16 +93,17 @@ final class TemperatureStorage: ObservableObject { } private func fileUrl(for dateIndex: Int) -> URL { - storageFolder.appendingPathComponent(fileName(for: dateIndex)) + temperatureStorageFolderUrl.appendingPathComponent(fileName(for: dateIndex)) } private func fileUrl(for fileName: String) -> URL { - storageFolder.appendingPathComponent(fileName) + temperatureStorageFolderUrl.appendingPathComponent(fileName) } private func loadLastMeasurements() { - let startDate = Date().addingTimeInterval(-lastValueInterval) - let todayIndex = Date().dateIndex + let now = Date.now + let startDate = now.addingTimeInterval(-lastValueInterval) + let todayIndex = now.dateIndex let todayValues = loadMeasurements(for: todayIndex) .filter { $0.date >= startDate } let dateIndexOfStart = startDate.dateIndex @@ -220,7 +228,7 @@ final class TemperatureStorage: ObservableObject { private func loadDailyCounts() { do { - let data = try Data(contentsOf: overviewFileUrl) + let data = try Data(contentsOf: dailyCountsFileUrl) dailyMeasurementCounts = try BinaryDecoder.decode(from: data) } catch { log.error("Failed to load overview: \(error)") @@ -230,7 +238,7 @@ final class TemperatureStorage: ObservableObject { private func saveDailyCounts() { do { let data = try BinaryEncoder.encode(dailyMeasurementCounts) - try data.write(to: overviewFileUrl) + try data.write(to: dailyCountsFileUrl) } catch { log.error("Failed to write overview: \(error)") } @@ -248,7 +256,7 @@ final class TemperatureStorage: ObservableObject { func recalculateDailyCounts() { do { - let files = try fm.contentsOfDirectory(atPath: storageFolder.path) + let files = try fm.contentsOfDirectory(atPath: temperatureStorageFolderUrl.path) let newValues: [Int: Int] = files .reduce(into: [:]) { counts, fileName in let dateString = fileName.replacingOccurrences(of: ".bin", with: "") @@ -269,6 +277,37 @@ final class TemperatureStorage: ObservableObject { log.error("Failed to load daily counts: \(error)") } } + + // MARK: Device time + + var lastDeviceTime: DeviceTime? { + get { + guard let data = lastDeviceTimeData else { + return nil + } + do { + let result: DeviceTime = try BinaryDecoder.decode(from: data) + return result + } catch { + log.error("Failed to decode device time: \(error)") + lastDeviceTimeData = nil + return nil + } + } + 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 + } + } + } } private extension Array where Element == TemperatureMeasurement { @@ -294,9 +333,9 @@ private extension Array where Element == TemperatureMeasurement { } } -extension TemperatureStorage { +extension PersistentStorage { - static var mock: TemperatureStorage { + static var mock: PersistentStorage { .init(lastMeasurements: TemperatureMeasurement.mockData) } } diff --git a/TempTrack/TempTrackApp.swift b/TempTrack/TempTrackApp.swift index f56400d..8e985e9 100644 --- a/TempTrack/TempTrackApp.swift +++ b/TempTrack/TempTrackApp.swift @@ -1,9 +1,6 @@ import SwiftUI -private let storage = TemperatureStorage() -private let bluetoothClient: BluetoothClient = { - .init(storage: storage) -}() +private let storage = PersistentStorage() @main struct TempTrackApp: App { @@ -12,7 +9,6 @@ struct TempTrackApp: App { WindowGroup { ContentView() .environmentObject(storage) - .environmentObject(bluetoothClient) } } } diff --git a/TempTrack/Temperature/TemperatureDataTransfer.swift b/TempTrack/Temperature/TemperatureDataTransfer.swift index f31d2fd..4260b01 100644 --- a/TempTrack/Temperature/TemperatureDataTransfer.swift +++ b/TempTrack/Temperature/TemperatureDataTransfer.swift @@ -1,20 +1,36 @@ import Foundation - +/* final class TemperatureDataTransfer { - + private let startDateOfCurrentTransfer: Date - private let interval: Int + private var interval: TimeInterval { + TimeInterval(info.measurementInterval) * dilation + } private var dataBuffer: Data = Data() private(set) var currentByteIndex = 0 + + private var info: DeviceInfo + + private let dilation: Double - private(set) var size: Int + var size: Int { + info.numberOfRecordedBytes + } - private(set) var blockSize: Int + var blockSize: Int { + min(50, info.transferBlockSize) + } - private var numberOfRecordingsInCurrentTransfer = 0 + private var numberOfRecordingsInCurrentTransfer: Int { + measurements.count + } + + var time: DeviceTime { + info.time + } var measurements: [TemperatureMeasurement] = [] @@ -22,7 +38,7 @@ final class TemperatureDataTransfer { private var lastRecording: TemperatureMeasurement = .init(sensor0: .notFound, sensor1: .notFound, date: .now) private var dateOfNextRecording: Date { - startDateOfCurrentTransfer.addingTimeInterval(TimeInterval(numberOfRecordingsInCurrentTransfer * interval)) + startDateOfCurrentTransfer.addingTimeInterval(TimeInterval(numberOfRecordingsInCurrentTransfer) * interval) } var unprocessedByteCount: Int { @@ -33,39 +49,44 @@ final class TemperatureDataTransfer { size - currentByteIndex } - init(info: DeviceInfo) { - self.interval = info.measurementInterval - let recordingTime = info.numberOfStoredMeasurements * info.measurementInterval - self.startDateOfCurrentTransfer = info.nextMeasurement.addingTimeInterval(-TimeInterval(recordingTime)) - self.size = info.numberOfRecordedBytes - self.blockSize = info.transferBlockSize + init(info: DeviceInfo, previous: DeviceTime?) { + let (estimatedStart, dilation) = info.estimatedTimeDilation(to: previous) + log.info("Starting transfer") + log.info("Estimated start of recording: \(estimatedStart)") + log.info("Estimated time dilation: \(dilation)") + self.info = info + self.dilation = dilation + self.startDateOfCurrentTransfer = estimatedStart + log.info("True measurement interval: \(interval)") } func update(info: DeviceInfo) { - self.size = info.numberOfRecordedBytes - self.blockSize = info.transferBlockSize + self.info = info + // Possible bug: Device time updated, but new measurement not transferred + // Future transfer will calculate wrong time } func nextRequest() -> BluetoothRequest { guard remainingBytesToTransfer > 0 else { - return .clearRecordingBuffer(byteCount: size) + return .clearRecordingBuffer(byteCount: currentByteIndex) } let chunkSize = min(remainingBytesToTransfer, blockSize) return .getRecordingData(offset: currentByteIndex, count: chunkSize) } - func add(data: Data, offset: Int, count: Int) { + func add(data: Data, offset: Int, count: Int) -> Bool { guard currentByteIndex == offset else { log.warning("Transfer: Discarding \(data.count) bytes at offset \(offset), expected \(currentByteIndex)") - return + return false } - if data.count != count { + guard data.count == count else { log.warning("Transfer: Expected \(count) bytes, received only \(data.count)") + return false } dataBuffer.append(data) currentByteIndex += data.count - processBytes() - log.info("Transfer: \(currentByteIndex) bytes (added \(data.count)), \(measurements.count) points") + log.info("Transfer: \(currentByteIndex) bytes (added \(data.count))") + return true } private func processBytes() { @@ -76,7 +97,7 @@ final class TemperatureDataTransfer { continue } guard dataBuffer.count >= 2 else { - // Wait for more data + // Missing data return } let temp0 = TemperatureValue(byte: dataBuffer.removeFirst()) @@ -85,16 +106,23 @@ final class TemperatureDataTransfer { } } - func completeTransfer() { + func completeTransfer() -> Bool { + let emptyIndices = dataBuffer.enumerated().filter { $0.element == 0 }.map { $0.offset } processBytes() - if !dataBuffer.isEmpty { + guard dataBuffer.isEmpty else { log.warning("\(dataBuffer.count) bytes remaining in transfer buffer") + return false } - log.info("Transfer: \(currentByteIndex) bytes, \(measurements.count) points") + log.info("Transfer complete: \(currentByteIndex) bytes, \(measurements.count) points") + log.info("Empty bytes: \(emptyIndices)") + if measurements.count != info.numberOfStoredMeasurements { + log.warning("Decoded \(measurements.count) points, but only \(info.numberOfStoredMeasurements) recorded") + } + return true } private func addRelative(byte: UInt8) { - add(sensor0: convertTemp(value: byte >> 4, relativeTo: lastRecording.sensor0), + add(sensor0: convertTemp(value: (byte >> 4) & 0x0F, relativeTo: lastRecording.sensor0), sensor1: convertTemp(value: byte & 0x0F, relativeTo: lastRecording.sensor1)) } @@ -103,8 +131,7 @@ final class TemperatureDataTransfer { sensor0: sensor0, sensor1: sensor1, date: dateOfNextRecording) - - numberOfRecordingsInCurrentTransfer += 1 + if measurement.sensor0.isValid { lastRecording.sensor0 = measurement.sensor0 } @@ -134,3 +161,4 @@ private extension TemperatureValue { return 0 } } +*/ diff --git a/TempTrack/Temperature/TemperatureDataTransferDelegate.swift b/TempTrack/Temperature/TemperatureDataTransferDelegate.swift new file mode 100644 index 0000000..fecc4ab --- /dev/null +++ b/TempTrack/Temperature/TemperatureDataTransferDelegate.swift @@ -0,0 +1 @@ +import Foundation diff --git a/TempTrack/Views/DayView.swift b/TempTrack/Views/DayView.swift index e55e445..b170d6e 100644 --- a/TempTrack/Views/DayView.swift +++ b/TempTrack/Views/DayView.swift @@ -12,7 +12,7 @@ struct DayView: View { let dateIndex: Int @EnvironmentObject - var storage: TemperatureStorage + var storage: PersistentStorage var entries: [TemperatureMeasurement] { storage.loadMeasurements(for: dateIndex) @@ -33,6 +33,6 @@ struct DayView: View { struct DayView_Previews: PreviewProvider { static var previews: some View { DayView(dateIndex: Date.now.dateIndex) - .environmentObject(TemperatureStorage.mock) + .environmentObject(PersistentStorage.mock) } } diff --git a/TempTrack/Views/DeviceInfoView.swift b/TempTrack/Views/DeviceInfoView.swift index f710fef..fa60aab 100644 --- a/TempTrack/Views/DeviceInfoView.swift +++ b/TempTrack/Views/DeviceInfoView.swift @@ -10,16 +10,16 @@ private let df: DateFormatter = { }() struct DeviceInfoView: View { - + private let storageWarnBytes = 500 let info: DeviceInfo @Binding var isPresented: Bool - + private var runTimeString: String { - let number = info.numberOfSecondsRunning + let number = info.time.secondsSincePowerOn guard number >= 60 else { return "\(number) s" } @@ -45,7 +45,7 @@ struct DeviceInfoView: View { } private var nextUpdateText: String { - let secs = Int(info.nextMeasurement.timeIntervalSinceNow.rounded()) + let secs = info.time.nextMeasurement.secondsToNow guard secs > 1 else { return "Now" } @@ -71,42 +71,25 @@ struct DeviceInfoView: View { Text("Sensor \(id)") .font(.headline) if let sensor { - HStack { - Image(systemSymbol: sensor.temperatureIcon) - .frame(width: 30) - Text("\(sensor.temperatureText) (\(sensor.updateText))") - } - HStack { - Image(systemSymbol: .tag) - .frame(width: 30) - Text(sensor.hexAddress) - } + IconAndTextView( + icon: sensor.temperatureIcon, + text: "\(sensor.temperatureText) (\(sensor.updateText))") + IconAndTextView( + icon: .tag, + text: sensor.hexAddress) } else { - HStack { - Image(systemSymbol: .thermometerMediumSlash) - .frame(width: 30) - Text("Not connected") - } + IconAndTextView( + icon: .thermometerMediumSlash, + text: "Not connected") } } } var updateText: String { - guard info.receivedDate.secondsToNow > 3 else { + guard info.time.date.secondsToNow > 3 else { return "Updated Now" } - return "Updated \(info.receivedDate.timePassedText)" - } - - var clockOffsetText: String { - guard info.hasDeviceStartTimeSet else { - return "Clock not synchronized" - } - let offset = info.clockOffset.roundedInt - guard abs(offset) > 1 else { - return "No clock offset" - } - return "Offset: \(offset) s" + return "Updated \(info.time.date.timePassedText)" } var body: some View { @@ -115,52 +98,32 @@ struct DeviceInfoView: View { VStack(alignment: .leading, spacing: 5) { Text("System") .font(.headline) - HStack { - Image(systemSymbol: .power) - .frame(width: 30) - Text("\(df.string(from: info.deviceStartTime)) (\(runTimeString))") - Spacer() - } - HStack { - Image(systemSymbol: .clockBadgeExclamationmark) - .frame(width: 30) - Text(clockOffsetText) - } - HStack { - Image(systemSymbol: .autostartstop) - .frame(width: 30) - Text("Wakeup: \(info.wakeupReason.text)") - Spacer() - } + IconAndTextView( + icon: .power, + text: "\(df.string(from: info.time.deviceStartTime)) (\(runTimeString))") + IconAndTextView( + icon: .autostartstop, + text: "Wakeup: \(info.wakeupReason.text)") } VStack(alignment: .leading, spacing: 5) { Text("Recording") .font(.headline) - HStack { - Image(systemSymbol: .stopwatch) - .frame(width: 30) - Text("\(nextUpdateText) (Every \(info.measurementInterval) s)") - Spacer() - } + IconAndTextView( + icon: .stopwatch, + text: "\(nextUpdateText) (Every \(info.measurementInterval) s)") } VStack(alignment: .leading, spacing: 5) { Text("Storage") .font(.headline) - HStack { - Image(systemSymbol: .speedometer) - .frame(width: 30) - Text("\(info.numberOfStoredMeasurements) Measurements (\(info.totalNumberOfMeasurements) total)") - } - HStack { - Image(systemSymbol: storageIcon) - .frame(width: 30) - Text(storageText) - } - HStack { - Image(systemSymbol: .iphoneAndArrowForward) - .frame(width: 30) - Text("\(info.transferBlockSize) Byte Block Size") - } + IconAndTextView( + icon: .speedometer, + text: "\(info.numberOfStoredMeasurements) Measurements (\(info.time.totalNumberOfMeasurements) total)") + IconAndTextView( + icon: storageIcon, + text: storageText) + IconAndTextView( + icon: .iphoneAndArrowForward, + text: "\(info.transferBlockSize) Byte Block Size") } sensorView(info.sensor0, id: 0) sensorView(info.sensor1, id: 1) diff --git a/TempTrack/Views/HistoryList.swift b/TempTrack/Views/HistoryList.swift index 7d96465..7c3a1af 100644 --- a/TempTrack/Views/HistoryList.swift +++ b/TempTrack/Views/HistoryList.swift @@ -3,7 +3,7 @@ import SwiftUI struct HistoryList: View { @EnvironmentObject - var storage: TemperatureStorage + var storage: PersistentStorage var body: some View { NavigationView { @@ -36,6 +36,6 @@ struct HistoryList: View { struct HistoryList_Previews: PreviewProvider { static var previews: some View { HistoryList() - .environmentObject(TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData)) + .environmentObject(PersistentStorage(lastMeasurements: TemperatureMeasurement.mockData)) } } diff --git a/TempTrack/Views/IconAndTextView.swift b/TempTrack/Views/IconAndTextView.swift new file mode 100644 index 0000000..30acf10 --- /dev/null +++ b/TempTrack/Views/IconAndTextView.swift @@ -0,0 +1,24 @@ +import SwiftUI +import SFSafeSymbols + +struct IconAndTextView: View { + + let icon: SFSymbol + + let text: String + + var body: some View { + HStack { + Image(systemSymbol: icon) + .frame(width: 30) + Text(text) + Spacer() + } + } +} + +struct IconAndTextView_Previews: PreviewProvider { + static var previews: some View { + IconAndTextView(icon: .power, text: "Awake time") + } +} diff --git a/TempTrack/Views/LogView.swift b/TempTrack/Views/LogView.swift index 48e0201..b52ce68 100644 --- a/TempTrack/Views/LogView.swift +++ b/TempTrack/Views/LogView.swift @@ -13,17 +13,20 @@ struct LogView: View { var log: Log var body: some View { - 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) + 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) + } } + .navigationTitle("Log") + .navigationBarTitleDisplayMode(.large) } - } } diff --git a/TempTrack/Views/TemperatureDayOverview.swift b/TempTrack/Views/TemperatureDayOverview.swift index 1cb2b3b..fe14f4a 100644 --- a/TempTrack/Views/TemperatureDayOverview.swift +++ b/TempTrack/Views/TemperatureDayOverview.swift @@ -9,7 +9,7 @@ struct TemperatureDayOverview: View { self.points = points } - init(storage: TemperatureStorage, dateIndex: Int) { + init(storage: PersistentStorage, dateIndex: Int) { let points = storage.loadMeasurements(for: dateIndex) self.points = points update() @@ -77,7 +77,7 @@ struct TemperatureDayOverview: View { struct TemperatureDayOverview_Previews: PreviewProvider { static var previews: some View { - TemperatureDayOverview(storage: TemperatureStorage.mock, dateIndex: Date().dateIndex) + TemperatureDayOverview(storage: PersistentStorage.mock, dateIndex: Date().dateIndex) .previewLayout(.fixed(width: 350, height: 150)) //.background(.gray) } diff --git a/TempTrack/Views/TransferView.swift b/TempTrack/Views/TransferView.swift new file mode 100644 index 0000000..6f6d3d8 --- /dev/null +++ b/TempTrack/Views/TransferView.swift @@ -0,0 +1,245 @@ +import SwiftUI +import SFSafeSymbols + +struct TransferView: View { + + private let storageWarnBytes = 500 + + + let bluetoothClient: BluetoothDevice + + @EnvironmentObject + var storage: PersistentStorage + + @State + var bytesTransferred: Double = 0.0 + + @State + var totalBytes: Double = 0.0 + + @State + var measurements: [TemperatureMeasurement] = [] + + @State + var transferIsRunning = false + + + private var storageIcon: SFSymbol { + guard let info = bluetoothClient.lastDeviceInfo else { + return .externaldrive + } + if info.storageSize - info.numberOfRecordedBytes < storageWarnBytes { + return .externaldriveTrianglebadgeExclamationmark + } + return .externaldrive + } + + private var measurementsText: String { + guard let info = bluetoothClient.lastDeviceInfo else { + return "No measurements" + } + return "\(info.numberOfStoredMeasurements) measurements (\(info.time.totalNumberOfMeasurements) total)" + } + + private var storageText: String { + guard let info = bluetoothClient.lastDeviceInfo else { + return "No data" + } + if info.storageSize <= 0 { + return "\(info.numberOfRecordedBytes) Bytes" + } + return "\(info.numberOfRecordedBytes) / \(info.storageSize) Bytes (\(info.storageFillPercentage) %)" + } + + private var transferSizeText: String { + guard let info = bluetoothClient.lastDeviceInfo else { + return "No transfer size" + } + return "\(info.transferBlockSize) Byte Block Size" + } + + private var transferByteText: String { + let total = Int(totalBytes) + guard total > 0 else { + return "No data" + } + return "\(Int(bytesTransferred)) / \(total) Bytes" + } + + private var transferMeasurementText: String { + guard !measurements.isEmpty else { + return "No measurements" + } + return "\(measurements.count) measurements" + } + + var body: some View { + NavigationView { + VStack { + VStack(alignment: .leading, spacing: 5) { + Text("Storage") + .font(.headline) + IconAndTextView( + icon: .speedometer, + text: measurementsText) + IconAndTextView( + icon: storageIcon, + text: storageText) + IconAndTextView( + icon: .iphoneAndArrowForward, + text: transferSizeText) + } + + Button(action: clearStorage) { + Text("Remove recorded data") + } + .disabled(transferIsRunning) + .padding() + + VStack(alignment: .leading, spacing: 5) { + Text("Transfer") + .font(.headline) + ProgressView(value: bytesTransferred, total: totalBytes) + .progressViewStyle(.linear) + .padding(.vertical, 5) + IconAndTextView( + icon: .externaldrive, + text: transferByteText) + IconAndTextView( + icon: .speedometer, + text: transferMeasurementText) + } + HStack { + Button(action: transferData) { + Text("Transfer") + } + .disabled(transferIsRunning) + .padding() + Spacer() + Button(action: saveTransfer) { + Text("Save") + } + .disabled(transferIsRunning || measurements.isEmpty) + .padding() + Spacer() + Button(action: discardTransfer) { + Text("Discard") + } + .disabled(transferIsRunning || measurements.isEmpty) + .padding() + } + Spacer() + VStack { + + } + } + .padding() + .navigationTitle("Data Transfer") + .navigationBarTitleDisplayMode(.large) + } + } + + func transferData() { + guard let info = bluetoothClient.lastDeviceInfo 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) + } + } + } + } + + func discardTransfer() { + self.measurements = [] + self.bytesTransferred = 0 + self.totalBytes = 0 + } + + func saveTransfer() { + // TODO: Save + + discardTransfer() + } + + func clearStorage() { + guard let byteCount = bluetoothClient.lastDeviceInfo?.numberOfRecordedBytes else { + return + } + Task { + guard await bluetoothClient.deleteDeviceData(byteCount: byteCount) else { + log.warning("Failed to delete data") + return + } + log.warning("Device storage cleared") + } + } +} + +struct TransferView_Previews: PreviewProvider { + static var previews: some View { + let storage = PersistentStorage(lastMeasurements: TemperatureMeasurement.mockData) + TransferView(bluetoothClient: .init()) + .environmentObject(storage) + } +} + +private extension TemperatureValue { + + var relativeValue: Double { + if case .value(let double) = self { + return double + } + return 0 + } +}