Transfer view, change data flow, actors

This commit is contained in:
Christoph Hagen 2023-07-02 17:29:39 +02:00
parent 8b4c4800c9
commit 396571fd30
24 changed files with 1285 additions and 302 deletions

View File

@ -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 */,

View File

@ -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
@ -22,9 +22,23 @@ final class BluetoothClient: ObservableObject {
return false
}
init(storage: TemperatureStorage, deviceInfo: DeviceInfo? = nil) {
private var isUpdatingFlag = false
@Published
var shouldConnect: Bool {
didSet {
isUpdatingFlag = true
connection.shouldConnectIfPossible = shouldConnect
log.info("Should connect: \(shouldConnect)")
isUpdatingFlag = false
}
}
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)
@ -103,6 +116,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 {
return
@ -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)
}
@ -203,6 +209,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 {
performNextRequest()
@ -232,20 +245,21 @@ extension BluetoothClient: DeviceManagerDelegate {
return
case .invalidNumberOfBytesToDelete:
guard case .clearRecordingBuffer = runningRequest else {
log.error("Request \(runningRequest) received non-matching response about number of bytes to delete")
return
}
// If clearing the recording buffer fails due to byte mismatch,
// then requesting new info will resolve the mismatch, and the transfer will be resumed
addRequest(.getInfo)
return
}
log.error("Request \(runningRequest) received non-matching responde about number of bytes to delete")
case .responseTooLarge:
guard case .getRecordingData = runningRequest else {
log.error("Unexpectedly exceeded payload size for request \(runningRequest)")
return
}
// If requesting bytes fails due to the response size,
// then requesting new info will update the response size, and the transfer will be resumed
addRequest(.getInfo)
return
}
log.error("Unexpectedly exceeded payload size for request \(runningRequest)")
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 {
}
}
*/

View File

@ -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
}
}
}
*/

View File

@ -2,7 +2,11 @@ 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
@ -10,31 +14,19 @@ struct DeviceInfo {
/// 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
let storageSize: Int
@ -50,47 +42,76 @@ struct DeviceInfo {
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)
}

View File

@ -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
}
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() {
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

View File

@ -2,6 +2,8 @@ import Foundation
protocol DeviceManagerDelegate: AnyObject {
func deviceManager(shouldConnectToDevice: Bool)
func deviceManager(didReceive data: Data)
func deviceManager(didChangeState state: DeviceState)

View File

@ -5,8 +5,6 @@ enum DeviceState {
case bluetoothDisabled
case bluetoothEnabled
case scanning
case connecting(device: CBPeripheral)
@ -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:

View 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)
}
}

View 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
}
}

View 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)
}
}

View File

@ -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
}
}
*/

View File

@ -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: {
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.scanner.isScanningForDevices.toggle()
} label: {
Image(systemSymbol: connectionSymbol)
.foregroundColor(.white)
}
.foregroundColor(.white)
Spacer()
if let device = scanner.configuredDevice {
Button {
self.showDeviceInfo = true
} label: {
if bluetoothClient.hasInfo {
Image(systemSymbol: .iphone)
.font(.system(size: 30, weight: .light))
}
Text(bluetoothClient.deviceState.text)
}
.disabled(!bluetoothClient.hasInfo)
.foregroundColor(.white)
Image(systemSymbol: .infoCircle)
.foregroundColor(device.lastDeviceInfo == nil ? .gray : .white)
}.disabled(device.lastDeviceInfo == nil)
Spacer()
Button {
bluetoothClient.collectRecordedData()
showDataTransferView = true
} label: {
Image(systemSymbol: .arrowUpArrowDownCircle)
.font(.system(size: 30, weight: .light))
.foregroundColor(.white)
}.disabled(!bluetoothClient.isConnected)
}
} else {
Image(systemSymbol: .infoCircle)
.foregroundColor(.gray)
Spacer()
Image(systemSymbol: .arrowUpArrowDownCircle)
.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)
}
}

View File

@ -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.
@ -35,21 +38,25 @@ final class TemperatureStorage: ObservableObject {
@Published
var dailyMeasurementCounts: [MeasurementDailyCount] = []
/// The formatter for the temperature measurement file names
private let fileNameFormatter: DateFormatter
private let storageFolder: URL
/// The storage of daily temperature measurements
private let temperatureStorageFolderUrl: URL
private let overviewFileUrl: 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)
}
}

View File

@ -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)
}
}
}

View File

@ -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(set) var size: Int
private var info: DeviceInfo
private(set) var blockSize: Int
private let dilation: Double
private var numberOfRecordingsInCurrentTransfer = 0
var size: Int {
info.numberOfRecordedBytes
}
var blockSize: Int {
min(50, info.transferBlockSize)
}
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))
}
@ -104,7 +132,6 @@ final class TemperatureDataTransfer {
sensor1: sensor1,
date: dateOfNextRecording)
numberOfRecordingsInCurrentTransfer += 1
if measurement.sensor0.isValid {
lastRecording.sensor0 = measurement.sensor0
}
@ -134,3 +161,4 @@ private extension TemperatureValue {
return 0
}
}
*/

View File

@ -0,0 +1 @@
import Foundation

View File

@ -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)
}
}

View File

@ -19,7 +19,7 @@ struct DeviceInfoView: View {
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)

View File

@ -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))
}
}

View 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")
}
}

View File

@ -13,6 +13,7 @@ struct LogView: View {
var log: Log
var body: some View {
NavigationView {
List(log.logEntries) { entry in
VStack(alignment: .leading) {
HStack {
@ -23,7 +24,9 @@ struct LogView: View {
Text(entry.message)
}
}
.navigationTitle("Log")
.navigationBarTitleDisplayMode(.large)
}
}
}

View File

@ -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)
}

View 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
}
}