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