2023-06-08 09:52:20 +02:00
|
|
|
import Foundation
|
|
|
|
import Combine
|
|
|
|
import BinaryCodable
|
|
|
|
import SwiftUI
|
|
|
|
|
2023-07-02 17:29:39 +02:00
|
|
|
final class PersistentStorage: ObservableObject {
|
2023-06-08 09:52:20 +02:00
|
|
|
|
|
|
|
static var documentDirectory: URL {
|
|
|
|
try! FileManager.default.url(
|
|
|
|
for: .documentDirectory,
|
|
|
|
in: .userDomainMask,
|
|
|
|
appropriateFor: nil, create: true)
|
|
|
|
}
|
2023-06-11 21:57:07 +02:00
|
|
|
|
2023-06-09 07:06:51 +02:00
|
|
|
@AppStorage("newestDate")
|
|
|
|
private var newestMeasurementTime: Int = 0
|
2023-06-11 21:57:07 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
The date of the latest measurement.
|
|
|
|
|
|
|
|
Incoming data older than this date will be rejected to prevent duplicate measurements
|
|
|
|
*/
|
2023-06-09 07:06:51 +02:00
|
|
|
private var newestMeasurementDate: Date {
|
|
|
|
get {
|
|
|
|
Date(seconds: newestMeasurementTime)
|
|
|
|
}
|
|
|
|
set {
|
|
|
|
newestMeasurementTime = newValue.seconds
|
|
|
|
}
|
|
|
|
}
|
2023-06-08 09:52:20 +02:00
|
|
|
|
|
|
|
@Published
|
|
|
|
var recentMeasurements: [TemperatureMeasurement]
|
|
|
|
|
|
|
|
@Published
|
|
|
|
var dailyMeasurementCounts: [MeasurementDailyCount] = []
|
2023-07-02 17:29:39 +02:00
|
|
|
|
|
|
|
/// The formatter for the temperature measurement file names
|
2023-06-08 09:52:20 +02:00
|
|
|
private let fileNameFormatter: DateFormatter
|
2023-07-02 17:29:39 +02:00
|
|
|
|
|
|
|
/// The storage of daily temperature measurements
|
|
|
|
private let temperatureStorageFolderUrl: URL
|
|
|
|
|
|
|
|
/// The storage of the measurement counts per day
|
|
|
|
private let dailyCountsFileUrl: URL
|
2023-06-08 09:52:20 +02:00
|
|
|
|
|
|
|
private let fm: FileManager
|
2023-07-02 17:29:39 +02:00
|
|
|
|
|
|
|
/// The interval in which the measurements should be kept in `recentMeasurements`
|
2023-06-08 09:52:20 +02:00
|
|
|
private let lastValueInterval: TimeInterval
|
|
|
|
|
|
|
|
init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) {
|
|
|
|
self.recentMeasurements = lastMeasurements
|
2023-07-02 17:29:39 +02:00
|
|
|
let documentDirectory = PersistentStorage.documentDirectory
|
|
|
|
self.temperatureStorageFolderUrl = documentDirectory.appendingPathComponent("measurements")
|
|
|
|
self.dailyCountsFileUrl = documentDirectory.appendingPathComponent("overview.bin")
|
2023-06-08 09:52:20 +02:00
|
|
|
self.fm = .default
|
|
|
|
self.fileNameFormatter = DateFormatter()
|
|
|
|
self.fileNameFormatter.dateFormat = "yyyyMMdd.bin"
|
|
|
|
self.lastValueInterval = lastValueInterval
|
|
|
|
|
|
|
|
if lastMeasurements.isEmpty {
|
|
|
|
loadLastMeasurements()
|
2023-06-14 16:16:56 +02:00
|
|
|
loadDailyCounts()
|
2023-06-08 09:52:20 +02:00
|
|
|
} else {
|
|
|
|
setDailyCounts(from: lastMeasurements)
|
|
|
|
}
|
|
|
|
|
|
|
|
ensureExistenceOfFolder()
|
2023-07-02 17:29:39 +02:00
|
|
|
//recalculateDailyCounts()
|
2023-07-03 13:28:51 +02:00
|
|
|
updateTransferCount()
|
|
|
|
updateDeviceInfoCount()
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private func ensureExistenceOfFolder() {
|
2023-07-02 17:29:39 +02:00
|
|
|
guard !fm.fileExists(atPath: temperatureStorageFolderUrl.path) else {
|
2023-06-08 09:52:20 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
do {
|
2023-07-02 17:29:39 +02:00
|
|
|
try fm.createDirectory(at: temperatureStorageFolderUrl, withIntermediateDirectories: true)
|
2023-06-08 09:52:20 +02:00
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to create folder: \(error)")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func fileName(for date: Date) -> String {
|
|
|
|
fileNameFormatter.string(from: date)
|
|
|
|
}
|
|
|
|
|
|
|
|
private func fileName(for index: Int) -> String {
|
|
|
|
String(format: "%08d.bin", index)
|
|
|
|
}
|
|
|
|
|
2023-06-13 17:14:57 +02:00
|
|
|
private func fileUrl(for dateIndex: Int) -> URL {
|
2023-07-02 17:29:39 +02:00
|
|
|
temperatureStorageFolderUrl.appendingPathComponent(fileName(for: dateIndex))
|
2023-06-13 17:14:57 +02:00
|
|
|
}
|
|
|
|
|
2023-06-08 09:52:20 +02:00
|
|
|
private func fileUrl(for fileName: String) -> URL {
|
2023-07-02 17:29:39 +02:00
|
|
|
temperatureStorageFolderUrl.appendingPathComponent(fileName)
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private func loadLastMeasurements() {
|
2023-07-02 17:29:39 +02:00
|
|
|
let now = Date.now
|
|
|
|
let startDate = now.addingTimeInterval(-lastValueInterval)
|
|
|
|
let todayIndex = now.dateIndex
|
2023-06-08 09:52:20 +02:00
|
|
|
let todayValues = loadMeasurements(for: todayIndex)
|
|
|
|
.filter { $0.date >= startDate }
|
|
|
|
let dateIndexOfStart = startDate.dateIndex
|
|
|
|
guard todayIndex != dateIndexOfStart else {
|
|
|
|
recentMeasurements = todayValues
|
2023-06-14 16:16:56 +02:00
|
|
|
log.info("Loaded \(recentMeasurements.count) recent measurements")
|
2023-06-08 09:52:20 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
let yesterdayValues = loadMeasurements(for: dateIndexOfStart)
|
|
|
|
.filter { $0.date >= startDate }
|
2023-06-14 17:52:43 +02:00
|
|
|
recentMeasurements = todayValues + yesterdayValues
|
2023-06-14 16:16:56 +02:00
|
|
|
log.info("Loaded \(recentMeasurements.count) recent measurements")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
2023-06-11 21:57:07 +02:00
|
|
|
|
|
|
|
private func updateLastMeasurements(_ measurements: [TemperatureMeasurement]) {
|
|
|
|
let startDate = Date().addingTimeInterval(-lastValueInterval).seconds
|
2023-06-14 17:52:43 +02:00
|
|
|
recentMeasurements = (measurements + recentMeasurements)
|
2023-07-03 13:28:51 +02:00
|
|
|
.filter { $0.id > startDate }.sorted().reversed()
|
2023-06-14 17:52:43 +02:00
|
|
|
log.info("\(recentMeasurements.count) recent measurements (with \(measurements.count) new entries)")
|
2023-06-11 21:57:07 +02:00
|
|
|
}
|
2023-06-08 09:52:20 +02:00
|
|
|
|
|
|
|
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 {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.info("No measurements for \(fileName)")
|
2023-06-08 09:52:20 +02:00
|
|
|
return []
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
let content = try Data(contentsOf: fileUrl)
|
|
|
|
let points: [TemperatureMeasurement] = try BinaryDecoder.decode(from: content)
|
2023-06-14 16:16:56 +02:00
|
|
|
log.info("Loaded \(points.count) points from \(fileName)")
|
2023-06-08 09:52:20 +02:00
|
|
|
return points
|
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to read file \(fileName): \(error)")
|
2023-06-08 09:52:20 +02:00
|
|
|
return []
|
|
|
|
}
|
|
|
|
}
|
2023-06-11 21:57:07 +02:00
|
|
|
|
|
|
|
func add(_ measurements: [TemperatureMeasurement]) {
|
|
|
|
let lastDate = self.newestMeasurementDate.seconds
|
2023-06-14 17:52:43 +02:00
|
|
|
let newerValues: [TemperatureMeasurement] = measurements.filter { $0.id > lastDate }.reversed()
|
2023-06-14 16:16:56 +02:00
|
|
|
let newValues = newerValues.splitByDate()
|
2023-06-14 17:52:43 +02:00
|
|
|
log.info("Adding \(newerValues.count) of \(measurements.count) measurements")
|
2023-06-11 21:57:07 +02:00
|
|
|
|
|
|
|
for (dateIndex, values) in newValues {
|
2023-06-08 09:52:20 +02:00
|
|
|
let count = saveNew(values, for: dateIndex)
|
2023-06-11 21:57:07 +02:00
|
|
|
setDailyCount(count, for: dateIndex)
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
saveDailyCounts()
|
2023-06-11 21:57:07 +02:00
|
|
|
updateLastMeasurements(measurements)
|
2023-06-14 17:52:43 +02:00
|
|
|
if let newest = newerValues.max()?.id {
|
|
|
|
newestMeasurementTime = newest
|
|
|
|
}
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
|
2023-06-13 17:14:57 +02:00
|
|
|
func removeMeasurements(for dateIndex: Int) {
|
|
|
|
let fileUrl = fileUrl(for: dateIndex)
|
|
|
|
guard fm.fileExists(atPath: fileUrl.path) else {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.warning("No measurements for \(fileUrl.lastPathComponent)")
|
2023-06-13 17:14:57 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
try fm.removeItem(at: fileUrl)
|
|
|
|
dailyMeasurementCounts = dailyMeasurementCounts.filter { $0.dateIndex != dateIndex }
|
|
|
|
recentMeasurements = recentMeasurements.filter { $0.date.dateIndex != dateIndex }
|
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to delete \(fileUrl.lastPathComponent): \(error)")
|
2023-06-13 17:14:57 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-08 09:52:20 +02:00
|
|
|
/**
|
|
|
|
- Returns: The number of new points
|
|
|
|
*/
|
|
|
|
private func saveNew(_ measurements: [TemperatureMeasurement], for dateIndex: Int) -> Int {
|
|
|
|
let fileName = fileName(for: dateIndex)
|
2023-06-14 17:52:43 +02:00
|
|
|
let values = measurements + loadMeasurements(from: fileName)
|
2023-06-11 21:57:07 +02:00
|
|
|
save(values, for: fileName)
|
|
|
|
return values.count
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private func save(_ measurements: [TemperatureMeasurement], for fileName: String) {
|
|
|
|
let fileUrl = fileUrl(for: fileName)
|
|
|
|
do {
|
2023-06-14 17:52:43 +02:00
|
|
|
let data = try BinaryEncoder.encode(measurements.sorted().reversed())
|
2023-06-08 09:52:20 +02:00
|
|
|
try data.write(to: fileUrl)
|
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to save \(fileName): \(error)")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
2023-06-14 17:52:43 +02:00
|
|
|
let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex > dateIndex }) ?? 0
|
2023-06-08 09:52:20 +02:00
|
|
|
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 {
|
2023-07-02 17:29:39 +02:00
|
|
|
let data = try Data(contentsOf: dailyCountsFileUrl)
|
2023-06-08 09:52:20 +02:00
|
|
|
dailyMeasurementCounts = try BinaryDecoder.decode(from: data)
|
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to load overview: \(error)")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func saveDailyCounts() {
|
|
|
|
do {
|
|
|
|
let data = try BinaryEncoder.encode(dailyMeasurementCounts)
|
2023-07-02 17:29:39 +02:00
|
|
|
try data.write(to: dailyCountsFileUrl)
|
2023-06-08 09:52:20 +02:00
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to write overview: \(error)")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
2023-06-14 17:52:43 +02:00
|
|
|
}
|
|
|
|
.map { MeasurementDailyCount(dateIndex: $0.key, count: $0.value) }
|
|
|
|
.sorted()
|
|
|
|
.reversed()
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func recalculateDailyCounts() {
|
|
|
|
do {
|
2023-07-02 17:29:39 +02:00
|
|
|
let files = try fm.contentsOfDirectory(atPath: temperatureStorageFolderUrl.path)
|
2023-06-14 16:16:56 +02:00
|
|
|
let newValues: [Int: Int] = files
|
2023-06-08 09:52:20 +02:00
|
|
|
.reduce(into: [:]) { counts, fileName in
|
2023-06-14 16:16:56 +02:00
|
|
|
let dateString = fileName.replacingOccurrences(of: ".bin", with: "")
|
|
|
|
guard let dateIndex = Int(dateString) else {
|
|
|
|
log.warning("Found file with invalid name \(fileName)")
|
2023-06-08 09:52:20 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
counts[dateIndex] = loadMeasurements(from: fileName).count
|
|
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.dailyMeasurementCounts = newValues
|
|
|
|
.map { .init(dateIndex: $0.key, count: $0.value) }
|
|
|
|
.sorted()
|
2023-06-14 17:52:43 +02:00
|
|
|
.reversed()
|
2023-06-14 16:16:56 +02:00
|
|
|
log.info("Daily counts recalculated from \(files.count) files")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
} catch {
|
2023-06-14 16:16:56 +02:00
|
|
|
log.error("Failed to load daily counts: \(error)")
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
}
|
2023-07-02 17:29:39 +02:00
|
|
|
|
2023-07-03 13:28:51 +02:00
|
|
|
// MARK: Device info archive
|
2023-07-02 17:29:39 +02:00
|
|
|
|
2023-07-03 13:28:51 +02:00
|
|
|
@Published
|
|
|
|
var numberOfStoredDeviceInfos: Int = 0
|
|
|
|
|
|
|
|
private func updateDeviceInfoCount() {
|
|
|
|
let count = countFiles(in: "info")
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.numberOfStoredDeviceInfos = count
|
2023-07-02 17:29:39 +02:00
|
|
|
}
|
2023-07-03 13:28:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
@Published
|
|
|
|
var numberOfStoredTransfers: Int = 0
|
|
|
|
|
|
|
|
private func updateTransferCount() {
|
|
|
|
let count = countFiles(in: "transfers")
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.numberOfStoredTransfers = count
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func countFiles(in folder: String) -> Int {
|
|
|
|
let folder = PersistentStorage.documentDirectory.appendingPathComponent(folder)
|
|
|
|
guard fm.fileExists(atPath: folder.path) else {
|
|
|
|
return 0
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
return try fm.contentsOfDirectory(atPath: folder.path).count
|
|
|
|
} catch {
|
|
|
|
log.error("Failed to count files in '\(folder)': \(error)")
|
|
|
|
return -1
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func save(data: Data, date: Date, in folderName: String) -> Bool {
|
|
|
|
let folder = PersistentStorage.documentDirectory.appendingPathComponent(folderName)
|
|
|
|
if !fm.fileExists(atPath: folder.path) {
|
2023-07-02 17:29:39 +02:00
|
|
|
do {
|
2023-07-03 13:28:51 +02:00
|
|
|
try fm.createDirectory(at: folder, withIntermediateDirectories: false)
|
2023-07-02 17:29:39 +02:00
|
|
|
} catch {
|
2023-07-03 13:28:51 +02:00
|
|
|
log.error("Failed to create folder '\(folderName)': \(error)")
|
|
|
|
return false
|
2023-07-02 17:29:39 +02:00
|
|
|
}
|
|
|
|
}
|
2023-07-03 13:28:51 +02:00
|
|
|
let url = folder.appendingPathComponent("\(date.seconds)")
|
|
|
|
do {
|
|
|
|
try data.write(to: url)
|
|
|
|
} catch {
|
|
|
|
log.error("Failed to write '\(url.lastPathComponent)' in '\(folder)': \(error)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func save(deviceInfo: DeviceInfo) -> Bool {
|
|
|
|
defer { updateDeviceInfoCount() }
|
|
|
|
let data: Data
|
|
|
|
do {
|
|
|
|
data = try BinaryEncoder.encode(deviceInfo)
|
|
|
|
} catch {
|
|
|
|
log.error("Failed to encode device info for storage: \(error)")
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return save(data: data, date: deviceInfo.time.date, in: "info")
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
func saveTransferData(data: Data, date: Date) -> Bool {
|
|
|
|
defer { updateTransferCount() }
|
|
|
|
return save(data: data, date: date, in: "transfers")
|
2023-07-02 17:29:39 +02:00
|
|
|
}
|
2023-06-08 09:52:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private extension Array where Element == TemperatureMeasurement {
|
|
|
|
|
|
|
|
@discardableResult
|
2023-06-13 17:14:57 +02:00
|
|
|
mutating func insertIntoSorted(_ measurement: TemperatureMeasurement) -> Bool {
|
2023-06-08 09:52:20 +02:00
|
|
|
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]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-02 17:29:39 +02:00
|
|
|
extension PersistentStorage {
|
2023-06-08 09:52:20 +02:00
|
|
|
|
2023-07-02 17:29:39 +02:00
|
|
|
static var mock: PersistentStorage {
|
2023-06-08 09:52:20 +02:00
|
|
|
.init(lastMeasurements: TemperatureMeasurement.mockData)
|
|
|
|
}
|
|
|
|
}
|