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 c52ec07..ba712d4 100644 Binary files a/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/TempTrack.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/TempTrack/Bluetooth/BluetoothClient.swift b/TempTrack/Bluetooth/BluetoothClient.swift 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 + } +}