Basic display of info

This commit is contained in:
Christoph Hagen 2023-06-03 08:15:00 +02:00
parent 0f97bfc316
commit 6e0910e47f
16 changed files with 1417 additions and 12 deletions

View File

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

View File

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

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

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

View File

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

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

View File

@ -0,0 +1,8 @@
import Foundation
protocol DeviceManagerDelegate: AnyObject {
func deviceManager(didReceive data: Data)
func deviceManager(didChangeState state: DeviceState)
}

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

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

View File

@ -0,0 +1,6 @@
import Foundation
protocol TemperatureDataTransferDelegate: AnyObject {
func didReceiveRecording(_ measurement: TemperatureMeasurement)
}

View File

@ -0,0 +1,10 @@
import Foundation
struct TemperatureMeasurement {
var sensor0: TemperatureValue
var sensor1: TemperatureValue
var date: Date
}

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

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

View File

@ -0,0 +1,12 @@
import Foundation
final class TemperatureStorage {
}
extension TemperatureStorage: TemperatureDataTransferDelegate {
func didReceiveRecording(_ measurement: TemperatureMeasurement) {
//print("Temperature \(measurement.date): \(temp1), \(temp2)")
}
}

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