Transfer view, change data flow, actors
This commit is contained in:
parent
8b4c4800c9
commit
396571fd30
@ -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 = "<group>"; };
|
||||
88CDE0602A25108100114294 /* BluetoothClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothClient.swift; sourceTree = "<group>"; };
|
||||
88CDE0622A253AD900114294 /* TemperatureDataTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDataTransfer.swift; sourceTree = "<group>"; };
|
||||
88CDE0672A2698B400114294 /* TemperatureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureStorage.swift; sourceTree = "<group>"; };
|
||||
88CDE0672A2698B400114294 /* PersistentStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentStorage.swift; sourceTree = "<group>"; };
|
||||
88CDE06C2A28A92000114294 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = "<group>"; };
|
||||
88CDE06F2A28AEA300114294 /* TemperatureMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMeasurement.swift; sourceTree = "<group>"; };
|
||||
88CDE0732A28AEE500114294 /* DeviceManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagerDelegate.swift; sourceTree = "<group>"; };
|
||||
@ -79,6 +91,18 @@
|
||||
E2A553FA2A39C82D005204C3 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = "<group>"; };
|
||||
E2A553FC2A39C86B005204C3 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
|
||||
E2A553FE2A3A1024005204C3 /* DayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayView.swift; sourceTree = "<group>"; };
|
||||
E2A554002A3A6403005204C3 /* DeviceTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTime.swift; sourceTree = "<group>"; };
|
||||
E2A554042A4ADA93005204C3 /* TransferView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferView.swift; sourceTree = "<group>"; };
|
||||
E2A554062A4ADB9C005204C3 /* TemperatureDataTransferDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDataTransferDelegate.swift; sourceTree = "<group>"; };
|
||||
E2A554082A4ADCC9005204C3 /* DeviceConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnection.swift; sourceTree = "<group>"; };
|
||||
E2A5540B2A4ADFC6005204C3 /* DeviceInfoRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoRequest.swift; sourceTree = "<group>"; };
|
||||
E2A5540D2A4C9C4C005204C3 /* BluetoothRequestType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothRequestType.swift; sourceTree = "<group>"; };
|
||||
E2A5540F2A4C9C68005204C3 /* DeviceRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRequest.swift; sourceTree = "<group>"; };
|
||||
E2A554132A4C9C96005204C3 /* DeviceDataRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataRequest.swift; sourceTree = "<group>"; };
|
||||
E2A554152A4C9D2E005204C3 /* DeviceDataResetRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataResetRequest.swift; sourceTree = "<group>"; };
|
||||
E2E69B5F2A4CD48F00C6035E /* IconAndTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconAndTextView.swift; sourceTree = "<group>"; };
|
||||
E2E69B612A4D7C3100C6035E /* BluetoothScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothScanner.swift; sourceTree = "<group>"; };
|
||||
E2E69B652A4DA48B00C6035E /* BluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothDevice.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@ -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 = "<group>";
|
||||
@ -198,6 +227,21 @@
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 */,
|
||||
|
Binary file not shown.
@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
protocol DeviceManagerDelegate: AnyObject {
|
||||
|
||||
func deviceManager(shouldConnectToDevice: Bool)
|
||||
|
||||
func deviceManager(didReceive data: Data)
|
||||
|
||||
|
@ -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:
|
||||
|
70
TempTrack/Bluetooth/DeviceTime.swift
Normal file
70
TempTrack/Bluetooth/DeviceTime.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
150
TempTrack/Connection/BluetoothDevice.swift
Normal file
150
TempTrack/Connection/BluetoothDevice.swift
Normal file
@ -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<Data?, Never>)?
|
||||
|
||||
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: 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
|
||||
}
|
||||
}
|
176
TempTrack/Connection/BluetoothScanner.swift
Normal file
176
TempTrack/Connection/BluetoothScanner.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -0,0 +1 @@
|
||||
import Foundation
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
24
TempTrack/Views/IconAndTextView.swift
Normal file
24
TempTrack/Views/IconAndTextView.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
245
TempTrack/Views/TransferView.swift
Normal file
245
TempTrack/Views/TransferView.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user