Add basic storage, temperature history display

This commit is contained in:
Christoph Hagen 2023-06-08 09:52:20 +02:00
parent 002eb11dc1
commit 147cd6a306
22 changed files with 843 additions and 211 deletions

View File

@ -7,6 +7,13 @@
objects = {
/* Begin PBXBuildFile section */
88404DD02A2E718B00D30244 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 88404DCF2A2E718B00D30244 /* BinaryCodable */; };
88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */; };
88404DD42A2F0DB100D30244 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DD32A2F0DB100D30244 /* Date+Extensions.swift */; };
88404DD82A2F381B00D30244 /* HistoryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DD72A2F381B00D30244 /* HistoryList.swift */; };
88404DDB2A2F4DCA00D30244 /* MeasurementDailyCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */; };
88404DDD2A2F587400D30244 /* HistoryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DDC2A2F587400D30244 /* HistoryListRow.swift */; };
88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */; };
88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE04E2A2508E900114294 /* TempTrackApp.swift */; };
88CDE0512A2508E900114294 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CDE0502A2508E900114294 /* ContentView.swift */; };
88CDE0532A2508EA00114294 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88CDE0522A2508EA00114294 /* Assets.xcassets */; };
@ -32,6 +39,12 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extensions.swift"; sourceTree = "<group>"; };
88404DD32A2F0DB100D30244 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
88404DD72A2F381B00D30244 /* HistoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryList.swift; sourceTree = "<group>"; };
88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementDailyCount.swift; sourceTree = "<group>"; };
88404DDC2A2F587400D30244 /* HistoryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListRow.swift; sourceTree = "<group>"; };
88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureDayOverview.swift; sourceTree = "<group>"; };
88CDE04B2A2508E900114294 /* TempTrack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TempTrack.app; sourceTree = BUILT_PRODUCTS_DIR; };
88CDE04E2A2508E900114294 /* TempTrackApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTrackApp.swift; sourceTree = "<group>"; };
88CDE0502A2508E900114294 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -59,6 +72,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
88404DD02A2E718B00D30244 /* BinaryCodable in Frameworks */,
E253A9272A2CA48A00EC6B28 /* SQLite in Frameworks */,
88CDE0662A25D08F00114294 /* SFSafeSymbols in Frameworks */,
88CDE06B2A2899C900114294 /* BottomSheet in Frameworks */,
@ -68,6 +82,15 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
88404DD92A2F4DB100D30244 /* Storage */ = {
isa = PBXGroup;
children = (
88404DDA2A2F4DCA00D30244 /* MeasurementDailyCount.swift */,
88CDE0672A2698B400114294 /* TemperatureStorage.swift */,
);
path = Storage;
sourceTree = "<group>";
};
88CDE0422A2508E800114294 = {
isa = PBXGroup;
children = (
@ -92,10 +115,10 @@
E253A9202A2B39A700EC6B28 /* Extensions */,
88CDE07C2A28AFE700114294 /* Views */,
88CDE0792A28AF3E00114294 /* Bluetooth */,
88404DD92A2F4DB100D30244 /* Storage */,
88CDE06E2A28AE8D00114294 /* Temperature */,
88CDE0522A2508EA00114294 /* Assets.xcassets */,
88CDE0542A2508EA00114294 /* Preview Content */,
88CDE0672A2698B400114294 /* TemperatureStorage.swift */,
88CDE06C2A28A92000114294 /* DeviceInfo.swift */,
);
path = TempTrack;
@ -138,6 +161,9 @@
children = (
88CDE07D2A28AFF400114294 /* DeviceInfoView.swift */,
E253A9232A2B462500EC6B28 /* TemperatureHistoryChart.swift */,
88404DD72A2F381B00D30244 /* HistoryList.swift */,
88404DDC2A2F587400D30244 /* HistoryListRow.swift */,
88404DDE2A2F68E100D30244 /* TemperatureDayOverview.swift */,
);
path = Views;
sourceTree = "<group>";
@ -146,6 +172,8 @@
isa = PBXGroup;
children = (
E253A9212A2B39B700EC6B28 /* Color+Extensions.swift */,
88404DD12A2F0D8F00D30244 /* Double+Extensions.swift */,
88404DD32A2F0DB100D30244 /* Date+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -170,6 +198,7 @@
88CDE0652A25D08F00114294 /* SFSafeSymbols */,
88CDE06A2A2899C900114294 /* BottomSheet */,
E253A9262A2CA48A00EC6B28 /* SQLite */,
88404DCF2A2E718B00D30244 /* BinaryCodable */,
);
productName = TempTrack;
productReference = 88CDE04B2A2508E900114294 /* TempTrack.app */;
@ -203,6 +232,7 @@
88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
88CDE0692A2899C900114294 /* XCRemoteSwiftPackageReference "bottom-sheet" */,
E253A9252A2CA48900EC6B28 /* XCRemoteSwiftPackageReference "SQLite" */,
88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
);
productRefGroup = 88CDE04C2A2508E900114294 /* Products */;
projectDirPath = "";
@ -230,23 +260,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
88404DDB2A2F4DCA00D30244 /* MeasurementDailyCount.swift in Sources */,
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 */,
88404DDF2A2F68E100D30244 /* TemperatureDayOverview.swift in Sources */,
88CDE04F2A2508E900114294 /* TempTrackApp.swift in Sources */,
88CDE0722A28AEB900114294 /* TemperatureDataTransferDelegate.swift in Sources */,
88CDE0782A28AF2C00114294 /* TemperatureSensor.swift in Sources */,
88CDE0682A2698B400114294 /* TemperatureStorage.swift in Sources */,
88404DD42A2F0DB100D30244 /* Date+Extensions.swift in Sources */,
88CDE0762A28AF0900114294 /* TemperatureValue.swift in Sources */,
88CDE07B2A28AF5100114294 /* BluetoothRequest.swift in Sources */,
E253A9242A2B462500EC6B28 /* TemperatureHistoryChart.swift in Sources */,
88404DD22A2F0D8F00D30244 /* Double+Extensions.swift in Sources */,
E253A9222A2B39B700EC6B28 /* Color+Extensions.swift in Sources */,
88CDE0612A25108100114294 /* BluetoothClient.swift in Sources */,
88CDE0742A28AEE500114294 /* DeviceManagerDelegate.swift in Sources */,
88CDE06D2A28A92000114294 /* DeviceInfo.swift in Sources */,
88404DD82A2F381B00D30244 /* HistoryList.swift in Sources */,
88CDE07E2A28AFF400114294 /* DeviceInfoView.swift in Sources */,
88404DDD2A2F587400D30244 /* HistoryListRow.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -453,6 +489,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
@ -480,6 +524,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
88404DCF2A2E718B00D30244 /* BinaryCodable */ = {
isa = XCSwiftPackageProductDependency;
package = 88404DCE2A2E718B00D30244 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
productName = BinaryCodable;
};
88CDE0652A25D08F00114294 /* SFSafeSymbols */ = {
isa = XCSwiftPackageProductDependency;
package = 88CDE0642A25D08F00114294 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;

View File

@ -1,5 +1,14 @@
{
"pins" : [
{
"identity" : "binarycodable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/christophhagen/BinaryCodable",
"state" : {
"revision" : "295ca6399b2b01d1aa4fa84d666416f3bf99ffde",
"version" : "2.0.0"
}
},
{
"identity" : "bottom-sheet",
"kind" : "remoteSourceControl",

View File

@ -4,10 +4,31 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>SQLite (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>SQLite (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>SQLite (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>TempTrack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>1</integer>
</dict>
</dict>
</dict>

View File

@ -18,11 +18,13 @@ enum BluetoothResponseType: UInt8 {
final class BluetoothClient: ObservableObject {
weak var delegate: TemperatureDataTransferDelegate?
private let updateInterval = 3.0
private let connection = DeviceManager()
private let recorder = TemperatureStorage()
private var didTransferData = false
init(deviceInfo: DeviceInfo? = nil) {
connection.delegate = self
@ -41,12 +43,25 @@ final class BluetoothClient: ObservableObject {
startRegularUpdates()
} else {
endRegularUpdates()
didTransferData = false
}
}
}
@Published
private(set) var deviceInfo: DeviceInfo?
private(set) var deviceInfo: DeviceInfo? {
didSet {
guard !didTransferData, runningTransfer == nil else {
return
}
guard !openRequests.contains(where: { if case .getRecordingData = $0 { return true }; return false }) else {
return
}
guard collectRecordedData() else {
return
}
}
}
private var openRequests: [BluetoothRequest] = []
@ -55,9 +70,11 @@ final class BluetoothClient: ObservableObject {
private var runningTransfer: TemperatureDataTransfer?
func updateDeviceInfo() {
if case .configured = deviceState {
addRequest(.getInfo)
guard case .configured = deviceState else {
return
}
addRequest(.getInfo)
}
private var dataUpdateTimer: Timer?
@ -118,7 +135,7 @@ final class BluetoothClient: ObservableObject {
}
let transfer = TemperatureDataTransfer(info: info)
runningTransfer = transfer
runningTransfer?.delegate = recorder
runningTransfer?.delegate = delegate
let next = transfer.nextRequest()
addRequest(next)
return true
@ -137,6 +154,12 @@ final class BluetoothClient: ObservableObject {
return // TODO: Start new transfer?
}
let next = runningTransfer.nextRequest()
if case .clearRecordingBuffer = next {
runningTransfer.completeTransfer()
self.runningTransfer = nil
didTransferData = true
return
}
addRequest(next)
}

View File

@ -4,8 +4,6 @@ import BottomSheet
struct ContentView: View {
private let updateInterval = 1.0
private let minTempColor = Color(hue: 0.624, saturation: 0.5, brightness: 1.0)
private let minTemperature = -20.0
@ -14,49 +12,29 @@ struct ContentView: View {
private let disconnectedColor = Color(white: 0.8)
@ObservedObject
var client = BluetoothClient()
@EnvironmentObject
var bluetoothClient: BluetoothClient
@ObservedObject
var storage = TemperatureStorage()
@EnvironmentObject
var storage: TemperatureStorage
@State
var showDeviceInfo = false
@State
var updateTimer: Timer?
@State
var updateInfoToggle = true
var showHistory = false
init() {
startRegularUpdates()
}
init(client: BluetoothClient, values: [TemperatureMeasurement]) {
self.client = client
self.storage = .init(lastMeasurements: values)
startRegularUpdates()
}
private func startRegularUpdates() {
guard updateTimer == nil else {
return
}
updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { timer in
self.updateInfoToggle.toggle()
}
updateTimer?.fire()
}
var hasDeviceInfo: Bool {
client.deviceInfo != nil
bluetoothClient.deviceInfo != nil
}
var averageTemperature: Double? {
let t1 = client.deviceInfo?.sensor1?.optionalValue
guard let t0 = client.deviceInfo?.sensor0?.optionalValue else {
let t1 = bluetoothClient.deviceInfo?.sensor1?.optionalValue
guard let t0 = bluetoothClient.deviceInfo?.sensor0?.optionalValue else {
return t1
}
guard let t1 else {
@ -123,10 +101,15 @@ struct ContentView: View {
}
Spacer()
TemperatureHistoryChart(points: storage.lastMeasurements)
Button {
self.showHistory = true
} label: {
TemperatureHistoryChart(points: $storage.recentMeasurements)
.frame(height: 150)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
}
HStack(alignment: .center) {
Button {
self.showDeviceInfo = true
@ -135,7 +118,7 @@ struct ContentView: View {
Image(systemSymbol: .iphone)
.font(.system(size: 30, weight: .regular))
}
Text(client.deviceState.text)
Text(bluetoothClient.deviceState.text)
}
.disabled(!hasDeviceInfo)
.foregroundColor(.white)
@ -144,23 +127,25 @@ struct ContentView: View {
}
.padding()
.bottomSheet(isPresented: $showDeviceInfo, height: 600) {
if let info = client.deviceInfo {
DeviceInfoView(
info: info,
isPresented: $showDeviceInfo, updateToggle: $updateInfoToggle)
if let info = bluetoothClient.deviceInfo {
DeviceInfoView(info: info, isPresented: $showDeviceInfo)
} else {
EmptyView()
}
}
.sheet(isPresented: $showHistory) {
HistoryList()
.environmentObject(storage)
}
.background(backgroundGradient)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(
client: BluetoothClient(deviceInfo: .mock),
values: TemperatureMeasurement.mockData)
ContentView()
.environmentObject(TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData))
.environmentObject(BluetoothClient(deviceInfo: .mock))
}
}

View File

@ -13,25 +13,29 @@ struct DeviceInfo {
/// 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
// MARK: Device time
/**
The number of seconds the device has been powered on
*/
var numberOfSecondsRunning: Int {
Int(-deviceStartTime.timeIntervalSinceNow)
}
let numberOfSecondsRunning: Int
let deviceStartTime: Date
let hasDeviceStartTimeSet: Bool
// MARK: Storage
let storageSize: Int
/// The maximum number of bytes which can be requested
let transferBlockSize: Int
var storageFillRatio: Double {
Double(numberOfRecordedBytes) / Double(storageSize)
@ -44,7 +48,7 @@ struct DeviceInfo {
extension DeviceInfo {
static var size = 38
static var size = 42
init?(info: Data) {
guard info.count == DeviceInfo.size else {
@ -60,10 +64,18 @@ extension DeviceInfo {
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.numberOfSecondsRunning = 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])
let deviceStartTimeSeconds = Int(uint32: data[41], data[40], data[39], data[38])
if deviceStartTimeSeconds != 0 {
self.hasDeviceStartTimeSet = true
self.deviceStartTime = Date(seconds: deviceStartTimeSeconds)
} else {
self.hasDeviceStartTimeSet = false
self.deviceStartTime = Date(seconds: Date().seconds - secondsSincePowerOn) // Round to nearest second
}
}
}
@ -93,11 +105,13 @@ extension DeviceInfo {
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)
numberOfSecondsRunning: 20,
deviceStartTime: .now.addingTimeInterval(-1000),
hasDeviceStartTimeSet: false,
storageSize: 10000,
transferBlockSize: 180)
}
}

View File

@ -0,0 +1,65 @@
import Foundation
extension Date {
var seconds: Int {
timeIntervalSince1970.roundedInt
}
var secondsToNow: Int {
-timeIntervalSinceNow.roundedInt
}
init(seconds: Int) {
self.init(timeIntervalSince1970: TimeInterval(seconds))
}
var timePassedText: String {
let secs = secondsToNow
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"
}
var dateIndex: Int {
let components = Calendar.current.dateComponents([.day, .month, .year], from: self)
return components.year! * 10000 + components.month! * 100 + components.day!
}
init(dateIndex: Int) {
let year = dateIndex / 10000
let month = (dateIndex % 10000) / 100
let day = dateIndex % 100
self = Calendar.current.date(from: .init(year: year, month: month, day: day))!
}
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
var startOfNextDay: Date {
startOfDay.addingTimeInterval(86400)
}
}

View File

@ -0,0 +1,8 @@
import Foundation
extension Double {
var roundedInt: Int {
Int(rounded())
}
}

View File

@ -0,0 +1,45 @@
import Foundation
struct MeasurementDailyCount {
var dateIndex: Int
var count: Int
var date: Date {
.init(dateIndex: dateIndex)
}
}
extension MeasurementDailyCount: Codable {
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.dateIndex = try container.decode(Int.self)
self.count = try container.decode(Int.self)
}
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(dateIndex)
try container.encode(count)
}
}
extension MeasurementDailyCount: Comparable {
static func < (lhs: MeasurementDailyCount, rhs: MeasurementDailyCount) -> Bool {
lhs.dateIndex < rhs.dateIndex
}
static func == (lhs: MeasurementDailyCount, rhs: MeasurementDailyCount) -> Bool {
lhs.dateIndex == rhs.dateIndex
}
}
extension MeasurementDailyCount: Identifiable {
var id: Int {
dateIndex
}
}

View File

@ -0,0 +1,275 @@
import Foundation
import Combine
import BinaryCodable
import SwiftUI
final class TemperatureStorage: ObservableObject {
static var documentDirectory: URL {
try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
@Published
var recentMeasurements: [TemperatureMeasurement]
@Published
var dailyMeasurementCounts: [MeasurementDailyCount] = []
private var unsavedMeasurements: [TemperatureMeasurement] = []
private let fileNameFormatter: DateFormatter
private let storageFolder: URL
private let overviewFileUrl: URL
private let fm: FileManager
private let lastValueInterval: TimeInterval
init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) {
self.recentMeasurements = lastMeasurements
let documentDirectory = TemperatureStorage.documentDirectory
self.storageFolder = documentDirectory.appendingPathComponent("measurements")
self.overviewFileUrl = documentDirectory.appendingPathComponent("overview.bin")
self.fm = .default
self.fileNameFormatter = DateFormatter()
self.fileNameFormatter.dateFormat = "yyyyMMdd.bin"
self.lastValueInterval = lastValueInterval
if lastMeasurements.isEmpty {
loadLastMeasurements()
} else {
setDailyCounts(from: lastMeasurements)
}
ensureExistenceOfFolder()
}
private func ensureExistenceOfFolder() {
guard !fm.fileExists(atPath: storageFolder.path) else {
return
}
do {
try fm.createDirectory(at: storageFolder, withIntermediateDirectories: true)
} catch {
print("Failed to create folder: \(error)")
}
}
private func fileName(for date: Date) -> String {
fileNameFormatter.string(from: date)
}
private func fileName(for index: Int) -> String {
String(format: "%08d.bin", index)
}
private func fileUrl(for fileName: String) -> URL {
storageFolder.appendingPathComponent(fileName)
}
private func loadLastMeasurements() {
let startDate = Date().addingTimeInterval(-lastValueInterval)
let todayIndex = Date().dateIndex
let todayValues = loadMeasurements(for: todayIndex)
.filter { $0.date >= startDate }
let dateIndexOfStart = startDate.dateIndex
guard todayIndex != dateIndexOfStart else {
recentMeasurements = todayValues
return
}
let yesterdayValues = loadMeasurements(for: dateIndexOfStart)
.filter { $0.date >= startDate }
recentMeasurements = yesterdayValues + todayValues
}
private func loadMeasurements(for date: Date) -> [TemperatureMeasurement] {
loadMeasurements(from: fileName(for: date))
}
func loadMeasurements(for dateIndex: Int) -> [TemperatureMeasurement] {
loadMeasurements(from: fileName(for: dateIndex))
}
private func loadMeasurements(from fileName: String) -> [TemperatureMeasurement] {
let fileUrl = fileUrl(for: fileName)
guard fm.fileExists(atPath: fileUrl.path) else {
print("No measurements for \(fileName)")
return []
}
do {
let content = try Data(contentsOf: fileUrl)
let points: [TemperatureMeasurement] = try BinaryDecoder.decode(from: content)
print("Loaded \(points.count) points for \(fileName)")
return points
} catch {
print("Failed to read file \(fileName): \(error)")
return []
}
}
func save() {
for (dateIndex, values) in unsavedMeasurements.splitByDate() {
let count = saveNew(values, for: dateIndex)
print("Day \(dateIndex): \(count) of \(values.count) saved")
}
unsavedMeasurements = []
saveDailyCounts()
}
/**
- Returns: The number of new points
*/
private func saveNew(_ measurements: [TemperatureMeasurement], for dateIndex: Int) -> Int {
let fileName = fileName(for: dateIndex)
var existing = loadMeasurements(from: fileName)
guard !existing.isEmpty else {
save(measurements, for: fileName)
setDailyCount(measurements.count, for: dateIndex)
return measurements.count
}
var inserted = 0
for value in measurements {
if existing.insert(value) {
inserted += 1
}
}
save(existing, for: fileName)
setDailyCount(existing.count, for: dateIndex)
return inserted
}
private func save(_ measurements: [TemperatureMeasurement], for fileName: String) {
let fileUrl = fileUrl(for: fileName)
do {
let data = try BinaryEncoder.encode(measurements.sorted())
try data.write(to: fileUrl)
} catch {
print("Failed to save \(fileName): \(error)")
}
}
// MARK: Daily counts
private func setDailyCount(_ count: Int, for dateIndex: Int) {
guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else {
add(dailyCount: count, for: dateIndex)
return
}
dailyMeasurementCounts[index].count = count
}
private func add(dailyCount count: Int, for dateIndex: Int) {
let entry = MeasurementDailyCount(dateIndex: dateIndex, count: count)
guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex < dateIndex }) else {
dailyMeasurementCounts.append(entry)
return
}
dailyMeasurementCounts.insert(entry, at: index)
}
private func incrementCount(for dateIndex: Int, by increment: Int = 1) {
guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else {
add(dailyCount: increment, for: dateIndex)
return
}
dailyMeasurementCounts[index].count += increment
}
private func loadDailyCounts() {
do {
let data = try Data(contentsOf: overviewFileUrl)
dailyMeasurementCounts = try BinaryDecoder.decode(from: data)
} catch {
print("Failed to load overview: \(error)")
}
}
private func saveDailyCounts() {
do {
let data = try BinaryEncoder.encode(dailyMeasurementCounts)
try data.write(to: overviewFileUrl)
} catch {
print("Failed to write overview: \(error)")
}
}
private func setDailyCounts(from measurements: [TemperatureMeasurement]) {
self.dailyMeasurementCounts = measurements.reduce(into: [Int: Int]()) { counts, value in
let index = value.date.dateIndex
counts[index] = (counts[index] ?? 0) + 1
}.map { MeasurementDailyCount(dateIndex: $0.key, count: $0.value) }
.sorted()
}
func recalculateDailyCounts() {
do {
let newValues: [Int: Int] = try fm.contentsOfDirectory(atPath: storageFolder.path)
.reduce(into: [:]) { counts, fileName in
guard let dateIndex = Int(fileName) else {
return
}
counts[dateIndex] = loadMeasurements(from: fileName).count
}
DispatchQueue.main.async {
self.dailyMeasurementCounts = newValues
.map { .init(dateIndex: $0.key, count: $0.value) }
.sorted()
}
} catch {
print("Failed to load daily counts: \(error)")
}
}
}
extension TemperatureStorage: TemperatureDataTransferDelegate {
func didReceiveRecording(_ measurement: TemperatureMeasurement) {
// Add to unsaved measurements
if unsavedMeasurements.insert(measurement) {
incrementCount(for: measurement.date.dateIndex)
}
// Add to last measurements
recentMeasurements.insert(measurement)
}
func saveAfterTransfer() {
save()
}
}
private extension Array where Element == TemperatureMeasurement {
@discardableResult
mutating func insert(_ measurement: TemperatureMeasurement) -> Bool {
guard !contains(measurement) else {
return false
}
guard let index = self.firstIndex(where: { $0.date > measurement.date }) else {
append(measurement)
return true
}
insert(measurement, at: index)
return true
}
func splitByDate() -> [Int : [TemperatureMeasurement]] {
reduce(into: [:]) { result, value in
let dateIndex = value.date.dateIndex
result[dateIndex] = (result[dateIndex] ?? []) + [value]
}
}
}
extension TemperatureStorage {
static var mock: TemperatureStorage {
.init(lastMeasurements: TemperatureMeasurement.mockData)
}
}

View File

@ -1,10 +1,19 @@
import SwiftUI
let storage = TemperatureStorage()
let bluetoothClient = BluetoothClient()
@main
struct TempTrackApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(storage)
.environmentObject(bluetoothClient)
.onAppear {
bluetoothClient.delegate = storage
}
}
}
}

View File

@ -78,6 +78,7 @@ final class TemperatureDataTransfer {
func completeTransfer() {
processBytes()
delegate?.saveAfterTransfer()
}
private func addRelative(byte: UInt8) {
@ -98,7 +99,7 @@ final class TemperatureDataTransfer {
if measurement.sensor1.isValid {
lastRecording.sensor1 = measurement.sensor1
}
lastRecording.date = measurement.date
lastRecording.id = measurement.id
delegate?.didReceiveRecording(measurement)
}

View File

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

View File

@ -6,14 +6,87 @@ struct TemperatureMeasurement: Identifiable {
var sensor1: TemperatureValue
var date: Date
var id: Int
var id: Int {
Int(date.timeIntervalSince1970.rounded())
var date: Date {
get {
Date(seconds: id)
}
set {
id = newValue.seconds
}
}
var secondsAgo: Int {
Int(date.timeIntervalSinceNow.rounded())
var secondsToNow: Int {
Date().seconds - id
}
var maximumValue: Double? {
guard let s0 = sensor0.optionalValue else {
return sensor1.optionalValue
}
guard let s1 = sensor1.optionalValue else {
return nil
}
return max(s0, s1)
}
var minimumValue: Double? {
guard let s0 = sensor0.optionalValue else {
return sensor1.optionalValue
}
guard let s1 = sensor1.optionalValue else {
return nil
}
return min(s0, s1)
}
}
extension TemperatureMeasurement {
init(sensor0: TemperatureValue, sensor1: TemperatureValue, date: Date) {
self.sensor0 = sensor0
self.sensor1 = sensor1
self.id = date.seconds
}
}
extension TemperatureMeasurement: Codable {
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.sensor0 = .init(byte: try container.decode(UInt8.self))
self.sensor1 = .init(byte: try container.decode(UInt8.self))
self.id = try container.decode(Int.self)
}
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(sensor0.byte)
try container.encode(sensor1.byte)
try container.encode(id)
}
}
extension TemperatureMeasurement: Comparable {
static func < (lhs: TemperatureMeasurement, rhs: TemperatureMeasurement) -> Bool {
lhs.id < rhs.id
}
static func == (lhs: TemperatureMeasurement, rhs: TemperatureMeasurement) -> Bool {
lhs.id == rhs.id
}
}
extension Array where Element == TemperatureMeasurement {
func maximumValue() -> Double? {
compactMap { $0.maximumValue }.max()
}
func minimumValue() -> Double? {
compactMap { $0.minimumValue }.min()
}
}
@ -28,19 +101,6 @@ private extension TemperatureValue {
}
}
private extension TemperatureMeasurement {
init(t0: Double?, t1: Double?, secs: Int) {
self.sensor0 = .init(value: t0)
self.sensor1 = .init(value: t1)
self.date = Date().addingTimeInterval(TimeInterval(secs-3600))
}
init(t0: Double?, t1: Double?, min: Int) {
self.init(t0: t0, t1: t1, secs: min * 60)
}
}
extension TemperatureMeasurement {
static let mockData: [TemperatureMeasurement] = {
@ -106,8 +166,13 @@ extension TemperatureMeasurement {
(15.5, 25.0),
(15.0, 25.0),
]
let seconds = Date().seconds
return temps.enumerated().map {
TemperatureMeasurement(t0: $0.element.0, t1: $0.element.1, min: $0.offset)
TemperatureMeasurement(
sensor0: .init(value: $0.element.0),
sensor1: .init(value: $0.element.1),
id: seconds + $0.offset * 60)
}
}()
}

View File

@ -60,35 +60,3 @@ extension TemperatureSensor {
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

@ -16,6 +16,18 @@ enum TemperatureValue {
}
}
var byte: UInt8 {
switch self {
case .notFound:
return 0
case .invalidMeasurement:
return 1
case .value(let double):
let value = Int(double + 40) * 2
return UInt8(clamping: value)
}
}
var optionalValue: Double? {
if case .value(let val) = self {
return val
@ -41,3 +53,18 @@ enum TemperatureValue {
}
}
}
extension TemperatureValue: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let byte = try container.decode(UInt8.self)
self.init(byte: byte)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(byte)
}
}

View File

@ -1,76 +0,0 @@
import Foundation
import Combine
import SQLite
final class TemperatureStorage: ObservableObject {
static var documentDirectory: URL {
try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
private let databaseUrl: URL
@Published
var lastMeasurements: [TemperatureMeasurement]
init(lastMeasurements: [TemperatureMeasurement] = []) {
self.lastMeasurements = lastMeasurements
self.databaseUrl = TemperatureStorage.documentDirectory.appendingPathComponent("db.sqlite3")
}
private let table = Table("values")
private let i
private func createDatabaseIfNeeded() throws {
let db = try Connection(databaseUrl.path)
let users = Table("users")
let id = Expression<Int64>("id")
let name = Expression<String?>("name")
let email = Expression<String>("email")
try db.run(users.create(ifNotExists: true) { t in
t.column(id, primaryKey: true)
t.column(name)
t.column(email, unique: true)
})
// CREATE TABLE "users" (
// "id" INTEGER PRIMARY KEY NOT NULL,
// "name" TEXT,
// "email" TEXT NOT NULL UNIQUE
// )
let insert = users.insert(name <- "Alice", email <- "alice@mac.com")
let rowid = try db.run(insert)
// INSERT INTO "users" ("name", "email") VALUES ('Alice', 'alice@mac.com')
for user in try db.prepare(users) {
print("id: \(user[id]), name: \(user[name]), email: \(user[email])")
// id: 1, name: Optional("Alice"), email: alice@mac.com
}
// SELECT * FROM "users"
let alice = users.filter(id == rowid)
try db.run(alice.update(email <- email.replace("mac.com", with: "me.com")))
// UPDATE "users" SET "email" = replace("email", 'mac.com', 'me.com')
// WHERE ("id" = 1)
try db.run(alice.delete())
// DELETE FROM "users" WHERE ("id" = 1)
try db.scalar(users.count) // 0
// SELECT count(*) FROM "users"
}
}
extension TemperatureStorage: TemperatureDataTransferDelegate {
func didReceiveRecording(_ measurement: TemperatureMeasurement) {
//print("Temperature \(measurement.date): \(temp1), \(temp2)")
}
}

View File

@ -18,9 +18,6 @@ struct DeviceInfoView: View {
@Binding
var isPresented: Bool
@Binding
var updateToggle: Bool
private var runTimeString: String {
let number = info.numberOfSecondsRunning
guard number >= 60 else {
@ -101,6 +98,10 @@ struct DeviceInfoView: View {
}
}
var updateText: String {
return "Updated \(info.receivedDate.timePassedText)"
}
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
@ -164,9 +165,11 @@ struct DeviceInfoView: View {
Spacer()
HStack {
Spacer()
Text("Updated \(info.receivedDate.timePassedText)")
TimelineView(.periodic(from: Date(), by: 1)) { context in
Text(updateText)
.font(.footnote)
.textCase(.uppercase)
}
Spacer()
}
}.padding()
@ -175,10 +178,7 @@ struct DeviceInfoView: View {
struct DeviceInfoView_Previews: PreviewProvider {
static var previews: some View {
DeviceInfoView(
info: .mock,
isPresented: .constant(true),
updateToggle: .constant(true))
DeviceInfoView(info: .mock, isPresented: .constant(true))
.previewLayout(.fixed(width: 375, height: 600))
}
}

View File

@ -0,0 +1,28 @@
import SwiftUI
struct HistoryList: View {
@EnvironmentObject
var storage: TemperatureStorage
var body: some View {
NavigationView {
List(storage.dailyMeasurementCounts) { day in
NavigationLink(destination: {
TemperatureDayOverview(storage: storage, dateIndex: day.dateIndex)
}) {
HistoryListRow(entry: day)
}
}
.navigationTitle("History")
.navigationBarTitleDisplayMode(.large)
}
}
}
struct HistoryList_Previews: PreviewProvider {
static var previews: some View {
HistoryList()
.environmentObject(TemperatureStorage(lastMeasurements: TemperatureMeasurement.mockData))
}
}

View File

@ -0,0 +1,29 @@
import SwiftUI
import SFSafeSymbols
private let df: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .none
return df
}()
struct HistoryListRow: View {
let entry: MeasurementDailyCount
var body: some View {
HStack {
Image(systemSymbol: .calendar)
Text(df.string(from: entry.date))
Spacer()
Text("\(entry.count)")
}
}
}
struct HistoryListRow_Previews: PreviewProvider {
static var previews: some View {
HistoryListRow(entry: .init(dateIndex: Date().dateIndex, count: 123))
}
}

View File

@ -0,0 +1,85 @@
import SwiftUI
import Charts
struct TemperatureDayOverview: View {
let storage: TemperatureStorage
@State
var points: [TemperatureMeasurement] = []
init(storage: TemperatureStorage, dateIndex: Int) {
self.storage = storage
let points = storage.loadMeasurements(for: dateIndex)
print("Loaded \(points.count) points for date \(dateIndex)")
self.points = points
update()
}
mutating func update() {
self.upperTempLimit = max(40, points.maximumValue() ?? 40)
self.lowerTempLimit = min(-20, points.minimumValue() ?? -20)
let startDay = points.first?.date.dateIndex ?? Date().dateIndex
self.pastDateLimit = Date(dateIndex: startDay)
let endDay = (points.last?.date.dateIndex ?? Date().dateIndex) + 1
self.futureDateLimit = Date(dateIndex: endDay)
}
var upperTempLimit: Double = 40.0
var lowerTempLimit: Double = -20
var pastDateLimit: Date = Date().startOfDay
var futureDateLimit: Date = Date().startOfNextDay
var body: some View {
Chart {
ForEach(points) { point in
if let s = point.sensor0.optionalValue {
LineMark(
x: .value("Date", point.date),
y: .value("Temperature", s))
.foregroundStyle(by: .value("Type", "Sensor 0"))
}
if let s = point.sensor1.optionalValue {
LineMark(
x: .value("Date", point.date),
y: .value("Temperature", s))
.foregroundStyle(by: .value("Type", "Sensor 1"))
}
}
}
.aspectRatio(2.6, contentMode: .fit)
//.chartXScale(domain: pastDateLimit...futureDateLimit)
.chartYScale(domain: lowerTempLimit...upperTempLimit)
.chartXAxis {
AxisMarks(preset: .automatic)
AxisMarks.init(values: AxisMarkValues.stride(by: .hour)) {
AxisGridLine()
}
}
.chartYAxis {
AxisMarks(position: .trailing, values: .automatic) { value in
AxisValueLabel(multiLabelAlignment: .trailing) {
if let intValue = value.as(Int.self) {
Text("\(intValue)°")
.font(.system(size: 10))
//.foregroundColor(.white)
}
}
}
AxisMarks.init(values: AxisMarkValues.stride(by: 5)) {
AxisGridLine()
}
}
.padding()
}
}
struct TemperatureDayOverview_Previews: PreviewProvider {
static var previews: some View {
TemperatureDayOverview(storage: TemperatureStorage.mock, dateIndex: Date().dateIndex)
.previewLayout(.fixed(width: 350, height: 150))
//.background(.gray)
}
}

View File

@ -3,7 +3,8 @@ import Charts
struct TemperatureHistoryChart: View {
let points: [TemperatureMeasurement]
@Binding
var points: [TemperatureMeasurement]
let upperTempLimit = 40.0
let lowerTempLimit = -20.0
@ -16,13 +17,13 @@ struct TemperatureHistoryChart: View {
ForEach(points) { point in
if let s = point.sensor0.optionalValue {
LineMark(
x: .value("Date", point.secondsAgo),
x: .value("Date", point.secondsToNow),
y: .value("Temperature", s))
.foregroundStyle(Color.red)
}
if let s = point.sensor1.optionalValue {
LineMark(
x: .value("Date", point.secondsAgo),
x: .value("Date", point.secondsToNow),
y: .value("Temperature", s))
.foregroundStyle(by: .value("Type", "Sensor 1"))
}
@ -36,13 +37,12 @@ struct TemperatureHistoryChart: View {
AxisMarks(position: .trailing, values: .automatic) { value in
AxisValueLabel(multiLabelAlignment: .trailing) {
if let intValue = value.as(Int.self) {
Text("\(intValue) km")
Text("\(intValue)°")
.font(.system(size: 10))
.foregroundColor(.white)
}
}
}
//AxisMarks(position: .trailing, stroke: StrokeStyle(lineWidth: 0))
}
.padding()
}
@ -51,7 +51,7 @@ struct TemperatureHistoryChart: View {
struct TemperatureHistoryChart_Previews: PreviewProvider {
static var previews: some View {
TemperatureHistoryChart(
points: TemperatureMeasurement.mockData)
points: .constant(TemperatureMeasurement.mockData))
.previewLayout(.fixed(width: 350, height: 150))
.background(.gray)
}