From 6e0910e47f173cbb214bc9e6181a3c81e030230b Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sat, 3 Jun 2023 08:15:00 +0200 Subject: [PATCH] Basic display of info --- TempTrack.xcodeproj/project.pbxproj | 124 ++++++++++ .../xcshareddata/swiftpm/Package.resolved | 23 ++ TempTrack/Bluetooth/BluetoothClient.swift | 204 ++++++++++++++++ TempTrack/Bluetooth/BluetoothRequest.swift | 86 +++++++ TempTrack/ContentView.swift | 117 ++++++++- TempTrack/DeviceInfo.swift | 103 ++++++++ TempTrack/DeviceManager.swift | 228 ++++++++++++++++++ TempTrack/DeviceManagerDelegate.swift | 8 + TempTrack/DeviceState.swift | 82 +++++++ .../Temperature/TemperatureDataTransfer.swift | 123 ++++++++++ .../TemperatureDataTransferDelegate.swift | 6 + .../Temperature/TemperatureMeasurement.swift | 10 + TempTrack/Temperature/TemperatureSensor.swift | 94 ++++++++ TempTrack/Temperature/TemperatureValue.swift | 43 ++++ TempTrack/TemperatureStorage.swift | 12 + TempTrack/Views/DeviceInfoView.swift | 166 +++++++++++++ 16 files changed, 1417 insertions(+), 12 deletions(-) create mode 100644 TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 TempTrack/Bluetooth/BluetoothClient.swift create mode 100644 TempTrack/Bluetooth/BluetoothRequest.swift create mode 100644 TempTrack/DeviceInfo.swift create mode 100644 TempTrack/DeviceManager.swift create mode 100644 TempTrack/DeviceManagerDelegate.swift create mode 100644 TempTrack/DeviceState.swift create mode 100644 TempTrack/Temperature/TemperatureDataTransfer.swift create mode 100644 TempTrack/Temperature/TemperatureDataTransferDelegate.swift create mode 100644 TempTrack/Temperature/TemperatureMeasurement.swift create mode 100644 TempTrack/Temperature/TemperatureSensor.swift create mode 100644 TempTrack/Temperature/TemperatureValue.swift create mode 100644 TempTrack/TemperatureStorage.swift create mode 100644 TempTrack/Views/DeviceInfoView.swift diff --git a/TempTrack.xcodeproj/project.pbxproj b/TempTrack.xcodeproj/project.pbxproj index 44e501e..cde577d 100644 --- a/TempTrack.xcodeproj/project.pbxproj +++ b/TempTrack.xcodeproj/project.pbxproj @@ -11,6 +11,21 @@ 88CDE0512A2508E900114294 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0502A2508E900114294 /* ContentView.swift */; }; 88CDE0532A2508EA00114294 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0522A2508EA00114294 /* Assets.xcassets */; }; 88CDE0562A2508EA00114294 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0552A2508EA00114294 /* Preview Assets.xcassets */; }; + 88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE05C2A250F3C00114294 /* DeviceManager.swift */; }; + 88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE05E2A250F5200114294 /* DeviceState.swift */; }; + 88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0602A25108100114294 /* BluetoothClient.swift */; }; + 88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */; }; + 88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 88CDE0652A25D08F00114294 /* SFSafeSymbols */; }; + 88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0672A2698B400114294 /* TemperatureStorage.swift */; }; + 88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */ = {isa = PBXBuildFile; productRef = 88CDE06A2A2899C900114294 /* BottomSheet */; }; + 88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE06C2A28A92000114294 /* DeviceInfo.swift */; }; + 88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE06F2A28AEA300114294 /* TemperatureMeasurement.swift */; }; + 88CDE0722A28AEB900114294 /* TemperatureDataTransferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0712A28AEB900114294 /* TemperatureDataTransferDelegate.swift */; }; + 88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */; }; + 88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0752A28AF0900114294 /* TemperatureValue.swift */; }; + 88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */; }; + 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */; }; + 88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -19,6 +34,19 @@ 88CDE0502A2508E900114294 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 88CDE0522A2508EA00114294 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 88CDE0552A2508EA00114294 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 88CDE05C2A250F3C00114294 /* DeviceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceManager.swift; sourceTree = ""; }; + 88CDE05E2A250F5200114294 /* DeviceState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceState.swift; sourceTree = ""; }; + 88CDE0602A25108100114294 /* BluetoothClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothClient.swift; sourceTree = ""; }; + 88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDataTransfer.swift; sourceTree = ""; }; + 88CDE0672A2698B400114294 /* TemperatureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureStorage.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 = ""; }; + 88CDE0712A28AEB900114294 /* TemperatureDataTransferDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDataTransferDelegate.swift; sourceTree = ""; }; + 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagerDelegate.swift; sourceTree = ""; }; + 88CDE0752A28AF0900114294 /* TemperatureValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureValue.swift; sourceTree = ""; }; + 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSensor.swift; sourceTree = ""; }; + 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequest.swift; sourceTree = ""; }; + 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +54,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */, + 88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -51,10 +81,18 @@ 88CDE04D2A2508E900114294 /* TempTrack */ = { isa = PBXGroup; children = ( + 88CDE07C2A28AFE700114294 /* Views */, + 88CDE0792A28AF3E00114294 /* Bluetooth */, + 88CDE06E2A28AE8D00114294 /* Temperature */, 88CDE04E2A2508E900114294 /* TempTrackApp.swift */, + 88CDE05C2A250F3C00114294 /* DeviceManager.swift */, + 88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */, + 88CDE05E2A250F5200114294 /* DeviceState.swift */, 88CDE0502A2508E900114294 /* ContentView.swift */, 88CDE0522A2508EA00114294 /* Assets.xcassets */, 88CDE0542A2508EA00114294 /* Preview Content */, + 88CDE0672A2698B400114294 /* TemperatureStorage.swift */, + 88CDE06C2A28A92000114294 /* DeviceInfo.swift */, ); path = TempTrack; sourceTree = ""; @@ -67,6 +105,35 @@ path = "Preview Content"; sourceTree = ""; }; + 88CDE06E2A28AE8D00114294 /* Temperature */ = { + isa = PBXGroup; + children = ( + 88CDE06F2A28AEA300114294 /* TemperatureMeasurement.swift */, + 88CDE0712A28AEB900114294 /* TemperatureDataTransferDelegate.swift */, + 88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */, + 88CDE0752A28AF0900114294 /* TemperatureValue.swift */, + 88CDE0772A28AF2C00114294 /* TemperatureSensor.swift */, + ); + path = Temperature; + sourceTree = ""; + }; + 88CDE0792A28AF3E00114294 /* Bluetooth */ = { + isa = PBXGroup; + children = ( + 88CDE0602A25108100114294 /* BluetoothClient.swift */, + 88CDE07A2A28AF5100114294 /* BluetoothRequest.swift */, + ); + path = Bluetooth; + sourceTree = ""; + }; + 88CDE07C2A28AFE700114294 /* Views */ = { + isa = PBXGroup; + children = ( + 88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */, + ); + path = Views; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -83,6 +150,10 @@ dependencies = ( ); name = TempTrack; + packageProductDependencies = ( + 88CDE0652A25D08F00114294 /* SFSafeSymbols */, + 88CDE06A2A2899C900114294 /* BottomSheet */, + ); productName = TempTrack; productReference = 88CDE04B2A2508E900114294 /* TempTrack.app */; productType = "com.apple.product-type.application"; @@ -111,6 +182,10 @@ Base, ); mainGroup = 88CDE0422A2508E800114294; + packageReferences = ( + 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, + 88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */, + ); productRefGroup = 88CDE04C2A2508E900114294 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -138,7 +213,20 @@ buildActionMask = 2147483647; files = ( 88CDE0512A2508E900114294 /* ContentView.swift in Sources */, + 88CDE05F2A250F5200114294 /* DeviceState.swift in Sources */, + 88CDE0632A253AD900114294 /* TemperatureDataTransfer.swift in Sources */, + 88CDE0702A28AEA300114294 /* TemperatureMeasurement.swift in Sources */, + 88CDE05D2A250F3C00114294 /* DeviceManager.swift in Sources */, 88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */, + 88CDE0722A28AEB900114294 /* TemperatureDataTransferDelegate.swift in Sources */, + 88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */, + 88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */, + 88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */, + 88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */, + 88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */, + 88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */, + 88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */, + 88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -270,6 +358,8 @@ DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Connect to loggers"; + INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Connect to temperature loggers"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -299,6 +389,8 @@ DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Connect to loggers"; + INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Connect to temperature loggers"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -339,6 +431,38 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; + }; + }; + 88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weitieda/bottom-sheet"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 88CDE0652A25D08F00114294 /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; + 88CDE06A2A2899C900114294 /* BottomSheet */ = { + isa = XCSwiftPackageProductDependency; + package = 88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */; + productName = BottomSheet; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 88CDE0432A2508E800114294 /* Project object */; } diff --git a/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..64ac4f3 --- /dev/null +++ b/TempTrack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "bottom-sheet", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weitieda/bottom-sheet", + "state" : { + "revision" : "6b21007153365235418f3943a960a1f9546592e0", + "version" : "1.0.12" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", + "version" : "4.1.1" + } + } + ], + "version" : 2 +} diff --git a/TempTrack/Bluetooth/BluetoothClient.swift b/TempTrack/Bluetooth/BluetoothClient.swift new file mode 100644 index 0000000..301a2bd --- /dev/null +++ b/TempTrack/Bluetooth/BluetoothClient.swift @@ -0,0 +1,204 @@ +import Foundation +import SwiftUI + +enum BluetoothResponseType: UInt8 { + + /// The response to the last request is provided + case success = 0 + + /// Invalid command received + case invalidCommand = 1 + + case responseTooLarge = 2 + + case unknownCommand = 3 + + case invalidNumberOfBytesToDelete = 4 +} + +final class BluetoothClient: ObservableObject { + + private let updateInterval = 3.0 + + private let connection = DeviceManager() + + private let recorder = TemperatureStorage() + + init(deviceInfo: DeviceInfo? = nil) { + connection.delegate = self + self.deviceInfo = deviceInfo + } + + func connect() -> Bool { + connection.connect() + } + + @Published + private(set) var deviceState: DeviceState = .disconnected { + didSet { + print("State: \(deviceState.text)") + if case .configured = deviceState { + startRegularUpdates() + } else { + endRegularUpdates() + } + } + } + + @Published + private(set) var deviceInfo: DeviceInfo? + + private var openRequests: [BluetoothRequest] = [] + + private var runningRequest: BluetoothRequest? + + private var runningTransfer: TemperatureDataTransfer? + + func updateDeviceInfo() { + addRequest(.getInfo) + } + + private var dataUpdateTimer: Timer? + + private func startRegularUpdates() { + guard dataUpdateTimer == nil else { + return + } + print("Starting updates") + dataUpdateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + self.updateDeviceInfo() + } + + dataUpdateTimer?.fire() + } + + private func endRegularUpdates() { + guard let dataUpdateTimer else { + return + } + dataUpdateTimer.invalidate() + runningRequest = nil + self.dataUpdateTimer = nil + print("Ending updates") + } + + private func performNextRequest() { + guard runningRequest == nil else { + return + } + guard !openRequests.isEmpty else { + return + } + let next = openRequests.removeFirst() + //print("Starting request \(next)") + + guard connection.send(next.serialized) else { + print("Failed to start request \(next)") + performNextRequest() + return + } + runningRequest = next + } + + func addRequest(_ request: BluetoothRequest) { + openRequests.append(request) + performNextRequest() + } + + + func collectRecordedData() -> Bool { + guard let info = deviceInfo else { + return false + } + let transfer = TemperatureDataTransfer(info: info) + runningTransfer = transfer + runningTransfer?.delegate = recorder + let next = transfer.nextRequest() + addRequest(next) + return true + } + + private func didReceive(data: Data, offset: Int, count: Int) { + guard let runningTransfer else { + return // TODO: Start new transfer? + } + runningTransfer.add(data: data, offset: offset, count: count) + continueTransfer() + } + + private func continueTransfer() { + guard let runningTransfer else { + return // TODO: Start new transfer? + } + let next = runningTransfer.nextRequest() + addRequest(next) + } + + private func decode(info: Data) { + guard let newInfo = DeviceInfo(info: info) else { + return + } + self.deviceInfo = newInfo + guard let runningTransfer else { + return + } + runningTransfer.update(info: newInfo) + let next = runningTransfer.nextRequest() + addRequest(next) + } +} + +extension BluetoothClient: DeviceManagerDelegate { + + func deviceManager(didReceive data: Data) { + defer { + self.runningRequest = nil + performNextRequest() + } + guard let runningRequest else { + print("No request active, but \(data) received") + return + } + + guard data.count > 0 else { + print("No response data for request \(runningRequest)") + return + } + + guard let type = BluetoothResponseType(rawValue: data[0]) else { + print("Unknown response \(data[0]) for request \(runningRequest)") + return + } + guard type == .success else { + print("Error response \(data[0]) for request \(runningRequest)") + // If clearing the recording buffer fails due to byte mismatch, + // then requesting new info will resolve the mismatch, and the transfer will be resumed + // 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) + return + } + let payload = data.dropFirst() + + switch runningRequest { + case .getInfo: + decode(info: payload) + case .getRecordingData(let offset, let count): + didReceive(data: payload, offset: offset, count: count) + case .clearRecordingBuffer: + runningTransfer?.completeTransfer() + runningTransfer = nil + } + } + + func deviceManager(didChangeState state: DeviceState) { + DispatchQueue.main.async { + self.deviceState = state + } + } +} + diff --git a/TempTrack/Bluetooth/BluetoothRequest.swift b/TempTrack/Bluetooth/BluetoothRequest.swift new file mode 100644 index 0000000..9905ec1 --- /dev/null +++ b/TempTrack/Bluetooth/BluetoothRequest.swift @@ -0,0 +1,86 @@ +import Foundation + +enum BluetoothRequest { + /** + * Request the number of bytes already recorded + * + * Request: + * - No additional bytes expected + * + * Response: + * - `BluetoothResponseType.success` + * - the number of recorded bytes as a `Uint16` (2 bytes) + * - the number of seconds until the next measurement as a `Uint16` (2 bytes) + * - the number of seconds between measurements as a `Uint16` (2 bytes) + * - the number of measurements as a `Uint16` (2 bytes) + * - the maximum number of bytes to request as a `Uint16` (2 bytes) + * - the number of seconds since power on as a `Uint32` (4 bytes) + */ + case getInfo + + /** + * Request recording data + * + * Request: + * - Bytes 1-2: Memory offset (`UInt16`) + * - Bytes 3-4: Number of bytes (`UInt16`) + * + * Response: + * - `BluetoothResponseType.success`, plus the requested bytes + * - `BluetoothResponseType.responseTooLarge` if too many bytes are requested + */ + case getRecordingData(offset: Int, count: Int) + + /** + * Request deletion of recordings + * + * Request: + * - Bytes 1-2: Number of bytes to clear (uint16_t) + * + * Response: + * - `BluetoothResponseType.success` + * - `BluetoothResponseType.invalidNumberOfBytesToDelete`, if the number of bytes does not match. + * This may happen when a new temperature recording is performed in between calls + */ + case clearRecordingBuffer(byteCount: Int) + + var serialized: Data { + let firstByte = Data([byte]) + switch self { + case .getInfo: + return firstByte + case .getRecordingData(let offset, let count): + return firstByte + count.twoByteData + offset.twoByteData + case .clearRecordingBuffer(let byteCount): + return firstByte + byteCount.twoByteData + } + } + + var byte: UInt8 { + switch self { + case .getInfo: return 0 + case .getRecordingData: return 1 + case .clearRecordingBuffer: return 2 + } + } +} + +private extension Int { + + var twoByteData: Data { + let value = UInt16(clamping: self) + return Data([value.low, value.high]) + } +} + +private extension UInt16 { + + var low: UInt8 { + UInt8(self & 0xFF) + } + + var high: UInt8 { + UInt8((self >> 8) & 0xFF) + } +} + diff --git a/TempTrack/ContentView.swift b/TempTrack/ContentView.swift index 66031e6..025feae 100644 --- a/TempTrack/ContentView.swift +++ b/TempTrack/ContentView.swift @@ -1,26 +1,119 @@ -// -// ContentView.swift -// TempTrack -// -// Created by iMac on 29.05.23. -// - import SwiftUI +import SFSafeSymbols +import BottomSheet struct ContentView: View { + + @ObservedObject + var client = BluetoothClient() + + init() { + + } + + init(client: BluetoothClient) { + self.client = client + } + + @State + var showDeviceInfo = false + + var averageTemperature: Double? { + let t1 = client.deviceInfo?.sensor1?.optionalValue + guard let t0 = client.deviceInfo?.sensor0?.optionalValue else { + return t1 + } + guard let t1 else { + return t0 + } + return (t0 + t1) / 2 + } + + var hasTemperature: Bool { + averageTemperature != nil + } + var temperatureString: String { + guard let temp = averageTemperature else { + return "?" + } + return String(format: "%.0f°", locale: .current, temp) + } + + var temperatureIcon: SFSymbol { + guard let temp = averageTemperature else { + return .thermometerMediumSlash + } + guard temp > 0 else { + return .thermometerSnowflake + } + guard temp > 15 else { + return .thermometerLow + } + guard temp > 25 else { + return .thermometerMedium + } + return .thermometerHigh + } + var body: some View { VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") + HStack { + Image(systemSymbol: .iphone) + .frame(width: 30) + Text(client.deviceState.text) + Spacer() + } + Spacer() + Image(systemSymbol: temperatureIcon) + .font(.system(size: 200, weight: .light)) + if hasTemperature { + Text(temperatureString) + .font(.system(size: 100, weight: .light)) + } + + Spacer() + HStack(alignment: .center) { + Button(action: { + _ = client.collectRecordedData() + }) { + Text("Transfer") + }.padding() + Spacer() + Button { + self.showDeviceInfo = true + } label: { + Image(systemSymbol: .infoCircle) + .font(.system(size: 40, weight: .regular)) + }.disabled(client.deviceInfo == nil) + }.padding() } .padding() + .bottomSheet(isPresented: $showDeviceInfo, height: 520) { + if let info = client.deviceInfo { + DeviceInfoView(info: info) + } else { + EmptyView() + } + } + } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + ContentView(client: BluetoothClient(deviceInfo: .mock)) } } + +extension HorizontalAlignment { + + private struct InfoTextAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let infoTextAlignmentGuide = HorizontalAlignment( + InfoTextAlignment.self + ) +} diff --git a/TempTrack/DeviceInfo.swift b/TempTrack/DeviceInfo.swift new file mode 100644 index 0000000..13d4109 --- /dev/null +++ b/TempTrack/DeviceInfo.swift @@ -0,0 +1,103 @@ +import Foundation + +struct DeviceInfo { + + let receivedDate: Date + + /// The number of bytes recorded by the tracker + let numberOfRecordedBytes: Int + + /// The number of measurements already performed + let numberOfMeasurements: Int + + /// The interval between measurements (in seconds) + let measurementInterval: Int + + /// The maximum number of bytes which can be requested + let transferBlockSize: Int + + let deviceStartTime: Date + + let nextMeasurement: Date + + let sensor0: TemperatureSensor? + + let sensor1: TemperatureSensor? + + let storageSize: Int + + /** + The number of seconds the device has been powered on + */ + var numberOfSecondsRunning: Int { + Int(-deviceStartTime.timeIntervalSinceNow) + } + + var storageFillRatio: Double { + Double(numberOfRecordedBytes) / Double(storageSize) + } + + var storageFillPercentage: Int { + Int((storageFillRatio * 100).rounded()) + } +} + +extension DeviceInfo { + + static var size = 38 + + init?(info: Data) { + guard info.count == DeviceInfo.size else { + print("Invalid info size \(info.count)") + return nil + } + let data = Array(info) + self.receivedDate = Date() + self.numberOfRecordedBytes = .init(high: data[1], low: data[0]) + let secondsUntilNextMeasurement = UInt16(high: data[3], low: data[2]) + self.nextMeasurement = Date().addingTimeInterval(Double(secondsUntilNextMeasurement)) + self.measurementInterval = .init(high: data[5], low: data[4]) + self.numberOfMeasurements = .init(high: data[7], low: data[6]) + self.transferBlockSize = .init(high: data[9], low: data[8]) + let secondsSincePowerOn = Int(uint32: data[13], data[12], data[11], data[10]) + self.deviceStartTime = Date().addingTimeInterval(Double(-secondsSincePowerOn)) + self.sensor0 = .init(address: Array(data[16..<24]), valueByte: data[14], secondsAgo: UInt16(high: data[33], low: data[32])) + self.sensor1 = .init(address: Array(data[24..<32]), valueByte: data[15], secondsAgo: UInt16(high: data[35], low: data[34])) + self.storageSize = .init(high: data[37], low: data[36]) + } +} + +private extension UInt16 { + + init(high: UInt8, low: UInt8) { + self = UInt16(high) << 8 + UInt16(low) + } +} + +private extension Int { + + init(uint32 byte0: UInt8, _ byte1: UInt8, _ byte2: UInt8, _ byte3: UInt8) { + self = (Int(byte0) << 24) | (Int(byte1) << 16) | (Int(byte2) << 8) | Int(byte3) + } + + init(high: UInt8, low: UInt8) { + self = Int(high) << 8 + Int(low) + } +} + +extension DeviceInfo { + + static var mock: DeviceInfo { + .init( + receivedDate: Date(), + numberOfRecordedBytes: 123, + numberOfMeasurements: 234, + measurementInterval: 60, + transferBlockSize: 180, + deviceStartTime: .now.addingTimeInterval(-1000), + 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)), + storageSize: 10000) + } +} diff --git a/TempTrack/DeviceManager.swift b/TempTrack/DeviceManager.swift new file mode 100644 index 0000000..56a9624 --- /dev/null +++ b/TempTrack/DeviceManager.swift @@ -0,0 +1,228 @@ +import Foundation +import CoreBluetooth + +final class DeviceManager: NSObject, CBCentralManagerDelegate { + + static let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001") + + static let characteristicUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000002") + + private var manager: CBCentralManager! = nil + + private(set) var lastRSSI: Int = 0 + + weak var delegate: DeviceManagerDelegate? + + var state: DeviceState = .disconnected { + didSet { + delegate?.deviceManager(didChangeState: state) + } + } + + override init() { + super.init() + self.manager = CBCentralManager(delegate: self, queue: nil) + } + + + private var dataUpdateTimer: Timer? + + @discardableResult + func connect() -> Bool { + switch state { + case .bluetoothDisabled: + print("Can't connect, bluetooth disabled") + return false + case .disconnected, .bluetoothEnabled: + break + default: + return true + } + guard !manager.isScanning else { + state = .scanning + return true + } + shouldConnectIfPossible = true + state = .scanning + manager.scanForPeripherals(withServices: [DeviceManager.serviceUUID]) + return true + } + + private var shouldConnectIfPossible = true + + func disconnect() { + shouldConnectIfPossible = false + switch state { + case .bluetoothDisabled, .bluetoothEnabled: + return + case .scanning: + manager.stopScan() + state = .disconnected + return + case .connecting(let device), + .discoveringCharacteristic(let device), + .discoveringServices(device: let device), + .configured(let device, _): + manager.cancelPeripheralConnection(device) + manager.stopScan() + state = .disconnected + return + case .disconnected: + return + } + } + + @discardableResult + func send(_ data: Data) -> Bool { + guard case .configured(let device, let characteristic) = state else { + return false + } + device.writeValue(data, for: characteristic, type: .withResponse) + //DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { } + return self.read() + } + + @discardableResult + private func read() -> Bool { + guard case .configured(let device, let characteristic) = state else { + return false + } + device.readValue(for: characteristic) + return true + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + //print("Found device '\(peripheral.name ?? "NO_NAME")'") + peripheral.delegate = self + manager.connect(peripheral) + manager.stopScan() + state = .connecting(device: peripheral) + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOff: + state = .bluetoothDisabled + case .poweredOn: + state = .bluetoothEnabled + connect() + case .unsupported: + state = .bluetoothDisabled + print("Bluetooth is not supported") + case .unknown: + state = .bluetoothDisabled + print("Bluetooth state is unknown") + case .resetting: + state = .bluetoothDisabled + print("Bluetooth is resetting") + case .unauthorized: + state = .bluetoothDisabled + print("Bluetooth is not authorized") + @unknown default: + state = .bluetoothDisabled + print("Unknown state \(central.state)") + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + //print("Connected to " + peripheral.name!) + peripheral.discoverServices([DeviceManager.serviceUUID]) + state = .discoveringServices(device: peripheral) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + print("Disconnected from " + peripheral.name!) + state = .disconnected + // Attempt to reconnect + if shouldConnectIfPossible { + connect() + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + print("Failed to connect device '\(peripheral.name ?? "NO_NAME")'") + if let error = error { + print(error) + } + state = manager.isScanning ? .scanning : .disconnected + // Attempt to reconnect + if shouldConnectIfPossible { + connect() + } + } +} + +extension DeviceManager: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let services = peripheral.services, !services.isEmpty else { + print("No services found for device '\(peripheral.name ?? "NO_NAME")'") + manager.cancelPeripheralConnection(peripheral) + return + } + guard let service = services.first(where: { $0.uuid.uuidString == DeviceManager.serviceUUID.uuidString }) else { + print("Required service not found for '\(peripheral.name ?? "NO_NAME")': \(services.map { $0.uuid.uuidString})") + manager.cancelPeripheralConnection(peripheral) + return + } + peripheral.discoverCharacteristics([DeviceManager.characteristicUUID], for: service) + state = .discoveringCharacteristic(device: peripheral) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + print("Failed to discover characteristics: \(error)") + manager.cancelPeripheralConnection(peripheral) + return + } + guard let characteristics = service.characteristics, !characteristics.isEmpty else { + print("No characteristics found for device") + manager.cancelPeripheralConnection(peripheral) + return + } + for characteristic in characteristics { + guard characteristic.uuid == DeviceManager.characteristicUUID else { + print("Unused characteristic \(characteristic.uuid.uuidString)") + continue + } + state = .configured(device: peripheral, characteristic: characteristic) + peripheral.setNotifyValue(true, for: characteristic) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + print("Peripheral failed to write value for \(characteristic.uuid.uuidString): \(error)") + } + //print("Peripheral did write value for \(characteristic.uuid.uuidString)") + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + if let error = error { + print("Failed to get RSSI: \(error)") + return + } + lastRSSI = RSSI.intValue + print("RSSI: \(lastRSSI)") + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + print("Failed to read value update: \(error)") + return + } + guard case .configured(device: _, characteristic: let storedCharacteristic) = state else { + print("Received data while not properly configured") + return + } + guard characteristic.uuid == storedCharacteristic.uuid else { + print("Read unknown characteristic \(characteristic.uuid.uuidString)") + return + } + guard let data = characteristic.value else { + print("No data") + return + } + delegate?.deviceManager(didReceive: data) + } +} diff --git a/TempTrack/DeviceManagerDelegate.swift b/TempTrack/DeviceManagerDelegate.swift new file mode 100644 index 0000000..dcd5aec --- /dev/null +++ b/TempTrack/DeviceManagerDelegate.swift @@ -0,0 +1,8 @@ +import Foundation + +protocol DeviceManagerDelegate: AnyObject { + + func deviceManager(didReceive data: Data) + + func deviceManager(didChangeState state: DeviceState) +} diff --git a/TempTrack/DeviceState.swift b/TempTrack/DeviceState.swift new file mode 100644 index 0000000..86f2fad --- /dev/null +++ b/TempTrack/DeviceState.swift @@ -0,0 +1,82 @@ +import Foundation +import CoreBluetooth + +enum DeviceState { + + case bluetoothDisabled + + case bluetoothEnabled + + case scanning + + case connecting(device: CBPeripheral) + + case discoveringServices(device: CBPeripheral) + + case discoveringCharacteristic(device: CBPeripheral) + + case configured(device: CBPeripheral, characteristic: CBCharacteristic) + + case disconnected + + var text: String { + switch self { + case .bluetoothDisabled: + return "Bluetooth is disabled" + case .bluetoothEnabled: + return "Bluetooth enabled" + case .scanning: + return "Scanning for devices..." + case .connecting(let device): + guard let name = device.name else { + return "Connecting to device..." + } + return "Connecting to \(name)..." + case .discoveringServices(let device): + guard let name = device.name else { + return "Setting up device..." + } + return "Setting up \(name)..." + case .discoveringCharacteristic(let device): + guard let name = device.name else { + return "Setting up device..." + } + return "Setting up \(name)..." + case .configured(let device, _): + guard let name = device.name else { + return "Connected" + } + return "Connected to \(name)" + case .disconnected: + return "Not connected" + } + } +} + +extension DeviceState: CustomStringConvertible { + + var description: String { + switch self { + case .bluetoothDisabled: + return "Bluetooth disabled" + case .bluetoothEnabled: + return "Bluetooth enabled" + case .scanning: + return "Searching for device" + case .connecting: + return "Connecting to device" + case .discoveringServices: + return "Discovering services" + case .discoveringCharacteristic: + return "Discovering characteristics" + case .configured: + return "Connected" + case .disconnected: + return "Disconnected" + } + } +} + +extension DeviceState: Equatable { + +} diff --git a/TempTrack/Temperature/TemperatureDataTransfer.swift b/TempTrack/Temperature/TemperatureDataTransfer.swift new file mode 100644 index 0000000..bd999dc --- /dev/null +++ b/TempTrack/Temperature/TemperatureDataTransfer.swift @@ -0,0 +1,123 @@ +import Foundation + +final class TemperatureDataTransfer { + + private let startDateOfCurrentTransfer: Date + + private let interval: Int + + weak var delegate: TemperatureDataTransferDelegate? + + private var dataBuffer: Data = Data() + + private(set) var currentByteIndex = 0 + + private(set) var size: Int + + private(set) var blockSize: Int + + private var numberOfRecordingsInCurrentTransfer = 0 + + private(set) var lastRecording: TemperatureMeasurement = .init(sensor0: .notFound, sensor1: .notFound, date: .now) + + private var dateOfNextRecording: Date { + startDateOfCurrentTransfer.addingTimeInterval(TimeInterval(numberOfRecordingsInCurrentTransfer * interval)) + } + + var unprocessedByteCount: Int { + dataBuffer.count + } + + var remainingBytesToTransfer: Int { + size - currentByteIndex + } + + init(info: DeviceInfo) { + self.interval = info.measurementInterval + let recordingTime = info.numberOfMeasurements * info.measurementInterval + self.startDateOfCurrentTransfer = info.nextMeasurement.addingTimeInterval(-TimeInterval(recordingTime)) + self.size = info.numberOfRecordedBytes + self.blockSize = info.transferBlockSize + } + + func update(info: DeviceInfo) { + self.size = info.numberOfRecordedBytes + self.blockSize = info.transferBlockSize + } + + func nextRequest() -> BluetoothRequest { + guard remainingBytesToTransfer > 0 else { + return .clearRecordingBuffer(byteCount: size) + } + let chunkSize = min(remainingBytesToTransfer, blockSize) + return .getRecordingData(offset: currentByteIndex, count: chunkSize) + } + + func add(data: Data, offset: Int, count: Int) { + dataBuffer.append(data) + currentByteIndex += data.count + processBytes() + } + + private func processBytes() { + while !dataBuffer.isEmpty { + let byte = dataBuffer.removeFirst() + guard (byte == 0xFF) else { + addRelative(byte: byte) + continue + } + guard dataBuffer.count >= 2 else { + // Wait for more data + return + } + let temp0 = TemperatureValue(byte: dataBuffer.removeFirst()) + let temp1 = TemperatureValue(byte: dataBuffer.removeFirst()) + add(sensor0: temp0, sensor1: temp1) + } + } + + func completeTransfer() { + processBytes() + } + + private func addRelative(byte: UInt8) { + add(sensor0: convertTemp(value: byte >> 4, relativeTo: lastRecording.sensor0), + sensor1: convertTemp(value: byte & 0x0F, relativeTo: lastRecording.sensor1)) + } + + private func add(sensor0: TemperatureValue, sensor1: TemperatureValue) { + let measurement = TemperatureMeasurement( + sensor0: sensor0, + sensor1: sensor1, + date: dateOfNextRecording) + + numberOfRecordingsInCurrentTransfer += 1 + if measurement.sensor0.isValid { + lastRecording.sensor0 = measurement.sensor0 + } + if measurement.sensor1.isValid { + lastRecording.sensor1 = measurement.sensor1 + } + lastRecording.date = measurement.date + delegate?.didReceiveRecording(measurement) + } + + private func convertTemp(value: UInt8, relativeTo previous: TemperatureValue) -> TemperatureValue { + if value == 0 { + return .notFound + } + let newValue = previous.relativeValue - (Double(value) - 8) * 0.5 + return .value(newValue) + } +} + + +private extension TemperatureValue { + + var relativeValue: Double { + if case .value(let double) = self { + return double + } + return 0 + } +} diff --git a/TempTrack/Temperature/TemperatureDataTransferDelegate.swift b/TempTrack/Temperature/TemperatureDataTransferDelegate.swift new file mode 100644 index 0000000..648ede8 --- /dev/null +++ b/TempTrack/Temperature/TemperatureDataTransferDelegate.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol TemperatureDataTransferDelegate: AnyObject { + + func didReceiveRecording(_ measurement: TemperatureMeasurement) +} diff --git a/TempTrack/Temperature/TemperatureMeasurement.swift b/TempTrack/Temperature/TemperatureMeasurement.swift new file mode 100644 index 0000000..06d2a46 --- /dev/null +++ b/TempTrack/Temperature/TemperatureMeasurement.swift @@ -0,0 +1,10 @@ +import Foundation + +struct TemperatureMeasurement { + + var sensor0: TemperatureValue + + var sensor1: TemperatureValue + + var date: Date +} diff --git a/TempTrack/Temperature/TemperatureSensor.swift b/TempTrack/Temperature/TemperatureSensor.swift new file mode 100644 index 0000000..a21bfa2 --- /dev/null +++ b/TempTrack/Temperature/TemperatureSensor.swift @@ -0,0 +1,94 @@ +import Foundation +import SFSafeSymbols + +struct TemperatureSensor { + + let address: [UInt8] + + let value: TemperatureValue + + let date: Date + + var optionalValue: Double? { + value.optionalValue + } + + var hexAddress: String { + String(format: "%02X %02X %02X %02X %02X %02X %02X %02X", + address[0], address[1], address[2], address[3], + address[4], address[5], address[6], address[7]) + } + + var temperatureIcon: SFSymbol { + TemperatureSensor.temperatureIcon(optionalValue) + } + + var temperatureText: String { + value.text + } + + var updateText: String { + date.timePassedText + } + + static func temperatureIcon(_ temp: Double?) -> SFSymbol { + guard let temp else { + return .thermometerMediumSlash + } + guard temp > 0 else { + return .thermometerSnowflake + } + guard temp > 15 else { + return .thermometerLow + } + guard temp > 25 else { + return .thermometerMedium + } + return .thermometerHigh + } +} + +extension TemperatureSensor { + + init?(address: [UInt8], valueByte: UInt8, secondsAgo: UInt16) { + guard address.contains(where: { $0 != 0 }) else { + // Empty address + return nil + } + self.address = address + self.value = .init(byte: valueByte) + self.date = Date().addingTimeInterval(-TimeInterval(secondsAgo)) + } +} + +extension Date { + + var timePassedText: String { + let secs = Int(-timeIntervalSinceNow.rounded()) + guard secs > 1 else { + return "Now" + } + guard secs >= 60 else { + return "\(secs) seconds ago" + } + let minutes = secs / 60 + guard minutes > 1 else { + return "1 minute ago" + } + guard minutes >= 60 else { + return "\(minutes) minutes ago" + } + let hours = minutes / 60 + guard hours > 1 else { + return "1 hour ago" + } + guard hours >= 60 else { + return "\(hours) hours ago" + } + let days = hours / 24 + guard days > 1 else { + return "1 day ago" + } + return "\(days) days ago" + } +} diff --git a/TempTrack/Temperature/TemperatureValue.swift b/TempTrack/Temperature/TemperatureValue.swift new file mode 100644 index 0000000..6ba3b2f --- /dev/null +++ b/TempTrack/Temperature/TemperatureValue.swift @@ -0,0 +1,43 @@ +import Foundation + +enum TemperatureValue { + case notFound + case invalidMeasurement + case value(Double) + + init(byte: UInt8) { + switch byte { + case 0: + self = .notFound + case 1: + self = .invalidMeasurement + default: + self = .value(Double(byte) * 0.5 - 40) + } + } + + var optionalValue: Double? { + if case .value(let val) = self { + return val + } + return nil + } + + var isValid: Bool { + if case .value = self { + return true + } + return false + } + + var text: String { + switch self { + case .notFound: + return "No sensor" + case .invalidMeasurement: + return "Invalid" + case .value(let double): + return "\(Int(double.rounded()))°C" + } + } +} diff --git a/TempTrack/TemperatureStorage.swift b/TempTrack/TemperatureStorage.swift new file mode 100644 index 0000000..69ceabd --- /dev/null +++ b/TempTrack/TemperatureStorage.swift @@ -0,0 +1,12 @@ +import Foundation + +final class TemperatureStorage { + +} + +extension TemperatureStorage: TemperatureDataTransferDelegate { + + func didReceiveRecording(_ measurement: TemperatureMeasurement) { + //print("Temperature \(measurement.date): \(temp1), \(temp2)") + } +} diff --git a/TempTrack/Views/DeviceInfoView.swift b/TempTrack/Views/DeviceInfoView.swift new file mode 100644 index 0000000..21c1300 --- /dev/null +++ b/TempTrack/Views/DeviceInfoView.swift @@ -0,0 +1,166 @@ +import SwiftUI +import SFSafeSymbols + +private let df: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.dateStyle = .short + df.timeStyle = .short + return df +}() + +struct DeviceInfoView: View { + + private let storageWarnBytes = 500 + + let info: DeviceInfo + + private var runTimeString: String { + let number = info.numberOfSecondsRunning + guard number >= 60 else { + return "\(number) seconds" + } + let minutes = number / 60 + guard minutes > 1 else { + return "1 minute" + } + guard minutes >= 60 else { + return "\(minutes) minutes" + } + let hours = minutes / 60 + guard hours > 1 else { + return "1 hour" + } + guard hours >= 60 else { + return "\(hours) hours" + } + let days = hours / 24 + guard days > 1 else { + return "1 day" + } + return "\(days) days" + } + + private var nextUpdateText: String { + let secs = Int(info.nextMeasurement.timeIntervalSinceNow.rounded()) + guard secs > 1 else { + return "Now" + } + return "In \(secs) seconds" + } + + private var storageIcon: SFSymbol { + if info.storageSize - info.numberOfRecordedBytes < storageWarnBytes { + return .externaldriveTrianglebadgeExclamationmark + } + return .externaldrive + } + + private var storageText: String { + if info.storageSize <= 0 { + return "\(info.numberOfRecordedBytes)" + } + return "\(info.numberOfRecordedBytes) / \(info.storageSize) Bytes (\(info.storageFillPercentage) %)" + } + + func sensorView(_ sensor: TemperatureSensor?, id: Int) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text("Sensor \(id)") + .font(.headline) + if let sensor { + HStack { + Image(systemSymbol: sensor.temperatureIcon) + .frame(width: 30) + Text(sensor.temperatureText) + } + HStack { + Image(systemSymbol: .arrowTriangle2Circlepath) + .frame(width: 30) + Text(sensor.updateText) + Spacer() + } + HStack { + Image(systemSymbol: .tag) + .frame(width: 30) + Text(sensor.hexAddress) + } + } else { + HStack { + Image(systemSymbol: .thermometerMediumSlash) + .frame(width: 30) + Text("Not connected") + } + } + + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + VStack(alignment: .leading, spacing: 5) { + Text("Recording") + .font(.headline) + HStack { + Image(systemSymbol: .power) + .frame(width: 30) + Text(df.string(from: info.deviceStartTime)) + Spacer() + } + HStack { + Image(systemSymbol: .clock) + .frame(width: 30) + Text("\(runTimeString)") + } + HStack { + Image(systemSymbol: .stopwatch) + .frame(width: 30) + Text("Every \(info.measurementInterval) seconds") + Spacer() + } + HStack { + Image(systemSymbol: .arrowTriangle2Circlepath) + .frame(width: 30) + Text(nextUpdateText) + Spacer() + } + } + VStack(alignment: .leading, spacing: 5) { + Text("Storage") + .font(.headline) + HStack { + Image(systemSymbol: .speedometer) + .frame(width: 30) + Text("\(info.numberOfMeasurements) Measurements") + } + HStack { + Image(systemSymbol: storageIcon) + .frame(width: 30) + Text(storageText) + } + HStack { + Image(systemSymbol: .iphoneAndArrowForward) + .frame(width: 30) + Text("\(info.transferBlockSize) Byte Block Size") + } + } + sensorView(info.sensor0, id: 0) + sensorView(info.sensor1, id: 1) + Spacer() + HStack { + Spacer() + Text("Updated \(info.receivedDate.timePassedText)") + .font(.footnote) + .textCase(.uppercase) + Spacer() + } + }.padding() + } +} + +struct DeviceInfoView_Previews: PreviewProvider { + static var previews: some View { + DeviceInfoView(info: .mock) + .previewLayout(.fixed(width: 375, height: 500)) + } +} +